From 0a94481b2f530669ae437dbcf529a0bfecb83eed Mon Sep 17 00:00:00 2001 From: karitham Date: Mon, 1 Jun 2026 16:03:53 +0200 Subject: [PATCH] frontend: fix song-end flash, clamp elapsed, stable onEnded --- frontend/src/App.tsx | 13 +- frontend/src/Player.tsx | 63 ++++++++-- frontend/src/style.css | 11 ++ src/transport.rs | 10 +- src/web.rs | 113 ++++++++++-------- static/dist/assets/index-BtSfMBqf.js | 1 + ...{index-B2UJYwrh.css => index-Dnm-Xdlz.css} | 2 +- static/dist/assets/index-Dz6fefYY.js | 1 - static/dist/index.html | 4 +- 9 files changed, 147 insertions(+), 71 deletions(-) create mode 100644 static/dist/assets/index-BtSfMBqf.js rename static/dist/assets/{index-B2UJYwrh.css => index-Dnm-Xdlz.css} (53%) delete mode 100644 static/dist/assets/index-Dz6fefYY.js diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fdf853f..584bdb4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -175,6 +175,14 @@ const Room: Component = () => { send({ type: "remove_queue_item", item_id: itemId }); }; + // Player reports the media element reached its end. Read the current + // track at call time so we always send the right id, even if the state + // shifted during the WS roundtrip. + const handlePlayerEnded = () => { + const track = state()?.current_track; + if (track) send({ type: "track_ended", item_id: track.id }); + }; + return (
@@ -199,10 +207,7 @@ const Room: Component = () => { { - const track = state()?.current_track; - if (track) send({ type: "track_ended", item_id: track.id }); - }} + onEnded={handlePlayerEnded} />
diff --git a/frontend/src/Player.tsx b/frontend/src/Player.tsx index 6825f78..ecdc136 100644 --- a/frontend/src/Player.tsx +++ b/frontend/src/Player.tsx @@ -11,6 +11,21 @@ function persistVolume(el: HTMLMediaElement) { localStorage.setItem(VOLUME_KEY, v.toString()); } +/// Parse "m:ss" or "h:mm:ss" duration string to seconds. Returns 0 on parse failure. +function parseDuration(s: string): number { + const parts = s.split(":").map(Number); + if (parts.length < 2 || parts.length > 3 || parts.some(isNaN)) return 0; + if (parts.length === 2) return parts[0] * 60 + parts[1]; + return parts[0] * 3600 + parts[1] * 60 + parts[2]; +} + +/// Clamp elapsed time to the track's known duration. Falls back to elapsed +/// when duration is unknown (still being fetched). +function clampElapsed(elapsedMs: number, durationStr: string): number { + const d = parseDuration(durationStr) * 1000; + return d > 0 ? Math.min(d, elapsedMs) : elapsedMs; +} + interface PlayerProps { track: TrackState | null; roomId: string; @@ -26,15 +41,18 @@ function isVideoTrack(track: TrackState): boolean { return true; } -/// Seek the element to the wall-clock live position. +/// Seek the element to the wall-clock live position. No-op if the expected +/// position is past the track duration (avoids a wasted seek + clamp). function seekLive(el: HTMLMediaElement, started_at: number) { const expected = (Date.now() - started_at) / 1000; + if (el.duration && expected >= el.duration) return; el.currentTime = expected; } export function Player(props: PlayerProps) { let mediaRef: HTMLMediaElement | undefined; const [live, setLive] = createSignal(true); + const [mediaError, setMediaError] = createSignal(null); const roomId = props.roomId; // never changes, safe to read once const setMediaRef = (el: HTMLMediaElement | null) => { if (mediaRef) { @@ -71,6 +89,8 @@ export function Player(props: PlayerProps) { // a new TrackState object but the same track is still playing. if (id === prevId) return; + setMediaError(null); + const track = trackRef; const el = mediaRef; if (!el || !track?.started_at) { @@ -114,19 +134,40 @@ export function Player(props: PlayerProps) { el.addEventListener("seeked", onSeeked, { once: true }); }; el.addEventListener("loadedmetadata", onSeekable, { once: true }); - el.addEventListener("canplay", onSeekable, { once: true }); + + // Collapse the player on natural end so the user doesn't see the + // ended state (replay overlay) while the server broadcasts the next + // track. The new snapshot's effect run will set the new src. + const onMediaEnded = () => { + el.removeAttribute("src"); + el.load(); + }; + el.addEventListener("ended", onMediaEnded, { once: true }); + + // Surface a real media error (404, decode failure) as a visible state. + // Code 1 = MEDIA_ERR_ABORTED, which fires when we manually clear src + // above — not a user-facing error. + const onMediaError = () => { + if (el.error?.code === 1) return; + setMediaError("Failed to load media"); + }; + el.addEventListener("error", onMediaError); const onPause = () => { // Don't seek when the track naturally ended. if (el.ended) return; + // Some browsers fire pause before ended at natural end. Snap-back + // would seek past the duration and clamp — skip it. + if (el.duration && el.currentTime >= el.duration - 0.1) return; seekLive(el, started_at); }; return () => { el.removeEventListener("loadedmetadata", onSeekable); - el.removeEventListener("canplay", onSeekable); if (onTime) el.removeEventListener("timeupdate", onTime); el.removeEventListener("pause", onPause); + el.removeEventListener("ended", onMediaEnded); + el.removeEventListener("error", onMediaError); }; }, ); @@ -171,20 +212,20 @@ export function Player(props: PlayerProps) { {t.title} {t.duration} - {displayTime(Date.now() - t.started_at)} + + {displayTime(clampElapsed(Date.now() - t.started_at, t.duration))} +
- {isVideoTrack(t) ? ( - + {mediaError() ? ( +
{mediaError()}
+ ) : isVideoTrack(t) ? ( +