diff --git a/js/src/components/keybindings-overlay.tsx b/js/src/components/keybindings-overlay.tsx new file mode 100644 index 0000000..9344b65 --- /dev/null +++ b/js/src/components/keybindings-overlay.tsx @@ -0,0 +1,99 @@ +import { useEffect } from "react"; +import { createPortal } from "react-dom"; + +export interface KeybindingEntry { + keys: string[]; + description: string; +} + +export interface KeybindingGroup { + label: string; + bindings: KeybindingEntry[]; +} + +interface KeybindingsOverlayProps { + open: boolean; + onClose: () => void; + groups: KeybindingGroup[]; + title?: string; +} + +export function KeybindingsOverlay({ + open, + onClose, + groups, + title = "Keyboard Shortcuts", +}: KeybindingsOverlayProps) { + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape" || e.key === "?") { + e.preventDefault(); + e.stopPropagation(); + onClose(); + } + }; + window.addEventListener("keydown", handler, { capture: true }); + return () => window.removeEventListener("keydown", handler, { capture: true }); + }, [open, onClose]); + + if (!open) return null; + + return createPortal( +
+ {/* Backdrop */} +
+ {/* Panel */} +
+
+

{title}

+ + + Esc + {" "} + or{" "} + + ? + {" "} + to close + +
+
+ {groups.map((group) => ( +
+

+ {group.label} +

+
    + {group.bindings.map((binding) => ( +
  • + + {binding.description} + + + {binding.keys.map((k) => ( + + {k} + + ))} + +
  • + ))} +
+
+ ))} +
+
+
, + document.body, + ); +} diff --git a/js/src/components/video-audio-toggle.tsx b/js/src/components/video-audio-toggle.tsx new file mode 100644 index 0000000..774e3ce --- /dev/null +++ b/js/src/components/video-audio-toggle.tsx @@ -0,0 +1,32 @@ +import { Video, Mic } from "lucide-react"; +import { useMediaSettingsStore } from "@/stores/media-settings-store"; + +export function VideoAudioToggle() { + const recordingMode = useMediaSettingsStore((s) => s.recordingMode); + const setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode); + + return ( + + + setRecordingMode(recordingMode === "video" ? "audio" : "video") + } + title={ + recordingMode === "video" ? "Switch to audio-only" : "Switch to video" + } + className="cursor-pointer rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs transition-colors hover:text-white/80" + > + {recordingMode === "video" ? ( + <> + + + ); +} diff --git a/js/src/features/compose/controls-indicator.tsx b/js/src/features/compose/controls-indicator.tsx deleted file mode 100644 index 81bde3d..0000000 --- a/js/src/features/compose/controls-indicator.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { Video, Mic, Paperclip } from "lucide-react"; -import { useMediaSettingsStore } from "@/stores/media-settings-store"; -import { PropsWithChildren } from "react"; - -interface ControlsIndicatorProps { - type: "reply" | "new"; - attachmentCount?: number; - onOpenAttachments?: () => void; - showEscape?: boolean; -} -export default function ControlsIndicator({ - type, - attachmentCount = 0, - showEscape = false, - onOpenAttachments, - children -}: PropsWithChildren) { - const recordingMode = useMediaSettingsStore((s) => s.recordingMode); - const setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode); - - return ( -
- {showEscape && ( - - - Esc - {" "} - back - - )} - {type === "new" && ( - - - ↑↓ - {" "} - - Enter - {" "} - navigate - - )} - - - setRecordingMode(recordingMode === "video" ? "audio" : "video") - } - title={ - recordingMode === "video" - ? "Switch to audio-only" - : "Switch to video" - } - className="cursor-pointer rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs transition-colors hover:text-white/80" - > - {recordingMode === "video" ? ( - <> - - - - Hold ` - {" "} - to {type === "reply" ? "reply" : "start"} - - - - T - {" "} - text - - {attachmentCount > 0 && ( - - )} - {children} -
- ); -} diff --git a/js/src/features/network-root.tsx b/js/src/features/network-root.tsx index 29bcf80..6f51593 100644 --- a/js/src/features/network-root.tsx +++ b/js/src/features/network-root.tsx @@ -3,7 +3,7 @@ import { useNavigate, useParams } from "react-router-dom"; import { CircleDot, CircleCheckBig } from "lucide-react"; import { particlePath } from "@/lib/particle-path"; import { ParticleListView } from "@/features/particles/particle-list-view"; -import ControlsIndicator from "@/features/compose/controls-indicator"; +import { VideoAudioToggle } from "@/components/video-audio-toggle"; import { ComposeOverlay } from "./compose/compose-overlay"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useStreamParticles } from "@/hooks/use-stream-particles"; @@ -63,9 +63,44 @@ export default function NetworkRoot() {
- +
); } + +function NetworkRootControls() { + return ( +
+ + + ↑↓ + {" "} + + Enter + {" "} + navigate + + + + 1–9 + {" "} + jump + + + + + Hold ` + {" "} + to start + + + + T + {" "} + text + +
+ ); +} diff --git a/js/src/features/particles/stream-view.tsx b/js/src/features/particles/stream-view.tsx index d867691..d43576d 100644 --- a/js/src/features/particles/stream-view.tsx +++ b/js/src/features/particles/stream-view.tsx @@ -10,7 +10,8 @@ import { MediaParticleView, type MediaParticleHandle } from "@/features/particle import { TextParticleView } from "@/features/particles/text-particle-view"; import { FallbackParticleView } from "@/features/particles/fallback-particle-view"; import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar"; -import ControlsIndicator from "@/features/compose/controls-indicator"; +import { VideoAudioToggle } from "@/components/video-audio-toggle"; +import { KeybindingsOverlay, type KeybindingGroup } from "@/components/keybindings-overlay"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useNetwork, useNetworks } from "@/hooks/use-networks"; import { Button } from "@/components/ui/button"; @@ -101,6 +102,34 @@ function useExitCountdown( return remainingMs; } +// --- Keybindings --- + +const STREAM_VIEW_KEYBINDINGS: KeybindingGroup[] = [ + { + label: "Navigation", + bindings: [ + { keys: ["←", "→"], description: "Previous / next particle" }, + { keys: ["↑", "↓"], description: "Previous / next particle" }, + { keys: ["Esc"], description: "Back to network" }, + ], + }, + { + label: "Playback", + bindings: [ + { keys: ["Hold", "Space"], description: "Pause" }, + { keys: ["Hold", "Shift"], description: "1.5× speed" }, + ], + }, + { + label: "Compose", + bindings: [ + { keys: ["Hold", "`"], description: "Reply" }, + { keys: ["T"], description: "Text compose" }, + { keys: ["H"], description: "Join huddle" }, + ], + }, +]; + // --- StreamView --- interface StreamViewProps { @@ -145,6 +174,7 @@ export function StreamView({ path, streamParticle }: StreamViewProps) { const [composeActive, setComposeActive] = useState(false); const [progress, setProgress] = useState(0); const [fastPlayback, setFastPlayback] = useState(false); + const [showKeybindings, setShowKeybindings] = useState(false); // Show/hide chrome on mouse activity (YouTube-style) const [showControls, setShowControls] = useState(true); @@ -228,6 +258,10 @@ export function StreamView({ path, streamParticle }: StreamViewProps) { navigate(`/${networkId}`); break; } + case "?": + e.preventDefault(); + setShowKeybindings((v) => !v); + break; } }; @@ -252,7 +286,7 @@ export function StreamView({ path, streamParticle }: StreamViewProps) { window.removeEventListener("keyup", handleKeyUp); }; }, - [composeActive, next, prev, pause, resume, navigate, networkId, streamParticle.id], + [composeActive, next, prev, pause, resume, navigate, networkId, streamParticle.id, setShowKeybindings], ); // Click-to-navigate: left 30% = prev, right 70% = next @@ -276,14 +310,10 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {

