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 ? (
+
})
+ ) : (
+
+ )}
@@ -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,