Implements seek/time bar
This commit is contained in:
@@ -160,8 +160,12 @@ export class MediaPlayer {
|
||||
public async getNowPlaying(): Promise<PlaylistItem> {
|
||||
const playlist = await this.getPlaylist();
|
||||
const currentlyPlayingSong = playlist.find((item: PlaylistItem) => item.current);
|
||||
const fetchMediaTitle = async (): Promise<string> => {
|
||||
return (await this.writeCommand("get_property", ["media-title"])).data;
|
||||
const fetchMediaTitle = async (): Promise<string | null> => {
|
||||
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<string> {
|
||||
public async getCurrentFile(): Promise<string | null> {
|
||||
return this.writeCommand("get_property", ["stream-open-filename"])
|
||||
.then((response) => {
|
||||
return response.data;
|
||||
});
|
||||
}, (reject) => { return null; });
|
||||
}
|
||||
|
||||
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> {
|
||||
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<any> {
|
||||
@@ -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) {
|
||||
|
||||
@@ -43,6 +43,7 @@ const withErrorHandling = (func: (req: any, res: any) => Promise<any>) => {
|
||||
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);
|
||||
|
||||
@@ -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<void> {
|
||||
await fetch('/api/player/seek', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ time }),
|
||||
});
|
||||
},
|
||||
|
||||
async search(query: string): Promise<SearchResponse> {
|
||||
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
|
||||
|
||||
@@ -87,6 +87,9 @@ const App: React.FC = () => {
|
||||
const [isIdle, setIsIdle] = useState(false);
|
||||
const [nowPlayingSong, setNowPlayingSong] = 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 [volumeSettingIsLocked, setVolumeSettingIsLocked] = useState(false);
|
||||
const [playlist, setPlaylist] = useState<PlaylistItem[]>([]);
|
||||
@@ -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) => (
|
||||
<button
|
||||
className={
|
||||
@@ -329,10 +348,14 @@ const App: React.FC = () => {
|
||||
fileName={nowPlayingFileName || ""}
|
||||
isPlaying={isPlaying}
|
||||
isIdle={isIdle}
|
||||
timePosition={timePosition}
|
||||
duration={duration}
|
||||
seekable={seekable}
|
||||
onPlayPause={togglePlayPause}
|
||||
onStop={handleStop}
|
||||
onSkip={handleSkip}
|
||||
onPrevious={handlePrevious}
|
||||
onSeek={handleSeek}
|
||||
onScreenShare={toggleScreenShare}
|
||||
isScreenSharing={isScreenSharing}
|
||||
volume={volume}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { HTMLAttributes } from 'react';
|
||||
import React, { HTMLAttributes, useState, useRef } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { FaPlay, FaPause, FaStepForward, FaStepBackward, FaVolumeUp, FaDesktop, FaStop } from 'react-icons/fa';
|
||||
import { Features } from '../api/player';
|
||||
@@ -9,10 +9,14 @@ interface NowPlayingProps extends HTMLAttributes<HTMLDivElement> {
|
||||
isPlaying: boolean;
|
||||
isIdle: boolean;
|
||||
volume: number;
|
||||
timePosition?: number;
|
||||
duration?: number;
|
||||
seekable?: boolean;
|
||||
onPlayPause: () => void;
|
||||
onStop: () => void;
|
||||
onSkip: () => void;
|
||||
onPrevious: () => void;
|
||||
onSeek: (time: number) => void;
|
||||
|
||||
onScreenShare: () => void;
|
||||
isScreenSharingSupported: boolean;
|
||||
@@ -34,68 +38,121 @@ interface NowPlayingProps extends HTMLAttributes<HTMLDivElement> {
|
||||
}
|
||||
|
||||
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 ? (
|
||||
<div className="flex flex-row items-center gap-2 text-white text-center justify-center">
|
||||
<FaDesktop size={24} />
|
||||
<div className="text-lg font-bold truncate">Screen Sharing</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={classNames(props.isIdle ? 'opacity-50' : 'opacity-100')}>
|
||||
<div className="text-lg font-bold truncate">{props.songName}</div>
|
||||
<div className="text-sm truncate">{props.fileName}</div>
|
||||
<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-sm truncate">{props.fileName}</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 (
|
||||
<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 h-full bg-black/50 rounded-lg p-5 gap-4">
|
||||
<div className="flex-grow min-w-0 w-full text-white text-left">
|
||||
{titleArea}
|
||||
<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">
|
||||
{titleArea}
|
||||
</div>
|
||||
<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]">
|
||||
<FaVolumeUp size={20} />
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={props.volume}
|
||||
onMouseDown={() => props.onVolumeWillChange(props.volume)}
|
||||
onMouseUp={() => props.onVolumeDidChange(props.volume)}
|
||||
onChange={(e) => props.onVolumeSettingChange(Number(e.target.value))}
|
||||
className="fancy-slider h-2 w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-grow"></div>
|
||||
|
||||
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onPrevious}>
|
||||
<FaStepBackward size={24} />
|
||||
</button>
|
||||
|
||||
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onPlayPause}>
|
||||
{(props.isPlaying && !props.isIdle) ? <FaPause size={24} /> : <FaPlay size={24} />}
|
||||
</button>
|
||||
|
||||
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onStop}>
|
||||
<FaStop size={24} className={props.isIdle ? 'opacity-25' : 'opacity-100'} />
|
||||
</button>
|
||||
|
||||
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onSkip}>
|
||||
<FaStepForward size={24} />
|
||||
</button>
|
||||
|
||||
{(props.isScreenSharingSupported && props.features?.screenshare) && (
|
||||
<button
|
||||
className={classNames("text-white hover:text-violet-300 transition-colors rounded-full p-2", props.isScreenSharing ? ' bg-violet-800' : '')}
|
||||
onClick={props.onScreenShare}
|
||||
title="Share your screen"
|
||||
>
|
||||
<FaDesktop size={24} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row items-center gap-4 w-full">
|
||||
<div className="flex items-center gap-2 text-white w-full max-w-[250px]">
|
||||
<FaVolumeUp size={20} />
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={props.volume}
|
||||
onMouseDown={() => 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 && (
|
||||
<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>
|
||||
|
||||
<div className="flex-grow"></div>
|
||||
|
||||
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onPrevious}>
|
||||
<FaStepBackward size={24} />
|
||||
</button>
|
||||
|
||||
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onPlayPause}>
|
||||
{(props.isPlaying && !props.isIdle) ? <FaPause size={24} /> : <FaPlay size={24} />}
|
||||
</button>
|
||||
|
||||
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onStop}>
|
||||
<FaStop size={24} className={props.isIdle ? 'opacity-25' : 'opacity-100'} />
|
||||
</button>
|
||||
|
||||
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onSkip}>
|
||||
<FaStepForward size={24} />
|
||||
</button>
|
||||
|
||||
{(props.isScreenSharingSupported && props.features?.screenshare) && (
|
||||
<button
|
||||
className={classNames("text-white hover:text-violet-300 transition-colors rounded-full p-2", props.isScreenSharing ? ' bg-violet-800' : '')}
|
||||
onClick={props.onScreenShare}
|
||||
title="Share your screen"
|
||||
>
|
||||
<FaDesktop size={24} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.features?.browserPlayback && (
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user