+6
CHANGELOG.md
+6
CHANGELOG.md
+13
-7
Justfile
+13
-7
Justfile
···
115
--inject:./system/Js/node-shims.js
116
117
# Main
118
-
{{ESBUILD}} ./src/Javascript/index.ts \
119
--outdir={{BUILD_DIR}}/js/ui/ \
120
--define:BUILD_TIMESTAMP=$build_timestamp \
121
--splitting
···
144
--inject:./system/Js/node-shims.js
145
146
# Main
147
-
{{ESBUILD}} ./src/Javascript/index.ts \
148
--outdir={{BUILD_DIR}}/js/ui/ \
149
--define:BUILD_TIMESTAMP=$build_timestamp \
150
--splitting \
···
180
)
181
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
188
189
190
@quality: check-versions
···
115
--inject:./system/Js/node-shims.js
116
117
# Main
118
+
{{ESBUILD}} ./src/Javascript/UI/index.ts \
119
--outdir={{BUILD_DIR}}/js/ui/ \
120
--define:BUILD_TIMESTAMP=$build_timestamp \
121
--splitting
···
144
--inject:./system/Js/node-shims.js
145
146
# Main
147
+
{{ESBUILD}} ./src/Javascript/UI/index.ts \
148
--outdir={{BUILD_DIR}}/js/ui/ \
149
--define:BUILD_TIMESTAMP=$build_timestamp \
150
--splitting \
···
180
)
181
182
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
194
195
196
@quality: check-versions
+2
-1
elm.json
+2
-1
elm.json
···
49
"truqu/elm-base64": "2.0.4",
50
"truqu/elm-md5": "1.1.0",
51
"wernerdegroot/listzipper": "4.0.0",
52
-
"ymtszw/elm-xml-decode": "3.2.1"
53
},
54
"indirect": {
55
"elm/bytes": "1.0.8",
56
"elm/parser": "1.1.0",
57
"fredcy/elm-parseint": "2.0.1",
58
"pzp1997/assoc-list": "1.0.0",
59
"zwilias/elm-utf-tools": "2.0.1"
60
}
···
49
"truqu/elm-base64": "2.0.4",
50
"truqu/elm-md5": "1.1.0",
51
"wernerdegroot/listzipper": "4.0.0",
52
+
"ymtszw/elm-xml-decode": "3.2.2"
53
},
54
"indirect": {
55
"elm/bytes": "1.0.8",
56
"elm/parser": "1.1.0",
57
"fredcy/elm-parseint": "2.0.1",
58
+
"miniBill/elm-xml-parser": "1.0.1",
59
"pzp1997/assoc-list": "1.0.0",
60
"zwilias/elm-utf-tools": "2.0.1"
61
}
+1
-1
gren.json
+1
-1
gren.json
+304
-266
package-lock.json
+304
-266
package-lock.json
···
1
{
2
"name": "diffuse",
3
-
"version": "3.4.0",
4
"lockfileVersion": 2,
5
"requires": true,
6
"packages": {
7
"": {
8
"name": "diffuse",
9
-
"version": "3.4.0",
10
"license": "SEE LICENSE IN LICENSE",
11
"dependencies": {
12
"@oddjs/odd": "^0.37.2",
···
15
"encoding-japanese": "^2.0.0",
16
"fast-text-encoding": "^1.0.6",
17
"file-saver": "^2.0.2",
18
-
"jschardet": "^3.0.0",
19
"jszip": "^3.7.1",
20
"load-script2": "^2.0.5",
21
"localforage": "^1.10.0",
22
"lunr": "^2.3.8",
23
-
"mediainfo.js": "^0.2.1",
24
"music-metadata-browser": "^2.5.10",
25
"readable-stream": "^4.5.2",
26
"remotestoragejs": "^2.0.0-beta.6",
···
36
"@tauri-apps/plugin-dialog": "^2.0.0-beta.0",
37
"@tauri-apps/plugin-fs": "^2.0.0-beta.0",
38
"@tauri-apps/plugin-shell": "^2.0.0-beta.0",
39
"@typescript-eslint/eslint-plugin": "^6.21.0",
40
"@typescript-eslint/parser": "^6.21.0",
41
"assert": "^2.1.0",
42
-
"autoprefixer": "^10.4.17",
43
"buffer": "^6.0.3",
44
"elm": "0.19.1-6",
45
"elm-format": "^0.8.7",
46
"elm-review": "^2.10.3",
47
-
"esbuild": "^0.20.0",
48
"esbuild-plugin-wasm": "^1.1.0",
49
"eslint": "^8.56.0",
50
"events": "^3.3.0",
···
276
]
277
},
278
"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
"cpu": [
283
"ppc64"
284
],
···
292
}
293
},
294
"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
"cpu": [
299
"arm"
300
],
···
308
}
309
},
310
"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
"cpu": [
315
"arm64"
316
],
···
324
}
325
},
326
"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
"cpu": [
331
"x64"
332
],
···
340
}
341
},
342
"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
"cpu": [
347
"arm64"
348
],
···
356
}
357
},
358
"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
"cpu": [
363
"x64"
364
],
···
372
}
373
},
374
"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
"cpu": [
379
"arm64"
380
],
···
388
}
389
},
390
"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
"cpu": [
395
"x64"
396
],
···
404
}
405
},
406
"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
"cpu": [
411
"arm"
412
],
···
420
}
421
},
422
"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
"cpu": [
427
"arm64"
428
],
···
436
}
437
},
438
"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
"cpu": [
443
"ia32"
444
],
···
452
}
453
},
454
"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
"cpu": [
459
"loong64"
460
],
···
468
}
469
},
470
"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
"cpu": [
475
"mips64el"
476
],
···
484
}
485
},
486
"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
"cpu": [
491
"ppc64"
492
],
···
500
}
501
},
502
"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
"cpu": [
507
"riscv64"
508
],
···
516
}
517
},
518
"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
"cpu": [
523
"s390x"
524
],
···
532
}
533
},
534
"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
"cpu": [
539
"x64"
540
],
···
548
}
549
},
550
"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
"cpu": [
555
"x64"
556
],
···
564
}
565
},
566
"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
"cpu": [
571
"x64"
572
],
···
580
}
581
},
582
"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
"cpu": [
587
"x64"
588
],
···
596
}
597
},
598
"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
"cpu": [
603
"arm64"
604
],
···
612
}
613
},
614
"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
"cpu": [
619
"ia32"
620
],
···
628
}
629
},
630
"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
"cpu": [
635
"x64"
636
],
···
1654
"@types/responselike": "^1.0.0"
1655
}
1656
},
1657
"node_modules/@types/http-cache-semantics": {
1658
"version": "4.0.1",
1659
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz",
···
1675
"@types/node": "*"
1676
}
1677
},
1678
"node_modules/@types/node": {
1679
"version": "18.16.3",
1680
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.16.3.tgz",
···
1693
"version": "7.5.6",
1694
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz",
1695
"integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==",
1696
"dev": true
1697
},
1698
"node_modules/@types/tv4": {
···
2150
}
2151
},
2152
"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==",
2156
"dev": true,
2157
"funding": [
2158
{
···
2169
}
2170
],
2171
"dependencies": {
2172
-
"browserslist": "^4.22.2",
2173
-
"caniuse-lite": "^1.0.30001578",
2174
"fraction.js": "^4.3.7",
2175
"normalize-range": "^0.1.2",
2176
"picocolors": "^1.0.0",
···
2493
}
2494
},
2495
"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==",
2499
"dev": true,
2500
"funding": [
2501
{
···
2512
}
2513
],
2514
"dependencies": {
2515
-
"caniuse-lite": "^1.0.30001580",
2516
-
"electron-to-chromium": "^1.4.648",
2517
"node-releases": "^2.0.14",
2518
-
"update-browserslist-db": "^1.0.13"
2519
},
2520
"bin": {
2521
"browserslist": "cli.js"
···
2650
}
2651
},
2652
"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==",
2656
"dev": true,
2657
"funding": [
2658
{
···
3265
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="
3266
},
3267
"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==",
3271
"dev": true
3272
},
3273
"node_modules/elm": {
···
3464
}
3465
},
3466
"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==",
3470
"dev": true,
3471
"hasInstallScript": true,
3472
"bin": {
···
3476
"node": ">=12"
3477
},
3478
"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"
3502
}
3503
},
3504
"node_modules/esbuild-plugin-wasm": {
···
5187
"js-yaml": "bin/js-yaml.js"
5188
}
5189
},
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
"node_modules/json-buffer": {
5199
"version": "3.0.1",
5200
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
···
5549
}
5550
},
5551
"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==",
5555
"dependencies": {
5556
"yargs": "^17.7.2"
5557
},
···
5559
"mediainfo.js": "dist/esm/cli.js"
5560
},
5561
"engines": {
5562
-
"node": ">=14.16"
5563
}
5564
},
5565
"node_modules/merge-options": {
···
6252
"dev": true
6253
},
6254
"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==",
6258
"dev": true
6259
},
6260
"node_modules/picomatch": {
···
7590
}
7591
},
7592
"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==",
7596
"dev": true,
7597
"funding": [
7598
{
···
7609
}
7610
],
7611
"dependencies": {
7612
-
"escalade": "^3.1.1",
7613
-
"picocolors": "^1.0.0"
7614
},
7615
"bin": {
7616
"update-browserslist-db": "cli.js"
···
8053
"optional": true
8054
},
8055
"@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==",
8059
"dev": true,
8060
"optional": true
8061
},
8062
"@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==",
8066
"dev": true,
8067
"optional": true
8068
},
8069
"@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==",
8073
"dev": true,
8074
"optional": true
8075
},
8076
"@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==",
8080
"dev": true,
8081
"optional": true
8082
},
8083
"@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==",
8087
"dev": true,
8088
"optional": true
8089
},
8090
"@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==",
8094
"dev": true,
8095
"optional": true
8096
},
8097
"@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==",
8101
"dev": true,
8102
"optional": true
8103
},
8104
"@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==",
8108
"dev": true,
8109
"optional": true
8110
},
8111
"@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==",
8115
"dev": true,
8116
"optional": true
8117
},
8118
"@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==",
8122
"dev": true,
8123
"optional": true
8124
},
8125
"@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==",
8129
"dev": true,
8130
"optional": true
8131
},
8132
"@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==",
8136
"dev": true,
8137
"optional": true
8138
},
8139
"@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==",
8143
"dev": true,
8144
"optional": true
8145
},
8146
"@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==",
8150
"dev": true,
8151
"optional": true
8152
},
8153
"@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==",
8157
"dev": true,
8158
"optional": true
8159
},
8160
"@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==",
8164
"dev": true,
8165
"optional": true
8166
},
8167
"@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==",
8171
"dev": true,
8172
"optional": true
8173
},
8174
"@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==",
8178
"dev": true,
8179
"optional": true
8180
},
8181
"@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==",
8185
"dev": true,
8186
"optional": true
8187
},
8188
"@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==",
8192
"dev": true,
8193
"optional": true
8194
},
8195
"@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==",
8199
"dev": true,
8200
"optional": true
8201
},
8202
"@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==",
8206
"dev": true,
8207
"optional": true
8208
},
8209
"@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==",
8213
"dev": true,
8214
"optional": true
8215
},
···
8921
"@types/responselike": "^1.0.0"
8922
}
8923
},
8924
"@types/http-cache-semantics": {
8925
"version": "4.0.1",
8926
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz",
···
8942
"@types/node": "*"
8943
}
8944
},
8945
"@types/node": {
8946
"version": "18.16.3",
8947
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.16.3.tgz",
···
8960
"version": "7.5.6",
8961
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz",
8962
"integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==",
8963
"dev": true
8964
},
8965
"@types/tv4": {
···
9261
"dev": true
9262
},
9263
"autoprefixer": {
9264
-
"version": "10.4.17",
9265
-
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.17.tgz",
9266
-
"integrity": "sha512-/cpVNRLSfhOtcGflT13P2794gVSgmPgTR+erw5ifnMLZb0UnSlkK4tquLmkd3BhA+nLo5tX8Cu0upUsGKvKbmg==",
9267
"dev": true,
9268
"requires": {
9269
-
"browserslist": "^4.22.2",
9270
-
"caniuse-lite": "^1.0.30001578",
9271
"fraction.js": "^4.3.7",
9272
"normalize-range": "^0.1.2",
9273
"picocolors": "^1.0.0",
···
9478
}
9479
},
9480
"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==",
9484
"dev": true,
9485
"requires": {
9486
-
"caniuse-lite": "^1.0.30001580",
9487
-
"electron-to-chromium": "^1.4.648",
9488
"node-releases": "^2.0.14",
9489
-
"update-browserslist-db": "^1.0.13"
9490
}
9491
},
9492
"buffer": {
···
9568
"dev": true
9569
},
9570
"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==",
9574
"dev": true
9575
},
9576
"catering": {
···
9997
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="
9998
},
9999
"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==",
10003
"dev": true
10004
},
10005
"elm": {
···
10147
"dev": true
10148
},
10149
"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==",
10153
"dev": true,
10154
"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"
10178
}
10179
},
10180
"esbuild-plugin-wasm": {
···
11337
"argparse": "^2.0.1"
11338
}
11339
},
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
"json-buffer": {
11346
"version": "3.0.1",
11347
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
···
11630
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="
11631
},
11632
"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==",
11636
"requires": {
11637
"yargs": "^17.7.2"
11638
}
···
12103
"dev": true
12104
},
12105
"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==",
12109
"dev": true
12110
},
12111
"picomatch": {
···
13030
"dev": true
13031
},
13032
"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==",
13036
"dev": true,
13037
"requires": {
13038
-
"escalade": "^3.1.1",
13039
-
"picocolors": "^1.0.0"
13040
}
13041
},
13042
"update-check": {
···
1
{
2
"name": "diffuse",
3
+
"version": "3.5.0",
4
"lockfileVersion": 2,
5
"requires": true,
6
"packages": {
7
"": {
8
"name": "diffuse",
9
+
"version": "3.5.0",
10
"license": "SEE LICENSE IN LICENSE",
11
"dependencies": {
12
"@oddjs/odd": "^0.37.2",
···
15
"encoding-japanese": "^2.0.0",
16
"fast-text-encoding": "^1.0.6",
17
"file-saver": "^2.0.2",
18
"jszip": "^3.7.1",
19
"load-script2": "^2.0.5",
20
"localforage": "^1.10.0",
21
"lunr": "^2.3.8",
22
+
"mediainfo.js": "^0.3.1",
23
"music-metadata-browser": "^2.5.10",
24
"readable-stream": "^4.5.2",
25
"remotestoragejs": "^2.0.0-beta.6",
···
35
"@tauri-apps/plugin-dialog": "^2.0.0-beta.0",
36
"@tauri-apps/plugin-fs": "^2.0.0-beta.0",
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",
42
"@typescript-eslint/eslint-plugin": "^6.21.0",
43
"@typescript-eslint/parser": "^6.21.0",
44
"assert": "^2.1.0",
45
+
"autoprefixer": "^10.4.19",
46
"buffer": "^6.0.3",
47
"elm": "0.19.1-6",
48
"elm-format": "^0.8.7",
49
"elm-review": "^2.10.3",
50
+
"esbuild": "^0.20.2",
51
"esbuild-plugin-wasm": "^1.1.0",
52
"eslint": "^8.56.0",
53
"events": "^3.3.0",
···
279
]
280
},
281
"node_modules/@esbuild/aix-ppc64": {
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==",
285
"cpu": [
286
"ppc64"
287
],
···
295
}
296
},
297
"node_modules/@esbuild/android-arm": {
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==",
301
"cpu": [
302
"arm"
303
],
···
311
}
312
},
313
"node_modules/@esbuild/android-arm64": {
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==",
317
"cpu": [
318
"arm64"
319
],
···
327
}
328
},
329
"node_modules/@esbuild/android-x64": {
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==",
333
"cpu": [
334
"x64"
335
],
···
343
}
344
},
345
"node_modules/@esbuild/darwin-arm64": {
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==",
349
"cpu": [
350
"arm64"
351
],
···
359
}
360
},
361
"node_modules/@esbuild/darwin-x64": {
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==",
365
"cpu": [
366
"x64"
367
],
···
375
}
376
},
377
"node_modules/@esbuild/freebsd-arm64": {
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==",
381
"cpu": [
382
"arm64"
383
],
···
391
}
392
},
393
"node_modules/@esbuild/freebsd-x64": {
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==",
397
"cpu": [
398
"x64"
399
],
···
407
}
408
},
409
"node_modules/@esbuild/linux-arm": {
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==",
413
"cpu": [
414
"arm"
415
],
···
423
}
424
},
425
"node_modules/@esbuild/linux-arm64": {
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==",
429
"cpu": [
430
"arm64"
431
],
···
439
}
440
},
441
"node_modules/@esbuild/linux-ia32": {
442
+
"version": "0.20.2",
443
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz",
444
+
"integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==",
445
"cpu": [
446
"ia32"
447
],
···
455
}
456
},
457
"node_modules/@esbuild/linux-loong64": {
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==",
461
"cpu": [
462
"loong64"
463
],
···
471
}
472
},
473
"node_modules/@esbuild/linux-mips64el": {
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==",
477
"cpu": [
478
"mips64el"
479
],
···
487
}
488
},
489
"node_modules/@esbuild/linux-ppc64": {
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==",
493
"cpu": [
494
"ppc64"
495
],
···
503
}
504
},
505
"node_modules/@esbuild/linux-riscv64": {
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==",
509
"cpu": [
510
"riscv64"
511
],
···
519
}
520
},
521
"node_modules/@esbuild/linux-s390x": {
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==",
525
"cpu": [
526
"s390x"
527
],
···
535
}
536
},
537
"node_modules/@esbuild/linux-x64": {
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==",
541
"cpu": [
542
"x64"
543
],
···
551
}
552
},
553
"node_modules/@esbuild/netbsd-x64": {
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==",
557
"cpu": [
558
"x64"
559
],
···
567
}
568
},
569
"node_modules/@esbuild/openbsd-x64": {
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==",
573
"cpu": [
574
"x64"
575
],
···
583
}
584
},
585
"node_modules/@esbuild/sunos-x64": {
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==",
589
"cpu": [
590
"x64"
591
],
···
599
}
600
},
601
"node_modules/@esbuild/win32-arm64": {
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==",
605
"cpu": [
606
"arm64"
607
],
···
615
}
616
},
617
"node_modules/@esbuild/win32-ia32": {
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==",
621
"cpu": [
622
"ia32"
623
],
···
631
}
632
},
633
"node_modules/@esbuild/win32-x64": {
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==",
637
"cpu": [
638
"x64"
639
],
···
1657
"@types/responselike": "^1.0.0"
1658
}
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
+
},
1672
"node_modules/@types/http-cache-semantics": {
1673
"version": "4.0.1",
1674
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz",
···
1690
"@types/node": "*"
1691
}
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
+
},
1699
"node_modules/@types/node": {
1700
"version": "18.16.3",
1701
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.16.3.tgz",
···
1714
"version": "7.5.6",
1715
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz",
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==",
1723
"dev": true
1724
},
1725
"node_modules/@types/tv4": {
···
2177
}
2178
},
2179
"node_modules/autoprefixer": {
2180
+
"version": "10.4.19",
2181
+
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz",
2182
+
"integrity": "sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==",
2183
"dev": true,
2184
"funding": [
2185
{
···
2196
}
2197
],
2198
"dependencies": {
2199
+
"browserslist": "^4.23.0",
2200
+
"caniuse-lite": "^1.0.30001599",
2201
"fraction.js": "^4.3.7",
2202
"normalize-range": "^0.1.2",
2203
"picocolors": "^1.0.0",
···
2520
}
2521
},
2522
"node_modules/browserslist": {
2523
+
"version": "4.23.1",
2524
+
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.1.tgz",
2525
+
"integrity": "sha512-TUfofFo/KsK/bWZ9TWQ5O26tsWW4Uhmt8IYklbnUa70udB6P2wA7w7o4PY4muaEPBQaAX+CEnmmIA41NVHtPVw==",
2526
"dev": true,
2527
"funding": [
2528
{
···
2539
}
2540
],
2541
"dependencies": {
2542
+
"caniuse-lite": "^1.0.30001629",
2543
+
"electron-to-chromium": "^1.4.796",
2544
"node-releases": "^2.0.14",
2545
+
"update-browserslist-db": "^1.0.16"
2546
},
2547
"bin": {
2548
"browserslist": "cli.js"
···
2677
}
2678
},
2679
"node_modules/caniuse-lite": {
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==",
2683
"dev": true,
2684
"funding": [
2685
{
···
3292
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="
3293
},
3294
"node_modules/electron-to-chromium": {
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==",
3298
"dev": true
3299
},
3300
"node_modules/elm": {
···
3491
}
3492
},
3493
"node_modules/esbuild": {
3494
+
"version": "0.20.2",
3495
+
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz",
3496
+
"integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==",
3497
"dev": true,
3498
"hasInstallScript": true,
3499
"bin": {
···
3503
"node": ">=12"
3504
},
3505
"optionalDependencies": {
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"
3529
}
3530
},
3531
"node_modules/esbuild-plugin-wasm": {
···
5214
"js-yaml": "bin/js-yaml.js"
5215
}
5216
},
5217
"node_modules/json-buffer": {
5218
"version": "3.0.1",
5219
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
···
5568
}
5569
},
5570
"node_modules/mediainfo.js": {
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==",
5574
"dependencies": {
5575
"yargs": "^17.7.2"
5576
},
···
5578
"mediainfo.js": "dist/esm/cli.js"
5579
},
5580
"engines": {
5581
+
"node": ">=18.0.0"
5582
}
5583
},
5584
"node_modules/merge-options": {
···
6271
"dev": true
6272
},
6273
"node_modules/picocolors": {
6274
+
"version": "1.0.1",
6275
+
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz",
6276
+
"integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==",
6277
"dev": true
6278
},
6279
"node_modules/picomatch": {
···
7609
}
7610
},
7611
"node_modules/update-browserslist-db": {
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==",
7615
"dev": true,
7616
"funding": [
7617
{
···
7628
}
7629
],
7630
"dependencies": {
7631
+
"escalade": "^3.1.2",
7632
+
"picocolors": "^1.0.1"
7633
},
7634
"bin": {
7635
"update-browserslist-db": "cli.js"
···
8072
"optional": true
8073
},
8074
"@esbuild/aix-ppc64": {
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==",
8078
"dev": true,
8079
"optional": true
8080
},
8081
"@esbuild/android-arm": {
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==",
8085
"dev": true,
8086
"optional": true
8087
},
8088
"@esbuild/android-arm64": {
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==",
8092
"dev": true,
8093
"optional": true
8094
},
8095
"@esbuild/android-x64": {
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==",
8099
"dev": true,
8100
"optional": true
8101
},
8102
"@esbuild/darwin-arm64": {
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==",
8106
"dev": true,
8107
"optional": true
8108
},
8109
"@esbuild/darwin-x64": {
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==",
8113
"dev": true,
8114
"optional": true
8115
},
8116
"@esbuild/freebsd-arm64": {
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==",
8120
"dev": true,
8121
"optional": true
8122
},
8123
"@esbuild/freebsd-x64": {
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==",
8127
"dev": true,
8128
"optional": true
8129
},
8130
"@esbuild/linux-arm": {
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==",
8134
"dev": true,
8135
"optional": true
8136
},
8137
"@esbuild/linux-arm64": {
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==",
8141
"dev": true,
8142
"optional": true
8143
},
8144
"@esbuild/linux-ia32": {
8145
+
"version": "0.20.2",
8146
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz",
8147
+
"integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==",
8148
"dev": true,
8149
"optional": true
8150
},
8151
"@esbuild/linux-loong64": {
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==",
8155
"dev": true,
8156
"optional": true
8157
},
8158
"@esbuild/linux-mips64el": {
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==",
8162
"dev": true,
8163
"optional": true
8164
},
8165
"@esbuild/linux-ppc64": {
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==",
8169
"dev": true,
8170
"optional": true
8171
},
8172
"@esbuild/linux-riscv64": {
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==",
8176
"dev": true,
8177
"optional": true
8178
},
8179
"@esbuild/linux-s390x": {
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==",
8183
"dev": true,
8184
"optional": true
8185
},
8186
"@esbuild/linux-x64": {
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==",
8190
"dev": true,
8191
"optional": true
8192
},
8193
"@esbuild/netbsd-x64": {
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==",
8197
"dev": true,
8198
"optional": true
8199
},
8200
"@esbuild/openbsd-x64": {
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==",
8204
"dev": true,
8205
"optional": true
8206
},
8207
"@esbuild/sunos-x64": {
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==",
8211
"dev": true,
8212
"optional": true
8213
},
8214
"@esbuild/win32-arm64": {
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==",
8218
"dev": true,
8219
"optional": true
8220
},
8221
"@esbuild/win32-ia32": {
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==",
8225
"dev": true,
8226
"optional": true
8227
},
8228
"@esbuild/win32-x64": {
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==",
8232
"dev": true,
8233
"optional": true
8234
},
···
8940
"@types/responselike": "^1.0.0"
8941
}
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
+
},
8955
"@types/http-cache-semantics": {
8956
"version": "4.0.1",
8957
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz",
···
8973
"@types/node": "*"
8974
}
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
+
},
8982
"@types/node": {
8983
"version": "18.16.3",
8984
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.16.3.tgz",
···
8997
"version": "7.5.6",
8998
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz",
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==",
9006
"dev": true
9007
},
9008
"@types/tv4": {
···
9304
"dev": true
9305
},
9306
"autoprefixer": {
9307
+
"version": "10.4.19",
9308
+
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz",
9309
+
"integrity": "sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==",
9310
"dev": true,
9311
"requires": {
9312
+
"browserslist": "^4.23.0",
9313
+
"caniuse-lite": "^1.0.30001599",
9314
"fraction.js": "^4.3.7",
9315
"normalize-range": "^0.1.2",
9316
"picocolors": "^1.0.0",
···
9521
}
9522
},
9523
"browserslist": {
9524
+
"version": "4.23.1",
9525
+
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.1.tgz",
9526
+
"integrity": "sha512-TUfofFo/KsK/bWZ9TWQ5O26tsWW4Uhmt8IYklbnUa70udB6P2wA7w7o4PY4muaEPBQaAX+CEnmmIA41NVHtPVw==",
9527
"dev": true,
9528
"requires": {
9529
+
"caniuse-lite": "^1.0.30001629",
9530
+
"electron-to-chromium": "^1.4.796",
9531
"node-releases": "^2.0.14",
9532
+
"update-browserslist-db": "^1.0.16"
9533
}
9534
},
9535
"buffer": {
···
9611
"dev": true
9612
},
9613
"caniuse-lite": {
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==",
9617
"dev": true
9618
},
9619
"catering": {
···
10040
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="
10041
},
10042
"electron-to-chromium": {
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==",
10046
"dev": true
10047
},
10048
"elm": {
···
10190
"dev": true
10191
},
10192
"esbuild": {
10193
+
"version": "0.20.2",
10194
+
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz",
10195
+
"integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==",
10196
"dev": true,
10197
"requires": {
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"
10221
}
10222
},
10223
"esbuild-plugin-wasm": {
···
11380
"argparse": "^2.0.1"
11381
}
11382
},
11383
"json-buffer": {
11384
"version": "3.0.1",
11385
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
···
11668
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="
11669
},
11670
"mediainfo.js": {
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==",
11674
"requires": {
11675
"yargs": "^17.7.2"
11676
}
···
12141
"dev": true
12142
},
12143
"picocolors": {
12144
+
"version": "1.0.1",
12145
+
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz",
12146
+
"integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==",
12147
"dev": true
12148
},
12149
"picomatch": {
···
13068
"dev": true
13069
},
13070
"update-browserslist-db": {
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==",
13074
"dev": true,
13075
"requires": {
13076
+
"escalade": "^3.1.2",
13077
+
"picocolors": "^1.0.1"
13078
}
13079
},
13080
"update-check": {
+8
-5
package.json
+8
-5
package.json
···
1
{
2
"name": "diffuse",
3
"description": "A music player that connects to your cloud/distributed storage",
4
-
"version": "3.4.0",
5
"author": "Steven Vandevelde <icid.asset@gmail.com>",
6
"homepage": "https://diffuse.sh",
7
"repository": "github:icidasset/diffuse",
···
12
"@tauri-apps/plugin-dialog": "^2.0.0-beta.0",
13
"@tauri-apps/plugin-fs": "^2.0.0-beta.0",
14
"@tauri-apps/plugin-shell": "^2.0.0-beta.0",
15
"@typescript-eslint/eslint-plugin": "^6.21.0",
16
"@typescript-eslint/parser": "^6.21.0",
17
"assert": "^2.1.0",
18
-
"autoprefixer": "^10.4.17",
19
"buffer": "^6.0.3",
20
"elm": "0.19.1-6",
21
"elm-format": "^0.8.7",
22
"elm-review": "^2.10.3",
23
-
"esbuild": "^0.20.0",
24
"esbuild-plugin-wasm": "^1.1.0",
25
"eslint": "^8.56.0",
26
"events": "^3.3.0",
···
42
"encoding-japanese": "^2.0.0",
43
"fast-text-encoding": "^1.0.6",
44
"file-saver": "^2.0.2",
45
-
"jschardet": "^3.0.0",
46
"jszip": "^3.7.1",
47
"load-script2": "^2.0.5",
48
"localforage": "^1.10.0",
49
"lunr": "^2.3.8",
50
-
"mediainfo.js": "^0.2.1",
51
"music-metadata-browser": "^2.5.10",
52
"readable-stream": "^4.5.2",
53
"remotestoragejs": "^2.0.0-beta.6",
···
1
{
2
"name": "diffuse",
3
"description": "A music player that connects to your cloud/distributed storage",
4
+
"version": "3.5.0",
5
"author": "Steven Vandevelde <icid.asset@gmail.com>",
6
"homepage": "https://diffuse.sh",
7
"repository": "github:icidasset/diffuse",
···
12
"@tauri-apps/plugin-dialog": "^2.0.0-beta.0",
13
"@tauri-apps/plugin-fs": "^2.0.0-beta.0",
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",
19
"@typescript-eslint/eslint-plugin": "^6.21.0",
20
"@typescript-eslint/parser": "^6.21.0",
21
"assert": "^2.1.0",
22
+
"autoprefixer": "^10.4.19",
23
"buffer": "^6.0.3",
24
"elm": "0.19.1-6",
25
"elm-format": "^0.8.7",
26
"elm-review": "^2.10.3",
27
+
"esbuild": "^0.20.2",
28
"esbuild-plugin-wasm": "^1.1.0",
29
"eslint": "^8.56.0",
30
"events": "^3.3.0",
···
46
"encoding-japanese": "^2.0.0",
47
"fast-text-encoding": "^1.0.6",
48
"file-saver": "^2.0.2",
49
"jszip": "^3.7.1",
50
"load-script2": "^2.0.5",
51
"localforage": "^1.10.0",
52
"lunr": "^2.3.8",
53
+
"mediainfo.js": "^0.3.1",
54
"music-metadata-browser": "^2.5.10",
55
"readable-stream": "^4.5.2",
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
import Alien
4
import Brain.Common.State as Common
5
import Brain.Ports as Ports
6
import Brain.Types exposing (..)
7
import Dict
8
import Json.Decode as Json
···
56
Return.singleton { model | currentTime = time }
57
58
59
toCache : Json.Value -> Manager
60
toCache data =
61
case Json.decodeValue Alien.hostDecoder data of
62
Ok alienEvent ->
63
-
alienEvent
64
-
|> Ports.toCache
65
-
|> Return.communicate
66
67
Err err ->
68
err
···
3
import Alien
4
import Brain.Common.State as Common
5
import Brain.Ports as Ports
6
+
import Brain.Task.Ports
7
import Brain.Types exposing (..)
8
import Dict
9
import Json.Decode as Json
···
57
Return.singleton { model | currentTime = time }
58
59
60
+
{-| Save alien data to cache.
61
+
-}
62
toCache : Json.Value -> Manager
63
toCache data =
64
case Json.decodeValue Alien.hostDecoder data of
65
Ok alienEvent ->
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"
75
76
Err err ->
77
err
-9
src/Applications/Brain/Ports.elm
-9
src/Applications/Brain/Ports.elm
···
12
port downloadTracks : Json.Value -> Cmd msg
13
14
15
-
port removeCache : Alien.Event -> Cmd msg
16
-
17
-
18
port removeTracksFromCache : Json.Value -> Cmd msg
19
-
20
-
21
-
port requestCache : Alien.Event -> Cmd msg
22
23
24
port requestSearch : String -> Cmd msg
···
31
32
33
port syncTags : ContextForTagsSync -> Cmd msg
34
-
35
-
36
-
port toCache : Alien.Event -> Cmd msg
37
38
39
port toUI : Alien.Event -> Cmd msg
-2
src/Applications/Brain/Tracks/State.elm
-2
src/Applications/Brain/Tracks/State.elm
···
119
makeTrackUrl model.currentTime trackPath maybeSource
120
in
121
dict
122
-
|> Dict.remove "trackPath"
123
-
|> Dict.remove "trackSourceId"
124
|> Dict.insert "trackGetUrl" (mkTrackUrl Get)
125
|> Dict.insert "trackHeadUrl" (mkTrackUrl Head)
126
|> Json.Encode.dict identity Json.Encode.string
+25
-18
src/Applications/Brain/User/State.elm
+25
-18
src/Applications/Brain/User/State.elm
···
99
|> User.decodeHypaethralData
100
|> Result.map
101
(\hypaethralData ->
102
-
( hypaethralJson
103
-
, hypaethralData
104
-
)
105
)
106
-
|> Result.withDefault
107
-
( User.encodeHypaethralData User.emptyHypaethralData
108
-
, User.emptyHypaethralData
109
-
)
110
-
|> Commence maybeMethod initialUrl
111
-
|> UserMsg
112
)
113
114
···
345
unsetSyncMethod model =
346
-- 💀
347
-- Unset & remove stored method.
348
-
[ Ports.removeCache (Alien.trigger Alien.SyncMethod)
349
-
, Ports.removeCache (Alien.trigger Alien.SecretKey)
350
351
--
352
, case model.userSyncMethod of
···
380
381
retrieveEnclosedData : Manager
382
retrieveEnclosedData =
383
-
Alien.EnclosedData
384
-
|> Alien.trigger
385
-
|> Ports.requestCache
386
|> Return.communicate
387
388
389
saveEnclosedData : Json.Value -> Manager
390
saveEnclosedData json =
391
json
392
-
|> Alien.broadcast Alien.EnclosedData
393
-
|> Ports.toCache
394
|> Return.communicate
395
396
···
668
saveMethod method model =
669
method
670
|> encodeMethod
671
-
|> Alien.broadcast Alien.SyncMethod
672
-
|> Ports.toCache
673
|> return { model | userSyncMethod = Just method }
674
675
···
99
|> User.decodeHypaethralData
100
|> Result.map
101
(\hypaethralData ->
102
+
Commence
103
+
maybeMethod
104
+
initialUrl
105
+
( hypaethralJson
106
+
, hypaethralData
107
+
)
108
)
109
+
|> Result.mapError Decode.errorToString
110
+
|> Common.reportErrorToUI UserMsg
111
)
112
113
···
344
unsetSyncMethod model =
345
-- 💀
346
-- Unset & remove stored method.
347
+
[ Common.attemptPortTask (always Brain.Bypass) (Brain.Task.Ports.removeCache Alien.SyncMethod)
348
+
, Common.attemptPortTask (always Brain.Bypass) (Brain.Task.Ports.removeCache Alien.SecretKey)
349
350
--
351
, case model.userSyncMethod of
···
379
380
retrieveEnclosedData : Manager
381
retrieveEnclosedData =
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
+
)
393
|> Return.communicate
394
395
396
saveEnclosedData : Json.Value -> Manager
397
saveEnclosedData json =
398
json
399
+
|> Brain.Task.Ports.toCache Alien.EnclosedData
400
+
|> Common.attemptPortTask (always Brain.Bypass)
401
|> Return.communicate
402
403
···
675
saveMethod method model =
676
method
677
|> encodeMethod
678
+
|> Brain.Task.Ports.toCache Alien.SyncMethod
679
+
|> Common.attemptPortTask (always Brain.Bypass)
680
|> return { model | userSyncMethod = Just method }
681
682
+47
-28
src/Applications/UI.elm
+47
-28
src/Applications/UI.elm
···
117
-----------------------------------------
118
-- Audio
119
-----------------------------------------
120
-
, audioDuration = 0
121
-
, audioHasStalled = False
122
-
, audioIsLoading = False
123
-
, audioIsPlaying = False
124
-
, audioPosition = 0
125
, progress = Dict.empty
126
, rememberProgress = True
127
···
136
-----------------------------------------
137
-- Debouncing
138
-----------------------------------------
139
, resizeDebouncer =
140
0.25
141
|> Debouncer.fromSeconds
···
174
-- Queue
175
-----------------------------------------
176
, dontPlay = []
177
-
, nowPlaying = Nothing
178
, playedPreviously = []
179
, playingNext = []
180
, selectedQueueItem = Nothing
···
273
-----------------------------------------
274
-- Audio
275
-----------------------------------------
276
NoteProgress a ->
277
Audio.noteProgress a
278
279
Pause ->
280
Audio.pause
···
284
285
Seek a ->
286
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
303
Stop ->
304
Audio.stop
···
562
-----------------------------------------
563
-- Audio
564
-----------------------------------------
565
-
, Ports.noteProgress NoteProgress
566
, Ports.requestPause (always Pause)
567
, Ports.requestPlay (always Play)
568
, Ports.requestPlayPause (always TogglePlay)
569
, 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
576
-----------------------------------------
577
-- Backdrop
···
591
-----------------------------------------
592
-- Queue
593
-----------------------------------------
594
-
, Ports.activeQueueItemEnded (QueueMsg << always Queue.Shift)
595
, Ports.requestNext (\_ -> QueueMsg Queue.Shift)
596
, Ports.requestPrevious (\_ -> QueueMsg Queue.Rewind)
597
···
117
-----------------------------------------
118
-- Audio
119
-----------------------------------------
120
+
, audioElements = []
121
+
, nowPlaying = Nothing
122
, progress = Dict.empty
123
, rememberProgress = True
124
···
133
-----------------------------------------
134
-- Debouncing
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
147
, resizeDebouncer =
148
0.25
149
|> Debouncer.fromSeconds
···
182
-- Queue
183
-----------------------------------------
184
, dontPlay = []
185
, playedPreviously = []
186
, playingNext = []
187
, selectedQueueItem = Nothing
···
280
-----------------------------------------
281
-- Audio
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
+
307
NoteProgress a ->
308
Audio.noteProgress a
309
+
310
+
NoteProgressDebounce a ->
311
+
Audio.noteProgressDebounce update a
312
313
Pause ->
314
Audio.pause
···
318
319
Seek a ->
320
Audio.seek a
321
322
Stop ->
323
Audio.stop
···
581
-----------------------------------------
582
-- Audio
583
-----------------------------------------
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
591
, Ports.requestPause (always Pause)
592
, Ports.requestPlay (always Play)
593
, Ports.requestPlayPause (always TogglePlay)
594
, Ports.requestStop (always Stop)
595
596
-----------------------------------------
597
-- Backdrop
···
611
-----------------------------------------
612
-- Queue
613
-----------------------------------------
614
, Ports.requestNext (\_ -> QueueMsg Queue.Shift)
615
, Ports.requestPrevious (\_ -> QueueMsg Queue.Rewind)
616
+6
-6
src/Applications/UI/Adjunct.elm
+6
-6
src/Applications/UI/Adjunct.elm
···
63
[ Keyboard.Character "]", Keyboard.Control ] ->
64
Queue.shift m
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
-
72
-- Meta key
73
--
74
[ Keyboard.Character "K", Keyboard.Meta ] ->
···
63
[ Keyboard.Character "]", Keyboard.Control ] ->
64
Queue.shift m
65
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
-- Meta key
73
--
74
[ Keyboard.Character "K", Keyboard.Meta ] ->
+311
-61
src/Applications/UI/Audio/State.elm
+311
-61
src/Applications/UI/Audio/State.elm
···
1
module UI.Audio.State exposing (..)
2
3
import Dict
4
import LastFm
5
-
import Maybe.Extra as Maybe
6
import Return exposing (return)
7
import Return.Ext as Return exposing (communicate)
8
import UI.Ports as Ports
9
import UI.Queue.State as Queue
10
-
import UI.Types as UI exposing (Manager)
11
import UI.User.State.Export as User
12
13
14
15
-
-- 📣
16
17
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
24
25
-
else if progress > 0.975 then
26
-
Dict.remove trackId model.progress
27
28
else
29
-
Dict.insert trackId progress model.progress
30
-
in
31
-
if model.rememberProgress then
32
-
User.saveProgress { model | progress = updatedProgressTable }
33
34
-
else
35
-
Return.singleton model
36
37
38
pause : Manager
39
pause model =
40
-
return model (Ports.pause ())
41
42
43
playPause : Manager
44
playPause model =
45
-
if Maybe.isNothing model.nowPlaying then
46
-
Queue.shift model
47
48
-
else if model.audioIsPlaying then
49
-
communicate (Ports.pause ()) model
50
51
-
else
52
-
communicate (Ports.play ()) model
53
54
55
play : Manager
56
play model =
57
-
if Maybe.isNothing model.nowPlaying then
58
-
Queue.shift model
59
60
-
else
61
-
return model (Ports.play ())
62
63
64
-
seek : Float -> Manager
65
-
seek percentage =
66
-
Return.communicate (Ports.seek percentage)
67
68
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
-
}
80
81
-
Nothing ->
82
-
Cmd.none
83
-
in
84
-
return { model | audioDuration = duration } cmd
85
86
87
-
setHasStalled : Bool -> Manager
88
-
setHasStalled hasStalled model =
89
-
Return.singleton { model | audioHasStalled = hasStalled }
90
91
92
-
setIsLoading : Bool -> Manager
93
-
setIsLoading isLoading model =
94
-
Return.singleton { model | audioIsLoading = isLoading }
95
96
97
-
setIsPlaying : Bool -> Manager
98
-
setIsPlaying isPlaying model =
99
-
Return.singleton { model | audioIsPlaying = isPlaying }
100
101
102
-
setPosition : Float -> Manager
103
-
setPosition position model =
104
-
Return.singleton { model | audioPosition = position }
105
106
107
-
stop : Manager
108
-
stop =
109
-
communicate (Ports.pause ())
110
111
112
toggleRememberProgress : Manager
113
toggleRememberProgress model =
114
User.saveSettings { model | rememberProgress = not model.rememberProgress }
···
1
module UI.Audio.State exposing (..)
2
3
+
import Base64
4
+
import Common exposing (boolToString)
5
+
import Debouncer.Basic as Debouncer
6
import Dict
7
import LastFm
8
+
import List.Extra as List
9
+
import MediaSession
10
import Return exposing (return)
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)
16
import UI.Ports as Ports
17
import UI.Queue.State as Queue
18
+
import UI.Types as UI exposing (Manager, Msg(..))
19
import UI.User.State.Export as User
20
21
22
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
60
61
+
_ ->
62
+
False
63
64
+
metadata =
65
+
{ album = track.tags.album
66
+
, artist = track.tags.artist
67
+
, title = track.tags.title
68
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)
118
119
else
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
+
)
147
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
214
215
216
pause : Manager
217
pause model =
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
229
230
231
playPause : Manager
232
playPause model =
233
+
case model.nowPlaying of
234
+
Just { isPlaying } ->
235
+
if isPlaying then
236
+
pause model
237
238
+
else
239
+
play model
240
241
+
Nothing ->
242
+
play model
243
244
245
play : Manager
246
play 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
256
257
+
Nothing ->
258
+
Queue.shift model
259
260
261
+
seek : { trackId : String, progress : Float } -> Manager
262
+
seek { trackId, progress } =
263
+
{ percentage = progress, trackId = trackId }
264
+
|> Ports.seek
265
+
|> Return.communicate
266
267
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
+
)
282
283
284
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
297
298
+
else
299
+
Dict.insert trackId progress model.progress
300
+
in
301
+
if model.rememberProgress then
302
+
User.saveProgress { model | progress = updatedProgressTable }
303
304
+
else
305
+
Return.singleton model
306
307
308
+
noteProgressDebounce : DebounceManager
309
+
noteProgressDebounce =
310
+
Common.debounce
311
+
.progressDebouncer
312
+
(\d m -> { m | progressDebouncer = d })
313
+
UI.NoteProgressDebounce
314
315
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
329
330
331
+
preloadDebounce : DebounceManager
332
+
preloadDebounce =
333
+
Common.debounce
334
+
.preloadDebouncer
335
+
(\d m -> { m | preloadDebouncer = d })
336
+
UI.AudioPreloadDebounce
337
338
339
toggleRememberProgress : Manager
340
toggleRememberProgress model =
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
nowPlayingCommands : UI.Model -> List (Item UI.Msg)
71
nowPlayingCommands model =
72
case model.nowPlaying of
73
-
Just queueItem ->
74
let
75
( queueItemIdentifiers, _ ) =
76
-
queueItem.identifiedTrack
77
78
identifiedTrack =
79
model.tracks.harvested
80
|> List.getAt queueItemIdentifiers.indexInList
81
-
|> Maybe.withDefault queueItem.identifiedTrack
82
83
( identifiers, track ) =
84
identifiedTrack
···
116
117
118
playbackCommands model =
119
-
[ if model.audioIsPlaying then
120
{ icon = Just (Icons.pause 16)
121
, title = "Pause"
122
, value = Command UI.TogglePlay
···
70
nowPlayingCommands : UI.Model -> List (Item UI.Msg)
71
nowPlayingCommands model =
72
case model.nowPlaying of
73
+
Just { item } ->
74
let
75
( queueItemIdentifiers, _ ) =
76
+
item.identifiedTrack
77
78
identifiedTrack =
79
model.tracks.harvested
80
|> List.getAt queueItemIdentifiers.indexInList
81
+
|> Maybe.withDefault item.identifiedTrack
82
83
( identifiers, track ) =
84
identifiedTrack
···
116
117
118
playbackCommands model =
119
+
[ if Maybe.map .isPlaying model.nowPlaying == Just True then
120
{ icon = Just (Icons.pause 16)
121
, title = "Pause"
122
, value = Command UI.TogglePlay
+80
-36
src/Applications/UI/Console.elm
+80
-36
src/Applications/UI/Console.elm
···
8
import Json.Decode as Decode
9
import Material.Icons.Round as Icons
10
import Material.Icons.Types exposing (Coloring(..))
11
-
import Queue
12
import UI.Queue.Types as Queue
13
import UI.Tracks.Types as Tracks
14
import UI.Types exposing (Msg(..))
···
19
20
21
view :
22
-
Maybe Queue.Item
23
-> Bool
24
-> Bool
25
-
-> { stalled : Bool, loading : Bool, playing : Bool }
26
-
-> ( Float, Float )
27
-> Html Msg
28
-
view activeQueueItem repeat shuffle { stalled, loading, playing } ( position, duration ) =
29
chunk
30
[ "antialiased"
31
, "mt-1"
···
45
, "py-4"
46
, "text-white"
47
]
48
-
[ if stalled then
49
-
text "Audio connection got interrupted, trying to reconnect ..."
50
51
-
else if loading then
52
-
text "Loading track ..."
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)
66
67
-
Nothing ->
68
-
text tags.title
69
-
]
70
71
-
Nothing ->
72
-
text "Diffuse"
73
]
74
75
-----------------------------------------
76
-- Progress Bar
77
-----------------------------------------
78
, let
79
progress =
80
-
if duration <= 0 then
81
-
0
82
83
-
else
84
-
(position / duration)
85
-
|> (*) 100
86
-
|> min 100
87
-
|> max 0
88
in
89
brick
90
-
[ on "click" (clickLocationDecoder Seek) ]
91
[ "cursor-pointer"
92
, "py-1"
93
]
···
137
(QueueMsg Queue.Rewind)
138
139
--
140
-
, button
141
""
142
-
(largeLight playing)
143
play
144
TogglePlay
145
···
8
import Json.Decode as Decode
9
import Material.Icons.Round as Icons
10
import Material.Icons.Types exposing (Coloring(..))
11
+
import Maybe.Extra as Maybe
12
+
import UI.Audio.Types exposing (AudioLoadingState(..), NowPlaying, nowPlayingIdentifiedTrack)
13
import UI.Queue.Types as Queue
14
import UI.Tracks.Types as Tracks
15
import UI.Types exposing (Msg(..))
···
20
21
22
view :
23
+
Maybe NowPlaying
24
-> Bool
25
-> Bool
26
-> Html Msg
27
+
view nowPlaying repeat shuffle =
28
chunk
29
[ "antialiased"
30
, "mt-1"
···
44
, "py-4"
45
, "text-white"
46
]
47
+
[ case Maybe.map .loadingState nowPlaying of
48
+
Nothing ->
49
+
text "Diffuse"
50
51
+
Just Loading ->
52
+
text "Loading track ..."
53
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
+
]
70
71
+
Nothing ->
72
+
text "Diffuse"
73
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."
88
]
89
90
-----------------------------------------
91
-- Progress Bar
92
-----------------------------------------
93
, let
94
+
maybeDuration =
95
+
Maybe.andThen .duration nowPlaying
96
+
97
+
maybePosition =
98
+
Maybe.map .playbackPosition nowPlaying
99
+
100
progress =
101
+
case ( maybeDuration, maybePosition ) of
102
+
( Just duration, Just position ) ->
103
+
if duration <= 0 then
104
+
0
105
106
+
else
107
+
(position / duration)
108
+
|> (*) 100
109
+
|> min 100
110
+
|> max 0
111
+
112
+
_ ->
113
+
0
114
in
115
brick
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
+
)
131
[ "cursor-pointer"
132
, "py-1"
133
]
···
177
(QueueMsg Queue.Rewind)
178
179
--
180
+
, let
181
+
isPlaying =
182
+
Maybe.unwrap False .isPlaying nowPlaying
183
+
in
184
+
button
185
""
186
+
(largeLight isPlaying)
187
play
188
TogglePlay
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
3
import Alien
4
import Common exposing (ServiceWorkerStatus(..))
5
import Notifications
6
import Return exposing (return)
7
import Time
···
41
42
setIsOnline : Bool -> Manager
43
setIsOnline bool model =
44
-
if bool then
45
-
syncHypaethralData { model | isOnline = bool }
46
47
-
else
48
-
Return.singleton { model | isOnline = bool }
49
50
51
setCurrentTime : Time.Posix -> Manager
···
2
3
import Alien
4
import Common exposing (ServiceWorkerStatus(..))
5
+
import Dict
6
import Notifications
7
import Return exposing (return)
8
import Time
···
42
43
setIsOnline : Bool -> Manager
44
setIsOnline bool model =
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
59
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
+
)
76
77
78
setCurrentTime : Time.Posix -> Manager
+54
-23
src/Applications/UI/Ports.elm
+54
-23
src/Applications/UI/Ports.elm
···
3
import Alien
4
import Json.Encode as Json
5
import Queue
6
7
8
···
33
port openUrlOnNewPage : String -> Cmd msg
34
35
36
-
port pause : () -> Cmd msg
37
38
39
port pickAverageBackgroundColor : String -> Cmd msg
40
41
42
-
port play : () -> Cmd msg
43
44
45
port preloadAudio : Queue.EngineItem -> Cmd msg
···
48
port reloadApp : () -> Cmd msg
49
50
51
-
port seek : Float -> Cmd msg
52
53
54
-
port setRepeat : Bool -> Cmd msg
55
56
57
port toBrain : Alien.Event -> Cmd msg
···
61
-- 📰
62
63
64
-
port activeQueueItemEnded : (() -> msg) -> Sub msg
65
66
67
port collectedFissionCapabilities : (() -> msg) -> Sub msg
···
88
port installingNewServiceWorker : (() -> msg) -> Sub msg
89
90
91
-
port noteProgress : ({ trackId : String, progress : Float } -> msg) -> Sub msg
92
-
93
-
94
port refreshedAccessToken : (Json.Value -> msg) -> Sub msg
95
96
97
port preferredColorSchemaChanged : ({ dark : Bool } -> msg) -> Sub msg
98
99
100
port requestNext : (() -> msg) -> Sub msg
···
116
117
118
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
135
136
port setAverageBackgroundColor : ({ r : Int, g : Int, b : Int } -> msg) -> Sub msg
···
3
import Alien
4
import Json.Encode as Json
5
import Queue
6
+
import UI.Audio.Types as Audio
7
8
9
···
34
port openUrlOnNewPage : String -> Cmd msg
35
36
37
+
port pause : { trackId : String } -> Cmd msg
38
+
39
+
40
+
port pauseScrobbleTimer : () -> Cmd msg
41
42
43
port pickAverageBackgroundColor : String -> Cmd msg
44
45
46
+
port play : { trackId : String, volume : Float } -> Cmd msg
47
+
48
+
49
+
port reloadAudioNodeIfNeeded : { play : Bool, progress : Maybe Float, trackId : String } -> Cmd msg
50
51
52
port preloadAudio : Queue.EngineItem -> Cmd msg
···
55
port reloadApp : () -> Cmd msg
56
57
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
80
81
82
+
port startScrobbleTimer : () -> Cmd msg
83
84
85
port toBrain : Alien.Event -> Cmd msg
···
89
-- 📰
90
91
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
111
112
113
port collectedFissionCapabilities : (() -> msg) -> Sub msg
···
134
port installingNewServiceWorker : (() -> msg) -> Sub msg
135
136
137
port refreshedAccessToken : (Json.Value -> msg) -> Sub msg
138
139
140
port preferredColorSchemaChanged : ({ dark : Bool } -> msg) -> Sub msg
141
+
142
+
143
+
port receiveTask : (Json.Value -> msg) -> Sub msg
144
145
146
port requestNext : (() -> msg) -> Sub msg
···
162
163
164
port scrobble : ({ duration : Int, timestamp : Int, trackId : String } -> msg) -> Sub msg
165
166
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
module UI.Queue.State exposing (..)
2
3
import Coordinates
4
import Dict
5
import Html.Events.Extra.Mouse as Mouse
6
import List.Extra as List
7
import Notifications
8
import Queue exposing (..)
9
-
import Return exposing (andThen, return)
10
import Tracks exposing (..)
11
import UI.Common.State as Common
12
import UI.Ports as Ports
13
import UI.Queue.ContextMenu as Queue
···
26
case msg of
27
Clear ->
28
clear
29
30
Reset ->
31
reset
···
85
86
changeActiveItem : Maybe Item -> Manager
87
changeActiveItem maybeItem model =
88
maybeItem
89
-
|> Maybe.map
90
-
(.identifiedTrack >> Tuple.second)
91
|> Maybe.map
92
(Queue.makeEngineItem
93
model.currentTime
94
model.sources
95
model.cachedTracks
···
100
Dict.empty
101
)
102
)
103
-
|> Ports.activeQueueItemChanged
104
-
|> return { model | nowPlaying = maybeItem }
105
|> andThen fill
106
107
···
145
else
146
let
147
fillState =
148
-
{ activeItem = m.nowPlaying
149
, future = m.playingNext
150
, ignored = m.dontPlay
151
, past = m.playedPreviously
···
158
else
159
{ m | playingNext = Fill.ordered timestamp nonMissingTracks fillState }
160
)
161
-
|> preloadNext
162
163
164
preloadNext : Manager
···
169
|> .identifiedTrack
170
|> Tuple.second
171
|> Queue.makeEngineItem
172
model.currentTime
173
model.sources
174
model.cachedTracks
···
178
else
179
Dict.empty
180
)
181
-
|> Ports.preloadAudio
182
-
|> return model
183
184
Nothing ->
185
Return.singleton model
···
192
{ model
193
| playingNext =
194
model.nowPlaying
195
-
|> Maybe.map (\item -> item :: model.playingNext)
196
|> Maybe.withDefault model.playingNext
197
, playedPreviously =
198
model.playedPreviously
···
226
|> List.drop 1
227
, playedPreviously =
228
model.nowPlaying
229
-
|> Maybe.map List.singleton
230
|> Maybe.map (List.append model.playedPreviously)
231
|> Maybe.withDefault model.playedPreviously
232
}
···
273
274
toggleRepeat : Manager
275
toggleRepeat model =
276
-
{ model | repeat = not model.repeat }
277
-
|> saveEnclosedUserData
278
-
|> Return.effect_ (.repeat >> Ports.setRepeat)
279
280
281
toggleShuffle : Manager
···
1
module UI.Queue.State exposing (..)
2
3
import Coordinates
4
+
import Debouncer.Basic as Debouncer
5
import Dict
6
import Html.Events.Extra.Mouse as Mouse
7
import List.Extra as List
8
import Notifications
9
import Queue exposing (..)
10
+
import Return exposing (andThen)
11
+
import Return.Ext as Return
12
import Tracks exposing (..)
13
+
import UI.Audio.Types exposing (AudioLoadingState(..))
14
import UI.Common.State as Common
15
import UI.Ports as Ports
16
import UI.Queue.ContextMenu as Queue
···
29
case msg of
30
Clear ->
31
clear
32
+
33
+
PreloadNext ->
34
+
preloadNext
35
36
Reset ->
37
reset
···
91
92
changeActiveItem : Maybe Item -> Manager
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
108
maybeItem
109
+
|> Maybe.map (.identifiedTrack >> Tuple.second)
110
|> Maybe.map
111
(Queue.makeEngineItem
112
+
False
113
model.currentTime
114
model.sources
115
model.cachedTracks
···
120
Dict.empty
121
)
122
)
123
+
|> Maybe.map insertTrack
124
+
|> Maybe.withDefault Return.singleton
125
+
|> (\fn -> fn { model | nowPlaying = maybeNowPlaying })
126
|> andThen fill
127
128
···
166
else
167
let
168
fillState =
169
+
{ activeItem = Maybe.map .item m.nowPlaying
170
, future = m.playingNext
171
, ignored = m.dontPlay
172
, past = m.playedPreviously
···
179
else
180
{ m | playingNext = Fill.ordered timestamp nonMissingTracks fillState }
181
)
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
+
)
239
240
241
preloadNext : Manager
···
246
|> .identifiedTrack
247
|> Tuple.second
248
|> Queue.makeEngineItem
249
+
True
250
model.currentTime
251
model.sources
252
model.cachedTracks
···
256
else
257
Dict.empty
258
)
259
+
|> (\engineItem ->
260
+
insertTrack engineItem model
261
+
)
262
263
Nothing ->
264
Return.singleton model
···
271
{ model
272
| playingNext =
273
model.nowPlaying
274
+
|> Maybe.map (\{ item } -> item :: model.playingNext)
275
|> Maybe.withDefault model.playingNext
276
, playedPreviously =
277
model.playedPreviously
···
305
|> List.drop 1
306
, playedPreviously =
307
model.nowPlaying
308
+
|> Maybe.map (.item >> List.singleton)
309
|> Maybe.map (List.append model.playedPreviously)
310
|> Maybe.withDefault model.playedPreviously
311
}
···
352
353
toggleRepeat : Manager
354
toggleRepeat model =
355
+
saveEnclosedUserData { model | repeat = not model.repeat }
356
357
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
+3
-4
src/Applications/UI/Tracks/Scene/Covers.elm
+3
-4
src/Applications/UI/Tracks/Scene/Covers.elm
···
15
import Material.Icons.Round as Icons
16
import Material.Icons.Types exposing (Coloring(..))
17
import Maybe.Extra as Maybe
18
-
import Queue
19
import Task
20
import Tracks exposing (..)
21
import UI.Tracks.Scene as Scene
···
36
, favouritesOnly : Bool
37
, infiniteList : InfiniteList.Model
38
, isVisible : Bool
39
-
, nowPlaying : Maybe Queue.Item
40
, selectedCover : Maybe Cover
41
, selectedTrackIndexes : List Int
42
, sortBy : SortBy
···
50
{ cachedCovers : Maybe (Dict String String)
51
, columns : Int
52
, containerWidth : Int
53
-
, nowPlaying : Maybe Queue.Item
54
, sortBy : SortBy
55
}
56
···
664
coverView { clickable, horizontal } { cachedCovers, nowPlaying } cover =
665
let
666
nowPlayingId =
667
-
Maybe.unwrap "" (.identifiedTrack >> Tuple.second >> .id) nowPlaying
668
669
missingTracks =
670
List.any
···
15
import Material.Icons.Round as Icons
16
import Material.Icons.Types exposing (Coloring(..))
17
import Maybe.Extra as Maybe
18
import Task
19
import Tracks exposing (..)
20
import UI.Tracks.Scene as Scene
···
35
, favouritesOnly : Bool
36
, infiniteList : InfiniteList.Model
37
, isVisible : Bool
38
+
, nowPlaying : Maybe IdentifiedTrack
39
, selectedCover : Maybe Cover
40
, selectedTrackIndexes : List Int
41
, sortBy : SortBy
···
49
{ cachedCovers : Maybe (Dict String String)
50
, columns : Int
51
, containerWidth : Int
52
+
, nowPlaying : Maybe IdentifiedTrack
53
, sortBy : SortBy
54
}
55
···
663
coverView { clickable, horizontal } { cachedCovers, nowPlaying } cover =
664
let
665
nowPlayingId =
666
+
Maybe.unwrap "" (Tuple.second >> .id) nowPlaying
667
668
missingTracks =
669
List.any
+6
-7
src/Applications/UI/Tracks/Scene/List.elm
+6
-7
src/Applications/UI/Tracks/Scene/List.elm
···
16
import Material.Icons.Round as Icons
17
import Material.Icons.Types exposing (Coloring(..))
18
import Maybe.Extra as Maybe
19
-
import Queue
20
import Task
21
import Tracks exposing (..)
22
import UI.DnD as DnD
···
48
}
49
50
51
-
view : Dependencies -> List IdentifiedTrack -> InfiniteList.Model -> Bool -> Maybe Queue.Item -> Maybe String -> SortBy -> SortDirection -> List Int -> Maybe (DnD.Model Int) -> Html Msg
52
view deps harvest infiniteList favouritesOnly nowPlaying searchTerm sortBy sortDirection selectedTrackIndexes maybeDnD =
53
brick
54
(tabindex (ifThenElse deps.isVisible 0 -1) :: viewAttributes)
···
261
-- INFINITE LIST
262
263
264
-
infiniteListView : Dependencies -> List IdentifiedTrack -> InfiniteList.Model -> Bool -> Maybe String -> ( Maybe Queue.Item, List Int ) -> Maybe (DnD.Model Int) -> Html Msg
265
infiniteListView deps harvest infiniteList favouritesOnly searchTerm ( nowPlaying, selectedTrackIndexes ) maybeDnD =
266
let
267
derivedColors =
···
364
defaultItemView :
365
{ derivedColors : DerivedColors
366
, favouritesOnly : Bool
367
-
, nowPlaying : Maybe Queue.Item
368
, roundedCorners : Bool
369
, selectedTrackIndexes : List Int
370
, showAlbum : Bool
···
394
395
rowIdentifiers =
396
{ isMissing = identifiers.isMissing
397
-
, isNowPlaying = Maybe.unwrap False (.identifiedTrack >> isNowPlaying identifiedTrack) nowPlaying
398
, isSelected = isSelected
399
}
400
···
480
]
481
482
483
-
playlistItemView : Bool -> Maybe Queue.Item -> Maybe String -> List Int -> DnD.Model Int -> Bool -> Bool -> DerivedColors -> Int -> Int -> IdentifiedTrack -> Html Msg
484
playlistItemView favouritesOnly nowPlaying _ selectedTrackIndexes dnd showAlbum darkMode derivedColors _ idx identifiedTrack =
485
let
486
( identifiers, track ) =
···
502
503
rowIdentifiers =
504
{ isMissing = identifiers.isMissing
505
-
, isNowPlaying = Maybe.unwrap False (.identifiedTrack >> isNowPlaying identifiedTrack) nowPlaying
506
, isSelected = isSelected
507
}
508
···
16
import Material.Icons.Round as Icons
17
import Material.Icons.Types exposing (Coloring(..))
18
import Maybe.Extra as Maybe
19
import Task
20
import Tracks exposing (..)
21
import UI.DnD as DnD
···
47
}
48
49
50
+
view : Dependencies -> List IdentifiedTrack -> InfiniteList.Model -> Bool -> Maybe IdentifiedTrack -> Maybe String -> SortBy -> SortDirection -> List Int -> Maybe (DnD.Model Int) -> Html Msg
51
view deps harvest infiniteList favouritesOnly nowPlaying searchTerm sortBy sortDirection selectedTrackIndexes maybeDnD =
52
brick
53
(tabindex (ifThenElse deps.isVisible 0 -1) :: viewAttributes)
···
260
-- INFINITE LIST
261
262
263
+
infiniteListView : Dependencies -> List IdentifiedTrack -> InfiniteList.Model -> Bool -> Maybe String -> ( Maybe IdentifiedTrack, List Int ) -> Maybe (DnD.Model Int) -> Html Msg
264
infiniteListView deps harvest infiniteList favouritesOnly searchTerm ( nowPlaying, selectedTrackIndexes ) maybeDnD =
265
let
266
derivedColors =
···
363
defaultItemView :
364
{ derivedColors : DerivedColors
365
, favouritesOnly : Bool
366
+
, nowPlaying : Maybe IdentifiedTrack
367
, roundedCorners : Bool
368
, selectedTrackIndexes : List Int
369
, showAlbum : Bool
···
393
394
rowIdentifiers =
395
{ isMissing = identifiers.isMissing
396
+
, isNowPlaying = Maybe.unwrap False (isNowPlaying identifiedTrack) nowPlaying
397
, isSelected = isSelected
398
}
399
···
479
]
480
481
482
+
playlistItemView : Bool -> Maybe IdentifiedTrack -> Maybe String -> List Int -> DnD.Model Int -> Bool -> Bool -> DerivedColors -> Int -> Int -> IdentifiedTrack -> Html Msg
483
playlistItemView favouritesOnly nowPlaying _ selectedTrackIndexes dnd showAlbum darkMode derivedColors _ idx identifiedTrack =
484
let
485
( identifiers, track ) =
···
501
502
rowIdentifiers =
503
{ isMissing = identifiers.isMissing
504
+
, isNowPlaying = Maybe.unwrap False (isNowPlaying identifiedTrack) nowPlaying
505
, isSelected = isSelected
506
}
507
+44
-10
src/Applications/UI/Tracks/State.elm
+44
-10
src/Applications/UI/Tracks/State.elm
···
1
module UI.Tracks.State exposing (..)
2
3
import Alien
4
import Common exposing (..)
5
import ContextMenu
6
import Coordinates exposing (Coordinates)
···
362
let
363
cachedCovers =
364
Maybe.withDefault Dict.empty model.cachedCovers
365
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)
374
|> Result.map (\dict -> { model | cachedCovers = Just dict })
375
|> Result.withDefault model
376
-
|> Return.singleton
377
378
379
groupBy : Tracks.Grouping -> Manager
···
727
scrollToNowPlaying model =
728
model.nowPlaying
729
|> Maybe.map
730
-
(.identifiedTrack >> Tuple.second >> .id)
731
|> Maybe.andThen
732
(\id ->
733
List.find
···
1
module UI.Tracks.State exposing (..)
2
3
import Alien
4
+
import Base64
5
import Common exposing (..)
6
import ContextMenu
7
import Coordinates exposing (Coordinates)
···
363
let
364
cachedCovers =
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
376
in
377
+
decodedValue
378
+
|> Result.map (\( _, key, url ) -> Dict.insert key url cachedCovers)
379
|> Result.map (\dict -> { model | cachedCovers = Just dict })
380
|> Result.withDefault model
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
+
)
411
412
413
groupBy : Tracks.Grouping -> Manager
···
761
scrollToNowPlaying model =
762
model.nowPlaying
763
|> Maybe.map
764
+
(.item >> .identifiedTrack >> Tuple.second >> .id)
765
|> Maybe.andThen
766
(\id ->
767
List.find
+3
-2
src/Applications/UI/Tracks/View.elm
+3
-2
src/Applications/UI/Tracks/View.elm
···
15
import Maybe.Extra as Maybe
16
import Playlists exposing (Playlist)
17
import Tracks exposing (..)
18
import UI.Kit
19
import UI.Navigation exposing (..)
20
import UI.Page as Page
···
95
, favouritesOnly = model.favouritesOnly
96
, infiniteList = model.infiniteList
97
, isVisible = isOnIndexPage
98
-
, nowPlaying = model.nowPlaying
99
, selectedCover = model.selectedCover
100
, selectedTrackIndexes = model.selectedTrackIndexes
101
, sortBy = model.sortBy
···
126
model.tracks.harvested
127
model.infiniteList
128
model.favouritesOnly
129
-
model.nowPlaying
130
model.searchTerm
131
model.sortBy
132
model.sortDirection
···
15
import Maybe.Extra as Maybe
16
import Playlists exposing (Playlist)
17
import Tracks exposing (..)
18
+
import UI.Audio.Types exposing (nowPlayingIdentifiedTrack)
19
import UI.Kit
20
import UI.Navigation exposing (..)
21
import UI.Page as Page
···
96
, favouritesOnly = model.favouritesOnly
97
, infiniteList = model.infiniteList
98
, isVisible = isOnIndexPage
99
+
, nowPlaying = Maybe.map nowPlayingIdentifiedTrack model.nowPlaying
100
, selectedCover = model.selectedCover
101
, selectedTrackIndexes = model.selectedTrackIndexes
102
, sortBy = model.sortBy
···
127
model.tracks.harvested
128
model.infiniteList
129
model.favouritesOnly
130
+
(Maybe.map nowPlayingIdentifiedTrack model.nowPlaying)
131
model.searchTerm
132
model.sortBy
133
model.sortDirection
+15
-12
src/Applications/UI/Types.elm
+15
-12
src/Applications/UI/Types.elm
···
26
import Sources exposing (Source)
27
import Time
28
import Tracks exposing (..)
29
import UI.DnD as DnD
30
import UI.Page exposing (Page)
31
import UI.Queue.Types as Queue
···
83
-----------------------------------------
84
-- Audio
85
-----------------------------------------
86
-
, audioDuration : Float
87
-
, audioHasStalled : Bool
88
-
, audioIsLoading : Bool
89
-
, audioIsPlaying : Bool
90
-
, audioPosition : Float
91
, progress : Dict String Float
92
, rememberProgress : Bool
93
···
102
-----------------------------------------
103
-- Debouncing
104
-----------------------------------------
105
, resizeDebouncer : Debouncer Msg Msg
106
, searchDebouncer : Debouncer Msg Msg
107
···
132
-- Queue
133
-----------------------------------------
134
, dontPlay : List Queue.Item
135
-
, nowPlaying : Maybe Queue.Item
136
, playedPreviously : List Queue.Item
137
, playingNext : List Queue.Item
138
, selectedQueueItem : Maybe Queue.Item
···
199
-----------------------------------------
200
-- Audio
201
-----------------------------------------
202
| NoteProgress { trackId : String, progress : Float }
203
| Pause
204
| Play
205
-
| Seek Float
206
-
| SetAudioDuration Float
207
-
| SetAudioHasStalled Bool
208
-
| SetAudioIsLoading Bool
209
-
| SetAudioIsPlaying Bool
210
-
| SetAudioPosition Float
211
| Stop
212
| TogglePlay
213
| ToggleRememberProgress
···
26
import Sources exposing (Source)
27
import Time
28
import Tracks exposing (..)
29
+
import UI.Audio.Types exposing (DurationChangeEvent, ErrorAudioEvent, GenericAudioEvent, NowPlaying, PlaybackStateEvent, TimeUpdatedEvent)
30
import UI.DnD as DnD
31
import UI.Page exposing (Page)
32
import UI.Queue.Types as Queue
···
84
-----------------------------------------
85
-- Audio
86
-----------------------------------------
87
+
, audioElements : List Queue.EngineItem
88
+
, nowPlaying : Maybe NowPlaying
89
, progress : Dict String Float
90
, rememberProgress : Bool
91
···
100
-----------------------------------------
101
-- Debouncing
102
-----------------------------------------
103
+
, preloadDebouncer : Debouncer Msg Msg
104
+
, progressDebouncer : Debouncer Msg Msg
105
, resizeDebouncer : Debouncer Msg Msg
106
, searchDebouncer : Debouncer Msg Msg
107
···
132
-- Queue
133
-----------------------------------------
134
, dontPlay : List Queue.Item
135
, playedPreviously : List Queue.Item
136
, playingNext : List Queue.Item
137
, selectedQueueItem : Maybe Queue.Item
···
198
-----------------------------------------
199
-- Audio
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
209
| NoteProgress { trackId : String, progress : Float }
210
+
| NoteProgressDebounce (Debouncer.Msg Msg)
211
| Pause
212
| Play
213
+
| Seek { trackId : String, progress : Float }
214
| Stop
215
| TogglePlay
216
| ToggleRememberProgress
+1
-5
src/Applications/UI/User/State/Import.elm
+1
-5
src/Applications/UI/User/State/Import.elm
···
18
import UI.Equalizer.State as Equalizer
19
import UI.Page as Page
20
import UI.Playlists.Directory
21
-
import UI.Ports as Ports
22
import UI.Sources.State as Sources
23
import UI.Tracks.State as Tracks
24
import UI.Types as UI exposing (..)
···
223
, sortDirection = data.sortDirection
224
}
225
--
226
-
, Cmd.batch
227
-
[ Equalizer.adjustAllKnobs newEqualizerSettings
228
-
, Ports.setRepeat data.repeat
229
-
]
230
)
231
232
Err err ->
···
18
import UI.Equalizer.State as Equalizer
19
import UI.Page as Page
20
import UI.Playlists.Directory
21
import UI.Sources.State as Sources
22
import UI.Tracks.State as Tracks
23
import UI.Types as UI exposing (..)
···
222
, sortDirection = data.sortDirection
223
}
224
--
225
+
, Equalizer.adjustAllKnobs newEqualizerSettings
226
)
227
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
// Album Covers
3
// (◕‿◕✿)
4
5
import { transformUrl } from "../urls"
6
-
import * as processing from "../processing"
7
8
9
const REJECT = () => Promise.reject("No artwork found")
10
11
12
-
export function find(prep, app) {
13
-
return findUsingTags(prep, app)
14
.then(a => a ? a : findUsingMusicBrainz(prep))
15
.then(a => a ? a : findUsingLastFm(prep))
16
.then(a => a ? a : REJECT())
17
.then(a => a.type.startsWith("image/") ? a : REJECT())
18
-
}
19
-
20
-
21
-
function decodeCacheKey(cacheKey) {
22
-
return decodeURIComponent(escape(atob(cacheKey)))
23
}
24
25
···
27
// 1. TAGS
28
29
30
-
async function findUsingTags(prep, app) {
31
return Promise.all(
32
[
33
transformUrl(prep.trackHeadUrl, app),
···
53
// 2. MUSIC BRAINZ
54
55
56
-
function findUsingMusicBrainz(prep) {
57
const parts = decodeCacheKey(prep.cacheKey).split(" --- ")
58
const artist = parts[ 0 ]
59
const album = parts[ 1 ] || parts[ 0 ]
···
64
return fetch(`https://musicbrainz.org/ws/2/release/?query=${encodedQuery}&fmt=json`)
65
.then(r => r.json())
66
.then(r => musicBrainzCover(r.releases))
67
-
.catch(_ => REJECT())
68
}
69
70
···
90
// 3. LAST FM
91
92
93
-
function findUsingLastFm(prep) {
94
-
const query = decodeCacheKey(prep.cacheKey).replace(" --- ", " ")
95
96
return fetch(`https://ws.audioscrobbler.com/2.0/?method=album.search&album=${query}&api_key=4f0fe85b67baef8bb7d008a8754a95e5&format=json`)
97
.then(r => r.json())
98
.then(r => lastFmCover(r.results.albummatches.album))
99
-
.catch(_ => REJECT())
100
}
101
102
···
2
// Album Covers
3
// (◕‿◕✿)
4
5
+
import * as Uint8arrays from "uint8arrays"
6
+
7
+
import * as processing from "./processing"
8
+
import { type App } from "./elm/types"
9
import { transformUrl } from "../urls"
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
+
// ㊙️
106
107
108
const REJECT = () => Promise.reject("No artwork found")
109
110
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)
121
.then(a => a ? a : findUsingMusicBrainz(prep))
122
.then(a => a ? a : findUsingLastFm(prep))
123
.then(a => a ? a : REJECT())
124
.then(a => a.type.startsWith("image/") ? a : REJECT())
125
}
126
127
···
129
// 1. TAGS
130
131
132
+
async function findUsingTags(prep: CoverPrepWithUrls) {
133
return Promise.all(
134
[
135
transformUrl(prep.trackHeadUrl, app),
···
155
// 2. MUSIC BRAINZ
156
157
158
+
function findUsingMusicBrainz(prep: CoverPrepWithUrls) {
159
+
if (!navigator.onLine) return null
160
+
161
const parts = decodeCacheKey(prep.cacheKey).split(" --- ")
162
const artist = parts[ 0 ]
163
const album = parts[ 1 ] || parts[ 0 ]
···
168
return fetch(`https://musicbrainz.org/ws/2/release/?query=${encodedQuery}&fmt=json`)
169
.then(r => r.json())
170
.then(r => musicBrainzCover(r.releases))
171
}
172
173
···
193
// 3. LAST FM
194
195
196
+
function findUsingLastFm(prep: CoverPrepWithUrls) {
197
+
if (!navigator.onLine) return null
198
+
199
+
const query = encodeURIComponent(
200
+
decodeCacheKey(prep.cacheKey).replace(" --- ", " ")
201
+
)
202
203
return fetch(`https://ws.audioscrobbler.com/2.0/?method=album.search&album=${query}&api_key=4f0fe85b67baef8bb7d008a8754a95e5&format=json`)
204
.then(r => r.json())
205
.then(r => lastFmCover(r.results.albummatches.album))
206
}
207
208
+13
-11
src/Javascript/Brain/common.ts
+13
-11
src/Javascript/Brain/common.ts
···
13
// 🔱
14
15
16
-
export function isLocalHost(url) {
17
return (
18
url.startsWith("localhost") ||
19
url.startsWith("localhost") ||
···
23
}
24
25
26
-
export function parseJsonIfNeeded(a) {
27
if (typeof a === "string") return JSON.parse(a)
28
return a
29
}
···
57
// Cache
58
// -----
59
60
-
export function removeCache(key: string) {
61
return db().removeItem(key)
62
}
63
64
65
-
export function fromCache(key: string) {
66
return db().getItem(key)
67
}
68
69
70
-
export function toCache(key: string, data: unknown) {
71
return db().setItem(key, data)
72
}
73
···
76
// Crypto
77
// ------
78
79
-
export function decryptIfNeeded(data) {
80
if (typeof data !== "string") {
81
return Promise.resolve(data)
82
83
-
} else if (data.startsWith("{") || data.startsWith("[")) {
84
return Promise.resolve(data)
85
86
} else if (data.length < 15 && Number.isInteger(parseInt(data, 10))) {
···
100
101
export async function encryptIfPossible(unencryptedData: string): Promise<string> {
102
return unencryptedData
103
-
? getSecretKey()
104
-
.then(secretKey => crypto.encrypt(secretKey, unencryptedData))
105
-
.catch(_ => unencryptedData)
106
: unencryptedData
107
}
108
···
110
export { encryptIfPossible as encryptWithSecretKey }
111
112
113
-
export function getSecretKey() {
114
return db().getItem(SECRET_KEY_LOCATION)
115
}
···
13
// 🔱
14
15
16
+
export function isLocalHost(url: string) {
17
return (
18
url.startsWith("localhost") ||
19
url.startsWith("localhost") ||
···
23
}
24
25
26
+
export function parseJsonIfNeeded(a: unknown) {
27
if (typeof a === "string") return JSON.parse(a)
28
return a
29
}
···
57
// Cache
58
// -----
59
60
+
export function removeCache(key: string): Promise<void> {
61
return db().removeItem(key)
62
}
63
64
65
+
export function fromCache(key: string): Promise<unknown> {
66
return db().getItem(key)
67
}
68
69
70
+
export function toCache(key: string, data: unknown): Promise<unknown> {
71
return db().setItem(key, data)
72
}
73
···
76
// Crypto
77
// ------
78
79
+
export function decryptIfNeeded(data: unknown): Promise<unknown | null> {
80
if (typeof data !== "string") {
81
return Promise.resolve(data)
82
83
+
} else if (typeof data === "string" && (data.startsWith("{") || data.startsWith("["))) {
84
return Promise.resolve(data)
85
86
} else if (data.length < 15 && Number.isInteger(parseInt(data, 10))) {
···
100
101
export async function encryptIfPossible(unencryptedData: string): Promise<string> {
102
return unencryptedData
103
+
? getSecretKey().then(secretKey =>
104
+
secretKey
105
+
? crypto.encrypt(secretKey, unencryptedData)
106
+
: unencryptedData
107
+
)
108
: unencryptedData
109
}
110
···
112
export { encryptIfPossible as encryptWithSecretKey }
113
114
115
+
export function getSecretKey(): Promise<CryptoKey | null> {
116
return db().getItem(SECRET_KEY_LOCATION)
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
//
5
// This worker is responsible for everything non-UI.
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
-
293
294
295
// 🚀
296
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
335
336
-
await moveOldDbValue({ oldName: "AUTH_SECRET_KEY", newName: "SECRET_KEY" })
337
-
await moveOldDbValue({ oldName: "AUTH_ENCLOSED_DATA", newName: "ENCLOSED_DATA" })
338
339
-
const method = await fromCache("AUTH_METHOD")
340
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 })
348
349
-
await removeCache("AUTH_METHOD")
350
351
-
} else if (method) {
352
-
await toCache("SYNC_METHOD", method)
353
-
await removeCache("AUTH_METHOD")
354
355
-
}
356
-
357
-
await toCache("MIGRATED", "3.3.0")
358
-
}
359
-
360
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
-
}
···
4
//
5
// This worker is responsible for everything non-UI.
6
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"
15
16
17
// 🚀
18
19
+
TaskPorts.register()
20
+
User.TaskPorts.register()
21
22
+
const app = Application.load()
23
+
const brain = self as unknown as Worker
24
25
+
// 🖼️
26
27
+
UI.link(brain, app)
28
29
+
// ⚡
30
+
Artwork.init(app)
31
+
Processing.init(app)
32
+
Search.init(app)
33
+
Tracks.init(app)
34
35
+
User.Ports.register(app)
36
37
+
// 🛫
38
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
8
// @ts-ignore
9
import * as TaskPort from "elm-taskport"
10
-
import { APP_INFO, ODD_CONFIG } from "../common"
11
12
import * as crypto from "../crypto"
13
14
import { decryptIfNeeded, encryptIfPossible, SECRET_KEY_LOCATION } from "./common"
15
import { parseJsonIfNeeded, removeCache, toCache } from "./common"
16
···
299
// EXPORT
300
// ======
301
302
-
export function setupPorts(app) {
303
Object.keys(ports).forEach(name => {
304
const fn = ports[ name ](app)
305
app.ports[ name ].subscribe(fn)
306
})
307
}
308
309
-
export function setupTaskPorts() {
310
Object.keys(taskPorts).forEach(name => {
311
const fn = taskPorts[ name ]
312
TaskPort.register(name, fn)
313
})
314
}
···
7
8
// @ts-ignore
9
import * as TaskPort from "elm-taskport"
10
+
11
+
import type { App } from "./elm/types"
12
13
import * as crypto from "../crypto"
14
15
+
import { APP_INFO, ODD_CONFIG } from "../common"
16
import { decryptIfNeeded, encryptIfPossible, SECRET_KEY_LOCATION } from "./common"
17
import { parseJsonIfNeeded, removeCache, toCache } from "./common"
18
···
301
// EXPORT
302
// ======
303
304
+
function registerPorts(app: App) {
305
Object.keys(ports).forEach(name => {
306
const fn = ports[ name ](app)
307
app.ports[ name ].subscribe(fn)
308
})
309
}
310
311
+
function registerTaskPorts() {
312
Object.keys(taskPorts).forEach(name => {
313
const fn = taskPorts[ name ]
314
TaskPort.register(name, fn)
315
})
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
)
18
19
20
-
let index
21
22
23
24
// Incoming messages
25
// -----------------
26
27
-
self.onmessage = event => {
28
switch (event.data.action) {
29
case "PERFORM_SEARCH":
30
performSearch(event.data.data)
···
53
// Actions
54
// -------
55
56
-
function performSearch(rawSearchTerm) {
57
-
let results =
58
[]
59
60
const searchTerm = rawSearchTerm
···
62
.replace(/\+\s+/g, "+")
63
.split(/ +/)
64
.reduce(
65
-
([ acc, previousOperator, previousPrefix ], chunk) => {
66
const operator = (a => a && a[0])( chunk.match(/^(\+|-)/) )
67
68
let chunkWithoutOperator = chunk.replace(/^(\+|-)/, "").replace(/\*$/, "").trim()
···
123
}
124
125
126
-
function updateSearchIndex(input) {
127
const tracks = (typeof input == "string")
128
? JSON.parse(input)
129
: input
130
131
-
index = customLunr(function() {
132
FIELDS.forEach(
133
-
field => this.field(field)
134
)
135
136
;(tracks || [])
137
.map(mapTrack)
138
-
.forEach(t => this.add(t))
139
})
140
}
141
142
143
144
-
function customLunr(config) {
145
const builder = new lunr.Builder
146
147
builder.pipeline.add(removeParenthesesFromToken, lunr.stemmer)
148
builder.searchPipeline.add(removeParenthesesFromToken, lunr.stemmer)
149
150
-
config.call(builder, builder)
151
return builder.build()
152
}
153
154
155
-
function removeParenthesesFromToken(token) {
156
return token.update(s => s.replace(/\(|\)/, ""))
157
}
···
17
)
18
19
20
+
let index: lunr.Index
21
22
23
24
// Incoming messages
25
// -----------------
26
27
+
self.onmessage = (event: MessageEvent) => {
28
switch (event.data.action) {
29
case "PERFORM_SEARCH":
30
performSearch(event.data.data)
···
53
// Actions
54
// -------
55
56
+
function performSearch(rawSearchTerm: string) {
57
+
let results: string[] =
58
[]
59
60
const searchTerm = rawSearchTerm
···
62
.replace(/\+\s+/g, "+")
63
.split(/ +/)
64
.reduce(
65
+
([ acc, previousOperator, previousPrefix ]: [ string[], string, string ], chunk: string): [ string[], string, string ] => {
66
const operator = (a => a && a[0])( chunk.match(/^(\+|-)/) )
67
68
let chunkWithoutOperator = chunk.replace(/^(\+|-)/, "").replace(/\*$/, "").trim()
···
123
}
124
125
126
+
function updateSearchIndex(input: string | object[]) {
127
const tracks = (typeof input == "string")
128
? JSON.parse(input)
129
: input
130
131
+
index = customLunr((builder: lunr.Builder) => {
132
FIELDS.forEach(
133
+
field => builder.field(field)
134
)
135
136
;(tracks || [])
137
.map(mapTrack)
138
+
.forEach(t => builder.add(t))
139
})
140
}
141
142
143
144
+
function customLunr(fn: (b: lunr.Builder) => void) {
145
const builder = new lunr.Builder
146
147
builder.pipeline.add(removeParenthesesFromToken, lunr.stemmer)
148
builder.searchPipeline.add(removeParenthesesFromToken, lunr.stemmer)
149
150
+
fn(builder)
151
return builder.build()
152
}
153
154
155
+
function removeParenthesesFromToken(token: lunr.Token): lunr.Token {
156
return token.update(s => s.replace(/\(|\)/, ""))
157
}
+6
-8
src/Javascript/Workers/service.ts
+6
-8
src/Javascript/Workers/service.ts
···
9
//
10
/// <reference lib="webworker" />
11
12
-
import { } from "../index.d"
13
-
14
15
const KEY =
16
/* eslint-disable no-undef */
···
19
20
const EXCLUDE =
21
[ "_headers"
22
-
, "_redirects"
23
-
, "CORS"
24
]
25
26
···
39
// 📣
40
41
42
-
self.addEventListener("activate", _event => {
43
// Remove all caches except the one with the currently used `KEY`
44
caches.keys().then(keys => {
45
keys.forEach(k => {
···
82
})()
83
)
84
85
-
// When doing a request with basic authentication in the url, put it in the headers instead
86
} else if (event.request.url.includes("service_worker_authentication=")) {
87
const url = new URL(event.request.url)
88
const token = url.searchParams.get("service_worker_authentication")
···
96
"Basic " + token
97
)
98
99
-
// When doing a request with access token in the url, put it in the headers instead
100
} else if (event.request.url.includes("bearer_token=")) {
101
const url = new URL(event.request.url)
102
const token = url.searchParams.get("bearer_token")
···
112
"Bearer " + token
113
)
114
115
-
// Use cache if internal request and not using native app
116
} else if (isInternal) {
117
event.respondWith(
118
isNativeWrapper
···
9
//
10
/// <reference lib="webworker" />
11
12
13
const KEY =
14
/* eslint-disable no-undef */
···
17
18
const EXCLUDE =
19
[ "_headers"
20
+
, "_redirects"
21
+
, "CORS"
22
]
23
24
···
37
// 📣
38
39
40
+
self.addEventListener("activate", () => {
41
// Remove all caches except the one with the currently used `KEY`
42
caches.keys().then(keys => {
43
keys.forEach(k => {
···
80
})()
81
)
82
83
+
// When doing a request with basic authentication in the url, put it in the headers instead
84
} else if (event.request.url.includes("service_worker_authentication=")) {
85
const url = new URL(event.request.url)
86
const token = url.searchParams.get("service_worker_authentication")
···
94
"Basic " + token
95
)
96
97
+
// When doing a request with access token in the url, put it in the headers instead
98
} else if (event.request.url.includes("bearer_token=")) {
99
const url = new URL(event.request.url)
100
const token = url.searchParams.get("bearer_token")
···
110
"Bearer " + token
111
)
112
113
+
// Use cache if internal request and not using native app
114
} else if (isInternal) {
115
event.respondWith(
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
const extractable = false
12
13
14
-
export function keyFromPassphrase(passphrase) {
15
-
return crypto.subtle.importKey(
16
"raw",
17
Uint8arrays.fromString(passphrase, "utf8"),
18
{
···
20
},
21
false,
22
[ "deriveKey" ]
23
24
-
).then(baseKey => crypto.subtle.deriveKey(
25
{
26
name: "PBKDF2",
27
salt: Uint8arrays.fromString("diffuse", "utf8"),
···
35
},
36
extractable,
37
[ "encrypt", "decrypt" ]
38
-
39
-
))
40
}
41
42
43
-
export function encrypt(key, string) {
44
-
let iv = crypto.getRandomValues(new Uint8Array(12))
45
46
-
return crypto.subtle.encrypt(
47
{
48
name: "AES-GCM",
49
iv: iv,
···
51
},
52
key,
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
59
60
-
})
61
}
62
63
64
-
export function decrypt(key, string) {
65
const iv_b64 = string.substring(0, 16)
66
const buf_b64 = string.substring(16)
67
68
const iv = Uint8arrays.fromString(iv_b64, "base64pad")
69
const buf = Uint8arrays.fromString(buf_b64, "base64pad")
70
71
-
return crypto.subtle.decrypt(
72
{
73
name: "AES-GCM",
74
iv: iv,
···
76
},
77
key,
78
buf
79
-
80
-
).then(
81
-
buffer => Uint8arrays.toString(
82
-
new Uint8Array(buffer),
83
-
"utf8"
84
-
)
85
86
)
87
}
···
11
const extractable = false
12
13
14
+
export async function keyFromPassphrase(passphrase: string): Promise<CryptoKey> {
15
+
const baseKey = await crypto.subtle.importKey(
16
"raw",
17
Uint8arrays.fromString(passphrase, "utf8"),
18
{
···
20
},
21
false,
22
[ "deriveKey" ]
23
+
)
24
25
+
return await crypto.subtle.deriveKey(
26
{
27
name: "PBKDF2",
28
salt: Uint8arrays.fromString("diffuse", "utf8"),
···
36
},
37
extractable,
38
[ "encrypt", "decrypt" ]
39
+
)
40
}
41
42
43
+
export async function encrypt(key: CryptoKey, string: string): Promise<string> {
44
+
const iv = crypto.getRandomValues(new Uint8Array(12))
45
46
+
const buf = await crypto.subtle.encrypt(
47
{
48
name: "AES-GCM",
49
iv: iv,
···
51
},
52
key,
53
Uint8arrays.fromString(string, "base64pad")
54
+
)
55
56
+
const iv_b64 = Uint8arrays.toString(iv, "base64pad")
57
+
const buf_b64 = Uint8arrays.toString(new Uint8Array(buf), "base64pad")
58
+
return iv_b64 + buf_b64
59
}
60
61
62
+
export async function decrypt(key: CryptoKey, string: string): Promise<string> {
63
const iv_b64 = string.substring(0, 16)
64
const buf_b64 = string.substring(16)
65
66
const iv = Uint8arrays.fromString(iv_b64, "base64pad")
67
const buf = Uint8arrays.fromString(buf_b64, "base64pad")
68
69
+
const decrypted = await crypto.subtle.decrypt(
70
{
71
name: "AES-GCM",
72
iv: iv,
···
74
},
75
key,
76
buf
77
+
)
78
79
+
return Uint8arrays.toString(
80
+
new Uint8Array(decrypted),
81
+
"utf8"
82
)
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
//
5
// Audio processing, getting metadata, etc.
6
7
-
import type { IAudioMetadata } from "music-metadata";
8
-
import type { MediaInfoType } from "mediainfo.js";
9
10
-
import * as Uint8arrays from "uint8arrays";
11
-
import { transformUrl } from "./urls";
12
13
// Contexts
14
// --------
15
16
export async function processContext(context, app) {
17
const initialPromise = Promise.resolve([]);
···
40
});
41
}
42
43
// Tags - General
44
// --------------
45
46
type Tags = {
47
disc: number;
···
63
const musicMetadata = await import("music-metadata-browser").then((a) => a.default);
64
const httpTokenizer = await import("@tokenizer/http").then((a) => a.default);
65
66
-
let tokenizer
67
-
let mmResult
68
69
try {
70
tokenizer = await httpTokenizer.makeTokenizer(headUrl);
···
78
tokenizer.rangeRequestClient.resolvedUrl = undefined;
79
}
80
81
-
mmResult = await musicMetadata.parseFromTokenizer(
82
-
tokenizer,
83
-
{ skipCovers: !covers }
84
-
).catch(err => {
85
-
console.warn(err)
86
-
return null
87
-
});
88
} catch (err) {
89
-
console.warn(err)
90
}
91
92
const mmTags = mmResult && pickTagsFromMusicMetadata(filename, mmResult);
···
94
95
const miResult = await (await mediaInfoClient(covers))
96
.analyzeData(getSize(headUrl), readChunk(getUrl))
97
-
.catch(err => {
98
-
console.warn(err)
99
-
return null
100
});
101
102
const miTags = miResult && pickTagsFromMediaInfo(filename, miResult);
···
164
return new Uint8Array(await response.arrayBuffer());
165
};
166
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;
170
171
let artist = typeof tags.Performer == "string" ? tags.Performer : null;
172
-
const album = typeof tags.Album == "string" ? tags.Album : null;
173
174
-
const title = typeof tags.Track == "string"
175
-
? tags.Track
176
-
: typeof tags.Title == "string"
177
-
? tags.Title
178
-
: null;
179
180
if (!artist && !title) return null;
181
182
// TODO: Encoding issues with mediainfo.js
183
-
if (artist?.includes("�") || album?.includes("�") || title?.includes("�")) return null
184
185
if (artist && artist.includes(" / ")) {
186
artist = artist
···
201
year: year !== null && isNaN(year) ? null : year,
202
picture: tags.Cover_Data
203
? {
204
-
data: Uint8arrays.fromString(tags.Cover_Data, "base64"),
205
format: tags.Cover_Mime || "image/jpeg",
206
}
207
: null,
208
};
209
}
210
211
// Tags - Music Metadata
212
// ---------------------
213
214
function pickTagsFromMusicMetadata(filename: string, result: IAudioMetadata): Tags | null {
215
const tags = result && result.common;
···
235
};
236
}
237
238
// 🛠️
239
-
// --
240
241
async function mediaInfoClient(covers: boolean) {
242
-
const MediaInfoFactory = await import("mediainfo.js").then(a => a.default)
243
244
return await MediaInfoFactory({
245
coverData: covers,
···
4
//
5
// Audio processing, getting metadata, etc.
6
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
+
53
54
55
// Contexts
56
// --------
57
+
58
59
export async function processContext(context, app) {
60
const initialPromise = Promise.resolve([]);
···
83
});
84
}
85
86
+
87
+
88
// Tags - General
89
// --------------
90
+
91
92
type Tags = {
93
disc: number;
···
109
const musicMetadata = await import("music-metadata-browser").then((a) => a.default);
110
const httpTokenizer = await import("@tokenizer/http").then((a) => a.default);
111
112
+
let tokenizer;
113
+
let mmResult;
114
115
try {
116
tokenizer = await httpTokenizer.makeTokenizer(headUrl);
···
124
tokenizer.rangeRequestClient.resolvedUrl = undefined;
125
}
126
127
+
mmResult = await musicMetadata
128
+
.parseFromTokenizer(tokenizer, { skipCovers: !covers })
129
+
.catch((err) => {
130
+
console.warn(err);
131
+
return null;
132
+
});
133
} catch (err) {
134
+
console.warn(err);
135
}
136
137
const mmTags = mmResult && pickTagsFromMusicMetadata(filename, mmResult);
···
139
140
const miResult = await (await mediaInfoClient(covers))
141
.analyzeData(getSize(headUrl), readChunk(getUrl))
142
+
.catch((err) => {
143
+
console.warn(err);
144
+
return null;
145
});
146
147
const miTags = miResult && pickTagsFromMediaInfo(filename, miResult);
···
209
return new Uint8Array(await response.arrayBuffer());
210
};
211
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;
216
217
let artist = typeof tags.Performer == "string" ? tags.Performer : null;
218
+
let album = typeof tags.Album == "string" ? tags.Album : null;
219
220
+
let title =
221
+
typeof tags.Track == "string" ? tags.Track : typeof tags.Title == "string" ? tags.Title : null;
222
223
if (!artist && !title) return null;
224
225
// TODO: Encoding issues with mediainfo.js
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)
230
231
if (artist && artist.includes(" / ")) {
232
artist = artist
···
247
year: year !== null && isNaN(year) ? null : year,
248
picture: tags.Cover_Data
249
? {
250
+
data: Uint8arrays.fromString(tags.Cover_Data.split(" / ")[0], "base64pad"),
251
format: tags.Cover_Mime || "image/jpeg",
252
}
253
: null,
254
};
255
}
256
257
+
258
// Tags - Music Metadata
259
// ---------------------
260
+
261
262
function pickTagsFromMusicMetadata(filename: string, result: IAudioMetadata): Tags | null {
263
const tags = result && result.common;
···
283
};
284
}
285
286
+
287
+
288
// 🛠️
289
+
290
291
async function mediaInfoClient(covers: boolean) {
292
+
const MediaInfoFactory = await import("mediainfo.js").then((a) => a.default);
293
294
return await MediaInfoFactory({
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
22
type alias EngineItem =
23
{ isCached : Bool
24
, progress : Maybe Float
25
, sourceId : String
26
, trackId : String
···
34
-- 🔱
35
36
37
-
makeEngineItem : Time.Posix -> List Source -> List String -> Dict String Float -> Track -> EngineItem
38
-
makeEngineItem timestamp sources cachedTrackIds progressTable track =
39
{ isCached = List.member track.id cachedTrackIds
40
, progress = Dict.get track.id progressTable
41
, sourceId = track.sourceId
42
, trackId = track.id
···
21
22
type alias EngineItem =
23
{ isCached : Bool
24
+
, isPreload : Bool
25
, progress : Maybe Float
26
, sourceId : String
27
, trackId : String
···
35
-- 🔱
36
37
38
+
makeEngineItem : Bool -> Time.Posix -> List Source -> List String -> Dict String Float -> Track -> EngineItem
39
+
makeEngineItem preload timestamp sources cachedTrackIds progressTable track =
40
{ isCached = List.member track.id cachedTrackIds
41
+
, isPreload = preload
42
, progress = Dict.get track.id progressTable
43
, sourceId = track.sourceId
44
, trackId = track.id
+5
src/Library/Tracks.elm
+5
src/Library/Tracks.elm
+1
-1
src/Static/Manifests/manifest.json
+1
-1
src/Static/Manifests/manifest.json