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:
parent
bc85ed9940
commit
e910bee641
181
main.go
181
main.go
|
|
@ -8662,24 +8662,49 @@ func buildVideoPane(state *appState, min fyne.Size, src *videoSource, onCover fu
|
|||
|
||||
var controls fyne.CanvasObject
|
||||
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 updatingVolume bool
|
||||
ensureSession := func() bool {
|
||||
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.playerPaused = true
|
||||
}
|
||||
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) {
|
||||
if updatingProgress {
|
||||
return
|
||||
}
|
||||
updateProgress(val)
|
||||
if ensureSession() {
|
||||
state.playSess.Seek(val)
|
||||
if !ensureSession() {
|
||||
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() {
|
||||
if volIcon == nil {
|
||||
|
|
@ -8744,16 +8769,31 @@ func buildVideoPane(state *appState, min fyne.Size, src *videoSource, onCover fu
|
|||
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() {
|
||||
// Placeholder: embed fullscreen toggle into playback surface later.
|
||||
})
|
||||
volBox := container.NewHBox(volIcon, container.NewMax(volSlider))
|
||||
progress := container.NewBorder(nil, nil, currentTime, totalTime, container.NewMax(slider))
|
||||
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,
|
||||
)
|
||||
} else {
|
||||
} else{
|
||||
slider := widget.NewSlider(0, math.Max(1, float64(len(src.PreviewFrames)-1)))
|
||||
slider.Step = 1
|
||||
slider.OnChanged = func(val float64) {
|
||||
|
|
@ -8812,24 +8852,26 @@ 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
|
||||
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)
|
||||
frameFunc func(int) // Callback for frame number updates
|
||||
img *canvas.Image
|
||||
mu sync.Mutex
|
||||
videoCmd *exec.Cmd
|
||||
audioCmd *exec.Cmd
|
||||
frameN int
|
||||
duration float64 // Total duration in seconds
|
||||
}
|
||||
|
||||
var audioCtxGlobal struct {
|
||||
|
|
@ -8845,7 +8887,7 @@ func getAudioContext(sampleRate, channels, bytesPerSample int) (*oto.Context, er
|
|||
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 {
|
||||
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))))
|
||||
}
|
||||
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,
|
||||
width: w,
|
||||
height: h,
|
||||
targetW: targetW,
|
||||
targetH: targetH,
|
||||
volume: 100,
|
||||
duration: duration,
|
||||
stop: make(chan struct{}),
|
||||
done: make(chan struct{}),
|
||||
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) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
|
@ -9044,6 +9152,9 @@ func (p *playSession) runVideo(offset float64) {
|
|||
if p.prog != nil {
|
||||
p.prog(p.current)
|
||||
}
|
||||
if p.frameFunc != nil {
|
||||
p.frameFunc(p.frameN)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user