playlist GUI
This commit is contained in:
@@ -701,6 +701,110 @@ func main() {
|
|||||||
drawFeedsSections()
|
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() {
|
drawHotkeysSection := func() {
|
||||||
cimgui.Text("F1 - show/hide stats")
|
cimgui.Text("F1 - show/hide stats")
|
||||||
cimgui.Text("F2 - show/hide settings")
|
cimgui.Text("F2 - show/hide settings")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"mxl-player/internal/playback"
|
"mxl-player/internal/playback"
|
||||||
@@ -135,3 +136,41 @@ func shouldAutoStartPlaylist(
|
|||||||
args.AudioFlowId == "" &&
|
args.AudioFlowId == "" &&
|
||||||
len(playlist.Entries) > 0
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
func newPlaylistTestPlayer(t *testing.T) *playerPlayback {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
return &playerPlayback{
|
return &playerPlayback{
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ Size=550,720
|
|||||||
Collapsed=0
|
Collapsed=0
|
||||||
|
|
||||||
[Window][Settings & Info]
|
[Window][Settings & Info]
|
||||||
Pos=580,0
|
Pos=1220,0
|
||||||
Size=700,720
|
Size=700,1080
|
||||||
Collapsed=0
|
Collapsed=0
|
||||||
|
|
||||||
[Window][Seetings & Info]
|
[Window][Seetings & Info]
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ type PlaylistTimingState struct {
|
|||||||
Revision uint64
|
Revision uint64
|
||||||
Duration time.Duration
|
Duration time.Duration
|
||||||
Started bool
|
Started bool
|
||||||
|
Expired bool
|
||||||
Deadline time.Time
|
Deadline time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ func NewPlaylistTiming(
|
|||||||
return PlaylistTimingState{
|
return PlaylistTimingState{
|
||||||
Revision: revision,
|
Revision: revision,
|
||||||
Duration: duration,
|
Duration: duration,
|
||||||
|
Expired: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,10 +26,12 @@ func StartPlaylistTiming(
|
|||||||
revision uint64,
|
revision uint64,
|
||||||
now time.Time,
|
now time.Time,
|
||||||
) (PlaylistTimingState, bool) {
|
) (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
|
return current, false
|
||||||
}
|
}
|
||||||
|
|
||||||
next := current
|
next := current
|
||||||
next.Started = true
|
next.Started = true
|
||||||
next.Deadline = now.Add(current.Duration)
|
next.Deadline = now.Add(current.Duration)
|
||||||
@@ -48,5 +52,6 @@ func ExpirePlaylistTiming(
|
|||||||
next := current
|
next := current
|
||||||
next.Started = false
|
next.Started = false
|
||||||
next.Deadline = time.Time{}
|
next.Deadline = time.Time{}
|
||||||
|
next.Expired = true
|
||||||
return next, true
|
return next, true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ func TestStartPlaylistTimingIgnoresInapplicableReadiness(t *testing.T) {
|
|||||||
{name: "stale revision", current: NewPlaylistTiming(4, time.Second), revision: 3},
|
{name: "stale revision", current: NewPlaylistTiming(4, time.Second), revision: 3},
|
||||||
{name: "future revision", current: NewPlaylistTiming(4, time.Second), revision: 5},
|
{name: "future revision", current: NewPlaylistTiming(4, time.Second), revision: 5},
|
||||||
{name: "already started", current: started, revision: 4},
|
{name: "already started", current: started, revision: 4},
|
||||||
|
{name: "expired", current: PlaylistTimingState{Revision: 4, Duration: time.Second, Expired: true}, revision: 4},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
@@ -70,7 +71,7 @@ func TestExpirePlaylistTiming(t *testing.T) {
|
|||||||
if !expired {
|
if !expired {
|
||||||
t.Fatal("ExpirePlaylistTiming() expired = false, want true")
|
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 {
|
if got != want {
|
||||||
t.Fatalf("ExpirePlaylistTiming() = %#v, want %#v", got, want)
|
t.Fatalf("ExpirePlaylistTiming() = %#v, want %#v", got, want)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user