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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user