frontend: Finish interactivity

This commit is contained in:
2025-02-15 16:28:47 -08:00
parent 9c4981b9cb
commit 79f53bbffe
9 changed files with 247 additions and 35 deletions

View File

@@ -0,0 +1,82 @@
export interface NowPlayingResponse {
success: boolean;
nowPlaying: string;
isPaused: boolean;
volume: number;
isIdle: boolean;
currentFile: string;
}
export interface PlaylistItem {
filename: string;
title: string | null;
id: number;
playing: boolean | null;
}
export const API = {
async getPlaylist(): Promise<PlaylistItem[]> {
const response = await fetch('/api/playlist');
return response.json();
},
async addToPlaylist(url: string): Promise<void> {
await fetch('/api/playlist', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ url }),
});
},
async removeFromPlaylist(index: number): Promise<void> {
await fetch(`/api/playlist/${index}`, {
method: 'DELETE',
});
},
async play(): Promise<void> {
await fetch('/api/play', { method: 'POST' });
},
async pause(): Promise<void> {
await fetch('/api/pause', { method: 'POST' });
},
async skip(): Promise<void> {
await fetch('/api/skip', { method: 'POST' });
},
async skipTo(index: number): Promise<void> {
await fetch(`/api/skip/${index}`, { method: 'POST' });
},
async previous(): Promise<void> {
await fetch('/api/previous', { method: 'POST' });
},
async getNowPlaying(): Promise<NowPlayingResponse> {
const response = await fetch('/api/nowplaying');
return response.json();
},
async setVolume(volume: number): Promise<void> {
await fetch('/api/volume', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ volume }),
});
},
subscribeToEvents(onMessage: (event: any) => void): WebSocket {
const ws = new WebSocket(`ws://${window.location.host}/api/events`);
ws.onmessage = (event) => {
onMessage(JSON.parse(event.data));
};
return ws;
}
};

View File

