diff --git a/backend/src/MediaPlayer.ts b/backend/src/MediaPlayer.ts index ab03b82..5b560ec 100644 --- a/backend/src/MediaPlayer.ts +++ b/backend/src/MediaPlayer.ts @@ -160,8 +160,12 @@ export class MediaPlayer { public async getNowPlaying(): Promise { const playlist = await this.getPlaylist(); const currentlyPlayingSong = playlist.find((item: PlaylistItem) => item.current); - const fetchMediaTitle = async (): Promise => { - return (await this.writeCommand("get_property", ["media-title"])).data; + const fetchMediaTitle = async (): Promise => { + try { + return (await this.writeCommand("get_property", ["media-title"])).data; + } catch (err) { + return null; + } }; if (currentlyPlayingSong !== undefined) { @@ -169,14 +173,14 @@ export class MediaPlayer { if (currentlyPlayingSong.title === undefined && currentlyPlayingSong.metadata?.title === undefined) { return { ...currentlyPlayingSong, - title: await fetchMediaTitle() + title: await fetchMediaTitle() || currentlyPlayingSong.filename }; } return currentlyPlayingSong; } - const mediaTitle = await fetchMediaTitle(); + const mediaTitle = await fetchMediaTitle() || ""; return { id: 0, filename: mediaTitle, @@ -184,11 +188,11 @@ export class MediaPlayer { }; } - public async getCurrentFile(): Promise { + public async getCurrentFile(): Promise { return this.writeCommand("get_property", ["stream-open-filename"]) .then((response) => { return response.data; - }); + }, (reject) => { return null; }); } public async getPauseState(): Promise { @@ -205,6 +209,27 @@ export class MediaPlayer { }); } + public async getTimePosition(): Promise { + return this.writeCommand("get_property", ["time-pos"]) + .then((response) => { + return response.data; + }, (rejected) => { return null; }); + } + + public async getDuration(): Promise { + return this.writeCommand("get_property", ["duration"]) + .then((response) => { + return response.data; + }, (rejected) => { return null; }); + } + + public async getSeekable(): Promise { + return this.writeCommand("get_property", ["seekable"]) + .then((response) => { + return response.data; + }, (rejected) => { return null; }); + } + public async getIdle(): Promise { return this.writeCommand("get_property", ["idle"]) .then((response) => { @@ -286,6 +311,10 @@ export class MediaPlayer { return this.modify(UserEvent.VolumeUpdate, () => this.writeCommand("set_property", ["volume", volume])); } + public async seek(time: number) { + return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("seek", [time, "absolute"])); + } + public subscribe(ws: WebSocket) { this.eventSubscribers.push(ws); } @@ -338,7 +367,10 @@ export class MediaPlayer { // Notify all subscribers this.handleEvent(event, {}); return result; - }); + }, (reject) => { + console.log("Error modifying playlist: " + reject); + return reject; + }); } private async writeCommand(command: string, args: any[]): Promise { @@ -429,7 +461,12 @@ export class MediaPlayer { if (response.request_id) { const pending = this.pendingCommands.get(response.request_id); if (pending) { - pending.resolve(response); + if (response.error == "success") { + pending.resolve(response); + } else { + pending.reject(response.error); + } + this.pendingCommands.delete(response.request_id); } } else if (response.event) { diff --git a/backend/src/server.ts b/backend/src/server.ts index ca08734..e0def02 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -43,6 +43,7 @@ const withErrorHandling = (func: (req: any, res: any) => Promise) => { try { await func(req, res); } catch (error: any) { + console.log(`Error (${func.name}): ${error}`); res.status(500).send(JSON.stringify({ success: false, error: error.message })); } }; @@ -108,6 +109,9 @@ apiRouter.get("/nowplaying", withErrorHandling(async (req, res) => { const pauseState = await mediaPlayer.getPauseState(); const volume = await mediaPlayer.getVolume(); const idle = await mediaPlayer.getIdle(); + const timePosition = await mediaPlayer.getTimePosition(); + const duration = await mediaPlayer.getDuration(); + const seekable = await mediaPlayer.getSeekable(); res.send(JSON.stringify({ success: true, @@ -115,7 +119,10 @@ apiRouter.get("/nowplaying", withErrorHandling(async (req, res) => { isPaused: pauseState, volume: volume, isIdle: idle, - currentFile: currentFile + currentFile: currentFile, + timePosition: timePosition, + duration: duration, + seekable: seekable })); })); @@ -125,6 +132,12 @@ apiRouter.post("/volume", withErrorHandling(async (req, res) => { res.send(JSON.stringify({ success: true })); })); +apiRouter.post("/player/seek", withErrorHandling(async (req, res) => { + const { time } = req.body as { time: number }; + await mediaPlayer.seek(time); + res.send(JSON.stringify({ success: true })); +})); + apiRouter.ws("/events", (ws, req) => { console.log("Events client connected"); mediaPlayer.subscribe(ws); diff --git a/frontend/src/api/player.tsx b/frontend/src/api/player.tsx index 179f941..f417904 100644 --- a/frontend/src/api/player.tsx +++ b/frontend/src/api/player.tsx @@ -5,6 +5,9 @@ export interface NowPlayingResponse { volume: number; isIdle: boolean; currentFile: string; + timePosition?: number; + duration?: number; + seekable?: boolean; } export interface Features { @@ -137,6 +140,16 @@ export const API = { body: JSON.stringify({ volume }), }); }, + + async seek(time: number): Promise { + await fetch('/api/player/seek', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ time }), + }); + }, async search(query: string): Promise { const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`); diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx index b0f9b5f..5e56f69 100644 --- a/frontend/src/components/App.tsx +++ b/frontend/src/components/App.tsx @@ -87,6 +87,9 @@ const App: React.FC = () => { const [isIdle, setIsIdle] = useState(false); const [nowPlayingSong, setNowPlayingSong] = useState(null); const [nowPlayingFileName, setNowPlayingFileName] = useState(null); + const [timePosition, setTimePosition] = useState(undefined); + const [duration, setDuration] = useState(undefined); + const [seekable, setSeekable] = useState(undefined); const [volume, setVolume] = useState(100); const [volumeSettingIsLocked, setVolumeSettingIsLocked] = useState(false); const [playlist, setPlaylist] = useState([]); @@ -127,6 +130,9 @@ const App: React.FC = () => { setIsPlaying(!nowPlaying.isPaused); setVolume(nowPlaying.volume); setIsIdle(nowPlaying.playingItem ? !nowPlaying.playingItem.playing : true); + setTimePosition(nowPlaying.timePosition); + setDuration(nowPlaying.duration); + setSeekable(nowPlaying.seekable); const features = await API.getFeatures(); setFeatures(features); @@ -176,6 +182,11 @@ const App: React.FC = () => { fetchNowPlaying(); }; + const handleSeek = async (time: number) => { + await API.seek(time); + fetchNowPlaying(); + }; + const handleVolumeSettingChange = async (volume: number) => { setVolume(volume); await API.setVolume(volume); @@ -204,7 +215,6 @@ const App: React.FC = () => { }, [fetchPlaylist, fetchNowPlaying, fetchFavorites]); const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/api/events`; - console.log('Connecting to WebSocket at', wsUrl); useWebSocket(wsUrl, { onOpen: () => { console.log('WebSocket connected'); @@ -247,6 +257,15 @@ const App: React.FC = () => { fetchFavorites(); }, [fetchPlaylist, fetchNowPlaying, fetchFavorites]); + useEffect(() => { + const interval = setInterval(() => { + if (isPlaying) { + fetchNowPlaying(); + } + }, 1000); + return () => clearInterval(interval); + }, [isPlaying, fetchNowPlaying]); + const AuxButton: React.FC<{ children: ReactNode, className: string, title: string, onClick: () => void }> = (props) => ( + + + + + + + + {(props.isScreenSharingSupported && props.features?.screenshare) && ( + + )} + + -
-
- - props.onVolumeWillChange(props.volume)} - onMouseUp={() => props.onVolumeDidChange(props.volume)} - onChange={(e) => props.onVolumeSettingChange(Number(e.target.value))} - className="fancy-slider h-2 w-full" + + {props.seekable !== false && ( +
{ + setIsSeeking(true); + handleSeek(e); + }} + onMouseMove={(e) => { + if (isSeeking) { + handleSeek(e); + } + }} + onMouseUp={() => setIsSeeking(false)} + onMouseLeave={() => setIsSeeking(false)} + > +
- -
- - - - - - - - - - {(props.isScreenSharingSupported && props.features?.screenshare) && ( - - )} -
+ )} {props.features?.browserPlayback && (