wip
This commit is contained in:
@@ -21,42 +21,45 @@ export function useAudioSource(
|
||||
>(new WeakMap());
|
||||
|
||||
useEffect(() => {
|
||||
if (!source) {
|
||||
setAudioSource(null);
|
||||
return;
|
||||
}
|
||||
// Build the source node (creating an AudioContext as needed) plus optional
|
||||
// teardown for contexts we own; the branches converge on one setState.
|
||||
let result: AudioSource | null = null;
|
||||
let cleanup: (() => void) | undefined;
|
||||
|
||||
if (source instanceof MediaStream) {
|
||||
const ctx = new AudioContext();
|
||||
ctx.resume();
|
||||
const sourceNode = ctx.createMediaStreamSource(source);
|
||||
setAudioSource({ sourceNode, ctx });
|
||||
|
||||
return () => {
|
||||
ctx.close();
|
||||
};
|
||||
result = { sourceNode, ctx };
|
||||
cleanup = () => ctx.close();
|
||||
} else if (source) {
|
||||
// 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
|
||||
const cached = elementSourceCache.current.get(source);
|
||||
if (cached) {
|
||||
cached.ctx.resume();
|
||||
setAudioSource(cached);
|
||||
return;
|
||||
}
|
||||
// Publishing an imperatively-created Web Audio node — external-resource
|
||||
// sync, not a re-render cascade.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setAudioSource(result);
|
||||
|
||||
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 });
|
||||
setAudioSource({ sourceNode, ctx });
|
||||
|
||||
return () => {
|
||||
ctx.close();
|
||||
elementSourceCache.current.delete(source);
|
||||
};
|
||||
return cleanup;
|
||||
}, [source]);
|
||||
|
||||
return audioSource;
|
||||
|
||||
@@ -23,16 +23,13 @@ export function ScreenSourcePicker({
|
||||
getSources().then((result) => {
|
||||
setSources(result);
|
||||
setLoading(false);
|
||||
// Auto-select if there's only one source.
|
||||
if (result.length === 1) {
|
||||
setSelectedId(result[0].id);
|
||||
}
|
||||
});
|
||||
}, [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 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 {
|
||||
ChevronLeft,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useDownloadUrl } from "@/hooks/use-download-url";
|
||||
import { useObjectUrl } from "@/hooks/use-object-url";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
||||
import { platform } from "@/lib/platform";
|
||||
@@ -71,17 +72,10 @@ export function AttachmentLightbox({
|
||||
const { data: remoteUrl, isLoading: isRemoteLoading } =
|
||||
useDownloadUrl(remoteObjectId);
|
||||
|
||||
// Local items get a fresh blob URL per item, revoked on change/close.
|
||||
const [localUrl, setLocalUrl] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (current?.source.kind !== "local") {
|
||||
setLocalUrl(null);
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(current.source.file);
|
||||
setLocalUrl(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [current?.id, current?.source.kind]);
|
||||
// Local items resolve to a blob URL; remote items use the signed-URL cache.
|
||||
const localFile =
|
||||
current?.source.kind === "local" ? current.source.file : null;
|
||||
const localUrl = useObjectUrl(localFile);
|
||||
|
||||
const url =
|
||||
current?.source.kind === "remote"
|
||||
@@ -90,18 +84,21 @@ export function AttachmentLightbox({
|
||||
|
||||
const canDownload = current?.source.kind === "remote" && !!url;
|
||||
|
||||
const goTo = (delta: number) => {
|
||||
if (openIndex === null || items.length === 0) return;
|
||||
const next = (openIndex + delta + items.length) % items.length;
|
||||
onOpenChange(next);
|
||||
};
|
||||
const goTo = useCallback(
|
||||
(delta: number) => {
|
||||
if (openIndex === null || items.length === 0) return;
|
||||
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;
|
||||
platform.attachment.download(url, current.filename);
|
||||
};
|
||||
}, [current, url]);
|
||||
|
||||
const handleRemove = () => {
|
||||
const handleRemove = useCallback(() => {
|
||||
if (!current || !onRemove) return;
|
||||
const wasLast = items.length <= 1;
|
||||
const wasAtEnd = openIndex === items.length - 1;
|
||||
@@ -112,7 +109,7 @@ export function AttachmentLightbox({
|
||||
onOpenChange(items.length - 2);
|
||||
}
|
||||
// Otherwise openIndex stays — the next item shifts into its place.
|
||||
};
|
||||
}, [current, onRemove, items.length, openIndex, onOpenChange]);
|
||||
|
||||
useSuspendPlayback(isOpen, "attachment-lightbox");
|
||||
|
||||
@@ -154,7 +151,7 @@ export function AttachmentLightbox({
|
||||
};
|
||||
window.addEventListener("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 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 { useAuthStore } from "@/stores/auth-store";
|
||||
import { useCreateParticle, useCreateStreamParticle } from "@/hooks/use-create-particle";
|
||||
@@ -80,15 +80,17 @@ export function ComposeOverlay({
|
||||
const invalidateUsage = useInvalidateNetworkUsage();
|
||||
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 recordStartRef = useRef(0);
|
||||
const disabledRef = useRef(disabled);
|
||||
disabledRef.current = disabled;
|
||||
const quotaExhaustedRef = useRef(quotaExhausted);
|
||||
quotaExhaustedRef.current = quotaExhausted;
|
||||
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) => {
|
||||
stepRef.current = next;
|
||||
@@ -361,8 +363,8 @@ export function ComposeOverlay({
|
||||
return false;
|
||||
}, [cancel]);
|
||||
|
||||
// Reply mode: create particle directly under targetPath
|
||||
const onSubmitReply = useEffectEvent(async () => {
|
||||
// Reply mode: create particle directly under targetPath.
|
||||
const onSubmitReply = useCallback(async () => {
|
||||
if (!targetPath || !userId || stepRef.current === "submitting") return;
|
||||
setStepSync("submitting");
|
||||
try {
|
||||
@@ -371,7 +373,7 @@ export function ComposeOverlay({
|
||||
} catch (err) {
|
||||
if (!handleQuotaError(err)) throw err;
|
||||
}
|
||||
});
|
||||
}, [targetPath, userId, setStepSync, createChildParticle, cancel, handleQuotaError]);
|
||||
|
||||
// New stream mode: create stream + first child
|
||||
const handleStreamSubmit = useCallback(
|
||||
@@ -397,7 +399,7 @@ export function ComposeOverlay({
|
||||
if (!handleQuotaError(err)) throw err;
|
||||
}
|
||||
},
|
||||
[networkId, userId, createStream, createChildParticle, cancel, handleQuotaError],
|
||||
[networkId, userId, createStream, createChildParticle, cancel, handleQuotaError, setStepSync],
|
||||
);
|
||||
|
||||
// --- Compose intent handlers ---
|
||||
@@ -468,20 +470,25 @@ export function ComposeOverlay({
|
||||
// executes the matching handler and clears the intent. Keyboard handlers
|
||||
// call the same handlers directly without a store round-trip.
|
||||
|
||||
const intent = useComposeIntentStore((s) => s.intent);
|
||||
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(() => {
|
||||
if (!intent) return;
|
||||
switch (intent.kind) {
|
||||
case "record": handleRecordIntent(); break;
|
||||
case "text": handleTextIntent(); break;
|
||||
case "stop": handleStopIntent(); break;
|
||||
case "cancel": handleCancelIntent(); break;
|
||||
case "send": handleSendIntent(); break;
|
||||
}
|
||||
clearIntent();
|
||||
}, [intent, handleRecordIntent, handleTextIntent, handleStopIntent, handleCancelIntent, handleSendIntent, clearIntent]);
|
||||
return useComposeIntentStore.subscribe((state, prev) => {
|
||||
const intent = state.intent;
|
||||
if (!intent || intent === prev.intent) return;
|
||||
switch (intent.kind) {
|
||||
case "record": handleRecordIntent(); break;
|
||||
case "text": handleTextIntent(); break;
|
||||
case "stop": handleStopIntent(); break;
|
||||
case "cancel": handleCancelIntent(); break;
|
||||
case "send": handleSendIntent(); break;
|
||||
}
|
||||
clearIntent();
|
||||
});
|
||||
}, [handleRecordIntent, handleTextIntent, handleStopIntent, handleCancelIntent, handleSendIntent, clearIntent]);
|
||||
|
||||
// --- Keyboard handling ---
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Paperclip } from "lucide-react";
|
||||
import type { RecordingMode } from "@/hooks/use-recording-mode";
|
||||
import { AudioLevelBars } from "@/components/audio/audio-level-bars";
|
||||
import { useAudioSource } from "@/components/audio/use-audio-source";
|
||||
import { useObjectUrl } from "@/hooks/use-object-url";
|
||||
import { AttachmentStrip } from "@/features/compose/attachment-strip";
|
||||
import type { PendingAttachment } from "@/features/compose/attachment-strip";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -65,23 +66,11 @@ function ReviewPlayback({
|
||||
mirror?: boolean;
|
||||
objectFit?: "cover" | "contain";
|
||||
}) {
|
||||
const urlRef = useRef<string | null>(null);
|
||||
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
||||
const objectUrl = useObjectUrl(blob);
|
||||
const audioElRef = useRef<HTMLAudioElement | null>(null);
|
||||
const [audioEl, setAudioEl] = useState<HTMLAudioElement | null>(null);
|
||||
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 (isVideo) {
|
||||
|
||||
@@ -33,22 +33,19 @@ export const MediaParticleView = forwardRef<MediaParticleHandle, MediaParticleVi
|
||||
onEnded,
|
||||
onProgress,
|
||||
}, ref) {
|
||||
// Prefer the worker-produced iOS-playable variant when present so desktop
|
||||
// and mobile read the same canonical asset. Falls back to the original.
|
||||
// Pin the choice for the lifetime of this particle: if a transcoded variant
|
||||
// arrives via Firestore mid-playback, swapping the <video> src would restart
|
||||
// playback from 0. Keep whatever we picked first; the original plays fine in
|
||||
// Electron, and the transcoded variant will be picked up on the next view.
|
||||
const pickedSourceRef = useRef<{ id: string; objectId: string; mime: string } | null>(null);
|
||||
if (pickedSourceRef.current?.id !== particle.id) {
|
||||
pickedSourceRef.current = {
|
||||
id: particle.id,
|
||||
objectId: particle.properties.transcoded_object_id ?? particle.properties.object_id,
|
||||
mime: particle.properties.transcoded_mime_type ?? particle.properties.mime_type,
|
||||
};
|
||||
}
|
||||
const activeObjectId = pickedSourceRef.current.objectId;
|
||||
const activeMime = pickedSourceRef.current.mime;
|
||||
// Prefer the worker-produced iOS-playable variant when present so desktop and
|
||||
// mobile read the same canonical asset, falling back to the original. Pinned
|
||||
// on mount (the parent keys this component by particle.id, so a new particle
|
||||
// remounts and re-picks): if a transcoded variant arrives via Firestore for
|
||||
// the same particle, swapping the <video> src would restart playback from 0.
|
||||
const [pickedSource] = useState(() => ({
|
||||
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 { data: url, error } = useDownloadUrl(activeObjectId);
|
||||
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 {
|
||||
Radio,
|
||||
@@ -186,7 +186,9 @@ const StreamRow = memo(function StreamRow({
|
||||
? getMessagePreview(latestChild)
|
||||
: 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 =
|
||||
latestChild &&
|
||||
@@ -254,12 +256,12 @@ const StreamRow = memo(function StreamRow({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<TypeIcon
|
||||
className={cn(
|
||||
{createElement(typeIcon, {
|
||||
className: cn(
|
||||
"size-3.5 shrink-0",
|
||||
isUnseen ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
),
|
||||
})}
|
||||
<Small
|
||||
className={cn(
|
||||
"truncate",
|
||||
|
||||
@@ -34,7 +34,10 @@ export function StreamMembersOverlay({
|
||||
const network = useNetwork(networkId);
|
||||
const humans = network?.humans ?? [];
|
||||
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(
|
||||
() => toFirestoreDocPath(particlePath(networkId, [streamParticle.id])),
|
||||
|
||||
@@ -51,24 +51,26 @@ function useExitCountdown(
|
||||
disabled: boolean,
|
||||
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(() => {
|
||||
onExit();
|
||||
});
|
||||
|
||||
// Start/cancel countdown based on playback status
|
||||
useEffect(() => {
|
||||
if (status === "ended") {
|
||||
setRemainingMs(EXIT_DELAY_MS);
|
||||
} else {
|
||||
setRemainingMs(null);
|
||||
}
|
||||
}, [status]);
|
||||
// Start the countdown when playback ends; cancel it otherwise.
|
||||
if (status !== prevStatus) {
|
||||
setPrevStatus(status);
|
||||
setRemainingMs(status === "ended" ? EXIT_DELAY_MS : null);
|
||||
}
|
||||
|
||||
const isCountingDown = remainingMs !== null && remainingMs > 0;
|
||||
|
||||
// Tick the countdown down (pauses when compose is active)
|
||||
useEffect(() => {
|
||||
if (remainingMs === null || remainingMs <= 0 || disabled) return;
|
||||
if (!isCountingDown || disabled) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setRemainingMs((prev) => {
|
||||
@@ -79,7 +81,7 @@ function useExitCountdown(
|
||||
}, EXIT_TICK_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [remainingMs !== null && remainingMs > 0, disabled]);
|
||||
}, [isCountingDown, disabled]);
|
||||
|
||||
// Navigate once countdown hits zero
|
||||
useEffect(() => {
|
||||
@@ -197,12 +199,13 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
|
||||
const reactions = getReactions(currentParticle);
|
||||
toggleParticleReaction(currentParticleDocPath, emoji, authedUser.id, reactions);
|
||||
}, [authedUser, currentParticle]);
|
||||
}, [authedUser, currentParticle, networkId, streamParticle.id]);
|
||||
|
||||
const [composeActive, setComposeActive] = useState(false);
|
||||
const [composeStep, setComposeStep] = useState<ComposeStep>("idle");
|
||||
const paused = usePlaybackPauseStore(selectIsPaused);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [prevParticleId, setPrevParticleId] = useState(currentParticle?.id);
|
||||
const [showKeybindings, setShowKeybindings] = useState(false);
|
||||
const [textReactionOpen, setTextReactionOpen] = useState(false);
|
||||
|
||||
@@ -286,10 +289,11 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
handleExitNavigate,
|
||||
);
|
||||
|
||||
// Reset progress when particle changes
|
||||
useEffect(() => {
|
||||
// Reset progress when the particle changes.
|
||||
if (currentParticle?.id !== prevParticleId) {
|
||||
setPrevParticleId(currentParticle?.id);
|
||||
setProgress(0);
|
||||
}, [currentParticle?.id]);
|
||||
}
|
||||
|
||||
const handleParticleCreated = useCallback((particleId: string) => {
|
||||
if (currentIndex === -1) return;
|
||||
|
||||
@@ -14,13 +14,19 @@ interface TextReactionInputProps {
|
||||
|
||||
export function TextReactionInput({ open, onSubmit, onClose }: TextReactionInputProps) {
|
||||
const [value, setValue] = useState("");
|
||||
const [prevOpen, setPrevOpen] = useState(open);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useSuspendPlayback(open, "text-reaction");
|
||||
|
||||
// Clear the input each time the popup opens.
|
||||
if (open !== prevOpen) {
|
||||
setPrevOpen(open);
|
||||
if (open) setValue("");
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setValue("");
|
||||
const id = requestAnimationFrame(() => inputRef.current?.focus());
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [open]);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useRef } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Transcript } from "@/api/types";
|
||||
|
||||
type Sentence = Transcript["paragraphs"][number]["sentences"][number];
|
||||
@@ -41,35 +41,40 @@ export function TranscriptOverlay({
|
||||
const activeWord =
|
||||
activeWordIndex !== null ? transcript.words[activeWordIndex] : null;
|
||||
|
||||
// Remember the last spoken word so highlights hold during pauses
|
||||
const lastSpokenWordRef = useRef<Word | null>(null);
|
||||
if (activeWord) {
|
||||
lastSpokenWordRef.current = activeWord;
|
||||
// Remember the last spoken word so highlights hold during pauses.
|
||||
const [lastSpokenWord, setLastSpokenWord] = useState<Word | null>(null);
|
||||
if (activeWord && activeWord !== lastSpokenWord) {
|
||||
setLastSpokenWord(activeWord);
|
||||
}
|
||||
const highlightWord = activeWord ?? lastSpokenWordRef.current;
|
||||
const highlightWord = activeWord ?? lastSpokenWord;
|
||||
|
||||
const lastChunkRef = useRef<Word[] | null>(null);
|
||||
|
||||
// Find which chunk contains the active word, holding the last one during pauses
|
||||
const activeChunk = useMemo(() => {
|
||||
if (activeWord) {
|
||||
for (const chunk of chunks) {
|
||||
if (chunk.some((w) => w.start === activeWord.start && w.end === activeWord.end)) {
|
||||
lastChunkRef.current = chunk;
|
||||
return chunk;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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;
|
||||
// The chunk currently being spoken (null during a pause or if not found).
|
||||
const spokenChunk = useMemo(() => {
|
||||
if (!activeWord) return null;
|
||||
return (
|
||||
chunks.find((chunk) =>
|
||||
chunk.some(
|
||||
(w) => w.start === activeWord.start && w.end === activeWord.end,
|
||||
),
|
||||
) ?? null
|
||||
);
|
||||
}, [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;
|
||||
|
||||
return (
|
||||
|
||||
@@ -34,12 +34,20 @@ function usePreviewStream(
|
||||
): { stream: MediaStream | null; error: string | null } {
|
||||
const [stream, setStream] = useState<MediaStream | 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) {
|
||||
setStream(null);
|
||||
return;
|
||||
setError(null);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
let cancelled = false;
|
||||
let active: MediaStream | null = null;
|
||||
|
||||
@@ -23,11 +23,7 @@ export function useChannel(channelId: string | null): UseChannelResult {
|
||||
const [messages, setMessages] = useState<ChannelMessage[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !channelId) {
|
||||
setPresence([]);
|
||||
setMessages([]);
|
||||
return;
|
||||
}
|
||||
if (!client || !channelId) return;
|
||||
|
||||
client.subscribe(channelId);
|
||||
|
||||
@@ -66,6 +62,9 @@ export function useChannel(channelId: string | null): UseChannelResult {
|
||||
client.off(channelId, "leave", onLeave);
|
||||
client.off(channelId, "message", onMessage);
|
||||
client.unsubscribe(channelId);
|
||||
// Clear on teardown so a new channel doesn't briefly show stale data.
|
||||
setPresence([]);
|
||||
setMessages([]);
|
||||
};
|
||||
}, [client, channelId]);
|
||||
|
||||
|
||||
@@ -10,9 +10,12 @@ export function useFileInput({ onFilesSelected, enabled }: UseFileInputOptions)
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
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);
|
||||
onFilesRef.current = onFilesSelected;
|
||||
useEffect(() => {
|
||||
onFilesRef.current = onFilesSelected;
|
||||
}, [onFilesSelected]);
|
||||
|
||||
// Hidden file input element
|
||||
useEffect(() => {
|
||||
|
||||
@@ -25,21 +25,23 @@ export function useMediaDevices(): UseMediaDevicesResult {
|
||||
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 refresh = useCallback(() => {
|
||||
return navigator.mediaDevices
|
||||
.enumerateDevices()
|
||||
.then((list) => {
|
||||
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 () => {
|
||||
|
||||
@@ -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 {
|
||||
subscribeToParticle,
|
||||
subscribeToParticleChildren,
|
||||
@@ -27,10 +27,6 @@ export function useLiveParticle(path: ParticlePath): UseLiveParticleResult {
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setParticle(null);
|
||||
|
||||
const docPath = toFirestoreDocPath(path);
|
||||
const unsubscribe = subscribeToParticle(
|
||||
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]);
|
||||
|
||||
return { particle, isLoading, error };
|
||||
@@ -83,16 +85,17 @@ export function useLiveParticleChildren(
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
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(() => {
|
||||
if (!path) {
|
||||
setChildren([]);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
onAddedRef.current = onAdded;
|
||||
onRemovedRef.current = onRemoved;
|
||||
}, [onAdded, onRemoved]);
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setChildren([]);
|
||||
useEffect(() => {
|
||||
if (!path) return;
|
||||
|
||||
const collectionPath = toFirestoreChildrenPath(path);
|
||||
|
||||
@@ -111,15 +114,27 @@ export function useLiveParticleChildren(
|
||||
visibilityScopes,
|
||||
orderByField,
|
||||
orderDirection,
|
||||
onAdded,
|
||||
onRemoved,
|
||||
onAdded: (child: Particle) => onAddedRef.current?.(child),
|
||||
onRemoved: (child: Particle, updatedChildren: Particle[]) =>
|
||||
onRemovedRef.current?.(child, updatedChildren),
|
||||
whereFilter,
|
||||
limit,
|
||||
}
|
||||
);
|
||||
|
||||
return unsubscribe;
|
||||
}, [path, whereFilter, limit]);
|
||||
return () => {
|
||||
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 };
|
||||
}
|
||||
@@ -134,9 +149,6 @@ export function useLiveLatestChild(path: ParticlePath): UseLiveLatestChildResult
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
setLatestChild(null);
|
||||
|
||||
const unsubscribe = subscribeToLatestChild(
|
||||
toFirestoreChildrenPath(path),
|
||||
(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]);
|
||||
|
||||
return { latestChild, isLoading };
|
||||
|
||||
@@ -12,19 +12,23 @@ export function useStreamKeyboardNav({
|
||||
enabled,
|
||||
onNavigate,
|
||||
}: 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 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]);
|
||||
// Select the first stream once they load and clear when empty — but not on
|
||||
// every Firestore update, which would scroll the list back to the top.
|
||||
if (streams.length !== prevStreamCount) {
|
||||
setPrevStreamCount(streams.length);
|
||||
if (streams.length === 0) {
|
||||
setSelectedIndex(null);
|
||||
} else if (selectedIndex === null) {
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
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 { useLiveParticleChildren } from "@/hooks/use-particle";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
@@ -52,14 +52,16 @@ export function useStreamParticles(
|
||||
const visibilityScopes = useVisibilityScopes(user?.id, networkId);
|
||||
|
||||
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
|
||||
// window. Avoids an ever-growing subscription across a long session.
|
||||
useEffect(() => {
|
||||
// Switching back to the closed tab starts a fresh window, avoiding an
|
||||
// ever-growing subscription across a long session.
|
||||
if (status !== prevStatus) {
|
||||
setPrevStatus(status);
|
||||
if (status === "closed") {
|
||||
setClosedLimit(CLOSED_INITIAL_PAGE_SIZE);
|
||||
}
|
||||
}, [status]);
|
||||
}
|
||||
|
||||
const whereFilter: QueryFieldFilterConstraint =
|
||||
status === "open" ? OPEN_STATUS_FILTER : CLOSED_STATUS_FILTER;
|
||||
|
||||
@@ -84,21 +84,25 @@ export function useStreamPlayback(
|
||||
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);
|
||||
// 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 ---
|
||||
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 onParticleRemoved = useCallback((removed: Particle, updatedChildren: Particle[]) => {
|
||||
const fallbackIndex = Math.min(currentIndexRef.current, 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,
|
||||
@@ -118,6 +122,11 @@ export function useStreamPlayback(
|
||||
|
||||
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
|
||||
const initFallback = useEffectEvent(() => {
|
||||
if (state.initialized || children.length === 0) return;
|
||||
|
||||
@@ -79,7 +79,7 @@ class SoundEffectsEngine {
|
||||
*/
|
||||
preload(name: string, url: string): Promise<AudioBuffer | null> {
|
||||
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);
|
||||
if (existing) return existing;
|
||||
|
||||
Reference in New Issue
Block a user