From 7d744f1182796fb91324141846dddc0e3fe1af2e Mon Sep 17 00:00:00 2001 From: niksedk Date: Mon, 15 Jun 2026 17:33:06 +0200 Subject: [PATCH] Honor snap settings in waveform: playhead snapping + shot-change toggle Two related "snap to frames"/"snap to shot changes" fixes for the waveform/video playhead: - Snap to frames now also applies when clicking the waveform and when stepping one frame back/forward. A waveform single-click rounds the playhead to the nearest frame, and next/previous-frame stepping lands on the project frame grid (so stepping stays frame-aligned even from an off-grid position). Both are gated on Waveform.SnapToFrames and a valid frame rate; otherwise behavior (incl. mpv native frame step) is unchanged. Extracted SetVideoPositionSeconds from MoveVideoPositionMs so stepping can seek to a sub-millisecond-accurate frame boundary. - "Snap to shot changes" setting (Waveform.SnapToShotChanges) was ignored: the AudioVisualizer property was hardcoded to true and never assigned from settings, so shot-change snapping was always on. It now reads the setting directly (like SnapToFrames), so the checkbox takes effect live. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../AudioVisualizerControl/AudioVisualizer.cs | 4 +- src/ui/Features/Main/MainViewModel.cs | 96 +++++++++++++++++-- 2 files changed, 90 insertions(+), 10 deletions(-) diff --git a/src/ui/Controls/AudioVisualizerControl/AudioVisualizer.cs b/src/ui/Controls/AudioVisualizerControl/AudioVisualizer.cs index a0d14435a4..1dae347b25 100644 --- a/src/ui/Controls/AudioVisualizerControl/AudioVisualizer.cs +++ b/src/ui/Controls/AudioVisualizerControl/AudioVisualizer.cs @@ -204,7 +204,9 @@ public Color WaveformParagraphRightColor public double ShotChangeSnapSeconds { get; set; } = 0.05; public WaveformDrawStyle WaveformDrawStyle { get; set; } = WaveformDrawStyle.Classic; - public bool SnapToShotChanges { get; set; } = true; + // Reads the user setting directly (like SnapToFrames) so the "Snap to shot changes" + // checkbox actually takes effect, and does so live without re-wiring at every call site. + public bool SnapToShotChanges => Se.Settings.Waveform.SnapToShotChanges; public bool FocusOnMouseOver { get; set; } = true; public int WaveformHeightPercentage { get; set; } = 50; public Color WaveformFancyHighColor { get; set; } = Colors.Orange; diff --git a/src/ui/Features/Main/MainViewModel.cs b/src/ui/Features/Main/MainViewModel.cs index ab7d3c7fa1..0fedb98782 100644 --- a/src/ui/Features/Main/MainViewModel.cs +++ b/src/ui/Features/Main/MainViewModel.cs @@ -8952,6 +8952,28 @@ private static int FramesToMilliseconds(int frames) return (int)Math.Round(frames * 1000.0 / frameRate, MidpointRounding.AwayFromZero); } + /// + /// Snaps a video position (in seconds) to the nearest frame boundary when "Snap to frames" + /// is enabled, so the playhead lands exactly on a frame. Returns the value unchanged when the + /// setting is off or no usable frame rate is available. + /// + private static double SnapSecondsToFrame(double seconds) + { + if (!Se.Settings.Waveform.SnapToFrames) + { + return seconds; + } + + var frameRate = Se.Settings.General.CurrentFrameRate; + if (frameRate < 1) + { + return seconds; + } + + var frameDur = 1.0 / frameRate; + return Math.Round(seconds / frameDur, MidpointRounding.AwayFromZero) * frameDur; + } + [RelayCommand] private void MergeSelectedLines() { @@ -12338,6 +12360,11 @@ private void VideoToggleBrightness() [RelayCommand] private void VideoOneFrameBack() { + if (TryStepVideoFrameSnapped(forward: false)) + { + return; + } + var vp = GetVideoPlayerControl(); if (vp != null && vp.VideoPlayer is LibMpvDynamicPlayer mpv) { @@ -12358,6 +12385,11 @@ private void VideoOneFrameBack() [RelayCommand] private void VideoOneFrameForward() { + if (TryStepVideoFrameSnapped(forward: true)) + { + return; + } + var vp = GetVideoPlayerControl(); if (vp != null && vp.VideoPlayer is LibMpvDynamicPlayer mpv) { @@ -12375,6 +12407,40 @@ private void VideoOneFrameForward() MoveVideoPositionMs(40); } + /// + /// When "Snap to frames" is on, steps the playhead to the previous/next frame boundary on the + /// project frame grid (so stepping stays frame-aligned even from an off-grid position). Returns + /// false when snapping is off or no usable frame rate is available, leaving the default + /// (player-native) frame step in charge. + /// + private bool TryStepVideoFrameSnapped(bool forward) + { + if (!Se.Settings.Waveform.SnapToFrames) + { + return false; + } + + var fps = Se.Settings.General.CurrentFrameRate; + if (fps < 1) + { + return false; + } + + var vp = GetVideoPlayerControl(); + if (vp == null || string.IsNullOrEmpty(_videoFileName) || AudioVisualizer == null) + { + return false; + } + + var frameDur = 1.0 / fps; + var currentFrame = vp.Position / frameDur; + var targetFrame = forward + ? Math.Floor(currentFrame + 1e-6) + 1 + : Math.Ceiling(currentFrame - 1e-6) - 1; + SetVideoPositionSeconds(targetFrame * frameDur); + return true; + } + [RelayCommand] private void VideoMoveCustom1Back() { @@ -12757,7 +12823,17 @@ private void MoveVideoPositionMs(int ms) return; } - var newPosition = vp.Position + (ms / 1000.0); + SetVideoPositionSeconds(vp.Position + (ms / 1000.0)); + } + + private void SetVideoPositionSeconds(double newPosition) + { + var vp = GetVideoPlayerControl(); + if (vp == null || string.IsNullOrEmpty(_videoFileName) || AudioVisualizer == null) + { + return; + } + if (newPosition < 0) { newPosition = 0; @@ -19980,11 +20056,13 @@ internal void AudioVisualizerOnPrimarySingleClicked(object sender, ParagraphNull if (Enum.TryParse(Se.Settings.Waveform.SingleClickAction, out var action)) { + // Land the playhead on a frame boundary when "Snap to frames" is on. + var seconds = SnapSecondsToFrame(e.Seconds); switch (action) { case WaveformSingleClickActionType.SetVideoPositionAndPauseAndSelectSubtitle: vp.VideoPlayer.Pause(); - vp.Position = e.Seconds; + vp.Position = seconds; if (e.Paragraph != null) { var p1 = Subtitles.FirstOrDefault(p => p.Id == e.Paragraph.Id); @@ -19997,37 +20075,37 @@ internal void AudioVisualizerOnPrimarySingleClicked(object sender, ParagraphNull break; case WaveformSingleClickActionType.SetVideopositionAndPauseAndSelectSubtitleAndCenter: vp.VideoPlayer.Pause(); - vp.Position = e.Seconds; + vp.Position = seconds; if (e.Paragraph != null) { var p2 = Subtitles.FirstOrDefault(p => p.Id == e.Paragraph.Id); if (p2 != null) { SelectAndScrollToSubtitle(p2); - AudioVisualizer.CenterOnPosition(e.Seconds); + AudioVisualizer.CenterOnPosition(seconds); } } break; case WaveformSingleClickActionType.SetVideoPositionAndPause: vp.VideoPlayer.Pause(); - vp.Position = e.Seconds; + vp.Position = seconds; break; case WaveformSingleClickActionType.SetVideopositionAndPauseAndCenter: vp.VideoPlayer.Pause(); - vp.Position = e.Seconds; + vp.Position = seconds; if (e.Paragraph != null) { - AudioVisualizer.CenterOnPosition(e.Seconds); + AudioVisualizer.CenterOnPosition(seconds); } break; case WaveformSingleClickActionType.SetVideoposition: - vp.Position = e.Seconds; + vp.Position = seconds; break; } - PinPlayheadTo(e.Seconds); + PinPlayheadTo(seconds); _updateAudioVisualizer = true; } }