- 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
353 lines
9.0 KiB
Go
353 lines
9.0 KiB
Go
package player
|
|
|
|
import (
|
|
"fmt"
|
|
"time"
|
|
|
|
"fyne.io/fyne/v2"
|
|
"fyne.io/fyne/v2/container"
|
|
"fyne.io/fyne/v2/dialog"
|
|
"fyne.io/fyne/v2/widget"
|
|
)
|
|
|
|
// FynePlayerUI provides a Fyne-based user interface for the VTPlayer
|
|
type FynePlayerUI struct {
|
|
app fyne.App
|
|
window fyne.Window
|
|
player VTPlayer
|
|
container *fyne.Container
|
|
|
|
// UI Components
|
|
playPauseBtn *widget.Button
|
|
stopBtn *widget.Button
|
|
seekSlider *widget.Slider
|
|
timeLabel *widget.Label
|
|
durationLabel *widget.Label
|
|
volumeSlider *widget.Slider
|
|
fullscreenBtn *widget.Button
|
|
fileBtn *widget.Button
|
|
frameLabel *widget.Label
|
|
fpsLabel *widget.Label
|
|
|
|
// State tracking
|
|
isPlaying bool
|
|
currentTime time.Duration
|
|
duration time.Duration
|
|
manualSeek bool
|
|
}
|
|
|
|
// NewFynePlayerUI creates a new Fyne UI for the VTPlayer
|
|
func NewFynePlayerUI(app fyne.App, player VTPlayer) *FynePlayerUI {
|
|
ui := &FynePlayerUI{
|
|
app: app,
|
|
player: player,
|
|
window: app.NewWindow("VideoTools Player"),
|
|
}
|
|
|
|
ui.setupUI()
|
|
ui.setupCallbacks()
|
|
ui.setupWindow()
|
|
|
|
return ui
|
|
}
|
|
|
|
// setupUI creates the user interface components
|
|
func (ui *FynePlayerUI) setupUI() {
|
|
// Control buttons - using text instead of icons for compatibility
|
|
ui.playPauseBtn = widget.NewButton("Play", ui.togglePlayPause)
|
|
ui.stopBtn = widget.NewButton("Stop", ui.stop)
|
|
ui.fullscreenBtn = widget.NewButton("Fullscreen", ui.toggleFullscreen)
|
|
ui.fileBtn = widget.NewButton("Open File", ui.openFile)
|
|
|
|
// Time controls
|
|
ui.seekSlider = widget.NewSlider(0, 100)
|
|
ui.seekSlider.OnChanged = ui.onSeekChanged
|
|
|
|
ui.timeLabel = widget.NewLabel("00:00:00")
|
|
ui.durationLabel = widget.NewLabel("00:00:00")
|
|
|
|
// Volume control
|
|
ui.volumeSlider = widget.NewSlider(0, 100)
|
|
ui.volumeSlider.SetValue(ui.player.GetVolume())
|
|
ui.volumeSlider.OnChanged = ui.onVolumeChanged
|
|
|
|
// Info labels
|
|
ui.frameLabel = widget.NewLabel("Frame: 0")
|
|
ui.fpsLabel = widget.NewLabel("FPS: 0.0")
|
|
|
|
// Volume percentage label
|
|
volumeLabel := widget.NewLabel(fmt.Sprintf("%.0f%%", ui.player.GetVolume()))
|
|
|
|
// Layout containers
|
|
buttonContainer := container.NewHBox(
|
|
ui.fileBtn,
|
|
ui.playPauseBtn,
|
|
ui.stopBtn,
|
|
ui.fullscreenBtn,
|
|
)
|
|
|
|
timeContainer := container.NewHBox(
|
|
ui.timeLabel,
|
|
ui.seekSlider,
|
|
ui.durationLabel,
|
|
)
|
|
|
|
volumeContainer := container.NewHBox(
|
|
widget.NewLabel("Volume:"),
|
|
ui.volumeSlider,
|
|
volumeLabel,
|
|
)
|
|
|
|
infoContainer := container.NewHBox(
|
|
ui.frameLabel,
|
|
ui.fpsLabel,
|
|
)
|
|
|
|
// Update volume label when slider changes
|
|
ui.volumeSlider.OnChanged = func(value float64) {
|
|
volumeLabel.SetText(fmt.Sprintf("%.0f%%", value))
|
|
ui.onVolumeChanged(value)
|
|
}
|
|
|
|
// Main container
|
|
ui.container = container.NewVBox(
|
|
buttonContainer,
|
|
timeContainer,
|
|
volumeContainer,
|
|
infoContainer,
|
|
)
|
|
}
|
|
|
|
// setupCallbacks registers player event callbacks
|
|
func (ui *FynePlayerUI) setupCallbacks() {
|
|
ui.player.SetTimeCallback(ui.onTimeUpdate)
|
|
ui.player.SetFrameCallback(ui.onFrameUpdate)
|
|
ui.player.SetStateCallback(ui.onStateUpdate)
|
|
}
|
|
|
|
// setupWindow configures the main window
|
|
func (ui *FynePlayerUI) setupWindow() {
|
|
ui.window.SetContent(ui.container)
|
|
ui.window.Resize(fyne.NewSize(600, 200))
|
|
ui.window.SetFixedSize(false)
|
|
ui.window.CenterOnScreen()
|
|
}
|
|
|
|
// Show makes the player UI visible
|
|
func (ui *FynePlayerUI) Show() {
|
|
ui.window.Show()
|
|
}
|
|
|
|
// Hide makes the player UI invisible
|
|
func (ui *FynePlayerUI) Hide() {
|
|
ui.window.Hide()
|
|
}
|
|
|
|
// Close closes the player and UI
|
|
func (ui *FynePlayerUI) Close() {
|
|
ui.player.Close()
|
|
ui.window.Close()
|
|
}
|
|
|
|
// togglePlayPause toggles between play and pause states
|
|
func (ui *FynePlayerUI) togglePlayPause() {
|
|
if ui.isPlaying {
|
|
ui.pause()
|
|
} else {
|
|
ui.play()
|
|
}
|
|
}
|
|
|
|
// play starts playback
|
|
func (ui *FynePlayerUI) play() {
|
|
if err := ui.player.Play(); err != nil {
|
|
dialog.ShowError(fmt.Errorf("Failed to play: %w", err), ui.window)
|
|
return
|
|
}
|
|
ui.isPlaying = true
|
|
ui.playPauseBtn.SetText("Pause")
|
|
}
|
|
|
|
// pause pauses playback
|
|
func (ui *FynePlayerUI) pause() {
|
|
if err := ui.player.Pause(); err != nil {
|
|
dialog.ShowError(fmt.Errorf("Failed to pause: %w", err), ui.window)
|
|
return
|
|
}
|
|
ui.isPlaying = false
|
|
ui.playPauseBtn.SetText("Play")
|
|
}
|
|
|
|
// stop stops playback
|
|
func (ui *FynePlayerUI) stop() {
|
|
if err := ui.player.Stop(); err != nil {
|
|
dialog.ShowError(fmt.Errorf("Failed to stop: %w", err), ui.window)
|
|
return
|
|
}
|
|
ui.isPlaying = false
|
|
ui.playPauseBtn.SetText("Play")
|
|
ui.seekSlider.SetValue(0)
|
|
ui.timeLabel.SetText("00:00:00")
|
|
}
|
|
|
|
// toggleFullscreen toggles fullscreen mode
|
|
func (ui *FynePlayerUI) toggleFullscreen() {
|
|
// Note: This would need to be implemented per-backend
|
|
// For now, just toggle the window fullscreen state
|
|
ui.window.SetFullScreen(!ui.window.FullScreen())
|
|
}
|
|
|
|
// openFile shows a file picker and loads the selected video
|
|
func (ui *FynePlayerUI) openFile() {
|
|
dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) {
|
|
if err != nil || reader == nil {
|
|
return
|
|
}
|
|
defer reader.Close()
|
|
|
|
filePath := reader.URI().Path()
|
|
if err := ui.player.Load(filePath, 0); err != nil {
|
|
dialog.ShowError(fmt.Errorf("Failed to load file: %w", err), ui.window)
|
|
return
|
|
}
|
|
|
|
// Update duration when file loads
|
|
ui.duration = ui.player.GetDuration()
|
|
ui.durationLabel.SetText(formatDuration(ui.duration))
|
|
ui.seekSlider.Max = float64(ui.duration.Milliseconds())
|
|
|
|
// Update video info
|
|
info := ui.player.GetVideoInfo()
|
|
ui.fpsLabel.SetText(fmt.Sprintf("FPS: %.2f", info.FrameRate))
|
|
|
|
}, ui.window)
|
|
}
|
|
|
|
// onSeekChanged handles seek slider changes
|
|
func (ui *FynePlayerUI) onSeekChanged(value float64) {
|
|
if ui.manualSeek {
|
|
// Convert slider value to time duration
|
|
seekTime := time.Duration(value) * time.Millisecond
|
|
if err := ui.player.SeekToTime(seekTime); err != nil {
|
|
dialog.ShowError(fmt.Errorf("Failed to seek: %w", err), ui.window)
|
|
}
|
|
}
|
|
}
|
|
|
|
// onVolumeChanged handles volume slider changes
|
|
func (ui *FynePlayerUI) onVolumeChanged(value float64) {
|
|
if err := ui.player.SetVolume(value); err != nil {
|
|
dialog.ShowError(fmt.Errorf("Failed to set volume: %w", err), ui.window)
|
|
return
|
|
}
|
|
}
|
|
|
|
// onTimeUpdate handles time position updates from the player
|
|
func (ui *FynePlayerUI) onTimeUpdate(currentTime time.Duration) {
|
|
ui.currentTime = currentTime
|
|
ui.timeLabel.SetText(formatDuration(currentTime))
|
|
|
|
// Update seek slider without triggering manual seek
|
|
ui.manualSeek = false
|
|
ui.seekSlider.SetValue(float64(currentTime.Milliseconds()))
|
|
ui.manualSeek = true
|
|
}
|
|
|
|
// onFrameUpdate handles frame position updates from the player
|
|
func (ui *FynePlayerUI) onFrameUpdate(frame int64) {
|
|
ui.frameLabel.SetText(fmt.Sprintf("Frame: %d", frame))
|
|
}
|
|
|
|
// onStateUpdate handles player state changes
|
|
func (ui *FynePlayerUI) onStateUpdate(state PlayerState) {
|
|
switch state {
|
|
case StatePlaying:
|
|
ui.isPlaying = true
|
|
ui.playPauseBtn.SetText("Pause")
|
|
case StatePaused:
|
|
ui.isPlaying = false
|
|
ui.playPauseBtn.SetText("Play")
|
|
case StateStopped:
|
|
ui.isPlaying = false
|
|
ui.playPauseBtn.SetText("Play")
|
|
ui.seekSlider.SetValue(0)
|
|
ui.timeLabel.SetText("00:00:00")
|
|
case StateError:
|
|
ui.isPlaying = false
|
|
ui.playPauseBtn.SetText("Play")
|
|
dialog.ShowError(fmt.Errorf("Player error occurred"), ui.window)
|
|
}
|
|
}
|
|
|
|
// formatDuration formats a time.Duration as HH:MM:SS
|
|
func formatDuration(d time.Duration) string {
|
|
if d < 0 {
|
|
d = 0
|
|
}
|
|
|
|
hours := int(d.Hours())
|
|
minutes := int(d.Minutes()) % 60
|
|
seconds := int(d.Seconds()) % 60
|
|
|
|
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds)
|
|
}
|
|
|
|
// LoadVideoFile loads a specific video file
|
|
func (ui *FynePlayerUI) LoadVideoFile(filePath string, offset time.Duration) error {
|
|
if err := ui.player.Load(filePath, offset); err != nil {
|
|
return fmt.Errorf("failed to load video file: %w", err)
|
|
}
|
|
|
|
// Update duration when file loads
|
|
ui.duration = ui.player.GetDuration()
|
|
ui.durationLabel.SetText(formatDuration(ui.duration))
|
|
ui.seekSlider.Max = float64(ui.duration.Milliseconds())
|
|
|
|
// Update video info
|
|
info := ui.player.GetVideoInfo()
|
|
ui.fpsLabel.SetText(fmt.Sprintf("FPS: %.2f", info.FrameRate))
|
|
|
|
return nil
|
|
}
|
|
|
|
// SeekToTime seeks to a specific time
|
|
func (ui *FynePlayerUI) SeekToTime(offset time.Duration) error {
|
|
if err := ui.player.SeekToTime(offset); err != nil {
|
|
return fmt.Errorf("failed to seek: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SeekToFrame seeks to a specific frame number
|
|
func (ui *FynePlayerUI) SeekToFrame(frame int64) error {
|
|
if err := ui.player.SeekToFrame(frame); err != nil {
|
|
return fmt.Errorf("failed to seek to frame: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetCurrentTime returns the current playback time
|
|
func (ui *FynePlayerUI) GetCurrentTime() time.Duration {
|
|
return ui.player.GetCurrentTime()
|
|
}
|
|
|
|
// GetCurrentFrame returns the current frame number
|
|
func (ui *FynePlayerUI) GetCurrentFrame() int64 {
|
|
return ui.player.GetCurrentFrame()
|
|
}
|
|
|
|
// ExtractFrame extracts a frame at the specified time
|
|
func (ui *FynePlayerUI) ExtractFrame(offset time.Duration) (interface{}, error) {
|
|
return ui.player.ExtractFrame(offset)
|
|
}
|
|
|
|
// EnablePreviewMode enables or disables preview mode
|
|
func (ui *FynePlayerUI) EnablePreviewMode(enabled bool) {
|
|
ui.player.EnablePreviewMode(enabled)
|
|
}
|
|
|
|
// IsPreviewMode returns whether preview mode is enabled
|
|
func (ui *FynePlayerUI) IsPreviewMode() bool {
|
|
return ui.player.IsPreviewMode()
|
|
}
|