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:aron@acme.com", "human:john@acme.com"] - visible only to Aron and John
// e.g. ["network:123"] - visible to everyone in the network
visible_to: z.array(z.string()),
// Marks emails to their `playback_position_at`: where they left off in a conversation
markers: z.record(z.string(), z.coerce.date()).optional(),
// Marks human_id to their `playback_position_at`: where they left off in a conversation
playback_markers: z.record(z.string(), z.coerce.date()).optional(),
// Timestamp of the most recent child particle
// used for sorting streams by recent activity without needing to query subcollections
last_child_created_at: z.coerce.date().optional(),
@@ -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 ?? "";
+2 -2
View File
@@ -1,5 +1,5 @@
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 { particlePath, ParticlePath, toFirestoreChildrenPath, toFirestoreDocPath } from "@/lib/particle-path";
@@ -23,7 +23,7 @@ export function useCreateParticle() {
);
const streamDocPath = toFirestoreDocPath(params.path);
await updateStreamParticleLastChildParticle(streamDocPath);
await updateStreamLastChildAt(streamDocPath);
return result;
}
});
+16 -3
View File
@@ -50,9 +50,9 @@ const particleConverter: FirestoreDataConverter<Particle> = {
created_by_email: raw.created_by_email,
updated_at: raw.updated_at ? (raw.updated_at as Timestamp).toDate() : undefined,
visible_to: raw.visible_to,
markers: raw.markers
playback_markers: raw.playback_markers
? Object.fromEntries(
Object.entries(raw.markers).map(([key, value]) => [
Object.entries(raw.playback_markers).map(([key, value]) => [
key,
(value as Timestamp).toDate(),
]),
@@ -240,7 +240,7 @@ export async function updateParticle(
});
}
export async function updateStreamParticleLastChildParticle(
export async function updateStreamLastChildAt(
docPath: string,
): Promise<void> {
const particleRef = typedDoc(docPath);
@@ -249,3 +249,16 @@ export async function updateStreamParticleLastChildParticle(
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(),
});
}