frontend: Finish interactivity
This commit is contained in:
82
frontend/src/api/player.tsx
Normal file
82
frontend/src/api/player.tsx
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user