diff --git a/js/src/components/keybindings-overlay.tsx b/js/src/components/keybindings-overlay.tsx index 01b5300..d81116e 100644 --- a/js/src/components/keybindings-overlay.tsx +++ b/js/src/components/keybindings-overlay.tsx @@ -1,4 +1,4 @@ -import { usePlaybackSuspenderStore } from "@/stores/playback-suspender-store"; +import { useSuspendPlayback } from "@/hooks/use-suspend-playback"; import { useEffect } from "react"; import { createPortal } from "react-dom"; @@ -25,13 +25,7 @@ export function KeybindingsOverlay({ groups, title = "Keyboard Shortcuts", }: KeybindingsOverlayProps) { - // Suspend stream playback - useEffect(() => { - if (!open) return; - const { suspend, release } = usePlaybackSuspenderStore.getState(); - suspend(); - return release; - }, [open]); + useSuspendPlayback(open, "keybindings"); useEffect(() => { if (!open) return; diff --git a/js/src/features/attachments/attachment-lightbox.tsx b/js/src/features/attachments/attachment-lightbox.tsx index 9979f38..9353424 100644 --- a/js/src/features/attachments/attachment-lightbox.tsx +++ b/js/src/features/attachments/attachment-lightbox.tsx @@ -11,7 +11,7 @@ import { } from "lucide-react"; import { useDownloadUrl } from "@/hooks/use-download-url"; import { Button } from "@/components/ui/button"; -import { usePlaybackSuspenderStore } from "@/stores/playback-suspender-store"; +import { useSuspendPlayback } from "@/hooks/use-suspend-playback"; export interface AttachmentItem { id: string; @@ -111,13 +111,7 @@ export function AttachmentLightbox({ // Otherwise openIndex stays — the next item shifts into its place. }; - // Suspend stream playback while the lightbox is open. - useEffect(() => { - if (!isOpen) return; - const { suspend, release } = usePlaybackSuspenderStore.getState(); - suspend(); - return release; - }, [isOpen]); + useSuspendPlayback(isOpen, "attachment-lightbox"); // Keyboard handling — only listens while open. Registered in the capture // phase with stopImmediatePropagation so we consume keys (arrows, D, ⌫) diff --git a/js/src/features/compose/compose-overlay.tsx b/js/src/features/compose/compose-overlay.tsx index d90b9eb..fe5ada2 100644 --- a/js/src/features/compose/compose-overlay.tsx +++ b/js/src/features/compose/compose-overlay.tsx @@ -21,6 +21,7 @@ import { useFileInput } from "@/hooks/use-file-input"; import { createImageThumbnail } from "@/lib/image-thumbnail"; import { MAX_ATTACHMENT_SIZE_BYTES, MAX_ATTACHMENTS } from "@/lib/constants"; import type { PendingAttachment } from "@/features/compose/attachment-strip"; +import { useSuspendPlayback } from "@/hooks/use-suspend-playback"; export type ComposeStep = "idle" | "picking" | "recording" | "reviewing" | "typing" | "configuring" | "submitting"; @@ -91,6 +92,8 @@ export function ComposeOverlay({ setStep(next); }, []); + useSuspendPlayback(step !== "idle", "compose"); + // Notify parent when active state changes useEffect(() => { onActiveChange?.(step !== "idle"); diff --git a/js/src/features/particles/delete-particle-overlay.tsx b/js/src/features/particles/delete-particle-overlay.tsx index 5d3167e..8ba496b 100644 --- a/js/src/features/particles/delete-particle-overlay.tsx +++ b/js/src/features/particles/delete-particle-overlay.tsx @@ -1,10 +1,10 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import { toast } from "sonner"; import { ConfirmDestructiveOverlay } from "@/components/confirm-destructive-overlay"; import { softDeleteParticle } from "@/lib/firestore-particles"; import { particlePath, toFirestoreDocPath } from "@/lib/particle-path"; import type { Particle } from "@/api/types"; -import { usePlaybackSuspenderStore } from "@/stores/playback-suspender-store"; +import { useSuspendPlayback } from "@/hooks/use-suspend-playback"; interface DeleteParticleOverlayProps { networkId: string; @@ -21,12 +21,7 @@ export function DeleteParticleOverlay({ userId, onClose, }: DeleteParticleOverlayProps) { - // Suspend stream playback - useEffect(() => { - const { suspend, release } = usePlaybackSuspenderStore.getState(); - suspend(); - return release; - }, []); + useSuspendPlayback(true, "delete-particle"); const [deleting, setDeleting] = useState(false); diff --git a/js/src/features/particles/media-particle-view.tsx b/js/src/features/particles/media-particle-view.tsx index 1d5d0a2..d08b145 100644 --- a/js/src/features/particles/media-particle-view.tsx +++ b/js/src/features/particles/media-particle-view.tsx @@ -72,7 +72,8 @@ export const MediaParticleView = forwardRef { console.warn("Playback failed", { particleId: particle.id }); }); diff --git a/js/src/features/particles/rename-stream-overlay.tsx b/js/src/features/particles/rename-stream-overlay.tsx index 9ca0969..0d623d8 100644 --- a/js/src/features/particles/rename-stream-overlay.tsx +++ b/js/src/features/particles/rename-stream-overlay.tsx @@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button"; import { updateParticleProperties } from "@/lib/firestore-particles"; import { particlePath, toFirestoreDocPath } from "@/lib/particle-path"; import type { Particle } from "@/api/types"; -import { usePlaybackSuspenderStore } from "@/stores/playback-suspender-store"; +import { useSuspendPlayback } from "@/hooks/use-suspend-playback"; interface RenameStreamOverlayProps { networkId: string; @@ -18,12 +18,7 @@ export function RenameStreamOverlay({ streamParticle, onClose, }: RenameStreamOverlayProps) { - // Suspend stream playback - useEffect(() => { - const { suspend, release } = usePlaybackSuspenderStore.getState(); - suspend(); - return release; - }, []); + useSuspendPlayback(true, "rename-stream"); const [name, setName] = useState(streamParticle.properties.name); const [saving, setSaving] = useState(false); diff --git a/js/src/features/particles/stream-members-overlay.tsx b/js/src/features/particles/stream-members-overlay.tsx index c95a089..999c5f1 100644 --- a/js/src/features/particles/stream-members-overlay.tsx +++ b/js/src/features/particles/stream-members-overlay.tsx @@ -14,7 +14,7 @@ import { useNetwork } from "@/hooks/use-networks"; import { cn, getInitials } from "@/lib/utils"; import { resolveHumanDisplay } from "@/lib/humans"; import type { Particle } from "@/api/types"; -import { usePlaybackSuspenderStore } from "@/stores/playback-suspender-store"; +import { useSuspendPlayback } from "@/hooks/use-suspend-playback"; interface StreamMembersOverlayProps { networkId: string; @@ -29,12 +29,7 @@ export function StreamMembersOverlay({ isCreator, onClose, }: StreamMembersOverlayProps) { - // Suspend stream playback - useEffect(() => { - const { suspend, release } = usePlaybackSuspenderStore.getState(); - suspend(); - return release; - }, []); + useSuspendPlayback(true, "stream-members"); const network = useNetwork(networkId); const humans = network?.humans ?? []; diff --git a/js/src/features/particles/stream-view.tsx b/js/src/features/particles/stream-view.tsx index d8a0247..c239e08 100644 --- a/js/src/features/particles/stream-view.tsx +++ b/js/src/features/particles/stream-view.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useEffectEvent, useCallback, useRef } from "react" import { useNavigate } from "react-router-dom"; import { useAuthStore } from "@/stores/auth-store"; import { apiClient } from "@/api/client"; -import { isParticleDeleted, type Particle, REACTION_EMOJIS } from "@/api/types"; +import { isParticleDeleted, type Particle } from "@/api/types"; import { parseParticlePath, particlePath, toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path"; import { ComposeOverlay, type ComposeStep } from "@/features/compose/compose-overlay"; import { PlaybackPageIndicator } from "@/features/particles/playback-page-indicator"; @@ -24,7 +24,10 @@ import { StreamPresenceProvider, useStreamPresence, useStreamComposing, useStrea import { ComposingIndicator } from "@/components/composing-indicator"; import { cn } from "@/lib/utils"; import { useMount } from "react-use"; -import { usePlaybackSuspenderStore } from "@/stores/playback-suspender-store"; +import { usePlaybackPauseStore, selectIsPaused } from "@/stores/playback-pause-store"; +import { usePlaybackKeys } from "@/hooks/use-playback-keys"; +import { useStreamNavigationKeys } from "@/hooks/use-stream-navigation-keys"; +import { useStreamActionKeys } from "@/hooks/use-stream-action-keys"; function getReactions(particle: Particle): Record | undefined { if (isParticleDeleted(particle)) return undefined; @@ -36,7 +39,6 @@ function getReactions(particle: Particle): Record | undefined const EXIT_DELAY_MS = 5000; const EXIT_TICK_MS = 100; -const SEEK_DELTA_SEC = 5; type PlaybackStatus = "idle" | "playing" | "ended"; @@ -151,12 +153,9 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) { currentParticle, currentIndex, status, - paused, next, prev, goTo, - pause, - resume, } = useStreamPlayback(streamParticle, path); usePrefetchAdjacentMedia(children, currentIndex); @@ -195,13 +194,42 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) { const [composeActive, setComposeActive] = useState(false); const [composeStep, setComposeStep] = useState("idle"); - const playbackSuspended = usePlaybackSuspenderStore((s) => s.suspendCount > 0); - const playbackBlocked = composeActive || playbackSuspended; - const [spaceHeld, setSpaceHeld] = useState(false); + const paused = usePlaybackPauseStore(selectIsPaused); const [progress, setProgress] = useState(0); - const [fastPlayback, setFastPlayback] = useState(false); const [showKeybindings, setShowKeybindings] = useState(false); + const { fastPlayback } = usePlaybackKeys({ mediaRef }); + + useStreamNavigationKeys({ + next, + prev, + currentIndex, + childrenLength: children.length, + mediaRef, + }); + + const handleOpenHuddle = useCallback(() => { + apiClient.getLivekitToken(networkId, streamParticle.id).then(({ token, server_url }) => { + window.electronWindow.openHuddle({ token, serverUrl: server_url }); + }); + navigate(`/${networkId}`); + }, [networkId, streamParticle.id, navigate]); + + const handleToggleRecordingMode = useCallback(() => { + setRecordingMode(recordingMode === "video" ? "audio" : "video"); + }, [recordingMode, setRecordingMode]); + + const handleToggleKeybindings = useCallback(() => { + setShowKeybindings((v) => !v); + }, []); + + useStreamActionKeys({ + onToggleReaction: handleToggleReaction, + onOpenHuddle: handleOpenHuddle, + onToggleRecordingMode: handleToggleRecordingMode, + onToggleKeybindings: handleToggleKeybindings, + }); + // Broadcast composing state to other viewers useEffect(() => { const stepToMode: Record = { @@ -240,7 +268,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) { const exitRemainingMs = useExitCountdown( status, - playbackBlocked || spaceHeld, + paused, handleExitNavigate, ); @@ -249,112 +277,6 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) { setProgress(0); }, [currentParticle?.id]); - // Pause/resume playback when compose overlay or lightbox is open. - useEffect(() => { - if (playbackBlocked) pause(); - else resume(); - }, [playbackBlocked, pause, resume]); - - // Playback keyboard: arrows, escape, hold-space-to-pause - useEffect(() => { - function isInputTarget(e: KeyboardEvent) { - const target = e.target as HTMLElement; - return ( - target.tagName === "INPUT" || - target.tagName === "TEXTAREA" || - target.isContentEditable - ); - } - - const handleKeyDown = (e: KeyboardEvent) => { - if (playbackBlocked) return; - if (isInputTarget(e)) return; - - const hasNext = currentIndex >= 0 && currentIndex < children.length - 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 " ": - e.preventDefault(); - if (!e.repeat) { - if (status !== "ended") pause(); - setSpaceHeld(true); - } - break; - case "Shift": - if (!e.repeat) { - mediaRef.current?.setPlaybackRate(1.5); - setFastPlayback(true); - } - break; - case "Escape": - e.preventDefault(); - navigate(-1); - break; - case "h": { - e.preventDefault(); - apiClient.getLivekitToken(networkId, streamParticle.id).then(({ token, server_url }) => { - window.electronWindow.openHuddle({ token, serverUrl: server_url }); - }); - navigate(`/${networkId}`); - break; - } - case "v": - e.preventDefault(); - setRecordingMode(recordingMode === "video" ? "audio" : "video"); - break; - case "?": - e.preventDefault(); - setShowKeybindings((v) => !v); - break; - case "1": case "2": case "3": case "4": case "5": case "6": case "7": - e.preventDefault(); - handleToggleReaction(REACTION_EMOJIS[parseInt(e.key) - 1]); - break; - } - }; - - const handleKeyUp = (e: KeyboardEvent) => { - if (playbackBlocked) return; - if (isInputTarget(e)) return; - - if (e.key === " ") { - e.preventDefault(); - if (status !== "ended") resume(); - setSpaceHeld(false); - } - if (e.key === "Shift") { - mediaRef.current?.setPlaybackRate(1); - setFastPlayback(false); - } - }; - - window.addEventListener("keydown", handleKeyDown); - window.addEventListener("keyup", handleKeyUp); - return () => { - window.removeEventListener("keydown", handleKeyDown); - window.removeEventListener("keyup", handleKeyUp); - }; - }, - [playbackBlocked, status, currentIndex, children.length, next, prev, pause, resume, navigate, networkId, streamParticle.id, setShowKeybindings, handleToggleReaction, recordingMode, setRecordingMode], - ); if (children.length === 0) { return ( diff --git a/js/src/features/particles/text-edit-overlay.tsx b/js/src/features/particles/text-edit-overlay.tsx index 3654547..1ffa065 100644 --- a/js/src/features/particles/text-edit-overlay.tsx +++ b/js/src/features/particles/text-edit-overlay.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import { createPortal } from "react-dom"; import { toast } from "sonner"; import type { Particle } from "@/api/types"; @@ -10,7 +10,7 @@ import { } from "@/lib/particle-path"; import { editTextParticleContent } from "@/lib/firestore-particles"; import { TextEditor } from "@/features/compose/text-editor"; -import { usePlaybackSuspenderStore } from "@/stores/playback-suspender-store"; +import { useSuspendPlayback } from "@/hooks/use-suspend-playback"; type TextParticle = Extract; @@ -25,11 +25,7 @@ export function TextEditOverlay({ streamPath, onClose, }: TextEditOverlayProps) { - useEffect(() => { - const { suspend, release } = usePlaybackSuspenderStore.getState(); - suspend(); - return release; - }, []); + useSuspendPlayback(true, "text-edit"); const [textContent, setTextContent] = useState(particle.properties.content); const [saving, setSaving] = useState(false); diff --git a/js/src/features/particles/text-particle-view.tsx b/js/src/features/particles/text-particle-view.tsx index 8048f56..dfeb543 100644 --- a/js/src/features/particles/text-particle-view.tsx +++ b/js/src/features/particles/text-particle-view.tsx @@ -165,7 +165,7 @@ export function TextParticleView({ }, [particle.id]); useEffect(() => { - if (paused || isEditing) return; + if (paused) return; const interval = setInterval(() => { elapsedRef.current += TICK_MS / 1000; @@ -179,7 +179,7 @@ export function TextParticleView({ }, TICK_MS); return () => clearInterval(interval); - }, [paused, isEditing, durationS, onEnded, onProgress, particle.id]); + }, [paused, durationS, onEnded, onProgress, particle.id]); // Content is just bare URLs with no surrounding text const contentTrimmed = content.trim(); diff --git a/js/src/hooks/use-playback-keys.ts b/js/src/hooks/use-playback-keys.ts new file mode 100644 index 0000000..97f65d3 --- /dev/null +++ b/js/src/hooks/use-playback-keys.ts @@ -0,0 +1,73 @@ +import { useEffect, 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"; + +interface UsePlaybackKeysOptions { + mediaRef: RefObject; +} + +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); + + 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) setSpaceHeld(true); + 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(); + setSpaceHeld(false); + 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 }; +} diff --git a/js/src/hooks/use-stream-action-keys.ts b/js/src/hooks/use-stream-action-keys.ts new file mode 100644 index 0000000..8ea23db --- /dev/null +++ b/js/src/hooks/use-stream-action-keys.ts @@ -0,0 +1,57 @@ +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; +} + +/** + * Reactions 1–7, `h` huddle, `v` toggle recording mode, `?` toggle + * keybindings overlay. Skipped while playback is paused for any reason. + */ +export function useStreamActionKeys({ + onToggleReaction, + onOpenHuddle, + onToggleRecordingMode, + onToggleKeybindings, +}: 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 "?": + 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]); +} diff --git a/js/src/hooks/use-stream-navigation-keys.ts b/js/src/hooks/use-stream-navigation-keys.ts new file mode 100644 index 0000000..dfc1a60 --- /dev/null +++ b/js/src/hooks/use-stream-navigation-keys.ts @@ -0,0 +1,66 @@ +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; +} + +/** + * 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; + if (selectIsPaused(usePlaybackPauseStore.getState())) 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]); +} diff --git a/js/src/hooks/use-stream-playback.ts b/js/src/hooks/use-stream-playback.ts index 5efb5bc..3b6ce5a 100644 --- a/js/src/hooks/use-stream-playback.ts +++ b/js/src/hooks/use-stream-playback.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useEffectEvent, useMemo, useReducer, useRef, useState } from "react"; +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"; @@ -12,7 +12,6 @@ type PlaybackStatus = "idle" | "playing" | "ended"; interface PlaybackState { currentParticleId: string | null; status: PlaybackStatus; - paused: boolean; initialized: boolean; } @@ -21,14 +20,11 @@ type PlaybackAction = | { type: "SET_PARTICLE"; particleId: string } | { type: "END" } | { type: "PARTICLE_ADDED"; particleId: string } - | { type: "PARTICLE_REMOVED"; removedParticleId: string; fallbackParticleId: string | null } - | { type: "PAUSE" } - | { type: "RESUME" }; + | { type: "PARTICLE_REMOVED"; removedParticleId: string; fallbackParticleId: string | null }; const initialState: PlaybackState = { currentParticleId: null, status: "idle", - paused: false, initialized: false, }; @@ -38,7 +34,6 @@ function playbackReducer(state: PlaybackState, action: PlaybackAction): Playback return { currentParticleId: action.particleId, status: "playing", - paused: false, initialized: true, }; case "SET_PARTICLE": @@ -46,13 +41,12 @@ function playbackReducer(state: PlaybackState, action: PlaybackAction): Playback ...state, currentParticleId: action.particleId, status: "playing", - paused: false, }; case "END": - return { ...state, status: "ended", paused: false }; + return { ...state, status: "ended" }; case "PARTICLE_ADDED": if (state.status === "ended") { - return { ...state, currentParticleId: action.particleId, status: "playing", paused: false }; + return { ...state, currentParticleId: action.particleId, status: "playing" }; } return state; case "PARTICLE_REMOVED": @@ -61,10 +55,6 @@ function playbackReducer(state: PlaybackState, action: PlaybackAction): Playback return { ...state, currentParticleId: action.fallbackParticleId, status: "playing" }; } return { ...state, currentParticleId: null, status: "idle" }; - case "PAUSE": - return { ...state, paused: true }; - case "RESUME": - return { ...state, paused: false }; } } @@ -79,14 +69,11 @@ interface UseStreamPlaybackResult { currentParticle: Particle | null; currentIndex: number; status: PlaybackStatus; - paused: boolean; initialized: boolean; next: () => void; prev: () => void; goTo: (index: number) => void; goToParticle: (particleId: string) => void; - pause: () => void; - resume: () => void; } export function useStreamPlayback( @@ -228,21 +215,15 @@ export function useStreamPlayback( dispatch({ type: "SET_PARTICLE", particleId }); }, []); - const pause = useCallback(() => dispatch({ type: "PAUSE" }), []); - const resume = useCallback(() => dispatch({ type: "RESUME" }), []); - return { children, currentParticle, currentIndex, status: state.status, - paused: state.paused, initialized: state.initialized, next, prev, goTo, goToParticle, - pause, - resume, }; } diff --git a/js/src/hooks/use-suspend-playback.ts b/js/src/hooks/use-suspend-playback.ts new file mode 100644 index 0000000..8ca363f --- /dev/null +++ b/js/src/hooks/use-suspend-playback.ts @@ -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]); +} diff --git a/js/src/lib/keyboard.ts b/js/src/lib/keyboard.ts new file mode 100644 index 0000000..8abc537 --- /dev/null +++ b/js/src/lib/keyboard.ts @@ -0,0 +1,9 @@ +export function isTypingTarget(e: KeyboardEvent): boolean { + const target = e.target as HTMLElement | null; + if (!target) return false; + return ( + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable + ); +} diff --git a/js/src/stores/playback-pause-store.ts b/js/src/stores/playback-pause-store.ts new file mode 100644 index 0000000..cd5ae56 --- /dev/null +++ b/js/src/stores/playback-pause-store.ts @@ -0,0 +1,27 @@ +import { create } from "zustand"; + +/** + * Single source of truth for "is stream playback paused." Each component that + * wants to pause playback registers a unique id via `useSuspendPlayback`; the + * label is for devtools only. Playback is paused while any id is registered. + */ +interface PlaybackPauseState { + activeIds: Record; + add: (id: string, label: string) => void; + remove: (id: string) => void; +} + +export const usePlaybackPauseStore = create((set) => ({ + activeIds: {}, + add: (id, label) => + set((s) => ({ activeIds: { ...s.activeIds, [id]: label } })), + remove: (id) => + set((s) => { + if (!(id in s.activeIds)) return s; + const { [id]: _, ...rest } = s.activeIds; + return { activeIds: rest }; + }), +})); + +export const selectIsPaused = (s: PlaybackPauseState) => + Object.keys(s.activeIds).length > 0; diff --git a/js/src/stores/playback-suspender-store.ts b/js/src/stores/playback-suspender-store.ts deleted file mode 100644 index 46a1dbf..0000000 --- a/js/src/stores/playback-suspender-store.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { create } from "zustand"; - -/** - * Overlays that should pause stream playback (e.g. attachment lightbox) call - * `suspend()` on mount and `release()` on unmount. Stream playback is suspended - * whenever `suspendCount > 0`. - */ -interface PlaybackSuspenderState { - suspendCount: number; - suspend: () => void; - release: () => void; -} - -export const usePlaybackSuspenderStore = create((set) => ({ - suspendCount: 0, - suspend: () => set((s) => ({ suspendCount: s.suspendCount + 1 })), - release: () => set((s) => ({ suspendCount: Math.max(0, s.suspendCount - 1) })), -}));