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 (
|
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"
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
<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>
|
||||||
|
|
||||||
<div className="flex flex-row gap-2">
|
<div className="flex flex-row gap-2">
|
||||||
|
|||||||
@@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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", []));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }));
|
||||||
|
|||||||
Reference in New Issue
Block a user