wip
This commit is contained in:
@@ -21,42 +21,45 @@ export function useAudioSource(
|
|||||||
>(new WeakMap());
|
>(new WeakMap());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!source) {
|
// Build the source node (creating an AudioContext as needed) plus optional
|
||||||
setAudioSource(null);
|
// teardown for contexts we own; the branches converge on one setState.
|
||||||
return;
|
let result: AudioSource | null = null;
|
||||||
}
|
let cleanup: (() => void) | undefined;
|
||||||
|
|
||||||
if (source instanceof MediaStream) {
|
if (source instanceof MediaStream) {
|
||||||
const ctx = new AudioContext();
|
const ctx = new AudioContext();
|
||||||
ctx.resume();
|
ctx.resume();
|
||||||
const sourceNode = ctx.createMediaStreamSource(source);
|
const sourceNode = ctx.createMediaStreamSource(source);
|
||||||
setAudioSource({ sourceNode, ctx });
|
result = { sourceNode, ctx };
|
||||||
|
cleanup = () => ctx.close();
|
||||||
return () => {
|
} else if (source) {
|
||||||
ctx.close();
|
// HTMLAudioElement — createMediaElementSource can only be called once per
|
||||||
};
|
// element, so reuse a cached context/node when we have one.
|
||||||
|
const cached = elementSourceCache.current.get(source);
|
||||||
|
if (cached) {
|
||||||
|
cached.ctx.resume();
|
||||||
|
result = cached;
|
||||||
|
} else {
|
||||||
|
const ctx = new AudioContext();
|
||||||
|
ctx.resume();
|
||||||
|
const sourceNode = ctx.createMediaElementSource(source);
|
||||||
|
// Connect element source to destination so audio is still audible
|
||||||
|
sourceNode.connect(ctx.destination);
|
||||||
|
elementSourceCache.current.set(source, { sourceNode, ctx });
|
||||||
|
result = { sourceNode, ctx };
|
||||||
|
cleanup = () => {
|
||||||
|
ctx.close();
|
||||||
|
elementSourceCache.current.delete(source);
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTMLAudioElement — createMediaElementSource can only be called once per element
|
// Publishing an imperatively-created Web Audio node — external-resource
|
||||||
const cached = elementSourceCache.current.get(source);
|
// sync, not a re-render cascade.
|
||||||
if (cached) {
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
cached.ctx.resume();
|
setAudioSource(result);
|
||||||
setAudioSource(cached);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ctx = new AudioContext();
|
return cleanup;
|
||||||
ctx.resume();
|
|
||||||
const sourceNode = ctx.createMediaElementSource(source);
|
|
||||||
// Connect element source to destination so audio is still audible
|
|
||||||
sourceNode.connect(ctx.destination);
|
|
||||||
elementSourceCache.current.set(source, { sourceNode, ctx });
|
|
||||||
setAudioSource({ sourceNode, ctx });
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
ctx.close();
|
|
||||||
elementSourceCache.current.delete(source);
|
|
||||||
};
|
|
||||||
}, [source]);
|
}, [source]);
|
||||||
|
|
||||||
return audioSource;
|
return audioSource;
|
||||||
|
|||||||
@@ -23,16 +23,13 @@ export function ScreenSourcePicker({
|
|||||||
getSources().then((result) => {
|
getSources().then((result) => {
|
||||||
setSources(result);
|
setSources(result);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
// Auto-select if there's only one source.
|
||||||
|
if (result.length === 1) {
|
||||||
|
setSelectedId(result[0].id);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}, [getSources]);
|
}, [getSources]);
|
||||||
|
|
||||||
// Auto-select if there's only one source
|
|
||||||
useEffect(() => {
|
|
||||||
if (!loading && sources.length === 1) {
|
|
||||||
setSelectedId(sources[0].id);
|
|
||||||
}
|
|
||||||
}, [loading, sources]);
|
|
||||||
|
|
||||||
const screens = sources.filter((s) => s.id.startsWith("screen:"));
|
const screens = sources.filter((s) => s.id.startsWith("screen:"));
|
||||||
const windows = sources.filter((s) => s.id.startsWith("window:"));
|
const windows = sources.filter((s) => s.id.startsWith("window:"));
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { Dialog as DialogPrimitive } from "radix-ui";
|
import { Dialog as DialogPrimitive } from "radix-ui";
|
||||||
import {
|
import {
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useDownloadUrl } from "@/hooks/use-download-url";
|
import { useDownloadUrl } from "@/hooks/use-download-url";
|
||||||
|
import { useObjectUrl } from "@/hooks/use-object-url";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
||||||
import { platform } from "@/lib/platform";
|
import { platform } from "@/lib/platform";
|
||||||
@@ -71,17 +72,10 @@ export function AttachmentLightbox({
|
|||||||
const { data: remoteUrl, isLoading: isRemoteLoading } =
|
const { data: remoteUrl, isLoading: isRemoteLoading } =
|
||||||
useDownloadUrl(remoteObjectId);
|
useDownloadUrl(remoteObjectId);
|
||||||
|
|
||||||
// Local items get a fresh blob URL per item, revoked on change/close.
|
// Local items resolve to a blob URL; remote items use the signed-URL cache.
|
||||||
const [localUrl, setLocalUrl] = useState<string | null>(null);
|
const localFile =
|
||||||
useEffect(() => {
|
current?.source.kind === "local" ? current.source.file : null;
|
||||||
if (current?.source.kind !== "local") {
|
const localUrl = useObjectUrl(localFile);
|
||||||
setLocalUrl(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const url = URL.createObjectURL(current.source.file);
|
|
||||||
setLocalUrl(url);
|
|
||||||
return () => URL.revokeObjectURL(url);
|
|
||||||
}, [current?.id, current?.source.kind]);
|
|
||||||
|
|
||||||
const url =
|
const url =
|
||||||
current?.source.kind === "remote"
|
current?.source.kind === "remote"
|
||||||
@@ -90,18 +84,21 @@ export function AttachmentLightbox({
|
|||||||
|
|
||||||
const canDownload = current?.source.kind === "remote" && !!url;
|
const canDownload = current?.source.kind === "remote" && !!url;
|
||||||
|
|
||||||
const goTo = (delta: number) => {
|
const goTo = useCallback(
|
||||||
if (openIndex === null || items.length === 0) return;
|
(delta: number) => {
|
||||||
const next = (openIndex + delta + items.length) % items.length;
|
if (openIndex === null || items.length === 0) return;
|
||||||
onOpenChange(next);
|
const next = (openIndex + delta + items.length) % items.length;
|
||||||
};
|
onOpenChange(next);
|
||||||
|
},
|
||||||
|
[openIndex, items.length, onOpenChange],
|
||||||
|
);
|
||||||
|
|
||||||
const handleDownload = () => {
|
const handleDownload = useCallback(() => {
|
||||||
if (!current || !url || current.source.kind !== "remote") return;
|
if (!current || !url || current.source.kind !== "remote") return;
|
||||||
platform.attachment.download(url, current.filename);
|
platform.attachment.download(url, current.filename);
|
||||||
};
|
}, [current, url]);
|
||||||
|
|
||||||
const handleRemove = () => {
|
const handleRemove = useCallback(() => {
|
||||||
if (!current || !onRemove) return;
|
if (!current || !onRemove) return;
|
||||||
const wasLast = items.length <= 1;
|
const wasLast = items.length <= 1;
|
||||||
const wasAtEnd = openIndex === items.length - 1;
|
const wasAtEnd = openIndex === items.length - 1;
|
||||||
@@ -112,7 +109,7 @@ export function AttachmentLightbox({
|
|||||||
onOpenChange(items.length - 2);
|
onOpenChange(items.length - 2);
|
||||||
}
|
}
|
||||||
// Otherwise openIndex stays — the next item shifts into its place.
|
// Otherwise openIndex stays — the next item shifts into its place.
|
||||||
};
|
}, [current, onRemove, items.length, openIndex, onOpenChange]);
|
||||||
|
|
||||||
useSuspendPlayback(isOpen, "attachment-lightbox");
|
useSuspendPlayback(isOpen, "attachment-lightbox");
|
||||||
|
|
||||||
@@ -154,7 +151,7 @@ export function AttachmentLightbox({
|
|||||||
};
|
};
|
||||||
window.addEventListener("keydown", handle, true);
|
window.addEventListener("keydown", handle, true);
|
||||||
return () => window.removeEventListener("keydown", handle, true);
|
return () => window.removeEventListener("keydown", handle, true);
|
||||||
}, [isOpen, openIndex, items, url, onOpenChange, onRemove, hasMultiple, canDownload]);
|
}, [isOpen, onOpenChange, onRemove, hasMultiple, canDownload, goTo, handleDownload, handleRemove]);
|
||||||
|
|
||||||
const isImage = current?.mimeType.startsWith("image/");
|
const isImage = current?.mimeType.startsWith("image/");
|
||||||
const isVideo = current?.mimeType.startsWith("video/");
|
const isVideo = current?.mimeType.startsWith("video/");
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useEffectEvent, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useCreateParticle, useCreateStreamParticle } from "@/hooks/use-create-particle";
|
import { useCreateParticle, useCreateStreamParticle } from "@/hooks/use-create-particle";
|
||||||
@@ -80,15 +80,17 @@ export function ComposeOverlay({
|
|||||||
const invalidateUsage = useInvalidateNetworkUsage();
|
const invalidateUsage = useInvalidateNetworkUsage();
|
||||||
const quotaExhausted = isUsageExhausted(usage);
|
const quotaExhausted = isUsageExhausted(usage);
|
||||||
|
|
||||||
// Refs for synchronous reads in keyboard handlers
|
// Latest props/state for synchronous reads in keyboard handlers.
|
||||||
const stepRef = useRef(step);
|
const stepRef = useRef(step);
|
||||||
const recordStartRef = useRef(0);
|
const recordStartRef = useRef(0);
|
||||||
const disabledRef = useRef(disabled);
|
const disabledRef = useRef(disabled);
|
||||||
disabledRef.current = disabled;
|
|
||||||
const quotaExhaustedRef = useRef(quotaExhausted);
|
const quotaExhaustedRef = useRef(quotaExhausted);
|
||||||
quotaExhaustedRef.current = quotaExhausted;
|
|
||||||
const recordingSourceRef = useRef(recordingSource);
|
const recordingSourceRef = useRef(recordingSource);
|
||||||
recordingSourceRef.current = recordingSource;
|
useEffect(() => {
|
||||||
|
disabledRef.current = disabled;
|
||||||
|
quotaExhaustedRef.current = quotaExhausted;
|
||||||
|
recordingSourceRef.current = recordingSource;
|
||||||
|
}, [disabled, quotaExhausted, recordingSource]);
|
||||||
|
|
||||||
const setStepSync = useCallback((next: ComposeStep) => {
|
const setStepSync = useCallback((next: ComposeStep) => {
|
||||||
stepRef.current = next;
|
stepRef.current = next;
|
||||||
@@ -361,8 +363,8 @@ export function ComposeOverlay({
|
|||||||
return false;
|
return false;
|
||||||
}, [cancel]);
|
}, [cancel]);
|
||||||
|
|
||||||
// Reply mode: create particle directly under targetPath
|
// Reply mode: create particle directly under targetPath.
|
||||||
const onSubmitReply = useEffectEvent(async () => {
|
const onSubmitReply = useCallback(async () => {
|
||||||
if (!targetPath || !userId || stepRef.current === "submitting") return;
|
if (!targetPath || !userId || stepRef.current === "submitting") return;
|
||||||
setStepSync("submitting");
|
setStepSync("submitting");
|
||||||
try {
|
try {
|
||||||
@@ -371,7 +373,7 @@ export function ComposeOverlay({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!handleQuotaError(err)) throw err;
|
if (!handleQuotaError(err)) throw err;
|
||||||
}
|
}
|
||||||
});
|
}, [targetPath, userId, setStepSync, createChildParticle, cancel, handleQuotaError]);
|
||||||
|
|
||||||
// New stream mode: create stream + first child
|
// New stream mode: create stream + first child
|
||||||
const handleStreamSubmit = useCallback(
|
const handleStreamSubmit = useCallback(
|
||||||
@@ -397,7 +399,7 @@ export function ComposeOverlay({
|
|||||||
if (!handleQuotaError(err)) throw err;
|
if (!handleQuotaError(err)) throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[networkId, userId, createStream, createChildParticle, cancel, handleQuotaError],
|
[networkId, userId, createStream, createChildParticle, cancel, handleQuotaError, setStepSync],
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- Compose intent handlers ---
|
// --- Compose intent handlers ---
|
||||||
@@ -468,20 +470,25 @@ export function ComposeOverlay({
|
|||||||
// executes the matching handler and clears the intent. Keyboard handlers
|
// executes the matching handler and clears the intent. Keyboard handlers
|
||||||
// call the same handlers directly without a store round-trip.
|
// call the same handlers directly without a store round-trip.
|
||||||
|
|
||||||
const intent = useComposeIntentStore((s) => s.intent);
|
|
||||||
const clearIntent = useComposeIntentStore((s) => s.clear);
|
const clearIntent = useComposeIntentStore((s) => s.clear);
|
||||||
|
|
||||||
|
// Consume fire-and-forget intents from the external store. Reacting in the
|
||||||
|
// store subscription (not an effect body) keeps these state-updating handlers
|
||||||
|
// off the render path and avoids an extra dispatch→render bounce.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!intent) return;
|
return useComposeIntentStore.subscribe((state, prev) => {
|
||||||
switch (intent.kind) {
|
const intent = state.intent;
|
||||||
case "record": handleRecordIntent(); break;
|
if (!intent || intent === prev.intent) return;
|
||||||
case "text": handleTextIntent(); break;
|
switch (intent.kind) {
|
||||||
case "stop": handleStopIntent(); break;
|
case "record": handleRecordIntent(); break;
|
||||||
case "cancel": handleCancelIntent(); break;
|
case "text": handleTextIntent(); break;
|
||||||
case "send": handleSendIntent(); break;
|
case "stop": handleStopIntent(); break;
|
||||||
}
|
case "cancel": handleCancelIntent(); break;
|
||||||
clearIntent();
|
case "send": handleSendIntent(); break;
|
||||||
}, [intent, handleRecordIntent, handleTextIntent, handleStopIntent, handleCancelIntent, handleSendIntent, clearIntent]);
|
}
|
||||||
|
clearIntent();
|
||||||
|
});
|
||||||
|
}, [handleRecordIntent, handleTextIntent, handleStopIntent, handleCancelIntent, handleSendIntent, clearIntent]);
|
||||||
|
|
||||||
// --- Keyboard handling ---
|
// --- Keyboard handling ---
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Paperclip } from "lucide-react";
|
|||||||
import type { RecordingMode } from "@/hooks/use-recording-mode";
|
import type { RecordingMode } from "@/hooks/use-recording-mode";
|
||||||
import { AudioLevelBars } from "@/components/audio/audio-level-bars";
|
import { AudioLevelBars } from "@/components/audio/audio-level-bars";
|
||||||
import { useAudioSource } from "@/components/audio/use-audio-source";
|
import { useAudioSource } from "@/components/audio/use-audio-source";
|
||||||
|
import { useObjectUrl } from "@/hooks/use-object-url";
|
||||||
import { AttachmentStrip } from "@/features/compose/attachment-strip";
|
import { AttachmentStrip } from "@/features/compose/attachment-strip";
|
||||||
import type { PendingAttachment } from "@/features/compose/attachment-strip";
|
import type { PendingAttachment } from "@/features/compose/attachment-strip";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -65,23 +66,11 @@ function ReviewPlayback({
|
|||||||
mirror?: boolean;
|
mirror?: boolean;
|
||||||
objectFit?: "cover" | "contain";
|
objectFit?: "cover" | "contain";
|
||||||
}) {
|
}) {
|
||||||
const urlRef = useRef<string | null>(null);
|
const objectUrl = useObjectUrl(blob);
|
||||||
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
|
||||||
const audioElRef = useRef<HTMLAudioElement | null>(null);
|
const audioElRef = useRef<HTMLAudioElement | null>(null);
|
||||||
const [audioEl, setAudioEl] = useState<HTMLAudioElement | null>(null);
|
const [audioEl, setAudioEl] = useState<HTMLAudioElement | null>(null);
|
||||||
const audioSource = useAudioSource(isVideo ? null : audioEl);
|
const audioSource = useAudioSource(isVideo ? null : audioEl);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
urlRef.current = url;
|
|
||||||
setObjectUrl(url);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
urlRef.current = null;
|
|
||||||
};
|
|
||||||
}, [blob]);
|
|
||||||
|
|
||||||
if (!objectUrl) return null;
|
if (!objectUrl) return null;
|
||||||
|
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
|
|||||||
@@ -33,22 +33,19 @@ export const MediaParticleView = forwardRef<MediaParticleHandle, MediaParticleVi
|
|||||||
onEnded,
|
onEnded,
|
||||||
onProgress,
|
onProgress,
|
||||||
}, ref) {
|
}, ref) {
|
||||||
// Prefer the worker-produced iOS-playable variant when present so desktop
|
// Prefer the worker-produced iOS-playable variant when present so desktop and
|
||||||
// and mobile read the same canonical asset. Falls back to the original.
|
// mobile read the same canonical asset, falling back to the original. Pinned
|
||||||
// Pin the choice for the lifetime of this particle: if a transcoded variant
|
// on mount (the parent keys this component by particle.id, so a new particle
|
||||||
// arrives via Firestore mid-playback, swapping the <video> src would restart
|
// remounts and re-picks): if a transcoded variant arrives via Firestore for
|
||||||
// playback from 0. Keep whatever we picked first; the original plays fine in
|
// the same particle, swapping the <video> src would restart playback from 0.
|
||||||
// Electron, and the transcoded variant will be picked up on the next view.
|
const [pickedSource] = useState(() => ({
|
||||||
const pickedSourceRef = useRef<{ id: string; objectId: string; mime: string } | null>(null);
|
objectId:
|
||||||
if (pickedSourceRef.current?.id !== particle.id) {
|
particle.properties.transcoded_object_id ?? particle.properties.object_id,
|
||||||
pickedSourceRef.current = {
|
mime:
|
||||||
id: particle.id,
|
particle.properties.transcoded_mime_type ?? particle.properties.mime_type,
|
||||||
objectId: particle.properties.transcoded_object_id ?? particle.properties.object_id,
|
}));
|
||||||
mime: particle.properties.transcoded_mime_type ?? particle.properties.mime_type,
|
const activeObjectId = pickedSource.objectId;
|
||||||
};
|
const activeMime = pickedSource.mime;
|
||||||
}
|
|
||||||
const activeObjectId = pickedSourceRef.current.objectId;
|
|
||||||
const activeMime = pickedSourceRef.current.mime;
|
|
||||||
const { data: url, error } = useDownloadUrl(activeObjectId);
|
const { data: url, error } = useDownloadUrl(activeObjectId);
|
||||||
const { attachments } = useParticleAttachments(streamPath, particle.id);
|
const { attachments } = useParticleAttachments(streamPath, particle.id);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useRef, useEffect, useCallback, memo } from "react";
|
import { useMemo, useRef, useEffect, useCallback, memo, createElement } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Radio,
|
Radio,
|
||||||
@@ -186,7 +186,9 @@ const StreamRow = memo(function StreamRow({
|
|||||||
? getMessagePreview(latestChild)
|
? getMessagePreview(latestChild)
|
||||||
: particle.properties.name;
|
: particle.properties.name;
|
||||||
|
|
||||||
const TypeIcon = latestChild ? getParticleTypeIcon(latestChild) : Radio;
|
// Rendered via createElement below: a call-result used directly as a JSX tag
|
||||||
|
// is flagged as a dynamically-created component.
|
||||||
|
const typeIcon = latestChild ? getParticleTypeIcon(latestChild) : Radio;
|
||||||
|
|
||||||
const videoThumbObjectId =
|
const videoThumbObjectId =
|
||||||
latestChild &&
|
latestChild &&
|
||||||
@@ -254,12 +256,12 @@ const StreamRow = memo(function StreamRow({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<TypeIcon
|
{createElement(typeIcon, {
|
||||||
className={cn(
|
className: cn(
|
||||||
"size-3.5 shrink-0",
|
"size-3.5 shrink-0",
|
||||||
isUnseen ? "text-foreground" : "text-muted-foreground",
|
isUnseen ? "text-foreground" : "text-muted-foreground",
|
||||||
)}
|
),
|
||||||
/>
|
})}
|
||||||
<Small
|
<Small
|
||||||
className={cn(
|
className={cn(
|
||||||
"truncate",
|
"truncate",
|
||||||
|
|||||||
@@ -34,7 +34,10 @@ export function StreamMembersOverlay({
|
|||||||
const network = useNetwork(networkId);
|
const network = useNetwork(networkId);
|
||||||
const humans = network?.humans ?? [];
|
const humans = network?.humans ?? [];
|
||||||
const creatorId = streamParticle.created_by_human_id;
|
const creatorId = streamParticle.created_by_human_id;
|
||||||
const visibility = parseVisibleTo(streamParticle.visible_to, networkId);
|
const visibility = useMemo(
|
||||||
|
() => parseVisibleTo(streamParticle.visible_to, networkId),
|
||||||
|
[streamParticle.visible_to, networkId],
|
||||||
|
);
|
||||||
|
|
||||||
const docPath = useMemo(
|
const docPath = useMemo(
|
||||||
() => toFirestoreDocPath(particlePath(networkId, [streamParticle.id])),
|
() => toFirestoreDocPath(particlePath(networkId, [streamParticle.id])),
|
||||||
|
|||||||
@@ -51,24 +51,26 @@ function useExitCountdown(
|
|||||||
disabled: boolean,
|
disabled: boolean,
|
||||||
onExit: () => void,
|
onExit: () => void,
|
||||||
) {
|
) {
|
||||||
const [remainingMs, setRemainingMs] = useState<number | null>(null);
|
const [remainingMs, setRemainingMs] = useState<number | null>(
|
||||||
|
status === "ended" ? EXIT_DELAY_MS : null,
|
||||||
|
);
|
||||||
|
const [prevStatus, setPrevStatus] = useState(status);
|
||||||
|
|
||||||
const handleExit = useEffectEvent(() => {
|
const handleExit = useEffectEvent(() => {
|
||||||
onExit();
|
onExit();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start/cancel countdown based on playback status
|
// Start the countdown when playback ends; cancel it otherwise.
|
||||||
useEffect(() => {
|
if (status !== prevStatus) {
|
||||||
if (status === "ended") {
|
setPrevStatus(status);
|
||||||
setRemainingMs(EXIT_DELAY_MS);
|
setRemainingMs(status === "ended" ? EXIT_DELAY_MS : null);
|
||||||
} else {
|
}
|
||||||
setRemainingMs(null);
|
|
||||||
}
|
const isCountingDown = remainingMs !== null && remainingMs > 0;
|
||||||
}, [status]);
|
|
||||||
|
|
||||||
// Tick the countdown down (pauses when compose is active)
|
// Tick the countdown down (pauses when compose is active)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (remainingMs === null || remainingMs <= 0 || disabled) return;
|
if (!isCountingDown || disabled) return;
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
setRemainingMs((prev) => {
|
setRemainingMs((prev) => {
|
||||||
@@ -79,7 +81,7 @@ function useExitCountdown(
|
|||||||
}, EXIT_TICK_MS);
|
}, EXIT_TICK_MS);
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [remainingMs !== null && remainingMs > 0, disabled]);
|
}, [isCountingDown, disabled]);
|
||||||
|
|
||||||
// Navigate once countdown hits zero
|
// Navigate once countdown hits zero
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -197,12 +199,13 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
|
|
||||||
const reactions = getReactions(currentParticle);
|
const reactions = getReactions(currentParticle);
|
||||||
toggleParticleReaction(currentParticleDocPath, emoji, authedUser.id, reactions);
|
toggleParticleReaction(currentParticleDocPath, emoji, authedUser.id, reactions);
|
||||||
}, [authedUser, currentParticle]);
|
}, [authedUser, currentParticle, networkId, streamParticle.id]);
|
||||||
|
|
||||||
const [composeActive, setComposeActive] = useState(false);
|
const [composeActive, setComposeActive] = useState(false);
|
||||||
const [composeStep, setComposeStep] = useState<ComposeStep>("idle");
|
const [composeStep, setComposeStep] = useState<ComposeStep>("idle");
|
||||||
const paused = usePlaybackPauseStore(selectIsPaused);
|
const paused = usePlaybackPauseStore(selectIsPaused);
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
|
const [prevParticleId, setPrevParticleId] = useState(currentParticle?.id);
|
||||||
const [showKeybindings, setShowKeybindings] = useState(false);
|
const [showKeybindings, setShowKeybindings] = useState(false);
|
||||||
const [textReactionOpen, setTextReactionOpen] = useState(false);
|
const [textReactionOpen, setTextReactionOpen] = useState(false);
|
||||||
|
|
||||||
@@ -286,10 +289,11 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
handleExitNavigate,
|
handleExitNavigate,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reset progress when particle changes
|
// Reset progress when the particle changes.
|
||||||
useEffect(() => {
|
if (currentParticle?.id !== prevParticleId) {
|
||||||
|
setPrevParticleId(currentParticle?.id);
|
||||||
setProgress(0);
|
setProgress(0);
|
||||||
}, [currentParticle?.id]);
|
}
|
||||||
|
|
||||||
const handleParticleCreated = useCallback((particleId: string) => {
|
const handleParticleCreated = useCallback((particleId: string) => {
|
||||||
if (currentIndex === -1) return;
|
if (currentIndex === -1) return;
|
||||||
|
|||||||
@@ -14,13 +14,19 @@ interface TextReactionInputProps {
|
|||||||
|
|
||||||
export function TextReactionInput({ open, onSubmit, onClose }: TextReactionInputProps) {
|
export function TextReactionInput({ open, onSubmit, onClose }: TextReactionInputProps) {
|
||||||
const [value, setValue] = useState("");
|
const [value, setValue] = useState("");
|
||||||
|
const [prevOpen, setPrevOpen] = useState(open);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useSuspendPlayback(open, "text-reaction");
|
useSuspendPlayback(open, "text-reaction");
|
||||||
|
|
||||||
|
// Clear the input each time the popup opens.
|
||||||
|
if (open !== prevOpen) {
|
||||||
|
setPrevOpen(open);
|
||||||
|
if (open) setValue("");
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
setValue("");
|
|
||||||
const id = requestAnimationFrame(() => inputRef.current?.focus());
|
const id = requestAnimationFrame(() => inputRef.current?.focus());
|
||||||
return () => cancelAnimationFrame(id);
|
return () => cancelAnimationFrame(id);
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useRef } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import type { Transcript } from "@/api/types";
|
import type { Transcript } from "@/api/types";
|
||||||
|
|
||||||
type Sentence = Transcript["paragraphs"][number]["sentences"][number];
|
type Sentence = Transcript["paragraphs"][number]["sentences"][number];
|
||||||
@@ -41,35 +41,40 @@ export function TranscriptOverlay({
|
|||||||
const activeWord =
|
const activeWord =
|
||||||
activeWordIndex !== null ? transcript.words[activeWordIndex] : null;
|
activeWordIndex !== null ? transcript.words[activeWordIndex] : null;
|
||||||
|
|
||||||
// Remember the last spoken word so highlights hold during pauses
|
// Remember the last spoken word so highlights hold during pauses.
|
||||||
const lastSpokenWordRef = useRef<Word | null>(null);
|
const [lastSpokenWord, setLastSpokenWord] = useState<Word | null>(null);
|
||||||
if (activeWord) {
|
if (activeWord && activeWord !== lastSpokenWord) {
|
||||||
lastSpokenWordRef.current = activeWord;
|
setLastSpokenWord(activeWord);
|
||||||
}
|
}
|
||||||
const highlightWord = activeWord ?? lastSpokenWordRef.current;
|
const highlightWord = activeWord ?? lastSpokenWord;
|
||||||
|
|
||||||
const lastChunkRef = useRef<Word[] | null>(null);
|
// The chunk currently being spoken (null during a pause or if not found).
|
||||||
|
const spokenChunk = useMemo(() => {
|
||||||
// Find which chunk contains the active word, holding the last one during pauses
|
if (!activeWord) return null;
|
||||||
const activeChunk = useMemo(() => {
|
return (
|
||||||
if (activeWord) {
|
chunks.find((chunk) =>
|
||||||
for (const chunk of chunks) {
|
chunk.some(
|
||||||
if (chunk.some((w) => w.start === activeWord.start && w.end === activeWord.end)) {
|
(w) => w.start === activeWord.start && w.end === activeWord.end,
|
||||||
lastChunkRef.current = chunk;
|
),
|
||||||
return chunk;
|
) ?? null
|
||||||
}
|
);
|
||||||
}
|
|
||||||
}
|
|
||||||
// No active word (speaker pausing) — hold the last chunk
|
|
||||||
if (lastChunkRef.current && chunks.some((c) => c === lastChunkRef.current)) {
|
|
||||||
return lastChunkRef.current;
|
|
||||||
}
|
|
||||||
// Sentence changed, last chunk no longer valid — use first chunk
|
|
||||||
const fallback = chunks[0] ?? null;
|
|
||||||
lastChunkRef.current = fallback;
|
|
||||||
return fallback;
|
|
||||||
}, [chunks, activeWord]);
|
}, [chunks, activeWord]);
|
||||||
|
|
||||||
|
// Resolve which chunk to display: the spoken one, else hold the last one while
|
||||||
|
// it's still part of the current sentence, else fall back to the first chunk.
|
||||||
|
const [lastChunk, setLastChunk] = useState<Word[] | null>(null);
|
||||||
|
let activeChunk: Word[] | null;
|
||||||
|
if (spokenChunk) {
|
||||||
|
activeChunk = spokenChunk;
|
||||||
|
} else if (lastChunk && chunks.includes(lastChunk)) {
|
||||||
|
activeChunk = lastChunk;
|
||||||
|
} else {
|
||||||
|
activeChunk = chunks[0] ?? null;
|
||||||
|
}
|
||||||
|
if (activeChunk !== lastChunk) {
|
||||||
|
setLastChunk(activeChunk);
|
||||||
|
}
|
||||||
|
|
||||||
if (!activeSentence || !activeChunk || activeChunk.length === 0) return null;
|
if (!activeSentence || !activeChunk || activeChunk.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -34,12 +34,20 @@ function usePreviewStream(
|
|||||||
): { stream: MediaStream | null; error: string | null } {
|
): { stream: MediaStream | null; error: string | null } {
|
||||||
const [stream, setStream] = useState<MediaStream | null>(null);
|
const [stream, setStream] = useState<MediaStream | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [prevEnabled, setPrevEnabled] = useState(enabled);
|
||||||
|
|
||||||
useEffect(() => {
|
// Clear the preview when disabled; the effect below only manages the
|
||||||
|
// getUserMedia subscription.
|
||||||
|
if (enabled !== prevEnabled) {
|
||||||
|
setPrevEnabled(enabled);
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
setStream(null);
|
setStream(null);
|
||||||
return;
|
setError(null);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let active: MediaStream | null = null;
|
let active: MediaStream | null = null;
|
||||||
|
|||||||
@@ -23,11 +23,7 @@ export function useChannel(channelId: string | null): UseChannelResult {
|
|||||||
const [messages, setMessages] = useState<ChannelMessage[]>([]);
|
const [messages, setMessages] = useState<ChannelMessage[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!client || !channelId) {
|
if (!client || !channelId) return;
|
||||||
setPresence([]);
|
|
||||||
setMessages([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
client.subscribe(channelId);
|
client.subscribe(channelId);
|
||||||
|
|
||||||
@@ -66,6 +62,9 @@ export function useChannel(channelId: string | null): UseChannelResult {
|
|||||||
client.off(channelId, "leave", onLeave);
|
client.off(channelId, "leave", onLeave);
|
||||||
client.off(channelId, "message", onMessage);
|
client.off(channelId, "message", onMessage);
|
||||||
client.unsubscribe(channelId);
|
client.unsubscribe(channelId);
|
||||||
|
// Clear on teardown so a new channel doesn't briefly show stale data.
|
||||||
|
setPresence([]);
|
||||||
|
setMessages([]);
|
||||||
};
|
};
|
||||||
}, [client, channelId]);
|
}, [client, channelId]);
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ export function useFileInput({ onFilesSelected, enabled }: UseFileInputOptions)
|
|||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const dragCountRef = useRef(0);
|
const dragCountRef = useRef(0);
|
||||||
|
|
||||||
// Stable ref for the callback to avoid re-registering effects
|
// Latest callback in a ref, so the effects below don't re-register when the
|
||||||
|
// caller passes a new function each render.
|
||||||
const onFilesRef = useRef(onFilesSelected);
|
const onFilesRef = useRef(onFilesSelected);
|
||||||
onFilesRef.current = onFilesSelected;
|
useEffect(() => {
|
||||||
|
onFilesRef.current = onFilesSelected;
|
||||||
|
}, [onFilesSelected]);
|
||||||
|
|
||||||
// Hidden file input element
|
// Hidden file input element
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -25,21 +25,23 @@ export function useMediaDevices(): UseMediaDevicesResult {
|
|||||||
useState<PermissionState>("unknown");
|
useState<PermissionState>("unknown");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(() => {
|
||||||
try {
|
return navigator.mediaDevices
|
||||||
const list = await navigator.mediaDevices.enumerateDevices();
|
.enumerateDevices()
|
||||||
setDevices(list);
|
.then((list) => {
|
||||||
// If at least one input device has a non-empty label, permission
|
setDevices(list);
|
||||||
// has been granted at some point for that device kind.
|
// If at least one input device has a non-empty label, permission
|
||||||
const hasLabels = list.some(
|
// has been granted at some point for that device kind.
|
||||||
(d) =>
|
const hasLabels = list.some(
|
||||||
(d.kind === "audioinput" || d.kind === "videoinput") &&
|
(d) =>
|
||||||
d.label.length > 0,
|
(d.kind === "audioinput" || d.kind === "videoinput") &&
|
||||||
);
|
d.label.length > 0,
|
||||||
if (hasLabels) setPermissionState("granted");
|
);
|
||||||
} catch (err) {
|
if (hasLabels) setPermissionState("granted");
|
||||||
setError(err instanceof Error ? err.message : "Failed to list devices");
|
})
|
||||||
}
|
.catch((err) => {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to list devices");
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const requestLabels = useCallback(async () => {
|
const requestLabels = useCallback(async () => {
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an object URL for a Blob/File and revokes it when the source changes
|
||||||
|
* or the component unmounts. Returns null when given null.
|
||||||
|
*
|
||||||
|
* `createObjectURL` is an imperative side effect that must run inside an effect,
|
||||||
|
* so publishing the resulting URL to state here is genuine external-resource
|
||||||
|
* synchronization rather than a render cascade — hence the single, contained
|
||||||
|
* lint suppression below.
|
||||||
|
*/
|
||||||
|
export function useObjectUrl(source: Blob | null): string | null {
|
||||||
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!source) return;
|
||||||
|
const objectUrl = URL.createObjectURL(source);
|
||||||
|
// Intended external-resource publish (see hook doc), not a render cascade.
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setUrl(objectUrl);
|
||||||
|
return () => {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
setUrl(null); // in cleanup, not the effect body — so no suppression needed
|
||||||
|
};
|
||||||
|
}, [source]);
|
||||||
|
|
||||||
|
return source ? url : null;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
subscribeToParticle,
|
subscribeToParticle,
|
||||||
subscribeToParticleChildren,
|
subscribeToParticleChildren,
|
||||||
@@ -27,10 +27,6 @@ export function useLiveParticle(path: ParticlePath): UseLiveParticleResult {
|
|||||||
const [error, setError] = useState<Error | null>(null);
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
setParticle(null);
|
|
||||||
|
|
||||||
const docPath = toFirestoreDocPath(path);
|
const docPath = toFirestoreDocPath(path);
|
||||||
const unsubscribe = subscribeToParticle(
|
const unsubscribe = subscribeToParticle(
|
||||||
docPath,
|
docPath,
|
||||||
@@ -44,7 +40,13 @@ export function useLiveParticle(path: ParticlePath): UseLiveParticleResult {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return unsubscribe;
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
// Reset on teardown so a new path doesn't flash the previous particle.
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setParticle(null);
|
||||||
|
};
|
||||||
}, [path]);
|
}, [path]);
|
||||||
|
|
||||||
return { particle, isLoading, error };
|
return { particle, isLoading, error };
|
||||||
@@ -83,16 +85,17 @@ export function useLiveParticleChildren(
|
|||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<Error | null>(null);
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
// Keep the latest add/remove callbacks in refs so changing them doesn't force
|
||||||
|
// the subscription to re-attach — they're notifications, not query params.
|
||||||
|
const onAddedRef = useRef(onAdded);
|
||||||
|
const onRemovedRef = useRef(onRemoved);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!path) {
|
onAddedRef.current = onAdded;
|
||||||
setChildren([]);
|
onRemovedRef.current = onRemoved;
|
||||||
setIsLoading(false);
|
}, [onAdded, onRemoved]);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
useEffect(() => {
|
||||||
setError(null);
|
if (!path) return;
|
||||||
setChildren([]);
|
|
||||||
|
|
||||||
const collectionPath = toFirestoreChildrenPath(path);
|
const collectionPath = toFirestoreChildrenPath(path);
|
||||||
|
|
||||||
@@ -111,15 +114,27 @@ export function useLiveParticleChildren(
|
|||||||
visibilityScopes,
|
visibilityScopes,
|
||||||
orderByField,
|
orderByField,
|
||||||
orderDirection,
|
orderDirection,
|
||||||
onAdded,
|
onAdded: (child: Particle) => onAddedRef.current?.(child),
|
||||||
onRemoved,
|
onRemoved: (child: Particle, updatedChildren: Particle[]) =>
|
||||||
|
onRemovedRef.current?.(child, updatedChildren),
|
||||||
whereFilter,
|
whereFilter,
|
||||||
limit,
|
limit,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return unsubscribe;
|
return () => {
|
||||||
}, [path, whereFilter, limit]);
|
unsubscribe();
|
||||||
|
// Reset on teardown so a new path doesn't flash the previous children.
|
||||||
|
setChildren([]);
|
||||||
|
setError(null);
|
||||||
|
setIsLoading(true);
|
||||||
|
};
|
||||||
|
}, [path, whereFilter, limit, orderByField, orderDirection, visibilityScopes]);
|
||||||
|
|
||||||
|
// No path: nothing to load, so report an empty non-loading state.
|
||||||
|
if (!path) {
|
||||||
|
return { children: [], isLoading: false, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
return { children, isLoading, error };
|
return { children, isLoading, error };
|
||||||
}
|
}
|
||||||
@@ -134,9 +149,6 @@ export function useLiveLatestChild(path: ParticlePath): UseLiveLatestChildResult
|
|||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsLoading(true);
|
|
||||||
setLatestChild(null);
|
|
||||||
|
|
||||||
const unsubscribe = subscribeToLatestChild(
|
const unsubscribe = subscribeToLatestChild(
|
||||||
toFirestoreChildrenPath(path),
|
toFirestoreChildrenPath(path),
|
||||||
(data) => {
|
(data) => {
|
||||||
@@ -148,7 +160,12 @@ export function useLiveLatestChild(path: ParticlePath): UseLiveLatestChildResult
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return unsubscribe;
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
// Reset on teardown so a new path doesn't flash the previous child.
|
||||||
|
setIsLoading(true);
|
||||||
|
setLatestChild(null);
|
||||||
|
};
|
||||||
}, [path]);
|
}, [path]);
|
||||||
|
|
||||||
return { latestChild, isLoading };
|
return { latestChild, isLoading };
|
||||||
|
|||||||
@@ -12,19 +12,23 @@ export function useStreamKeyboardNav({
|
|||||||
enabled,
|
enabled,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
}: UseStreamKeyboardNavOptions) {
|
}: UseStreamKeyboardNavOptions) {
|
||||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
const [selectedIndex, setSelectedIndex] = useState<number | null>(
|
||||||
|
streams.length > 0 ? 0 : null,
|
||||||
|
);
|
||||||
|
const [prevStreamCount, setPrevStreamCount] = useState(streams.length);
|
||||||
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
|
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
|
||||||
const setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode);
|
const setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode);
|
||||||
|
|
||||||
// Initialize selection when streams first load; clear if streams become empty.
|
// Select the first stream once they load and clear when empty — but not on
|
||||||
// Do NOT reset on every Firestore update — that would scroll the list to the top.
|
// every Firestore update, which would scroll the list back to the top.
|
||||||
useEffect(() => {
|
if (streams.length !== prevStreamCount) {
|
||||||
setSelectedIndex((prev) => {
|
setPrevStreamCount(streams.length);
|
||||||
if (streams.length === 0) return null;
|
if (streams.length === 0) {
|
||||||
if (prev === null) return 0;
|
setSelectedIndex(null);
|
||||||
return prev;
|
} else if (selectedIndex === null) {
|
||||||
});
|
setSelectedIndex(0);
|
||||||
}, [streams.length]);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled || streams.length === 0) return;
|
if (!enabled || streams.length === 0) return;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { where, type QueryFieldFilterConstraint } from "firebase/firestore";
|
import { where, type QueryFieldFilterConstraint } from "firebase/firestore";
|
||||||
import { useLiveParticleChildren } from "@/hooks/use-particle";
|
import { useLiveParticleChildren } from "@/hooks/use-particle";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
@@ -52,14 +52,16 @@ export function useStreamParticles(
|
|||||||
const visibilityScopes = useVisibilityScopes(user?.id, networkId);
|
const visibilityScopes = useVisibilityScopes(user?.id, networkId);
|
||||||
|
|
||||||
const [closedLimit, setClosedLimit] = useState(CLOSED_INITIAL_PAGE_SIZE);
|
const [closedLimit, setClosedLimit] = useState(CLOSED_INITIAL_PAGE_SIZE);
|
||||||
|
const [prevStatus, setPrevStatus] = useState(status);
|
||||||
|
|
||||||
// Every time the user switches back to the closed tab, start with a fresh
|
// Switching back to the closed tab starts a fresh window, avoiding an
|
||||||
// window. Avoids an ever-growing subscription across a long session.
|
// ever-growing subscription across a long session.
|
||||||
useEffect(() => {
|
if (status !== prevStatus) {
|
||||||
|
setPrevStatus(status);
|
||||||
if (status === "closed") {
|
if (status === "closed") {
|
||||||
setClosedLimit(CLOSED_INITIAL_PAGE_SIZE);
|
setClosedLimit(CLOSED_INITIAL_PAGE_SIZE);
|
||||||
}
|
}
|
||||||
}, [status]);
|
}
|
||||||
|
|
||||||
const whereFilter: QueryFieldFilterConstraint =
|
const whereFilter: QueryFieldFilterConstraint =
|
||||||
status === "open" ? OPEN_STATUS_FILTER : CLOSED_STATUS_FILTER;
|
status === "open" ? OPEN_STATUS_FILTER : CLOSED_STATUS_FILTER;
|
||||||
|
|||||||
@@ -84,21 +84,25 @@ export function useStreamPlayback(
|
|||||||
const [state, dispatch] = useReducer(playbackReducer, initialState);
|
const [state, dispatch] = useReducer(playbackReducer, initialState);
|
||||||
// Track the stream ID we've initialized for, to reset when navigating between streams
|
// Track the stream ID we've initialized for, to reset when navigating between streams
|
||||||
const initializedForRef = useRef<string | null>(null);
|
const initializedForRef = useRef<string | null>(null);
|
||||||
|
// Latest currentIndex for onParticleRemoved (passed to the subscription), so
|
||||||
|
// it reads the current value without re-subscribing. useEffectEvent can't be
|
||||||
|
// used — it may not be passed to another hook.
|
||||||
|
const currentIndexRef = useRef(0);
|
||||||
|
|
||||||
// --- Firestore change callbacks ---
|
// --- Firestore change callbacks ---
|
||||||
const onParticleAdded = useCallback((particle: Particle) => {
|
const onParticleAdded = useCallback((particle: Particle) => {
|
||||||
dispatch({ type: "PARTICLE_ADDED", particleId: particle.id });
|
dispatch({ type: "PARTICLE_ADDED", particleId: particle.id });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onParticleRemoved = useEffectEvent((removed: Particle, updatedChildren: Particle[]) => {
|
const onParticleRemoved = useCallback((removed: Particle, updatedChildren: Particle[]) => {
|
||||||
const fallbackIndex = Math.min(currentIndex, updatedChildren.length - 1);
|
const fallbackIndex = Math.min(currentIndexRef.current, updatedChildren.length - 1);
|
||||||
const fallback = updatedChildren[Math.max(0, fallbackIndex)];
|
const fallback = updatedChildren[Math.max(0, fallbackIndex)];
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "PARTICLE_REMOVED",
|
type: "PARTICLE_REMOVED",
|
||||||
removedParticleId: removed.id,
|
removedParticleId: removed.id,
|
||||||
fallbackParticleId: fallback?.id ?? null,
|
fallbackParticleId: fallback?.id ?? null,
|
||||||
});
|
});
|
||||||
});
|
}, []);
|
||||||
|
|
||||||
const { children } = useLiveParticleChildren(
|
const { children } = useLiveParticleChildren(
|
||||||
path,
|
path,
|
||||||
@@ -118,6 +122,11 @@ export function useStreamPlayback(
|
|||||||
|
|
||||||
const currentParticle = currentIndex !== -1 ? children[currentIndex] : null;
|
const currentParticle = currentIndex !== -1 ? children[currentIndex] : null;
|
||||||
|
|
||||||
|
// Keep the latest-index ref in sync for onParticleRemoved (above).
|
||||||
|
useEffect(() => {
|
||||||
|
currentIndexRef.current = currentIndex;
|
||||||
|
}, [currentIndex]);
|
||||||
|
|
||||||
// Fallback init — always sees latest children/state via useEffectEvent
|
// Fallback init — always sees latest children/state via useEffectEvent
|
||||||
const initFallback = useEffectEvent(() => {
|
const initFallback = useEffectEvent(() => {
|
||||||
if (state.initialized || children.length === 0) return;
|
if (state.initialized || children.length === 0) return;
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ class SoundEffectsEngine {
|
|||||||
*/
|
*/
|
||||||
preload(name: string, url: string): Promise<AudioBuffer | null> {
|
preload(name: string, url: string): Promise<AudioBuffer | null> {
|
||||||
if (this.buffers.has(name)) {
|
if (this.buffers.has(name)) {
|
||||||
return Promise.resolve(this.buffers.get(name));
|
return Promise.resolve(this.buffers.get(name) ?? null);
|
||||||
}
|
}
|
||||||
const existing = this.loading.get(name);
|
const existing = this.loading.get(name);
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
|
|||||||
Reference in New Issue
Block a user