feat: playback where I left off
This commit is contained in:
+2
-2
@@ -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;
|
||||
|
||||
@@ -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 ?? "";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user