diff --git a/main.go b/main.go index ec98fb5..b5c83d8 100644 --- a/main.go +++ b/main.go @@ -3728,23 +3728,10 @@ type playSession struct { muted bool paused bool current float64 - mu sync.Mutex - cmd *exec.Cmd - stdin io.WriteCloser + mpv *mpvClient + prog func(float64) done chan struct{} -} - -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 + mu sync.Mutex } 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("📁 Video: %s\n", filepath.Base(path)) 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") - // 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 } - - if _, err := exec.LookPath("ffplay"); err != nil { - fmt.Printf("❌ ERROR: ffplay not found in PATH: %v\n", err) + if _, err := exec.LookPath("mpv"); err != nil { + fmt.Printf("❌ ERROR: mpv not found in PATH: %v\n", err) return nil } - fmt.Printf("✅ Play session created successfully\n") - return &playSession{ + 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.cmd == nil { - fmt.Printf("⚡ Starting ffplay subprocess...\n") - p.startFFplayLocked(p.current) - 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) + if p.mpv != nil { + _ = p.mpv.Play() + p.paused = false } } func (p *playSession) Pause() { p.mu.Lock() defer p.mu.Unlock() - if p.stdin != nil { - _, _ = p.stdin.Write([]byte("p")) - p.paused = !p.paused - fmt.Printf("⏸️ Pause toggle -> %v\n", p.paused) + if p.mpv != nil { + _ = p.mpv.Pause() + p.paused = true } } @@ -3810,32 +3800,16 @@ func (p *playSession) Seek(offset float64) { offset = 0 } p.current = offset - p.stopLocked() - p.startFFplayLocked(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 - } - // Runtime volume changes are not pushed to ffplay; a restart or pause toggle will pick it up. -} +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) { - // Approximate by seeking one frame forward/backward step := 1.0 / math.Max(p.fps, 24) p.Seek(p.current + float64(direction)*step) } @@ -3844,6 +3818,9 @@ func (p *playSession) StepFrame(direction int) { func (p *playSession) GetCurrentPosition() float64 { p.mu.Lock() defer p.mu.Unlock() + if p.mpv != nil { + return p.mpv.Position() + } return p.current } @@ -3854,49 +3831,33 @@ func (p *playSession) Stop() { } func (p *playSession) stopLocked() { - if p.cmd != nil && p.cmd.Process != nil { - _ = p.cmd.Process.Kill() - _, _ = p.cmd.Process.Wait() + if p.mpv != nil { + _ = p.mpv.Quit() + p.mpv = nil } - p.cmd = nil - p.stdin = nil + close(p.done) p.done = make(chan struct{}) } -func (p *playSession) startFFplayLocked(offset float64) { - var stderr bytes.Buffer - 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) +func (p *playSession) startProgressPoll() { + if p.prog == nil { return } - cmd.Stdout = os.Stdout - 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 + ticker := time.NewTicker(250 * time.Millisecond) go func() { - _ = cmd.Wait() - close(p.done) + defer ticker.Stop() + for { + select { + case <-ticker.C: + pos := p.GetCurrentPosition() + p.mu.Lock() + p.current = pos + p.mu.Unlock() + p.prog(pos) + case <-p.done: + return + } + } }() } diff --git a/mpv_client.go b/mpv_client.go new file mode 100644 index 0000000..9eeae85 --- /dev/null +++ b/mpv_client.go @@ -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 +}