Compare commits

...

8 Commits

Author SHA1 Message Date
itten e81dda3b3f Merge pull request 'Cleanout' (#4) from cleanout into main
Reviewed-on: http://gitea.lan/itten/go-mxl-player/pulls/4
2026-09-02 00:46:12 +03:00
Dmitry Sergeev 7a1b81c6ea UI fixes 2026-09-02 00:45:29 +03:00
Dmitry Sergeev b8cb750fb4 frame pacing fix 2026-09-02 00:39:30 +03:00
Dmitry Sergeev da5ff8ea4a GUI fields now trim leading and trailing whitespace: 2026-09-02 00:23:03 +03:00
Dmitry Sergeev e7b032ce77 architecture cleanup 2026-09-02 00:08:53 +03:00
Dmitry Sergeev f3f53fc7ad end-to-end controller tests. 2026-09-02 00:06:02 +03:00
Dmitry Sergeev a0e46a7bb5 Playlist-level retry configuration 2026-09-02 00:03:47 +03:00
Dmitry Sergeev ea8c9eeddd explicit info in GUI for playlist 2026-09-01 23:59:45 +03:00
29 changed files with 970 additions and 153 deletions
+22 -8
View File
@@ -11,8 +11,20 @@ at any time. Synchronization is a runtime relationship between the slots, not a
startup mode. startup mode.
The design must leave a clean extension point for an `mxlfabrics` reader after The design must leave a clean extension point for an `mxlfabrics` reader after
the local MXL player is stable. It must also support a later playlist layer the local MXL player is stable. Playlist timing and selection must remain above
without moving playlist timing or selection into media readers. media readers and playback workers.
## Current status
- Stages 0 through 11 are complete.
- The reader-factory extension point from Stage 12 is complete; the
`mxlfabrics` implementation remains future work.
- Stage 13 is complete, including manual/timed navigation, looping,
pause/resume, failure policies, playlist-level retry configuration, and GUI
diagnostics.
- Playlist lifecycle messages are explicit `PlaylistEvent` values produced by
`PlaylistEventCoordinator`; runtime cancellation owns shutdown, so shared
event channels are not closed by either endpoint.
## Required behaviour ## Required behaviour
@@ -61,7 +73,7 @@ without moving playlist timing or selection into media readers.
- Stopping both members of a synchronized group closes the group atomically. - Stopping both members of a synchronized group closes the group atomically.
- Stop commands cancel active reads and retry backoff promptly. - Stop commands cancel active reads and retry backoff promptly.
### Future playlist behaviour ### Playlist behaviour
A playlist is an ordered list of playback entries. Each entry describes a A playlist is an ordered list of playback entries. Each entry describes a
complete desired session state and may contain: complete desired session state and may contain:
@@ -244,15 +256,15 @@ transition.
4. Start independent workers for both configured slots. 4. Start independent workers for both configured slots.
5. Let either worker begin playing without waiting for the other. 5. Let either worker begin playing without waiting for the other.
## Future playlist model ## Playlist model
The exact public types can be chosen later, but the intended model is: The implemented model is conceptually:
```go ```go
type PlaylistEntry struct { type PlaylistEntry struct {
Name string Name string
VideoUUID string Video PlaylistFeed // domain + UUID, optional
AudioUUID string Audio PlaylistFeed // domain + UUID, optional
SyncRequested bool SyncRequested bool
Duration time.Duration Duration time.Duration
} }
@@ -260,6 +272,8 @@ type PlaylistEntry struct {
type Playlist struct { type Playlist struct {
Entries []PlaylistEntry Entries []PlaylistEntry
Loop bool Loop bool
OnFailure PlaylistFailurePolicy // wait or next
Retry *RetryPolicy // optional playlist-wide override
} }
``` ```
@@ -505,7 +519,7 @@ Acceptance criteria:
- Fake readers can drive all controller and supervisor tests. - Fake readers can drive all controller and supervisor tests.
- Local MXL remains the reference implementation. - Local MXL remains the reference implementation.
### Stage 13 — Simple playlist ### Stage 13 — Simple playlist (complete)
Work: Work:
+20
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"mxl-player/internal/playback" "mxl-player/internal/playback"
"strings"
"time" "time"
) )
@@ -17,6 +18,25 @@ func resolveDomain(shared, override string) string {
return shared return shared
} }
func normalizeFeedInput(domain, uuid string) (string, string) {
return strings.TrimSpace(domain), strings.TrimSpace(uuid)
}
func resolveRetryPolicy(
cli playback.RetryPolicy,
cliMaxAttemptsSet bool,
playlist playback.Playlist,
) playback.RetryPolicy {
if playlist.Retry == nil {
return cli
}
resolved := *playlist.Retry
if cliMaxAttemptsSet {
resolved.MaxAttempts = cli.MaxAttempts
}
return resolved
}
func (a appArgs) playbackConfig() playback.SessionConfig { func (a appArgs) playbackConfig() playback.SessionConfig {
return playback.SessionConfig{ return playback.SessionConfig{
Video: playback.FeedConfig{ Video: playback.FeedConfig{
+58 -1
View File
@@ -1,6 +1,11 @@
package main package main
import "testing" import (
"testing"
"time"
"mxl-player/internal/playback"
)
func TestAppArgsPlaybackConfig(t *testing.T) { func TestAppArgsPlaybackConfig(t *testing.T) {
tests := []struct { tests := []struct {
@@ -139,3 +144,55 @@ func TestAppArgsPlaybackConfig(t *testing.T) {
}) })
} }
} }
func TestResolveRetryPolicy(t *testing.T) {
cli := playback.RetryPolicy{
MaxAttempts: 0, InitialDelay: time.Second, MaxDelay: 5 * time.Second,
}
fileRetry := playback.RetryPolicy{
MaxAttempts: 4, InitialDelay: 250 * time.Millisecond, MaxDelay: 2 * time.Second,
}
tests := []struct {
name string
playlist playback.Playlist
cliMaxAttemptsSet bool
want playback.RetryPolicy
}{
{name: "no playlist retry uses CLI", want: cli},
{
name: "playlist retry is used by default",
playlist: playback.Playlist{Retry: &fileRetry},
want: fileRetry,
},
{
name: "explicit CLI infinite overrides playlist attempts",
playlist: playback.Playlist{Retry: &fileRetry},
cliMaxAttemptsSet: true,
want: playback.RetryPolicy{
MaxAttempts: 0, InitialDelay: fileRetry.InitialDelay, MaxDelay: fileRetry.MaxDelay,
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := resolveRetryPolicy(cli, test.cliMaxAttemptsSet, test.playlist); got != test.want {
t.Fatalf("resolveRetryPolicy() = %#v, want %#v", got, test.want)
}
})
}
}
func TestNormalizeFeedInput(t *testing.T) {
domain, uuid := normalizeFeedInput(
" \t/dev/shm/mxl\n",
"\r 5fbec3b1-1b0f-417d-9059-8b94a47197ef \t",
)
if domain != "/dev/shm/mxl" {
t.Fatalf("domain = %q", domain)
}
if uuid != "5fbec3b1-1b0f-417d-9059-8b94a47197ef" {
t.Fatalf("UUID = %q", uuid)
}
}
+24
View File
@@ -0,0 +1,24 @@
package main
import "time"
const guiFrameInterval = time.Second / 60
// videoPollInterval bounds GUI latency when no video producer is waiting.
// It is not a video-rate cap: Next returns immediately whenever a frame
// arrives, including for sources faster than this interval.
const videoPollInterval = 8 * time.Millisecond
func remainingFrameTime(start, now time.Time, interval time.Duration) time.Duration {
remaining := interval - now.Sub(start)
if remaining < 0 {
return 0
}
return remaining
}
func paceFrame(start time.Time) {
if remaining := remainingFrameTime(start, time.Now(), guiFrameInterval); remaining > 0 {
time.Sleep(remaining)
}
}
+29
View File
@@ -0,0 +1,29 @@
package main
import (
"testing"
"time"
)
func TestRemainingFrameTime(t *testing.T) {
start := time.Date(2026, time.September, 2, 0, 0, 0, 0, time.UTC)
interval := 16 * time.Millisecond
tests := []struct {
name string
elapsed time.Duration
want time.Duration
}{
{name: "no work", want: interval},
{name: "partial budget", elapsed: 5 * time.Millisecond, want: 11 * time.Millisecond},
{name: "exact budget", elapsed: interval},
{name: "over budget", elapsed: 20 * time.Millisecond},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := remainingFrameTime(start, start.Add(test.elapsed), interval)
if got != test.want {
t.Fatalf("remainingFrameTime() = %v, want %v", got, test.want)
}
})
}
}
+81 -5
View File
@@ -22,7 +22,7 @@ import (
const ( const (
APP_NAME = "MXL Player" APP_NAME = "MXL Player"
APP_VER = "0.1.0" APP_VER = "1.0.0"
WIN_WIDTH int32 = 1280 WIN_WIDTH int32 = 1280
WIN_HEIGHT int32 = 720 WIN_HEIGHT int32 = 720
) )
@@ -176,6 +176,11 @@ func main() {
os.Exit(2) os.Exit(2)
} }
configuredPlaylist = playlist configuredPlaylist = playlist
retryPolicy = resolveRetryPolicy(
retryPolicy,
flagSet.Changed("max-attempts"),
configuredPlaylist,
)
} }
if args.VideoDomain == "" { if args.VideoDomain == "" {
args.VideoDomain = args.Domain args.VideoDomain = args.Domain
@@ -372,6 +377,8 @@ func main() {
} }
} }
doReconnect := func() { doReconnect := func() {
videoDomainStr, videoStr = normalizeFeedInput(videoDomainStr, videoStr)
audioDomainStr, audioStr = normalizeFeedInput(audioDomainStr, audioStr)
videoActive = videoStr != "" videoActive = videoStr != ""
audioActive = audioStr != "" audioActive = audioStr != ""
@@ -465,6 +472,8 @@ func main() {
displayedVideoWidth uint32 = placeholderWidth displayedVideoWidth uint32 = placeholderWidth
displayedVideoHeight uint32 = placeholderHeight displayedVideoHeight uint32 = placeholderHeight
displayedVideoStride uint32 = placeholderStride displayedVideoStride uint32 = placeholderStride
displayedVideoSource playback.FeedConfig
hasDisplayedVideo bool
fps float64 fps float64
dropTracker videoDropTracker dropTracker videoDropTracker
@@ -480,6 +489,8 @@ func main() {
// ImGui // ImGui
var ( var (
statsWindowWidth float32 = 460
statsWindowHeight float32 = 510
settingWindowWidth float32 = 700 settingWindowWidth float32 = 700
settingsWindowState bool = true settingsWindowState bool = true
) )
@@ -523,6 +534,7 @@ func main() {
if err := r.RecreateSwapchain(); err != nil { if err := r.RecreateSwapchain(); err != nil {
if errors.Is(err, renderer.ErrMinimized) { if errors.Is(err, renderer.ErrMinimized) {
resized = true resized = true
paceFrame(frameStart)
continue continue
} }
panic(err) panic(err)
@@ -534,7 +546,7 @@ func main() {
var shownSource playback.FeedConfig var shownSource playback.FeedConfig
hasFrame := false hasFrame := false
frameCtx, frameCancel := context.WithTimeout(ctx, 100*time.Millisecond) frameCtx, frameCancel := context.WithTimeout(ctx, videoPollInterval)
pendingFrame, frameErr := videoBridge.Next(frameCtx) pendingFrame, frameErr := videoBridge.Next(frameCtx)
frameCancel() frameCancel()
@@ -570,6 +582,16 @@ func main() {
} }
snapshot, hasSnapshot := player.Controller.Snapshot() snapshot, hasSnapshot := player.Controller.Snapshot()
desiredVideo := desiredVideoFeed(snapshot, hasSnapshot)
if !desiredVideo.Active ||
desiredVideo.Domain != displayedVideoSource.Domain ||
desiredVideo.UUID != displayedVideoSource.UUID {
hasDisplayedVideo = false
}
if hasFrame {
displayedVideoSource = shownSource
hasDisplayedVideo = shouldShowVideo(desiredVideo, shownSource)
}
// stats // stats
if hasFrame { if hasFrame {
@@ -592,8 +614,8 @@ func main() {
if r != nil { if r != nil {
gui.BeginFrame(time.Since(lastFrame), int32(r.Extent().Width), int32(r.Extent().Height)) gui.BeginFrame(time.Since(lastFrame), int32(r.Extent().Width), int32(r.Extent().Height))
if showStats { if showStats {
cimgui.SetNextWindowPos(cimgui.Vec2{X: 0, Y: 0}) cimgui.SetNextWindowPos(cimgui.Vec2{X: float32(r.Extent().Width) - statsWindowWidth, Y: 0})
cimgui.SetNextWindowSize(cimgui.Vec2{X: 460, Y: 510}) cimgui.SetNextWindowSize(cimgui.Vec2{X: statsWindowWidth, Y: statsWindowHeight})
cimgui.BeginV("Stats", &showStats, cimgui.WindowFlagsNoResize|cimgui.WindowFlagsNoScrollbar) cimgui.BeginV("Stats", &showStats, cimgui.WindowFlagsNoResize|cimgui.WindowFlagsNoScrollbar)
mediaStats := player.MediaStats.Snapshot() mediaStats := player.MediaStats.Snapshot()
cimgui.SeparatorText("Video") cimgui.SeparatorText("Video")
@@ -717,7 +739,13 @@ func main() {
drawFeedsSections := func() { drawFeedsSections := func() {
cimgui.SeparatorText("Video") cimgui.SeparatorText("Video")
cimgui.InputTextWithHint("Video domain", "/dev/shm/mxl", &videoDomainStr, 0, nil) cimgui.InputTextWithHint("Video domain", "/dev/shm/mxl", &videoDomainStr, 0, nil)
if cimgui.IsItemDeactivatedAfterEdit() {
videoDomainStr, _ = normalizeFeedInput(videoDomainStr, "")
}
cimgui.InputTextWithHint("Video UUID", "", &videoStr, 0, nil) cimgui.InputTextWithHint("Video UUID", "", &videoStr, 0, nil)
if cimgui.IsItemDeactivatedAfterEdit() {
_, videoStr = normalizeFeedInput("", videoStr)
}
if videoActive { if videoActive {
cimgui.SameLine() cimgui.SameLine()
if cimgui.Button("Stop##video") { if cimgui.Button("Stop##video") {
@@ -742,7 +770,13 @@ func main() {
} }
cimgui.SeparatorText("Audio") cimgui.SeparatorText("Audio")
cimgui.InputTextWithHint("Audio domain", "/dev/shm/mxl", &audioDomainStr, 0, nil) cimgui.InputTextWithHint("Audio domain", "/dev/shm/mxl", &audioDomainStr, 0, nil)
if cimgui.IsItemDeactivatedAfterEdit() {
audioDomainStr, _ = normalizeFeedInput(audioDomainStr, "")
}
cimgui.InputTextWithHint("Audio UUID", "", &audioStr, 0, nil) cimgui.InputTextWithHint("Audio UUID", "", &audioStr, 0, nil)
if cimgui.IsItemDeactivatedAfterEdit() {
_, audioStr = normalizeFeedInput("", audioStr)
}
if audioActive { if audioActive {
cimgui.SameLine() cimgui.SameLine()
if cimgui.Button("Stop##audio") { if cimgui.Button("Stop##audio") {
@@ -811,6 +845,46 @@ func main() {
} else { } else {
cimgui.Text("End behavior: stop") cimgui.Text("End behavior: stop")
} }
cimgui.Text(fmt.Sprintf(
"Failure behavior: %s",
configuredPlaylist.OnFailure,
))
if retryPolicy.MaxAttempts == 0 {
cimgui.Text("Retries: infinite")
} else {
cimgui.Text(fmt.Sprintf(
"Attempts per entry: %d",
retryPolicy.MaxAttempts,
))
}
cimgui.Text(fmt.Sprintf(
"Retry delay: %s to %s",
retryPolicy.InitialDelay,
retryPolicy.MaxDelay,
))
if hasPlaylistSnapshot && playlistSnapshot.HasFailure {
failure := playlistSnapshot.Failure
name := failure.EntryName
if name == "" {
name = fmt.Sprintf("Entry %d", failure.EntryIndex+1)
}
cimgui.SeparatorText("Last failure")
cimgui.TextWrapped(fmt.Sprintf(
"%s: %s failed",
name,
failure.Status.Unit,
))
cimgui.Text(fmt.Sprintf(
"Attempts: %d | failed attempts: %d",
failure.Status.Attempt,
failure.Status.FailedAttempts,
))
cimgui.Text(fmt.Sprintf("Policy: %s", failure.Policy))
if failure.Status.Err != nil {
cimgui.TextWrapped(failure.Status.Err.Error())
}
}
preview := "No entry selected" preview := "No entry selected"
if hasPlaylistSnapshot && playlistSnapshot.State.HasSelection { if hasPlaylistSnapshot && playlistSnapshot.State.HasSelection {
@@ -1003,7 +1077,7 @@ func main() {
} }
if settingsWindowState { if settingsWindowState {
cimgui.SetNextWindowPos(cimgui.Vec2{X: float32(r.Extent().Width) - settingWindowWidth, Y: 0}) cimgui.SetNextWindowPos(cimgui.Vec2{X: 0, Y: 0})
cimgui.SetNextWindowSize(cimgui.Vec2{X: settingWindowWidth, Y: float32(r.Extent().Height)}) cimgui.SetNextWindowSize(cimgui.Vec2{X: settingWindowWidth, Y: float32(r.Extent().Height)})
if cimgui.BeginV("Settings & Info", &settingsWindowState, cimgui.WindowFlagsNone) { if cimgui.BeginV("Settings & Info", &settingsWindowState, cimgui.WindowFlagsNone) {
drawSettingsContents() drawSettingsContents()
@@ -1017,11 +1091,13 @@ func main() {
displayedVideoWidth, displayedVideoWidth,
displayedVideoHeight, displayedVideoHeight,
displayedVideoStride, displayedVideoStride,
hasDisplayedVideo,
) )
if errors.Is(err, renderer.ErrOutOfDate) { if errors.Is(err, renderer.ErrOutOfDate) {
if rerr := r.RecreateSwapchain(); rerr != nil { if rerr := r.RecreateSwapchain(); rerr != nil {
if errors.Is(rerr, renderer.ErrMinimized) { if errors.Is(rerr, renderer.ErrMinimized) {
resized = true resized = true
paceFrame(frameStart)
continue continue
} }
panic(rerr) panic(rerr)
+45
View File
@@ -14,6 +14,13 @@ type playlistFile struct {
Entries []playlistFileEntry `json:"entries"` Entries []playlistFileEntry `json:"entries"`
Loop bool `json:"loop"` Loop bool `json:"loop"`
OnFailure string `json:"on_failure"` OnFailure string `json:"on_failure"`
Retry *playlistFileRetry `json:"retry"`
}
type playlistFileRetry struct {
MaxAttempts *int `json:"max_attempts"`
InitialDelay string `json:"initial_delay"`
MaxDelay string `json:"max_delay"`
} }
type playlistFileEntry struct { type playlistFileEntry struct {
@@ -73,6 +80,13 @@ func decodePlaylistFile(reader io.Reader) (playback.Playlist, error) {
"on_failure %q: %w", file.OnFailure, playback.ErrPlaylistFailurePolicy, "on_failure %q: %w", file.OnFailure, playback.ErrPlaylistFailurePolicy,
) )
} }
if file.Retry != nil {
retry, err := decodePlaylistRetry(*file.Retry)
if err != nil {
return playback.Playlist{}, err
}
playlist.Retry = &retry
}
for index, entry := range file.Entries { for index, entry := range file.Entries {
duration := time.Duration(0) duration := time.Duration(0)
if entry.Duration != "" { if entry.Duration != "" {
@@ -103,6 +117,37 @@ func decodePlaylistFile(reader io.Reader) (playback.Playlist, error) {
return playlist, nil return playlist, nil
} }
func decodePlaylistRetry(file playlistFileRetry) (playback.RetryPolicy, error) {
retry := playback.RetryPolicy{
InitialDelay: initialRetryDelay,
MaxDelay: maxRetryDelay,
}
if file.MaxAttempts != nil {
retry.MaxAttempts = *file.MaxAttempts
}
var err error
if file.InitialDelay != "" {
retry.InitialDelay, err = time.ParseDuration(file.InitialDelay)
if err != nil {
return playback.RetryPolicy{}, fmt.Errorf(
"retry initial_delay %q: %w", file.InitialDelay, err,
)
}
}
if file.MaxDelay != "" {
retry.MaxDelay, err = time.ParseDuration(file.MaxDelay)
if err != nil {
return playback.RetryPolicy{}, fmt.Errorf(
"retry max_delay %q: %w", file.MaxDelay, err,
)
}
}
if err := retry.Validate(); err != nil {
return playback.RetryPolicy{}, fmt.Errorf("validate playlist retry: %w", err)
}
return retry, nil
}
func playlistFileFeedToPlayback(feed *playlistFileFeed) playback.PlaylistFeed { func playlistFileFeedToPlayback(feed *playlistFileFeed) playback.PlaylistFeed {
if feed == nil { if feed == nil {
return playback.PlaylistFeed{} return playback.PlaylistFeed{}
+39
View File
@@ -91,6 +91,41 @@ func TestDecodePlaylistFileAllowsEmptyPlaylist(t *testing.T) {
} }
} }
func TestDecodePlaylistFileRetry(t *testing.T) {
input := `{
"retry": {
"max_attempts": 3,
"initial_delay": "250ms",
"max_delay": "2s"
},
"entries": []
}`
got, err := decodePlaylistFile(strings.NewReader(input))
if err != nil {
t.Fatalf("decodePlaylistFile() error = %v", err)
}
if got.Retry == nil {
t.Fatal("decodePlaylistFile() retry = nil")
}
want := playback.RetryPolicy{
MaxAttempts: 3, InitialDelay: 250 * time.Millisecond, MaxDelay: 2 * time.Second,
}
if *got.Retry != want {
t.Fatalf("retry = %#v, want %#v", *got.Retry, want)
}
}
func TestDecodePlaylistFileRetryDefaults(t *testing.T) {
got, err := decodePlaylistFile(strings.NewReader(`{"retry":{},"entries":[]}`))
if err != nil {
t.Fatalf("decodePlaylistFile() error = %v", err)
}
if got.Retry == nil || got.Retry.MaxAttempts != 0 ||
got.Retry.InitialDelay != initialRetryDelay || got.Retry.MaxDelay != maxRetryDelay {
t.Fatalf("retry = %#v", got.Retry)
}
}
func TestDecodePlaylistFileRejectsInvalidInput(t *testing.T) { func TestDecodePlaylistFileRejectsInvalidInput(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -103,6 +138,10 @@ func TestDecodePlaylistFileRejectsInvalidInput(t *testing.T) {
{name: "unknown field", input: `{"unknown": true}`, wantText: "unknown field"}, {name: "unknown field", input: `{"unknown": true}`, wantText: "unknown field"},
{name: "multiple roots", input: `{"entries": []} {"entries": []}`, wantText: "multiple root values"}, {name: "multiple roots", input: `{"entries": []} {"entries": []}`, wantText: "multiple root values"},
{name: "invalid failure policy", input: `{"on_failure":"skip","entries":[]}`, wantErr: playback.ErrPlaylistFailurePolicy}, {name: "invalid failure policy", input: `{"on_failure":"skip","entries":[]}`, wantErr: playback.ErrPlaylistFailurePolicy},
{name: "negative retry attempts", input: `{"retry":{"max_attempts":-1},"entries":[]}`, wantErr: playback.ErrInvalidMaxAttempts},
{name: "invalid initial delay", input: `{"retry":{"initial_delay":"soon"},"entries":[]}`, wantText: "retry initial_delay"},
{name: "invalid maximum delay", input: `{"retry":{"max_delay":"later"},"entries":[]}`, wantText: "retry max_delay"},
{name: "invalid retry range", input: `{"retry":{"initial_delay":"2s","max_delay":"1s"},"entries":[]}`, wantErr: playback.ErrInvalidRetryRange},
{ {
name: "invalid duration", name: "invalid duration",
input: `{"entries":[{"video":{"domain":"/video","uuid":"video"},"duration":"later"}]}`, input: `{"entries":[{"video":{"domain":"/video","uuid":"video"},"duration":"later"}]}`,
+18 -14
View File
@@ -9,7 +9,7 @@ import (
"mxl-player/internal/playback" "mxl-player/internal/playback"
) )
const playlistReadinessInterval = 10 * time.Millisecond const playlistEventInterval = 10 * time.Millisecond
var ( var (
ErrPlayerPlaybackRequired = errors.New("player playback is required") ErrPlayerPlaybackRequired = errors.New("player playback is required")
@@ -19,9 +19,13 @@ var (
type playerPlaylist struct { type playerPlaylist struct {
Controller *playback.PlaylistController Controller *playback.PlaylistController
Coordinator *playback.PlaylistReadinessCoordinator
Commands chan playback.PlaylistCommand // commands is written by GUI-facing methods and consumed by Controller.
Readiness chan playback.PlaylistReadiness // events is written by coordinator and consumed by Controller. Run owns
// both goroutine lifecycles; cancellation replaces channel closing.
coordinator *playback.PlaylistEventCoordinator
commands chan playback.PlaylistCommand
events chan playback.PlaylistEvent
} }
func newPlayerPlaylist( func newPlayerPlaylist(
@@ -40,7 +44,7 @@ func newPlayerPlaylist(
} }
commands := make(chan playback.PlaylistCommand, 32) commands := make(chan playback.PlaylistCommand, 32)
readiness := make(chan playback.PlaylistReadiness, 8) events := make(chan playback.PlaylistEvent, 8)
controller, err := playback.NewPlaylistController( controller, err := playback.NewPlaylistController(
playlist, playlist,
retry, retry,
@@ -49,12 +53,12 @@ func newPlayerPlaylist(
if err != nil { if err != nil {
return nil, err return nil, err
} }
coordinator, err := playback.NewPlaylistReadinessCoordinator( coordinator, err := playback.NewPlaylistEventCoordinator(
controller, controller,
player.Controller, player.Controller,
player.Status, player.Status,
readiness, events,
playlistReadinessInterval, playlistEventInterval,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@@ -62,9 +66,9 @@ func newPlayerPlaylist(
return &playerPlaylist{ return &playerPlaylist{
Controller: controller, Controller: controller,
Coordinator: coordinator, coordinator: coordinator,
Commands: commands, commands: commands,
Readiness: readiness, events: events,
}, nil }, nil
} }
@@ -74,10 +78,10 @@ func (p *playerPlaylist) Run(ctx context.Context) error {
results := make(chan error, 2) results := make(chan error, 2)
go func() { go func() {
results <- p.Controller.Run(runCtx, p.Commands, p.Readiness) results <- p.Controller.Run(runCtx, p.commands, p.events)
}() }()
go func() { go func() {
results <- p.Coordinator.Run(runCtx) results <- p.coordinator.Run(runCtx)
}() }()
first := <-results first := <-results
@@ -121,7 +125,7 @@ func (p *playerPlaylist) Resume() bool {
func (p *playerPlaylist) enqueue(command playback.PlaylistCommand) bool { func (p *playerPlaylist) enqueue(command playback.PlaylistCommand) bool {
select { select {
case p.Commands <- command: case p.commands <- command:
return true return true
default: default:
return false return false
+4 -4
View File
@@ -104,10 +104,10 @@ func TestNewPlayerPlaylistWiresComponents(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("newPlayerPlaylist() error = %v", err) t.Fatalf("newPlayerPlaylist() error = %v", err)
} }
if runtime.Controller == nil || runtime.Coordinator == nil { if runtime.Controller == nil || runtime.coordinator == nil {
t.Fatalf("runtime components = %#v", runtime) t.Fatalf("runtime components = %#v", runtime)
} }
if runtime.Commands == nil || runtime.Readiness == nil { if runtime.commands == nil || runtime.events == nil {
t.Fatalf("runtime channels = %#v", runtime) t.Fatalf("runtime channels = %#v", runtime)
} }
} }
@@ -135,7 +135,7 @@ func TestPlayerPlaylistNavigationHelpers(t *testing.T) {
if !test.send() { if !test.send() {
t.Fatal("navigation helper returned false") t.Fatal("navigation helper returned false")
} }
if got := <-runtime.Commands; got != test.want { if got := <-runtime.commands; got != test.want {
t.Fatalf("navigation command = %#v, want %#v", got, test.want) t.Fatalf("navigation command = %#v, want %#v", got, test.want)
} }
} }
@@ -150,7 +150,7 @@ func TestPlayerPlaylistNavigationQueueFull(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("newPlayerPlaylist() error = %v", err) t.Fatalf("newPlayerPlaylist() error = %v", err)
} }
for range cap(runtime.Commands) { for range cap(runtime.commands) {
if !runtime.Next() { if !runtime.Next() {
t.Fatal("queue filled before reaching capacity") t.Fatal("queue filled before reaching capacity")
} }
+30
View File
@@ -0,0 +1,30 @@
package main
import "mxl-player/internal/playback"
func desiredVideoFeed(
snapshot playback.SessionSnapshot,
available bool,
) playback.FeedConfig {
if !available {
return playback.FeedConfig{}
}
switch snapshot.Plan.Topology {
case playback.TopologyIndependent:
if snapshot.Plan.Video.Active {
return snapshot.Plan.Video
}
case playback.TopologySynchronized:
if snapshot.Plan.Sync.Active() {
return snapshot.Plan.Sync.Video
}
}
return playback.FeedConfig{}
}
func shouldShowVideo(
desired playback.FeedConfig,
delivered playback.FeedConfig,
) bool {
return desired.Active && sameVideoSource(desired, delivered)
}
+46
View File
@@ -0,0 +1,46 @@
package main
import (
"testing"
"mxl-player/internal/playback"
)
func TestDesiredVideoFeed(t *testing.T) {
video := playback.FeedConfig{Domain: "/video", UUID: "video", Active: true}
tests := []struct {
name string
available bool
plan playback.SessionPlan
want playback.FeedConfig
}{
{name: "snapshot unavailable"},
{name: "idle", available: true, plan: playback.SessionPlan{Topology: playback.TopologyIdle}},
{name: "audio only", available: true, plan: playback.SessionPlan{Topology: playback.TopologyIndependent}},
{name: "independent video", available: true, plan: playback.SessionPlan{Topology: playback.TopologyIndependent, Video: video}, want: video},
{name: "synchronized video", available: true, plan: playback.SessionPlan{Topology: playback.TopologySynchronized, Sync: playback.SyncPairConfig{Video: video, Audio: playback.FeedConfig{Active: true}}}, want: video},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := desiredVideoFeed(playback.SessionSnapshot{Plan: test.plan}, test.available)
if got != test.want {
t.Fatalf("desiredVideoFeed() = %#v, want %#v", got, test.want)
}
})
}
}
func TestShouldShowVideoRequiresDesiredSource(t *testing.T) {
desired := playback.FeedConfig{Domain: "/video", UUID: "video", Active: true}
if !shouldShowVideo(desired, desired) {
t.Fatal("matching active video was hidden")
}
if shouldShowVideo(playback.FeedConfig{}, desired) {
t.Fatal("video was shown without an active desired feed")
}
other := desired
other.UUID = "other"
if shouldShowVideo(desired, other) {
t.Fatal("frame from old source was shown")
}
}
+8
View File
@@ -0,0 +1,8 @@
5fbec3b1-1b0f-417d-9059-8b94a47197ed
5fbec3b1-1b0f-417d-9059-8b94a47197ec
5fbec3b1-1b0f-417d-9059-8b94a47197ef
5fbec3b1-1b0f-417d-9059-8b94a47197eb
2618979d-76a5-45e0-83cb-0f192978d1cd
9d2a041b-01cf-4ee4-bffa-188fe093c99b
+3 -2
View File
@@ -4,11 +4,12 @@ Size=400,400
Collapsed=0 Collapsed=0
[Window][Settings & Info] [Window][Settings & Info]
Pos=580,0 Pos=0,0
Size=700,720 Size=700,1080
Collapsed=0 Collapsed=0
[Window][Stats] [Window][Stats]
Pos=1460,0
Size=460,510 Size=460,510
Collapsed=0 Collapsed=0
+13
View File
@@ -3,6 +3,8 @@ package imgui
import ( import (
"time" "time"
"mxl-player/internal/sdl"
cimgui "github.com/AllenDang/cimgui-go/imgui" cimgui "github.com/AllenDang/cimgui-go/imgui"
) )
@@ -17,9 +19,20 @@ func New() *Context {
ctx := cimgui.CreateContext() ctx := cimgui.CreateContext()
cimgui.SetCurrentContext(ctx) cimgui.SetCurrentContext(ctx)
io := cimgui.CurrentIO() io := cimgui.CurrentIO()
cimgui.CurrentPlatformIO().SetClipboardHandler(sdlClipboardHandler{})
return &Context{ctx: ctx, io: io} return &Context{ctx: ctx, io: io}
} }
type sdlClipboardHandler struct{}
func (sdlClipboardHandler) GetClipboard() string {
return sdl.GetClipboardText()
}
func (sdlClipboardHandler) SetClipboard(text string) {
sdl.SetClipboardText(text)
}
func (c *Context) Destroy() { func (c *Context) Destroy() {
cimgui.DestroyContext() cimgui.DestroyContext()
} }
+20
View File
@@ -7,6 +7,13 @@ import (
cimgui "github.com/AllenDang/cimgui-go/imgui" cimgui "github.com/AllenDang/cimgui-go/imgui"
) )
const (
sdlKModShift uint16 = 0x0001 | 0x0002
sdlKModCtrl uint16 = 0x0040 | 0x0080
sdlKModAlt uint16 = 0x0100 | 0x0200
sdlKModGUI uint16 = 0x0400 | 0x0800
)
// SDL3 event (128 byte raw buffer) -> imgui // SDL3 event (128 byte raw buffer) -> imgui
func (c *Context) ProcessEvent(event *[128]byte) { func (c *Context) ProcessEvent(event *[128]byte) {
eventType := *(*uint32)(unsafe.Pointer(&event[0])) eventType := *(*uint32)(unsafe.Pointer(&event[0]))
@@ -15,7 +22,9 @@ func (c *Context) ProcessEvent(event *[128]byte) {
switch eventType { switch eventType {
case sdl.EventKeyDown, sdl.EventKeyUp: case sdl.EventKeyDown, sdl.EventKeyUp:
scancode := *(*uint32)(unsafe.Pointer(&event[24])) scancode := *(*uint32)(unsafe.Pointer(&event[24]))
modifiers := *(*uint16)(unsafe.Pointer(&event[32]))
down := eventType == sdl.EventKeyDown down := eventType == sdl.EventKeyDown
c.addKeyModifiers(modifiers)
key := sdlScancodeToImGuiKey(scancode) key := sdlScancodeToImGuiKey(scancode)
if key >= 0 { if key >= 0 {
c.io.AddKeyEvent(key, down) c.io.AddKeyEvent(key, down)
@@ -47,6 +56,13 @@ func (c *Context) ProcessEvent(event *[128]byte) {
} }
} }
func (c *Context) addKeyModifiers(modifiers uint16) {
c.io.AddKeyEvent(cimgui.ModCtrl, modifiers&sdlKModCtrl != 0)
c.io.AddKeyEvent(cimgui.ModShift, modifiers&sdlKModShift != 0)
c.io.AddKeyEvent(cimgui.ModAlt, modifiers&sdlKModAlt != 0)
c.io.AddKeyEvent(cimgui.ModSuper, modifiers&sdlKModGUI != 0)
}
func sdlScancodeToImGuiKey(scancode uint32) cimgui.Key { func sdlScancodeToImGuiKey(scancode uint32) cimgui.Key {
switch scancode { switch scancode {
case 40: // SDL_SCANCODE_RETURN case 40: // SDL_SCANCODE_RETURN
@@ -79,6 +95,10 @@ func sdlScancodeToImGuiKey(scancode uint32) cimgui.Key {
return cimgui.KeyLeftAlt return cimgui.KeyLeftAlt
case 230: // SDL_SCANCODE_RALT case 230: // SDL_SCANCODE_RALT
return cimgui.KeyRightAlt return cimgui.KeyRightAlt
case 227: // SDL_SCANCODE_LGUI
return cimgui.KeyLeftSuper
case 231: // SDL_SCANCODE_RGUI
return cimgui.KeyRightSuper
default: default:
// Letters A-Z: scancodes 4-29 map to ImGui KeyA-KeyZ // Letters A-Z: scancodes 4-29 map to ImGui KeyA-KeyZ
if scancode >= 4 && scancode <= 29 { if scancode >= 4 && scancode <= 29 {
+6
View File
@@ -56,6 +56,7 @@ type Playlist struct {
Entries []PlaylistEntry Entries []PlaylistEntry
Loop bool Loop bool
OnFailure PlaylistFailurePolicy OnFailure PlaylistFailurePolicy
Retry *RetryPolicy
} }
func (f PlaylistFeed) IsConfigured() bool { func (f PlaylistFeed) IsConfigured() bool {
@@ -95,6 +96,11 @@ func (p Playlist) Validate() error {
if err := p.OnFailure.Validate(); err != nil { if err := p.OnFailure.Validate(); err != nil {
return err return err
} }
if p.Retry != nil {
if err := p.Retry.Validate(); err != nil {
return fmt.Errorf("playlist retry: %w", err)
}
}
for index, entry := range p.Entries { for index, entry := range p.Entries {
if err := entry.Validate(); err != nil { if err := entry.Validate(); err != nil {
return fmt.Errorf("playlist entry %d: %w", index, err) return fmt.Errorf("playlist entry %d: %w", index, err)
+44 -11
View File
@@ -17,12 +17,9 @@ const (
type PlaylistEvent struct { type PlaylistEvent struct {
Revision uint64 Revision uint64
Kind PlaylistEventKind Kind PlaylistEventKind
Failure Status
} }
// PlaylistReadiness is retained as an alias for callers that only publish
// ready events. Its zero Kind is PlaylistEventReady.
type PlaylistReadiness = PlaylistEvent
type playlistTimer interface { type playlistTimer interface {
C() <-chan time.Time C() <-chan time.Time
Stop() bool Stop() bool
@@ -47,6 +44,15 @@ type PlaylistController struct {
mu sync.RWMutex mu sync.RWMutex
snapshot PlaylistSnapshot snapshot PlaylistSnapshot
hasSnapshot bool hasSnapshot bool
failure PlaylistFailure
}
type PlaylistFailure struct {
EntryIndex int
EntryName string
Revision uint64
Policy PlaylistFailurePolicy
Status Status
} }
type PlaylistSnapshot struct { type PlaylistSnapshot struct {
@@ -54,6 +60,8 @@ type PlaylistSnapshot struct {
Entry PlaylistEntry Entry PlaylistEntry
Revision uint64 Revision uint64
Timing PlaylistTimingState Timing PlaylistTimingState
Failure PlaylistFailure
HasFailure bool
} }
var ( var (
@@ -88,7 +96,7 @@ func NewPlaylistController(
func (c *PlaylistController) Run( func (c *PlaylistController) Run(
ctx context.Context, ctx context.Context,
commands <-chan PlaylistCommand, commands <-chan PlaylistCommand,
readiness <-chan PlaylistReadiness, events <-chan PlaylistEvent,
) error { ) error {
state := PlaylistState{} state := PlaylistState{}
revision := uint64(0) revision := uint64(0)
@@ -155,6 +163,7 @@ func (c *PlaylistController) Run(
continue continue
} }
if apply { if apply {
c.clearFailure()
stopTimer() stopTimer()
select { select {
case <-ctx.Done(): case <-ctx.Done():
@@ -169,16 +178,23 @@ func (c *PlaylistController) Run(
state = next state = next
c.publish(state, revision, timing) c.publish(state, revision, timing)
case ready, ok := <-readiness: case event, ok := <-events:
if !ok { if !ok {
readiness = nil events = nil
continue continue
} }
if ready.Kind == PlaylistEventFailed { if event.Kind == PlaylistEventFailed {
if ready.Revision != revision { if event.Revision != revision {
continue continue
} }
stopTimer() stopTimer()
c.setFailure(PlaylistFailure{
EntryIndex: state.CurrentIndex,
EntryName: c.playlist.Entries[state.CurrentIndex].Name,
Revision: revision,
Policy: c.playlist.OnFailure,
Status: event.Failure,
})
// A failed entry must not retain a live or apparently active // A failed entry must not retain a live or apparently active
// duration clock, even when the policy is to wait. // duration clock, even when the policy is to wait.
timing = NewPlaylistTiming(revision, timing.Duration) timing = NewPlaylistTiming(revision, timing.Duration)
@@ -208,8 +224,11 @@ func (c *PlaylistController) Run(
c.publish(state, revision, timing) c.publish(state, revision, timing)
continue continue
} }
if event.Kind != PlaylistEventReady {
continue
}
if timing.Paused && if timing.Paused &&
ready.Revision == timing.Revision && event.Revision == timing.Revision &&
timing.Duration > 0 && timing.Duration > 0 &&
!timing.Expired { !timing.Expired {
timing.Ready = true timing.Ready = true
@@ -218,7 +237,7 @@ func (c *PlaylistController) Run(
} }
nextTiming, started := StartPlaylistTiming( nextTiming, started := StartPlaylistTiming(
timing, timing,
ready.Revision, event.Revision,
c.now(), c.now(),
) )
if !started { if !started {
@@ -289,11 +308,25 @@ func (c *PlaylistController) publish(
Entry: entry, Entry: entry,
Revision: revision, Revision: revision,
Timing: timing, Timing: timing,
Failure: c.failure,
HasFailure: c.failure.Revision != 0,
} }
c.hasSnapshot = true c.hasSnapshot = true
c.mu.Unlock() c.mu.Unlock()
} }
func (c *PlaylistController) setFailure(failure PlaylistFailure) {
c.mu.Lock()
c.failure = failure
c.mu.Unlock()
}
func (c *PlaylistController) clearFailure() {
c.mu.Lock()
c.failure = PlaylistFailure{}
c.mu.Unlock()
}
func stopPlaylistTimer(timer playlistTimer) { func stopPlaylistTimer(timer playlistTimer) {
if timer == nil || timer.Stop() { if timer == nil || timer.Stop() {
return return
@@ -68,9 +68,11 @@ func TestPlaylistControllerStartsOneTimerForMatchingReadiness(t *testing.T) {
return snapshot.Revision == 1 return snapshot.Revision == 1
}) })
readiness <- PlaylistReadiness{Revision: 0} readiness <- PlaylistEvent{Revision: 0}
assertNoPlaylistTimer(t, timers) assertNoPlaylistTimer(t, timers)
readiness <- PlaylistReadiness{Revision: 1} readiness <- PlaylistEvent{Revision: 1, Kind: PlaylistEventKind(99)}
assertNoPlaylistTimer(t, timers)
readiness <- PlaylistEvent{Revision: 1}
timer := receiveFakePlaylistTimer(t, timers) timer := receiveFakePlaylistTimer(t, timers)
snapshot := waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { snapshot := waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
@@ -79,7 +81,7 @@ func TestPlaylistControllerStartsOneTimerForMatchingReadiness(t *testing.T) {
if snapshot.Timing.Deadline != now.Add(10*time.Second) { if snapshot.Timing.Deadline != now.Add(10*time.Second) {
t.Fatalf("deadline = %v, want %v", snapshot.Timing.Deadline, now.Add(10*time.Second)) t.Fatalf("deadline = %v, want %v", snapshot.Timing.Deadline, now.Add(10*time.Second))
} }
readiness <- PlaylistReadiness{Revision: 1} readiness <- PlaylistEvent{Revision: 1}
assertNoPlaylistTimer(t, timers) assertNoPlaylistTimer(t, timers)
if timer.isStopped() { if timer.isStopped() {
t.Fatal("timer stopped after duplicate readiness") t.Fatal("timer stopped after duplicate readiness")
@@ -101,7 +103,7 @@ func TestPlaylistControllerTimerExpiryAdvances(t *testing.T) {
commands <- PlaylistCommand{Kind: PlaylistNext} commands <- PlaylistCommand{Kind: PlaylistNext}
_ = receivePlaylistSession(t, sessions) _ = receivePlaylistSession(t, sessions)
readiness <- PlaylistReadiness{Revision: 1} readiness <- PlaylistEvent{Revision: 1}
timer := receiveFakePlaylistTimer(t, timers) timer := receiveFakePlaylistTimer(t, timers)
timer.fire(now.Add(10 * time.Second)) timer.fire(now.Add(10 * time.Second))
@@ -132,7 +134,7 @@ func TestPlaylistControllerLoopingTimerExpiryWraps(t *testing.T) {
commands <- PlaylistCommand{Kind: PlaylistSelect, Index: 1} commands <- PlaylistCommand{Kind: PlaylistSelect, Index: 1}
_ = receivePlaylistSession(t, sessions) _ = receivePlaylistSession(t, sessions)
readiness <- PlaylistReadiness{Revision: 1} readiness <- PlaylistEvent{Revision: 1}
timer := receiveFakePlaylistTimer(t, timers) timer := receiveFakePlaylistTimer(t, timers)
waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.Timing.Started return snapshot.Timing.Started
@@ -160,7 +162,7 @@ func TestPlaylistControllerFinalExpiryStopsWithoutCommand(t *testing.T) {
commands <- PlaylistCommand{Kind: PlaylistSelect, Index: 1} commands <- PlaylistCommand{Kind: PlaylistSelect, Index: 1}
_ = receivePlaylistSession(t, sessions) _ = receivePlaylistSession(t, sessions)
readiness <- PlaylistReadiness{Revision: 1} readiness <- PlaylistEvent{Revision: 1}
timer := receiveFakePlaylistTimer(t, timers) timer := receiveFakePlaylistTimer(t, timers)
waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.Timing.Started return snapshot.Timing.Started
@@ -192,7 +194,7 @@ func TestPlaylistControllerManualSelectionStopsOldTimer(t *testing.T) {
commands <- PlaylistCommand{Kind: PlaylistNext} commands <- PlaylistCommand{Kind: PlaylistNext}
_ = receivePlaylistSession(t, sessions) _ = receivePlaylistSession(t, sessions)
readiness <- PlaylistReadiness{Revision: 1} readiness <- PlaylistEvent{Revision: 1}
oldTimer := receiveFakePlaylistTimer(t, timers) oldTimer := receiveFakePlaylistTimer(t, timers)
commands <- PlaylistCommand{Kind: PlaylistSelect, Index: 1} commands <- PlaylistCommand{Kind: PlaylistSelect, Index: 1}
@@ -229,7 +231,7 @@ func TestPlaylistControllerZeroDurationDoesNotCreateTimer(t *testing.T) {
commands <- PlaylistCommand{Kind: PlaylistNext} commands <- PlaylistCommand{Kind: PlaylistNext}
_ = receivePlaylistSession(t, sessions) _ = receivePlaylistSession(t, sessions)
readiness <- PlaylistReadiness{Revision: 1} readiness <- PlaylistEvent{Revision: 1}
assertNoPlaylistTimer(t, timers) assertNoPlaylistTimer(t, timers)
close(commands) close(commands)
@@ -244,7 +246,7 @@ func TestPlaylistControllerCancellationStopsTimer(t *testing.T) {
commands <- PlaylistCommand{Kind: PlaylistNext} commands <- PlaylistCommand{Kind: PlaylistNext}
_ = receivePlaylistSession(t, sessions) _ = receivePlaylistSession(t, sessions)
readiness <- PlaylistReadiness{Revision: 1} readiness <- PlaylistEvent{Revision: 1}
timer := receiveFakePlaylistTimer(t, timers) timer := receiveFakePlaylistTimer(t, timers)
cancel() cancel()
if err := waitForPlaylistResult(t, result); !errors.Is(err, context.Canceled) { if err := waitForPlaylistResult(t, result); !errors.Is(err, context.Canceled) {
@@ -262,7 +264,7 @@ func TestPlaylistControllerPauseAndResumeTimer(t *testing.T) {
commands <- PlaylistCommand{Kind: PlaylistNext} commands <- PlaylistCommand{Kind: PlaylistNext}
_ = receivePlaylistSession(t, sessions) _ = receivePlaylistSession(t, sessions)
readiness <- PlaylistReadiness{Revision: 1} readiness <- PlaylistEvent{Revision: 1}
oldTimer := receiveFakePlaylistTimer(t, timers) oldTimer := receiveFakePlaylistTimer(t, timers)
waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.Timing.Started return snapshot.Timing.Started
@@ -344,7 +346,7 @@ func TestPlaylistControllerRecordsQueuedReadinessWhilePaused(t *testing.T) {
return snapshot.Timing.Paused return snapshot.Timing.Paused
}) })
readiness <- PlaylistReadiness{Revision: 1} readiness <- PlaylistEvent{Revision: 1}
waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.Timing.Paused && snapshot.Timing.Ready return snapshot.Timing.Paused && snapshot.Timing.Ready
}) })
@@ -368,7 +370,7 @@ func startTimedPlaylistController(
) ( ) (
*PlaylistController, *PlaylistController,
chan PlaylistCommand, chan PlaylistCommand,
chan PlaylistReadiness, chan PlaylistEvent,
chan SessionCommand, chan SessionCommand,
chan *fakePlaylistTimer, chan *fakePlaylistTimer,
time.Time, time.Time,
@@ -390,7 +392,7 @@ func startTimedPlaylistController(
return timer return timer
} }
commands := make(chan PlaylistCommand, 16) commands := make(chan PlaylistCommand, 16)
readiness := make(chan PlaylistReadiness, 16) readiness := make(chan PlaylistEvent, 16)
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
result := make(chan error, 1) result := make(chan error, 1)
go func() { result <- controller.Run(ctx, commands, readiness) }() go func() { result <- controller.Run(ctx, commands, readiness) }()
@@ -18,45 +18,45 @@ type PlaybackStatusSnapshotSource interface {
SnapshotAll() PlaybackStatusSnapshot SnapshotAll() PlaybackStatusSnapshot
} }
type playlistReadinessTicker interface { type playlistEventTicker interface {
C() <-chan time.Time C() <-chan time.Time
Stop() Stop()
} }
type playlistReadinessTickerFactory func(time.Duration) playlistReadinessTicker type playlistEventTickerFactory func(time.Duration) playlistEventTicker
type realPlaylistReadinessTicker struct { type realPlaylistEventTicker struct {
ticker *time.Ticker ticker *time.Ticker
} }
func (t realPlaylistReadinessTicker) C() <-chan time.Time { return t.ticker.C } func (t realPlaylistEventTicker) C() <-chan time.Time { return t.ticker.C }
func (t realPlaylistReadinessTicker) Stop() { t.ticker.Stop() } func (t realPlaylistEventTicker) Stop() { t.ticker.Stop() }
var ( var (
ErrPlaylistSnapshotSourceRequired = errors.New("playlist snapshot source is required") ErrPlaylistSnapshotSourceRequired = errors.New("playlist snapshot source is required")
ErrSessionSnapshotSourceRequired = errors.New("session snapshot source is required") ErrSessionSnapshotSourceRequired = errors.New("session snapshot source is required")
ErrStatusSnapshotSourceRequired = errors.New("playback status snapshot source is required") ErrStatusSnapshotSourceRequired = errors.New("playback status snapshot source is required")
ErrPlaylistReadinessOutputRequired = errors.New("playlist readiness output channel is required") ErrPlaylistEventOutputRequired = errors.New("playlist event output channel is required")
ErrPlaylistReadinessInterval = errors.New("playlist readiness interval must be positive") ErrPlaylistEventInterval = errors.New("playlist event interval must be positive")
) )
type PlaylistReadinessCoordinator struct { type PlaylistEventCoordinator struct {
playlist PlaylistSnapshotSource playlist PlaylistSnapshotSource
session SessionSnapshotSource session SessionSnapshotSource
statuses PlaybackStatusSnapshotSource statuses PlaybackStatusSnapshotSource
output chan<- PlaylistReadiness output chan<- PlaylistEvent
interval time.Duration interval time.Duration
newTicker playlistReadinessTickerFactory newTicker playlistEventTickerFactory
} }
func NewPlaylistReadinessCoordinator( func NewPlaylistEventCoordinator(
playlist PlaylistSnapshotSource, playlist PlaylistSnapshotSource,
session SessionSnapshotSource, session SessionSnapshotSource,
statuses PlaybackStatusSnapshotSource, statuses PlaybackStatusSnapshotSource,
output chan<- PlaylistReadiness, output chan<- PlaylistEvent,
interval time.Duration, interval time.Duration,
) (*PlaylistReadinessCoordinator, error) { ) (*PlaylistEventCoordinator, error) {
if playlist == nil { if playlist == nil {
return nil, ErrPlaylistSnapshotSourceRequired return nil, ErrPlaylistSnapshotSourceRequired
} }
@@ -67,25 +67,25 @@ func NewPlaylistReadinessCoordinator(
return nil, ErrStatusSnapshotSourceRequired return nil, ErrStatusSnapshotSourceRequired
} }
if output == nil { if output == nil {
return nil, ErrPlaylistReadinessOutputRequired return nil, ErrPlaylistEventOutputRequired
} }
if interval <= 0 { if interval <= 0 {
return nil, ErrPlaylistReadinessInterval return nil, ErrPlaylistEventInterval
} }
return &PlaylistReadinessCoordinator{ return &PlaylistEventCoordinator{
playlist: playlist, playlist: playlist,
session: session, session: session,
statuses: statuses, statuses: statuses,
output: output, output: output,
interval: interval, interval: interval,
newTicker: func(interval time.Duration) playlistReadinessTicker { newTicker: func(interval time.Duration) playlistEventTicker {
return realPlaylistReadinessTicker{ticker: time.NewTicker(interval)} return realPlaylistEventTicker{ticker: time.NewTicker(interval)}
}, },
}, nil }, nil
} }
func (c *PlaylistReadinessCoordinator) Run(ctx context.Context) error { func (c *PlaylistEventCoordinator) Run(ctx context.Context) error {
ticker := c.newTicker(c.interval) ticker := c.newTicker(c.interval)
defer ticker.Stop() defer ticker.Stop()
@@ -112,13 +112,14 @@ func (c *PlaylistReadinessCoordinator) Run(ctx context.Context) error {
continue continue
} }
statuses := c.statuses.SnapshotAll() statuses := c.statuses.SnapshotAll()
if IsSessionFailed(sessionSnapshot, statuses) { if failure, failed := SessionFailureStatus(sessionSnapshot, statuses); failed {
if playlistSnapshot.Revision == emittedFailedRevision { if playlistSnapshot.Revision == emittedFailedRevision {
continue continue
} }
failed := PlaylistEvent{ failed := PlaylistEvent{
Revision: playlistSnapshot.Revision, Revision: playlistSnapshot.Revision,
Kind: PlaylistEventFailed, Kind: PlaylistEventFailed,
Failure: failure,
} }
select { select {
case <-ctx.Done(): case <-ctx.Done():
@@ -138,7 +139,7 @@ func (c *PlaylistReadinessCoordinator) Run(ctx context.Context) error {
continue continue
} }
ready := PlaylistReadiness{Revision: playlistSnapshot.Revision} ready := PlaylistEvent{Revision: playlistSnapshot.Revision}
select { select {
case <-ctx.Done(): case <-ctx.Done():
return ctx.Err() return ctx.Err()
@@ -217,24 +218,39 @@ func IsSessionFailed(
session SessionSnapshot, session SessionSnapshot,
statuses PlaybackStatusSnapshot, statuses PlaybackStatusSnapshot,
) bool { ) bool {
_, failed := SessionFailureStatus(session, statuses)
return failed
}
func SessionFailureStatus(
session SessionSnapshot,
statuses PlaybackStatusSnapshot,
) (Status, bool) {
if statuses.Generation != session.Generation { if statuses.Generation != session.Generation {
return false return Status{}, false
} }
switch session.Plan.Topology { switch session.Plan.Topology {
case TopologyIndependent: case TopologyIndependent:
return (session.Plan.Video.Active && statusIsFailed( if session.Plan.Video.Active && statusIsFailed(
statuses.Video, statuses.HasVideo, session.Generation, session.Plan.Video, statuses.Video, statuses.HasVideo, session.Generation, session.Plan.Video,
)) || (session.Plan.Audio.Active && statusIsFailed( ) {
return statuses.Video, true
}
if session.Plan.Audio.Active && statusIsFailed(
statuses.Audio, statuses.HasAudio, session.Generation, session.Plan.Audio, statuses.Audio, statuses.HasAudio, session.Generation, session.Plan.Audio,
)) ) {
return statuses.Audio, true
}
return Status{}, false
case TopologySynchronized: case TopologySynchronized:
return statuses.HasSync && failed := statuses.HasSync &&
statuses.Sync.Generation == session.Generation && statuses.Sync.Generation == session.Generation &&
statuses.Sync.State == StateFailed && statuses.Sync.State == StateFailed &&
sameSyncSource(statuses.Sync.Pair, session.Plan.Sync) sameSyncSource(statuses.Sync.Pair, session.Plan.Sync)
return statuses.Sync, failed
default: default:
return false return Status{}, false
} }
} }
@@ -63,55 +63,55 @@ func (s *fakePlaybackStatusSnapshotSource) set(snapshot PlaybackStatusSnapshot)
s.mu.Unlock() s.mu.Unlock()
} }
type fakePlaylistReadinessTicker struct { type fakePlaylistEventTicker struct {
ch chan time.Time ch chan time.Time
mu sync.Mutex mu sync.Mutex
stopped bool stopped bool
} }
func newFakePlaylistReadinessTicker() *fakePlaylistReadinessTicker { func newFakePlaylistEventTicker() *fakePlaylistEventTicker {
return &fakePlaylistReadinessTicker{ch: make(chan time.Time, 16)} return &fakePlaylistEventTicker{ch: make(chan time.Time, 16)}
} }
func (t *fakePlaylistReadinessTicker) C() <-chan time.Time { return t.ch } func (t *fakePlaylistEventTicker) C() <-chan time.Time { return t.ch }
func (t *fakePlaylistReadinessTicker) Stop() { func (t *fakePlaylistEventTicker) Stop() {
t.mu.Lock() t.mu.Lock()
t.stopped = true t.stopped = true
t.mu.Unlock() t.mu.Unlock()
} }
func (t *fakePlaylistReadinessTicker) tick() { t.ch <- time.Now() } func (t *fakePlaylistEventTicker) tick() { t.ch <- time.Now() }
func (t *fakePlaylistReadinessTicker) isStopped() bool { func (t *fakePlaylistEventTicker) isStopped() bool {
t.mu.Lock() t.mu.Lock()
defer t.mu.Unlock() defer t.mu.Unlock()
return t.stopped return t.stopped
} }
func TestNewPlaylistReadinessCoordinatorValidatesDependencies(t *testing.T) { func TestNewPlaylistEventCoordinatorValidatesDependencies(t *testing.T) {
playlist := &fakePlaylistSnapshotSource{} playlist := &fakePlaylistSnapshotSource{}
session := &fakeSessionSnapshotSource{} session := &fakeSessionSnapshotSource{}
statuses := &fakePlaybackStatusSnapshotSource{} statuses := &fakePlaybackStatusSnapshotSource{}
output := make(chan PlaylistReadiness) output := make(chan PlaylistEvent)
tests := []struct { tests := []struct {
name string name string
playlist PlaylistSnapshotSource playlist PlaylistSnapshotSource
session SessionSnapshotSource session SessionSnapshotSource
statuses PlaybackStatusSnapshotSource statuses PlaybackStatusSnapshotSource
output chan<- PlaylistReadiness output chan<- PlaylistEvent
interval time.Duration interval time.Duration
wantErr error wantErr error
}{ }{
{name: "playlist", session: session, statuses: statuses, output: output, interval: time.Millisecond, wantErr: ErrPlaylistSnapshotSourceRequired}, {name: "playlist", session: session, statuses: statuses, output: output, interval: time.Millisecond, wantErr: ErrPlaylistSnapshotSourceRequired},
{name: "session", playlist: playlist, statuses: statuses, output: output, interval: time.Millisecond, wantErr: ErrSessionSnapshotSourceRequired}, {name: "session", playlist: playlist, statuses: statuses, output: output, interval: time.Millisecond, wantErr: ErrSessionSnapshotSourceRequired},
{name: "statuses", playlist: playlist, session: session, output: output, interval: time.Millisecond, wantErr: ErrStatusSnapshotSourceRequired}, {name: "statuses", playlist: playlist, session: session, output: output, interval: time.Millisecond, wantErr: ErrStatusSnapshotSourceRequired},
{name: "output", playlist: playlist, session: session, statuses: statuses, interval: time.Millisecond, wantErr: ErrPlaylistReadinessOutputRequired}, {name: "output", playlist: playlist, session: session, statuses: statuses, interval: time.Millisecond, wantErr: ErrPlaylistEventOutputRequired},
{name: "interval", playlist: playlist, session: session, statuses: statuses, output: output, wantErr: ErrPlaylistReadinessInterval}, {name: "interval", playlist: playlist, session: session, statuses: statuses, output: output, wantErr: ErrPlaylistEventInterval},
{name: "valid", playlist: playlist, session: session, statuses: statuses, output: output, interval: time.Millisecond}, {name: "valid", playlist: playlist, session: session, statuses: statuses, output: output, interval: time.Millisecond},
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
coordinator, err := NewPlaylistReadinessCoordinator( coordinator, err := NewPlaylistEventCoordinator(
test.playlist, test.playlist,
test.session, test.session,
test.statuses, test.statuses,
@@ -119,7 +119,7 @@ func TestNewPlaylistReadinessCoordinatorValidatesDependencies(t *testing.T) {
test.interval, test.interval,
) )
if !errors.Is(err, test.wantErr) { if !errors.Is(err, test.wantErr) {
t.Fatalf("NewPlaylistReadinessCoordinator() error = %v, want %v", err, test.wantErr) t.Fatalf("NewPlaylistEventCoordinator() error = %v, want %v", err, test.wantErr)
} }
if test.wantErr != nil && coordinator != nil { if test.wantErr != nil && coordinator != nil {
t.Fatalf("coordinator = %#v, want nil", coordinator) t.Fatalf("coordinator = %#v, want nil", coordinator)
@@ -174,10 +174,10 @@ func TestPlaylistEntryMatchesSession(t *testing.T) {
} }
} }
func TestPlaylistReadinessCoordinatorEmitsOncePerRevision(t *testing.T) { func TestPlaylistEventCoordinatorEmitsOncePerRevision(t *testing.T) {
playlist, session, statuses := readyVideoSnapshots(1) playlist, session, statuses := readyVideoSnapshots(1)
output := make(chan PlaylistReadiness, 4) output := make(chan PlaylistEvent, 4)
coordinator, ticker, cancel, result := startReadinessCoordinator( coordinator, ticker, cancel, result := startEventCoordinator(
t, t,
playlist, playlist,
session, session,
@@ -188,16 +188,16 @@ func TestPlaylistReadinessCoordinatorEmitsOncePerRevision(t *testing.T) {
defer cancel() defer cancel()
ticker.tick() ticker.tick()
if got := receivePlaylistReadiness(t, output); got.Revision != 1 { if got := receivePlaylistEvent(t, output); got.Revision != 1 {
t.Fatalf("readiness revision = %d, want 1", got.Revision) t.Fatalf("readiness revision = %d, want 1", got.Revision)
} }
ticker.tick() ticker.tick()
assertNoPlaylistReadiness(t, output) assertNoPlaylistEvent(t, output)
next := playlistSnapshotForVideo(2) next := playlistSnapshotForVideo(2)
playlist.set(next, true) playlist.set(next, true)
ticker.tick() ticker.tick()
if got := receivePlaylistReadiness(t, output); got.Revision != 2 { if got := receivePlaylistEvent(t, output); got.Revision != 2 {
t.Fatalf("readiness revision = %d, want 2", got.Revision) t.Fatalf("readiness revision = %d, want 2", got.Revision)
} }
@@ -210,10 +210,10 @@ func TestPlaylistReadinessCoordinatorEmitsOncePerRevision(t *testing.T) {
} }
} }
func TestPlaylistReadinessCoordinatorWaitsForAllConditions(t *testing.T) { func TestPlaylistEventCoordinatorWaitsForAllConditions(t *testing.T) {
playlist, session, statuses := readyVideoSnapshots(1) playlist, session, statuses := readyVideoSnapshots(1)
output := make(chan PlaylistReadiness, 1) output := make(chan PlaylistEvent, 1)
_, ticker, cancel, result := startReadinessCoordinator(t, playlist, session, statuses, output) _, ticker, cancel, result := startEventCoordinator(t, playlist, session, statuses, output)
defer cancel() defer cancel()
tests := []struct { tests := []struct {
@@ -256,7 +256,7 @@ func TestPlaylistReadinessCoordinatorWaitsForAllConditions(t *testing.T) {
statuses.set(validStatuses.snapshot) statuses.set(validStatuses.snapshot)
test.mutate() test.mutate()
ticker.tick() ticker.tick()
assertNoPlaylistReadiness(t, output) assertNoPlaylistEvent(t, output)
}) })
} }
@@ -264,10 +264,10 @@ func TestPlaylistReadinessCoordinatorWaitsForAllConditions(t *testing.T) {
_ = waitForPlaylistResult(t, result) _ = waitForPlaylistResult(t, result)
} }
func TestPlaylistReadinessCoordinatorCancellationWhileBlockedSending(t *testing.T) { func TestPlaylistEventCoordinatorCancellationWhileBlockedSending(t *testing.T) {
playlist, session, statuses := readyVideoSnapshots(1) playlist, session, statuses := readyVideoSnapshots(1)
output := make(chan PlaylistReadiness) output := make(chan PlaylistEvent)
_, ticker, cancel, result := startReadinessCoordinator(t, playlist, session, statuses, output) _, ticker, cancel, result := startEventCoordinator(t, playlist, session, statuses, output)
ticker.tick() ticker.tick()
time.Sleep(time.Millisecond) time.Sleep(time.Millisecond)
@@ -322,15 +322,15 @@ func playlistSnapshotForVideo(revision uint64) PlaylistSnapshot {
} }
} }
func startReadinessCoordinator( func startEventCoordinator(
t *testing.T, t *testing.T,
playlist PlaylistSnapshotSource, playlist PlaylistSnapshotSource,
session SessionSnapshotSource, session SessionSnapshotSource,
statuses PlaybackStatusSnapshotSource, statuses PlaybackStatusSnapshotSource,
output chan<- PlaylistReadiness, output chan<- PlaylistEvent,
) (*PlaylistReadinessCoordinator, *fakePlaylistReadinessTicker, context.CancelFunc, <-chan error) { ) (*PlaylistEventCoordinator, *fakePlaylistEventTicker, context.CancelFunc, <-chan error) {
t.Helper() t.Helper()
coordinator, err := NewPlaylistReadinessCoordinator( coordinator, err := NewPlaylistEventCoordinator(
playlist, playlist,
session, session,
statuses, statuses,
@@ -338,28 +338,28 @@ func startReadinessCoordinator(
time.Millisecond, time.Millisecond,
) )
if err != nil { if err != nil {
t.Fatalf("NewPlaylistReadinessCoordinator() error = %v", err) t.Fatalf("NewPlaylistEventCoordinator() error = %v", err)
} }
ticker := newFakePlaylistReadinessTicker() ticker := newFakePlaylistEventTicker()
coordinator.newTicker = func(time.Duration) playlistReadinessTicker { return ticker } coordinator.newTicker = func(time.Duration) playlistEventTicker { return ticker }
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
result := make(chan error, 1) result := make(chan error, 1)
go func() { result <- coordinator.Run(ctx) }() go func() { result <- coordinator.Run(ctx) }()
return coordinator, ticker, cancel, result return coordinator, ticker, cancel, result
} }
func receivePlaylistReadiness(t *testing.T, output <-chan PlaylistReadiness) PlaylistReadiness { func receivePlaylistEvent(t *testing.T, output <-chan PlaylistEvent) PlaylistEvent {
t.Helper() t.Helper()
select { select {
case readiness := <-output: case readiness := <-output:
return readiness return readiness
case <-time.After(time.Second): case <-time.After(time.Second):
t.Fatal("timed out waiting for playlist readiness") t.Fatal("timed out waiting for playlist readiness")
return PlaylistReadiness{} return PlaylistEvent{}
} }
} }
func assertNoPlaylistReadiness(t *testing.T, output <-chan PlaylistReadiness) { func assertNoPlaylistEvent(t *testing.T, output <-chan PlaylistEvent) {
t.Helper() t.Helper()
select { select {
case readiness := <-output: case readiness := <-output:
+32 -3
View File
@@ -2,6 +2,7 @@ package playback
import ( import (
"context" "context"
"errors"
"testing" "testing"
"time" "time"
) )
@@ -145,14 +146,22 @@ func TestPlaylistControllerFailurePolicy(t *testing.T) {
t.Fatalf("NewPlaylistController() error = %v", err) t.Fatalf("NewPlaylistController() error = %v", err)
} }
commands := make(chan PlaylistCommand, 2) commands := make(chan PlaylistCommand, 2)
events := make(chan PlaylistReadiness, 2) events := make(chan PlaylistEvent, 2)
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
result := make(chan error, 1) result := make(chan error, 1)
go func() { result <- controller.Run(ctx, commands, events) }() go func() { result <- controller.Run(ctx, commands, events) }()
commands <- PlaylistCommand{Kind: PlaylistSelect, Index: 0} commands <- PlaylistCommand{Kind: PlaylistSelect, Index: 0}
<-sessions <-sessions
events <- PlaylistEvent{Revision: 1, Kind: PlaylistEventFailed} failureErr := errors.New("flow unavailable")
events <- PlaylistEvent{
Revision: 1,
Kind: PlaylistEventFailed,
Failure: Status{
Unit: UnitVideo, State: StateFailed,
Attempt: 2, FailedAttempts: 2, Err: failureErr,
},
}
if test.wantAdvance { if test.wantAdvance {
select { select {
@@ -160,16 +169,23 @@ func TestPlaylistControllerFailurePolicy(t *testing.T) {
case <-time.After(time.Second): case <-time.After(time.Second):
t.Fatal("failure did not advance playlist") t.Fatal("failure did not advance playlist")
} }
snapshot, _ := controller.Snapshot() snapshot := waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.State.CurrentIndex == 1 && snapshot.Revision == 2
})
if snapshot.State.CurrentIndex != 1 || snapshot.Revision != 2 { if snapshot.State.CurrentIndex != 1 || snapshot.Revision != 2 {
t.Fatalf("snapshot = %#v, want index 1 revision 2", snapshot) t.Fatalf("snapshot = %#v, want index 1 revision 2", snapshot)
} }
assertPlaylistFailure(t, snapshot, failureErr)
} else { } else {
select { select {
case command := <-sessions: case command := <-sessions:
t.Fatalf("unexpected session command: %#v", command) t.Fatalf("unexpected session command: %#v", command)
case <-time.After(20 * time.Millisecond): case <-time.After(20 * time.Millisecond):
} }
snapshot := waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.HasFailure
})
assertPlaylistFailure(t, snapshot, failureErr)
} }
cancel() cancel()
@@ -179,3 +195,16 @@ func TestPlaylistControllerFailurePolicy(t *testing.T) {
}) })
} }
} }
func assertPlaylistFailure(t *testing.T, snapshot PlaylistSnapshot, wantErr error) {
t.Helper()
if !snapshot.HasFailure {
t.Fatal("snapshot has no playlist failure")
}
failure := snapshot.Failure
if failure.EntryIndex != 0 || failure.EntryName != "first" ||
failure.Status.Unit != UnitVideo || failure.Status.Attempt != 2 ||
failure.Status.FailedAttempts != 2 || !errors.Is(failure.Status.Err, wantErr) {
t.Fatalf("failure = %#v", failure)
}
}
+175
View File
@@ -0,0 +1,175 @@
package playback
import (
"context"
"errors"
"testing"
"time"
)
func TestPlaylistFailurePipeline(t *testing.T) {
for _, test := range []struct {
name string
policy PlaylistFailurePolicy
wantAdvance bool
}{
{name: "wait retains failed entry", policy: PlaylistFailureWait},
{name: "next advances failed entry", policy: PlaylistFailureNext, wantAdvance: true},
} {
t.Run(test.name, func(t *testing.T) {
playlist := Playlist{
OnFailure: test.policy,
Entries: []PlaylistEntry{
{
Name: "independent",
Video: PlaylistFeed{Domain: "/video", UUID: "video"},
Audio: PlaylistFeed{Domain: "/audio", UUID: "audio"},
},
{Name: "next", Video: PlaylistFeed{Domain: "/video", UUID: "next"}},
},
}
h := startPlaylistPipeline(t, playlist)
defer h.stop(t)
h.commands <- PlaylistCommand{Kind: PlaylistSelect, Index: 0}
selected := receivePlaylistSession(t, h.sessions).Session
waitForPlaylistSnapshot(t, h.controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.Revision == 1
})
h.setSession(selected, 4)
failureErr := errors.New("audio retries exhausted")
h.statuses.set(PlaybackStatusSnapshot{
Generation: 4,
Audio: Status{
Unit: UnitAudio, State: StateFailed, Generation: 4,
Feed: selected.Audio, Attempt: 3, FailedAttempts: 3, Err: failureErr,
},
HasAudio: true,
})
h.ticker.tick()
if test.wantAdvance {
next := receivePlaylistSession(t, h.sessions)
if next.Session.Video.UUID != "next" || next.Session.Audio.IsConfigured() {
t.Fatalf("advanced session = %#v", next.Session)
}
} else {
select {
case command := <-h.sessions:
t.Fatalf("wait policy advanced with %#v", command)
case <-time.After(20 * time.Millisecond):
}
}
snapshot := waitForPlaylistSnapshot(t, h.controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.HasFailure
})
if snapshot.Failure.Status.Unit != UnitAudio ||
!errors.Is(snapshot.Failure.Status.Err, failureErr) {
t.Fatalf("failure snapshot = %#v", snapshot.Failure)
}
wantIndex := 0
if test.wantAdvance {
wantIndex = 1
}
if snapshot.State.CurrentIndex != wantIndex {
t.Fatalf("current index = %d, want %d", snapshot.State.CurrentIndex, wantIndex)
}
})
}
}
func TestPlaylistManualSelectionWhileFeedIsReconnecting(t *testing.T) {
playlist := navigationPlaylist(false)
h := startPlaylistPipeline(t, playlist)
defer h.stop(t)
h.commands <- PlaylistCommand{Kind: PlaylistSelect, Index: 0}
selected := receivePlaylistSession(t, h.sessions).Session
waitForPlaylistSnapshot(t, h.controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.Revision == 1
})
h.setSession(selected, 2)
h.statuses.set(PlaybackStatusSnapshot{
Generation: 2,
Video: Status{
Unit: UnitVideo, State: StateReconnecting, Generation: 2,
Feed: selected.Video, Attempt: 2, FailedAttempts: 1,
},
HasVideo: true,
})
h.ticker.tick()
h.commands <- PlaylistCommand{Kind: PlaylistSelect, Index: 1}
next := receivePlaylistSession(t, h.sessions)
if next.Session.Audio.UUID != "audio-2" || next.Session.Video.IsConfigured() {
t.Fatalf("manual selection session = %#v", next.Session)
}
waitForPlaylistSnapshot(t, h.controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.Revision == 2 && snapshot.State.CurrentIndex == 1
})
}
type playlistPipelineHarness struct {
controller *PlaylistController
commands chan PlaylistCommand
sessions chan SessionCommand
session *fakeSessionSnapshotSource
statuses *fakePlaybackStatusSnapshotSource
ticker *fakePlaylistEventTicker
cancel context.CancelFunc
results chan error
}
func startPlaylistPipeline(t *testing.T, playlist Playlist) *playlistPipelineHarness {
t.Helper()
sessions := make(chan SessionCommand, 8)
controller, err := NewPlaylistController(playlist, validPlaylistRetryPolicy(), sessions)
if err != nil {
t.Fatal(err)
}
events := make(chan PlaylistEvent, 8)
session := &fakeSessionSnapshotSource{}
statuses := &fakePlaybackStatusSnapshotSource{}
coordinator, err := NewPlaylistEventCoordinator(
controller, session, statuses, events, time.Millisecond,
)
if err != nil {
t.Fatal(err)
}
ticker := newFakePlaylistEventTicker()
coordinator.newTicker = func(time.Duration) playlistEventTicker { return ticker }
commands := make(chan PlaylistCommand, 8)
ctx, cancel := context.WithCancel(context.Background())
results := make(chan error, 2)
go func() { results <- controller.Run(ctx, commands, events) }()
go func() { results <- coordinator.Run(ctx) }()
return &playlistPipelineHarness{
controller: controller, commands: commands, sessions: sessions,
session: session, statuses: statuses, ticker: ticker,
cancel: cancel, results: results,
}
}
func (h *playlistPipelineHarness) setSession(desired SessionConfig, generation uint64) {
plan, err := BuildSessionPlan(desired, func(FeedConfig, FeedConfig) bool { return false })
if err != nil {
panic(err)
}
h.session.set(SessionSnapshot{Desired: desired, Plan: plan, Generation: generation}, true)
}
func (h *playlistPipelineHarness) stop(t *testing.T) {
t.Helper()
h.cancel()
for range 2 {
select {
case err := <-h.results:
if !errors.Is(err, context.Canceled) {
t.Fatalf("pipeline Run() error = %v", err)
}
case <-time.After(time.Second):
t.Fatal("playlist pipeline did not stop")
}
}
}
@@ -344,6 +344,38 @@ func TestSessionControllerCancellationStopsAndJoinsRuntime(t *testing.T) {
} }
} }
func TestSessionControllerStopAllWhileSessionIsActive(t *testing.T) {
events := make(chan controllerEvent, 32)
controller := newRecordingController(t, events)
initial := validCommandSession()
initial.SyncRequested = false
commands := make(chan SessionCommand)
done := make(chan error, 1)
go func() { done <- controller.Run(context.Background(), initial, commands) }()
receiveIndependentStarts(t, events)
commands <- SessionCommand{Kind: CommandStopAll}
stopped := map[Unit]bool{}
for len(stopped) < 2 {
event := receiveControllerEvent(t, events)
if event.action != "stop" || (event.unit != UnitVideo && event.unit != UnitAudio) {
t.Fatalf("unexpected stop event: %+v", event)
}
stopped[event.unit] = true
}
snapshot := waitControllerSnapshot(t, controller, func(snapshot SessionSnapshot) bool {
return snapshot.Plan.Topology == TopologyIdle
})
if snapshot.Desired.Video.Active || snapshot.Desired.Audio.Active {
t.Fatalf("stopped desired session = %#v", snapshot.Desired)
}
close(commands)
if err := <-done; err != nil {
t.Fatalf("Run() error = %v", err)
}
}
func TestSessionControllerSnapshotTracksDesiredPlanAndGeneration(t *testing.T) { func TestSessionControllerSnapshotTracksDesiredPlanAndGeneration(t *testing.T) {
events := make(chan controllerEvent, 64) events := make(chan controllerEvent, 64)
controller := newRecordingController(t, events) controller := newRecordingController(t, events)
+13
View File
@@ -57,6 +57,19 @@ func (b *VideoBridge) Next(
} }
} }
// TryNext returns a frame only when a producer is already waiting. It never
// blocks the caller, allowing UI/render loops to run independently of video
// frame cadence. A returned frame has the same completion requirements as
// one returned by Next.
func (b *VideoBridge) TryNext() (*PendingVideoFrame, bool) {
select {
case pending := <-b.requests:
return pending, true
default:
return nil, false
}
}
func (f *PendingVideoFrame) Complete(err error) { func (f *PendingVideoFrame) Complete(err error) {
f.completeOnce.Do(func() { f.completeOnce.Do(func() {
f.result <- err f.result <- err
+49
View File
@@ -144,3 +144,52 @@ func TestVideoBridgeNextHonorsCancellation(t *testing.T) {
t.Fatalf("Next() error = %v, want %v", err, context.Canceled) t.Fatalf("Next() error = %v, want %v", err, context.Canceled)
} }
} }
func TestVideoBridgeTryNextReturnsImmediatelyWhenEmpty(t *testing.T) {
bridge := NewVideoBridge()
if pending, ok := bridge.TryNext(); ok || pending != nil {
t.Fatalf("TryNext() = %#v, %t; want nil, false", pending, ok)
}
}
func TestVideoBridgeTryNextDeliversWithoutCopyAndRequiresCompletion(t *testing.T) {
bridge := NewVideoBridge()
frame := VideoFrame{Index: 9, Payload: []byte{1, 2, 3}}
consumeResult := make(chan error, 1)
started := make(chan struct{})
go func() {
close(started)
consumeResult <- bridge.ConsumeVideo(context.Background(), frame)
}()
<-started
deadline := time.Now().Add(videoBridgeTestTimeout)
var pending *PendingVideoFrame
for pending == nil && time.Now().Before(deadline) {
pending, _ = bridge.TryNext()
if pending == nil {
time.Sleep(time.Millisecond)
}
}
if pending == nil {
t.Fatal("TryNext() did not receive waiting producer")
}
if &pending.Frame.Payload[0] != &frame.Payload[0] {
t.Fatal("TryNext() copied borrowed payload")
}
select {
case err := <-consumeResult:
t.Fatalf("ConsumeVideo() returned before completion: %v", err)
default:
}
pending.Complete(nil)
select {
case err := <-consumeResult:
if err != nil {
t.Fatalf("ConsumeVideo() error = %v", err)
}
case <-time.After(videoBridgeTestTimeout):
t.Fatal("ConsumeVideo() did not return after completion")
}
}
+9 -2
View File
@@ -455,7 +455,12 @@ func validateFramePayload(
// DrawFrame acquires an image, records commands, submits, and presents. // DrawFrame acquires an image, records commands, submits, and presents.
// Returns ErrOutOfDate if the swapchain needs recreation // Returns ErrOutOfDate if the swapchain needs recreation
func (r *Renderer) DrawFrame(videoW, videoH, stride uint32) error { func (r *Renderer) DrawFrame(
videoW uint32,
videoH uint32,
stride uint32,
showVideo bool,
) error {
imageIndex, res := r.dev.AcquireNextImage(r.swapchain, r.imageAvailable, ^uint64(0)) imageIndex, res := r.dev.AcquireNextImage(r.swapchain, r.imageAvailable, ^uint64(0))
if res == vk.ErrorOutOfDateKHR || res == vk.SuboptimalKHR { if res == vk.ErrorOutOfDateKHR || res == vk.SuboptimalKHR {
return ErrOutOfDate return ErrOutOfDate
@@ -483,7 +488,7 @@ func (r *Renderer) DrawFrame(videoW, videoH, stride uint32) error {
r.fbs[imageIndex], r.fbs[imageIndex],
vk.Rect2D{Offset: vk.Offset2D{X: 0, Y: 0}, Extent: r.extent}, vk.Rect2D{Offset: vk.Offset2D{X: 0, Y: 0}, Extent: r.extent},
[]vk.ClearValue{ []vk.ClearValue{
vk.ClearColor(0.0, 0.0, 0.0, 1.0), vk.ClearColor(0.025, 0.03, 0.04, 1.0),
vk.ClearDepthStencil(1.0, 0), vk.ClearDepthStencil(1.0, 0),
}, },
) )
@@ -494,6 +499,7 @@ func (r *Renderer) DrawFrame(videoW, videoH, stride uint32) error {
MinDepth: 0, MaxDepth: 1, MinDepth: 0, MaxDepth: 1,
}) })
cmd.SetScissor(vk.Rect2D{Offset: vk.Offset2D{X: 0, Y: 0}, Extent: r.extent}) cmd.SetScissor(vk.Rect2D{Offset: vk.Offset2D{X: 0, Y: 0}, Extent: r.extent})
if showVideo {
cmd.BindPipeline(r.decodePipeline) cmd.BindPipeline(r.decodePipeline)
cmd.BindDescriptorSet(r.decodeLayout, 0, r.decodeSet) cmd.BindDescriptorSet(r.decodeLayout, 0, r.decodeSet)
pc := PushConstants{ pc := PushConstants{
@@ -505,6 +511,7 @@ func (r *Renderer) DrawFrame(videoW, videoH, stride uint32) error {
} }
cmd.PushConstants(r.decodeLayout, vk.ShaderStageFragment, 0, unsafe.Pointer(&pc), 20) cmd.PushConstants(r.decodeLayout, vk.ShaderStageFragment, 0, unsafe.Pointer(&pc), 20)
cmd.Draw(3, 1, 0, 0) cmd.Draw(3, 1, 0, 0)
}
if r.ImGuiDraw != nil { if r.ImGuiDraw != nil {
r.ImGuiDraw(cmd) r.ImGuiDraw(cmd)
} }
+24
View File
@@ -65,6 +65,9 @@ var (
sdlStartTextInput func(window uintptr) sdlStartTextInput func(window uintptr)
sdlStopTextInput func(window uintptr) sdlStopTextInput func(window uintptr)
sdlGetClipboardText func() uintptr
sdlSetClipboardText func(text *byte) bool
sdlFree func(memory uintptr)
) )
var loaded = false var loaded = false
@@ -98,6 +101,9 @@ func Load() error {
// input // input
purego.RegisterLibFunc(&sdlStartTextInput, h, "SDL_StartTextInput") purego.RegisterLibFunc(&sdlStartTextInput, h, "SDL_StartTextInput")
purego.RegisterLibFunc(&sdlStopTextInput, h, "SDL_StopTextInput") purego.RegisterLibFunc(&sdlStopTextInput, h, "SDL_StopTextInput")
purego.RegisterLibFunc(&sdlGetClipboardText, h, "SDL_GetClipboardText")
purego.RegisterLibFunc(&sdlSetClipboardText, h, "SDL_SetClipboardText")
purego.RegisterLibFunc(&sdlFree, h, "SDL_free")
loaded = true loaded = true
return nil return nil
} }
@@ -197,3 +203,21 @@ func GetAudioPlaybackDevices() []AudioDevice {
// Input wrappers // Input wrappers
func StartTextInput(window uintptr) { sdlStartTextInput(window) } func StartTextInput(window uintptr) { sdlStartTextInput(window) }
func StopTextInput(window uintptr) { sdlStopTextInput(window) } func StopTextInput(window uintptr) { sdlStopTextInput(window) }
func GetClipboardText() string {
text := sdlGetClipboardText()
if text == 0 {
return ""
}
result := cstr(text)
sdlFree(text)
return result
}
func SetClipboardText(text string) bool {
bytes := make([]byte, len(text)+1)
copy(bytes, text)
result := sdlSetClipboardText(&bytes[0])
runtime.KeepAlive(bytes)
return result
}
+5
View File
@@ -1,6 +1,11 @@
{ {
"loop": true, "loop": true,
"on_failure": "next", "on_failure": "next",
"retry": {
"max_attempts": 3,
"initial_delay": "500ms",
"max_delay": "5s"
},
"entries": [ "entries": [
{ {
"name": "timelapse", "name": "timelapse",