feat: improve player controls and queue actions
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ChevronDown, Heart, ListMusic, Pause, Play, Repeat2, Rewind, Shuffle, SkipForward, Volume2 } from 'lucide-react'
|
||||
import { ChevronDown, ListMusic, Pause, Play, Repeat2, Rewind, Shuffle, SkipForward, Trash2, Volume2 } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { coverArtUrl } from '@/lib/api'
|
||||
import { FavoriteToggle } from '@/components/favorite-toggle'
|
||||
import { coverArtUrl, fetchFavorites } from '@/lib/api'
|
||||
import { usePlayerStore } from '@/stores/player-store'
|
||||
|
||||
type LyricsLine = {
|
||||
@@ -16,12 +17,24 @@ export function FullPlayer() {
|
||||
const currentTime = usePlayerStore((state) => state.currentTime)
|
||||
const duration = usePlayerStore((state) => state.duration)
|
||||
const volume = usePlayerStore((state) => state.volume)
|
||||
const shuffle = usePlayerStore((state) => state.shuffle)
|
||||
const repeatMode = usePlayerStore((state) => state.repeatMode)
|
||||
const togglePlayback = usePlayerStore((state) => state.togglePlayback)
|
||||
const playNext = usePlayerStore((state) => state.playNext)
|
||||
const playPrevious = usePlayerStore((state) => state.playPrevious)
|
||||
const playAtIndex = usePlayerStore((state) => state.playAtIndex)
|
||||
const removeFromQueue = usePlayerStore((state) => state.removeFromQueue)
|
||||
const toggleShuffle = usePlayerStore((state) => state.toggleShuffle)
|
||||
const cycleRepeatMode = usePlayerStore((state) => state.cycleRepeatMode)
|
||||
const setVolume = usePlayerStore((state) => state.setVolume)
|
||||
const seekTo = usePlayerStore((state) => state.seekTo)
|
||||
const setFullPlayerOpen = usePlayerStore((state) => state.setFullPlayerOpen)
|
||||
const [tab, setTab] = useState<'queue' | 'now' | 'lyrics'>('now')
|
||||
const favoritesQuery = useQuery({
|
||||
queryKey: ['favorites'],
|
||||
queryFn: fetchFavorites,
|
||||
enabled: !!currentTrack,
|
||||
})
|
||||
|
||||
const lyricsQuery = useQuery({
|
||||
queryKey: ['lrclib', currentTrack?.id],
|
||||
@@ -40,6 +53,7 @@ export function FullPlayer() {
|
||||
})
|
||||
|
||||
const parsedLyrics = useMemo(() => parseLyrics(lyricsQuery.data?.syncedLyrics ?? lyricsQuery.data?.plainLyrics ?? ''), [lyricsQuery.data])
|
||||
const favoriteTrackIds = useMemo(() => new Set((favoritesQuery.data?.tracks ?? []).map((item) => item.id)), [favoritesQuery.data])
|
||||
const activeLine = useMemo(() => {
|
||||
if (parsedLyrics.length === 0) {
|
||||
return -1
|
||||
@@ -113,22 +127,35 @@ export function FullPlayer() {
|
||||
{tab === 'queue' ? (
|
||||
<div className="mx-auto max-w-5xl space-y-3">
|
||||
{queue.map((track, index) => (
|
||||
<div
|
||||
<button
|
||||
key={`${track.id}-${index}`}
|
||||
className={[
|
||||
'flex items-center gap-4 rounded-[12px] px-4 py-3',
|
||||
'flex w-full items-center gap-4 rounded-[12px] px-4 py-3 text-left',
|
||||
track.id === currentTrack.id ? 'bg-white/10 text-white' : 'text-white/70',
|
||||
].join(' ')}
|
||||
onClick={() => playAtIndex(index)}
|
||||
type="button"
|
||||
>
|
||||
<div className="w-8 text-right text-sm">{index + 1}</div>
|
||||
<div className="h-12 w-12 overflow-hidden rounded-[8px] bg-white/10">
|
||||
{track.coverArtId ? <img alt={track.title} className="h-full w-full object-cover" src={coverArtUrl(track.id)} /> : null}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-lg">{track.title}</div>
|
||||
<div className="truncate text-sm text-white/55">{track.artistName}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="text-white/50 transition hover:text-white"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
removeFromQueue(track.id)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -137,9 +164,15 @@ export function FullPlayer() {
|
||||
<div className="mt-8">
|
||||
<div className="flex items-center gap-4 text-white/90">
|
||||
<span className="w-12 text-right text-[2rem]">{formatClock(currentTime)}</span>
|
||||
<div className="h-1.5 flex-1 rounded-full bg-white/20">
|
||||
<div className="h-1.5 rounded-full bg-white" style={{ width: `${duration ? (currentTime / duration) * 100 : 0}%` }} />
|
||||
</div>
|
||||
<input
|
||||
className="h-1.5 flex-1 accent-white"
|
||||
max={duration || 0}
|
||||
min={0}
|
||||
onChange={(event) => seekTo(Number(event.target.value))}
|
||||
step={0.1}
|
||||
type="range"
|
||||
value={Math.min(currentTime, duration || 0)}
|
||||
/>
|
||||
<span className="w-12 text-[2rem]">{formatClock(duration)}</span>
|
||||
</div>
|
||||
|
||||
@@ -154,17 +187,17 @@ export function FullPlayer() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-8 text-white/90">
|
||||
<IconControl icon={<Shuffle size={22} />} />
|
||||
<IconControl active={shuffle} icon={<Shuffle size={22} />} onClick={toggleShuffle} />
|
||||
<IconControl icon={<Rewind size={22} />} onClick={playPrevious} />
|
||||
<button className="grid h-16 w-16 place-items-center rounded-full bg-white text-[#121827]" onClick={togglePlayback} type="button">
|
||||
{isPlaying ? <Pause size={28} /> : <Play size={28} className="translate-x-[2px]" />}
|
||||
</button>
|
||||
<IconControl icon={<SkipForward size={22} />} onClick={playNext} />
|
||||
<IconControl icon={<Repeat2 size={22} />} />
|
||||
<IconControl active={repeatMode !== 'off'} icon={<Repeat2 size={22} />} label={repeatMode === 'one' ? '1' : undefined} onClick={cycleRepeatMode} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-6 text-white/90">
|
||||
<IconControl icon={<Heart size={22} />} />
|
||||
<FavoriteToggle active={favoriteTrackIds.has(currentTrack.id)} className="transition hover:text-white text-white/90" entityId={currentTrack.id} entityType="track" size={22} />
|
||||
<div className="flex items-center gap-3">
|
||||
<Volume2 size={22} />
|
||||
<input
|
||||
@@ -214,14 +247,19 @@ function MetaTag({ children }: { children: React.ReactNode }) {
|
||||
|
||||
function IconControl({
|
||||
icon,
|
||||
active = false,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
active?: boolean
|
||||
label?: string
|
||||
onClick?: () => void
|
||||
}) {
|
||||
return (
|
||||
<button className="transition hover:text-white" onClick={onClick} type="button">
|
||||
<button className={['relative transition hover:text-white', active ? 'text-[#16bf8c]' : ''].join(' ')} onClick={onClick} type="button">
|
||||
{icon}
|
||||
{label ? <span className="absolute -right-2 -top-2 text-[10px] font-semibold text-white">{label}</span> : null}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useEffect, useRef } from 'react'
|
||||
import {
|
||||
Expand,
|
||||
Forward,
|
||||
Heart,
|
||||
ListMusic,
|
||||
Pause,
|
||||
Play,
|
||||
@@ -21,12 +20,20 @@ export function PlayerBar() {
|
||||
const volume = usePlayerStore((state) => state.volume)
|
||||
const currentTime = usePlayerStore((state) => state.currentTime)
|
||||
const duration = usePlayerStore((state) => state.duration)
|
||||
const shuffle = usePlayerStore((state) => state.shuffle)
|
||||
const repeatMode = usePlayerStore((state) => state.repeatMode)
|
||||
const seekRequest = usePlayerStore((state) => state.seekRequest)
|
||||
const togglePlayback = usePlayerStore((state) => state.togglePlayback)
|
||||
const playNext = usePlayerStore((state) => state.playNext)
|
||||
const playPrevious = usePlayerStore((state) => state.playPrevious)
|
||||
const toggleShuffle = usePlayerStore((state) => state.toggleShuffle)
|
||||
const cycleRepeatMode = usePlayerStore((state) => state.cycleRepeatMode)
|
||||
const setVolume = usePlayerStore((state) => state.setVolume)
|
||||
const setCurrentTime = usePlayerStore((state) => state.setCurrentTime)
|
||||
const setDuration = usePlayerStore((state) => state.setDuration)
|
||||
const seekTo = usePlayerStore((state) => state.seekTo)
|
||||
const clearSeekRequest = usePlayerStore((state) => state.clearSeekRequest)
|
||||
const handleTrackEnded = usePlayerStore((state) => state.handleTrackEnded)
|
||||
const setFullPlayerOpen = usePlayerStore((state) => state.setFullPlayerOpen)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -51,10 +58,19 @@ export function PlayerBar() {
|
||||
}
|
||||
}, [isPlaying, volume])
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioRef.current || seekRequest == null) {
|
||||
return
|
||||
}
|
||||
audioRef.current.currentTime = seekRequest
|
||||
clearSeekRequest()
|
||||
}, [seekRequest, clearSeekRequest])
|
||||
|
||||
return (
|
||||
<footer className="grid grid-cols-[260px_minmax(0,1fr)_280px] items-center border-t border-[#24314f] bg-[#091228] px-4 py-3">
|
||||
<audio
|
||||
ref={audioRef}
|
||||
onEnded={handleTrackEnded}
|
||||
onLoadedMetadata={(event) => setDuration(event.currentTarget.duration || 0)}
|
||||
onTimeUpdate={(event) => setCurrentTime(event.currentTarget.currentTime)}
|
||||
preload="metadata"
|
||||
@@ -74,7 +90,7 @@ export function PlayerBar() {
|
||||
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex items-center gap-5 text-slate-400">
|
||||
<BarIcon icon={<Shuffle size={18} />} />
|
||||
<BarIcon active={shuffle} icon={<Shuffle size={18} />} onClick={toggleShuffle} />
|
||||
<BarIcon icon={<Rewind size={18} />} onClick={playPrevious} />
|
||||
<button
|
||||
className="grid h-11 w-11 place-items-center rounded-full bg-[#16bf8c] text-[#081225] transition hover:brightness-105"
|
||||
@@ -84,20 +100,25 @@ export function PlayerBar() {
|
||||
{isPlaying ? <Pause size={18} /> : <Play size={18} className="translate-x-[1px]" />}
|
||||
</button>
|
||||
<BarIcon icon={<Forward size={18} />} onClick={playNext} />
|
||||
<BarIcon icon={<Repeat2 size={18} />} />
|
||||
<BarIcon active={repeatMode !== 'off'} icon={<Repeat2 size={18} />} label={repeatMode === 'one' ? '1' : undefined} onClick={cycleRepeatMode} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex w-full max-w-xl items-center gap-3 text-xs text-slate-500">
|
||||
<span>{formatClock(currentTime)}</span>
|
||||
<div className="h-1.5 flex-1 rounded-full bg-[#1d2940]">
|
||||
<div className="h-1.5 rounded-full bg-[#16bf8c]" style={{ width: `${duration ? (currentTime / duration) * 100 : 0}%` }} />
|
||||
</div>
|
||||
<input
|
||||
className="h-1.5 flex-1 accent-[#16bf8c]"
|
||||
max={duration || 0}
|
||||
min={0}
|
||||
onChange={(event) => seekTo(Number(event.target.value))}
|
||||
step={0.1}
|
||||
type="range"
|
||||
value={Math.min(currentTime, duration || 0)}
|
||||
/>
|
||||
<span>{formatClock(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-4 text-slate-400">
|
||||
<BarIcon icon={<Heart size={17} />} />
|
||||
<BarIcon icon={<ListMusic size={17} />} />
|
||||
<BarIcon icon={<Volume2 size={17} />} />
|
||||
<input
|
||||
@@ -117,14 +138,19 @@ export function PlayerBar() {
|
||||
|
||||
function BarIcon({
|
||||
icon,
|
||||
active = false,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
active?: boolean
|
||||
label?: string
|
||||
onClick?: () => void
|
||||
}) {
|
||||
return (
|
||||
<button className="transition hover:text-white" onClick={onClick} type="button">
|
||||
<button className={['relative transition hover:text-white', active ? 'text-[#16bf8c]' : ''].join(' ')} onClick={onClick} type="button">
|
||||
{icon}
|
||||
{label ? <span className="absolute -right-2 -top-2 text-[10px] font-semibold text-white">{label}</span> : null}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,15 +8,25 @@ type PlayerState = {
|
||||
volume: number
|
||||
currentTime: number
|
||||
duration: number
|
||||
shuffle: boolean
|
||||
repeatMode: 'off' | 'all' | 'one'
|
||||
fullPlayerOpen: boolean
|
||||
seekRequest: number | null
|
||||
setQueue: (tracks: Track[], startIndex?: number) => void
|
||||
playTrack: (track: Track, queue?: Track[]) => void
|
||||
togglePlayback: () => void
|
||||
playNext: () => void
|
||||
playPrevious: () => void
|
||||
playAtIndex: (index: number) => void
|
||||
removeFromQueue: (trackId: string) => void
|
||||
toggleShuffle: () => void
|
||||
cycleRepeatMode: () => void
|
||||
setVolume: (volume: number) => void
|
||||
setCurrentTime: (currentTime: number) => void
|
||||
setDuration: (duration: number) => void
|
||||
seekTo: (currentTime: number) => void
|
||||
clearSeekRequest: () => void
|
||||
handleTrackEnded: () => void
|
||||
setFullPlayerOpen: (fullPlayerOpen: boolean) => void
|
||||
}
|
||||
|
||||
@@ -27,7 +37,10 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
volume: 0.7,
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
shuffle: false,
|
||||
repeatMode: 'off',
|
||||
fullPlayerOpen: false,
|
||||
seekRequest: null,
|
||||
setQueue: (queue, startIndex = 0) =>
|
||||
set({
|
||||
queue,
|
||||
@@ -48,11 +61,26 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
if (!state.currentTrack || state.queue.length === 0) {
|
||||
return state
|
||||
}
|
||||
if (state.repeatMode === 'one') {
|
||||
return {
|
||||
currentTrack: state.currentTrack,
|
||||
isPlaying: true,
|
||||
currentTime: 0,
|
||||
seekRequest: 0,
|
||||
}
|
||||
}
|
||||
const index = state.queue.findIndex((track) => track.id === state.currentTrack?.id)
|
||||
const nextTrack = state.queue[index + 1] ?? state.queue[0] ?? null
|
||||
let nextTrack: Track | null = null
|
||||
if (state.shuffle && state.queue.length > 1) {
|
||||
const candidates = state.queue.filter((track) => track.id !== state.currentTrack?.id)
|
||||
nextTrack = candidates[Math.floor(Math.random() * candidates.length)] ?? state.queue[0] ?? null
|
||||
} else {
|
||||
nextTrack = state.queue[index + 1] ?? (state.repeatMode === 'all' ? state.queue[0] ?? null : null)
|
||||
}
|
||||
return {
|
||||
currentTrack: nextTrack,
|
||||
isPlaying: !!nextTrack,
|
||||
currentTime: 0,
|
||||
}
|
||||
}),
|
||||
playPrevious: () =>
|
||||
@@ -68,8 +96,40 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
currentTime: 0,
|
||||
}
|
||||
}),
|
||||
playAtIndex: (index) =>
|
||||
set((state) => ({
|
||||
currentTrack: state.queue[index] ?? state.currentTrack,
|
||||
isPlaying: !!state.queue[index],
|
||||
currentTime: 0,
|
||||
})),
|
||||
removeFromQueue: (trackId) =>
|
||||
set((state) => {
|
||||
const nextQueue = state.queue.filter((track) => track.id !== trackId)
|
||||
const removedCurrent = state.currentTrack?.id === trackId
|
||||
const nextCurrent = removedCurrent ? nextQueue[0] ?? null : state.currentTrack
|
||||
return {
|
||||
queue: nextQueue,
|
||||
currentTrack: nextCurrent,
|
||||
isPlaying: removedCurrent ? !!nextCurrent : state.isPlaying,
|
||||
}
|
||||
}),
|
||||
toggleShuffle: () => set((state) => ({ shuffle: !state.shuffle })),
|
||||
cycleRepeatMode: () =>
|
||||
set((state) => ({
|
||||
repeatMode: state.repeatMode === 'off' ? 'all' : state.repeatMode === 'all' ? 'one' : 'off',
|
||||
})),
|
||||
setVolume: (volume) => set({ volume }),
|
||||
setCurrentTime: (currentTime) => set({ currentTime }),
|
||||
setDuration: (duration) => set({ duration }),
|
||||
seekTo: (currentTime) => set({ currentTime, seekRequest: currentTime }),
|
||||
clearSeekRequest: () => set({ seekRequest: null }),
|
||||
handleTrackEnded: () => {
|
||||
const state = get()
|
||||
if (state.repeatMode === 'one') {
|
||||
set({ currentTime: 0, seekRequest: 0, isPlaying: true })
|
||||
return
|
||||
}
|
||||
state.playNext()
|
||||
},
|
||||
setFullPlayerOpen: (fullPlayerOpen) => set({ fullPlayerOpen }),
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user