fix: polish player controls and remove fake track stats

This commit is contained in:
2026-04-03 02:27:24 +03:00
parent d7e21956db
commit db6e2818c1
8 changed files with 253 additions and 39 deletions

View File

@@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query'
import { ChevronDown, ListMusic, Pause, Play, Repeat2, Rewind, Shuffle, SkipForward, Trash2, Volume2 } from 'lucide-react'
import { ChevronDown, ListMusic, Pause, Play, Repeat2, Shuffle, SkipBack, SkipForward, Trash2, Volume2 } from 'lucide-react'
import { useMemo, useState } from 'react'
import { FavoriteToggle } from '@/components/favorite-toggle'
import { coverArtUrl, fetchFavorites } from '@/lib/api'
@@ -188,7 +188,7 @@ export function FullPlayer() {
<div className="flex items-center gap-8 text-white/90">
<IconControl active={shuffle} icon={<Shuffle size={22} />} onClick={toggleShuffle} />
<IconControl icon={<Rewind size={22} />} onClick={playPrevious} />
<IconControl icon={<SkipBack 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>

View File

@@ -1,16 +1,16 @@
import { useEffect, useRef } from 'react'
import {
Expand,
Forward,
ListMusic,
Pause,
Play,
Repeat2,
Rewind,
SkipBack,
SkipForward,
Shuffle,
Volume2,
} from 'lucide-react'
import { scrobbleTrack, streamUrl } from '@/lib/api'
import { coverArtUrl, scrobbleTrack, streamUrl } from '@/lib/api'
import { usePlayerStore } from '@/stores/player-store'
export function PlayerBar() {
@@ -39,15 +39,22 @@ export function PlayerBar() {
const setFullPlayerOpen = usePlayerStore((state) => state.setFullPlayerOpen)
useEffect(() => {
if (!audioRef.current || !currentTrack) {
if (!audioRef.current) {
return
}
if (!currentTrack) {
audioRef.current.pause()
audioRef.current.removeAttribute('src')
audioRef.current.load()
return
}
audioRef.current.src = streamUrl(currentTrack.id)
audioRef.current.currentTime = 0
setCurrentTime(0)
setDuration(0)
lastStartedTrackRef.current = null
lastSubmittedTrackRef.current = null
if (isPlaying) {
void audioRef.current.play().catch(() => {})
}
}, [currentTrack, isPlaying])
}, [currentTrack, setCurrentTime, setDuration])
useEffect(() => {
if (!audioRef.current) {
@@ -96,6 +103,12 @@ export function PlayerBar() {
clientName: 'temporserv-web',
}).catch(() => {})
}
if (repeatMode === 'one' && audioRef.current) {
audioRef.current.currentTime = 0
setCurrentTime(0)
void audioRef.current.play().catch(() => {})
return
}
handleTrackEnded()
}}
onLoadedMetadata={(event) => setDuration(event.currentTarget.duration || 0)}
@@ -122,8 +135,12 @@ export function PlayerBar() {
/>
<div className="flex min-w-0 items-center gap-3">
<div className="grid h-[68px] w-[68px] place-items-center rounded-[8px] bg-[#1b2638]">
<div className="h-7 w-7 rounded-full border-l-2 border-r-2 border-[#f1f5fb]" />
<div className="grid h-[68px] w-[68px] place-items-center overflow-hidden rounded-[8px] bg-[#1b2638]">
{currentTrack?.coverArtId ? (
<img alt={currentTrack.title} className="h-full w-full object-cover" src={coverArtUrl(currentTrack.id)} />
) : (
<div className="h-7 w-7 rounded-full border-l-2 border-r-2 border-[#f1f5fb]" />
)}
</div>
<div className="min-w-0">
<div className="line-clamp-1 text-[1.05rem] font-medium text-white">
@@ -136,7 +153,7 @@ export function PlayerBar() {
<div className="flex flex-col items-center">
<div className="flex items-center gap-5 text-slate-400">
<BarIcon active={shuffle} icon={<Shuffle size={18} />} onClick={toggleShuffle} />
<BarIcon icon={<Rewind size={18} />} onClick={playPrevious} />
<BarIcon icon={<SkipBack size={18} />} onClick={playPrevious} />
<button
className="grid h-11 w-11 place-items-center rounded-full bg-[#16bf8c] text-[#081225] transition hover:brightness-105"
onClick={togglePlayback}
@@ -144,7 +161,7 @@ export function PlayerBar() {
>
{isPlaying ? <Pause size={18} /> : <Play size={18} className="translate-x-[1px]" />}
</button>
<BarIcon icon={<Forward size={18} />} onClick={playNext} />
<BarIcon icon={<SkipForward size={18} />} onClick={playNext} />
<BarIcon active={repeatMode !== 'off'} icon={<Repeat2 size={18} />} label={repeatMode === 'one' ? '1' : undefined} onClick={cycleRepeatMode} />
</div>

View File

@@ -32,7 +32,10 @@ export type Track = {
albumTitle: string
trackNumber: number
durationSeconds: number
contentType?: string
coverArtId?: string
playCount?: number
lastPlayedAt?: string
}
export type ArtistDetail = Artist & {

View File

@@ -3,7 +3,7 @@ import { ErrorPanel, LoadingPanel } from '@/components/query-state'
import { MoreVertical, Play, Shuffle } from 'lucide-react'
import { FavoriteToggle } from '@/components/favorite-toggle'
import { useParams } from 'react-router-dom'
import { coverArtUrl, fetchAlbum, fetchFavorites } from '@/lib/api'
import { type Track, coverArtUrl, fetchAlbum, fetchFavorites } from '@/lib/api'
import { usePlayerStore } from '@/stores/player-store'
export function AlbumDetailPage() {
@@ -53,7 +53,7 @@ export function AlbumDetailPage() {
<span></span>
<span>{album.trackCount} треков</span>
<span></span>
<span>около {formatLongDuration(totalDuration)}</span>
<span>{formatLongDuration(totalDuration)}</span>
</div>
</div>
</div>
@@ -101,11 +101,11 @@ export function AlbumDetailPage() {
</div>
</div>
<div className="text-base text-slate-200">{formatDuration(track.durationSeconds)}</div>
<div className="text-base text-slate-400">1</div>
<div className="text-base text-slate-400">недавно</div>
<div className="text-base text-slate-200">935 kbps</div>
<div className="text-base text-slate-400">{track.playCount ?? 0}</div>
<div className="text-base text-slate-400">{formatLastPlayed(track.lastPlayedAt)}</div>
<div className="text-base text-slate-200"></div>
<div>
<span className="rounded-full bg-[#38455d] px-3 py-1 text-sm font-semibold text-white">FLAC</span>
<span className="rounded-full bg-[#38455d] px-3 py-1 text-sm font-semibold text-white">{formatQuality(track)}</span>
</div>
<div className="grid place-items-center text-slate-500">
<FavoriteToggle active={favoriteTrackIds.has(track.id)} entityId={track.id} entityType="track" size={16} />
@@ -119,14 +119,71 @@ export function AlbumDetailPage() {
}
function formatDuration(value: number) {
if (!value) {
return '—'
}
const minutes = Math.floor(value / 60)
const seconds = value % 60
return `${minutes}:${seconds.toString().padStart(2, '0')}`
}
function formatLongDuration(value: number) {
if (!value) {
return 'длительность неизвестна'
}
const minutes = Math.floor(value / 60)
const hours = Math.floor(minutes / 60)
const restMinutes = minutes % 60
return `${hours ? `${hours} ч ` : ''}${restMinutes} мин`
return `около ${hours ? `${hours} ч ` : ''}${restMinutes} мин`
}
function formatLastPlayed(value?: string) {
if (!value) {
return '—'
}
const playedAt = new Date(value)
if (Number.isNaN(playedAt.getTime())) {
return '—'
}
const diffMs = Date.now() - playedAt.getTime()
const diffMinutes = Math.floor(diffMs / 60000)
if (diffMinutes < 1) {
return 'только что'
}
if (diffMinutes < 60) {
return `${diffMinutes} мин назад`
}
const diffHours = Math.floor(diffMinutes / 60)
if (diffHours < 24) {
return `${diffHours} ч назад`
}
const diffDays = Math.floor(diffHours / 24)
if (diffDays < 30) {
return `${diffDays} дн назад`
}
const diffMonths = Math.floor(diffDays / 30)
return `${diffMonths} мес назад`
}
function formatQuality(track: Track) {
const contentType = (track.contentType ?? '').toLowerCase()
if (contentType.includes('flac')) {
return 'FLAC'
}
if (contentType.includes('mpeg')) {
return 'MP3'
}
if (contentType.includes('mp4')) {
return 'M4A'
}
if (contentType.includes('ogg')) {
return 'OGG'
}
if (contentType.includes('wav')) {
return 'WAV'
}
if (contentType.includes('aac')) {
return 'AAC'
}
return 'AUDIO'
}

View File

@@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query'
import { ErrorPanel, LoadingPanel } from '@/components/query-state'
import { Search } from 'lucide-react'
import { FavoriteToggle } from '@/components/favorite-toggle'
import { coverArtUrl, fetchFavorites, fetchTracks } from '@/lib/api'
import { type Track, coverArtUrl, fetchFavorites, fetchTracks } from '@/lib/api'
import { useNavigate } from 'react-router-dom'
import { usePlayerStore } from '@/stores/player-store'
@@ -69,10 +69,10 @@ export function TracksPage() {
</div>
<div className="truncate text-base text-slate-300">{track.albumTitle}</div>
<div className="text-base text-slate-200">{formatDuration(track.durationSeconds)}</div>
<div className="text-base text-slate-400">1</div>
<div className="text-base text-slate-400">1 месяц назад</div>
<div className="text-base text-slate-400">{track.playCount ?? 0}</div>
<div className="text-base text-slate-400">{formatLastPlayed(track.lastPlayedAt)}</div>
<div>
<span className="rounded-full bg-[#38455d] px-3 py-1 text-sm font-semibold text-white">FLAC</span>
<span className="rounded-full bg-[#38455d] px-3 py-1 text-sm font-semibold text-white">{formatQuality(track)}</span>
</div>
<div className="grid place-items-center text-slate-500">
<FavoriteToggle active={favoriteTrackIds.has(track.id)} entityId={track.id} entityType="track" size={16} />
@@ -96,7 +96,61 @@ function HeaderSearch({ onClick }: { onClick: () => void }) {
}
function formatDuration(durationSeconds: number) {
if (!durationSeconds) {
return '—'
}
const minutes = Math.floor(durationSeconds / 60)
const seconds = durationSeconds % 60
return `${minutes}:${seconds.toString().padStart(2, '0')}`
}
function formatLastPlayed(value?: string) {
if (!value) {
return '—'
}
const playedAt = new Date(value)
if (Number.isNaN(playedAt.getTime())) {
return '—'
}
const diffMs = Date.now() - playedAt.getTime()
const diffMinutes = Math.floor(diffMs / 60000)
if (diffMinutes < 1) {
return 'только что'
}
if (diffMinutes < 60) {
return `${diffMinutes} мин назад`
}
const diffHours = Math.floor(diffMinutes / 60)
if (diffHours < 24) {
return `${diffHours} ч назад`
}
const diffDays = Math.floor(diffHours / 24)
if (diffDays < 30) {
return `${diffDays} дн назад`
}
const diffMonths = Math.floor(diffDays / 30)
return `${diffMonths} мес назад`
}
function formatQuality(track: Track) {
const contentType = (track.contentType ?? '').toLowerCase()
if (contentType.includes('flac')) {
return 'FLAC'
}
if (contentType.includes('mpeg')) {
return 'MP3'
}
if (contentType.includes('mp4')) {
return 'M4A'
}
if (contentType.includes('ogg')) {
return 'OGG'
}
if (contentType.includes('wav')) {
return 'WAV'
}
if (contentType.includes('aac')) {
return 'AAC'
}
return 'AUDIO'
}

View File

@@ -47,6 +47,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
currentTrack: queue[startIndex] ?? null,
isPlaying: queue.length > 0,
currentTime: 0,
duration: 0,
}),
playTrack: (currentTrack, queue) =>
set((state) => ({
@@ -54,6 +55,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
queue: queue ?? state.queue,
isPlaying: true,
currentTime: 0,
duration: 0,
})),
togglePlayback: () => set((state) => ({ isPlaying: !state.isPlaying })),
playNext: () =>
@@ -61,26 +63,20 @@ 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 currentIndex = index >= 0 ? index : 0
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)
nextTrack = state.queue[currentIndex + 1] ?? (state.repeatMode === 'all' ? state.queue[0] ?? null : null)
}
return {
currentTrack: nextTrack,
isPlaying: !!nextTrack,
currentTime: 0,
duration: 0,
}
}),
playPrevious: () =>
@@ -89,11 +85,13 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
return state
}
const index = state.queue.findIndex((track) => track.id === state.currentTrack?.id)
const previousTrack = state.queue[index - 1] ?? state.queue[state.queue.length - 1] ?? null
const currentIndex = index >= 0 ? index : 0
const previousTrack = state.queue[currentIndex - 1] ?? state.queue[state.queue.length - 1] ?? null
return {
currentTrack: previousTrack,
isPlaying: !!previousTrack,
currentTime: 0,
duration: 0,
}
}),
playAtIndex: (index) =>
@@ -101,6 +99,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
currentTrack: state.queue[index] ?? state.currentTrack,
isPlaying: !!state.queue[index],
currentTime: 0,
duration: state.queue[index] ? 0 : state.duration,
})),
removeFromQueue: (trackId) =>
set((state) => {
@@ -125,10 +124,6 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
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 }),