From 9312f095b2068a735271c9dcda8845e90cc3ffa1 Mon Sep 17 00:00:00 2001 From: talksik Date: Tue, 7 Apr 2026 16:48:40 -0700 Subject: [PATCH] feat: seek media particles and speed playback Closes #79 Closes #80 --- .../particles/media-particle-view.tsx | 29 ++++++++++++++++--- js/src/features/particles/stream-view.tsx | 26 +++++++++++++++-- 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/js/src/features/particles/media-particle-view.tsx b/js/src/features/particles/media-particle-view.tsx index af3d7ed..990b6b1 100644 --- a/js/src/features/particles/media-particle-view.tsx +++ b/js/src/features/particles/media-particle-view.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react"; import type { Particle } from "@/api/types"; import type { ParticlePath } from "@/lib/particle-path"; import { useDownloadUrl } from "@/hooks/use-download-url"; @@ -12,6 +12,12 @@ import { ParticleAttachments } from "@/features/particles/particle-attachments"; type MediaParticle = Extract; +export interface MediaParticleHandle { + /** Seek by delta. Returns true if seeked, false if at boundary (should navigate). */ + seek: (deltaSec: number) => boolean; + setPlaybackRate: (rate: number) => void; +} + interface MediaParticleViewProps { particle: MediaParticle; streamPath: ParticlePath; @@ -20,19 +26,34 @@ interface MediaParticleViewProps { onProgress?: (ratio: number) => void; } -export function MediaParticleView({ +export const MediaParticleView = forwardRef(function MediaParticleView({ particle, streamPath, paused, onEnded, onProgress, -}: MediaParticleViewProps) { +}, ref) { const { data: url, error } = useDownloadUrl(particle.properties.object_id); const { attachments } = useParticleAttachments(streamPath, particle.id); const videoRef = useRef(null); const audioRef = useRef(null); const isAudio = particle.properties.mime_type?.startsWith("audio/"); + + useImperativeHandle(ref, () => ({ + seek(deltaSec: number) { + const el = isAudio ? audioRef.current : videoRef.current; + if (!el) return false; + if (deltaSec < 0 && el.currentTime < Math.abs(deltaSec)) return false; + if (deltaSec > 0 && el.duration - el.currentTime < deltaSec) return false; + el.currentTime = Math.max(0, Math.min(el.duration, el.currentTime + deltaSec)); + return true; + }, + setPlaybackRate(rate: number) { + const el = isAudio ? audioRef.current : videoRef.current; + if (el) el.playbackRate = rate; + }, + }), [isAudio]); const [currentTime, setCurrentTime] = useState(0); const transcript = particle.properties.transcript; @@ -140,4 +161,4 @@ export function MediaParticleView({ {attachmentOverlay} ); -} +}); diff --git a/js/src/features/particles/stream-view.tsx b/js/src/features/particles/stream-view.tsx index cf938e4..d867691 100644 --- a/js/src/features/particles/stream-view.tsx +++ b/js/src/features/particles/stream-view.tsx @@ -6,7 +6,7 @@ import type { Particle } from "@/api/types"; import { parseParticlePath, particlePath, toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path"; import { ComposeOverlay } from "@/features/compose/compose-overlay"; import { PlaybackPageIndicator } from "@/features/particles/playback-page-indicator"; -import { MediaParticleView } from "@/features/particles/media-particle-view"; +import { MediaParticleView, type MediaParticleHandle } from "@/features/particles/media-particle-view"; import { TextParticleView } from "@/features/particles/text-particle-view"; import { FallbackParticleView } from "@/features/particles/fallback-particle-view"; import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar"; @@ -140,8 +140,11 @@ export function StreamView({ path, streamParticle }: StreamViewProps) { authedUser?.id, ); + const mediaRef = useRef(null); + const [composeActive, setComposeActive] = useState(false); const [progress, setProgress] = useState(0); + const [fastPlayback, setFastPlayback] = useState(false); // Show/hide chrome on mouse activity (YouTube-style) const [showControls, setShowControls] = useState(true); @@ -196,17 +199,23 @@ export function StreamView({ path, streamParticle }: StreamViewProps) { case "ArrowRight": case "ArrowDown": e.preventDefault(); - next(); + if (!mediaRef.current?.seek(10)) next(); break; case "ArrowLeft": case "ArrowUp": e.preventDefault(); - prev(); + if (!mediaRef.current?.seek(-10)) prev(); break; case " ": e.preventDefault(); if (!e.repeat) pause(); break; + case "Shift": + if (!e.repeat) { + mediaRef.current?.setPlaybackRate(1.5); + setFastPlayback(true); + } + break; case "Escape": e.preventDefault(); navigate(`/${networkId}`); @@ -230,6 +239,10 @@ export function StreamView({ path, streamParticle }: StreamViewProps) { e.preventDefault(); resume(); } + if (e.key === "Shift") { + mediaRef.current?.setPlaybackRate(1); + setFastPlayback(false); + } }; window.addEventListener("keydown", handleKeyDown); @@ -287,6 +300,7 @@ export function StreamView({ path, streamParticle }: StreamViewProps) { case "media": return ( {renderParticle(currentParticle)} + + {fastPlayback && ( +
+ 1.5x +
+ )} )}