feat: add recently played and scrobble flow

This commit is contained in:
2026-04-03 02:15:57 +03:00
parent 2bbf52a41b
commit 4d44632fbf
7 changed files with 320 additions and 54 deletions

View File

@@ -10,11 +10,13 @@ import {
Shuffle,
Volume2,
} from 'lucide-react'
import { streamUrl } from '@/lib/api'
import { scrobbleTrack, streamUrl } from '@/lib/api'
import { usePlayerStore } from '@/stores/player-store'
export function PlayerBar() {
const audioRef = useRef<HTMLAudioElement | null>(null)
const lastStartedTrackRef = useRef<string | null>(null)
const lastSubmittedTrackRef = useRef<string | null>(null)
const currentTrack = usePlayerStore((state) => state.currentTrack)
const isPlaying = usePlayerStore((state) => state.isPlaying)
const volume = usePlayerStore((state) => state.volume)
@@ -41,6 +43,7 @@ export function PlayerBar() {
return
}
audioRef.current.src = streamUrl(currentTrack.id)
lastSubmittedTrackRef.current = null
if (isPlaying) {
void audioRef.current.play().catch(() => {})
}
@@ -66,13 +69,55 @@ export function PlayerBar() {
clearSeekRequest()
}, [seekRequest, clearSeekRequest])
useEffect(() => {
if (!currentTrack || !isPlaying || lastStartedTrackRef.current === currentTrack.id) {
return
}
lastStartedTrackRef.current = currentTrack.id
void scrobbleTrack({
trackId: currentTrack.id,
submission: false,
time: Date.now(),
clientName: 'temporserv-web',
}).catch(() => {})
}, [currentTrack, isPlaying])
return (
<footer className="grid grid-cols-[260px_minmax(0,1fr)_280px] items-center border-t border-[#24314f] bg-[#091228] px-4 py-3">
<audio
ref={audioRef}
onEnded={handleTrackEnded}
onEnded={() => {
if (currentTrack && lastSubmittedTrackRef.current !== currentTrack.id) {
lastSubmittedTrackRef.current = currentTrack.id
void scrobbleTrack({
trackId: currentTrack.id,
submission: true,
time: Date.now(),
clientName: 'temporserv-web',
}).catch(() => {})
}
handleTrackEnded()
}}
onLoadedMetadata={(event) => setDuration(event.currentTarget.duration || 0)}
onTimeUpdate={(event) => setCurrentTime(event.currentTarget.currentTime)}
onTimeUpdate={(event) => {
const nextTime = event.currentTarget.currentTime
const nextDuration = event.currentTarget.duration || 0
setCurrentTime(nextTime)
if (
currentTrack &&
lastSubmittedTrackRef.current !== currentTrack.id &&
nextDuration > 0 &&
(nextTime >= Math.min(nextDuration * 0.5, 240) || nextTime >= nextDuration-1)
) {
lastSubmittedTrackRef.current = currentTrack.id
void scrobbleTrack({
trackId: currentTrack.id,
submission: true,
time: Date.now(),
clientName: 'temporserv-web',
}).catch(() => {})
}
}}
preload="metadata"
/>

View File

@@ -61,6 +61,7 @@ export type ScanStatus = {
export type HomePayload = {
recentAlbums: Album[]
recentTracks: Track[]
artists: Artist[]
}
@@ -70,6 +71,10 @@ export type FavoritesPayload = {
tracks: Track[]
}
export type TrackListPayload = {
items: Track[]
}
export type PlaylistSummary = {
id: string
name: string
@@ -143,7 +148,7 @@ export async function fetchAlbum(id: string) {
}
export async function fetchTracks() {
return request<{ items: Track[] }>('/api/tracks')
return request<TrackListPayload>('/api/tracks')
}
export async function fetchTrack(id: string) {
@@ -206,6 +211,22 @@ export async function fetchFavorites() {
return request<FavoritesPayload>('/api/favorites')
}
export async function fetchRecentlyPlayed() {
return request<TrackListPayload>('/api/recently-played')
}
export async function scrobbleTrack(input: {
trackId: string
submission?: boolean
time?: number
clientName?: string
}) {
return request<{ status: string }>('/api/history/scrobble', {
method: 'POST',
body: JSON.stringify(input),
})
}
export async function starFavorites(input: {
trackIds?: string[]
albumIds?: string[]

View File

@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { coverArtUrl, fetchHome, fetchTracks } from '@/lib/api'
import { type Track, coverArtUrl, fetchHome, fetchRecentlyPlayed, fetchTracks } from '@/lib/api'
import { Link } from 'react-router-dom'
import { usePlayerStore } from '@/stores/player-store'
@@ -14,9 +14,14 @@ export function HomePage() {
queryKey: ['tracks'],
queryFn: fetchTracks,
})
const recentTracksQuery = useQuery({
queryKey: ['recently-played'],
queryFn: fetchRecentlyPlayed,
})
const heroTrack = tracksQuery.data?.items[0]
const recentAlbums = homeQuery.data?.recentAlbums ?? []
const recentTracks = recentTracksQuery.data?.items ?? homeQuery.data?.recentTracks ?? []
const popularAlbums = [...recentAlbums].reverse()
return (
@@ -46,12 +51,59 @@ export function HomePage() {
</div>
</section>
<AlbumRow title="Недавно прослушанные" albums={recentAlbums} onPlayAll={() => setQueue(tracksQuery.data?.items ?? [])} />
<TrackRow title="Недавно прослушанные" tracks={recentTracks} onPlayAll={() => setQueue(recentTracks)} />
<AlbumRow title="Недавно добавленные" albums={recentAlbums} onPlayAll={() => setQueue(tracksQuery.data?.items ?? [])} />
<AlbumRow title="Наиболее прослушиваемые" albums={popularAlbums} onPlayAll={() => setQueue(tracksQuery.data?.items ?? [])} />
</div>
)
}
function TrackRow({
title,
tracks,
onPlayAll,
}: {
title: string
tracks: Track[]
onPlayAll: () => void
}) {
const playTrack = usePlayerStore((state) => state.playTrack)
return (
<section>
<div className="mb-5 flex items-center justify-between">
<h3 className="text-[2rem] font-semibold tracking-tight text-white">{title}</h3>
<div className="flex items-center gap-3">
<button className="text-base text-slate-400 transition hover:text-white" onClick={onPlayAll} type="button">
Еще
</button>
<CarouselButton icon={<ChevronLeft size={18} />} />
<CarouselButton icon={<ChevronRight size={18} />} />
</div>
</div>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-7">
{tracks.map((track) => (
<button
key={track.id}
className="text-left"
onClick={() => playTrack(track, tracks)}
type="button"
>
<div className="aspect-square overflow-hidden rounded-[8px] bg-[#232d42]">
{track.coverArtId ? (
<img alt={track.title} className="h-full w-full object-cover" src={coverArtUrl(track.id)} />
) : null}
</div>
<div className="mt-3 line-clamp-1 block text-[1.08rem] font-semibold text-white">{track.title}</div>
<div className="line-clamp-1 text-base text-slate-400">{track.artistName}</div>
<div className="line-clamp-1 text-sm text-slate-500">{track.albumTitle}</div>
</button>
))}
</div>
</section>
)
}
function AlbumRow({
title,
albums,