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