Compare commits

..

24 Commits

Author SHA1 Message Date
Stu
2e4b433f01 Rewrite gtkplayer entry cleanly 2025-12-13 21:46:59 -05:00
Stu
a4653dd116 Fix GetWindow return handling 2025-12-13 21:45:25 -05:00
Stu
1c40324cd6 Simplify GTK window retrieval for mpv embed 2025-12-13 21:44:19 -05:00
Stu
bab96baee8 Handle gdk window retrieval without boolean conversion 2025-12-13 21:42:35 -05:00
Stu
e3305ce80c Fix Gtk window checks for mpv embed 2025-12-13 21:41:48 -05:00
Stu
08e0da1d45 Fix GTK window handle calls for mpv embed 2025-12-13 21:40:04 -05:00
Stu
ba1db9e16f Fix GTK/mpv player build issues (imports, window ID, polling) 2025-12-13 21:39:04 -05:00
Stu
26c48ab981 Vendor gotk3, add mpv cgo wrapper, and GTK mpv player stub 2025-12-13 18:58:03 -05:00
Stu
7f0ea613d6 Install mpv on Windows via choco/scoop 2025-12-13 12:43:33 -05:00
Stu
051a17243c Add mpv runtime dependency to installer and build scripts 2025-12-10 05:55:05 -05:00
Stu
1dfab7000b Switch playback to mpv IPC and poll progress 2025-12-10 05:47:38 -05:00
Stu
0ba248af4e Coalesce player view renders and schedule once 2025-12-10 05:31:48 -05:00
Stu
4929918d4b Replace internal decoder with ffplay subprocess 2025-12-10 05:27:24 -05:00
Stu
3d43123840 Throttle player view calls aggressively 2025-12-10 05:22:30 -05:00
Stu
feeaf8e39a Throttle player view rebuilds after video load 2025-12-10 05:20:37 -05:00
Stu
8479bfef6f Guard player view render and refresh after loads 2025-12-10 05:17:02 -05:00
Stu
9d255680bf Track current source to avoid rebuilds 2025-12-10 05:09:01 -05:00
Stu
a393183d83 Trim extra UI rebuild guard 2025-12-10 05:02:17 -05:00
Stu
22e325e123 Keep player session alive and stop repeated rebuilds 2025-12-10 04:59:43 -05:00
Stu
47067aabf0 Keep player session alive in showPlayerView 2025-12-10 04:54:55 -05:00
Stu
e727b8ea09 Auto-start playback, guard view rebuilds, and clean window close 2025-12-10 03:23:40 -05:00
Stu
fc1e91bda6 Avoid player view rebuild when already active 2025-12-10 03:13:54 -05:00
Stu
ee08618142 Add playback session logging for troubleshooting 2025-12-09 18:53:30 -05:00
Stu
ab9f19095d Set video surface size and surface ffmpeg errors 2025-12-09 18:51:41 -05:00
11 changed files with 1160 additions and 484 deletions

View File

@ -127,6 +127,7 @@ VideoToolsClean # Clean build artifacts and cache
### Essential
- **Go 1.21 or later** - https://go.dev/dl/
- **Bash or Zsh** shell
- **mpv** (runtime playback backend)
### Optional
- **FFmpeg** (for actual video encoding)
@ -142,6 +143,20 @@ VideoToolsClean # Clean build artifacts and cache
---
## Troubleshooting
### "mpv not found"
**Solution:** Install mpv from your package manager:
```bash
# Debian/Ubuntu
sudo apt-get install -y mpv
# Fedora/RHEL
sudo dnf install -y mpv
# Arch
sudo pacman -S --needed mpv
```
### "Go is not installed"
@ -356,4 +371,3 @@ Installation works in WSL environment. Ensure you have WSL with Linux distro ins
---
Enjoy using VideoTools! 🎬

218
cmd/gtkplayer/main.go Normal file
View File

@ -0,0 +1,218 @@
package main
import (
"fmt"
"log"
"path/filepath"
"time"
"git.leaktechnologies.dev/stu/VT_Player/player/mpvembed"
"github.com/gotk3/gotk3/gdk"
"github.com/gotk3/gotk3/glib"
"github.com/gotk3/gotk3/gtk"
)
type pane struct {
area *gtk.DrawingArea
mpv *mpvembed.Client
path string
}
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)
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() {
if p.mpv != nil {
return
}
mpv, err := mpvembed.New()
if err != nil {
log.Printf("mpv create: %v", err)
return
}
p.mpv = mpv
if w, ok := da.GetWindow(); ok && 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 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() { 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.FileChooserDialogNewWith1Button("Open Video", win, gtk.FILE_CHOOSER_ACTION_OPEN, "Open", gtk.RESPONSE_ACCEPT)
if resp := dlg.Run(); resp == gtk.RESPONSE_ACCEPT {
filename := dlg.GetFilename()
if filename != "" {
loadIntoPane(p, filename)
}
}
dlg.Destroy()
}
func loadIntoPane(p *pane, filename string) {
if p.mpv == nil {
return
}
p.path = filename
if err := p.mpv.Command("loadfile", filename, "replace"); err != nil {
log.Printf("loadfile %s: %v", filename, err)
}
_ = p.mpv.SetPropertyBool("pause", false)
}
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")
return fmt.Sprintf("%s | %dx%d | %.1f/%.1fs", 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
}
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())
}

3
go.mod
View File

