diff --git a/cmd/mxl-player/main.go b/cmd/mxl-player/main.go index f7b7cf0..5bbdf50 100644 --- a/cmd/mxl-player/main.go +++ b/cmd/mxl-player/main.go @@ -342,30 +342,31 @@ func main() { videoActive = videoStr != "" audioActive = audioStr != "" - if videoStr == "" { - enqueueCommand(playback.SessionCommand{Kind: playback.CommandRemoveVideo}) - } else { - enqueueCommand(playback.SessionCommand{ - Kind: playback.CommandSetVideo, - Config: playback.FeedConfig{ - Domain: videoDomainStr, - UUID: videoStr, - Active: true, - }, - }) + videoConfig := playback.FeedConfig{} + if videoActive { + videoConfig = playback.FeedConfig{ + Domain: videoDomainStr, + UUID: videoStr, + Active: true, + } } - if audioStr == "" { - enqueueCommand(playback.SessionCommand{Kind: playback.CommandRemoveAudio}) - } else { - enqueueCommand(playback.SessionCommand{ - Kind: playback.CommandSetAudio, - Config: playback.FeedConfig{ - Domain: audioDomainStr, - UUID: audioStr, - Active: true, - }, - }) + audioConfig := playback.FeedConfig{} + if audioActive { + audioConfig = playback.FeedConfig{ + Domain: audioDomainStr, + UUID: audioStr, + Active: true, + } } + + enqueueCommand(playback.SessionCommand{ + Kind: playback.CommandSetSession, + Session: playback.SessionConfig{ + Video: videoConfig, + Audio: audioConfig, + SyncRequested: syncRequested, + }, + }) } playbackDone := make(chan error, 1) diff --git a/cmd/mxl-player/playback_runtime.go b/cmd/mxl-player/playback_runtime.go index 8291fa1..151514f 100644 --- a/cmd/mxl-player/playback_runtime.go +++ b/cmd/mxl-player/playback_runtime.go @@ -8,12 +8,19 @@ import ( "mxl-player/internal/playback" ) +type playerAudioSink interface { + playback.AudioSink + Close() error +} + +var _ playerAudioSink = (*output.SDLAudioSink)(nil) + type playerPlayback struct { Controller *playback.SessionController Commands chan playback.SessionCommand Video *playback.VideoBridge Status *playback.StatusStore - Audio *output.SDLAudioSink + Audio playerAudioSink } func newPlayerPlayback( diff --git a/imgui.ini b/imgui.ini index 564fc96..883190a 100644 --- a/imgui.ini +++ b/imgui.ini @@ -14,7 +14,7 @@ Size=200,200 Collapsed=0 [Window][Connection] -Pos=322,387 -Size=618,275 +Pos=322,130 +Size=661,444 Collapsed=0 diff --git a/internal/playback/session_command.go b/internal/playback/session_command.go index 38f0b5d..71d6ed3 100644 --- a/internal/playback/session_command.go +++ b/internal/playback/session_command.go @@ -20,11 +20,13 @@ const ( CommandRemoveAudio CommandEnableSync CommandDisableSync + CommandSetSession ) type SessionCommand struct { - Kind SessionCommandKind - Config FeedConfig // Used only by SetVideo and SetAudio. + Kind SessionCommandKind + Config FeedConfig // Used only by SetVideo and SetAudio. + Session SessionConfig // Used only by SetSession. } var ( @@ -99,6 +101,12 @@ func ApplySessionCommand( case CommandDisableSync: next.SyncRequested = false + case CommandSetSession: + next = command.Session + // Retry policy belongs to the running controller configuration, not to + // GUI or playlist session selections. + next.Retry = current.Retry + default: return current, ErrUnknownSessionCommand } diff --git a/internal/playback/session_command_test.go b/internal/playback/session_command_test.go index a5bf098..bc828f9 100644 --- a/internal/playback/session_command_test.go +++ b/internal/playback/session_command_test.go @@ -114,6 +114,27 @@ func TestApplySessionCommand(t *testing.T) { command: SessionCommand{Kind: CommandDisableSync}, want: func() SessionConfig { c := base; c.SyncRequested = false; return c }(), }, + { + name: "set complete session atomically and preserve retry", + current: base, + command: SessionCommand{ + Kind: CommandSetSession, + Session: SessionConfig{ + Video: newVideo, + Audio: newAudio, + SyncRequested: false, + Retry: RetryPolicy{ + MaxAttempts: 99, + }, + }, + }, + want: SessionConfig{ + Video: newVideo, + Audio: newAudio, + SyncRequested: false, + Retry: base.Retry, + }, + }, } for _, tt := range tests { @@ -157,6 +178,17 @@ func TestApplySessionCommandFailurePreservesState(t *testing.T) { ErrAudioNotConfigured, }, {"unknown command", base, SessionCommand{Kind: 255}, ErrUnknownSessionCommand}, + { + "invalid complete session", + base, + SessionCommand{ + Kind: CommandSetSession, + Session: SessionConfig{ + Video: FeedConfig{UUID: "video", Active: true}, + }, + }, + ErrFeedDomainRequired, + }, } for _, tt := range tests { diff --git a/internal/playback/session_controller_test.go b/internal/playback/session_controller_test.go index 68dcdc4..89f2bf6 100644 --- a/internal/playback/session_controller_test.go +++ b/internal/playback/session_controller_test.go @@ -240,6 +240,39 @@ func TestSessionControllerUpdatesOnlyChangedIndependentSlot(t *testing.T) { } } +func TestSessionControllerReplacesSynchronizedPairWithOneCommand(t *testing.T) { + events := make(chan controllerEvent, 32) + controller := newRecordingController(t, events) + initial := validCommandSession() + commands := make(chan SessionCommand) + done := make(chan error, 1) + go func() { done <- controller.Run(context.Background(), initial, commands) }() + start := receiveControllerEvent(t, events) + if start.unit != UnitSync || start.action != "start" { + t.Fatalf("initial event = %+v, want sync start", start) + } + + next := initial + next.Video = FeedConfig{Domain: "/next", UUID: "next-video", Active: true} + next.Audio = FeedConfig{Domain: "/next", UUID: "next-audio", Active: true} + commands <- SessionCommand{Kind: CommandSetSession, Session: next} + event := receiveControllerEvent(t, events) + wantPair := SyncPairConfig{Video: next.Video, Audio: next.Audio} + if event.unit != UnitSync || event.action != "command" || event.pair != wantPair { + t.Fatalf("replacement event = %+v, want one sync command for %#v", event, wantPair) + } + select { + case event := <-events: + t.Fatalf("atomic replacement emitted an extra event: %+v", event) + case <-time.After(20 * time.Millisecond): + } + + close(commands) + if err := <-done; err != nil { + t.Fatalf("Run() error = %v", err) + } +} + func TestSessionControllerStopsIndependentSlotsBeforeStartingSync(t *testing.T) { events := make(chan controllerEvent, 32) controller := newRecordingController(t, events)