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
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user