diff --git a/core/src/player/audio-adapter.ts b/core/src/player/audio-adapter.ts index 68b4eb35..6fecc58b 100644 --- a/core/src/player/audio-adapter.ts +++ b/core/src/player/audio-adapter.ts @@ -68,6 +68,11 @@ export interface AudioAdapter { duration(): number; /** Subscribe to an event. Returns an unsubscribe function. */ on(event: AudioAdapterEvent, handler: () => void): () => void; + /** + * What the platform said about the latest `error`, for the failure's + * details; null when it said nothing. + */ + errorDetail?(): string | null; /** Release any resources held by this adapter. */ dispose(): void; } diff --git a/core/src/player/index.ts b/core/src/player/index.ts index 764549a3..a0bf42e6 100644 --- a/core/src/player/index.ts +++ b/core/src/player/index.ts @@ -8,6 +8,8 @@ export { fisherYatesWithAnchor, repeatModeLabel, SMOOTH_MIX_SECONDS, + type PlaybackError, + type PlaybackErrorDetails, type PlayerControls, type PlayerState, type PlayOptions, @@ -20,6 +22,11 @@ export { type AudioAdapterEvent, } from "./audio-adapter"; export { usePlayerCore } from "./use-player-core"; +export { + playbackErrorLines, + playbackErrorText, + type PlaybackErrorLine, +} from "./playback-error"; export { activityTrack, playbackDeviceButtonLabel, diff --git a/core/src/player/playback-error.ts b/core/src/player/playback-error.ts new file mode 100644 index 00000000..52cb2454 --- /dev/null +++ b/core/src/player/playback-error.ts @@ -0,0 +1,42 @@ +import type { TrackListItem } from "../api"; +import { displayText } from "../format"; +import type { PlaybackError } from "./player-core"; + +/** One labelled line of a playback failure's details. */ +export interface PlaybackErrorLine { + label: string; + value: string; +} + +/** + * A playback failure's details as labelled lines, in the order they're shown + * and copied. `track` is the failed track, while it's still loaded. + */ +export function playbackErrorLines( + error: PlaybackError, + track: TrackListItem | null, +): PlaybackErrorLine[] { + const { at, source, player, server } = error.details; + const lines: PlaybackErrorLine[] = [{ label: "Error", value: error.message }]; + if (track?.id === error.trackId) { + const artist = displayText(track.artist); + const title = displayText(track.title, "Untitled"); + lines.push({ label: "Track", value: artist ? `${title} — ${artist}` : title }); + } + lines.push( + { label: "Track ID", value: error.trackId }, + { label: "Source", value: source }, + { label: "Player", value: player ?? "Gave no reason" }, + { + label: "Server", + value: server === undefined ? "Asking…" : (server ?? "Not asked: a downloaded copy"), + }, + { label: "Time", value: new Date(at).toISOString() }, + ); + return lines; +} + +/** The lines as plain text, to copy into a bug report. */ +export function playbackErrorText(lines: PlaybackErrorLine[]): string { + return lines.map(({ label, value }) => `${label}: ${value}`).join("\n"); +} diff --git a/core/src/player/player-core.ts b/core/src/player/player-core.ts index fda7b3d0..69a08aa4 100644 --- a/core/src/player/player-core.ts +++ b/core/src/player/player-core.ts @@ -7,6 +7,23 @@ export interface PlaybackError { trackId: string; /** Short and user-facing: the server's reason, or a generic one. */ message: string; + /** What happened, for a closer look or a bug report. */ + details: PlaybackErrorDetails; +} + +/** The facts behind a `PlaybackError`. */ +export interface PlaybackErrorDetails { + /** When it failed, in ms since the epoch. */ + at: number; + /** What the player was given: a server path, or a downloaded copy's URI. */ + source: string; + /** What the audio player said about it; null when it said nothing. */ + player: string | null; + /** + * What the server answered when asked for the stream again. Undefined + * while it's being asked; null when it wasn't (a downloaded copy). + */ + server?: string | null; } export interface PlayerState { diff --git a/core/src/player/use-player-core.ts b/core/src/player/use-player-core.ts index 448df427..d8fa2261 100644 --- a/core/src/player/use-player-core.ts +++ b/core/src/player/use-player-core.ts @@ -20,6 +20,7 @@ import { shouldReportPlay, withPlayNext, type PlaybackError, + type PlaybackErrorDetails, type PlayerControls, type PlayerState, type RepeatMode, @@ -91,29 +92,82 @@ function firstPlayableIndex( const PLAYBACK_ERROR_FALLBACK = "Couldn't play this track."; +/** What asking for a failed stream again found. */ +interface StreamProbe { + /** The server's reason it can't serve the stream, when it gave one. */ + reason: string | null; + /** The answer, for the failure's details. */ + answer: string; +} + /** * Why a stream failed, in the server's words: request it once more. The * backend answers a TIDAL stream it can't serve with a 502 or 503 and a short * plain-text reason (and remembers TIDAL's refusal, so this doesn't ask TIDAL - * again). Anything else, such as a stream that works now, a network failure, - * another error or a reverse proxy's HTML error page, gets the generic - * message. + * again). Any other answer, such as a stream that works now, a network + * failure, another error or a reverse proxy's HTML error page, gives no + * reason; it is only described. */ -async function streamFailureMessage(url: string): Promise { +async function probeStream( + url: string, + { firstByte, signal }: { firstByte: boolean; signal: AbortSignal }, +): Promise { try { - const response = await rawFetch(url); - // Nothing to explain; don't download the playlist. + // A library file is served with ranges, so one byte answers as well as + // the whole file. A TIDAL stream is asked exactly as the player asks it. + const response = await rawFetch(url, { + signal, + ...(firstByte ? { headers: { Range: "bytes=0-0" } } : {}), + }); + // Nothing to explain; don't download the stream. void response.body?.cancel().catch(() => {}); + const type = response.headers.get("content-type"); + return { + reason: null, + answer: detailLine(`HTTP ${response.status}${type ? ` · ${type}` : ""}`) ?? "", + }; } catch (error) { - const text = - error instanceof ApiError && (error.status === 502 || error.status === 503) - ? error.message - : ""; - if (text && text.length <= 300 && !text.startsWith("<") && !text.includes("\n")) { - return text; + if (!(error instanceof ApiError)) { + return { reason: null, answer: detailLine(`No answer: ${String(error)}`) ?? "No answer" }; } + const text = error.message; + const readable = + text.length <= 300 && !text.startsWith("<") && !text.includes("\n") ? text : ""; + return { + reason: + readable && (error.status === 502 || error.status === 503) ? detailLine(readable) : null, + answer: detailLine(`HTTP ${error.status}${readable ? ` · ${readable}` : ""}`) ?? "", + }; + } +} + +/** + * A failure's account fit for a bug report: on one line, capped, and without + * addresses (hls.js quotes a failed key's URI, which may be signed, and a + * proxy's error page may name its upstream). + */ +function detailLine(text: string | null | undefined): string | null { + const plain = text + ?.replace(/[a-z][a-z0-9+.-]*:\/\/[^\s"'<>]+/gi, "
") + .replace(/\s+/g, " ") + .trim(); + if (!plain) return null; + return plain.length > 300 ? `${plain.slice(0, 299)}…` : plain; +} + +/** + * A failed source as the details show it: a server address without its + * origin, or a downloaded copy by its file name (its folder is the device's). + */ +function failedSource(uri: string): string { + if (/^file:/i.test(uri)) return `Downloaded copy · ${uri.slice(uri.lastIndexOf("/") + 1)}`; + if (!/^https?:\/\//i.test(uri)) return uri; + try { + const { pathname, search } = new URL(uri); + return pathname + search; + } catch { + return uri; } - return PLAYBACK_ERROR_FALLBACK; } /** @@ -155,6 +209,8 @@ export function usePlayerCore({ // message may still be on the way): playing that track again retries it, // and a message only lands while this is still the same failure. const failureRef = useRef<{ trackId: string } | null>(null); + // The server being asked why the current failure happened. + const probeRef = useRef(null); const playbackReportedRef = useRef(null); const lastFMScrobbledRef = useRef(null); // Lazily seeded (see the track-change effect below) rather than @@ -165,6 +221,10 @@ export function usePlayerCore({ const listenedSecondsRef = useRef(0); const listeningTickRef = useRef(null); const loadedTrackIdRef = useRef(null); + // The source the adapter was last given, as loaded or activated. A failure + // is about it, whatever the track would resolve to now (a download removed + // while it plays still played from the file). + const loadedUriRef = useRef(null); const preparedNextRef = useRef<{ trackId: string; uri: string } | null>(null); const playbackAttemptRef = useRef(0); const handoffRef = useRef<{ track: TrackListItem; attempt: number } | null>(null); @@ -282,6 +342,9 @@ export function usePlayerCore({ }, [adapter]); const clearPlaybackError = useCallback(() => { + // A failure that's gone has nothing left to ask about. + probeRef.current?.abort(); + probeRef.current = null; if (!failureRef.current) return; failureRef.current = null; setPlaybackError(null); @@ -305,7 +368,8 @@ export function usePlayerCore({ setDuration(0); anchorRef.current = { audioTime: 0, wallTime: performance.now() }; loadedTrackIdRef.current = track.id; - adapter.load(resolvePlayableUri(track.id)); + loadedUriRef.current = resolvePlayableUri(track.id); + adapter.load(loadedUriRef.current); startPlayback(); handoffRef.current = { track, attempt: playbackAttemptRef.current }; setIsPlaying(true); @@ -518,6 +582,7 @@ export function usePlayerCore({ crossfade || (isPrepared && adapter.activatePrepared?.(nextUri) === true); if (activated) { loadedTrackIdRef.current = nextTrack.id; + loadedUriRef.current = nextUri; } else { adapter.clearPrepared?.(); } @@ -532,6 +597,7 @@ export function usePlayerCore({ adapter.seek(0); } else { loadedTrackIdRef.current = nextTrack.id; + loadedUriRef.current = nextUri; adapter.load(nextUri); } startPlayback(); @@ -725,7 +791,8 @@ export function usePlayerCore({ if (!current) return; if (loadedTrackIdRef.current === current.id) return; loadedTrackIdRef.current = current.id; - adapter.load(resolvePlayableUri(current.id)); + loadedUriRef.current = resolvePlayableUri(current.id); + adapter.load(loadedUriRef.current); }, [adapter, current, resolvePlayableUri]); const currentId = current?.id; @@ -964,16 +1031,39 @@ export function usePlayerCore({ if (!track) return; const failure = { trackId: track.id }; failureRef.current = failure; - const report = (message: string) => { - if (failureRef.current === failure) setPlaybackError({ trackId: track.id, message }); + // The source loaded for this track. Until a track change commits, the + // next track's may be loaded already; this one is resolved again then. + const uri = + (loadedTrackIdRef.current === track.id && loadedUriRef.current) || resolveUri(track.id); + const url = streamUrl(track.id); + // A downloaded copy just didn't play; only a stream can be asked again. + const streamed = uri === url; + const details: PlaybackErrorDetails = { + at: Date.now(), + source: failedSource(uri), + player: detailLine(adapter.errorDetail?.()), + server: streamed ? undefined : null, + }; + const report = (message: string, server = details.server) => { + if (failureRef.current === failure) { + setPlaybackError({ trackId: track.id, message, details: { ...details, server } }); + } }; // Say so right away; the server's reason, if any, replaces it. report(PLAYBACK_ERROR_FALLBACK); - // Only the server can say why its TIDAL stream failed. A downloaded - // copy or a library file just didn't play. - const url = streamUrl(track.id); - if (isTidalTrack(track) && resolveUri(track.id) === url) { - void streamFailureMessage(url).then(report); + probeRef.current?.abort(); + probeRef.current = null; + if (streamed) { + const probe = new AbortController(); + probeRef.current = probe; + void probeStream(url, { firstByte: !isTidalTrack(track), signal: probe.signal }).then( + ({ reason, answer }) => { + if (probe.signal.aborted) return; + if (probeRef.current === probe) probeRef.current = null; + // Only TIDAL streams fail for a reason worth showing on the bar. + report(isTidalTrack(track) && reason ? reason : PLAYBACK_ERROR_FALLBACK, answer); + }, + ); } }); return () => { @@ -988,6 +1078,7 @@ export function usePlayerCore({ }, [adapter, startPlayback]); useEffect(() => () => adapter.clearPrepared?.(), [adapter]); + useEffect(() => () => probeRef.current?.abort(), []); // The exposed clock is quantized to 250 ms. Sample at that cadence while // retaining the audio/wall-clock anchor between native updates. diff --git a/core/tests/playback-error.test.ts b/core/tests/playback-error.test.ts new file mode 100644 index 00000000..ee7308df --- /dev/null +++ b/core/tests/playback-error.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import type { TrackListItem } from "../src/api"; +import { playbackErrorLines, playbackErrorText } from "../src/player/playback-error"; +import type { PlaybackError } from "../src/player/player-core"; + +const track: TrackListItem = { + id: "tidal:1", + title: "Thrills (v2.2)", + artist: "Someone", + duration_ms: 180_000, +}; + +const failure = (details: Partial = {}): PlaybackError => ({ + trackId: "tidal:1", + message: "Couldn't play this track.", + details: { + at: Date.UTC(2026, 9, 29, 12, 0, 0), + source: "/api/tracks/tidal%3A1/stream", + player: "hls.js manifestLoadError: A network error (status 502) occurred while loading manifest", + server: "HTTP 502 · TIDAL refused to stream this track", + ...details, + }, +}); + +describe("playbackErrorLines", () => { + it("lists what happened, in order", () => { + expect(playbackErrorText(playbackErrorLines(failure(), track))).toBe( + [ + "Error: Couldn't play this track.", + "Track: Thrills (v2.2) — Someone", + "Track ID: tidal:1", + "Source: /api/tracks/tidal%3A1/stream", + "Player: hls.js manifestLoadError: A network error (status 502) occurred while loading manifest", + "Server: HTTP 502 · TIDAL refused to stream this track", + "Time: 2026-10-29T12:00:00.000Z", + ].join("\n"), + ); + }); + + it("says when nobody gave a reason, or the server wasn't asked", () => { + const lines = playbackErrorLines(failure({ player: null, server: null }), track); + expect(lines).toContainEqual({ label: "Player", value: "Gave no reason" }); + expect(lines).toContainEqual({ label: "Server", value: "Not asked: a downloaded copy" }); + }); + + it("says the server is still being asked", () => { + const lines = playbackErrorLines(failure({ server: undefined }), track); + expect(lines).toContainEqual({ label: "Server", value: "Asking…" }); + }); + + it("leaves the track out once another one is loaded", () => { + const lines = playbackErrorLines(failure(), { ...track, id: "tidal:2" }); + expect(lines.map((line) => line.label)).not.toContain("Track"); + }); +}); diff --git a/core/tests/use-player-core.test.tsx b/core/tests/use-player-core.test.tsx index ad444709..78b078b9 100644 --- a/core/tests/use-player-core.test.tsx +++ b/core/tests/use-player-core.test.tsx @@ -105,6 +105,12 @@ const ids = (tracks: TrackListItem[]) => tracks.map((track) => track.id); beforeEach(() => { vi.clearAllMocks(); + // A failed stream is asked for again; unless a test answers, it never is. + vi.stubGlobal("fetch", vi.fn(() => new Promise(() => {}))); +}); + +afterEach(() => { + vi.unstubAllGlobals(); }); describe("usePlayerCore", () => { @@ -802,10 +808,6 @@ describe("usePlayerCore playback errors", () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); - afterEach(() => { - vi.unstubAllGlobals(); - }); - it("stops on the failed track and shows the server's reason for a TIDAL stream", async () => { const fetchMock = vi.fn(async () => new Response(refusal, { status: 502 })); vi.stubGlobal("fetch", fetchMock); @@ -815,30 +817,75 @@ describe("usePlayerCore playback errors", () => { act(() => emit("error")); expect(result.current.state.isPlaying).toBe(false); await settle(); - expect(result.current.state.playbackError).toEqual({ trackId: "tidal:1", message: refusal }); + expect(result.current.state.playbackError).toEqual({ + trackId: "tidal:1", + message: refusal, + details: { + at: expect.any(Number), + source: "test://stream/tidal:1", + player: null, + server: `HTTP 502 · ${refusal}`, + }, + }); expect(result.current.state.current?.id).toBe("tidal:1"); expect(fetchMock).toHaveBeenCalledExactlyOnceWith("test://stream/tidal:1", expect.anything()); }); it.each([ - ["a library track", t("a"), undefined], - ["a reverse proxy's HTML page", tidal("1"), () => new Response("502 Bad Gateway", { status: 502 })], - ["a stream that answers now", tidal("1"), () => new Response("#EXTM3U\n", { status: 200 })], - ["an error that isn't TIDAL's", tidal("1"), () => new Response("internal error", { status: 500 })], - ["an unreachable server", tidal("1"), () => Promise.reject(new TypeError("Failed to fetch"))], - ])("shows a generic message for %s", async (_case, track, respond) => { - const fetchMock = vi.fn(async () => respond ? respond() : new Response(refusal, { status: 502 })); + ["a library track's refusal", t("a"), () => new Response(refusal, { status: 502 }), `HTTP 502 · ${refusal}`], + ["a library file gone from disk", t("a"), () => new Response("file missing on disk", { status: 410 }), "HTTP 410 · file missing on disk"], + ["a reverse proxy's HTML page", tidal("1"), () => new Response("502 Bad Gateway", { status: 502 }), "HTTP 502"], + ["a stream that answers now", tidal("1"), () => new Response("#EXTM3U\n", { status: 200, headers: { "Content-Type": "application/vnd.apple.mpegurl" } }), "HTTP 200 · application/vnd.apple.mpegurl"], + ["an error that isn't TIDAL's", tidal("1"), () => new Response("internal error", { status: 500 }), "HTTP 500 · internal error"], + ["an unreachable server", tidal("1"), () => Promise.reject(new TypeError("Failed to fetch")), "No answer: TypeError: Failed to fetch"], + ])("shows a generic message for %s, and the answer in the details", async (_case, track, respond, server) => { + const fetchMock = vi.fn(async () => respond()); vi.stubGlobal("fetch", fetchMock); const { result, emit } = await setup(); act(() => result.current.controls.play(track)); act(() => emit("error")); await settle(); - expect(result.current.state.playbackError).toEqual({ + expect(result.current.state.playbackError).toMatchObject({ trackId: track.id, message: "Couldn't play this track.", + details: { server }, }); - expect(fetchMock).toHaveBeenCalledTimes(respond ? 1 : 0); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("asks for a library file's first byte, and a TIDAL stream as the player does", async () => { + const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => new Response("x", { status: 206 })); + vi.stubGlobal("fetch", fetchMock); + const { result, emit } = await setup(); + const range = (call: number) => + (fetchMock.mock.calls[call][1]?.headers as Record | undefined)?.Range; + + act(() => result.current.controls.play(t("a"), [t("a"), tidal("1")])); + act(() => emit("error")); + await settle(); + expect(range(0)).toBe("bytes=0-0"); + + act(() => result.current.controls.next()); + act(() => emit("error")); + await settle(); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(range(1)).toBeUndefined(); + }); + + it.each([ + ["an error", () => new Response("upstream http://10.0.0.5:8080/x unreachable", { status: 502 }), "HTTP 502 · upstream
unreachable"], + ["a success", () => new Response("x", { status: 200, headers: { "Content-Type": `audio/flac; see=https://proxy.example/why ${"x".repeat(400)}` } }), "HTTP 200 · audio/flac; see=
x"], + ])("keeps no address from the server's answer to %s, and caps it", async (_case, respond, start) => { + vi.stubGlobal("fetch", vi.fn(async () => respond())); + const { result, emit } = await setup(); + act(() => result.current.controls.play(t("a"))); + + act(() => emit("error")); + await settle(); + const server = result.current.state.playbackError?.details.server ?? ""; + expect(server.startsWith(start)).toBe(true); + expect(server.length).toBeLessThanOrEqual(300); }); it("does not ask the server about a downloaded TIDAL track", async () => { @@ -849,10 +896,131 @@ describe("usePlayerCore playback errors", () => { act(() => emit("error")); await settle(); - expect(result.current.state.playbackError?.message).toBe("Couldn't play this track."); + expect(result.current.state.playbackError).toMatchObject({ + message: "Couldn't play this track.", + details: { source: "Downloaded copy · offline.flac", server: null }, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("describes the source that was loaded, even if the track resolves elsewhere now", async () => { + const fetchMock = vi.fn(async () => new Response(refusal, { status: 502 })); + vi.stubGlobal("fetch", fetchMock); + let downloaded = true; + const { result, emit } = await setup({ + resolveTrackUri: () => (downloaded ? "file:///offline.flac" : undefined), + }); + act(() => result.current.controls.play(t("a"))); + + // The download is removed while its file plays; then the file fails. + downloaded = false; + act(() => emit("error")); + await settle(); + expect(result.current.state.playbackError?.details).toMatchObject({ + source: "Downloaded copy · offline.flac", + server: null, + }); expect(fetchMock).not.toHaveBeenCalled(); }); + it("stops asking the server once the failure is gone", async () => { + const fetchMock = vi.fn((_url: string, _init?: RequestInit) => new Promise(() => {})); + vi.stubGlobal("fetch", fetchMock); + const { result, emit } = await setup(); + act(() => result.current.controls.play(t("a"), [t("a"), t("b")])); + act(() => emit("error")); + const signal = fetchMock.mock.calls[0][1]?.signal; + expect(signal?.aborted).toBe(false); + + act(() => result.current.controls.next()); + expect(signal?.aborted).toBe(true); + expect(result.current.state.playbackError).toBeNull(); + }); + + it("keeps no address from TIDAL's reason either", async () => { + vi.stubGlobal("fetch", vi.fn(async () => + new Response("TIDAL refused to stream this track: see https://cdn.example/x?sig=abc", { status: 502 }), + )); + const { result, emit } = await setup(); + act(() => result.current.controls.play(tidal("1"))); + + act(() => emit("error")); + await settle(); + const message = result.current.state.playbackError?.message ?? ""; + expect(message).toBe("TIDAL refused to stream this track: see
"); + }); + + it("describes the failed track's own source while the next one is loading", async () => { + const { result, emit } = await setup(); + act(() => result.current.controls.play(t("a"), [t("a"), t("b")])); + + // The next track's source is loaded before its change commits; a failure + // reported then is still the playing track's. + act(() => { + result.current.controls.next(); + emit("error"); + }); + expect(result.current.state.playbackError).toMatchObject({ + trackId: "a", + details: { source: "test://stream/a" }, + }); + }); + + it("keeps what the audio player said about the failure", async () => { + const fake = createFakeAdapter(); + fake.adapter.errorDetail = () => "MEDIA_ERR_DECODE: PIPELINE_ERROR_DECODE"; + const { result } = renderHook(() => + usePlayerCore({ + adapter: fake.adapter, + storage: createMemoryStorage(), + interpolateProgress: false, + }), + ); + act(() => result.current.controls.play(t("a"))); + + act(() => fake.emit("error")); + expect(result.current.state.playbackError?.details.player).toBe( + "MEDIA_ERR_DECODE: PIPELINE_ERROR_DECODE", + ); + }); + + it("keeps no address from what the audio player said, and caps it", async () => { + const fake = createFakeAdapter(); + let said = 'hls.js keyLoadError: Invalid key URI: "https://cdn.example/key?token=secret"'; + fake.adapter.errorDetail = () => said; + const { result } = renderHook(() => + usePlayerCore({ + adapter: fake.adapter, + storage: createMemoryStorage(), + interpolateProgress: false, + }), + ); + act(() => result.current.controls.play(t("a"))); + + act(() => fake.emit("error")); + expect(result.current.state.playbackError?.details.player).toBe( + 'hls.js keyLoadError: Invalid key URI: "
"', + ); + + said = `MEDIA_ERR_DECODE:\n${"x".repeat(400)}`; + act(() => result.current.controls.toggle()); + act(() => fake.emit("error")); + const player = result.current.state.playbackError?.details.player ?? ""; + expect(player).toHaveLength(300); + expect(player.startsWith("MEDIA_ERR_DECODE: xxx")).toBe(true); + expect(player.endsWith("…")).toBe(true); + }); + + it("shows a server address without its origin", async () => { + const { result, emit } = await setup({ + resolveTrackUri: (id) => `https://music.example/api/tracks/${id}/stream`, + }); + act(() => result.current.controls.play(t("a"))); + + act(() => emit("error")); + expect(result.current.state.playbackError?.details.source).toBe("/api/tracks/a/stream"); + }); + it("ignores a probe that answers after another track started", async () => { let answer!: (response: Response) => void; vi.stubGlobal("fetch", vi.fn(() => new Promise((resolve) => { answer = resolve; }))); @@ -903,6 +1071,7 @@ describe("usePlayerCore playback errors", () => { act(() => emit("error")); expect(result.current.state.playbackError?.message).toBe("Couldn't play this track."); + expect(result.current.state.playbackError?.details.server).toBeUndefined(); answer(new Response(refusal, { status: 502 })); await settle(); expect(result.current.state.playbackError?.message).toBe(refusal); diff --git a/frontend/src/adapters/html-audio-adapter.ts b/frontend/src/adapters/html-audio-adapter.ts index e8513c41..3c69ab1e 100644 --- a/frontend/src/adapters/html-audio-adapter.ts +++ b/frontend/src/adapters/html-audio-adapter.ts @@ -51,6 +51,9 @@ export function useHtmlAudioAdapter(): { // One registry for the adapter's lifetime: useState never replaces it, so // listing it as a dependency below re-runs nothing. const [listeners] = useState(createAudioAdapterEmitter); + // What the element or hls.js said about the latest failure; set right + // before each "error" is emitted. + const errorDetailRef = useRef(null); // Wire native events → listener registry once the element mounts. useEffect(() => { @@ -75,7 +78,9 @@ export function useHtmlAudioAdapter(): { // (it may still report the missing src), and hls.js reports and // recovers the media it drives itself. error: () => { - if (a.getAttribute("src") && !hlsRef.current) dispatchError(); + if (!a.getAttribute("src") || hlsRef.current) return; + errorDetailRef.current = describeMediaError(a.error); + dispatchError(); }, }; for (const [event, handler] of Object.entries(handlers)) { @@ -237,6 +242,7 @@ export function useHtmlAudioAdapter(): { if (!data.fatal || !giveUpOnFatalHlsError(HlsRuntime, hls, data)) return; if (hlsRef.current === hls) { hlsRef.current = null; + errorDetailRef.current = describeHlsError(data); listeners.emit("error"); } }); @@ -318,6 +324,7 @@ export function useHtmlAudioAdapter(): { preparedUrlRef.current = null; } else if (hlsRef.current === hls) { hlsRef.current = null; + errorDetailRef.current = describeHlsError(data); listeners.emit("error"); } }); @@ -409,6 +416,9 @@ export function useHtmlAudioAdapter(): { return activeAudioRef.current?.duration ?? 0; }, on: listeners.on, + errorDetail() { + return errorDetailRef.current; + }, dispose() { finishFade(); loadGenerationRef.current += 1; @@ -440,6 +450,34 @@ export function useHtmlAudioAdapter(): { return { adapter, audioRefs }; } +const MEDIA_ERROR_NAMES: Record = { + 1: "MEDIA_ERR_ABORTED", + 2: "MEDIA_ERR_NETWORK", + 3: "MEDIA_ERR_DECODE", + 4: "MEDIA_ERR_SRC_NOT_SUPPORTED", +}; + +/** + * The element's own account of a failure, e.g. "MEDIA_ERR_SRC_NOT_SUPPORTED: + * DEMUXER_ERROR_COULD_NOT_OPEN: FFmpegDemuxer: open context failed". + */ +function describeMediaError(error: MediaError | null): string | null { + if (!error) return null; + const name = MEDIA_ERROR_NAMES[error.code] ?? `MediaError ${error.code}`; + return error.message ? `${name}: ${error.message}` : name; +} + +/** + * hls.js's account of the error it gave up on, e.g. "hls.js manifestLoadError: + * A network error (status 502) occurred while loading manifest". Not its + * `url`: a segment's carries TIDAL's signed address. The few messages that + * quote one lose it in core, which replaces every address in the detail. + */ +function describeHlsError(data: ErrorData): string { + const message = data.error?.message; + return `hls.js ${data.details}${message ? `: ${message}` : ""}`; +} + /** * Recover from a fatal hls.js error where hls.js can, else destroy it. * Returns true when it gave up and the source is gone. A playlist that never diff --git a/frontend/src/components/MiniPlayer.tsx b/frontend/src/components/MiniPlayer.tsx index b607f323..10bd93f8 100644 --- a/frontend/src/components/MiniPlayer.tsx +++ b/frontend/src/components/MiniPlayer.tsx @@ -74,6 +74,7 @@ export default function MiniPlayer() { title={displayTitle} artist={displayArtist} error={displayError} + errorPanel={miniPlayerMode ? panelToggle("error") : undefined} onContextMenu={ displayCurrent && !isRemoteMode ? bindCtx(displayCurrent) @@ -153,7 +154,11 @@ export default function MiniPlayer() { {miniPlayerMode && panel && ( - + )} diff --git a/frontend/src/components/player/MiniPlayerPanel.tsx b/frontend/src/components/player/MiniPlayerPanel.tsx index 2f9e10fd..ca4840d5 100644 --- a/frontend/src/components/player/MiniPlayerPanel.tsx +++ b/frontend/src/components/player/MiniPlayerPanel.tsx @@ -1,6 +1,7 @@ import { usePlayer, usePlayerTime, useRemotePlayback } from "../../context/Player"; import { lyricsDurationSeconds, useTrackLyrics } from "../../lib/useTrackLyrics"; import { DeviceConnectionStatus, DeviceList } from "../PlaybackDevicePopover"; +import { PlaybackErrorDetails } from "./PlaybackErrorPopover"; import PlayerLyricsLine from "../PlayerLyricsLine"; import { QueueList, useQueuePosition } from "../QueuePopover"; import { @@ -10,29 +11,34 @@ import { import { MINI_PANEL_ID, type MiniPanel } from "./useMiniPlayerMode"; const TITLES: Record = { + error: "Playback error", devices: "Play on", lyrics: "Lyrics", queue: "Queue", }; const LABELS: Record = { + error: "Playback error", devices: "Playback device", lyrics: "Lyrics", queue: "Play queue", }; /** - * The mini player's side panel: the queue, lyrics or device list the utility - * buttons used to open as popovers. Renders two items of the player grid, a + * The mini player's side panel: the queue, lyrics, device list or playback + * error details that open as popovers outside mini mode. Renders two items of the player grid, a * heading beside the utility row and the body under it. `enter` slides a * panel swapped in for another from the side its button sits on. */ export default function MiniPlayerPanel({ panel, enter, + closing = false, }: { panel: MiniPanel; enter?: "forward" | "back"; + /** Asked to go: the panel keeps what it showed while it morphs away. */ + closing?: boolean; }) { const position = useQueuePosition(); @@ -60,6 +66,8 @@ export default function MiniPlayerPanel({ ) : panel === "lyrics" ? ( + ) : panel === "error" ? ( + ) : ( )} diff --git a/frontend/src/components/player/NowPlaying.tsx b/frontend/src/components/player/NowPlaying.tsx index fb2f96a0..91b31999 100644 --- a/frontend/src/components/player/NowPlaying.tsx +++ b/frontend/src/components/player/NowPlaying.tsx @@ -1,43 +1,101 @@ +import { useEffect, useState } from "react"; import type { TrackListItem } from "@music-library/core"; import { trackCoverUrl } from "../../api"; import { displayText } from "../../lib/format"; import CoverArt from "../CoverArt"; +import PlaybackErrorPopover from "./PlaybackErrorPopover"; +import { usePopoverOpen, type MiniPanelToggle } from "./useMiniPlayerMode"; export default function NowPlaying({ track, title, artist, error, + errorPanel, onContextMenu, }: { track: TrackListItem | null; title: string; artist: string; - /** Why the track stopped playing; shown in place of the artist. */ + /** + * Why the track stopped playing; shown in place of the artist. Clicking it + * shows what happened. + */ error?: string | null; + /** In the mini player the details open in its panel instead of a popover. */ + errorPanel?: MiniPanelToggle; onContextMenu?: React.MouseEventHandler; }) { const coverSrc = track ? trackCoverUrl(track) : null; + // Held in state, as in DevicePickerButton: the popover reads them during + // render. + const [block, setBlock] = useState(null); + const [errorButton, setErrorButton] = useState(null); + const [detailsOpen, setDetailsOpen] = usePopoverOpen(!!errorPanel); + // The details go with the failure: playing again, or another track, shuts + // them, so the next failure doesn't open them by itself. + if (!error && detailsOpen) setDetailsOpen(false); + const panelShowsGoneError = !error && !!errorPanel?.open; + const closePanel = errorPanel?.close; + useEffect(() => { + if (panelShowsGoneError) closePanel?.(); + }, [panelShowsGoneError, closePanel]); + const open = errorPanel ? errorPanel.open : detailsOpen; + return ( -
- -
-
{title}
-
- {/* Always rendered, so screen readers announce a failure as it - appears rather than only when they reach the line. */} - {error} - {!error && artist} + <> +
+ +
+
{title}
+
+ {/* Always rendered, so screen readers announce a failure as it + appears rather than only when they reach the line. */} + + {error && ( + + )} + + {!error && artist} +
-
+ {/* Outside the block, so its context menu doesn't reach the popover. */} + setDetailsOpen(false)} + /> + ); } diff --git a/frontend/src/components/player/PlaybackErrorPopover.tsx b/frontend/src/components/player/PlaybackErrorPopover.tsx new file mode 100644 index 00000000..c26e6046 --- /dev/null +++ b/frontend/src/components/player/PlaybackErrorPopover.tsx @@ -0,0 +1,173 @@ +import { useEffect, useId, useRef, useState, type Ref } from "react"; +import { createPortal } from "react-dom"; +import { + Check as CheckIcon, + Clipboard as ClipboardIcon, + X as XMarkIcon, +} from "lucide-react"; +import { + playbackErrorLines, + playbackErrorText, + type PlaybackError, + type TrackListItem, +} from "@music-library/core"; +import { usePlayer } from "../../context/Player"; +import { copyText } from "../../lib/clipboard"; +import { useCopiedFlag } from "../../lib/useCopiedFlag"; +import { useDismiss } from "../../lib/useDismiss"; +import { useTransitionMount } from "../../lib/useTransitionMount"; +import { Button } from "../Button"; + +/** The playback error line's popover: what happened, above the track. */ +export default function PlaybackErrorPopover({ + open, + anchor, + toggle, + onClose, +}: { + open: boolean; + /** The now-playing block; the popover opens above it. */ + anchor: HTMLElement | null; + /** The error line that opened it, which toggles it rather than dismissing. */ + toggle: HTMLElement | null; + onClose: () => void; +}) { + const ref = useRef(null); + const copyRef = useRef(null); + // Focus lands on Copy, past the lines: they describe the dialog, so a + // screen reader reads them as focus enters. + const linesId = useId(); + + // Closing while focus is inside sends it back to the line the popover came + // from, rather than dropping it with the popover. A click elsewhere closes + // it too, and that click's own focus then wins. + const close = () => { + if (ref.current?.contains(document.activeElement)) toggle?.focus(); + onClose(); + }; + + useDismiss(ref, { + onDismiss: close, + enabled: open, + capture: true, + ignore: (target) => !!toggle?.contains(target), + }); + + const { mounted, visible } = useTransitionMount(open, 180); + + // Portaled to the end of the page, it would be the last thing Tab reaches: + // focus moves in as it opens, to its action, so Space and Enter stay there + // rather than reaching the player's own keys. It goes back to the line + // when the popover closes. + useEffect(() => { + if (visible) copyRef.current?.focus(); + }, [visible]); + + // Placed from the line's position at render; a window resize places it + // again. + const [, setResized] = useState(0); + useEffect(() => { + if (!open) return; + const onResize = () => setResized((n) => n + 1); + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); + }, [open]); + + if (!mounted || !anchor) return null; + + const rect = anchor.getBoundingClientRect(); + const width = Math.min(440, window.innerWidth - 24); + const bottom = Math.max(12, window.innerHeight - rect.top + 8); + const left = Math.max(12, Math.min(rect.left, window.innerWidth - width - 12)); + // In a short window the details scroll under the heading, which stays put. + const maxHeight = Math.max(120, window.innerHeight - bottom - 12); + + return createPortal( +
event.stopPropagation()} + onMouseDown={(event) => event.stopPropagation()} + > +
+ Playback error + + +
+ +
, + document.body, + ); +} + +/** + * What happened to the current track, line by line, and a button that copies + * it for a bug report. Shared by the popover and the mini player's panel. + */ +export function PlaybackErrorDetails({ + frozen = false, + copyRef, + linesId, +}: { + /** Closing: keep what was shown, even if another failure comes in. */ + frozen?: boolean; + copyRef?: Ref; + /** An id for the lines, for whatever they describe. */ + linesId?: string; +}) { + const { playbackError, current } = usePlayer(); + // Keeps the last failure, and the track it was on, on screen while the + // popover or panel closes after it clears (another track may be loaded). + const [last, setLast] = useState<{ + error: PlaybackError; + track: TrackListItem | null; + } | null>(null); + // Frozen only once there's something to keep: a panel that opens already + // on its way out still shows the failure. + const live = frozen && last ? null : playbackError; + if (live && live !== last?.error) setLast({ error: live, track: current }); + const shown = live ? { error: live, track: current } : last; + const { copied, flash } = useCopiedFlag(2000); + if (!shown) return null; + + const lines = playbackErrorLines(shown.error, shown.track); + return ( +
+
+ {lines.map(({ label, value }) => ( +
+
{label}
+
{value}
+
+ ))} +
+
+ +
+
+ ); +} diff --git a/frontend/src/components/player/useMiniPlayerMode.ts b/frontend/src/components/player/useMiniPlayerMode.ts index d4e025bc..84f081b6 100644 --- a/frontend/src/components/player/useMiniPlayerMode.ts +++ b/frontend/src/components/player/useMiniPlayerMode.ts @@ -9,8 +9,11 @@ import { setMiniPlayerPanel, } from "../../lib/platform"; -/** What the mini player's side panel can show, in utility-row order. */ -export const MINI_PANELS = ["devices", "lyrics", "queue"] as const; +/** + * What the mini player's side panel can show, in the order their controls + * sit in the bar: the playback error line, then the utility row. + */ +export const MINI_PANELS = ["error", "devices", "lyrics", "queue"] as const; export type MiniPanel = (typeof MINI_PANELS)[number]; export const MINI_PANEL_ID = "mini-player-panel"; @@ -20,6 +23,8 @@ export interface MiniPanelToggle { open: boolean; controls: string; onToggle: () => void; + /** Shuts the panel if it's this one, as when what it shows goes. */ + close: () => void; } /** @@ -172,6 +177,9 @@ export function useMiniPlayerMode() { open: requested === which, controls: MINI_PANEL_ID, onToggle: () => togglePanel(which), + close: () => { + if (wanted.current === which) requestPanel(null); + }, }); // Escape closes the panel, without the morph: a keyboard dismissal should diff --git a/frontend/src/styles/player-popovers.css b/frontend/src/styles/player-popovers.css index d99dc7e0..e83b7aab 100644 --- a/frontend/src/styles/player-popovers.css +++ b/frontend/src/styles/player-popovers.css @@ -246,6 +246,59 @@ z-index: 1; } +/* The playback error's details (PlaybackErrorPopover.tsx): above the + now-playing block, so it grows from the left. The body is shared with the + mini player's error panel. */ +.error-pop { + display: flex; + flex-direction: column; + transform-origin: bottom left; +} +.error-pop > .device-pop-head { + flex: none; +} +.error-pop > .error-pop-body { + min-height: 0; + overflow-y: auto; +} +.error-pop-body { + padding: 12px 14px 14px; + display: flex; + flex-direction: column; + gap: 14px; +} +.error-pop-lines { + margin: 0; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 6px 14px; + font-size: 12px; + line-height: 1.45; +} +.error-pop-line { + display: contents; +} +.error-pop-line dt { + color: var(--muted-foreground); +} +/* Selectable, though the desktop app turns selection off: a part is worth + copying on its own. */ +.error-pop-line dd { + margin: 0; + min-width: 0; + color: var(--foreground); + overflow-wrap: anywhere; + -webkit-user-select: text; + user-select: text; +} +.error-pop-line:first-child dd { + color: var(--destructive); +} +.error-pop-actions { + display: flex; + justify-content: flex-end; +} + .queue-pop[data-closed], .device-pop[data-closed] { opacity: 0; diff --git a/frontend/src/styles/player.css b/frontend/src/styles/player.css index 7fa12015..f3d58cdf 100644 --- a/frontend/src/styles/player.css +++ b/frontend/src/styles/player.css @@ -121,10 +121,32 @@ text-overflow: ellipsis; } /* A playback failure in place of the artist. Specific enough to beat the - mini player's own artist color. */ + mini player's own artist color. The line is a button that shows what + happened; it truncates itself, so the line lets its focus ring show. */ .np-text .np-artist.np-error { + overflow: visible; color: var(--destructive); } +.np-error-btn { + display: inline-block; + max-width: 100%; + padding: 0; + border: 0; + border-radius: var(--radius-sm); + background: none; + color: inherit; + font: inherit; + vertical-align: top; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; +} +.np-error-btn:hover, +.np-error-btn[aria-expanded="true"] { + text-decoration: underline; + text-underline-offset: 2px; +} .transport { display: flex; diff --git a/frontend/tests/html-audio-adapter.test.tsx b/frontend/tests/html-audio-adapter.test.tsx index 15f9e0c8..aac2e9d2 100644 --- a/frontend/tests/html-audio-adapter.test.tsx +++ b/frontend/tests/html-audio-adapter.test.tsx @@ -121,6 +121,32 @@ it("gives up on a playlist or segment the server refused", async () => { expect(error).toHaveBeenCalledOnce(); }); +it("says what the element or hls.js reported about the failure", async () => { + const { adapter, active, error } = setup(); + act(() => adapter.load("/api/tracks/one/stream")); + Object.defineProperty(active, "error", { + configurable: true, + value: { code: 4, message: "DEMUXER_ERROR_COULD_NOT_OPEN: FFmpegDemuxer: open context failed" }, + }); + active.dispatchEvent(new Event("error")); + expect(error).toHaveBeenCalledOnce(); + expect(adapter.errorDetail?.()).toBe( + "MEDIA_ERR_SRC_NOT_SUPPORTED: DEMUXER_ERROR_COULD_NOT_OPEN: FFmpegDemuxer: open context failed", + ); + + act(() => adapter.load("/api/tracks/tidal%3Atwo/stream")); + await vi.waitFor(() => expect(mock.instances).toHaveLength(1)); + mock.instances[0].fail({ + ...manifestFailed, + url: "https://cdn.example/signed?token=secret", + error: new Error("A network error (status 502) occurred while loading manifest"), + }); + expect(error).toHaveBeenCalledTimes(2); + expect(adapter.errorDetail?.()).toBe( + "hls.js manifestLoadError: A network error (status 502) occurred while loading manifest", + ); +}); + // jsdom never plays an element; mark it playing for a crossfade to start. function playing(audio: HTMLAudioElement) { Object.defineProperty(audio, "paused", { configurable: true, get: () => false }); diff --git a/frontend/tests/mini-player-mode.test.tsx b/frontend/tests/mini-player-mode.test.tsx new file mode 100644 index 00000000..d9eb569f --- /dev/null +++ b/frontend/tests/mini-player-mode.test.tsx @@ -0,0 +1,39 @@ +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { useMiniPlayerMode } from "../src/components/player/useMiniPlayerMode"; + +const platform = vi.hoisted(() => ({ + canSetMiniPlayer: () => true, + getMiniPlayerPanelAnchor: vi.fn(async () => "top" as const), + setMiniPlayerMode: vi.fn(async (on: boolean) => ({ ok: true, miniPlayer: on })), + setMiniPlayerPanel: vi.fn(async (open: boolean) => ({ ok: true, open, anchor: "top" as const })), +})); +vi.mock("../src/lib/platform", () => platform); +vi.mock("../src/context/LyricsPanel", () => ({ + useLyricsPanel: () => ({ open: false, setOpen: () => {} }), +})); + +afterEach(cleanup); + +it("closes a panel through its own toggle only", async () => { + const { result } = renderHook(() => useMiniPlayerMode()); + await act(() => result.current.toggleMiniPlayerMode()); + expect(result.current.miniPlayerMode).toBe(true); + act(() => result.current.panelToggle("error").onToggle()); + await waitFor(() => expect(result.current.panel).toBe("error")); + + // Another panel's close leaves this one up. + act(() => result.current.panelToggle("queue").close()); + expect(result.current.panelToggle("error").open).toBe(true); + + act(() => result.current.panelToggle("error").close()); + expect(result.current.panelToggle("error").open).toBe(false); + await waitFor(() => expect(result.current.panel).toBeNull()); + expect(platform.setMiniPlayerPanel).toHaveBeenLastCalledWith(false); + + // Again once it's shut: nothing to do. + platform.setMiniPlayerPanel.mockClear(); + act(() => result.current.panelToggle("error").close()); + expect(result.current.panelToggle("error").open).toBe(false); + expect(platform.setMiniPlayerPanel).not.toHaveBeenCalled(); +}); diff --git a/frontend/tests/player-display.test.tsx b/frontend/tests/player-display.test.tsx index e9f1a081..0ae4a40d 100644 --- a/frontend/tests/player-display.test.tsx +++ b/frontend/tests/player-display.test.tsx @@ -1,7 +1,9 @@ -import { render, renderHook, screen } from "@testing-library/react"; -import { beforeEach, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, renderHook, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; import type { PlaybackDevice, PlayerState, TrackListItem } from "@music-library/core"; +import MiniPlayerPanel from "../src/components/player/MiniPlayerPanel"; import NowPlaying from "../src/components/player/NowPlaying"; +import { PlaybackErrorDetails } from "../src/components/player/PlaybackErrorPopover"; import { usePlayerDisplay } from "../src/components/player/usePlayerDisplay"; const mock = vi.hoisted(() => ({ @@ -44,6 +46,7 @@ beforeEach(() => { }; mock.remote = { targetDevice: desktop, commandPending: false }; }); +afterEach(cleanup); it("shows the provider's displayed state while controlling another device", () => { const { result } = renderHook(() => usePlayerDisplay()); @@ -75,15 +78,22 @@ it("keeps transport enabled while a remote command is pending", () => { expect(result.current.transportDisabled).toBe(false); }); +const details = { + at: Date.UTC(2026, 9, 29, 12, 0, 0), + source: "/api/tracks/t1/stream", + player: "MEDIA_ERR_SRC_NOT_SUPPORTED: DEMUXER_ERROR_COULD_NOT_OPEN", + server: "HTTP 200 · audio/flac", +}; + it("shows the current track's playback error in place of its artist", () => { const message = "TIDAL refused to stream this track: Not available in your region"; mock.remote = { targetDevice: null, commandPending: false }; - mock.player = { ...mock.player, isPlaying: false, playbackError: { trackId: "t1", message } }; + mock.player = { ...mock.player, isPlaying: false, playbackError: { trackId: "t1", message, details } }; const { result, rerender } = renderHook(() => usePlayerDisplay()); expect(result.current.displayError).toBe(message); expect(result.current.displayArtist).toBe("Artist · Album"); - mock.player = { ...mock.player, playbackError: { trackId: "t0", message } }; + mock.player = { ...mock.player, playbackError: { trackId: "t0", message, details } }; rerender(); expect(result.current.displayError).toBeNull(); }); @@ -101,7 +111,261 @@ it("renders the error as the artist line and announces it", () => { , ); expect(line.textContent).toBe(message); - expect(line.getAttribute("title")).toBe(message); + expect(screen.getByRole("button", { name: message }).getAttribute("title")).toBe(message); expect(line.classList.contains("np-error")).toBe(true); expect(screen.getByRole("status").textContent).toBe(message); }); + +it("shows what happened when the error is clicked, and copies it", async () => { + const message = "Couldn't play this track."; + mock.player = { ...mock.player, current: track, playbackError: { trackId: "t1", message, details } }; + const writeText = vi.fn(async () => {}); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } }); + render(); + + const line = screen.getByRole("button", { name: message }); + expect(line.getAttribute("aria-expanded")).toBe("false"); + fireEvent.click(line); + expect(line.getAttribute("aria-expanded")).toBe("true"); + const dialog = await screen.findByRole("dialog", { name: "Playback error" }); + expect(dialog.textContent).toContain("DEMUXER_ERROR_COULD_NOT_OPEN"); + expect(dialog.textContent).toContain("HTTP 200 · audio/flac"); + + fireEvent.click(within(dialog).getByRole("button", { name: "Copy details" })); + await waitFor(() => expect(writeText).toHaveBeenCalledOnce()); + expect(writeText.mock.calls[0]).toEqual([ + [ + "Error: Couldn't play this track.", + "Track: Song — Artist", + "Track ID: t1", + "Source: /api/tracks/t1/stream", + "Player: MEDIA_ERR_SRC_NOT_SUPPORTED: DEMUXER_ERROR_COULD_NOT_OPEN", + "Server: HTTP 200 · audio/flac", + "Time: 2026-10-29T12:00:00.000Z", + ].join("\n"), + ]); + expect(await within(dialog).findByRole("button", { name: "Copied" })).toBeTruthy(); +}); + +it("closes the details on Escape or a click outside", async () => { + const message = "Couldn't play this track."; + mock.player = { ...mock.player, current: track, playbackError: { trackId: "t1", message, details } }; + render(); + const line = screen.getByRole("button", { name: message }); + + fireEvent.click(line); + await screen.findByRole("dialog", { name: "Playback error" }); + fireEvent.keyDown(window, { key: "Escape" }); + expect(line.getAttribute("aria-expanded")).toBe("false"); + + fireEvent.click(line); + expect(line.getAttribute("aria-expanded")).toBe("true"); + fireEvent.mouseDown(document.body); + expect(line.getAttribute("aria-expanded")).toBe("false"); +}); + +it("shuts the details when the failure clears, and doesn't reopen them for the next", async () => { + const message = "Couldn't play this track."; + mock.player = { ...mock.player, current: track, playbackError: { trackId: "t1", message, details } }; + const { rerender } = render( + , + ); + fireEvent.click(screen.getByRole("button", { name: message })); + await screen.findByRole("dialog", { name: "Playback error" }); + + // Played again: the failure is gone, and so are its details. + mock.player = { ...mock.player, playbackError: null }; + rerender(); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + + mock.player = { ...mock.player, playbackError: { trackId: "t1", message, details: { ...details } } }; + rerender(); + expect(screen.getByRole("button", { name: message }).getAttribute("aria-expanded")).toBe("false"); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(screen.queryByRole("dialog")).toBeNull(); +}); + +it("keeps the failed track in the details while they close over the next one", () => { + const message = "Couldn't play this track."; + const next: TrackListItem = { ...track, id: "t2", title: "Next song" }; + mock.player = { ...mock.player, current: track, playbackError: { trackId: "t1", message, details } }; + const { container, rerender } = render(); + expect(container.textContent).toContain("Song — Artist"); + + mock.player = { ...mock.player, current: next, playbackError: null }; + rerender(); + expect(container.textContent).toContain("Song — Artist"); + expect(container.textContent).toContain(message); +}); + +it("keeps the closing details on the failure they opened for", () => { + const first = { trackId: "t1", message: "Couldn't play this track.", details }; + const second = { trackId: "t2", message: "Couldn't play this track.", details: { ...details, server: "HTTP 404 · not found" } }; + mock.player = { ...mock.player, current: track, playbackError: first }; + const { container, rerender } = render(); + + // The next track failed while the popover fades out. + mock.player = { ...mock.player, current: { ...track, id: "t2" }, playbackError: second }; + rerender(); + expect(container.textContent).toContain("HTTP 200 · audio/flac"); + expect(container.textContent).not.toContain("HTTP 404"); +}); + +it("moves focus to the details' action as they open", async () => { + const message = "Couldn't play this track."; + mock.player = { ...mock.player, current: track, playbackError: { trackId: "t1", message, details } }; + render(); + fireEvent.click(screen.getByRole("button", { name: message })); + const dialog = await screen.findByRole("dialog", { name: "Playback error" }); + const copy = within(dialog).getByRole("button", { name: "Copy details" }); + await waitFor(() => expect(document.activeElement).toBe(copy)); + // Focus skips the lines, so they describe the dialog for screen readers. + const lines = document.getElementById(dialog.getAttribute("aria-describedby") ?? ""); + expect(lines?.tagName).toBe("DL"); + expect(lines?.textContent).toContain("HTTP 200 · audio/flac"); +}); + +it("sends focus back to the error line when the line closes the details", async () => { + const message = "Couldn't play this track."; + mock.player = { ...mock.player, current: track, playbackError: { trackId: "t1", message, details } }; + render(); + const line = screen.getByRole("button", { name: message }); + fireEvent.click(line); + const dialog = await screen.findByRole("dialog", { name: "Playback error" }); + const copy = within(dialog).getByRole("button", { name: "Copy details" }); + await waitFor(() => expect(document.activeElement).toBe(copy)); + + // As in Safari, the click itself doesn't move focus to the line. + fireEvent.click(line); + expect(line.getAttribute("aria-expanded")).toBe("false"); + expect(document.activeElement).toBe(line); +}); + +it.each(["its button", "Escape"])("sends focus back to the error line when closed with %s", async (how) => { + const message = "Couldn't play this track."; + mock.player = { ...mock.player, current: track, playbackError: { trackId: "t1", message, details } }; + render(); + const line = screen.getByRole("button", { name: message }); + fireEvent.click(line); + const dialog = await screen.findByRole("dialog", { name: "Playback error" }); + const copy = within(dialog).getByRole("button", { name: "Copy details" }); + copy.focus(); + + if (how === "Escape") fireEvent.keyDown(copy, { key: "Escape" }); + else fireEvent.click(within(dialog).getByRole("button", { name: "Close playback error" })); + expect(line.getAttribute("aria-expanded")).toBe("false"); + expect(document.activeElement).toBe(line); +}); + +it("leaves focus alone when the details close from outside", async () => { + const message = "Couldn't play this track."; + mock.player = { ...mock.player, current: track, playbackError: { trackId: "t1", message, details } }; + render( + <> + + + , + ); + fireEvent.click(screen.getByRole("button", { name: message })); + const dialog = await screen.findByRole("dialog", { name: "Playback error" }); + const copy = within(dialog).getByRole("button", { name: "Copy details" }); + await waitFor(() => expect(document.activeElement).toBe(copy)); + const search = screen.getByRole("textbox", { name: "Search" }); + search.focus(); + + fireEvent.keyDown(search, { key: "Escape" }); + expect(document.activeElement).toBe(search); +}); + +it("shows the details in the mini player's error panel", () => { + const message = "Couldn't play this track."; + mock.remote = { targetDevice: null, commandPending: false }; + mock.player = { ...mock.player, current: track, playbackError: { trackId: "t1", message, details } }; + render(); + expect(screen.getByText("Playback error", { selector: ".mini-panel-title" })).toBeTruthy(); + const region = screen.getByRole("region", { name: "Playback error" }); + expect(region.textContent).toContain("HTTP 200 · audio/flac"); + expect(within(region).getByRole("button", { name: "Copy details" })).toBeTruthy(); +}); + +it("shows the failure in a mini panel that opens already on its way out", () => { + const message = "Couldn't play this track."; + mock.remote = { targetDevice: null, commandPending: false }; + mock.player = { ...mock.player, current: track, playbackError: { trackId: "t1", message, details } }; + render(); + const region = screen.getByRole("region", { name: "Playback error" }); + expect(region.textContent).toContain("HTTP 200 · audio/flac"); +}); + +it("keeps the mini player's error panel on its failure while it closes", () => { + const message = "Couldn't play this track."; + mock.remote = { targetDevice: null, commandPending: false }; + mock.player = { ...mock.player, current: track, playbackError: { trackId: "t1", message, details } }; + const { rerender } = render(); + + mock.player = { + ...mock.player, + current: { ...track, id: "t2" }, + playbackError: { trackId: "t2", message, details: { ...details, server: "HTTP 404 · not found" } }, + }; + rerender(); + const region = screen.getByRole("region", { name: "Playback error" }); + expect(region.textContent).toContain("HTTP 200 · audio/flac"); + expect(region.textContent).not.toContain("HTTP 404"); +}); + +it("asks the mini player for its error panel instead of opening a popover", () => { + const message = "Couldn't play this track."; + const onToggle = vi.fn(); + render( + , + ); + const line = screen.getByRole("button", { name: message }); + expect(line.hasAttribute("aria-haspopup")).toBe(false); + fireEvent.click(line); + expect(onToggle).toHaveBeenCalledOnce(); + expect(screen.queryByRole("dialog")).toBeNull(); +}); + +it("places the details again when the window is resized", async () => { + const message = "Couldn't play this track."; + mock.player = { ...mock.player, current: track, playbackError: { trackId: "t1", message, details } }; + const { container } = render(); + const block = container.querySelector(".np")!; + let top = 700; + vi.spyOn(block, "getBoundingClientRect").mockImplementation(() => ({ top, left: 20 }) as DOMRect); + fireEvent.click(screen.getByRole("button", { name: message })); + const dialog = await screen.findByRole("dialog", { name: "Playback error" }); + const bottom = () => dialog.style.bottom; + const before = bottom(); + + top = 500; + act(() => { + window.dispatchEvent(new Event("resize")); + }); + expect(bottom()).not.toBe(before); +}); + +it("shuts the mini player's error panel when the failure clears", () => { + const message = "Couldn't play this track."; + const close = vi.fn(); + const panel = (open: boolean) => ({ open, controls: "mini-player-panel", onToggle: vi.fn(), close }); + const { rerender } = render( + , + ); + expect(close).not.toHaveBeenCalled(); + + // Another panel is up: nothing to shut. + rerender(); + expect(close).not.toHaveBeenCalled(); + + rerender(); + rerender(); + expect(close).toHaveBeenCalled(); +}); diff --git a/frontend/tests/ui/overlays.ui.ts b/frontend/tests/ui/overlays.ui.ts index 0527fcf1..257bfd84 100644 --- a/frontend/tests/ui/overlays.ui.ts +++ b/frontend/tests/ui/overlays.ui.ts @@ -137,6 +137,30 @@ test("settings playback section", async ({ page }, testInfo) => { await checkUI(page, testInfo); }); +test("playback error details", async ({ page }, testInfo) => { + // A library file the server no longer has. + await page.route(`**/api/tracks/${menuTrack.id}/stream`, (route) => + route.fulfill({ status: 410, contentType: "text/plain", body: "file missing on disk" }), + ); + await page.locator(".track-title").and(page.getByTitle(menuTrack.title, { exact: true })).dispatchEvent("click"); + // A real click: the line must take the pointer, not just the event. + await page.locator(".player-bar").getByRole("button", { name: "Couldn't play this track." }).click(); + const dialog = page.getByRole("dialog", { name: "Playback error" }); + await ready(page, dialog.getByText("HTTP 410 · file missing on disk")); + await checkUI(page, testInfo, { + exceptions: [ + { + // axe compares the stack under each line of text, the page beneath + // the popover included. The track's two lines sit over the sidebar + // and the list's rows, so it gives up. + selector: '.error-pop-line[data-label="Track"] dd', + rules: ["text-contrast"], + reason: "A wrapped line of popover text over the sidebar and the track list, which axe can't resolve. The popover's other lines, in the same style, are measured.", + }, + ], + }); +}); + test("queue popover", async ({ page }, testInfo) => { await page.locator(".player-bar").getByRole("button", { name: "Queue", exact: true }).dispatchEvent("click"); await ready(page, page.locator(".queue-pop").getByText(loadedTrack.title).first());