Add Audio player

This commit is contained in:
Chandler Swift 2026-02-10 23:00:00 -06:00
parent ef61a96440
commit 2146def609
Signed by: chandlerswift
GPG key ID: A851D929D52FB93F
5 changed files with 110 additions and 24 deletions

View file

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useEffect, useRef, useState } from 'react'
import { MusicBrainzApi, type ITrack } from 'musicbrainz-api';
import Album from "./components/Album/Album";
import AlbumArt from "./components/AlbumArt/AlbumArt";
@ -28,7 +28,7 @@ async function getCoverArtSrcURL(releaseId: string, releaseGroupId: string): Pro
continue;
}
const data = await res.json();
const front = (data.images || []).find(img => img.front) || data.images?.[0];
const front = (data.images || []).find((img: any) => img.front) || data.images?.[0];
if (front) {
return front.thumbnails?.["1200"]|| front.image;
}
@ -55,11 +55,48 @@ function navidromeURL(path: string, params: Record<string, string>): string {
function App() {
const [album, setAlbum] = useState<AlbumType | null>(null);
const [buffer, setBuffer] = useState<string[]>([]);
const [nowPlaying, play] = useState<Track | null>(null);
const [nowPlaying, play] = useState<number>(0);
const [searching, setSearching] = useState(false);
const [paused, setPaused] = useState(false);
const [paused, setPaused] = useState(true);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const audioRef = useRef<HTMLAudioElement | null>(null);
// Handle input
const handleEnded = () => {
if ((album?.tracks?.length ?? 0) > nowPlaying + 1) { // if there's a next track
play(nowPlaying + 1);
setPaused(false);
}
};
// When a track ends or is skipped, play the new track
useEffect(() => {
const audio = audioRef.current;
if (!audio) {
return;
}
audio.currentTime = 0;
audio.load();
setCurrentTime(0);
setDuration(0);
audio.play().catch(() => setPaused(true));
}, [nowPlaying]);
// Handle play/pause
useEffect(() => {
const audio = audioRef.current;
if (!audio) {
return;
}
if (paused) {
audio.pause();
} else {
audio.play().catch(() => setPaused(true));
}
}, [paused]);
// Handle keyboard/scanner input
useEffect(() => {
const handleKeyDown = (ev: KeyboardEvent) => {
if (ev.key === "s") { // TODO: remove
@ -119,7 +156,7 @@ function App() {
const ndRes = await fetch(navidromeURL('/rest/getAlbum.view', { id: ndFoundAlbum.id })).then(res => res.json());
const ndAlbum = ndRes["subsonic-response"].album;
console.log("Found album in Navidrome:", ndAlbum);
let tracks: PlayableTrack[] = ndAlbum.song?.map((t: any) => ({
let tracks: Track[] = ndAlbum.song?.map((t: any) => ({
title: t.title,
duration: t.duration,
id: t.id,
@ -132,10 +169,10 @@ function App() {
releaseDate: ndAlbum.originalReleaseDate??'',
tracks,
coverArtLink: navidromeURL('/rest/getCoverArt.view', { id: ndAlbum.coverArt }),
})
});
// # …and play!
// play(album.tracks[0]);
// TODO
play(0);
setPaused(false);
setSearching(false);
} else { // Not found in Navidrome; populate with MusicBrainz data instead
const fullMbRelease = await mbApi.lookup('release', mbRelease.id, ['recordings', 'artist-credits', 'release-groups']);
@ -150,7 +187,7 @@ function App() {
artistName: fullMbRelease['artist-credit']?.[0].name || '', // TODO
releaseDate: fullMbRelease.date || '',
tracks,
coverArtLink: await getCoverArtSrcURL(fullMbRelease.id, fullMbRelease['release-group'].id) || '',
coverArtLink: await getCoverArtSrcURL(fullMbRelease.id, fullMbRelease['release-group']?.id || '') || '',
})
console.log(album);
setSearching(false);
@ -160,13 +197,30 @@ function App() {
if (album) {
// If the Album is a PlayableAlbum
const player = album.tracks[0]?.source ? (
<NowPlaying nowPlaying={nowPlaying} paused={paused} setPaused={setPaused} />
<NowPlaying
currentTrack={album.tracks[nowPlaying]}
paused={paused}
currentTime={currentTime}
duration={duration}
setPaused={setPaused}
/>
) : (
<p className='welcome' style={{ gridColumn: 'span 2' }}>This album cannot be played, as Chandler doesn't own a copy.</p>
);
return (
<div className="app">
<Album album={album} dismissAlbum={() => setAlbum(null)} play={play} nowPlaying={nowPlaying} />
<audio
ref={audioRef}
src={album.tracks[nowPlaying].source}
preload="auto"
onTimeUpdate={ev => setCurrentTime(ev.currentTarget.currentTime)}
onLoadedMetadata={ev => {setDuration(ev.currentTarget.duration); setCurrentTime(ev.currentTarget.currentTime)}}
onEnded={handleEnded}
onPlay={() => setPaused(false)}
onPause={() => setPaused(true)}
style={{ display: "none" }}
/>
<Album album={album} dismissAlbum={() => setAlbum(null)} play={play} nowPlaying={album.tracks[nowPlaying]} />
<AlbumArt url={album.coverArtLink} />
{player}
</div>

View file

@ -1,8 +1,8 @@
export interface Album<TTrack extends Track = Track> {
export interface Album {
title: string;
artistName: string;
releaseDate: any; // TODO: Date;
tracks: TTrack[];
tracks: Track[];
coverArtLink: string;
}

View file

@ -2,7 +2,7 @@ import Track from "./Track";
import "./Album.css";
import type { Album, Track as TrackType } from "../../album";
export default function Album({ album, dismissAlbum, play, nowPlaying }: { album: Album; dismissAlbum: () => void, play: (track: TrackType) => void, nowPlaying: any }) {
export default function Album({ album, dismissAlbum, play, nowPlaying }: { album: Album; dismissAlbum: () => void, play: (i: number) => void, nowPlaying: TrackType }) {
return (
<section className="panel">
<div className="meta">
@ -14,8 +14,8 @@ export default function Album({ album, dismissAlbum, play, nowPlaying }: { album
{album.artistName} {album.releaseDate.year ? ' • ' + album.releaseDate.year : ''} {/* TODO: .toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) */}
</p>
<div className="queue" id="queue">
{album.tracks.map(t => (
<Track key={t.id} track={t} play={play} nowPlaying={nowPlaying} />
{album.tracks.map((t, i) => (
<Track key={t.id} idx={i} track={t} play={play} nowPlaying={nowPlaying} />
))}
</div>
</div>

View file

@ -1,8 +1,18 @@
import type { Track } from "../../album";
import css from "./Track.module.css";
export default function Track({track, play, nowPlaying}:{track:any, play: (track:Track) => void, nowPlaying:any}) {
const handler = track.source ? () => play(track) : undefined;
export default function Track({
track,
idx,
play,
nowPlaying,
}: {
track: Track;
idx: number;
play: (i: number) => void;
nowPlaying: Track | null;
}) {
const handler = track.source ? () => play(idx) : undefined;
return (
<>
<div style={{ cursor: track.source ? "pointer" : "default" }} className={`${css.track} ${nowPlaying?.id === track.id ? css.active : ""}`} onClick={handler}>

View file

@ -1,17 +1,39 @@
import type { Track } from '../../album';
import './NowPlaying.css';
export default function NowPlaying({ nowPlaying, paused, setPaused }: { nowPlaying: Track|null; paused: boolean; setPaused: (paused: boolean) => void }) {
const formatTime = (value: number) => {
const minutes = Math.floor(value / 60);
const seconds = Math.round(value) % 60;
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
};
export default function NowPlaying({
currentTrack,
paused,
currentTime,
duration,
setPaused,
}: {
currentTrack: Track;
paused: boolean;
currentTime: number;
duration: number;
setPaused: (p: boolean) => void;
}) {
return (
<div className="progress">
<button type="button" id="play-pause" onClick={() => setPaused(!paused)}>{paused ? "⏸" : "▶"} </button>
<button type="button" id="play-pause" onClick={() => setPaused(!paused)}>{paused ? "▶" : "⏸"} </button>
<div className="progress-body">
<div className="progress-label">
<span id="progress-track">{nowPlaying?.title??"—"}</span>
<span id="progress-time">0:00 / 0:00</span>
<span id="progress-track">{currentTrack?.title??"—"}</span>
<span id="progress-time">{formatTime(currentTime)} / {formatTime(duration)}</span>
</div>
<div className="progress-track">
<div className="progress-fill" id="progress-fill"></div>
<div
className="progress-fill"
id="progress-fill"
style={{ width: `${(duration > 0 ? Math.min(1, Math.max(0, currentTime / duration)) : 0) * 100}%` }}
></div>
</div>
</div>
</div>