Switch playback to mpv IPC and poll progress

This commit is contained in:
Stu 2025-12-10 05:47:38 -05:00
parent 0ba248af4e
commit 1dfab7000b
2 changed files with 229 additions and 93 deletions

147
main.go
View File

@ -3728,23 +3728,10 @@ type playSession struct {
muted bool muted bool
paused bool paused bool
current float64 current float64
mu sync.Mutex mpv *mpvClient
cmd *exec.Cmd prog func(float64)
stdin io.WriteCloser
done chan struct{} done chan struct{}
} mu sync.Mutex
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
} }
func newPlaySession(path string, w, h int, fps float64, targetW, targetH int, prog func(float64), img *canvas.Image) *playSession { func newPlaySession(path string, w, h int, fps float64, targetW, targetH int, prog func(float64), img *canvas.Image) *playSession {
@ -3753,53 +3740,56 @@ func newPlaySession(path string, w, h int, fps float64, targetW, targetH int, pr
fmt.Printf("═══════════════════════════════════════════════════════\n") fmt.Printf("═══════════════════════════════════════════════════════\n")
fmt.Printf("📁 Video: %s\n", filepath.Base(path)) fmt.Printf("📁 Video: %s\n", filepath.Base(path))
fmt.Printf("📐 Source: %dx%d @ %.2f fps\n", w, h, fps) fmt.Printf("📐 Source: %dx%d @ %.2f fps\n", w, h, fps)
fmt.Printf("🎯 Playback via ffplay subprocess\n") fmt.Printf("🎯 Playback via mpv subprocess\n")
fmt.Printf("═══════════════════════════════════════════════════════\n\n") fmt.Printf("═══════════════════════════════════════════════════════\n\n")
// Check if video file exists
if _, err := os.Stat(path); os.IsNotExist(err) { if _, err := os.Stat(path); os.IsNotExist(err) {
fmt.Printf("❌ ERROR: Video file does not exist: %s\n", path) fmt.Printf("❌ ERROR: Video file does not exist: %s\n", path)
return nil return nil
} }
if _, err := exec.LookPath("mpv"); err != nil {
if _, err := exec.LookPath("ffplay"); err != nil { fmt.Printf("❌ ERROR: mpv not found in PATH: %v\n", err)
fmt.Printf("❌ ERROR: ffplay not found in PATH: %v\n", err)
return nil return nil
} }
fmt.Printf("✅ Play session created successfully\n") ps := &playSession{
return &playSession{
path: path, path: path,
fps: fps, fps: fps,
volume: 100, volume: 100,
done: make(chan struct{}), 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() { func (p *playSession) Play() {
fmt.Printf("▶️ PLAY called (current position: %.2fs)\n", p.current) fmt.Printf("▶️ PLAY called (current position: %.2fs)\n", p.current)
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock() defer p.mu.Unlock()
if p.cmd == nil { if p.mpv != nil {
fmt.Printf("⚡ Starting ffplay subprocess...\n") _ = p.mpv.Play()
p.startFFplayLocked(p.current) p.paused = false
return
}
// Toggle pause/resume by sending "p" to stdin
if p.stdin != nil {
_, _ = p.stdin.Write([]byte("p"))
p.paused = !p.paused
fmt.Printf("⏯️ Toggled pause -> %v\n", p.paused)
} }
} }
func (p *playSession) Pause() { func (p *playSession) Pause() {
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock() defer p.mu.Unlock()
if p.stdin != nil { if p.mpv != nil {
_, _ = p.stdin.Write([]byte("p")) _ = p.mpv.Pause()
p.paused = !p.paused p.paused = true
fmt.Printf("⏸️ Pause toggle -> %v\n", p.paused)
} }
} }
@ -3810,32 +3800,16 @@ func (p *playSession) Seek(offset float64) {
offset = 0 offset = 0
} }
p.current = offset p.current = offset
p.stopLocked() if p.mpv != nil {
p.startFFplayLocked(p.current) _ = p.mpv.Seek(p.current)
}
} }
func (p *playSession) SetVolume(v float64) { 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
}
// Runtime volume changes are not pushed to ffplay; a restart or pause toggle will pick it up.
}
// StepFrame steps forward or backward by one frame // StepFrame steps forward or backward by one frame
// direction: 1 for forward, -1 for backward // direction: 1 for forward, -1 for backward
func (p *playSession) StepFrame(direction int) { func (p *playSession) StepFrame(direction int) {
// Approximate by seeking one frame forward/backward
step := 1.0 / math.Max(p.fps, 24) step := 1.0 / math.Max(p.fps, 24)
p.Seek(p.current + float64(direction)*step) p.Seek(p.current + float64(direction)*step)
} }
@ -3844,6 +3818,9 @@ func (p *playSession) StepFrame(direction int) {
func (p *playSession) GetCurrentPosition() float64 { func (p *playSession) GetCurrentPosition() float64 {
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock() defer p.mu.Unlock()
if p.mpv != nil {
return p.mpv.Position()
}
return p.current return p.current
} }
@ -3854,49 +3831,33 @@ func (p *playSession) Stop() {
} }
func (p *playSession) stopLocked() { func (p *playSession) stopLocked() {
if p.cmd != nil && p.cmd.Process != nil { if p.mpv != nil {
_ = p.cmd.Process.Kill() _ = p.mpv.Quit()
_, _ = p.cmd.Process.Wait() p.mpv = nil
} }
p.cmd = nil close(p.done)
p.stdin = nil
p.done = make(chan struct{}) p.done = make(chan struct{})
} }
func (p *playSession) startFFplayLocked(offset float64) { func (p *playSession) startProgressPoll() {
var stderr bytes.Buffer if p.prog == nil {
args := []string{
"-hide_banner",
"-loglevel", "warning",
"-autoexit",
"-ss", fmt.Sprintf("%.3f", offset),
"-i", p.path,
"-window_title", "VT Player",
}
if p.volume > 0 {
args = append([]string{"-volume", fmt.Sprintf("%d", int(p.volume))}, args...)
}
cmd := exec.Command("ffplay", args...)
cmd.Stderr = &stderr
stdin, err := cmd.StdinPipe()
if err != nil {
fmt.Printf("❌ ERROR: failed to create ffplay stdin: %v\n", err)
return return
} }
cmd.Stdout = os.Stdout ticker := time.NewTicker(250 * time.Millisecond)
if err := cmd.Start(); err != nil {
fmt.Printf("❌ ERROR: ffplay failed to start: %v\n", err)
if msg := strings.TrimSpace(stderr.String()); msg != "" {
fmt.Printf(" ffplay stderr: %s\n", msg)
}
return
}
p.cmd = cmd
p.stdin = stdin
p.paused = false
go func() { go func() {
_ = cmd.Wait() defer ticker.Stop()
close(p.done) for {
select {
case <-ticker.C:
pos := p.GetCurrentPosition()
p.mu.Lock()
p.current = pos
p.mu.Unlock()
p.prog(pos)
case <-p.done:
return
}
}
}() }()
} }

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
}