refactor: re-structure stream playback control
This commit is contained in:
@@ -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 ?? [];
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useEffect, useId } from "react";
|
||||
import { usePlaybackPauseStore } from "@/stores/playback-pause-store";
|
||||
|
||||
/**
|
||||
* Suspend stream playback while `active` is true. The hook owns its own
|
||||
* registration id; multiple instances compose. `label` is for devtools only.
|
||||
*/
|
||||
export function useSuspendPlayback(active: boolean, label: string) {
|
||||
const id = useId();
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const { add, remove } = usePlaybackPauseStore.getState();
|
||||
add(id, label);
|
||||
return () => remove(id);
|
||||
}, [active, id, label]);
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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) })),
|
||||
}));
|
||||
Reference in New Issue
Block a user