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
+2 -2
View File
@@ -136,8 +136,8 @@ export const ParticleSchema = z.discriminatedUnion("type", [
// e.g. ["human:[email protected]", "human:[email protected]"] - visible only to Aron and John // e.g. ["human:[email protected]", "human:[email protected]"] - visible only to Aron and John
// e.g. ["network:123"] - visible to everyone in the network // e.g. ["network:123"] - visible to everyone in the network
visible_to: z.array(z.string()), visible_to: z.array(z.string()),
// Marks emails to their `playback_position_at`: where they left off in a conversation // Marks human_id to their `playback_position_at`: where they left off in a conversation
markers: z.record(z.string(), z.coerce.date()).optional(), playback_markers: z.record(z.string(), z.coerce.date()).optional(),
// Timestamp of the most recent child particle // Timestamp of the most recent child particle
// used for sorting streams by recent activity without needing to query subcollections // used for sorting streams by recent activity without needing to query subcollections
last_child_created_at: z.coerce.date().optional(), last_child_created_at: z.coerce.date().optional(),
@@ -84,6 +84,7 @@ function StreamRow({
const streamPath = particlePath(networkId, [particle.id]); const streamPath = particlePath(networkId, [particle.id]);
const { latestChild } = useLiveLatestChild(streamPath); const { latestChild } = useLiveLatestChild(streamPath);
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const userId = user?.id ?? "";
const userEmail = user?.email ?? ""; const userEmail = user?.email ?? "";
const isDM = const isDM =
@@ -107,9 +108,9 @@ function StreamRow({
if (!latestChild) return false; if (!latestChild) return false;
const latestChildTimestamp = latestChild.created_at.getTime(); const latestChildTimestamp = latestChild.created_at.getTime();
const userPlaybackPosition = const userPlaybackPosition =
particle.markers?.[userEmail]?.getTime() ?? 0; particle.playback_markers?.[userId]?.getTime() ?? 0;
return latestChildTimestamp > userPlaybackPosition; return latestChildTimestamp > userPlaybackPosition;
}, [latestChild, particle.markers, userEmail]); }, [latestChild, particle.playback_markers, userId]);
const senderPrefix = useMemo(() => { const senderPrefix = useMemo(() => {
if (!latestChild) return null; 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 { useNavigate } from "react-router-dom";
import { useAuthStore } from "@/stores/auth-store";
import type { Particle } from "@/api/types"; import type { Particle } from "@/api/types";
import { useLiveParticleChildren } from "@/hooks/use-particle"; 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 { 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 { ParticleRenderer } from "@/features/playback/particle-renderer";
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";
// --- Playback reducer --- // --- Playback reducer ---
@@ -20,7 +22,7 @@ interface PlaybackState {
} }
type PlaybackAction = type PlaybackAction =
| { type: "INIT"; particleCount: number } | { type: "INIT"; particleCount: number, initialIndex?: number }
| { type: "NEXT"; particleCount: number } | { type: "NEXT"; particleCount: number }
| { type: "PREV" } | { type: "PREV" }
| { type: "GO_TO"; index: number; particleCount: number } | { type: "GO_TO"; index: number; particleCount: number }
@@ -35,7 +37,7 @@ function playbackReducer(
switch (action.type) { switch (action.type) {
case "INIT": case "INIT":
return { return {
currentIndex: 0, currentIndex: action.initialIndex ?? 0,
status: action.particleCount > 0 ? "playing" : "idle", status: action.particleCount > 0 ? "playing" : "idle",
paused: false, paused: false,
}; };
@@ -89,7 +91,7 @@ const initialState: PlaybackState = {
// --- StreamView --- // --- StreamView ---
interface StreamViewProps { interface StreamViewProps {
streamParticle: Particle; streamParticle: Particle & { type: "stream" };
path: ParticlePath; path: ParticlePath;
} }
@@ -100,16 +102,36 @@ 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 hasInitializedRef = useRef<string | null>(null);
// Init playback when the stream particle changes const userId = useAuthStore((s) => s.user?.id);
useEffect(() => {
dispatch({ type: "INIT", particleCount: children.length });
}, [streamParticle.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(() => { 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 }); dispatch({ type: "SYNC_PARTICLES", particleCount: children.length });
}, [children.length]); }, [children.length, streamParticle.id]);
// Pause/resume playback when compose overlay opens/closes // Pause/resume playback when compose overlay opens/closes
useEffect(() => { useEffect(() => {
@@ -133,8 +155,9 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
); );
// Playback keyboard: arrows, escape // Playback keyboard: arrows, escape
const handleKeyDown = useCallback(
(e: KeyboardEvent) => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (composeActive) return; if (composeActive) return;
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
@@ -162,22 +185,24 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
navigate(`/${networkId}`); navigate(`/${networkId}`);
break; break;
} }
}, }
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
},
[composeActive, next, prev, navigate, networkId], [composeActive, next, prev, navigate, networkId],
); );
useEffect(() => {
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [handleKeyDown]);
const currentParticle = children[state.currentIndex] ?? null; 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) // Stream name from properties (narrowed to stream type)
const streamName = const streamName = streamParticle.properties.name
streamParticle.type === "stream"
? streamParticle.properties.name
: "";
// Author info from current particle // Author info from current particle
const authorEmail = currentParticle?.created_by_email ?? ""; const authorEmail = currentParticle?.created_by_email ?? "";
+2 -2
View File
@@ -1,5 +1,5 @@
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { createParticle, updateStreamParticleLastChildParticle } from "@/lib/firestore-particles"; import { createParticle, updateStreamLastChildAt } from "@/lib/firestore-particles";
import type { ParticleType, ParticlePropertiesMap } from "@/api/types"; import type { ParticleType, ParticlePropertiesMap } from "@/api/types";
import { particlePath, ParticlePath, toFirestoreChildrenPath, toFirestoreDocPath } from "@/lib/particle-path"; import { particlePath, ParticlePath, toFirestoreChildrenPath, toFirestoreDocPath } from "@/lib/particle-path";
@@ -23,7 +23,7 @@ export function useCreateParticle() {
); );
const streamDocPath = toFirestoreDocPath(params.path); const streamDocPath = toFirestoreDocPath(params.path);
await updateStreamParticleLastChildParticle(streamDocPath); await updateStreamLastChildAt(streamDocPath);
return result; return result;
} }
}); });
+16 -3
View File
@@ -50,9 +50,9 @@ const particleConverter: FirestoreDataConverter<Particle> = {
created_by_email: raw.created_by_email, created_by_email: raw.created_by_email,
updated_at: raw.updated_at ? (raw.updated_at as Timestamp).toDate() : undefined, updated_at: raw.updated_at ? (raw.updated_at as Timestamp).toDate() : undefined,
visible_to: raw.visible_to, visible_to: raw.visible_to,
markers: raw.markers playback_markers: raw.playback_markers
? Object.fromEntries( ? Object.fromEntries(
Object.entries(raw.markers).map(([key, value]) => [ Object.entries(raw.playback_markers).map(([key, value]) => [
key, key,
(value as Timestamp).toDate(), (value as Timestamp).toDate(),
]), ]),
@@ -240,7 +240,7 @@ export async function updateParticle(
}); });
} }
export async function updateStreamParticleLastChildParticle( export async function updateStreamLastChildAt(
docPath: string, docPath: string,
): Promise<void> { ): Promise<void> {
const particleRef = typedDoc(docPath); const particleRef = typedDoc(docPath);
@@ -249,3 +249,16 @@ export async function updateStreamParticleLastChildParticle(
updated_at: serverTimestamp(), updated_at: serverTimestamp(),
}); });
} }
export async function updateStreamPlaybackMarker(
docPath: string,
humanId: string,
playbackPositionAt: Date,
): Promise<void> {
const particleRef = typedDoc(docPath);
const markerField = `playback_markers.${humanId}`;
await updateDoc(particleRef, {
[markerField]: Timestamp.fromDate(playbackPositionAt),
updated_at: serverTimestamp(),
});
}