Files
TermorServer/apps/web/src/pages/tracks-page.tsx

157 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'
}