package main import ( "fmt" "log" "net/url" "path/filepath" "strings" "time" "git.leaktechnologies.dev/stu/VT_Player/player/mpvembed" "github.com/gotk3/gotk3/gdk" "github.com/gotk3/gotk3/glib" "github.com/gotk3/gotk3/gtk" ) const appCSS = ` * { font-family: "Noto Sans", "Cantarell", "Sans"; color: #E1EEFF; } window, GtkDrawingArea, box { background-color: #0B0F1A; } button { background: #171C2A; color: #E1EEFF; border-radius: 6px; padding: 4px 8px; } button:hover { background: #24314A; } label { color: #E1EEFF; } ` type pane struct { area *gtk.DrawingArea mpv *mpvembed.Client path string id int } func (p *pane) hasVideo() bool { return p.path != "" } type videoEntry struct { id int path string } var ( playlist []videoEntry nextVideoID = 1 ) func main() { gtk.Init(nil) win, err := gtk.WindowNew(gtk.WINDOW_TOPLEVEL) if err != nil { log.Fatalf("window: %v", err) } win.SetTitle("VT Player (GTK/mpv)") win.SetDefaultSize(1400, 800) grid, _ := gtk.GridNew() grid.SetColumnHomogeneous(true) grid.SetRowHomogeneous(false) win.Add(grid) left := newPane() right := newPane() controls := buildControls(win, left, right) grid.Attach(controls, 0, 0, 2, 1) grid.Attach(left.area, 0, 1, 1, 1) grid.Attach(right.area, 1, 1, 1, 1) applyCSS() preferDark() setupDragDest(left, left, right) setupDragDest(right, left, right) win.Connect("destroy", func() { if left.mpv != nil { left.mpv.Destroy() } if right.mpv != nil { right.mpv.Destroy() } gtk.MainQuit() }) win.ShowAll() gtk.Main() } func newPane() *pane { da, _ := gtk.DrawingAreaNew() da.SetHExpand(true) da.SetVExpand(true) p := &pane{area: da} da.Connect("realize", func() { var xid uint64 if w, err := da.GetWindow(); err == nil && w != nil { xid = getWindowID(w) } if p.mpv == nil { mpv, err := mpvembed.New() if err != nil { log.Printf("mpv create: %v", err) return } p.mpv = mpv _ = p.mpv.SetOptionString("pause", "yes") if xid != 0 { _ = p.mpv.SetWID(xid) } if err := p.mpv.Initialize(); err != nil { log.Printf("mpv init: %v", err) } return } // mpv already exists (created before realize); make sure WID is bound now if xid != 0 { _ = p.mpv.SetWID(xid) } }) return p } func buildControls(win *gtk.Window, left, right *pane) *gtk.Box { box, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 6) row1, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 4) row2, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 4) openL, _ := gtk.ButtonNewWithLabel("Open Left") openR, _ := gtk.ButtonNewWithLabel("Open Right") play, _ := gtk.ButtonNewWithLabel("Play Both") pause, _ := gtk.ButtonNewWithLabel("Pause Both") seek0, _ := gtk.ButtonNewWithLabel("Seek 0") stepF, _ := gtk.ButtonNewWithLabel("Step +1f") stepB, _ := gtk.ButtonNewWithLabel("Step -1f") info, _ := gtk.LabelNew("Meta: -") openL.Connect("clicked", func() { chooseAndLoad(win, left) }) openR.Connect("clicked", func() { chooseAndLoad(win, right) }) play.Connect("clicked", func() { if left.mpv != nil { _ = left.mpv.SetPropertyBool("pause", false) } if right.mpv != nil { _ = right.mpv.SetPropertyBool("pause", false) } }) pause.Connect("clicked", func() { if left.mpv != nil { _ = left.mpv.SetPropertyBool("pause", true) } if right.mpv != nil { _ = right.mpv.SetPropertyBool("pause", true) } }) seek0.Connect("clicked", func() { if left.mpv != nil { _ = left.mpv.Command("seek", "0", "absolute", "exact") } if right.mpv != nil { _ = right.mpv.Command("seek", "0", "absolute", "exact") } }) stepF.Connect("clicked", func() { if left.mpv != nil { _ = left.mpv.Command("frame-step") } if right.mpv != nil { _ = right.mpv.Command("frame-step") } }) stepB.Connect("clicked", func() { if left.mpv != nil { _ = left.mpv.Command("frame-back-step") } if right.mpv != nil { _ = right.mpv.Command("frame-back-step") } }) go func() { t := time.NewTicker(500 * time.Millisecond) defer t.Stop() for range t.C { text := metaSummary(left, right) _ = glib.IdleAdd(func() { defer func() { _ = recover() }() info.SetText(text) }) } }() row1.PackStart(openL, false, false, 0) row1.PackStart(openR, false, false, 0) row1.PackStart(play, false, false, 0) row1.PackStart(pause, false, false, 0) row1.PackStart(seek0, false, false, 0) row1.PackStart(stepB, false, false, 0) row1.PackStart(stepF, false, false, 0) row2.PackStart(info, false, false, 0) box.PackStart(row1, false, false, 0) box.PackStart(row2, false, false, 0) return box } func chooseAndLoad(win *gtk.Window, p *pane) { dlg, _ := gtk.FileChooserDialogNewWith2Buttons( "Open Video", win, gtk.FILE_CHOOSER_ACTION_OPEN, "Cancel", gtk.RESPONSE_CANCEL, "Open", gtk.RESPONSE_ACCEPT, ) dlg.SetModal(true) // Add file filter for video files filter, _ := gtk.FileFilterNew() filter.SetName("Video Files") filter.AddMimeType("video/*") filter.AddPattern("*.mp4") filter.AddPattern("*.mkv") filter.AddPattern("*.avi") filter.AddPattern("*.mov") filter.AddPattern("*.webm") filter.AddPattern("*.flv") filter.AddPattern("*.wmv") filter.AddPattern("*.m4v") filter.AddPattern("*.mpg") filter.AddPattern("*.mpeg") dlg.AddFilter(filter) // Add "All Files" filter as fallback allFilter, _ := gtk.FileFilterNew() allFilter.SetName("All Files") allFilter.AddPattern("*") dlg.AddFilter(allFilter) if resp := dlg.Run(); resp == gtk.RESPONSE_ACCEPT { filename := dlg.GetFilename() if filename != "" { log.Printf("Selected file: %s", filename) loadIntoPane(p, filename) } } dlg.Destroy() } func loadIntoPane(p *pane, filename string) { log.Printf("loadIntoPane: filename=%q", filename) if !ensurePaneReady(p) { log.Printf("loadIntoPane: pane not ready") return } p.path = filename p.id = getOrAddVideoID(filename) log.Printf("loadIntoPane: calling mpv loadfile command") if err := p.mpv.Command("loadfile", filename, "replace"); err != nil { log.Printf("loadfile %s: ERROR: %v", filename, err) } else { log.Printf("loadfile %s: success", filename) } _ = p.mpv.SetPropertyBool("pause", false) log.Printf("loadIntoPane: complete") } func metaSummary(a, b *pane) string { parts := func(p *pane) string { if p == nil || p.mpv == nil || p.path == "" { return "[empty]" } dur, _ := p.mpv.GetPropertyDouble("duration") pos, _ := p.mpv.GetPropertyDouble("time-pos") w, _ := p.mpv.GetPropertyInt64("width") h, _ := p.mpv.GetPropertyInt64("height") tag := "" if p.id > 0 { tag = fmt.Sprintf("#%d ", p.id) } return fmt.Sprintf("%s%s | %dx%d | %.1f/%.1fs", tag, filepath.Base(p.path), w, h, pos, dur) } return fmt.Sprintf("L: %s | R: %s", parts(a), parts(b)) } // getWindowID returns the native window handle (XID on X11, HWND on Windows). func getWindowID(w *gdk.Window) uint64 { if w == nil { return 0 } // gdk_x11_window_get_xid only works on X11; return 0 on other backends. return uint64(gdkWindowGetXID(w)) } // gdkWindowGetXID extracts the XID from a GDK window when running on X11. func gdkWindowGetXID(w *gdk.Window) uint { return uint(w.GetXID()) } func applyCSS() { provider, err := gtk.CssProviderNew() if err != nil { return } if err := provider.LoadFromData(appCSS); err != nil { return } screen, err := gdk.ScreenGetDefault() if err != nil { return } gtk.AddProviderForScreen(screen, provider, gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) } func preferDark() { if settings, err := gtk.SettingsGetDefault(); err == nil && settings != nil { _ = settings.SetProperty("gtk-application-prefer-dark-theme", true) } } func setupDragDest(targetPane *pane, left, right *pane) { uriTarget, err := gtk.TargetEntryNew("text/uri-list", gtk.TARGET_OTHER_APP, 0) if err != nil { return } targets := []gtk.TargetEntry{*uriTarget} targetPane.area.DragDestSet(gtk.DEST_DEFAULT_ALL, targets, gdk.ACTION_COPY) targetPane.area.Connect("drag-data-received", func(_ *gtk.DrawingArea, _ *gdk.DragContext, x, y int, data *gtk.SelectionData, _ uint, _ uint32) { defer func() { if r := recover(); r != nil { log.Printf("drag handler panic: %v", r) } }() if data == nil { log.Printf("drag-data-received: data is nil") return } log.Printf("drag-data-received: got data") uris := parseURIs(data) log.Printf("drag-data-received: parsed URIs: %v", uris) for _, u := range uris { if u == "" { continue } assignPathToPane(u, left, right) break } }) } // decide which pane to load based on availability: left prefers first, right second. func assignPathToPane(uri string, left, right *pane) { path := uriToPath(uri) if path == "" { return } if left != nil && !left.hasVideo() { loadIntoPane(left, path) return } if right != nil && !right.hasVideo() { loadIntoPane(right, path) return } // default: replace left if left != nil { loadIntoPane(left, path) } } func ensurePaneReady(p *pane) bool { if p == nil { return false } if p.mpv != nil { return true } mpv, err := mpvembed.New() if err != nil { log.Printf("mpv create: %v", err) return false } // Bind window if realized if w, err := p.area.GetWindow(); err == nil && w != nil { if xid := getWindowID(w); xid != 0 { _ = mpv.SetWID(xid) } } _ = mpv.SetOptionString("pause", "yes") if err := mpv.Initialize(); err != nil { log.Printf("mpv init: %v", err) return false } p.mpv = mpv return true } func uriToPath(u string) string { if u == "" { return "" } log.Printf("uriToPath: input=%q", u) // text/uri-list format: file:///path if strings.HasPrefix(u, "file://") { // Use url.Parse to properly handle URL encoding parsed, err := url.Parse(u) if err != nil { log.Printf("uriToPath: url.Parse error: %v", err) // Fallback: just strip file:// u = strings.TrimPrefix(u, "file://") // Handle localhost u = strings.TrimPrefix(u, "localhost") return u } path := parsed.Path log.Printf("uriToPath: parsed path=%q", path) return path } // Not a file:// URI, return as-is return u } func getOrAddVideoID(path string) int { if path == "" { return 0 } for _, e := range playlist { if e.path == path { return e.id } } id := nextVideoID nextVideoID++ playlist = append(playlist, videoEntry{id: id, path: path}) return id } // parseURIs tries to extract URIs from SelectionData while avoiding crashes on bad payloads. func parseURIs(data *gtk.SelectionData) []string { if data == nil { return nil } // try safe path using raw bytes first raw := data.GetData() if len(raw) == 0 { if txt := data.GetText(); txt != "" { raw = []byte(txt) } } if len(raw) > 0 { var out []string for _, ln := range strings.Split(string(raw), "\n") { ln = strings.TrimSpace(ln) if ln != "" { out = append(out, ln) } } if len(out) > 0 { return out } } // fallback to GetURIs; guard with recover because upstream may panic on nil C arrays defer func() { if r := recover(); r != nil { log.Printf("GetURIs panic: %v", r) } }() if uris := data.GetURIs(); len(uris) > 0 { return uris } return nil }