diff --git a/backend/player/player.go b/backend/player/player.go index 0f9936d..bc5ff9b 100644 --- a/backend/player/player.go +++ b/backend/player/player.go @@ -38,6 +38,7 @@ type Player struct { volume *effects.Volume speakerStreamer beep.Streamer playbackFinishedHandler func() + trackChangeID uint64 } // State represents the current playback state. @@ -223,9 +224,14 @@ func (p *Player) emitTrackChanged() { speaker.Unlock() } + // Increment track change ID so the frontend can detect changes + // even when the same file plays consecutively. + p.trackChangeID++ + // Emit comprehensive track info trackInfo["trackLength"] = trackLengthSecs trackInfo["seekPosition"] = seekPosition + trackInfo["trackChangeId"] = p.trackChangeID runtime.EventsEmit(p.ctx, events.TrackChanged, trackInfo) p.logger.Info("Emitting TrackChangedEvent with track info", "trackInfo", trackInfo) diff --git a/backend/queue/queue.go b/backend/queue/queue.go index eb28d27..c783df7 100644 --- a/backend/queue/queue.go +++ b/backend/queue/queue.go @@ -653,7 +653,8 @@ func (q *Queue) RemoveTrack(position int) { q.emitQueueChanged() } -// Next advances to the next track. +// Next advances to the next track. In RepeatOne mode, the current +// track is replayed instead of advancing. func (q *Queue) Next() { q.mu.Lock() defer q.mu.Unlock() @@ -662,6 +663,14 @@ func (q *Queue) Next() { return } + // Repeat One: replay the current track. + if q.repeatMode == RepeatOne { + q.playCurrentTrack() + q.emitQueueChanged() + + return + } + nextIdx := q.nextIndex() if nextIdx == -1 { q.onQueueExhausted() @@ -675,6 +684,7 @@ func (q *Queue) Next() { } // Previous goes to the previous track (or restarts current if >3s in). +// In RepeatOne mode, the current track is replayed instead of navigating. func (q *Queue) Previous() { q.mu.Lock() defer q.mu.Unlock() @@ -683,6 +693,14 @@ func (q *Queue) Previous() { return } + // Repeat One: replay the current track. + if q.repeatMode == RepeatOne { + q.playCurrentTrack() + q.emitQueueChanged() + + return + } + // If more than 3 seconds into the track, restart it. if q.player != nil { posSecs, err := q.player.CurrentPositionSeconds() diff --git a/frontend/src/components/audio-player/seekbar/seek-bar.ts b/frontend/src/components/audio-player/seekbar/seek-bar.ts index 23fc0f9..47af5b0 100644 --- a/frontend/src/components/audio-player/seekbar/seek-bar.ts +++ b/frontend/src/components/audio-player/seekbar/seek-bar.ts @@ -12,7 +12,7 @@ export class SeekBar extends LitElement { private player = new PlayerController(this); private rangeRef = createRef(); private timerID: number = -1; - private previousTrackPath: string | null = null; + private previousTrackChangeId: number = -1; @state() private seekValue: number = 0; @@ -74,11 +74,13 @@ export class SeekBar extends LitElement { } override updated() { - // Detect track change and reset seek position - const currentPath = this.player.currentTrack?.filePath ?? null; + // Detect track change and reset seek position. + // Uses trackChangeId instead of filePath so the seek bar resets + // even when the same file plays consecutively in the queue. + const currentChangeId = this.player.currentTrack?.trackChangeId ?? -1; - if (currentPath !== this.previousTrackPath) { - this.previousTrackPath = currentPath; + if (currentChangeId !== this.previousTrackChangeId) { + this.previousTrackChangeId = currentChangeId; this.seekValue = this.player.currentTrack?.seekPosition ?? 0; this.stopProgress(); } diff --git a/frontend/src/store/player-store.ts b/frontend/src/store/player-store.ts index 28dd9a0..444c67d 100644 --- a/frontend/src/store/player-store.ts +++ b/frontend/src/store/player-store.ts @@ -13,6 +13,7 @@ export interface TrackInfo { album: string; // album name coverArt: string; // URL path to cover art (e.g., "/covers/abc.jpg") or empty string coverArtThumbnail: string; // URL path to thumbnail (e.g., "/covers/abc_thumb.jpg") or empty string + trackChangeId: number; // monotonic counter to detect track changes even when the same file plays consecutively } export interface PlayerState {