feat(mobile): Wave 5 — voice-join camera preview + Electron settings entries + toast positioning

Closes the mobile parity push.

- MobileVoiceJoinSheet: full pre-join camera preview. Dormant-by-default (never auto-fires getUserMedia), explicit user gesture to arm, hard-bound disarm on sheet close (any path). Camera picker popup portaled to document.body so a long device list stays scrollable above the aspect-video preview tile (max-height min(50vh,320px), iOS scroll momentum). iOS Safari-safe: autoPlay playsInline muted + post-await play().
- MobileSettingsScreen: Keybinds + Desktop sections gated on isElectron(); they reuse the existing KeybindsPanel/DesktopPanel which are already mobile-fit. screenMap entries added in MobileShell. (Verify on Electron desktop build at narrow viewport.)
- ToastContainer: real overlap was hiding the voice-fullscreen control bar. Mobile branch now reads isMobile/mobileStack/currentVoiceChannelId and computes the bottom offset across five mobile states (voice-full / pushed+voice / pushed / root+voice / root), all with safe-area-inset-bottom; left-3 right-3 + items-center keeps toasts in the safe-tap zone. Desktop bottom-6 right-6 unchanged.

Specs: docs/systems/mobile-ui.md (Toast Positioning section, screenMap rows, Electron-entry rationale, z-index row); docs/systems/voice.md (Mobile pre-join preview subsection covering lifecycle + camera picker portal).
This commit is contained in:
Jannis Braun
2026-05-07 23:44:06 +02:00
parent d9c9f18b20
commit 74ccf20308
6 changed files with 604 additions and 8 deletions
@@ -1,5 +1,6 @@
import React from 'react';
import { useUIStore } from '../../stores/uiStore';
import { useVoiceStore } from '../../stores/voiceStore';
const borderColors = {
info: 'border-l-accent-sky',
@@ -7,14 +8,90 @@ const borderColors = {
success: 'border-l-accent-mint',
} as const;
/**
* Resolve the bottom-edge offset for the toast stack on mobile.
*
* Mobile layout has up to three pieces of bottom chrome that toasts must
* clear, depending on what's visible:
*
* - Bottom nav (`MobileBottomNav`): 56px + safe-area-inset-bottom — visible
* when the screen stack is empty.
* - Voice mini-bar (`MobileVoiceMiniBar`): ~56px on top of whatever sits
* below it — visible whenever a voice channel is connected and `voice-full`
* is NOT the topmost screen.
* - Voice fullscreen control bar (`MobileVoiceFullScreen`): ~72px including
* its `mb-2 + safe-area-inset-bottom` — visible only when `voice-full` is
* the topmost screen, and the bottom nav + mini-bar are both hidden in
* that mode.
*
* Returns a CSS bottom offset value (string with units) that lifts the toast
* stack above whichever chrome is currently rendered, plus an extra 12px of
* breathing room.
*/
function resolveMobileBottomOffset(
hasStack: boolean,
topScreen: string | null,
inVoice: boolean,
): string {
// Voice fullscreen is on top: clear its control bar (mx-2 mb-2 round bar
// with safe-area inset). Bottom nav + mini-bar are hidden in this mode.
if (topScreen === 'voice-full') {
return 'calc(72px + 12px + env(safe-area-inset-bottom))';
}
// Stack non-empty (some pushed screen other than voice-full): bottom nav is
// hidden. Mini-bar is visible iff in voice.
if (hasStack) {
if (inVoice) {
// Mini-bar (~56px + mb-1) sits at bottom alone.
return 'calc(64px + 12px + env(safe-area-inset-bottom))';
}
return 'calc(12px + env(safe-area-inset-bottom))';
}
// Root tab (no stack). Bottom nav is visible. Mini-bar may also be present
// above it.
if (inVoice) {
return 'calc(56px + 64px + 12px + env(safe-area-inset-bottom))';
}
return 'calc(56px + 12px + env(safe-area-inset-bottom))';
}
export function ToastContainer() {
const toasts = useUIStore((s) => s.toasts);
const removeToast = useUIStore((s) => s.removeToast);
const isMobile = useUIStore((s) => s.isMobile);
const mobileStack = useUIStore((s) => s.mobileStack);
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
if (toasts.length === 0) return null;
const topScreen =
mobileStack.length > 0 ? mobileStack[mobileStack.length - 1]?.screen ?? null : null;
const inVoice = currentVoiceChannelId !== null;
// Mobile: lift above bottom chrome and center horizontally so the toast
// doesn't get cropped by `right-6` against narrow viewports. Desktop:
// keep the existing `bottom-6 right-6` anchor.
const containerStyle: React.CSSProperties = isMobile
? {
position: 'fixed',
left: '12px',
right: '12px',
bottom: resolveMobileBottomOffset(mobileStack.length > 0, topScreen, inVoice),
zIndex: 300,
}
: { position: 'fixed', bottom: '24px', right: '24px', zIndex: 300 };
return (
<div className="fixed bottom-6 right-6 z-[300] flex flex-col gap-2 pointer-events-none">
<div
className={
isMobile
? 'flex flex-col gap-2 pointer-events-none items-center'
: 'flex flex-col gap-2 pointer-events-none'
}
style={containerStyle}
>
{toasts.map((toast) => (
<div
key={toast.id}