feat: autoclip, play/pause improvements, number key exports, focus fix

- Autoclip (A): adjusts clip count to fit current pause position
- Pause no longer resets playback position — stays where paused
- Play resumes from pause point instead of restarting
- Spread/clips changes update loop end without restarting playback
- Number keys 1-9 export to subprofiles
- Click-away clears focus from spinboxes so hotkeys work again
- Lock mode: double-click marker jumps cursor to end of clip span

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-16 13:19:21 +02:00
parent 9e5bd4a8ec
commit 7abf0b4d4c
+60 -9
View File
@@ -1030,6 +1030,7 @@ class TimelineWidget(QWidget):
if abs(x - frac * w) <= 10:
t = frac * self._duration
self.marker_clicked.emit(t, output_path)
if not self._locked:
self._seek(x)
return
self.marker_deselected.emit()
@@ -1336,12 +1337,17 @@ class MpvWidget(QWidget):
except SystemError:
pass
def play_loop(self, a: float, b: float):
def play_loop(self, a: float, b: float, resume: bool = False):
self._player["ab-loop-a"] = a
self._player["ab-loop-b"] = min(b, self._player.duration or b)
if not resume:
self._player.seek(a, "absolute")
self._player.pause = False
def update_loop_end(self, b: float):
"""Adjust the B point of the current loop without seeking."""
self._player["ab-loop-b"] = min(b, self._player.duration or b)
def stop_loop(self):
self._player["ab-loop-a"] = "no"
self._player["ab-loop-b"] = "no"
@@ -1797,12 +1803,20 @@ class PlaylistWidget(QListWidget):
class _KeyFilter(QObject):
"""Suppress global keyboard shortcuts when a text input widget has focus."""
"""Suppress global keyboard shortcuts when a text input widget has focus,
and release focus from input widgets on click-away."""
_INPUT_TYPES = (QSpinBox, QDoubleSpinBox, QLineEdit, QComboBox)
def eventFilter(self, obj, event):
from PyQt6.QtCore import QEvent
if event.type() == QEvent.Type.ShortcutOverride and isinstance(obj, QLineEdit):
event.accept()
return True
if event.type() == QEvent.Type.MouseButtonPress:
if not isinstance(obj, self._INPUT_TYPES):
focused = QApplication.focusWidget()
if isinstance(focused, self._INPUT_TYPES):
focused.clearFocus()
return super().eventFilter(obj, event)
@@ -2293,9 +2307,14 @@ class MainWindow(QMainWindow):
)
QShortcut(QKeySequence("K"), self, context=ctx).activated.connect(self._on_pause)
QShortcut(QKeySequence("E"), self, context=ctx).activated.connect(self._on_export)
for i in range(1, 10):
QShortcut(QKeySequence(str(i)), self, context=ctx).activated.connect(
lambda _, idx=i - 1: self._export_subprofile(idx)
)
QShortcut(QKeySequence("M"), self, context=ctx).activated.connect(self._jump_to_next_marker)
QShortcut(QKeySequence("N"), self, context=ctx).activated.connect(self._playlist.advance)
QShortcut(QKeySequence("G"), self, context=ctx).activated.connect(self._btn_lock.toggle)
QShortcut(QKeySequence("A"), self, context=ctx).activated.connect(self._autoclip)
for key in ("?", "F1"):
QShortcut(QKeySequence(key), self, context=ctx).activated.connect(self._show_shortcuts)
@@ -2320,12 +2339,14 @@ class MainWindow(QMainWindow):
"<tr><td><b>Space / P</b></td><td>Play / Pause</td></tr>"
"<tr><td><b>K</b></td><td>Pause and snap to cursor</td></tr>"
"<tr><td><b>E</b></td><td>Export</td></tr>"
"<tr><td><b>19</b></td><td>Export to subprofile 19</td></tr>"
"<tr><td><b>M</b></td><td>Jump to next marker</td></tr>"
"<tr><td><b>N</b></td><td>Next file in playlist</td></tr>"
"<tr><td><b>G</b></td><td>Toggle cursor lock</td></tr>"
"<tr><td><b>A</b></td><td>Autoclip — fit clip count to pause position</td></tr>"
"<tr><td><b>? / F1</b></td><td>This help</td></tr>"
"<tr><td colspan='2'><hr></td></tr>"
"<tr><td><b>Double-click marker</b></td><td>Enter overwrite mode</td></tr>"
"<tr><td><b>Double-click marker</b></td><td>Enter overwrite mode (locked: jump to end of clip span)</td></tr>"
"<tr><td><b>Right-click marker</b></td><td>Delete clip group</td></tr>"
"<tr><td><b>Click video / crop bar</b></td><td>Reposition portrait crop</td></tr>"
"</table>"
@@ -2430,6 +2451,10 @@ class MainWindow(QMainWindow):
self._settings.setValue("subprofiles", self._subprofiles)
self._rebuild_subprofile_buttons()
def _export_subprofile(self, idx: int):
if idx < len(self._subprofiles):
self._on_export(folder_suffix=self._subprofiles[idx])
def _remove_subprofile(self, name: str):
if name in self._subprofiles:
self._subprofiles.remove(name)
@@ -2585,6 +2610,20 @@ class MainWindow(QMainWindow):
self._show_status(f"Deleted keyframe @ {format_time(time)}", 3000)
def _on_marker_clicked(self, start_time: float, output_path: str) -> None:
# In lock mode, move cursor to the end of this marker's span.
if self._btn_lock.isChecked():
meta = self._db.get_by_output_path(output_path)
clip_count = meta["clip_count"] or self._spn_clips.value() if meta else self._spn_clips.value()
spread = meta["spread"] or self._spn_spread.value() if meta else self._spn_spread.value()
next_pos = start_time + 8.0 + (clip_count - 1) * spread
self._cursor = next_pos
self._timeline.set_cursor(next_pos)
self._mpv.seek(next_pos)
self._lbl_time.setText(f"{format_time(next_pos)} / {format_time(self._mpv.get_duration())}")
self._update_next_label()
self._preview_timer.start()
self._show_status(f"Cursor → end of {os.path.basename(os.path.dirname(output_path))}", 3000)
return
self._overwrite_path = output_path
self._overwrite_group = self._db.get_group(output_path)
n = len(self._overwrite_group)
@@ -2900,26 +2939,38 @@ class MainWindow(QMainWindow):
if self._mpv.is_playing():
self._on_pause()
else:
self._on_play()
self._on_play(resume=True)
@property
def _clip_span(self) -> float:
"""Total time covered by the overlapping clips."""
return 8.0 + (self._spn_clips.value() - 1) * self._spn_spread.value()
def _on_play(self):
def _on_play(self, resume: bool = False):
if not self._file_path:
return
self._mpv.play_loop(self._cursor, self._cursor + self._clip_span)
self._mpv.play_loop(self._cursor, self._cursor + self._clip_span, resume=resume)
def _update_play_loop(self):
if self._file_path and self._mpv.is_playing():
self._mpv.play_loop(self._cursor, self._cursor + self._clip_span)
self._mpv.update_loop_end(self._cursor + self._clip_span)
def _on_pause(self):
self._mpv.stop_loop()
self._mpv.seek(self._cursor)
self._timeline.set_play_position(None)
def _autoclip(self):
"""Set clip count to fit the current pause position."""
if not self._file_path:
return
play_t = self._timeline._play_pos
if play_t is None or play_t <= self._cursor:
return
elapsed = play_t - self._cursor
spread = self._spn_spread.value()
# n clips span 8 + (n-1)*spread seconds
n = int((elapsed - 8.0) / spread) + 1
n = max(1, n)
self._spn_clips.setValue(n)
def _step_cursor(self, delta: float) -> None:
if not self._file_path: