src
applets
pages
scripts
styles
···
18
18
19
19
// Initial state
20
20
context.data = {
21
21
-
nowPlaying: {},
21
21
+
items: {},
22
22
};
23
23
24
24
// State helpers
···
26
26
context.data = { ...context.data, ...partial };
27
27
}
28
28
29
29
-
function updateNowPlaying(trackId: string, partial: Partial<TrackState>): void {
29
29
+
function updateItems(trackId: string, partial: Partial<TrackState>): void {
30
30
update({
31
31
...context.data,
32
32
-
nowPlaying: {
33
33
-
...context.data.nowPlaying,
34
34
-
[trackId]: { ...context.data.nowPlaying[trackId], ...partial },
32
32
+
items: {
33
33
+
...context.data.items,
34
34
+
[trackId]: { ...context.data.items[trackId], ...partial },
35
35
},
36
36
});
37
37
}
···
73
73
audio.removeAttribute("data-did-preload");
74
74
}
75
75
76
76
-
updateNowPlaying(audio.id, { isPlaying: true });
76
76
+
updateItems(audio.id, { isPlaying: true });
77
77
78
78
promise.catch((e) => {
79
79
if (!audio.isConnected)
80
80
return; /* The node was removed from the DOM, we can ignore this error */
81
81
const err = "Couldn't play audio automatically. Please resume playback manually.";
82
82
console.error(err, e);
83
83
-
updateNowPlaying(trackId, { isPlaying: false });
83
83
+
updateItems(trackId, { isPlaying: false });
84
84
});
85
85
});
86
86
}
···
152
152
}, Promise.resolve());
153
153
154
154
// Now playing state
155
155
-
const nowPlaying = tracks.reduce((acc, track) => {
155
155
+
const items = tracks.reduce((acc, track) => {
156
156
return {
157
157
...acc,
158
158
-
[track.id]: context.data.nowPlaying[track.id] || {
158
158
+
[track.id]: context.data.items[track.id] || {
159
159
duration: 0,
160
160
id: track.id,
161
161
loadingState: "loading",
···
166
166
};
167
167
}, {});
168
168
169
169
-
update({ nowPlaying });
169
169
+
update({ items });
170
170
}
171
171
172
172
export async function createElement(track: Track) {
···
225
225
const audio = event.target as HTMLAudioElement;
226
226
227
227
if (!isNaN(audio.duration)) {
228
228
-
updateNowPlaying(audio.id, { duration: audio.duration });
228
228
+
updateItems(audio.id, { duration: audio.duration });
229
229
}
230
230
}
231
231
232
232
function endedEvent(event: Event) {
233
233
const audio = event.target as HTMLAudioElement;
234
234
audio.currentTime = 0;
235
235
-
// TODO
235
235
+
updateItems(audio.id, { hasEnded: true, isPlaying: false });
236
236
}
237
237
238
238
function errorEvent(event: Event) {
239
239
const audio = event.target as HTMLAudioElement;
240
240
const code = audio.error?.code || 0;
241
241
-
updateNowPlaying(audio.id, { loadingState: { error: { code } } });
241
241
+
updateItems(audio.id, { loadingState: { error: { code } } });
242
242
}
243
243
244
244
function pauseEvent(event: Event) {
245
245
const audio = event.target as HTMLAudioElement;
246
246
-
updateNowPlaying(audio.id, { isPlaying: false });
246
246
+
updateItems(audio.id, { isPlaying: false });
247
247
}
248
248
249
249
function playEvent(event: Event) {
250
250
const audio = event.target as HTMLAudioElement;
251
251
-
updateNowPlaying(audio.id, { isPlaying: true });
251
251
+
updateItems(audio.id, { isPlaying: true });
252
252
253
253
// In case audio was preloaded:
254
254
if (audio.readyState === 4) finishedLoading(event);
···
261
261
function timeupdateEvent(event: Event) {
262
262
const audio = event.target as HTMLAudioElement;
263
263
264
264
-
updateNowPlaying(audio.id, {
264
264
+
updateItems(audio.id, {
265
265
progress:
266
266
isNaN(audio.duration) || audio.duration === 0 ? 0 : audio.currentTime / audio.duration,
267
267
});
···
277
277
278
278
function finishedLoading(event: Event) {
279
279
const audio = event.target as HTMLAudioElement;
280
280
-
updateNowPlaying(audio.id, { loadingState: "loaded" });
280
280
+
updateItems(audio.id, { loadingState: "loaded" });
281
281
}
282
282
283
283
function initiateLoading(event: Event) {
284
284
const audio = event.target as HTMLAudioElement;
285
285
-
if (audio.readyState < 4) updateNowPlaying(audio.id, { loadingState: "loading" });
285
285
+
if (audio.readyState < 4) updateItems(audio.id, { loadingState: "loading" });
286
286
}
287
287
288
288
function withActiveAudioNode(fn: (node: HTMLAudioElement) => void): void {
···
1
1
export interface State {
2
2
-
nowPlaying: Record<string, TrackState>;
2
2
+
items: Record<string, TrackState>;
3
3
}
4
4
5
5
export interface Track {
···
13
13
export interface TrackState {
14
14
duration: number;
15
15
id: string;
16
16
+
hasEnded: boolean;
16
17
loadingState: "initialisation" | "loading" | "loaded" | {
17
18
error: { code: number };
18
19
};
···
1
1
+
<div id="container"></div>
2
2
+
3
3
+
<script>
4
4
+
import { applets } from "@web-applets/sdk";
5
5
+
import { QueueItem, State } from "./types";
6
6
+
7
7
+
////////////////////////////////////////////
8
8
+
// SETUP
9
9
+
////////////////////////////////////////////
10
10
+
const context = applets.register<State>();
11
11
+
12
12
+
// Initial state
13
13
+
context.data = {
14
14
+
past: [],
15
15
+
now: null,
16
16
+
future: [],
17
17
+
};
18
18
+
19
19
+
// State helpers
20
20
+
function update(partial: Partial<State>): void {
21
21
+
context.data = { ...context.data, ...partial };
22
22
+
}
23
23
+
24
24
+
////////////////////////////////////////////
25
25
+
// ACTIONS
26
26
+
////////////////////////////////////////////
27
27
+
context.setActionHandler("add", add);
28
28
+
context.setActionHandler("shift", shift);
29
29
+
30
30
+
function add(items: QueueItem[]) {
31
31
+
update({ future: [...context.data.future, ...items] });
32
32
+
}
33
33
+
34
34
+
function shift() {
35
35
+
const now = context.data.future[0] || null;
36
36
+
const future = context.data.future.slice(1);
37
37
+
const past = context.data.now ? [...context.data.past, context.data.now] : context.data.past;
38
38
+
39
39
+
update({ past, now, future });
40
40
+
}
41
41
+
</script>
···
1
1
+
{
2
2
+
"name": "diffuse/engine/queue",
3
3
+
"title": "Diffuse Queue",
4
4
+
"entrypoint": "index.html",
5
5
+
"actions": {
6
6
+
"add": {
7
7
+
"title": "Add",
8
8
+
"description": "Add items to the queue.",
9
9
+
"params_schema": {
10
10
+
"type": "array",
11
11
+
"items": {
12
12
+
"anyOf": [
13
13
+
{
14
14
+
"type": "object",
15
15
+
"properties": {
16
16
+
"expiresAt": { "type": "number" },
17
17
+
"id": { "type": "string" },
18
18
+
"url": { "type": "string" }
19
19
+
},
20
20
+
"required": ["expiresAt", "id", "url"]
21
21
+
}
22
22
+
]
23
23
+
}
24
24
+
}
25
25
+
},
26
26
+
"shift": {
27
27
+
"title": "Shift",
28
28
+
"description": "Shift the queue, picking the first item from the up next array and putting the currently playing item into the history list."
29
29
+
}
30
30
+
}
31
31
+
}
···
1
1
+
export interface QueueItem {
2
2
+
expiresAt: number;
3
3
+
id: string;
4
4
+
url: string;
5
5
+
}
6
6
+
7
7
+
export interface State {
8
8
+
past: QueueItem[];
9
9
+
now: QueueItem | null;
10
10
+
future: QueueItem[];
11
11
+
}
···
106
106
////////////////////////////////////////////
107
107
// Actions
108
108
////////////////////////////////////////////
109
109
-
context.setActionHandler("set_is_playing", (isPlaying: boolean) => {
109
109
+
context.setActionHandler("modifyIsPlaying", (isPlaying: boolean) => {
110
110
context.data.isPlaying = isPlaying;
111
111
render();
112
112
});
113
113
114
114
-
context.setActionHandler("set_progress", (progress: number) => {
114
114
+
context.setActionHandler("modifyProgress", (progress: number) => {
115
115
const p = isNaN(progress) || !isFinite(progress) ? 0 : Math.min(Math.max(progress, 0), 1);
116
116
document.body.querySelector("progress").value = p * 100;
117
117
});
···
2
2
"name": "diffuse/ui/audio",
3
3
"entrypoint": "index.html",
4
4
"actions": {
5
5
-
"set_is_playing": {
5
5
+
"modifyIsPlaying": {
6
6
"title": "Set is-playing state",
7
7
"description": "Indicate if audio is playing or not.",
8
8
"params_schema": {
9
9
"type": "boolean"
10
10
}
11
11
},
12
12
-
"set_progress": {
12
12
+
"modifyProgress": {
13
13
"title": "Set progress",
14
14
"description": "Indicate how far the audio has progressed.",
15
15
"params_schema": {
···
9
9
<div class="filler" style="flex: 1;"></div>
10
10
11
11
<!-- Theme applets -->
12
12
-
<iframe id="applet__ui__audio" src="ui/audio/" frameborder="0"></iframe>
12
12
+
<iframe id="applet__ui__audio" src="ui/audio/"></iframe>
13
13
14
14
<!-- Other applets -->
15
15
-
<iframe id="applet__engine__audio" src="../../engine/audio/" frameborder="0" height="1" width="1"
16
16
-
></iframe>
15
15
+
<iframe id="applet__engine__audio" src="../../engine/audio/"></iframe>
16
16
+
<iframe id="applet__engine__queue" src="../../engine/queue/"></iframe>
17
17
</Page>
···
10
10
////////////////////////////////////////////
11
11
import type * as AudioEngine from "../../../applets/engine/audio/types.ts";
12
12
import type * as AudioUI from "../../../applets/themes/pilot/ui/audio/types.ts";
13
13
+
import type * as QueueEngine from "../../../applets/engine/queue/types.ts";
13
14
14
15
const engine = {
15
16
audio: await applet<AudioEngine.State>("../../engine/audio"),
17
17
+
queue: await applet<AudioEngine.State>("../../engine/queue"),
16
18
};
17
19
18
20
const ui = {
19
21
audio: await applet<AudioUI.State>("ui/audio", { setHeight: true }),
20
22
};
21
23
24
24
+
// NOTE:
25
25
+
// Themes are just limited imaginations.
26
26
+
//
27
27
+
// For example, this theme limits the currently playing audio to one item.
28
28
+
// But you might as well create a DJ "theme" that plays multiple items at
29
29
+
// the same time. With that in mind, you could abstract things here that are
30
30
+
// reused across multiple themes. But it might also be good to keep the code
31
31
+
// repetition because there are some defaults hidden in here.
32
32
+
22
33
////////////////////////////////////////////
23
23
-
// ▒▒ [Connections ⚡]
24
24
-
// ▒▒ Audio UI → Audio Engine
34
34
+
// ⚙️ [Connections → Engines]
35
35
+
// 🔉 AUDIO
36
36
+
////////////////////////////////////////////
37
37
+
38
38
+
// NOTE:
39
39
+
// These could probably be optimised, but it works.
40
40
+
//
41
41
+
42
42
+
reactive(
43
43
+
engine.audio,
44
44
+
(data: AudioEngine.State) =>
45
45
+
data.items[engine.queue.data.now?.id]?.isPlaying ?? false,
46
46
+
(isPlaying) => ui.audio.sendAction("modifyIsPlaying", isPlaying),
47
47
+
);
48
48
+
49
49
+
reactive(
50
50
+
engine.audio,
51
51
+
(data: AudioEngine.State) =>
52
52
+
data.items[engine.queue.data.now?.id]?.hasEnded ?? false,
53
53
+
(hasEnded) => {
54
54
+
if (hasEnded) engine.queue.sendAction("shift");
55
55
+
},
56
56
+
);
57
57
+
58
58
+
reactive(
59
59
+
engine.audio,
60
60
+
(data: AudioEngine.State) =>
61
61
+
data.items[engine.queue.data.now?.id]?.progress ?? 0,
62
62
+
(progress: number) => ui.audio.sendAction("modifyProgress", progress),
63
63
+
);
64
64
+
65
65
+
////////////////////////////////////////////
66
66
+
// ⚙️ [Connections → Engines]
67
67
+
// 🚏 QUEUE
68
68
+
////////////////////////////////////////////
69
69
+
reactive(
70
70
+
engine.queue,
71
71
+
(data: QueueEngine.State) => data.now,
72
72
+
(playingNow) => {
73
73
+
const volume = 0.5; // TODO
74
74
+
75
75
+
if (!playingNow) {
76
76
+
// NOTE: This probably isn't correct, keep preloads?
77
77
+
engine.audio.sendAction("render", { tracks: [] });
78
78
+
return;
79
79
+
}
80
80
+
81
81
+
engine.audio.sendAction("render", {
82
82
+
tracks: [{
83
83
+
id: playingNow.id,
84
84
+
isPreload: false,
85
85
+
url: playingNow.url,
86
86
+
}],
87
87
+
play: {
88
88
+
trackId: playingNow.id,
89
89
+
volume,
90
90
+
},
91
91
+
});
92
92
+
},
93
93
+
);
94
94
+
95
95
+
////////////////////////////////////////////
96
96
+
// 🌅 [Connections → UI]
97
97
+
// 🔉 AUDIO
25
98
////////////////////////////////////////////
26
99
reactive(
27
100
ui.audio,
28
28
-
// TODO: Shouldn't need to pass in the type here
29
101
(data: AudioUI.State) => data.isPlaying,
30
102
(isPlaying) => {
103
103
+
const trackId = engine.queue.data.now?.id;
104
104
+
const volume = 0.5; // TODO
105
105
+
106
106
+
// Automatically start playing something if nothing is playing yet.
107
107
+
if (!trackId) {
108
108
+
if (isPlaying) engine.queue.sendAction("shift");
109
109
+
return;
110
110
+
}
111
111
+
112
112
+
// Otherwise just control the audio
31
113
if (isPlaying) {
32
32
-
// TODO: Replace with an actual queue system (shift queue)
33
33
-
engine.audio.sendAction("render", {
34
34
-
tracks: [{
35
35
-
id: "TODO",
36
36
-
isPreload: false,
37
37
-
url:
38
38
-
"https://archive.org/download/78_lollipop_the-chordettes-j-dixon-b-ross-archie-bleyer_gbia0068558a/Lollipop%20-%20The%20Chordettes%20-%20J.%20Dixon%20-%20B.%20Ross.mp3",
39
39
-
}],
40
40
-
play: {
41
41
-
trackId: "TODO",
42
42
-
volume: 0.5,
43
43
-
},
44
44
-
});
114
114
+
engine.audio.sendAction("play", { trackId, volume });
45
115
} else {
46
46
-
engine.audio.sendAction("pause", { trackId: "TODO" });
116
116
+
engine.audio.sendAction("pause", { trackId });
47
117
}
48
118
},
49
119
);
···
52
122
ui.audio,
53
123
(data: AudioUI.State) => data.seekPosition,
54
124
(seekPosition) => {
55
55
-
if (seekPosition) {
125
125
+
if (seekPosition !== undefined && engine.queue.data.now?.id) {
56
126
engine.audio.sendAction("seek", {
57
127
percentage: seekPosition,
58
58
-
trackId: "TODO",
128
128
+
trackId: engine.queue.data.now.id,
59
129
});
60
130
}
61
131
},
62
132
);
63
133
64
134
////////////////////////////////////////////
65
65
-
// ▒▒ [Connections ⚡]
66
66
-
// ▒▒ Audio Engine → Audio UI
135
135
+
// 🚀
67
136
////////////////////////////////////////////
68
68
-
reactive(
69
69
-
engine.audio,
70
70
-
(data: AudioEngine.State) =>
71
71
-
// TODO: Simplify using queue engine
72
72
-
Object.values(data.nowPlaying).some((s) => s.isPlaying),
73
73
-
(isPlaying) => ui.audio.sendAction("set_is_playing", isPlaying),
74
74
-
);
75
137
76
76
-
reactive(
77
77
-
engine.audio,
78
78
-
(data: AudioEngine.State) => {
79
79
-
// TODO: Simplify using queue engine
80
80
-
return Object.values(data.nowPlaying).filter((s) => !s.isPreload)[0]
81
81
-
?.progress ??
82
82
-
0;
138
138
+
// TODO: Replace with an actual music collection
139
139
+
await engine.queue.sendAction("add", [
140
140
+
{
141
141
+
id: "Yours Truly, Johnny Dollar",
142
142
+
expiresAt: Infinity,
143
143
+
url:
144
144
+
"https://archive.org/download/SUSPENSE_Radio_Digitally_Restored_Collection/%2040-07-22%20The%20Lodger%20%28audition%29%20%28Herbert%20Marshall%2C%20Alfred%20Hitchcock%2C%20Edmund%20Gwenn%29.mp3",
145
145
+
},
146
146
+
{
147
147
+
id: "Dimension X",
148
148
+
expiresAt: Infinity,
149
149
+
url:
150
150
+
"https://archive.org/download/OTRR_Dimension_X_Singles/Dimension_X_1950-04-08__01_OuterLimit.mp3",
83
151
},
84
84
-
(progress: number) => ui.audio.sendAction("set_progress", progress),
85
85
-
);
152
152
+
]);
···
27
27
height: 100dvh;
28
28
}
29
29
30
30
+
iframe {
31
31
+
border: 0;
32
32
+
}
33
33
+
30
34
/***********************************
31
35
* Applets (UI)
32
36
***********************************/
···
46
50
/***********************************
47
51
* Applets (No UI)
48
52
***********************************/
53
53
+
iframe[src*="/engine/"] {
54
54
+
height: 0;
55
55
+
left: 110vw;
56
56
+
opacity: 0;
57
57
+
overflow: hidden;
58
58
+
pointer-events: none;
59
59
+
position: absolute;
60
60
+
top: 110vh;
61
61
+
width: 0;
62
62
+
}
49
63
50
64
/* Audio is special case, iframe needs to be "visible" in order to play the audio. */
51
65
#applet__engine__audio {
66
66
+
height: 1px;
52
67
left: 0;
53
68
opacity: 0;
54
69
pointer-events: none;
55
70
position: absolute;
56
71
top: 0;
72
72
+
width: 1px;
57
73
}