All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 43s
The player now holds a queue instead of a single item, so albums play through and single tracks share the same code path. Adds a "stream" item kind for expiring, remotely-hosted sources: Bandcamp signs its mp3 URLs for ~24h, so an item carries the page it came from and re-resolves itself on error or on a stale restored session, falling back to the iframe embed if that fails too. Gated behind GERBEUR_BANDCAMP_PLAYER (default "embed"), injected into a meta tag the same way as site-name/site-emoji. Also: player header gains artwork and a subtitle, volume persists across queue advances and reloads, and cross-origin streams get a plain seek bar rather than a waveform that can never decode. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SbtjNsT5wvuABnfqhegZEJ
141 lines
4.4 KiB
TypeScript
141 lines
4.4 KiB
TypeScript
import { createContext } from "react";
|
|
|
|
/** Fields every playable item shares, whatever its source. */
|
|
interface PlayerItemBase {
|
|
title?: string;
|
|
dumpHref?: string;
|
|
/** Square image for the player header — provider thumbnail, album art, or a
|
|
* video still. Raw URL: Thumbnail decides whether to proxy it. */
|
|
artworkUrl?: string;
|
|
/** Secondary line under the title: the artist, the site name, the filename —
|
|
* whatever identifies the source at a glance. */
|
|
subtitle?: string;
|
|
}
|
|
|
|
export type PlayerItem =
|
|
| (PlayerItemBase & {
|
|
kind: "embed";
|
|
embedUrl: string;
|
|
type: string;
|
|
})
|
|
| (PlayerItemBase & {
|
|
kind: "file";
|
|
fileUrl: string;
|
|
mimeType: string;
|
|
})
|
|
/**
|
|
* An expiring, remotely-hosted stream (today: a Bandcamp mp3).
|
|
*
|
|
* Unlike a "file" item the URL is signed and dies after ~24h, so the item
|
|
* carries everything needed to fetch a fresh one: `resolveUrl` is the page it
|
|
* came from and `resolveIndex` its position in that page's tracklist. That
|
|
* pair is also the item's stable identity, since `streamUrl` changes on every
|
|
* re-resolve.
|
|
*/
|
|
| (PlayerItemBase & {
|
|
kind: "stream";
|
|
streamUrl: string;
|
|
/** Brand key driving `global-player--${type}` styling, e.g. "bandcamp". */
|
|
type: string;
|
|
/** Known before playback from the tracklist, so rows can show a length. */
|
|
duration?: number;
|
|
trackNum?: number;
|
|
resolveUrl: string;
|
|
resolveIndex: number;
|
|
/** Epoch ms of the resolution that produced `streamUrl`. */
|
|
resolvedAt: number;
|
|
/** Iframe embed to fall back to if native playback stops working. */
|
|
embedUrl?: string;
|
|
});
|
|
|
|
/**
|
|
* Stable identity for an item, used for active-state comparisons and to decide
|
|
* when the player is showing something new. Deliberately independent of
|
|
* `streamUrl` so a re-resolved track is still recognised as the same track.
|
|
*/
|
|
export function playerItemKey(item: PlayerItem): string {
|
|
switch (item.kind) {
|
|
case "embed":
|
|
return `embed:${item.embedUrl}`;
|
|
case "file":
|
|
return `file:${item.fileUrl}`;
|
|
case "stream":
|
|
return `stream:${item.resolveUrl}#${item.resolveIndex}`;
|
|
}
|
|
}
|
|
|
|
export interface PlayerContextValue {
|
|
// Playback state — readable by any consumer
|
|
current: PlayerItem | null;
|
|
playing: boolean;
|
|
currentTime: number;
|
|
duration: number;
|
|
|
|
// Queue. Every playback is a queue of at least one item, so single tracks and
|
|
// albums share one code path. `current` is always `queue[queueIndex]`.
|
|
queue: PlayerItem[];
|
|
queueIndex: number;
|
|
hasNext: boolean;
|
|
hasPrevious: boolean;
|
|
/** True while a stream is being (re-)resolved, for spinner affordances. */
|
|
resolving: boolean;
|
|
|
|
// Initial seek offset for the active item (non-zero only for an item restored
|
|
// from a previous session, or resumed after a re-resolve) and whether the
|
|
// active MediaPlayer should autoplay. A restored item appears paused; a fresh
|
|
// play() autoplays on the user gesture.
|
|
startTime: number;
|
|
autoplay: boolean;
|
|
|
|
// Control — callable by any consumer
|
|
play(item: PlayerItem): void;
|
|
playQueue(items: PlayerItem[], startIndex?: number): void;
|
|
playAt(index: number): void;
|
|
next(): void;
|
|
previous(): void;
|
|
stop(): void;
|
|
seekTo(time: number): void;
|
|
togglePlay(): void;
|
|
|
|
// Internal: GlobalPlayer registers MediaPlayer's imperative handles here
|
|
// so seekTo / togglePlay can reach into the actual media element.
|
|
seekRef: { current: ((t: number) => void) | null };
|
|
toggleRef: { current: (() => void) | null };
|
|
|
|
// Internal: GlobalPlayer calls these to push state back into the provider
|
|
onPlayStateChange(playing: boolean): void;
|
|
onTimeUpdate(time: number, duration: number): void;
|
|
/** Current item finished — advances the queue, or stops at the end. */
|
|
onEnded(): void;
|
|
/** The media element failed. For streams, triggers a re-resolve. */
|
|
onError(): void;
|
|
}
|
|
|
|
export const PlayerContext = createContext<PlayerContextValue>({
|
|
current: null,
|
|
playing: false,
|
|
currentTime: 0,
|
|
duration: 0,
|
|
queue: [],
|
|
queueIndex: 0,
|
|
hasNext: false,
|
|
hasPrevious: false,
|
|
resolving: false,
|
|
startTime: 0,
|
|
autoplay: false,
|
|
play: () => {},
|
|
playQueue: () => {},
|
|
playAt: () => {},
|
|
next: () => {},
|
|
previous: () => {},
|
|
stop: () => {},
|
|
seekTo: () => {},
|
|
togglePlay: () => {},
|
|
seekRef: { current: null },
|
|
toggleRef: { current: null },
|
|
onPlayStateChange: () => {},
|
|
onTimeUpdate: () => {},
|
|
onEnded: () => {},
|
|
onError: () => {},
|
|
});
|