@ -4,9 +4,12 @@ go 1.25.1
require (
fyne.io/fyne/v2 v2.7.1
github.com/gotk3/gotk3 v0.6.4
github.com/hajimehoshi/oto v0.7.1
)
replace github.com/gotk3/gotk3 => ./third_party/gotk3
require (
fyne.io/systray v1.11.1-0.20250603113521-ca66a66d8b58 // indirect
github.com/BurntSushi/toml v1.5.0 // indirect

2
go.sum
View File

@ -35,6 +35,8 @@ github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y=
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
github.com/gotk3/gotk3 v0.6.4 h1:5ur/PRr86PwCG8eSj98D1eXvhrNNK6GILS2zq779dCg=
github.com/gotk3/gotk3 v0.6.4/go.mod h1:/hqFpkNa9T3JgNAE2fLvCdov7c5bw//FHNZrZ3Uv9/Q=
github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8=

691
main.go
View File

@ -162,44 +162,66 @@ func (c convertConfig) CoverLabel() string {
return filepath.Base(c.CoverArtPath)
}
// requestPlayerView schedules a single player view render, coalescing rapid requests.
func (s *appState) requestPlayerView() {
s.playerViewPendingMu.Lock()
if s.playerViewPending {
s.playerViewPendingMu.Unlock()
return
}
s.playerViewPending = true
s.playerViewRefreshNeeded = true
s.playerViewPendingMu.Unlock()
fyne.Do(func() {
s.showPlayerView()
})
}
type appState struct {
window fyne.Window
active string
lastModule string
source *videoSource
loadedVideos []*videoSource // Multiple loaded videos for navigation
currentIndex int // Current video index in loadedVideos
anim *previewAnimator
convert convertConfig
currentFrame string
player player.Controller
playerReady bool
playerVolume float64
playerMuted bool
lastVolume float64
playerPaused bool
playerPos float64
playerLast time.Time
compareSess1 *playSession
compareSess2 *playSession
progressQuit chan struct{}
convertCancel context.CancelFunc
playerSurf *playerSurface
convertBusy bool
convertStatus string
convertActiveIn string
convertActiveOut string
convertProgress float64
playSess *playSession
jobQueue *queue.Queue
statsBar *ui.ConversionStatsBar
queueBtn *widget.Button
queueScroll *container.Scroll
queueOffset fyne.Position
compareFile1 *videoSource
compareFile2 *videoSource
keyframingMode bool // Toggle for frame-accurate editing features
isFullscreen bool // Fullscreen mode state
window fyne.Window
active string
lastModule string
source *videoSource
loadedVideos []*videoSource // Multiple loaded videos for navigation
currentIndex int // Current video index in loadedVideos
anim *previewAnimator
convert convertConfig
currentFrame string
player player.Controller
playerReady bool
playerVolume float64
playerMuted bool
lastVolume float64
playerPaused bool
playerPos float64
playerLast time.Time
compareSess1 *playSession
compareSess2 *playSession
lastSourcePath string
playerViewSource string
renderingPlayerView bool
playerViewRefreshNeeded bool
lastPlayerViewRender time.Time
playerViewPendingMu sync.Mutex
playerViewPending bool
progressQuit chan struct{}
convertCancel context.CancelFunc
playerSurf *playerSurface
convertBusy bool
convertStatus string
convertActiveIn string
convertActiveOut string
convertProgress float64
playSess *playSession
jobQueue *queue.Queue
statsBar *ui.ConversionStatsBar
queueBtn *widget.Button
queueScroll *container.Scroll
queueOffset fyne.Position
compareFile1 *videoSource
compareFile2 *videoSource
keyframingMode bool // Toggle for frame-accurate editing features
isFullscreen bool // Fullscreen mode state
}
func (s *appState) stopPreview() {
@ -266,19 +288,7 @@ func (s *appState) queueProgressCounts() (completed, total int) {
}
func (s *appState) updateQueueButtonLabel() {
if s.queueBtn == nil {
return
}
completed, total := s.queueProgressCounts()
// Include active direct conversion in totals
if s.convertBusy {
total++
}
label := "View Queue"
if total > 0 {
label = fmt.Sprintf("View Queue %d/%d", completed, total)
}
s.queueBtn.SetText(label)
// Queue UI hidden in player-only mode.
}
type playerSurface struct {
@ -428,7 +438,17 @@ func (s *appState) showErrorWithCopy(title string, err error) {
func (s *appState) showMainMenu() {
fmt.Printf("🎬 showMainMenu called - going to player view\n")
// Minimal entry point: go straight to the player view.
s.showPlayerView()
s.requestPlayerView()
}
func (s *appState) onClose() {
s.stopPreview()
s.stopPlayer()
s.stopCompareSessions()
if s.jobQueue != nil {
s.jobQueue.Stop()
}
logging.Close()
}
// showCompareView renders a simple side-by-side player for the first two loaded videos.
@ -469,6 +489,7 @@ func (s *appState) buildComparePane(src *videoSource, onStop func(), setSess fun
stageBG := canvas.NewRectangle(utils.MustHex("#0F1529"))
stageBG.SetMinSize(fyne.NewSize(640, 360))
videoImg := canvas.NewImageFromResource(nil)
videoImg.SetMinSize(fyne.NewSize(960, 540))
// Populate a preview frame if available
if len(src.PreviewFrames) == 0 {
if frames, err := capturePreviewFrames(src.Path, src.Duration); err == nil && len(frames) > 0 {
@ -578,11 +599,38 @@ func (s *appState) buildComparePane(src *videoSource, onStop func(), setSess fun
// showPlayerView renders the player-focused UI with a lightweight playlist.
func (s *appState) showPlayerView() {
fmt.Printf("🎬 showPlayerView called\n")
s.stopPreview()
s.stopPlayer()
s.playerViewPendingMu.Lock()
s.playerViewPending = false
s.playerViewPendingMu.Unlock()
if s.renderingPlayerView {
fmt.Printf("⏳ player view render already in progress, skipping\n")
return
}
currentPath := ""
if s.source != nil {
currentPath = s.source.Path
}
// Hard throttle: if we rendered recently for the same source, skip.
if !s.playerViewRefreshNeeded && s.active == "player" && s.playerViewSource == currentPath && time.Since(s.lastPlayerViewRender) < time.Second {
fmt.Printf("⏳ throttle: rendered recently for %s\n", currentPath)
return
}
s.renderingPlayerView = true
defer func() { s.renderingPlayerView = false }()
if !s.playerViewRefreshNeeded && s.active == "player" && s.source != nil && s.playerViewSource == currentPath {
fmt.Printf("📺 already in player view for this source; skipping rebuild\n")
return
}
s.playerViewRefreshNeeded = false
// Do not stop the player; only clear compare previews.
s.stopCompareSessions()
s.active = "player"
if s.source != nil {
s.lastSourcePath = s.source.Path
}
fmt.Printf("📺 s.source is nil: %v\n", s.source == nil)
s.lastPlayerViewRender = time.Now()
// Helper to refresh the view after selection/loads.
refresh := func() {
@ -596,7 +644,7 @@ func (s *appState) showPlayerView() {
if err != nil || r == nil {
return
}
path := r.URI().Path()
path := uriPath(r.URI())
r.Close()
go s.loadVideo(path)
}, s.window)
@ -653,8 +701,6 @@ func (s *appState) showPlayerView() {
}
}()
}
refresh()
})
keyframeModeItem.Checked = s.keyframingMode
toolsMenu.Items = append(toolsMenu.Items, keyframeModeItem)
@ -665,6 +711,7 @@ func (s *appState) showPlayerView() {
// Player area
var playerArea fyne.CanvasObject
if s.source == nil {
s.playerViewSource = ""
bg := canvas.NewRectangle(utils.MustHex("#05070C"))
bg.SetMinSize(fyne.NewSize(960, 540))
@ -714,6 +761,7 @@ func (s *appState) showPlayerView() {
s.setContent(playerArea)
} else {
src := s.source
s.playerViewSource = src.Path
fmt.Printf("🎬 Creating player view for loaded video\n")
// Image surface
@ -1399,7 +1447,7 @@ func (s *appState) batchAddToQueue(paths []string) {
}
}
s.loadVideos(combined)
s.showPlayerView()
s.requestPlayerView()
}
}, false)
}
@ -1986,6 +2034,7 @@ func runGUI() {
a.Settings().SetTheme(&ui.MonoTheme{})
logging.Debug(logging.CatUI, "created fyne app: %#v", a)
w := a.NewWindow("VT Player")
state.window = w
if icon := utils.LoadAppIcon(); icon != nil {
a.SetIcon(icon)
w.SetIcon(icon)
@ -2047,7 +2096,7 @@ func runGUI() {
// Initialize conversion stats bar
state.statsBar = ui.NewConversionStatsBar(func() {
// Clicking the stats bar opens the queue view
state.showQueue()
// Queue hidden in player-only mode
})
// Initialize job queue
@ -2060,9 +2109,6 @@ func runGUI() {
app.Driver().DoFromGoroutine(func() {
state.updateStatsBar()
state.updateQueueButtonLabel()
if state.active == "queue" {
state.refreshQueueView()
}
}, false)
})
@ -2099,19 +2145,12 @@ func runGUI() {
fmt.Printf("✅ showMainMenu completed\n")
logging.Debug(logging.CatUI, "main menu rendered with %d modules", len(modulesList))
// Start stats bar update loop on a timer
go func() {
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for range ticker.C {
app := fyne.CurrentApp()
if app != nil && app.Driver() != nil {
app.Driver().DoFromGoroutine(func() {
state.updateStatsBar()
}, false)
}
}
}()
// Stats update loop disabled in player-only mode to avoid view churn.
w.SetOnClosed(func() {
state.onClose()
a.Quit()
})
w.ShowAndRun()
}
@ -3446,11 +3485,9 @@ func buildVideoPane(state *appState, min fyne.Size, src *videoSource, onCover fu
state.currentFrame = sourceFrame
}
var img *canvas.Image
img := canvas.NewImageFromResource(nil)
if sourceFrame != "" {
img = canvas.NewImageFromFile(sourceFrame)
} else {
img = canvas.NewImageFromResource(nil)
}
img.FillMode = canvas.ImageFillContain
// Don't set rigid MinSize on image - it will scale to container
@ -3514,14 +3551,22 @@ func buildVideoPane(state *appState, min fyne.Size, src *videoSource, onCover fu
}
var controls fyne.CanvasObject
autoStarted := false
if usePlayer {
var volIcon *widget.Button
var updatingVolume bool
ensureSession := func() bool {
if state.playSess == nil {
fmt.Printf("🎥 Creating play session for %s\n", src.Path)
state.playSess = newPlaySession(src.Path, src.Width, src.Height, src.FrameRate, int(targetWidth-28), int(targetHeight-40), updateProgress, img)
if state.playSess == nil {
fmt.Printf("❌ ERROR: Failed to create play session\n")
return false
}
state.playSess.SetVolume(state.playerVolume)
state.playerPaused = true
state.playerPaused = false
state.playSess.Play()
autoStarted = true
}
return state.playSess != nil
}
@ -3586,15 +3631,18 @@ func buildVideoPane(state *appState, min fyne.Size, src *videoSource, onCover fu
updateVolIcon()
volSlider.Refresh()
playBtn := utils.MakeIconButton("▶/⏸", "Play/Pause", func() {
fmt.Printf("▶ Play/Pause pressed\n")
if !ensureSession() {
return
}
if state.playerPaused {
state.playSess.Play()
state.playerPaused = false
fmt.Printf("▶ Playback started\n")
} else {
state.playSess.Pause()
state.playerPaused = true
fmt.Printf("⏸ Playback paused\n")
}
})
fullBtn := utils.MakeIconButton("⛶", "Toggle fullscreen", func() {
@ -3606,6 +3654,15 @@ func buildVideoPane(state *appState, min fyne.Size, src *videoSource, onCover fu
container.NewHBox(playBtn, fullBtn, coverBtn, importBtn, layout.NewSpacer(), volBox),
progress,
)
// Auto-start playback once when view is built
if !autoStarted {
if ensureSession() {
state.playSess.Play()
state.playerPaused = false
fmt.Printf("▶ Auto-start playback\n")
}
}
} else {
slider := widget.NewSlider(0, math.Max(1, float64(len(src.PreviewFrames)-1)))
slider.Step = 1
@ -3665,37 +3722,16 @@ func buildVideoPane(state *appState, min fyne.Size, src *videoSource, onCover fu
}
type playSession struct {
path string
fps float64
width int
height int
targetW int
targetH int
volume float64
muted bool
paused bool
current float64
stop chan struct{}
done chan struct{}
prog func(float64)
img *canvas.Image
mu sync.Mutex
videoCmd *exec.Cmd
audioCmd *exec.Cmd
frameN int
}
var audioCtxGlobal struct {
once sync.Once
ctx *oto.Context
err error
}
func getAudioContext(sampleRate, channels, bytesPerSample int) (*oto.Context, error) {
audioCtxGlobal.once.Do(func() {
audioCtxGlobal.ctx, audioCtxGlobal.err = oto.NewContext(sampleRate, channels, bytesPerSample, 2048)
})
return audioCtxGlobal.ctx, audioCtxGlobal.err
path string
fps float64
volume float64
muted bool
paused bool
current float64
mpv *mpvClient
prog func(float64)
done chan struct{}
mu sync.Mutex
}
func newPlaySession(path string, w, h int, fps float64, targetW, targetH int, prog func(float64), img *canvas.Image) *playSession {
@ -3704,62 +3740,57 @@ func newPlaySession(path string, w, h int, fps float64, targetW, targetH int, pr
fmt.Printf("═══════════════════════════════════════════════════════\n")
fmt.Printf("📁 Video: %s\n", filepath.Base(path))
fmt.Printf("📐 Source: %dx%d @ %.2f fps\n", w, h, fps)
fmt.Printf("🎯 Target: %dx%d\n", targetW, targetH)
fmt.Printf("🎯 Playback via mpv subprocess\n")
fmt.Printf("═══════════════════════════════════════════════════════\n\n")
// Validate input parameters
if fps <= 0 {
fps = 24
fmt.Printf("⚠️ Invalid FPS (%.2f), defaulting to 24\n", fps)
}
if targetW <= 0 {
targetW = 640
fmt.Printf("⚠️ Invalid target width (%d), defaulting to 640\n", targetW)
}
if targetH <= 0 {
targetH = int(float64(targetW) * (float64(h) / float64(utils.MaxInt(w, 1))))
fmt.Printf("⚠️ Invalid target height (%d), calculating to %d\n", targetH, targetH)
}
// Check if video file exists
if _, err := os.Stat(path); os.IsNotExist(err) {
fmt.Printf("❌ ERROR: Video file does not exist: %s\n", path)
return nil
}
fmt.Printf("✅ Play session created successfully\n")
return &playSession{
path: path,
fps: fps,
width: w,
height: h,
targetW: targetW,
targetH: targetH,
volume: 100,
stop: make(chan struct{}),
done: make(chan struct{}),
prog: prog,
img: img,
if _, err := exec.LookPath("mpv"); err != nil {
fmt.Printf("❌ ERROR: mpv not found in PATH: %v\n", err)
return nil
}
ps := &playSession{
path: path,
fps: fps,
volume: 100,
done: make(chan struct{}),
prog: prog,
mpv: newMPVClient(),
}
if err := ps.mpv.EnsureRunning(); err != nil {
fmt.Printf("❌ ERROR: failed to start mpv: %v\n", err)
return nil
}
if err := ps.mpv.LoadFile(path); err != nil {
fmt.Printf("❌ ERROR: mpv load failed: %v\n", err)
return nil
}
_ = ps.mpv.SetVolume(ps.volume)
ps.startProgressPoll()
fmt.Printf("✅ Play session created successfully\n")
return ps
}
func (p *playSession) Play() {
fmt.Printf("▶️ PLAY called (current position: %.2fs)\n", p.current)
p.mu.Lock()
defer p.mu.Unlock()
if p.videoCmd == nil && p.audioCmd == nil {
fmt.Printf("⚡ Starting playback from scratch...\n")
p.startLocked(p.current)
return
if p.mpv != nil {
_ = p.mpv.Play()
p.paused = false
}
fmt.Printf("▶️ Resuming playback\n")
p.paused = false
}
func (p *playSession) Pause() {
p.mu.Lock()
defer p.mu.Unlock()
p.paused = true
if p.mpv != nil {
_ = p.mpv.Pause()
p.paused = true
}
}
func (p *playSession) Seek(offset float64) {
@ -3768,80 +3799,28 @@ func (p *playSession) Seek(offset float64) {
if offset < 0 {
offset = 0
}
paused := p.paused
p.current = offset
p.stopLocked()
p.startLocked(p.current)
p.paused = paused
if p.paused {
// Ensure loops honor paused right after restart.
time.AfterFunc(30*time.Millisecond, func() {
p.mu.Lock()
defer p.mu.Unlock()
p.paused = true
})
}
if p.prog != nil {
p.prog(p.current)
if p.mpv != nil {
_ = p.mpv.Seek(p.current)
}
}
func (p *playSession) SetVolume(v float64) {
p.mu.Lock()
defer p.mu.Unlock()
if v < 0 {
v = 0
}
if v > 100 {
v = 100
}
p.volume = v
if v > 0 {
p.muted = false
} else {
p.muted = true
}
}
func (p *playSession) SetVolume(v float64) {}
// StepFrame steps forward or backward by one frame
// direction: 1 for forward, -1 for backward
func (p *playSession) StepFrame(direction int) {
p.mu.Lock()
defer p.mu.Unlock()
// Ensure we're paused for frame stepping
if !p.paused {
p.paused = true
}
// Calculate new position (1 frame = 1/fps seconds)
frameDuration := 1.0 / p.fps
newPos := p.current + (float64(direction) * frameDuration)
if newPos < 0 {
newPos = 0
}
p.current = newPos
p.stopLocked()
p.startLocked(p.current)
p.paused = true
// Ensure paused state sticks
time.AfterFunc(30*time.Millisecond, func() {
p.mu.Lock()
defer p.mu.Unlock()
p.paused = true
})
if p.prog != nil {
p.prog(p.current)
}
step := 1.0 / math.Max(p.fps, 24)
p.Seek(p.current + float64(direction)*step)
}
// GetCurrentPosition returns the current playback position
func (p *playSession) GetCurrentPosition() float64 {
p.mu.Lock()
defer p.mu.Unlock()
if p.mpv != nil {
return p.mpv.Position()
}
return p.current
}
@ -3852,285 +3831,30 @@ func (p *playSession) Stop() {
}
func (p *playSession) stopLocked() {
select {
case <-p.stop:
default:
close(p.stop)
if p.mpv != nil {
_ = p.mpv.Quit()
p.mpv = nil
}
if p.videoCmd != nil && p.videoCmd.Process != nil {
_ = p.videoCmd.Process.Kill()
_ = p.videoCmd.Wait()
}
if p.audioCmd != nil && p.audioCmd.Process != nil {
_ = p.audioCmd.Process.Kill()
_ = p.audioCmd.Wait()
}
p.videoCmd = nil
p.audioCmd = nil
p.stop = make(chan struct{})
close(p.done)
p.done = make(chan struct{})
}
func (p *playSession) startLocked(offset float64) {
p.paused = false
p.current = offset
p.frameN = 0
logging.Debug(logging.CatFFMPEG, "playSession start path=%s offset=%.3f fps=%.3f target=%dx%d", p.path, offset, p.fps, p.targetW, p.targetH)
p.runVideo(offset)
p.runAudio(offset)
}
func (p *playSession) runVideo(offset float64) {
fmt.Printf("📹 Starting video decode pipeline...\n")
fmt.Printf(" - Resolution: %dx%d\n", p.targetW, p.targetH)
fmt.Printf(" - Frame rate: %.2f fps\n", p.fps)
fmt.Printf(" - Offset: %.2fs\n", offset)
var stderr bytes.Buffer
args := []string{
"-hide_banner", "-loglevel", "error",
"-ss", fmt.Sprintf("%.3f", offset),
"-i", p.path,
"-vf", fmt.Sprintf("scale=%d:%d:flags=bilinear", p.targetW, p.targetH),
"-f", "rawvideo",
"-pix_fmt", "rgb24",
"-r", fmt.Sprintf("%.3f", p.fps),
"-vsync", "0", // Avoid frame duplication
"-",
}
fmt.Printf("🔧 FFmpeg command: ffmpeg %s\n", strings.Join(args, " "))
cmd := exec.Command("ffmpeg", args...)
cmd.Stderr = &stderr
stdout, err := cmd.StdoutPipe()
if err != nil {
fmt.Printf("❌ ERROR: Failed to create video pipe: %v\n", err)
logging.Debug(logging.CatFFMPEG, "video pipe error: %v", err)
func (p *playSession) startProgressPoll() {
if p.prog == nil {
return
}
fmt.Printf("⚡ Starting FFmpeg process...\n")
startTime := time.Now()
if err := cmd.Start(); err != nil {
errMsg := strings.TrimSpace(stderr.String())
fmt.Printf("❌ ERROR: FFmpeg failed to start: %v\n", err)
if errMsg != "" {
fmt.Printf(" FFmpeg error: %s\n", errMsg)
}
// Check if ffmpeg is available
if _, pathErr := exec.LookPath("ffmpeg"); pathErr != nil {
fmt.Printf("❌ FATAL: ffmpeg not found in PATH: %v\n", pathErr)
}
logging.Debug(logging.CatFFMPEG, "video start failed: %v (%s)", err, errMsg)
return
}
fmt.Printf("✅ FFmpeg started (PID: %d) in %.3fs\n", cmd.Process.Pid, time.Since(startTime).Seconds())
// Pace frames to the source frame rate instead of hammering refreshes as fast as possible.
frameDur := time.Second
if p.fps > 0 {
frameDur = time.Duration(float64(time.Second) / math.Max(p.fps, 0.1))
}
nextFrameAt := time.Now()
p.videoCmd = cmd
frameSize := p.targetW * p.targetH * 3
buf := make([]byte, frameSize)
fmt.Printf("📦 Frame buffer allocated: %d bytes (%.2f MB)\n", frameSize, float64(frameSize)/(1024*1024))
ticker := time.NewTicker(250 * time.Millisecond)
go func() {
defer cmd.Process.Kill()
// Performance monitoring variables
var lastFPSReport time.Time
var fpsCounter int
loopStart := time.Now()
fmt.Printf("🔄 Frame decode loop started\n")
defer ticker.Stop()
for {
select {
case <-p.stop:
fmt.Printf("⏹️ Video decode loop stopped\n")
logging.Debug(logging.CatFFMPEG, "video loop stop")
return
default:
}
if p.paused {
time.Sleep(30 * time.Millisecond)
nextFrameAt = time.Now().Add(frameDur)
continue
}
readStart := time.Now()
_, err := io.ReadFull(stdout, buf)
readDuration := time.Since(readStart)
if err != nil {
if errors.Is(err, io.EOF) {
fmt.Printf("📺 Video playback completed (reached end)\n")
return
}
msg := strings.TrimSpace(stderr.String())
fmt.Printf("❌ ERROR: Frame read failed: %v\n", err)
if msg != "" {
fmt.Printf(" FFmpeg error: %s\n", msg)
}
logging.Debug(logging.CatFFMPEG, "video read failed: %v (%s)", err, msg)
return
}
// Track first frame timing
if p.frameN == 0 {
elapsed := time.Since(loopStart)
fmt.Printf("🎞️ FIRST FRAME decoded in %.3fs (read: %.3fs)\n", elapsed.Seconds(), readDuration.Seconds())
}
// Improved frame pacing - use a more stable timing approach
now := time.Now()
if now.Before(nextFrameAt) {
time.Sleep(nextFrameAt.Sub(now))
}
nextFrameAt = nextFrameAt.Add(frameDur)
// Allocate a fresh frame to avoid concurrent texture reuse issues.
frame := image.NewRGBA(image.Rect(0, 0, p.targetW, p.targetH))
utils.CopyRGBToRGBA(frame.Pix, buf)
renderStart := time.Now()
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
if p.img != nil {
// Ensure we render the live frame, not a stale resource preview.
p.img.Resource = nil
p.img.File = ""
p.img.Image = frame
p.img.Refresh()
}
}, false)
renderDuration := time.Since(renderStart)
// Log first few frames in detail
if p.frameN < 3 {
fmt.Printf("🖼️ Frame %d: decode=%.3fs render=%.3fs\n", p.frameN+1, readDuration.Seconds(), renderDuration.Seconds())
logging.Debug(logging.CatFFMPEG, "video frame %d drawn (%.2fs)", p.frameN+1, p.current)
}
p.frameN++
fpsCounter++
// FPS report every 2 seconds
if time.Since(lastFPSReport) >= 2*time.Second {
fps := float64(fpsCounter) / time.Since(lastFPSReport).Seconds()
fmt.Printf("📊 Performance: %.1f FPS (target: %.1f FPS) | Frames: %d\n", fps, p.fps, p.frameN)
fpsCounter = 0
lastFPSReport = time.Now()
}
if p.fps > 0 {
p.current = offset + (float64(p.frameN) / p.fps)
}
if p.prog != nil {
p.prog(p.current)
}
}
}()
}
func (p *playSession) runAudio(offset float64) {
const sampleRate = 48000
const channels = 2
const bytesPerSample = 2
var stderr bytes.Buffer
cmd := exec.Command("ffmpeg",
"-hide_banner", "-loglevel", "error",
"-ss", fmt.Sprintf("%.3f", offset),
"-i", p.path,
"-vn",
"-ac", fmt.Sprintf("%d", channels),
"-ar", fmt.Sprintf("%d", sampleRate),
"-f", "s16le",
"-",
)
cmd.Stderr = &stderr
stdout, err := cmd.StdoutPipe()
if err != nil {
logging.Debug(logging.CatFFMPEG, "audio pipe error: %v", err)
return
}
if err := cmd.Start(); err != nil {
errMsg := strings.TrimSpace(stderr.String())
fmt.Printf("❌ ERROR: Audio FFmpeg failed to start: %v\n", err)
if errMsg != "" {
fmt.Printf(" Audio FFmpeg error: %s\n", errMsg)
}
logging.Debug(logging.CatFFMPEG, "audio start failed: %v (%s)", err, errMsg)
return
}
fmt.Printf("✅ Audio FFmpeg started (PID: %d)\n", cmd.Process.Pid)
p.audioCmd = cmd
ctx, err := getAudioContext(sampleRate, channels, bytesPerSample)
if err != nil {
logging.Debug(logging.CatFFMPEG, "audio context error: %v", err)
return
}
player := ctx.NewPlayer()
if player == nil {
logging.Debug(logging.CatFFMPEG, "audio player creation failed")
return
}
localPlayer := player
go func() {
defer cmd.Process.Kill()
defer localPlayer.Close()
chunk := make([]byte, 4096)
tmp := make([]byte, 4096)
loggedFirst := false
for {
select {
case <-p.stop:
logging.Debug(logging.CatFFMPEG, "audio loop stop")
return
default:
}
if p.paused {
time.Sleep(30 * time.Millisecond)
continue
}
n, err := stdout.Read(chunk)
if n > 0 {
if !loggedFirst {
logging.Debug(logging.CatFFMPEG, "audio stream delivering bytes")
loggedFirst = true
}
gain := p.volume / 100.0
if gain < 0 {
gain = 0
}
if gain > 2 {
gain = 2
}
copy(tmp, chunk[:n])
if p.muted || gain <= 0 {
for i := 0; i < n; i++ {
tmp[i] = 0
}
} else if math.Abs(1-gain) > 0.001 {
for i := 0; i+1 < n; i += 2 {
sample := int16(binary.LittleEndian.Uint16(tmp[i:]))
amp := int(float64(sample) * gain)
if amp > math.MaxInt16 {
amp = math.MaxInt16
}
if amp < math.MinInt16 {
amp = math.MinInt16
}
binary.LittleEndian.PutUint16(tmp[i:], uint16(int16(amp)))
}
}
localPlayer.Write(tmp[:n])
}
if err != nil {
if !errors.Is(err, io.EOF) {
logging.Debug(logging.CatFFMPEG, "audio read failed: %v (%s)", err, strings.TrimSpace(stderr.String()))
}
case <-ticker.C:
pos := p.GetCurrentPosition()
p.mu.Lock()
p.current = pos
p.mu.Unlock()
p.prog(pos)
case <-p.done:
return
}
}
@ -4300,8 +4024,10 @@ func (s *appState) handleDropPlayer(items []fyne.URI) {
if len(videoPaths) > 1 {
go s.loadVideos(videoPaths)
s.requestPlayerView()
} else {
go s.loadVideo(videoPaths[0])
s.requestPlayerView()
}
}
@ -4478,6 +4204,7 @@ func (s *appState) loadVideo(path string) {
fmt.Printf("🔄 Switching to player view...\n")
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
s.switchToVideo(s.currentIndex)
s.requestPlayerView()
}, false)
}
@ -4494,8 +4221,11 @@ func (s *appState) clearVideo() {
s.convert.CoverArtPath = ""
s.convert.AspectHandling = "Auto"
s.convert.OutputAspect = "Source"
s.lastSourcePath = ""
s.playerViewSource = ""
s.playerViewRefreshNeeded = true
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
s.showPlayerView()
s.requestPlayerView()
}, false)
}
@ -4599,7 +4329,9 @@ func (s *appState) loadVideos(paths []string) {
s.loadedVideos = loaded
s.currentIndex = 0
fyne.Do(func() {
s.playerViewRefreshNeeded = true
s.switchToVideo(0)
s.requestPlayerView()
})
}()
}
@ -4642,9 +4374,8 @@ func (s *appState) switchToVideo(index int) {
s.playerPos = 0
s.playerPaused = true
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
s.showPlayerView()
}, false)
// Do not rebuild the view; player session stays active.
s.lastSourcePath = src.Path
}
// nextVideo switches to the next loaded video

175
mpv_client.go Normal file
View File

@ -0,0 +1,175 @@
package main
import (
"encoding/json"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"sync"
"time"
)
// mpvClient manages a single mpv process via IPC.
type mpvClient struct {
cmd *exec.Cmd
sockPath string
conn net.Conn
enc *json.Encoder
mu sync.Mutex
quitOnce sync.Once
}
func newMPVClient() *mpvClient {
sock := filepath.Join(os.TempDir(), fmt.Sprintf("vtplayer-mpv-%d.sock", time.Now().UnixNano()))
return &mpvClient{sockPath: sock}
}
func (m *mpvClient) EnsureRunning() error {
m.mu.Lock()
running := m.cmd != nil && m.conn != nil
m.mu.Unlock()
if running {
return nil
}
if _, err := exec.LookPath("mpv"); err != nil {
return fmt.Errorf("mpv not found in PATH: %w", err)
}
// Clean old socket if exists
_ = os.Remove(m.sockPath)
args := []string{
"--input-ipc-server=" + m.sockPath,
"--idle=yes",
"--force-window=yes",
"--keep-open=yes",
"--no-terminal",
"--pause",
}
cmd := exec.Command("mpv", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to start mpv: %w", err)
}
// Wait for socket to appear and connect
deadline := time.Now().Add(3 * time.Second)
var conn net.Conn
for time.Now().Before(deadline) {
c, err := net.Dial("unix", m.sockPath)
if err == nil {
conn = c
break
}
time.Sleep(50 * time.Millisecond)
}
if conn == nil {
_ = cmd.Process.Kill()
return fmt.Errorf("mpv IPC socket not available")
}
m.mu.Lock()
m.cmd = cmd
m.conn = conn
m.enc = json.NewEncoder(conn)
m.mu.Unlock()
return nil
}
func (m *mpvClient) sendCommand(cmd []interface{}) error {
m.mu.Lock()
enc := m.enc
conn := m.conn
m.mu.Unlock()
if enc == nil || conn == nil {
return fmt.Errorf("mpv not connected")
}
payload := map[string]interface{}{"command": cmd}
return enc.Encode(payload)
}
func (m *mpvClient) LoadFile(path string) error {
if err := m.EnsureRunning(); err != nil {
return err
}
return m.sendCommand([]interface{}{"loadfile", path, "replace"})
}
func (m *mpvClient) Play() error {
if err := m.EnsureRunning(); err != nil {
return err
}
return m.sendCommand([]interface{}{"set_property", "pause", false})
}
func (m *mpvClient) Pause() error {
if err := m.EnsureRunning(); err != nil {
return err
}
return m.sendCommand([]interface{}{"set_property", "pause", true})
}
func (m *mpvClient) Seek(seconds float64) error {
if err := m.EnsureRunning(); err != nil {
return err
}
return m.sendCommand([]interface{}{"seek", seconds, "absolute"})
}
func (m *mpvClient) SetVolume(vol float64) error {
if err := m.EnsureRunning(); err != nil {
return err
}
return m.sendCommand([]interface{}{"set_property", "volume", vol})
}
func (m *mpvClient) Position() float64 {
// Query synchronously by opening a short connection; mpv IPC replies on same socket.
// For simplicity here, we return 0 if it fails.
m.mu.Lock()
conn := m.conn
m.mu.Unlock()
if conn == nil {
return 0
}
// Make a temporary connection to avoid racing on the encoder
c, err := net.Dial("unix", m.sockPath)
if err != nil {
return 0
}
defer c.Close()
dec := json.NewDecoder(c)
enc := json.NewEncoder(c)
_ = enc.Encode(map[string]interface{}{"command": []interface{}{"get_property", "time-pos"}})
var resp map[string]interface{}
if err := dec.Decode(&resp); err != nil {
return 0
}
if v, ok := resp["data"].(float64); ok {
return v
}
return 0
}
func (m *mpvClient) Quit() error {
var err error
m.quitOnce.Do(func() {
_ = m.sendCommand([]interface{}{"quit"})
m.mu.Lock()
if m.conn != nil {
_ = m.conn.Close()
m.conn = nil
}
if m.cmd != nil && m.cmd.Process != nil {
_ = m.cmd.Process.Kill()
}
m.cmd = nil
m.enc = nil
m.mu.Unlock()
_ = os.Remove(m.sockPath)
})
return err
}

177
player/mpvembed/mpv.go Normal file
View File

@ -0,0 +1,177 @@
package mpvembed
/*
#cgo pkg-config: mpv
#include <mpv/client.h>
#include <stdlib.h>
static inline const char* mpv_errstr(int err) { return mpv_error_string(err); }
*/
import "C"
import (
"errors"
"fmt"
"unsafe"
)
// Client wraps a libmpv handle.
type Client struct {
handle *C.mpv_handle
}
// New creates a new mpv client.
func New() (*Client, error) {
h := C.mpv_create()
if h == nil {
return nil, errors.New("mpv_create returned nil")
}
return &Client{handle: h}, nil
}
// Initialize must be called before issuing commands.
func (c *Client) Initialize() error {
if c.handle == nil {
return errors.New("mpv handle is nil")
}
if res := C.mpv_initialize(c.handle); res < 0 {
return fmt.Errorf("mpv_initialize failed: %s", C.GoString(C.mpv_errstr(res)))
}
return nil
}
// Destroy terminates and frees the client.
func (c *Client) Destroy() {
if c.handle != nil {
C.mpv_terminate_destroy(c.handle)
c.handle = nil
}
}
// SetOptionString sets an option before initialize.
func (c *Client) SetOptionString(name, value string) error {
if c.handle == nil {
return errors.New("mpv handle is nil")
}
cname := C.CString(name)
defer C.free(unsafe.Pointer(cname))
cval := C.CString(value)
defer C.free(unsafe.Pointer(cval))
if res := C.mpv_set_option_string(c.handle, cname, cval); res < 0 {
return fmt.Errorf("mpv_set_option_string %s failed: %s", name, C.GoString(C.mpv_errstr(res)))
}
return nil
}
// SetOptionInt sets an integer option.
func (c *Client) SetOptionInt(name string, val int64) error {
if c.handle == nil {
return errors.New("mpv handle is nil")
}
cname := C.CString(name)
defer C.free(unsafe.Pointer(cname))
cval := C.longlong(val)
if res := C.mpv_set_option(c.handle, cname, C.mpv_format(C.MPV_FORMAT_INT64), unsafe.Pointer(&cval)); res < 0 {
return fmt.Errorf("mpv_set_option %s failed: %s", name, C.GoString(C.mpv_errstr(res)))
}
return nil
}
// SetWID binds a native window ID to mpv (for embedding).
func (c *Client) SetWID(wid uint64) error {
return c.SetOptionInt("wid", int64(wid))
}
// Command issues an mpv command with arguments.
func (c *Client) Command(args ...string) error {
if c.handle == nil {
return errors.New("mpv handle is nil")
}
// Build a NULL-terminated array of *char
cargs := make([]*C.char, len(args)+1)
for i, a := range args {
cstr := C.CString(a)
defer C.free(unsafe.Pointer(cstr))
cargs[i] = cstr
}
cargs[len(args)] = nil
if res := C.mpv_command(c.handle, &cargs[0]); res < 0 {
return fmt.Errorf("mpv_command %v failed: %s", args, C.GoString(C.mpv_errstr(res)))
}
return nil
}
// SetPropertyBool sets a boolean property.
func (c *Client) SetPropertyBool(name string, v bool) error {
if c.handle == nil {
return errors.New("mpv handle is nil")
}
cname := C.CString(name)
defer C.free(unsafe.Pointer(cname))
cval := C.int(0)
if v {
cval = 1
}
if res := C.mpv_set_property(c.handle, cname, C.mpv_format(C.MPV_FORMAT_FLAG), unsafe.Pointer(&cval)); res < 0 {
return fmt.Errorf("mpv_set_property %s failed: %s", name, C.GoString(C.mpv_errstr(res)))
}
return nil
}
// SetPropertyDouble sets a double property.
func (c *Client) SetPropertyDouble(name string, v float64) error {
if c.handle == nil {
return errors.New("mpv handle is nil")
}
cname := C.CString(name)
defer C.free(unsafe.Pointer(cname))
cval := C.double(v)
if res := C.mpv_set_property(c.handle, cname, C.mpv_format(C.MPV_FORMAT_DOUBLE), unsafe.Pointer(&cval)); res < 0 {
return fmt.Errorf("mpv_set_property %s failed: %s", name, C.GoString(C.mpv_errstr(res)))
}
return nil
}
// GetPropertyDouble gets a double property.
func (c *Client) GetPropertyDouble(name string) (float64, error) {
if c.handle == nil {
return 0, errors.New("mpv handle is nil")
}
cname := C.CString(name)
defer C.free(unsafe.Pointer(cname))
var out C.double
if res := C.mpv_get_property(c.handle, cname, C.mpv_format(C.MPV_FORMAT_DOUBLE), unsafe.Pointer(&out)); res < 0 {
return 0, fmt.Errorf("mpv_get_property %s failed: %s", name, C.GoString(C.mpv_errstr(res)))
}
return float64(out), nil
}
// GetPropertyInt64 gets an int64 property.
func (c *Client) GetPropertyInt64(name string) (int64, error) {
if c.handle == nil {
return 0, errors.New("mpv handle is nil")
}
cname := C.CString(name)
defer C.free(unsafe.Pointer(cname))
var out C.longlong
if res := C.mpv_get_property(c.handle, cname, C.mpv_format(C.MPV_FORMAT_INT64), unsafe.Pointer(&out)); res < 0 {
return 0, fmt.Errorf("mpv_get_property %s failed: %s", name, C.GoString(C.mpv_errstr(res)))
}
return int64(out), nil
}
// GetPropertyString gets a string property.
func (c *Client) GetPropertyString(name string) (string, error) {
if c.handle == nil {
return "", errors.New("mpv handle is nil")
}
cname := C.CString(name)
defer C.free(unsafe.Pointer(cname))
var out *C.char
if res := C.mpv_get_property(c.handle, cname, C.mpv_format(C.MPV_FORMAT_STRING), unsafe.Pointer(&out)); res < 0 {
return "", fmt.Errorf("mpv_get_property %s failed: %s", name, C.GoString(C.mpv_errstr(res)))
}
if out == nil {
return "", nil
}
return C.GoString(out), nil
}

View File

@ -42,6 +42,10 @@ fi
if ! pkg-config --exists gl 2>/dev/null; then
MISSING_DEPS+=("mesa-libGL-devel")
fi
# Runtime dependency: mpv
if ! command -v mpv >/dev/null 2>&1; then
MISSING_DEPS+=("mpv")
fi
if [ ${#MISSING_DEPS[@]} -gt 0 ]; then
echo "⚠️ Missing system dependencies: ${MISSING_DEPS[*]}"

View File

@ -35,6 +35,7 @@ install_fedora() {
libXxf86vm-devel \
mesa-libGL-devel \
alsa-lib-devel \
mpv \
ffmpeg-free \
golang
echo "✓ Fedora dependencies installed"
@ -55,6 +56,7 @@ install_ubuntu() {
libxi-dev \
libxxf86vm-dev \
libasound2-dev \
mpv \
ffmpeg \
golang-go
echo "✓ Ubuntu/Debian dependencies installed"
@ -74,6 +76,7 @@ install_arch() {
libxi \
libxxf86vm \
alsa-lib \
mpv \
ffmpeg \
go
echo "✓ Arch Linux dependencies installed"
@ -93,6 +96,7 @@ install_opensuse() {
libXi-devel \
libXxf86vm-devel \
alsa-devel \
mpv \
ffmpeg \
go
echo "✓ openSUSE dependencies installed"
@ -158,6 +162,13 @@ else
echo "⚠️ ffmpeg not found in PATH"
fi
# Check mpv
if command -v mpv &> /dev/null; then
echo "✓ mpv: $(mpv --version | head -1)"
else
echo "⚠️ mpv not found in PATH"
fi
# Check pkg-config
if command -v pkg-config &> /dev/null; then
echo "✓ pkg-config: $(pkg-config --version)"

View File

@ -3,7 +3,8 @@
param(
[switch]$UseScoop = $false,
[switch]$SkipFFmpeg = $false
[switch]$SkipFFmpeg = $false,
[switch]$SkipMPV = $false
)
Write-Host "════════════════════════════════════════════════════════════════" -ForegroundColor Cyan
@ -85,8 +86,20 @@ function Install-ViaChocolatey {
Write-Host "Installing ffmpeg..." -ForegroundColor Yellow
choco install -y ffmpeg
} else {
Write-Host "✓ ffmpeg already installed" -ForegroundColor Green
Write-Host "✓ ffmpeg already installed" -ForegroundColor Green
}
}
# Install mpv
if (-not $SkipMPV) {
if (-not (Test-Command mpv)) {
Write-Host "Installing mpv..." -ForegroundColor Yellow
choco install -y mpv
} else {
Write-Host "✓ mpv already installed" -ForegroundColor Green
}
} else {
Write-Host " mpv skipped (use -SkipMPV:$false to install)" -ForegroundColor Cyan
}
Write-Host "✓ Chocolatey installation complete" -ForegroundColor Green
@ -143,9 +156,21 @@ function Install-ViaScoop {
if (-not (Test-Command ffmpeg)) {
Write-Host "Installing ffmpeg..." -ForegroundColor Yellow
scoop install ffmpeg
} else {
Write-Host "✓ ffmpeg already installed" -ForegroundColor Green
}
}
# Install mpv
if (-not $SkipMPV) {
if (-not (Test-Command mpv)) {
Write-Host "Installing mpv..." -ForegroundColor Yellow
scoop install mpv
} else {
Write-Host "✓ ffmpeg already installed" -ForegroundColor Green
Write-Host "mpv already installed" -ForegroundColor Green
}
} else {
Write-Host " mpv skipped (use -SkipMPV:$false to install)" -ForegroundColor Cyan
}
Write-Host "✓ Scoop installation complete" -ForegroundColor Green
@ -229,6 +254,17 @@ if (Test-Command ffmpeg) {
}
}
if (Test-Command mpv) {
$mpvVersion = mpv --version | Select-Object -First 1
Write-Host "✓ mpv: $mpvVersion" -ForegroundColor Green
} else {
if ($SkipMPV) {
Write-Host " mpv skipped (use -SkipMPV:$false to install)" -ForegroundColor Cyan
} else {
Write-Host "⚠️ mpv not found in PATH (restart terminal or install with -SkipMPV:$false)" -ForegroundColor Yellow
}
}
if (Test-Command git) {
$gitVersion = git --version
Write-Host "✓ Git: $gitVersion" -ForegroundColor Green

305
third_party/gotk3/gdk/gdk_since_3_22.go vendored Normal file
View File

@ -0,0 +1,305 @@
// +build !gtk_3_6,!gtk_3_8,!gtk_3_10,!gtk_3_12,!gtk_3_14,!gtk_3_16,!gtk_3_18,!gtk_3_20
// Supports building with gtk 3.22+
// Copyright (c) 2013-2014 Conformal Systems <info@conformal.com>
//
// This file originated from: http://opensource.conformal.com/
//
// Permission to use, copy, modify, and distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package gdk
// #include <gdk/gdk.h>
// #include "gdk_since_3_22.go.h"
import "C"
import (
"unsafe"
"github.com/gotk3/gotk3/glib"
"github.com/gotk3/gotk3/internal/callback"
)
func init() {
tm := []glib.TypeMarshaler{
{glib.Type(C.gdk_subpixel_layout_get_type()), marshalSubpixelLayout},
}
glib.RegisterGValueMarshalers(tm)
}
/*
* Constants
*/
// SeatCapabilities is a representation of GDK's GdkSeatCapabilities
type SeatCapabilities int
const (
SEAT_CAPABILITY_NONE SeatCapabilities = C.GDK_SEAT_CAPABILITY_NONE
SEAT_CAPABILITY_POINTER SeatCapabilities = C.GDK_SEAT_CAPABILITY_POINTER
SEAT_CAPABILITY_TOUCH SeatCapabilities = C.GDK_SEAT_CAPABILITY_TOUCH
SEAT_CAPABILITY_TABLET_STYLUS SeatCapabilities = C.GDK_SEAT_CAPABILITY_TABLET_STYLUS
SEAT_CAPABILITY_KEYBOARD SeatCapabilities = C.GDK_SEAT_CAPABILITY_KEYBOARD
SEAT_CAPABILITY_ALL_POINTING SeatCapabilities = C.GDK_SEAT_CAPABILITY_ALL_POINTING
SEAT_CAPABILITY_ALL SeatCapabilities = C.GDK_SEAT_CAPABILITY_ALL
)
func marshalSeatCapabilitiesLayout(p uintptr) (interface{}, error) {
c := C.g_value_get_enum((*C.GValue)(unsafe.Pointer(p)))
return SeatCapabilities(c), nil
}
// SubpixelLayout is a representation of GDK's GdkSubpixelLayout.
type SubpixelLayout int
const (
SUBPIXEL_LAYOUT_UNKNOWN SubpixelLayout = C.GDK_SUBPIXEL_LAYOUT_UNKNOWN
SUBPIXEL_LAYOUT_NONE SubpixelLayout = C.GDK_SUBPIXEL_LAYOUT_NONE
SUBPIXEL_LAYOUT_HORIZONTAL_RGB SubpixelLayout = C.GDK_SUBPIXEL_LAYOUT_HORIZONTAL_RGB
SUBPIXEL_LAYOUT_HORIZONTAL_BGR SubpixelLayout = C.GDK_SUBPIXEL_LAYOUT_HORIZONTAL_BGR
SUBPIXEL_LAYOUT_VERTICAL_RGB SubpixelLayout = C.GDK_SUBPIXEL_LAYOUT_VERTICAL_RGB
SUBPIXEL_LAYOUT_VERTICAL_BGR SubpixelLayout = C.GDK_SUBPIXEL_LAYOUT_VERTICAL_BGR
)
func marshalSubpixelLayout(p uintptr) (interface{}, error) {
c := C.g_value_get_enum((*C.GValue)(unsafe.Pointer(p)))
return SubpixelLayout(c), nil
}
/*
* GdkDisplay
*/
// GetNMonitors is a wrapper around gdk_display_get_n_monitors().
func (v *Display) GetNMonitors() int {
c := C.gdk_display_get_n_monitors(v.native())
return int(c)
}
// GetPrimaryMonitor is a wrapper around gdk_display_get_primary_monitor().
func (v *Display) GetPrimaryMonitor() (*Monitor, error) {
c := C.gdk_display_get_primary_monitor(v.native())
if c == nil {
return nil, nilPtrErr
}
return &Monitor{glib.Take(unsafe.Pointer(c))}, nil
}
// GetMonitor is a wrapper around gdk_display_get_monitor().
func (v *Display) GetMonitor(num int) (*Monitor, error) {
c := C.gdk_display_get_monitor(v.native(), C.int(num))
if c == nil {
return nil, nilPtrErr
}
return &Monitor{glib.Take(unsafe.Pointer(c))}, nil
}
// GetMonitorAtWindow is a wrapper around gdk_display_get_monitor_at_window().
func (v *Display) GetMonitorAtWindow(w *Window) (*Monitor, error) {
c := C.gdk_display_get_monitor_at_window(v.native(), w.native())
if c == nil {
return nil, nilPtrErr
}
return &Monitor{glib.Take(unsafe.Pointer(c))}, nil
}
// GetMonitorAtPoint is a wrapper around gdk_display_get_monitor_at_point().
func (v *Display) GetMonitorAtPoint(x int, y int) (*Monitor, error) {
c := C.gdk_display_get_monitor_at_point(v.native(), C.int(x), C.int(y))
if c == nil {
return nil, nilPtrErr
}
return &Monitor{glib.Take(unsafe.Pointer(c))}, nil
}
/*
* GdkSeat
*/
// GdkSeatGrabPrepareFunc
type GrabPrepareFunc func(seat *Seat, window *Window, user_data C.gpointer)
// GetDisplay is a wrapper around gdk_seat_get_display().
func (v *Seat) GetDisplay() (*Display, error) {
return toDisplay(C.gdk_seat_get_display(v.native()))
}
// Grab is a wrapper around gdk_seat_grab().
func (v *Seat) Grab(window *Window, capabilities SeatCapabilities, owner_events bool, cursor *Cursor, event *Event, prepare_func GrabPrepareFunc, prepare_func_data C.gpointer) GrabStatus {
return GrabStatus(C.gdk_seat_grab(v.native(), window.native(), C.GdkSeatCapabilities(capabilities), gbool(owner_events), cursor.native(), event.native(), (*[0]byte)(C.gpointer(callback.Assign(prepare_func))), prepare_func_data))
}
// UnGrab is a wrapper around gdk_seat_ungrab().
func (v *Seat) UnGrab() {
C.gdk_seat_ungrab(v.native())
}
// GetCapabilities is a wrapper around gdk_seat_get_capabilities().
func (v *Seat) GetCapabilities() SeatCapabilities {
return SeatCapabilities(C.gdk_seat_get_capabilities(v.native()))
}
// GetKeyboard is a wrapper around gdk_seat_get_keyboard().
func (v *Seat) GetKeyboard() (*Device, error) {
return toDevice(C.gdk_seat_get_keyboard(v.native()))
}
// GetSlaves is a wrapper around gdk_seat_get_slaves().
func (v *Seat) GetSlaves(capabilities SeatCapabilities) *[]Device {
clist := C.gdk_seat_get_slaves(v.native(), C.GdkSeatCapabilities(capabilities))
if clist == nil {
return nil
}
dlist := glib.WrapSList(uintptr(unsafe.Pointer(clist)))
defer dlist.Free()
var slaves = make([]Device, 0, dlist.Length())
for ; dlist.DataRaw() != nil; dlist = dlist.Next() {
d := (*C.GdkDevice)(dlist.DataRaw())
obj := &glib.Object{glib.ToGObject(unsafe.Pointer(d))}
slaves = append(slaves, Device{obj})
}
return &slaves
}
/*
* GdkMonitor
*/
// Monitor is a representation of GDK's GdkMonitor.
type Monitor struct {
*glib.Object
}
// native returns a pointer to the underlying GdkMonitor.
func (v *Monitor) native() *C.GdkMonitor {
if v == nil || v.GObject == nil {
return nil
}
p := unsafe.Pointer(v.GObject)
return C.toGdkMonitor(p)
}
// Native returns a pointer to the underlying GdkMonitor.
func (v *Monitor) Native() uintptr {
return uintptr(unsafe.Pointer(v.native()))
}
func marshalMonitor(p uintptr) (interface{}, error) {
c := C.g_value_get_object((*C.GValue)(unsafe.Pointer(p)))
obj := &glib.Object{glib.ToGObject(unsafe.Pointer(c))}
return &Monitor{obj}, nil
}
func toMonitor(s *C.GdkMonitor) (*Monitor, error) {
if s == nil {
return nil, nilPtrErr
}
obj := &glib.Object{glib.ToGObject(unsafe.Pointer(s))}
return &Monitor{obj}, nil
}
// GetDisplay is a wrapper around gdk_monitor_get_display().
func (v *Monitor) GetDisplay() (*Display, error) {
return toDisplay(C.gdk_monitor_get_display(v.native()))
}
// GetGeometry is a wrapper around gdk_monitor_get_geometry().
func (v *Monitor) GetGeometry() *Rectangle {
var rect C.GdkRectangle
C.gdk_monitor_get_geometry(v.native(), &rect)
return wrapRectangle(&rect)
}
// GetWorkarea is a wrapper around gdk_monitor_get_workarea().
func (v *Monitor) GetWorkarea() *Rectangle {
var rect C.GdkRectangle
C.gdk_monitor_get_workarea(v.native(), &rect)
return wrapRectangle(&rect)
}
// GetWidthMM is a wrapper around gdk_monitor_get_width_mm().
func (v *Monitor) GetWidthMM() int {
return int(C.gdk_monitor_get_width_mm(v.native()))
}
// GetHeightMM is a wrapper around gdk_monitor_get_height_mm().
func (v *Monitor) GetHeightMM() int {
return int(C.gdk_monitor_get_height_mm(v.native()))
}
// GetManufacturer is a wrapper around gdk_monitor_get_manufacturer().
func (v *Monitor) GetManufacturer() string {
// transfer none: don't free data after the code is done.
return C.GoString(C.gdk_monitor_get_manufacturer(v.native()))
}
// GetModel is a wrapper around gdk_monitor_get_model().
func (v *Monitor) GetModel() string {
// transfer none: don't free data after the code is done.
return C.GoString(C.gdk_monitor_get_model(v.native()))
}
// GetScaleFactor is a wrapper around gdk_monitor_get_scale_factor().
func (v *Monitor) GetScaleFactor() int {
return int(C.gdk_monitor_get_scale_factor(v.native()))
}
// GetRefreshRate is a wrapper around gdk_monitor_get_refresh_rate().
func (v *Monitor) GetRefreshRate() int {
return int(C.gdk_monitor_get_refresh_rate(v.native()))
}
// GetSubpixelLayout is a wrapper around gdk_monitor_get_subpixel_layout().
func (v *Monitor) GetSubpixelLayout() SubpixelLayout {
return SubpixelLayout(C.gdk_monitor_get_subpixel_layout(v.native()))
}
// IsPrimary is a wrapper around gdk_monitor_is_primary().
func (v *Monitor) IsPrimary() bool {
return gobool(C.gdk_monitor_is_primary(v.native()))
}
/*
* GdkDevice
*/
// TODO:
// gdk_device_get_axes().
// gdk_device_tool_get_serial().
// gdk_device_tool_get_tool_type().
/*
* GdkGLContext
*/
// GetUseES is a wrapper around gdk_gl_context_get_use_es().
func (v *GLContext) GetUseES() bool {
return gobool(C.gdk_gl_context_get_use_es(v.native()))
}
// SetUseES is a wrapper around gdk_gl_context_set_use_es().
func (v *GLContext) SetUseES(es int) {
C.gdk_gl_context_set_use_es(v.native(), (C.int)(es))
}