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" ? (
+ <>
+ Video
+ >
+ ) : (
+ <>
+ Audio
+ >
+ )}
+
+
+ );
+}
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" ? (
- <> Video>
- ) : (
- <> Audio>
- )}
-
-
-
-
- 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);