diff --git a/cmd/mxl-player/main.go b/cmd/mxl-player/main.go index bc76fbc..d6b7fad 100644 --- a/cmd/mxl-player/main.go +++ b/cmd/mxl-player/main.go @@ -753,6 +753,21 @@ func main() { if cimgui.Button("Next##playlist") && !playlistRuntime.Next() { log.Print("playlist command queue is full") } + if hasPlaylistSnapshot && + playlistSnapshot.State.HasSelection && + playlistSnapshot.Entry.Duration > 0 && + !playlistSnapshot.Timing.Expired { + cimgui.SameLine() + if playlistSnapshot.Timing.Paused { + if cimgui.Button("Resume timer##playlist") && + !playlistRuntime.Resume() { + log.Print("playlist command queue is full") + } + } else if cimgui.Button("Pause timer##playlist") && + !playlistRuntime.Pause() { + log.Print("playlist command queue is full") + } + } if hasPlaylistSnapshot && playlistSnapshot.State.HasSelection { entry := playlistSnapshot.Entry @@ -786,6 +801,17 @@ func main() { switch { case entry.Duration == 0: cimgui.Text("Timing: manual advance") + case playlistSnapshot.Timing.Paused: + fraction, remaining := playlistTimingProgress( + playlistSnapshot.Timing, + time.Now(), + ) + cimgui.Text("Timing: paused") + cimgui.ProgressBarV( + fraction, + cimgui.Vec2{X: -1, Y: 0}, + remaining.Round(time.Second).String(), + ) case playlistSnapshot.Timing.Started: fraction, remaining := playlistTimingProgress( playlistSnapshot.Timing, diff --git a/cmd/mxl-player/playlist_runtime.go b/cmd/mxl-player/playlist_runtime.go index bac0ab8..9aa6ed0 100644 --- a/cmd/mxl-player/playlist_runtime.go +++ b/cmd/mxl-player/playlist_runtime.go @@ -111,6 +111,14 @@ func (p *playerPlaylist) Previous() bool { return p.enqueue(playback.PlaylistCommand{Kind: playback.PlaylistPrevious}) } +func (p *playerPlaylist) Pause() bool { + return p.enqueue(playback.PlaylistCommand{Kind: playback.PlaylistPause}) +} + +func (p *playerPlaylist) Resume() bool { + return p.enqueue(playback.PlaylistCommand{Kind: playback.PlaylistResume}) +} + func (p *playerPlaylist) enqueue(command playback.PlaylistCommand) bool { select { case p.Commands <- command: @@ -154,11 +162,14 @@ func playlistTimingProgress( if timing.Expired { return 1, 0 } - if !timing.Started { + remaining := timing.Remaining + if timing.Paused { + // Retain the remaining time captured when the owned timer stopped. + } else if !timing.Started { return 0, timing.Duration + } else { + remaining = timing.Deadline.Sub(now) } - - remaining := timing.Deadline.Sub(now) if remaining < 0 { remaining = 0 } diff --git a/cmd/mxl-player/playlist_runtime_test.go b/cmd/mxl-player/playlist_runtime_test.go index 50cebf2..19ebc69 100644 --- a/cmd/mxl-player/playlist_runtime_test.go +++ b/cmd/mxl-player/playlist_runtime_test.go @@ -3,6 +3,7 @@ package main import ( "context" "errors" + "math" "testing" "time" @@ -127,6 +128,8 @@ func TestPlayerPlaylistNavigationHelpers(t *testing.T) { {send: func() bool { return runtime.Select(4) }, want: playback.PlaylistCommand{Kind: playback.PlaylistSelect, Index: 4}}, {send: runtime.Next, want: playback.PlaylistCommand{Kind: playback.PlaylistNext}}, {send: runtime.Previous, want: playback.PlaylistCommand{Kind: playback.PlaylistPrevious}}, + {send: runtime.Pause, want: playback.PlaylistCommand{Kind: playback.PlaylistPause}}, + {send: runtime.Resume, want: playback.PlaylistCommand{Kind: playback.PlaylistResume}}, } for _, test := range tests { if !test.send() { @@ -344,6 +347,16 @@ func TestPlaylistTimingProgress(t *testing.T) { timing: playback.PlaylistTimingState{Duration: 10 * time.Second, Expired: true}, wantFraction: 1, }, + { + name: "paused", + timing: playback.PlaylistTimingState{ + Duration: 10 * time.Second, + Paused: true, + Remaining: 6 * time.Second, + }, + wantFraction: 0.4, + wantRemaining: 6 * time.Second, + }, { name: "deadline passed", timing: playback.PlaylistTimingState{ @@ -358,7 +371,8 @@ func TestPlaylistTimingProgress(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { fraction, remaining := playlistTimingProgress(test.timing, now) - if fraction != test.wantFraction || remaining != test.wantRemaining { + if math.Abs(float64(fraction-test.wantFraction)) > 0.000001 || + remaining != test.wantRemaining { t.Fatalf( "playlistTimingProgress() = %v, %v; want %v, %v", fraction, diff --git a/internal/playback/playlist_controller.go b/internal/playback/playlist_controller.go index 3cf6825..faafd2b 100644 --- a/internal/playback/playlist_controller.go +++ b/internal/playback/playlist_controller.go @@ -102,6 +102,36 @@ func (c *PlaylistController) Run( if !ok { return nil } + if command.Kind == PlaylistPause { + nextTiming, changed := PausePlaylistTiming( + timing, + revision, + c.now(), + ) + if changed { + stopTimer() + timing = nextTiming + c.publish(state, revision, timing) + } + continue + } + if command.Kind == PlaylistResume { + nextTiming, changed := ResumePlaylistTiming( + timing, + revision, + c.now(), + ) + if changed { + timing = nextTiming + if timing.Started { + timerRevision = timing.Revision + timer = c.newTimer(timing.Remaining) + timerC = timer.C() + } + c.publish(state, revision, timing) + } + continue + } next, sessionCommand, apply, err := ApplyPlaylistSelection( c.playlist, @@ -132,6 +162,14 @@ func (c *PlaylistController) Run( readiness = nil continue } + if timing.Paused && + ready.Revision == timing.Revision && + timing.Duration > 0 && + !timing.Expired { + timing.Ready = true + c.publish(state, revision, timing) + continue + } nextTiming, started := StartPlaylistTiming( timing, ready.Revision, diff --git a/internal/playback/playlist_controller_timing_test.go b/internal/playback/playlist_controller_timing_test.go index 3cdc502..56e6ad7 100644 --- a/internal/playback/playlist_controller_timing_test.go +++ b/internal/playback/playlist_controller_timing_test.go @@ -255,6 +255,113 @@ func TestPlaylistControllerCancellationStopsTimer(t *testing.T) { } } +func TestPlaylistControllerPauseAndResumeTimer(t *testing.T) { + controller, commands, readiness, sessions, timers, now, cancel, result := + startTimedPlaylistController(t, timedPlaylist(false)) + defer cancel() + + commands <- PlaylistCommand{Kind: PlaylistNext} + _ = receivePlaylistSession(t, sessions) + readiness <- PlaylistReadiness{Revision: 1} + oldTimer := receiveFakePlaylistTimer(t, timers) + waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { + return snapshot.Timing.Started + }) + + commands <- PlaylistCommand{Kind: PlaylistPause} + paused := waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { + return snapshot.Timing.Paused + }) + if paused.Revision != 1 || paused.Timing.Started { + t.Fatalf("paused snapshot = %#v", paused) + } + if !oldTimer.isStopped() { + t.Fatal("Pause did not stop active timer") + } + oldTimer.fire(now.Add(10 * time.Second)) + select { + case command := <-sessions: + t.Fatalf("paused stale timer sent session command: %#v", command) + case <-time.After(20 * time.Millisecond): + } + + commands <- PlaylistCommand{Kind: PlaylistResume} + _ = receiveFakePlaylistTimer(t, timers) + resumed := waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { + return snapshot.Timing.Started && !snapshot.Timing.Paused + }) + if resumed.Revision != 1 { + t.Fatalf("resume changed revision: %#v", resumed) + } + select { + case command := <-sessions: + t.Fatalf("pause/resume sent session command: %#v", command) + default: + } + + close(commands) + if err := waitForPlaylistResult(t, result); err != nil { + t.Fatalf("Run() error = %v", err) + } +} + +func TestPlaylistControllerManualSelectionClearsPause(t *testing.T) { + controller, commands, _, sessions, _, _, cancel, result := + startTimedPlaylistController(t, timedPlaylist(false)) + defer cancel() + + commands <- PlaylistCommand{Kind: PlaylistNext} + _ = receivePlaylistSession(t, sessions) + commands <- PlaylistCommand{Kind: PlaylistPause} + waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { + return snapshot.Timing.Paused + }) + + commands <- PlaylistCommand{Kind: PlaylistNext} + _ = receivePlaylistSession(t, sessions) + next := waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { + return snapshot.Revision == 2 + }) + if next.Timing.Paused || next.State.CurrentIndex != 1 { + t.Fatalf("new selection retained pause: %#v", next) + } + + close(commands) + if err := waitForPlaylistResult(t, result); err != nil { + t.Fatalf("Run() error = %v", err) + } +} + +func TestPlaylistControllerRecordsQueuedReadinessWhilePaused(t *testing.T) { + controller, commands, readiness, sessions, timers, _, cancel, result := + startTimedPlaylistController(t, timedPlaylist(false)) + defer cancel() + + commands <- PlaylistCommand{Kind: PlaylistNext} + _ = receivePlaylistSession(t, sessions) + commands <- PlaylistCommand{Kind: PlaylistPause} + waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { + return snapshot.Timing.Paused + }) + + readiness <- PlaylistReadiness{Revision: 1} + waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { + return snapshot.Timing.Paused && snapshot.Timing.Ready + }) + assertNoPlaylistTimer(t, timers) + + commands <- PlaylistCommand{Kind: PlaylistResume} + _ = receiveFakePlaylistTimer(t, timers) + waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { + return snapshot.Timing.Started && !snapshot.Timing.Paused + }) + + close(commands) + if err := waitForPlaylistResult(t, result); err != nil { + t.Fatalf("Run() error = %v", err) + } +} + func startTimedPlaylistController( t *testing.T, playlist Playlist, diff --git a/internal/playback/playlist_navigation.go b/internal/playback/playlist_navigation.go index 1edb78e..b0043b1 100644 --- a/internal/playback/playlist_navigation.go +++ b/internal/playback/playlist_navigation.go @@ -8,6 +8,8 @@ const ( PlaylistSelect PlaylistCommandKind = iota + 1 PlaylistNext PlaylistPrevious + PlaylistPause + PlaylistResume ) type PlaylistCommand struct { diff --git a/internal/playback/playlist_readiness.go b/internal/playback/playlist_readiness.go index 143a3bb..bd8d336 100644 --- a/internal/playback/playlist_readiness.go +++ b/internal/playback/playlist_readiness.go @@ -102,6 +102,7 @@ func (c *PlaylistReadinessCoordinator) Run(ctx context.Context) error { playlistSnapshot.Revision == 0 || playlistSnapshot.Entry.Duration <= 0 || playlistSnapshot.Timing.Started || + playlistSnapshot.Timing.Paused || playlistSnapshot.Revision == emittedRevision { continue } diff --git a/internal/playback/playlist_readiness_coordinator_test.go b/internal/playback/playlist_readiness_coordinator_test.go index d1a65c3..9620560 100644 --- a/internal/playback/playlist_readiness_coordinator_test.go +++ b/internal/playback/playlist_readiness_coordinator_test.go @@ -229,6 +229,7 @@ func TestPlaylistReadinessCoordinatorWaitsForAllConditions(t *testing.T) { {name: "zero revision", mutate: func() { value := playlistSnapshotForVideo(1); value.Revision = 0; playlist.set(value, true) }}, {name: "zero duration", mutate: func() { value := playlistSnapshotForVideo(1); value.Entry.Duration = 0; playlist.set(value, true) }}, {name: "already started", mutate: func() { value := playlistSnapshotForVideo(1); value.Timing.Started = true; playlist.set(value, true) }}, + {name: "paused", mutate: func() { value := playlistSnapshotForVideo(1); value.Timing.Paused = true; playlist.set(value, true) }}, {name: "session mismatch", mutate: func() { playlist.set(playlistSnapshotForVideo(1), true) value, _ := session.Snapshot() diff --git a/internal/playback/playlist_timing.go b/internal/playback/playlist_timing.go index 7b5d574..c99d7cd 100644 --- a/internal/playback/playlist_timing.go +++ b/internal/playback/playlist_timing.go @@ -3,11 +3,14 @@ package playback import "time" type PlaylistTimingState struct { - Revision uint64 - Duration time.Duration - Started bool - Expired bool - Deadline time.Time + Revision uint64 + Duration time.Duration + Ready bool + Started bool + Paused bool + Expired bool + Remaining time.Duration + Deadline time.Time } func NewPlaylistTiming( @@ -15,9 +18,9 @@ func NewPlaylistTiming( duration time.Duration, ) PlaylistTimingState { return PlaylistTimingState{ - Revision: revision, - Duration: duration, - Expired: false, + Revision: revision, + Duration: duration, + Remaining: duration, } } @@ -29,12 +32,66 @@ func StartPlaylistTiming( if revision != current.Revision || current.Duration <= 0 || current.Started || + current.Paused || current.Expired { return current, false } next := current + if next.Remaining <= 0 { + next.Remaining = next.Duration + } + next.Ready = true next.Started = true - next.Deadline = now.Add(current.Duration) + next.Deadline = now.Add(next.Remaining) + return next, true +} + +func PausePlaylistTiming( + current PlaylistTimingState, + revision uint64, + now time.Time, +) (PlaylistTimingState, bool) { + if revision != current.Revision || + current.Duration <= 0 || + current.Paused || + current.Expired { + return current, false + } + + next := current + if next.Started { + next.Remaining = next.Deadline.Sub(now) + if next.Remaining < 0 { + next.Remaining = 0 + } + if next.Remaining > next.Duration { + next.Remaining = next.Duration + } + next.Started = false + next.Deadline = time.Time{} + } + next.Paused = true + return next, true +} + +func ResumePlaylistTiming( + current PlaylistTimingState, + revision uint64, + now time.Time, +) (PlaylistTimingState, bool) { + if revision != current.Revision || + current.Duration <= 0 || + !current.Paused || + current.Expired { + return current, false + } + + next := current + next.Paused = false + if next.Ready { + next.Started = true + next.Deadline = now.Add(next.Remaining) + } return next, true } @@ -51,7 +108,9 @@ func ExpirePlaylistTiming( next := current next.Started = false + next.Paused = false next.Deadline = time.Time{} next.Expired = true + next.Remaining = 0 return next, true } diff --git a/internal/playback/playlist_timing_test.go b/internal/playback/playlist_timing_test.go index cc640b0..752aec9 100644 --- a/internal/playback/playlist_timing_test.go +++ b/internal/playback/playlist_timing_test.go @@ -7,7 +7,11 @@ import ( func TestNewPlaylistTimingResetsState(t *testing.T) { got := NewPlaylistTiming(7, 10*time.Second) - want := PlaylistTimingState{Revision: 7, Duration: 10 * time.Second} + want := PlaylistTimingState{ + Revision: 7, + Duration: 10 * time.Second, + Remaining: 10 * time.Second, + } if got != want { t.Fatalf("NewPlaylistTiming() = %#v, want %#v", got, want) } @@ -22,10 +26,12 @@ func TestStartPlaylistTiming(t *testing.T) { t.Fatal("StartPlaylistTiming() started = false, want true") } want := PlaylistTimingState{ - Revision: 4, - Duration: 10 * time.Second, - Started: true, - Deadline: now.Add(10 * time.Second), + Revision: 4, + Duration: 10 * time.Second, + Ready: true, + Started: true, + Remaining: 10 * time.Second, + Deadline: now.Add(10 * time.Second), } if got != want { t.Fatalf("StartPlaylistTiming() = %#v, want %#v", got, want) @@ -47,6 +53,7 @@ func TestStartPlaylistTimingIgnoresInapplicableReadiness(t *testing.T) { {name: "stale revision", current: NewPlaylistTiming(4, time.Second), revision: 3}, {name: "future revision", current: NewPlaylistTiming(4, time.Second), revision: 5}, {name: "already started", current: started, revision: 4}, + {name: "paused", current: PlaylistTimingState{Revision: 4, Duration: time.Second, Paused: true, Remaining: time.Second}, revision: 4}, {name: "expired", current: PlaylistTimingState{Revision: 4, Duration: time.Second, Expired: true}, revision: 4}, } @@ -71,7 +78,13 @@ func TestExpirePlaylistTiming(t *testing.T) { if !expired { t.Fatal("ExpirePlaylistTiming() expired = false, want true") } - want := PlaylistTimingState{Revision: 9, Duration: 5 * time.Second, Expired: true} + want := PlaylistTimingState{ + Revision: 9, + Duration: 5 * time.Second, + Ready: true, + Expired: true, + Remaining: 0, + } if got != want { t.Fatalf("ExpirePlaylistTiming() = %#v, want %#v", got, want) } @@ -118,3 +131,74 @@ func TestNewPlaylistTimingInvalidatesPreviousDeadline(t *testing.T) { t.Fatalf("old expiry changed new timing: %#v, %v", got, expired) } } + +func TestPauseAndResumePlaylistTimingBeforeReadiness(t *testing.T) { + now := time.Date(2026, time.September, 1, 12, 0, 0, 0, time.UTC) + current := NewPlaylistTiming(3, 10*time.Second) + + paused, changed := PausePlaylistTiming(current, 3, now) + if !changed || !paused.Paused || paused.Ready || paused.Started { + t.Fatalf("PausePlaylistTiming() = %#v, %v", paused, changed) + } + resumed, changed := ResumePlaylistTiming(paused, 3, now.Add(time.Second)) + if !changed || resumed.Paused || resumed.Ready || resumed.Started { + t.Fatalf("ResumePlaylistTiming() = %#v, %v", resumed, changed) + } + if resumed.Remaining != 10*time.Second { + t.Fatalf("remaining = %v, want 10s", resumed.Remaining) + } +} + +func TestPauseAndResumeActivePlaylistTimingUsesRemaining(t *testing.T) { + now := time.Date(2026, time.September, 1, 12, 0, 0, 0, time.UTC) + current, _ := StartPlaylistTiming(NewPlaylistTiming(5, 10*time.Second), 5, now) + + paused, changed := PausePlaylistTiming(current, 5, now.Add(4*time.Second)) + if !changed || !paused.Paused || paused.Started || !paused.Ready { + t.Fatalf("PausePlaylistTiming() = %#v, %v", paused, changed) + } + if paused.Remaining != 6*time.Second || !paused.Deadline.IsZero() { + t.Fatalf("paused timing = %#v, want 6s remaining and no deadline", paused) + } + + resumeAt := now.Add(20 * time.Second) + resumed, changed := ResumePlaylistTiming(paused, 5, resumeAt) + if !changed || resumed.Paused || !resumed.Started || !resumed.Ready { + t.Fatalf("ResumePlaylistTiming() = %#v, %v", resumed, changed) + } + if resumed.Deadline != resumeAt.Add(6*time.Second) { + t.Fatalf("resumed deadline = %v, want %v", resumed.Deadline, resumeAt.Add(6*time.Second)) + } +} + +func TestPauseAndResumePlaylistTimingIgnoreInvalidTransitions(t *testing.T) { + now := time.Now() + base := NewPlaylistTiming(2, time.Second) + paused, _ := PausePlaylistTiming(base, 2, now) + tests := []struct { + name string + state PlaylistTimingState + pause bool + revision uint64 + }{ + {name: "pause wrong revision", state: base, pause: true, revision: 1}, + {name: "duplicate pause", state: paused, pause: true, revision: 2}, + {name: "resume wrong revision", state: paused, revision: 1}, + {name: "duplicate resume", state: base, revision: 2}, + {name: "pause expired", state: PlaylistTimingState{Revision: 2, Duration: time.Second, Expired: true}, pause: true, revision: 2}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var got PlaylistTimingState + var changed bool + if test.pause { + got, changed = PausePlaylistTiming(test.state, test.revision, now) + } else { + got, changed = ResumePlaylistTiming(test.state, test.revision, now) + } + if changed || got != test.state { + t.Fatalf("transition = %#v, %v; want unchanged", got, changed) + } + }) + } +} diff --git a/sample-list.json b/sample-list.json index da5f5eb..de250aa 100644 --- a/sample-list.json +++ b/sample-list.json @@ -2,31 +2,31 @@ "loop": true, "entries": [ { - "name": "Synchronized feed", + "name": "timelapse", "video": { "domain": "/dev/shm/mxl", - "uuid": "video-uuid" + "uuid": "5fbec3b1-1b0f-417d-9059-8b94a47197ed" }, "audio": { "domain": "/dev/shm/mxl", - "uuid": "audio-uuid" + "uuid": "5fbec3b1-1b0f-417d-9059-8b94a47197ec" }, "sync": true, "duration": "10s" }, { - "name": "Video only", + "name": "F1", "video": { - "domain": "/another/domain", - "uuid": "video-uuid" + "domain": "/dev/shm/mxl", + "uuid": "5fbec3b1-1b0f-417d-9059-8b94a47197ef" }, "duration": "15s" }, { - "name": "Manual audio", + "name": "Costa Rica", "audio": { - "domain": "/dev/shm/audio", - "uuid": "audio-uuid" + "domain": "/dev/shm/mxl", + "uuid": "9d2a041b-01cf-4ee4-bffa-188fe093c99b" } } ]