feat: improve player controls and queue actions

This commit is contained in:
2026-04-03 01:42:33 +03:00
parent 3abc864abd
commit 62ab2a9417
3 changed files with 146 additions and 22 deletions

View File

@@ -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>
)
}