Files
kino-app/frontend/src/lib/musicPlayer.jsx
T
Myron Blair 5fc37e46e4 Fix 8 code-review findings across transcode queue, requests, cleanup, music
- Episode transcode (single + bulk) now accepts storage_type=scan, matching
  stream_episode and the movie-transcode endpoint — Library-Scan-imported
  TV episodes can finally be transcoded, not just streamed.
- retry_job now forwards content_type, fixing retry for failed/cancelled
  episode jobs (was silently treating them as movies and failing again).
- cancel_job now updates the correct collection (movies vs episodes) based
  on content_type instead of always writing to db.movies.
- approve_request atomically claims the request (pending -> approving)
  before contacting Radarr/Sonarr, so a double-click or two concurrent
  admins can't both add the same content; releases the claim back to
  pending on any failure so a fixable error can be retried.
- Music enrichment's update_many now re-checks album_art_url is still
  empty, so it no longer clobbers art an admin manually set via PATCH.
- Library Cleanup's dangling-refs scan now also checks episode_progress
  for rows referencing a deleted episode (previously only watchlist/
  progress against movie_id were checked).
- Settings now surfaces a failed bulk-TMDB-enrich run as an error instead
  of a false "Enrichment done" success summary.
- Music player's isPlaying now syncs from the audio element's native
  play/pause events instead of only being set inside togglePlay/track-
  change, so it can't drift from actual playback state (buffering stalls,
  OS media-key pauses, etc).
2026-07-26 22:24:09 -05:00

100 lines
3.7 KiB
React

import { createContext, useContext, useRef, useState, useCallback, useEffect } from "react";
import { getTrackStreamUrl } from "./api";
const MusicPlayerCtx = createContext(null);
export const MusicPlayerProvider = ({ children }) => {
const audioRef = useRef(new Audio());
const [queue, setQueue] = useState([]);
const [index, setIndex] = useState(-1);
const [isPlaying, setIsPlaying] = useState(false);
const [progress, setProgress] = useState({ position: 0, duration: 0 });
const [outputDevices, setOutputDevices] = useState([]);
const [sinkId, setSinkId] = useState("default");
const sinkSupported = typeof audioRef.current.setSinkId === "function";
const current = index >= 0 ? queue[index] : null;
const refreshOutputDevices = useCallback(async () => {
if (!navigator.mediaDevices?.enumerateDevices) return;
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const outs = devices.filter((d) => d.kind === "audiooutput");
setOutputDevices(outs.map((d, i) => ({ deviceId: d.deviceId, label: d.label || `Output ${i + 1}` })));
} catch { /* enumeration unsupported/denied — picker just won't populate */ }
}, []);
const selectOutput = useCallback(async (deviceId) => {
if (!sinkSupported) return;
try {
await audioRef.current.setSinkId(deviceId);
setSinkId(deviceId);
} catch { /* device may have disappeared (e.g. BT speaker turned off) — keep current output */ }
}, [sinkSupported]);
const playAt = useCallback((newQueue, startIndex) => {
setQueue(newQueue);
setIndex(startIndex);
}, []);
useEffect(() => {
const audio = audioRef.current;
if (!current) { audio.pause(); return; }
audio.src = getTrackStreamUrl(current);
audio.play().catch(() => {}); // isPlaying reflects reality via the native play/pause listeners below
}, [current]);
// isPlaying always mirrors the audio element's own state, so it stays correct through anything
// that changes playback outside togglePlay — a stall, an OS media-key pause, autoplay being blocked.
useEffect(() => {
const audio = audioRef.current;
const onTime = () => setProgress({ position: audio.currentTime, duration: audio.duration || 0 });
const onEnded = () => {
if (index >= 0 && index < queue.length - 1) setIndex(index + 1);
};
const onPlay = () => setIsPlaying(true);
const onPause = () => setIsPlaying(false);
audio.addEventListener("timeupdate", onTime);
audio.addEventListener("ended", onEnded);
audio.addEventListener("play", onPlay);
audio.addEventListener("pause", onPause);
return () => {
audio.removeEventListener("timeupdate", onTime);
audio.removeEventListener("ended", onEnded);
audio.removeEventListener("play", onPlay);
audio.removeEventListener("pause", onPause);
};
}, [index, queue]);
const togglePlay = useCallback(() => {
const audio = audioRef.current;
if (!current) return;
if (audio.paused) audio.play().catch(() => {});
else audio.pause();
}, [current]);
const next = useCallback(() => {
if (index >= 0 && index < queue.length - 1) setIndex(index + 1);
}, [index, queue]);
const prev = useCallback(() => {
if (index > 0) setIndex(index - 1);
}, [index]);
const seek = useCallback((seconds) => {
audioRef.current.currentTime = seconds;
}, []);
return (
<MusicPlayerCtx.Provider value={{
current, isPlaying, progress, playAt, togglePlay, next, prev, seek,
hasNext: index < queue.length - 1, hasPrev: index > 0,
outputDevices, sinkId, sinkSupported, refreshOutputDevices, selectOutput,
}}>
{children}
</MusicPlayerCtx.Provider>
);
};
export const useMusicPlayer = () => useContext(MusicPlayerCtx);