fix: polish player controls and remove fake track stats
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
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 { useMemo, useState } from 'react'
|
||||||
import { FavoriteToggle } from '@/components/favorite-toggle'
|
import { FavoriteToggle } from '@/components/favorite-toggle'
|
||||||
import { coverArtUrl, fetchFavorites } from '@/lib/api'
|
import { coverArtUrl, fetchFavorites } from '@/lib/api'
|
||||||
@@ -188,7 +188,7 @@ export function FullPlayer() {
|
|||||||
|
|
||||||
<div className="flex items-center gap-8 text-white/90">
|
<div className="flex items-center gap-8 text-white/90">
|
||||||
<IconControl active={shuffle} icon={<Shuffle size={22} />} onClick={toggleShuffle} />
|
<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">
|
<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]" />}
|
{isPlaying ? <Pause size={28} /> : <Play size={28} className="translate-x-[2px]" />}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import {
|
import {
|
||||||
Expand,
|
Expand,
|
||||||
Forward,
|
|
||||||
ListMusic,
|
ListMusic,
|
||||||
Pause,
|
Pause,
|
||||||
Play,
|
Play,
|
||||||
Repeat2,
|
Repeat2,
|
||||||
Rewind,
|
SkipBack,
|
||||||
|
SkipForward,
|
||||||
Shuffle,
|
Shuffle,
|
||||||
Volume2,
|
Volume2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { scrobbleTrack, streamUrl } from '@/lib/api'
|
import { coverArtUrl, scrobbleTrack, streamUrl } from '@/lib/api'
|
||||||
import { usePlayerStore } from '@/stores/player-store'
|
import { usePlayerStore } from '@/stores/player-store'
|
||||||
|
|
||||||
export function PlayerBar() {
|
export function PlayerBar() {
|
||||||
@@ -39,15 +39,22 @@ export function PlayerBar() {
|
|||||||
const setFullPlayerOpen = usePlayerStore((state) => state.setFullPlayerOpen)
|
const setFullPlayerOpen = usePlayerStore((state) => state.setFullPlayerOpen)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!audioRef.current || !currentTrack) {
|
if (!audioRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!currentTrack) {
|
||||||
|
audioRef.current.pause()
|
||||||
|
audioRef.current.removeAttribute('src')
|
||||||
|
audioRef.current.load()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
audioRef.current.src = streamUrl(currentTrack.id)
|
audioRef.current.src = streamUrl(currentTrack.id)
|
||||||
|
audioRef.current.currentTime = 0
|
||||||
|
setCurrentTime(0)
|
||||||
|
setDuration(0)
|
||||||
|
lastStartedTrackRef.current = null
|
||||||
lastSubmittedTrackRef.current = null
|
lastSubmittedTrackRef.current = null
|
||||||
if (isPlaying) {
|
}, [currentTrack, setCurrentTime, setDuration])
|
||||||
void audioRef.current.play().catch(() => {})
|
|
||||||
}
|
|
||||||
}, [currentTrack, isPlaying])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!audioRef.current) {
|
if (!audioRef.current) {
|
||||||
@@ -96,6 +103,12 @@ export function PlayerBar() {
|
|||||||
clientName: 'temporserv-web',
|
clientName: 'temporserv-web',
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
if (repeatMode === 'one' && audioRef.current) {
|
||||||
|
audioRef.current.currentTime = 0
|
||||||
|
setCurrentTime(0)
|
||||||
|
void audioRef.current.play().catch(() => {})
|
||||||
|
return
|
||||||
|
}
|
||||||
handleTrackEnded()
|
handleTrackEnded()
|
||||||
}}
|
}}
|
||||||
onLoadedMetadata={(event) => setDuration(event.currentTarget.duration || 0)}
|
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="flex min-w-0 items-center gap-3">
|
||||||
<div className="grid h-[68px] w-[68px] place-items-center rounded-[8px] bg-[#1b2638]">
|
<div className="grid h-[68px] w-[68px] place-items-center overflow-hidden rounded-[8px] bg-[#1b2638]">
|
||||||
<div className="h-7 w-7 rounded-full border-l-2 border-r-2 border-[#f1f5fb]" />
|
{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>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="line-clamp-1 text-[1.05rem] font-medium text-white">
|
<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 flex-col items-center">
|
||||||
<div className="flex items-center gap-5 text-slate-400">
|
<div className="flex items-center gap-5 text-slate-400">
|
||||||
<BarIcon active={shuffle} icon={<Shuffle size={18} />} onClick={toggleShuffle} />
|
<BarIcon active={shuffle} icon={<Shuffle size={18} />} onClick={toggleShuffle} />
|
||||||
<BarIcon icon={<Rewind size={18} />} onClick={playPrevious} />
|
<BarIcon icon={<SkipBack size={18} />} onClick={playPrevious} />
|
||||||
<button
|
<button
|
||||||
className="grid h-11 w-11 place-items-center rounded-full bg-[#16bf8c] text-[#081225] transition hover:brightness-105"
|
className="grid h-11 w-11 place-items-center rounded-full bg-[#16bf8c] text-[#081225] transition hover:brightness-105"
|
||||||
onClick={togglePlayback}
|
onClick={togglePlayback}
|
||||||
@@ -144,7 +161,7 @@ export function PlayerBar() {
|
|||||||
>
|
>
|
||||||
{isPlaying ? <Pause size={18} /> : <Play size={18} className="translate-x-[1px]" />}
|
{isPlaying ? <Pause size={18} /> : <Play size={18} className="translate-x-[1px]" />}
|
||||||
</button>
|
</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} />
|
<BarIcon active={repeatMode !== 'off'} icon={<Repeat2 size={18} />} label={repeatMode === 'one' ? '1' : undefined} onClick={cycleRepeatMode} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,10 @@ export type Track = {
|
|||||||
albumTitle: string
|
albumTitle: string
|
||||||
trackNumber: number
|
trackNumber: number
|
||||||
durationSeconds: number
|
durationSeconds: number
|
||||||
|
contentType?: string
|
||||||
coverArtId?: string
|
coverArtId?: string
|
||||||
|
playCount?: number
|
||||||
|
lastPlayedAt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ArtistDetail = Artist & {
|
export type ArtistDetail = Artist & {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ErrorPanel, LoadingPanel } from '@/components/query-state'
|
|||||||
import { MoreVertical, Play, Shuffle } from 'lucide-react'
|
import { MoreVertical, Play, Shuffle } from 'lucide-react'
|
||||||
import { FavoriteToggle } from '@/components/favorite-toggle'
|
import { FavoriteToggle } from '@/components/favorite-toggle'
|
||||||
import { useParams } from 'react-router-dom'
|
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'
|
import { usePlayerStore } from '@/stores/player-store'
|
||||||
|
|
||||||
export function AlbumDetailPage() {
|
export function AlbumDetailPage() {
|
||||||
@@ -53,7 +53,7 @@ export function AlbumDetailPage() {
|
|||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>{album.trackCount} треков</span>
|
<span>{album.trackCount} треков</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>около {formatLongDuration(totalDuration)}</span>
|
<span>{formatLongDuration(totalDuration)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -101,11 +101,11 @@ export function AlbumDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-base text-slate-200">{formatDuration(track.durationSeconds)}</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">{track.playCount ?? 0}</div>
|
||||||
<div className="text-base text-slate-400">недавно</div>
|
<div className="text-base text-slate-400">{formatLastPlayed(track.lastPlayedAt)}</div>
|
||||||
<div className="text-base text-slate-200">935 kbps</div>
|
<div className="text-base text-slate-200">—</div>
|
||||||
<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>
|
||||||
<div className="grid place-items-center text-slate-500">
|
<div className="grid place-items-center text-slate-500">
|
||||||
<FavoriteToggle active={favoriteTrackIds.has(track.id)} entityId={track.id} entityType="track" size={16} />
|
<FavoriteToggle active={favoriteTrackIds.has(track.id)} entityId={track.id} entityType="track" size={16} />
|
||||||
@@ -119,14 +119,71 @@ export function AlbumDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatDuration(value: number) {
|
function formatDuration(value: number) {
|
||||||
|
if (!value) {
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
const minutes = Math.floor(value / 60)
|
const minutes = Math.floor(value / 60)
|
||||||
const seconds = value % 60
|
const seconds = value % 60
|
||||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatLongDuration(value: number) {
|
function formatLongDuration(value: number) {
|
||||||
|
if (!value) {
|
||||||
|
return 'длительность неизвестна'
|
||||||
|
}
|
||||||
const minutes = Math.floor(value / 60)
|
const minutes = Math.floor(value / 60)
|
||||||
const hours = Math.floor(minutes / 60)
|
const hours = Math.floor(minutes / 60)
|
||||||
const restMinutes = 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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query'
|
|||||||
import { ErrorPanel, LoadingPanel } from '@/components/query-state'
|
import { ErrorPanel, LoadingPanel } from '@/components/query-state'
|
||||||
import { Search } from 'lucide-react'
|
import { Search } from 'lucide-react'
|
||||||
import { FavoriteToggle } from '@/components/favorite-toggle'
|
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 { useNavigate } from 'react-router-dom'
|
||||||
import { usePlayerStore } from '@/stores/player-store'
|
import { usePlayerStore } from '@/stores/player-store'
|
||||||
|
|
||||||
@@ -69,10 +69,10 @@ export function TracksPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="truncate text-base text-slate-300">{track.albumTitle}</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-200">{formatDuration(track.durationSeconds)}</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">1 месяц назад</div>
|
<div className="text-base text-slate-400">{formatLastPlayed(track.lastPlayedAt)}</div>
|
||||||
<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>
|
||||||
<div className="grid place-items-center text-slate-500">
|
<div className="grid place-items-center text-slate-500">
|
||||||
<FavoriteToggle active={favoriteTrackIds.has(track.id)} entityId={track.id} entityType="track" size={16} />
|
<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) {
|
function formatDuration(durationSeconds: number) {
|
||||||
|
if (!durationSeconds) {
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
const minutes = Math.floor(durationSeconds / 60)
|
const minutes = Math.floor(durationSeconds / 60)
|
||||||
const seconds = durationSeconds % 60
|
const seconds = durationSeconds % 60
|
||||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
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'
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|||||||
currentTrack: queue[startIndex] ?? null,
|
currentTrack: queue[startIndex] ?? null,
|
||||||
isPlaying: queue.length > 0,
|
isPlaying: queue.length > 0,
|
||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
|
duration: 0,
|
||||||
}),
|
}),
|
||||||
playTrack: (currentTrack, queue) =>
|
playTrack: (currentTrack, queue) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
@@ -54,6 +55,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|||||||
queue: queue ?? state.queue,
|
queue: queue ?? state.queue,
|
||||||
isPlaying: true,
|
isPlaying: true,
|
||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
|
duration: 0,
|
||||||
})),
|
})),
|
||||||
togglePlayback: () => set((state) => ({ isPlaying: !state.isPlaying })),
|
togglePlayback: () => set((state) => ({ isPlaying: !state.isPlaying })),
|
||||||
playNext: () =>
|
playNext: () =>
|
||||||
@@ -61,26 +63,20 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|||||||
if (!state.currentTrack || state.queue.length === 0) {
|
if (!state.currentTrack || state.queue.length === 0) {
|
||||||
return state
|
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 index = state.queue.findIndex((track) => track.id === state.currentTrack?.id)
|
||||||
|
const currentIndex = index >= 0 ? index : 0
|
||||||
let nextTrack: Track | null = null
|
let nextTrack: Track | null = null
|
||||||
if (state.shuffle && state.queue.length > 1) {
|
if (state.shuffle && state.queue.length > 1) {
|
||||||
const candidates = state.queue.filter((track) => track.id !== state.currentTrack?.id)
|
const candidates = state.queue.filter((track) => track.id !== state.currentTrack?.id)
|
||||||
nextTrack = candidates[Math.floor(Math.random() * candidates.length)] ?? state.queue[0] ?? null
|
nextTrack = candidates[Math.floor(Math.random() * candidates.length)] ?? state.queue[0] ?? null
|
||||||
} else {
|
} 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 {
|
return {
|
||||||
currentTrack: nextTrack,
|
currentTrack: nextTrack,
|
||||||
isPlaying: !!nextTrack,
|
isPlaying: !!nextTrack,
|
||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
|
duration: 0,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
playPrevious: () =>
|
playPrevious: () =>
|
||||||
@@ -89,11 +85,13 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
const index = state.queue.findIndex((track) => track.id === state.currentTrack?.id)
|
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 {
|
return {
|
||||||
currentTrack: previousTrack,
|
currentTrack: previousTrack,
|
||||||
isPlaying: !!previousTrack,
|
isPlaying: !!previousTrack,
|
||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
|
duration: 0,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
playAtIndex: (index) =>
|
playAtIndex: (index) =>
|
||||||
@@ -101,6 +99,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|||||||
currentTrack: state.queue[index] ?? state.currentTrack,
|
currentTrack: state.queue[index] ?? state.currentTrack,
|
||||||
isPlaying: !!state.queue[index],
|
isPlaying: !!state.queue[index],
|
||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
|
duration: state.queue[index] ? 0 : state.duration,
|
||||||
})),
|
})),
|
||||||
removeFromQueue: (trackId) =>
|
removeFromQueue: (trackId) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
@@ -125,10 +124,6 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|||||||
clearSeekRequest: () => set({ seekRequest: null }),
|
clearSeekRequest: () => set({ seekRequest: null }),
|
||||||
handleTrackEnded: () => {
|
handleTrackEnded: () => {
|
||||||
const state = get()
|
const state = get()
|
||||||
if (state.repeatMode === 'one') {
|
|
||||||
set({ currentTime: 0, seekRequest: 0, isPlaying: true })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
state.playNext()
|
state.playNext()
|
||||||
},
|
},
|
||||||
setFullPlayerOpen: (fullPlayerOpen) => set({ fullPlayerOpen }),
|
setFullPlayerOpen: (fullPlayerOpen) => set({ fullPlayerOpen }),
|
||||||
|
|||||||
@@ -171,11 +171,17 @@ func (a app) me(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a app) home(w http.ResponseWriter, r *http.Request) {
|
func (a app) home(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := currentUserFromContext(r)
|
||||||
home, err := a.library.Home(r.Context())
|
home, err := a.library.Home(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load home"})
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load home"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
home.RecentTracks, err = a.library.PopulateTrackStats(r.Context(), user.ID, home.RecentTracks)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load home"})
|
||||||
|
return
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, home)
|
writeJSON(w, http.StatusOK, home)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,6 +201,11 @@ func (a app) recentlyPlayed(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load recent tracks"})
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load recent tracks"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
items, err = a.library.PopulateTrackStats(r.Context(), user.ID, items)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load recent tracks"})
|
||||||
|
return
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,6 +232,7 @@ func (a app) albums(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a app) albumByID(w http.ResponseWriter, r *http.Request) {
|
func (a app) albumByID(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := currentUserFromContext(r)
|
||||||
item, err := a.library.AlbumByID(r.Context(), chi.URLParam(r, "id"))
|
item, err := a.library.AlbumByID(r.Context(), chi.URLParam(r, "id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, library.ErrNotFound) {
|
if errors.Is(err, library.ErrNotFound) {
|
||||||
@@ -230,19 +242,31 @@ func (a app) albumByID(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load album"})
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load album"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
item.Tracks, err = a.library.PopulateTrackStats(r.Context(), user.ID, item.Tracks)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load album"})
|
||||||
|
return
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, item)
|
writeJSON(w, http.StatusOK, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a app) tracks(w http.ResponseWriter, r *http.Request) {
|
func (a app) tracks(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := currentUserFromContext(r)
|
||||||
items, err := a.library.Tracks(r.Context(), 200)
|
items, err := a.library.Tracks(r.Context(), 200)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load tracks"})
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load tracks"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
items, err = a.library.PopulateTrackStats(r.Context(), user.ID, items)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load tracks"})
|
||||||
|
return
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a app) trackByID(w http.ResponseWriter, r *http.Request) {
|
func (a app) trackByID(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := currentUserFromContext(r)
|
||||||
item, err := a.library.TrackByID(r.Context(), chi.URLParam(r, "id"))
|
item, err := a.library.TrackByID(r.Context(), chi.URLParam(r, "id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, library.ErrNotFound) {
|
if errors.Is(err, library.ErrNotFound) {
|
||||||
@@ -252,6 +276,14 @@ func (a app) trackByID(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load track"})
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load track"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
enriched, err := a.library.PopulateTrackStats(r.Context(), user.ID, []library.Track{item})
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load track"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(enriched) > 0 {
|
||||||
|
item = enriched[0]
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, item)
|
writeJSON(w, http.StatusOK, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ type Track struct {
|
|||||||
FilePath string `json:"filePath"`
|
FilePath string `json:"filePath"`
|
||||||
ContentType string `json:"contentType"`
|
ContentType string `json:"contentType"`
|
||||||
CoverArtID string `json:"coverArtId"`
|
CoverArtID string `json:"coverArtId"`
|
||||||
|
PlayCount int `json:"playCount"`
|
||||||
|
LastPlayedAt string `json:"lastPlayedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type HomePayload struct {
|
type HomePayload struct {
|
||||||
@@ -491,6 +493,60 @@ func (s *Service) RecordPlayEvent(ctx context.Context, userID, trackID, eventTyp
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) PopulateTrackStats(ctx context.Context, userID string, tracks []Track) ([]Track, error) {
|
||||||
|
if userID == "" || len(tracks) == 0 {
|
||||||
|
return tracks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
placeholders := make([]string, 0, len(tracks))
|
||||||
|
args := make([]any, 0, len(tracks)+1)
|
||||||
|
args = append(args, userID)
|
||||||
|
for _, track := range tracks {
|
||||||
|
placeholders = append(placeholders, "?")
|
||||||
|
args = append(args, track.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf(
|
||||||
|
`SELECT track_id, COUNT(*) AS play_count, COALESCE(MAX(played_at), '')
|
||||||
|
FROM play_history
|
||||||
|
WHERE user_id = ? AND track_id IN (%s)
|
||||||
|
GROUP BY track_id`,
|
||||||
|
strings.Join(placeholders, ","),
|
||||||
|
)
|
||||||
|
|
||||||
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query track stats: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type stats struct {
|
||||||
|
playCount int
|
||||||
|
lastPlayedAt string
|
||||||
|
}
|
||||||
|
byTrackID := map[string]stats{}
|
||||||
|
for rows.Next() {
|
||||||
|
var trackID string
|
||||||
|
var item stats
|
||||||
|
if err := rows.Scan(&trackID, &item.playCount, &item.lastPlayedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan track stats: %w", err)
|
||||||
|
}
|
||||||
|
byTrackID[trackID] = item
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("iterate track stats: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for index := range tracks {
|
||||||
|
if item, ok := byTrackID[tracks[index].ID]; ok {
|
||||||
|
tracks[index].PlayCount = item.playCount
|
||||||
|
tracks[index].LastPlayedAt = item.lastPlayedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tracks, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) albumsByArtistID(ctx context.Context, artistID string) ([]Album, error) {
|
func (s *Service) albumsByArtistID(ctx context.Context, artistID string) ([]Album, error) {
|
||||||
rows, err := s.db.QueryContext(
|
rows, err := s.db.QueryContext(
|
||||||
ctx,
|
ctx,
|
||||||
|
|||||||
Reference in New Issue
Block a user