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:
@@ -0,0 +1,85 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"yellowjacket/backend/events"
|
||||
)
|
||||
|
||||
// recordedPlayer is a player with an event sink installed and no audio
|
||||
// device: enough to assert on what the frontend would receive from the
|
||||
// paths that do not touch the speaker.
|
||||
func recordedPlayer(t *testing.T) (*Player, *events.Recorder) {
|
||||
t.Helper()
|
||||
|
||||
p := NewPlayer(slog.Default(), nil)
|
||||
rec := events.NewRecorder()
|
||||
p.SetContext(events.WithSink(t.Context(), rec))
|
||||
|
||||
return p, rec
|
||||
}
|
||||
|
||||
func TestSeek_WithNoTrackEmitsSeekFailed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
p, rec := recordedPlayer(t)
|
||||
|
||||
if err := p.Seek(5); err == nil {
|
||||
t.Fatal("Seek with no track loaded returned nil error")
|
||||
}
|
||||
|
||||
// C2: the frontend has made an optimistic move it now has to take
|
||||
// back, and this is the only thing that tells it so.
|
||||
if _, ok := rec.Last(events.SeekFailed); !ok {
|
||||
t.Errorf("no SeekFailed emitted; got %v", rec.Names())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPositionTicker_SilentWhileNotPlaying(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, rec := recordedPlayer(t)
|
||||
|
||||
// The ticker is running (SetContext started it) but nothing is
|
||||
// playing, so a paused app must not push a position a second
|
||||
// forever.
|
||||
time.Sleep(positionTickInterval * 2)
|
||||
|
||||
if got := rec.Count(events.PlaybackPositionChanged); got != 0 {
|
||||
t.Errorf("position emitted %d times while stopped, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitPosition_CarriesLengthAndSequence(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
p := NewPlayer(slog.Default(), nil)
|
||||
rec := events.NewRecorder()
|
||||
p.ctx = events.WithSink(context.Background(), rec)
|
||||
|
||||
p.mu.Lock()
|
||||
p.emitPositionLocked()
|
||||
p.emitPositionLocked()
|
||||
p.mu.Unlock()
|
||||
|
||||
ticks := rec.Named(events.PlaybackPositionChanged)
|
||||
if len(ticks) != 2 {
|
||||
t.Fatalf("emitted %d positions, want 2", len(ticks))
|
||||
}
|
||||
|
||||
first, ok := ticks[0].Payload().(PositionInfo)
|
||||
if !ok {
|
||||
t.Fatalf("payload is %T, want player.PositionInfo", ticks[0].Payload())
|
||||
}
|
||||
|
||||
second, _ := ticks[1].Payload().(PositionInfo)
|
||||
|
||||
// The sequence is what lets the seek bar reset its interpolation
|
||||
// on a tick that reports the same second twice.
|
||||
if second.Seq <= first.Seq {
|
||||
t.Errorf("seq did not advance: %d then %d", first.Seq, second.Seq)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user