+6
CHANGELOG.md
+6
CHANGELOG.md
+13
-7
Justfile
+13
-7
Justfile
···
115
115
--inject:./system/Js/node-shims.js
116
116
117
117
# Main
118
-
{{ESBUILD}} ./src/Javascript/index.ts \
118
+
{{ESBUILD}} ./src/Javascript/UI/index.ts \
119
119
--outdir={{BUILD_DIR}}/js/ui/ \
120
120
--define:BUILD_TIMESTAMP=$build_timestamp \
121
121
--splitting
···
144
144
--inject:./system/Js/node-shims.js
145
145
146
146
# Main
147
-
{{ESBUILD}} ./src/Javascript/index.ts \
147
+
{{ESBUILD}} ./src/Javascript/UI/index.ts \
148
148
--outdir={{BUILD_DIR}}/js/ui/ \
149
149
--define:BUILD_TIMESTAMP=$build_timestamp \
150
150
--splitting \
···
180
180
)
181
181
182
182
183
-
@elm-housekeeping:
184
-
echo "> Running elm-format"
185
-
{{NPM_DIR}}/.bin/elm-format {{SRC_DIR}} --yes
186
-
echo "> Running elm-review"
187
-
{{ELM_REVIEW}} --fix-all
183
+
@elm-format:
184
+
echo "> Running elm-format"
185
+
{{NPM_DIR}}/.bin/elm-format {{SRC_DIR}} --yes
186
+
187
+
188
+
@elm-housekeeping: elm-format elm-review
189
+
190
+
191
+
@elm-review:
192
+
echo "> Running elm-review"
193
+
{{ELM_REVIEW}} --fix-all
188
194
189
195
190
196
@quality: check-versions
+2
-1
elm.json
+2
-1
elm.json
···
49
49
"truqu/elm-base64": "2.0.4",
50
50
"truqu/elm-md5": "1.1.0",
51
51
"wernerdegroot/listzipper": "4.0.0",
52
-
"ymtszw/elm-xml-decode": "3.2.1"
52
+
"ymtszw/elm-xml-decode": "3.2.2"
53
53
},
54
54
"indirect": {
55
55
"elm/bytes": "1.0.8",
56
56
"elm/parser": "1.1.0",
57
57
"fredcy/elm-parseint": "2.0.1",
58
+
"miniBill/elm-xml-parser": "1.0.1",
58
59
"pzp1997/assoc-list": "1.0.0",
59
60
"zwilias/elm-utf-tools": "2.0.1"
60
61
}
+1
-1
gren.json
+1
-1
gren.json
+304
-266
package-lock.json
+304
-266
package-lock.json
···
1
1
{
2
2
"name": "diffuse",
3
-
"version": "3.4.0",
3
+
"version": "3.5.0",
4
4
"lockfileVersion": 2,
5
5
"requires": true,
6
6
"packages": {
7
7
"": {
8
8
"name": "diffuse",
9
-
"version": "3.4.0",
9
+
"version": "3.5.0",
10
10
"license": "SEE LICENSE IN LICENSE",
11
11
"dependencies": {
12
12
"@oddjs/odd": "^0.37.2",
···
15
15
"encoding-japanese": "^2.0.0",
16
16
"fast-text-encoding": "^1.0.6",
17
17
"file-saver": "^2.0.2",
18
-
"jschardet": "^3.0.0",
19
18
"jszip": "^3.7.1",
20
19
"load-script2": "^2.0.5",
21
20
"localforage": "^1.10.0",
22
21
"lunr": "^2.3.8",
23
-
"mediainfo.js": "^0.2.1",
22
+
"mediainfo.js": "^0.3.1",
24
23
"music-metadata-browser": "^2.5.10",
25
24
"readable-stream": "^4.5.2",
26
25
"remotestoragejs": "^2.0.0-beta.6",
···
36
35
"@tauri-apps/plugin-dialog": "^2.0.0-beta.0",
37
36
"@tauri-apps/plugin-fs": "^2.0.0-beta.0",
38
37
"@tauri-apps/plugin-shell": "^2.0.0-beta.0",
38
+
"@types/elm": "^0.19.3",
39
+
"@types/file-saver": "^2.0.7",
40
+
"@types/lunr": "^2.3.7",
41
+
"@types/throttle-debounce": "^5.0.2",
39
42
"@typescript-eslint/eslint-plugin": "^6.21.0",
40
43
"@typescript-eslint/parser": "^6.21.0",
41
44
"assert": "^2.1.0",
42
-
"autoprefixer": "^10.4.17",
45
+
"autoprefixer": "^10.4.19",
43
46
"buffer": "^6.0.3",
44
47
"elm": "0.19.1-6",
45
48
"elm-format": "^0.8.7",
46
49
"elm-review": "^2.10.3",
47
-
"esbuild": "^0.20.0",
50
+
"esbuild": "^0.20.2",
48
51
"esbuild-plugin-wasm": "^1.1.0",
49
52
"eslint": "^8.56.0",
50
53
"events": "^3.3.0",
···
276
279
]
277
280
},
278
281
"node_modules/@esbuild/aix-ppc64": {
279
-
"version": "0.20.0",
280
-
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.0.tgz",
281
-
"integrity": "sha512-fGFDEctNh0CcSwsiRPxiaqX0P5rq+AqE0SRhYGZ4PX46Lg1FNR6oCxJghf8YgY0WQEgQuh3lErUFE4KxLeRmmw==",
282
+
"version": "0.20.2",
283
+
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz",
284
+
"integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==",
282
285
"cpu": [
283
286
"ppc64"
284
287
],
···
292
295
}
293
296
},
294
297
"node_modules/@esbuild/android-arm": {
295
-
"version": "0.20.0",
296
-
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.0.tgz",
297
-
"integrity": "sha512-3bMAfInvByLHfJwYPJRlpTeaQA75n8C/QKpEaiS4HrFWFiJlNI0vzq/zCjBrhAYcPyVPG7Eo9dMrcQXuqmNk5g==",
298
+
"version": "0.20.2",
299
+
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz",
300
+
"integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==",
298
301
"cpu": [
299
302
"arm"
300
303
],
···
308
311
}
309
312
},
310
313
"node_modules/@esbuild/android-arm64": {
311
-
"version": "0.20.0",
312
-
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.0.tgz",
313
-
"integrity": "sha512-aVpnM4lURNkp0D3qPoAzSG92VXStYmoVPOgXveAUoQBWRSuQzt51yvSju29J6AHPmwY1BjH49uR29oyfH1ra8Q==",
314
+
"version": "0.20.2",
315
+
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz",
316
+
"integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==",
314
317
"cpu": [
315
318
"arm64"
316
319
],
···
324
327
}
325
328
},
326
329
"node_modules/@esbuild/android-x64": {
327
-
"version": "0.20.0",
328
-
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.0.tgz",
329
-
"integrity": "sha512-uK7wAnlRvjkCPzh8jJ+QejFyrP8ObKuR5cBIsQZ+qbMunwR8sbd8krmMbxTLSrDhiPZaJYKQAU5Y3iMDcZPhyQ==",
330
+
"version": "0.20.2",
331
+
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz",
332
+
"integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==",
330
333
"cpu": [
331
334
"x64"
332
335
],
···
340
343
}
341
344
},
342
345
"node_modules/@esbuild/darwin-arm64": {
343
-
"version": "0.20.0",
344
-
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.0.tgz",
345
-
"integrity": "sha512-AjEcivGAlPs3UAcJedMa9qYg9eSfU6FnGHJjT8s346HSKkrcWlYezGE8VaO2xKfvvlZkgAhyvl06OJOxiMgOYQ==",
346
+
"version": "0.20.2",
347
+
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz",
348
+
"integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==",
346
349
"cpu": [
347
350
"arm64"
348
351
],
···
356
359
}
357
360
},
358
361
"node_modules/@esbuild/darwin-x64": {
359
-
"version": "0.20.0",
360
-
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.0.tgz",
361
-
"integrity": "sha512-bsgTPoyYDnPv8ER0HqnJggXK6RyFy4PH4rtsId0V7Efa90u2+EifxytE9pZnsDgExgkARy24WUQGv9irVbTvIw==",
362
+
"version": "0.20.2",
363
+
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz",
364
+
"integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==",
362
365
"cpu": [
363
366
"x64"
364
367
],
···
372
375
}
373
376
},
374
377
"node_modules/@esbuild/freebsd-arm64": {
375
-
"version": "0.20.0",
376
-
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.0.tgz",
377
-
"integrity": "sha512-kQ7jYdlKS335mpGbMW5tEe3IrQFIok9r84EM3PXB8qBFJPSc6dpWfrtsC/y1pyrz82xfUIn5ZrnSHQQsd6jebQ==",
378
+
"version": "0.20.2",
379
+
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz",
380
+
"integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==",
378
381
"cpu": [
379
382
"arm64"
380
383
],
···
388
391
}
389
392
},
390
393
"node_modules/@esbuild/freebsd-x64": {
391
-
"version": "0.20.0",
392
-
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.0.tgz",
393
-
"integrity": "sha512-uG8B0WSepMRsBNVXAQcHf9+Ko/Tr+XqmK7Ptel9HVmnykupXdS4J7ovSQUIi0tQGIndhbqWLaIL/qO/cWhXKyQ==",
394
+
"version": "0.20.2",
395
+
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz",
396
+
"integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==",
394
397
"cpu": [
395
398
"x64"
396
399
],
···
404
407
}
405
408
},
406
409
"node_modules/@esbuild/linux-arm": {
407
-
"version": "0.20.0",
408
-
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.0.tgz",
409
-
"integrity": "sha512-2ezuhdiZw8vuHf1HKSf4TIk80naTbP9At7sOqZmdVwvvMyuoDiZB49YZKLsLOfKIr77+I40dWpHVeY5JHpIEIg==",
410
+
"version": "0.20.2",
411
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz",
412
+
"integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==",
410
413
"cpu": [
411
414
"arm"
412
415
],
···
420
423
}
421
424
},
422
425
"node_modules/@esbuild/linux-arm64": {
423
-
"version": "0.20.0",
424
-
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.0.tgz",
425
-
"integrity": "sha512-uTtyYAP5veqi2z9b6Gr0NUoNv9F/rOzI8tOD5jKcCvRUn7T60Bb+42NDBCWNhMjkQzI0qqwXkQGo1SY41G52nw==",
426
+
"version": "0.20.2",
427
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz",
428
+
"integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==",
426
429
"cpu": [
427
430
"arm64"
428
431
],
···
436
439
}
437
440
},
438
441
"node_modules/@esbuild/linux-ia32": {
439
-
"version": "0.20.0",
440
-
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.0.tgz",
441
-
"integrity": "sha512-c88wwtfs8tTffPaoJ+SQn3y+lKtgTzyjkD8NgsyCtCmtoIC8RDL7PrJU05an/e9VuAke6eJqGkoMhJK1RY6z4w==",
442
+
"version": "0.20.2",
443
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz",
444
+
"integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==",
442
445
"cpu": [
443
446
"ia32"
444
447
],
···
452
455
}
453
456
},
454
457
"node_modules/@esbuild/linux-loong64": {
455
-
"version": "0.20.0",
456
-
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.0.tgz",
457
-
"integrity": "sha512-lR2rr/128/6svngnVta6JN4gxSXle/yZEZL3o4XZ6esOqhyR4wsKyfu6qXAL04S4S5CgGfG+GYZnjFd4YiG3Aw==",
458
+
"version": "0.20.2",
459
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz",
460
+
"integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==",
458
461
"cpu": [
459
462
"loong64"
460
463
],
···
468
471
}
469
472
},
470
473
"node_modules/@esbuild/linux-mips64el": {
471
-
"version": "0.20.0",
472
-
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.0.tgz",
473
-
"integrity": "sha512-9Sycc+1uUsDnJCelDf6ZNqgZQoK1mJvFtqf2MUz4ujTxGhvCWw+4chYfDLPepMEvVL9PDwn6HrXad5yOrNzIsQ==",
474
+
"version": "0.20.2",
475
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz",
476
+
"integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==",
474
477
"cpu": [
475
478
"mips64el"
476
479
],
···
484
487
}
485
488
},
486
489
"node_modules/@esbuild/linux-ppc64": {
487
-
"version": "0.20.0",
488
-
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.0.tgz",
489
-
"integrity": "sha512-CoWSaaAXOZd+CjbUTdXIJE/t7Oz+4g90A3VBCHLbfuc5yUQU/nFDLOzQsN0cdxgXd97lYW/psIIBdjzQIwTBGw==",
490
+
"version": "0.20.2",
491
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz",
492
+
"integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==",
490
493
"cpu": [
491
494
"ppc64"
492
495
],
···
500
503
}
501
504
},
502
505
"node_modules/@esbuild/linux-riscv64": {
503
-
"version": "0.20.0",
504
-
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.0.tgz",
505
-
"integrity": "sha512-mlb1hg/eYRJUpv8h/x+4ShgoNLL8wgZ64SUr26KwglTYnwAWjkhR2GpoKftDbPOCnodA9t4Y/b68H4J9XmmPzA==",
506
+
"version": "0.20.2",
507
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz",
508
+
"integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==",
506
509
"cpu": [
507
510
"riscv64"
508
511
],
···
516
519
}
517
520
},
518
521
"node_modules/@esbuild/linux-s390x": {
519
-
"version": "0.20.0",
520
-
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.0.tgz",
521
-
"integrity": "sha512-fgf9ubb53xSnOBqyvWEY6ukBNRl1mVX1srPNu06B6mNsNK20JfH6xV6jECzrQ69/VMiTLvHMicQR/PgTOgqJUQ==",
522
+
"version": "0.20.2",
523
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz",
524
+
"integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==",
522
525
"cpu": [
523
526
"s390x"
524
527
],
···
532
535
}
533
536
},
534
537
"node_modules/@esbuild/linux-x64": {
535
-
"version": "0.20.0",
536
-
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.0.tgz",
537
-
"integrity": "sha512-H9Eu6MGse++204XZcYsse1yFHmRXEWgadk2N58O/xd50P9EvFMLJTQLg+lB4E1cF2xhLZU5luSWtGTb0l9UeSg==",
538
+
"version": "0.20.2",
539
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz",
540
+
"integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==",
538
541
"cpu": [
539
542
"x64"
540
543
],
···
548
551
}
549
552
},
550
553
"node_modules/@esbuild/netbsd-x64": {
551
-
"version": "0.20.0",
552
-
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.0.tgz",
553
-
"integrity": "sha512-lCT675rTN1v8Fo+RGrE5KjSnfY0x9Og4RN7t7lVrN3vMSjy34/+3na0q7RIfWDAj0e0rCh0OL+P88lu3Rt21MQ==",
554
+
"version": "0.20.2",
555
+
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz",
556
+
"integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==",
554
557
"cpu": [
555
558
"x64"
556
559
],
···
564
567
}
565
568
},
566
569
"node_modules/@esbuild/openbsd-x64": {
567
-
"version": "0.20.0",
568
-
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.0.tgz",
569
-
"integrity": "sha512-HKoUGXz/TOVXKQ+67NhxyHv+aDSZf44QpWLa3I1lLvAwGq8x1k0T+e2HHSRvxWhfJrFxaaqre1+YyzQ99KixoA==",
570
+
"version": "0.20.2",
571
+
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz",
572
+
"integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==",
570
573
"cpu": [
571
574
"x64"
572
575
],
···
580
583
}
581
584
},
582
585
"node_modules/@esbuild/sunos-x64": {
583
-
"version": "0.20.0",
584
-
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.0.tgz",
585
-
"integrity": "sha512-GDwAqgHQm1mVoPppGsoq4WJwT3vhnz/2N62CzhvApFD1eJyTroob30FPpOZabN+FgCjhG+AgcZyOPIkR8dfD7g==",
586
+
"version": "0.20.2",
587
+
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz",
588
+
"integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==",
586
589
"cpu": [
587
590
"x64"
588
591
],
···
596
599
}
597
600
},
598
601
"node_modules/@esbuild/win32-arm64": {
599
-
"version": "0.20.0",
600
-
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.0.tgz",
601
-
"integrity": "sha512-0vYsP8aC4TvMlOQYozoksiaxjlvUcQrac+muDqj1Fxy6jh9l9CZJzj7zmh8JGfiV49cYLTorFLxg7593pGldwQ==",
602
+
"version": "0.20.2",
603
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz",
604
+
"integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==",
602
605
"cpu": [
603
606
"arm64"
604
607
],
···
612
615
}
613
616
},
614
617
"node_modules/@esbuild/win32-ia32": {
615
-
"version": "0.20.0",
616
-
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.0.tgz",
617
-
"integrity": "sha512-p98u4rIgfh4gdpV00IqknBD5pC84LCub+4a3MO+zjqvU5MVXOc3hqR2UgT2jI2nh3h8s9EQxmOsVI3tyzv1iFg==",
618
+
"version": "0.20.2",
619
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz",
620
+
"integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==",
618
621
"cpu": [
619
622
"ia32"
620
623
],
···
628
631
}
629
632
},
630
633
"node_modules/@esbuild/win32-x64": {
631
-
"version": "0.20.0",
632
-
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.0.tgz",
633
-
"integrity": "sha512-NgJnesu1RtWihtTtXGFMU5YSE6JyyHPMxCwBZK7a6/8d31GuSo9l0Ss7w1Jw5QnKUawG6UEehs883kcXf5fYwg==",
634
+
"version": "0.20.2",
635
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz",
636
+
"integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==",
634
637
"cpu": [
635
638
"x64"
636
639
],
···
1654
1657
"@types/responselike": "^1.0.0"
1655
1658
}
1656
1659
},
1660
+
"node_modules/@types/elm": {
1661
+
"version": "0.19.3",
1662
+
"resolved": "https://registry.npmjs.org/@types/elm/-/elm-0.19.3.tgz",
1663
+
"integrity": "sha512-1DnHZiIHvDyjL6MHrePqbD3ooLLix13k6ow8gEydFOAXImkcvbzQX0Ri+WJOM7RvgPfmyUe6uQ2Acupb1oL+GA==",
1664
+
"dev": true
1665
+
},
1666
+
"node_modules/@types/file-saver": {
1667
+
"version": "2.0.7",
1668
+
"resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz",
1669
+
"integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==",
1670
+
"dev": true
1671
+
},
1657
1672
"node_modules/@types/http-cache-semantics": {
1658
1673
"version": "4.0.1",
1659
1674
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz",
···
1675
1690
"@types/node": "*"
1676
1691
}
1677
1692
},
1693
+
"node_modules/@types/lunr": {
1694
+
"version": "2.3.7",
1695
+
"resolved": "https://registry.npmjs.org/@types/lunr/-/lunr-2.3.7.tgz",
1696
+
"integrity": "sha512-Tb/kUm38e8gmjahQzdCKhbdsvQ9/ppzHFfsJ0dMs3ckqQsRj+P5IkSAwFTBrBxdyr3E/LoMUUrZngjDYAjiE3A==",
1697
+
"dev": true
1698
+
},
1678
1699
"node_modules/@types/node": {
1679
1700
"version": "18.16.3",
1680
1701
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.16.3.tgz",
···
1693
1714
"version": "7.5.6",
1694
1715
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz",
1695
1716
"integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==",
1717
+
"dev": true
1718
+
},
1719
+
"node_modules/@types/throttle-debounce": {
1720
+
"version": "5.0.2",
1721
+
"resolved": "https://registry.npmjs.org/@types/throttle-debounce/-/throttle-debounce-5.0.2.tgz",
1722
+
"integrity": "sha512-pDzSNulqooSKvSNcksnV72nk8p7gRqN8As71Sp28nov1IgmPKWbOEIwAWvBME5pPTtaXJAvG3O4oc76HlQ4kqQ==",
1696
1723
"dev": true
1697
1724
},
1698
1725
"node_modules/@types/tv4": {
···
2150
2177
}
2151
2178
},
2152
2179
"node_modules/autoprefixer": {
2153
-
"version": "10.4.17",
2154
-
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.17.tgz",
2155
-
"integrity": "sha512-/cpVNRLSfhOtcGflT13P2794gVSgmPgTR+erw5ifnMLZb0UnSlkK4tquLmkd3BhA+nLo5tX8Cu0upUsGKvKbmg==",
2180
+
"version": "10.4.19",
2181
+
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz",
2182
+
"integrity": "sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==",
2156
2183
"dev": true,
2157
2184
"funding": [
2158
2185
{
···
2169
2196
}
2170
2197
],
2171
2198
"dependencies": {
2172
-
"browserslist": "^4.22.2",
2173
-
"caniuse-lite": "^1.0.30001578",
2199
+
"browserslist": "^4.23.0",
2200
+
"caniuse-lite": "^1.0.30001599",
2174
2201
"fraction.js": "^4.3.7",
2175
2202
"normalize-range": "^0.1.2",
2176
2203
"picocolors": "^1.0.0",
···
2493
2520
}
2494
2521
},
2495
2522
"node_modules/browserslist": {
2496
-
"version": "4.22.3",
2497
-
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.3.tgz",
2498
-
"integrity": "sha512-UAp55yfwNv0klWNapjs/ktHoguxuQNGnOzxYmfnXIS+8AsRDZkSDxg7R1AX3GKzn078SBI5dzwzj/Yx0Or0e3A==",
2523
+
"version": "4.23.1",
2524
+
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.1.tgz",
2525
+
"integrity": "sha512-TUfofFo/KsK/bWZ9TWQ5O26tsWW4Uhmt8IYklbnUa70udB6P2wA7w7o4PY4muaEPBQaAX+CEnmmIA41NVHtPVw==",
2499
2526
"dev": true,
2500
2527
"funding": [
2501
2528
{
···
2512
2539
}
2513
2540
],
2514
2541
"dependencies": {
2515
-
"caniuse-lite": "^1.0.30001580",
2516
-
"electron-to-chromium": "^1.4.648",
2542
+
"caniuse-lite": "^1.0.30001629",
2543
+
"electron-to-chromium": "^1.4.796",
2517
2544
"node-releases": "^2.0.14",
2518
-
"update-browserslist-db": "^1.0.13"
2545
+
"update-browserslist-db": "^1.0.16"
2519
2546
},
2520
2547
"bin": {
2521
2548
"browserslist": "cli.js"
···
2650
2677
}
2651
2678
},
2652
2679
"node_modules/caniuse-lite": {
2653
-
"version": "1.0.30001584",
2654
-
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001584.tgz",
2655
-
"integrity": "sha512-LOz7CCQ9M1G7OjJOF9/mzmqmj3jE/7VOmrfw6Mgs0E8cjOsbRXQJHsPBfmBOXDskXKrHLyyW3n7kpDW/4BsfpQ==",
2680
+
"version": "1.0.30001636",
2681
+
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001636.tgz",
2682
+
"integrity": "sha512-bMg2vmr8XBsbL6Lr0UHXy/21m84FTxDLWn2FSqMd5PrlbMxwJlQnC2YWYxVgp66PZE+BBNF2jYQUBKCo1FDeZg==",
2656
2683
"dev": true,
2657
2684
"funding": [
2658
2685
{
···
3265
3292
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="
3266
3293
},
3267
3294
"node_modules/electron-to-chromium": {
3268
-
"version": "1.4.657",
3269
-
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.657.tgz",
3270
-
"integrity": "sha512-On2ymeleg6QbRuDk7wNgDdXtNqlJLM2w4Agx1D/RiTmItiL+a9oq5p7HUa2ZtkAtGBe/kil2dq/7rPfkbe0r5w==",
3295
+
"version": "1.4.806",
3296
+
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.806.tgz",
3297
+
"integrity": "sha512-nkoEX2QIB8kwCOtvtgwhXWy2IHVcOLQZu9Qo36uaGB835mdX/h8uLRlosL6QIhLVUnAiicXRW00PwaPZC74Nrg==",
3271
3298
"dev": true
3272
3299
},
3273
3300
"node_modules/elm": {
···
3464
3491
}
3465
3492
},
3466
3493
"node_modules/esbuild": {
3467
-
"version": "0.20.0",
3468
-
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.0.tgz",
3469
-
"integrity": "sha512-6iwE3Y2RVYCME1jLpBqq7LQWK3MW6vjV2bZy6gt/WrqkY+WE74Spyc0ThAOYpMtITvnjX09CrC6ym7A/m9mebA==",
3494
+
"version": "0.20.2",
3495
+
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz",
3496
+
"integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==",
3470
3497
"dev": true,
3471
3498
"hasInstallScript": true,
3472
3499
"bin": {
···
3476
3503
"node": ">=12"
3477
3504
},
3478
3505
"optionalDependencies": {
3479
-
"@esbuild/aix-ppc64": "0.20.0",
3480
-
"@esbuild/android-arm": "0.20.0",
3481
-
"@esbuild/android-arm64": "0.20.0",
3482
-
"@esbuild/android-x64": "0.20.0",
3483
-
"@esbuild/darwin-arm64": "0.20.0",
3484
-
"@esbuild/darwin-x64": "0.20.0",
3485
-
"@esbuild/freebsd-arm64": "0.20.0",
3486
-
"@esbuild/freebsd-x64": "0.20.0",
3487
-
"@esbuild/linux-arm": "0.20.0",
3488
-
"@esbuild/linux-arm64": "0.20.0",
3489
-
"@esbuild/linux-ia32": "0.20.0",
3490
-
"@esbuild/linux-loong64": "0.20.0",
3491
-
"@esbuild/linux-mips64el": "0.20.0",
3492
-
"@esbuild/linux-ppc64": "0.20.0",
3493
-
"@esbuild/linux-riscv64": "0.20.0",
3494
-
"@esbuild/linux-s390x": "0.20.0",
3495
-
"@esbuild/linux-x64": "0.20.0",
3496
-
"@esbuild/netbsd-x64": "0.20.0",
3497
-
"@esbuild/openbsd-x64": "0.20.0",
3498
-
"@esbuild/sunos-x64": "0.20.0",
3499
-
"@esbuild/win32-arm64": "0.20.0",
3500
-
"@esbuild/win32-ia32": "0.20.0",
3501
-
"@esbuild/win32-x64": "0.20.0"
3506
+
"@esbuild/aix-ppc64": "0.20.2",
3507
+
"@esbuild/android-arm": "0.20.2",
3508
+
"@esbuild/android-arm64": "0.20.2",
3509
+
"@esbuild/android-x64": "0.20.2",
3510
+
"@esbuild/darwin-arm64": "0.20.2",
3511
+
"@esbuild/darwin-x64": "0.20.2",
3512
+
"@esbuild/freebsd-arm64": "0.20.2",
3513
+
"@esbuild/freebsd-x64": "0.20.2",
3514
+
"@esbuild/linux-arm": "0.20.2",
3515
+
"@esbuild/linux-arm64": "0.20.2",
3516
+
"@esbuild/linux-ia32": "0.20.2",
3517
+
"@esbuild/linux-loong64": "0.20.2",
3518
+
"@esbuild/linux-mips64el": "0.20.2",
3519
+
"@esbuild/linux-ppc64": "0.20.2",
3520
+
"@esbuild/linux-riscv64": "0.20.2",
3521
+
"@esbuild/linux-s390x": "0.20.2",
3522
+
"@esbuild/linux-x64": "0.20.2",
3523
+
"@esbuild/netbsd-x64": "0.20.2",
3524
+
"@esbuild/openbsd-x64": "0.20.2",
3525
+
"@esbuild/sunos-x64": "0.20.2",
3526
+
"@esbuild/win32-arm64": "0.20.2",
3527
+
"@esbuild/win32-ia32": "0.20.2",
3528
+
"@esbuild/win32-x64": "0.20.2"
3502
3529
}
3503
3530
},
3504
3531
"node_modules/esbuild-plugin-wasm": {
···
5187
5214
"js-yaml": "bin/js-yaml.js"
5188
5215
}
5189
5216
},
5190
-
"node_modules/jschardet": {
5191
-
"version": "3.0.0",
5192
-
"resolved": "https://registry.npmjs.org/jschardet/-/jschardet-3.0.0.tgz",
5193
-
"integrity": "sha512-lJH6tJ77V8Nzd5QWRkFYCLc13a3vADkh3r/Fi8HupZGWk2OVVDfnZP8V/VgQgZ+lzW0kG2UGb5hFgt3V3ndotQ==",
5194
-
"engines": {
5195
-
"node": ">=0.1.90"
5196
-
}
5197
-
},
5198
5217
"node_modules/json-buffer": {
5199
5218
"version": "3.0.1",
5200
5219
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
···
5549
5568
}
5550
5569
},
5551
5570
"node_modules/mediainfo.js": {
5552
-
"version": "0.2.1",
5553
-
"resolved": "https://registry.npmjs.org/mediainfo.js/-/mediainfo.js-0.2.1.tgz",
5554
-
"integrity": "sha512-xbTstvy34gDmxNLVytixbY8Uw4DGKKsQIMvX7q1K8FwIk/gwAVLd30EVvPh/g+QHVscATRuqrNtbTb7XUjDeyw==",
5571
+
"version": "0.3.1",
5572
+
"resolved": "https://registry.npmjs.org/mediainfo.js/-/mediainfo.js-0.3.1.tgz",
5573
+
"integrity": "sha512-qUehPOCsqmEn0SmTaEOTgyaIiN9LZrDFYyDibsx2rpe8QaxWA+Dzr/fPMTMaHDt5L6J4Jm7pmcEhREN0N0ewrA==",
5555
5574
"dependencies": {
5556
5575
"yargs": "^17.7.2"
5557
5576
},
···
5559
5578
"mediainfo.js": "dist/esm/cli.js"
5560
5579
},
5561
5580
"engines": {
5562
-
"node": ">=14.16"
5581
+
"node": ">=18.0.0"
5563
5582
}
5564
5583
},
5565
5584
"node_modules/merge-options": {
···
6252
6271
"dev": true
6253
6272
},
6254
6273
"node_modules/picocolors": {
6255
-
"version": "1.0.0",
6256
-
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
6257
-
"integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==",
6274
+
"version": "1.0.1",
6275
+
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz",
6276
+
"integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==",
6258
6277
"dev": true
6259
6278
},
6260
6279
"node_modules/picomatch": {
···
7590
7609
}
7591
7610
},
7592
7611
"node_modules/update-browserslist-db": {
7593
-
"version": "1.0.13",
7594
-
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz",
7595
-
"integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==",
7612
+
"version": "1.0.16",
7613
+
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.16.tgz",
7614
+
"integrity": "sha512-KVbTxlBYlckhF5wgfyZXTWnMn7MMZjMu9XG8bPlliUOP9ThaF4QnhP8qrjrH7DRzHfSk0oQv1wToW+iA5GajEQ==",
7596
7615
"dev": true,
7597
7616
"funding": [
7598
7617
{
···
7609
7628
}
7610
7629
],
7611
7630
"dependencies": {
7612
-
"escalade": "^3.1.1",
7613
-
"picocolors": "^1.0.0"
7631
+
"escalade": "^3.1.2",
7632
+
"picocolors": "^1.0.1"
7614
7633
},
7615
7634
"bin": {
7616
7635
"update-browserslist-db": "cli.js"
···
8053
8072
"optional": true
8054
8073
},
8055
8074
"@esbuild/aix-ppc64": {
8056
-
"version": "0.20.0",
8057
-
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.0.tgz",
8058
-
"integrity": "sha512-fGFDEctNh0CcSwsiRPxiaqX0P5rq+AqE0SRhYGZ4PX46Lg1FNR6oCxJghf8YgY0WQEgQuh3lErUFE4KxLeRmmw==",
8075
+
"version": "0.20.2",
8076
+
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz",
8077
+
"integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==",
8059
8078
"dev": true,
8060
8079
"optional": true
8061
8080
},
8062
8081
"@esbuild/android-arm": {
8063
-
"version": "0.20.0",
8064
-
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.0.tgz",
8065
-
"integrity": "sha512-3bMAfInvByLHfJwYPJRlpTeaQA75n8C/QKpEaiS4HrFWFiJlNI0vzq/zCjBrhAYcPyVPG7Eo9dMrcQXuqmNk5g==",
8082
+
"version": "0.20.2",
8083
+
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz",
8084
+
"integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==",
8066
8085
"dev": true,
8067
8086
"optional": true
8068
8087
},
8069
8088
"@esbuild/android-arm64": {
8070
-
"version": "0.20.0",
8071
-
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.0.tgz",
8072
-
"integrity": "sha512-aVpnM4lURNkp0D3qPoAzSG92VXStYmoVPOgXveAUoQBWRSuQzt51yvSju29J6AHPmwY1BjH49uR29oyfH1ra8Q==",
8089
+
"version": "0.20.2",
8090
+
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz",
8091
+
"integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==",
8073
8092
"dev": true,
8074
8093
"optional": true
8075
8094
},
8076
8095
"@esbuild/android-x64": {
8077
-
"version": "0.20.0",
8078
-
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.0.tgz",
8079
-
"integrity": "sha512-uK7wAnlRvjkCPzh8jJ+QejFyrP8ObKuR5cBIsQZ+qbMunwR8sbd8krmMbxTLSrDhiPZaJYKQAU5Y3iMDcZPhyQ==",
8096
+
"version": "0.20.2",
8097
+
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz",
8098
+
"integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==",
8080
8099
"dev": true,
8081
8100
"optional": true
8082
8101
},
8083
8102
"@esbuild/darwin-arm64": {
8084
-
"version": "0.20.0",
8085
-
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.0.tgz",
8086
-
"integrity": "sha512-AjEcivGAlPs3UAcJedMa9qYg9eSfU6FnGHJjT8s346HSKkrcWlYezGE8VaO2xKfvvlZkgAhyvl06OJOxiMgOYQ==",
8103
+
"version": "0.20.2",
8104
+
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz",
8105
+
"integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==",
8087
8106
"dev": true,
8088
8107
"optional": true
8089
8108
},
8090
8109
"@esbuild/darwin-x64": {
8091
-
"version": "0.20.0",
8092
-
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.0.tgz",
8093
-
"integrity": "sha512-bsgTPoyYDnPv8ER0HqnJggXK6RyFy4PH4rtsId0V7Efa90u2+EifxytE9pZnsDgExgkARy24WUQGv9irVbTvIw==",
8110
+
"version": "0.20.2",
8111
+
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz",
8112
+
"integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==",
8094
8113
"dev": true,
8095
8114
"optional": true
8096
8115
},
8097
8116
"@esbuild/freebsd-arm64": {
8098
-
"version": "0.20.0",
8099
-
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.0.tgz",
8100
-
"integrity": "sha512-kQ7jYdlKS335mpGbMW5tEe3IrQFIok9r84EM3PXB8qBFJPSc6dpWfrtsC/y1pyrz82xfUIn5ZrnSHQQsd6jebQ==",
8117
+
"version": "0.20.2",
8118
+
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz",
8119
+
"integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==",
8101
8120
"dev": true,
8102
8121
"optional": true
8103
8122
},
8104
8123
"@esbuild/freebsd-x64": {
8105
-
"version": "0.20.0",
8106
-
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.0.tgz",
8107
-
"integrity": "sha512-uG8B0WSepMRsBNVXAQcHf9+Ko/Tr+XqmK7Ptel9HVmnykupXdS4J7ovSQUIi0tQGIndhbqWLaIL/qO/cWhXKyQ==",
8124
+
"version": "0.20.2",
8125
+
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz",
8126
+
"integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==",
8108
8127
"dev": true,
8109
8128
"optional": true
8110
8129
},
8111
8130
"@esbuild/linux-arm": {
8112
-
"version": "0.20.0",
8113
-
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.0.tgz",
8114
-
"integrity": "sha512-2ezuhdiZw8vuHf1HKSf4TIk80naTbP9At7sOqZmdVwvvMyuoDiZB49YZKLsLOfKIr77+I40dWpHVeY5JHpIEIg==",
8131
+
"version": "0.20.2",
8132
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz",
8133
+
"integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==",
8115
8134
"dev": true,
8116
8135
"optional": true
8117
8136
},
8118
8137
"@esbuild/linux-arm64": {
8119
-
"version": "0.20.0",
8120
-
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.0.tgz",
8121
-
"integrity": "sha512-uTtyYAP5veqi2z9b6Gr0NUoNv9F/rOzI8tOD5jKcCvRUn7T60Bb+42NDBCWNhMjkQzI0qqwXkQGo1SY41G52nw==",
8138
+
"version": "0.20.2",
8139
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz",
8140
+
"integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==",
8122
8141
"dev": true,
8123
8142
"optional": true
8124
8143
},
8125
8144
"@esbuild/linux-ia32": {
8126
-
"version": "0.20.0",
8127
-
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.0.tgz",
8128
-
"integrity": "sha512-c88wwtfs8tTffPaoJ+SQn3y+lKtgTzyjkD8NgsyCtCmtoIC8RDL7PrJU05an/e9VuAke6eJqGkoMhJK1RY6z4w==",
8145
+
"version": "0.20.2",
8146
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz",
8147
+
"integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==",
8129
8148
"dev": true,
8130
8149
"optional": true
8131
8150
},
8132
8151
"@esbuild/linux-loong64": {
8133
-
"version": "0.20.0",
8134
-
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.0.tgz",
8135
-
"integrity": "sha512-lR2rr/128/6svngnVta6JN4gxSXle/yZEZL3o4XZ6esOqhyR4wsKyfu6qXAL04S4S5CgGfG+GYZnjFd4YiG3Aw==",
8152
+
"version": "0.20.2",
8153
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz",
8154
+
"integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==",
8136
8155
"dev": true,
8137
8156
"optional": true
8138
8157
},
8139
8158
"@esbuild/linux-mips64el": {
8140
-
"version": "0.20.0",
8141
-
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.0.tgz",
8142
-
"integrity": "sha512-9Sycc+1uUsDnJCelDf6ZNqgZQoK1mJvFtqf2MUz4ujTxGhvCWw+4chYfDLPepMEvVL9PDwn6HrXad5yOrNzIsQ==",
8159
+
"version": "0.20.2",
8160
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz",
8161
+
"integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==",
8143
8162
"dev": true,
8144
8163
"optional": true
8145
8164
},
8146
8165
"@esbuild/linux-ppc64": {
8147
-
"version": "0.20.0",
8148
-
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.0.tgz",
8149
-
"integrity": "sha512-CoWSaaAXOZd+CjbUTdXIJE/t7Oz+4g90A3VBCHLbfuc5yUQU/nFDLOzQsN0cdxgXd97lYW/psIIBdjzQIwTBGw==",
8166
+
"version": "0.20.2",
8167
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz",
8168
+
"integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==",
8150
8169
"dev": true,
8151
8170
"optional": true
8152
8171
},
8153
8172
"@esbuild/linux-riscv64": {
8154
-
"version": "0.20.0",
8155
-
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.0.tgz",
8156
-
"integrity": "sha512-mlb1hg/eYRJUpv8h/x+4ShgoNLL8wgZ64SUr26KwglTYnwAWjkhR2GpoKftDbPOCnodA9t4Y/b68H4J9XmmPzA==",
8173
+
"version": "0.20.2",
8174
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz",
8175
+
"integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==",
8157
8176
"dev": true,
8158
8177
"optional": true
8159
8178
},
8160
8179
"@esbuild/linux-s390x": {
8161
-
"version": "0.20.0",
8162
-
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.0.tgz",
8163
-
"integrity": "sha512-fgf9ubb53xSnOBqyvWEY6ukBNRl1mVX1srPNu06B6mNsNK20JfH6xV6jECzrQ69/VMiTLvHMicQR/PgTOgqJUQ==",
8180
+
"version": "0.20.2",
8181
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz",
8182
+
"integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==",
8164
8183
"dev": true,
8165
8184
"optional": true
8166
8185
},
8167
8186
"@esbuild/linux-x64": {
8168
-
"version": "0.20.0",
8169
-
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.0.tgz",
8170
-
"integrity": "sha512-H9Eu6MGse++204XZcYsse1yFHmRXEWgadk2N58O/xd50P9EvFMLJTQLg+lB4E1cF2xhLZU5luSWtGTb0l9UeSg==",
8187
+
"version": "0.20.2",
8188
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz",
8189
+
"integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==",
8171
8190
"dev": true,
8172
8191
"optional": true
8173
8192
},
8174
8193
"@esbuild/netbsd-x64": {
8175
-
"version": "0.20.0",
8176
-
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.0.tgz",
8177
-
"integrity": "sha512-lCT675rTN1v8Fo+RGrE5KjSnfY0x9Og4RN7t7lVrN3vMSjy34/+3na0q7RIfWDAj0e0rCh0OL+P88lu3Rt21MQ==",
8194
+
"version": "0.20.2",
8195
+
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz",
8196
+
"integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==",
8178
8197
"dev": true,
8179
8198
"optional": true
8180
8199
},
8181
8200
"@esbuild/openbsd-x64": {
8182
-
"version": "0.20.0",
8183
-
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.0.tgz",
8184
-
"integrity": "sha512-HKoUGXz/TOVXKQ+67NhxyHv+aDSZf44QpWLa3I1lLvAwGq8x1k0T+e2HHSRvxWhfJrFxaaqre1+YyzQ99KixoA==",
8201
+
"version": "0.20.2",
8202
+
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz",
8203
+
"integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==",
8185
8204
"dev": true,
8186
8205
"optional": true
8187
8206
},
8188
8207
"@esbuild/sunos-x64": {
8189
-
"version": "0.20.0",
8190
-
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.0.tgz",
8191
-
"integrity": "sha512-GDwAqgHQm1mVoPppGsoq4WJwT3vhnz/2N62CzhvApFD1eJyTroob30FPpOZabN+FgCjhG+AgcZyOPIkR8dfD7g==",
8208
+
"version": "0.20.2",
8209
+
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz",
8210
+
"integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==",
8192
8211
"dev": true,
8193
8212
"optional": true
8194
8213
},
8195
8214
"@esbuild/win32-arm64": {
8196
-
"version": "0.20.0",
8197
-
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.0.tgz",
8198
-
"integrity": "sha512-0vYsP8aC4TvMlOQYozoksiaxjlvUcQrac+muDqj1Fxy6jh9l9CZJzj7zmh8JGfiV49cYLTorFLxg7593pGldwQ==",
8215
+
"version": "0.20.2",
8216
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz",
8217
+
"integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==",
8199
8218
"dev": true,
8200
8219
"optional": true
8201
8220
},
8202
8221
"@esbuild/win32-ia32": {
8203
-
"version": "0.20.0",
8204
-
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.0.tgz",
8205
-
"integrity": "sha512-p98u4rIgfh4gdpV00IqknBD5pC84LCub+4a3MO+zjqvU5MVXOc3hqR2UgT2jI2nh3h8s9EQxmOsVI3tyzv1iFg==",
8222
+
"version": "0.20.2",
8223
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz",
8224
+
"integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==",
8206
8225
"dev": true,
8207
8226
"optional": true
8208
8227
},
8209
8228
"@esbuild/win32-x64": {
8210
-
"version": "0.20.0",
8211
-
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.0.tgz",
8212
-
"integrity": "sha512-NgJnesu1RtWihtTtXGFMU5YSE6JyyHPMxCwBZK7a6/8d31GuSo9l0Ss7w1Jw5QnKUawG6UEehs883kcXf5fYwg==",
8229
+
"version": "0.20.2",
8230
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz",
8231
+
"integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==",
8213
8232
"dev": true,
8214
8233
"optional": true
8215
8234
},
···
8921
8940
"@types/responselike": "^1.0.0"
8922
8941
}
8923
8942
},
8943
+
"@types/elm": {
8944
+
"version": "0.19.3",
8945
+
"resolved": "https://registry.npmjs.org/@types/elm/-/elm-0.19.3.tgz",
8946
+
"integrity": "sha512-1DnHZiIHvDyjL6MHrePqbD3ooLLix13k6ow8gEydFOAXImkcvbzQX0Ri+WJOM7RvgPfmyUe6uQ2Acupb1oL+GA==",
8947
+
"dev": true
8948
+
},
8949
+
"@types/file-saver": {
8950
+
"version": "2.0.7",
8951
+
"resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz",
8952
+
"integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==",
8953
+
"dev": true
8954
+
},
8924
8955
"@types/http-cache-semantics": {
8925
8956
"version": "4.0.1",
8926
8957
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz",
···
8942
8973
"@types/node": "*"
8943
8974
}
8944
8975
},
8976
+
"@types/lunr": {
8977
+
"version": "2.3.7",
8978
+
"resolved": "https://registry.npmjs.org/@types/lunr/-/lunr-2.3.7.tgz",
8979
+
"integrity": "sha512-Tb/kUm38e8gmjahQzdCKhbdsvQ9/ppzHFfsJ0dMs3ckqQsRj+P5IkSAwFTBrBxdyr3E/LoMUUrZngjDYAjiE3A==",
8980
+
"dev": true
8981
+
},
8945
8982
"@types/node": {
8946
8983
"version": "18.16.3",
8947
8984
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.16.3.tgz",
···
8960
8997
"version": "7.5.6",
8961
8998
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz",
8962
8999
"integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==",
9000
+
"dev": true
9001
+
},
9002
+
"@types/throttle-debounce": {
9003
+
"version": "5.0.2",
9004
+
"resolved": "https://registry.npmjs.org/@types/throttle-debounce/-/throttle-debounce-5.0.2.tgz",
9005
+
"integrity": "sha512-pDzSNulqooSKvSNcksnV72nk8p7gRqN8As71Sp28nov1IgmPKWbOEIwAWvBME5pPTtaXJAvG3O4oc76HlQ4kqQ==",
8963
9006
"dev": true
8964
9007
},
8965
9008
"@types/tv4": {
···
9261
9304
"dev": true
9262
9305
},
9263
9306
"autoprefixer": {
9264
-
"version": "10.4.17",
9265
-
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.17.tgz",
9266
-
"integrity": "sha512-/cpVNRLSfhOtcGflT13P2794gVSgmPgTR+erw5ifnMLZb0UnSlkK4tquLmkd3BhA+nLo5tX8Cu0upUsGKvKbmg==",
9307
+
"version": "10.4.19",
9308
+
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz",
9309
+
"integrity": "sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==",
9267
9310
"dev": true,
9268
9311
"requires": {
9269
-
"browserslist": "^4.22.2",
9270
-
"caniuse-lite": "^1.0.30001578",
9312
+
"browserslist": "^4.23.0",
9313
+
"caniuse-lite": "^1.0.30001599",
9271
9314
"fraction.js": "^4.3.7",
9272
9315
"normalize-range": "^0.1.2",
9273
9316
"picocolors": "^1.0.0",
···
9478
9521
}
9479
9522
},
9480
9523
"browserslist": {
9481
-
"version": "4.22.3",
9482
-
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.3.tgz",
9483
-
"integrity": "sha512-UAp55yfwNv0klWNapjs/ktHoguxuQNGnOzxYmfnXIS+8AsRDZkSDxg7R1AX3GKzn078SBI5dzwzj/Yx0Or0e3A==",
9524
+
"version": "4.23.1",
9525
+
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.1.tgz",
9526
+
"integrity": "sha512-TUfofFo/KsK/bWZ9TWQ5O26tsWW4Uhmt8IYklbnUa70udB6P2wA7w7o4PY4muaEPBQaAX+CEnmmIA41NVHtPVw==",
9484
9527
"dev": true,
9485
9528
"requires": {
9486
-
"caniuse-lite": "^1.0.30001580",
9487
-
"electron-to-chromium": "^1.4.648",
9529
+
"caniuse-lite": "^1.0.30001629",
9530
+
"electron-to-chromium": "^1.4.796",
9488
9531
"node-releases": "^2.0.14",
9489
-
"update-browserslist-db": "^1.0.13"
9532
+
"update-browserslist-db": "^1.0.16"
9490
9533
}
9491
9534
},
9492
9535
"buffer": {
···
9568
9611
"dev": true
9569
9612
},
9570
9613
"caniuse-lite": {
9571
-
"version": "1.0.30001584",
9572
-
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001584.tgz",
9573
-
"integrity": "sha512-LOz7CCQ9M1G7OjJOF9/mzmqmj3jE/7VOmrfw6Mgs0E8cjOsbRXQJHsPBfmBOXDskXKrHLyyW3n7kpDW/4BsfpQ==",
9614
+
"version": "1.0.30001636",
9615
+
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001636.tgz",
9616
+
"integrity": "sha512-bMg2vmr8XBsbL6Lr0UHXy/21m84FTxDLWn2FSqMd5PrlbMxwJlQnC2YWYxVgp66PZE+BBNF2jYQUBKCo1FDeZg==",
9574
9617
"dev": true
9575
9618
},
9576
9619
"catering": {
···
9997
10040
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="
9998
10041
},
9999
10042
"electron-to-chromium": {
10000
-
"version": "1.4.657",
10001
-
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.657.tgz",
10002
-
"integrity": "sha512-On2ymeleg6QbRuDk7wNgDdXtNqlJLM2w4Agx1D/RiTmItiL+a9oq5p7HUa2ZtkAtGBe/kil2dq/7rPfkbe0r5w==",
10043
+
"version": "1.4.806",
10044
+
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.806.tgz",
10045
+
"integrity": "sha512-nkoEX2QIB8kwCOtvtgwhXWy2IHVcOLQZu9Qo36uaGB835mdX/h8uLRlosL6QIhLVUnAiicXRW00PwaPZC74Nrg==",
10003
10046
"dev": true
10004
10047
},
10005
10048
"elm": {
···
10147
10190
"dev": true
10148
10191
},
10149
10192
"esbuild": {
10150
-
"version": "0.20.0",
10151
-
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.0.tgz",
10152
-
"integrity": "sha512-6iwE3Y2RVYCME1jLpBqq7LQWK3MW6vjV2bZy6gt/WrqkY+WE74Spyc0ThAOYpMtITvnjX09CrC6ym7A/m9mebA==",
10193
+
"version": "0.20.2",
10194
+
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz",
10195
+
"integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==",
10153
10196
"dev": true,
10154
10197
"requires": {
10155
-
"@esbuild/aix-ppc64": "0.20.0",
10156
-
"@esbuild/android-arm": "0.20.0",
10157
-
"@esbuild/android-arm64": "0.20.0",
10158
-
"@esbuild/android-x64": "0.20.0",
10159
-
"@esbuild/darwin-arm64": "0.20.0",
10160
-
"@esbuild/darwin-x64": "0.20.0",
10161
-
"@esbuild/freebsd-arm64": "0.20.0",
10162
-
"@esbuild/freebsd-x64": "0.20.0",
10163
-
"@esbuild/linux-arm": "0.20.0",
10164
-
"@esbuild/linux-arm64": "0.20.0",
10165
-
"@esbuild/linux-ia32": "0.20.0",
10166
-
"@esbuild/linux-loong64": "0.20.0",
10167
-
"@esbuild/linux-mips64el": "0.20.0",
10168
-
"@esbuild/linux-ppc64": "0.20.0",
10169
-
"@esbuild/linux-riscv64": "0.20.0",
10170
-
"@esbuild/linux-s390x": "0.20.0",
10171
-
"@esbuild/linux-x64": "0.20.0",
10172
-
"@esbuild/netbsd-x64": "0.20.0",
10173
-
"@esbuild/openbsd-x64": "0.20.0",
10174
-
"@esbuild/sunos-x64": "0.20.0",
10175
-
"@esbuild/win32-arm64": "0.20.0",
10176
-
"@esbuild/win32-ia32": "0.20.0",
10177
-
"@esbuild/win32-x64": "0.20.0"
10198
+
"@esbuild/aix-ppc64": "0.20.2",
10199
+
"@esbuild/android-arm": "0.20.2",
10200
+
"@esbuild/android-arm64": "0.20.2",
10201
+
"@esbuild/android-x64": "0.20.2",
10202
+
"@esbuild/darwin-arm64": "0.20.2",
10203
+
"@esbuild/darwin-x64": "0.20.2",
10204
+
"@esbuild/freebsd-arm64": "0.20.2",
10205
+
"@esbuild/freebsd-x64": "0.20.2",
10206
+
"@esbuild/linux-arm": "0.20.2",
10207
+
"@esbuild/linux-arm64": "0.20.2",
10208
+
"@esbuild/linux-ia32": "0.20.2",
10209
+
"@esbuild/linux-loong64": "0.20.2",
10210
+
"@esbuild/linux-mips64el": "0.20.2",
10211
+
"@esbuild/linux-ppc64": "0.20.2",
10212
+
"@esbuild/linux-riscv64": "0.20.2",
10213
+
"@esbuild/linux-s390x": "0.20.2",
10214
+
"@esbuild/linux-x64": "0.20.2",
10215
+
"@esbuild/netbsd-x64": "0.20.2",
10216
+
"@esbuild/openbsd-x64": "0.20.2",
10217
+
"@esbuild/sunos-x64": "0.20.2",
10218
+
"@esbuild/win32-arm64": "0.20.2",
10219
+
"@esbuild/win32-ia32": "0.20.2",
10220
+
"@esbuild/win32-x64": "0.20.2"
10178
10221
}
10179
10222
},
10180
10223
"esbuild-plugin-wasm": {
···
11337
11380
"argparse": "^2.0.1"
11338
11381
}
11339
11382
},
11340
-
"jschardet": {
11341
-
"version": "3.0.0",
11342
-
"resolved": "https://registry.npmjs.org/jschardet/-/jschardet-3.0.0.tgz",
11343
-
"integrity": "sha512-lJH6tJ77V8Nzd5QWRkFYCLc13a3vADkh3r/Fi8HupZGWk2OVVDfnZP8V/VgQgZ+lzW0kG2UGb5hFgt3V3ndotQ=="
11344
-
},
11345
11383
"json-buffer": {
11346
11384
"version": "3.0.1",
11347
11385
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
···
11630
11668
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="
11631
11669
},
11632
11670
"mediainfo.js": {
11633
-
"version": "0.2.1",
11634
-
"resolved": "https://registry.npmjs.org/mediainfo.js/-/mediainfo.js-0.2.1.tgz",
11635
-
"integrity": "sha512-xbTstvy34gDmxNLVytixbY8Uw4DGKKsQIMvX7q1K8FwIk/gwAVLd30EVvPh/g+QHVscATRuqrNtbTb7XUjDeyw==",
11671
+
"version": "0.3.1",
11672
+
"resolved": "https://registry.npmjs.org/mediainfo.js/-/mediainfo.js-0.3.1.tgz",
11673
+
"integrity": "sha512-qUehPOCsqmEn0SmTaEOTgyaIiN9LZrDFYyDibsx2rpe8QaxWA+Dzr/fPMTMaHDt5L6J4Jm7pmcEhREN0N0ewrA==",
11636
11674
"requires": {
11637
11675
"yargs": "^17.7.2"
11638
11676
}
···
12103
12141
"dev": true
12104
12142
},
12105
12143
"picocolors": {
12106
-
"version": "1.0.0",
12107
-
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
12108
-
"integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==",
12144
+
"version": "1.0.1",
12145
+
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz",
12146
+
"integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==",
12109
12147
"dev": true
12110
12148
},
12111
12149
"picomatch": {
···
13030
13068
"dev": true
13031
13069
},
13032
13070
"update-browserslist-db": {
13033
-
"version": "1.0.13",
13034
-
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz",
13035
-
"integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==",
13071
+
"version": "1.0.16",
13072
+
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.16.tgz",
13073
+
"integrity": "sha512-KVbTxlBYlckhF5wgfyZXTWnMn7MMZjMu9XG8bPlliUOP9ThaF4QnhP8qrjrH7DRzHfSk0oQv1wToW+iA5GajEQ==",
13036
13074
"dev": true,
13037
13075
"requires": {
13038
-
"escalade": "^3.1.1",
13039
-
"picocolors": "^1.0.0"
13076
+
"escalade": "^3.1.2",
13077
+
"picocolors": "^1.0.1"
13040
13078
}
13041
13079
},
13042
13080
"update-check": {
+8
-5
package.json
+8
-5
package.json
···
1
1
{
2
2
"name": "diffuse",
3
3
"description": "A music player that connects to your cloud/distributed storage",
4
-
"version": "3.4.0",
4
+
"version": "3.5.0",
5
5
"author": "Steven Vandevelde <icid.asset@gmail.com>",
6
6
"homepage": "https://diffuse.sh",
7
7
"repository": "github:icidasset/diffuse",
···
12
12
"@tauri-apps/plugin-dialog": "^2.0.0-beta.0",
13
13
"@tauri-apps/plugin-fs": "^2.0.0-beta.0",
14
14
"@tauri-apps/plugin-shell": "^2.0.0-beta.0",
15
+
"@types/elm": "^0.19.3",
16
+
"@types/file-saver": "^2.0.7",
17
+
"@types/lunr": "^2.3.7",
18
+
"@types/throttle-debounce": "^5.0.2",
15
19
"@typescript-eslint/eslint-plugin": "^6.21.0",
16
20
"@typescript-eslint/parser": "^6.21.0",
17
21
"assert": "^2.1.0",
18
-
"autoprefixer": "^10.4.17",
22
+
"autoprefixer": "^10.4.19",
19
23
"buffer": "^6.0.3",
20
24
"elm": "0.19.1-6",
21
25
"elm-format": "^0.8.7",
22
26
"elm-review": "^2.10.3",
23
-
"esbuild": "^0.20.0",
27
+
"esbuild": "^0.20.2",
24
28
"esbuild-plugin-wasm": "^1.1.0",
25
29
"eslint": "^8.56.0",
26
30
"events": "^3.3.0",
···
42
46
"encoding-japanese": "^2.0.0",
43
47
"fast-text-encoding": "^1.0.6",
44
48
"file-saver": "^2.0.2",
45
-
"jschardet": "^3.0.0",
46
49
"jszip": "^3.7.1",
47
50
"load-script2": "^2.0.5",
48
51
"localforage": "^1.10.0",
49
52
"lunr": "^2.3.8",
50
-
"mediainfo.js": "^0.2.1",
53
+
"mediainfo.js": "^0.3.1",
51
54
"music-metadata-browser": "^2.5.10",
52
55
"readable-stream": "^4.5.2",
53
56
"remotestoragejs": "^2.0.0-beta.6",
+1
-1
src-tauri/Cargo.toml
+1
-1
src-tauri/Cargo.toml
+1
-1
src-tauri/tauri.conf.json
+1
-1
src-tauri/tauri.conf.json
+12
-3
src/Applications/Brain/Other/State.elm
+12
-3
src/Applications/Brain/Other/State.elm
···
3
3
import Alien
4
4
import Brain.Common.State as Common
5
5
import Brain.Ports as Ports
6
+
import Brain.Task.Ports
6
7
import Brain.Types exposing (..)
7
8
import Dict
8
9
import Json.Decode as Json
···
56
57
Return.singleton { model | currentTime = time }
57
58
58
59
60
+
{-| Save alien data to cache.
61
+
-}
59
62
toCache : Json.Value -> Manager
60
63
toCache data =
61
64
case Json.decodeValue Alien.hostDecoder data of
62
65
Ok alienEvent ->
63
-
alienEvent
64
-
|> Ports.toCache
65
-
|> Return.communicate
66
+
case Alien.tagFromString alienEvent.tag of
67
+
Just tag ->
68
+
alienEvent.data
69
+
|> Brain.Task.Ports.toCache tag
70
+
|> Common.attemptPortTask (always Bypass)
71
+
|> Return.communicate
72
+
73
+
Nothing ->
74
+
Common.reportUI Alien.ToCache "Failed to decode alien tag"
66
75
67
76
Err err ->
68
77
err
-9
src/Applications/Brain/Ports.elm
-9
src/Applications/Brain/Ports.elm
···
12
12
port downloadTracks : Json.Value -> Cmd msg
13
13
14
14
15
-
port removeCache : Alien.Event -> Cmd msg
16
-
17
-
18
15
port removeTracksFromCache : Json.Value -> Cmd msg
19
-
20
-
21
-
port requestCache : Alien.Event -> Cmd msg
22
16
23
17
24
18
port requestSearch : String -> Cmd msg
···
31
25
32
26
33
27
port syncTags : ContextForTagsSync -> Cmd msg
34
-
35
-
36
-
port toCache : Alien.Event -> Cmd msg
37
28
38
29
39
30
port toUI : Alien.Event -> Cmd msg
-2
src/Applications/Brain/Tracks/State.elm
-2
src/Applications/Brain/Tracks/State.elm
···
119
119
makeTrackUrl model.currentTime trackPath maybeSource
120
120
in
121
121
dict
122
-
|> Dict.remove "trackPath"
123
-
|> Dict.remove "trackSourceId"
124
122
|> Dict.insert "trackGetUrl" (mkTrackUrl Get)
125
123
|> Dict.insert "trackHeadUrl" (mkTrackUrl Head)
126
124
|> Json.Encode.dict identity Json.Encode.string
+25
-18
src/Applications/Brain/User/State.elm
+25
-18
src/Applications/Brain/User/State.elm
···
99
99
|> User.decodeHypaethralData
100
100
|> Result.map
101
101
(\hypaethralData ->
102
-
( hypaethralJson
103
-
, hypaethralData
104
-
)
102
+
Commence
103
+
maybeMethod
104
+
initialUrl
105
+
( hypaethralJson
106
+
, hypaethralData
107
+
)
105
108
)
106
-
|> Result.withDefault
107
-
( User.encodeHypaethralData User.emptyHypaethralData
108
-
, User.emptyHypaethralData
109
-
)
110
-
|> Commence maybeMethod initialUrl
111
-
|> UserMsg
109
+
|> Result.mapError Decode.errorToString
110
+
|> Common.reportErrorToUI UserMsg
112
111
)
113
112
114
113
···
345
344
unsetSyncMethod model =
346
345
-- 💀
347
346
-- Unset & remove stored method.
348
-
[ Ports.removeCache (Alien.trigger Alien.SyncMethod)
349
-
, Ports.removeCache (Alien.trigger Alien.SecretKey)
347
+
[ Common.attemptPortTask (always Brain.Bypass) (Brain.Task.Ports.removeCache Alien.SyncMethod)
348
+
, Common.attemptPortTask (always Brain.Bypass) (Brain.Task.Ports.removeCache Alien.SecretKey)
350
349
351
350
--
352
351
, case model.userSyncMethod of
···
380
379
381
380
retrieveEnclosedData : Manager
382
381
retrieveEnclosedData =
383
-
Alien.EnclosedData
384
-
|> Alien.trigger
385
-
|> Ports.requestCache
382
+
Decode.value
383
+
|> Brain.Task.Ports.fromCache Alien.EnclosedData
384
+
|> Common.attemptPortTask
385
+
(\maybe ->
386
+
case maybe of
387
+
Just json ->
388
+
Brain.UserMsg (EnclosedDataRetrieved json)
389
+
390
+
Nothing ->
391
+
Brain.Bypass
392
+
)
386
393
|> Return.communicate
387
394
388
395
389
396
saveEnclosedData : Json.Value -> Manager
390
397
saveEnclosedData json =
391
398
json
392
-
|> Alien.broadcast Alien.EnclosedData
393
-
|> Ports.toCache
399
+
|> Brain.Task.Ports.toCache Alien.EnclosedData
400
+
|> Common.attemptPortTask (always Brain.Bypass)
394
401
|> Return.communicate
395
402
396
403
···
668
675
saveMethod method model =
669
676
method
670
677
|> encodeMethod
671
-
|> Alien.broadcast Alien.SyncMethod
672
-
|> Ports.toCache
678
+
|> Brain.Task.Ports.toCache Alien.SyncMethod
679
+
|> Common.attemptPortTask (always Brain.Bypass)
673
680
|> return { model | userSyncMethod = Just method }
674
681
675
682
+47
-28
src/Applications/UI.elm
+47
-28
src/Applications/UI.elm
···
117
117
-----------------------------------------
118
118
-- Audio
119
119
-----------------------------------------
120
-
, audioDuration = 0
121
-
, audioHasStalled = False
122
-
, audioIsLoading = False
123
-
, audioIsPlaying = False
124
-
, audioPosition = 0
120
+
, audioElements = []
121
+
, nowPlaying = Nothing
125
122
, progress = Dict.empty
126
123
, rememberProgress = True
127
124
···
136
133
-----------------------------------------
137
134
-- Debouncing
138
135
-----------------------------------------
136
+
, preloadDebouncer =
137
+
30
138
+
|> Debouncer.fromSeconds
139
+
|> Debouncer.debounce
140
+
|> Debouncer.toDebouncer
141
+
, progressDebouncer =
142
+
30
143
+
|> Debouncer.fromSeconds
144
+
|> Debouncer.throttle
145
+
|> Debouncer.emitWhenUnsettled Nothing
146
+
|> Debouncer.toDebouncer
139
147
, resizeDebouncer =
140
148
0.25
141
149
|> Debouncer.fromSeconds
···
174
182
-- Queue
175
183
-----------------------------------------
176
184
, dontPlay = []
177
-
, nowPlaying = Nothing
178
185
, playedPreviously = []
179
186
, playingNext = []
180
187
, selectedQueueItem = Nothing
···
273
280
-----------------------------------------
274
281
-- Audio
275
282
-----------------------------------------
283
+
AudioDurationChange a ->
284
+
Audio.durationChange a
285
+
286
+
AudioEnded a ->
287
+
Audio.ended a
288
+
289
+
AudioError a ->
290
+
Audio.error a
291
+
292
+
AudioHasLoaded a ->
293
+
Audio.hasLoaded a
294
+
295
+
AudioIsLoading a ->
296
+
Audio.isLoading a
297
+
298
+
AudioPlaybackStateChanged a ->
299
+
Audio.playbackStateChanged a
300
+
301
+
AudioPreloadDebounce a ->
302
+
Audio.preloadDebounce update a
303
+
304
+
AudioTimeUpdated a ->
305
+
Audio.timeUpdated a
306
+
276
307
NoteProgress a ->
277
308
Audio.noteProgress a
309
+
310
+
NoteProgressDebounce a ->
311
+
Audio.noteProgressDebounce update a
278
312
279
313
Pause ->
280
314
Audio.pause
···
284
318
285
319
Seek a ->
286
320
Audio.seek a
287
-
288
-
SetAudioDuration a ->
289
-
Audio.setDuration a
290
-
291
-
SetAudioHasStalled a ->
292
-
Audio.setHasStalled a
293
-
294
-
SetAudioIsLoading a ->
295
-
Audio.setIsLoading a
296
-
297
-
SetAudioIsPlaying a ->
298
-
Audio.setIsPlaying a
299
-
300
-
SetAudioPosition a ->
301
-
Audio.setPosition a
302
321
303
322
Stop ->
304
323
Audio.stop
···
562
581
-----------------------------------------
563
582
-- Audio
564
583
-----------------------------------------
565
-
, Ports.noteProgress NoteProgress
584
+
, Ports.audioDurationChange AudioDurationChange
585
+
, Ports.audioEnded AudioEnded
586
+
, Ports.audioError AudioError
587
+
, Ports.audioPlaybackStateChanged AudioPlaybackStateChanged
588
+
, Ports.audioIsLoading AudioIsLoading
589
+
, Ports.audioHasLoaded AudioHasLoaded
590
+
, Ports.audioTimeUpdated AudioTimeUpdated
566
591
, Ports.requestPause (always Pause)
567
592
, Ports.requestPlay (always Play)
568
593
, Ports.requestPlayPause (always TogglePlay)
569
594
, Ports.requestStop (always Stop)
570
-
, Ports.setAudioDuration SetAudioDuration
571
-
, Ports.setAudioHasStalled SetAudioHasStalled
572
-
, Ports.setAudioIsLoading SetAudioIsLoading
573
-
, Ports.setAudioIsPlaying SetAudioIsPlaying
574
-
, Ports.setAudioPosition SetAudioPosition
575
595
576
596
-----------------------------------------
577
597
-- Backdrop
···
591
611
-----------------------------------------
592
612
-- Queue
593
613
-----------------------------------------
594
-
, Ports.activeQueueItemEnded (QueueMsg << always Queue.Shift)
595
614
, Ports.requestNext (\_ -> QueueMsg Queue.Shift)
596
615
, Ports.requestPrevious (\_ -> QueueMsg Queue.Rewind)
597
616
+6
-6
src/Applications/UI/Adjunct.elm
+6
-6
src/Applications/UI/Adjunct.elm
···
63
63
[ Keyboard.Character "]", Keyboard.Control ] ->
64
64
Queue.shift m
65
65
66
-
[ Keyboard.Character "{", Keyboard.Shift, Keyboard.Control ] ->
67
-
Audio.seek ((m.audioPosition - 10) / m.audioDuration) m
68
-
69
-
[ Keyboard.Character "}", Keyboard.Shift, Keyboard.Control ] ->
70
-
Audio.seek ((m.audioPosition + 10) / m.audioDuration) m
71
-
66
+
-- TODO:
67
+
-- [ Keyboard.Character "{", Keyboard.Shift, Keyboard.Control ] ->
68
+
-- Audio.seek ((m.audioPosition - 10) / m.audioDuration) m
69
+
--
70
+
-- [ Keyboard.Character "}", Keyboard.Shift, Keyboard.Control ] ->
71
+
-- Audio.seek ((m.audioPosition + 10) / m.audioDuration) m
72
72
-- Meta key
73
73
--
74
74
[ Keyboard.Character "K", Keyboard.Meta ] ->
+311
-61
src/Applications/UI/Audio/State.elm
+311
-61
src/Applications/UI/Audio/State.elm
···
1
1
module UI.Audio.State exposing (..)
2
2
3
+
import Base64
4
+
import Common exposing (boolToString)
5
+
import Debouncer.Basic as Debouncer
3
6
import Dict
4
7
import LastFm
5
-
import Maybe.Extra as Maybe
8
+
import List.Extra as List
9
+
import MediaSession
6
10
import Return exposing (return)
7
11
import Return.Ext as Return exposing (communicate)
12
+
import Tracks
13
+
import UI.Audio.Types exposing (..)
14
+
import UI.Common.State as Common
15
+
import UI.Common.Types exposing (DebounceManager)
8
16
import UI.Ports as Ports
9
17
import UI.Queue.State as Queue
10
-
import UI.Types as UI exposing (Manager)
18
+
import UI.Types as UI exposing (Manager, Msg(..))
11
19
import UI.User.State.Export as User
12
20
13
21
14
22
15
-
-- 📣
23
+
-- 📣 ░░ EVENTS
24
+
25
+
26
+
durationChange : DurationChangeEvent -> Manager
27
+
durationChange { trackId, duration } =
28
+
onlyIfMatchesNowPlaying
29
+
{ trackId = trackId }
30
+
(\nowPlaying model ->
31
+
let
32
+
( identifiers, track ) =
33
+
nowPlaying.item.identifiedTrack
34
+
35
+
maybeCover =
36
+
List.find
37
+
(\c -> List.member trackId c.trackIds)
38
+
model.covers.arranged
39
+
40
+
coverPrep =
41
+
Maybe.map
42
+
(\cover ->
43
+
{ cacheKey = Base64.encode (Tracks.coverKey cover.variousArtists track)
44
+
, trackFilename = identifiers.filename
45
+
, trackPath = track.path
46
+
, trackSourceId = track.sourceId
47
+
, variousArtists = boolToString cover.variousArtists
48
+
}
49
+
)
50
+
maybeCover
51
+
52
+
coverLoaded =
53
+
case ( maybeCover, model.cachedCovers ) of
54
+
( Just cover, Just cachedCovers ) ->
55
+
let
56
+
key =
57
+
Base64.encode (Tracks.coverKey cover.variousArtists track)
58
+
in
59
+
Dict.member key cachedCovers
16
60
61
+
_ ->
62
+
False
17
63
18
-
noteProgress : { trackId : String, progress : Float } -> Manager
19
-
noteProgress { trackId, progress } model =
20
-
let
21
-
updatedProgressTable =
22
-
if not model.rememberProgress then
23
-
model.progress
64
+
metadata =
65
+
{ album = track.tags.album
66
+
, artist = track.tags.artist
67
+
, title = track.tags.title
24
68
25
-
else if progress > 0.975 then
26
-
Dict.remove trackId model.progress
69
+
--
70
+
, coverPrep = coverPrep
71
+
}
72
+
in
73
+
model
74
+
|> replaceNowPlaying { nowPlaying | coverLoaded = coverLoaded, duration = Just duration }
75
+
|> Return.command (Ports.setMediaSessionMetadata metadata)
76
+
|> Return.command (Ports.resetScrobbleTimer { duration = duration, trackId = trackId })
77
+
|> Return.andThen (notifyScrobblersOfTrackPlaying { duration = duration })
78
+
)
79
+
80
+
81
+
error : ErrorAudioEvent -> Manager
82
+
error { trackId, code } =
83
+
onlyIfMatchesNowPlaying
84
+
{ trackId = trackId }
85
+
(\nowPlaying ->
86
+
replaceNowPlaying
87
+
(case code of
88
+
2 ->
89
+
{ nowPlaying | loadingState = NetworkError }
90
+
91
+
3 ->
92
+
{ nowPlaying | loadingState = DecodeError }
93
+
94
+
4 ->
95
+
{ nowPlaying | loadingState = NotSupportedError }
96
+
97
+
_ ->
98
+
nowPlaying
99
+
)
100
+
)
101
+
102
+
103
+
ended : GenericAudioEvent -> Manager
104
+
ended { trackId } =
105
+
onlyIfMatchesNowPlaying
106
+
{ trackId = trackId }
107
+
(\nowPlaying model ->
108
+
if model.repeat then
109
+
Return.command
110
+
(case nowPlaying.duration of
111
+
Just duration ->
112
+
Ports.resetScrobbleTimer { duration = duration, trackId = trackId }
113
+
114
+
Nothing ->
115
+
Cmd.none
116
+
)
117
+
(play model)
27
118
28
119
else
29
-
Dict.insert trackId progress model.progress
30
-
in
31
-
if model.rememberProgress then
32
-
User.saveProgress { model | progress = updatedProgressTable }
120
+
Return.andThen
121
+
(if Maybe.map (\d -> Tracks.shouldNoteProgress { duration = d }) nowPlaying.duration == Just True then
122
+
noteProgress { trackId = trackId, progress = 1.0 }
123
+
124
+
else
125
+
Return.singleton
126
+
)
127
+
(Queue.shift model)
128
+
)
129
+
130
+
131
+
hasLoaded : GenericAudioEvent -> Manager
132
+
hasLoaded { trackId } =
133
+
onlyIfMatchesNowPlaying
134
+
{ trackId = trackId }
135
+
(\nowPlaying ->
136
+
replaceNowPlaying { nowPlaying | loadingState = Loaded }
137
+
)
138
+
139
+
140
+
isLoading : GenericAudioEvent -> Manager
141
+
isLoading { trackId } =
142
+
onlyIfMatchesNowPlaying
143
+
{ trackId = trackId }
144
+
(\nowPlaying ->
145
+
replaceNowPlaying { nowPlaying | loadingState = Loading }
146
+
)
33
147
34
-
else
35
-
Return.singleton model
148
+
149
+
playbackStateChanged : PlaybackStateEvent -> Manager
150
+
playbackStateChanged { trackId, isPlaying } =
151
+
onlyIfMatchesNowPlaying
152
+
{ trackId = trackId }
153
+
(\nowPlaying model ->
154
+
{ model | nowPlaying = Just { nowPlaying | isPlaying = isPlaying } }
155
+
|> Return.singleton
156
+
|> Return.command
157
+
(if isPlaying then
158
+
Ports.startScrobbleTimer ()
159
+
160
+
else
161
+
Ports.pauseScrobbleTimer ()
162
+
)
163
+
|> Return.command
164
+
(Ports.setMediaSessionPlaybackState
165
+
(if isPlaying then
166
+
MediaSession.states.playing
167
+
168
+
else
169
+
MediaSession.states.paused
170
+
)
171
+
)
172
+
)
173
+
174
+
175
+
timeUpdated : TimeUpdatedEvent -> Manager
176
+
timeUpdated { trackId, currentTime, duration } =
177
+
onlyIfMatchesNowPlaying
178
+
{ trackId = trackId }
179
+
(\nowPlaying model ->
180
+
let
181
+
dur =
182
+
Maybe.withDefault 0 duration
183
+
in
184
+
{ model | nowPlaying = Just { nowPlaying | duration = duration, playbackPosition = currentTime } }
185
+
|> (if Tracks.shouldNoteProgress { duration = dur } then
186
+
{ trackId = trackId
187
+
, progress = currentTime / dur
188
+
}
189
+
|> NoteProgress
190
+
|> Debouncer.provideInput
191
+
|> NoteProgressDebounce
192
+
|> Return.task
193
+
|> Return.communicate
194
+
195
+
else
196
+
Return.singleton
197
+
)
198
+
|> Return.command
199
+
(case duration of
200
+
Just d ->
201
+
Ports.setMediaSessionPositionState
202
+
{ currentTime = currentTime
203
+
, duration = d
204
+
}
205
+
206
+
Nothing ->
207
+
Cmd.none
208
+
)
209
+
)
210
+
211
+
212
+
213
+
-- 📣 ░░ COMMANDS
36
214
37
215
38
216
pause : Manager
39
217
pause model =
40
-
return model (Ports.pause ())
218
+
case model.nowPlaying of
219
+
Just { item } ->
220
+
communicate
221
+
(Ports.pause
222
+
{ trackId = (Tuple.second item.identifiedTrack).id
223
+
}
224
+
)
225
+
model
226
+
227
+
Nothing ->
228
+
Return.singleton model
41
229
42
230
43
231
playPause : Manager
44
232
playPause model =
45
-
if Maybe.isNothing model.nowPlaying then
46
-
Queue.shift model
233
+
case model.nowPlaying of
234
+
Just { isPlaying } ->
235
+
if isPlaying then
236
+
pause model
47
237
48
-
else if model.audioIsPlaying then
49
-
communicate (Ports.pause ()) model
238
+
else
239
+
play model
50
240
51
-
else
52
-
communicate (Ports.play ()) model
241
+
Nothing ->
242
+
play model
53
243
54
244
55
245
play : Manager
56
246
play model =
57
-
if Maybe.isNothing model.nowPlaying then
58
-
Queue.shift model
247
+
case model.nowPlaying of
248
+
Just { item } ->
249
+
communicate
250
+
(Ports.play
251
+
{ trackId = (Tuple.second item.identifiedTrack).id
252
+
, volume = model.eqSettings.volume
253
+
}
254
+
)
255
+
model
59
256
60
-
else
61
-
return model (Ports.play ())
257
+
Nothing ->
258
+
Queue.shift model
62
259
63
260
64
-
seek : Float -> Manager
65
-
seek percentage =
66
-
Return.communicate (Ports.seek percentage)
261
+
seek : { trackId : String, progress : Float } -> Manager
262
+
seek { trackId, progress } =
263
+
{ percentage = progress, trackId = trackId }
264
+
|> Ports.seek
265
+
|> Return.communicate
67
266
68
267
69
-
setDuration : Float -> Manager
70
-
setDuration duration model =
71
-
let
72
-
cmd =
73
-
case Maybe.map .identifiedTrack model.nowPlaying of
74
-
Just ( _, track ) ->
75
-
LastFm.nowPlaying model.lastFm
76
-
{ duration = round duration
77
-
, msg = UI.Bypass
78
-
, track = track
79
-
}
268
+
stop : Manager
269
+
stop model =
270
+
model.audioElements
271
+
|> List.filter (.isPreload >> (==) True)
272
+
|> (\a -> { model | audioElements = a })
273
+
|> Queue.changeActiveItem Nothing
274
+
|> Return.effect_
275
+
(\m ->
276
+
Ports.renderAudioElements
277
+
{ items = m.audioElements
278
+
, play = Nothing
279
+
, volume = m.eqSettings.volume
280
+
}
281
+
)
80
282
81
-
Nothing ->
82
-
Cmd.none
83
-
in
84
-
return { model | audioDuration = duration } cmd
85
283
86
284
87
-
setHasStalled : Bool -> Manager
88
-
setHasStalled hasStalled model =
89
-
Return.singleton { model | audioHasStalled = hasStalled }
285
+
-- 📣
286
+
287
+
288
+
noteProgress : { trackId : String, progress : Float } -> Manager
289
+
noteProgress { trackId, progress } model =
290
+
let
291
+
updatedProgressTable =
292
+
if not model.rememberProgress then
293
+
model.progress
294
+
295
+
else if progress > 0.975 then
296
+
Dict.remove trackId model.progress
90
297
298
+
else
299
+
Dict.insert trackId progress model.progress
300
+
in
301
+
if model.rememberProgress then
302
+
User.saveProgress { model | progress = updatedProgressTable }
91
303
92
-
setIsLoading : Bool -> Manager
93
-
setIsLoading isLoading model =
94
-
Return.singleton { model | audioIsLoading = isLoading }
304
+
else
305
+
Return.singleton model
95
306
96
307
97
-
setIsPlaying : Bool -> Manager
98
-
setIsPlaying isPlaying model =
99
-
Return.singleton { model | audioIsPlaying = isPlaying }
308
+
noteProgressDebounce : DebounceManager
309
+
noteProgressDebounce =
310
+
Common.debounce
311
+
.progressDebouncer
312
+
(\d m -> { m | progressDebouncer = d })
313
+
UI.NoteProgressDebounce
100
314
101
315
102
-
setPosition : Float -> Manager
103
-
setPosition position model =
104
-
Return.singleton { model | audioPosition = position }
316
+
notifyScrobblersOfTrackPlaying : { duration : Float } -> Manager
317
+
notifyScrobblersOfTrackPlaying { duration } model =
318
+
case model.nowPlaying of
319
+
Just { item } ->
320
+
{ duration = round duration
321
+
, msg = UI.Bypass
322
+
, track = Tuple.second item.identifiedTrack
323
+
}
324
+
|> LastFm.nowPlaying model.lastFm
325
+
|> return model
326
+
327
+
Nothing ->
328
+
Return.singleton model
105
329
106
330
107
-
stop : Manager
108
-
stop =
109
-
communicate (Ports.pause ())
331
+
preloadDebounce : DebounceManager
332
+
preloadDebounce =
333
+
Common.debounce
334
+
.preloadDebouncer
335
+
(\d m -> { m | preloadDebouncer = d })
336
+
UI.AudioPreloadDebounce
110
337
111
338
112
339
toggleRememberProgress : Manager
113
340
toggleRememberProgress model =
114
341
User.saveSettings { model | rememberProgress = not model.rememberProgress }
342
+
343
+
344
+
345
+
-- 🛠️
346
+
347
+
348
+
onlyIfMatchesNowPlaying : { trackId : String } -> (NowPlaying -> Manager) -> Manager
349
+
onlyIfMatchesNowPlaying { trackId } fn model =
350
+
case model.nowPlaying of
351
+
Just ({ item } as nowPlaying) ->
352
+
if trackId == (Tuple.second item.identifiedTrack).id then
353
+
fn nowPlaying model
354
+
355
+
else
356
+
Return.singleton model
357
+
358
+
Nothing ->
359
+
Return.singleton model
360
+
361
+
362
+
replaceNowPlaying : NowPlaying -> Manager
363
+
replaceNowPlaying np model =
364
+
Return.singleton { model | nowPlaying = Just np }
+69
src/Applications/UI/Audio/Types.elm
+69
src/Applications/UI/Audio/Types.elm
···
1
+
module UI.Audio.Types exposing (..)
2
+
3
+
import Queue
4
+
import Tracks exposing (IdentifiedTrack)
5
+
6
+
7
+
8
+
-- 🌳
9
+
10
+
11
+
type AudioLoadingState
12
+
= Loading
13
+
| Loaded
14
+
-- Errors
15
+
| DecodeError
16
+
| NetworkError
17
+
| NotSupportedError
18
+
19
+
20
+
type alias CoverPrep =
21
+
{ cacheKey : String
22
+
, trackFilename : String
23
+
, trackPath : String
24
+
, trackSourceId : String
25
+
, variousArtists : String
26
+
}
27
+
28
+
29
+
type alias NowPlaying =
30
+
{ coverLoaded : Bool
31
+
, duration : Maybe Float
32
+
, isPlaying : Bool
33
+
, item : Queue.Item
34
+
, loadingState : AudioLoadingState
35
+
, playbackPosition : Float
36
+
}
37
+
38
+
39
+
40
+
-- 🌳 ░░ EVENTS
41
+
42
+
43
+
type alias DurationChangeEvent =
44
+
{ trackId : String, duration : Float }
45
+
46
+
47
+
type alias ErrorAudioEvent =
48
+
{ trackId : String, code : Int }
49
+
50
+
51
+
type alias GenericAudioEvent =
52
+
{ trackId : String }
53
+
54
+
55
+
type alias PlaybackStateEvent =
56
+
{ trackId : String, isPlaying : Bool }
57
+
58
+
59
+
type alias TimeUpdatedEvent =
60
+
{ trackId : String, currentTime : Float, duration : Maybe Float }
61
+
62
+
63
+
64
+
-- 🛠️
65
+
66
+
67
+
nowPlayingIdentifiedTrack : NowPlaying -> IdentifiedTrack
68
+
nowPlayingIdentifiedTrack { item } =
69
+
item.identifiedTrack
+4
-4
src/Applications/UI/Commands/Alfred.elm
+4
-4
src/Applications/UI/Commands/Alfred.elm
···
70
70
nowPlayingCommands : UI.Model -> List (Item UI.Msg)
71
71
nowPlayingCommands model =
72
72
case model.nowPlaying of
73
-
Just queueItem ->
73
+
Just { item } ->
74
74
let
75
75
( queueItemIdentifiers, _ ) =
76
-
queueItem.identifiedTrack
76
+
item.identifiedTrack
77
77
78
78
identifiedTrack =
79
79
model.tracks.harvested
80
80
|> List.getAt queueItemIdentifiers.indexInList
81
-
|> Maybe.withDefault queueItem.identifiedTrack
81
+
|> Maybe.withDefault item.identifiedTrack
82
82
83
83
( identifiers, track ) =
84
84
identifiedTrack
···
116
116
117
117
118
118
playbackCommands model =
119
-
[ if model.audioIsPlaying then
119
+
[ if Maybe.map .isPlaying model.nowPlaying == Just True then
120
120
{ icon = Just (Icons.pause 16)
121
121
, title = "Pause"
122
122
, value = Command UI.TogglePlay
+80
-36
src/Applications/UI/Console.elm
+80
-36
src/Applications/UI/Console.elm
···
8
8
import Json.Decode as Decode
9
9
import Material.Icons.Round as Icons
10
10
import Material.Icons.Types exposing (Coloring(..))
11
-
import Queue
11
+
import Maybe.Extra as Maybe
12
+
import UI.Audio.Types exposing (AudioLoadingState(..), NowPlaying, nowPlayingIdentifiedTrack)
12
13
import UI.Queue.Types as Queue
13
14
import UI.Tracks.Types as Tracks
14
15
import UI.Types exposing (Msg(..))
···
19
20
20
21
21
22
view :
22
-
Maybe Queue.Item
23
+
Maybe NowPlaying
23
24
-> Bool
24
25
-> Bool
25
-
-> { stalled : Bool, loading : Bool, playing : Bool }
26
-
-> ( Float, Float )
27
26
-> Html Msg
28
-
view activeQueueItem repeat shuffle { stalled, loading, playing } ( position, duration ) =
27
+
view nowPlaying repeat shuffle =
29
28
chunk
30
29
[ "antialiased"
31
30
, "mt-1"
···
45
44
, "py-4"
46
45
, "text-white"
47
46
]
48
-
[ if stalled then
49
-
text "Audio connection got interrupted, trying to reconnect ..."
47
+
[ case Maybe.map .loadingState nowPlaying of
48
+
Nothing ->
49
+
text "Diffuse"
50
50
51
-
else if loading then
52
-
text "Loading track ..."
51
+
Just Loading ->
52
+
text "Loading track ..."
53
53
54
-
else
55
-
case Maybe.map .identifiedTrack activeQueueItem of
56
-
Just ( _, { tags } ) ->
57
-
slab
58
-
Html.span
59
-
[ onClick (TracksMsg Tracks.ScrollToNowPlaying)
60
-
, title "Scroll to track"
61
-
]
62
-
[ "cursor-pointer" ]
63
-
[ case tags.artist of
64
-
Just artist ->
65
-
text (artist ++ " - " ++ tags.title)
54
+
Just Loaded ->
55
+
case Maybe.map nowPlayingIdentifiedTrack nowPlaying of
56
+
Just ( _, { tags } ) ->
57
+
slab
58
+
Html.span
59
+
[ onClick (TracksMsg Tracks.ScrollToNowPlaying)
60
+
, title "Scroll to track"
61
+
]
62
+
[ "cursor-pointer" ]
63
+
[ case tags.artist of
64
+
Just artist ->
65
+
text (artist ++ " - " ++ tags.title)
66
+
67
+
Nothing ->
68
+
text tags.title
69
+
]
66
70
67
-
Nothing ->
68
-
text tags.title
69
-
]
71
+
Nothing ->
72
+
text "Diffuse"
70
73
71
-
Nothing ->
72
-
text "Diffuse"
74
+
-----------------------------------------
75
+
-- Errors
76
+
-----------------------------------------
77
+
Just DecodeError ->
78
+
text "(!) An error occurred while decoding the audio"
79
+
80
+
Just NetworkError ->
81
+
text "Waiting until your internet connection comes back online ..."
82
+
83
+
Just NotSupportedError ->
84
+
text "(!) Your browser does not support playing this type of audio"
85
+
86
+
-- Just NotSupportedOrMissing ->
87
+
-- text "The audio is missing or is in a format not supported by your browser."
73
88
]
74
89
75
90
-----------------------------------------
76
91
-- Progress Bar
77
92
-----------------------------------------
78
93
, let
94
+
maybeDuration =
95
+
Maybe.andThen .duration nowPlaying
96
+
97
+
maybePosition =
98
+
Maybe.map .playbackPosition nowPlaying
99
+
79
100
progress =
80
-
if duration <= 0 then
81
-
0
101
+
case ( maybeDuration, maybePosition ) of
102
+
( Just duration, Just position ) ->
103
+
if duration <= 0 then
104
+
0
82
105
83
-
else
84
-
(position / duration)
85
-
|> (*) 100
86
-
|> min 100
87
-
|> max 0
106
+
else
107
+
(position / duration)
108
+
|> (*) 100
109
+
|> min 100
110
+
|> max 0
111
+
112
+
_ ->
113
+
0
88
114
in
89
115
brick
90
-
[ on "click" (clickLocationDecoder Seek) ]
116
+
(case nowPlaying of
117
+
Just { item } ->
118
+
item.identifiedTrack
119
+
|> Tuple.second
120
+
|> .id
121
+
|> (\id ->
122
+
\float -> Seek { progress = float, trackId = id }
123
+
)
124
+
|> clickLocationDecoder
125
+
|> on "click"
126
+
|> List.singleton
127
+
128
+
Nothing ->
129
+
[]
130
+
)
91
131
[ "cursor-pointer"
92
132
, "py-1"
93
133
]
···
137
177
(QueueMsg Queue.Rewind)
138
178
139
179
--
140
-
, button
180
+
, let
181
+
isPlaying =
182
+
Maybe.unwrap False .isPlaying nowPlaying
183
+
in
184
+
button
141
185
""
142
-
(largeLight playing)
186
+
(largeLight isPlaying)
143
187
play
144
188
TogglePlay
145
189
+1
src/Applications/UI/Notifications.elm
+1
src/Applications/UI/Notifications.elm
+31
-4
src/Applications/UI/Other/State.elm
+31
-4
src/Applications/UI/Other/State.elm
···
2
2
3
3
import Alien
4
4
import Common exposing (ServiceWorkerStatus(..))
5
+
import Dict
5
6
import Notifications
6
7
import Return exposing (return)
7
8
import Time
···
41
42
42
43
setIsOnline : Bool -> Manager
43
44
setIsOnline bool model =
44
-
if bool then
45
-
syncHypaethralData { model | isOnline = bool }
45
+
{ model | isOnline = bool }
46
+
|> Return.singleton
47
+
|> Return.command
48
+
(case model.nowPlaying of
49
+
Just { isPlaying, item } ->
50
+
let
51
+
trackId =
52
+
(Tuple.second item.identifiedTrack).id
53
+
in
54
+
Ports.reloadAudioNodeIfNeeded
55
+
{ play = isPlaying
56
+
, progress =
57
+
if model.rememberProgress then
58
+
Dict.get trackId model.progress
46
59
47
-
else
48
-
Return.singleton { model | isOnline = bool }
60
+
else
61
+
Nothing
62
+
, trackId = trackId
63
+
}
64
+
65
+
Nothing ->
66
+
Cmd.none
67
+
)
68
+
|> Return.andThen
69
+
(case ( model.isOnline, bool ) of
70
+
( False, True ) ->
71
+
syncHypaethralData
72
+
73
+
_ ->
74
+
Return.singleton
75
+
)
49
76
50
77
51
78
setCurrentTime : Time.Posix -> Manager
+54
-23
src/Applications/UI/Ports.elm
+54
-23
src/Applications/UI/Ports.elm
···
3
3
import Alien
4
4
import Json.Encode as Json
5
5
import Queue
6
+
import UI.Audio.Types as Audio
6
7
7
8
8
9
···
33
34
port openUrlOnNewPage : String -> Cmd msg
34
35
35
36
36
-
port pause : () -> Cmd msg
37
+
port pause : { trackId : String } -> Cmd msg
38
+
39
+
40
+
port pauseScrobbleTimer : () -> Cmd msg
37
41
38
42
39
43
port pickAverageBackgroundColor : String -> Cmd msg
40
44
41
45
42
-
port play : () -> Cmd msg
46
+
port play : { trackId : String, volume : Float } -> Cmd msg
47
+
48
+
49
+
port reloadAudioNodeIfNeeded : { play : Bool, progress : Maybe Float, trackId : String } -> Cmd msg
43
50
44
51
45
52
port preloadAudio : Queue.EngineItem -> Cmd msg
···
48
55
port reloadApp : () -> Cmd msg
49
56
50
57
51
-
port seek : Float -> Cmd msg
58
+
port renderAudioElements : { items : List Queue.EngineItem, play : Maybe String, volume : Float } -> Cmd msg
59
+
60
+
61
+
port resetScrobbleTimer : { duration : Float, trackId : String } -> Cmd msg
62
+
63
+
64
+
port seek : { percentage : Float, trackId : String } -> Cmd msg
65
+
66
+
67
+
port sendTask : Json.Value -> Cmd msg
68
+
69
+
70
+
port setMediaSessionArtwork : { blobUrl : String, imageType : String } -> Cmd msg
71
+
72
+
73
+
port setMediaSessionMetadata : { album : Maybe String, artist : Maybe String, title : String, coverPrep : Maybe Audio.CoverPrep } -> Cmd msg
74
+
75
+
76
+
port setMediaSessionPlaybackState : String -> Cmd msg
77
+
78
+
79
+
port setMediaSessionPositionState : { currentTime : Float, duration : Float } -> Cmd msg
52
80
53
81
54
-
port setRepeat : Bool -> Cmd msg
82
+
port startScrobbleTimer : () -> Cmd msg
55
83
56
84
57
85
port toBrain : Alien.Event -> Cmd msg
···
61
89
-- 📰
62
90
63
91
64
-
port activeQueueItemEnded : (() -> msg) -> Sub msg
92
+
port audioDurationChange : (Audio.DurationChangeEvent -> msg) -> Sub msg
93
+
94
+
95
+
port audioEnded : (Audio.GenericAudioEvent -> msg) -> Sub msg
96
+
97
+
98
+
port audioError : (Audio.ErrorAudioEvent -> msg) -> Sub msg
99
+
100
+
101
+
port audioPlaybackStateChanged : (Audio.PlaybackStateEvent -> msg) -> Sub msg
102
+
103
+
104
+
port audioIsLoading : (Audio.GenericAudioEvent -> msg) -> Sub msg
105
+
106
+
107
+
port audioHasLoaded : (Audio.GenericAudioEvent -> msg) -> Sub msg
108
+
109
+
110
+
port audioTimeUpdated : (Audio.TimeUpdatedEvent -> msg) -> Sub msg
65
111
66
112
67
113
port collectedFissionCapabilities : (() -> msg) -> Sub msg
···
88
134
port installingNewServiceWorker : (() -> msg) -> Sub msg
89
135
90
136
91
-
port noteProgress : ({ trackId : String, progress : Float } -> msg) -> Sub msg
92
-
93
-
94
137
port refreshedAccessToken : (Json.Value -> msg) -> Sub msg
95
138
96
139
97
140
port preferredColorSchemaChanged : ({ dark : Bool } -> msg) -> Sub msg
141
+
142
+
143
+
port receiveTask : (Json.Value -> msg) -> Sub msg
98
144
99
145
100
146
port requestNext : (() -> msg) -> Sub msg
···
116
162
117
163
118
164
port scrobble : ({ duration : Int, timestamp : Int, trackId : String } -> msg) -> Sub msg
119
-
120
-
121
-
port setAudioPosition : (Float -> msg) -> Sub msg
122
-
123
-
124
-
port setAudioDuration : (Float -> msg) -> Sub msg
125
-
126
-
127
-
port setAudioIsLoading : (Bool -> msg) -> Sub msg
128
-
129
-
130
-
port setAudioIsPlaying : (Bool -> msg) -> Sub msg
131
-
132
-
133
-
port setAudioHasStalled : (Bool -> msg) -> Sub msg
134
165
135
166
136
167
port setAverageBackgroundColor : ({ r : Int, g : Int, b : Int } -> msg) -> Sub msg
+91
-14
src/Applications/UI/Queue/State.elm
+91
-14
src/Applications/UI/Queue/State.elm
···
1
1
module UI.Queue.State exposing (..)
2
2
3
3
import Coordinates
4
+
import Debouncer.Basic as Debouncer
4
5
import Dict
5
6
import Html.Events.Extra.Mouse as Mouse
6
7
import List.Extra as List
7
8
import Notifications
8
9
import Queue exposing (..)
9
-
import Return exposing (andThen, return)
10
+
import Return exposing (andThen)
11
+
import Return.Ext as Return
10
12
import Tracks exposing (..)
13
+
import UI.Audio.Types exposing (AudioLoadingState(..))
11
14
import UI.Common.State as Common
12
15
import UI.Ports as Ports
13
16
import UI.Queue.ContextMenu as Queue
···
26
29
case msg of
27
30
Clear ->
28
31
clear
32
+
33
+
PreloadNext ->
34
+
preloadNext
29
35
30
36
Reset ->
31
37
reset
···
85
91
86
92
changeActiveItem : Maybe Item -> Manager
87
93
changeActiveItem maybeItem model =
94
+
let
95
+
maybeNowPlaying =
96
+
Maybe.map
97
+
(\item ->
98
+
{ coverLoaded = False
99
+
, duration = Nothing
100
+
, isPlaying = False
101
+
, item = item
102
+
, loadingState = Loading
103
+
, playbackPosition = 0
104
+
}
105
+
)
106
+
maybeItem
107
+
in
88
108
maybeItem
89
-
|> Maybe.map
90
-
(.identifiedTrack >> Tuple.second)
109
+
|> Maybe.map (.identifiedTrack >> Tuple.second)
91
110
|> Maybe.map
92
111
(Queue.makeEngineItem
112
+
False
93
113
model.currentTime
94
114
model.sources
95
115
model.cachedTracks
···
100
120
Dict.empty
101
121
)
102
122
)
103
-
|> Ports.activeQueueItemChanged
104
-
|> return { model | nowPlaying = maybeItem }
123
+
|> Maybe.map insertTrack
124
+
|> Maybe.withDefault Return.singleton
125
+
|> (\fn -> fn { model | nowPlaying = maybeNowPlaying })
105
126
|> andThen fill
106
127
107
128
···
145
166
else
146
167
let
147
168
fillState =
148
-
{ activeItem = m.nowPlaying
169
+
{ activeItem = Maybe.map .item m.nowPlaying
149
170
, future = m.playingNext
150
171
, ignored = m.dontPlay
151
172
, past = m.playedPreviously
···
158
179
else
159
180
{ m | playingNext = Fill.ordered timestamp nonMissingTracks fillState }
160
181
)
161
-
|> preloadNext
182
+
|> Return.communicate
183
+
(Queue.PreloadNext
184
+
|> QueueMsg
185
+
|> Debouncer.provideInput
186
+
|> AudioPreloadDebounce
187
+
|> Return.task
188
+
)
189
+
190
+
191
+
insertTrack : EngineItem -> Manager
192
+
insertTrack item model =
193
+
item
194
+
|> (\engineItem ->
195
+
if
196
+
List.any
197
+
(\a -> engineItem.trackId == a.trackId)
198
+
model.audioElements
199
+
then
200
+
List.map
201
+
(\a ->
202
+
if engineItem.trackId == a.trackId then
203
+
{ a | isPreload = False }
204
+
205
+
else
206
+
a
207
+
)
208
+
model.audioElements
209
+
210
+
else
211
+
model.audioElements ++ [ engineItem ]
212
+
)
213
+
|> List.filter
214
+
(\a ->
215
+
if item.isPreload then
216
+
True
217
+
218
+
else if a.trackId /= item.trackId && not a.isPreload then
219
+
False
220
+
221
+
else
222
+
True
223
+
)
224
+
|> (\a -> { model | audioElements = a })
225
+
|> Return.singleton
226
+
|> Return.effect_
227
+
(\m ->
228
+
Ports.renderAudioElements
229
+
{ items = m.audioElements
230
+
, play =
231
+
if item.isPreload then
232
+
Nothing
233
+
234
+
else
235
+
Just item.trackId
236
+
, volume = m.eqSettings.volume
237
+
}
238
+
)
162
239
163
240
164
241
preloadNext : Manager
···
169
246
|> .identifiedTrack
170
247
|> Tuple.second
171
248
|> Queue.makeEngineItem
249
+
True
172
250
model.currentTime
173
251
model.sources
174
252
model.cachedTracks
···
178
256
else
179
257
Dict.empty
180
258
)
181
-
|> Ports.preloadAudio
182
-
|> return model
259
+
|> (\engineItem ->
260
+
insertTrack engineItem model
261
+
)
183
262
184
263
Nothing ->
185
264
Return.singleton model
···
192
271
{ model
193
272
| playingNext =
194
273
model.nowPlaying
195
-
|> Maybe.map (\item -> item :: model.playingNext)
274
+
|> Maybe.map (\{ item } -> item :: model.playingNext)
196
275
|> Maybe.withDefault model.playingNext
197
276
, playedPreviously =
198
277
model.playedPreviously
···
226
305
|> List.drop 1
227
306
, playedPreviously =
228
307
model.nowPlaying
229
-
|> Maybe.map List.singleton
308
+
|> Maybe.map (.item >> List.singleton)
230
309
|> Maybe.map (List.append model.playedPreviously)
231
310
|> Maybe.withDefault model.playedPreviously
232
311
}
···
273
352
274
353
toggleRepeat : Manager
275
354
toggleRepeat model =
276
-
{ model | repeat = not model.repeat }
277
-
|> saveEnclosedUserData
278
-
|> Return.effect_ (.repeat >> Ports.setRepeat)
355
+
saveEnclosedUserData { model | repeat = not model.repeat }
279
356
280
357
281
358
toggleShuffle : Manager
+1
src/Applications/UI/Queue/Types.elm
+1
src/Applications/UI/Queue/Types.elm
+1
-1
src/Applications/UI/Services/State.elm
+1
-1
src/Applications/UI/Services/State.elm
···
56
56
57
57
scrobble : { duration : Int, timestamp : Int, trackId : String } -> Manager
58
58
scrobble { duration, timestamp, trackId } model =
59
-
case Maybe.map .identifiedTrack model.nowPlaying of
59
+
case Maybe.map (.item >> .identifiedTrack) model.nowPlaying of
60
60
Just ( _, track ) ->
61
61
if trackId == track.id then
62
62
( model
+3
-4
src/Applications/UI/Tracks/Scene/Covers.elm
+3
-4
src/Applications/UI/Tracks/Scene/Covers.elm
···
15
15
import Material.Icons.Round as Icons
16
16
import Material.Icons.Types exposing (Coloring(..))
17
17
import Maybe.Extra as Maybe
18
-
import Queue
19
18
import Task
20
19
import Tracks exposing (..)
21
20
import UI.Tracks.Scene as Scene
···
36
35
, favouritesOnly : Bool
37
36
, infiniteList : InfiniteList.Model
38
37
, isVisible : Bool
39
-
, nowPlaying : Maybe Queue.Item
38
+
, nowPlaying : Maybe IdentifiedTrack
40
39
, selectedCover : Maybe Cover
41
40
, selectedTrackIndexes : List Int
42
41
, sortBy : SortBy
···
50
49
{ cachedCovers : Maybe (Dict String String)
51
50
, columns : Int
52
51
, containerWidth : Int
53
-
, nowPlaying : Maybe Queue.Item
52
+
, nowPlaying : Maybe IdentifiedTrack
54
53
, sortBy : SortBy
55
54
}
56
55
···
664
663
coverView { clickable, horizontal } { cachedCovers, nowPlaying } cover =
665
664
let
666
665
nowPlayingId =
667
-
Maybe.unwrap "" (.identifiedTrack >> Tuple.second >> .id) nowPlaying
666
+
Maybe.unwrap "" (Tuple.second >> .id) nowPlaying
668
667
669
668
missingTracks =
670
669
List.any
+6
-7
src/Applications/UI/Tracks/Scene/List.elm
+6
-7
src/Applications/UI/Tracks/Scene/List.elm
···
16
16
import Material.Icons.Round as Icons
17
17
import Material.Icons.Types exposing (Coloring(..))
18
18
import Maybe.Extra as Maybe
19
-
import Queue
20
19
import Task
21
20
import Tracks exposing (..)
22
21
import UI.DnD as DnD
···
48
47
}
49
48
50
49
51
-
view : Dependencies -> List IdentifiedTrack -> InfiniteList.Model -> Bool -> Maybe Queue.Item -> Maybe String -> SortBy -> SortDirection -> List Int -> Maybe (DnD.Model Int) -> Html Msg
50
+
view : Dependencies -> List IdentifiedTrack -> InfiniteList.Model -> Bool -> Maybe IdentifiedTrack -> Maybe String -> SortBy -> SortDirection -> List Int -> Maybe (DnD.Model Int) -> Html Msg
52
51
view deps harvest infiniteList favouritesOnly nowPlaying searchTerm sortBy sortDirection selectedTrackIndexes maybeDnD =
53
52
brick
54
53
(tabindex (ifThenElse deps.isVisible 0 -1) :: viewAttributes)
···
261
260
-- INFINITE LIST
262
261
263
262
264
-
infiniteListView : Dependencies -> List IdentifiedTrack -> InfiniteList.Model -> Bool -> Maybe String -> ( Maybe Queue.Item, List Int ) -> Maybe (DnD.Model Int) -> Html Msg
263
+
infiniteListView : Dependencies -> List IdentifiedTrack -> InfiniteList.Model -> Bool -> Maybe String -> ( Maybe IdentifiedTrack, List Int ) -> Maybe (DnD.Model Int) -> Html Msg
265
264
infiniteListView deps harvest infiniteList favouritesOnly searchTerm ( nowPlaying, selectedTrackIndexes ) maybeDnD =
266
265
let
267
266
derivedColors =
···
364
363
defaultItemView :
365
364
{ derivedColors : DerivedColors
366
365
, favouritesOnly : Bool
367
-
, nowPlaying : Maybe Queue.Item
366
+
, nowPlaying : Maybe IdentifiedTrack
368
367
, roundedCorners : Bool
369
368
, selectedTrackIndexes : List Int
370
369
, showAlbum : Bool
···
394
393
395
394
rowIdentifiers =
396
395
{ isMissing = identifiers.isMissing
397
-
, isNowPlaying = Maybe.unwrap False (.identifiedTrack >> isNowPlaying identifiedTrack) nowPlaying
396
+
, isNowPlaying = Maybe.unwrap False (isNowPlaying identifiedTrack) nowPlaying
398
397
, isSelected = isSelected
399
398
}
400
399
···
480
479
]
481
480
482
481
483
-
playlistItemView : Bool -> Maybe Queue.Item -> Maybe String -> List Int -> DnD.Model Int -> Bool -> Bool -> DerivedColors -> Int -> Int -> IdentifiedTrack -> Html Msg
482
+
playlistItemView : Bool -> Maybe IdentifiedTrack -> Maybe String -> List Int -> DnD.Model Int -> Bool -> Bool -> DerivedColors -> Int -> Int -> IdentifiedTrack -> Html Msg
484
483
playlistItemView favouritesOnly nowPlaying _ selectedTrackIndexes dnd showAlbum darkMode derivedColors _ idx identifiedTrack =
485
484
let
486
485
( identifiers, track ) =
···
502
501
503
502
rowIdentifiers =
504
503
{ isMissing = identifiers.isMissing
505
-
, isNowPlaying = Maybe.unwrap False (.identifiedTrack >> isNowPlaying identifiedTrack) nowPlaying
504
+
, isNowPlaying = Maybe.unwrap False (isNowPlaying identifiedTrack) nowPlaying
506
505
, isSelected = isSelected
507
506
}
508
507
+44
-10
src/Applications/UI/Tracks/State.elm
+44
-10
src/Applications/UI/Tracks/State.elm
···
1
1
module UI.Tracks.State exposing (..)
2
2
3
3
import Alien
4
+
import Base64
4
5
import Common exposing (..)
5
6
import ContextMenu
6
7
import Coordinates exposing (Coordinates)
···
362
363
let
363
364
cachedCovers =
364
365
Maybe.withDefault Dict.empty model.cachedCovers
366
+
367
+
decodedValue =
368
+
Json.decodeValue
369
+
(Json.map3
370
+
(\i k u -> ( i, k, u ))
371
+
(Json.field "imageType" Json.string)
372
+
(Json.field "key" Json.string)
373
+
(Json.field "url" Json.string)
374
+
)
375
+
json
365
376
in
366
-
json
367
-
|> Json.decodeValue
368
-
(Json.map2
369
-
Tuple.pair
370
-
(Json.field "key" Json.string)
371
-
(Json.field "url" Json.string)
372
-
)
373
-
|> Result.map (\( key, url ) -> Dict.insert key url cachedCovers)
377
+
decodedValue
378
+
|> Result.map (\( _, key, url ) -> Dict.insert key url cachedCovers)
374
379
|> Result.map (\dict -> { model | cachedCovers = Just dict })
375
380
|> Result.withDefault model
376
-
|> Return.singleton
381
+
|> (\m ->
382
+
case ( m.nowPlaying, decodedValue ) of
383
+
( Just nowPlaying, Ok val ) ->
384
+
let
385
+
( imageType, key, url ) =
386
+
val
387
+
388
+
( _, track ) =
389
+
nowPlaying.item.identifiedTrack
390
+
391
+
hasntLoadedYet =
392
+
nowPlaying.coverLoaded == False
393
+
394
+
( keyA, keyB ) =
395
+
( Base64.encode (Tracks.coverKey False track)
396
+
, Base64.encode (Tracks.coverKey True track)
397
+
)
398
+
399
+
keyMatches =
400
+
keyA == key || keyB == key
401
+
in
402
+
if hasntLoadedYet && keyMatches then
403
+
( m, Ports.setMediaSessionArtwork { blobUrl = url, imageType = imageType } )
404
+
405
+
else
406
+
Return.singleton m
407
+
408
+
_ ->
409
+
Return.singleton m
410
+
)
377
411
378
412
379
413
groupBy : Tracks.Grouping -> Manager
···
727
761
scrollToNowPlaying model =
728
762
model.nowPlaying
729
763
|> Maybe.map
730
-
(.identifiedTrack >> Tuple.second >> .id)
764
+
(.item >> .identifiedTrack >> Tuple.second >> .id)
731
765
|> Maybe.andThen
732
766
(\id ->
733
767
List.find
+3
-2
src/Applications/UI/Tracks/View.elm
+3
-2
src/Applications/UI/Tracks/View.elm
···
15
15
import Maybe.Extra as Maybe
16
16
import Playlists exposing (Playlist)
17
17
import Tracks exposing (..)
18
+
import UI.Audio.Types exposing (nowPlayingIdentifiedTrack)
18
19
import UI.Kit
19
20
import UI.Navigation exposing (..)
20
21
import UI.Page as Page
···
95
96
, favouritesOnly = model.favouritesOnly
96
97
, infiniteList = model.infiniteList
97
98
, isVisible = isOnIndexPage
98
-
, nowPlaying = model.nowPlaying
99
+
, nowPlaying = Maybe.map nowPlayingIdentifiedTrack model.nowPlaying
99
100
, selectedCover = model.selectedCover
100
101
, selectedTrackIndexes = model.selectedTrackIndexes
101
102
, sortBy = model.sortBy
···
126
127
model.tracks.harvested
127
128
model.infiniteList
128
129
model.favouritesOnly
129
-
model.nowPlaying
130
+
(Maybe.map nowPlayingIdentifiedTrack model.nowPlaying)
130
131
model.searchTerm
131
132
model.sortBy
132
133
model.sortDirection
+15
-12
src/Applications/UI/Types.elm
+15
-12
src/Applications/UI/Types.elm
···
26
26
import Sources exposing (Source)
27
27
import Time
28
28
import Tracks exposing (..)
29
+
import UI.Audio.Types exposing (DurationChangeEvent, ErrorAudioEvent, GenericAudioEvent, NowPlaying, PlaybackStateEvent, TimeUpdatedEvent)
29
30
import UI.DnD as DnD
30
31
import UI.Page exposing (Page)
31
32
import UI.Queue.Types as Queue
···
83
84
-----------------------------------------
84
85
-- Audio
85
86
-----------------------------------------
86
-
, audioDuration : Float
87
-
, audioHasStalled : Bool
88
-
, audioIsLoading : Bool
89
-
, audioIsPlaying : Bool
90
-
, audioPosition : Float
87
+
, audioElements : List Queue.EngineItem
88
+
, nowPlaying : Maybe NowPlaying
91
89
, progress : Dict String Float
92
90
, rememberProgress : Bool
93
91
···
102
100
-----------------------------------------
103
101
-- Debouncing
104
102
-----------------------------------------
103
+
, preloadDebouncer : Debouncer Msg Msg
104
+
, progressDebouncer : Debouncer Msg Msg
105
105
, resizeDebouncer : Debouncer Msg Msg
106
106
, searchDebouncer : Debouncer Msg Msg
107
107
···
132
132
-- Queue
133
133
-----------------------------------------
134
134
, dontPlay : List Queue.Item
135
-
, nowPlaying : Maybe Queue.Item
136
135
, playedPreviously : List Queue.Item
137
136
, playingNext : List Queue.Item
138
137
, selectedQueueItem : Maybe Queue.Item
···
199
198
-----------------------------------------
200
199
-- Audio
201
200
-----------------------------------------
201
+
| AudioDurationChange DurationChangeEvent
202
+
| AudioError ErrorAudioEvent
203
+
| AudioEnded GenericAudioEvent
204
+
| AudioHasLoaded GenericAudioEvent
205
+
| AudioIsLoading GenericAudioEvent
206
+
| AudioPlaybackStateChanged PlaybackStateEvent
207
+
| AudioPreloadDebounce (Debouncer.Msg Msg)
208
+
| AudioTimeUpdated TimeUpdatedEvent
202
209
| NoteProgress { trackId : String, progress : Float }
210
+
| NoteProgressDebounce (Debouncer.Msg Msg)
203
211
| Pause
204
212
| Play
205
-
| Seek Float
206
-
| SetAudioDuration Float
207
-
| SetAudioHasStalled Bool
208
-
| SetAudioIsLoading Bool
209
-
| SetAudioIsPlaying Bool
210
-
| SetAudioPosition Float
213
+
| Seek { trackId : String, progress : Float }
211
214
| Stop
212
215
| TogglePlay
213
216
| ToggleRememberProgress
+1
-5
src/Applications/UI/User/State/Import.elm
+1
-5
src/Applications/UI/User/State/Import.elm
···
18
18
import UI.Equalizer.State as Equalizer
19
19
import UI.Page as Page
20
20
import UI.Playlists.Directory
21
-
import UI.Ports as Ports
22
21
import UI.Sources.State as Sources
23
22
import UI.Tracks.State as Tracks
24
23
import UI.Types as UI exposing (..)
···
223
222
, sortDirection = data.sortDirection
224
223
}
225
224
--
226
-
, Cmd.batch
227
-
[ Equalizer.adjustAllKnobs newEqualizerSettings
228
-
, Ports.setRepeat data.repeat
229
-
]
225
+
, Equalizer.adjustAllKnobs newEqualizerSettings
230
226
)
231
227
232
228
Err err ->
-7
src/Applications/UI/View.elm
-7
src/Applications/UI/View.elm
+24
src/Javascript/Brain/application.ts
+24
src/Javascript/Brain/application.ts
···
1
+
import "./index.d"
2
+
3
+
// @ts-ignore
4
+
import { Elm } from "brain.elm.js"
5
+
6
+
7
+
// 🚀
8
+
9
+
10
+
const flags: Record<string, string> = location
11
+
.hash
12
+
.substring(1)
13
+
.split("&")
14
+
.reduce((acc, flag) => {
15
+
const [k, v] = flag.split("=")
16
+
return { ...acc, [k]: v }
17
+
}, {})
18
+
19
+
20
+
export const load = () => Elm.Brain.init({
21
+
flags: {
22
+
initialUrl: decodeURIComponent(flags.appHref) || ""
23
+
}
24
+
})
+120
-14
src/Javascript/Brain/artwork.ts
+120
-14
src/Javascript/Brain/artwork.ts
···
2
2
// Album Covers
3
3
// (◕‿◕✿)
4
4
5
+
import * as Uint8arrays from "uint8arrays"
6
+
7
+
import * as processing from "./processing"
8
+
import { type App } from "./elm/types"
5
9
import { transformUrl } from "../urls"
6
-
import * as processing from "../processing"
10
+
import { toCache } from "./common"
11
+
import { type CoverPrep } from "../common"
12
+
13
+
14
+
// 🌳
15
+
16
+
17
+
type CoverPrepWithUrls = CoverPrep & {
18
+
trackGetUrl: string
19
+
trackHeadUrl: string
20
+
}
21
+
22
+
23
+
24
+
// 🏔️
25
+
26
+
27
+
let artworkQueue: CoverPrep[] = []
28
+
let app: App
29
+
30
+
31
+
32
+
// 🚀
33
+
34
+
35
+
export function init(a: App) {
36
+
app = a
37
+
38
+
app.ports.provideArtworkTrackUrls.subscribe(provideArtworkTrackUrls)
39
+
}
40
+
41
+
42
+
43
+
// PORTS
44
+
45
+
46
+
function provideArtworkTrackUrls(prep: CoverPrepWithUrls) {
47
+
find(prep).then(blob => {
48
+
return toCache(`coverCache.${prep.cacheKey}`, blob).then(_ => blob)
49
+
})
50
+
.then((blob: Blob) => {
51
+
const url = URL.createObjectURL(blob)
52
+
53
+
self.postMessage({
54
+
tag: "GOT_CACHED_COVER",
55
+
data: { imageType: blob.type, key: prep.cacheKey, url: url },
56
+
error: null
57
+
})
58
+
})
59
+
.catch(err => {
60
+
if (err === "No artwork found") {
61
+
// Indicate that we've tried to find artwork,
62
+
// so that we don't try to find it each time we launch the app.
63
+
return toCache(`coverCache.${prep.cacheKey}`, "TRIED")
64
+
65
+
} else {
66
+
// Something went wrong
67
+
console.error(err)
68
+
return toCache(`coverCache.${prep.cacheKey}`, "TRIED")
69
+
70
+
}
71
+
})
72
+
.catch(() => {
73
+
console.warn("Failed to download artwork for ", prep)
74
+
})
75
+
.finally(shiftQueue)
76
+
}
77
+
78
+
79
+
80
+
// 🛠️
81
+
82
+
83
+
export function download(list: CoverPrep[]) {
84
+
const exe = !artworkQueue[0]
85
+
artworkQueue = artworkQueue.concat(list)
86
+
if (exe) shiftQueue()
87
+
}
88
+
89
+
90
+
function shiftQueue() {
91
+
const next = artworkQueue.shift()
92
+
93
+
if (next) {
94
+
app.ports.makeArtworkTrackUrls.send(next)
95
+
} else {
96
+
self.postMessage({
97
+
action: "FINISHED_DOWNLOADING_ARTWORK",
98
+
data: null
99
+
})
100
+
}
101
+
}
102
+
103
+
104
+
105
+
// ㊙️
7
106
8
107
9
108
const REJECT = () => Promise.reject("No artwork found")
10
109
11
110
12
-
export function find(prep, app) {
13
-
return findUsingTags(prep, app)
111
+
function decodeCacheKey(cacheKey: string) {
112
+
return Uint8arrays.toString(
113
+
Uint8arrays.fromString(cacheKey, "base64"),
114
+
"utf8"
115
+
)
116
+
}
117
+
118
+
119
+
function find(prep: CoverPrepWithUrls) {
120
+
return findUsingTags(prep)
14
121
.then(a => a ? a : findUsingMusicBrainz(prep))
15
122
.then(a => a ? a : findUsingLastFm(prep))
16
123
.then(a => a ? a : REJECT())
17
124
.then(a => a.type.startsWith("image/") ? a : REJECT())
18
-
}
19
-
20
-
21
-
function decodeCacheKey(cacheKey) {
22
-
return decodeURIComponent(escape(atob(cacheKey)))
23
125
}
24
126
25
127
···
27
129
// 1. TAGS
28
130
29
131
30
-
async function findUsingTags(prep, app) {
132
+
async function findUsingTags(prep: CoverPrepWithUrls) {
31
133
return Promise.all(
32
134
[
33
135
transformUrl(prep.trackHeadUrl, app),
···
53
155
// 2. MUSIC BRAINZ
54
156
55
157
56
-
function findUsingMusicBrainz(prep) {
158
+
function findUsingMusicBrainz(prep: CoverPrepWithUrls) {
159
+
if (!navigator.onLine) return null
160
+
57
161
const parts = decodeCacheKey(prep.cacheKey).split(" --- ")
58
162
const artist = parts[ 0 ]
59
163
const album = parts[ 1 ] || parts[ 0 ]
···
64
168
return fetch(`https://musicbrainz.org/ws/2/release/?query=${encodedQuery}&fmt=json`)
65
169
.then(r => r.json())
66
170
.then(r => musicBrainzCover(r.releases))
67
-
.catch(_ => REJECT())
68
171
}
69
172
70
173
···
90
193
// 3. LAST FM
91
194
92
195
93
-
function findUsingLastFm(prep) {
94
-
const query = decodeCacheKey(prep.cacheKey).replace(" --- ", " ")
196
+
function findUsingLastFm(prep: CoverPrepWithUrls) {
197
+
if (!navigator.onLine) return null
198
+
199
+
const query = encodeURIComponent(
200
+
decodeCacheKey(prep.cacheKey).replace(" --- ", " ")
201
+
)
95
202
96
203
return fetch(`https://ws.audioscrobbler.com/2.0/?method=album.search&album=${query}&api_key=4f0fe85b67baef8bb7d008a8754a95e5&format=json`)
97
204
.then(r => r.json())
98
205
.then(r => lastFmCover(r.results.albummatches.album))
99
-
.catch(_ => REJECT())
100
206
}
101
207
102
208
+13
-11
src/Javascript/Brain/common.ts
+13
-11
src/Javascript/Brain/common.ts
···
13
13
// 🔱
14
14
15
15
16
-
export function isLocalHost(url) {
16
+
export function isLocalHost(url: string) {
17
17
return (
18
18
url.startsWith("localhost") ||
19
19
url.startsWith("localhost") ||
···
23
23
}
24
24
25
25
26
-
export function parseJsonIfNeeded(a) {
26
+
export function parseJsonIfNeeded(a: unknown) {
27
27
if (typeof a === "string") return JSON.parse(a)
28
28
return a
29
29
}
···
57
57
// Cache
58
58
// -----
59
59
60
-
export function removeCache(key: string) {
60
+
export function removeCache(key: string): Promise<void> {
61
61
return db().removeItem(key)
62
62
}
63
63
64
64
65
-
export function fromCache(key: string) {
65
+
export function fromCache(key: string): Promise<unknown> {
66
66
return db().getItem(key)
67
67
}
68
68
69
69
70
-
export function toCache(key: string, data: unknown) {
70
+
export function toCache(key: string, data: unknown): Promise<unknown> {
71
71
return db().setItem(key, data)
72
72
}
73
73
···
76
76
// Crypto
77
77
// ------
78
78
79
-
export function decryptIfNeeded(data) {
79
+
export function decryptIfNeeded(data: unknown): Promise<unknown | null> {
80
80
if (typeof data !== "string") {
81
81
return Promise.resolve(data)
82
82
83
-
} else if (data.startsWith("{") || data.startsWith("[")) {
83
+
} else if (typeof data === "string" && (data.startsWith("{") || data.startsWith("["))) {
84
84
return Promise.resolve(data)
85
85
86
86
} else if (data.length < 15 && Number.isInteger(parseInt(data, 10))) {
···
100
100
101
101
export async function encryptIfPossible(unencryptedData: string): Promise<string> {
102
102
return unencryptedData
103
-
? getSecretKey()
104
-
.then(secretKey => crypto.encrypt(secretKey, unencryptedData))
105
-
.catch(_ => unencryptedData)
103
+
? getSecretKey().then(secretKey =>
104
+
secretKey
105
+
? crypto.encrypt(secretKey, unencryptedData)
106
+
: unencryptedData
107
+
)
106
108
: unencryptedData
107
109
}
108
110
···
110
112
export { encryptIfPossible as encryptWithSecretKey }
111
113
112
114
113
-
export function getSecretKey() {
115
+
export function getSecretKey(): Promise<CryptoKey | null> {
114
116
return db().getItem(SECRET_KEY_LOCATION)
115
117
}
+10
src/Javascript/Brain/elm/types.ts
+10
src/Javascript/Brain/elm/types.ts
+14
src/Javascript/Brain/index.d.ts
+14
src/Javascript/Brain/index.d.ts
···
1
+
import type { ElmPorts } from "./elm/types"
2
+
3
+
4
+
export { }
5
+
6
+
7
+
declare const Elm: { Brain: ElmMain<ElmPorts> }
8
+
declare const BUILD_TIMESTAMP: string
9
+
10
+
11
+
declare module "elm-taskport" {
12
+
const install: () => void
13
+
const register: (a: string, b: (arg: any) => any) => void
14
+
}
+22
-356
src/Javascript/Brain/index.ts
+22
-356
src/Javascript/Brain/index.ts
···
4
4
//
5
5
// This worker is responsible for everything non-UI.
6
6
7
-
import type { } from "../index.d"
8
-
9
-
// @ts-ignore
10
-
import * as TaskPort from "elm-taskport"
11
-
12
-
import * as artwork from "./artwork"
13
-
import * as processing from "../processing"
14
-
import * as user from "./user"
15
-
16
-
import { db } from "../common"
17
-
import { fromCache, removeCache, reportError } from "./common"
18
-
import { sendData, toCache } from "./common"
19
-
import { transformUrl } from "../urls"
20
-
21
-
// @ts-ignore
22
-
import { Elm } from "brain.elm.js"
23
-
24
-
25
-
// 🍱
26
-
27
-
28
-
let app
29
-
const wire: any = {}
30
-
31
-
32
-
TaskPort.install()
33
-
34
-
35
-
TaskPort.register("fromCache", fromCache)
36
-
TaskPort.register("removeCache", removeCache)
37
-
TaskPort.register("toCache", ({ key, value }) => toCache(key, value))
38
-
39
-
40
-
user.setupTaskPorts()
41
-
42
-
43
-
44
-
// UI
45
-
// ==
46
-
47
-
wire.ui = () => {
48
-
app.ports.toUI.subscribe(event => {
49
-
self.postMessage(event)
50
-
})
51
-
}
52
-
53
-
54
-
self.onmessage = event => {
55
-
if (event.data.action) return handleAction(event.data.action, event.data.data)
56
-
if (event.data.tag) return app.ports.fromAlien.send(event.data)
57
-
}
58
-
59
-
60
-
function handleAction(action, data) {
61
-
switch (action) {
62
-
case "DOWNLOAD_ARTWORK": return downloadArtwork(data)
63
-
}
64
-
}
65
-
66
-
67
-
68
-
// Cache
69
-
// -----
70
-
71
-
wire.caching = () => {
72
-
app.ports.removeCache.subscribe(event => {
73
-
removeCache(event.tag)
74
-
.catch(reportError(app, event))
75
-
})
76
-
77
-
app.ports.requestCache.subscribe(event => {
78
-
const key = event.data && event.data.file
79
-
? event.tag + "_" + event.data.file
80
-
: event.tag
81
-
82
-
fromCache(key)
83
-
.then(sendData(app, event))
84
-
.catch(reportError(app, event))
85
-
})
86
-
87
-
app.ports.toCache.subscribe(event => {
88
-
const key = event.data && event.data.file
89
-
? event.tag + "_" + event.data.file
90
-
: event.tag
91
-
92
-
toCache(key, event.data.data || event.data)
93
-
.catch(reportError(app, event))
94
-
})
95
-
}
96
-
97
-
98
-
99
-
// Cache (Artwork)
100
-
// ---------------
101
-
102
-
let artworkQueue = []
103
-
104
-
105
-
wire.artworkCaching = () => {
106
-
app.ports.provideArtworkTrackUrls.subscribe(provideArtworkTrackUrls)
107
-
}
108
-
109
-
110
-
function downloadArtwork(list) {
111
-
const exe = !artworkQueue[0]
112
-
artworkQueue = artworkQueue.concat(list)
113
-
if (exe) shiftArtworkQueue()
114
-
}
115
-
116
-
117
-
function shiftArtworkQueue() {
118
-
const next = artworkQueue.shift()
119
-
120
-
if (next) {
121
-
app.ports.makeArtworkTrackUrls.send(next)
122
-
} else {
123
-
self.postMessage({
124
-
action: "FINISHED_DOWNLOADING_ARTWORK",
125
-
data: null
126
-
})
127
-
}
128
-
}
129
-
130
-
131
-
function provideArtworkTrackUrls(prep) {
132
-
artwork
133
-
.find(prep, app)
134
-
.then(blob => {
135
-
const url = URL.createObjectURL(blob)
136
-
137
-
self.postMessage({
138
-
tag: "GOT_CACHED_COVER",
139
-
data: { key: prep.cacheKey, url: url },
140
-
error: null
141
-
})
142
-
143
-
return toCache(`coverCache.${prep.cacheKey}`, blob)
144
-
})
145
-
.catch(err => {
146
-
if (err === "No artwork found") {
147
-
// Indicate that we've tried to find artwork,
148
-
// so that we don't try to find it each time we launch the app.
149
-
return toCache(`coverCache.${prep.cacheKey}`, "TRIED")
150
-
151
-
} else {
152
-
// Something went wrong
153
-
reportError(app, { tag: "REPORT_ERROR" })(err)
154
-
155
-
}
156
-
})
157
-
.catch(() => {
158
-
console.warn("Failed to download artwork for ", prep)
159
-
})
160
-
.finally(shiftArtworkQueue)
161
-
}
162
-
163
-
164
-
165
-
// Cache (Tracks)
166
-
// --------------
167
-
168
-
wire.tracksCaching = () => {
169
-
app.ports.removeTracksFromCache.subscribe(removeTracksFromCache)
170
-
app.ports.storeTracksInCache.subscribe(storeTracksInCache)
171
-
}
172
-
173
-
174
-
function removeTracksFromCache(trackIds) {
175
-
trackIds.reduce(
176
-
(acc, id) => acc.then(_ => db("tracks").removeItem(id)),
177
-
Promise.resolve()
178
-
179
-
).catch(
180
-
_ => reportError
181
-
(app, { tag: "REMOVE_TRACKS_FROM_CACHE" })
182
-
("Failed to remove tracks from cache")
183
-
184
-
)
185
-
}
186
-
187
-
188
-
function storeTracksInCache(list) {
189
-
list.reduce(
190
-
(acc, item) => {
191
-
return acc
192
-
.then(_ => transformUrl(item.url, app))
193
-
.then(fetch)
194
-
.then(r => r.blob())
195
-
.then(b => db("tracks").setItem(item.trackId, b))
196
-
},
197
-
Promise.resolve()
198
-
199
-
).then(
200
-
_ => self.postMessage({
201
-
tag: "STORE_TRACKS_IN_CACHE",
202
-
data: list.map(l => l.trackId),
203
-
error: null
204
-
})
205
-
206
-
).catch(
207
-
err => {
208
-
console.error(err)
209
-
self.postMessage({
210
-
tag: "STORE_TRACKS_IN_CACHE",
211
-
data: list.map(l => l.trackId),
212
-
error: err.message || err
213
-
})
214
-
}
215
-
216
-
)
217
-
}
218
-
219
-
220
-
221
-
// Downloading
222
-
// -----------
223
-
224
-
wire.downloading = () => {
225
-
app.ports.downloadTracks.subscribe(group => {
226
-
self.postMessage({
227
-
action: "DOWNLOAD_TRACKS",
228
-
data: group
229
-
})
230
-
})
231
-
}
232
-
233
-
234
-
235
-
// Search
236
-
// ------
237
-
238
-
const search = new Worker(
239
-
"../../search.js",
240
-
{ type: "module" }
241
-
)
242
-
243
-
244
-
wire.search = () => {
245
-
app.ports.requestSearch.subscribe(requestSearch)
246
-
app.ports.updateSearchIndex.subscribe(updateSearchIndex)
247
-
}
248
-
249
-
250
-
function requestSearch(searchTerm) {
251
-
search.postMessage({
252
-
action: "PERFORM_SEARCH",
253
-
data: searchTerm
254
-
})
255
-
}
256
-
257
-
258
-
function updateSearchIndex(tracksJson) {
259
-
search.postMessage({
260
-
action: "UPDATE_SEARCH_INDEX",
261
-
data: tracksJson
262
-
})
263
-
}
264
-
265
-
266
-
search.onmessage = event => {
267
-
switch (event.data.action) {
268
-
case "PERFORM_SEARCH":
269
-
app.ports.receiveSearchResults.send(event.data.data)
270
-
break
271
-
}
272
-
}
273
-
274
-
275
-
276
-
// Tags
277
-
// ----
278
-
279
-
wire.tags = () => {
280
-
app.ports.requestTags.subscribe(context => {
281
-
processing.processContext(context, app).then(newContext => {
282
-
app.ports.receiveTags.send(newContext)
283
-
})
284
-
})
285
-
286
-
app.ports.syncTags.subscribe(context => {
287
-
processing.processContext(context, app).then(newContext => {
288
-
app.ports.replaceTags.send(newContext)
289
-
})
290
-
})
291
-
}
292
-
7
+
import * as Application from "./application"
8
+
import * as Artwork from "./artwork"
9
+
import * as Processing from "./processing"
10
+
import * as Search from "./search"
11
+
import * as User from "./user"
12
+
import * as TaskPorts from "./task-ports"
13
+
import * as Tracks from "./tracks"
14
+
import * as UI from "./ui"
293
15
294
16
295
17
// 🚀
296
18
297
-
298
-
const flags: Record<string, string> = location
299
-
.hash
300
-
.substr(1)
301
-
.split("&")
302
-
.reduce((acc, flag) => {
303
-
const [k, v] = flag.split("=")
304
-
return { ...acc, [k]: v }
305
-
}, {})
306
-
307
-
308
-
forwardCompatibility().then(initialise)
309
-
310
-
311
-
function initialise() {
312
-
app = Elm.Brain.init({
313
-
flags: {
314
-
initialUrl: decodeURIComponent(flags.appHref) || ""
315
-
}
316
-
})
317
-
318
-
user.setupPorts(app)
319
-
320
-
wire.ui()
321
-
wire.caching()
322
-
wire.artworkCaching()
323
-
wire.tracksCaching()
324
-
wire.downloading()
325
-
wire.search()
326
-
wire.tags()
327
-
328
-
self.postMessage({ action: "READY" })
329
-
}
330
-
331
-
332
-
async function forwardCompatibility() {
333
-
// TODO: Future, check version to migrate
334
-
if (await fromCache("MIGRATED")) return
19
+
TaskPorts.register()
20
+
User.TaskPorts.register()
335
21
336
-
await moveOldDbValue({ oldName: "AUTH_SECRET_KEY", newName: "SECRET_KEY" })
337
-
await moveOldDbValue({ oldName: "AUTH_ENCLOSED_DATA", newName: "ENCLOSED_DATA" })
22
+
const app = Application.load()
23
+
const brain = self as unknown as Worker
338
24
339
-
const method = await fromCache("AUTH_METHOD")
25
+
// 🖼️
340
26
341
-
if (method === "LOCAL") {
342
-
await moveOldDbValue({ oldName: "AUTH_ANONYMOUS_favourites.json", newName: "SYNC_LOCAL_favourites.json", parseJSON: true })
343
-
await moveOldDbValue({ oldName: "AUTH_ANONYMOUS_playlists.json", newName: "SYNC_LOCAL_playlists.json", parseJSON: true })
344
-
await moveOldDbValue({ oldName: "AUTH_ANONYMOUS_progress.json", newName: "SYNC_LOCAL_progress.json", parseJSON: true })
345
-
await moveOldDbValue({ oldName: "AUTH_ANONYMOUS_settings.json", newName: "SYNC_LOCAL_settings.json", parseJSON: true })
346
-
await moveOldDbValue({ oldName: "AUTH_ANONYMOUS_sources.json", newName: "SYNC_LOCAL_sources.json", parseJSON: true })
347
-
await moveOldDbValue({ oldName: "AUTH_ANONYMOUS_tracks.json", newName: "SYNC_LOCAL_tracks.json", parseJSON: true })
27
+
UI.link(brain, app)
348
28
349
-
await removeCache("AUTH_METHOD")
29
+
// ⚡
30
+
Artwork.init(app)
31
+
Processing.init(app)
32
+
Search.init(app)
33
+
Tracks.init(app)
350
34
351
-
} else if (method) {
352
-
await toCache("SYNC_METHOD", method)
353
-
await removeCache("AUTH_METHOD")
35
+
User.Ports.register(app)
354
36
355
-
}
356
-
357
-
await toCache("MIGRATED", "3.3.0")
358
-
}
359
-
37
+
// 🛫
360
38
361
-
async function moveOldDbValue(
362
-
{ oldName, newName, parseJSON }: {
363
-
oldName: string
364
-
newName: string
365
-
parseJSON?: boolean
366
-
}
367
-
) {
368
-
const value = await fromCache(oldName)
369
-
if (value && typeof value === "string") {
370
-
await toCache(newName, parseJSON ? JSON.parse(value) : value)
371
-
await removeCache(oldName)
372
-
}
373
-
}
39
+
brain.postMessage({ action: "READY" })
+54
src/Javascript/Brain/search.ts
+54
src/Javascript/Brain/search.ts
···
1
+
import type { App } from "./elm/types"
2
+
3
+
4
+
// 🏔️
5
+
6
+
7
+
let app: App
8
+
9
+
10
+
11
+
// 🚀
12
+
13
+
14
+
export function init(a: App) {
15
+
app = a
16
+
17
+
app.ports.requestSearch.subscribe(requestSearch)
18
+
app.ports.updateSearchIndex.subscribe(updateSearchIndex)
19
+
}
20
+
21
+
22
+
const search = new Worker(
23
+
"../../search.js",
24
+
{ type: "module" }
25
+
)
26
+
27
+
28
+
search.onmessage = event => {
29
+
switch (event.data.action) {
30
+
case "PERFORM_SEARCH":
31
+
app.ports.receiveSearchResults.send(event.data.data)
32
+
break
33
+
}
34
+
}
35
+
36
+
37
+
38
+
// PORTS
39
+
40
+
41
+
function requestSearch(searchTerm: string) {
42
+
search.postMessage({
43
+
action: "PERFORM_SEARCH",
44
+
data: searchTerm
45
+
})
46
+
}
47
+
48
+
49
+
function updateSearchIndex(tracksJson: string) {
50
+
search.postMessage({
51
+
action: "UPDATE_SEARCH_INDEX",
52
+
data: tracksJson
53
+
})
54
+
}
+13
src/Javascript/Brain/task-ports.ts
+13
src/Javascript/Brain/task-ports.ts
···
1
+
// @ts-ignore
2
+
import * as TaskPort from "elm-taskport"
3
+
4
+
import { fromCache, removeCache, toCache } from "./common"
5
+
6
+
7
+
export function register() {
8
+
TaskPort.install()
9
+
10
+
TaskPort.register("fromCache", fromCache)
11
+
TaskPort.register("removeCache", removeCache)
12
+
TaskPort.register("toCache", ({ key, value }) => toCache(key, value))
13
+
}
+81
src/Javascript/Brain/tracks.ts
+81
src/Javascript/Brain/tracks.ts
···
1
+
import type { App } from "./elm/types"
2
+
import { db } from "../common"
3
+
import { reportError } from "./common"
4
+
import { transformUrl } from "../urls"
5
+
6
+
7
+
// 🏔️
8
+
9
+
10
+
let app: App
11
+
12
+
13
+
14
+
// 🚀
15
+
16
+
17
+
export function init(a: App) {
18
+
app = a
19
+
20
+
app.ports.downloadTracks.subscribe(downloadTracks)
21
+
app.ports.removeTracksFromCache.subscribe(removeTracksFromCache)
22
+
app.ports.storeTracksInCache.subscribe(storeTracksInCache)
23
+
}
24
+
25
+
26
+
27
+
// PORTS
28
+
29
+
30
+
function downloadTracks(group) {
31
+
self.postMessage({
32
+
action: "DOWNLOAD_TRACKS",
33
+
data: group
34
+
})
35
+
}
36
+
37
+
38
+
function removeTracksFromCache(trackIds) {
39
+
trackIds.reduce(
40
+
(acc, id) => acc.then(_ => db("tracks").removeItem(id)),
41
+
Promise.resolve()
42
+
43
+
).catch(
44
+
_ => reportError
45
+
(app, { tag: "REMOVE_TRACKS_FROM_CACHE" })
46
+
("Failed to remove tracks from cache")
47
+
48
+
)
49
+
}
50
+
51
+
52
+
function storeTracksInCache(list) {
53
+
list.reduce(
54
+
(acc, item) => {
55
+
return acc
56
+
.then(_ => transformUrl(item.url, app))
57
+
.then(fetch)
58
+
.then(r => r.blob())
59
+
.then(b => db("tracks").setItem(item.trackId, b))
60
+
},
61
+
Promise.resolve()
62
+
63
+
).then(
64
+
_ => self.postMessage({
65
+
tag: "STORE_TRACKS_IN_CACHE",
66
+
data: list.map(l => l.trackId),
67
+
error: null
68
+
})
69
+
70
+
).catch(
71
+
err => {
72
+
console.error(err)
73
+
self.postMessage({
74
+
tag: "STORE_TRACKS_IN_CACHE",
75
+
data: list.map(l => l.trackId),
76
+
error: err.message || err
77
+
})
78
+
}
79
+
80
+
)
81
+
}
+21
src/Javascript/Brain/ui.ts
+21
src/Javascript/Brain/ui.ts
···
1
+
import type { App } from "./elm/types"
2
+
import * as Artwork from "./artwork"
3
+
4
+
5
+
export function link(worker: Worker, app: App) {
6
+
app.ports.toUI.subscribe(event => {
7
+
worker.postMessage(event)
8
+
})
9
+
10
+
worker.onmessage = event => {
11
+
if (event.data.action) return handleAction(event.data.action, event.data.data)
12
+
if (event.data.tag) return app.ports.fromAlien.send(event.data)
13
+
}
14
+
15
+
16
+
function handleAction(action: string, data: unknown) {
17
+
switch (action) {
18
+
case "DOWNLOAD_ARTWORK": return Artwork.download(data)
19
+
}
20
+
}
21
+
}
+9
-3
src/Javascript/Brain/user.ts
+9
-3
src/Javascript/Brain/user.ts
···
7
7
8
8
// @ts-ignore
9
9
import * as TaskPort from "elm-taskport"
10
-
import { APP_INFO, ODD_CONFIG } from "../common"
10
+
11
+
import type { App } from "./elm/types"
11
12
12
13
import * as crypto from "../crypto"
13
14
15
+
import { APP_INFO, ODD_CONFIG } from "../common"
14
16
import { decryptIfNeeded, encryptIfPossible, SECRET_KEY_LOCATION } from "./common"
15
17
import { parseJsonIfNeeded, removeCache, toCache } from "./common"
16
18
···
299
301
// EXPORT
300
302
// ======
301
303
302
-
export function setupPorts(app) {
304
+
function registerPorts(app: App) {
303
305
Object.keys(ports).forEach(name => {
304
306
const fn = ports[ name ](app)
305
307
app.ports[ name ].subscribe(fn)
306
308
})
307
309
}
308
310
309
-
export function setupTaskPorts() {
311
+
function registerTaskPorts() {
310
312
Object.keys(taskPorts).forEach(name => {
311
313
const fn = taskPorts[ name ]
312
314
TaskPort.register(name, fn)
313
315
})
314
316
}
317
+
318
+
319
+
export const TaskPorts = { register: registerTaskPorts }
320
+
export const Ports = { register: registerPorts }
+106
src/Javascript/UI/application.ts
+106
src/Javascript/UI/application.ts
···
1
+
import "./index.d"
2
+
import type { App } from "./elm/types"
3
+
import { version } from "../../../package.json"
4
+
5
+
6
+
// 🏔️
7
+
8
+
9
+
let app: App
10
+
let channel: BroadcastChannel
11
+
12
+
13
+
14
+
// 🚀
15
+
16
+
17
+
export const load = ({ isNativeWrapper, reg }: { isNativeWrapper: boolean, reg: ServiceWorkerRegistration }) => Elm.UI.init({
18
+
node: document.getElementById("elm") || undefined,
19
+
flags: {
20
+
buildTimestamp: BUILD_TIMESTAMP,
21
+
darkMode: preferredColorScheme().matches,
22
+
initialTime: Date.now(),
23
+
isInstallingServiceWorker: !!reg.installing,
24
+
isOnline: navigator.onLine,
25
+
isTauri: isNativeWrapper,
26
+
version,
27
+
viewport: {
28
+
height: window.innerHeight,
29
+
width: window.innerWidth
30
+
}
31
+
}
32
+
})
33
+
34
+
35
+
export function init(a: App, c: BroadcastChannel) {
36
+
app = a
37
+
channel = c
38
+
39
+
app.ports.downloadJsonUsingTauri.subscribe(downloadJsonUsingTauri)
40
+
app.ports.openUrlOnNewPage.subscribe(openUrlOnNewPage)
41
+
app.ports.reloadApp.subscribe(reloadApp)
42
+
}
43
+
44
+
45
+
46
+
// 🌗
47
+
48
+
49
+
function preferredColorScheme() {
50
+
const m =
51
+
window.matchMedia &&
52
+
window.matchMedia("(prefers-color-scheme: dark)")
53
+
54
+
m?.addEventListener("change", e => {
55
+
app.ports.preferredColorSchemaChanged.send({ dark: e.matches })
56
+
})
57
+
58
+
return m
59
+
}
60
+
61
+
62
+
63
+
// PORTS
64
+
65
+
66
+
async function downloadJsonUsingTauri(
67
+
{ filename, json }: { filename: string, json: string }
68
+
) {
69
+
const { save } = await import("@tauri-apps/plugin-dialog")
70
+
const { writeTextFile } = await import("@tauri-apps/plugin-fs")
71
+
const { BaseDirectory } = await import("@tauri-apps/api/path")
72
+
73
+
const filePath = await save({ defaultPath: filename })
74
+
await writeTextFile(filePath || filename, json, { baseDir: BaseDirectory.Download })
75
+
}
76
+
77
+
78
+
function openUrlOnNewPage(url: string) {
79
+
if (globalThis.__TAURI__) {
80
+
globalThis.__TAURI__.shell.open(
81
+
url.includes("://") ? url : `${location.origin}/${url.replace(/^\.\//, "")}`
82
+
)
83
+
84
+
} else {
85
+
window.open(url, "_blank")
86
+
87
+
}
88
+
}
89
+
90
+
91
+
function reloadApp() {
92
+
const timeout = setTimeout(async () => {
93
+
const reg = await navigator.serviceWorker.getRegistration()
94
+
if (reg?.waiting) reg.waiting.postMessage("skipWaiting")
95
+
window.location.reload()
96
+
}, 250)
97
+
98
+
channel.addEventListener("message", event => {
99
+
if (event.data === "PONG") {
100
+
clearTimeout(timeout)
101
+
alert("⚠️ You can only update the app when you have no more than one instance open.")
102
+
}
103
+
})
104
+
105
+
channel.postMessage("PING")
106
+
}
+111
src/Javascript/UI/artwork.ts
+111
src/Javascript/UI/artwork.ts
···
1
+
import { debounce } from "throttle-debounce"
2
+
3
+
import type { App } from "./elm/types"
4
+
import { type CoverPrep, db } from "../common"
5
+
6
+
7
+
8
+
// 🏔️
9
+
10
+
11
+
let app: App
12
+
let brain: Worker
13
+
14
+
15
+
16
+
// 🚀
17
+
18
+
19
+
export function init(a: App, b: Worker) {
20
+
app = a
21
+
brain = b
22
+
23
+
app.ports.loadAlbumCovers.subscribe(
24
+
debounce(500, loadAlbumCoversFromDom)
25
+
)
26
+
27
+
db().keys().then(cachedCovers)
28
+
}
29
+
30
+
31
+
32
+
// 🛠️
33
+
34
+
35
+
export function albumCover(coverKey: string): Promise<Blob | null> {
36
+
return db().getItem(`coverCache.${coverKey}`)
37
+
}
38
+
39
+
40
+
async function loadAlbumCoversFromDom({ coverView, list }: { coverView: boolean, list: boolean }): Promise<void> {
41
+
let nodes: HTMLElement[] = []
42
+
43
+
if (list) nodes = nodes.concat(Array.from(
44
+
document.querySelectorAll("#diffuse__track-covers [data-key]")
45
+
))
46
+
47
+
if (coverView) nodes = nodes.concat(Array.from(
48
+
document.querySelectorAll("#diffuse__track-covers + div [data-key]")
49
+
))
50
+
51
+
if (!nodes.length) return;
52
+
53
+
const coverPrepList = nodes.reduce((acc: CoverPrep[], node: HTMLElement) => {
54
+
const a = {
55
+
cacheKey: node.getAttribute("data-key"),
56
+
trackFilename: node.getAttribute("data-filename"),
57
+
trackPath: node.getAttribute("data-path"),
58
+
trackSourceId: node.getAttribute("data-source-id"),
59
+
variousArtists: node.getAttribute("data-various-artists")
60
+
}
61
+
62
+
if (a.cacheKey && a.trackFilename && a.trackPath && a.trackSourceId && a.variousArtists) {
63
+
return [...acc, a as CoverPrep]
64
+
} else {
65
+
return acc
66
+
}
67
+
}, [] as CoverPrep[])
68
+
69
+
return loadAlbumCovers(coverPrepList)
70
+
}
71
+
72
+
73
+
export async function loadAlbumCovers(coverPrepList: CoverPrep[]): Promise<void> {
74
+
const withoutEarlierAttempts = await coverPrepList.reduce(async (
75
+
acc: Promise<CoverPrep[]>,
76
+
prep: CoverPrep
77
+
): Promise<CoverPrep[]> => {
78
+
const arr = await acc
79
+
const a = await albumCover(prep.cacheKey)
80
+
if (!a) return [...arr, prep]
81
+
return arr
82
+
}, Promise.resolve([]))
83
+
84
+
brain.postMessage({
85
+
action: "DOWNLOAD_ARTWORK",
86
+
data: withoutEarlierAttempts
87
+
})
88
+
}
89
+
90
+
91
+
// Send a dictionary of the cached covers to the app.
92
+
async function cachedCovers(keys: string[]) {
93
+
const cacheKeys = keys.filter(
94
+
k => k.startsWith("coverCache.")
95
+
)
96
+
97
+
const cache = await cacheKeys.reduce(async (acc, key) => {
98
+
const c = await acc
99
+
const blob = await db().getItem(key)
100
+
const cacheKey = key.slice(11)
101
+
102
+
if (blob && typeof blob !== "string" && blob instanceof Blob) {
103
+
c[cacheKey] = URL.createObjectURL(blob)
104
+
}
105
+
106
+
return c
107
+
}, Promise.resolve({}))
108
+
109
+
app.ports.insertCoverCache.send(cache)
110
+
setTimeout(() => loadAlbumCoversFromDom({ list: true, coverView: true }), 500)
111
+
}
+513
src/Javascript/UI/audio.ts
+513
src/Javascript/UI/audio.ts
···
1
+
//
2
+
// Audio engine
3
+
// ♪(´ε` )
4
+
5
+
import type { App } from "./elm/types"
6
+
7
+
import Timer from "timer.js"
8
+
import { debounce } from "throttle-debounce"
9
+
import { CoverPrep, db, mimeType } from "../common"
10
+
import { albumCover, loadAlbumCovers } from "./artwork"
11
+
12
+
13
+
// 🏔️
14
+
15
+
16
+
const silentMp3File =
17
+
"data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU2LjM2LjEwMAAAAAAAAAAAAAAA//OEAAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8AAAAEAAABIADAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV6urq6urq6urq6urq6urq6urq6urq6urq6v////////////////////////////////8AAAAATGF2YzU2LjQxAAAAAAAAAAAAAAAAJAAAAAAAAAAAASDs90hvAAAAAAAAAAAAAAAAAAAA//MUZAAAAAGkAAAAAAAAA0gAAAAATEFN//MUZAMAAAGkAAAAAAAAA0gAAAAARTMu//MUZAYAAAGkAAAAAAAAA0gAAAAAOTku//MUZAkAAAGkAAAAAAAAA0gAAAAANVVV"
18
+
19
+
20
+
let app: App
21
+
let container: Element | null = null
22
+
let scrobbleTimer: Timer | null = null
23
+
24
+
25
+
26
+
// 🚀
27
+
28
+
29
+
export function init(a: App) {
30
+
app = a
31
+
32
+
app.ports.adjustEqualizerSetting.subscribe(adjustEqualizerSetting)
33
+
app.ports.pause.subscribe(pause)
34
+
app.ports.pauseScrobbleTimer.subscribe(pauseScrobbleTimer)
35
+
app.ports.play.subscribe(play)
36
+
app.ports.reloadAudioNodeIfNeeded.subscribe(reloadAudioNodeIfNeeded)
37
+
app.ports.renderAudioElements.subscribe(renderAudioElements)
38
+
app.ports.resetScrobbleTimer.subscribe(resetScrobbleTimer)
39
+
app.ports.seek.subscribe(seek)
40
+
app.ports.setMediaSessionArtwork.subscribe(setMediaSessionArtwork)
41
+
app.ports.setMediaSessionMetadata.subscribe(setMediaSessionMetadata)
42
+
app.ports.setMediaSessionPlaybackState.subscribe(setMediaSessionPlaybackState)
43
+
app.ports.setMediaSessionPositionState.subscribe(setMediaSessionPositionState)
44
+
app.ports.startScrobbleTimer.subscribe(startScrobbleTimer)
45
+
}
46
+
47
+
48
+
49
+
// 🌳
50
+
51
+
52
+
/**
53
+
* Javascript representation of `Queue.EngineItem` in Elm.
54
+
*/
55
+
type EngineItem = {
56
+
isCached: boolean
57
+
isPreload: boolean
58
+
progress: number | null
59
+
sourceId: string
60
+
trackId: string
61
+
trackTags: TrackTags
62
+
trackPath: string
63
+
url: string
64
+
}
65
+
66
+
67
+
/**/
68
+
type TrackTags = {
69
+
disc: number
70
+
nr: number
71
+
72
+
// Main
73
+
album: string | null
74
+
artist: string | null
75
+
title: string
76
+
77
+
// Extra
78
+
genre: string | null
79
+
picture: string | null
80
+
year: number | null
81
+
}
82
+
83
+
84
+
85
+
// Ports
86
+
// -----
87
+
88
+
89
+
function adjustEqualizerSetting({ knob, value }: { knob: string, value: number }): void {
90
+
switch (knob) {
91
+
case "VOLUME":
92
+
Array.from(
93
+
document.body.querySelectorAll('#audio-elements audio[data-is-preload="false"]'),
94
+
).forEach((audio) => ((audio as HTMLAudioElement).volume = value))
95
+
break
96
+
}
97
+
}
98
+
99
+
100
+
function pause({ trackId }: { trackId: string }) {
101
+
withAudioNode(trackId, (audio) => audio.pause())
102
+
}
103
+
104
+
105
+
function pauseScrobbleTimer() {
106
+
if (this.scrobbleTimer) this.scrobbleTimer.pause()
107
+
}
108
+
109
+
110
+
function play({ trackId, volume }: { trackId: string, volume: number }) {
111
+
withAudioNode(trackId, (audio) => {
112
+
audio.volume = volume
113
+
audio.muted = false
114
+
115
+
if (audio.readyState === 0) audio.load()
116
+
117
+
const promise = audio.play() || Promise.resolve()
118
+
119
+
promise.catch((e) => {
120
+
const err = "Couldn't play audio automatically. Please resume playback manually."
121
+
console.error(err, e)
122
+
if (app) app.ports.fromAlien.send({ tag: "", data: null, error: err })
123
+
})
124
+
})
125
+
}
126
+
127
+
128
+
async function reloadAudioNodeIfNeeded(args: { play: boolean, progress: number | null, trackId: string }) {
129
+
withAudioNode(args.trackId, (audio) => {
130
+
if (audio.readyState === 0 || audio.error?.code === 2) {
131
+
audio.load()
132
+
133
+
if (args.progress) {
134
+
audio.setAttribute("data-initial-progress", JSON.stringify(args.progress))
135
+
}
136
+
137
+
if (args.play) {
138
+
play({ trackId: args.trackId, volume: audio.volume })
139
+
}
140
+
}
141
+
})
142
+
}
143
+
144
+
145
+
async function renderAudioElements(args: {
146
+
items: Array<EngineItem>
147
+
play: string | null
148
+
volume: number
149
+
}) {
150
+
await render(args.items)
151
+
if (args.play) play({ trackId: args.play, volume: args.volume })
152
+
}
153
+
154
+
155
+
function resetScrobbleTimer({ duration, trackId }: { duration: number, trackId: string }) {
156
+
const timestamp = Math.round(Date.now() / 1000)
157
+
const scrobbleTimeoutDuration = Math.min(240 + 0.5, duration / 1.95)
158
+
159
+
if (this.scrobbleTimer) this.scrobbleTimer.stop()
160
+
161
+
scrobbleTimer = new Timer({
162
+
onend: () =>
163
+
app.ports.scrobble.send({
164
+
duration: Math.round(duration),
165
+
timestamp,
166
+
trackId,
167
+
}),
168
+
})
169
+
170
+
scrobbleTimer.start(scrobbleTimeoutDuration)
171
+
}
172
+
173
+
174
+
function seek({ percentage, trackId }: { percentage: number, trackId: string }) {
175
+
withAudioNode(trackId, (audio) => {
176
+
if (!isNaN(audio.duration)) {
177
+
audio.currentTime = audio.duration * percentage
178
+
}
179
+
})
180
+
}
181
+
182
+
183
+
async function setMediaSessionArtwork({ blobUrl, imageType }: { blobUrl: string, imageType: string }) {
184
+
const artwork: MediaImage[] = [{
185
+
src: blobUrl,
186
+
type: imageType
187
+
}]
188
+
189
+
navigator.mediaSession.metadata = new MediaMetadata({
190
+
title: navigator.mediaSession.metadata?.title,
191
+
artist: navigator.mediaSession.metadata?.artist,
192
+
album: navigator.mediaSession.metadata?.album,
193
+
artwork: artwork,
194
+
})
195
+
}
196
+
197
+
198
+
async function setMediaSessionMetadata({
199
+
album,
200
+
artist,
201
+
title,
202
+
203
+
coverPrep,
204
+
}: {
205
+
album: string | null
206
+
artist: string | null
207
+
title: string
208
+
209
+
coverPrep: CoverPrep | null
210
+
}) {
211
+
let artwork: MediaImage[] = []
212
+
213
+
if (coverPrep) {
214
+
const blob = await albumCover(coverPrep.cacheKey)
215
+
216
+
artwork = blob
217
+
? [{
218
+
src: URL.createObjectURL(blob),
219
+
type: blob.type
220
+
}]
221
+
: []
222
+
223
+
if (!blob) {
224
+
// Download artwork and set it later
225
+
loadAlbumCovers([coverPrep])
226
+
}
227
+
}
228
+
229
+
navigator.mediaSession.metadata = new MediaMetadata({
230
+
title,
231
+
artist: artist || undefined,
232
+
album: album || undefined,
233
+
artwork: artwork,
234
+
})
235
+
}
236
+
237
+
238
+
function setMediaSessionPlaybackState(state: MediaSessionPlaybackState) {
239
+
if (navigator.mediaSession) navigator.mediaSession.playbackState = state
240
+
}
241
+
242
+
243
+
function setMediaSessionPositionState({
244
+
currentTime,
245
+
duration,
246
+
}: {
247
+
currentTime: number
248
+
duration: number
249
+
}) {
250
+
try {
251
+
navigator?.mediaSession?.setPositionState({
252
+
duration: duration,
253
+
position: currentTime,
254
+
})
255
+
} catch (_err) {
256
+
//
257
+
}
258
+
}
259
+
260
+
261
+
function startScrobbleTimer() {
262
+
if (this.scrobbleTimer) this.scrobbleTimer.start()
263
+
}
264
+
265
+
266
+
267
+
// Media Keys
268
+
// ----------
269
+
270
+
271
+
if ("mediaSession" in navigator) {
272
+
navigator.mediaSession.setActionHandler("play", () => {
273
+
app.ports.requestPlay.send(null)
274
+
})
275
+
276
+
navigator.mediaSession.setActionHandler("pause", () => {
277
+
app.ports.requestPause.send(null)
278
+
})
279
+
280
+
navigator.mediaSession.setActionHandler("previoustrack", () => {
281
+
app.ports.requestPrevious.send(null)
282
+
})
283
+
284
+
navigator.mediaSession.setActionHandler("nexttrack", () => {
285
+
app.ports.requestNext.send(null)
286
+
})
287
+
288
+
navigator.mediaSession.setActionHandler("seekbackward", (event) => {
289
+
const seekOffset = event.seekOffset || 10
290
+
withActiveAudioNode(
291
+
(audio) => (audio.currentTime = Math.max(audio.currentTime - seekOffset, 0)),
292
+
)
293
+
})
294
+
295
+
navigator.mediaSession.setActionHandler("seekforward", (event) => {
296
+
const seekOffset = event.seekOffset || 10
297
+
withActiveAudioNode(
298
+
(audio) => (audio.currentTime = Math.min(audio.currentTime + seekOffset, audio.duration)),
299
+
)
300
+
})
301
+
302
+
navigator.mediaSession.setActionHandler("seekto", (event) => {
303
+
withActiveAudioNode((audio) => (audio.currentTime = event.seekTime || audio.currentTime))
304
+
})
305
+
}
306
+
307
+
308
+
309
+
// 🖼️
310
+
311
+
312
+
async function render(items: Array<EngineItem>) {
313
+
if (!container) {
314
+
container = document.createElement("div")
315
+
container.id = "audio-elements"
316
+
container.className = "absolute h-0 invisible left-0 pointer-events-none top-0 w-0"
317
+
318
+
document.body.appendChild(container)
319
+
}
320
+
321
+
const trackIds = items.map((e) => e.trackId)
322
+
const existingNodes = {}
323
+
324
+
// Manage existing nodes
325
+
Array.from(container.querySelectorAll("audio")).map((node: HTMLAudioElement) => {
326
+
if (trackIds.includes(node.id)) {
327
+
existingNodes[node.id] = node
328
+
} else {
329
+
node.src = silentMp3File
330
+
container?.removeChild(node)
331
+
}
332
+
})
333
+
334
+
// Adjust existing and add new
335
+
await items.reduce(async (acc: Promise<void>, item: EngineItem) => {
336
+
await acc
337
+
338
+
if (existingNodes[item.trackId]) {
339
+
existingNodes[item.trackId].setAttribute(
340
+
"data-is-preload",
341
+
item.isPreload ? "true" : "false",
342
+
)
343
+
} else {
344
+
await createElement(item)
345
+
}
346
+
}, Promise.resolve())
347
+
}
348
+
349
+
350
+
export async function createElement(item: EngineItem) {
351
+
const url = item.isCached
352
+
? await db("tracks")
353
+
.getItem(item.trackId)
354
+
.then((blob) => (blob ? URL.createObjectURL(blob as Blob) : item.url))
355
+
: item.url
356
+
357
+
// Mime + SRC
358
+
const fileName = item.trackPath.split("/").reverse()[0]
359
+
const fileExtMatch = fileName.match(/\.(\w+)$/)
360
+
const fileExt = fileExtMatch && fileExtMatch[1]
361
+
const mime = fileExt ? mimeType(fileExt) : null
362
+
363
+
const source = document.createElement("source")
364
+
if (mime) source.setAttribute("type", mime)
365
+
source.setAttribute("src", url)
366
+
367
+
// Audio node
368
+
const audio = new Audio()
369
+
audio.setAttribute("id", item.trackId)
370
+
audio.setAttribute("crossorigin", "anonymous")
371
+
audio.setAttribute("data-initial-progress", JSON.stringify(item.progress))
372
+
audio.setAttribute("data-is-preload", item.isPreload ? "true" : "false")
373
+
audio.setAttribute("muted", "true")
374
+
audio.setAttribute("preload", "auto")
375
+
audio.appendChild(source)
376
+
377
+
audio.addEventListener("canplay", canplayEvent)
378
+
audio.addEventListener("durationchange", durationchangeEvent)
379
+
audio.addEventListener("ended", endedEvent)
380
+
audio.addEventListener("error", errorEvent)
381
+
audio.addEventListener("pause", pauseEvent)
382
+
audio.addEventListener("play", playEvent)
383
+
audio.addEventListener("suspend", suspendEvent)
384
+
audio.addEventListener("timeupdate", timeupdateEvent)
385
+
audio.addEventListener("waiting", debounce(1500, waitingEvent))
386
+
387
+
container?.appendChild(audio)
388
+
}
389
+
390
+
391
+
392
+
// 🖼 ░░ EVENTS
393
+
394
+
395
+
function canplayEvent(event: Event) {
396
+
const target = event.target as HTMLAudioElement
397
+
398
+
if (target.hasAttribute("data-initial-progress") && target.duration && !isNaN(target.duration)) {
399
+
const progress = JSON.parse(target.getAttribute("data-initial-progress") as string)
400
+
target.currentTime = target.duration * progress
401
+
target.removeAttribute("data-initial-progress")
402
+
}
403
+
404
+
finishedLoading(event)
405
+
}
406
+
407
+
408
+
function durationchangeEvent(event: Event) {
409
+
const target = event.target as HTMLAudioElement
410
+
411
+
if (!isNaN(target.duration)) {
412
+
app.ports.audioDurationChange.send({
413
+
trackId: target.id,
414
+
duration: target.duration,
415
+
})
416
+
}
417
+
}
418
+
419
+
function endedEvent(event: Event) {
420
+
app.ports.audioEnded.send({
421
+
trackId: (event.target as HTMLAudioElement).id,
422
+
})
423
+
}
424
+
425
+
function errorEvent(event: Event) {
426
+
const audio = event.target as HTMLAudioElement
427
+
428
+
app.ports.audioError.send({
429
+
trackId: audio.id,
430
+
code: audio.error?.code || 0
431
+
})
432
+
}
433
+
434
+
435
+
function pauseEvent(event: Event) {
436
+
app.ports.audioPlaybackStateChanged.send({
437
+
trackId: (event.target as HTMLAudioElement).id,
438
+
isPlaying: false,
439
+
})
440
+
}
441
+
442
+
443
+
function playEvent(event: Event) {
444
+
const audio = event.target as HTMLAudioElement
445
+
446
+
app.ports.audioPlaybackStateChanged.send({
447
+
trackId: audio.id,
448
+
isPlaying: true,
449
+
})
450
+
451
+
// In case audio was preloaded:
452
+
if (audio.readyState === 4) finishedLoading(event)
453
+
}
454
+
455
+
456
+
function suspendEvent(event: Event) {
457
+
finishedLoading(event)
458
+
}
459
+
460
+
461
+
function timeupdateEvent(event: Event) {
462
+
const target = event.target as HTMLAudioElement
463
+
464
+
app.ports.audioTimeUpdated.send({
465
+
trackId: target.id,
466
+
currentTime: target.currentTime,
467
+
duration: isNaN(target.duration) ? null : target.duration,
468
+
})
469
+
}
470
+
471
+
472
+
function waitingEvent(event: Event) {
473
+
initiateLoading(event)
474
+
}
475
+
476
+
477
+
478
+
// 🛠️
479
+
480
+
481
+
function finishedLoading(event: Event) {
482
+
app.ports.audioHasLoaded.send({
483
+
trackId: (event.target as HTMLAudioElement).id,
484
+
})
485
+
}
486
+
487
+
488
+
function initiateLoading(event: Event) {
489
+
const audio = event.target as HTMLAudioElement
490
+
491
+
if (audio.readyState < 4)
492
+
app.ports.audioIsLoading.send({
493
+
trackId: audio.id,
494
+
})
495
+
}
496
+
497
+
498
+
function withActiveAudioNode(fn: (node: HTMLAudioElement) => void): void {
499
+
const nonPreloadNodes: HTMLAudioElement[] = Array.from(
500
+
document.body.querySelectorAll(`#audio-elements audio[data-is-preload="false"]`),
501
+
)
502
+
const playingNodes = nonPreloadNodes.filter((n) => n.paused === false)
503
+
const node = playingNodes.length ? playingNodes[0] : nonPreloadNodes[0]
504
+
if (node) fn(node)
505
+
}
506
+
507
+
508
+
function withAudioNode(trackId: string, fn: (node: HTMLAudioElement) => void): void {
509
+
const node = document.body.querySelector(
510
+
`#audio-elements audio[id="${trackId}"][data-is-preload="false"]`,
511
+
)
512
+
if (node) fn(node as HTMLAudioElement)
513
+
}
+46
src/Javascript/UI/backdrop.ts
+46
src/Javascript/UI/backdrop.ts
···
1
+
// 🚀
2
+
3
+
4
+
export function init(app) {
5
+
app.ports.pickAverageBackgroundColor.subscribe((src: string) => {
6
+
const avgColor = pickAverageBackgroundColor(src)
7
+
if (avgColor) app.ports.setAverageBackgroundColor.send(avgColor)
8
+
})
9
+
}
10
+
11
+
12
+
13
+
// 🛠️
14
+
15
+
16
+
function averageColorOfImage(img: HTMLImageElement): { r: number, g: number, b: number } | null {
17
+
const canvas = document.createElement("canvas")
18
+
const ctx = canvas.getContext("2d")
19
+
canvas.width = img.naturalWidth
20
+
canvas.height = img.naturalHeight
21
+
22
+
if (!ctx) return null
23
+
24
+
ctx.drawImage(img, 0, 0)
25
+
26
+
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
27
+
const color = { r: 0, g: 0, b: 0 }
28
+
29
+
for (let i = 0, l = imageData.data.length; i < l; i += 4) {
30
+
color.r += imageData.data[i]
31
+
color.g += imageData.data[i + 1]
32
+
color.b += imageData.data[i + 2]
33
+
}
34
+
35
+
color.r = Math.floor(color.r / (imageData.data.length / 4))
36
+
color.g = Math.floor(color.g / (imageData.data.length / 4))
37
+
color.b = Math.floor(color.b / (imageData.data.length / 4))
38
+
39
+
return color
40
+
}
41
+
42
+
43
+
function pickAverageBackgroundColor(src: string): { r: number, g: number, b: number } | null {
44
+
const img = document.querySelector(`img[src$="${src}"]`)
45
+
return img ? averageColorOfImage(img as HTMLImageElement) : null
46
+
}
+39
src/Javascript/UI/brain.ts
+39
src/Javascript/UI/brain.ts
···
1
+
import type { App } from "./elm/types"
2
+
import * as Tracks from "./tracks"
3
+
4
+
5
+
export async function load(): Promise<Worker> {
6
+
const brain = new Worker(
7
+
"./js/brain/index.js#appHref=" + encodeURIComponent(window.location.href),
8
+
{ type: "module" }
9
+
)
10
+
11
+
await new Promise((resolve, reject) => {
12
+
brain.onmessage = event => {
13
+
if (event.data.action === "READY") resolve(null)
14
+
}
15
+
16
+
brain.addEventListener("error", () => {
17
+
reject("<strong>Failed to load web worker.</strong><br />If you're using Firefox, you might need to upgrade your browser (version 113 and up) and set `dom.workers.modules.enabled` to `true` in `about:config`")
18
+
})
19
+
})
20
+
21
+
// Fin
22
+
return brain
23
+
}
24
+
25
+
26
+
export function link({ app, brain }: { app: App, brain: Worker }) {
27
+
function handleAction(action, data, _ports) {
28
+
switch (action) {
29
+
case "DOWNLOAD_TRACKS": return Tracks.download(data)
30
+
}
31
+
}
32
+
33
+
brain.onmessage = event => {
34
+
if (event.data.action) return handleAction(event.data.action, event.data.data, event.ports)
35
+
if (event.data.tag) app.ports.fromAlien.send(event.data)
36
+
}
37
+
38
+
app.ports.toBrain.subscribe(a => brain.postMessage(a))
39
+
}
+11
src/Javascript/UI/broadcast.ts
+11
src/Javascript/UI/broadcast.ts
+11
src/Javascript/UI/elm/types.ts
+11
src/Javascript/UI/elm/types.ts
+20
src/Javascript/UI/errors.ts
+20
src/Javascript/UI/errors.ts
···
1
+
export function failure(text: string): void {
2
+
const note = document.createElement("div")
3
+
4
+
note.className = "flex flex-col font-body items-center h-screen italic justify-center leading-relaxed px-4 text-center text-base text-white"
5
+
note.innerHTML = `
6
+
<a class="block logo mb-5" href="../">
7
+
<img src="../images/diffuse-light.svg" />
8
+
</a>
9
+
10
+
<p class="max-w-sm opacity-60">
11
+
${text}
12
+
</p>
13
+
`
14
+
15
+
document.body.appendChild(note)
16
+
17
+
// Remove loader
18
+
const elm = document.querySelector("#elm")
19
+
elm?.parentNode?.removeChild(elm)
20
+
}
+10
src/Javascript/UI/index.d.ts
+10
src/Javascript/UI/index.d.ts
+61
src/Javascript/UI/index.ts
+61
src/Javascript/UI/index.ts
···
1
+
//
2
+
// | (• ◡•)| (❍ᴥ❍ʋ)
3
+
//
4
+
// The bit where we launch the Elm apps & workers,
5
+
// and connect the other bits to it.
6
+
7
+
import "./pointer-events"
8
+
9
+
import * as Application from "./application"
10
+
import * as Artwork from "./artwork"
11
+
import * as Audio from "./audio"
12
+
import * as Backdrop from "./backdrop"
13
+
import * as Brain from "./brain"
14
+
import * as Broadcast from "./broadcast"
15
+
import * as Errors from "./errors"
16
+
import * as Misc from "./misc"
17
+
import * as ServiceWorker from "./service-worker"
18
+
import * as Tracks from "./tracks"
19
+
import * as UserLayer from "./user-layer"
20
+
21
+
22
+
23
+
// 🌸
24
+
25
+
26
+
const isNativeWrapper = !!globalThis.__TAURI__
27
+
28
+
29
+
30
+
// 🚀
31
+
32
+
33
+
ServiceWorker
34
+
.load({ isNativeWrapper })
35
+
.then(async (reg: ServiceWorkerRegistration) => {
36
+
const brain = await Brain.load()
37
+
const app = Application.load({ isNativeWrapper, reg })
38
+
const channel = Broadcast.channel()
39
+
40
+
// 🧑🏭
41
+
ServiceWorker.link({
42
+
app, isNativeWrapper, reg
43
+
})
44
+
45
+
// 🧠
46
+
Brain.link({
47
+
app, brain
48
+
})
49
+
50
+
// ⚡
51
+
Application.init(app, channel)
52
+
Artwork.init(app, brain)
53
+
Audio.init(app)
54
+
Backdrop.init(app)
55
+
Misc.init(app)
56
+
Tracks.init(app)
57
+
UserLayer.init(app)
58
+
})
59
+
.catch(
60
+
Errors.failure
61
+
)
+96
src/Javascript/UI/misc.ts
+96
src/Javascript/UI/misc.ts
···
1
+
import type { App } from "./elm/types"
2
+
3
+
4
+
// 🏔️
5
+
6
+
7
+
let app: App
8
+
9
+
10
+
11
+
// 🚀
12
+
13
+
14
+
export function init(a: App) {
15
+
app = a
16
+
17
+
app.ports.copyToClipboard.subscribe(copyToClipboard)
18
+
}
19
+
20
+
21
+
22
+
// Clipboard
23
+
// ---------
24
+
25
+
26
+
async function copyToClipboard(text: string) {
27
+
navigator.clipboard.writeText(text)
28
+
}
29
+
30
+
31
+
32
+
// Focus
33
+
// -----
34
+
35
+
window.addEventListener("blur", event => {
36
+
if (app && event.target === window) app.ports.lostWindowFocus.send(null)
37
+
})
38
+
39
+
40
+
41
+
// Forms
42
+
// -----
43
+
// Adds a `changed` attribute to form fields, if the form was "changed".
44
+
// This is to help with styling, we don't want to show an error immediately.
45
+
46
+
const FIELD_SELECTOR = "input, textarea"
47
+
48
+
49
+
document.addEventListener("keyup", e => {
50
+
const field = e.target && (<HTMLElement>e.target).closest(FIELD_SELECTOR)
51
+
if (field) field.setAttribute("changed", "")
52
+
})
53
+
54
+
55
+
document.addEventListener("click", e => {
56
+
if (!e.target || (<HTMLElement>e.target).tagName !== "BUTTON") return;
57
+
const form = (<HTMLElement>e.target).closest("form")
58
+
if (form) markAllFormFieldsAsChanged(form)
59
+
})
60
+
61
+
62
+
document.addEventListener("submit", e => {
63
+
const form = e.target && (<HTMLElement>e.target).closest("form")
64
+
if (form) markAllFormFieldsAsChanged(form)
65
+
})
66
+
67
+
68
+
function markAllFormFieldsAsChanged(form) {
69
+
[].slice.call(form.querySelectorAll(FIELD_SELECTOR)).forEach(field => {
70
+
field.setAttribute("changed", "")
71
+
})
72
+
}
73
+
74
+
75
+
76
+
// Internet Connection
77
+
// -------------------
78
+
79
+
window.addEventListener("online", onlineStatusChanged)
80
+
window.addEventListener("offline", onlineStatusChanged)
81
+
82
+
83
+
function onlineStatusChanged() {
84
+
if (app) app.ports.setIsOnline.send(navigator.onLine)
85
+
}
86
+
87
+
88
+
89
+
// Touch Device
90
+
// ------------
91
+
92
+
window.addEventListener("touchstart", function onFirstTouch() {
93
+
if (!app) return
94
+
app.ports.indicateTouchDevice.send()
95
+
window.removeEventListener("touchstart", onFirstTouch, false)
96
+
}, false)
+116
src/Javascript/UI/pointer-events.ts
+116
src/Javascript/UI/pointer-events.ts
···
1
+
import "./index.d"
2
+
import "tocca"
3
+
4
+
// Pointer Events
5
+
// --------------
6
+
// Thanks to https://github.com/mpizenberg/elm-pep/
7
+
8
+
let enteredElement
9
+
10
+
11
+
tocca({
12
+
dbltapThreshold: 400,
13
+
tapThreshold: 250
14
+
})
15
+
16
+
17
+
function mousePointerEvent(eventType, mouseEvent) {
18
+
const pointerEvent: any = new MouseEvent(eventType, mouseEvent)
19
+
pointerEvent.pointerId = 1
20
+
pointerEvent.isPrimary = true
21
+
pointerEvent.pointerType = "mouse"
22
+
pointerEvent.width = 1
23
+
pointerEvent.height = 1
24
+
pointerEvent.tiltX = 0
25
+
pointerEvent.tiltY = 0
26
+
27
+
"buttons" in mouseEvent && mouseEvent.buttons !== 0
28
+
? (pointerEvent.pressure = 0.5)
29
+
: (pointerEvent.pressure = 0)
30
+
31
+
return pointerEvent
32
+
}
33
+
34
+
35
+
function touchPointerEvent(eventType, touchEvent, touch) {
36
+
const pointerEvent: any = new CustomEvent(eventType, {
37
+
bubbles: true,
38
+
cancelable: true
39
+
})
40
+
41
+
pointerEvent.ctrlKey = touchEvent.ctrlKey
42
+
pointerEvent.shiftKey = touchEvent.shiftKey
43
+
pointerEvent.altKey = touchEvent.altKey
44
+
pointerEvent.metaKey = touchEvent.metaKey
45
+
46
+
pointerEvent.clientX = touch.clientX
47
+
pointerEvent.clientY = touch.clientY
48
+
pointerEvent.screenX = touch.screenX
49
+
pointerEvent.screenY = touch.screenY
50
+
pointerEvent.pageX = touch.pageX
51
+
pointerEvent.pageY = touch.pageY
52
+
53
+
const rect = touch.target.getBoundingClientRect()
54
+
pointerEvent.offsetX = touch.clientX - rect.left
55
+
pointerEvent.offsetY = touch.clientY - rect.top
56
+
pointerEvent.pointerId = 1 + touch.identifier
57
+
58
+
pointerEvent.button = 0
59
+
pointerEvent.buttons = 1
60
+
pointerEvent.movementX = 0
61
+
pointerEvent.movementY = 0
62
+
pointerEvent.region = null
63
+
pointerEvent.relatedTarget = null
64
+
pointerEvent.x = pointerEvent.clientX
65
+
pointerEvent.y = pointerEvent.clientY
66
+
67
+
pointerEvent.pointerType = "touch"
68
+
pointerEvent.width = 1
69
+
pointerEvent.height = 1
70
+
pointerEvent.tiltX = 0
71
+
pointerEvent.tiltY = 0
72
+
pointerEvent.pressure = 1
73
+
pointerEvent.isPrimary = true
74
+
75
+
return pointerEvent
76
+
}
77
+
78
+
79
+
// Simulate `pointerenter` and `pointerleave` event for non-touch devices
80
+
if (!self.PointerEvent) {
81
+
document.addEventListener("mouseover", event => {
82
+
const section = document.body.querySelector("section")
83
+
const isDragging = section && section.classList.contains("dragging-something")
84
+
const node = isDragging && document.elementFromPoint(event.clientX, event.clientY)
85
+
86
+
if (node && node != enteredElement) {
87
+
enteredElement && enteredElement.dispatchEvent(mousePointerEvent("pointerleave", event))
88
+
node.dispatchEvent(mousePointerEvent("pointerenter", event))
89
+
enteredElement = node
90
+
}
91
+
})
92
+
}
93
+
94
+
95
+
// Simulate `pointerenter` and `pointerleave` event for touch devices
96
+
document.body.addEventListener("touchmove", event => {
97
+
const section = document.body.querySelector("section")
98
+
const isDragging = section && section.classList.contains("dragging-something")
99
+
const touch = event.touches[0]
100
+
101
+
let node
102
+
103
+
if (isDragging && touch) {
104
+
node = document.elementFromPoint(touch.clientX, touch.clientY)
105
+
}
106
+
107
+
if (node && node != enteredElement) {
108
+
enteredElement && enteredElement.dispatchEvent(touchPointerEvent("pointerleave", event, touch))
109
+
node.dispatchEvent(touchPointerEvent("pointerenter", event, touch))
110
+
enteredElement = node
111
+
}
112
+
113
+
if (isDragging) {
114
+
event.stopPropagation()
115
+
}
116
+
})
+112
src/Javascript/UI/service-worker.ts
+112
src/Javascript/UI/service-worker.ts
···
1
+
import type { App } from "./elm/types"
2
+
3
+
4
+
/**
5
+
* Load:
6
+
*
7
+
* 1. Redirect to HTTPS if using the `diffuse.sh` domain (subdomains included).
8
+
* 2. Fail if not a secure context.
9
+
* 3. Set up service worker, ensure it's ready and then continue initialisation.
10
+
*/
11
+
export async function load({ isNativeWrapper } : { isNativeWrapper: boolean }): Promise<ServiceWorkerRegistration> {
12
+
return new Promise((resolve, reject) => {
13
+
if (location.hostname.endsWith("diffuse.sh") && location.protocol === "http:") {
14
+
location.href = location.href.replace("http://", "https://")
15
+
reject("Just a moment, redirecting to HTTPS.")
16
+
17
+
} else if (!self.isSecureContext) {
18
+
reject(`
19
+
This app only works on a <a class="underline" target="_blank" href="https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#When_is_a_context_considered_secure">secure context</a>, HTTPS & localhost, and modern browsers.
20
+
`)
21
+
22
+
} else if ("serviceWorker" in navigator) {
23
+
// Service worker
24
+
window.addEventListener("load", () => {
25
+
navigator.serviceWorker
26
+
.getRegistrations()
27
+
.then(async registrations => {
28
+
const serverIsOnline = navigator.onLine && await fetch(`${location.origin}?ping=1`)
29
+
.then(r => r.text())
30
+
.then(a => a === "false" ? false : true)
31
+
32
+
if (isNativeWrapper) await Promise.all(
33
+
registrations.map(r => r.unregister())
34
+
)
35
+
36
+
if (serverIsOnline) return navigator.serviceWorker.register(
37
+
"service-worker.js",
38
+
{ type: "module" }
39
+
)
40
+
41
+
if (registrations[0]) return registrations
42
+
43
+
throw new Error("Web server is offline")
44
+
})
45
+
.then(() => navigator.serviceWorker.ready)
46
+
.then(resolve)
47
+
.catch(err => {
48
+
const isFirefox = navigator.userAgent.toLowerCase().includes("firefox")
49
+
50
+
console.error(err)
51
+
return reject(
52
+
location.protocol === "https:" || location.hostname === "localhost"
53
+
? "Failed to start the service worker." + (isFirefox ? " Make sure the setting <strong>Delete cookies and site data when Firefox is closed</strong> is off, or Diffuse's domain is added as an exception." : "")
54
+
: "Failed to start the service worker, try using HTTPS."
55
+
)
56
+
})
57
+
})
58
+
59
+
}
60
+
})
61
+
}
62
+
63
+
64
+
/**
65
+
* Link.
66
+
*/
67
+
export function link(
68
+
{ app, isNativeWrapper, reg } : { app: App, isNativeWrapper: boolean, reg: ServiceWorkerRegistration }
69
+
) {
70
+
if (reg.installing) console.log("🧑✈️ Service worker is installing")
71
+
const initialInstall = reg.installing
72
+
73
+
initialInstall?.addEventListener("statechange", function() {
74
+
if (this.state === "activated") {
75
+
console.log("🧑✈️ Service worker is activated")
76
+
app.ports.installedNewServiceWorker.send(null)
77
+
}
78
+
})
79
+
80
+
if (reg.waiting) {
81
+
console.log("🧑✈️ A new version of Diffuse is available")
82
+
app.ports.installingNewServiceWorker.send(null)
83
+
app.ports.installedNewServiceWorker.send(null)
84
+
}
85
+
86
+
if (initialInstall?.state === "activated") {
87
+
console.log("🧑✈️ Service worker is activated")
88
+
app.ports.installedNewServiceWorker.send(null)
89
+
}
90
+
91
+
reg.addEventListener("updatefound", () => {
92
+
const newWorker = reg.installing
93
+
if (!newWorker) return
94
+
95
+
// No worker was installed yet, so we'll only want to track the state changes
96
+
if (newWorker !== initialInstall) {
97
+
console.log("🧑✈️ A new version of Diffuse is available")
98
+
app.ports.installingNewServiceWorker.send(null)
99
+
}
100
+
101
+
newWorker.addEventListener("statechange", (e: any) => {
102
+
console.log("🧑✈️ Service worker is", e.target.state)
103
+
if (e.target.state === "installed") app.ports.installedNewServiceWorker.send(null)
104
+
})
105
+
})
106
+
107
+
// Check for service worker updates and every hour after that
108
+
if (!isNativeWrapper && navigator.onLine) {
109
+
reg.update()
110
+
setInterval(() => reg.update(), 1 * 1000 * 60 * 60)
111
+
}
112
+
}
+50
src/Javascript/UI/tracks.ts
+50
src/Javascript/UI/tracks.ts
···
1
+
import type { App } from "./elm/types"
2
+
import { fileExtension } from "../common"
3
+
import { transformUrl } from "../urls"
4
+
5
+
6
+
// 🏔️
7
+
8
+
9
+
let app: App
10
+
11
+
12
+
13
+
// 🚀
14
+
15
+
16
+
export function init(a: App) {
17
+
app = a
18
+
}
19
+
20
+
21
+
22
+
// 🛠️
23
+
24
+
25
+
export async function download(group) {
26
+
const { saveAs } = await import("file-saver").then(a => a.default)
27
+
const JSZip = await import("jszip").then(a => a.default)
28
+
29
+
const zip = new JSZip()
30
+
const folder = zip.folder("Diffuse - " + group.name)
31
+
if (!folder) throw new Error("Failed to create ZIP file")
32
+
33
+
return group.tracks
34
+
.reduce((acc, track) => {
35
+
return acc
36
+
.then(() => transformUrl(track.url, app))
37
+
.then(fetch)
38
+
.then((r: Response) => {
39
+
const mimeType = r.headers.get("content-type")
40
+
const fileExt = (mimeType ? fileExtension(mimeType) : null) || "unknown"
41
+
42
+
return r.blob().then((b: Blob) => folder.file(track.filename + "." + fileExt, b))
43
+
})
44
+
}, Promise.resolve())
45
+
.then(() => zip.generateAsync({ type: "blob" }))
46
+
.then((zipFile: Blob) => {
47
+
saveAs(zipFile, "Diffuse - " + group.name + ".zip")
48
+
app.ports.downloadTracksFinished.send(null)
49
+
})
50
+
}
+76
src/Javascript/UI/user-layer.ts
+76
src/Javascript/UI/user-layer.ts
···
1
+
import type { Program as OddProgram } from "@oddjs/odd"
2
+
import type { App } from "./elm/types.js"
3
+
4
+
import { ODD_CONFIG } from "../common"
5
+
6
+
7
+
// 🏔️
8
+
9
+
10
+
let app: App
11
+
let odd
12
+
13
+
14
+
15
+
// 🚀
16
+
17
+
18
+
export function init(a: App) {
19
+
app = a
20
+
21
+
app.ports.authenticateWithFission.subscribe(async () => {
22
+
const program = await oddProgram()
23
+
await program.capabilities.request({
24
+
returnUrl: location.origin + "?action=authenticate/fission"
25
+
})
26
+
})
27
+
28
+
app.ports.collectFissionCapabilities.subscribe(() => {
29
+
// The ODD SDK should collect the capabilities for us,
30
+
// if everything is valid, we'll receive a session.
31
+
oddProgram().then(
32
+
() => {
33
+
history.replaceState({}, "", location.origin)
34
+
app.ports.collectedFissionCapabilities.send(null)
35
+
}
36
+
).catch(
37
+
err => console.error(err)
38
+
)
39
+
})
40
+
}
41
+
42
+
43
+
44
+
// Fission ~ ODD
45
+
// -------------
46
+
47
+
48
+
async function oddProgram(): Promise<OddProgram> {
49
+
try {
50
+
await loadOdd()
51
+
} catch (err) {
52
+
console.trace(err)
53
+
throw new Error("Failed to load the ODD SDK")
54
+
}
55
+
56
+
const capComponent = await import("../Odd/components/capabilities.js")
57
+
58
+
const crypto = await odd.defaultCryptoComponent(ODD_CONFIG)
59
+
const storage = await odd.defaultStorageComponent(ODD_CONFIG)
60
+
const depot = await odd.defaultDepotComponent({ storage }, ODD_CONFIG)
61
+
62
+
return odd.program({
63
+
...ODD_CONFIG,
64
+
capabilities: capComponent.implementation({
65
+
crypto,
66
+
depot
67
+
}),
68
+
fileSystem: { loadImmediately: false }
69
+
})
70
+
}
71
+
72
+
73
+
async function loadOdd() {
74
+
if (odd) return
75
+
odd = await import("@oddjs/odd")
76
+
}
+12
-12
src/Javascript/Workers/search.ts
+12
-12
src/Javascript/Workers/search.ts
···
17
17
)
18
18
19
19
20
-
let index
20
+
let index: lunr.Index
21
21
22
22
23
23
24
24
// Incoming messages
25
25
// -----------------
26
26
27
-
self.onmessage = event => {
27
+
self.onmessage = (event: MessageEvent) => {
28
28
switch (event.data.action) {
29
29
case "PERFORM_SEARCH":
30
30
performSearch(event.data.data)
···
53
53
// Actions
54
54
// -------
55
55
56
-
function performSearch(rawSearchTerm) {
57
-
let results =
56
+
function performSearch(rawSearchTerm: string) {
57
+
let results: string[] =
58
58
[]
59
59
60
60
const searchTerm = rawSearchTerm
···
62
62
.replace(/\+\s+/g, "+")
63
63
.split(/ +/)
64
64
.reduce(
65
-
([ acc, previousOperator, previousPrefix ], chunk) => {
65
+
([ acc, previousOperator, previousPrefix ]: [ string[], string, string ], chunk: string): [ string[], string, string ] => {
66
66
const operator = (a => a && a[0])( chunk.match(/^(\+|-)/) )
67
67
68
68
let chunkWithoutOperator = chunk.replace(/^(\+|-)/, "").replace(/\*$/, "").trim()
···
123
123
}
124
124
125
125
126
-
function updateSearchIndex(input) {
126
+
function updateSearchIndex(input: string | object[]) {
127
127
const tracks = (typeof input == "string")
128
128
? JSON.parse(input)
129
129
: input
130
130
131
-
index = customLunr(function() {
131
+
index = customLunr((builder: lunr.Builder) => {
132
132
FIELDS.forEach(
133
-
field => this.field(field)
133
+
field => builder.field(field)
134
134
)
135
135
136
136
;(tracks || [])
137
137
.map(mapTrack)
138
-
.forEach(t => this.add(t))
138
+
.forEach(t => builder.add(t))
139
139
})
140
140
}
141
141
142
142
143
143
144
-
function customLunr(config) {
144
+
function customLunr(fn: (b: lunr.Builder) => void) {
145
145
const builder = new lunr.Builder
146
146
147
147
builder.pipeline.add(removeParenthesesFromToken, lunr.stemmer)
148
148
builder.searchPipeline.add(removeParenthesesFromToken, lunr.stemmer)
149
149
150
-
config.call(builder, builder)
150
+
fn(builder)
151
151
return builder.build()
152
152
}
153
153
154
154
155
-
function removeParenthesesFromToken(token) {
155
+
function removeParenthesesFromToken(token: lunr.Token): lunr.Token {
156
156
return token.update(s => s.replace(/\(|\)/, ""))
157
157
}
+6
-8
src/Javascript/Workers/service.ts
+6
-8
src/Javascript/Workers/service.ts
···
9
9
//
10
10
/// <reference lib="webworker" />
11
11
12
-
import { } from "../index.d"
13
-
14
12
15
13
const KEY =
16
14
/* eslint-disable no-undef */
···
19
17
20
18
const EXCLUDE =
21
19
[ "_headers"
22
-
, "_redirects"
23
-
, "CORS"
20
+
, "_redirects"
21
+
, "CORS"
24
22
]
25
23
26
24
···
39
37
// 📣
40
38
41
39
42
-
self.addEventListener("activate", _event => {
40
+
self.addEventListener("activate", () => {
43
41
// Remove all caches except the one with the currently used `KEY`
44
42
caches.keys().then(keys => {
45
43
keys.forEach(k => {
···
82
80
})()
83
81
)
84
82
85
-
// When doing a request with basic authentication in the url, put it in the headers instead
83
+
// When doing a request with basic authentication in the url, put it in the headers instead
86
84
} else if (event.request.url.includes("service_worker_authentication=")) {
87
85
const url = new URL(event.request.url)
88
86
const token = url.searchParams.get("service_worker_authentication")
···
96
94
"Basic " + token
97
95
)
98
96
99
-
// When doing a request with access token in the url, put it in the headers instead
97
+
// When doing a request with access token in the url, put it in the headers instead
100
98
} else if (event.request.url.includes("bearer_token=")) {
101
99
const url = new URL(event.request.url)
102
100
const token = url.searchParams.get("bearer_token")
···
112
110
"Bearer " + token
113
111
)
114
112
115
-
// Use cache if internal request and not using native app
113
+
// Use cache if internal request and not using native app
116
114
} else if (isInternal) {
117
115
event.respondWith(
118
116
isNativeWrapper
-569
src/Javascript/audio-engine.ts
-569
src/Javascript/audio-engine.ts
···
1
-
//
2
-
// Audio engine
3
-
// ♪(´ε` )
4
-
//
5
-
// Creates audio elements and interacts with the Web Audio API.
6
-
7
-
8
-
import { throttle } from "throttle-debounce"
9
-
import Timer from "timer.js"
10
-
11
-
import { db } from "./common"
12
-
import { transformUrl } from "./urls"
13
-
import { mimeType } from "./common"
14
-
15
-
16
-
// ⛩
17
-
18
-
19
-
const IS_SAFARI = !!navigator.platform.match(/iPhone|iPod|iPad/) ||
20
-
navigator.vendor === "Apple Computer, Inc."
21
-
22
-
23
-
24
-
// Container for <audio> elements
25
-
// ------------------------------
26
-
27
-
const audioElementsContainer: HTMLElement = (() => {
28
-
let c
29
-
let styles =
30
-
[ "height: 0"
31
-
, "width: 0"
32
-
, "visibility: hidden"
33
-
, "pointer-events: none"
34
-
]
35
-
36
-
c = document.createElement("div")
37
-
c.setAttribute("class", "absolute left-0 top-0")
38
-
c.setAttribute("style", styles.join("; "))
39
-
40
-
return c
41
-
})()
42
-
43
-
44
-
function addAudioContainer() {
45
-
document.body.appendChild(audioElementsContainer)
46
-
}
47
-
48
-
49
-
50
-
// Setup
51
-
// -----
52
-
53
-
const silentMp3File = "data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU2LjM2LjEwMAAAAAAAAAAAAAAA//OEAAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8AAAAEAAABIADAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV6urq6urq6urq6urq6urq6urq6urq6urq6v////////////////////////////////8AAAAATGF2YzU2LjQxAAAAAAAAAAAAAAAAJAAAAAAAAAAAASDs90hvAAAAAAAAAAAAAAAAAAAA//MUZAAAAAGkAAAAAAAAA0gAAAAATEFN//MUZAMAAAGkAAAAAAAAA0gAAAAARTMu//MUZAYAAAGkAAAAAAAAA0gAAAAAOTku//MUZAkAAAGkAAAAAAAAA0gAAAAANVVV"
54
-
55
-
56
-
export function setup(orchestrion) {
57
-
addAudioContainer()
58
-
}
59
-
60
-
61
-
62
-
// EQ
63
-
// --
64
-
65
-
let volume = 0.5
66
-
67
-
export function adjustEqualizerSetting(orchestrion, knobType, value) {
68
-
switch (knobType) {
69
-
case "VOLUME":
70
-
volume = value
71
-
if (orchestrion.audio) orchestrion.audio.volume = value
72
-
break;
73
-
}
74
-
}
75
-
76
-
77
-
78
-
// Playback
79
-
// --------
80
-
81
-
export function insertTrack(orchestrion, queueItem, maybeArtwork = null) {
82
-
if (queueItem.url == undefined) console.error("insertTrack, missing `url`");
83
-
if (queueItem.trackId == undefined) console.error("insertTrack, missing `trackId`");
84
-
85
-
// reset
86
-
orchestrion.app.ports.setAudioHasStalled.send(false)
87
-
orchestrion.app.ports.setAudioPosition.send(0)
88
-
clearTimeout(orchestrion.unstallTimeout)
89
-
timesStalled = 1
90
-
91
-
// metadata
92
-
setMediaSessionMetadata(queueItem, maybeArtwork)
93
-
94
-
// initial promise
95
-
const initialPromise = queueItem.isCached
96
-
? db("tracks").getItem(queueItem.trackId).then(blobUrl)
97
-
: transformUrl(queueItem.url, orchestrion.app)
98
-
99
-
// find or create audio node
100
-
let audioNode
101
-
102
-
return initialPromise.then(url => {
103
-
queueItem.url = url
104
-
audioNode = audioElementsContainer.querySelector("audio")
105
-
106
-
if (audioNode = findExistingAudioElement(queueItem)) {
107
-
audioNode.setAttribute("data-preload", "f")
108
-
audioNode.setAttribute("data-timestamp", Date.now())
109
-
audioNode.volume = 1
110
-
111
-
if (audioNode.readyState >= 4) {
112
-
playAudio(audioNode, queueItem, orchestrion.app)
113
-
} else {
114
-
orchestrion.app.ports.setAudioIsLoading.send(true)
115
-
audioNode.load()
116
-
}
117
-
118
-
} else {
119
-
audioNode = createAudioElement(orchestrion, queueItem, Date.now(), false)
120
-
121
-
}
122
-
123
-
audioNode.volume = volume
124
-
orchestrion.audio = audioNode
125
-
})
126
-
}
127
-
128
-
129
-
function findExistingAudioElement(queueItem) {
130
-
return audioElementsContainer.querySelector(`[rel="${queueItem.trackId}"]`)
131
-
}
132
-
133
-
134
-
function createAudioElement(orchestrion, queueItem, timestampInMilliseconds, isPreload) {
135
-
const bind = fn => event => {
136
-
const is = isActiveAudioElement(orchestrion, event.target)
137
-
if (is) fn.call(orchestrion, event)
138
-
}
139
-
140
-
const crossorigin = isCrossOrginUrl(queueItem.url) ? "use-credentials" : "anonymous"
141
-
142
-
const fileName = queueItem.trackPath.split("/").reverse()[ 0 ]
143
-
const fileExtMatch = fileName.match(/\.(\w+)$/)
144
-
const fileExt = fileExtMatch && fileExtMatch[ 1 ]
145
-
const mime = mimeType(fileExt)
146
-
147
-
const source = document.createElement("source")
148
-
if (mime) source.setAttribute("type", mime)
149
-
source.setAttribute("src", queueItem.url)
150
-
151
-
const audio = document.createElement("audio")
152
-
audio.setAttribute("crossorigin", crossorigin)
153
-
audio.setAttribute("data-preload", isPreload ? "t" : "f")
154
-
audio.setAttribute("data-timestamp", timestampInMilliseconds)
155
-
audio.setAttribute("preload", "auto")
156
-
audio.setAttribute("rel", queueItem.trackId)
157
-
audio.appendChild(source)
158
-
159
-
audio.crossOrigin = "anonymous"
160
-
audio.volume = isPreload ? 0 : 1
161
-
162
-
audio.addEventListener("canplay", bind(audioCanPlayEvent))
163
-
audio.addEventListener("ended", bind(audioEndEvent))
164
-
audio.addEventListener("error", bind(audioErrorEvent))
165
-
audio.addEventListener("loadstart", bind(audioLoading))
166
-
audio.addEventListener("loadeddata", bind(audioLoaded))
167
-
audio.addEventListener("pause", bind(audioPauseEvent))
168
-
audio.addEventListener("play", bind(audioPlayEvent))
169
-
audio.addEventListener("seeking", bind(audioLoading))
170
-
audio.addEventListener("seeked", bind(audioLoaded))
171
-
audio.addEventListener("timeupdate", bind(audioTimeUpdateEvent))
172
-
173
-
// Audio stalled event doesn't work well in Safari
174
-
if (!IS_SAFARI) {
175
-
audio.addEventListener("stalled", bind(audioStalledEvent))
176
-
}
177
-
178
-
audioElementsContainer.appendChild(audio)
179
-
audio.load()
180
-
181
-
return audio
182
-
}
183
-
184
-
185
-
export function preloadAudioElement(orchestrion, queueItem) {
186
-
// already loaded?
187
-
if (findExistingAudioElement(queueItem)) return
188
-
189
-
// remove other preloads
190
-
audioElementsContainer.querySelectorAll(`[data-preload="t"]`).forEach(
191
-
n => n.parentNode?.removeChild(n)
192
-
)
193
-
194
-
// audio element remains valid for 45 minutes
195
-
transformUrl(queueItem.url, orchestrion.app).then(url => {
196
-
const queueItemWithTransformedUrl =
197
-
Object.assign({}, queueItem, { url: url })
198
-
199
-
createAudioElement(
200
-
orchestrion,
201
-
queueItemWithTransformedUrl,
202
-
Date.now() + 1000 * 60 * 45,
203
-
true
204
-
)
205
-
})
206
-
}
207
-
208
-
209
-
export function playAudio(element, queueItem, app) {
210
-
if (queueItem.progress && element.duration) {
211
-
element.currentTime = queueItem.progress * element.duration
212
-
}
213
-
214
-
const promise = element.play() || Promise.resolve()
215
-
216
-
promise.catch(e => {
217
-
const err = "Couldn't play audio automatically. Please resume playback manually."
218
-
console.error(err, e)
219
-
if (app) app.ports.fromAlien.send({ tag: "", data: null, error: err })
220
-
})
221
-
}
222
-
223
-
224
-
export function seek(orchestrion, percentage) {
225
-
const audio = orchestrion.audio
226
-
if (audio && !isNaN(audio.duration)) {
227
-
if (audio.paused) playAudio(audio, orchestrion.activeQueueItem, orchestrion.app)
228
-
audio.currentTime = audio.duration * percentage
229
-
}
230
-
}
231
-
232
-
233
-
export function isCrossOrginUrl(url) {
234
-
return url.includes("service_worker_authentication")
235
-
}
236
-
237
-
238
-
239
-
// Audio events
240
-
// ------------
241
-
242
-
let showedNoNetworkError = false
243
-
let timesStalled = 1
244
-
245
-
246
-
function audioErrorEvent(event) {
247
-
this.app.ports.setAudioIsPlaying.send(false)
248
-
249
-
switch (event.target.error.code) {
250
-
case event.target.error.MEDIA_ERR_ABORTED:
251
-
console.error("You aborted the audio playback.")
252
-
break
253
-
case event.target.error.MEDIA_ERR_NETWORK:
254
-
console.error("A network error caused the audio download to fail.")
255
-
showNetworkErrorNotification.call(this)
256
-
audioStalledEvent.call(this, event)
257
-
break
258
-
case event.target.error.MEDIA_ERR_DECODE:
259
-
console.error("The audio playback was aborted due to a corruption problem or because the video used features your browser did not support.")
260
-
break
261
-
case event.target.error.MEDIA_ERR_SRC_NOT_SUPPORTED:
262
-
console.error("The audio not be loaded, either because the server or network failed or because the format is not supported.")
263
-
if (event.target.currentTime && event.target.currentTime > 0) {
264
-
showNetworkErrorNotification.call(this)
265
-
audioStalledEvent.call(this, event)
266
-
} else if (navigator.onLine) {
267
-
showUnsupportedSrcErrorNotification.call(this)
268
-
clearTimeout(this.loadingTimeoutId)
269
-
this.app.ports.setAudioIsLoading.send(false)
270
-
} else {
271
-
showNetworkErrorNotification.call(this)
272
-
audioStalledEvent.call(this, event)
273
-
}
274
-
break
275
-
default:
276
-
console.error("An unknown error occurred.")
277
-
}
278
-
}
279
-
280
-
281
-
function showNetworkErrorNotification() {
282
-
if (showedNoNetworkError) return
283
-
showedNoNetworkError = true
284
-
this.app.ports.showErrorNotification.send(
285
-
navigator.onLine
286
-
? "I can't play this track because of a network error. I'll try to reconnect."
287
-
: "I can't play this track because we're offline. I'll try to reconnect."
288
-
)
289
-
}
290
-
291
-
292
-
function showUnsupportedSrcErrorNotification() {
293
-
this.app.ports.showErrorNotification.send(
294
-
"__I can't play this track because your browser didn't recognize it.__ Try checking your developer console for a warning to find out why."
295
-
)
296
-
}
297
-
298
-
299
-
function audioStalledEvent(event, notifyAppImmediately) {
300
-
this.app.ports.setAudioIsLoading.send(true)
301
-
clearTimeout(this.unstallTimeout)
302
-
303
-
// Notify app
304
-
if (timesStalled >= 3 || notifyAppImmediately) {
305
-
this.app.ports.setAudioHasStalled.send(true)
306
-
}
307
-
308
-
// Timeout
309
-
this.unstallTimeout = setTimeout(_ => {
310
-
if (isActiveAudioElement(this, event.target)) {
311
-
unstallAudio.call(this, event.target)
312
-
}
313
-
}, timesStalled * 2500)
314
-
315
-
// Increase counter
316
-
timesStalled++
317
-
}
318
-
319
-
320
-
function audioTimeUpdateEvent(event) {
321
-
const node = event.target
322
-
323
-
if (
324
-
isNaN(node.duration) ||
325
-
isNaN(node.currentTime) ||
326
-
node.duration === 0
327
-
) return;
328
-
329
-
setDurationIfNecessary.call(this, node)
330
-
this.app.ports.setAudioPosition.send(node.currentTime)
331
-
332
-
if (navigator.mediaSession && navigator.mediaSession.setPositionState) {
333
-
try {
334
-
navigator.mediaSession.setPositionState({
335
-
duration: node.duration,
336
-
position: node.currentTime
337
-
})
338
-
} catch (_err) { }
339
-
}
340
-
341
-
const progress = node.currentTime / node.duration
342
-
343
-
if (node.duration >= 30 * 60) {
344
-
sendProgress(this, progress)
345
-
}
346
-
}
347
-
348
-
349
-
function audioEndEvent(event) {
350
-
if (this.repeat) {
351
-
event.target.startedPlayingAt = Math.floor(Date.now() / 1000)
352
-
if (this.scrobbleTimer) this.scrobbleTimer.stop()
353
-
playAudio(event.target, this.activeQueueItem, this.app)
354
-
} else {
355
-
this.app.ports.noteProgress.send({ trackId: this.activeQueueItem.trackId, progress: 1 })
356
-
this.app.ports.activeQueueItemEnded.send(null)
357
-
}
358
-
}
359
-
360
-
361
-
function audioLoading(event) {
362
-
clearTimeout(this.loadingTimeoutId)
363
-
364
-
this.loadingTimeoutId = setTimeout(() => {
365
-
const audio = event.target
366
-
367
-
if (!audio || !isActiveAudioElement(this, audio)) {
368
-
return
369
-
} else if (audio.readyState === 4 && audio.currentTime === 0) {
370
-
this.app.ports.setAudioIsLoading.send(false)
371
-
} else if (audio.readyState < 3 && IS_SAFARI) {
372
-
this.app.ports.setAudioIsLoading.send(true)
373
-
this.unstallTimeout = setTimeout(
374
-
() => {
375
-
if (isActiveAudioElement(this, audio)) {
376
-
unstallSafariAudio.call(this, audio)
377
-
}
378
-
},
379
-
timesStalled * 2500
380
-
)
381
-
} else {
382
-
this.app.ports.setAudioIsLoading.send(true)
383
-
}
384
-
}, 1750)
385
-
}
386
-
387
-
388
-
function audioLoaded(event) {
389
-
clearTimeout(this.loadingTimeoutId)
390
-
clearTimeout(this.unstallTimeout)
391
-
this.app.ports.setAudioHasStalled.send(false)
392
-
this.app.ports.setAudioIsLoading.send(false)
393
-
if (event.target.paused && (event.type === "seeked" || !event.target.hasPlayed)) {
394
-
playAudio(event.target, this.activeQueueItem, this.app)
395
-
}
396
-
}
397
-
398
-
399
-
function audioPlayEvent(event) {
400
-
event.target.hasPlayed = true
401
-
this.app.ports.setAudioIsPlaying.send(true)
402
-
if (navigator.mediaSession) navigator.mediaSession.playbackState = "playing"
403
-
if (this.scrobbleTimer) this.scrobbleTimer.start()
404
-
}
405
-
406
-
407
-
function audioPauseEvent(event) {
408
-
this.app.ports.setAudioIsPlaying.send(false)
409
-
if (navigator.mediaSession) navigator.mediaSession.playbackState = "paused"
410
-
if (this.scrobbleTimer) this.scrobbleTimer.pause()
411
-
}
412
-
413
-
414
-
function audioCanPlayEvent(event) {
415
-
showedNoNetworkError = false
416
-
setDurationIfNecessary.call(this, event.target)
417
-
}
418
-
419
-
420
-
421
-
// 🖍 Utensils
422
-
// -----------
423
-
424
-
function audioElementTrackId(node) {
425
-
return node ? node.getAttribute("rel") : undefined
426
-
}
427
-
428
-
429
-
function blobUrl(blob) {
430
-
return URL.createObjectURL(blob)
431
-
}
432
-
433
-
434
-
function isActiveAudioElement(orchestrion, node) {
435
-
const isActive = (
436
-
!orchestrion.activeQueueItem ||
437
-
!node ||
438
-
node.getAttribute("data-preload") === "t" ||
439
-
node.getAttribute("data-deactivated") === "t"
440
-
)
441
-
? false
442
-
: orchestrion.activeQueueItem.trackId === audioElementTrackId(node);
443
-
444
-
return isActive
445
-
}
446
-
447
-
448
-
const sendProgress = throttle(30000, (orchestrion, progress) => {
449
-
orchestrion.app.ports.noteProgress.send({
450
-
trackId: orchestrion.activeQueueItem.trackId,
451
-
progress: progress
452
-
})
453
-
}, {
454
-
noLeading: false,
455
-
noTrailing: false
456
-
})
457
-
458
-
459
-
let lastSetDuration = 0
460
-
461
-
462
-
function setDurationIfNecessary(audio) {
463
-
if (audio.duration === lastSetDuration) return;
464
-
465
-
this.app.ports.setAudioDuration.send(audio.duration || 0)
466
-
lastSetDuration = audio.duration
467
-
468
-
// Scrobble
469
-
if (!lastSetDuration || lastSetDuration < 30) return;
470
-
471
-
const timestamp = Math.floor(Date.now() / 1000)
472
-
const scrobbleTimeoutDuration = Math.min(240 + 0.5, lastSetDuration / 1.95)
473
-
const trackId = audio.getAttribute("rel")
474
-
475
-
audio.startedPlayingAt = timestamp
476
-
477
-
this.scrobbleTimer = new Timer({
478
-
onend: _ => this.app.ports.scrobble.send({
479
-
duration: Math.round(lastSetDuration),
480
-
timestamp: audio.startedPlayingAt || timestamp,
481
-
trackId: trackId
482
-
})
483
-
})
484
-
485
-
this.scrobbleTimer.start(scrobbleTimeoutDuration)
486
-
}
487
-
488
-
489
-
export function setMediaSessionMetadata(queueItem, maybeArtwork) {
490
-
if ("mediaSession" in navigator === false || !queueItem.trackTags) return
491
-
492
-
let artwork: MediaImage[] = []
493
-
494
-
if (maybeArtwork && typeof maybeArtwork !== "string") {
495
-
artwork = [ {
496
-
src: URL.createObjectURL(maybeArtwork),
497
-
type: maybeArtwork.type
498
-
} ]
499
-
}
500
-
501
-
navigator.mediaSession.metadata = new MediaMetadata({
502
-
title: queueItem.trackTags.title,
503
-
artist: queueItem.trackTags.artist,
504
-
album: queueItem.trackTags.album,
505
-
artwork: artwork
506
-
})
507
-
}
508
-
509
-
510
-
function unstallAudio(node: HTMLAudioElement) {
511
-
const time = node.currentTime
512
-
513
-
node.load()
514
-
node.currentTime = time
515
-
516
-
if (timesStalled > 5 && !showedNoNetworkError && navigator.onLine) {
517
-
this.app.ports.showStickyErrorNotification.send(
518
-
"You loaded too many tracks too quickly, " +
519
-
"which the browser can't handle. " +
520
-
"You'll most likely have to reload the browser."
521
-
)
522
-
}
523
-
}
524
-
525
-
526
-
function unstallSafariAudio(node: HTMLAudioElement) {
527
-
timesStalled++
528
-
529
-
// Deactivate
530
-
node.setAttribute("data-deactivated", "t")
531
-
532
-
// Force browser to stop loading
533
-
try { node.src = silentMp3File } catch (_err) { }
534
-
535
-
// Remove element
536
-
audioElementsContainer.removeChild(node)
537
-
538
-
// Create new element
539
-
createAudioElement(this, this.activeQueueItem, Date.now() + 1000 * 60 * 45, false)
540
-
}
541
-
542
-
543
-
544
-
// 💥
545
-
// --
546
-
// Remove all the audio elements with a timestamp older than the given one.
547
-
548
-
export function removeOlderAudioElements(timestamp) {
549
-
const nodes: NodeListOf<HTMLAudioElement> = audioElementsContainer.querySelectorAll(
550
-
"audio[data-timestamp]"
551
-
)
552
-
553
-
nodes.forEach(node => {
554
-
const tAttr = node.getAttribute("data-timestamp")
555
-
if (!tAttr) return
556
-
557
-
const t = parseInt(tAttr, 10)
558
-
if (t >= timestamp) return
559
-
560
-
// Deactivate
561
-
node.setAttribute("data-deactivated", "t")
562
-
563
-
// Force browser to stop loading
564
-
try { node.src = silentMp3File } catch (_err) { }
565
-
566
-
// Remove element
567
-
audioElementsContainer.removeChild(node)
568
-
})
569
-
}
+13
src/Javascript/common.ts
+13
src/Javascript/common.ts
+18
-22
src/Javascript/crypto.ts
+18
-22
src/Javascript/crypto.ts
···
11
11
const extractable = false
12
12
13
13
14
-
export function keyFromPassphrase(passphrase) {
15
-
return crypto.subtle.importKey(
14
+
export async function keyFromPassphrase(passphrase: string): Promise<CryptoKey> {
15
+
const baseKey = await crypto.subtle.importKey(
16
16
"raw",
17
17
Uint8arrays.fromString(passphrase, "utf8"),
18
18
{
···
20
20
},
21
21
false,
22
22
[ "deriveKey" ]
23
+
)
23
24
24
-
).then(baseKey => crypto.subtle.deriveKey(
25
+
return await crypto.subtle.deriveKey(
25
26
{
26
27
name: "PBKDF2",
27
28
salt: Uint8arrays.fromString("diffuse", "utf8"),
···
35
36
},
36
37
extractable,
37
38
[ "encrypt", "decrypt" ]
38
-
39
-
))
39
+
)
40
40
}
41
41
42
42
43
-
export function encrypt(key, string) {
44
-
let iv = crypto.getRandomValues(new Uint8Array(12))
43
+
export async function encrypt(key: CryptoKey, string: string): Promise<string> {
44
+
const iv = crypto.getRandomValues(new Uint8Array(12))
45
45
46
-
return crypto.subtle.encrypt(
46
+
const buf = await crypto.subtle.encrypt(
47
47
{
48
48
name: "AES-GCM",
49
49
iv: iv,
···
51
51
},
52
52
key,
53
53
Uint8arrays.fromString(string, "base64pad")
54
-
55
-
).then(buf => {
56
-
const iv_b64 = Uint8arrays.toString(iv, "base64pad")
57
-
const buf_b64 = Uint8arrays.toString(new Uint8Array(buf), "base64pad")
58
-
return iv_b64 + buf_b64
54
+
)
59
55
60
-
})
56
+
const iv_b64 = Uint8arrays.toString(iv, "base64pad")
57
+
const buf_b64 = Uint8arrays.toString(new Uint8Array(buf), "base64pad")
58
+
return iv_b64 + buf_b64
61
59
}
62
60
63
61
64
-
export function decrypt(key, string) {
62
+
export async function decrypt(key: CryptoKey, string: string): Promise<string> {
65
63
const iv_b64 = string.substring(0, 16)
66
64
const buf_b64 = string.substring(16)
67
65
68
66
const iv = Uint8arrays.fromString(iv_b64, "base64pad")
69
67
const buf = Uint8arrays.fromString(buf_b64, "base64pad")
70
68
71
-
return crypto.subtle.decrypt(
69
+
const decrypted = await crypto.subtle.decrypt(
72
70
{
73
71
name: "AES-GCM",
74
72
iv: iv,
···
76
74
},
77
75
key,
78
76
buf
79
-
80
-
).then(
81
-
buffer => Uint8arrays.toString(
82
-
new Uint8Array(buffer),
83
-
"utf8"
84
-
)
77
+
)
85
78
79
+
return Uint8arrays.toString(
80
+
new Uint8Array(decrypted),
81
+
"utf8"
86
82
)
87
83
}
-8
src/Javascript/index.d.ts
-8
src/Javascript/index.d.ts
-970
src/Javascript/index.ts
-970
src/Javascript/index.ts
···
1
-
//
2
-
// | (• ◡•)| (❍ᴥ❍ʋ)
3
-
//
4
-
// The bit where we launch the Elm app,
5
-
// and connect the other bits to it.
6
-
7
-
import "tocca"
8
-
9
-
import type { Program as OddProgram } from "@oddjs/odd"
10
-
import type { } from "./index.d"
11
-
12
-
import { debounce } from "throttle-debounce"
13
-
14
-
import * as audioEngine from "./audio-engine"
15
-
import { db, fileExtension, ODD_CONFIG } from "./common"
16
-
import { transformUrl } from "./urls"
17
-
import { version } from "../../package.json"
18
-
19
-
20
-
21
-
// 🌸
22
-
23
-
24
-
const isNativeWrapper = !!globalThis.__TAURI__
25
-
26
-
27
-
28
-
// 🔐
29
-
30
-
31
-
// Redirect to HTTPS if using the `diffuse.sh` domain (subdomains included)
32
-
if (location.hostname.endsWith("diffuse.sh") && location.protocol === "http:") {
33
-
location.href = location.href.replace("http://", "https://")
34
-
failure("Just a moment, redirecting to HTTPS.")
35
-
36
-
// Not a secure context
37
-
} else if (!self.isSecureContext) {
38
-
failure(`
39
-
This app only works on a <a class="underline" target="_blank" href="https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#When_is_a_context_considered_secure">secure context</a>, HTTPS & localhost, and modern browsers.
40
-
`)
41
-
42
-
// Service worker
43
-
} else if ("serviceWorker" in navigator) {
44
-
window.addEventListener("load", () => {
45
-
navigator.serviceWorker
46
-
.getRegistrations()
47
-
.then(async registrations => {
48
-
const resp = await fetch(`${location.origin}?ping=1`).then(r => r.text()).then(a => a === "false" ? false : true)
49
-
const serverIsOnline = navigator.onLine && resp
50
-
51
-
if (isNativeWrapper) await Promise.all(
52
-
registrations.map(r => r.unregister())
53
-
)
54
-
55
-
return serverIsOnline
56
-
})
57
-
.then(async serverIsOnline => {
58
-
if (serverIsOnline) {
59
-
return navigator.serviceWorker.register(
60
-
"service-worker.js",
61
-
{ type: "module" }
62
-
)
63
-
}
64
-
})
65
-
.then(_ => {
66
-
return navigator.serviceWorker.ready
67
-
})
68
-
.catch(err => {
69
-
const isFirefox = navigator.userAgent.toLowerCase().includes("firefox")
70
-
71
-
console.error(err)
72
-
return failure(
73
-
location.protocol === "https:" || location.hostname === "localhost"
74
-
? "Failed to start the service worker." + (isFirefox ? " Make sure the setting <strong>Delete cookies and site data when Firefox is closed</strong> is off, or Diffuse's domain is added as an exception." : "")
75
-
: "Failed to start the service worker, try using HTTPS."
76
-
)
77
-
})
78
-
.then(initialise)
79
-
.catch(err => {
80
-
console.error(err)
81
-
return failure("<strong>Failed to start the application.</strong><br />See browser console for details.")
82
-
})
83
-
})
84
-
85
-
}
86
-
87
-
88
-
89
-
// 🍱
90
-
91
-
92
-
let app
93
-
let brain
94
-
let wire: any = {}
95
-
96
-
97
-
async function initialise(reg) {
98
-
brain = new Worker(
99
-
"./js/brain/index.js#appHref=" + encodeURIComponent(window.location.href),
100
-
{ type: "module" }
101
-
)
102
-
103
-
brain.addEventListener("error", err => {
104
-
failure("<strong>Failed to load web worker.</strong><br />If you're using Firefox, you might need to upgrade your browser (version 113 and up) and set `dom.workers.modules.enabled` to `true` in `about:config`")
105
-
})
106
-
107
-
await new Promise(resolve => {
108
-
brain.onmessage = event => {
109
-
if (event.data.action === "READY") resolve(null)
110
-
}
111
-
})
112
-
113
-
app = Elm.UI.init({
114
-
node: document.getElementById("elm"),
115
-
flags: {
116
-
buildTimestamp: BUILD_TIMESTAMP,
117
-
darkMode: preferredColorScheme().matches,
118
-
initialTime: Date.now(),
119
-
isInstallingServiceWorker: !!reg.installing,
120
-
isOnline: navigator.onLine,
121
-
isTauri: isNativeWrapper,
122
-
version,
123
-
viewport: {
124
-
height: window.innerHeight,
125
-
width: window.innerWidth
126
-
}
127
-
}
128
-
})
129
-
130
-
// ⚡️
131
-
wire.brain()
132
-
wire.audio()
133
-
wire.backdrop()
134
-
wire.broadcastChannel()
135
-
wire.clipboard()
136
-
wire.covers()
137
-
wire.serviceWorker(reg)
138
-
wire.odd()
139
-
140
-
// Other ports
141
-
app.ports.downloadJsonUsingTauri.subscribe(async (
142
-
{ filename, json }: { filename: string, json: string }
143
-
) => {
144
-
const { save } = await import("@tauri-apps/plugin-dialog")
145
-
const { writeTextFile } = await import("@tauri-apps/plugin-fs")
146
-
const { BaseDirectory } = await import("@tauri-apps/api/path")
147
-
148
-
const filePath = await save({ defaultPath: filename })
149
-
await writeTextFile(filePath || filename, json, { baseDir: BaseDirectory.Download })
150
-
})
151
-
152
-
app.ports.openUrlOnNewPage.subscribe((url: string) => {
153
-
if (globalThis.__TAURI__) {
154
-
globalThis.__TAURI__.shell.open(
155
-
url.includes("://") ? url : `${location.origin}/${url.replace(/^\.\//, "")}`
156
-
)
157
-
158
-
} else {
159
-
window.open(url, "_blank")
160
-
161
-
}
162
-
})
163
-
164
-
app.ports.reloadApp.subscribe(_ => {
165
-
let timeout = setTimeout(() => {
166
-
if (reg.waiting) reg.waiting.postMessage("skipWaiting")
167
-
window.location.reload()
168
-
}, 250)
169
-
170
-
bc.addEventListener("message", event => {
171
-
if (event.data === "PONG") {
172
-
clearTimeout(timeout)
173
-
alert("⚠️ You can only update the app when you have no more than one instance open.")
174
-
}
175
-
})
176
-
177
-
bc.postMessage("PING")
178
-
})
179
-
}
180
-
181
-
182
-
function failure(text: string) {
183
-
const note = document.createElement("div")
184
-
185
-
note.className = "flex flex-col font-body items-center h-screen italic justify-center leading-relaxed px-4 text-center text-base text-white"
186
-
note.innerHTML = `
187
-
<a class="block logo mb-5" href="../">
188
-
<img src="../images/diffuse-light.svg" />
189
-
</a>
190
-
191
-
<p class="max-w-sm opacity-60">
192
-
${text}
193
-
</p>
194
-
`
195
-
196
-
document.body.appendChild(note)
197
-
198
-
// Remove loader
199
-
const elm = document.querySelector("#elm")
200
-
elm?.parentNode?.removeChild(elm)
201
-
}
202
-
203
-
204
-
205
-
// Brain
206
-
// =====
207
-
208
-
wire.brain = () => {
209
-
brain.onmessage = event => {
210
-
if (event.data.action) return handleAction(event.data.action, event.data.data, event.ports)
211
-
if (event.data.tag) app.ports.fromAlien.send(event.data)
212
-
213
-
switch (event.data.tag) {
214
-
case "GOT_CACHED_COVER": return gotCachedCover(event.data.data)
215
-
}
216
-
}
217
-
218
-
app.ports.toBrain.subscribe(a => brain.postMessage(a))
219
-
}
220
-
221
-
222
-
function handleAction(action, data, _ports) {
223
-
switch (action) {
224
-
case "DOWNLOAD_TRACKS": return downloadTracks(data)
225
-
case "FINISHED_DOWNLOADING_ARTWORK": return finishedDownloadingArtwork()
226
-
}
227
-
}
228
-
229
-
230
-
231
-
// Audio
232
-
// -----
233
-
234
-
let orchestrion
235
-
236
-
237
-
wire.audio = () => {
238
-
orchestrion = {
239
-
activeQueueItem: null,
240
-
audio: null,
241
-
app: app,
242
-
repeat: false
243
-
}
244
-
245
-
audioEngine.setup(orchestrion)
246
-
247
-
app.ports.activeQueueItemChanged.subscribe(activeQueueItemChanged)
248
-
app.ports.adjustEqualizerSetting.subscribe(adjustEqualizerSetting)
249
-
app.ports.pause.subscribe(pause)
250
-
app.ports.play.subscribe(play)
251
-
app.ports.preloadAudio.subscribe(preloadAudio())
252
-
app.ports.seek.subscribe(seek)
253
-
app.ports.setRepeat.subscribe(setRepeat)
254
-
}
255
-
256
-
257
-
function activeQueueItemChanged(item) {
258
-
if (
259
-
orchestrion.activeQueueItem &&
260
-
orchestrion.audio &&
261
-
item &&
262
-
item.trackId === orchestrion.activeQueueItem.trackId
263
-
) {
264
-
orchestrion.audio.currentTime = 0
265
-
return
266
-
}
267
-
268
-
const timestampInMilliseconds = Date.now()
269
-
270
-
orchestrion.activeQueueItem = item
271
-
orchestrion.audio = null
272
-
orchestrion.coverPrep = null
273
-
274
-
// Reset scrobble timer
275
-
if (orchestrion.scrobbleTimer) {
276
-
orchestrion.scrobbleTimer.stop()
277
-
orchestrion.scrobbleTimer = null
278
-
}
279
-
280
-
// Remove older audio elements if possible
281
-
audioEngine.removeOlderAudioElements(timestampInMilliseconds)
282
-
283
-
// 🎵
284
-
if (item) {
285
-
const coverPrep = {
286
-
cacheKey: btoa(unescape(encodeURIComponent((item.trackTags.artist || "?") + " --- " + (item.trackTags.album || "?")))),
287
-
trackFilename: item.trackPath.split("/").reverse()[0],
288
-
trackPath: item.trackPath,
289
-
trackSourceId: item.sourceId,
290
-
variousArtists: "f"
291
-
}
292
-
293
-
albumCover(coverPrep.cacheKey).then(maybeCover => {
294
-
maybeCover = maybeCover === "TRIED" ? null : maybeCover
295
-
orchestrion.coverPrep = coverPrep
296
-
297
-
audioEngine.insertTrack(
298
-
orchestrion,
299
-
item,
300
-
maybeCover as any
301
-
302
-
).then(() => {
303
-
if (!maybeCover) {
304
-
if (!orchestrion.audio) return
305
-
orchestrion.audio.waitingForArtwork = coverPrep.cacheKey
306
-
loadAlbumCovers([coverPrep])
307
-
} else {
308
-
orchestrion.audio.waitingForArtwork = null
309
-
}
310
-
311
-
})
312
-
})
313
-
314
-
// ✋
315
-
} else {
316
-
app.ports.setAudioIsPlaying.send(false)
317
-
app.ports.setAudioPosition.send(0)
318
-
if (navigator.mediaSession) navigator.mediaSession.playbackState = "none"
319
-
320
-
}
321
-
}
322
-
323
-
324
-
function adjustEqualizerSetting(e) {
325
-
audioEngine.adjustEqualizerSetting(orchestrion, e.knob, e.value)
326
-
}
327
-
328
-
329
-
function pause(_) {
330
-
if (orchestrion.audio) orchestrion.audio.pause()
331
-
}
332
-
333
-
334
-
function play(_) {
335
-
if (orchestrion.audio) {
336
-
audioEngine.playAudio(orchestrion.audio, orchestrion.activeQueueItem, app)
337
-
}
338
-
}
339
-
340
-
341
-
function preloadAudio() {
342
-
if (navigator.onLine === false) return;
343
-
344
-
return debounce(15000, item => {
345
-
// Wait 15 seconds to preload something.
346
-
// This is particularly useful when quickly shifting through tracks,
347
-
// or when moving things around in the queue.
348
-
item.isCached
349
-
? false
350
-
: audioEngine.preloadAudioElement(orchestrion, item)
351
-
})
352
-
}
353
-
354
-
355
-
function seek(percentage) {
356
-
audioEngine.seek(orchestrion, percentage)
357
-
}
358
-
359
-
360
-
function setRepeat(repeat) {
361
-
orchestrion.repeat = repeat
362
-
}
363
-
364
-
365
-
366
-
// Backdrop
367
-
// --------
368
-
369
-
wire.backdrop = () => {
370
-
app.ports.pickAverageBackgroundColor.subscribe(pickAverageBackgroundColor)
371
-
}
372
-
373
-
374
-
function averageColorOfImage(img) {
375
-
const canvas = document.createElement("canvas")
376
-
const ctx = canvas.getContext("2d")
377
-
canvas.width = img.naturalWidth
378
-
canvas.height = img.naturalHeight
379
-
380
-
if (!ctx) return null
381
-
382
-
ctx.drawImage(img, 0, 0)
383
-
384
-
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
385
-
const color = { r: 0, g: 0, b: 0 }
386
-
387
-
for (let i = 0, l = imageData.data.length; i < l; i += 4) {
388
-
color.r += imageData.data[i]
389
-
color.g += imageData.data[i + 1]
390
-
color.b += imageData.data[i + 2]
391
-
}
392
-
393
-
color.r = Math.floor(color.r / (imageData.data.length / 4))
394
-
color.g = Math.floor(color.g / (imageData.data.length / 4))
395
-
color.b = Math.floor(color.b / (imageData.data.length / 4))
396
-
397
-
return color
398
-
}
399
-
400
-
401
-
function pickAverageBackgroundColor(src) {
402
-
const img = document.querySelector(`img[src$="${src}"]`)
403
-
404
-
if (img) {
405
-
const avgColor = averageColorOfImage(img)
406
-
app.ports.setAverageBackgroundColor.send(avgColor)
407
-
}
408
-
}
409
-
410
-
411
-
412
-
// Broadcast channel
413
-
// -----------------
414
-
415
-
let bc
416
-
417
-
wire.broadcastChannel = () => {
418
-
bc = new BroadcastChannel(`Diffuse-${location.hostname}`)
419
-
bc.addEventListener("message", event => {
420
-
switch (event.data) {
421
-
case "PING": return bc.postMessage("PONG")
422
-
}
423
-
})
424
-
}
425
-
426
-
427
-
428
-
// Clipboard
429
-
// ---------
430
-
431
-
wire.clipboard = () => {
432
-
app.ports.copyToClipboard.subscribe(async text => {
433
-
// TODO: Find a better solution for this
434
-
const adjustedText = (() => {
435
-
if (text.startsWith("dropbox://")) {
436
-
return transformUrl(text, app)
437
-
} else if (text.startsWith("google://")) {
438
-
return transformUrl(text, app)
439
-
} else {
440
-
return text
441
-
442
-
}
443
-
})()
444
-
445
-
navigator.clipboard.writeText(await adjustedText)
446
-
})
447
-
}
448
-
449
-
450
-
451
-
// Covers
452
-
// ------
453
-
454
-
wire.covers = () => {
455
-
app.ports.loadAlbumCovers.subscribe(
456
-
debounce(500, loadAlbumCoversFromDom)
457
-
)
458
-
459
-
db().keys().then(cachedCovers)
460
-
}
461
-
462
-
463
-
function albumCover(coverKey) {
464
-
return db().getItem(`coverCache.${coverKey}`)
465
-
}
466
-
467
-
468
-
function gotCachedCover({ key, url }) {
469
-
const item = orchestrion.activeQueueItem
470
-
471
-
if (item && orchestrion.coverPrep && key === orchestrion.coverPrep.key && url) {
472
-
let artwork = [{ src: url, type: undefined }]
473
-
474
-
if (typeof url !== "string") {
475
-
artwork = [{
476
-
src: URL.createObjectURL(url),
477
-
type: url.type
478
-
}]
479
-
}
480
-
481
-
navigator.mediaSession.metadata = new MediaMetadata({
482
-
title: item.trackTags.title,
483
-
artist: item.trackTags.artist,
484
-
album: item.trackTags.album,
485
-
artwork: artwork
486
-
})
487
-
}
488
-
}
489
-
490
-
491
-
function loadAlbumCoversFromDom({ coverView, list }) {
492
-
if (!navigator.onLine) return
493
-
494
-
let nodes: HTMLElement[] = []
495
-
496
-
if (list) nodes = nodes.concat(Array.from(
497
-
document.querySelectorAll("#diffuse__track-covers [data-key]")
498
-
))
499
-
500
-
if (coverView) nodes = nodes.concat(Array.from(
501
-
document.querySelectorAll("#diffuse__track-covers + div [data-key]")
502
-
))
503
-
504
-
if (!nodes.length) return;
505
-
506
-
const coverPrepList = nodes.map(node => ({
507
-
cacheKey: node.getAttribute("data-key"),
508
-
trackFilename: node.getAttribute("data-filename"),
509
-
trackPath: node.getAttribute("data-path"),
510
-
trackSourceId: node.getAttribute("data-source-id"),
511
-
variousArtists: node.getAttribute("data-various-artists")
512
-
}))
513
-
514
-
return loadAlbumCovers(coverPrepList)
515
-
}
516
-
517
-
518
-
function loadAlbumCovers(coverPrepList) {
519
-
return coverPrepList.reduce((acc, prep) => {
520
-
return acc.then(arr => {
521
-
return albumCover(prep.cacheKey).then(a => {
522
-
if (!a) return arr.concat([prep])
523
-
return arr
524
-
})
525
-
})
526
-
527
-
}, Promise.resolve([])).then(withoutEarlierAttempts => {
528
-
brain.postMessage({
529
-
action: "DOWNLOAD_ARTWORK",
530
-
data: withoutEarlierAttempts
531
-
})
532
-
533
-
})
534
-
}
535
-
536
-
537
-
// Send a dictionary of the cached covers to the app.
538
-
function cachedCovers(keys) {
539
-
const cacheKeys = keys.filter(
540
-
k => k.startsWith("coverCache.")
541
-
)
542
-
543
-
const cachePromise = cacheKeys.reduce((acc, key) => {
544
-
return acc.then(cache => {
545
-
return db().getItem(key).then(blob => {
546
-
const cacheKey = key.slice(11)
547
-
548
-
if (blob && typeof blob !== "string" && blob instanceof Blob) {
549
-
cache[cacheKey] = URL.createObjectURL(blob)
550
-
}
551
-
552
-
return cache
553
-
})
554
-
})
555
-
}, Promise.resolve({}))
556
-
557
-
cachePromise.then(cache => {
558
-
app.ports.insertCoverCache.send(cache)
559
-
setTimeout(() => loadAlbumCoversFromDom({ list: true, coverView: true }), 500)
560
-
})
561
-
}
562
-
563
-
564
-
function finishedDownloadingArtwork() {
565
-
if (!orchestrion.audio || !orchestrion.audio.waitingForArtwork || !orchestrion.activeQueueItem) return
566
-
567
-
albumCover(orchestrion.audio.waitingForArtwork).then(maybeArtwork => {
568
-
audioEngine.setMediaSessionMetadata(orchestrion.activeQueueItem, maybeArtwork)
569
-
})
570
-
571
-
orchestrion.audio.waitingForArtwork = null
572
-
}
573
-
574
-
575
-
576
-
// Dark mode
577
-
// ---------
578
-
579
-
function preferredColorScheme() {
580
-
const m =
581
-
window.matchMedia &&
582
-
window.matchMedia("(prefers-color-scheme: dark)")
583
-
584
-
m && m.addEventListener && m.addEventListener("change", e => {
585
-
app.ports.preferredColorSchemaChanged.send({ dark: e.matches })
586
-
})
587
-
588
-
return m
589
-
}
590
-
591
-
592
-
593
-
// Downloading
594
-
// -----------
595
-
596
-
async function downloadTracks(group) {
597
-
const { saveAs } = await import("file-saver").then(a => a.default)
598
-
const JSZip = await import("jszip").then(a => a.default)
599
-
600
-
const zip = new JSZip()
601
-
const folder = zip.folder("Diffuse - " + group.name)
602
-
if (!folder) throw new Error("Failed to create ZIP file")
603
-
604
-
return group.tracks.reduce(
605
-
(acc, track) => {
606
-
return acc
607
-
.then(_ => transformUrl(track.url, app))
608
-
.then(fetch)
609
-
.then(r => {
610
-
const mimeType = r.headers.get("content-type")
611
-
const fileExt = fileExtension(mimeType) || "unknown"
612
-
613
-
return r.blob().then(
614
-
b => folder.file(track.filename + "." + fileExt, b)
615
-
)
616
-
})
617
-
},
618
-
Promise.resolve()
619
-
620
-
).then(_ => zip.generateAsync({ type: "blob" })
621
-
).then(zipFile => {
622
-
saveAs(zipFile, "Diffuse - " + group.name + ".zip")
623
-
app.ports.downloadTracksFinished.send(null)
624
-
625
-
})
626
-
}
627
-
628
-
629
-
630
-
// Focus
631
-
// -----
632
-
633
-
window.addEventListener("blur", event => {
634
-
if (app && event.target === window) app.ports.lostWindowFocus.send(null)
635
-
})
636
-
637
-
638
-
639
-
// Forms
640
-
// -----
641
-
// Adds a `changed` attribute to form fields, if the form was "changed".
642
-
// This is to help with styling, we don't want to show an error immediately.
643
-
644
-
const FIELD_SELECTOR = "input, textarea"
645
-
646
-
647
-
document.addEventListener("keyup", e => {
648
-
const field = e.target && (<HTMLElement>e.target).closest(FIELD_SELECTOR)
649
-
if (field) field.setAttribute("changed", "")
650
-
})
651
-
652
-
653
-
document.addEventListener("click", e => {
654
-
if (!e.target || (<HTMLElement>e.target).tagName !== "BUTTON") return;
655
-
const form = (<HTMLElement>e.target).closest("form")
656
-
if (form) markAllFormFieldsAsChanged(form)
657
-
})
658
-
659
-
660
-
document.addEventListener("submit", e => {
661
-
const form = e.target && (<HTMLElement>e.target).closest("form")
662
-
if (form) markAllFormFieldsAsChanged(form)
663
-
})
664
-
665
-
666
-
function markAllFormFieldsAsChanged(form) {
667
-
[].slice.call(form.querySelectorAll(FIELD_SELECTOR)).forEach(field => {
668
-
field.setAttribute("changed", "")
669
-
})
670
-
}
671
-
672
-
673
-
674
-
// Internet Connection
675
-
// -------------------
676
-
677
-
window.addEventListener("online", onlineStatusChanged)
678
-
window.addEventListener("offline", onlineStatusChanged)
679
-
680
-
681
-
function onlineStatusChanged() {
682
-
app.ports.setIsOnline.send(navigator.onLine)
683
-
}
684
-
685
-
686
-
687
-
// Media Keys
688
-
// ----------
689
-
690
-
if ("mediaSession" in navigator) {
691
-
692
-
navigator.mediaSession.setActionHandler("play", () => {
693
-
app.ports.requestPlay.send(null)
694
-
})
695
-
696
-
697
-
navigator.mediaSession.setActionHandler("pause", () => {
698
-
app.ports.requestPause.send(null)
699
-
})
700
-
701
-
702
-
navigator.mediaSession.setActionHandler("previoustrack", () => {
703
-
app.ports.requestPrevious.send(null)
704
-
})
705
-
706
-
707
-
navigator.mediaSession.setActionHandler("nexttrack", () => {
708
-
app.ports.requestNext.send(null)
709
-
})
710
-
711
-
712
-
navigator.mediaSession.setActionHandler("seekbackward", event => {
713
-
const audio = orchestrion.audio
714
-
const seekOffset = event.seekOffset || 10
715
-
if (audio) audio.currentTime = Math.max(audio.currentTime - seekOffset, 0)
716
-
})
717
-
718
-
719
-
navigator.mediaSession.setActionHandler("seekforward", event => {
720
-
const audio = orchestrion.audio
721
-
const seekOffset = event.seekOffset || 10
722
-
if (audio) audio.currentTime = Math.min(audio.currentTime + seekOffset, audio.duration)
723
-
})
724
-
725
-
726
-
navigator.mediaSession.setActionHandler("seekto", event => {
727
-
const audio = orchestrion.audio
728
-
if (audio) audio.currentTime = event.seekTime
729
-
})
730
-
731
-
}
732
-
733
-
734
-
735
-
// Pointer Events
736
-
// --------------
737
-
// Thanks to https://github.com/mpizenberg/elm-pep/
738
-
739
-
let enteredElement
740
-
741
-
742
-
tocca({
743
-
dbltapThreshold: 400,
744
-
tapThreshold: 250
745
-
})
746
-
747
-
748
-
function mousePointerEvent(eventType, mouseEvent) {
749
-
let pointerEvent: any = new MouseEvent(eventType, mouseEvent)
750
-
pointerEvent.pointerId = 1
751
-
pointerEvent.isPrimary = true
752
-
pointerEvent.pointerType = "mouse"
753
-
pointerEvent.width = 1
754
-
pointerEvent.height = 1
755
-
pointerEvent.tiltX = 0
756
-
pointerEvent.tiltY = 0
757
-
758
-
"buttons" in mouseEvent && mouseEvent.buttons !== 0
759
-
? (pointerEvent.pressure = 0.5)
760
-
: (pointerEvent.pressure = 0)
761
-
762
-
return pointerEvent
763
-
}
764
-
765
-
766
-
function touchPointerEvent(eventType, touchEvent, touch) {
767
-
let pointerEvent: any = new CustomEvent(eventType, {
768
-
bubbles: true,
769
-
cancelable: true
770
-
})
771
-
772
-
pointerEvent.ctrlKey = touchEvent.ctrlKey
773
-
pointerEvent.shiftKey = touchEvent.shiftKey
774
-
pointerEvent.altKey = touchEvent.altKey
775
-
pointerEvent.metaKey = touchEvent.metaKey
776
-
777
-
pointerEvent.clientX = touch.clientX
778
-
pointerEvent.clientY = touch.clientY
779
-
pointerEvent.screenX = touch.screenX
780
-
pointerEvent.screenY = touch.screenY
781
-
pointerEvent.pageX = touch.pageX
782
-
pointerEvent.pageY = touch.pageY
783
-
784
-
const rect = touch.target.getBoundingClientRect()
785
-
pointerEvent.offsetX = touch.clientX - rect.left
786
-
pointerEvent.offsetY = touch.clientY - rect.top
787
-
pointerEvent.pointerId = 1 + touch.identifier
788
-
789
-
pointerEvent.button = 0
790
-
pointerEvent.buttons = 1
791
-
pointerEvent.movementX = 0
792
-
pointerEvent.movementY = 0
793
-
pointerEvent.region = null
794
-
pointerEvent.relatedTarget = null
795
-
pointerEvent.x = pointerEvent.clientX
796
-
pointerEvent.y = pointerEvent.clientY
797
-
798
-
pointerEvent.pointerType = "touch"
799
-
pointerEvent.width = 1
800
-
pointerEvent.height = 1
801
-
pointerEvent.tiltX = 0
802
-
pointerEvent.tiltY = 0
803
-
pointerEvent.pressure = 1
804
-
pointerEvent.isPrimary = true
805
-
806
-
return pointerEvent
807
-
}
808
-
809
-
810
-
// Simulate `pointerenter` and `pointerleave` event for non-touch devices
811
-
if (!self.PointerEvent) {
812
-
document.addEventListener("mouseover", event => {
813
-
const section = document.body.querySelector("section")
814
-
const isDragging = section && section.classList.contains("dragging-something")
815
-
const node = isDragging && document.elementFromPoint(event.clientX, event.clientY)
816
-
817
-
if (node && node != enteredElement) {
818
-
enteredElement && enteredElement.dispatchEvent(mousePointerEvent("pointerleave", event))
819
-
node.dispatchEvent(mousePointerEvent("pointerenter", event))
820
-
enteredElement = node
821
-
}
822
-
})
823
-
}
824
-
825
-
826
-
// Simulate `pointerenter` and `pointerleave` event for touch devices
827
-
document.body.addEventListener("touchmove", event => {
828
-
const section = document.body.querySelector("section")
829
-
const isDragging = section && section.classList.contains("dragging-something")
830
-
831
-
let touch = event.touches[0]
832
-
let node
833
-
834
-
if (isDragging && touch) {
835
-
node = document.elementFromPoint(touch.clientX, touch.clientY)
836
-
}
837
-
838
-
if (node && node != enteredElement) {
839
-
enteredElement && enteredElement.dispatchEvent(touchPointerEvent("pointerleave", event, touch))
840
-
node.dispatchEvent(touchPointerEvent("pointerenter", event, touch))
841
-
enteredElement = node
842
-
}
843
-
844
-
if (isDragging) {
845
-
event.stopPropagation()
846
-
}
847
-
})
848
-
849
-
850
-
851
-
// Service worker
852
-
// --------------
853
-
854
-
wire.serviceWorker = async (reg: ServiceWorkerRegistration) => {
855
-
if (reg.installing) console.log("🧑✈️ Service worker is installing")
856
-
const initialInstall = reg.installing
857
-
858
-
initialInstall?.addEventListener("statechange", function() {
859
-
if (this.state === "activated") {
860
-
console.log("🧑✈️ Service worker is activated")
861
-
app.ports.installedNewServiceWorker.send(null)
862
-
}
863
-
})
864
-
865
-
if (reg.waiting) {
866
-
console.log("🧑✈️ A new version of Diffuse is available")
867
-
app.ports.installingNewServiceWorker.send(null)
868
-
app.ports.installedNewServiceWorker.send(null)
869
-
}
870
-
871
-
if (initialInstall?.state === "activated") {
872
-
console.log("🧑✈️ Service worker is activated")
873
-
app.ports.installedNewServiceWorker.send(null)
874
-
}
875
-
876
-
reg.addEventListener("updatefound", () => {
877
-
const newWorker = reg.installing
878
-
if (!newWorker) return
879
-
880
-
// No worker was installed yet, so we'll only want to track the state changes
881
-
if (newWorker !== initialInstall) {
882
-
console.log("🧑✈️ A new version of Diffuse is available")
883
-
app.ports.installingNewServiceWorker.send(null)
884
-
}
885
-
886
-
newWorker.addEventListener("statechange", (e: any) => {
887
-
console.log("🧑✈️ Service worker is", e.target.state)
888
-
if (e.target.state === "installed") app.ports.installedNewServiceWorker.send(null)
889
-
})
890
-
})
891
-
892
-
// Check for service worker updates and every hour after that
893
-
if (!isNativeWrapper && navigator.onLine) {
894
-
reg.update()
895
-
setInterval(() => reg.update(), 1 * 1000 * 60 * 60)
896
-
}
897
-
}
898
-
899
-
900
-
901
-
// Syncing
902
-
// -------
903
-
904
-
let odd
905
-
906
-
907
-
wire.odd = () => {
908
-
app.ports.authenticateWithFission.subscribe(async () => {
909
-
const program = await oddProgram()
910
-
await program.capabilities.request({
911
-
returnUrl: location.origin + "?action=authenticate/fission"
912
-
})
913
-
})
914
-
915
-
app.ports.collectFissionCapabilities.subscribe(() => {
916
-
// The ODD SDK should collect the capabilities for us,
917
-
// if everything is valid, we'll receive a session.
918
-
oddProgram().then(
919
-
program => {
920
-
history.replaceState({}, "", location.origin)
921
-
app.ports.collectedFissionCapabilities.send(null)
922
-
}
923
-
).catch(
924
-
err => console.error(err)
925
-
)
926
-
})
927
-
}
928
-
929
-
930
-
931
-
async function oddProgram(): Promise<OddProgram> {
932
-
try {
933
-
await loadOdd()
934
-
} catch (err) {
935
-
console.trace(err)
936
-
throw new Error("Failed to load the ODD SDK")
937
-
}
938
-
939
-
const capComponent = await import("./Odd/components/capabilities.js")
940
-
941
-
const crypto = await odd.defaultCryptoComponent(ODD_CONFIG)
942
-
const storage = await odd.defaultStorageComponent(ODD_CONFIG)
943
-
const depot = await odd.defaultDepotComponent({ storage }, ODD_CONFIG)
944
-
945
-
return odd.program({
946
-
...ODD_CONFIG,
947
-
capabilities: capComponent.implementation({
948
-
crypto,
949
-
depot
950
-
}),
951
-
fileSystem: { loadImmediately: false }
952
-
})
953
-
}
954
-
955
-
956
-
async function loadOdd() {
957
-
if (odd) return
958
-
odd = await import("@oddjs/odd")
959
-
}
960
-
961
-
962
-
963
-
// Touch Device
964
-
// ------------
965
-
966
-
window.addEventListener("touchstart", function onFirstTouch() {
967
-
if (!app) return
968
-
app.ports.indicateTouchDevice.send(null)
969
-
window.removeEventListener("touchstart", onFirstTouch, false)
970
-
}, false)
+80
-30
src/Javascript/processing.ts
src/Javascript/Brain/processing.ts
+80
-30
src/Javascript/processing.ts
src/Javascript/Brain/processing.ts
···
4
4
//
5
5
// Audio processing, getting metadata, etc.
6
6
7
-
import type { IAudioMetadata } from "music-metadata";
8
-
import type { MediaInfoType } from "mediainfo.js";
7
+
import type { IAudioMetadata } from "music-metadata"
8
+
import type { GeneralTrack, MediaInfoResult } from "mediainfo.js"
9
+
10
+
import * as Uint8arrays from "uint8arrays"
11
+
import { type App } from "./elm/types"
12
+
import { transformUrl } from "../urls"
13
+
14
+
15
+
// 🏔️
16
+
17
+
18
+
const ENCODING_ISSUE_REPLACE_CHAR = '▩';
19
+
20
+
let app: App
21
+
22
+
23
+
24
+
// 🚀
25
+
26
+
27
+
export function init(a: App) {
28
+
app = a
29
+
30
+
app.ports.requestTags.subscribe(requestTags)
31
+
app.ports.syncTags.subscribe(syncTags)
32
+
}
33
+
34
+
35
+
36
+
// Ports
37
+
// -----
38
+
39
+
40
+
function requestTags(context) {
41
+
processContext(context, app).then(newContext => {
42
+
app.ports.receiveTags.send(newContext)
43
+
})
44
+
}
45
+
46
+
47
+
function syncTags(context) {
48
+
processContext(context, app).then(newContext => {
49
+
app.ports.replaceTags.send(newContext)
50
+
})
51
+
}
52
+
9
53
10
-
import * as Uint8arrays from "uint8arrays";
11
-
import { transformUrl } from "./urls";
12
54
13
55
// Contexts
14
56
// --------
57
+
15
58
16
59
export async function processContext(context, app) {
17
60
const initialPromise = Promise.resolve([]);
···
40
83
});
41
84
}
42
85
86
+
87
+
43
88
// Tags - General
44
89
// --------------
90
+
45
91
46
92
type Tags = {
47
93
disc: number;
···
63
109
const musicMetadata = await import("music-metadata-browser").then((a) => a.default);
64
110
const httpTokenizer = await import("@tokenizer/http").then((a) => a.default);
65
111
66
-
let tokenizer
67
-
let mmResult
112
+
let tokenizer;
113
+
let mmResult;
68
114
69
115
try {
70
116
tokenizer = await httpTokenizer.makeTokenizer(headUrl);
···
78
124
tokenizer.rangeRequestClient.resolvedUrl = undefined;
79
125
}
80
126
81
-
mmResult = await musicMetadata.parseFromTokenizer(
82
-
tokenizer,
83
-
{ skipCovers: !covers }
84
-
).catch(err => {
85
-
console.warn(err)
86
-
return null
87
-
});
127
+
mmResult = await musicMetadata
128
+
.parseFromTokenizer(tokenizer, { skipCovers: !covers })
129
+
.catch((err) => {
130
+
console.warn(err);
131
+
return null;
132
+
});
88
133
} catch (err) {
89
-
console.warn(err)
134
+
console.warn(err);
90
135
}
91
136
92
137
const mmTags = mmResult && pickTagsFromMusicMetadata(filename, mmResult);
···
94
139
95
140
const miResult = await (await mediaInfoClient(covers))
96
141
.analyzeData(getSize(headUrl), readChunk(getUrl))
97
-
.catch(err => {
98
-
console.warn(err)
99
-
return null
142
+
.catch((err) => {
143
+
console.warn(err);
144
+
return null;
100
145
});
101
146
102
147
const miTags = miResult && pickTagsFromMediaInfo(filename, miResult);
···
164
209
return new Uint8Array(await response.arrayBuffer());
165
210
};
166
211
167
-
function pickTagsFromMediaInfo(filename: string, result: MediaInfoType): Tags | null {
168
-
const tags = result?.media?.track?.filter((t) => t["@type"] === "General")[0];
169
-
if (!tags) return null;
212
+
function pickTagsFromMediaInfo(filename: string, result: MediaInfoResult): Tags | null {
213
+
const tagsRaw = result?.media?.track?.filter((t) => t["@type"] === "General")[0];
214
+
const tags = tagsRaw === undefined ? undefined : tagsRaw as GeneralTrack;
215
+
if (tags === undefined) return null;
170
216
171
217
let artist = typeof tags.Performer == "string" ? tags.Performer : null;
172
-
const album = typeof tags.Album == "string" ? tags.Album : null;
218
+
let album = typeof tags.Album == "string" ? tags.Album : null;
173
219
174
-
const title = typeof tags.Track == "string"
175
-
? tags.Track
176
-
: typeof tags.Title == "string"
177
-
? tags.Title
178
-
: null;
220
+
let title =
221
+
typeof tags.Track == "string" ? tags.Track : typeof tags.Title == "string" ? tags.Title : null;
179
222
180
223
if (!artist && !title) return null;
181
224
182
225
// TODO: Encoding issues with mediainfo.js
183
-
if (artist?.includes("�") || album?.includes("�") || title?.includes("�")) return null
226
+
// https://github.com/buzz/mediainfo.js/issues/150
227
+
if (artist?.includes("�")) artist = artist.replace("�", ENCODING_ISSUE_REPLACE_CHAR)
228
+
if (album?.includes("�")) album = album.replace("�", ENCODING_ISSUE_REPLACE_CHAR)
229
+
if (title?.includes("�")) title = title.replace("�", ENCODING_ISSUE_REPLACE_CHAR)
184
230
185
231
if (artist && artist.includes(" / ")) {
186
232
artist = artist
···
201
247
year: year !== null && isNaN(year) ? null : year,
202
248
picture: tags.Cover_Data
203
249
? {
204
-
data: Uint8arrays.fromString(tags.Cover_Data, "base64"),
250
+
data: Uint8arrays.fromString(tags.Cover_Data.split(" / ")[0], "base64pad"),
205
251
format: tags.Cover_Mime || "image/jpeg",
206
252
}
207
253
: null,
208
254
};
209
255
}
210
256
257
+
211
258
// Tags - Music Metadata
212
259
// ---------------------
260
+
213
261
214
262
function pickTagsFromMusicMetadata(filename: string, result: IAudioMetadata): Tags | null {
215
263
const tags = result && result.common;
···
235
283
};
236
284
}
237
285
286
+
287
+
238
288
// 🛠️
239
-
// --
289
+
240
290
241
291
async function mediaInfoClient(covers: boolean) {
242
-
const MediaInfoFactory = await import("mediainfo.js").then(a => a.default)
292
+
const MediaInfoFactory = await import("mediainfo.js").then((a) => a.default);
243
293
244
294
return await MediaInfoFactory({
245
295
coverData: covers,
+8
src/Library/MediaSession.elm
+8
src/Library/MediaSession.elm
+4
-2
src/Library/Queue.elm
+4
-2
src/Library/Queue.elm
···
21
21
22
22
type alias EngineItem =
23
23
{ isCached : Bool
24
+
, isPreload : Bool
24
25
, progress : Maybe Float
25
26
, sourceId : String
26
27
, trackId : String
···
34
35
-- 🔱
35
36
36
37
37
-
makeEngineItem : Time.Posix -> List Source -> List String -> Dict String Float -> Track -> EngineItem
38
-
makeEngineItem timestamp sources cachedTrackIds progressTable track =
38
+
makeEngineItem : Bool -> Time.Posix -> List Source -> List String -> Dict String Float -> Track -> EngineItem
39
+
makeEngineItem preload timestamp sources cachedTrackIds progressTable track =
39
40
{ isCached = List.member track.id cachedTrackIds
41
+
, isPreload = preload
40
42
, progress = Dict.get track.id progressTable
41
43
, sourceId = track.sourceId
42
44
, trackId = track.id
+5
src/Library/Tracks.elm
+5
src/Library/Tracks.elm
···
425
425
|> (\( t, _ ) -> { playlist | tracks = t })
426
426
427
427
428
+
shouldNoteProgress : { duration : Float } -> Bool
429
+
shouldNoteProgress { duration } =
430
+
duration >= 30 * 60
431
+
432
+
428
433
shouldRenderGroup : Identifiers -> Bool
429
434
shouldRenderGroup identifiers =
430
435
identifiers.group
+1
-1
src/Static/Manifests/manifest.json
+1
-1
src/Static/Manifests/manifest.json