VT_Player foundation: Frame-accurate navigation and responsive scrubbing

Frame Navigation:
- Add frame-by-frame stepping with Previous/Next frame buttons
- Implement StepFrame() method for precise frame control
- Auto-pause when frame stepping for accuracy
- Display real-time frame counter during playback

Responsive Scrubbing:
- Add 150ms debounce to progress bar seeking
- Prevents rapid FFmpeg restarts during drag operations
- Smoother user experience when scrubbing through video

Player Session Improvements:
- Track frame numbers accurately with frameFunc callback
- Add duration field for proper frame calculations
- Update frame counter in real-time during playback
- Display current frame number in UI (Frame: N)

UI Enhancements:
- Frame step buttons: ◀| (previous) and |▶ (next)
- Frame counter label with monospace styling
- Integrated into existing player controls layout

Technical Details:
- Frame calculation: targetFrame = currentFrame ± delta
- Time conversion: offset = frameNumber / fps
- Clamp frame numbers to valid range [0, maxFrame]
- Call frameFunc callback on each displayed frame

Foundation ready for future enhancements (keyboard shortcuts, etc.)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Stu Leak 2025-12-23 15:37:26 -05:00
parent bc85ed9940
commit e910bee641

181
main.go
View File

@ -8662,24 +8662,49 @@ func buildVideoPane(state *appState, min fyne.Size, src *videoSource, onCover fu
var controls fyne.CanvasObject var controls fyne.CanvasObject
if usePlayer { if usePlayer {
// Frame counter label
frameLabel := widget.NewLabel("Frame: 0")
frameLabel.TextStyle = fyne.TextStyle{Monospace: true}
updateFrame := func(frameNum int) {
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
frameLabel.SetText(fmt.Sprintf("Frame: %d", frameNum))
}, false)
}
var volIcon *widget.Button var volIcon *widget.Button
var updatingVolume bool var updatingVolume bool
ensureSession := func() bool { ensureSession := func() bool {
if state.playSess == nil { if state.playSess == nil {
state.playSess = newPlaySession(src.Path, src.Width, src.Height, src.FrameRate, int(targetWidth-28), int(targetHeight-40), updateProgress, img) state.playSess = newPlaySession(src.Path, src.Width, src.Height, src.FrameRate, src.Duration, int(targetWidth-28), int(targetHeight-40), updateProgress, updateFrame, img)
state.playSess.SetVolume(state.playerVolume) state.playSess.SetVolume(state.playerVolume)
state.playerPaused = true state.playerPaused = true
} }
return state.playSess != nil return state.playSess != nil
} }
// Debounced seeking - only seek after user stops dragging
var seekTimer *time.Timer
var seekMutex sync.Mutex
slider.OnChanged = func(val float64) { slider.OnChanged = func(val float64) {
if updatingProgress { if updatingProgress {
return return
} }
updateProgress(val) updateProgress(val)
if ensureSession() { if !ensureSession() {
state.playSess.Seek(val) return
} }
// Debounce seeking - wait 150ms after last change
seekMutex.Lock()
if seekTimer != nil {
seekTimer.Stop()
}
seekTimer = time.AfterFunc(150*time.Millisecond, func() {
if state.playSess != nil {
state.playSess.Seek(val)
}
})
seekMutex.Unlock()
} }
updateVolIcon := func() { updateVolIcon := func() {
if volIcon == nil { if volIcon == nil {
@ -8744,16 +8769,31 @@ func buildVideoPane(state *appState, min fyne.Size, src *videoSource, onCover fu
state.playerPaused = true state.playerPaused = true
} }
}) })
// Frame stepping buttons
prevFrameBtn := utils.MakeIconButton("◀|", "Previous frame (Left Arrow)", func() {
if !ensureSession() {
return
}
state.playSess.StepFrame(-1)
})
nextFrameBtn := utils.MakeIconButton("|▶", "Next frame (Right Arrow)", func() {
if !ensureSession() {
return
}
state.playSess.StepFrame(1)
})
fullBtn := utils.MakeIconButton("⛶", "Toggle fullscreen", func() { fullBtn := utils.MakeIconButton("⛶", "Toggle fullscreen", func() {
// Placeholder: embed fullscreen toggle into playback surface later. // Placeholder: embed fullscreen toggle into playback surface later.
}) })
volBox := container.NewHBox(volIcon, container.NewMax(volSlider)) volBox := container.NewHBox(volIcon, container.NewMax(volSlider))
progress := container.NewBorder(nil, nil, currentTime, totalTime, container.NewMax(slider)) progress := container.NewBorder(nil, nil, currentTime, totalTime, container.NewMax(slider))
controls = container.NewVBox( controls = container.NewVBox(
container.NewHBox(playBtn, fullBtn, coverBtn, saveFrameBtn, importBtn, layout.NewSpacer(), volBox), container.NewHBox(prevFrameBtn, playBtn, nextFrameBtn, fullBtn, coverBtn, saveFrameBtn, importBtn, layout.NewSpacer(), frameLabel, volBox),
progress, progress,
) )
} else { } else{
slider := widget.NewSlider(0, math.Max(1, float64(len(src.PreviewFrames)-1))) slider := widget.NewSlider(0, math.Max(1, float64(len(src.PreviewFrames)-1)))
slider.Step = 1 slider.Step = 1
slider.OnChanged = func(val float64) { slider.OnChanged = func(val float64) {
@ -8812,24 +8852,26 @@ func buildVideoPane(state *appState, min fyne.Size, src *videoSource, onCover fu
} }
type playSession struct { type playSession struct {
path string path string
fps float64 fps float64
width int width int
height int height int
targetW int targetW int
targetH int targetH int
volume float64 volume float64
muted bool muted bool
paused bool paused bool
current float64 current float64
stop chan struct{} stop chan struct{}
done chan struct{} done chan struct{}
prog func(float64) prog func(float64)
img *canvas.Image frameFunc func(int) // Callback for frame number updates
mu sync.Mutex img *canvas.Image
videoCmd *exec.Cmd mu sync.Mutex
audioCmd *exec.Cmd videoCmd *exec.Cmd
frameN int audioCmd *exec.Cmd
frameN int
duration float64 // Total duration in seconds
} }
var audioCtxGlobal struct { var audioCtxGlobal struct {
@ -8845,7 +8887,7 @@ func getAudioContext(sampleRate, channels, bytesPerSample int) (*oto.Context, er
return audioCtxGlobal.ctx, audioCtxGlobal.err return audioCtxGlobal.ctx, audioCtxGlobal.err
} }
func newPlaySession(path string, w, h int, fps float64, targetW, targetH int, prog func(float64), img *canvas.Image) *playSession { func newPlaySession(path string, w, h int, fps, duration float64, targetW, targetH int, prog func(float64), frameFunc func(int), img *canvas.Image) *playSession {
if fps <= 0 { if fps <= 0 {
fps = 24 fps = 24
} }
@ -8856,17 +8898,19 @@ func newPlaySession(path string, w, h int, fps float64, targetW, targetH int, pr
targetH = int(float64(targetW) * (float64(h) / float64(utils.MaxInt(w, 1)))) targetH = int(float64(targetW) * (float64(h) / float64(utils.MaxInt(w, 1))))
} }
return &playSession{ return &playSession{
path: path, path: path,
fps: fps, fps: fps,
width: w, width: w,
height: h, height: h,
targetW: targetW, targetW: targetW,
targetH: targetH, targetH: targetH,
volume: 100, volume: 100,
stop: make(chan struct{}), duration: duration,
done: make(chan struct{}), stop: make(chan struct{}),
prog: prog, done: make(chan struct{}),
img: img, prog: prog,
frameFunc: frameFunc,
img: img,
} }
} }
@ -8910,6 +8954,70 @@ func (p *playSession) Seek(offset float64) {
} }
} }
// StepFrame moves forward or backward by a specific number of frames.
// Positive delta moves forward, negative moves backward.
func (p *playSession) StepFrame(delta int) {
p.mu.Lock()
defer p.mu.Unlock()
if p.fps <= 0 {
return
}
// Calculate target frame number
currentFrame := p.frameN
targetFrame := currentFrame + delta
// Clamp to valid range
if targetFrame < 0 {
targetFrame = 0
}
maxFrame := int(p.duration * p.fps)
if targetFrame > maxFrame {
targetFrame = maxFrame
}
// Convert to time offset
offset := float64(targetFrame) / p.fps
if offset < 0 {
offset = 0
}
if offset > p.duration {
offset = p.duration
}
// Auto-pause when frame stepping
wasPaused := p.paused
p.paused = true
p.current = offset
p.frameN = targetFrame
p.stopLocked()
p.startLocked(p.current)
p.paused = true
// Ensure pause is maintained
time.AfterFunc(30*time.Millisecond, func() {
p.mu.Lock()
defer p.mu.Unlock()
p.paused = true
})
if p.prog != nil {
p.prog(p.current)
}
if p.frameFunc != nil {
p.frameFunc(targetFrame)
}
_ = wasPaused // Keep for potential future use
}
// GetCurrentFrame returns the current frame number
func (p *playSession) GetCurrentFrame() int {
p.mu.Lock()
defer p.mu.Unlock()
return p.frameN
}
func (p *playSession) SetVolume(v float64) { func (p *playSession) SetVolume(v float64) {
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock() defer p.mu.Unlock()
@ -9044,6 +9152,9 @@ func (p *playSession) runVideo(offset float64) {
if p.prog != nil { if p.prog != nil {
p.prog(p.current) p.prog(p.current)
} }
if p.frameFunc != nil {
p.frameFunc(p.frameN)
}
} }
}() }()
} }