diff --git a/frontend/src/api/player.tsx b/frontend/src/api/player.tsx new file mode 100644 index 0000000..2a69e12 --- /dev/null +++ b/frontend/src/api/player.tsx @@ -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 { + const response = await fetch('/api/playlist'); + return response.json(); + }, + + async addToPlaylist(url: string): Promise { + await fetch('/api/playlist', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ url }), + }); + }, + + async removeFromPlaylist(index: number): Promise { + await fetch(`/api/playlist/${index}`, { + method: 'DELETE', + }); + }, + + async play(): Promise { + await fetch('/api/play', { method: 'POST' }); + }, + + async pause(): Promise { + await fetch('/api/pause', { method: 'POST' }); + }, + + async skip(): Promise { + await fetch('/api/skip', { method: 'POST' }); + }, + + async skipTo(index: number): Promise { + await fetch(`/api/skip/${index}`, { method: 'POST' }); + }, + + async previous(): Promise { + await fetch('/api/previous', { method: 'POST' }); + }, + + async getNowPlaying(): Promise { + const response = await fetch('/api/nowplaying'); + return response.json(); + }, + + async setVolume(volume: number): Promise { + 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; + } +}; diff --git a/frontend/src/components/AddSongPanel.tsx b/frontend/src/components/AddSongPanel.tsx index f6517b8..a33541d 100644 --- a/frontend/src/components/AddSongPanel.tsx +++ b/frontend/src/components/AddSongPanel.tsx @@ -13,7 +13,7 @@ const AddSongPanel: React.FC = ({ onAddURL }) => { } return ( -
+
{ const [isPlaying, setIsPlaying] = useState(false); const [nowPlayingSong, setNowPlayingSong] = useState(null); const [nowPlayingFileName, setNowPlayingFileName] = useState(null); - const [songs, setSongs] = useState([]); + const [songs, setSongs] = useState([]); + const [ws, setWs] = useState(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 (
@@ -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 ? ( diff --git a/frontend/src/components/NowPlaying.tsx b/frontend/src/components/NowPlaying.tsx index de42757..69fe70f 100644 --- a/frontend/src/components/NowPlaying.tsx +++ b/frontend/src/components/NowPlaying.tsx @@ -7,27 +7,27 @@ interface NowPlayingProps extends HTMLAttributes { fileName: string; isPlaying: boolean; onPlayPause: () => void; - onStepForward: () => void; - onStepBackward: () => void; + onSkip: () => void; + onPrevious: () => void; } const NowPlaying: React.FC = (props) => { return ( -
+
-
-
{props.songName}
-
{props.fileName}
+
+
{props.songName}
+
{props.fileName}
- -
diff --git a/frontend/src/components/SongRow.tsx b/frontend/src/components/SongRow.tsx index d01060e..a01002b 100644 --- a/frontend/src/components/SongRow.tsx +++ b/frontend/src/components/SongRow.tsx @@ -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 = ({ song, onDelete, onPlay }) => { +const SongRow: React.FC = ({ song, playState, onDelete, onPlay }) => { const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const buttonRef = useRef(null); @@ -28,20 +37,40 @@ const SongRow: React.FC = ({ song, onDelete, onPlay }) => { }, [showDeleteConfirm]); return ( -
+
-
- {song} -
+ { + song.title ? ( +
+
+ {song.title} +
+
+ {song.filename} +
+
+ ) : ( +
+ {song.filename} +
+ ) + }
diff --git a/frontend/src/components/SongTable.tsx b/frontend/src/components/SongTable.tsx index a3dc955..7113068 100644 --- a/frontend/src/components/SongTable.tsx +++ b/frontend/src/components/SongTable.tsx @@ -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 = ({ songs, onDelete, onSkipTo }) => { +const SongTable: React.FC = ({ songs, isPlaying, onDelete, onSkipTo }) => { + const nowPlayingIndex = songs.findIndex(song => song.playing ?? false); + const songTableRef = useRef(null); + + useEffect(() => { + const songTable = songTableRef.current; + if (songTable) { + songTable.scrollTop = nowPlayingIndex * 100; + } + }, [nowPlayingIndex]); + return ( -
+
{songs.map((song, index) => ( onDelete(index)} onPlay={() => onSkipTo(index)} /> diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 13e8cc5..154ca4b 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -10,7 +10,8 @@ export default defineConfig({ proxy: { '/api': { target: 'http://localhost:3000', - changeOrigin: true + changeOrigin: true, + ws: true } } } diff --git a/src/MediaPlayer.ts b/src/MediaPlayer.ts index 5a3de57..bde8e8a 100644 --- a/src/MediaPlayer.ts +++ b/src/MediaPlayer.ts @@ -96,6 +96,10 @@ export class MediaPlayer { return this.modify(() => this.writeCommand("playlist-next", [])); } + public async skipTo(index: number) { + return this.modify(() => this.writeCommand("playlist-play-index", [index])); + } + public async previous() { return this.modify(() => this.writeCommand("playlist-prev", [])); } diff --git a/src/server.ts b/src/server.ts index 278dd94..2a1e563 100644 --- a/src/server.ts +++ b/src/server.ts @@ -51,6 +51,12 @@ apiRouter.post("/skip", withErrorHandling(async (req, res) => { 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) => { await mediaPlayer.previous(); res.send(JSON.stringify({ success: true }));