src
pages
constituent
engine
orchestrator
scripts
···
409
409
engine.audio,
410
410
(data) =>
411
411
data.isPlaying && (data.items[engine.queue.data.now?.id ?? Infinity]?.isPlaying ?? false),
412
412
-
(isPlaying) => setTimeout(() => setIsPlaying(isPlaying), 0),
412
412
+
(isPlaying) => setIsPlaying(isPlaying),
413
413
);
414
414
415
415
reactive(
416
416
engine.audio,
417
417
(data) => data.items[engine.queue.data.now?.id ?? Infinity]?.progress ?? 0,
418
418
-
(progress) => setTimeout(() => setProgress(progress), 0),
418
418
+
(progress) => setProgress(progress),
419
419
);
420
420
421
421
reactive(
422
422
engine.audio,
423
423
(data) => data.volume.default,
424
424
-
(volume) => setTimeout(() => setVolume(volume), 0),
424
424
+
(volume) => setVolume(volume),
425
425
);
426
426
427
427
////////////////////////////////////////////
···
460
460
},
461
461
);
462
462
463
463
-
setArtwork(art);
463
463
+
const currTrack = activeTrack();
464
464
+
const currCacheId = currTrack ? await trackArtworkCacheId(currTrack) : undefined;
465
465
+
if (cacheId === currCacheId) setArtwork(art);
464
466
},
465
467
);
466
468
···
486
488
487
489
effect(() => {
488
490
const art = artwork();
491
491
+
492
492
+
console.log("ART", art[0]);
489
493
490
494
// TODO: Remove existing art?
491
495
if (art.length === 0) {
···
29
29
// ACTIONS
30
30
////////////////////////////////////////////
31
31
context.setActionHandler("add", add);
32
32
-
context.setActionHandler("fill", fill);
32
32
+
context.setActionHandler("pool", pool);
33
33
context.setActionHandler("shift", shift);
34
34
context.setActionHandler("unshift", unshift);
35
35
···
37
37
context.data = await worker.call.add(context.data, items);
38
38
}
39
39
40
40
-
async function fill(availableItems: Track[]) {
41
41
-
context.data = await worker.call.fill(context.data, availableItems);
40
40
+
async function pool(items: Track[]) {
41
41
+
context.data = await worker.call.pool(context.data, items);
42
42
}
43
43
44
44
async function shift() {
···
5
5
"actions": {
6
6
"add": {
7
7
"title": "Add",
8
8
-
"description": "Add items to the queue.",
8
8
+
"description": "Add tracks to the queue.",
9
9
"params_schema": {
10
10
"type": "array",
11
11
+
"description": "Array of tracks",
11
12
"items": {
12
12
-
"anyOf": [
13
13
-
{
14
14
-
"type": "object",
15
15
-
"properties": {
16
16
-
"id": { "type": "string" },
17
17
-
"uri": { "type": "string" }
18
18
-
},
19
19
-
"required": ["id", "uri"]
20
20
-
}
21
21
-
]
13
13
+
"type": "object",
14
14
+
"properties": {
15
15
+
"id": { "type": "string" },
16
16
+
"uri": { "type": "string" }
17
17
+
},
18
18
+
"required": ["id", "uri"]
19
19
+
}
20
20
+
}
21
21
+
},
22
22
+
"pool": {
23
23
+
"title": "Pool",
24
24
+
"description": "Set the queue pool.",
25
25
+
"params_schema": {
26
26
+
"type": "array",
27
27
+
"description": "Array of tracks",
28
28
+
"items": {
29
29
+
"type": "object",
30
30
+
"properties": {
31
31
+
"id": { "type": "string" },
32
32
+
"uri": { "type": "string" }
33
33
+
},
34
34
+
"required": ["id", "uri"]
22
35
}
23
36
}
24
37
},
···
40
40
{ timeoutDuration: 60000 * 5 },
41
41
);
42
42
43
43
+
// Available tracks
43
44
const tracks = Object.values(groups).reduce((acc: Track[], value) => {
44
45
if (value.available === false) return acc;
45
46
return [...acc, ...value.tracks];
46
47
}, []);
47
48
48
48
-
engine.queue.sendAction("fill", tracks);
49
49
+
// Clear
50
50
+
engine.queue.sendAction("pool", tracks);
49
51
},
50
52
);
51
53
});
···
292
292
export function reactive<D, T>(
293
293
applet: Applet<D> | AppletScope<D>,
294
294
dataFn: (data: D) => T,
295
295
-
effectFn: (t: T, setter: (t: T) => void) => void,
295
295
+
effectFn: (t: T) => void,
296
296
) {
297
297
-
const [getter, setter] = signal(dataFn(applet.data));
298
298
-
299
299
-
effect(() => {
300
300
-
effectFn(getter(), setter);
301
301
-
});
297
297
+
let value = dataFn(applet.data);
298
298
+
effectFn(value);
302
299
303
300
applet.addEventListener("data", (event: AppletEvent) => {
304
304
-
setter(dataFn(event.data));
301
301
+
const newData = dataFn(event.data);
302
302
+
if (newData !== value) {
303
303
+
value = newData;
304
304
+
effectFn(value);
305
305
+
}
305
306
});
306
307
}
307
308
···
46
46
return xxh32(JSON.stringify(value));
47
47
}
48
48
49
49
-
export function endpoint<T extends Record<string, any>>(port: MessagePort) {
50
50
-
const e = createEndpoint<T>(port);
51
51
-
if ("start" in port) port.start();
52
52
-
else
53
53
-
console.warn("Missing `start` function in port, probably using a regular worker:", port as any);
49
49
+
export function endpoint<T extends Record<string, any>>(ini: MessageEndpoint) {
50
50
+
const e = createEndpoint<T>(ini);
51
51
+
if ("start" in ini && typeof ini.start === "function") ini.start();
54
52
return e;
55
53
}
56
54
57
55
export function expose<T extends Record<string, any>>(actions: T): T {
58
58
-
(self as unknown as SharedWorkerGlobalScope).onconnect = (event: MessageEvent) => {
59
59
-
const port = event.ports[0];
60
60
-
createEndpoint<T>(port).expose(actions);
61
61
-
port.start();
62
62
-
};
56
56
+
if (globalThis.SharedWorkerGlobalScope && self instanceof SharedWorkerGlobalScope) {
57
57
+
self.onconnect = (event: MessageEvent) => {
58
58
+
const port = event.ports[0];
59
59
+
createEndpoint<T>(port).expose(actions);
60
60
+
port.start();
61
61
+
};
62
62
+
63
63
+
(self as any).connected = true;
64
64
+
} else {
65
65
+
createEndpoint<T>(self).expose(actions);
66
66
+
}
63
67
64
68
return actions;
65
69
}
···
3
3
import { expose } from "@scripts/common.ts";
4
4
5
5
////////////////////////////////////////////
6
6
+
// STATE
7
7
+
////////////////////////////////////////////
8
8
+
9
9
+
const QUEUE_SIZE = 25;
10
10
+
11
11
+
const internal: { pool: Track[] } = {
12
12
+
pool: [],
13
13
+
};
14
14
+
15
15
+
////////////////////////////////////////////
6
16
// ACTIONS
7
17
////////////////////////////////////////////
8
18
const actions = expose({
9
19
add,
10
10
-
fill,
20
20
+
pool,
11
21
shift,
12
22
unshift,
13
23
});
···
20
30
return { ...state, future: [...state.future, ...items] };
21
31
}
22
32
23
23
-
// TODO: Shuffle, limit track amount, etc.
24
24
-
function fill(state: State, availableItems: Track[]): State {
25
25
-
state = add(state, availableItems);
33
33
+
function pool(state: State, tracks: Track[]): State {
34
34
+
internal.pool = tracks;
35
35
+
36
36
+
// TODO: If the pool changes, only remove non-existing tracks
37
37
+
// instead of resetting the whole future queue.
38
38
+
//
39
39
+
// What about past queue items?
40
40
+
41
41
+
state = fill({ ...state, future: [] });
26
42
27
43
// Automatically insert track if there isn't any
28
44
if (!state.now) return shift(state);
···
34
50
const future = state.future.slice(1);
35
51
const past = state.now ? [...state.past, state.now] : state.past;
36
52
37
37
-
return { past, now, future };
53
53
+
return fill({ past, now, future });
38
54
}
39
55
40
56
function unshift(state: State): State {
···
47
63
48
64
return { past, now, future };
49
65
}
66
66
+
67
67
+
// 🛠️
68
68
+
69
69
+
// TODO: Shuffle, limit track amount, etc.
70
70
+
function fill(state: State): State {
71
71
+
return state.future.length < QUEUE_SIZE
72
72
+
? add(
73
73
+
state,
74
74
+
internal.pool.slice(
75
75
+
state.past.length,
76
76
+
state.past.length + (QUEUE_SIZE - state.future.length),
77
77
+
),
78
78
+
)
79
79
+
: state;
80
80
+
}
···
1
1
import type { IPicture } from "music-metadata";
2
2
import * as IDB from "idb-keyval";
3
3
4
4
-
import type { Actions as MetadataActions } from "../metadata/worker";
5
4
import type { Artwork, ArtworkRequest } from "./types";
6
5
import { expose } from "@scripts/common";
7
6
import { IDB_ARTWORK_PREFIX } from "./constants";
8
8
-
import { createEndpoint } from "@remote-ui/rpc";
7
7
+
import { musicMetadataTags } from "../metadata/common";
9
8
10
9
// State
11
10
let queue: ArtworkRequest[] = [];
12
11
13
13
-
// Metadata worker
14
14
-
const metadataWorker = createEndpoint<MetadataActions>(
15
15
-
new Worker(new URL("../metadata/worker", import.meta.url), { type: "module" }),
16
16
-
);
17
17
-
18
12
////////////////////////////////////////////
19
13
// ACTIONS
20
14
////////////////////////////////////////////
···
28
22
// Actions
29
23
30
24
function artwork(request: ArtworkRequest) {
25
25
+
console.log("INSERT REQ", request);
31
26
return processRequest(request);
32
27
}
33
28
···
49
44
`https://ws.audioscrobbler.com/2.0/?method=album.search&album=${query}&api_key=4f0fe85b67baef8bb7d008a8754a95e5&format=json`,
50
45
)
51
46
.then((r) => r.json())
52
52
-
.then((r) => lastFmCover(r.results.albummatches.album));
47
47
+
.then((r) => lastFmCover(r.results.albummatches.album))
48
48
+
.catch((err) => {
49
49
+
console.error(err);
50
50
+
return [];
51
51
+
});
53
52
}
54
53
55
55
-
function lastFmCover(remainingMatches: any[]): Promise<Artwork[]> {
54
54
+
async function lastFmCover(remainingMatches: any[]): Promise<Artwork[]> {
56
55
const album = remainingMatches[0];
57
56
const url = album ? album.image[album.image.length - 1]["#text"] : null;
58
57
59
58
return url && url !== ""
60
60
-
? fetch(url)
59
59
+
? await fetch(url)
61
60
.then((r) => r.blob())
62
62
-
.then(async (b) => [{ bytes: await b.bytes(), mime: b.type }])
63
63
-
.catch((_) => lastFmCover(remainingMatches.slice(1)))
64
64
-
: album && lastFmCover(remainingMatches.slice(1));
61
61
+
.then(async (b) => [
62
62
+
{ bytes: await b.arrayBuffer().then((buf) => new Uint8Array(buf)), mime: b.type },
63
63
+
])
64
64
+
.catch((err) => {
65
65
+
console.error(err);
66
66
+
return lastFmCover(remainingMatches.slice(1));
67
67
+
})
68
68
+
: album
69
69
+
? lastFmCover(remainingMatches.slice(1))
70
70
+
: [];
65
71
}
66
72
67
73
async function musicBrainz(req: ArtworkRequest): Promise<Artwork[]> {
···
79
85
80
86
return await fetch(`https://musicbrainz.org/ws/2/release/?query=${encodedQuery}&fmt=json`)
81
87
.then((r) => r.json())
82
82
-
.then((r) => musicBrainzCover(r.releases));
88
88
+
.then((r) => musicBrainzCover(r.releases))
89
89
+
.catch((err) => {
90
90
+
console.error(err);
91
91
+
return [];
92
92
+
});
83
93
}
84
94
85
95
async function musicBrainzCover(remainingReleases: any[]): Promise<Artwork[]> {
···
89
99
return await fetch(`https://coverartarchive.org/release/${release.id}/front-500`)
90
100
.then((r) => r.blob())
91
101
.then(async (b) => {
92
92
-
if (b && b.type.startsWith("image/")) {
93
93
-
return [{ bytes: await b.bytes(), mime: b.type }];
102
102
+
if (b.type.startsWith("image/")) {
103
103
+
return [{ bytes: await b.arrayBuffer().then((buf) => new Uint8Array(buf)), mime: b.type }];
94
104
} else {
95
105
return musicBrainzCover(remainingReleases.slice(1));
96
106
}
97
107
})
98
98
-
.catch(() => musicBrainzCover(remainingReleases.slice(1)));
108
108
+
.catch((err) => {
109
109
+
console.error(err);
110
110
+
return musicBrainzCover(remainingReleases.slice(1));
111
111
+
});
99
112
}
100
113
101
114
async function processRequest(req: ArtworkRequest): Promise<Artwork[]> {
102
115
// Check if already processed
103
116
// TODO: Retry if none was found?
104
117
const cache = await IDB.get(`${IDB_ARTWORK_PREFIX}/${req.cacheId}`);
105
105
-
if (cache) return cache;
118
118
+
if (cache && Array.isArray(cache) && cache.length) return cache;
106
119
107
120
// 🚀
108
121
let art: Artwork[] = [];
109
122
110
123
// Get metadata + possible artwork from file metadata
111
111
-
const meta = await metadataWorker.call.supply({ ...req, includeArtwork: true });
124
124
+
console.log("ART REQ", req);
125
125
+
const meta = await musicMetadataTags({ ...req, includeArtwork: true });
126
126
+
console.log("ART META", meta);
112
127
if (!req.tags) req.tags = meta.tags;
113
128
114
129
// Add artwork from metadata
···
1
1
+
import { parseBlob, parseFromTokenizer, parseWebStream } from "music-metadata";
2
2
+
import { contentType } from "@std/media-types";
3
3
+
import * as URI from "uri-js";
4
4
+
import * as HTTP_TOKENIZER from "@tokenizer/http";
5
5
+
import * as RANGE_TOKENIZER from "@tokenizer/range";
6
6
+
7
7
+
import type { TrackStats, TrackTags } from "@applets/core/types";
8
8
+
import type { Extraction, Urls } from "./types";
9
9
+
10
10
+
// 🛠️
11
11
+
12
12
+
export async function musicMetadataTags({
13
13
+
includeArtwork,
14
14
+
mimeType,
15
15
+
stream,
16
16
+
urls,
17
17
+
}: {
18
18
+
includeArtwork?: boolean;
19
19
+
mimeType?: string;
20
20
+
stream?: ReadableStream;
21
21
+
urls?: Urls;
22
22
+
}): Promise<Extraction> {
23
23
+
const uri = urls ? URI.parse(urls.get) : undefined;
24
24
+
const pathParts = uri?.path?.split("/");
25
25
+
const filename = pathParts?.[pathParts.length - 1];
26
26
+
27
27
+
let meta;
28
28
+
29
29
+
console.log(urls?.get, stream, includeArtwork);
30
30
+
31
31
+
if (urls?.get.startsWith("blob:")) {
32
32
+
const blob = await fetch(urls.get).then((r) => r.blob());
33
33
+
meta = await parseBlob(blob, { skipCovers: !includeArtwork });
34
34
+
} else if (urls) {
35
35
+
const httpClient = new HTTP_TOKENIZER.HttpClient(urls.head, { resolveUrl: false });
36
36
+
httpClient.resolvedUrl = urls.get;
37
37
+
38
38
+
const tokenizer = await RANGE_TOKENIZER.tokenizer(httpClient);
39
39
+
40
40
+
meta = await parseFromTokenizer(tokenizer, { skipCovers: !includeArtwork });
41
41
+
} else if (stream) {
42
42
+
meta = await parseWebStream(stream, { mimeType }, { skipCovers: !includeArtwork });
43
43
+
} else {
44
44
+
throw new Error("Missing args, need either some urls or a stream.");
45
45
+
}
46
46
+
47
47
+
const stats: TrackStats = {
48
48
+
duration: meta.format.duration,
49
49
+
};
50
50
+
51
51
+
const tags: TrackTags = {
52
52
+
album: meta.common.album,
53
53
+
artist: meta.common.artist,
54
54
+
disc: { no: meta.common.disk.no || 1, of: meta.common.disk.of ?? undefined },
55
55
+
genre: Array.isArray(meta.common.genre) ? meta.common.genre[0] : meta.common.genre,
56
56
+
title: meta.common.title || filename || urls?.head || "Unknown",
57
57
+
track: { no: meta.common.track.no || 1, of: meta.common.track.of ?? undefined },
58
58
+
year: meta.common.year,
59
59
+
};
60
60
+
61
61
+
return {
62
62
+
artwork: includeArtwork ? meta.common.picture : undefined,
63
63
+
stats,
64
64
+
tags,
65
65
+
};
66
66
+
}
···
1
1
-
import { parseFromTokenizer, parseWebStream } from "music-metadata";
2
2
-
import { contentType } from "@std/media-types";
3
3
-
import * as URI from "uri-js";
4
4
-
import * as HTTP_TOKENIZER from "@tokenizer/http";
5
5
-
import * as RANGE_TOKENIZER from "@tokenizer/range";
6
6
-
7
7
-
import type { TrackStats, TrackTags } from "@applets/core/types";
8
1
import type { Extraction, Urls } from "./types.d.ts";
9
2
import { expose } from "@scripts/common";
3
3
+
import { musicMetadataTags } from "./common.ts";
10
4
11
5
////////////////////////////////////////////
12
6
// ACTIONS
···
37
31
// Fin
38
32
return response;
39
33
}
40
40
-
41
41
-
////////////////////////////////////////////
42
42
-
// 🛠️
43
43
-
////////////////////////////////////////////
44
44
-
async function musicMetadataTags({
45
45
-
includeArtwork,
46
46
-
mimeType,
47
47
-
stream,
48
48
-
urls,
49
49
-
}: {
50
50
-
includeArtwork?: boolean;
51
51
-
mimeType?: string;
52
52
-
stream?: ReadableStream;
53
53
-
urls?: Urls;
54
54
-
}): Promise<Extraction> {
55
55
-
const uri = urls ? URI.parse(urls.get) : undefined;
56
56
-
const pathParts = uri?.path?.split("/");
57
57
-
const filename = pathParts?.[pathParts.length - 1];
58
58
-
59
59
-
let meta;
60
60
-
61
61
-
if (urls?.get.startsWith("blob:")) {
62
62
-
const mimeFallback = filename?.includes(".")
63
63
-
? contentType(filename.split(".").reverse()[0])
64
64
-
: undefined;
65
65
-
66
66
-
const resp = await fetch(urls.get);
67
67
-
const stream = resp.body;
68
68
-
69
69
-
if (!stream) return {};
70
70
-
meta = await parseWebStream(
71
71
-
stream,
72
72
-
{ mimeType: mimeType || mimeFallback },
73
73
-
{ skipCovers: !includeArtwork },
74
74
-
);
75
75
-
} else if (urls) {
76
76
-
const httpClient = new HTTP_TOKENIZER.HttpClient(urls.head, { resolveUrl: false });
77
77
-
httpClient.resolvedUrl = urls.get;
78
78
-
79
79
-
const tokenizer = await RANGE_TOKENIZER.tokenizer(httpClient);
80
80
-
81
81
-
meta = await parseFromTokenizer(tokenizer, { skipCovers: !includeArtwork });
82
82
-
} else if (stream) {
83
83
-
meta = await parseWebStream(stream, { mimeType }, { skipCovers: !includeArtwork });
84
84
-
} else {
85
85
-
throw new Error("Missing args, need either some urls or a stream.");
86
86
-
}
87
87
-
88
88
-
const stats: TrackStats = {
89
89
-
duration: meta.format.duration,
90
90
-
};
91
91
-
92
92
-
const tags: TrackTags = {
93
93
-
album: meta.common.album,
94
94
-
artist: meta.common.artist,
95
95
-
disc: { no: meta.common.disk.no || 1, of: meta.common.disk.of ?? undefined },
96
96
-
genre: Array.isArray(meta.common.genre) ? meta.common.genre[0] : meta.common.genre,
97
97
-
title: meta.common.title || filename || urls?.head || "Unknown",
98
98
-
track: { no: meta.common.track.no || 1, of: meta.common.track.of ?? undefined },
99
99
-
year: meta.common.year,
100
100
-
};
101
101
-
102
102
-
return {
103
103
-
artwork: includeArtwork ? meta.common.picture : undefined,
104
104
-
stats,
105
105
-
tags,
106
106
-
};
107
107
-
}