forked from Leak_Technologies/VideoTools
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:
parent
1618558314
commit
3a5b1a1f1e
226
main.go
226
main.go
|
|
@ -617,6 +617,20 @@ func (s *appState) showPlayerView() {
|
||||||
// Keyframing mode toggle
|
// Keyframing mode toggle
|
||||||
keyframeModeItem := fyne.NewMenuItem("Frame-Accurate Mode", func() {
|
keyframeModeItem := fyne.NewMenuItem("Frame-Accurate Mode", func() {
|
||||||
s.keyframingMode = !s.keyframingMode
|
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()
|
refresh()
|
||||||
})
|
})
|
||||||
keyframeModeItem.Checked = s.keyframingMode
|
keyframeModeItem.Checked = s.keyframingMode
|
||||||
|
|
@ -734,11 +748,21 @@ func (s *appState) showPlayerView() {
|
||||||
slider.Step = 0.5
|
slider.Step = 0.5
|
||||||
var updatingProgress bool
|
var updatingProgress bool
|
||||||
|
|
||||||
|
// Frame counter (declared early for use in updateProgress)
|
||||||
|
frameCounter := widget.NewLabel("Frame: 0")
|
||||||
|
|
||||||
updateProgress := func(val float64) {
|
updateProgress := func(val float64) {
|
||||||
fyne.Do(func() {
|
fyne.Do(func() {
|
||||||
updatingProgress = true
|
updatingProgress = true
|
||||||
currentTime.SetText(formatClock(val))
|
currentTime.SetText(formatClock(val))
|
||||||
slider.SetValue(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
|
updatingProgress = false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -843,16 +867,164 @@ func (s *appState) showPlayerView() {
|
||||||
volContainer := container.NewHBox(volIcon, container.NewMax(volSlider))
|
volContainer := container.NewHBox(volIcon, container.NewMax(volSlider))
|
||||||
volContainer.Resize(fyne.NewSize(150, 32))
|
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
|
// Center the playback controls
|
||||||
playbackControls := container.NewHBox(prevBtn, playBtn, nextBtn)
|
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
|
// Create control row with centered playback controls
|
||||||
controlRow := container.NewBorder(
|
var controlRow *fyne.Container
|
||||||
nil, nil,
|
if s.keyframingMode {
|
||||||
volContainer, // Volume on left
|
// Show frame counter in keyframing mode
|
||||||
container.NewHBox(playlistToggleBtn), // Playlist toggle on right
|
controlRow = container.NewBorder(
|
||||||
container.NewCenter(playbackControls), // Playback controls centered
|
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(
|
playerArea = container.NewBorder(
|
||||||
nil,
|
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() {
|
func (p *playSession) Stop() {
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
defer p.mu.Unlock()
|
defer p.mu.Unlock()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user