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 { 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">
|
||||||
|
|||||||
Reference in New Issue
Block a user