Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions core/src/player/audio-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
7 changes: 7 additions & 0 deletions core/src/player/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export {
fisherYatesWithAnchor,
repeatModeLabel,
SMOOTH_MIX_SECONDS,
type PlaybackError,
type PlaybackErrorDetails,
type PlayerControls,
type PlayerState,
type PlayOptions,
Expand All @@ -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,
Expand Down
42 changes: 42 additions & 0 deletions core/src/player/playback-error.ts
Original file line number Diff line number Diff line change
@@ -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");
}
17 changes: 17 additions & 0 deletions core/src/player/player-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
135 changes: 113 additions & 22 deletions core/src/player/use-player-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
shouldReportPlay,
withPlayNext,
type PlaybackError,
type PlaybackErrorDetails,
type PlayerControls,
type PlayerState,
type RepeatMode,
Expand Down Expand Up @@ -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<string> {
async function probeStream(
url: string,
{ firstByte, signal }: { firstByte: boolean; signal: AbortSignal },
): Promise<StreamProbe> {
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, "<address>")
.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;
}

/**
Expand Down Expand Up @@ -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<AbortController | null>(null);
const playbackReportedRef = useRef<string | null>(null);
const lastFMScrobbledRef = useRef<string | null>(null);
// Lazily seeded (see the track-change effect below) rather than
Expand All @@ -165,6 +221,10 @@ export function usePlayerCore({
const listenedSecondsRef = useRef(0);
const listeningTickRef = useRef<number | null>(null);
const loadedTrackIdRef = useRef<string | null>(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<string | null>(null);
const preparedNextRef = useRef<{ trackId: string; uri: string } | null>(null);
const playbackAttemptRef = useRef(0);
const handoffRef = useRef<{ track: TrackListItem; attempt: number } | null>(null);
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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?.();
}
Expand All @@ -532,6 +597,7 @@ export function usePlayerCore({
adapter.seek(0);
} else {
loadedTrackIdRef.current = nextTrack.id;
loadedUriRef.current = nextUri;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
adapter.load(nextUri);
}
startPlayback();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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.
Expand Down
55 changes: 55 additions & 0 deletions core/tests/playback-error.test.ts
Original file line number Diff line number Diff line change
@@ -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["details"]> = {}): 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");
});
});
Loading
Loading