refactor: organize desktop vs. mobile into separate folders

This commit is contained in:
Arjun Patel
2026-04-29 08:42:56 -07:00
parent 3d9fe67936
commit 3a11a82cd3
194 changed files with 213 additions and 213 deletions
+29
View File
@@ -0,0 +1,29 @@
import { useQuery, useMutation } from "@tanstack/react-query";
import { apiClient } from "@/api/client";
import type { BillingCadence } from "@/api/types";
export function useNetworkBilling(networkId: string | undefined) {
return useQuery({
queryKey: ["network-billing", networkId],
queryFn: () => apiClient.getNetworkBilling(networkId!),
enabled: !!networkId,
// Refetch on window focus so the UI catches up after the user returns
// from Stripe Checkout (webhook may land a second or two later).
// FIX: doesn't work with electron
refetchOnWindowFocus: true,
refetchInterval: 10000
});
}
export function useCreateCheckoutSession(networkId: string) {
return useMutation({
mutationFn: (cadence: BillingCadence) =>
apiClient.createCheckoutSession(networkId, cadence),
});
}
export function useCreatePortalSession(networkId: string) {
return useMutation({
mutationFn: () => apiClient.createPortalSession(networkId),
});
}
+85
View File
@@ -0,0 +1,85 @@
import { useEffect, useState, useCallback, useRef } 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,79 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { createParticle, createStreamParticle } from "@/lib/firestore-particles";
import { CONTAINER_TYPES, type NetworkUsage, type ParticleType, type ParticlePropertiesMap } from "@/api/types";
import { parseParticlePath, particlePath, ParticlePath, toFirestoreChildrenPath } from "@/lib/particle-path";
import { QuotaExceededError } from "@/lib/errors";
import {
isUsageExhausted,
networkUsageQueryKey,
useBumpNetworkUsage,
useInvalidateNetworkUsage,
} from "./use-network-usage";
interface CreateParticleParams<T extends ParticleType = ParticleType> {
// Path to which the new particle will be added as a child
path: ParticlePath;
type: T;
properties: ParticlePropertiesMap[T];
createdByHumanId: string;
}
export function useCreateParticle() {
const qc = useQueryClient();
const bumpUsage = useBumpNetworkUsage();
const invalidateUsage = useInvalidateNetworkUsage();
return useMutation({
// Compose UI renders a custom quota-exceeded toast + cancels the overlay.
// Opt out of the global mutation error toast to avoid a double-toast.
meta: { suppressToast: true },
mutationFn: async (params: CreateParticleParams) => {
const { networkId } = parseParticlePath(params.path);
// Containers aren't counted server-side, so we block them here
if (!CONTAINER_TYPES.has(params.type)) {
const cached = qc.getQueryData<NetworkUsage>(networkUsageQueryKey(networkId));
if (isUsageExhausted(cached)) {
throw new QuotaExceededError(networkId);
}
}
const collectionPath = toFirestoreChildrenPath(params.path);
const result = await createParticle(
collectionPath,
params.type,
params.properties,
params.createdByHumanId,
);
if (!CONTAINER_TYPES.has(params.type)) {
bumpUsage(networkId);
void invalidateUsage(networkId);
}
return result;
},
});
}
type CreateStreamParticleParams = {
networkId: string;
properties: ParticlePropertiesMap["stream"];
createdByHumanId: string;
visibleTo?: string[];
};
export function useCreateStreamParticle() {
return useMutation({
mutationFn: async (params: CreateStreamParticleParams) => {
const path = particlePath(params.networkId, []);
const networkCollectionPath = toFirestoreChildrenPath(path);
return await createStreamParticle(
networkCollectionPath,
params.properties,
params.createdByHumanId,
params.visibleTo,
);
}
});
}
+55
View File
@@ -0,0 +1,55 @@
import { useEffect, useMemo } from "react";
import { where } from "firebase/firestore";
import { useLiveParticleChildren } from "@/hooks/use-particle";
import { useAuthStore } from "@/stores/auth-store";
import { particlePath } from "@/lib/particle-path";
import type { Particle, StreamProperties } from "@/api/types";
type StreamParticle = Particle & { type: "stream"; properties: StreamProperties };
const openStatusFilter = where("status", "==", "open");
/**
* Self-contained hook that syncs the macOS dock badge with the count of
* unseen open streams the current user is involved in.
*
* Sets up its own Firestore listener so it works independently of
* whatever stream list is rendered on screen.
*/
export function useDockBadge(networkId: string | undefined) {
const user = useAuthStore((s) => s.user);
const userId = user?.id;
const visibilityScopes = useMemo(() => {
const scopes: string[] = [];
if (userId) scopes.push(`human:${userId}`);
if (networkId) scopes.push(`network:${networkId}`);
return scopes;
}, [userId, networkId]);
const path = networkId ? particlePath(networkId, []) : undefined;
const { children } = useLiveParticleChildren(path, {
orderByField: "last_child_created_at",
orderDirection: "desc",
visibilityScopes,
whereFilter: openStatusFilter,
});
const unseenCount = useMemo(() => {
if (!userId) return 0;
return children.filter((c): c is StreamParticle => {
if (c.type !== "stream") return false;
const lastActivity = c.last_child_created_at?.getTime();
if (!lastActivity) return false;
const marker = c.playback_markers?.[userId]?.getTime();
if (marker === undefined) return false;
return lastActivity > marker;
}).length;
}, [children, userId]);
useEffect(() => {
window.electronApp.setDockBadge(unseenCount);
return () => window.electronApp.setDockBadge(0);
}, [unseenCount]);
}
+11
View File
@@ -0,0 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "@/api/client";
export function useDownloadUrl(objectId?: string) {
return useQuery({
queryKey: ["download-url", objectId],
queryFn: () => apiClient.getParticleDownloadUrl(objectId!),
enabled: !!objectId,
staleTime: 1000 * 60 * 60, // 1 hour — signed URLs valid for 24h
});
}
@@ -0,0 +1,24 @@
import type { SavedDevice } from "@/stores/media-devices-store";
/**
* Resolves a saved device preference against the currently available
* devices. Returns the saved `deviceId` only if it still appears in the
* list — otherwise `undefined` so getUserMedia falls back to the
* system default. This keeps "unplugged device" handling in one place.
*/
export function resolveEffectiveDeviceId(
saved: SavedDevice | null,
available: MediaDeviceInfo[],
): string | undefined {
if (!saved) return undefined;
const match = available.find((d) => d.deviceId === saved.deviceId);
return match ? match.deviceId : undefined;
}
export function isSavedDeviceAvailable(
saved: SavedDevice | null,
available: MediaDeviceInfo[],
): boolean {
if (!saved) return true;
return available.some((d) => d.deviceId === saved.deviceId);
}
+109
View File
@@ -0,0 +1,109 @@
import { useCallback, useEffect, useRef, useState } from "react";
interface UseFileInputOptions {
onFilesSelected: (files: File[]) => void;
enabled: boolean;
}
export function useFileInput({ onFilesSelected, enabled }: UseFileInputOptions) {
const [isDragging, setIsDragging] = useState(false);
const inputRef = useRef<HTMLInputElement | null>(null);
const dragCountRef = useRef(0);
// Stable ref for the callback to avoid re-registering effects
const onFilesRef = useRef(onFilesSelected);
onFilesRef.current = onFilesSelected;
// Hidden file input element
useEffect(() => {
const input = document.createElement("input");
input.type = "file";
input.multiple = true;
input.style.display = "none";
input.addEventListener("change", () => {
if (input.files?.length) {
onFilesRef.current(Array.from(input.files));
input.value = "";
}
});
document.body.appendChild(input);
inputRef.current = input;
return () => {
document.body.removeChild(input);
inputRef.current = null;
};
}, []);
const openFilePicker = useCallback(() => {
inputRef.current?.click();
}, []);
// Clipboard paste
useEffect(() => {
if (!enabled) return;
const handlePaste = (e: ClipboardEvent) => {
const files = Array.from(e.clipboardData?.files ?? []);
if (files.length > 0) {
e.preventDefault();
onFilesRef.current(files);
}
};
window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste);
}, [enabled]);
// Drag and drop handlers
const onDragOver = useCallback(
(e: React.DragEvent) => {
if (!enabled) return;
e.preventDefault();
e.stopPropagation();
},
[enabled],
);
const onDragEnter = useCallback(
(e: React.DragEvent) => {
if (!enabled) return;
e.preventDefault();
e.stopPropagation();
dragCountRef.current++;
if (dragCountRef.current === 1) setIsDragging(true);
},
[enabled],
);
const onDragLeave = useCallback(
(e: React.DragEvent) => {
if (!enabled) return;
e.preventDefault();
e.stopPropagation();
dragCountRef.current--;
if (dragCountRef.current === 0) setIsDragging(false);
},
[enabled],
);
const onDrop = useCallback(
(e: React.DragEvent) => {
if (!enabled) return;
e.preventDefault();
e.stopPropagation();
dragCountRef.current = 0;
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
onFilesRef.current(files);
}
},
[enabled],
);
return {
openFilePicker,
isDragging,
dropZoneProps: { onDragOver, onDragEnter, onDragLeave, onDrop },
};
}
+45
View File
@@ -0,0 +1,45 @@
import { useQueries, useQuery } from "@tanstack/react-query";
import { extractUrls, type LinkMetadata } from "@/lib/link-metadata";
export function useLinkMetadata(url: string | null) {
return useQuery<LinkMetadata | null>({
queryKey: ["link-metadata", url],
queryFn: () => window.electronLink.fetchMetadata(url!),
enabled: !!url,
staleTime: Infinity,
gcTime: 30 * 60 * 1000,
retry: 1,
});
}
export function useFirstLinkMetadata(text: string) {
const urls = extractUrls(text);
const firstUrl = urls[0] ?? null;
return { ...useLinkMetadata(firstUrl), url: firstUrl };
}
export interface LinkPreviewEntry {
url: string;
metadata: LinkMetadata | null | undefined;
isLoading: boolean;
}
export function useAllLinkMetadata(text: string): LinkPreviewEntry[] {
const urls = extractUrls(text);
const results = useQueries({
queries: urls.map((url) => ({
queryKey: ["link-metadata", url],
queryFn: () => window.electronLink.fetchMetadata(url),
staleTime: Infinity,
gcTime: 30 * 60 * 1000,
retry: 1,
})),
});
return urls.map((url, i) => ({
url,
metadata: results[i].data,
isLoading: results[i].isLoading,
}));
}
+83
View File
@@ -0,0 +1,83 @@
import { useCallback, useEffect, useState } from "react";
export type PermissionState = "unknown" | "granted" | "denied";
interface UseMediaDevicesResult {
audioInputs: MediaDeviceInfo[];
videoInputs: MediaDeviceInfo[];
permissionState: PermissionState;
refresh: () => Promise<void>;
requestLabels: () => Promise<void>;
error: string | null;
}
/**
* Enumerates input devices and stays subscribed to `devicechange`.
*
* Labels are only populated after the user has granted mic/camera
* permission — `requestLabels` triggers a brief getUserMedia so that
* subsequent enumerations return human-readable names, matching the
* pattern most video-conferencing apps use.
*/
export function useMediaDevices(): UseMediaDevicesResult {
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
const [permissionState, setPermissionState] =
useState<PermissionState>("unknown");
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
try {
const list = await navigator.mediaDevices.enumerateDevices();
setDevices(list);
// If at least one input device has a non-empty label, permission
// has been granted at some point for that device kind.
const hasLabels = list.some(
(d) =>
(d.kind === "audioinput" || d.kind === "videoinput") &&
d.label.length > 0,
);
if (hasLabels) setPermissionState("granted");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to list devices");
}
}, []);
const requestLabels = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: true,
});
// Immediately stop — we only needed the permission grant.
stream.getTracks().forEach((t) => t.stop());
setPermissionState("granted");
setError(null);
await refresh();
} catch (err) {
setPermissionState("denied");
setError(
err instanceof Error ? err.message : "Microphone/camera access denied",
);
}
}, [refresh]);
useEffect(() => {
refresh();
const handle = () => {
refresh();
};
navigator.mediaDevices.addEventListener("devicechange", handle);
return () => {
navigator.mediaDevices.removeEventListener("devicechange", handle);
};
}, [refresh]);
return {
audioInputs: devices.filter((d) => d.kind === "audioinput"),
videoInputs: devices.filter((d) => d.kind === "videoinput"),
permissionState,
refresh,
requestLabels,
error,
};
}
@@ -0,0 +1,62 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiClient } from "@/api/client";
export function useMyInvitations() {
return useQuery({
queryKey: ["my-invitations"],
queryFn: () => apiClient.listMyInvitations(),
refetchInterval: 10_000,
});
}
export function useNetworkInvitations(networkId: string) {
return useQuery({
queryKey: ["network-invitations", networkId],
queryFn: () => apiClient.listNetworkInvitations(networkId),
});
}
export function useInviteMembers(networkId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (emailAddresses: string[]) =>
apiClient.addMembers(networkId, { email_addresses: emailAddresses }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["network-invitations", networkId] });
queryClient.invalidateQueries({ queryKey: ["networks"] });
},
});
}
export function useAcceptInvitation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (networkId: string) =>
apiClient.acceptInvitation({ network_id: networkId }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["my-invitations"] });
queryClient.invalidateQueries({ queryKey: ["networks"] });
},
});
}
export function useRevokeInvitation(networkId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (email: string) =>
apiClient.revokeInvitation(networkId, { email }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["network-invitations", networkId] });
},
});
}
export function useRemoveMember(networkId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (humanId: string) => apiClient.removeMember(networkId, humanId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["networks"] });
},
});
}
+58
View File
@@ -0,0 +1,58 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback } from "react";
import { apiClient } from "@/api/client";
import type { NetworkUsage } from "@/api/types";
export const networkUsageQueryKey = (networkId: string | undefined) =>
["network-usage", networkId] as const;
export function useNetworkUsage(networkId: string | undefined) {
return useQuery({
queryKey: networkUsageQueryKey(networkId),
queryFn: () => apiClient.getNetworkUsage(networkId!),
enabled: !!networkId,
// Refetch whenever a consumer mounts (billing settings, compose indicator)
// so users land on fresh quota state without listener wiring.
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchInterval: 10000
});
}
/**
* Returns a callback that invalidates the usage query for a network.
* Callers: own-send success path, inbound-particle listener.
*/
export function useInvalidateNetworkUsage() {
const qc = useQueryClient();
return useCallback(
(networkId: string) =>
qc.invalidateQueries({ queryKey: networkUsageQueryKey(networkId) }),
[qc],
);
}
/**
* Optimistic bump of the cached `used` count. The worker-written truth is
* reconciled on the next invalidation/refetch.
*/
export function useBumpNetworkUsage() {
const qc = useQueryClient();
return useCallback(
(networkId: string) => {
qc.setQueryData<NetworkUsage>(networkUsageQueryKey(networkId), (prev) =>
prev ? { ...prev, used: prev.used + 1 } : prev,
);
},
[qc],
);
}
/**
* True iff the network is on the free plan and has exhausted today's quota.
*/
export function isUsageExhausted(usage: NetworkUsage | undefined): boolean {
if (!usage) return false;
if (usage.limit == null) return false;
return usage.used >= usage.limit;
}
+22
View File
@@ -0,0 +1,22 @@
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(),
});
}
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,29 @@
import { useMemo } from "react";
import type { Particle } from "@/api/types";
import { useLiveParticleChildren } from "@/hooks/use-particle";
import { particlePath, type ParticlePath, parseParticlePath } from "@/lib/particle-path";
type FileParticle = Extract<Particle, { type: "file" }>;
/**
* Fetches file children (attachments) of a particle in a stream.
* Uses a one-shot query since attachments are immutable after creation.
*/
export function useParticleAttachments(
streamPath: ParticlePath,
particleId: string,
) {
const childrenPath = useMemo(() => {
const { networkId, segments } = parseParticlePath(streamPath);
return particlePath(networkId, [...segments, particleId]);
}, [streamPath, particleId]);
const { children, isLoading } = useLiveParticleChildren(childrenPath);
const attachments = useMemo(
() => (children ?? []).filter((c): c is FileParticle => c.type === "file"),
[children],
);
return { attachments, isLoading };
}
+181
View File
@@ -0,0 +1,181 @@
import { useState, useEffect } from "react";
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 { useQuery } from "@tanstack/react-query";
import { QueryFieldFilterConstraint } from "firebase/firestore";
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) => {
console.warn(err);
setError(err);
setIsLoading(false);
},
visibilityScopes,
orderByField,
orderDirection,
onAdded,
onRemoved,
whereFilter,
limit,
}
);
return unsubscribe;
}, [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);
},
() => {
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
});
}
+92
View File
@@ -0,0 +1,92 @@
import { useEffect, useRef, useState, type RefObject } from "react";
import type { MediaParticleHandle } from "@/features/particles/media-particle-view";
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
import { selectIsPaused, usePlaybackPauseStore } from "@/stores/playback-pause-store";
import { isTypingTarget } from "@/lib/keyboard";
import { set } from "zod";
const SPACE_TAP_THRESHOLD_MS = 250;
interface UsePlaybackKeysOptions {
mediaRef: RefObject<MediaParticleHandle | null>;
}
interface UsePlaybackKeysResult {
fastPlayback: boolean;
}
/**
* Hold-space to pause, hold-shift for 1.5x. Space is always handled (it
* manages its own suspender via useSuspendPlayback); other keys bail when
* playback is already paused for an external reason.
*/
export function usePlaybackKeys({ mediaRef }: UsePlaybackKeysOptions): UsePlaybackKeysResult {
const [spaceHeld, setSpaceHeld] = useState(false);
const [fastPlayback, setFastPlayback] = useState(false);
const spaceStartRef = useRef(0);
useSuspendPlayback(spaceHeld, "hold-space");
useEffect(() => {
const isExternallyPaused = () =>
selectIsPaused(usePlaybackPauseStore.getState()) && !spaceHeld;
const onKeyDown = (e: KeyboardEvent) => {
if (isTypingTarget(e)) return;
if (e.key === " ") {
e.preventDefault();
if (!e.repeat) {
if (spaceHeld) {
setSpaceHeld(false);
spaceStartRef.current = 0;
} else {
setSpaceHeld(true);
spaceStartRef.current = Date.now();
}
}
return;
}
if (isExternallyPaused()) return;
if (e.key === "Shift" && !e.repeat) {
mediaRef.current?.setPlaybackRate(1.5);
setFastPlayback(true);
}
};
const onKeyUp = (e: KeyboardEvent) => {
if (isTypingTarget(e)) return;
if (e.key === " ") {
e.preventDefault();
// keep space-held if it was a quick tap, to allow for space-to-toggle behavior
if (Date.now() - spaceStartRef.current < SPACE_TAP_THRESHOLD_MS) {
return;
}
setSpaceHeld(false);
spaceStartRef.current = 0;
return;
}
if (isExternallyPaused()) return;
if (e.key === "Shift") {
mediaRef.current?.setPlaybackRate(1);
setFastPlayback(false);
}
};
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
return () => {
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
};
}, [mediaRef, spaceHeld]);
return { fastPlayback };
}
@@ -0,0 +1,46 @@
import { useEffect } from "react";
import { preload } from "react-dom";
import { useQueryClient } from "@tanstack/react-query";
import type { Particle } from "@/api/types";
import { apiClient } from "@/api/client";
// TODO: verify that caching is actually working by slowing down our network to simulate
/**
* Prefetches download URLs and warms the browser cache for adjacent media particles.
*/
export function usePrefetchAdjacentMedia(
children: Particle[],
currentIndex: number,
) {
const queryClient = useQueryClient();
useEffect(() => {
const adjacentIndices = [currentIndex - 1, currentIndex + 1];
const mediaParticles = adjacentIndices
.filter((i) => i >= 0 && i < children.length)
.map((i) => children[i])
.filter(
(p): p is Extract<Particle, { type: "media" }> => p.type === "media",
);
for (const particle of mediaParticles) {
const objectId = particle.properties.object_id;
queryClient
.prefetchQuery({
queryKey: ["download-url", objectId],
queryFn: () => apiClient.getParticleDownloadUrl(objectId),
staleTime: 1000 * 60 * 60,
})
.then(() => {
const url = queryClient.getQueryData<string>([
"download-url",
objectId,
]);
if (url) {
preload(url, { as: "fetch", crossOrigin: "anonymous" });
}
});
}
}, [children, currentIndex, queryClient]);
}
@@ -0,0 +1,55 @@
import { useMemo } from "react";
import type { Human, Particle } from "@/api/types";
export interface HumanPresence {
humanId: string;
email: string;
emailPrefix: string;
}
/**
* Maps playback markers to segment indices, returning which users are present at each particle.
*/
export function usePresencePositions(
playbackMarkers: Record<string, Date> | undefined,
children: Particle[],
networkHumans: Human[] | undefined,
currentUserId: string | undefined,
): Map<number, HumanPresence[]> {
return useMemo(() => {
const result = new Map<number, HumanPresence[]>();
if (!playbackMarkers || !networkHumans || children.length === 0) return result;
for (const [userId, markerTimestamp] of Object.entries(playbackMarkers)) {
if (userId === currentUserId) continue;
const human = networkHumans.find((h) => h.id === userId);
if (!human) continue;
// Find the last particle whose created_at <= marker timestamp
let segmentIndex = -1;
for (let i = children.length - 1; i >= 0; i--) {
if (children[i].created_at.getTime() <= markerTimestamp.getTime()) {
segmentIndex = i;
break;
}
}
if (segmentIndex === -1) continue;
const existing = result.get(segmentIndex);
const presence: HumanPresence = { humanId: human.id, email: human.email, emailPrefix: human.email_prefix };
if (existing) {
existing.push(presence);
} else {
result.set(segmentIndex, [presence]);
}
}
// Sort each segment's presence list by humanId for stable render order
for (const presenceList of result.values()) {
presenceList.sort((a, b) => a.humanId.localeCompare(b.humanId));
}
return result;
}, [playbackMarkers, children, networkHumans, currentUserId]);
}
@@ -0,0 +1,19 @@
import { useState, useCallback } from "react";
export type RecordingMode = "video" | "audio";
const KEY = "llink:recording-mode";
export function useRecordingMode(): [RecordingMode, (mode: RecordingMode) => void] {
const [mode, setModeState] = useState<RecordingMode>(() => {
const stored = localStorage.getItem(KEY);
return stored === "audio" ? "audio" : "video";
});
const setMode = useCallback((m: RecordingMode) => {
localStorage.setItem(KEY, m);
setModeState(m);
}, []);
return [mode, setMode];
}
@@ -0,0 +1,63 @@
import { useEffect } from "react";
import { REACTION_EMOJIS } from "@/api/types";
import { selectIsPaused, usePlaybackPauseStore } from "@/stores/playback-pause-store";
import { isTypingTarget } from "@/lib/keyboard";
interface UseStreamActionKeysOptions {
onToggleReaction: (emoji: string) => void;
onOpenHuddle: () => void;
onToggleRecordingMode: () => void;
onToggleKeybindings: () => void;
onOpenTextReaction: () => void;
}
/**
* Reactions 17, `r` quick text reply, `h` huddle, `v` toggle recording mode,
* `?` toggle keybindings overlay. Skipped while playback is paused for any reason.
*/
export function useStreamActionKeys({
onToggleReaction,
onOpenHuddle,
onToggleRecordingMode,
onToggleKeybindings,
onOpenTextReaction,
}: UseStreamActionKeysOptions) {
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (isTypingTarget(e)) return;
if (selectIsPaused(usePlaybackPauseStore.getState())) return;
switch (e.key) {
case "h":
e.preventDefault();
onOpenHuddle();
break;
case "v":
e.preventDefault();
onToggleRecordingMode();
break;
case "r":
e.preventDefault();
onOpenTextReaction();
break;
case "?":
e.preventDefault();
onToggleKeybindings();
break;
case "1":
case "2":
case "3":
case "4":
case "5":
case "6":
case "7":
e.preventDefault();
onToggleReaction(REACTION_EMOJIS[parseInt(e.key) - 1]);
break;
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [onToggleReaction, onOpenHuddle, onToggleRecordingMode, onToggleKeybindings, onOpenTextReaction]);
}
@@ -0,0 +1,72 @@
import { useEffect, useRef } from "react";
import beepSound from "../../assets/sound.wav";
import type { Network, Particle, StreamProperties } from "@/api/types";
import { apiClient } from "@/api/client";
import { useAuthStore } from "@/stores/auth-store";
import { useAutoplayStore } from "@/stores/autoplay-store";
import { resolveHumanDisplay } from "@/lib/humans";
import { logError } from "@/lib/errors";
/**
* Triggers autoplay when a stream's latest child changes to a new media particle.
* Plays a beep sound for new text particles from other users.
* Sends autoplay data to the separate autoplay window via IPC.
*/
export function useStreamAutoplay(
latestChild: Particle | null,
streamParticle: Particle & { type: "stream"; properties: StreamProperties },
networkId: string,
network: Network | undefined,
) {
const userId = useAuthStore((s) => s.user?.id) ?? "";
const settledIdRef = useRef<string | undefined>(undefined);
useEffect(() => {
if (!latestChild) return;
// First real value: record as baseline, don't autoplay
if (settledIdRef.current === undefined) {
settledIdRef.current = latestChild.id;
return;
}
if (latestChild.id === settledIdRef.current) return;
settledIdRef.current = latestChild.id;
if (latestChild.created_by_human_id === userId) return;
if (useAutoplayStore.getState().muted) return;
if (latestChild.type === "text") {
// Browser autoplay policy can block this before user interaction; that's
// fine — the beep is a nice-to-have, not a critical signal.
new Audio(beepSound).play().catch((err) =>
logError(err, { scope: "autoplay.beep" }),
);
return;
}
if (latestChild.type !== "media") return;
const particle = latestChild;
const { displayName, initials } = resolveHumanDisplay(
particle.created_by_human_id,
network?.humans,
);
apiClient.getParticleDownloadUrl(particle.properties.object_id).then((downloadUrl) => {
window.electronAutoplay.play({
particleId: particle.id,
streamId: streamParticle.id,
networkId,
downloadUrl,
mimeType: particle.properties.mime_type,
durationMs: particle.properties.duration_ms,
senderName: displayName,
senderInitials: initials,
});
}).catch((err) =>
logError(err, { scope: "autoplay.fetchUrl", particleId: particle.id }),
);
}, [latestChild?.id]); // eslint-disable-line react-hooks/exhaustive-deps
}
@@ -0,0 +1,97 @@
import { useEffect, useState } from "react";
import { useMediaSettingsStore } from "@/stores/media-settings-store";
interface UseStreamKeyboardNavOptions {
streams: Array<{ id: string }>;
enabled: boolean;
onNavigate: (streamId: string) => void;
}
export function useStreamKeyboardNav({
streams,
enabled,
onNavigate,
}: UseStreamKeyboardNavOptions) {
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
const setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode);
// Initialize selection when streams first load; clear if streams become empty.
// Do NOT reset on every Firestore update — that would scroll the list to the top.
useEffect(() => {
setSelectedIndex((prev) => {
if (streams.length === 0) return null;
if (prev === null) return 0;
return prev;
});
}, [streams.length]);
useEffect(() => {
if (!enabled || streams.length === 0) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.defaultPrevented) return;
const target = e.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable
) {
return;
}
// Number keys 1-9: immediate navigation
const digit = parseInt(e.key, 10);
if (digit >= 1 && digit <= 9) {
const index = digit - 1;
if (index < streams.length) {
e.preventDefault();
onNavigate(streams[index].id);
}
return;
}
// Enter: navigate to selected
if (e.key === "Enter") {
setSelectedIndex((idx) => {
if (idx !== null && idx < streams.length) {
e.preventDefault();
onNavigate(streams[idx].id);
}
return idx;
});
return;
}
// V: toggle video / audio
if (e.key === "v" || e.key === "V") {
e.preventDefault();
setRecordingMode(recordingMode === "video" ? "audio" : "video");
return;
}
// Arrow keys: move selection
let delta: number | null = null;
if (e.key === "ArrowDown") delta = 1;
else if (e.key === "ArrowUp") delta = -1;
if (delta !== null) {
e.preventDefault();
setSelectedIndex((prev) => {
if (prev === null) return 0;
const next = prev + delta;
return Math.max(0, Math.min(next, streams.length - 1));
});
}
};
// Use capture phase so arrow keys are intercepted before Radix UI
// components (ToggleGroup, etc.) consume them for their own navigation.
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [enabled, streams, onNavigate, recordingMode, setRecordingMode]);
return { selectedIndex };
}
@@ -0,0 +1,65 @@
import { useEffect, type RefObject } from "react";
import { useNavigate } from "react-router-dom";
import type { MediaParticleHandle } from "@/features/particles/media-particle-view";
import { selectIsPaused, usePlaybackPauseStore } from "@/stores/playback-pause-store";
import { isTypingTarget } from "@/lib/keyboard";
const SEEK_DELTA_SEC = 5;
interface UseStreamNavigationKeysOptions {
next: () => void;
prev: () => void;
currentIndex: number;
childrenLength: number;
mediaRef: RefObject<MediaParticleHandle | null>;
}
/**
* Arrow keys (with shift+arrow seek), Escape. Skipped while playback is
* paused for any reason (overlay, hold-space, compose).
*/
export function useStreamNavigationKeys({
next,
prev,
currentIndex,
childrenLength,
mediaRef,
}: UseStreamNavigationKeysOptions) {
const navigate = useNavigate();
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (isTypingTarget(e)) return;
const hasNext = currentIndex >= 0 && currentIndex < childrenLength - 1;
switch (e.key) {
case "ArrowRight":
e.preventDefault();
if (!e.shiftKey || !mediaRef.current?.seek(SEEK_DELTA_SEC)) {
if (hasNext) next();
}
break;
case "ArrowDown":
e.preventDefault();
if (hasNext) next();
break;
case "ArrowLeft":
e.preventDefault();
if (!e.shiftKey || !mediaRef.current?.seek(-SEEK_DELTA_SEC)) prev();
break;
case "ArrowUp":
e.preventDefault();
prev();
break;
case "Escape":
e.preventDefault();
navigate(-1);
break;
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [next, prev, currentIndex, childrenLength, mediaRef, navigate]);
}
@@ -0,0 +1,91 @@
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;
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 } = 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, networkId, canLoadMore, loadMore };
}
+229
View File
@@ -0,0 +1,229 @@
import { useCallback, useEffect, useEffectEvent, 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";
// --- 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" };
}
}
// --- Init timeout ---
const INIT_FALLBACK_TIMEOUT_MS = 5000;
// --- Hook ---
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 the stream ID we've initialized for, to reset when navigating between streams
const initializedForRef = useRef<string | null>(null);
// --- Firestore change callbacks ---
const onParticleAdded = useCallback((particle: Particle) => {
dispatch({ type: "PARTICLE_ADDED", particleId: particle.id });
}, []);
const onParticleRemoved = useEffectEvent((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;
// Fallback init — always sees latest children/state via useEffectEvent
const initFallback = useEffectEvent(() => {
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(() => {
// Reset if we navigated to a different stream
if (initializedForRef.current !== null && initializedForRef.current !== streamParticle.id) {
initializedForRef.current = null;
}
// Already initialized for this stream
if (state.initialized && initializedForRef.current === streamParticle.id) return;
if (children.length === 0) return;
const playbackPosition = streamParticle.playback_markers?.[userId ?? ""];
if (!playbackPosition) {
// No marker — start from the beginning
initializedForRef.current = streamParticle.id;
dispatch({ type: "INIT", particleId: children[0].id });
return;
}
// Try to find the marker's target particle
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 });
}
// Marker target not found yet — fall back after timeout
const timeout = setTimeout(initFallback, INIT_FALLBACK_TIMEOUT_MS);
return () => clearTimeout(timeout);
}, [children, streamParticle.id, streamParticle.playback_markers, userId, state.initialized]);
// --- 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];
// Only update if advancing beyond the current marker
if (existingMarker && currentTime.getTime() <= existingMarker.getTime()) return;
lastPersistedMarkerRef.current = currentTime;
const streamDocPath = toFirestoreDocPath(path);
updateStreamPlaybackMarker(streamDocPath, userId, currentTime);
}, [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],
);
// NOTE: if particle doesn't exist in children, this will lead to a brief moment where currentParticle is null
const goToParticle = useCallback(
(particleId: string) => {
// Dispatch directly by ID — if the particle isn't in children yet
// (e.g. just created), it will resolve once the live query delivers it.
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]);
}
@@ -0,0 +1,49 @@
import { useMemo } from "react";
import type { Transcript } from "@/api/types";
type Sentence = Transcript["paragraphs"][number]["sentences"][number];
interface TranscriptPlaybackState {
/** The sentence currently being spoken, or null if between sentences */
activeSentence: Sentence | null;
/** Index of the active word within the transcript's flat words array */
activeWordIndex: number | null;
}
export function useTranscriptPlayback(
transcript: Transcript | undefined,
currentTime: number,
): TranscriptPlaybackState {
return useMemo(() => {
if (!transcript) return { activeSentence: null, activeWordIndex: null };
// Find the active sentence across all paragraphs
let activeSentence: Sentence | null = null;
for (const paragraph of transcript.paragraphs) {
const sentence = paragraph.sentences.find(
(s) => currentTime >= s.start && currentTime <= s.end,
);
if (sentence) {
activeSentence = sentence;
break;
}
}
// Binary search for active word
const words = transcript.words;
let activeWordIndex: number | null = null;
let lo = 0;
let hi = words.length - 1;
while (lo <= hi) {
const mid = (lo + hi) >>> 1;
if (currentTime < words[mid].start) hi = mid - 1;
else if (currentTime > words[mid].end) lo = mid + 1;
else {
activeWordIndex = mid;
break;
}
}
return { activeSentence, activeWordIndex };
}, [transcript, currentTime]);
}