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 (
<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">
<input
type="text"

View File

@@ -1,30 +1,103 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import SongTable from './SongTable';
import NowPlaying from './NowPlaying';
import AddSongPanel from './AddSongPanel';
import { API, PlaylistItem } from '../api/player';
const App: React.FC = () => {
const [isPlaying, setIsPlaying] = useState(false);
const [nowPlayingSong, setNowPlayingSong] = 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 urlToAdd = url.trim();
if (urlToAdd) {
setSongs([...songs, urlToAdd]);
API.addToPlaylist(urlToAdd);
fetchPlaylist();
}
};
const handleDelete = (index: number) => {
setSongs(songs.filter((_, i) => i !== index));
API.removeFromPlaylist(index);
fetchPlaylist();
fetchNowPlaying();
};
const handleSkipTo = (index: number) => {
setNowPlayingSong(songs[index]);
setNowPlayingFileName(songs[index].split('/').pop() || null);
setIsPlaying(true);
const handleSkipTo = async (index: number) => {
const song = songs[index];
if (song.playing) {
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 (
<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">
@@ -33,14 +106,15 @@ const App: React.FC = () => {
songName={nowPlayingSong || "(Not Playing)"}
fileName={nowPlayingFileName || ""}
isPlaying={isPlaying}
onPlayPause={() => setIsPlaying(!isPlaying)}
onStepForward={() => {}}
onStepBackward={() => {}}
onPlayPause={togglePlayPause}
onSkip={handleSkip}
onPrevious={handlePrevious}
/>
{songs.length > 0 ? (
<SongTable
songs={songs}
isPlaying={isPlaying}
onDelete={handleDelete}
onSkipTo={handleSkipTo}
/>

View File

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

View File

@@ -1,13 +1,22 @@
import classNames from 'classnames';
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 {
song: string;
export enum PlayState {
NotPlaying,
Playing,
Paused,
}
export interface SongRowProps {
song: PlaylistItem;
playState: PlayState;
onDelete: () => 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 buttonRef = useRef<HTMLButtonElement>(null);
@@ -28,20 +37,40 @@ const SongRow: React.FC<SongRowProps> = ({ song, onDelete, onPlay }) => {
}, [showDeleteConfirm]);
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">
<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}
>
<FaPlay size={12} />
{
playState === PlayState.Playing ? <FaVolumeUp size={12} />
: playState === PlayState.Paused ? <FaVolumeOff size={12} />
: <FaPlay size={12} />
}
</button>
</div>
<div className="flex-grow min-w-0">
<div className="text-white text-md truncate">
{song}
</div>
{
song.title ? (
<div>
<div className="text-white text-md truncate text-bold">
{song.title}
</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 className="flex flex-row gap-2">

View File

@@ -1,19 +1,35 @@
import React from "react";
import SongRow from "./SongRow";
import React, { useEffect, useRef } from "react";
import SongRow, { PlayState } from "./SongRow";
import { PlaylistItem } from "../api/player";
interface SongTableProps {
songs: string[];
songs: PlaylistItem[];
isPlaying: boolean;
onDelete: (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 (
<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) => (
<SongRow
key={index}
song={song}
playState={
(song.playing ?? false) ? (isPlaying ? PlayState.Playing : PlayState.Paused)
: PlayState.NotPlaying
}
onDelete={() => onDelete(index)}
onPlay={() => onSkipTo(index)}
/>

View File

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