mobile v0.1 with deployment for ios (#191)
* stage 1: project init * stage 2: skeleton with navigation * step 2.5: streams list * step 4: stream playback experience * step 5-6: compose experience * fix: broken record * transcode media particles to mp4 * build: reproducible go generate * build: rename skaffold module for particle processor worker * infra: increase particle processor worker resources Was dealing with OOM errors * tweaks to mobile * log transcode work * view on desktop placeholder * tweak padding * cap video resolution to save on memory * infra: bump memory limits as insurance * ux improvements * update bundle id for mobile * config for mobile
This commit was merged in pull request #191.
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { usePusherClient } from "@/lib/pusher-provider";
|
||||
import type { ChannelMessage } from "@/lib/pusher-client";
|
||||
|
||||
interface UseChannelResult {
|
||||
/** Current set of humanIds present in the channel */
|
||||
presence: string[];
|
||||
/** Messages received on this channel (since the hook mounted) */
|
||||
messages: ChannelMessage[];
|
||||
/** Send a message to the channel */
|
||||
sendMessage: (payload: unknown) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a pusher channel. Manages presence tracking and message delivery.
|
||||
* Subscribes on mount, unsubscribes on unmount.
|
||||
*
|
||||
* @param channelId - The channel to subscribe to, or null to skip.
|
||||
*/
|
||||
export function useChannel(channelId: string | null): UseChannelResult {
|
||||
const client = usePusherClient();
|
||||
const [presence, setPresence] = useState<string[]>([]);
|
||||
const [messages, setMessages] = useState<ChannelMessage[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !channelId) {
|
||||
setPresence([]);
|
||||
setMessages([]);
|
||||
return;
|
||||
}
|
||||
|
||||
client.subscribe(channelId);
|
||||
|
||||
const onSubscribed = (msg: { presence?: string[] }) => {
|
||||
setPresence(msg.presence ?? []);
|
||||
};
|
||||
|
||||
const onJoin = (msg: { humanId?: string }) => {
|
||||
if (msg.humanId) {
|
||||
setPresence((prev) =>
|
||||
prev.includes(msg.humanId!) ? prev : [...prev, msg.humanId!],
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onLeave = (msg: { humanId?: string }) => {
|
||||
if (msg.humanId) {
|
||||
setPresence((prev) => prev.filter((id) => id !== msg.humanId));
|
||||
}
|
||||
};
|
||||
|
||||
const onMessage = (msg: { humanId?: string; payload?: unknown }) => {
|
||||
if (msg.humanId) {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ humanId: msg.humanId!, payload: msg.payload },
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
client.on(channelId, "subscribed", onSubscribed);
|
||||
client.on(channelId, "join", onJoin);
|
||||
client.on(channelId, "leave", onLeave);
|
||||
client.on(channelId, "message", onMessage);
|
||||
|
||||
return () => {
|
||||
client.off(channelId, "subscribed", onSubscribed);
|
||||
client.off(channelId, "join", onJoin);
|
||||
client.off(channelId, "leave", onLeave);
|
||||
client.off(channelId, "message", onMessage);
|
||||
client.unsubscribe(channelId);
|
||||
};
|
||||
}, [client, channelId]);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
(payload: unknown) => {
|
||||
if (client && channelId) {
|
||||
client.sendMessage(channelId, payload);
|
||||
}
|
||||
},
|
||||
[client, channelId],
|
||||
);
|
||||
|
||||
return { presence, messages, sendMessage };
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useCallback, useLayoutEffect, useRef } from "react";
|
||||
|
||||
// Polyfill for React's `useEffectEvent` (canary). The returned function has a
|
||||
// stable identity but always sees the latest closure — exactly what
|
||||
// `useEffectEvent` provides. Stable enough that we use it everywhere we'd
|
||||
// otherwise reach for a ref + .current dance inside an effect.
|
||||
//
|
||||
// Replace with `useEffectEvent` once it ships in stable React. Call sites
|
||||
// don't need to change.
|
||||
export function useEvent<TArgs extends unknown[], TReturn>(
|
||||
fn: (...args: TArgs) => TReturn,
|
||||
): (...args: TArgs) => TReturn {
|
||||
const ref = useRef(fn);
|
||||
useLayoutEffect(() => {
|
||||
ref.current = fn;
|
||||
});
|
||||
return useCallback((...args: TArgs) => ref.current(...args), []);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiClient } from "@/api/client";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
|
||||
export function useNetworks() {
|
||||
return useQuery({
|
||||
queryKey: ["networks"],
|
||||
queryFn: () => apiClient.listNetworks(),
|
||||
meta: { toastOnError: true },
|
||||
});
|
||||
}
|
||||
|
||||
export function useNetwork(networkId: string) {
|
||||
const { data: networks } = useNetworks();
|
||||
return networks?.find((n) => n.id === networkId) ?? null;
|
||||
}
|
||||
|
||||
export function useIsNetworkAdmin(networkId: string): boolean {
|
||||
const network = useNetwork(networkId);
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
if (!network || !userId) return false;
|
||||
return network.admin_human.id === userId;
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { QueryFieldFilterConstraint } from "firebase/firestore";
|
||||
import {
|
||||
subscribeToParticle,
|
||||
subscribeToParticleChildren,
|
||||
subscribeToLatestChild,
|
||||
getParticle,
|
||||
getParticleChildren,
|
||||
} from "@/lib/firestore-particles";
|
||||
import type { Particle } from "@/api/types";
|
||||
import {
|
||||
type ParticlePath,
|
||||
toFirestoreDocPath,
|
||||
toFirestoreChildrenPath,
|
||||
} from "@/lib/particle-path";
|
||||
import { logError } from "@/lib/errors";
|
||||
|
||||
interface UseLiveParticleResult {
|
||||
particle: Particle | null;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export function useLiveParticle(path: ParticlePath): UseLiveParticleResult {
|
||||
const [particle, setParticle] = useState<Particle | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setParticle(null);
|
||||
|
||||
const docPath = toFirestoreDocPath(path);
|
||||
const unsubscribe = subscribeToParticle(
|
||||
docPath,
|
||||
(data) => {
|
||||
setParticle(data);
|
||||
setIsLoading(false);
|
||||
},
|
||||
(err) => {
|
||||
setError(err);
|
||||
setIsLoading(false);
|
||||
},
|
||||
);
|
||||
|
||||
return unsubscribe;
|
||||
}, [path]);
|
||||
|
||||
return { particle, isLoading, error };
|
||||
}
|
||||
|
||||
interface UseLiveParticleChildrenResult {
|
||||
children: Particle[];
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
interface UseLiveParticleChildrenParams {
|
||||
orderByField?: string;
|
||||
orderDirection?: "asc" | "desc";
|
||||
visibilityScopes?: string[];
|
||||
onAdded?: (child: Particle) => void;
|
||||
onRemoved?: (child: Particle, updatedChildren: Particle[]) => void;
|
||||
whereFilter?: QueryFieldFilterConstraint;
|
||||
/** Optional cap on results. Changes trigger a re-subscription. */
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export function useLiveParticleChildren(
|
||||
path: ParticlePath | undefined,
|
||||
{
|
||||
orderByField = "created_at",
|
||||
orderDirection = "desc",
|
||||
visibilityScopes,
|
||||
onAdded,
|
||||
onRemoved,
|
||||
whereFilter,
|
||||
limit,
|
||||
}: UseLiveParticleChildrenParams = {},
|
||||
): UseLiveParticleChildrenResult {
|
||||
const [children, setChildren] = useState<Particle[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!path) {
|
||||
setChildren([]);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setChildren([]);
|
||||
|
||||
const collectionPath = toFirestoreChildrenPath(path);
|
||||
|
||||
const unsubscribe = subscribeToParticleChildren(collectionPath, {
|
||||
onData: (data) => {
|
||||
setChildren(data);
|
||||
setIsLoading(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
logError(err, { scope: "firestore.particle-children", path });
|
||||
setError(err);
|
||||
setIsLoading(false);
|
||||
},
|
||||
visibilityScopes,
|
||||
orderByField,
|
||||
orderDirection,
|
||||
onAdded,
|
||||
onRemoved,
|
||||
whereFilter,
|
||||
limit,
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
// The hook intentionally keys only on path/whereFilter/limit — desktop
|
||||
// does the same. Visibility scope changes are absorbed by the active
|
||||
// listener; reordering causes a re-subscription.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [path, whereFilter, limit]);
|
||||
|
||||
return { children, isLoading, error };
|
||||
}
|
||||
|
||||
interface UseLiveLatestChildResult {
|
||||
latestChild: Particle | null;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function useLiveLatestChild(
|
||||
path: ParticlePath,
|
||||
): UseLiveLatestChildResult {
|
||||
const [latestChild, setLatestChild] = useState<Particle | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
setLatestChild(null);
|
||||
|
||||
const unsubscribe = subscribeToLatestChild(
|
||||
toFirestoreChildrenPath(path),
|
||||
(data) => {
|
||||
setLatestChild(data);
|
||||
setIsLoading(false);
|
||||
},
|
||||
(err) => {
|
||||
logError(err, { scope: "firestore.latest-child", path });
|
||||
setIsLoading(false);
|
||||
},
|
||||
);
|
||||
|
||||
return unsubscribe;
|
||||
}, [path]);
|
||||
|
||||
return { latestChild, isLoading };
|
||||
}
|
||||
|
||||
export function useParticle(path?: ParticlePath) {
|
||||
return useQuery({
|
||||
queryKey: ["particle", path],
|
||||
queryFn: async () => {
|
||||
if (!path) return null;
|
||||
const docPath = toFirestoreDocPath(path);
|
||||
const particle = await getParticle(docPath);
|
||||
return particle;
|
||||
},
|
||||
enabled: !!path,
|
||||
});
|
||||
}
|
||||
|
||||
export function useParticleChildren(path?: ParticlePath) {
|
||||
return useQuery({
|
||||
queryKey: ["particle-children", path],
|
||||
queryFn: async () => {
|
||||
if (!path) return [];
|
||||
const collectionPath = toFirestoreChildrenPath(path);
|
||||
return getParticleChildren(collectionPath);
|
||||
},
|
||||
enabled: !!path,
|
||||
staleTime: 1000 * 60 * 5, // 5 min — attachments don't change
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { where, type QueryFieldFilterConstraint } from "firebase/firestore";
|
||||
import { useLiveParticleChildren } from "@/hooks/use-particle";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { parseParticlePath, type ParticlePath } from "@/lib/particle-path";
|
||||
import type { Particle, StreamProperties } from "@/api/types";
|
||||
|
||||
export type StreamParticle = Particle & {
|
||||
type: "stream";
|
||||
properties: StreamProperties;
|
||||
};
|
||||
|
||||
const CLOSED_INITIAL_PAGE_SIZE = 50;
|
||||
const CLOSED_PAGE_INCREMENT = 50;
|
||||
|
||||
// Stable where-constraint references so the Firestore subscription only
|
||||
// re-attaches when the tab actually changes, not on every render.
|
||||
const OPEN_STATUS_FILTER = where("status", "==", "open");
|
||||
const CLOSED_STATUS_FILTER = where("status", "==", "closed");
|
||||
|
||||
function useVisibilityScopes(userId?: string, networkId?: string) {
|
||||
return useMemo(() => {
|
||||
const scopes: string[] = [];
|
||||
if (userId) scopes.push(`human:${userId}`);
|
||||
if (networkId) scopes.push(`network:${networkId}`);
|
||||
return scopes;
|
||||
}, [userId, networkId]);
|
||||
}
|
||||
|
||||
interface UseStreamParticlesOptions {
|
||||
/**
|
||||
* Which streams to subscribe to. Open streams are loaded in full (bounded
|
||||
* by active work — full realtime coverage is needed for autoplay/huddles).
|
||||
* Closed streams are paginated via `loadMore`.
|
||||
*/
|
||||
status: "open" | "closed";
|
||||
}
|
||||
|
||||
interface UseStreamParticlesResult {
|
||||
streams: StreamParticle[];
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
networkId: string;
|
||||
/** True when more closed streams may exist beyond the current window. */
|
||||
canLoadMore: boolean;
|
||||
/** Extend the pagination window. No-op on the open tab. */
|
||||
loadMore: () => void;
|
||||
}
|
||||
|
||||
export function useStreamParticles(
|
||||
path: ParticlePath,
|
||||
{ status }: UseStreamParticlesOptions,
|
||||
): UseStreamParticlesResult {
|
||||
const { networkId } = parseParticlePath(path);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const visibilityScopes = useVisibilityScopes(user?.id, networkId);
|
||||
|
||||
const [closedLimit, setClosedLimit] = useState(CLOSED_INITIAL_PAGE_SIZE);
|
||||
|
||||
// Every time the user switches back to the closed tab, start with a fresh
|
||||
// window. Avoids an ever-growing subscription across a long session.
|
||||
useEffect(() => {
|
||||
if (status === "closed") {
|
||||
setClosedLimit(CLOSED_INITIAL_PAGE_SIZE);
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
const whereFilter: QueryFieldFilterConstraint =
|
||||
status === "open" ? OPEN_STATUS_FILTER : CLOSED_STATUS_FILTER;
|
||||
const limit = status === "closed" ? closedLimit : undefined;
|
||||
|
||||
const { children, isLoading, error } = useLiveParticleChildren(path, {
|
||||
orderByField: "last_child_created_at",
|
||||
orderDirection: "desc",
|
||||
visibilityScopes,
|
||||
whereFilter,
|
||||
limit,
|
||||
});
|
||||
|
||||
const streams = useMemo(
|
||||
() => children.filter((c): c is StreamParticle => c.type === "stream"),
|
||||
[children],
|
||||
);
|
||||
|
||||
// Heuristic: if we got back as many items as we asked for, assume there
|
||||
// might be more. Clicking load-more when there are no more is a no-op.
|
||||
const canLoadMore = status === "closed" && streams.length >= closedLimit;
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (status !== "closed") return;
|
||||
setClosedLimit((prev) => prev + CLOSED_PAGE_INCREMENT);
|
||||
}, [status]);
|
||||
|
||||
return { streams, isLoading, error, networkId, canLoadMore, loadMore };
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef } from "react";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import type { Particle } from "@/api/types";
|
||||
import { useLiveParticleChildren } from "@/hooks/use-particle";
|
||||
import { toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path";
|
||||
import { updateStreamPlaybackMarker } from "@/lib/firestore-particles";
|
||||
import { logError } from "@/lib/errors";
|
||||
import { useEvent } from "@/hooks/use-event";
|
||||
|
||||
// --- Playback reducer (ID-based) ---
|
||||
|
||||
type PlaybackStatus = "idle" | "playing" | "ended";
|
||||
|
||||
interface PlaybackState {
|
||||
currentParticleId: string | null;
|
||||
status: PlaybackStatus;
|
||||
initialized: boolean;
|
||||
}
|
||||
|
||||
type PlaybackAction =
|
||||
| { type: "INIT"; particleId: string }
|
||||
| { type: "SET_PARTICLE"; particleId: string }
|
||||
| { type: "END" }
|
||||
| { type: "PARTICLE_ADDED"; particleId: string }
|
||||
| {
|
||||
type: "PARTICLE_REMOVED";
|
||||
removedParticleId: string;
|
||||
fallbackParticleId: string | null;
|
||||
};
|
||||
|
||||
const initialState: PlaybackState = {
|
||||
currentParticleId: null,
|
||||
status: "idle",
|
||||
initialized: false,
|
||||
};
|
||||
|
||||
function playbackReducer(
|
||||
state: PlaybackState,
|
||||
action: PlaybackAction,
|
||||
): PlaybackState {
|
||||
switch (action.type) {
|
||||
case "INIT":
|
||||
return {
|
||||
currentParticleId: action.particleId,
|
||||
status: "playing",
|
||||
initialized: true,
|
||||
};
|
||||
case "SET_PARTICLE":
|
||||
return {
|
||||
...state,
|
||||
currentParticleId: action.particleId,
|
||||
status: "playing",
|
||||
};
|
||||
case "END":
|
||||
return { ...state, status: "ended" };
|
||||
case "PARTICLE_ADDED":
|
||||
if (state.status === "ended") {
|
||||
return {
|
||||
...state,
|
||||
currentParticleId: action.particleId,
|
||||
status: "playing",
|
||||
};
|
||||
}
|
||||
return state;
|
||||
case "PARTICLE_REMOVED":
|
||||
if (action.removedParticleId !== state.currentParticleId) return state;
|
||||
if (action.fallbackParticleId) {
|
||||
return {
|
||||
...state,
|
||||
currentParticleId: action.fallbackParticleId,
|
||||
status: "playing",
|
||||
};
|
||||
}
|
||||
return { ...state, currentParticleId: null, status: "idle" };
|
||||
}
|
||||
}
|
||||
|
||||
const INIT_FALLBACK_TIMEOUT_MS = 5000;
|
||||
|
||||
interface UseStreamPlaybackResult {
|
||||
children: Particle[];
|
||||
currentParticle: Particle | null;
|
||||
currentIndex: number;
|
||||
status: PlaybackStatus;
|
||||
initialized: boolean;
|
||||
next: () => void;
|
||||
prev: () => void;
|
||||
goTo: (index: number) => void;
|
||||
goToParticle: (particleId: string) => void;
|
||||
}
|
||||
|
||||
export function useStreamPlayback(
|
||||
streamParticle: Particle & { type: "stream" },
|
||||
path: ParticlePath,
|
||||
): UseStreamPlaybackResult {
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
const [state, dispatch] = useReducer(playbackReducer, initialState);
|
||||
// Track which stream we initialized for, so navigating to a sibling resets cleanly.
|
||||
const initializedForRef = useRef<string | null>(null);
|
||||
|
||||
const onParticleAdded = useCallback((particle: Particle) => {
|
||||
dispatch({ type: "PARTICLE_ADDED", particleId: particle.id });
|
||||
}, []);
|
||||
|
||||
const onParticleRemoved = useEvent(
|
||||
(removed: Particle, updatedChildren: Particle[]) => {
|
||||
const fallbackIndex = Math.min(currentIndex, updatedChildren.length - 1);
|
||||
const fallback = updatedChildren[Math.max(0, fallbackIndex)];
|
||||
dispatch({
|
||||
type: "PARTICLE_REMOVED",
|
||||
removedParticleId: removed.id,
|
||||
fallbackParticleId: fallback?.id ?? null,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const { children } = useLiveParticleChildren(path, {
|
||||
orderByField: "created_at",
|
||||
orderDirection: "asc",
|
||||
onAdded: onParticleAdded,
|
||||
onRemoved: onParticleRemoved,
|
||||
});
|
||||
|
||||
// Derive current index and particle from ID
|
||||
const currentIndex = useMemo(() => {
|
||||
if (!state.currentParticleId) return -1;
|
||||
return children.findIndex((c) => c.id === state.currentParticleId);
|
||||
}, [children, state.currentParticleId]);
|
||||
|
||||
const currentParticle = currentIndex !== -1 ? children[currentIndex] : null;
|
||||
|
||||
const initFallback = useEvent(() => {
|
||||
if (state.initialized || children.length === 0) return;
|
||||
initializedForRef.current = streamParticle.id;
|
||||
dispatch({ type: "INIT", particleId: children[0].id });
|
||||
});
|
||||
|
||||
// --- Init logic: runs on every children change until initialized ---
|
||||
useEffect(() => {
|
||||
if (
|
||||
initializedForRef.current !== null &&
|
||||
initializedForRef.current !== streamParticle.id
|
||||
) {
|
||||
initializedForRef.current = null;
|
||||
}
|
||||
|
||||
if (state.initialized && initializedForRef.current === streamParticle.id)
|
||||
return;
|
||||
|
||||
if (children.length === 0) return;
|
||||
|
||||
const playbackPosition = streamParticle.playback_markers?.[userId ?? ""];
|
||||
|
||||
if (!playbackPosition) {
|
||||
initializedForRef.current = streamParticle.id;
|
||||
dispatch({ type: "INIT", particleId: children[0].id });
|
||||
return;
|
||||
}
|
||||
|
||||
const found = children.find(
|
||||
(c) => c.created_at.getTime() > playbackPosition.getTime(),
|
||||
);
|
||||
|
||||
if (found) {
|
||||
initializedForRef.current = streamParticle.id;
|
||||
dispatch({ type: "INIT", particleId: found.id });
|
||||
return;
|
||||
} else {
|
||||
initializedForRef.current = streamParticle.id;
|
||||
dispatch({
|
||||
type: "INIT",
|
||||
particleId: children[children.length - 1].id,
|
||||
});
|
||||
}
|
||||
|
||||
const timeout = setTimeout(initFallback, INIT_FALLBACK_TIMEOUT_MS);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [
|
||||
children,
|
||||
streamParticle.id,
|
||||
streamParticle.playback_markers,
|
||||
userId,
|
||||
state.initialized,
|
||||
initFallback,
|
||||
]);
|
||||
|
||||
// --- Persist playback marker (only advance forward, never backwards) ---
|
||||
const lastPersistedMarkerRef = useRef<Date | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId || !state.initialized || !currentParticle) return;
|
||||
|
||||
const currentTime = currentParticle.created_at;
|
||||
const existingMarker =
|
||||
lastPersistedMarkerRef.current ??
|
||||
streamParticle.playback_markers?.[userId];
|
||||
|
||||
if (existingMarker && currentTime.getTime() <= existingMarker.getTime())
|
||||
return;
|
||||
|
||||
lastPersistedMarkerRef.current = currentTime;
|
||||
const streamDocPath = toFirestoreDocPath(path);
|
||||
updateStreamPlaybackMarker(streamDocPath, userId, currentTime).catch(
|
||||
(err) => logError(err, { scope: "playback.marker", path }),
|
||||
);
|
||||
// streamParticle.playback_markers is read at effect time; not in deps to
|
||||
// avoid double-writes when the snapshot we just persisted echoes back.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentParticle?.id, state.initialized, userId, path]);
|
||||
|
||||
// --- Navigation callbacks ---
|
||||
const next = useCallback(() => {
|
||||
if (currentIndex === -1) return;
|
||||
if (currentIndex < children.length - 1) {
|
||||
dispatch({
|
||||
type: "SET_PARTICLE",
|
||||
particleId: children[currentIndex + 1].id,
|
||||
});
|
||||
} else {
|
||||
dispatch({ type: "END" });
|
||||
}
|
||||
}, [children, currentIndex]);
|
||||
|
||||
const prev = useCallback(() => {
|
||||
if (currentIndex <= 0) return;
|
||||
dispatch({
|
||||
type: "SET_PARTICLE",
|
||||
particleId: children[currentIndex - 1].id,
|
||||
});
|
||||
}, [children, currentIndex]);
|
||||
|
||||
const goTo = useCallback(
|
||||
(index: number) => {
|
||||
if (index >= 0 && index < children.length) {
|
||||
dispatch({ type: "SET_PARTICLE", particleId: children[index].id });
|
||||
}
|
||||
},
|
||||
[children],
|
||||
);
|
||||
|
||||
// If the particle isn't in `children` yet (e.g. just-created), the live
|
||||
// query will resolve it shortly and the derived index/particle will catch up.
|
||||
const goToParticle = useCallback((particleId: string) => {
|
||||
dispatch({ type: "SET_PARTICLE", particleId });
|
||||
}, []);
|
||||
|
||||
return {
|
||||
children,
|
||||
currentParticle,
|
||||
currentIndex,
|
||||
status: state.status,
|
||||
initialized: state.initialized,
|
||||
next,
|
||||
prev,
|
||||
goTo,
|
||||
goToParticle,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useEffect, useId } from "react";
|
||||
import { usePlaybackPauseStore } from "@/stores/playback-pause-store";
|
||||
|
||||
/**
|
||||
* Suspend stream playback while `active` is true. The hook owns its own
|
||||
* registration id; multiple instances compose. `label` is for devtools only.
|
||||
*/
|
||||
export function useSuspendPlayback(active: boolean, label: string) {
|
||||
const id = useId();
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const { add, remove } = usePlaybackPauseStore.getState();
|
||||
add(id, label);
|
||||
return () => remove(id);
|
||||
}, [active, id, label]);
|
||||
}
|
||||
Reference in New Issue
Block a user