@@ -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 { Particle } from "@/api/types";
|
||||||
import type { ParticlePath } from "@/lib/particle-path";
|
import type { ParticlePath } from "@/lib/particle-path";
|
||||||
import { useDownloadUrl } from "@/hooks/use-download-url";
|
import { useDownloadUrl } from "@/hooks/use-download-url";
|
||||||
@@ -12,6 +12,12 @@ import { ParticleAttachments } from "@/features/particles/particle-attachments";
|
|||||||
|
|
||||||
type MediaParticle = Extract<Particle, { type: "media" }>;
|
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 {
|
interface MediaParticleViewProps {
|
||||||
particle: MediaParticle;
|
particle: MediaParticle;
|
||||||
streamPath: ParticlePath;
|
streamPath: ParticlePath;
|
||||||
@@ -20,19 +26,34 @@ interface MediaParticleViewProps {
|
|||||||
onProgress?: (ratio: number) => void;
|
onProgress?: (ratio: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MediaParticleView({
|
export const MediaParticleView = forwardRef<MediaParticleHandle, MediaParticleViewProps>(function MediaParticleView({
|
||||||
particle,
|
particle,
|
||||||
streamPath,
|
streamPath,
|
||||||
paused,
|
paused,
|
||||||
onEnded,
|
onEnded,
|
||||||
onProgress,
|
onProgress,
|
||||||
}: MediaParticleViewProps) {
|
}, ref) {
|
||||||
const { data: url, error } = useDownloadUrl(particle.properties.object_id);
|
const { data: url, error } = useDownloadUrl(particle.properties.object_id);
|
||||||
const { attachments } = useParticleAttachments(streamPath, particle.id);
|
const { attachments } = useParticleAttachments(streamPath, particle.id);
|
||||||
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
const isAudio = particle.properties.mime_type?.startsWith("audio/");
|
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 [currentTime, setCurrentTime] = useState(0);
|
||||||
|
|
||||||
const transcript = particle.properties.transcript;
|
const transcript = particle.properties.transcript;
|
||||||
@@ -140,4 +161,4 @@ export function MediaParticleView({
|
|||||||
{attachmentOverlay}
|
{attachmentOverlay}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { Particle } from "@/api/types";
|
|||||||
import { parseParticlePath, particlePath, toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path";
|
import { parseParticlePath, particlePath, toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path";
|
||||||
import { ComposeOverlay } from "@/features/compose/compose-overlay";
|
import { ComposeOverlay } from "@/features/compose/compose-overlay";
|
||||||
import { PlaybackPageIndicator } from "@/features/particles/playback-page-indicator";
|
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 { TextParticleView } from "@/features/particles/text-particle-view";
|
||||||
import { FallbackParticleView } from "@/features/particles/fallback-particle-view";
|
import { FallbackParticleView } from "@/features/particles/fallback-particle-view";
|
||||||
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
|
||||||
@@ -140,8 +140,11 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
|
|||||||
authedUser?.id,
|
authedUser?.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const mediaRef = useRef<MediaParticleHandle>(null);
|
||||||
|
|
||||||
const [composeActive, setComposeActive] = useState(false);
|
const [composeActive, setComposeActive] = useState(false);
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
|
const [fastPlayback, setFastPlayback] = useState(false);
|
||||||
|
|
||||||
// Show/hide chrome on mouse activity (YouTube-style)
|
// Show/hide chrome on mouse activity (YouTube-style)
|
||||||
const [showControls, setShowControls] = useState(true);
|
const [showControls, setShowControls] = useState(true);
|
||||||
@@ -196,17 +199,23 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
|
|||||||
case "ArrowRight":
|
case "ArrowRight":
|
||||||
case "ArrowDown":
|
case "ArrowDown":
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
next();
|
if (!mediaRef.current?.seek(10)) next();
|
||||||
break;
|
break;
|
||||||
case "ArrowLeft":
|
case "ArrowLeft":
|
||||||
case "ArrowUp":
|
case "ArrowUp":
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
prev();
|
if (!mediaRef.current?.seek(-10)) prev();
|
||||||
break;
|
break;
|
||||||
case " ":
|
case " ":
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!e.repeat) pause();
|
if (!e.repeat) pause();
|
||||||
break;
|
break;
|
||||||
|
case "Shift":
|
||||||
|
if (!e.repeat) {
|
||||||
|
mediaRef.current?.setPlaybackRate(1.5);
|
||||||
|
setFastPlayback(true);
|
||||||
|
}
|
||||||
|
break;
|
||||||
case "Escape":
|
case "Escape":
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
navigate(`/${networkId}`);
|
navigate(`/${networkId}`);
|
||||||
@@ -230,6 +239,10 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
resume();
|
resume();
|
||||||
}
|
}
|
||||||
|
if (e.key === "Shift") {
|
||||||
|
mediaRef.current?.setPlaybackRate(1);
|
||||||
|
setFastPlayback(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
@@ -287,6 +300,7 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
|
|||||||
case "media":
|
case "media":
|
||||||
return (
|
return (
|
||||||
<MediaParticleView
|
<MediaParticleView
|
||||||
|
ref={mediaRef}
|
||||||
key={particle.id}
|
key={particle.id}
|
||||||
particle={particle}
|
particle={particle}
|
||||||
streamPath={path}
|
streamPath={path}
|
||||||
@@ -333,6 +347,12 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
|
|||||||
onClick={handlePlaybackClick}
|
onClick={handlePlaybackClick}
|
||||||
>
|
>
|
||||||
{renderParticle(currentParticle)}
|
{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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user