diff --git a/main.go b/main.go index 5adc71c..29f43b7 100644 --- a/main.go +++ b/main.go @@ -3704,24 +3704,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 + path string + fps float64 + volume float64 + muted bool + paused bool + current float64 + mu sync.Mutex + cmd *exec.Cmd + stdin io.WriteCloser + done chan struct{} } var audioCtxGlobal struct { @@ -3743,42 +3735,26 @@ 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 ffplay 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 } + if _, err := exec.LookPath("ffplay"); err != nil { + fmt.Printf("❌ ERROR: ffplay not found in PATH: %v\n", err) + 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, + path: path, + fps: fps, + volume: 100, + done: make(chan struct{}), } } @@ -3786,19 +3762,27 @@ 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) + if p.cmd == nil { + fmt.Printf("⚡ Starting ffplay subprocess...\n") + p.startFFplayLocked(p.current) return } - fmt.Printf("▶️ Resuming playback\n") - p.paused = false + // 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() { p.mu.Lock() defer p.mu.Unlock() - p.paused = true + if p.stdin != nil { + _, _ = p.stdin.Write([]byte("p")) + p.paused = !p.paused + fmt.Printf("⏸️ Pause toggle -> %v\n", p.paused) + } } func (p *playSession) Seek(offset float64) { @@ -3807,22 +3791,9 @@ 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) - } + p.startFFplayLocked(p.current) } func (p *playSession) SetVolume(v float64) { @@ -3840,41 +3811,15 @@ func (p *playSession) SetVolume(v float64) { } 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 // 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) - } + // Approximate by seeking one frame forward/backward + step := 1.0 / math.Max(p.fps, 24) + p.Seek(p.current + float64(direction)*step) } // GetCurrentPosition returns the current playback position @@ -3891,288 +3836,49 @@ func (p *playSession) Stop() { } func (p *playSession) stopLocked() { - select { - case <-p.stop: - default: - close(p.stop) + if p.cmd != nil && p.cmd.Process != nil { + _ = p.cmd.Process.Kill() + _, _ = p.cmd.Process.Wait() } - 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{}) + p.cmd = nil + p.stdin = nil 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) - +func (p *playSession) startFFplayLocked(offset float64) { var stderr bytes.Buffer args := []string{ - "-hide_banner", "-loglevel", "error", + "-hide_banner", + "-loglevel", "warning", + "-autoexit", "-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 - "-", + "-window_title", "VT Player", } - fmt.Printf("🔧 FFmpeg command: ffmpeg %s\n", strings.Join(args, " ")) - - cmd := exec.Command("ffmpeg", args...) + if p.volume > 0 { + args = append([]string{"-volume", fmt.Sprintf("%d", int(p.volume))}, args...) + } + cmd := exec.Command("ffplay", args...) cmd.Stderr = &stderr - stdout, err := cmd.StdoutPipe() + stdin, err := cmd.StdinPipe() if err != nil { - fmt.Printf("❌ ERROR: Failed to create video pipe: %v\n", err) - logging.Debug(logging.CatFFMPEG, "video pipe error: %v", err) + fmt.Printf("❌ ERROR: failed to create ffplay stdin: %v\n", err) return } - - fmt.Printf("⚡ Starting FFmpeg process...\n") - startTime := time.Now() + cmd.Stdout = os.Stdout 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) + fmt.Printf("❌ ERROR: ffplay failed to start: %v\n", err) + if msg := strings.TrimSpace(stderr.String()); msg != "" { + fmt.Printf(" ffplay stderr: %s\n", msg) } - // 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)) - + p.cmd = cmd + p.stdin = stdin + p.paused = false 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") - - 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())) - } - return - } - } + _ = cmd.Wait() + close(p.done) }() }