Skip to content
This repository was archived by the owner on Oct 4, 2026. It is now read-only.
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
9 changes: 8 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ jobs:

- run: pnpm typecheck

- run: pnpm exec tsc --noEmit -p examples/playground/tsconfig.json

- name: Install media verification tools
run: sudo apt-get update && sudo apt-get install -y ffmpeg

- run: pnpm test:run

# Rebuilds dist/ and fails if it drifts from what's committed,
Expand All @@ -30,6 +35,8 @@ jobs:

- run: pnpm check:package

- run: pnpm exec playwright install --with-deps chromium
- run: pnpm check:seo

- run: pnpm exec playwright install --with-deps chromium webkit

- run: pnpm test:e2e
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,18 @@ classic windows, and a live `.wsz` skin picker:
pnpm --filter playground dev # http://localhost:5173
```

### Transmission 001

The playground also includes `/transmission-001`, a finite listening edition
with authored visual chapters, an artist-note detour, and shareable moments.
Its release media are not yet supplied; the route shows a preparation state
until the approved manifest is populated. See [the transmission contract](docs/TRANSMISSION-001.md).

Custom hosts can use `<PlayerProvider transportMode="single" onTrackEnded={...}>`
to stop at each track’s end and disable playlist/OS track navigation. The
default remains `playlist`. `seek()` queues positions until metadata is ready;
`retry()` preserves position and play/pause intent.

## License

MIT © Kris Krüg · classic sprite data ported from Webamp (MIT, see `NOTICE`).
3 changes: 3 additions & 0 deletions agentic/contract.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,18 @@
"verification": {
"commands": [
"pnpm typecheck",
"pnpm exec tsc --noEmit -p examples/playground/tsconfig.json",
"pnpm test:run",
"pnpm build",
"pnpm check:dist",
"pnpm check:package",
"pnpm check:seo",
"pnpm test:e2e",
"pnpm --filter playground build"
],
"playground_applicability": "Run pnpm --filter playground build when the playground is affected; pnpm check:seo also builds the playground."
},
"boundary_scope": "Defaults unless the user explicitly authorizes a bounded action; record that scope in the issue. Never infer merge or release authorization.",
"boundaries": {
"github_mutations": false,
"linear_writes": false,
Expand Down
138 changes: 94 additions & 44 deletions dist/index.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ function usePlayer() {
function PlayerProvider({
tracks,
onNowPlaying,
transportMode = "playlist",
onTrackEnded,
children
}) {
const audioRef = react.useRef(null);
Expand Down Expand Up @@ -52,6 +54,9 @@ function PlayerProvider({
const playbackStatusRef = react.useRef("idle");
const intentRef = react.useRef("cue");
const suppressPauseRef = react.useRef(false);
const metadataReadyRef = react.useRef(false);
const pendingSeekRef = react.useRef(null);
const positionRef = react.useRef(0);
const setPlaybackStatus = react.useCallback((status) => {
playbackStatusRef.current = status;
setPlaybackStatusState(status);
Expand Down Expand Up @@ -168,39 +173,51 @@ function PlayerProvider({
const trackId = currentIdRef.current;
const token = sourceTokenRef.current;
if (!el || !trackId) return;
void ctxRef.current?.resume();
try {
const result = el.play();
void result?.catch(() => {
if (token !== sourceTokenRef.current || trackId !== currentIdRef.current) return;
setPlaybackStatus("error");
setPlaybackError({ trackId, code: "play" });
setPlaying(false);
});
} catch {
if (token !== sourceTokenRef.current || trackId !== currentIdRef.current) return;
const fail = () => {
if (token !== sourceTokenRef.current || trackId !== currentIdRef.current || intentRef.current !== "play") return;
setPlaybackStatus("error");
setPlaybackError({ trackId, code: "play" });
setPlaying(false);
};
try {
ensureGraph();
void ctxRef.current?.resume().catch(fail);
const result = el.play();
void result?.catch(fail);
} catch {
fail();
}
}, [setPlaybackStatus]);
}, [ensureGraph, setPlaybackStatus]);
const applyPendingSeek = react.useCallback(() => {
const el = audioRef.current;
const requested = pendingSeekRef.current;
if (!el || !metadataReadyRef.current || requested === null) return;
const target = Number.isFinite(el.duration) ? Math.min(requested, el.duration) : requested;
el.currentTime = target;
pendingSeekRef.current = null;
positionRef.current = target;
setTime(target);
}, []);
const selectTrack = react.useCallback(
(t, intent, reload) => {
(t, intent, reload, position = 0) => {
const el = audioRef.current;
if (!el || !t.audioUrl) return;
sourceTokenRef.current += 1;
currentIdRef.current = t.id;
intentRef.current = intent;
sourceRef.current = t.audioUrl;
setCurrentId(t.id);
setTime(0);
metadataReadyRef.current = false;
pendingSeekRef.current = position;
positionRef.current = position;
setTime(position);
setDuration(0);
setPlaying(false);
setPlaybackError(null);
setPlaybackStatus("loading");
suppressPauseRef.current = true;
if (reload || el.src !== t.audioUrl) el.src = t.audioUrl;
sourceRef.current = el.currentSrc || el.src || t.audioUrl;
sourceRef.current = el.src;
try {
el.load();
} catch {
Expand All @@ -222,7 +239,6 @@ function PlayerProvider({
(id) => {
const t = tracks.find((track) => track.id === id);
if (!t?.audioUrl) return;
ensureGraph();
if (currentIdRef.current !== id) {
selectTrack(t, "play", false);
return;
Expand All @@ -233,12 +249,12 @@ function PlayerProvider({
driveScene(t);
attemptPlay();
},
[attemptPlay, driveScene, ensureGraph, selectTrack, setPlaybackStatus, tracks]
[attemptPlay, driveScene, selectTrack, setPlaybackStatus, tracks]
);
const toggle = react.useCallback(() => {
const play = react.useCallback(() => {
const el = audioRef.current;
if (!el) return;
if (!currentId) {
if (!currentIdRef.current) {
if (playable[0]) playTrack(playable[0].id);
return;
}
Expand All @@ -247,19 +263,29 @@ function PlayerProvider({
setPlaybackError(null);
setPlaybackStatus("loading");
attemptPlay();
} else {
el.pause();
}
}, [attemptPlay, currentId, playable, playTrack, setPlaybackStatus]);
}, [attemptPlay, playable, playTrack, setPlaybackStatus]);
const pause = react.useCallback(() => {
intentRef.current = "cue";
const el = audioRef.current;
if (!el || el.paused) return;
const position = el.currentTime;
el.pause();
if (ctxRef.current && metadataReadyRef.current) el.currentTime = position;
}, []);
const toggle = react.useCallback(() => {
if (audioRef.current?.paused) play();
else pause();
}, [play, pause]);
const retry = react.useCallback(() => {
const id = currentIdRef.current;
const t = id ? tracks.find((track) => track.id === id) : null;
if (!t?.audioUrl) return;
selectTrack(t, intentRef.current, true);
selectTrack(t, intentRef.current, true, positionRef.current);
}, [selectTrack, tracks]);
const step = react.useCallback(
(dir) => {
if (playable.length === 0) return;
if (transportMode === "single" || playable.length === 0) return;
const i = playable.findIndex((t) => t.id === currentId);
let nextIndex;
if (shuffleRef.current && playable.length > 1) {
Expand All @@ -271,14 +297,17 @@ function PlayerProvider({
}
playTrack(playable[nextIndex].id);
},
[playable, currentId, playTrack]
[playable, currentId, playTrack, transportMode]
);
const next = react.useCallback(() => step(1), [step]);
const prev = react.useCallback(() => step(-1), [step]);
const seek = react.useCallback((t) => {
const el = audioRef.current;
if (el && Number.isFinite(t)) el.currentTime = t;
}, []);
if (!currentIdRef.current || !Number.isFinite(t)) return;
pendingSeekRef.current = Math.max(0, t);
positionRef.current = pendingSeekRef.current;
setTime(positionRef.current);
applyPendingSeek();
}, [applyPendingSeek]);
const setVolume = react.useCallback((v) => {
const el = audioRef.current;
const clamped = Math.min(1, Math.max(0, v));
Expand All @@ -294,18 +323,18 @@ function PlayerProvider({
} catch {
}
};
set("play", () => toggle());
set("pause", () => toggle());
set("previoustrack", () => prev());
set("nexttrack", () => next());
set("play", play);
set("pause", pause);
set("previoustrack", transportMode === "single" ? null : prev);
set("nexttrack", transportMode === "single" ? null : next);
set("seekto", (d) => {
if (typeof d.seekTime === "number") seek(d.seekTime);
});
return () => {
for (const a of ["play", "pause", "previoustrack", "nexttrack", "seekto"])
set(a, null);
};
}, [toggle, prev, next, seek]);
}, [play, pause, prev, next, seek, transportMode]);
react.useEffect(() => {
if (typeof navigator !== "undefined" && "mediaSession" in navigator) {
navigator.mediaSession.playbackState = playing ? "playing" : "paused";
Expand All @@ -323,11 +352,19 @@ function PlayerProvider({
if (detail?.src && detail.src !== currentSource && !currentSource.endsWith(detail.src)) return false;
return Boolean(activeSource) && (activeSource === currentSource || currentSource.endsWith(activeSource));
};
const onTime = () => {
if (currentIdRef.current) setTime(el.currentTime);
const onTime = (event) => {
if (!isCurrentMediaEvent(event) || pendingSeekRef.current !== null) return;
positionRef.current = el.currentTime;
setTime(el.currentTime);
};
const onDur = () => {
if (currentIdRef.current) setDuration(Number.isFinite(el.duration) ? el.duration : 0);
const onDur = (event) => {
if (isCurrentMediaEvent(event)) setDuration(Number.isFinite(el.duration) ? el.duration : 0);
};
const onMetadata = (event) => {
if (!isCurrentMediaEvent(event)) return;
metadataReadyRef.current = true;
onDur(event);
applyPendingSeek();
};
const onLoadStart = (event) => {
if (!isCurrentMediaEvent(event)) return;
Expand All @@ -338,10 +375,11 @@ function PlayerProvider({
if (!isCurrentMediaEvent(event)) return;
suppressPauseRef.current = false;
if (playbackStatusRef.current === "error") return;
if (playbackStatusRef.current !== "playing") setPlaybackStatus("ready");
if (!el.paused) setPlaybackStatus("playing");
else if (playbackStatusRef.current !== "paused") setPlaybackStatus("ready");
};
const onPlay = (event) => {
if (!isCurrentMediaEvent(event)) return;
if (!isCurrentMediaEvent(event) || el.paused) return;
suppressPauseRef.current = false;
setPlaying(true);
setPlaybackError(null);
Expand All @@ -350,10 +388,9 @@ function PlayerProvider({
const onPause = (event) => {
if (!isCurrentMediaEvent(event)) return;
setPlaying(false);
if (suppressPauseRef.current) return;
if (playbackStatusRef.current !== "error") {
setPlaybackStatus(currentIdRef.current ? "paused" : "idle");
}
if (suppressPauseRef.current || el.error || playbackStatusRef.current === "error") return;
intentRef.current = "cue";
setPlaybackStatus(currentIdRef.current ? "paused" : "idle");
};
const onBuffering = (event) => {
if (!isCurrentMediaEvent(event)) return;
Expand All @@ -369,6 +406,15 @@ function PlayerProvider({
};
const onEnded = (event) => {
if (!isCurrentMediaEvent(event)) return;
onTrackEnded?.(currentIdRef.current);
if (transportMode === "single") {
intentRef.current = "cue";
setPlaying(false);
setPlaybackStatus("paused");
positionRef.current = el.currentTime;
setTime(el.currentTime);
return;
}
if (repeatRef.current) {
el.currentTime = 0;
intentRef.current = "play";
Expand All @@ -379,9 +425,11 @@ function PlayerProvider({
};
el.addEventListener("timeupdate", onTime);
el.addEventListener("durationchange", onDur);
el.addEventListener("loadedmetadata", onMetadata);
el.addEventListener("loadstart", onLoadStart);
el.addEventListener("canplay", onCanPlay);
el.addEventListener("play", onPlay);
el.addEventListener("playing", onPlay);
el.addEventListener("pause", onPause);
el.addEventListener("waiting", onBuffering);
el.addEventListener("stalled", onBuffering);
Expand All @@ -390,16 +438,18 @@ function PlayerProvider({
return () => {
el.removeEventListener("timeupdate", onTime);
el.removeEventListener("durationchange", onDur);
el.removeEventListener("loadedmetadata", onMetadata);
el.removeEventListener("loadstart", onLoadStart);
el.removeEventListener("canplay", onCanPlay);
el.removeEventListener("play", onPlay);
el.removeEventListener("playing", onPlay);
el.removeEventListener("pause", onPause);
el.removeEventListener("waiting", onBuffering);
el.removeEventListener("stalled", onBuffering);
el.removeEventListener("error", onError);
el.removeEventListener("ended", onEnded);
};
}, [attemptPlay, setPlaybackStatus, step, volume]);
}, [attemptPlay, applyPendingSeek, onTrackEnded, setPlaybackStatus, step, transportMode, volume]);
const value = react.useMemo(
() => ({
allTracks: tracks,
Expand Down
2 changes: 1 addition & 1 deletion dist/index.cjs.map

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion dist/index.d.cts
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,13 @@ type PlayerValue = {
setVolume: (v: number) => void;
};
declare function usePlayer(): PlayerValue;
declare function PlayerProvider({ tracks, onNowPlaying, children, }: {
declare function PlayerProvider({ tracks, onNowPlaying, transportMode, onTrackEnded, children, }: {
tracks: PlayerTrack[];
/** Optional hook so a host app can react to the playing track (e.g. drive an
* ambient scene) without the player depending on anything app-specific. */
onNowPlaying?: (info: NowPlaying) => void;
transportMode?: "playlist" | "single";
onTrackEnded?: (trackId: string) => void;
children: ReactNode;
}): react.JSX.Element;

Expand Down
4 changes: 3 additions & 1 deletion dist/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,13 @@ type PlayerValue = {
setVolume: (v: number) => void;
};
declare function usePlayer(): PlayerValue;
declare function PlayerProvider({ tracks, onNowPlaying, children, }: {
declare function PlayerProvider({ tracks, onNowPlaying, transportMode, onTrackEnded, children, }: {
tracks: PlayerTrack[];
/** Optional hook so a host app can react to the playing track (e.g. drive an
* ambient scene) without the player depending on anything app-specific. */
onNowPlaying?: (info: NowPlaying) => void;
transportMode?: "playlist" | "single";
onTrackEnded?: (trackId: string) => void;
children: ReactNode;
}): react.JSX.Element;

Expand Down
Loading
Loading