feat: show playback progress in bar & auto-play text

This commit is contained in:
talksik
2026-03-19 12:10:01 -07:00
parent 78320f33c8
commit 122d8fed73
5 changed files with 127 additions and 108 deletions
+56 -9
View File
@@ -6,7 +6,9 @@ import { useLiveParticleChildren } from "@/hooks/use-particle";
import { parseParticlePath, toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path"; import { parseParticlePath, 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/playback/playback-page-indicator"; import { PlaybackPageIndicator } from "@/features/playback/playback-page-indicator";
import { ParticleRenderer } from "@/features/playback/particle-renderer"; import { MediaParticleView } from "@/features/playback/media-particle-view";
import { TextParticleView } from "@/features/playback/text-particle-view";
import { FallbackParticleView } from "@/features/playback/fallback-particle-view";
import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import ControlsIndicator from "@/features/compose/controls-indicator"; import ControlsIndicator from "@/features/compose/controls-indicator";
import { updateStreamPlaybackMarker } from "@/lib/firestore-particles"; import { updateStreamPlaybackMarker } from "@/lib/firestore-particles";
@@ -102,10 +104,16 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
const [state, dispatch] = useReducer(playbackReducer, initialState); const [state, dispatch] = useReducer(playbackReducer, initialState);
const [composeActive, setComposeActive] = useState(false); const [composeActive, setComposeActive] = useState(false);
const [progress, setProgress] = useState(0);
const hasInitializedRef = useRef<string | null>(null); const hasInitializedRef = useRef<string | null>(null);
const userId = useAuthStore((s) => s.user?.id); const userId = useAuthStore((s) => s.user?.id);
// Reset progress when particle changes
useEffect(() => {
setProgress(0);
}, [state.currentIndex]);
// Init playback once per stream entry, only after children have loaded // Init playback once per stream entry, only after children have loaded
useEffect(() => { useEffect(() => {
if (children.length === 0) return; if (children.length === 0) return;
@@ -154,8 +162,18 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
[children.length], [children.length],
); );
// Playback keyboard: arrows, escape // Click-to-navigate: left 30% = prev, right 70% = next
const handlePlaybackClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
if (x < 0.3) prev();
else if (x > 0.7) next();
},
[prev, next],
);
// Playback keyboard: arrows, escape
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (composeActive) return; if (composeActive) return;
@@ -221,6 +239,34 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
); );
} }
// Render particle content inline (replaces ParticleRenderer)
function renderParticle(particle: Particle) {
switch (particle.type) {
case "media":
return (
<MediaParticleView
key={particle.id}
particle={particle}
paused={state.paused}
onEnded={next}
onProgress={setProgress}
/>
);
case "text":
return (
<TextParticleView
key={particle.id}
particle={particle}
paused={state.paused}
onEnded={next}
onProgress={setProgress}
/>
);
default:
return <FallbackParticleView particle={particle} />;
}
}
return ( return (
<div className="relative flex h-full flex-col bg-black text-white"> <div className="relative flex h-full flex-col bg-black text-white">
{/* Progress indicator */} {/* Progress indicator */}
@@ -228,13 +274,14 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
<PlaybackPageIndicator <PlaybackPageIndicator
total={children.length} total={children.length}
current={state.currentIndex} current={state.currentIndex}
progress={progress}
onGoTo={goTo} onGoTo={goTo}
/> />
</div> </div>
{/* Author overlay */} {/* Author overlay */}
{currentParticle && ( {currentParticle && (
<div className="absolute top-5 left-0 right-0 z-10 flex items-center justify-center gap-2"> <div className="absolute top-5 left-1/2 transform -translate-x-1/2 z-10 flex items-center justify-center gap-2 bg-black/30 backdrop-blur-sm p-1 pr-2 rounded-full">
<Avatar size="sm"> <Avatar size="sm">
<AvatarFallback className="bg-white/20 text-[10px] font-medium text-white"> <AvatarFallback className="bg-white/20 text-[10px] font-medium text-white">
{authorInitials} {authorInitials}
@@ -249,12 +296,12 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
{/* Main playback area */} {/* Main playback area */}
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
{currentParticle && ( {currentParticle && (
<ParticleRenderer <div
particle={currentParticle} className="relative flex h-full w-full cursor-pointer items-center justify-center"
paused={state.paused} onClick={handlePlaybackClick}
onNext={next} >
onPrev={prev} {renderParticle(currentParticle)}
/> </div>
)} )}
</div> </div>
@@ -11,42 +11,20 @@ interface MediaParticleViewProps {
particle: MediaParticle; particle: MediaParticle;
paused: boolean; paused: boolean;
onEnded: () => void; onEnded: () => void;
} onProgress?: (ratio: number) => void;
function formatTime(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
function DurationPill({
currentTimeMs,
totalDurationMs,
}: {
currentTimeMs: number;
totalDurationMs: number;
}) {
return (
<div className="absolute top-3 right-3 rounded-full bg-white/10 px-2.5 py-1 backdrop-blur-sm">
<span className="font-mono text-xs text-white/80">
{formatTime(currentTimeMs)} / {formatTime(totalDurationMs)}
</span>
</div>
);
} }
export function MediaParticleView({ export function MediaParticleView({
particle, particle,
paused, paused,
onEnded, onEnded,
onProgress,
}: MediaParticleViewProps) { }: MediaParticleViewProps) {
const { data: url, error } = useDownloadUrl(particle.properties.object_id); const { data: url, error } = useDownloadUrl(particle.properties.object_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/");
const [currentTimeMs, setCurrentTimeMs] = useState(0);
// WORKAROUND: useAudioSource needs an audio element, but audioRef is only set after mount // WORKAROUND: useAudioSource needs an audio element, but audioRef is only set after mount
const [audioEl, setAudioEl] = useState<HTMLAudioElement | null>(null); const [audioEl, setAudioEl] = useState<HTMLAudioElement | null>(null);
@@ -90,7 +68,8 @@ export function MediaParticleView({
autoPlay autoPlay
onEnded={onEnded} onEnded={onEnded}
onTimeUpdate={(e) => { onTimeUpdate={(e) => {
setCurrentTimeMs(e.currentTarget.currentTime * 1000); const { currentTime, duration } = e.currentTarget;
if (duration > 0) onProgress?.(currentTime / duration);
}} }}
/> />
@@ -99,11 +78,6 @@ export function MediaParticleView({
<AudioLevelBars sourceNode={audioSource.sourceNode} /> <AudioLevelBars sourceNode={audioSource.sourceNode} />
</div> </div>
)} )}
<DurationPill
currentTimeMs={currentTimeMs}
totalDurationMs={particle.properties.duration_ms}
/>
</div> </div>
); );
} }
@@ -117,14 +91,11 @@ export function MediaParticleView({
playsInline playsInline
onEnded={onEnded} onEnded={onEnded}
onTimeUpdate={(e) => { onTimeUpdate={(e) => {
setCurrentTimeMs(e.currentTarget.currentTime * 1000); const { currentTime, duration } = e.currentTarget;
if (duration > 0) onProgress?.(currentTime / duration);
}} }}
className="h-full w-full object-cover" className="h-full w-full object-cover"
/> />
<DurationPill
currentTimeMs={currentTimeMs}
totalDurationMs={particle.properties.duration_ms}
/>
</div> </div>
); );
} }
@@ -1,60 +0,0 @@
import type { Particle } from "@/api/types";
import { MediaParticleView } from "./media-particle-view";
import { TextParticleView } from "./text-particle-view";
import { FallbackParticleView } from "./fallback-particle-view";
interface ParticleRendererProps {
particle: Particle;
paused: boolean;
onNext: () => void;
onPrev: () => void;
}
export function ParticleRenderer({
particle,
paused,
onNext,
onPrev,
}: ParticleRendererProps) {
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
if (x < 0.3) onPrev();
else if (x > 0.7) onNext();
};
return (
<div
className="relative flex h-full w-full cursor-pointer items-center justify-center"
onClick={handleClick}
>
<ParticleContent particle={particle} paused={paused} onEnded={onNext} />
</div>
);
}
function ParticleContent({
particle,
paused,
onEnded,
}: {
particle: Particle;
paused: boolean;
onEnded: () => void;
}) {
switch (particle.type) {
case "media":
return (
<MediaParticleView
key={particle.id}
particle={particle}
paused={paused}
onEnded={onEnded}
/>
);
case "text":
return <TextParticleView particle={particle} />;
default:
return <FallbackParticleView particle={particle} />;
}
}
@@ -3,12 +3,14 @@ import { cn } from "@/lib/utils";
interface PlaybackPageIndicatorProps { interface PlaybackPageIndicatorProps {
total: number; total: number;
current: number; current: number;
progress: number;
onGoTo: (index: number) => void; onGoTo: (index: number) => void;
} }
export function PlaybackPageIndicator({ export function PlaybackPageIndicator({
total, total,
current, current,
progress,
onGoTo, onGoTo,
}: PlaybackPageIndicatorProps) { }: PlaybackPageIndicatorProps) {
if (total === 0) return null; if (total === 0) return null;
@@ -24,14 +26,29 @@ export function PlaybackPageIndicator({
}} }}
className="group relative h-3 flex-1" className="group relative h-3 flex-1"
> >
{/* Track */} {/* Dim track */}
<div <div
className={cn( className={cn(
"absolute inset-x-0 top-1 h-1 rounded-full transition-all", "absolute inset-x-0 top-1 h-1 rounded-full bg-white/30",
i <= current ? "bg-white/90" : "bg-white/30",
"group-hover:h-1.5 group-hover:top-0.5", "group-hover:h-1.5 group-hover:top-0.5",
)} )}
/> />
{/* Fill */}
<div
className={cn(
"absolute left-0 top-1 h-1 rounded-full bg-white/90",
"group-hover:h-1.5 group-hover:top-0.5",
)}
style={{
width:
i < current
? "100%"
: i === current
? `${progress * 100}%`
: "0%",
transition: i === current ? "width 150ms linear" : "none",
}}
/>
</button> </button>
))} ))}
</div> </div>
@@ -1,3 +1,4 @@
import { useEffect, useRef } from "react";
import type { Particle } from "@/api/types"; import type { Particle } from "@/api/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -5,6 +6,20 @@ type TextParticle = Extract<Particle, { type: "text" }>;
interface TextParticleViewProps { interface TextParticleViewProps {
particle: TextParticle; particle: TextParticle;
paused: boolean;
onEnded: () => void;
onProgress?: (ratio: number) => void;
}
const WORDS_PER_MINUTE = 200;
const MIN_DURATION_S = 3;
const MAX_DURATION_S = 15;
const TICK_MS = 100;
function computeReadDuration(text: string): number {
const wordCount = text.trim().split(/\s+/).length;
const seconds = (wordCount / WORDS_PER_MINUTE) * 60;
return Math.min(Math.max(seconds, MIN_DURATION_S), MAX_DURATION_S);
} }
function getTextStyle(length: number) { function getTextStyle(length: number) {
@@ -14,8 +29,37 @@ function getTextStyle(length: number) {
return { size: "text-lg", weight: "font-normal" }; return { size: "text-lg", weight: "font-normal" };
} }
export function TextParticleView({ particle }: TextParticleViewProps) { export function TextParticleView({
particle,
paused,
onEnded,
onProgress,
}: TextParticleViewProps) {
const style = getTextStyle(particle.properties.content.length); const style = getTextStyle(particle.properties.content.length);
const durationS = computeReadDuration(particle.properties.content);
const elapsedRef = useRef(0);
// Reset elapsed when particle changes
useEffect(() => {
elapsedRef.current = 0;
}, [particle.id]);
useEffect(() => {
if (paused) return;
const interval = setInterval(() => {
elapsedRef.current += TICK_MS / 1000;
const ratio = Math.min(elapsedRef.current / durationS, 1);
onProgress?.(ratio);
if (ratio >= 1) {
clearInterval(interval);
onEnded();
}
}, TICK_MS);
return () => clearInterval(interval);
}, [paused, durationS, onEnded, onProgress, particle.id]);
return ( return (
<div className="flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 p-8"> <div className="flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 p-8">