@@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user