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({ 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: () => {}, });