feat: add recently played and scrobble flow
This commit is contained in:
@@ -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"
|
||||
/>
|
||||
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user