Instant-replay game clipping for Linux. Forked from https://github.com/eklonofficial/Vice
0

Configure Feed

Select the types of activity you want to include in your feed.

Make theme accents apply consistently across UI surfaces

Andrew Marin (Mar 9, 2026, 11:25 AM -0500) a912ee65 80903adc

+342 -60
+1
README.md
··· 138 138 encoder = "auto" # auto | h264_nvenc | libx264 | hevc_nvenc | h264_vaapi 139 139 backend = "auto" # auto | gsr | wf-recorder | ffmpeg 140 140 capture_audio = true 141 + apply_watermark = false # enable only if you want watermark text on exports 141 142 142 143 [hotkeys] 143 144 clip = "KEY_F9"
+9
vice/audio.py
··· 6 6 CLIP_SOUND — quick two-note ascending ping (clip saved) 7 7 SESSION_START — three ascending tones (session recording started) 8 8 SESSION_END — three descending tones (session recording stopped) 9 + HIGHLIGHT_SOUND — soft single chime (session highlight marked) 9 10 10 11 All playback is non-blocking (asyncio task). 11 12 No external audio files needed — pure Python + stdlib wave module. ··· 85 86 CLIP_SOUND = _make_wav((880, 0.07), (1109, 0.11)) 86 87 SESSION_START = _make_wav((523, 0.09), (659, 0.09), (784, 0.13)) 87 88 SESSION_END = _make_wav((784, 0.09), (659, 0.09), (523, 0.14)) 89 + HIGHLIGHT_SOUND = _make_wav((988, 0.06)) 88 90 89 91 90 92 # ── Playback ─────────────────────────────────────────────────────────────────── ··· 94 96 _TMP_CLIP = _TMP_DIR / "snd_clip.wav" 95 97 _TMP_START = _TMP_DIR / "snd_session_start.wav" 96 98 _TMP_END = _TMP_DIR / "snd_session_end.wav" 99 + _TMP_HL = _TMP_DIR / "snd_highlight.wav" 97 100 98 101 # Map sound bytes → stable temp path (written once, reused) 99 102 _SOUND_MAP: dict[int, Path] = { 100 103 id(CLIP_SOUND): _TMP_CLIP, 101 104 id(SESSION_START): _TMP_START, 102 105 id(SESSION_END): _TMP_END, 106 + id(HIGHLIGHT_SOUND): _TMP_HL, 103 107 } 104 108 105 109 ··· 166 170 def play_session_end() -> None: 167 171 """Fire-and-forget: play the session-ended notification sound.""" 168 172 asyncio.create_task(_play(SESSION_END)) 173 + 174 + 175 + def play_highlight() -> None: 176 + """Fire-and-forget: play the session-highlight marker sound.""" 177 + asyncio.create_task(_play(HIGHLIGHT_SOUND))
+3
vice/config.py
··· 38 38 backend: str = "auto" 39 39 # Include desktop audio in clips. 40 40 capture_audio: bool = True 41 + # Burn the "Clipped with Vice" watermark into exported clips. 42 + # Disabled by default to avoid encoding spikes while gaming. 43 + apply_watermark: bool = False 41 44 # PulseAudio/PipeWire sink name. "default" works for most setups. 42 45 audio_sink: str = "default" 43 46
+8
vice/hotkey.py
··· 67 67 """ 68 68 self._double_bindings.setdefault(key_name, []).append(callback) 69 69 70 + def clear_bindings(self) -> None: 71 + """Remove all hotkey bindings and cancel pending single-tap timers.""" 72 + self._bindings.clear() 73 + self._double_bindings.clear() 74 + for t in self._pending.values(): 75 + t.cancel() 76 + self._pending.clear() 77 + 70 78 async def start(self) -> None: 71 79 """Discover keyboards and spawn a listener task per device.""" 72 80 keyboards = _find_keyboards()
+28 -5
vice/main.py
··· 67 67 self.share = ShareServer(self.cfg) 68 68 self.share.trigger_clip_cb = self._handle_clip_hotkey 69 69 self.share.get_status_cb = self._get_status 70 + self.share.apply_config_cb = self._apply_live_config 70 71 await self.share.start() 71 72 72 73 # Recorder callback — fires for both normal clips and session clips ··· 84 85 "type": "status", "recording": True, 85 86 "backend": self.recorder.name, 86 87 "session_active": self._session_active, 88 + "clip_key": self.cfg.hotkeys.clip, 87 89 }) 88 90 ) 89 91 90 92 self.recorder.on_clip_saved(_on_clip_saved) 91 93 92 94 # Hotkeys 95 + self._bind_hotkeys() 93 96 clip_key = self.cfg.hotkeys.clip 94 - if clip_key: 95 - # Single tap → save clip (or add session highlight) 96 - self.hotkeys.on(clip_key, self._handle_clip_hotkey) 97 - # Double tap → toggle session recording 98 - self.hotkeys.on_double(clip_key, self._handle_session_toggle) 99 97 100 98 PID_FILE.write_text(str(os.getpid())) 101 99 ··· 130 128 await stop_event.wait() 131 129 await self._shutdown(server) 132 130 131 + def _bind_hotkeys(self) -> None: 132 + """(Re)bind runtime hotkeys from current config.""" 133 + self.hotkeys.clear_bindings() 134 + clip_key = self.cfg.hotkeys.clip 135 + if clip_key: 136 + # Single tap → save clip (or add session highlight) 137 + self.hotkeys.on(clip_key, self._handle_clip_hotkey) 138 + # Double tap → toggle session recording 139 + self.hotkeys.on_double(clip_key, self._handle_session_toggle) 140 + 141 + async def _apply_live_config(self) -> None: 142 + """Apply config changes that can be updated without daemon restart.""" 143 + self._bind_hotkeys() 144 + if self.share: 145 + await self.share.broadcast({ 146 + "type": "status", 147 + "recording": True, 148 + "backend": self.recorder.name, 149 + "session_active": self._session_active, 150 + "clip_key": self.cfg.hotkeys.clip, 151 + }) 152 + 133 153 def _get_status(self) -> dict: 134 154 return { 135 155 "recording": True, 136 156 "backend": self.recorder.name, 137 157 "clips": self._clip_count, 138 158 "session_active": self._session_active, 159 + "clip_key": self.cfg.hotkeys.clip, 139 160 } 140 161 141 162 async def _shutdown(self, server) -> None: ··· 161 182 entry = {"time": round(elapsed, 3), "label": label, "color": color} 162 183 self._session_highlights.append(entry) 163 184 click.echo(f"[Vice] Session highlight at {elapsed:.1f}s", err=True) 185 + audio.play_highlight() 164 186 if self.share: 165 187 asyncio.create_task( 166 188 self.share.broadcast({ ··· 257 279 "output": self.cfg.output.directory, 258 280 "share_url": self.share.base_url() if self.share else None, 259 281 "session_active": self._session_active, 282 + "clip_key": self.cfg.hotkeys.clip, 260 283 }).encode() + b"\n") 261 284 elif cmd == "url": 262 285 url = self.share.base_url() if self.share else ""
+68 -15
vice/recorder.py
··· 20 20 import asyncio 21 21 import logging 22 22 import os 23 + import re 23 24 import shutil 24 25 import signal 25 26 import subprocess ··· 61 62 62 63 def _has(tool: str) -> bool: 63 64 return shutil.which(tool) is not None 65 + 66 + 67 + def _desktop_audio_source(preferred: str) -> str: 68 + """ 69 + Resolve a Pulse/PipeWire source name that captures desktop output audio. 70 + 71 + When users leave audio_sink as "default", ffmpeg/wf-recorder may record 72 + the current default *input* source (microphone) on some setups. We prefer 73 + the default sink's monitor source so clips contain system/game audio. 74 + """ 75 + if preferred and preferred != "default": 76 + return preferred 77 + 78 + if not _has("pactl"): 79 + return preferred 80 + 81 + try: 82 + sink = subprocess.check_output( 83 + ["pactl", "get-default-sink"], text=True, stderr=subprocess.DEVNULL 84 + ).strip() 85 + if sink: 86 + return f"{sink}.monitor" 87 + except Exception: 88 + pass 89 + 90 + try: 91 + out = subprocess.check_output( 92 + ["pactl", "list", "short", "sources"], text=True, stderr=subprocess.DEVNULL 93 + ) 94 + for line in out.splitlines(): 95 + cols = re.split(r"\s+", line.strip()) 96 + if len(cols) > 1 and cols[1].endswith(".monitor"): 97 + return cols[1] 98 + except Exception: 99 + pass 100 + 101 + return preferred 64 102 65 103 66 104 # ────────────────────────────────────────────────────────────────────────────── ··· 229 267 log.error("Session file not found after stop: %s", path) 230 268 return None 231 269 232 - await _apply_watermark(path) 270 + if self.cfg.recording.apply_watermark: 271 + await _apply_watermark(path) 233 272 log.info("Session clip saved: %s", path) 234 273 self._emit(path) 235 274 return path ··· 240 279 encoder = choose_encoder(rc.encoder) 241 280 242 281 if _is_wayland(): 243 - # wf-recorder is the most reliable direct-to-file tool on Wayland 282 + # Prefer gpu-screen-recorder on Wayland (especially smoother on NVIDIA). 283 + if _has("gpu-screen-recorder"): 284 + return self._gsr_session_cmd(out_path, rc) 285 + 286 + # Fallback: wf-recorder direct-to-file on Wayland. 244 287 if _has("wf-recorder"): 245 288 cmd = ["wf-recorder", "--force-yuv", "-f", str(out_path)] 246 289 if rc.capture_audio: 247 - cmd += ["--audio"] 290 + cmd += [f"--audio={_desktop_audio_source(rc.audio_sink)}"] 248 291 if encoder in ("h264_nvenc", "hevc_nvenc"): 249 292 cmd += ["-c", encoder] 250 293 elif encoder == "h264_vaapi": ··· 252 295 else: 253 296 cmd += ["-c", "libx264"] 254 297 return cmd 255 - # Fall through to ffmpeg (Wayland capture via pipewire portal or kmsgrab) 256 - # For simplicity, use x11grab if DISPLAY is also set (XWayland) 298 + 299 + # Last resort on XWayland sessions. 257 300 if os.environ.get("DISPLAY") and _has("ffmpeg"): 258 301 return self._ffmpeg_session_cmd(out_path, encoder, rc) 259 302 return None ··· 264 307 return None 265 308 266 309 @staticmethod 310 + def _gsr_session_cmd(out_path: Path, rc) -> list[str]: 311 + cmd = ["gpu-screen-recorder"] 312 + cmd += ["-w", "screen" if _is_wayland() else os.environ.get("DISPLAY", ":0")] 313 + cmd += ["-f", str(rc.fps)] 314 + cmd += ["-c", "mp4"] 315 + if rc.capture_audio: 316 + cmd += ["-a", "default_output"] 317 + cmd += ["-o", str(out_path)] 318 + return cmd 319 + 320 + @staticmethod 267 321 def _ffmpeg_session_cmd(out_path: Path, encoder: str, rc) -> list[str]: 268 322 display = os.environ.get("DISPLAY", ":0") 269 323 cmd = ["ffmpeg", "-hide_banner", "-loglevel", "warning"] ··· 284 338 cmd += ["-s", res] 285 339 cmd += ["-i", display] 286 340 if rc.capture_audio: 287 - cmd += ["-f", "pulse", "-i", rc.audio_sink] 341 + cmd += ["-f", "pulse", "-i", _desktop_audio_source(rc.audio_sink)] 288 342 cmd += _encoder_flags(encoder, rc.crf) 289 343 if rc.capture_audio: 290 344 cmd += ["-c:a", "aac", "-b:a", "128k"] ··· 296 350 # Clip trimming helper (used by GSR backend) 297 351 # ────────────────────────────────────────────────────────────────────────────── 298 352 299 - import re as _re 300 - 301 - 302 353 def _next_clip_path(out_dir: Path) -> Path: 303 354 """Return the next available Vice_Clip_N.mp4 path in out_dir.""" 304 355 max_n = 0 305 356 for f in out_dir.glob("Vice_Clip_*.mp4"): 306 - m = _re.match(r"^Vice_Clip_(\d+)\.mp4$", f.name) 357 + m = re.match(r"^Vice_Clip_(\d+)\.mp4$", f.name) 307 358 if m: 308 359 n = int(m.group(1)) 309 360 if n > max_n: ··· 315 366 """Return the next available Vice_Session_N.mp4 path in out_dir.""" 316 367 max_n = 0 317 368 for f in out_dir.glob("Vice_Session_*.mp4"): 318 - m = _re.match(r"^Vice_Session_(\d+)\.mp4$", f.name) 369 + m = re.match(r"^Vice_Session_(\d+)\.mp4$", f.name) 319 370 if m: 320 371 n = int(m.group(1)) 321 372 if n > max_n: ··· 528 579 self._seen_files = {f.name for f in self._out_dir.glob("*.mp4")} 529 580 # GSR saves the entire buffer; trim to the requested clip duration. 530 581 trimmed = await _trim_to_last_n_seconds(newest, self.cfg.recording.clip_duration) 531 - await _apply_watermark(trimmed) 582 + if self.cfg.recording.apply_watermark: 583 + await _apply_watermark(trimmed) 532 584 log.info("Clip saved: %s", trimmed) 533 585 self._emit(trimmed) 534 586 return trimmed ··· 577 629 # wf-recorder geometry flag 578 630 pass # resolution is auto by default; geometry can be set with -g 579 631 if rc.capture_audio: 580 - cmd += ["--audio"] 632 + cmd += [f"--audio={_desktop_audio_source(rc.audio_sink)}"] 581 633 # Use ffmpeg codec flags via wf-recorder's -c option 582 634 codec = self._encoder 583 635 if codec in ("h264_nvenc", "hevc_nvenc"): ··· 600 652 cmd += ["-i", display] 601 653 602 654 if rc.capture_audio: 603 - cmd += ["-f", "pulse", "-i", rc.audio_sink] 655 + cmd += ["-f", "pulse", "-i", _desktop_audio_source(rc.audio_sink)] 604 656 605 657 enc_flags = _encoder_flags(self._encoder, rc.crf) 606 658 cmd += enc_flags ··· 774 826 log.error("ffmpeg timed out during clip extraction") 775 827 return None 776 828 777 - await _apply_watermark(out_path) 829 + if self.cfg.recording.apply_watermark: 830 + await _apply_watermark(out_path) 778 831 log.info("Clip saved: %s", out_path) 779 832 self._emit(out_path) 780 833 return out_path
+57 -12
vice/share.py
··· 56 56 (HIGHLIGHTS_DIR / f"{slug}.json").write_text(json.dumps(highlights)) 57 57 58 58 59 + def _thumb_path(path: Path) -> Path: 60 + """Return cache path unique to this clip file content/version.""" 61 + try: 62 + st = path.stat() 63 + key = f"{path.stem}_{st.st_size}_{st.st_mtime_ns}" 64 + except OSError: 65 + key = path.stem 66 + return THUMB_DIR / f"{key}.jpg" 67 + 68 + 69 + def _purge_slug_thumbs(slug: str) -> None: 70 + """Remove any cached thumbs for a slug (legacy + versioned variants).""" 71 + THUMB_DIR.mkdir(parents=True, exist_ok=True) 72 + for t in THUMB_DIR.glob(f"{slug}*.jpg"): 73 + t.unlink(missing_ok=True) 74 + 75 + 59 76 # ── helpers ────────────────────────────────────────────────────────────────── 60 77 61 78 def _local_ip() -> str: ··· 95 112 async def _make_thumb(path: Path) -> Path: 96 113 """Lazily generate a 640px-wide JPEG thumbnail stored in THUMB_DIR.""" 97 114 THUMB_DIR.mkdir(parents=True, exist_ok=True) 98 - thumb = THUMB_DIR / f"{path.stem}.jpg" 115 + thumb = _thumb_path(path) 99 116 if thumb.exists(): 100 117 return thumb 101 118 try: 119 + # Seek a little after the start so we avoid intro black frames. 120 + # Keep -ss after -i for accurate frame selection. 102 121 proc = await asyncio.create_subprocess_exec( 103 122 "ffmpeg", "-hide_banner", "-loglevel", "error", 104 - "-ss", "2", "-i", str(path), 105 - "-vframes", "1", "-vf", "scale=640:-2", "-q:v", "4", 123 + "-i", str(path), 124 + "-ss", "0.75", 125 + "-frames:v", "1", 126 + "-vf", "thumbnail,scale=640:-2", 127 + "-q:v", "4", 106 128 str(thumb), 107 129 stdout=asyncio.subprocess.DEVNULL, 108 130 stderr=asyncio.subprocess.DEVNULL, ··· 168 190 self.trigger_clip_cb: Optional[Callable[[], Coroutine]] = None 169 191 # Injected so /api/status can report live state 170 192 self.get_status_cb: Optional[Callable[[], dict]] = None 193 + # Injected so config changes can be applied without restart when possible. 194 + self.apply_config_cb: Optional[Callable[[], Coroutine]] = None 171 195 172 196 self._setup_routes() 173 197 ··· 279 303 return self._meta[slug] 280 304 281 305 def _clip_json(self, slug: str, path: Path, meta: dict) -> dict: 282 - base = self._tunnel_url or self._base_url 306 + public_base = self._tunnel_url or self._base_url 283 307 try: 284 308 st = path.stat() 285 309 size = st.st_size 310 + mtime_ns = st.st_mtime_ns 286 311 created_at = datetime.fromtimestamp(st.st_mtime).isoformat() 287 312 except OSError: 288 - size, created_at = 0, "" 313 + size, mtime_ns, created_at = 0, 0, "" 314 + 315 + thumb_rev = f"{size}-{mtime_ns}" 289 316 return { 290 317 "slug": slug, 291 318 "name": path.name, ··· 294 321 "duration": meta.get("duration", 0), 295 322 "width": meta.get("width", 0), 296 323 "height": meta.get("height", 0), 297 - "share_url": f"{base}/c/{slug}", 298 - "video_url": f"{base}/v/{slug}", 299 - "thumb_url": f"{base}/t/{slug}", 324 + # Keep share links public, but serve media via local relative URLs 325 + # so the app UI never fetches video through an external tunnel. 326 + "share_url": f"{public_base}/c/{slug}", 327 + "video_url": f"/v/{slug}", 328 + # Cache-bust by clip file identity to avoid stale thumbs when slugs are reused. 329 + "thumb_url": f"/t/{slug}?v={thumb_rev}", 300 330 } 301 331 302 332 # ── route handlers ──────────────────────────────────────────────────────── ··· 372 402 path = self._clips.pop(slug, None) 373 403 if path and path.exists(): 374 404 path.unlink() 375 - (THUMB_DIR / f"{slug}.jpg" ).unlink(missing_ok=True) 405 + _purge_slug_thumbs(slug) 376 406 (HIGHLIGHTS_DIR / f"{slug}.json").unlink(missing_ok=True) 377 407 self._meta.pop(slug, None) 378 408 await self.broadcast({"type": "clip_deleted", "slug": slug}) ··· 414 444 415 445 tmp.replace(path) 416 446 # Clear cached thumbnail and metadata so they regenerate on next access 417 - (THUMB_DIR / f"{slug}.jpg").unlink(missing_ok=True) 447 + _purge_slug_thumbs(slug) 418 448 self._meta.pop(slug, None) 419 449 asyncio.create_task(self._broadcast_clip(slug, path)) 420 450 return web.json_response({"ok": True, "slug": slug}) ··· 432 462 433 463 # Sanitise — no path separators; always .mp4 434 464 new_name = new_name.replace("/", "").replace("\\", "").replace("\0", "") 465 + if " " in new_name: 466 + return web.json_response({"ok": False, "error": "Clip name cannot contain spaces"}) 435 467 if not new_name.lower().endswith(".mp4"): 436 468 new_name += ".mp4" 437 469 ··· 445 477 # Update internal state 446 478 self._clips.pop(slug, None) 447 479 self._clips[new_slug] = new_path 448 - (THUMB_DIR / f"{slug}.jpg").unlink(missing_ok=True) 480 + _purge_slug_thumbs(slug) 449 481 self._meta.pop(slug, None) 450 482 451 483 # Rename highlights file if it exists ··· 501 533 h["label"] = (body["label"] or "Highlight").strip() or "Highlight" 502 534 if "color" in body: 503 535 h["color"] = body["color"] 536 + if "time" in body: 537 + try: 538 + h["time"] = round(max(0.0, float(body["time"])), 3) 539 + except (TypeError, ValueError): 540 + pass 541 + hl.sort(key=lambda x: float(x.get("time", 0))) 504 542 _save_highlights(slug, hl) 505 543 return web.json_response({"ok": True}) 506 544 return web.json_response({"ok": False, "error": "highlight not found"}) ··· 586 624 }), 587 625 ) 588 626 save_cfg(new_cfg) 589 - # Apply live (takes effect on next daemon restart for recording settings) 627 + # Apply live (some settings still require daemon restart, e.g. recorder backend). 590 628 for field in ("recording", "hotkeys", "output", "sharing"): 591 629 setattr(self.cfg, field, getattr(new_cfg, field)) 630 + 631 + if self.apply_config_cb: 632 + try: 633 + await self.apply_config_cb() 634 + except Exception as exc: 635 + log.warning("Live config apply failed: %s", exc) 636 + 592 637 return web.json_response({"ok": True}) 593 638 594 639 async def _api_status(self, _: web.Request) -> web.Response:
+168 -28
vice/ui/index.html
··· 22 22 --accent: #3b82f6; 23 23 --accent-hi: #60a5fa; 24 24 --accent-dim: rgba(59,130,246,0.13); 25 - --accent-glow: 0 0 28px rgba(59,130,246,0.20); 25 + --accent-glow: 0 0 28px rgba(var(--accent-rgb),0.20); 26 + --accent-rgb: 59,130,246; 26 27 --text: #e1e7f0; 27 28 --muted: #5a6b82; 28 29 --muted-hi: #8899b0; ··· 161 162 .tunnel-pill { 162 163 display: none; align-items: center; gap: 5px; 163 164 padding: 5px 9px; border-radius: 20px; 164 - background: rgba(59,130,246,0.12); 165 - border: 1px solid rgba(59,130,246,0.2); 165 + background: rgba(var(--accent-rgb),0.12); 166 + border: 1px solid rgba(var(--accent-rgb),0.2); 166 167 font-size: 11px; font-weight: 500; color: var(--accent); 167 168 cursor: pointer; transition: background .12s; 168 169 overflow: hidden; 169 170 } 170 171 .tunnel-pill.visible { display: flex; } 171 - .tunnel-pill:hover { background: rgba(59,130,246,0.2); } 172 + .tunnel-pill:hover { background: rgba(var(--accent-rgb),0.2); } 172 173 .tunnel-pill svg { width: 11px; height: 11px; flex-shrink: 0; } 173 174 .tunnel-pill-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 174 175 ··· 199 200 .btn svg { width: 13px; height: 13px; } 200 201 .btn-primary { 201 202 background: var(--accent); color: #fff; 202 - box-shadow: 0 2px 10px rgba(59,130,246,0.3); 203 + box-shadow: 0 2px 10px rgba(var(--accent-rgb),0.3); 203 204 } 204 205 .btn-primary:hover { 205 206 background: var(--accent-hi); 206 - box-shadow: 0 4px 14px rgba(59,130,246,0.4); 207 + box-shadow: 0 4px 14px rgba(var(--accent-rgb),0.4); 207 208 } 208 209 .btn-ghost { background: rgba(255,255,255,0.06); color: var(--text); border: 1px solid var(--border); } 209 210 .btn-ghost:hover { background: rgba(255,255,255,0.10); border-color: var(--border-hi); } ··· 242 243 transition: border-color .16s, transform .16s, box-shadow .16s; 243 244 } 244 245 .clip-card:hover { 245 - border-color: rgba(59,130,246,.28); 246 + border-color: rgba(var(--accent-rgb),.28); 246 247 transform: translateY(-2px); 247 248 box-shadow: 0 10px 30px rgba(0,0,0,.5), var(--accent-glow); 248 249 } ··· 301 302 color: var(--text); 302 303 font: inherit; font-size: 13px; font-weight: 600; 303 304 padding: 2px 7px; outline: none; 304 - box-shadow: 0 0 0 3px rgba(59,130,246,0.12); 305 + box-shadow: 0 0 0 3px rgba(var(--accent-rgb),0.12); 305 306 } 306 307 307 308 /* ── Settings ───────────────────────────────────────────────────── */ ··· 344 345 } 345 346 select:focus, input:focus { 346 347 border-color: var(--accent); 347 - box-shadow: 0 0 0 3px rgba(59,130,246,0.12); 348 + box-shadow: 0 0 0 3px rgba(var(--accent-rgb),0.12); 348 349 } 349 350 select { 350 351 cursor: pointer; padding-right: 28px; appearance: none; ··· 366 367 -webkit-appearance: none; 367 368 width: 14px; height: 14px; border-radius: 50%; 368 369 background: var(--accent); cursor: pointer; 369 - box-shadow: 0 0 8px rgba(59,130,246,.5); 370 + box-shadow: 0 0 8px rgba(var(--accent-rgb),.5); 370 371 transition: transform .1s; 371 372 } 372 373 input[type=range]::-webkit-slider-thumb:hover { transform: scale(1.2); } ··· 664 665 } 665 666 .tl-selection { 666 667 position: absolute; top: 0; bottom: 0; 667 - background: rgba(59,130,246,.14); 668 + background: rgba(var(--accent-rgb),.14); 668 669 border-top: 2px solid var(--accent); 669 670 border-bottom: 2px solid var(--accent); 670 671 pointer-events: none; ··· 676 677 background: var(--accent); 677 678 border-radius: 4px; 678 679 cursor: ew-resize; z-index: 3; 679 - box-shadow: 0 2px 12px rgba(59,130,246,.55); 680 + box-shadow: 0 2px 12px rgba(var(--accent-rgb),.55); 680 681 display: flex; align-items: center; justify-content: center; 681 682 transition: transform .1s; 682 683 } ··· 788 789 background: var(--accent); border: 2px solid #fff; 789 790 border-radius: 50%; transform: translate(-50%, -50%); 790 791 pointer-events: none; z-index: 3; 791 - box-shadow: 0 0 8px rgba(59,130,246,.5); 792 + box-shadow: 0 0 8px rgba(var(--accent-rgb),.5); 792 793 } 793 794 #viewer-hl-markers { position: absolute; inset: 0; pointer-events: none; overflow: visible; } 794 795 .hl-marker { ··· 802 803 transition: transform .12s; 803 804 } 804 805 .hl-marker:hover { transform: translate(-50%,-50%) scale(1.45); } 806 + .hl-marker.dragging { transform: translate(-50%,-50%) scale(1.35); box-shadow: 0 0 0 2px rgba(255,255,255,.15); } 805 807 806 808 /* Highlight list */ 807 809 .viewer-hl-list { display: flex; flex-direction: column; gap: 3px; max-height: 136px; overflow-y: auto; } ··· 869 871 width: 3px; background: #f59e0b; 870 872 transform: translateX(-50%); pointer-events: none; 871 873 opacity: 0.8; z-index: 2; 874 + } 875 + @media (prefers-reduced-motion: reduce) { 876 + * { animation: none !important; transition: none !important; } 872 877 } 873 878 </style> 874 879 </head> ··· 1263 1268 </div> 1264 1269 1265 1270 <div class="viewer-video-wrap"> 1266 - <video id="viewer-video" controls></video> 1271 + <video id="viewer-video" preload="auto" playsinline controls></video> 1267 1272 <button class="viewer-arrow viewer-arrow-prev" id="viewer-prev" onclick="viewerPrev()" title="Previous clip (←)"> 1268 1273 <svg data-lucide="chevron-left"></svg> 1269 1274 </button> ··· 1336 1341 // Theme system 1337 1342 // ═══════════════════════════════════════════════════════════════════ 1338 1343 const THEMES = { 1339 - blue: { '--accent': '#3b82f6', '--accent-hi': '#60a5fa', '--accent-dim': 'rgba(59,130,246,0.13)', '--accent-glow': '0 0 28px rgba(59,130,246,0.20)' }, 1340 - purple: { '--accent': '#8b5cf6', '--accent-hi': '#a78bfa', '--accent-dim': 'rgba(139,92,246,0.13)', '--accent-glow': '0 0 28px rgba(139,92,246,0.20)' }, 1341 - green: { '--accent': '#10b981', '--accent-hi': '#34d399', '--accent-dim': 'rgba(16,185,129,0.13)', '--accent-glow': '0 0 28px rgba(16,185,129,0.20)' }, 1342 - red: { '--accent': '#ef4444', '--accent-hi': '#f87171', '--accent-dim': 'rgba(239,68,68,0.13)', '--accent-glow': '0 0 28px rgba(239,68,68,0.20)' }, 1343 - orange: { '--accent': '#f97316', '--accent-hi': '#fb923c', '--accent-dim': 'rgba(249,115,22,0.13)', '--accent-glow': '0 0 28px rgba(249,115,22,0.20)' }, 1344 + blue: { '--accent': '#3b82f6', '--accent-hi': '#60a5fa', '--accent-rgb': '59,130,246', '--accent-dim': 'rgba(59,130,246,0.13)', '--accent-glow': '0 0 28px rgba(59,130,246,0.20)' }, 1345 + purple: { '--accent': '#8b5cf6', '--accent-hi': '#a78bfa', '--accent-rgb': '139,92,246', '--accent-dim': 'rgba(139,92,246,0.13)', '--accent-glow': '0 0 28px rgba(139,92,246,0.20)' }, 1346 + green: { '--accent': '#10b981', '--accent-hi': '#34d399', '--accent-rgb': '16,185,129', '--accent-dim': 'rgba(16,185,129,0.13)', '--accent-glow': '0 0 28px rgba(16,185,129,0.20)' }, 1347 + red: { '--accent': '#ef4444', '--accent-hi': '#f87171', '--accent-rgb': '239,68,68', '--accent-dim': 'rgba(239,68,68,0.13)', '--accent-glow': '0 0 28px rgba(239,68,68,0.20)' }, 1348 + orange: { '--accent': '#f97316', '--accent-hi': '#fb923c', '--accent-rgb': '249,115,22', '--accent-dim': 'rgba(249,115,22,0.13)', '--accent-glow': '0 0 28px rgba(249,115,22,0.20)' }, 1344 1349 }; 1345 1350 1346 1351 function setTheme(name) { ··· 1598 1603 } 1599 1604 } 1600 1605 1606 + 1607 + let activePreviewVideo = null; 1608 + 1609 + function stopActivePreview(resetTime = true) { 1610 + if (!activePreviewVideo) return; 1611 + try { 1612 + activePreviewVideo.pause(); 1613 + if (resetTime) activePreviewVideo.currentTime = 0; 1614 + } catch (_) {} 1615 + activePreviewVideo = null; 1616 + } 1617 + 1618 + function startPreview(slug) { 1619 + const card = document.getElementById('card-' + slug); 1620 + if (!card) return; 1621 + const vid = card.querySelector('video.preview-video'); 1622 + if (!vid) return; 1623 + 1624 + if (activePreviewVideo && activePreviewVideo !== vid) { 1625 + stopActivePreview(true); 1626 + } 1627 + 1628 + activePreviewVideo = vid; 1629 + const maybe = vid.play(); 1630 + if (maybe && typeof maybe.catch === 'function') maybe.catch(() => {}); 1631 + } 1632 + 1633 + function stopPreview(slug) { 1634 + const card = document.getElementById('card-' + slug); 1635 + const vid = card?.querySelector('video.preview-video'); 1636 + if (!vid) return; 1637 + try { 1638 + vid.pause(); 1639 + vid.currentTime = 0; 1640 + } catch (_) {} 1641 + if (activePreviewVideo === vid) activePreviewVideo = null; 1642 + } 1643 + 1601 1644 function renderClips() { 1602 1645 const grid = document.getElementById('clips-grid'); 1603 1646 const empty = document.getElementById('clips-empty'); ··· 1607 1650 sub.textContent = n === 0 ? 'No clips saved yet' : `${n} clip${n !== 1 ? 's' : ''}`; 1608 1651 empty.style.display = n === 0 ? 'block' : 'none'; 1609 1652 1653 + stopActivePreview(true); 1610 1654 grid.innerHTML = clips.map(c => cardHTML(c)).join(''); 1611 1655 lucide.createIcons(); 1612 1656 } ··· 1622 1666 1623 1667 return ` 1624 1668 <div class="clip-card" id="card-${escAttr(c.slug)}"> 1625 - <div class="thumb-wrap" onclick="openViewer('${escAttr(c.slug)}')" style="cursor:pointer"> 1669 + <div class="thumb-wrap" onclick="openViewer('${escAttr(c.slug)}')" onmouseenter="startPreview('${escAttr(c.slug)}')" onmouseleave="stopPreview('${escAttr(c.slug)}')" style="cursor:pointer"> 1626 1670 ${c.thumb_url 1627 1671 ? `<img src="${c.thumb_url}" loading="lazy" alt=""> 1628 - <video src="${c.video_url}" muted loop preload="none" 1629 - onmouseenter="this.play()" onmouseleave="this.pause();this.currentTime=0"></video>` 1672 + <video class="preview-video" src="${c.video_url}" muted loop playsinline preload="none"></video>` 1630 1673 : `<div class="thumb-placeholder"><svg data-lucide="film" width="24" height="24"></svg></div>`} 1631 1674 ${durStr ? `<span class="clip-dur-badge">${durStr}</span>` : ''} 1632 1675 ${isNew ? `<span class="clip-new-badge">NEW</span>` : ''} ··· 1981 2024 let viewerSlug = null; 1982 2025 let viewerIdx = -1; 1983 2026 let viewerHighlights = []; 2027 + let draggingHighlight = null; 2028 + 2029 + function seekViewerTo(seconds) { 2030 + const vid = document.getElementById('viewer-video'); 2031 + if (!vid) return; 2032 + const target = Math.max(0, Number(seconds) || 0); 2033 + try { 2034 + if (typeof vid.fastSeek === 'function') vid.fastSeek(target); 2035 + else vid.currentTime = target; 2036 + } catch (_) { 2037 + vid.currentTime = target; 2038 + } 2039 + } 1984 2040 1985 2041 function openViewer(slug) { 2042 + stopActivePreview(true); 1986 2043 const idx = clips.findIndex(c => c.slug === slug); 1987 2044 if (idx < 0) return; 1988 2045 viewerSlug = slug; ··· 2002 2059 clips.length > 1 ? `${idx + 1} / ${clips.length}` : ''; 2003 2060 2004 2061 const vid = document.getElementById('viewer-video'); 2005 - vid.src = c.video_url; 2062 + if (vid.src !== c.video_url) { 2063 + vid.pause(); 2064 + vid.src = c.video_url; 2065 + vid.load(); 2066 + } 2006 2067 document.getElementById('viewer-progress').style.width = '0%'; 2007 2068 document.getElementById('viewer-playhead').style.left = '0%'; 2008 2069 document.getElementById('viewer-prev').disabled = idx === 0; ··· 2014 2075 } 2015 2076 2016 2077 function closeViewer() { 2078 + stopActivePreview(true); 2017 2079 document.getElementById('viewer-modal').classList.remove('open'); 2018 2080 const vid = document.getElementById('viewer-video'); 2019 2081 vid.pause(); vid.src = ''; ··· 2031 2093 const vid = document.getElementById('viewer-video'); 2032 2094 if (!vid.duration) return; 2033 2095 const rect = document.getElementById('viewer-timeline').getBoundingClientRect(); 2034 - vid.currentTime = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) * vid.duration; 2096 + seekViewerTo(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) * vid.duration); 2035 2097 } 2036 2098 2037 2099 (function() { ··· 2092 2154 m.className = 'hl-marker'; 2093 2155 m.style.left = pct + '%'; 2094 2156 m.style.background = color; 2095 - m.title = `${hl.label} — ${fmtSec(hl.time, true)}`; 2096 - m.onclick = ev => { ev.stopPropagation(); vid.currentTime = hl.time; }; 2157 + m.title = `${hl.label} — ${fmtSec(hl.time, true)} (drag to move)`; 2158 + m.onpointerdown = ev => beginHighlightDrag(ev, hl, m); 2159 + m.onclick = ev => { if (!draggingHighlight) { ev.stopPropagation(); seekViewerTo(hl.time); } }; 2097 2160 markersEl.appendChild(m); 2098 2161 }); 2099 2162 ··· 2124 2187 const timeEl = document.createElement('span'); 2125 2188 timeEl.className = 'hl-time'; 2126 2189 timeEl.textContent = fmtSec(hl.time, true); 2127 - timeEl.onclick = ev => { ev.stopPropagation(); vid.currentTime = hl.time; }; 2190 + timeEl.onclick = ev => { ev.stopPropagation(); seekViewerTo(hl.time); }; 2128 2191 2129 2192 const labelWrap = document.createElement('span'); 2130 2193 labelWrap.className = 'hl-label-wrap'; ··· 2144 2207 item.appendChild(timeEl); 2145 2208 item.appendChild(labelWrap); 2146 2209 item.appendChild(delBtn); 2147 - item.onclick = () => { vid.currentTime = hl.time; }; 2210 + item.onclick = () => { seekViewerTo(hl.time); }; 2148 2211 listEl.appendChild(item); 2149 2212 }); 2150 2213 lucide.createIcons(); 2151 2214 } 2152 2215 2216 + function dragTimeFromPointer(ev) { 2217 + const vid = document.getElementById('viewer-video'); 2218 + const tl = document.getElementById('viewer-timeline'); 2219 + if (!vid || !tl || !vid.duration) return null; 2220 + const rect = tl.getBoundingClientRect(); 2221 + const x = Math.max(rect.left, Math.min(rect.right, ev.clientX)); 2222 + const ratio = Math.max(0, Math.min(1, (x - rect.left) / rect.width)); 2223 + return ratio * vid.duration; 2224 + } 2225 + 2226 + function beginHighlightDrag(ev, hl, markerEl) { 2227 + if (ev.button !== undefined && ev.button !== 0) return; 2228 + ev.preventDefault(); 2229 + ev.stopPropagation(); 2230 + const t = dragTimeFromPointer(ev); 2231 + draggingHighlight = { id: hl.id, markerEl, startTime: hl.time, time: t ?? hl.time }; 2232 + markerEl.classList.add('dragging'); 2233 + 2234 + const onMove = moveEv => { 2235 + if (!draggingHighlight) return; 2236 + const next = dragTimeFromPointer(moveEv); 2237 + if (next == null) return; 2238 + draggingHighlight.time = next; 2239 + const item = viewerHighlights.find(h => h.id === draggingHighlight.id); 2240 + if (item) { 2241 + item.time = Number(next.toFixed(3)); 2242 + viewerHighlights.sort((a, b) => a.time - b.time); 2243 + renderViewerHighlights(); 2244 + } 2245 + }; 2246 + 2247 + const onUp = async upEv => { 2248 + window.removeEventListener('pointermove', onMove, true); 2249 + window.removeEventListener('pointerup', onUp, true); 2250 + window.removeEventListener('pointercancel', onUp, true); 2251 + 2252 + const cur = draggingHighlight; 2253 + draggingHighlight = null; 2254 + if (cur?.markerEl) cur.markerEl.classList.remove('dragging'); 2255 + 2256 + if (!cur) return; 2257 + const finalTime = dragTimeFromPointer(upEv); 2258 + const applied = Number((finalTime ?? cur.time).toFixed(3)); 2259 + const item = viewerHighlights.find(h => h.id === cur.id); 2260 + if (item) item.time = applied; 2261 + viewerHighlights.sort((a, b) => a.time - b.time); 2262 + renderViewerHighlights(); 2263 + 2264 + if (!viewerSlug) return; 2265 + try { 2266 + await fetch(`/api/clips/${viewerSlug}/highlights/${cur.id}`, { 2267 + method: 'PATCH', 2268 + headers: { 'Content-Type': 'application/json' }, 2269 + body: JSON.stringify({ time: applied }), 2270 + }); 2271 + toast(`Highlight moved to ${fmtSec(applied, true)}`, 'ok'); 2272 + } catch (_) { 2273 + const old = viewerHighlights.find(h => h.id === cur.id); 2274 + if (old) old.time = cur.startTime; 2275 + viewerHighlights.sort((a, b) => a.time - b.time); 2276 + renderViewerHighlights(); 2277 + toast('Failed to move highlight', 'err'); 2278 + } 2279 + }; 2280 + 2281 + window.addEventListener('pointermove', onMove, true); 2282 + window.addEventListener('pointerup', onUp, true); 2283 + window.addEventListener('pointercancel', onUp, true); 2284 + } 2285 + 2153 2286 function showColorPicker(hlId, currentColor, anchorEl) { 2154 2287 // Remove any existing picker 2155 2288 document.getElementById('hl-color-picker-popup')?.remove(); ··· 2316 2449 const newName = input.value.trim(); 2317 2450 if (!newName || newName === current) { 2318 2451 input.replaceWith(nameEl); 2452 + return; 2453 + } 2454 + if (newName.includes(' ')) { 2455 + toast('Clip name cannot contain spaces', 'err'); 2456 + submitted = false; 2457 + input.focus(); 2458 + input.select(); 2319 2459 return; 2320 2460 } 2321 2461 try {