feat: playback where I left off

This commit is contained in:
talksik
2026-03-19 11:24:41 -07:00
parent 71dd0149bc
commit c43f66d915
5 changed files with 71 additions and 32 deletions
@@ -84,6 +84,7 @@ function StreamRow({
const streamPath = particlePath(networkId, [particle.id]);
const { latestChild } = useLiveLatestChild(streamPath);
const user = useAuthStore((s) => s.user);
const userId = user?.id ?? "";
const userEmail = user?.email ?? "";
const isDM =
@@ -107,9 +108,9 @@ function StreamRow({
if (!latestChild) return false;
const latestChildTimestamp = latestChild.created_at.getTime();
const userPlaybackPosition =
particle.markers?.[userEmail]?.getTime() ?? 0;
particle.playback_markers?.[userId]?.getTime() ?? 0;
return latestChildTimestamp > userPlaybackPosition;
}, [latestChild, particle.markers, userEmail]);
}, [latestChild, particle.playback_markers, userId]);
const senderPrefix = useMemo(() => {
if (!latestChild) return null;
+48 -23
View File
@@ -1,13 +1,15 @@
import { useState, useEffect, useCallback, useReducer } from "react";
import { useState, useEffect, useCallback, useReducer, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { useAuthStore } from "@/stores/auth-store";
import type { Particle } from "@/api/types";
import { useLiveParticleChildren } from "@/hooks/use-particle";
import { parseParticlePath, type ParticlePath } from "@/lib/particle-path";
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 { Avatar, AvatarFallback } from "@/components/ui/avatar";
import ControlsIndicator from "@/features/compose/controls-indicator";
import { updateStreamPlaybackMarker } from "@/lib/firestore-particles";
// --- Playback reducer ---
@@ -20,7 +22,7 @@ interface PlaybackState {
}
type PlaybackAction =
| { type: "INIT"; particleCount: number }
| { type: "INIT"; particleCount: number, initialIndex?: number }
| { type: "NEXT"; particleCount: number }
| { type: "PREV" }
| { type: "GO_TO"; index: number; particleCount: number }
@@ -35,7 +37,7 @@ function playbackReducer(
switch (action.type) {
case "INIT":
return {
currentIndex: 0,
currentIndex: action.initialIndex ?? 0,
status: action.particleCount > 0 ? "playing" : "idle",
paused: false,
};
@@ -89,7 +91,7 @@ const initialState: PlaybackState = {
// --- StreamView ---
interface StreamViewProps {
streamParticle: Particle;
streamParticle: Particle & { type: "stream" };
path: ParticlePath;
}
@@ -100,16 +102,36 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
const [state, dispatch] = useReducer(playbackReducer, initialState);
const [composeActive, setComposeActive] = useState(false);
const hasInitializedRef = useRef<string | null>(null);
// Init playback when the stream particle changes
useEffect(() => {
dispatch({ type: "INIT", particleCount: children.length });
}, [streamParticle.id]);
const userId = useAuthStore((s) => s.user?.id);
// Sync when children list changes (e.g. new particle appended via Firestore)
// Init playback once per stream entry, only after children have loaded
useEffect(() => {
if (children.length === 0) return;
if (hasInitializedRef.current === streamParticle.id) return;
hasInitializedRef.current = streamParticle.id;
const playbackPosition = streamParticle.playback_markers?.[userId ?? ""];
let initialIndex = 0;
if (playbackPosition) {
const foundIndex = children.findIndex(
(c) => c.created_at.getTime() === playbackPosition.getTime(),
);
if (foundIndex !== -1) {
initialIndex = foundIndex;
}
}
dispatch({ type: "INIT", particleCount: children.length, initialIndex });
}, [streamParticle.id, userId, children]);
// Sync on subsequent changes (new particle appended, removed, etc.)
useEffect(() => {
if (hasInitializedRef.current !== streamParticle.id) return;
dispatch({ type: "SYNC_PARTICLES", particleCount: children.length });
}, [children.length]);
}, [children.length, streamParticle.id]);
// Pause/resume playback when compose overlay opens/closes
useEffect(() => {
@@ -133,8 +155,9 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
);
// Playback keyboard: arrows, escape
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (composeActive) return;
const target = e.target as HTMLElement;
@@ -162,22 +185,24 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
navigate(`/${networkId}`);
break;
}
},
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
},
[composeActive, next, prev, navigate, networkId],
);
useEffect(() => {
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [handleKeyDown]);
const currentParticle = children[state.currentIndex] ?? null;
useEffect(() => {
if (!userId || !currentParticle) return;
const streamDocPath = toFirestoreDocPath(path);
updateStreamPlaybackMarker(streamDocPath, userId, currentParticle.created_at);
}, [currentParticle, path])
// Stream name from properties (narrowed to stream type)
const streamName =
streamParticle.type === "stream"
? streamParticle.properties.name
: "";
const streamName = streamParticle.properties.name
// Author info from current particle
const authorEmail = currentParticle?.created_by_email ?? "";