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 { useEffect } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
@@ -25,13 +25,7 @@ export function KeybindingsOverlay({
|
|||||||
groups,
|
groups,
|
||||||
title = "Keyboard Shortcuts",
|
title = "Keyboard Shortcuts",
|
||||||
}: KeybindingsOverlayProps) {
|
}: KeybindingsOverlayProps) {
|
||||||
// Suspend stream playback
|
useSuspendPlayback(open, "keybindings");
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
const { suspend, release } = usePlaybackSuspenderStore.getState();
|
|
||||||
suspend();
|
|
||||||
return release;
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useDownloadUrl } from "@/hooks/use-download-url";
|
import { useDownloadUrl } from "@/hooks/use-download-url";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { usePlaybackSuspenderStore } from "@/stores/playback-suspender-store";
|
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
||||||
|
|
||||||
export interface AttachmentItem {
|
export interface AttachmentItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -111,13 +111,7 @@ export function AttachmentLightbox({
|
|||||||
// Otherwise openIndex stays — the next item shifts into its place.
|
// Otherwise openIndex stays — the next item shifts into its place.
|
||||||
};
|
};
|
||||||
|
|
||||||
// Suspend stream playback while the lightbox is open.
|
useSuspendPlayback(isOpen, "attachment-lightbox");
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return;
|
|
||||||
const { suspend, release } = usePlaybackSuspenderStore.getState();
|
|
||||||
suspend();
|
|
||||||
return release;
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
// Keyboard handling — only listens while open. Registered in the capture
|
// Keyboard handling — only listens while open. Registered in the capture
|
||||||
// phase with stopImmediatePropagation so we consume keys (arrows, D, ⌫)
|
// 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 { createImageThumbnail } from "@/lib/image-thumbnail";
|
||||||
import { MAX_ATTACHMENT_SIZE_BYTES, MAX_ATTACHMENTS } from "@/lib/constants";
|
import { MAX_ATTACHMENT_SIZE_BYTES, MAX_ATTACHMENTS } from "@/lib/constants";
|
||||||
import type { PendingAttachment } from "@/features/compose/attachment-strip";
|
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";
|
export type ComposeStep = "idle" | "picking" | "recording" | "reviewing" | "typing" | "configuring" | "submitting";
|
||||||
|
|
||||||
@@ -91,6 +92,8 @@ export function ComposeOverlay({
|
|||||||
setStep(next);
|
setStep(next);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useSuspendPlayback(step !== "idle", "compose");
|
||||||
|
|
||||||
// Notify parent when active state changes
|
// Notify parent when active state changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onActiveChange?.(step !== "idle");
|
onActiveChange?.(step !== "idle");
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { ConfirmDestructiveOverlay } from "@/components/confirm-destructive-overlay";
|
import { ConfirmDestructiveOverlay } from "@/components/confirm-destructive-overlay";
|
||||||
import { softDeleteParticle } from "@/lib/firestore-particles";
|
import { softDeleteParticle } from "@/lib/firestore-particles";
|
||||||
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path";
|
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path";
|
||||||
import type { Particle } from "@/api/types";
|
import type { Particle } from "@/api/types";
|
||||||
import { usePlaybackSuspenderStore } from "@/stores/playback-suspender-store";
|
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
||||||
|
|
||||||
interface DeleteParticleOverlayProps {
|
interface DeleteParticleOverlayProps {
|
||||||
networkId: string;
|
networkId: string;
|
||||||
@@ -21,12 +21,7 @@ export function DeleteParticleOverlay({
|
|||||||
userId,
|
userId,
|
||||||
onClose,
|
onClose,
|
||||||
}: DeleteParticleOverlayProps) {
|
}: DeleteParticleOverlayProps) {
|
||||||
// Suspend stream playback
|
useSuspendPlayback(true, "delete-particle");
|
||||||
useEffect(() => {
|
|
||||||
const { suspend, release } = usePlaybackSuspenderStore.getState();
|
|
||||||
suspend();
|
|
||||||
return release;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,8 @@ export const MediaParticleView = forwardRef<MediaParticleHandle, MediaParticleVi
|
|||||||
|
|
||||||
if (paused) {
|
if (paused) {
|
||||||
el.pause();
|
el.pause();
|
||||||
} else {
|
} else if (!el.ended) {
|
||||||
|
// Calling play() on a naturally-finished element restarts it from 0.
|
||||||
el.play().catch(() => {
|
el.play().catch(() => {
|
||||||
console.warn("Playback failed", { particleId: particle.id });
|
console.warn("Playback failed", { particleId: particle.id });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { updateParticleProperties } from "@/lib/firestore-particles";
|
import { updateParticleProperties } from "@/lib/firestore-particles";
|
||||||
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path";
|
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path";
|
||||||
import type { Particle } from "@/api/types";
|
import type { Particle } from "@/api/types";
|
||||||
import { usePlaybackSuspenderStore } from "@/stores/playback-suspender-store";
|
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
||||||
|
|
||||||
interface RenameStreamOverlayProps {
|
interface RenameStreamOverlayProps {
|
||||||
networkId: string;
|
networkId: string;
|
||||||
@@ -18,12 +18,7 @@ export function RenameStreamOverlay({
|
|||||||
streamParticle,
|
streamParticle,
|
||||||
onClose,
|
onClose,
|
||||||
}: RenameStreamOverlayProps) {
|
}: RenameStreamOverlayProps) {
|
||||||
// Suspend stream playback
|
useSuspendPlayback(true, "rename-stream");
|
||||||
useEffect(() => {
|
|
||||||
const { suspend, release } = usePlaybackSuspenderStore.getState();
|
|
||||||
suspend();
|
|
||||||
return release;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const [name, setName] = useState(streamParticle.properties.name);
|
const [name, setName] = useState(streamParticle.properties.name);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { useNetwork } from "@/hooks/use-networks";
|
|||||||
import { cn, getInitials } from "@/lib/utils";
|
import { cn, getInitials } from "@/lib/utils";
|
||||||
import { resolveHumanDisplay } from "@/lib/humans";
|
import { resolveHumanDisplay } from "@/lib/humans";
|
||||||
import type { Particle } from "@/api/types";
|
import type { Particle } from "@/api/types";
|
||||||
import { usePlaybackSuspenderStore } from "@/stores/playback-suspender-store";
|
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
||||||
|
|
||||||
interface StreamMembersOverlayProps {
|
interface StreamMembersOverlayProps {
|
||||||
networkId: string;
|
networkId: string;
|
||||||
@@ -29,12 +29,7 @@ export function StreamMembersOverlay({
|
|||||||
isCreator,
|
isCreator,
|
||||||
onClose,
|
onClose,
|
||||||
}: StreamMembersOverlayProps) {
|
}: StreamMembersOverlayProps) {
|
||||||
// Suspend stream playback
|
useSuspendPlayback(true, "stream-members");
|
||||||
useEffect(() => {
|
|
||||||
const { suspend, release } = usePlaybackSuspenderStore.getState();
|
|
||||||
suspend();
|
|
||||||
return release;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const network = useNetwork(networkId);
|
const network = useNetwork(networkId);
|
||||||
const humans = network?.humans ?? [];
|
const humans = network?.humans ?? [];
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState, useEffect, useEffectEvent, useCallback, useRef } from "react"
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { apiClient } from "@/api/client";
|
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 { parseParticlePath, particlePath, toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path";
|
||||||
import { ComposeOverlay, type ComposeStep } from "@/features/compose/compose-overlay";
|
import { ComposeOverlay, type ComposeStep } from "@/features/compose/compose-overlay";
|
||||||
import { PlaybackPageIndicator } from "@/features/particles/playback-page-indicator";
|
import { PlaybackPageIndicator } from "@/features/particles/playback-page-indicator";
|
||||||
@@ -24,7 +24,10 @@ import { StreamPresenceProvider, useStreamPresence, useStreamComposing, useStrea
|
|||||||
import { ComposingIndicator } from "@/components/composing-indicator";
|
import { ComposingIndicator } from "@/components/composing-indicator";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useMount } from "react-use";
|
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 {
|
function getReactions(particle: Particle): Record<string, string[]> | undefined {
|
||||||
if (isParticleDeleted(particle)) return 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_DELAY_MS = 5000;
|
||||||
const EXIT_TICK_MS = 100;
|
const EXIT_TICK_MS = 100;
|
||||||
const SEEK_DELTA_SEC = 5;
|
|
||||||
|
|
||||||
type PlaybackStatus = "idle" | "playing" | "ended";
|
type PlaybackStatus = "idle" | "playing" | "ended";
|
||||||
|
|
||||||
@@ -151,12 +153,9 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
currentParticle,
|
currentParticle,
|
||||||
currentIndex,
|
currentIndex,
|
||||||
status,
|
status,
|
||||||
paused,
|
|
||||||
next,
|
next,
|
||||||
prev,
|
prev,
|
||||||
goTo,
|
goTo,
|
||||||
pause,
|
|
||||||
resume,
|
|
||||||
} = useStreamPlayback(streamParticle, path);
|
} = useStreamPlayback(streamParticle, path);
|
||||||
|
|
||||||
usePrefetchAdjacentMedia(children, currentIndex);
|
usePrefetchAdjacentMedia(children, currentIndex);
|
||||||
@@ -195,13 +194,42 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
|
|
||||||
const [composeActive, setComposeActive] = useState(false);
|
const [composeActive, setComposeActive] = useState(false);
|
||||||
const [composeStep, setComposeStep] = useState<ComposeStep>("idle");
|
const [composeStep, setComposeStep] = useState<ComposeStep>("idle");
|
||||||
const playbackSuspended = usePlaybackSuspenderStore((s) => s.suspendCount > 0);
|
const paused = usePlaybackPauseStore(selectIsPaused);
|
||||||
const playbackBlocked = composeActive || playbackSuspended;
|
|
||||||
const [spaceHeld, setSpaceHeld] = useState(false);
|
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
const [fastPlayback, setFastPlayback] = useState(false);
|
|
||||||
const [showKeybindings, setShowKeybindings] = 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
|
// Broadcast composing state to other viewers
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const stepToMode: Record<string, ComposingMode | null> = {
|
const stepToMode: Record<string, ComposingMode | null> = {
|
||||||
@@ -240,7 +268,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
|
|
||||||
const exitRemainingMs = useExitCountdown(
|
const exitRemainingMs = useExitCountdown(
|
||||||
status,
|
status,
|
||||||
playbackBlocked || spaceHeld,
|
paused,
|
||||||
handleExitNavigate,
|
handleExitNavigate,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -249,112 +277,6 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
setProgress(0);
|
setProgress(0);
|
||||||
}, [currentParticle?.id]);
|
}, [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) {
|
if (children.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { Particle } from "@/api/types";
|
import type { Particle } from "@/api/types";
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
} from "@/lib/particle-path";
|
} from "@/lib/particle-path";
|
||||||
import { editTextParticleContent } from "@/lib/firestore-particles";
|
import { editTextParticleContent } from "@/lib/firestore-particles";
|
||||||
import { TextEditor } from "@/features/compose/text-editor";
|
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" }>;
|
type TextParticle = Extract<Particle, { type: "text" }>;
|
||||||
|
|
||||||
@@ -25,11 +25,7 @@ export function TextEditOverlay({
|
|||||||
streamPath,
|
streamPath,
|
||||||
onClose,
|
onClose,
|
||||||
}: TextEditOverlayProps) {
|
}: TextEditOverlayProps) {
|
||||||
useEffect(() => {
|
useSuspendPlayback(true, "text-edit");
|
||||||
const { suspend, release } = usePlaybackSuspenderStore.getState();
|
|
||||||
suspend();
|
|
||||||
return release;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const [textContent, setTextContent] = useState(particle.properties.content);
|
const [textContent, setTextContent] = useState(particle.properties.content);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ export function TextParticleView({
|
|||||||
}, [particle.id]);
|
}, [particle.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (paused || isEditing) return;
|
if (paused) return;
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
elapsedRef.current += TICK_MS / 1000;
|
elapsedRef.current += TICK_MS / 1000;
|
||||||
@@ -179,7 +179,7 @@ export function TextParticleView({
|
|||||||
}, TICK_MS);
|
}, TICK_MS);
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
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
|
// Content is just bare URLs with no surrounding text
|
||||||
const contentTrimmed = content.trim();
|
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 { useAuthStore } from "@/stores/auth-store";
|
||||||
import type { Particle } from "@/api/types";
|
import type { Particle } from "@/api/types";
|
||||||
import { useLiveParticleChildren } from "@/hooks/use-particle";
|
import { useLiveParticleChildren } from "@/hooks/use-particle";
|
||||||
@@ -12,7 +12,6 @@ type PlaybackStatus = "idle" | "playing" | "ended";
|
|||||||
interface PlaybackState {
|
interface PlaybackState {
|
||||||
currentParticleId: string | null;
|
currentParticleId: string | null;
|
||||||
status: PlaybackStatus;
|
status: PlaybackStatus;
|
||||||
paused: boolean;
|
|
||||||
initialized: boolean;
|
initialized: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,14 +20,11 @@ type PlaybackAction =
|
|||||||
| { type: "SET_PARTICLE"; particleId: string }
|
| { type: "SET_PARTICLE"; particleId: string }
|
||||||
| { type: "END" }
|
| { type: "END" }
|
||||||
| { type: "PARTICLE_ADDED"; particleId: string }
|
| { type: "PARTICLE_ADDED"; particleId: string }
|
||||||
| { type: "PARTICLE_REMOVED"; removedParticleId: string; fallbackParticleId: string | null }
|
| { type: "PARTICLE_REMOVED"; removedParticleId: string; fallbackParticleId: string | null };
|
||||||
| { type: "PAUSE" }
|
|
||||||
| { type: "RESUME" };
|
|
||||||
|
|
||||||
const initialState: PlaybackState = {
|
const initialState: PlaybackState = {
|
||||||
currentParticleId: null,
|
currentParticleId: null,
|
||||||
status: "idle",
|
status: "idle",
|
||||||
paused: false,
|
|
||||||
initialized: false,
|
initialized: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,7 +34,6 @@ function playbackReducer(state: PlaybackState, action: PlaybackAction): Playback
|
|||||||
return {
|
return {
|
||||||
currentParticleId: action.particleId,
|
currentParticleId: action.particleId,
|
||||||
status: "playing",
|
status: "playing",
|
||||||
paused: false,
|
|
||||||
initialized: true,
|
initialized: true,
|
||||||
};
|
};
|
||||||
case "SET_PARTICLE":
|
case "SET_PARTICLE":
|
||||||
@@ -46,13 +41,12 @@ function playbackReducer(state: PlaybackState, action: PlaybackAction): Playback
|
|||||||
...state,
|
...state,
|
||||||
currentParticleId: action.particleId,
|
currentParticleId: action.particleId,
|
||||||
status: "playing",
|
status: "playing",
|
||||||
paused: false,
|
|
||||||
};
|
};
|
||||||
case "END":
|
case "END":
|
||||||
return { ...state, status: "ended", paused: false };
|
return { ...state, status: "ended" };
|
||||||
case "PARTICLE_ADDED":
|
case "PARTICLE_ADDED":
|
||||||
if (state.status === "ended") {
|
if (state.status === "ended") {
|
||||||
return { ...state, currentParticleId: action.particleId, status: "playing", paused: false };
|
return { ...state, currentParticleId: action.particleId, status: "playing" };
|
||||||
}
|
}
|
||||||
return state;
|
return state;
|
||||||
case "PARTICLE_REMOVED":
|
case "PARTICLE_REMOVED":
|
||||||
@@ -61,10 +55,6 @@ function playbackReducer(state: PlaybackState, action: PlaybackAction): Playback
|
|||||||
return { ...state, currentParticleId: action.fallbackParticleId, status: "playing" };
|
return { ...state, currentParticleId: action.fallbackParticleId, status: "playing" };
|
||||||
}
|
}
|
||||||
return { ...state, currentParticleId: null, status: "idle" };
|
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;
|
currentParticle: Particle | null;
|
||||||
currentIndex: number;
|
currentIndex: number;
|
||||||
status: PlaybackStatus;
|
status: PlaybackStatus;
|
||||||
paused: boolean;
|
|
||||||
initialized: boolean;
|
initialized: boolean;
|
||||||
next: () => void;
|
next: () => void;
|
||||||
prev: () => void;
|
prev: () => void;
|
||||||
goTo: (index: number) => void;
|
goTo: (index: number) => void;
|
||||||
goToParticle: (particleId: string) => void;
|
goToParticle: (particleId: string) => void;
|
||||||
pause: () => void;
|
|
||||||
resume: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useStreamPlayback(
|
export function useStreamPlayback(
|
||||||
@@ -228,21 +215,15 @@ export function useStreamPlayback(
|
|||||||
dispatch({ type: "SET_PARTICLE", particleId });
|
dispatch({ type: "SET_PARTICLE", particleId });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const pause = useCallback(() => dispatch({ type: "PAUSE" }), []);
|
|
||||||
const resume = useCallback(() => dispatch({ type: "RESUME" }), []);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
children,
|
children,
|
||||||
currentParticle,
|
currentParticle,
|
||||||
currentIndex,
|
currentIndex,
|
||||||
status: state.status,
|
status: state.status,
|
||||||
paused: state.paused,
|
|
||||||
initialized: state.initialized,
|
initialized: state.initialized,
|
||||||
next,
|
next,
|
||||||
prev,
|
prev,
|
||||||
goTo,
|
goTo,
|
||||||
goToParticle,
|
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