VideoTools/internal/player/ffplay_wrapper.go
Stu Leak e9608c6085 Implement VT_Player module with frame-accurate video playback
- Add VTPlayer interface with microsecond precision seeking
- Implement MPV controller for frame-accurate playback
- Add VLC backend support for cross-platform compatibility
- Create FFplay wrapper to bridge existing controller
- Add factory pattern for automatic backend selection
- Implement Fyne UI wrapper with real-time controls
- Add frame extraction capabilities for preview system
- Support preview mode for trim/upscale/filter modules
- Include working demo and implementation documentation
2025-12-21 16:31:44 -05:00

421 lines
9.4 KiB
Go

package player
import (
"context"
"fmt"
"image"
"sync"
"time"
)
// FFplayWrapper wraps the existing ffplay controller to implement VTPlayer interface
type FFplayWrapper struct {
mu sync.Mutex
ctx context.Context
cancel context.CancelFunc
// Original ffplay controller
ffplay Controller
// Enhanced state tracking
currentTime time.Duration
currentFrame int64
duration time.Duration
frameRate float64
volume float64
speed float64
previewMode bool
// Window state
windowX, windowY int
windowW, windowH int
// Video info
videoInfo *VideoInfo
// Callbacks
timeCallback func(time.Duration)
frameCallback func(int64)
stateCallback func(PlayerState)
// Configuration
config *Config
// State monitoring
monitorActive bool
lastUpdateTime time.Time
currentPath string
state PlayerState
}
// NewFFplayWrapper creates a new wrapper around the existing FFplay controller
func NewFFplayWrapper(config *Config) (*FFplayWrapper, error) {
if config == nil {
config = &Config{
Backend: BackendFFplay,
Volume: 100.0,
}
}
ctx, cancel := context.WithCancel(context.Background())
// Create the original ffplay controller
ffplay := New()
wrapper := &FFplayWrapper{
ctx: ctx,
cancel: cancel,
ffplay: ffplay,
volume: config.Volume,
speed: 1.0,
config: config,
frameRate: 30.0, // Default, will be updated when file loads
}
// Start monitoring for position updates
go wrapper.monitorPosition()
return wrapper, nil
}
// Load loads a video file at the specified offset
func (f *FFplayWrapper) Load(path string, offset time.Duration) error {
f.mu.Lock()
defer f.mu.Unlock()
f.setState(StateLoading)
// Set window properties before loading
if f.windowW > 0 && f.windowH > 0 {
f.ffplay.SetWindow(f.windowX, f.windowY, f.windowW, f.windowH)
}
// Load using the original controller
if err := f.ffplay.Load(path, float64(offset)/float64(time.Second)); err != nil {
f.setState(StateError)
return fmt.Errorf("failed to load file: %w", err)
}
f.currentPath = path
f.currentTime = offset
f.currentFrame = int64(float64(offset) * f.frameRate / float64(time.Second))
// Initialize video info (limited capabilities with ffplay)
f.videoInfo = &VideoInfo{
Duration: time.Hour * 24, // Placeholder, will be updated if we can detect
FrameRate: f.frameRate,
Width: 0, // Will be updated if detectable
Height: 0, // Will be updated if detectable
}
f.setState(StatePaused)
// Auto-play if configured
if f.config.AutoPlay {
return f.Play()
}
return nil
}
// Play starts playback
func (f *FFplayWrapper) Play() error {
f.mu.Lock()
defer f.mu.Unlock()
if err := f.ffplay.Play(); err != nil {
return fmt.Errorf("failed to start playback: %w", err)
}
f.setState(StatePlaying)
return nil
}
// Pause pauses playback
func (f *FFplayWrapper) Pause() error {
f.mu.Lock()
defer f.mu.Unlock()
if err := f.ffplay.Pause(); err != nil {
return fmt.Errorf("failed to pause playback: %w", err)
}
f.setState(StatePaused)
return nil
}
// Stop stops playback and resets position
func (f *FFplayWrapper) Stop() error {
f.mu.Lock()
defer f.mu.Unlock()
if err := f.ffplay.Stop(); err != nil {
return fmt.Errorf("failed to stop playback: %w", err)
}
f.currentTime = 0
f.currentFrame = 0
f.setState(StateStopped)
return nil
}
// Close cleans up resources
func (f *FFplayWrapper) Close() {
f.cancel()
f.mu.Lock()
defer f.mu.Unlock()
if f.ffplay != nil {
f.ffplay.Close()
}
f.setState(StateStopped)
}
// SeekToTime seeks to a specific time with frame accuracy
func (f *FFplayWrapper) SeekToTime(offset time.Duration) error {
f.mu.Lock()
defer f.mu.Unlock()
if err := f.ffplay.Seek(float64(offset) / float64(time.Second)); err != nil {
return fmt.Errorf("seek failed: %w", err)
}
f.currentTime = offset
f.currentFrame = int64(float64(offset) * f.frameRate / float64(time.Second))
return nil
}
// SeekToFrame seeks to a specific frame number
func (f *FFplayWrapper) SeekToFrame(frame int64) error {
if f.frameRate <= 0 {
return fmt.Errorf("invalid frame rate")
}
offset := time.Duration(float64(frame) * float64(time.Second) / f.frameRate)
return f.SeekToTime(offset)
}
// GetCurrentTime returns the current playback time
func (f *FFplayWrapper) GetCurrentTime() time.Duration {
f.mu.Lock()
defer f.mu.Unlock()
return f.currentTime
}
// GetCurrentFrame returns the current frame number
func (f *FFplayWrapper) GetCurrentFrame() int64 {
f.mu.Lock()
defer f.mu.Unlock()
return f.currentFrame
}
// GetFrameRate returns the video frame rate
func (f *FFplayWrapper) GetFrameRate() float64 {
f.mu.Lock()
defer f.mu.Unlock()
return f.frameRate
}
// GetDuration returns the total video duration
func (f *FFplayWrapper) GetDuration() time.Duration {
f.mu.Lock()
defer f.mu.Unlock()
return f.duration
}
// GetVideoInfo returns video metadata
func (f *FFplayWrapper) GetVideoInfo() *VideoInfo {
f.mu.Lock()
defer f.mu.Unlock()
if f.videoInfo == nil {
return &VideoInfo{}
}
info := *f.videoInfo
return &info
}
// ExtractFrame extracts a frame at the specified time
func (f *FFplayWrapper) ExtractFrame(offset time.Duration) (image.Image, error) {
// FFplay doesn't support frame extraction through its interface
// This would require using ffmpeg directly for frame extraction
return nil, fmt.Errorf("frame extraction not supported by FFplay backend")
}
// ExtractCurrentFrame extracts the currently displayed frame
func (f *FFplayWrapper) ExtractCurrentFrame() (image.Image, error) {
return f.ExtractFrame(f.currentTime)
}
// SetWindow sets the window position and size
func (f *FFplayWrapper) SetWindow(x, y, w, h int) {
f.mu.Lock()
defer f.mu.Unlock()
f.windowX, f.windowY, f.windowW, f.windowH = x, y, w, h
f.ffplay.SetWindow(x, y, w, h)
}
// SetFullScreen toggles fullscreen mode
func (f *FFplayWrapper) SetFullScreen(fullscreen bool) {
f.mu.Lock()
defer f.mu.Unlock()
if fullscreen {
f.ffplay.FullScreen()
}
}
// GetWindowSize returns the current window geometry
func (f *FFplayWrapper) GetWindowSize() (x, y, w, h int) {
f.mu.Lock()
defer f.mu.Unlock()
return f.windowX, f.windowY, f.windowW, f.windowH
}
// SetVolume sets the audio volume (0-100)
func (f *FFplayWrapper) SetVolume(level float64) error {
f.mu.Lock()
defer f.mu.Unlock()
if level < 0 {
level = 0
} else if level > 100 {
level = 100
}
f.volume = level
if err := f.ffplay.SetVolume(level); err != nil {
return fmt.Errorf("failed to set volume: %w", err)
}
return nil
}
// GetVolume returns the current volume level
func (f *FFplayWrapper) GetVolume() float64 {
f.mu.Lock()
defer f.mu.Unlock()
return f.volume
}
// SetMuted sets the mute state
func (f *FFplayWrapper) SetMuted(muted bool) {
f.mu.Lock()
defer f.mu.Unlock()
// FFplay doesn't have explicit mute control, set volume to 0 instead
if muted {
f.ffplay.SetVolume(0)
} else {
f.ffplay.SetVolume(f.volume)
}
}
// IsMuted returns the current mute state
func (f *FFplayWrapper) IsMuted() bool {
// Since FFplay doesn't have explicit mute, return false
return false
}
// SetSpeed sets the playback speed
func (f *FFplayWrapper) SetSpeed(speed float64) error {
// FFplay doesn't support speed changes through the controller interface
return fmt.Errorf("speed control not supported by FFplay backend")
}
// GetSpeed returns the current playback speed
func (f *FFplayWrapper) GetSpeed() float64 {
return f.speed
}
// SetTimeCallback sets the time position callback
func (f *FFplayWrapper) SetTimeCallback(callback func(time.Duration)) {
f.mu.Lock()
defer f.mu.Unlock()
f.timeCallback = callback
}
// SetFrameCallback sets the frame position callback
func (f *FFplayWrapper) SetFrameCallback(callback func(int64)) {
f.mu.Lock()
defer f.mu.Unlock()
f.frameCallback = callback
}
// SetStateCallback sets the player state callback
func (f *FFplayWrapper) SetStateCallback(callback func(PlayerState)) {
f.mu.Lock()
defer f.mu.Unlock()
f.stateCallback = callback
}
// EnablePreviewMode enables or disables preview mode
func (f *FFplayWrapper) EnablePreviewMode(enabled bool) {
f.mu.Lock()
defer f.mu.Unlock()
f.previewMode = enabled
}
// IsPreviewMode returns whether preview mode is enabled
func (f *FFplayWrapper) IsPreviewMode() bool {
f.mu.Lock()
defer f.mu.Unlock()
return f.previewMode
}
func (f *FFplayWrapper) setState(newState PlayerState) {
if f.state != newState {
f.state = newState
if f.stateCallback != nil {
go f.stateCallback(newState)
}
}
}
func (f *FFplayWrapper) monitorPosition() {
ticker := time.NewTicker(100 * time.Millisecond) // 10Hz update rate
defer ticker.Stop()
for {
select {
case <-f.ctx.Done():
return
case <-ticker.C:
f.updatePosition()
}
}
}
func (f *FFplayWrapper) updatePosition() {
f.mu.Lock()
defer f.mu.Unlock()
if f.state != StatePlaying {
return
}
// Simple time estimation since we can't get exact position from ffplay
now := time.Now()
elapsed := now.Sub(f.lastUpdateTime)
if !f.lastUpdateTime.IsZero() {
f.currentTime += time.Duration(float64(elapsed) * f.speed)
if f.frameRate > 0 {
f.currentFrame = int64(float64(f.currentTime) * f.frameRate / float64(time.Second))
}
// Trigger callbacks
if f.timeCallback != nil {
go f.timeCallback(f.currentTime)
}
if f.frameCallback != nil {
go f.frameCallback(f.currentFrame)
}
}
f.lastUpdateTime = now
// Check if we've exceeded estimated duration
if f.duration > 0 && f.currentTime >= f.duration {
f.setState(StateStopped)
}
}