From b0e7bc4cc3e8365b18c6f011e1e6cc52b2b56c3f Mon Sep 17 00:00:00 2001 From: Dmitry Sergeev Date: Tue, 1 Sep 2026 22:26:26 +0300 Subject: [PATCH] playlist GUI --- cmd/mxl-player/main.go | 104 ++++++++++++++++++++++ cmd/mxl-player/playlist_runtime.go | 39 ++++++++ cmd/mxl-player/playlist_runtime_test.go | 73 +++++++++++++++ imgui.ini | 4 +- internal/playback/playlist_timing.go | 9 +- internal/playback/playlist_timing_test.go | 3 +- sample-list.json | 33 +++++++ 7 files changed, 260 insertions(+), 5 deletions(-) create mode 100644 sample-list.json diff --git a/cmd/mxl-player/main.go b/cmd/mxl-player/main.go index c064608..bc76fbc 100644 --- a/cmd/mxl-player/main.go +++ b/cmd/mxl-player/main.go @@ -701,6 +701,110 @@ func main() { drawFeedsSections() } + if playlistRuntime != nil && + cimgui.CollapsingHeaderTreeNodeFlagsV("Playlist", collapsingHeaderFlags) { + playlistSnapshot, hasPlaylistSnapshot := + playlistRuntime.Controller.Snapshot() + + cimgui.TextWrapped(fmt.Sprintf("File: %s", args.PlaylistPath)) + cimgui.Text(fmt.Sprintf("Entries: %d", len(configuredPlaylist.Entries))) + if configuredPlaylist.Loop { + cimgui.Text("End behavior: loop") + } else { + cimgui.Text("End behavior: stop") + } + + preview := "No entry selected" + if hasPlaylistSnapshot && playlistSnapshot.State.HasSelection { + preview = playlistEntryDisplayName( + playlistSnapshot.Entry, + playlistSnapshot.State.CurrentIndex, + ) + } + if cimgui.BeginCombo("Entry##playlist", preview) { + for index, entry := range configuredPlaylist.Entries { + selected := hasPlaylistSnapshot && + playlistSnapshot.State.HasSelection && + playlistSnapshot.State.CurrentIndex == index + label := fmt.Sprintf( + "%s##playlist-entry-%d", + playlistEntryDisplayName(entry, index), + index, + ) + if cimgui.SelectableBoolV( + label, + selected, + cimgui.SelectableFlagsNone, + cimgui.Vec2{}, + ) && !playlistRuntime.Select(index) { + log.Print("playlist command queue is full") + } + if selected { + cimgui.SetItemDefaultFocus() + } + } + cimgui.EndCombo() + } + + if cimgui.Button("Previous##playlist") && !playlistRuntime.Previous() { + log.Print("playlist command queue is full") + } + cimgui.SameLine() + if cimgui.Button("Next##playlist") && !playlistRuntime.Next() { + log.Print("playlist command queue is full") + } + + if hasPlaylistSnapshot && playlistSnapshot.State.HasSelection { + entry := playlistSnapshot.Entry + cimgui.SeparatorText("Current entry") + cimgui.Text(fmt.Sprintf( + "%d of %d: %s", + playlistSnapshot.State.CurrentIndex+1, + len(configuredPlaylist.Entries), + playlistEntryDisplayName(entry, playlistSnapshot.State.CurrentIndex), + )) + if entry.Video.IsConfigured() { + cimgui.TextWrapped(fmt.Sprintf( + "Video: %s (%s)", + entry.Video.UUID, + entry.Video.Domain, + )) + } + if entry.Audio.IsConfigured() { + cimgui.TextWrapped(fmt.Sprintf( + "Audio: %s (%s)", + entry.Audio.UUID, + entry.Audio.Domain, + )) + } + if entry.SyncRequested { + cimgui.Text("Synchronization: requested") + } else { + cimgui.Text("Synchronization: independent") + } + + switch { + case entry.Duration == 0: + cimgui.Text("Timing: manual advance") + case playlistSnapshot.Timing.Started: + fraction, remaining := playlistTimingProgress( + playlistSnapshot.Timing, + time.Now(), + ) + cimgui.Text(fmt.Sprintf("Duration: %s", entry.Duration)) + cimgui.ProgressBarV( + fraction, + cimgui.Vec2{X: -1, Y: 0}, + remaining.Round(time.Second).String(), + ) + case playlistSnapshot.Timing.Expired: + cimgui.Text("Timing: finished") + default: + cimgui.Text("Timing: waiting for playback") + } + } + } + drawHotkeysSection := func() { cimgui.Text("F1 - show/hide stats") cimgui.Text("F2 - show/hide settings") diff --git a/cmd/mxl-player/playlist_runtime.go b/cmd/mxl-player/playlist_runtime.go index 52208c5..bac0ab8 100644 --- a/cmd/mxl-player/playlist_runtime.go +++ b/cmd/mxl-player/playlist_runtime.go @@ -3,6 +3,7 @@ package main import ( "context" "errors" + "fmt" "time" "mxl-player/internal/playback" @@ -135,3 +136,41 @@ func shouldAutoStartPlaylist( args.AudioFlowId == "" && len(playlist.Entries) > 0 } + +func playlistEntryDisplayName(entry playback.PlaylistEntry, index int) string { + if entry.Name != "" { + return entry.Name + } + return fmt.Sprintf("Entry %d", index+1) +} + +func playlistTimingProgress( + timing playback.PlaylistTimingState, + now time.Time, +) (float32, time.Duration) { + if timing.Duration <= 0 { + return 0, 0 + } + if timing.Expired { + return 1, 0 + } + if !timing.Started { + return 0, timing.Duration + } + + remaining := timing.Deadline.Sub(now) + if remaining < 0 { + remaining = 0 + } + if remaining > timing.Duration { + remaining = timing.Duration + } + fraction := 1 - float32(remaining)/float32(timing.Duration) + if fraction < 0 { + fraction = 0 + } + if fraction > 1 { + fraction = 1 + } + return fraction, remaining +} diff --git a/cmd/mxl-player/playlist_runtime_test.go b/cmd/mxl-player/playlist_runtime_test.go index 5c93180..50cebf2 100644 --- a/cmd/mxl-player/playlist_runtime_test.go +++ b/cmd/mxl-player/playlist_runtime_test.go @@ -298,6 +298,79 @@ func TestShouldAutoStartPlaylist(t *testing.T) { } } +func TestPlaylistEntryDisplayName(t *testing.T) { + tests := []struct { + entry playback.PlaylistEntry + index int + want string + }{ + {entry: playback.PlaylistEntry{Name: "News"}, index: 0, want: "News"}, + {entry: playback.PlaylistEntry{}, index: 0, want: "Entry 1"}, + {entry: playback.PlaylistEntry{}, index: 4, want: "Entry 5"}, + } + for _, test := range tests { + if got := playlistEntryDisplayName(test.entry, test.index); got != test.want { + t.Fatalf("playlistEntryDisplayName() = %q, want %q", got, test.want) + } + } +} + +func TestPlaylistTimingProgress(t *testing.T) { + now := time.Date(2026, time.September, 1, 12, 0, 0, 0, time.UTC) + tests := []struct { + name string + timing playback.PlaylistTimingState + wantFraction float32 + wantRemaining time.Duration + }{ + {name: "manual"}, + { + name: "waiting", + timing: playback.PlaylistTimingState{Duration: 10 * time.Second}, + wantRemaining: 10 * time.Second, + }, + { + name: "half complete", + timing: playback.PlaylistTimingState{ + Duration: 10 * time.Second, + Started: true, + Deadline: now.Add(5 * time.Second), + }, + wantFraction: 0.5, + wantRemaining: 5 * time.Second, + }, + { + name: "expired", + timing: playback.PlaylistTimingState{Duration: 10 * time.Second, Expired: true}, + wantFraction: 1, + }, + { + name: "deadline passed", + timing: playback.PlaylistTimingState{ + Duration: 10 * time.Second, + Started: true, + Deadline: now.Add(-time.Second), + }, + wantFraction: 1, + }, + } + + 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 { + t.Fatalf( + "playlistTimingProgress() = %v, %v; want %v, %v", + fraction, + remaining, + test.wantFraction, + test.wantRemaining, + ) + } + }) + } +} + func newPlaylistTestPlayer(t *testing.T) *playerPlayback { t.Helper() return &playerPlayback{ diff --git a/imgui.ini b/imgui.ini index 6a98b59..f8dd632 100644 --- a/imgui.ini +++ b/imgui.ini @@ -29,8 +29,8 @@ Size=550,720 Collapsed=0 [Window][Settings & Info] -Pos=580,0 -Size=700,720 +Pos=1220,0 +Size=700,1080 Collapsed=0 [Window][Seetings & Info] diff --git a/internal/playback/playlist_timing.go b/internal/playback/playlist_timing.go index f766257..7b5d574 100644 --- a/internal/playback/playlist_timing.go +++ b/internal/playback/playlist_timing.go @@ -6,6 +6,7 @@ type PlaylistTimingState struct { Revision uint64 Duration time.Duration Started bool + Expired bool Deadline time.Time } @@ -16,6 +17,7 @@ func NewPlaylistTiming( return PlaylistTimingState{ Revision: revision, Duration: duration, + Expired: false, } } @@ -24,10 +26,12 @@ func StartPlaylistTiming( revision uint64, now time.Time, ) (PlaylistTimingState, bool) { - if revision != current.Revision || current.Duration <= 0 || current.Started { + if revision != current.Revision || + current.Duration <= 0 || + current.Started || + current.Expired { return current, false } - next := current next.Started = true next.Deadline = now.Add(current.Duration) @@ -48,5 +52,6 @@ func ExpirePlaylistTiming( next := current next.Started = false next.Deadline = time.Time{} + next.Expired = true return next, true } diff --git a/internal/playback/playlist_timing_test.go b/internal/playback/playlist_timing_test.go index 9a73fd9..cc640b0 100644 --- a/internal/playback/playlist_timing_test.go +++ b/internal/playback/playlist_timing_test.go @@ -47,6 +47,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: "expired", current: PlaylistTimingState{Revision: 4, Duration: time.Second, Expired: true}, revision: 4}, } for _, test := range tests { @@ -70,7 +71,7 @@ func TestExpirePlaylistTiming(t *testing.T) { if !expired { t.Fatal("ExpirePlaylistTiming() expired = false, want true") } - want := PlaylistTimingState{Revision: 9, Duration: 5 * time.Second} + want := PlaylistTimingState{Revision: 9, Duration: 5 * time.Second, Expired: true} if got != want { t.Fatalf("ExpirePlaylistTiming() = %#v, want %#v", got, want) } diff --git a/sample-list.json b/sample-list.json new file mode 100644 index 0000000..da5f5eb --- /dev/null +++ b/sample-list.json @@ -0,0 +1,33 @@ +{ + "loop": true, + "entries": [ + { + "name": "Synchronized feed", + "video": { + "domain": "/dev/shm/mxl", + "uuid": "video-uuid" + }, + "audio": { + "domain": "/dev/shm/mxl", + "uuid": "audio-uuid" + }, + "sync": true, + "duration": "10s" + }, + { + "name": "Video only", + "video": { + "domain": "/another/domain", + "uuid": "video-uuid" + }, + "duration": "15s" + }, + { + "name": "Manual audio", + "audio": { + "domain": "/dev/shm/audio", + "uuid": "audio-uuid" + } + } + ] +}