Compare commits

..

2 Commits

Author SHA1 Message Date
Dmitry Sergeev a4626fbb19 Made the Connection panel more usable for runtime testing 2026-09-01 01:28:36 +03:00
Dmitry Sergeev 047f334413 Implemented atomic session replacement. 2026-09-01 01:22:03 +03:00
8 changed files with 240 additions and 67 deletions
+116 -53
View File
@@ -342,30 +342,53 @@ func main() {
videoActive = videoStr != ""
audioActive = audioStr != ""
if videoStr == "" {
enqueueCommand(playback.SessionCommand{Kind: playback.CommandRemoveVideo})
} else {
enqueueCommand(playback.SessionCommand{
Kind: playback.CommandSetVideo,
Config: playback.FeedConfig{
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{
}
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,
},
})
}
drawUnitStatus := func(label string, unit playback.Unit) {
status, ok := statusStore.Snapshot(unit)
if !ok {
cimgui.Text(fmt.Sprintf("%s: not started", label))
return
}
cimgui.Text(fmt.Sprintf("%s: %s", label, status.State))
cimgui.Text(fmt.Sprintf(
"Attempt: %d, failed: %d",
status.Attempt,
status.FailedAttempts,
))
if status.RetryIn > 0 {
cimgui.Text(fmt.Sprintf(
"Retry in: %s",
status.RetryIn.Round(time.Millisecond),
))
}
if status.Err != nil {
cimgui.TextWrapped(status.Err.Error())
}
}
playbackDone := make(chan error, 1)
@@ -523,20 +546,48 @@ func main() {
cimgui.End()
}
cimgui.Begin("Connection")
cimgui.InputTextWithHint("Video domain", "/dev/shm/mxl", &videoDomainStr, 0, nil)
cimgui.InputTextWithHint("Audio domain", "/dev/shm/mxl", &audioDomainStr, 0, nil)
cimgui.InputTextWithHint("Video UUID", "", &videoStr, 0, nil)
cimgui.InputTextWithHint("Audio UUID", "", &audioStr, 0, nil)
if snapshot, ok := player.Controller.Snapshot(); ok {
snapshot, hasSnapshot := player.Controller.Snapshot()
videoConfigured := videoStr != ""
audioConfigured := audioStr != ""
if hasSnapshot {
videoActive = snapshot.Desired.Video.Active
audioActive = snapshot.Desired.Audio.Active
videoConfigured = snapshot.Desired.Video.IsConfigured()
audioConfigured = snapshot.Desired.Audio.IsConfigured()
syncRequested = snapshot.Desired.SyncRequested
cimgui.Text(fmt.Sprintf(
"Topology: %s (generation %d)",
snapshot.Plan.Topology,
snapshot.Generation,
))
} else {
cimgui.Text("Topology: starting")
}
if cimgui.Button("Connect") {
cimgui.Separator()
cimgui.InputTextWithHint("Video domain", "/dev/shm/mxl", &videoDomainStr, 0, nil)
cimgui.InputTextWithHint("Video UUID", "", &videoStr, 0, nil)
cimgui.InputTextWithHint("Audio domain", "/dev/shm/mxl", &audioDomainStr, 0, nil)
cimgui.InputTextWithHint("Audio UUID", "", &audioStr, 0, nil)
if cimgui.Button("Apply feeds") {
doReconnect()
}
cimgui.SameLine()
cimgui.Checkbox("Show stats", &showStats)
if videoActive || audioActive {
if cimgui.Button("Stop all") {
enqueueCommand(playback.SessionCommand{Kind: playback.CommandStopAll})
}
}
if (videoConfigured && !videoActive) || (audioConfigured && !audioActive) {
if videoActive || audioActive {
cimgui.SameLine()
}
if cimgui.Button("Resume all") {
enqueueCommand(playback.SessionCommand{Kind: playback.CommandResumeAll})
}
}
if cimgui.Checkbox("Synchronize", &syncRequested) {
kind := playback.CommandDisableSync
if syncRequested {
@@ -544,20 +595,36 @@ func main() {
}
enqueueCommand(playback.SessionCommand{Kind: kind})
}
if hasSnapshot && syncRequested && snapshot.Plan.Topology != playback.TopologySynchronized {
switch {
case !videoConfigured || !audioConfigured:
cimgui.TextWrapped("Sync requested: waiting for both feeds to be configured.")
case !videoActive || !audioActive:
cimgui.TextWrapped("Sync requested: waiting for both feeds to be active.")
case snapshot.Desired.Video.Domain != snapshot.Desired.Audio.Domain:
cimgui.TextWrapped("Sync requested, but native MXL sync requires matching domains. Playing independently.")
default:
cimgui.TextWrapped("Sync requested but currently unavailable. Playing independently.")
}
}
cimgui.Separator()
cimgui.Text("Video")
if videoActive {
if cimgui.Button("Stop video") {
videoActive = false
enqueueCommand(playback.SessionCommand{Kind: playback.CommandStopVideo})
}
}
if !videoActive && videoStr != "" {
if !videoActive && videoConfigured {
cimgui.SameLine()
if cimgui.Button("Resume video") {
videoActive = true
enqueueCommand(playback.SessionCommand{Kind: playback.CommandResumeVideo})
}
}
if videoStr != "" {
if videoConfigured {
cimgui.SameLine()
if cimgui.Button("Remove video") {
videoActive = false
videoStr = ""
@@ -566,24 +633,14 @@ func main() {
}
if videoActive {
cimgui.Text("Video desired: active")
} else if videoStr != "" {
} else if videoConfigured {
cimgui.Text("Video desired: stopped")
} else {
cimgui.Text("Video desired: not configured")
}
if status, ok := statusStore.Snapshot(playback.UnitVideo); ok {
cimgui.Text(fmt.Sprintf("Video actual: %s", status.State))
cimgui.Text(fmt.Sprintf("Attempt: %d, failed: %d", status.Attempt, status.FailedAttempts))
if status.RetryIn > 0 {
cimgui.Text(fmt.Sprintf("Retry in: %s", status.RetryIn.Round(time.Millisecond)))
}
if status.Err != nil {
cimgui.TextWrapped(status.Err.Error())
}
} else {
cimgui.Text("Video actual: not started")
}
cimgui.Separator()
cimgui.Text("Audio")
if audioActive {
if cimgui.Button("Stop audio") {
audioActive = false
@@ -591,14 +648,16 @@ func main() {
}
}
if !audioActive && audioStr != "" {
if !audioActive && audioConfigured {
cimgui.SameLine()
if cimgui.Button("Resume audio") {
audioActive = true
enqueueCommand(playback.SessionCommand{Kind: playback.CommandResumeAudio})
}
}
if audioStr != "" {
if audioConfigured {
cimgui.SameLine()
if cimgui.Button("Remove audio") {
audioActive = false
audioStr = ""
@@ -608,31 +667,35 @@ func main() {
if audioActive {
cimgui.Text("Audio desired: active")
} else if audioStr != "" {
} else if audioConfigured {
cimgui.Text("Audio desired: stopped")
} else {
cimgui.Text("Audio desired: not configured")
}
if status, ok := statusStore.Snapshot(playback.UnitAudio); ok {
cimgui.Text(fmt.Sprintf("Audio actual: %s", status.State))
cimgui.Text(fmt.Sprintf("Attempt: %d, failed: %d", status.Attempt, status.FailedAttempts))
if status.RetryIn > 0 {
cimgui.Text(fmt.Sprintf("Retry in: %s", status.RetryIn.Round(time.Millisecond)))
cimgui.Separator()
cimgui.Text("Current playback")
if hasSnapshot {
switch snapshot.Plan.Topology {
case playback.TopologySynchronized:
drawUnitStatus("Synchronized group", playback.UnitSync)
case playback.TopologyIndependent:
if snapshot.Plan.Video.Active {
drawUnitStatus("Video", playback.UnitVideo)
}
if status.Err != nil {
cimgui.TextWrapped(status.Err.Error())
if snapshot.Plan.Audio.Active {
drawUnitStatus("Audio", playback.UnitAudio)
}
case playback.TopologyIdle:
cimgui.Text("No active feeds")
}
} else {
cimgui.Text("Audio actual: not started")
}
if status, ok := statusStore.Snapshot(playback.UnitSync); ok {
cimgui.Text(fmt.Sprintf("Sync actual: %s", status.State))
if status.Err != nil {
cimgui.TextWrapped(status.Err.Error())
}
cimgui.Text("Playback controller is starting")
}
cimgui.Separator()
cimgui.Checkbox("Show stats", &showStats)
cimgui.End()
gui.EndFrame()
lastFrame = time.Now()
+8 -1
View File
@@ -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(
+2 -2
View File
@@ -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
+8
View File
@@ -20,11 +20,13 @@ const (
CommandRemoveAudio
CommandEnableSync
CommandDisableSync
CommandSetSession
)
type SessionCommand struct {
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
}
+32
View File
@@ -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 {
@@ -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)
+13
View File
@@ -10,6 +10,19 @@ const (
TopologySynchronized
)
func (t SessionTopology) String() string {
switch t {
case TopologyIdle:
return "idle"
case TopologyIndependent:
return "independent"
case TopologySynchronized:
return "synchronized"
default:
return fmt.Sprintf("SessionTopology(%d)", uint8(t))
}
}
type SyncPredicate func(video, audio FeedConfig) bool
type SessionPlan struct {
+17
View File
@@ -5,6 +5,23 @@ import (
"testing"
)
func TestSessionTopologyString(t *testing.T) {
tests := []struct {
topology SessionTopology
want string
}{
{TopologyIdle, "idle"},
{TopologyIndependent, "independent"},
{TopologySynchronized, "synchronized"},
{SessionTopology(99), "SessionTopology(99)"},
}
for _, tt := range tests {
if got := tt.topology.String(); got != tt.want {
t.Errorf("%d.String() = %q, want %q", tt.topology, got, tt.want)
}
}
}
func TestBuildSessionPlan(t *testing.T) {
base := validCommandSession()
stoppedVideo := stoppedFeed(base.Video)