@@ -13,7 +13,7 @@ const AddSongPanel: React.FC<AddSongPanelProps> = ({ onAddURL }) => {
} }
return ( return (
<div className="flex items-center justify-center h-fit bg-black/50 rounded-b-2xl"> <div className="flex items-center justify-center h-fit bg-black/50 rounded-b-2xl text-white">
<div className="flex flex-row items-center gap-4 w-full px-8 py-4"> <div className="flex flex-row items-center gap-4 w-full px-8 py-4">
<input <input
type="text" type="text"

View File

@@ -1,30 +1,103 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import SongTable from './SongTable'; import SongTable from './SongTable';
import NowPlaying from './NowPlaying'; import NowPlaying from './NowPlaying';
import AddSongPanel from './AddSongPanel'; import AddSongPanel from './AddSongPanel';
import { API, PlaylistItem } from '../api/player';
const App: React.FC = () => { const App: React.FC = () => {
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = 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 [songs, setSongs] = useState<string[]>([]); const [songs, setSongs] = useState<PlaylistItem[]>([]);
const [ws, setWs] = useState<WebSocket | null>(null);
const fetchPlaylist = async () => {
const playlist = await API.getPlaylist();
setSongs(playlist);
};
const fetchNowPlaying = async () => {
const nowPlaying = await API.getNowPlaying();
setNowPlayingSong(nowPlaying.nowPlaying);
setNowPlayingFileName(nowPlaying.currentFile);
setIsPlaying(!nowPlaying.isPaused);
};
const handleAddURL = (url: string) => { const handleAddURL = (url: string) => {
const urlToAdd = url.trim(); const urlToAdd = url.trim();
if (urlToAdd) { if (urlToAdd) {
setSongs([...songs, urlToAdd]); API.addToPlaylist(urlToAdd);
fetchPlaylist();
} }
}; };
const handleDelete = (index: number) => { const handleDelete = (index: number) => {
setSongs(songs.filter((_, i) => i !== index)); setSongs(songs.filter((_, i) => i !== index));
API.removeFromPlaylist(index);
fetchPlaylist();
fetchNowPlaying();
}; };
const handleSkipTo = (index: number) => { const handleSkipTo = async (index: number) => {
setNowPlayingSong(songs[index]); const song = songs[index];
setNowPlayingFileName(songs[index].split('/').pop() || null); if (song.playing) {
setIsPlaying(true); togglePlayPause();
} else {
await API.skipTo(index);
await API.play();
}
fetchNowPlaying();
fetchPlaylist();
}; };
const togglePlayPause = async () => {
if (isPlaying) {
await API.pause();
} else {
await API.play();
}
fetchNowPlaying();
};
const handleSkip = async () => {
await API.skip();
fetchNowPlaying();
};
const handlePrevious = async () => {
await API.previous();
fetchNowPlaying();
};
const watchForEvents = () => {
const ws = API.subscribeToEvents((event) => {
switch (event.event) {
case 'user_modify':
case 'end-file':
case 'playback-restart':
fetchPlaylist();
fetchNowPlaying();
break;
}
});
return ws;
};
useEffect(() => {
fetchPlaylist();
fetchNowPlaying();
setWs(watchForEvents());
return () => {
if (ws) {
ws.close();
}
};
}, []);
return ( return (
<div className="flex items-center justify-center h-screen w-screen bg-black py-10"> <div className="flex items-center justify-center h-screen w-screen bg-black py-10">
<div className="bg-violet-900 w-full md:max-w-xl h-full md:max-h-xl md:border md:rounded-2xl flex flex-col"> <div className="bg-violet-900 w-full md:max-w-xl h-full md:max-h-xl md:border md:rounded-2xl flex flex-col">
@@ -33,14 +106,15 @@ const App: React.FC = () => {
songName={nowPlayingSong || "(Not Playing)"} songName={nowPlayingSong || "(Not Playing)"}
fileName={nowPlayingFileName || ""} fileName={nowPlayingFileName || ""}
isPlaying={isPlaying} isPlaying={isPlaying}
onPlayPause={() => setIsPlaying(!isPlaying)} onPlayPause={togglePlayPause}
onStepForward={() => {}} onSkip={handleSkip}
onStepBackward={() => {}} onPrevious={handlePrevious}
/> />
{songs.length > 0 ? ( {songs.length > 0 ? (
<SongTable <SongTable
songs={songs} songs={songs}
isPlaying={isPlaying}
onDelete={handleDelete} onDelete={handleDelete}
onSkipTo={handleSkipTo} onSkipTo={handleSkipTo}
/> />

View File

@@ -7,27 +7,27 @@ interface NowPlayingProps extends HTMLAttributes<HTMLDivElement> {
fileName: string; fileName: string;
isPlaying: boolean; isPlaying: boolean;
onPlayPause: () => void; onPlayPause: () => void;
onStepForward: () => void; onSkip: () => void;
onStepBackward: () => void; onPrevious: () => void;
} }
const NowPlaying: React.FC<NowPlayingProps> = (props) => { const NowPlaying: React.FC<NowPlayingProps> = (props) => {
return ( return (
<div className={classNames(props.className, 'bg-violet-100/50 h-[150px] p-5')}> <div className={classNames(props.className, 'bg-black/50 h-[150px] p-5')}>
<div className="flex flex-col w-full gap-2"> <div className="flex flex-col w-full gap-2">
<div className="flex flex-row w-full h-full bg-black/50 rounded-lg p-5 items-center"> <div className="flex flex-row w-full h-full bg-black/50 rounded-lg p-5 items-center">
<div className="flex-grow"> <div className="flex-grow min-w-0">
<div className="text-white text-lg font-bold">{props.songName}</div> <div className="text-white text-lg font-bold truncate">{props.songName}</div>
<div className="text-white text-sm">{props.fileName}</div> <div className="text-white text-sm truncate">{props.fileName}</div>
</div> </div>
<div className="flex flex-row gap-4"> <div className="flex flex-row gap-4">
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onStepBackward}> <button className="text-white hover:text-violet-300 transition-colors" onClick={props.onPrevious}>
<FaStepBackward size={24} /> <FaStepBackward size={24} />
</button> </button>
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onPlayPause}> <button className="text-white hover:text-violet-300 transition-colors" onClick={props.onPlayPause}>
{props.isPlaying ? <FaPause size={24} /> : <FaPlay size={24} />} {props.isPlaying ? <FaPause size={24} /> : <FaPlay size={24} />}
</button> </button>
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onStepForward}> <button className="text-white hover:text-violet-300 transition-colors" onClick={props.onSkip}>
<FaStepForward size={24} /> <FaStepForward size={24} />
</button> </button>
</div> </div>

View File

@@ -1,13 +1,22 @@
import classNames from 'classnames';
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { FaPlay } from 'react-icons/fa'; import { FaPlay, FaVolumeUp, FaVolumeOff } from 'react-icons/fa';
import { PlaylistItem } from '../api/player';
interface SongRowProps { export enum PlayState {
song: string; NotPlaying,
Playing,
Paused,
}
export interface SongRowProps {
song: PlaylistItem;
playState: PlayState;
onDelete: () => void; onDelete: () => void;
onPlay: () => void; onPlay: () => void;
} }
const SongRow: React.FC<SongRowProps> = ({ song, onDelete, onPlay }) => { const SongRow: React.FC<SongRowProps> = ({ song, playState, onDelete, onPlay }) => {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null); const buttonRef = useRef<HTMLButtonElement>(null);
@@ -28,20 +37,40 @@ const SongRow: React.FC<SongRowProps> = ({ song, onDelete, onPlay }) => {
}, [showDeleteConfirm]); }, [showDeleteConfirm]);
return ( return (
<div className="flex flex-row w-full h-[100px] bg-black/40 px-2 py-5 items-center border-b gap-2 hover:bg-black/50 transition-colors"> <div className={classNames("flex flex-row w-full h-[100px] px-2 py-5 items-center border-b gap-2 transition-colors", {
"bg-black/10": (playState === PlayState.Playing || playState === PlayState.Paused),
"bg-black/30": playState === PlayState.NotPlaying,
})}>
<div className="flex flex-row gap-2"> <div className="flex flex-row gap-2">
<button <button
className="text-white/40 hover:text-white transition-colors px-3 py-1 bg-white/10 rounded" className="text-white/40 hover:text-white transition-colors px-3 py-1 rounded"
onClick={onPlay} onClick={onPlay}
> >
<FaPlay size={12} /> {
playState === PlayState.Playing ? <FaVolumeUp size={12} />
: playState === PlayState.Paused ? <FaVolumeOff size={12} />
: <FaPlay size={12} />
}
</button> </button>
</div> </div>
<div className="flex-grow min-w-0"> <div className="flex-grow min-w-0">
<div className="text-white text-md truncate"> {
{song} song.title ? (
<div>
<div className="text-white text-md truncate text-bold">
{song.title}
</div> </div>
<div className="text-white/80 text-xs truncate">
{song.filename}
</div>
</div>
) : (
<div className="text-white text-md truncate text-bold">
{song.filename}
</div>
)
}
</div> </div>
<div className="flex flex-row gap-2"> <div className="flex flex-row gap-2">

View File

@@ -1,19 +1,35 @@
import React from "react"; import React, { useEffect, useRef } from "react";
import SongRow from "./SongRow"; import SongRow, { PlayState } from "./SongRow";
import { PlaylistItem } from "../api/player";
interface SongTableProps { interface SongTableProps {
songs: string[]; songs: PlaylistItem[];
isPlaying: boolean;
onDelete: (index: number) => void; onDelete: (index: number) => void;
onSkipTo: (index: number) => void; onSkipTo: (index: number) => void;
} }
const SongTable: React.FC<SongTableProps> = ({ songs, onDelete, onSkipTo }) => { const SongTable: React.FC<SongTableProps> = ({ songs, isPlaying, onDelete, onSkipTo }) => {
const nowPlayingIndex = songs.findIndex(song => song.playing ?? false);
const songTableRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const songTable = songTableRef.current;
if (songTable) {
songTable.scrollTop = nowPlayingIndex * 100;
}
}, [nowPlayingIndex]);
return ( return (
<div className="flex flex-col w-full h-full overflow-y-auto border-y"> <div className="flex flex-col w-full h-full overflow-y-auto border-y" ref={songTableRef}>
{songs.map((song, index) => ( {songs.map((song, index) => (
<SongRow <SongRow
key={index} key={index}
song={song} song={song}
playState={
(song.playing ?? false) ? (isPlaying ? PlayState.Playing : PlayState.Paused)
: PlayState.NotPlaying
}
onDelete={() => onDelete(index)} onDelete={() => onDelete(index)}
onPlay={() => onSkipTo(index)} onPlay={() => onSkipTo(index)}
/> />

View File

@@ -10,7 +10,8 @@ export default defineConfig({
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:3000', target: 'http://localhost:3000',
changeOrigin: true changeOrigin: true,
ws: true
} }
} }
} }

View File

@@ -96,6 +96,10 @@ export class MediaPlayer {
return this.modify(() => this.writeCommand("playlist-next", [])); return this.modify(() => this.writeCommand("playlist-next", []));
} }
public async skipTo(index: number) {
return this.modify(() => this.writeCommand("playlist-play-index", [index]));
}
public async previous() { public async previous() {
return this.modify(() => this.writeCommand("playlist-prev", [])); return this.modify(() => this.writeCommand("playlist-prev", []));
} }

View File

@@ -51,6 +51,12 @@ apiRouter.post("/skip", withErrorHandling(async (req, res) => {
res.send(JSON.stringify({ success: true })); res.send(JSON.stringify({ success: true }));
})); }));
apiRouter.post("/skip/:index", withErrorHandling(async (req, res) => {
const { index } = req.params as { index: string };
await mediaPlayer.skipTo(parseInt(index));
res.send(JSON.stringify({ success: true }));
}));
apiRouter.post("/previous", withErrorHandling(async (req, res) => { apiRouter.post("/previous", withErrorHandling(async (req, res) => {
await mediaPlayer.previous(); await mediaPlayer.previous();
res.send(JSON.stringify({ success: true })); res.send(JSON.stringify({ success: true }));