feat: seek media particles and speed playback

Closes #79
Closes #80
This commit is contained in:
talksik
2026-04-07 16:48:40 -07:00
parent 695adddaff
commit 9312f095b2
2 changed files with 48 additions and 7 deletions
@@ -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<Particle, { type: "media" }>;
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<MediaParticleHandle, MediaParticleViewProps>(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<HTMLVideoElement>(null);
const audioRef = useRef<HTMLAudioElement>(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}
</div>
);
}
});
+23 -3
View File
@@ -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<MediaParticleHandle>(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 (
<MediaParticleView
ref={mediaRef}
key={particle.id}
particle={particle}
streamPath={path}
@@ -333,6 +347,12 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
onClick={handlePlaybackClick}
>
{renderParticle(currentParticle)}
{fastPlayback && (
<div className="pointer-events-none absolute right-4 top-14 z-20 rounded-full bg-black/50 px-2.5 py-1 text-xs font-medium text-white backdrop-blur-sm">
1.5x
</div>
)}
</div>
)}
</div>