package player import ( "bufio" "context" "encoding/binary" "fmt" "image" "io" "os/exec" "strings" "sync" "time" "git.leaktechnologies.dev/stu/VideoTools/internal/utils" "git.leaktechnologies.dev/stu/VideoTools/internal/logging" ) // UnifiedPlayer implements rock-solid video playback with proper A/V synchronization // and frame-accurate seeking using a single FFmpeg process type UnifiedPlayer struct { mu sync.RWMutex ctx context.Context cancel context.CancelFunc // FFmpeg process cmd *exec.Cmd stdin *bufio.Writer stdout *bufio.Reader stderr *bufio.Reader // Video output pipes videoPipeReader *io.PipeReader videoPipeWriter *io.PipeWriter audioPipeReader *io.PipeReader audioPipeWriter *io.PipeWriter // State tracking currentPath string currentTime time.Duration currentFrame int64 duration time.Duration frameRate float64 state PlayerState volume float64 speed float64 muted bool fullscreen bool previewMode bool // Video info videoInfo *VideoInfo // Synchronization syncClock time.Time videoPTS int64 audioPTS int64 ptsOffset int64 // Buffer management frameBuffer *sync.Pool audioBuffer []byte audioBufferSize int // Window state windowX, windowY int windowW, windowH int // Callbacks timeCallback func(time.Duration) frameCallback func(int64) stateCallback func(PlayerState) // Configuration config Config } // NewUnifiedPlayer creates a new unified player with proper A/V synchronization func NewUnifiedPlayer(config Config) *UnifiedPlayer { player := &UnifiedPlayer{ config: config, frameBuffer: &sync.Pool{ New: func() interface{} { return &image.RGBA{ Pix: make([]uint8, 0), Stride: 0, Rect: image.Rect(0, 0, 0, 0), } }, }, audioBufferSize: 32768, // 170ms at 48kHz for smooth playback } ctx, cancel := context.WithCancel(context.Background()) player.ctx = ctx player.cancel = cancel return player } // Load loads a video file and initializes playback func (p *UnifiedPlayer) Load(path string, offset time.Duration) error { p.mu.Lock() defer p.mu.Unlock() p.currentPath = path p.state = StateLoading // Create pipes for FFmpeg communication p.videoPipeReader, p.videoPipeWriter = io.Pipe() p.audioPipeReader, p.audioPipeWriter = io.Pipe() // Build FFmpeg command with unified A/V output args := []string{ "-hide_banner", "-loglevel", "error", "-ss", fmt.Sprintf("%.3f", offset.Seconds()), "-i", path, // Video stream to pipe 4 "-map", "0:v:0", "-f", "rawvideo", "-pix_fmt", "rgb24", "-r", "24", // We'll detect actual framerate "pipe:4", // Audio stream to pipe 5 "-map", "0:a:0", "-ac", "2", "-ar", "48000", "-f", "s16le", "pipe:5", } // Add hardware acceleration if available if p.config.HardwareAccel { if args = p.addHardwareAcceleration(args); args != nil { logging.Debug(logging.CatPlayer, "Hardware acceleration enabled: %v", args) } } p.cmd = exec.Command(utils.GetFFmpegPath(), args...) p.cmd.Stdout = p.videoPipeWriter p.cmd.Stderr = p.audioPipeWriter utils.ApplyNoWindow(p.cmd) if err := p.cmd.Start(); err != nil { logging.Error(logging.CatPlayer, "Failed to start FFmpeg: %v", err) return fmt.Errorf("failed to start FFmpeg: %w", err) } // Initialize audio buffer p.audioBuffer = make([]byte, 0, p.audioBufferSize) // Start goroutine for reading audio stream go p.readAudioStream() // Detect video properties if err := p.detectVideoProperties(); err != nil { logging.Error(logging.CatPlayer, "Failed to detect video properties: %w", err) return fmt.Errorf("failed to detect video properties: %w", err) } logging.Debug(logging.CatPlayer, "Loaded video: %s", path) return nil } // Play starts or resumes playback func (p *UnifiedPlayer) Play() error { p.mu.Lock() defer p.mu.Unlock() if p.state == StateStopped { if err := p.startVideoProcess(); err != nil { return err } p.state = StatePlaying } else if p.state == StatePaused { p.state = StatePlaying } if p.stateCallback != nil { p.stateCallback(p.state) } logging.Debug(logging.CatPlayer, "Playback started") return nil } // Pause pauses playback func (p *UnifiedPlayer) Pause() error { p.mu.Lock() defer p.mu.Unlock() if p.state == StatePlaying { p.state = StatePaused if p.stateCallback != nil { p.stateCallback(p.state) } } logging.Debug(logging.CatPlayer, "Playback paused") return nil } // Stop stops playback and cleans up resources func (p *UnifiedPlayer) Stop() error { p.mu.Lock() defer p.mu.Unlock() if p.cancel != nil { p.cancel() } // Close pipes if p.videoPipeReader != nil { p.videoPipeReader.Close() p.videoPipeWriter.Close() } if p.audioPipeReader != nil { p.audioPipeReader.Close() p.audioPipeWriter.Close() } // Wait for process to finish if p.cmd != nil && p.cmd.Process != nil { p.cmd.Process.Wait() } p.state = StateStopped if p.stateCallback != nil { p.stateCallback(p.state) } logging.Debug(logging.CatPlayer, "Playback stopped") return nil } // SeekToTime seeks to a specific time without restarting processes func (p *UnifiedPlayer) SeekToTime(offset time.Duration) error { p.mu.Lock() defer p.mu.Unlock() if offset < 0 { offset = 0 } // Seek to exact time without restart seekTime := offset.Seconds() logging.Debug(logging.CatPlayer, "Seeking to time: %.3f seconds", seekTime) // Send seek command to FFmpeg p.writeStringToStdin(fmt.Sprintf("seek %.3f\n", seekTime)) p.currentTime = offset p.syncClock = time.Now() if p.timeCallback != nil { p.timeCallback(offset) } logging.Debug(logging.CatPlayer, "Seek completed to %.3f seconds", offset.Seconds()) return nil } // SeekToFrame seeks to a specific frame without restarting processes func (p *UnifiedPlayer) SeekToFrame(frame int64) error { if p.frameRate <= 0 { return fmt.Errorf("invalid frame rate: %f", p.frameRate) } // Convert frame number to time frameTime := time.Duration(float64(frame) * float64(time.Second) / p.frameRate) return p.SeekToTime(frameTime) } // GetCurrentTime returns the current playback time func (p *UnifiedPlayer) GetCurrentTime() time.Duration { p.mu.RLock() defer p.mu.RUnlock() return p.currentTime } // GetCurrentFrame returns the current frame number func (p *UnifiedPlayer) GetCurrentFrame() int64 { p.mu.RLock() defer p.mu.RUnlock() if p.frameRate > 0 { return int64(p.currentTime.Seconds() * p.frameRate) } return 0 } // GetDuration returns the total video duration func (p *UnifiedPlayer) GetDuration() time.Duration { p.mu.RLock() defer p.mu.RUnlock() return p.duration } // GetFrameRate returns the video frame rate func (p *UnifiedPlayer) GetFrameRate() float64 { p.mu.RLock() defer p.mu.RUnlock() return p.frameRate } // GetVideoInfo returns video metadata func (p *UnifiedPlayer) GetVideoInfo() *VideoInfo { p.mu.RLock() defer p.mu.RUnlock() if p.videoInfo == nil { return &VideoInfo{} } return p.videoInfo } // SetWindow sets the window position and size func (p *UnifiedPlayer) SetWindow(x, y, w, h int) { p.mu.Lock() defer p.mu.Unlock() p.windowX, p.windowY, p.windowW, p.windowH = x, y, w, h // Send window command to FFmpeg p.writeStringToStdin(fmt.Sprintf("window %d %d %d %d\n", x, y, w, h)) } // SetFullScreen toggles fullscreen mode func (p *UnifiedPlayer) SetFullScreen(fullscreen bool) error { p.mu.Lock() defer p.mu.Unlock() p.fullscreen = fullscreen // Send fullscreen command to FFmpeg var cmd string if fullscreen { cmd = "fullscreen" } else { cmd = "windowed" } p.writeStringToStdin(fmt.Sprintf("%s\n", cmd)) logging.Debug(logging.CatPlayer, "Fullscreen set to: %v", fullscreen) return nil } // GetWindowSize returns current window dimensions func (p *UnifiedPlayer) GetWindowSize() (x, y, w, h int) { p.mu.RLock() defer p.mu.RUnlock() return p.windowX, p.windowY, p.windowW, p.windowH } // SetVolume sets the audio volume (0.0-1.0) func (p *UnifiedPlayer) SetVolume(level float64) error { p.mu.Lock() defer p.mu.Unlock() // Clamp volume to valid range if level < 0 { level = 0 } else if level > 1 { level = 1 } p.volume = level // Send volume command to FFmpeg p.writeStringToStdin(fmt.Sprintf("volume %.3f\n", level)) logging.Debug(logging.CatPlayer, "Volume set to: %.3f", level) return nil } // GetVolume returns current volume level func (p *UnifiedPlayer) GetVolume() float64 { p.mu.RLock() defer p.mu.RUnlock() return p.volume } // SetMuted sets the mute state func (p *UnifiedPlayer) SetMuted(muted bool) { p.mu.Lock() defer p.mu.Unlock() p.muted = muted // Send mute command to FFmpeg var cmd string if muted { cmd = "mute" } else { cmd = "unmute" } p.writeStringToStdin(fmt.Sprintf("%s\n", cmd)) logging.Debug(logging.CatPlayer, "Mute set to: %v", muted) } // IsMuted returns current mute state func (p *UnifiedPlayer) IsMuted() bool { p.mu.RLock() defer p.mu.RUnlock() return p.muted } // SetSpeed sets playback speed func (p *UnifiedPlayer) SetSpeed(speed float64) error { p.mu.Lock() defer p.mu.Unlock() p.speed = speed // Send speed command to FFmpeg p.writeStringToStdin(fmt.Sprintf("speed %.2f\n", speed)) logging.Debug(logging.CatPlayer, "Speed set to: %.2f", speed) return nil } // GetSpeed returns current playback speed func (p *UnifiedPlayer) GetSpeed() float64 { p.mu.RLock() defer p.mu.RUnlock() return p.speed } // SetTimeCallback sets the time update callback func (p *UnifiedPlayer) SetTimeCallback(callback func(time.Duration)) { p.mu.Lock() defer p.mu.Unlock() p.timeCallback = callback } // SetFrameCallback sets the frame update callback func (p *UnifiedPlayer) SetFrameCallback(callback func(int64)) { p.mu.Lock() defer p.mu.Unlock() p.frameCallback = callback } // SetStateCallback sets the state change callback func (p *UnifiedPlayer) SetStateCallback(callback func(PlayerState)) { p.mu.Lock() defer p.mu.Unlock() p.stateCallback = callback } // EnablePreviewMode enables or disables preview mode func (p *UnifiedPlayer) EnablePreviewMode(enabled bool) { p.mu.Lock() defer p.mu.Unlock() p.previewMode = enabled } // IsPreviewMode returns current preview mode state func (p *UnifiedPlayer) IsPreviewMode() bool { p.mu.RLock() defer p.mu.RUnlock() return p.previewMode } // Close shuts down the player and cleans up resources func (p *UnifiedPlayer) Close() { p.Stop() p.mu.Lock() defer p.mu.Unlock() p.frameBuffer = nil p.audioBuffer = nil } // Helper methods // startVideoProcess starts the video processing goroutine func (p *UnifiedPlayer) startVideoProcess() error { go func() { frameDuration := time.Second / time.Duration(p.frameRate) frameTime := p.syncClock for { select { case <-p.ctx.Done(): logging.Debug(logging.CatPlayer, "Video processing goroutine stopped") return default: // Read frame from video pipe frame, err := p.readVideoFrame() if err != nil { logging.Error(logging.CatPlayer, "Failed to read video frame: %v", err) continue } if frame == nil { continue } // Update timing p.currentTime = frameTime.Sub(p.syncClock) frameTime = frameTime.Add(frameDuration) p.syncClock = time.Now() // Notify callback if p.frameCallback != nil { p.frameCallback(p.GetCurrentFrame()) } // Sleep until next frame time sleepTime := frameTime.Sub(time.Now()) if sleepTime > 0 { time.Sleep(sleepTime) } } } }() return nil } // readAudioStream reads and processes audio from the audio pipe func (p *UnifiedPlayer) readAudioStream() { buffer := make([]byte, 4096) // 85ms chunks for { select { case <-p.ctx.Done(): logging.Debug(logging.CatPlayer, "Audio reading goroutine stopped") return default: // Read from audio pipe n, err := p.audioPipeReader.Read(buffer) if err != nil && err.Error() != "EOF" { logging.Error(logging.CatPlayer, "Audio read error: %v", err) continue } if n == 0 { continue } // Apply volume if not muted if !p.muted && p.volume > 0 { p.applyVolumeToBuffer(buffer[:n]) } // Send to audio output (this would connect to audio system) // For now, we'll store in buffer for playback sync monitoring p.audioBuffer = append(p.audioBuffer, buffer[:n]...) // Simple audio sync timing p.updateAVSync() } } } // readVideoStream reads video frames from the video pipe func (p *UnifiedPlayer) readVideoFrame() (*image.RGBA, error) { // Read RGB24 frame data frameSize := p.windowW * p.windowH * 3 // RGB24 = 3 bytes per pixel frameData := make([]byte, frameSize) n, err := p.videoPipeReader.Read(frameData) if err != nil && err.Error() != "EOF" { return nil, fmt.Errorf("video read error: %w", err) } if n == 0 { return nil, nil } // Get frame from pool img := p.frameBuffer.Get().(*image.RGBA) img.Pix = make([]uint8, frameSize) img.Stride = p.windowW * 3 img.Rect = image.Rect(0, 0, p.windowW, p.windowH) // Copy RGB data to image copy(img.Pix, frameData[:frameSize]) return img, nil } // detectVideoProperties analyzes the video to determine properties func (p *UnifiedPlayer) detectVideoProperties() error { // Use ffprobe to get video information cmd := exec.Command(utils.GetFFprobePath(), "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=r_frame_rate,duration,width,height", p.currentPath, ) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("ffprobe failed: %w", err) } // Parse frame rate and duration p.frameRate = 25.0 // Default fallback p.duration = 0 lines := strings.Split(string(output), "\n") for _, line := range lines { if strings.Contains(line, "r_frame_rate=") { if parts := strings.Split(line, "="); len(parts) > 1 { var fr float64 if _, err := fmt.Sscanf(parts[1], "%f", &fr); err == nil { p.frameRate = fr } } } else if strings.Contains(line, "duration=") { if parts := strings.Split(line, "="); len(parts) > 1 { if dur, err := time.ParseDuration(parts[1]); err == nil { p.duration = dur } } } } if p.frameRate > 0 && p.duration > 0 { p.videoInfo = &VideoInfo{ Width: p.windowW, Height: p.windowH, Duration: p.duration, FrameRate: p.frameRate, FrameCount: int64(p.duration.Seconds() * p.frameRate), } } else { p.videoInfo = &VideoInfo{ Width: p.windowW, Height: p.windowH, Duration: p.duration, FrameRate: p.frameRate, FrameCount: 0, } } logging.Debug(logging.CatPlayer, "Video properties: %dx%d@%.3ffps, %.2fs", p.windowW, p.windowH, p.frameRate, p.duration.Seconds()) return nil } // writeStringToStdin sends a command to FFmpeg's stdin func (p *UnifiedPlayer) writeStringToStdin(cmd string) { // TODO: Implement stdin command writing for interactive FFmpeg control // Currently a no-op as stdin is not configured in this player implementation logging.Debug(logging.CatPlayer, "Stdin command (not implemented): %s", cmd) } // updateAVSync maintains synchronization between audio and video func (p *UnifiedPlayer) updateAVSync() { // Simple drift correction using master clock reference if p.audioPTS > 0 && p.videoPTS > 0 { drift := p.audioPTS - p.videoPTS if abs(drift) > 1000 { // More than 1 frame of drift logging.Debug(logging.CatPlayer, "A/V sync drift: %d PTS", drift) // Adjust sync clock gradually p.ptsOffset += drift / 100 } } } // addHardwareAcceleration adds hardware acceleration flags to FFmpeg args func (p *UnifiedPlayer) addHardwareAcceleration(args []string) []string { // This is a placeholder - actual implementation would detect available hardware // and add appropriate flags like "-hwaccel cuda", "-c:v h264_nvenc" // For now, just log that hardware acceleration is considered logging.Debug(logging.CatPlayer, "Hardware acceleration requested but not yet implemented") return args } // applyVolumeToBuffer applies volume adjustments to audio buffer func (p *UnifiedPlayer) applyVolumeToBuffer(buffer []byte) { if p.volume <= 0 { // Muted - set to silence for i := range buffer { buffer[i] = 0 } } else { // Apply volume gain gain := p.volume for i := 0; i < len(buffer); i += 2 { if i+1 < len(buffer) { sample := int16(binary.LittleEndian.Uint16(buffer[i : i+2])) adjusted := int(float64(sample) * gain) // Clamp to int16 range if adjusted > 32767 { adjusted = 32767 } else if adjusted < -32768 { adjusted = -32768 } binary.LittleEndian.PutUint16(buffer[i:i+2], uint16(adjusted)) } } } } // abs returns absolute value of int64 func abs(x int64) int64 { if x < 0 { return -x } return x }