Skip to content

Commit 290f200

Browse files
committed
feat(native): keyboard shortcuts + waveform strip
Space=play/pause, I/O=set in/out, Left/Right=seek 1s, Home=start (ignored while URL field focused). Waveform strip (ffmpeg showwavespic) under the timeline with playhead + dimmed out-of-trim regions; click to seek.
1 parent 1114aa9 commit 290f200

1 file changed

Lines changed: 96 additions & 1 deletion

File tree

native_app.py

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from pathlib import Path
2020

2121
from PySide6.QtCore import Qt, QThread, Signal, QUrl, QRectF, QPointF, QSizeF, QTimer
22-
from PySide6.QtGui import QColor, QPen, QBrush, QPainter, QAction
22+
from PySide6.QtGui import QColor, QPen, QBrush, QPainter, QAction, QPixmap
2323
from PySide6.QtWidgets import (
2424
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QGridLayout,
2525
QLineEdit, QPushButton, QLabel, QFileDialog, QSlider, QComboBox, QCheckBox,
@@ -423,6 +423,69 @@ def run(self):
423423
self.failed.emit(str(e))
424424

425425

426+
class WaveformWorker(QThread):
427+
"""Render the clip's audio waveform to a PNG via ffmpeg showwavespic."""
428+
done = Signal(str)
429+
430+
def __init__(self, src, out):
431+
super().__init__()
432+
self.src, self.out = src, out
433+
434+
def run(self):
435+
try:
436+
run_subprocess([FFMPEG, "-y", "-i", str(self.src), "-filter_complex",
437+
f"showwavespic=s=1200x80:colors={ACCENT}",
438+
"-frames:v", "1", str(self.out)], timeout=60)
439+
if Path(self.out).exists():
440+
self.done.emit(str(self.out))
441+
except Exception:
442+
pass
443+
444+
445+
class WaveformBar(QWidget):
446+
"""Waveform strip with a playhead and dimmed out-of-trim regions; click to seek."""
447+
seek_to = Signal(float)
448+
449+
def __init__(self):
450+
super().__init__()
451+
self.setFixedHeight(64)
452+
self.setObjectName("wave")
453+
self._pix = None
454+
self._frac = 0.0
455+
self._in = 0.0
456+
self._out = 1.0
457+
458+
def set_image(self, path):
459+
self._pix = QPixmap(path)
460+
self.update()
461+
462+
def set_position(self, frac):
463+
self._frac = max(0.0, min(1.0, frac))
464+
self.update()
465+
466+
def set_region(self, in_frac, out_frac):
467+
self._in, self._out = in_frac, out_frac
468+
self.update()
469+
470+
def paintEvent(self, event):
471+
p = QPainter(self)
472+
w, h = self.width(), self.height()
473+
if self._pix and not self._pix.isNull():
474+
p.drawPixmap(self.rect(), self._pix)
475+
p.setPen(Qt.NoPen)
476+
p.setBrush(QColor(0, 0, 0, 150))
477+
p.drawRect(QRectF(0, 0, self._in * w, h))
478+
p.drawRect(QRectF(self._out * w, 0, w - self._out * w, h))
479+
pen = QPen(QColor(ACCENT)); pen.setWidth(2)
480+
p.setPen(pen)
481+
x = self._frac * w
482+
p.drawLine(QPointF(x, 0), QPointF(x, h))
483+
484+
def mousePressEvent(self, event):
485+
if self.width():
486+
self.seek_to.emit(max(0.0, min(1.0, event.position().x() / self.width())))
487+
488+
426489
# ──────────────────────────────────────────────────────────────────────
427490
# Welcome overlay (first run)
428491
# ──────────────────────────────────────────────────────────────────────
@@ -491,6 +554,9 @@ def __init__(self):
491554
self.time_lbl = QLabel("0:00 / 0:00"); self.time_lbl.setObjectName("mono")
492555
tr.addWidget(self.play_btn); tr.addWidget(self.seek, 1); tr.addWidget(self.time_lbl)
493556
left.addLayout(tr)
557+
self.wave = WaveformBar()
558+
self.wave.seek_to.connect(lambda f: self.player.setPosition(int(f * self.duration * 1000)))
559+
left.addWidget(self.wave)
494560
root.addLayout(left, 1)
495561

496562
# right: controls panel
@@ -657,6 +723,11 @@ def _loaded(self, path):
657723
self.set_ratio("Original")
658724
self._update_trim_lbl()
659725
self.status.setText(f"{Path(path).name} · {w}×{h} · native preview (no proxy)")
726+
# render the waveform strip in the background
727+
self.wave.set_image("")
728+
self.wf = WaveformWorker(path, Path(path).parent / "wave.png")
729+
self.wf.done.connect(self.wave.set_image)
730+
self.wf.start()
660731

661732
# --- playback ---
662733
def toggle_play(self):
@@ -672,8 +743,29 @@ def on_seek(self, v):
672743
def on_position(self, ms):
673744
if self.duration and not self.seek.isSliderDown():
674745
self.seek.setValue(int(ms / 1000 / self.duration * 1000))
746+
if self.duration:
747+
self.wave.set_position(ms / 1000 / self.duration)
675748
self.time_lbl.setText(f"{self._fmt(ms / 1000)} / {self._fmt(self.duration)}")
676749

750+
def keyPressEvent(self, event):
751+
if self.url_input.hasFocus():
752+
return super().keyPressEvent(event)
753+
k = event.key()
754+
if k == Qt.Key_Space:
755+
self.toggle_play()
756+
elif k == Qt.Key_I:
757+
self.set_in()
758+
elif k == Qt.Key_O:
759+
self.set_out()
760+
elif k == Qt.Key_Left:
761+
self.player.setPosition(max(0, self.player.position() - 1000))
762+
elif k == Qt.Key_Right:
763+
self.player.setPosition(self.player.position() + 1000)
764+
elif k == Qt.Key_Home:
765+
self.player.setPosition(0)
766+
else:
767+
return super().keyPressEvent(event)
768+
677769
def on_player_duration(self, ms):
678770
if ms > 0 and self.duration <= 0:
679771
self.duration = ms / 1000
@@ -708,6 +800,8 @@ def set_out(self):
708800
def _update_trim_lbl(self):
709801
self.trim_lbl.setText(
710802
f"In {self._fmt(self.trim_in)} · Out {self._fmt(self.trim_out)} · {self._fmt(self.trim_out - self.trim_in)}")
803+
if self.duration:
804+
self.wave.set_region(self.trim_in / self.duration, self.trim_out / self.duration)
711805

712806
# --- export ---
713807
def on_export(self):
@@ -776,6 +870,7 @@ def _fmt(secs):
776870
#welcomeTitle {{ font-size: 27px; font-weight: 800; color: #ffffff; }}
777871
#welcomeSub {{ color: #c7ccd6; font-size: 14px; }}
778872
#welcomeSteps {{ color: #9aa0ab; font-size: 14px; }}
873+
#wave {{ background: #0f1014; border-radius: 6px; }}
779874
"""
780875

781876

0 commit comments

Comments
 (0)