diff --git a/src/components/engine/audio/element.js b/src/components/engine/audio/element.js index 796857ae..0f7ca0c0 100644 --- a/src/components/engine/audio/element.js +++ b/src/components/engine/audio/element.js @@ -191,7 +191,17 @@ class AudioEngine extends BroadcastableDiffuseElement { * @type {Actions["pause"]} */ pause({ audioId }) { - this.#withAudioNode(audioId, (audio) => audio.pause()); + this.#withAudioNode(audioId, (audio, item) => { + audio.pause(); + // Set `isPlaying` to false optimistically, mirroring `play()`. The + // `pause` event would normally do this via `pauseEvent`, but when + // `play()` was called before the audio had buffered enough to start + // (e.g. readyState < HAVE_FUTURE_DATA) the browser may never fire a + // `pause` event — leaving `isPlaying` stuck on the optimistic `true` + // that `play()` set. It also prevents `canplayEvent`'s retry-on-ready + // logic from restarting playback after an explicit pause. + item.$state.isPlaying.set(false); + }); } /** diff --git a/src/components/metadata/common.js b/src/components/metadata/common.js index 3dd8eb48..3ff247dd 100644 --- a/src/components/metadata/common.js +++ b/src/components/metadata/common.js @@ -1,4 +1,4 @@ -import { parseBlob, parseFromTokenizer, parseWebStream } from "music-metadata"; +import { parseBuffer, parseFromTokenizer, parseWebStream } from "music-metadata"; import * as URI from "fast-uri"; import { HttpClient } from "@tokenizer/http"; import { tokenizer as rangeTokenizer } from "@tokenizer/range"; @@ -12,6 +12,42 @@ import { removeUndefinedValuesFromRecord } from "~/common/utils.js"; // 🛠️ +/** + * Maps the audio MIME types music-metadata's loaders register to the + * extension `findLoaderForExtension` recognises. Used for blob URLs, which + * carry no filename, so the parser can be picked by extension instead of + * by content-type (whose matcher is broken in the browser bundle). + * + * @type {Record} + */ +const MIME_TO_EXT = { + "audio/mpeg": "mp3", + "audio/mp3": "mp3", + "audio/aac": "aac", + "audio/aacp": "aacp", + "audio/mp4": "m4a", + "audio/m4a": "m4a", + "audio/ogg": "ogg", + "audio/opus": "opus", + "audio/speex": "spx", + "audio/flac": "flac", + "audio/aiff": "aiff", + "audio/aif": "aif", + "audio/aifc": "aifc", + "audio/wav": "wav", + "audio/wave": "wav", + "audio/vnd.wave": "wav", + "audio/x-wav": "wav", + "audio/webm": "webm", + "audio/ape": "ape", + "audio/monkeys-audio": "ape", + "audio/musepack": "mpc", + "audio/wavpack": "wv", + "audio/asf": "asf", + "audio/ms-wma": "wma", + "audio/dsf": "dsf", +}; + /** * @param {{ includeArtwork?: boolean; mimeType?: string; stream?: ReadableStream; urls?: Urls; }} _ * @returns {Promise} @@ -30,7 +66,20 @@ export async function musicMetadataTags({ if (urls?.get.startsWith("blob:")) { const blob = await fetch(urls.get).then((r) => r.blob()); - meta = await parseBlob(blob, { skipCovers: !includeArtwork }); + // Blob URLs carry no filename, so `filename` above is just the blob's UUID. + // Without a path or a recognised MIME-type music-metadata falls back to + // content-sniffing, whose content-type matcher is broken in the browser + // bundle (the `content-type` CJS interop leaves `default` undefined, so + // `findLoaderForContentType` always throws — see the range-tokenizer + // branch below for the same issue). Derive a filename from the blob's + // MIME type so the parser is picked by extension instead. + const ext = MIME_TO_EXT[blob.type]; + const buffer = new Uint8Array(await blob.arrayBuffer()); + meta = await parseBuffer( + buffer, + ext ? { path: `file.${ext}` } : undefined, + { skipCovers: !includeArtwork }, + ); } else if (urls) { const httpClient = new HttpClient(urls.head, { resolveUrl: false, diff --git a/src/facets/themes/tidal/player/element.js b/src/facets/themes/tidal/player/element.js index c77426e3..4f1e1573 100644 --- a/src/facets/themes/tidal/player/element.js +++ b/src/facets/themes/tidal/player/element.js @@ -133,7 +133,7 @@ class Player extends DiffuseElement { } const mime = detectMime(bytes); const url = URL.createObjectURL( - new Blob([bytes], { type: mime }), + new Blob([/** @type {BlobPart} */ (bytes)], { type: mime }), ); this.#artUrl.value = url; }); @@ -313,10 +313,14 @@ class Player extends DiffuseElement { // RENDER /** - * @param {RenderArg} { html } + * @param {RenderArg} _ */ render({ html }) { const artUrl = this.#artUrl.value; + const currentTrack = this.$controller.value?.currentTrack(); + const isFav = currentTrack + ? this.$favourites.value?.isFavourite(currentTrack) ?? false + : false; return html` @@ -338,34 +342,19 @@ class Player extends DiffuseElement {
- ${this.$controller.value?.currentTrack()?.tags?.title ?? ""} + ${currentTrack?.tags?.title ?? ""} - ${this.$controller.value?.currentTrack()?.tags?.artist ?? ""} + ${currentTrack?.tags?.artist ?? ""}
diff --git a/tests/components/configurator/input/test.ts b/tests/components/configurator/input/test.ts index 3730599f..468f5007 100644 --- a/tests/components/configurator/input/test.ts +++ b/tests/components/configurator/input/test.ts @@ -78,7 +78,7 @@ describe("components/configurator/input", () => { ]); }); - expect(result["ipfs"]?.available).toBe(false); + expect(result["ipfs"]?.available).toBe("no"); expect(result["ipfs"]?.uris).toEqual(["ipfs://QmA", "ipfs://QmB"]); }); diff --git a/tests/components/input/ephemeral-cache/test.ts b/tests/components/input/ephemeral-cache/test.ts index 9b008fc8..cb810c48 100644 --- a/tests/components/input/ephemeral-cache/test.ts +++ b/tests/components/input/ephemeral-cache/test.ts @@ -59,7 +59,7 @@ describe("components/input/ephemeral-cache", () => { expect(result.supported).toBe(true); if (result.supported) { - expect(result.consult).toBe(false); + expect(result.consult).toBe("no"); } }); @@ -88,7 +88,7 @@ describe("components/input/ephemeral-cache", () => { expect(result.supported).toBe(true); if (result.supported) { - expect(result.consult).toBe(true); + expect(result.consult).toBe("yes"); } }); @@ -116,7 +116,7 @@ describe("components/input/ephemeral-cache", () => { return result; }); - expect(result["ephemeral+cache"]?.available).toBe(true); + expect(result["ephemeral+cache"]?.available).toBe("yes"); expect(result["ephemeral+cache"]?.uris).toEqual([ "ephemeral+cache://bafkgroupcached", ]); @@ -136,7 +136,7 @@ describe("components/input/ephemeral-cache", () => { ]); }); - expect(result["ephemeral+cache"]?.available).toBe(true); + expect(result["ephemeral+cache"]?.available).toBe("yes"); expect(result["ephemeral+cache"]?.uris).toEqual([]); }); diff --git a/tests/components/orchestrator/scoped-tracks/test.ts b/tests/components/orchestrator/scoped-tracks/test.ts index 4573c77d..e8aed504 100644 --- a/tests/components/orchestrator/scoped-tracks/test.ts +++ b/tests/components/orchestrator/scoped-tracks/test.ts @@ -224,7 +224,7 @@ describe("components/orchestrator/scoped-tracks – disabled sources", () => { SCHEME = "test"; sources() { return []; } async groupConsult(uris: string[]) { - return { all: { available: true, scheme: "test", uris } }; + return { all: { available: "yes", scheme: "test", uris } }; } } customElements.define("di-test-input-1", TestInput); @@ -274,7 +274,7 @@ describe("components/orchestrator/scoped-tracks – disabled sources", () => { SCHEME = "test"; sources() { return []; } async groupConsult(uris: string[]) { - return { all: { available: true, scheme: "test", uris } }; + return { all: { available: "yes", scheme: "test", uris } }; } } customElements.define("di-test-input-2", TestInput); @@ -326,7 +326,7 @@ describe("components/orchestrator/scoped-tracks – disabled sources", () => { SCHEME = "test"; sources() { return []; } async groupConsult(uris: string[]) { - return { all: { available: true, scheme: "test", uris } }; + return { all: { available: "yes", scheme: "test", uris } }; } } customElements.define("di-test-input-3", TestInput); @@ -378,7 +378,7 @@ describe("components/orchestrator/scoped-tracks – disabled sources", () => { SCHEME = "test"; sources() { return []; } async groupConsult(uris: string[]) { - return { all: { available: true, scheme: "test", uris } }; + return { all: { available: "yes", scheme: "test", uris } }; } } customElements.define("di-test-input-4", TestInput); @@ -430,7 +430,7 @@ describe("components/orchestrator/scoped-tracks – disabled sources", () => { SCHEME = "test"; sources() { return []; } async groupConsult(uris: string[]) { - return { all: { available: true, scheme: "test", uris } }; + return { all: { available: "yes", scheme: "test", uris } }; } } customElements.define("di-test-input-5", TestInput);