From db6e2818c1140bbf06bbbdd0e4e48fbb6a63f119 Mon Sep 17 00:00:00 2001 From: benya Date: Fri, 3 Apr 2026 02:27:24 +0300 Subject: [PATCH] fix: polish player controls and remove fake track stats --- apps/web/src/components/full-player.tsx | 4 +- apps/web/src/components/player-bar.tsx | 41 ++++++++++---- apps/web/src/lib/api.ts | 3 + apps/web/src/pages/album-detail-page.tsx | 71 +++++++++++++++++++++--- apps/web/src/pages/tracks-page.tsx | 62 +++++++++++++++++++-- apps/web/src/stores/player-store.ts | 23 +++----- internal/httpapi/router.go | 32 +++++++++++ internal/library/service.go | 56 +++++++++++++++++++ 8 files changed, 253 insertions(+), 39 deletions(-) diff --git a/apps/web/src/components/full-player.tsx b/apps/web/src/components/full-player.tsx index 3cb1734..e9bcea6 100644 --- a/apps/web/src/components/full-player.tsx +++ b/apps/web/src/components/full-player.tsx @@ -1,5 +1,5 @@ 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 { FavoriteToggle } from '@/components/favorite-toggle' import { coverArtUrl, fetchFavorites } from '@/lib/api' @@ -188,7 +188,7 @@ export function FullPlayer() {
} onClick={toggleShuffle} /> - } onClick={playPrevious} /> + } onClick={playPrevious} /> diff --git a/apps/web/src/components/player-bar.tsx b/apps/web/src/components/player-bar.tsx index efc441a..bf1042c 100644 --- a/apps/web/src/components/player-bar.tsx +++ b/apps/web/src/components/player-bar.tsx @@ -1,16 +1,16 @@ import { useEffect, useRef } from 'react' import { Expand, - Forward, ListMusic, Pause, Play, Repeat2, - Rewind, + SkipBack, + SkipForward, Shuffle, Volume2, } from 'lucide-react' -import { scrobbleTrack, streamUrl } from '@/lib/api' +import { coverArtUrl, scrobbleTrack, streamUrl } from '@/lib/api' import { usePlayerStore } from '@/stores/player-store' export function PlayerBar() { @@ -39,15 +39,22 @@ export function PlayerBar() { const setFullPlayerOpen = usePlayerStore((state) => state.setFullPlayerOpen) useEffect(() => { - if (!audioRef.current || !currentTrack) { + if (!audioRef.current) { + return + } + if (!currentTrack) { + audioRef.current.pause() + audioRef.current.removeAttribute('src') + audioRef.current.load() return } audioRef.current.src = streamUrl(currentTrack.id) + audioRef.current.currentTime = 0 + setCurrentTime(0) + setDuration(0) + lastStartedTrackRef.current = null lastSubmittedTrackRef.current = null - if (isPlaying) { - void audioRef.current.play().catch(() => {}) - } - }, [currentTrack, isPlaying]) + }, [currentTrack, setCurrentTime, setDuration]) useEffect(() => { if (!audioRef.current) { @@ -96,6 +103,12 @@ export function PlayerBar() { clientName: 'temporserv-web', }).catch(() => {}) } + if (repeatMode === 'one' && audioRef.current) { + audioRef.current.currentTime = 0 + setCurrentTime(0) + void audioRef.current.play().catch(() => {}) + return + } handleTrackEnded() }} onLoadedMetadata={(event) => setDuration(event.currentTarget.duration || 0)} @@ -122,8 +135,12 @@ export function PlayerBar() { />
-
-
+
+ {currentTrack?.coverArtId ? ( + {currentTrack.title} + ) : ( +
+ )}
@@ -136,7 +153,7 @@ export function PlayerBar() {
} onClick={toggleShuffle} /> - } onClick={playPrevious} /> + } onClick={playPrevious} /> - } onClick={playNext} /> + } onClick={playNext} /> } label={repeatMode === 'one' ? '1' : undefined} onClick={cycleRepeatMode} />
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index a176c66..66c747b 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -32,7 +32,10 @@ export type Track = { albumTitle: string trackNumber: number durationSeconds: number + contentType?: string coverArtId?: string + playCount?: number + lastPlayedAt?: string } export type ArtistDetail = Artist & { diff --git a/apps/web/src/pages/album-detail-page.tsx b/apps/web/src/pages/album-detail-page.tsx index 253e791..64175d3 100644 --- a/apps/web/src/pages/album-detail-page.tsx +++ b/apps/web/src/pages/album-detail-page.tsx @@ -3,7 +3,7 @@ import { ErrorPanel, LoadingPanel } from '@/components/query-state' import { MoreVertical, Play, Shuffle } from 'lucide-react' import { FavoriteToggle } from '@/components/favorite-toggle' 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' export function AlbumDetailPage() { @@ -53,7 +53,7 @@ export function AlbumDetailPage() { {album.trackCount} треков - около {formatLongDuration(totalDuration)} + {formatLongDuration(totalDuration)}
@@ -101,11 +101,11 @@ export function AlbumDetailPage() {
{formatDuration(track.durationSeconds)}
-
1
-
недавно
-
935 kbps
+
{track.playCount ?? 0}
+
{formatLastPlayed(track.lastPlayedAt)}
+
- FLAC + {formatQuality(track)}
@@ -119,14 +119,71 @@ export function AlbumDetailPage() { } function formatDuration(value: number) { + if (!value) { + return '—' + } const minutes = Math.floor(value / 60) const seconds = value % 60 return `${minutes}:${seconds.toString().padStart(2, '0')}` } function formatLongDuration(value: number) { + if (!value) { + return 'длительность неизвестна' + } const minutes = Math.floor(value / 60) const hours = Math.floor(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' } diff --git a/apps/web/src/pages/tracks-page.tsx b/apps/web/src/pages/tracks-page.tsx index 0d8ebde..5a70e8d 100644 --- a/apps/web/src/pages/tracks-page.tsx +++ b/apps/web/src/pages/tracks-page.tsx @@ -2,7 +2,7 @@ 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 { coverArtUrl, fetchFavorites, fetchTracks } from '@/lib/api' +import { type Track, coverArtUrl, fetchFavorites, fetchTracks } from '@/lib/api' import { useNavigate } from 'react-router-dom' import { usePlayerStore } from '@/stores/player-store' @@ -69,10 +69,10 @@ export function TracksPage() {
{track.albumTitle}
{formatDuration(track.durationSeconds)}
-
1
-
1 месяц назад
+
{track.playCount ?? 0}
+
{formatLastPlayed(track.lastPlayedAt)}
- FLAC + {formatQuality(track)}
@@ -96,7 +96,61 @@ function HeaderSearch({ onClick }: { onClick: () => void }) { } 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' +} diff --git a/apps/web/src/stores/player-store.ts b/apps/web/src/stores/player-store.ts index 93072c0..c4f9db9 100644 --- a/apps/web/src/stores/player-store.ts +++ b/apps/web/src/stores/player-store.ts @@ -47,6 +47,7 @@ export const usePlayerStore = create((set, get) => ({ currentTrack: queue[startIndex] ?? null, isPlaying: queue.length > 0, currentTime: 0, + duration: 0, }), playTrack: (currentTrack, queue) => set((state) => ({ @@ -54,6 +55,7 @@ export const usePlayerStore = create((set, get) => ({ queue: queue ?? state.queue, isPlaying: true, currentTime: 0, + duration: 0, })), togglePlayback: () => set((state) => ({ isPlaying: !state.isPlaying })), playNext: () => @@ -61,26 +63,20 @@ export const usePlayerStore = create((set, get) => ({ if (!state.currentTrack || state.queue.length === 0) { 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 currentIndex = index >= 0 ? index : 0 let nextTrack: Track | null = null if (state.shuffle && state.queue.length > 1) { const candidates = state.queue.filter((track) => track.id !== state.currentTrack?.id) nextTrack = candidates[Math.floor(Math.random() * candidates.length)] ?? state.queue[0] ?? null } 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 { currentTrack: nextTrack, isPlaying: !!nextTrack, currentTime: 0, + duration: 0, } }), playPrevious: () => @@ -89,11 +85,13 @@ export const usePlayerStore = create((set, get) => ({ return state } 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 { currentTrack: previousTrack, isPlaying: !!previousTrack, currentTime: 0, + duration: 0, } }), playAtIndex: (index) => @@ -101,6 +99,7 @@ export const usePlayerStore = create((set, get) => ({ currentTrack: state.queue[index] ?? state.currentTrack, isPlaying: !!state.queue[index], currentTime: 0, + duration: state.queue[index] ? 0 : state.duration, })), removeFromQueue: (trackId) => set((state) => { @@ -125,10 +124,6 @@ export const usePlayerStore = create((set, get) => ({ clearSeekRequest: () => set({ seekRequest: null }), handleTrackEnded: () => { const state = get() - if (state.repeatMode === 'one') { - set({ currentTime: 0, seekRequest: 0, isPlaying: true }) - return - } state.playNext() }, setFullPlayerOpen: (fullPlayerOpen) => set({ fullPlayerOpen }), diff --git a/internal/httpapi/router.go b/internal/httpapi/router.go index ae57a14..57a914e 100644 --- a/internal/httpapi/router.go +++ b/internal/httpapi/router.go @@ -171,11 +171,17 @@ func (a app) me(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()) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load home"}) 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) } @@ -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"}) 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}) } @@ -221,6 +232,7 @@ func (a app) albums(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")) if err != nil { 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"}) 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) } func (a app) tracks(w http.ResponseWriter, r *http.Request) { + user := currentUserFromContext(r) items, err := a.library.Tracks(r.Context(), 200) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load tracks"}) 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}) } func (a app) trackByID(w http.ResponseWriter, r *http.Request) { + user := currentUserFromContext(r) item, err := a.library.TrackByID(r.Context(), chi.URLParam(r, "id")) if err != nil { 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"}) 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) } diff --git a/internal/library/service.go b/internal/library/service.go index efbf0ac..25689ee 100644 --- a/internal/library/service.go +++ b/internal/library/service.go @@ -40,6 +40,8 @@ type Track struct { FilePath string `json:"filePath"` ContentType string `json:"contentType"` CoverArtID string `json:"coverArtId"` + PlayCount int `json:"playCount"` + LastPlayedAt string `json:"lastPlayedAt"` } type HomePayload struct { @@ -491,6 +493,60 @@ func (s *Service) RecordPlayEvent(ctx context.Context, userID, trackID, eventTyp 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) { rows, err := s.db.QueryContext( ctx,