Implements seek/time bar

This commit is contained in:
2025-06-26 01:38:12 -07:00
parent 5e9842f02d
commit 6d0c52b96f
5 changed files with 201 additions and 58 deletions

View File

@@ -160,8 +160,12 @@ export class MediaPlayer {
public async getNowPlaying(): Promise<PlaylistItem> { public async getNowPlaying(): Promise<PlaylistItem> {
const playlist = await this.getPlaylist(); const playlist = await this.getPlaylist();
const currentlyPlayingSong = playlist.find((item: PlaylistItem) => item.current); const currentlyPlayingSong = playlist.find((item: PlaylistItem) => item.current);
const fetchMediaTitle = async (): Promise<string> => { const fetchMediaTitle = async (): Promise<string | null> => {
try {
return (await this.writeCommand("get_property", ["media-title"])).data; return (await this.writeCommand("get_property", ["media-title"])).data;
} catch (err) {
return null;
}
}; };
if (currentlyPlayingSong !== undefined) { if (currentlyPlayingSong !== undefined) {
@@ -169,14 +173,14 @@ export class MediaPlayer {
if (currentlyPlayingSong.title === undefined && currentlyPlayingSong.metadata?.title === undefined) { if (currentlyPlayingSong.title === undefined && currentlyPlayingSong.metadata?.title === undefined) {
return { return {
...currentlyPlayingSong, ...currentlyPlayingSong,
title: await fetchMediaTitle() title: await fetchMediaTitle() || currentlyPlayingSong.filename
}; };
} }
return currentlyPlayingSong; return currentlyPlayingSong;
} }
const mediaTitle = await fetchMediaTitle(); const mediaTitle = await fetchMediaTitle() || "";
return { return {
id: 0, id: 0,
filename: mediaTitle, filename: mediaTitle,
@@ -184,11 +188,11 @@ export class MediaPlayer {
}; };
} }
public async getCurrentFile(): Promise<string> { public async getCurrentFile(): Promise<string | null> {
return this.writeCommand("get_property", ["stream-open-filename"]) return this.writeCommand("get_property", ["stream-open-filename"])
.then((response) => { .then((response) => {
return response.data; return response.data;
}); }, (reject) => { return null; });
} }
public async getPauseState(): Promise<boolean> { public async getPauseState(): Promise<boolean> {
@@ -205,6 +209,27 @@ export class MediaPlayer {
}); });
} }
public async getTimePosition(): Promise<number | null> {
return this.writeCommand("get_property", ["time-pos"])
.then((response) => {
return response.data;
}, (rejected) => { return null; });
}
public async getDuration(): Promise<number | null> {
return this.writeCommand("get_property", ["duration"])
.then((response) => {
return response.data;
}, (rejected) => { return null; });
}
public async getSeekable(): Promise<boolean | null> {
return this.writeCommand("get_property", ["seekable"])
.then((response) => {
return response.data;
}, (rejected) => { return null; });
}
public async getIdle(): Promise<boolean> { public async getIdle(): Promise<boolean> {
return this.writeCommand("get_property", ["idle"]) return this.writeCommand("get_property", ["idle"])
.then((response) => { .then((response) => {
@@ -286,6 +311,10 @@ export class MediaPlayer {
return this.modify(UserEvent.VolumeUpdate, () => this.writeCommand("set_property", ["volume", volume])); 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) { public subscribe(ws: WebSocket) {
this.eventSubscribers.push(ws); this.eventSubscribers.push(ws);
} }
@@ -338,6 +367,9 @@ export class MediaPlayer {
// Notify all subscribers // Notify all subscribers
this.handleEvent(event, {}); this.handleEvent(event, {});
return result; return result;
}, (reject) => {
console.log("Error modifying playlist: " + reject);
return reject;
}); });
} }
@@ -429,7 +461,12 @@ export class MediaPlayer {
if (response.request_id) { if (response.request_id) {
const pending = this.pendingCommands.get(response.request_id); const pending = this.pendingCommands.get(response.request_id);
if (pending) { if (pending) {
if (response.error == "success") {
pending.resolve(response); pending.resolve(response);
} else {
pending.reject(response.error);
}
this.pendingCommands.delete(response.request_id); this.pendingCommands.delete(response.request_id);
} }
} else if (response.event) { } else if (response.event) {

View File

@@ -43,6 +43,7 @@ const withErrorHandling = (func: (req: any, res: any) => Promise<any>) => {
try { try {
await func(req, res); await func(req, res);
} catch (error: any) { } catch (error: any) {
console.log(`Error (${func.name}): ${error}`);
res.status(500).send(JSON.stringify({ success: false, error: error.message })); 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 pauseState = await mediaPlayer.getPauseState();
const volume = await mediaPlayer.getVolume(); const volume = await mediaPlayer.getVolume();
const idle = await mediaPlayer.getIdle(); const idle = await mediaPlayer.getIdle();
const timePosition = await mediaPlayer.getTimePosition();
const duration = await mediaPlayer.getDuration();
const seekable = await mediaPlayer.getSeekable();
res.send(JSON.stringify({ res.send(JSON.stringify({
success: true, success: true,
@@ -115,7 +119,10 @@ apiRouter.get("/nowplaying", withErrorHandling(async (req, res) => {
isPaused: pauseState, isPaused: pauseState,
volume: volume, volume: volume,
isIdle: idle, 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 })); 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) => { apiRouter.ws("/events", (ws, req) => {
console.log("Events client connected"); console.log("Events client connected");
mediaPlayer.subscribe(ws); mediaPlayer.subscribe(ws);

View File

@@ -5,6 +5,9 @@ export interface NowPlayingResponse {
volume: number; volume: number;
isIdle: boolean; isIdle: boolean;
currentFile: string; currentFile: string;
timePosition?: number;
duration?: number;
seekable?: boolean;
} }
export interface Features { export interface Features {
@@ -138,6 +141,16 @@ export const API = {
}); });
}, },
async seek(time: number): Promise<void> {
await fetch('/api/player/seek', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ time }),
});
},
async search(query: string): Promise<SearchResponse> { async search(query: string): Promise<SearchResponse> {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`); const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
return response.json(); return response.json();

View File

@@ -87,6 +87,9 @@ const App: React.FC = () => {
const [isIdle, setIsIdle] = useState(false); const [isIdle, setIsIdle] = useState(false);
const [nowPlayingSong, setNowPlayingSong] = useState<string | null>(null); const [nowPlayingSong, setNowPlayingSong] = useState<string | null>(null);
const [nowPlayingFileName, setNowPlayingFileName] = useState<string | null>(null); const [nowPlayingFileName, setNowPlayingFileName] = useState<string | null>(null);
const [timePosition, setTimePosition] = useState<number | undefined>(undefined);
const [duration, setDuration] = useState<number | undefined>(undefined);
const [seekable, setSeekable] = useState<boolean | undefined>(undefined);
const [volume, setVolume] = useState(100); const [volume, setVolume] = useState(100);
const [volumeSettingIsLocked, setVolumeSettingIsLocked] = useState(false); const [volumeSettingIsLocked, setVolumeSettingIsLocked] = useState(false);
const [playlist, setPlaylist] = useState<PlaylistItem[]>([]); const [playlist, setPlaylist] = useState<PlaylistItem[]>([]);
@@ -127,6 +130,9 @@ const App: React.FC = () => {
setIsPlaying(!nowPlaying.isPaused); setIsPlaying(!nowPlaying.isPaused);
setVolume(nowPlaying.volume); setVolume(nowPlaying.volume);
setIsIdle(nowPlaying.playingItem ? !nowPlaying.playingItem.playing : true); setIsIdle(nowPlaying.playingItem ? !nowPlaying.playingItem.playing : true);
setTimePosition(nowPlaying.timePosition);
setDuration(nowPlaying.duration);
setSeekable(nowPlaying.seekable);
const features = await API.getFeatures(); const features = await API.getFeatures();
setFeatures(features); setFeatures(features);
@@ -176,6 +182,11 @@ const App: React.FC = () => {
fetchNowPlaying(); fetchNowPlaying();
}; };
const handleSeek = async (time: number) => {
await API.seek(time);
fetchNowPlaying();
};
const handleVolumeSettingChange = async (volume: number) => { const handleVolumeSettingChange = async (volume: number) => {
setVolume(volume); setVolume(volume);
await API.setVolume(volume); await API.setVolume(volume);
@@ -204,7 +215,6 @@ const App: React.FC = () => {
}, [fetchPlaylist, fetchNowPlaying, fetchFavorites]); }, [fetchPlaylist, fetchNowPlaying, fetchFavorites]);
const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/api/events`; const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/api/events`;
console.log('Connecting to WebSocket at', wsUrl);
useWebSocket(wsUrl, { useWebSocket(wsUrl, {
onOpen: () => { onOpen: () => {
console.log('WebSocket connected'); console.log('WebSocket connected');
@@ -247,6 +257,15 @@ const App: React.FC = () => {
fetchFavorites(); fetchFavorites();
}, [fetchPlaylist, fetchNowPlaying, 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) => ( const AuxButton: React.FC<{ children: ReactNode, className: string, title: string, onClick: () => void }> = (props) => (
<button <button
className={ className={
@@ -329,10 +348,14 @@ const App: React.FC = () => {
fileName={nowPlayingFileName || ""} fileName={nowPlayingFileName || ""}
isPlaying={isPlaying} isPlaying={isPlaying}
isIdle={isIdle} isIdle={isIdle}
timePosition={timePosition}
duration={duration}
seekable={seekable}
onPlayPause={togglePlayPause} onPlayPause={togglePlayPause}
onStop={handleStop} onStop={handleStop}
onSkip={handleSkip} onSkip={handleSkip}
onPrevious={handlePrevious} onPrevious={handlePrevious}
onSeek={handleSeek}
onScreenShare={toggleScreenShare} onScreenShare={toggleScreenShare}
isScreenSharing={isScreenSharing} isScreenSharing={isScreenSharing}
volume={volume} volume={volume}

View File

@@ -1,4 +1,4 @@
import React, { HTMLAttributes } from 'react'; import React, { HTMLAttributes, useState, useRef } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { FaPlay, FaPause, FaStepForward, FaStepBackward, FaVolumeUp, FaDesktop, FaStop } from 'react-icons/fa'; import { FaPlay, FaPause, FaStepForward, FaStepBackward, FaVolumeUp, FaDesktop, FaStop } from 'react-icons/fa';
import { Features } from '../api/player'; import { Features } from '../api/player';
@@ -9,10 +9,14 @@ interface NowPlayingProps extends HTMLAttributes<HTMLDivElement> {
isPlaying: boolean; isPlaying: boolean;
isIdle: boolean; isIdle: boolean;
volume: number; volume: number;
timePosition?: number;
duration?: number;
seekable?: boolean;
onPlayPause: () => void; onPlayPause: () => void;
onStop: () => void; onStop: () => void;
onSkip: () => void; onSkip: () => void;
onPrevious: () => void; onPrevious: () => void;
onSeek: (time: number) => void;
onScreenShare: () => void; onScreenShare: () => void;
isScreenSharingSupported: boolean; isScreenSharingSupported: boolean;
@@ -34,26 +38,54 @@ interface NowPlayingProps extends HTMLAttributes<HTMLDivElement> {
} }
const NowPlaying: React.FC<NowPlayingProps> = (props) => { const NowPlaying: React.FC<NowPlayingProps> = (props) => {
const [isSeeking, setIsSeeking] = useState(false);
const progressBarRef = useRef<HTMLDivElement>(null);
const formatTime = (time: number) => {
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
};
const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
if (progressBarRef.current) {
const rect = progressBarRef.current.getBoundingClientRect();
const newSeekPosition = (e.clientX - rect.left) / rect.width;
if (props.duration) {
props.onSeek(newSeekPosition * props.duration);
}
}
};
const titleArea = props.isScreenSharing ? ( const titleArea = props.isScreenSharing ? (
<div className="flex flex-row items-center gap-2 text-white text-center justify-center"> <div className="flex flex-row items-center gap-2 text-white text-center justify-center">
<FaDesktop size={24} /> <FaDesktop size={24} />
<div className="text-lg font-bold truncate">Screen Sharing</div> <div className="text-lg font-bold truncate">Screen Sharing</div>
</div> </div>
) : ( ) : (
<div className={classNames(props.isIdle ? 'opacity-50' : 'opacity-100')}> <div className={classNames(props.isIdle ? 'opacity-50' : 'opacity-100', "flex flex-row items-center justify-between w-full")}>
<div>
<div className="text-lg font-bold truncate">{props.songName}</div> <div className="text-lg font-bold truncate">{props.songName}</div>
<div className="text-sm truncate">{props.fileName}</div> <div className="text-sm truncate">{props.fileName}</div>
</div> </div>
<div className="text-sm opacity-50">
{props.timePosition && props.duration ?
(props.seekable ? `${formatTime(props.timePosition)} / ${formatTime(props.duration)}`
: `${formatTime(props.timePosition)}` )
: ''}
</div>
</div>
); );
return ( return (
<div className={classNames(props.className, 'bg-black/50 h-fit p-5')}> <div className={classNames(props.className, 'bg-black/50 h-fit p-5')}>
<div className="flex flex-col w-full gap-2"> <div className="flex flex-col w-full gap-2">
<div className="flex flex-col w-full h-full bg-black/50 rounded-lg p-5 gap-4"> <div className="flex flex-col w-full h-full bg-black/50 rounded-lg gap-4 overflow-hidden">
<div className="p-5">
<div className="flex-grow min-w-0 w-full text-white text-left"> <div className="flex-grow min-w-0 w-full text-white text-left">
{titleArea} {titleArea}
</div> </div>
<div className="flex flex-row items-center gap-4 w-full"> <div className="flex flex-row items-center gap-4 w-full pt-4">
<div className="flex items-center gap-2 text-white w-full max-w-[250px]"> <div className="flex items-center gap-2 text-white w-full max-w-[250px]">
<FaVolumeUp size={20} /> <FaVolumeUp size={20} />
<input <input
@@ -95,7 +127,32 @@ const NowPlaying: React.FC<NowPlayingProps> = (props) => {
<FaDesktop size={24} /> <FaDesktop size={24} />
</button> </button>
)} )}
</div> </div>
</div>
{props.seekable !== false && (
<div
ref={progressBarRef}
className="w-full h-2 bg-gray-600 cursor-pointer -mt-3"
onMouseDown={(e) => {
setIsSeeking(true);
handleSeek(e);
}}
onMouseMove={(e) => {
if (isSeeking) {
handleSeek(e);
}
}}
onMouseUp={() => setIsSeeking(false)}
onMouseLeave={() => setIsSeeking(false)}
>
<div
className="h-full bg-violet-500"
style={{ width: `${(props.timePosition && props.duration ? (props.timePosition / props.duration) * 100 : 0)}%` }}
/>
</div>
)}
{props.features?.browserPlayback && ( {props.features?.browserPlayback && (
<div> <div>