157 lines
5.6 KiB
TypeScript
157 lines
5.6 KiB
TypeScript
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 { type Track, coverArtUrl, fetchFavorites, fetchTracks } from '@/lib/api'
|
||
import { useNavigate } from 'react-router-dom'
|
||
import { usePlayerStore } from '@/stores/player-store'
|
||
|
||
export function TracksPage() {
|
||
const navigate = useNavigate()
|
||
const setQueue = usePlayerStore((state) => state.setQueue)
|
||
const playTrack = usePlayerStore((state) => state.playTrack)
|
||
const tracksQuery = useQuery({
|
||
queryKey: ['tracks'],
|
||
queryFn: fetchTracks,
|
||
})
|
||
const favoritesQuery = useQuery({
|
||
queryKey: ['favorites'],
|
||
queryFn: fetchFavorites,
|
||
})
|
||
|
||
const tracks = tracksQuery.data?.items ?? []
|
||
const favoriteTrackIds = new Set((favoritesQuery.data?.tracks ?? []).map((track) => track.id))
|
||
|
||
if (tracksQuery.isLoading) {
|
||
return <LoadingPanel title="Загружаю треки" />
|
||
}
|
||
|
||
if (tracksQuery.isError) {
|
||
return <ErrorPanel onRetry={() => void tracksQuery.refetch()} title="Не получилось загрузить треки" />
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<HeaderSearch onClick={() => navigate('/search')} />
|
||
<div className="overflow-hidden rounded-[12px] border border-[#24314f] bg-[#121b2e]">
|
||
<div className="grid grid-cols-[56px_minmax(0,2.3fr)_minmax(0,1.4fr)_80px_140px_180px_120px_48px] gap-4 bg-[#202b3c] px-4 py-3 text-base text-slate-300">
|
||
<div>#</div>
|
||
<div>Название</div>
|
||
<div>Альбом</div>
|
||
<div>◷</div>
|
||
<div>Прослушивания</div>
|
||
<div>Прослушано последний раз</div>
|
||
<div>Качество</div>
|
||
<div>♡</div>
|
||
</div>
|
||
<div className="max-h-[calc(100vh-280px)] overflow-auto">
|
||
{tracks.map((track, index) => (
|
||
<button
|
||
key={track.id}
|
||
className="grid w-full grid-cols-[56px_minmax(0,2.3fr)_minmax(0,1.4fr)_80px_140px_180px_120px_48px] gap-4 border-t border-[#1f2940] px-4 py-3 text-left transition hover:bg-[#172237]"
|
||
onClick={() => {
|
||
setQueue(tracks, index)
|
||
playTrack(track, tracks)
|
||
}}
|
||
type="button"
|
||
>
|
||
<div className="text-lg text-slate-200">{index + 1}</div>
|
||
<div className="flex min-w-0 items-center gap-3">
|
||
<div className="h-10 w-10 shrink-0 overflow-hidden rounded-[6px] bg-[#303b4d]">
|
||
{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="truncate text-[1.02rem] text-white">{track.title}</div>
|
||
<div className="truncate text-base text-slate-400">{track.artistName}</div>
|
||
</div>
|
||
</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">{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">{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} />
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function HeaderSearch({ onClick }: { onClick: () => void }) {
|
||
return (
|
||
<div className="flex items-center justify-end">
|
||
<button className="grid h-10 w-10 place-items-center rounded-[8px] border border-[#24314f] text-slate-400 hover:bg-[#18233a] hover:text-white" onClick={onClick} type="button">
|
||
<Search size={18} />
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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'
|
||
}
|