VideoTools/cmd/gtkplayer/main.go
Stu 42c1f32647 Fix file dialog and drag-and-drop loading issues
File dialog improvements:
- Add video file filters (*.mp4, *.mkv, etc.) so files are visible
- Add "All Files" filter as fallback
- Make dialog modal with Cancel button
- Improve usability with proper MIME type filtering

Drag-and-drop improvements:
- Use net/url.Parse for proper URL decoding (%20 for spaces, etc.)
- Handle file:// URIs correctly with localhost stripping
- Add comprehensive debug logging throughout load chain

Debug logging added to:
- uriToPath() - shows URI parsing
- loadIntoPane() - tracks load progress and MPV errors
- drag-data-received - shows received URIs
- file dialog - logs selected files

These changes should fix both file navigation in dialogs and
drag-and-drop video loading from file managers.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-15 15:37:15 -05:00

496 lines
11 KiB
Go

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
}