refactor: re-structure stream playback control

This commit is contained in:
talksik
2026-04-25 13:48:37 -07:00
parent cf143f232d
commit 13c6bbd0fb
18 changed files with 313 additions and 206 deletions
+2 -8
View File
@@ -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;
@@ -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, ⌫)
@@ -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");
@@ -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);
@@ -72,7 +72,8 @@ export const MediaParticleView = forwardRef<MediaParticleHandle, MediaParticleVi
if (paused) {
el.pause();
} else {
} else if (!el.ended) {
// Calling play() on a naturally-finished element restarts it from 0.
el.play().catch(() => {
console.warn("Playback failed", { particleId: particle.id });
});
@@ -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);
@@ -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 ?? [];
+39 -117
View File
@@ -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<string, string[]> | undefined {
if (isParticleDeleted(particle)) return undefined;
@@ -36,7 +39,6 @@ function getReactions(particle: Particle): Record<string, string[]> | 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<ComposeStep>("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<string, ComposingMode | null> = {
@@ -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 (
@@ -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<Particle, { type: "text" }>;
@@ -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);
@@ -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();
+73
View File
@@ -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<MediaParticleHandle | null>;
}
interface UsePlaybackKeysResult {
fastPlayback: boolean;
}
/**
* Hold-space to pause, hold-shift for 1.5x. Space is always handled (it
* manages its own suspender via useSuspendPlayback); other keys bail when
* playback is already paused for an external reason.
*/
export function usePlaybackKeys({ mediaRef }: UsePlaybackKeysOptions): UsePlaybackKeysResult {
const [spaceHeld, setSpaceHeld] = useState(false);
const [fastPlayback, setFastPlayback] = useState(false);
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 };
}
+57
View File
@@ -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 17, `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]);
}
@@ -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<MediaParticleHandle | null>;
}
/**
* Arrow keys (with shift+arrow seek), Escape. Skipped while playback is
* paused for any reason (overlay, hold-space, compose).
*/
export function useStreamNavigationKeys({
next,
prev,
currentIndex,
childrenLength,
mediaRef,
}: UseStreamNavigationKeysOptions) {
const navigate = useNavigate();
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (isTypingTarget(e)) return;
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]);
}
+4 -23
View File
@@ -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,
};
}
+17
View File
@@ -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]);
}
+9
View File
@@ -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
);
}
+27
View File
@@ -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<string, string>;
add: (id: string, label: string) => void;
remove: (id: string) => void;
}
export const usePlaybackPauseStore = create<PlaybackPauseState>((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;
-18
View File
@@ -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<PlaybackSuspenderState>((set) => ({
suspendCount: 0,
suspend: () => set((s) => ({ suspendCount: s.suspendCount + 1 })),
release: () => set((s) => ({ suspendCount: Math.max(0, s.suspendCount - 1) })),
}));