feat(player): report the real position, and skip an unplayable track

The seek bar was a setInterval counter reconciled only on track
change: measured 3 s behind during steady playback and 30 s behind
after four keyboard seeks, because the seek shortcut never told it.
And `loadCurrentTrack`/`playCurrentTrack` logged, returned false and
emitted nothing, so double-clicking a moved file did nothing, twice,
forever — while auto-advance onto a bad file stopped playback dead.

- A 1 Hz position ticker while playing, plus an immediate report on
  load, play, pause, seek and natural finish. The payload carries a
  `trackChangeId` (the store is a singleton, so a bar mounting later
  must not adopt a report about the previous track) and a `seq` (the
  same second reported twice still has to reset interpolation).
- `PlaybackFailed` from both failure paths, and `playCurrentOrSkip`
  steps over tracks that will not load — bounded by the queue length,
  so a disconnected drive stops after one pass instead of spinning
  through a RepeatAll wrap. `PlayIndex` still reverts: the user picked
  that track.
- `SeekFailed` is emitted when the seek itself fails, not only when
  nothing is loaded, and is followed by a position report so the
  optimistic move is taken back by the mechanism that fixed the drift.
- A queue that simply ran out no longer unloads the player, so the
  finished track stays on the bar at 0:00.
This commit is contained in:
2026-08-12 01:17:54 -04:00
parent 55aa3ea5b0
commit df11ef23f4
6 changed files with 516 additions and 16 deletions
+22
View File
@@ -43,6 +43,28 @@ func (q *Queue) emitModeChanged() {
)
}
// emitPlaybackFailed tells the frontend that a track could not be
// played. Before this existed the failure was logged, the index was
// reverted and nothing reached the user: a moved file was a button
// that did nothing, twice, forever (errors.C1).
func (q *Queue) emitPlaybackFailed(track Track, reason error) {
msg := ""
if reason != nil {
msg = reason.Error()
}
events.Emit(
q.ctx,
events.PlaybackFailed,
PlaybackFailure{
FilePath: track.FilePath,
Title: track.Title,
Artist: track.Artist,
Reason: msg,
},
)
}
// emitTracksModified emits a delta update for track list changes.
func (q *Queue) emitTracksModified(
action string,
+6 -4
View File
@@ -29,18 +29,20 @@ func (q *Queue) OnPlaybackFinished() {
nextIdx := q.nextIndex()
if nextIdx == -1 {
// Queue exhausted — this is the extension point for a future fallback playlist.
q.onQueueExhausted()
q.onQueueExhausted(false)
q.mu.Unlock()
q.recordPlay(finishedID)
return
}
prevIndex := q.currentIndex
q.currentIndex = nextIdx
if !q.playCurrentTrack() {
q.currentIndex = prevIndex
// Skip over tracks that cannot be played instead of reverting.
// Reverting stopped playback dead on the first moved file and left
// Next unable to get past it, since Next hit the same track.
if !q.playCurrentOrSkip(true, q.nextIndex) {
q.onQueueExhausted(false)
q.mu.Unlock()
q.recordPlay(finishedID)
+213
View File
@@ -0,0 +1,213 @@
package queue
import (
"context"
"errors"
"log/slog"
"testing"
"yellowjacket/backend/database"
"yellowjacket/backend/events"
)
var errFileMissing = errors.New("no such file or directory")
// failingLoader is a TrackLoader that refuses to load a named set of
// paths — a moved file, in other words, which is the whole of
// errors.C1.
type failingLoader struct {
mockTrackLoader
fails map[string]bool
unloaded int
}
func (f *failingLoader) LoadFile(filePath string) error {
if f.fails[filePath] {
return errFileMissing
}
return f.mockTrackLoader.LoadFile(filePath)
}
func (f *failingLoader) UnloadTrack() { f.unloaded++ }
// setupFailingQueue is setupRecordedQueue with a loader that fails on
// the given paths.
func setupFailingQueue(
t *testing.T,
) (*Queue, *database.DB, *events.Recorder, *failingLoader) {
t.Helper()
db := database.NewTestDB(t)
q := NewQueue(slog.Default(), db)
loader := &failingLoader{fails: map[string]bool{}}
q.SetPlayer(loader)
rec := events.NewRecorder()
q.SetContext(events.WithSink(context.Background(), rec))
return q, db, rec, loader
}
// failureOf returns the payload of the most recent PlaybackFailed.
func failureOf(t *testing.T, rec *events.Recorder) PlaybackFailure {
t.Helper()
ev, ok := rec.Last(events.PlaybackFailed)
if !ok {
t.Fatalf("no PlaybackFailed emitted; got %v", rec.Names())
}
failure, ok := ev.Payload().(PlaybackFailure)
if !ok {
t.Fatalf(
"PlaybackFailed payload is %T, want queue.PlaybackFailure",
ev.Payload(),
)
}
return failure
}
func TestPlaybackFailed_EmittedForAMissingFile(t *testing.T) {
t.Parallel()
q, db, rec, loader := setupFailingQueue(t)
paths := seedAudioFiles(t, db, 3)
loader.fails[paths[1]] = true
q.SetQueue(paths, 0, false)
q.PlayIndex(1)
failure := failureOf(t, rec)
if failure.FilePath != paths[1] {
t.Errorf("filePath: got %q, want %q", failure.FilePath, paths[1])
}
if failure.Reason == "" {
t.Error("reason is empty; the frontend has nothing to log")
}
if failure.Title == "" {
t.Error("title is empty; a message cannot name the track")
}
}
func TestPlaybackFailed_AutoAdvanceSkipsPastIt(t *testing.T) {
t.Parallel()
q, db, rec, loader := setupFailingQueue(t)
paths := seedAudioFiles(t, db, 3)
loader.fails[paths[1]] = true
q.SetQueue(paths, 0, false)
q.Play()
// The first track finished: auto-advance lands on the missing
// file and must step over it rather than stopping dead.
q.OnPlaybackFinished()
if got := q.GetState().CurrentIndex; got != 2 {
t.Errorf("currentIndex after skipping: got %d, want 2", got)
}
if _, ok := rec.Last(events.PlaybackFailed); !ok {
t.Errorf("skipped silently; events were %v", rec.Names())
}
}
func TestPlaybackFailed_NextSkipsPastIt(t *testing.T) {
t.Parallel()
q, db, _, loader := setupFailingQueue(t)
paths := seedAudioFiles(t, db, 4)
loader.fails[paths[1]] = true
loader.fails[paths[2]] = true
q.SetQueue(paths, 0, false)
q.Next()
if got := q.GetState().CurrentIndex; got != 3 {
t.Errorf(
"currentIndex after two unplayable tracks: got %d, want 3",
got,
)
}
}
func TestPlaybackFailed_WholeQueueUnplayableStopsOnce(t *testing.T) {
t.Parallel()
q, db, rec, loader := setupFailingQueue(t)
paths := seedAudioFiles(t, db, 3)
for _, p := range paths {
loader.fails[p] = true
}
q.SetQueue(paths, 0, false)
q.repeatMode = RepeatAll
rec.Reset()
q.Next()
// Every file is gone (a disconnected drive). One pass, then
// stop — not an endless wrap around a RepeatAll queue.
if got := q.GetState().CurrentIndex; got != -1 {
t.Errorf("currentIndex: got %d, want -1 (exhausted)", got)
}
if got := rec.Count(events.PlaybackFailed); got != len(paths) {
t.Errorf(
"PlaybackFailed count: got %d, want %d (one pass)",
got, len(paths),
)
}
if loader.unloaded != 0 {
t.Errorf(
"player unloaded %d times; the finished track should stay "+
"on the now-playing bar",
loader.unloaded,
)
}
}
func TestQueueExhausted_KeepsTheFinishedTrackLoaded(t *testing.T) {
t.Parallel()
q, db, _, loader := setupFailingQueue(t)
paths := seedAudioFiles(t, db, 1)
q.SetQueue(paths, 0, false)
q.Play()
q.OnPlaybackFinished()
if q.GetState().CurrentIndex != -1 {
t.Errorf(
"currentIndex: got %d, want -1",
q.GetState().CurrentIndex,
)
}
// H-18: the bar used to blank while the queue panel still listed
// what had just played.
if loader.unloaded != 0 {
t.Errorf("player unloaded %d times, want 0", loader.unloaded)
}
}
func TestQueueExhausted_UnloadsWhenTheTrackIsRemoved(t *testing.T) {
t.Parallel()
q, db, _, loader := setupFailingQueue(t)
paths := seedAudioFiles(t, db, 1)
q.SetQueue(paths, 0, false)
q.RemoveTrack(0)
// Nothing left to show, so the bar clears.
if loader.unloaded == 0 {
t.Error("player not unloaded after its track left the queue")
}
}
+59 -12
View File
@@ -113,6 +113,16 @@ type ModeChanged struct {
RepeatMode RepeatMode `json:"repeatMode"`
}
// PlaybackFailure is the payload for the PlaybackFailed event. It
// carries enough to name the track in a message without the frontend
// having to look anything up, and the raw reason for the log.
type PlaybackFailure struct {
FilePath string `json:"filePath"`
Title string `json:"title"`
Artist string `json:"artist"`
Reason string `json:"reason"`
}
// TracksModified is the payload for the QueueTracksModified event.
type TracksModified struct {
Action string `json:"action"`
@@ -910,16 +920,15 @@ func (q *Queue) Next() {
nextIdx := q.nextIndex()
if nextIdx == -1 {
q.onQueueExhausted()
q.onQueueExhausted(false)
return
}
prevIndex := q.currentIndex
q.currentIndex = nextIdx
if !q.playOrLoadCurrentTrack(wasPlaying) {
q.currentIndex = prevIndex
if !q.playCurrentOrSkip(wasPlaying, q.nextIndex) {
q.onQueueExhausted(false)
return
}
@@ -974,7 +983,9 @@ func (q *Queue) Previous() {
prevCurrentIndex := q.currentIndex
q.currentIndex = prevIdx
if !q.playOrLoadCurrentTrack(wasPlaying) {
// Backwards for the same reason Next skips forwards: otherwise a
// bad file behind you makes Previous a button that does nothing.
if !q.playCurrentOrSkip(wasPlaying, q.previousIndex) {
q.currentIndex = prevCurrentIndex
return
@@ -1041,7 +1052,7 @@ func (q *Queue) playFromStart() {
q.currentIndex = 0
}
if !q.playCurrentTrack() {
if !q.playCurrentOrSkip(true, q.nextIndex) {
q.currentIndex = -1
return
@@ -1208,6 +1219,7 @@ func (q *Queue) loadCurrentTrack() bool {
"Failed to load file from queue",
"filePath", track.FilePath, "err", err,
)
q.emitPlaybackFailed(track, err)
return false
}
@@ -1231,6 +1243,7 @@ func (q *Queue) playCurrentTrack() bool {
"Failed to play file from queue",
"filePath", track.FilePath, "err", err,
)
q.emitPlaybackFailed(track, err)
return false
}
@@ -1238,12 +1251,39 @@ func (q *Queue) playCurrentTrack() bool {
return true
}
// playCurrentOrSkip plays (or loads) the track at currentIndex, and on
// failure steps to the next candidate rather than giving up. A moved
// or unreadable file in the middle of a queue used to stop playback
// dead, and Next did not help because it hit the same track and
// reverted.
//
// The attempt count is bounded by the queue length so a queue whose
// files have all gone (a disconnected drive) stops after one pass
// instead of spinning forever through a RepeatAll wrap. Returns false
// when nothing reachable can be played.
func (q *Queue) playCurrentOrSkip(autoPlay bool, step func() int) bool {
for range len(q.tracks) {
if q.playOrLoadCurrentTrack(autoPlay) {
return true
}
next := step()
if next == -1 {
return false
}
q.currentIndex = next
}
return false
}
// handleCurrentTrackRemoved handles the case where the currently loaded
// track was removed from the queue. If tracks remain it loads the track
// now at currentIndex (paused); otherwise it exhausts the queue.
func (q *Queue) handleCurrentTrackRemoved() {
if len(q.tracks) == 0 {
q.onQueueExhausted()
q.onQueueExhausted(true)
return
}
@@ -1252,14 +1292,21 @@ func (q *Queue) handleCurrentTrackRemoved() {
}
// onQueueExhausted is called when there are no more tracks to play.
// It unloads the current track, resets the index to -1 (no current track),
// and notifies the frontend.
func (q *Queue) onQueueExhausted() {
q.logger.Info("Queue exhausted, unloading track")
// It resets the index to -1 (no current track) and notifies the
// frontend.
//
// unload says whether the player should also let go of the file it
// holds. When the queue simply ran out, it should not: the finished
// track stays on the now-playing bar, paused at 0:00, rather than the
// bar blanking while the queue panel still lists what just played
// (H-18). When the current track was removed from the queue, or the
// queue was cleared, there is nothing left to show and it does.
func (q *Queue) onQueueExhausted(unload bool) {
q.logger.Info("Queue exhausted", "unload", unload)
q.currentIndex = -1
if q.player != nil {
if unload && q.player != nil {
q.player.UnloadTrack()
}