From da5ff8ea4a358dfb74b95f40cd23ea50c6d10b8f Mon Sep 17 00:00:00 2001 From: Dmitry Sergeev Date: Wed, 2 Sep 2026 00:23:03 +0300 Subject: [PATCH] GUI fields now trim leading and trailing whitespace: --- cmd/mxl-player/config.go | 5 +++ cmd/mxl-player/config_test.go | 13 +++++++ cmd/mxl-player/main.go | 29 +++++++++++++++- cmd/mxl-player/video_visibility.go | 30 ++++++++++++++++ cmd/mxl-player/video_visibility_test.go | 46 +++++++++++++++++++++++++ internal/imgui/imgui.go | 13 +++++++ internal/imgui/input_sdl3.go | 20 +++++++++++ internal/renderer/renderer.go | 31 ++++++++++------- internal/sdl/sdl.go | 28 +++++++++++++-- 9 files changed, 200 insertions(+), 15 deletions(-) create mode 100644 cmd/mxl-player/video_visibility.go create mode 100644 cmd/mxl-player/video_visibility_test.go diff --git a/cmd/mxl-player/config.go b/cmd/mxl-player/config.go index 41d9cea..411c221 100644 --- a/cmd/mxl-player/config.go +++ b/cmd/mxl-player/config.go @@ -2,6 +2,7 @@ package main import ( "mxl-player/internal/playback" + "strings" "time" ) @@ -17,6 +18,10 @@ func resolveDomain(shared, override string) string { return shared } +func normalizeFeedInput(domain, uuid string) (string, string) { + return strings.TrimSpace(domain), strings.TrimSpace(uuid) +} + func resolveRetryPolicy( cli playback.RetryPolicy, cliMaxAttemptsSet bool, diff --git a/cmd/mxl-player/config_test.go b/cmd/mxl-player/config_test.go index 0f7e4c8..b7b7327 100644 --- a/cmd/mxl-player/config_test.go +++ b/cmd/mxl-player/config_test.go @@ -183,3 +183,16 @@ func TestResolveRetryPolicy(t *testing.T) { }) } } + +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) + } +} diff --git a/cmd/mxl-player/main.go b/cmd/mxl-player/main.go index 3feaa14..7dd41ef 100644 --- a/cmd/mxl-player/main.go +++ b/cmd/mxl-player/main.go @@ -22,7 +22,7 @@ import ( const ( APP_NAME = "MXL Player" - APP_VER = "0.1.0" + APP_VER = "1.0.0" WIN_WIDTH int32 = 1280 WIN_HEIGHT int32 = 720 ) @@ -377,6 +377,8 @@ func main() { } } doReconnect := func() { + videoDomainStr, videoStr = normalizeFeedInput(videoDomainStr, videoStr) + audioDomainStr, audioStr = normalizeFeedInput(audioDomainStr, audioStr) videoActive = videoStr != "" audioActive = audioStr != "" @@ -470,6 +472,8 @@ func main() { displayedVideoWidth uint32 = placeholderWidth displayedVideoHeight uint32 = placeholderHeight displayedVideoStride uint32 = placeholderStride + displayedVideoSource playback.FeedConfig + hasDisplayedVideo bool fps float64 dropTracker videoDropTracker @@ -575,6 +579,16 @@ func main() { } 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 if hasFrame { @@ -722,7 +736,13 @@ func main() { drawFeedsSections := func() { cimgui.SeparatorText("Video") cimgui.InputTextWithHint("Video domain", "/dev/shm/mxl", &videoDomainStr, 0, nil) + if cimgui.IsItemDeactivatedAfterEdit() { + videoDomainStr, _ = normalizeFeedInput(videoDomainStr, "") + } cimgui.InputTextWithHint("Video UUID", "", &videoStr, 0, nil) + if cimgui.IsItemDeactivatedAfterEdit() { + _, videoStr = normalizeFeedInput("", videoStr) + } if videoActive { cimgui.SameLine() if cimgui.Button("Stop##video") { @@ -747,7 +767,13 @@ func main() { } cimgui.SeparatorText("Audio") cimgui.InputTextWithHint("Audio domain", "/dev/shm/mxl", &audioDomainStr, 0, nil) + if cimgui.IsItemDeactivatedAfterEdit() { + audioDomainStr, _ = normalizeFeedInput(audioDomainStr, "") + } cimgui.InputTextWithHint("Audio UUID", "", &audioStr, 0, nil) + if cimgui.IsItemDeactivatedAfterEdit() { + _, audioStr = normalizeFeedInput("", audioStr) + } if audioActive { cimgui.SameLine() if cimgui.Button("Stop##audio") { @@ -1062,6 +1088,7 @@ func main() { displayedVideoWidth, displayedVideoHeight, displayedVideoStride, + hasDisplayedVideo, ) if errors.Is(err, renderer.ErrOutOfDate) { if rerr := r.RecreateSwapchain(); rerr != nil { diff --git a/cmd/mxl-player/video_visibility.go b/cmd/mxl-player/video_visibility.go new file mode 100644 index 0000000..026b4e3 --- /dev/null +++ b/cmd/mxl-player/video_visibility.go @@ -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) +} diff --git a/cmd/mxl-player/video_visibility_test.go b/cmd/mxl-player/video_visibility_test.go new file mode 100644 index 0000000..79491ca --- /dev/null +++ b/cmd/mxl-player/video_visibility_test.go @@ -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") + } +} diff --git a/internal/imgui/imgui.go b/internal/imgui/imgui.go index 7584645..aa46b3a 100644 --- a/internal/imgui/imgui.go +++ b/internal/imgui/imgui.go @@ -3,6 +3,8 @@ package imgui import ( "time" + "mxl-player/internal/sdl" + cimgui "github.com/AllenDang/cimgui-go/imgui" ) @@ -17,9 +19,20 @@ func New() *Context { ctx := cimgui.CreateContext() cimgui.SetCurrentContext(ctx) io := cimgui.CurrentIO() + cimgui.CurrentPlatformIO().SetClipboardHandler(sdlClipboardHandler{}) 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() { cimgui.DestroyContext() } diff --git a/internal/imgui/input_sdl3.go b/internal/imgui/input_sdl3.go index cc63ee7..54936c6 100644 --- a/internal/imgui/input_sdl3.go +++ b/internal/imgui/input_sdl3.go @@ -7,6 +7,13 @@ import ( 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 func (c *Context) ProcessEvent(event *[128]byte) { eventType := *(*uint32)(unsafe.Pointer(&event[0])) @@ -15,7 +22,9 @@ func (c *Context) ProcessEvent(event *[128]byte) { switch eventType { case sdl.EventKeyDown, sdl.EventKeyUp: scancode := *(*uint32)(unsafe.Pointer(&event[24])) + modifiers := *(*uint16)(unsafe.Pointer(&event[32])) down := eventType == sdl.EventKeyDown + c.addKeyModifiers(modifiers) key := sdlScancodeToImGuiKey(scancode) if key >= 0 { 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 { switch scancode { case 40: // SDL_SCANCODE_RETURN @@ -79,6 +95,10 @@ func sdlScancodeToImGuiKey(scancode uint32) cimgui.Key { return cimgui.KeyLeftAlt case 230: // SDL_SCANCODE_RALT return cimgui.KeyRightAlt + case 227: // SDL_SCANCODE_LGUI + return cimgui.KeyLeftSuper + case 231: // SDL_SCANCODE_RGUI + return cimgui.KeyRightSuper default: // Letters A-Z: scancodes 4-29 map to ImGui KeyA-KeyZ if scancode >= 4 && scancode <= 29 { diff --git a/internal/renderer/renderer.go b/internal/renderer/renderer.go index 9a7b2bc..5d9e772 100644 --- a/internal/renderer/renderer.go +++ b/internal/renderer/renderer.go @@ -455,7 +455,12 @@ func validateFramePayload( // DrawFrame acquires an image, records commands, submits, and presents. // 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)) if res == vk.ErrorOutOfDateKHR || res == vk.SuboptimalKHR { return ErrOutOfDate @@ -483,7 +488,7 @@ func (r *Renderer) DrawFrame(videoW, videoH, stride uint32) error { r.fbs[imageIndex], vk.Rect2D{Offset: vk.Offset2D{X: 0, Y: 0}, Extent: r.extent}, []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), }, ) @@ -494,17 +499,19 @@ func (r *Renderer) DrawFrame(videoW, videoH, stride uint32) error { MinDepth: 0, MaxDepth: 1, }) cmd.SetScissor(vk.Rect2D{Offset: vk.Offset2D{X: 0, Y: 0}, Extent: r.extent}) - cmd.BindPipeline(r.decodePipeline) - cmd.BindDescriptorSet(r.decodeLayout, 0, r.decodeSet) - pc := PushConstants{ - Width: videoW, - Height: videoH, - StrideBytes: stride, - WinW: r.extent.Width, - WinH: r.extent.Height, + if showVideo { + cmd.BindPipeline(r.decodePipeline) + cmd.BindDescriptorSet(r.decodeLayout, 0, r.decodeSet) + pc := PushConstants{ + Width: videoW, + Height: videoH, + StrideBytes: stride, + WinW: r.extent.Width, + WinH: r.extent.Height, + } + cmd.PushConstants(r.decodeLayout, vk.ShaderStageFragment, 0, unsafe.Pointer(&pc), 20) + cmd.Draw(3, 1, 0, 0) } - cmd.PushConstants(r.decodeLayout, vk.ShaderStageFragment, 0, unsafe.Pointer(&pc), 20) - cmd.Draw(3, 1, 0, 0) if r.ImGuiDraw != nil { r.ImGuiDraw(cmd) } diff --git a/internal/sdl/sdl.go b/internal/sdl/sdl.go index b8cb2e4..aa951f2 100644 --- a/internal/sdl/sdl.go +++ b/internal/sdl/sdl.go @@ -63,8 +63,11 @@ var ( sdlGetAudioPlaybackDevices func(count *int32) uintptr sdlGetAudioDeviceName func(devid uint32) uintptr - sdlStartTextInput func(window uintptr) - sdlStopTextInput func(window uintptr) + sdlStartTextInput func(window uintptr) + sdlStopTextInput func(window uintptr) + sdlGetClipboardText func() uintptr + sdlSetClipboardText func(text *byte) bool + sdlFree func(memory uintptr) ) var loaded = false @@ -98,6 +101,9 @@ func Load() error { // input purego.RegisterLibFunc(&sdlStartTextInput, h, "SDL_StartTextInput") 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 return nil } @@ -197,3 +203,21 @@ func GetAudioPlaybackDevices() []AudioDevice { // Input wrappers func StartTextInput(window uintptr) { sdlStartTextInput(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 +}