No particles in this stream yet

- - - - H - {" "} - huddle - - + setShowKeybindings(true)} + /> setShowKeybindings(true)} + /> + + setShowKeybindings(false)} + groups={STREAM_VIEW_KEYBINDINGS} + title="Stream View" /> ); @@ -386,6 +424,7 @@ function BottomBar({ onGoTo, presenceBySegment, exitRemainingMs, + onOpenKeybindings, }: { visible: boolean; total: number; @@ -394,6 +433,7 @@ function BottomBar({ onGoTo: (index: number) => void; presenceBySegment: Map; exitRemainingMs: number | null; + onOpenKeybindings: () => void; }) { return (
-
+
{exitRemainingMs !== null && (
@@ -426,20 +466,64 @@ function BottomBar({
)} - - - - H - {" "} - huddle - - +
); } +function StreamViewControls({ + showEscape, + onOpenKeybindings, +}: { + showEscape?: boolean; + onOpenKeybindings: () => void; +}) { + return ( +
+ {showEscape && ( + + + Esc + {" "} + back + + )} + + + + Hold ` + {" "} + to reply + + + + T + {" "} + text + + + + H + {" "} + huddle + + + ? + +
+ ); +} + function TopBar({ networkId, particle, streamParticle }: { networkId: string; particle: Particle | null; streamParticle: Particle & { type: "stream" } }) { const navigate = useNavigate(); const network = useNetwork(networkId);