A music player that connects to your cloud/distributed storage.
at v4 4.0 kB view raw
1import { BroadcastableDiffuseElement, query } from "@common/element.js"; 2import { signal, untracked } from "@common/signal.js"; 3 4/** 5 * @import {InputElement} from "@components/input/types.d.ts" 6 */ 7 8//////////////////////////////////////////// 9// ELEMENT 10//////////////////////////////////////////// 11 12/** 13 * When the active queue item changes, 14 * coordinate the audio engine accordingly. 15 * 16 * Vice versa, when the audio ends, 17 * shift the queue if needed. 18 */ 19class QueueAudioOrchestrator extends BroadcastableDiffuseElement { 20 static NAME = "diffuse/orchestrator/queue-audio"; 21 static observedAttributes = ["repeat"]; 22 23 // SIGNALS 24 25 #repeat = signal(false); 26 27 // LIFE CYCLE 28 29 /** 30 * @override 31 */ 32 async connectedCallback() { 33 this.#repeat.value = this.hasAttribute("repeat"); 34 35 // Broadcast if needed 36 if (this.hasAttribute("group")) { 37 this.broadcast(this.nameWithGroup, {}); 38 } 39 40 // Super 41 super.connectedCallback(); 42 43 /** @type {InputElement} */ 44 this.input = query(this, "input-selector"); 45 46 /** @type {import("@components/engine/audio/element.js").CLASS} */ 47 this.audio = query(this, "audio-engine-selector"); 48 49 /** @type {import("@components/engine/queue/element.js").CLASS} */ 50 this.queue = query(this, "queue-engine-selector"); 51 52 // Wait until defined 53 await customElements.whenDefined(this.audio.localName); 54 await customElements.whenDefined(this.input.localName); 55 await customElements.whenDefined(this.queue.localName); 56 57 // Effects 58 this.effect(() => this.monitorActiveQueueItem()); 59 this.effect(() => this.monitorAudioEnd()); 60 } 61 62 /** 63 * @override 64 * @param {string} name 65 * @param {string} oldValue 66 * @param {string} newValue 67 */ 68 attributeChangedCallback(name, oldValue, newValue) { 69 super.attributeChangedCallback(name, oldValue, newValue); 70 71 if (name === "repeat") { 72 this.#repeat.value = newValue != null; 73 } 74 } 75 76 // 🛠️ 77 78 async monitorActiveQueueItem() { 79 if (!this.audio) return; 80 if (!this.input) return; 81 if (!this.queue) return; 82 83 const activeTrack = this.queue.now(); 84 if ((await this.isLeader()) === false) return; 85 86 const isPlaying = untracked(this.audio.isPlaying); 87 88 // Resolve URIs 89 const resolvedUri = activeTrack 90 ? await this.input.resolve({ method: "GET", uri: activeTrack.uri }) 91 : undefined; 92 93 if (resolvedUri && "stream" in resolvedUri) { 94 throw new Error("Streams are not supported yet."); 95 } 96 97 const url = resolvedUri?.url; 98 99 // Check if we still need to render 100 if (this.queue.now?.()?.id !== activeTrack?.id) return; 101 102 // Play new active queue item 103 // TODO: Take URL expiration timestamp into account 104 // TODO: Preload next queue item 105 this.audio.supply({ 106 audio: activeTrack && url 107 ? [{ 108 id: activeTrack.id, 109 isPreload: false, 110 url, 111 }] 112 // TODO: Keep preloads 113 : [], 114 play: activeTrack && isPlaying ? { audioId: activeTrack.id } : undefined, 115 }); 116 } 117 118 async monitorAudioEnd() { 119 if (!this.audio) return; 120 if (!this.queue) return; 121 122 const now = this.queue.now(); 123 const aud = now ? this.audio.state(now.id) : undefined; 124 125 if (aud?.hasEnded() && (await this.isLeader())) { 126 // TODO: Not sure yet if this is the best way to approach this. 127 // The idea is that scrobblers would more easily pick this up, 128 // as opposed to just resetting the audio. 129 if (this.#repeat.value) { 130 const now = this.queue.now(); 131 if (now) { 132 await this.queue.add({ 133 inFront: true, 134 tracks: [now], 135 }); 136 } 137 } 138 139 await this.queue.shift(); 140 } 141 } 142} 143 144export default QueueAudioOrchestrator; 145 146//////////////////////////////////////////// 147// REGISTER 148//////////////////////////////////////////// 149 150export const CLASS = QueueAudioOrchestrator; 151export const NAME = "do-queue-audio"; 152 153customElements.define(NAME, QueueAudioOrchestrator);