very nice

This commit is contained in:
2026-04-03 07:18:42 -03:00
parent 84dc1405dc
commit 51c0bdd2da
8 changed files with 178 additions and 17 deletions

View File

@@ -176,6 +176,24 @@ class FramesPanel(Gtk.Box):
self._selected = None
self.emit("selection-changed")
def highlight_nearest(self, timestamp: float) -> None:
"""Scroll to and briefly highlight the frame closest to *timestamp*."""
if not self._order:
return
best_id = None
best_dist = float("inf")
for fid, ts in self._timestamps.items():
d = abs(ts - timestamp)
if d < best_dist:
best_dist = d
best_id = fid
if not best_id or best_id not in self._widgets:
return
widget = self._widgets[best_id]
widget.add_css_class("frame-highlight")
self._scroll_to(widget)
GLib.timeout_add(400, lambda: widget.remove_css_class("frame-highlight") or False)
def clear(self):
"""Remove all items and reset state."""
self._selected = None

View File

@@ -217,12 +217,8 @@ class MonitorWidget(Gtk.Box):
self._stack.set_visible_child_name("review")
return
elif current == "live":
# Transitioning from live: load MKV at cursor position atomically
pos = s.cursor
if self._review_player and self._recording_path:
self._review_player.load_at(self._recording_path, pos, pause=s.paused)
if not s.paused:
self._review_player.play()
# Transitioning from live to scrub: just switch stack.
# Don't auto-load the growing MKV — user picks a segment via scrub bar.
self._stack.set_visible_child_name("review")
else:
# Already in review (non-scrub): seek if cursor moved

View File

@@ -9,9 +9,12 @@ Drag within a block to scrub frame-by-frame at mouse speed.
import logging
from pathlib import Path
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import Gtk, Gdk, GLib, GObject, Pango
gi.require_version("GdkPixbuf", "2.0")
from gi.repository import Gtk, Gdk, GLib, GObject, GdkPixbuf
import cairo
@@ -49,6 +52,7 @@ class ScrubBar(Gtk.DrawingArea):
self._proxy_states = {} # segment_index → "generating" | "ready"
self._scene_markers = [] # global timestamps
self._scrubbing = False
self._frame_thumbs = [] # list of {timestamp, surface} — cairo surfaces
self.set_draw_func(self._draw)
@@ -77,6 +81,12 @@ class ScrubBar(Gtk.DrawingArea):
self._total_duration = sum(s["duration"] for s in manifest)
self.queue_draw()
def set_duration(self, duration: float) -> None:
"""Update total duration (from Timeline, overrides manifest sum if larger)."""
if duration > self._total_duration:
self._total_duration = duration
self.queue_draw()
def set_cursor(self, global_time: float) -> None:
"""Update the cursor position (from Timeline)."""
self._cursor = global_time
@@ -97,6 +107,66 @@ class ScrubBar(Gtk.DrawingArea):
self._proxy_states[segment_index] = state
self.queue_draw()
def set_frames(self, frames: list[dict]) -> None:
"""Set frame thumbnails. Each dict: {timestamp, path}.
Loads thumbnails scaled to fit the bar height and caches as cairo surfaces.
"""
self._frame_thumbs = []
thumb_h = BAR_HEIGHT - 4 # 2px margin top/bottom
thumb_w = int(thumb_h * 16 / 9) # assume 16:9 aspect
for f in frames:
path = f.get("path")
ts = f.get("timestamp", 0)
if not path or not Path(path).exists():
continue
try:
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
str(path), thumb_w, thumb_h, True
)
surface = self._pixbuf_to_surface(pixbuf)
self._frame_thumbs.append({
"timestamp": ts,
"surface": surface,
"width": pixbuf.get_width(),
"height": pixbuf.get_height(),
})
except Exception as e:
log.debug("Thumb load failed for %s: %s", path, e)
self.queue_draw()
def add_frame(self, timestamp: float, path: str) -> None:
"""Add a single frame thumbnail incrementally."""
if not Path(path).exists():
return
thumb_h = BAR_HEIGHT - 4
thumb_w = int(thumb_h * 16 / 9)
try:
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(path, thumb_w, thumb_h, True)
surface = self._pixbuf_to_surface(pixbuf)
self._frame_thumbs.append({
"timestamp": timestamp,
"surface": surface,
"width": pixbuf.get_width(),
"height": pixbuf.get_height(),
})
self.queue_draw()
except Exception as e:
log.debug("Thumb load failed for %s: %s", path, e)
@staticmethod
def _pixbuf_to_surface(pixbuf):
"""Convert a GdkPixbuf to a cairo ImageSurface."""
w, h = pixbuf.get_width(), pixbuf.get_height()
has_alpha = pixbuf.get_has_alpha()
fmt = cairo.FORMAT_ARGB32 if has_alpha else cairo.FORMAT_RGB24
surface = cairo.ImageSurface(fmt, w, h)
cr = cairo.Context(surface)
Gdk.cairo_set_source_pixbuf(cr, pixbuf, 0, 0)
cr.paint()
surface.flush()
return surface
# -- Drawing --
def _draw(self, area, cr, width, height):
@@ -144,7 +214,31 @@ class ScrubBar(Gtk.DrawingArea):
cr.arc(x + w - 8, 8, 3, 0, 6.28)
cr.fill()
# Scene markers
# Frame thumbnails at their timestamp positions
for thumb in self._frame_thumbs:
tx = self._global_to_x(thumb["timestamp"], width)
tw, th = thumb["width"], thumb["height"]
# Center thumbnail on its timestamp, vertically centered in bar
x0 = tx - tw / 2
y0 = (height - th) / 2
cr.save()
# Dark outline for separation against any background
cr.set_source_rgba(0, 0, 0, 0.7)
cr.set_line_width(2)
cr.rectangle(x0 - 1, y0 - 1, tw + 2, th + 2)
cr.stroke()
# Thumbnail
cr.set_source_surface(thumb["surface"], x0, y0)
cr.rectangle(x0, y0, tw, th)
cr.fill()
# Bright inner border
cr.set_source_rgba(1, 1, 1, 0.5)
cr.set_line_width(1)
cr.rectangle(x0, y0, tw, th)
cr.stroke()
cr.restore()
# Scene markers (on top of thumbs)
cr.set_source_rgb(*MARKER_COLOR)
for ts in self._scene_markers:
mx = self._global_to_x(ts, width)

View File

@@ -210,6 +210,7 @@ class TimelineControls(Gtk.Box):
else:
self._live_btn.remove_css_class("suggested-action")
self._scrub_bar.set_duration(s.duration)
self._scrub_bar.set_cursor(s.cursor)
self._scrub_bar.set_scene_markers(s.scene_markers)
self._time_label.set_text(self._fmt_time(s.cursor))

View File

@@ -173,6 +173,24 @@ class TranscriptPanel(Gtk.Box):
self._selected.clear()
self.emit("selection-changed")
def highlight_nearest(self, timestamp: float) -> None:
"""Scroll to and briefly highlight the transcript segment closest to *timestamp*."""
if not self._order:
return
best_id = None
best_dist = float("inf")
for sid, ts in self._timestamps.items():
d = abs(ts - timestamp)
if d < best_dist:
best_dist = d
best_id = sid
if not best_id or best_id not in self._rows:
return
row = self._rows[best_id]
row.add_css_class("frame-highlight")
self._scroll_to_row(row)
GLib.timeout_add(400, lambda: row.remove_css_class("frame-highlight") or False)
def clear(self):
"""Remove all items and reset state."""
self._selected.clear()