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:[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;
|
||||||
|
|||||||
@@ -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 ?? "";
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user