diff --git a/src/applets/engine/audio/applet.astro b/src/applets/engine/audio/applet.astro index ee5c0bc..0d8f0b8 100644 --- a/src/applets/engine/audio/applet.astro +++ b/src/applets/engine/audio/applet.astro @@ -18,7 +18,7 @@ // Initial state context.data = { - nowPlaying: {}, + items: {}, }; // State helpers @@ -26,12 +26,12 @@ context.data = { ...context.data, ...partial }; } - function updateNowPlaying(trackId: string, partial: Partial): void { + function updateItems(trackId: string, partial: Partial): void { update({ ...context.data, - nowPlaying: { - ...context.data.nowPlaying, - [trackId]: { ...context.data.nowPlaying[trackId], ...partial }, + items: { + ...context.data.items, + [trackId]: { ...context.data.items[trackId], ...partial }, }, }); } @@ -73,14 +73,14 @@ audio.removeAttribute("data-did-preload"); } - updateNowPlaying(audio.id, { isPlaying: true }); + updateItems(audio.id, { isPlaying: true }); promise.catch((e) => { if (!audio.isConnected) return; /* The node was removed from the DOM, we can ignore this error */ const err = "Couldn't play audio automatically. Please resume playback manually."; console.error(err, e); - updateNowPlaying(trackId, { isPlaying: false }); + updateItems(trackId, { isPlaying: false }); }); }); } @@ -152,10 +152,10 @@ }, Promise.resolve()); // Now playing state - const nowPlaying = tracks.reduce((acc, track) => { + const items = tracks.reduce((acc, track) => { return { ...acc, - [track.id]: context.data.nowPlaying[track.id] || { + [track.id]: context.data.items[track.id] || { duration: 0, id: track.id, loadingState: "loading", @@ -166,7 +166,7 @@ }; }, {}); - update({ nowPlaying }); + update({ items }); } export async function createElement(track: Track) { @@ -225,30 +225,30 @@ const audio = event.target as HTMLAudioElement; if (!isNaN(audio.duration)) { - updateNowPlaying(audio.id, { duration: audio.duration }); + updateItems(audio.id, { duration: audio.duration }); } } function endedEvent(event: Event) { const audio = event.target as HTMLAudioElement; audio.currentTime = 0; - // TODO + updateItems(audio.id, { hasEnded: true, isPlaying: false }); } function errorEvent(event: Event) { const audio = event.target as HTMLAudioElement; const code = audio.error?.code || 0; - updateNowPlaying(audio.id, { loadingState: { error: { code } } }); + updateItems(audio.id, { loadingState: { error: { code } } }); } function pauseEvent(event: Event) { const audio = event.target as HTMLAudioElement; - updateNowPlaying(audio.id, { isPlaying: false }); + updateItems(audio.id, { isPlaying: false }); } function playEvent(event: Event) { const audio = event.target as HTMLAudioElement; - updateNowPlaying(audio.id, { isPlaying: true }); + updateItems(audio.id, { isPlaying: true }); // In case audio was preloaded: if (audio.readyState === 4) finishedLoading(event); @@ -261,7 +261,7 @@ function timeupdateEvent(event: Event) { const audio = event.target as HTMLAudioElement; - updateNowPlaying(audio.id, { + updateItems(audio.id, { progress: isNaN(audio.duration) || audio.duration === 0 ? 0 : audio.currentTime / audio.duration, }); @@ -277,12 +277,12 @@ function finishedLoading(event: Event) { const audio = event.target as HTMLAudioElement; - updateNowPlaying(audio.id, { loadingState: "loaded" }); + updateItems(audio.id, { loadingState: "loaded" }); } function initiateLoading(event: Event) { const audio = event.target as HTMLAudioElement; - if (audio.readyState < 4) updateNowPlaying(audio.id, { loadingState: "loading" }); + if (audio.readyState < 4) updateItems(audio.id, { loadingState: "loading" }); } function withActiveAudioNode(fn: (node: HTMLAudioElement) => void): void { diff --git a/src/applets/engine/audio/types.ts b/src/applets/engine/audio/types.ts index 7ebe8e9..ee7f51e 100644 --- a/src/applets/engine/audio/types.ts +++ b/src/applets/engine/audio/types.ts @@ -1,5 +1,5 @@ export interface State { - nowPlaying: Record; + items: Record; } export interface Track { @@ -13,6 +13,7 @@ export interface Track { export interface TrackState { duration: number; id: string; + hasEnded: boolean; loadingState: "initialisation" | "loading" | "loaded" | { error: { code: number }; }; diff --git a/src/applets/engine/queue/applet.astro b/src/applets/engine/queue/applet.astro new file mode 100644 index 0000000..7a1c256 --- /dev/null +++ b/src/applets/engine/queue/applet.astro @@ -0,0 +1,41 @@ +
+ + diff --git a/src/applets/engine/queue/manifest.json b/src/applets/engine/queue/manifest.json new file mode 100644 index 0000000..e1273e1 --- /dev/null +++ b/src/applets/engine/queue/manifest.json @@ -0,0 +1,31 @@ +{ + "name": "diffuse/engine/queue", + "title": "Diffuse Queue", + "entrypoint": "index.html", + "actions": { + "add": { + "title": "Add", + "description": "Add items to the queue.", + "params_schema": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "expiresAt": { "type": "number" }, + "id": { "type": "string" }, + "url": { "type": "string" } + }, + "required": ["expiresAt", "id", "url"] + } + ] + } + } + }, + "shift": { + "title": "Shift", + "description": "Shift the queue, picking the first item from the up next array and putting the currently playing item into the history list." + } + } +} diff --git a/src/applets/engine/queue/types.ts b/src/applets/engine/queue/types.ts new file mode 100644 index 0000000..229c328 --- /dev/null +++ b/src/applets/engine/queue/types.ts @@ -0,0 +1,11 @@ +export interface QueueItem { + expiresAt: number; + id: string; + url: string; +} + +export interface State { + past: QueueItem[]; + now: QueueItem | null; + future: QueueItem[]; +} diff --git a/src/applets/themes/pilot/ui/audio/applet.astro b/src/applets/themes/pilot/ui/audio/applet.astro index 4d61be1..498af61 100644 --- a/src/applets/themes/pilot/ui/audio/applet.astro +++ b/src/applets/themes/pilot/ui/audio/applet.astro @@ -106,12 +106,12 @@ //////////////////////////////////////////// // Actions //////////////////////////////////////////// - context.setActionHandler("set_is_playing", (isPlaying: boolean) => { + context.setActionHandler("modifyIsPlaying", (isPlaying: boolean) => { context.data.isPlaying = isPlaying; render(); }); - context.setActionHandler("set_progress", (progress: number) => { + context.setActionHandler("modifyProgress", (progress: number) => { const p = isNaN(progress) || !isFinite(progress) ? 0 : Math.min(Math.max(progress, 0), 1); document.body.querySelector("progress").value = p * 100; }); diff --git a/src/applets/themes/pilot/ui/audio/manifest.json b/src/applets/themes/pilot/ui/audio/manifest.json index e80154d..c84b4b8 100644 --- a/src/applets/themes/pilot/ui/audio/manifest.json +++ b/src/applets/themes/pilot/ui/audio/manifest.json @@ -2,14 +2,14 @@ "name": "diffuse/ui/audio", "entrypoint": "index.html", "actions": { - "set_is_playing": { + "modifyIsPlaying": { "title": "Set is-playing state", "description": "Indicate if audio is playing or not.", "params_schema": { "type": "boolean" } }, - "set_progress": { + "modifyProgress": { "title": "Set progress", "description": "Indicate how far the audio has progressed.", "params_schema": { diff --git a/src/pages/themes/pilot/index.astro b/src/pages/themes/pilot/index.astro index cca6568..bc9aedf 100644 --- a/src/pages/themes/pilot/index.astro +++ b/src/pages/themes/pilot/index.astro @@ -9,9 +9,9 @@ import Page from "../../../layouts/page.astro";
- + - + + diff --git a/src/scripts/themes/pilot/index.ts b/src/scripts/themes/pilot/index.ts index ef8369f..51e4dcc 100644 --- a/src/scripts/themes/pilot/index.ts +++ b/src/scripts/themes/pilot/index.ts @@ -10,40 +10,110 @@ import "../../../styles/themes/pilot/index.css"; //////////////////////////////////////////// import type * as AudioEngine from "../../../applets/engine/audio/types.ts"; import type * as AudioUI from "../../../applets/themes/pilot/ui/audio/types.ts"; +import type * as QueueEngine from "../../../applets/engine/queue/types.ts"; const engine = { audio: await applet("../../engine/audio"), + queue: await applet("../../engine/queue"), }; const ui = { audio: await applet("ui/audio", { setHeight: true }), }; +// NOTE: +// Themes are just limited imaginations. +// +// For example, this theme limits the currently playing audio to one item. +// But you might as well create a DJ "theme" that plays multiple items at +// the same time. With that in mind, you could abstract things here that are +// reused across multiple themes. But it might also be good to keep the code +// repetition because there are some defaults hidden in here. + +//////////////////////////////////////////// +// ⚙️ [Connections → Engines] +// 🔉 AUDIO +//////////////////////////////////////////// + +// NOTE: +// These could probably be optimised, but it works. +// + +reactive( + engine.audio, + (data: AudioEngine.State) => + data.items[engine.queue.data.now?.id]?.isPlaying ?? false, + (isPlaying) => ui.audio.sendAction("modifyIsPlaying", isPlaying), +); + +reactive( + engine.audio, + (data: AudioEngine.State) => + data.items[engine.queue.data.now?.id]?.hasEnded ?? false, + (hasEnded) => { + if (hasEnded) engine.queue.sendAction("shift"); + }, +); + +reactive( + engine.audio, + (data: AudioEngine.State) => + data.items[engine.queue.data.now?.id]?.progress ?? 0, + (progress: number) => ui.audio.sendAction("modifyProgress", progress), +); + //////////////////////////////////////////// -// ▒▒ [Connections ⚡] -// ▒▒ Audio UI → Audio Engine +// ⚙️ [Connections → Engines] +// 🚏 QUEUE +//////////////////////////////////////////// +reactive( + engine.queue, + (data: QueueEngine.State) => data.now, + (playingNow) => { + const volume = 0.5; // TODO + + if (!playingNow) { + // NOTE: This probably isn't correct, keep preloads? + engine.audio.sendAction("render", { tracks: [] }); + return; + } + + engine.audio.sendAction("render", { + tracks: [{ + id: playingNow.id, + isPreload: false, + url: playingNow.url, + }], + play: { + trackId: playingNow.id, + volume, + }, + }); + }, +); + +//////////////////////////////////////////// +// 🌅 [Connections → UI] +// 🔉 AUDIO //////////////////////////////////////////// reactive( ui.audio, - // TODO: Shouldn't need to pass in the type here (data: AudioUI.State) => data.isPlaying, (isPlaying) => { + const trackId = engine.queue.data.now?.id; + const volume = 0.5; // TODO + + // Automatically start playing something if nothing is playing yet. + if (!trackId) { + if (isPlaying) engine.queue.sendAction("shift"); + return; + } + + // Otherwise just control the audio if (isPlaying) { - // TODO: Replace with an actual queue system (shift queue) - engine.audio.sendAction("render", { - tracks: [{ - id: "TODO", - isPreload: false, - url: - "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", - }], - play: { - trackId: "TODO", - volume: 0.5, - }, - }); + engine.audio.sendAction("play", { trackId, volume }); } else { - engine.audio.sendAction("pause", { trackId: "TODO" }); + engine.audio.sendAction("pause", { trackId }); } }, ); @@ -52,34 +122,31 @@ reactive( ui.audio, (data: AudioUI.State) => data.seekPosition, (seekPosition) => { - if (seekPosition) { + if (seekPosition !== undefined && engine.queue.data.now?.id) { engine.audio.sendAction("seek", { percentage: seekPosition, - trackId: "TODO", + trackId: engine.queue.data.now.id, }); } }, ); //////////////////////////////////////////// -// ▒▒ [Connections ⚡] -// ▒▒ Audio Engine → Audio UI +// 🚀 //////////////////////////////////////////// -reactive( - engine.audio, - (data: AudioEngine.State) => - // TODO: Simplify using queue engine - Object.values(data.nowPlaying).some((s) => s.isPlaying), - (isPlaying) => ui.audio.sendAction("set_is_playing", isPlaying), -); -reactive( - engine.audio, - (data: AudioEngine.State) => { - // TODO: Simplify using queue engine - return Object.values(data.nowPlaying).filter((s) => !s.isPreload)[0] - ?.progress ?? - 0; +// TODO: Replace with an actual music collection +await engine.queue.sendAction("add", [ + { + id: "Yours Truly, Johnny Dollar", + expiresAt: Infinity, + url: + "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", }, - (progress: number) => ui.audio.sendAction("set_progress", progress), -); + { + id: "Dimension X", + expiresAt: Infinity, + url: + "https://archive.org/download/OTRR_Dimension_X_Singles/Dimension_X_1950-04-08__01_OuterLimit.mp3", + }, +]); diff --git a/src/styles/themes/pilot/index.css b/src/styles/themes/pilot/index.css index ebfeabe..0c54008 100644 --- a/src/styles/themes/pilot/index.css +++ b/src/styles/themes/pilot/index.css @@ -27,6 +27,10 @@ body { height: 100dvh; } +iframe { + border: 0; +} + /*********************************** * Applets (UI) ***********************************/ @@ -46,12 +50,24 @@ body { /*********************************** * Applets (No UI) ***********************************/ +iframe[src*="/engine/"] { + height: 0; + left: 110vw; + opacity: 0; + overflow: hidden; + pointer-events: none; + position: absolute; + top: 110vh; + width: 0; +} /* Audio is special case, iframe needs to be "visible" in order to play the audio. */ #applet__engine__audio { + height: 1px; left: 0; opacity: 0; pointer-events: none; position: absolute; top: 0; + width: 1px; }