diff --git a/main.go b/main.go index 53d8264..711904b 100644 --- a/main.go +++ b/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()