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 { ComposeOverlay } from "@/features/compose/compose-overlay";
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 ControlsIndicator from "@/features/compose/controls-indicator";
import { updateStreamPlaybackMarker } from "@/lib/firestore-particles";
@@ -102,10 +104,16 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
const [state, dispatch] = useReducer(playbackReducer, initialState);
const [composeActive, setComposeActive] = useState(false);
const [progress, setProgress] = useState(0);
const hasInitializedRef = useRef<string | null>(null);
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
useEffect(() => {
if (children.length === 0) return;
@@ -154,8 +162,18 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
[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(() => {
const handleKeyDown = (e: KeyboardEvent) => {
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 (
<div className="relative flex h-full flex-col bg-black text-white">
{/* Progress indicator */}
@@ -228,13 +274,14 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
<PlaybackPageIndicator
total={children.length}
current={state.currentIndex}
progress={progress}
onGoTo={goTo}
/>
</div>
{/* Author overlay */}
{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">
<AvatarFallback className="bg-white/20 text-[10px] font-medium text-white">
{authorInitials}
@@ -249,12 +296,12 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
{/* Main playback area */}
<div className="flex-1 overflow-hidden">
{currentParticle && (
<ParticleRenderer
particle={currentParticle}
paused={state.paused}
onNext={next}
onPrev={prev}
/>
<div
className="relative flex h-full w-full cursor-pointer items-center justify-center"
onClick={handlePlaybackClick}
>
{renderParticle(currentParticle)}
</div>
)}
</div>
@@ -11,42 +11,20 @@ interface MediaParticleViewProps {
particle: MediaParticle;
paused: boolean;
onEnded: () => 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>
);
onProgress?: (ratio: number) => void;
}
export function MediaParticleView({
particle,
paused,
onEnded,
onProgress,
}: MediaParticleViewProps) {
const { data: url, error } = useDownloadUrl(particle.properties.object_id);
const videoRef = useRef<HTMLVideoElement>(null);
const audioRef = useRef<HTMLAudioElement>(null);
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
const [audioEl, setAudioEl] = useState<HTMLAudioElement | null>(null);
@@ -90,7 +68,8 @@ export function MediaParticleView({
autoPlay
onEnded={onEnded}
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} />
</div>
)}
<DurationPill
currentTimeMs={currentTimeMs}
totalDurationMs={particle.properties.duration_ms}
/>
</div>
);
}
@@ -117,14 +91,11 @@ export function MediaParticleView({
playsInline
onEnded={onEnded}
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"
/>
<DurationPill
currentTimeMs={currentTimeMs}
totalDurationMs={particle.properties.duration_ms}
/>
</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 {
total: number;
current: number;
progress: number;
onGoTo: (index: number) => void;
}
export function PlaybackPageIndicator({
total,
current,
progress,
onGoTo,
}: PlaybackPageIndicatorProps) {
if (total === 0) return null;
@@ -24,14 +26,29 @@ export function PlaybackPageIndicator({
}}
className="group relative h-3 flex-1"
>
{/* Track */}
{/* Dim track */}
<div
className={cn(
"absolute inset-x-0 top-1 h-1 rounded-full transition-all",
i <= current ? "bg-white/90" : "bg-white/30",
"absolute inset-x-0 top-1 h-1 rounded-full bg-white/30",
"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>
))}
</div>
@@ -1,3 +1,4 @@
import { useEffect, useRef } from "react";
import type { Particle } from "@/api/types";
import { cn } from "@/lib/utils";
@@ -5,6 +6,20 @@ type TextParticle = Extract<Particle, { type: "text" }>;
interface TextParticleViewProps {
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) {
@@ -14,8 +29,37 @@ function getTextStyle(length: number) {
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 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 (
<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">