mirror of
https://github.com/myronblair/kino-app
synced 2026-07-29 05:54:01 -05:00
Add filesystem Library Scan (movies/tv/music) and a full Music library
- New admin Library Scan page walks the NAS mounts directly for movies, TV, and music not already in Radarr/Sonarr/local storage, and imports them with storage_type=scan (fully integrated: streamable, transcodable, listed in Admin) — Radarr/Sonarr import flows are untouched. - New Music section (nav tab, album-grid browse, per-album track list, persistent bottom player with queue/seek) backed by a Track model and range-request audio streaming endpoint. - MusicBrainz + Cover Art Archive integration (free, no API key) to fill in album art for scanned tracks, paced to their 1 req/sec rate limit. - Mounted the NAS music share into CT104 (new mp2 LXC mountpoint, CIFS read-only) alongside the existing video mount. - Optional Bluetooth/output-device picker on the player bar via the Audio Output Devices API (setSinkId) for already-paired speakers — Chrome/Edge only, since browsers can't pair new BT devices directly.
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
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().then(() => setIsPlaying(true)).catch(() => setIsPlaying(false));
|
||||
}, [current]);
|
||||
|
||||
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);
|
||||
else setIsPlaying(false);
|
||||
};
|
||||
audio.addEventListener("timeupdate", onTime);
|
||||
audio.addEventListener("ended", onEnded);
|
||||
return () => {
|
||||
audio.removeEventListener("timeupdate", onTime);
|
||||
audio.removeEventListener("ended", onEnded);
|
||||
};
|
||||
}, [index, queue]);
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!current) return;
|
||||
if (audio.paused) audio.play().then(() => setIsPlaying(true));
|
||||
else { audio.pause(); setIsPlaying(false); }
|
||||
}, [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);
|
||||
Reference in New Issue
Block a user