Add frame-accurate navigation controls (Commit 5)

Implements comprehensive frame navigation UI and keyboard shortcuts:

Frame Navigation UI:
- Frame step buttons (←/→ icons) for single-frame stepping
- Keyframe jump buttons (⏮/⏭ icons) for I-frame navigation
- Frame counter display showing current frame number
- All navigation controls only visible in keyframing mode
- Automatic keyframe index loading when enabling frame mode

Keyboard Shortcuts:
- Left/Right arrows: step one frame backward/forward
- Up/Down arrows: jump to previous/next keyframe
- Space: play/pause toggle
- All shortcuts only active in keyframing mode

Frame Counter:
- Displays current frame number during playback
- Updates in real-time as video plays
- Shows "(KF)" suffix when on a keyframe
- Positioned next to playlist toggle button

Technical Details:
- StepFrame() method pauses playback and seeks precisely
- GetCurrentPosition() added to playSession for position queries
- Keyframe navigation uses binary search from detector.go
- All UI updates properly synchronized via Fyne.Do()
- Frame counter declared early for use in updateProgress callback

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Stu 2025-12-05 15:28:41 -05:00
parent 1618558314
commit 3a5b1a1f1e

226
main.go
View File

@ -617,6 +617,20 @@ func (s *appState) showPlayerView() {
// Keyframing mode toggle
keyframeModeItem := fyne.NewMenuItem("Frame-Accurate Mode", func() {
s.keyframingMode = !s.keyframingMode
// Load keyframe index if enabling and not already loaded
if s.keyframingMode && s.source != nil && s.source.KeyframeIndex == nil {
go func() {
idx, err := keyframe.DetectKeyframesWithCache(s.source.Path)
if err != nil {
logging.Debug(logging.CatFFMPEG, "failed to load keyframe index: %v", err)
} else {
s.source.KeyframeIndex = idx
logging.Debug(logging.CatFFMPEG, "loaded %d keyframes for %s", idx.NumKeyframes(), s.source.Path)
}
}()
}
refresh()
})
keyframeModeItem.Checked = s.keyframingMode
@ -734,11 +748,21 @@ func (s *appState) showPlayerView() {
slider.Step = 0.5
var updatingProgress bool
// Frame counter (declared early for use in updateProgress)
frameCounter := widget.NewLabel("Frame: 0")
updateProgress := func(val float64) {
fyne.Do(func() {
updatingProgress = true
currentTime.SetText(formatClock(val))
slider.SetValue(val)
// Update frame counter if in keyframing mode
if s.keyframingMode && src.FrameRate > 0 {
frameNum := int(val * src.FrameRate)
frameCounter.SetText(fmt.Sprintf("Frame: %d", frameNum))
}
updatingProgress = false
})
}
@ -843,16 +867,164 @@ func (s *appState) showPlayerView() {
volContainer := container.NewHBox(volIcon, container.NewMax(volSlider))
volContainer.Resize(fyne.NewSize(150, 32))
// Frame navigation controls (only visible in keyframing mode)
var frameStepPrevBtn, frameStepNextBtn *widget.Button
var keyframePrevBtn, keyframeNextBtn *widget.Button
frameStepPrevBtn = ui.NewIconButton(ui.IconFramePrevious, "Previous Frame", func() {
if !ensureSession() {
return
}
s.playSess.StepFrame(-1)
// Update frame counter
if s.source != nil && s.source.FrameRate > 0 {
pos := s.playSess.GetCurrentPosition()
frameNum := int(pos * s.source.FrameRate)
frameCounter.SetText(fmt.Sprintf("Frame: %d", frameNum))
}
})
frameStepNextBtn = ui.NewIconButton(ui.IconFrameNext, "Next Frame", func() {
if !ensureSession() {
return
}
s.playSess.StepFrame(1)
// Update frame counter
if s.source != nil && s.source.FrameRate > 0 {
pos := s.playSess.GetCurrentPosition()
frameNum := int(pos * s.source.FrameRate)
frameCounter.SetText(fmt.Sprintf("Frame: %d", frameNum))
}
})
keyframePrevBtn = ui.NewIconButton(ui.IconKeyframePrevious, "Previous Keyframe", func() {
if !ensureSession() {
return
}
if s.source == nil || s.source.KeyframeIndex == nil {
return
}
currentPos := s.playSess.GetCurrentPosition()
kf := s.source.KeyframeIndex.FindNearestKeyframe(currentPos-0.001, "before")
if kf != nil {
s.playSess.Seek(kf.Timestamp)
frameCounter.SetText(fmt.Sprintf("Frame: %d (KF)", kf.FrameNum))
}
})
keyframeNextBtn = ui.NewIconButton(ui.IconKeyframeNext, "Next Keyframe", func() {
if !ensureSession() {
return
}
if s.source == nil || s.source.KeyframeIndex == nil {
return
}
currentPos := s.playSess.GetCurrentPosition()
kf := s.source.KeyframeIndex.FindNearestKeyframe(currentPos+0.001, "after")
if kf != nil {
s.playSess.Seek(kf.Timestamp)
frameCounter.SetText(fmt.Sprintf("Frame: %d (KF)", kf.FrameNum))
}
})
// Center the playback controls
playbackControls := container.NewHBox(prevBtn, playBtn, nextBtn)
// Add frame navigation if keyframing mode is enabled
if s.keyframingMode {
playbackControls = container.NewHBox(
prevBtn,
keyframePrevBtn,
frameStepPrevBtn,
playBtn,
frameStepNextBtn,
keyframeNextBtn,
nextBtn,
)
}
// Create control row with centered playback controls
controlRow := container.NewBorder(
nil, nil,
volContainer, // Volume on left
container.NewHBox(playlistToggleBtn), // Playlist toggle on right
container.NewCenter(playbackControls), // Playback controls centered
)
var controlRow *fyne.Container
if s.keyframingMode {
// Show frame counter in keyframing mode
controlRow = container.NewBorder(
nil, nil,
volContainer, // Volume on left
container.NewHBox(frameCounter, playlistToggleBtn), // Frame counter and playlist toggle on right
container.NewCenter(playbackControls), // Playback controls centered
)
} else {
controlRow = container.NewBorder(
nil, nil,
volContainer, // Volume on left
container.NewHBox(playlistToggleBtn), // Playlist toggle on right
container.NewCenter(playbackControls), // Playback controls centered
)
}
// Set up keyboard shortcuts for frame navigation
if s.keyframingMode {
canvas := s.window.Canvas()
canvas.SetOnTypedKey(func(key *fyne.KeyEvent) {
if !ensureSession() {
return
}
switch key.Name {
case fyne.KeyLeft:
// Left arrow: previous frame
s.playSess.StepFrame(-1)
if s.source != nil && s.source.FrameRate > 0 {
pos := s.playSess.GetCurrentPosition()
frameNum := int(pos * s.source.FrameRate)
frameCounter.SetText(fmt.Sprintf("Frame: %d", frameNum))
}
case fyne.KeyRight:
// Right arrow: next frame
s.playSess.StepFrame(1)
if s.source != nil && s.source.FrameRate > 0 {
pos := s.playSess.GetCurrentPosition()
frameNum := int(pos * s.source.FrameRate)
frameCounter.SetText(fmt.Sprintf("Frame: %d", frameNum))
}
case fyne.KeyUp:
// Up arrow: previous keyframe
if s.source != nil && s.source.KeyframeIndex != nil {
currentPos := s.playSess.GetCurrentPosition()
kf := s.source.KeyframeIndex.FindNearestKeyframe(currentPos-0.001, "before")
if kf != nil {
s.playSess.Seek(kf.Timestamp)
frameCounter.SetText(fmt.Sprintf("Frame: %d (KF)", kf.FrameNum))
}
}
case fyne.KeyDown:
// Down arrow: next keyframe
if s.source != nil && s.source.KeyframeIndex != nil {
currentPos := s.playSess.GetCurrentPosition()
kf := s.source.KeyframeIndex.FindNearestKeyframe(currentPos+0.001, "after")
if kf != nil {
s.playSess.Seek(kf.Timestamp)
frameCounter.SetText(fmt.Sprintf("Frame: %d (KF)", kf.FrameNum))
}
}
case fyne.KeySpace:
// Space: play/pause
if s.playerPaused {
s.playSess.Play()
s.playerPaused = false
playBtn.SetText(ui.IconPause)
} else {
s.playSess.Pause()
s.playerPaused = true
playBtn.SetText(ui.IconPlayArrow)
}
}
})
}
playerArea = container.NewBorder(
nil,
@ -3500,6 +3672,48 @@ func (p *playSession) SetVolume(v float64) {
}
}
// StepFrame steps forward or backward by one frame
// direction: 1 for forward, -1 for backward
func (p *playSession) StepFrame(direction int) {
p.mu.Lock()
defer p.mu.Unlock()
// Ensure we're paused for frame stepping
if !p.paused {
p.paused = true
}
// Calculate new position (1 frame = 1/fps seconds)
frameDuration := 1.0 / p.fps
newPos := p.current + (float64(direction) * frameDuration)
if newPos < 0 {
newPos = 0
}
p.current = newPos
p.stopLocked()
p.startLocked(p.current)
p.paused = true
// Ensure paused state sticks
time.AfterFunc(30*time.Millisecond, func() {
p.mu.Lock()
defer p.mu.Unlock()
p.paused = true
})
if p.prog != nil {
p.prog(p.current)
}
}
// GetCurrentPosition returns the current playback position
func (p *playSession) GetCurrentPosition() float64 {
p.mu.Lock()
defer p.mu.Unlock()
return p.current
}
func (p *playSession) Stop() {
p.mu.Lock()
defer p.mu.Unlock()