Add Audio player
This commit is contained in:
parent
ef61a96440
commit
2146def609
5 changed files with 110 additions and 24 deletions
78
src/App.tsx
78
src/App.tsx
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue