-
Notifications
You must be signed in to change notification settings - Fork 0
/
index.js
312 lines (257 loc) · 10 KB
/
index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
import electron from 'electron';
import timers from 'timers/promises';
import fs from 'fs';
import askSpotify from './askSpotify.js';
import promptAuthorization from './promptAuthorization.js';
import runAppleScript from './runAppleScript.js';
// Set working directory for the production builds
process.chdir(electron.app.getPath('home'));
await fs.promises.mkdir('Lyrics', { recursive: true });
process.chdir(electron.app.getPath('home') + '/Lyrics');
await fs.promises.mkdir('lyrics', { recursive: true });
// Disable CSP warnings coming from the Spotify web which I can't control
process.env['ELECTRON_DISABLE_SECURITY_WARNINGS'] = 'true';
// Enable Chromium features required by Spotify which print a warning otherwise
electron.app.commandLine.appendSwitch('--enable-features', 'ConversionMeasurement,AttributionReportingCrossAppWeb');
electron.app.on('ready', async () => {
// Prevent "exited with signal SIGINT" to be printed to the console
// Note that this must be in the `ready` event handler
process.on("SIGINT", () => process.exit(0));
let authorization = await promptAuthorization();
let authorizationStamp = (await fs.promises.stat('token.json')).ctime;
const display = electron.screen.getPrimaryDisplay();
const { width, height } = display.workAreaSize;
const window = new electron.BrowserWindow({ width, height, hasShadow: false, frame: false, transparent: true });
window.loadFile('index.html');
// Make the window always stay on top
window.setAlwaysOnTop(true);
// Prevent minimizing and maximizing
// Note that this is redundant with `frame: false` but I still like to keep it
window.setMaximizable(false);
window.setMinimizable(false);
// Make the window click-through
window.setIgnoreMouseEvents(true);
/** @type {'playing' | 'paused'} */
let state;
let position = 0;
let duration = 0;
/** @typedef {{ timeTag: string; words: string; }} Line */
/** @type {({ artist: string; song: string; } & ({ error: string; } | { syncType: 'LINE_SYNCED' | 'UNSYNCED'; lines: Line[]; })) | undefined} */
let lyrics;
/** @type {Line | undefined} */
let line;
function refreshDockMenu() {
const menu = new electron.Menu();
if (lyrics) {
const artistSongMenuItem = new electron.MenuItem({
label: `${lyrics.artist} - ${lyrics.song} (${lyrics.syncType === 'LINE_SYNCED' ? 'synchronized' : 'unsynchronized'})`,
enabled: false
});
menu.append(artistSongMenuItem);
}
const refreshTokenMenuItem = new electron.MenuItem({
label: `Refresh token (${authorizationStamp.toLocaleTimeString()})`,
click: async () => {
authorization = await promptAuthorization(true);
authorizationStamp = new Date();
refreshDockMenu();
}
});
menu.append(refreshTokenMenuItem);
const dataPathMenuItem = new electron.MenuItem({
label: `Open Lyrics directory (${electron.app.getPath('home')}/Lyrics)`,
click: () => electron.shell.openPath(electron.app.getPath('home') + '/Lyrics')
});
menu.append(dataPathMenuItem);
electron.app.dock.setMenu(menu);
}
refreshDockMenu();
// Render current lyrics line on a fast interval for smooth updates
setInterval(
async () => {
// Handle the window no longer existing after Ctrl+C while testing
if (window.isDestroyed()) {
process.exit(0);
}
if (!lyrics || ('error' in lyrics) || state !== 'playing') {
// Clear the last line in case Spotify got paused or stopped
await window.webContents.executeJavaScript(`document.body.dataset.lyric = '';`);
return;
}
/** @type {string | null | undefined} */
let lyric;
switch (lyrics.syncType) {
case 'UNSYNCED': {
const ratio = position / duration;
const index = ~~(lyrics.lines.length * ratio);
const _line = lyrics.lines[index];
if (_line !== line) {
line = _line;
lyric = line?.words ?? '';
}
break;
}
case 'LINE_SYNCED': {
{
const index = lyrics.lines.findIndex(line => line.startTimeMs >= position * 1000 + 100);
const _line = lyrics.lines[index - 1];
if (_line !== line) {
line = _line;
lyric = line?.words ?? '';
}
break;
}
}
default: {
throw new Error(`Unexpected sync type '${lyrics.syncType}'!`);
}
}
// Advance the position by 100 ms to keep progressing until next 5 s sync
position += .1;
if (lyric === undefined) {
// Keep the lyrics display as-is
return;
}
// Coerce the musical note character to an empty lyric line instead
if (lyric === '♪') {
lyric = null;
}
if (lyric) {
// Handle the window no longer existing after Ctrl+C while testing
if (window.isDestroyed()) {
process.exit(0);
}
// Escape single quotes and line breaks to make the string safe to pass
const text = lyric.replace(/'/g, '\\\'').replace(/(\r|\n)/g, '');
await window.webContents.executeJavaScript(`document.body.dataset.lyric = '${text}';`);
await window.webContents.executeJavaScript(`document.body.dataset.unsynced = '${lyrics.syncType === 'UNSYNCED' ? '~' : ''}';`);
console.log(`Flashed ${lyrics.syncType === 'LINE_SYNCED' ? 'synchronized' : 'unsynchronized'} lyric "${lyric}"`);
}
else {
console.log(`Cleared ${lyrics.syncType === 'LINE_SYNCED' ? 'synchronized' : 'unsynchronized'} lyric`);
}
},
100
);
// Check artist, song and time and fetch and update lyrics on slow interval
while (true) {
// Wait a bit seconds between AppleScript Spotify checks to not be spammy
await timers.setTimeout(2000);
// Determine if Spotify is running to avoid starting it unintentionally
if (!+await runAppleScript('tell application "System Events" to count (every process whose name is "Spotify")')) {
// Reset the lyris so we don't get stuck on the last line
lyrics = undefined;
continue;
}
/** @type {string} */
let artist;
try {
artist = await askSpotify('artist of current track');
}
catch (error) {
console.log('Failed to get Spotify artist: ' + error);
continue;
}
/** @type {string} */
let song;
try {
song = await askSpotify('name of current track');
}
catch (error) {
console.log('Failed to get Spotify song: ' + error);
continue;
}
// Download new lyrics if we don't already have them
if (authorization && (lyrics?.artist !== artist || lyrics?.song !== song)) {
const path = `lyrics/${artist} - ${song}.json`;
try {
await fs.promises.access(path);
lyrics = JSON.parse(await fs.promises.readFile(path));
}
catch (error) {
if (error.code !== 'ENOENT') {
console.log(`Failed to load ${artist} - ${song}: ${error}`);
}
/** @type {string} */
let id;
try {
// E.g.: "ID spotify:track:…"
id = (await askSpotify('id of the current track')).split(':').at(-1).trimEnd();
}
catch (error) {
console.log('Failed to get Spotify ID: ' + error);
continue;
}
console.log(`Downloading ${artist} - ${song}…`);
// Download LRC (timestamped) lyrics from the unofficial Spotify Lyrics API
// Inspect the `open.spotify.com` developer tools `lyrics` network call to maintain this
const response = await fetch(`https://spclient.wg.spotify.com/color-lyrics/v2/track/${id}?format=json`, { headers: { authorization, 'app-platform': 'WebPlayer' } });
if (response.ok) {
const data = await response.json();
lyrics = { artist, song, ...data.lyrics };
await fs.promises.writeFile(path, JSON.stringify(lyrics, null, 2));
console.log(`Downloaded ${lyrics.syncType === 'LINE_SYNCED' ? 'synchronized' : 'unsynchronized'} ${artist} - ${song}`);
}
else {
lyrics = { artist, song, error: await response.text() };
switch (response.status) {
// Force the user to re-authenticate if the token is invalid
case 401: {
// Reset the field while re-authenticating to prevent multiple prompts
authorization = undefined;
authorization = await promptAuthorization(true);
authorizationStamp = new Date();
// Refresh the `Refresh token` menu item in the Dock context menu
refreshDockMenu();
// Reset the lyrics so they are re-tried with the new token
lyrics = undefined;
break;
}
case 404: {
console.log(`No lyrics found for ${artist} - ${song}`);
break;
}
default: {
console.log(`Failed to download ${artist} - ${song}: ${response.status} ${response.statusText} ${lyrics.error}`);
}
}
}
}
// Refresh the `Artist - Song` menu item in the Dock context menu
refreshDockMenu();
}
try {
const _state = await askSpotify('player state');
if (_state !== 'playing' && _state !== 'paused' && _state !== 'stopped') {
throw new Error(`Unexpected player state '${_state}'!`);
}
if (state !== _state) {
if (!state) {
console.log(`Spotify is ${_state}`);
}
else {
console.log(`Spotify went from ${state} to ${_state}`);
}
}
state = _state;
}
catch (error) {
console.log('Failed to get Spotify state: ' + error);
continue;
}
try {
position = Number(await askSpotify('player position'));
}
catch (error) {
console.log('Failed to get Spotify position: ' + error);
continue;
}
try {
duration = Number(await askSpotify('duration of current track')) / 1000;
}
catch (error) {
console.log('Failed to get Spotify duration: ' + error);
continue;
}
}
});