A music player that connects to your cloud/distributed storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: ability to play streams

+184 -48
+45 -18
src/components/engine/audio/element.js
··· 129 129 130 130 if (!el.audio) return; 131 131 132 - const progress = el.$state.progress.value; 132 + const currentTime = el.$state.currentTime.value; 133 133 const canPlay = () => { 134 134 this.seek({ 135 135 audioId: item.id, 136 - percentage: progress, 136 + currentTime: currentTime, 137 137 }); 138 138 139 139 if (el.$state.isPlaying.value) this.play({ audioId: item.id }); ··· 224 224 /** 225 225 * @type {Actions["seek"]} 226 226 */ 227 - seek({ audioId, percentage }) { 227 + seek({ audioId, currentTime, percentage }) { 228 228 this.#withAudioNode(audioId, (audio) => { 229 - if (!isNaN(audio.duration)) { 230 - audio.currentTime = audio.duration * percentage; 229 + if (currentTime != undefined) { 230 + audio.currentTime = currentTime; 231 + } else if ( 232 + percentage != undefined && !isNaN(audio.duration) && 233 + audio.duration !== Infinity 234 + ) { 235 + audio.currentTime = percentage * audio.duration; 231 236 } 232 237 }); 233 238 } ··· 503 508 const state = this.state(item.id); 504 509 if (!state) return false; 505 510 506 - return state.isPlaying() || state.hasEnded() || state.progress() === 1; 511 + return state.isPlaying() || state.hasEnded() || 512 + state.currentTime() === state.duration(); 507 513 }); 508 514 } 509 515 ··· 575 581 constructor() { 576 582 super(); 577 583 578 - const ip = this.getAttribute("initial-progress"); 584 + // TODO: 585 + // const ip = this.getAttribute("initial-progress"); 579 586 580 587 /** 581 588 * @type {AudioState} 582 589 */ 583 590 this.$state = { 591 + currentTime: signal(0), 584 592 duration: signal(0), 585 593 hasEnded: signal(false), 586 594 isPlaying: signal(false), 587 595 isPreload: signal(this.hasAttribute("preload")), 588 596 loadingState: signal(/** @type {LoadingState} */ ("loading")), 589 - progress: signal(ip ? parseFloat(ip) : 0), 597 + 598 + progress: computed(() => { 599 + const currentTime = this.$state.currentTime.value; 600 + const duration = this.$state.duration.value; 601 + 602 + if (isNaN(duration)) return 0; 603 + if (duration === Infinity) return 0; 604 + 605 + return currentTime / duration; 606 + }), 590 607 }; 591 608 } 592 609 ··· 613 630 const actions = this.broadcast( 614 631 this.identifier, 615 632 { 633 + getCurrentTime: { 634 + strategy: "leaderOnly", 635 + fn: this.$state.currentTime.get, 636 + }, 616 637 getDuration: { strategy: "leaderOnly", fn: this.$state.duration.get }, 617 638 getHasEnded: { strategy: "leaderOnly", fn: this.$state.hasEnded.get }, 618 639 getIsPlaying: { ··· 627 648 strategy: "leaderOnly", 628 649 fn: this.$state.loadingState.get, 629 650 }, 630 - getProgress: { strategy: "leaderOnly", fn: this.$state.progress.get }, 631 651 632 652 // SET 653 + setCurrentTime: { 654 + strategy: "replicate", 655 + fn: this.$state.currentTime.set, 656 + }, 633 657 setDuration: { strategy: "replicate", fn: this.$state.duration.set }, 634 658 setHasEnded: { strategy: "replicate", fn: this.$state.hasEnded.set }, 635 659 setIsPlaying: { ··· 644 668 strategy: "replicate", 645 669 fn: this.$state.loadingState.set, 646 670 }, 647 - setProgress: { strategy: "replicate", fn: this.$state.progress.set }, 648 671 }, 649 672 { 650 673 // Sync leadership with engine's broadcasting channel ··· 653 676 ); 654 677 655 678 if (actions) { 679 + this.$state.currentTime.set = actions.setCurrentTime; 656 680 this.$state.duration.set = actions.setDuration; 657 681 this.$state.hasEnded.set = actions.setHasEnded; 658 682 this.$state.isPlaying.set = actions.setIsPlaying; 659 683 this.$state.isPreload.set = actions.setIsPreload; 660 684 this.$state.loadingState.set = actions.setLoadingState; 661 - this.$state.progress.set = actions.setProgress; 662 685 663 686 untracked(async () => { 687 + this.$state.currentTime.value = await actions.getCurrentTime(); 664 688 this.$state.duration.value = await actions.getDuration(); 665 689 this.$state.hasEnded.value = await actions.getHasEnded(); 666 690 this.$state.isPlaying.value = await actions.getIsPlaying(); 667 691 this.$state.isPreload.value = await actions.getIsPreload(); 668 692 this.$state.loadingState.value = await actions.getLoadingState(); 669 - this.$state.progress.value = await actions.getProgress(); 670 693 }); 671 694 } 672 695 } ··· 686 709 mimeType: this.getAttribute("mime-type") ?? undefined, 687 710 url: this.getAttribute("url") ?? "", 688 711 712 + currentTime: this.$state.currentTime.get, 689 713 duration: this.$state.duration.get, 690 714 hasEnded: this.$state.hasEnded.get, 691 715 isPlaying: this.$state.isPlaying.get, 692 716 isPreload: this.$state.isPreload.get, 693 717 loadingState: this.$state.loadingState.get, 694 - progress: this.$state.progress.get, 718 + 719 + progress: this.$state.progress, 695 720 }; 696 721 } 697 722 ··· 726 751 const progress = JSON.parse( 727 752 item.getAttribute("initial-progress") ?? "0", 728 753 ); 729 - audio.currentTime = audio.duration * progress; 754 + if ( 755 + progress !== 0 && !isNaN(audio.duration) && audio.duration !== Infinity 756 + ) { 757 + audio.currentTime = audio.duration * progress; 758 + } 759 + 730 760 item.removeAttribute("initial-progress"); 731 761 } 732 762 ··· 802 832 const audio = /** @type {HTMLAudioElement} */ (event.target); 803 833 if (isNaN(audio.duration) || audio.duration === 0) return; 804 834 805 - const progress = audio.currentTime / audio.duration; 806 - if (progress === 0) return; 807 - 808 - engineItem(audio)?.$state.progress.set(progress); 835 + engineItem(audio)?.$state.currentTime.set(audio.currentTime); 809 836 } 810 837 811 838 /**
+14 -3
src/components/engine/audio/types.d.ts
··· 5 5 pause: (_: { audioId: string }) => void; 6 6 play: (_: { audioId: string; volume?: number }) => void; 7 7 reload: (_: { audioId: string; play: boolean; progress?: number }) => void; 8 - seek: (_: { audioId: string; percentage: number }) => void; 8 + seek: ( 9 + _: { currentTime?: number; audioId: string; percentage?: number }, 10 + ) => void; 9 11 supply: ( 10 12 _: { audio: Audio[]; play?: { audioId: string; volume?: number } }, 11 13 ) => void; ··· 29 31 id: string; 30 32 isPreload: boolean; 31 33 mimeType?: string; 32 - // NOTE: Initial progress 34 + /** 35 + * Initial progress 36 + */ 33 37 progress?: number; 34 38 }; 35 39 36 40 export type AudioState = { 41 + currentTime: Signal<number>; 37 42 duration: Signal<number>; 38 43 hasEnded: Signal<boolean>; 39 44 isPlaying: Signal<boolean>; 40 45 isPreload: Signal<boolean>; 41 46 loadingState: Signal<LoadingState>; 42 - progress: Signal<number>; 47 + 48 + // Computed 49 + progress: SignalReader<number>; 43 50 }; 44 51 45 52 export type AudioStateReadOnly = { ··· 47 54 url: string; 48 55 mimeType: string | undefined; 49 56 57 + /** https://developer.mozilla.org/en-US/docs/Web/API/BaseAudioContext/currentTime */ 58 + currentTime: SignalReader<number>; 50 59 duration: SignalReader<number>; 51 60 hasEnded: SignalReader<boolean>; 52 61 isPlaying: SignalReader<boolean>; 53 62 isPreload: SignalReader<boolean>; 54 63 loadingState: SignalReader<LoadingState>; 64 + 65 + // Computed 55 66 progress: SignalReader<number>; 56 67 }; 57 68
+3 -1
src/components/engine/queue/worker.js
··· 228 228 export function _shift(future) { 229 229 const n = $now.value; 230 230 const f = future ?? $future.value; 231 + const v = f[0]; 231 232 232 - $now.value = f[0] ?? null; 233 + if (!v) return; 234 + $now.value = v; 233 235 if (n) $past.value = [...$past.value, n]; 234 236 $future.value = f.slice(1); 235 237 }
+33 -20
src/components/input/icecast/common.js
··· 8 8 */ 9 9 10 10 /** 11 - * Build an icecast:// URI from an HTTPS URL. 11 + * Build an icecast:// URI from an HTTP or HTTPS URL. 12 + * HTTP streams are encoded with a `tls=0` query parameter; HTTPS is the default. 12 13 * 13 - * @param {string} httpsUrl 14 + * @param {string} streamUrl 14 15 * @returns {string} 15 16 * 16 17 * @example Build URI from HTTPS URL ··· 22 23 * expect(uri).toBe("icecast://radio.example.com/stream.mp3"); 23 24 * ``` 24 25 * 25 - * @example Build URI with port 26 + * @example Build URI from HTTP URL 26 27 * ```ts 27 28 * import { expect } from "@std/expect"; 28 29 * import { buildURI } from "./common.js"; 29 30 * 30 - * const uri = buildURI("https://radio.example.com:8000/live"); 31 - * expect(uri).toBe("icecast://radio.example.com:8000/live"); 31 + * const uri = buildURI("http://radio.example.com:8000/live"); 32 + * expect(uri).toBe("icecast://radio.example.com:8000/live?tls=0"); 32 33 * ``` 33 34 */ 34 - export function buildURI(httpsUrl) { 35 - const url = new URL(httpsUrl); 36 - return `${SCHEME}://${url.host}${url.pathname}${url.search}`; 35 + export function buildURI(streamUrl) { 36 + const url = new URL(streamUrl); 37 + const tls = url.protocol === "https:"; 38 + const query = tls ? url.search : `${url.search ? url.search + "&" : "?"}tls=0`; 39 + return `${SCHEME}://${url.host}${url.pathname}${query}`; 37 40 } 38 41 39 42 /** 40 43 * Parse an icecast:// URI. 44 + * Returns the resolved HTTP or HTTPS stream URL based on the `tls` query param 45 + * (absent or `tls=1` → HTTPS; `tls=0` → HTTP). 41 46 * 42 47 * @param {string} uriString 43 - * @returns {{ host: string; path: string; httpsUrl: string } | undefined} 48 + * @returns {{ host: string; path: string; streamUrl: string } | undefined} 44 49 * 45 - * @example Parse a valid icecast URI 50 + * @example Parse a valid icecast URI (defaults to HTTPS) 46 51 * ```ts 47 52 * import { expect } from "@std/expect"; 48 53 * import { parseURI } from "./common.js"; ··· 50 55 * const result = parseURI("icecast://radio.example.com/stream.mp3"); 51 56 * expect(result?.host).toBe("radio.example.com"); 52 57 * expect(result?.path).toBe("/stream.mp3"); 53 - * expect(result?.httpsUrl).toBe("https://radio.example.com/stream.mp3"); 58 + * expect(result?.streamUrl).toBe("https://radio.example.com/stream.mp3"); 54 59 * ``` 55 60 * 56 - * @example Parse icecast URI with port 61 + * @example Parse icecast URI for an HTTP stream 57 62 * ```ts 58 63 * import { expect } from "@std/expect"; 59 64 * import { parseURI } from "./common.js"; 60 65 * 61 - * const result = parseURI("icecast://radio.example.com:8000/live"); 66 + * const result = parseURI("icecast://radio.example.com:8000/live?tls=0"); 62 67 * expect(result?.host).toBe("radio.example.com:8000"); 63 - * expect(result?.httpsUrl).toBe("https://radio.example.com:8000/live"); 68 + * expect(result?.streamUrl).toBe("http://radio.example.com:8000/live"); 64 69 * ``` 65 70 * 66 71 * @example Reject non-icecast URI ··· 77 82 const url = new URL(uriString); 78 83 if (url.protocol !== `${SCHEME}:`) return undefined; 79 84 85 + const tls = url.searchParams.get("tls") !== "0"; 86 + const protocol = tls ? "https" : "http"; 87 + 88 + // Strip the tls param from the forwarded URL's search string 89 + const params = new URLSearchParams(url.search); 90 + params.delete("tls"); 91 + const search = params.size > 0 ? `?${params}` : ""; 92 + 80 93 return { 81 94 host: url.host, 82 95 path: url.pathname, 83 - httpsUrl: `https://${url.host}${url.pathname}${url.search}`, 96 + streamUrl: `${protocol}://${url.host}${url.pathname}${search}`, 84 97 }; 85 98 } catch { 86 99 return undefined; ··· 163 176 * Fetch ICY metadata from an Icecast stream. 164 177 * Returns undefined if the stream is unreachable or does not support ICY metadata. 165 178 * 166 - * @param {string} httpsUrl 179 + * @param {string} streamUrl 167 180 * @returns {Promise<import("@cloudradio/icy-parser").IcyMetadata | undefined>} 168 181 */ 169 - export async function fetchMetadata(httpsUrl) { 182 + export async function fetchMetadata(streamUrl) { 170 183 try { 171 - const parser = new IcyParser(httpsUrl); 184 + const parser = new IcyParser(streamUrl); 172 185 return await parser.parseOnce(); 173 186 } catch { 174 187 return undefined; ··· 179 192 async function consultStream(uri) { 180 193 const parsed = parseURI(uri); 181 194 if (!parsed) return false; 182 - const metadata = await fetchMetadata(parsed.httpsUrl); 195 + const metadata = await fetchMetadata(parsed.streamUrl); 183 196 return metadata !== undefined; 184 197 } 185 198 186 199 export const consultStreamCached = cachedConsult( 187 200 consultStream, 188 - (uri) => new URL(uri.replace(/^icecast:/, "https:")).host, 201 + (uri) => parseURI(uri)?.host ?? uri, 189 202 );
+2 -2
src/components/input/icecast/worker.js
··· 95 95 const parsed = parseURI(track.uri); 96 96 if (!parsed) return track; 97 97 98 - const metadata = await fetchMetadata(parsed.httpsUrl); 98 + const metadata = await fetchMetadata(parsed.streamUrl); 99 99 if (!metadata) return track; 100 100 101 101 return { ··· 131 131 const expiresAtSeconds = Math.round(Date.now() / 1000) + expiresInSeconds; 132 132 133 133 return { 134 - url: parsed.httpsUrl, 134 + url: parsed.streamUrl, 135 135 expiresAt: expiresAtSeconds, 136 136 }; 137 137 }
+3
src/components/processor/artwork/worker.js
··· 259 259 260 260 art.push(...fromMeta); 261 261 262 + // Stop here if insufficient metadata is present 263 + if (!req.tags?.artist || !req.tags?.album) return art; 264 + 262 265 // If no artwork, try finding it on other sources 263 266 if (art.length === 0) { 264 267 const fromMusicBrainz = await musicBrainz(req);
+10 -4
src/themes/blur/artwork-controller/element.js
··· 255 255 #formatTimestamps() { 256 256 const currTrack = this.currentTrack(); 257 257 const audio = this.audio(); 258 - const prog = audio?.progress() ?? 0; 258 + const curMs = (audio?.currentTime() ?? 0) * 1000; 259 259 const durMs = currTrack?.stats?.duration ?? 260 260 (audio?.duration() != null ? audio.duration() * 1000 : undefined); 261 261 262 - if (audio && durMs != undefined && !isNaN(durMs)) { 262 + if (audio && durMs && !isNaN(durMs)) { 263 263 const p = Temporal.Duration.from({ 264 - milliseconds: Math.round(durMs * prog), 264 + milliseconds: Math.round(curMs), 265 265 }).round({ 266 266 largestUnit: "hours", 267 267 smallestUnit: "seconds", 268 268 }); 269 + 270 + if (durMs === Infinity) { 271 + this.#time.value = this.#formatTime(p); 272 + this.#duration.value = "∞"; 273 + return; 274 + } 269 275 270 276 const d = Temporal.Duration.from({ milliseconds: Math.round(durMs) }) 271 277 .round({ ··· 467 473 ? `normal` 468 474 : `italic`}"> 469 475 ${activeQueueItem?.tags?.artist ?? 470 - (activeQueueItem ? `Waiting on queue ...` : ``)} 476 + (activeQueueItem ? `` : `Waiting on queue ...`)} 471 477 </span> 472 478 </cite> 473 479
+74
src/themes/webamp/configurators/input/element.js
··· 13 13 import { isSupported as supportsLocalFsAccess } from "~/components/input/local/common.js"; 14 14 15 15 import { SCHEME as HTTPS_SCHEME } from "~/components/input/https/constants.js"; 16 + import { buildURI as buildIcecastURI } from "~/components/input/icecast/common.js"; 17 + import { SCHEME as ICECAST_SCHEME } from "~/components/input/icecast/constants.js"; 16 18 import { SCHEME as LOCAL_SCHEME } from "~/components/input/local/constants.js"; 17 19 import { SCHEME as OPENSUBSONIC_SCHEME } from "~/components/input/opensubsonic/constants.js"; 18 20 import { SCHEME as S3_SCHEME } from "~/components/input/s3/constants.js"; ··· 240 242 /** 241 243 * @param {Event} event 242 244 */ 245 + #addIcecastUrl = async (event) => { 246 + event.preventDefault(); 247 + 248 + /** @type {HTMLButtonElement | null} */ 249 + const button = this.root().querySelector("#icecast-submit"); 250 + if (button) button.disabled = true; 251 + 252 + const url = this.formElement("icecast-url")?.value; 253 + 254 + if (!url) { 255 + throw new Error("Missing required `url` input value"); 256 + } 257 + 258 + await this.addSource(buildIcecastURI(url)); 259 + 260 + if (button) button.disabled = false; 261 + }; 262 + 263 + /** 264 + * @param {Event} event 265 + */ 243 266 #deleteSelected = async (event) => { 244 267 const button = /** @type {HTMLElement} */ (event.target); 245 268 const fieldset = event.target ? button.closest("fieldset") : null; ··· 381 404 /* FORMS */ 382 405 383 406 input, select, textarea { 407 + color: rgb(34, 34, 34); 384 408 flex: 1; 385 409 } 386 410 </style> ··· 395 419 <li role="tab" aria-selected="${this.$tab.value === "https"}"> 396 420 <label @click="${() => this.$tab.value = "https"}"> 397 421 <span>HTTPS</span> 422 + </label> 423 + </li> 424 + <li role="tab" aria-selected="${this.$tab.value === "icecast"}"> 425 + <label @click="${() => this.$tab.value = "icecast"}"> 426 + <span>Icecast</span> 398 427 </label> 399 428 </li> 400 429 <li role="tab" aria-selected="${this.$tab.value === "local"}"> ··· 430 459 return this.#renderOverviewTab(html); 431 460 case "https": 432 461 return this.#renderHttpsTab(html); 462 + case "icecast": 463 + return this.#renderIcecastTab(html); 433 464 case "local": 434 465 return this.#renderLocalTab(html); 435 466 case "opensubsonic": ··· 501 532 502 533 <p> 503 534 <button type="submit" id="https-submit">Add URL</button> 535 + </p> 536 + </form> 537 + </div> 538 + `; 539 + } 540 + 541 + /** 542 + * @param {RenderArg["html"]} html 543 + */ 544 + #renderIcecastTab(html) { 545 + const sources = this.$sourcesOrchestrator.value?.sources(); 546 + 547 + return html` 548 + <div class="window-body"> 549 + <fieldset> 550 + ${this.#renderList( 551 + html, 552 + sources?.[ICECAST_SCHEME] ?? [], 553 + "Added streams", 554 + )} 555 + 556 + <p> 557 + <button disabled role="delete" @click="${this.#deleteSelected}"> 558 + Delete selected 559 + </button> 560 + </p> 561 + </fieldset> 562 + 563 + <form @submit="${this.#addIcecastUrl}"> 564 + <fieldset> 565 + <div class="field-row"> 566 + <label for="icecast-url">URL:</label> 567 + <input 568 + id="icecast-url" 569 + type="url" 570 + required 571 + placeholder="https://example.com/stream" 572 + /> 573 + </div> 574 + </fieldset> 575 + 576 + <p> 577 + <button type="submit" id="icecast-submit">Add stream</button> 504 578 </p> 505 579 </form> 506 580 </div>