diff --git a/js/src/features/particles/stream-view.tsx b/js/src/features/particles/stream-view.tsx index c8307a7..43de66c 100644 --- a/js/src/features/particles/stream-view.tsx +++ b/js/src/features/particles/stream-view.tsx @@ -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(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) => { + 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 ( + + ); + case "text": + return ( + + ); + default: + return ; + } + } + return (
{/* Progress indicator */} @@ -228,13 +274,14 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
{/* Author overlay */} {currentParticle && ( -
+
{authorInitials} @@ -249,12 +296,12 @@ export function StreamView({ path, streamParticle }: StreamViewProps) { {/* Main playback area */}
{currentParticle && ( - +
+ {renderParticle(currentParticle)} +
)}
diff --git a/js/src/features/playback/media-particle-view.tsx b/js/src/features/playback/media-particle-view.tsx index bf49763..b2cfd1d 100644 --- a/js/src/features/playback/media-particle-view.tsx +++ b/js/src/features/playback/media-particle-view.tsx @@ -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 ( -
- - {formatTime(currentTimeMs)} / {formatTime(totalDurationMs)} - -
- ); + onProgress?: (ratio: number) => void; } export function MediaParticleView({ particle, paused, onEnded, + onProgress, }: MediaParticleViewProps) { const { data: url, error } = useDownloadUrl(particle.properties.object_id); const videoRef = useRef(null); const audioRef = useRef(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(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({
)} - -
); } @@ -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" /> - ); } diff --git a/js/src/features/playback/particle-renderer.tsx b/js/src/features/playback/particle-renderer.tsx deleted file mode 100644 index f6d08e1..0000000 --- a/js/src/features/playback/particle-renderer.tsx +++ /dev/null @@ -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) => { - 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 ( -
- -
- ); -} - -function ParticleContent({ - particle, - paused, - onEnded, -}: { - particle: Particle; - paused: boolean; - onEnded: () => void; -}) { - switch (particle.type) { - case "media": - return ( - - ); - case "text": - return ; - default: - return ; - } -} diff --git a/js/src/features/playback/playback-page-indicator.tsx b/js/src/features/playback/playback-page-indicator.tsx index 86cca38..7bcf8f9 100644 --- a/js/src/features/playback/playback-page-indicator.tsx +++ b/js/src/features/playback/playback-page-indicator.tsx @@ -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 */}
+ {/* Fill */} +
))}
diff --git a/js/src/features/playback/text-particle-view.tsx b/js/src/features/playback/text-particle-view.tsx index f55eb8c..da8ff2e 100644 --- a/js/src/features/playback/text-particle-view.tsx +++ b/js/src/features/playback/text-particle-view.tsx @@ -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; 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 (