enhance keyboard experience
Now we have network root and stream-view defining their own hints instead of trying to re-use one component. And we also introduce an overlay for additional commands for people.
This commit is contained in:
@@ -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(
|
||||
<div className="fixed inset-0 z-[100]">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
{/* Panel */}
|
||||
<div className="absolute left-1/2 top-1/2 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-white/10 bg-white/5 p-6 shadow-2xl backdrop-blur-xl">
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-white/70">{title}</h2>
|
||||
<span className="text-xs text-white/30">
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
Esc
|
||||
</kbd>{" "}
|
||||
or{" "}
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
?
|
||||
</kbd>{" "}
|
||||
to close
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-5">
|
||||
{groups.map((group) => (
|
||||
<section key={group.label}>
|
||||
<h3 className="mb-2 text-[10px] font-medium uppercase tracking-widest text-white/30">
|
||||
{group.label}
|
||||
</h3>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{group.bindings.map((binding) => (
|
||||
<li
|
||||
key={binding.description}
|
||||
className="flex items-center justify-between border-b border-white/5 pb-1 last:border-0 last:pb-0"
|
||||
>
|
||||
<span className="text-sm text-white/70">
|
||||
{binding.description}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
{binding.keys.map((k) => (
|
||||
<kbd
|
||||
key={k}
|
||||
className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs text-white/60"
|
||||
>
|
||||
{k}
|
||||
</kbd>
|
||||
))}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<span>
|
||||
<kbd
|
||||
role="button"
|
||||
onClick={() =>
|
||||
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 className="inline size-3" /> Video
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Mic className="inline size-3" /> Audio
|
||||
</>
|
||||
)}
|
||||
</kbd>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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<ControlsIndicatorProps>) {
|
||||
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
|
||||
const setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4 text-sm text-white/50">
|
||||
{showEscape && (
|
||||
<span>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
Esc
|
||||
</kbd>{" "}
|
||||
back
|
||||
</span>
|
||||
)}
|
||||
{type === "new" && (
|
||||
<span>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
↑↓
|
||||
</kbd>{" "}
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
Enter
|
||||
</kbd>{" "}
|
||||
navigate
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
<kbd
|
||||
role="button"
|
||||
onClick={() =>
|
||||
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 className="inline size-3" /> Video</>
|
||||
) : (
|
||||
<><Mic className="inline size-3" /> Audio</>
|
||||
)}
|
||||
</kbd>
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
Hold `
|
||||
</kbd>{" "}
|
||||
to {type === "reply" ? "reply" : "start"}
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
T
|
||||
</kbd>{" "}
|
||||
text
|
||||
</span>
|
||||
{attachmentCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenAttachments}
|
||||
className="flex items-center gap-1 text-white/50 transition-colors hover:text-white/80"
|
||||
>
|
||||
<kbd className="self-end rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
A
|
||||
</kbd>{" "}
|
||||
<Paperclip className="size-3" />
|
||||
{attachmentCount} {attachmentCount === 1 ? "attachment" : "attachments"}
|
||||
</button>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
<ComposeOverlay networkId={networkId!} onActiveChange={setComposeActive} />
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 flex justify-center p-3">
|
||||
<div className="pointer-events-auto">
|
||||
<ControlsIndicator type={"new"} />
|
||||
<NetworkRootControls />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NetworkRootControls() {
|
||||
return (
|
||||
<div className="flex items-center gap-4 text-sm text-white/50">
|
||||
<span>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
↑↓
|
||||
</kbd>{" "}
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
Enter
|
||||
</kbd>{" "}
|
||||
navigate
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
1–9
|
||||
</kbd>{" "}
|
||||
jump
|
||||
</span>
|
||||
<VideoAudioToggle />
|
||||
<span>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
Hold `
|
||||
</kbd>{" "}
|
||||
to start
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
T
|
||||
</kbd>{" "}
|
||||
text
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No particles in this stream yet
|
||||
</p>
|
||||
<ControlsIndicator type="reply" showEscape={true}>
|
||||
<span>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
H
|
||||
</kbd>{" "}
|
||||
huddle
|
||||
</span>
|
||||
</ControlsIndicator>
|
||||
<StreamViewControls
|
||||
showEscape
|
||||
onOpenKeybindings={() => setShowKeybindings(true)}
|
||||
/>
|
||||
<ComposeOverlay
|
||||
networkId={networkId}
|
||||
targetPath={path}
|
||||
@@ -373,6 +403,14 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
|
||||
onGoTo={goTo}
|
||||
presenceBySegment={presenceBySegment}
|
||||
exitRemainingMs={exitRemainingMs}
|
||||
onOpenKeybindings={() => setShowKeybindings(true)}
|
||||
/>
|
||||
|
||||
<KeybindingsOverlay
|
||||
open={showKeybindings}
|
||||
onClose={() => setShowKeybindings(false)}
|
||||
groups={STREAM_VIEW_KEYBINDINGS}
|
||||
title="Stream View"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -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<number, import("@/hooks/use-presence-positions").HumanPresence[]>;
|
||||
exitRemainingMs: number | null;
|
||||
onOpenKeybindings: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn(
|
||||
@@ -418,7 +458,7 @@ function BottomBar({
|
||||
onGoTo={onGoTo}
|
||||
layer="tracks"
|
||||
/>
|
||||
<div className="flex items-center justify-center px-3 pt-2">
|
||||
<div className="flex items-center justify-center px-3 pt-2 gap-2">
|
||||
{exitRemainingMs !== null && (
|
||||
<div className="flex justify-center">
|
||||
<span className="rounded-full bg-black/30 px-2 text-xs text-white/70 backdrop-blur-sm">
|
||||
@@ -426,20 +466,64 @@ function BottomBar({
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<ControlsIndicator type="reply" showEscape={true}>
|
||||
<span>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
H
|
||||
</kbd>{" "}
|
||||
huddle
|
||||
</span>
|
||||
</ControlsIndicator>
|
||||
<StreamViewControls
|
||||
showEscape
|
||||
onOpenKeybindings={onOpenKeybindings}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StreamViewControls({
|
||||
showEscape,
|
||||
onOpenKeybindings,
|
||||
}: {
|
||||
showEscape?: boolean;
|
||||
onOpenKeybindings: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-4 text-sm text-white/50">
|
||||
{showEscape && (
|
||||
<span>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
Esc
|
||||
</kbd>{" "}
|
||||
back
|
||||
</span>
|
||||
)}
|
||||
<VideoAudioToggle />
|
||||
<span>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
Hold `
|
||||
</kbd>{" "}
|
||||
to reply
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
T
|
||||
</kbd>{" "}
|
||||
text
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
H
|
||||
</kbd>{" "}
|
||||
huddle
|
||||
</span>
|
||||
<kbd
|
||||
role="button"
|
||||
onClick={onOpenKeybindings}
|
||||
className="cursor-pointer rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs transition-colors hover:text-white/80"
|
||||
title="Show all shortcuts"
|
||||
>
|
||||
?
|
||||
</kbd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TopBar({ networkId, particle, streamParticle }: { networkId: string; particle: Particle | null; streamParticle: Particle & { type: "stream" } }) {
|
||||
const navigate = useNavigate();
|
||||
const network = useNetwork(networkId);
|
||||
|
||||
Reference in New Issue
Block a user