feat: show playback progress in bar & auto-play text
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user