merge: wip(mobile): in-progress mobile + voice polish into main
Brings the parked mobile voice + screenshare polish from wip/mobile-polish
(commit dbb9b2c) into main. Conflicts in MainContent.tsx and mobile-ui.md
expected per the WIP commit message — resolved manually.
This commit is contained in:
+40
-14
@@ -510,27 +510,52 @@ File: `MobileVoiceFullScreen.tsx`
|
||||
|
||||
**Header:** Collapse chevron (down arrow, pops screen), channel name, space name subtitle, participant count, members button (space channels only).
|
||||
|
||||
**Participant grid:**
|
||||
- `grid-cols-1` for 1-2 participants, `grid-cols-2` for 3+
|
||||
- Avatar size: 80px for 1-2 participants, 56px for 3+
|
||||
- Mute/deafen badge overlay on avatar (bottom-right, rose circle with icon)
|
||||
- Shows self-mute, space mute, permission mute, self-deafen, space deafen
|
||||
- Context menu on other participants: voice mod items, local mute checkbox, volume slider
|
||||
**Participant grid:** **Renders `<VoiceGrid participants={participants} />` from `packages/web/src/components/voice/VoiceGrid.tsx` — the same component desktop uses.** This is the source of the camera + screen-share rendering pipeline; mobile has no separate tile components. Reusing `VoiceGrid` gives mobile feature parity with desktop for free:
|
||||
|
||||
**Control bar:** `glass-bubble` container with safe area padding.
|
||||
- Camera tracks (`p.videoTrack` from `ParticipantInfo`) attach to a `<video>` element via LiveKit's `Track.attach(el)` so the SFU adaptive-stream observer can downshift simulcast layers based on the tile's painted pixel size — automatically scaling quality down on phone-shaped tiles.
|
||||
- Local camera: the local participant's `videoTrack` is attached identically, with `muted` on the `<video>` element so the user's own camera doesn't echo through their speakers. (`VoiceUser` sets `muted={isLocal}`.)
|
||||
- Remote screen-share tracks render in their own `StreamTile` (one extra tile per streaming participant) — the user must tap "Watch Stream" or focus the tile to subscribe; until then it's an avatar placeholder. `setStreamSubscription` and the `stream_watch` data-channel protocol fire identically on mobile.
|
||||
- Tap-to-focus: tapping any tile sets `voiceStore.focusedParticipantId` and the grid switches into the focused-publisher layout (one large tile + bottom strip of others). This works through touch events without modification.
|
||||
- Mute / deafen / camera badges, speaking-ring, "(you)" suffix, context-menu (right-click on desktop, long-press on iOS — Safari fires `contextmenu` on long-press), local-mute/volume sliders, watch/unwatch — all carry over.
|
||||
|
||||
| Button | State Colors |
|
||||
|--------|-------------|
|
||||
| Mute | Active: `bg-accent-rose/20 text-accent-rose`, Inactive: `bg-surface-elevated text-txt-primary` |
|
||||
| Deafen | Same as mute |
|
||||
| Camera | Active: `bg-accent-mint/20 text-accent-mint`, Inactive: same |
|
||||
| Screen share | Same as camera |
|
||||
| Disconnect | Always `bg-accent-rose text-white` |
|
||||
**Auto-focus on screen-share (mobile-only).** When `MobileVoiceFullScreen` mounts (or while it's already mounted) and a screen-share publication appears, the screen sets `focusedParticipantId` to the first live `${identity}:stream` tile so the user lands directly on the watchable stream. Two refs gate the behaviour:
|
||||
|
||||
- `userTouchedFocusRef` — flips `true` the first time `focusedParticipantId` changes to anything other than the auto-focused key, or to `null` after auto-focus had been set. Once flipped it stays flipped for the screen lifetime; auto-focus bails out. This means a user who explicitly dismisses focus via the Grid button never has it forced back on, even if a new screen-share starts.
|
||||
- `lastAutoFocusedKeyRef` — records the key we last auto-focused so the user-interaction detection can distinguish "user picked a different tile" from "we just set it ourselves".
|
||||
|
||||
The unmount cleanup clears `focusedParticipantId` so re-entering the call screen is a clean slate. Desktop is unaffected — auto-focus lives in `MobileVoiceFullScreen`, not `VoiceGrid`.
|
||||
|
||||
**Control bar:** `glass-bubble` container with safe area padding. Five round buttons.
|
||||
|
||||
| Button | Action | State Colors |
|
||||
|--------|--------|-------------|
|
||||
| Mute | `voiceStore.toggleMic` | Active: `bg-accent-rose/20 text-accent-rose`, Inactive: `bg-surface-elevated text-txt-primary` |
|
||||
| Deafen | `voiceStore.toggleDeafen` | Same as mute |
|
||||
| Camera | `handleCameraAction` (canonical, from `utils/voiceActions`) | Active: `bg-accent-mint/20 text-accent-mint`, Inactive: same |
|
||||
| Screen share | `handleScreenShareAction` (canonical, from `utils/voiceActions`) | Same as camera |
|
||||
| Disconnect | DM call or space voice teardown + pop screen | Always `bg-accent-rose text-white` |
|
||||
|
||||
**Screen-share button wiring (load-bearing).** The button calls `handleScreenShareAction`, **not** `voiceStore.toggleScreenShare`. The store action only flips the boolean — it never calls `getDisplayMedia` or publishes a track. `handleScreenShareAction` is the canonical path (also used by `VoiceControlBar` and the keybind handler) that calls `startScreenShare(room)` / `stopScreenShare(room)` and broadcasts voice status. iOS Safari does not support `getDisplayMedia` and the call rejects there — that's a platform limitation, not a Backspace bug; the same call behaves identically on desktop and Android.
|
||||
|
||||
**In-call camera switcher (mobile-only).** When `isCameraOn === true` AND `enumerateDevices()` returns more than one `videoinput`, a small chevron pill ("Switch camera", `aria-haspopup="menu"`) is overlaid on the top-right corner of the camera control button. Tapping it opens an upward-expanding menu portaled to `document.body` (so the popup escapes the control bar's `glass-bubble` clipping), pinned to the chevron's screen rect via `getBoundingClientRect()` and re-pinned on resize / capturing scroll. The menu lists every videoinput plus an "Auto (system default)" entry; the current selection is highlighted via `aria-checked` + a `text-txt-primary` accent. Selecting an entry calls `voiceStore.setCameraDeviceId(deviceId)` and closes the picker. The `useLiveKit` `syncCamera` effect picks up the store change and calls `room.switchActiveDevice('videoinput', target)` for an in-place hot-swap (no republish) — see `docs/systems/voice.md` "Hot-swap mid-call". Click-outside dismissal listens to both `mousedown` AND `touchstart` (iOS Safari does not synthesize `mousedown` reliably from a single tap).
|
||||
|
||||
The chevron is gated on `isCameraOn && cameraDevices.length > 1` so single-camera phones / desktops never see it. On iOS Safari the videoinput list is populated only after the OS-level camera permission has been granted at least once in the current session — that grant happens when the user first turns on the camera via `handleCameraAction` (which calls `getUserMedia`), so by the time the chevron is eligible to show, labels and device IDs are available. Before grant, `enumerateDevices()` returns one entry with empty `deviceId` and no label; the gate (`length > 1`) keeps the chevron hidden, so the user sees no broken-state UI.
|
||||
|
||||
**Voice tile text-selection suppression.** `VoiceUser` and `StreamTile` outer containers carry `data-context-menu` attribute. The global `@media (max-width: 767px)` rule in `globals.css` applies `user-select: none` (and inheriting `-webkit-touch-callout: none` via the `*` rule) to every `[data-context-menu]` element, which also inherits to children. Without this, iOS Safari's long-press handler synthesizes the context menu correctly via `useGlobalLongPress`, but the OS *also* triggers native text selection during the 500 ms hold, leaving the entire page's text highlighted in the background after the menu opens. The `data-context-menu` opt-in is the established convention used by `Message.tsx`, `MobileDmsScreen.tsx`, `MobileFolderSheet.tsx`, and `MobileSpacesScreen.tsx`.
|
||||
|
||||
**Disconnect:** Same logic as mini-bar (handles DM calls and space voice, calls `disconnectFn`, pops screen).
|
||||
|
||||
**Guard:** If `currentVoiceChannelId` is falsy, calls `popMobileScreen()` and returns null.
|
||||
|
||||
**Layout sizing.** The grid body is `flex-1` between the 48 px header and the floating control bar; on a 390 × 844 viewport the body is roughly 390 × 720, so:
|
||||
|
||||
- **1 participant:** the lone tile fills the body minus padding (~366 × 206 at 16 : 9). Just the local user's avatar / camera with the standard speaking-ring + name overlay.
|
||||
- **2 participants:** `useGridLayout` picks the configuration that maximises tile area. On a portrait phone that's `cols=1, rows=2` — two stacked 16 : 9 tiles ~366 × 200 each, vertically centred.
|
||||
- **3–4 participants:** typically `cols=2, rows=2` (4) or `cols=1, rows=3` (3). The same area-maximising algorithm runs on mobile and desktop; nothing is mobile-specific.
|
||||
- **Focused mode:** the focused tile fills the body minus the 120 px (max 20 vh) bottom strip; the strip horizontally scrolls if other-participant count exceeds the visible width.
|
||||
|
||||
No mobile-specific min-tile clamp exists. If the area-maximising solver picks an absurdly small tile for many participants, paginate-or-scroll is intentionally not added — the issue is symmetric with desktop and any future mobile-only paginator should land on desktop too.
|
||||
|
||||
---
|
||||
|
||||
## MobileFolderSheet
|
||||
@@ -660,6 +685,7 @@ keyboardOcclusion = window.innerHeight - (visualViewport.offsetTop + visualViewp
|
||||
It returns `{ value, keyboardOpen, height, offsetTop }`:
|
||||
- `value` — `'<n>px'` when the keyboard is open (the occlusion), or the literal `'env(safe-area-inset-bottom)'` string when it is not. Provided for legacy / fallback use.
|
||||
- `keyboardOpen` — `true` when `keyboardOcclusion > 1`.
|
||||
- `textInputFocused` — `true` while a text-entry element holds focus. Required for iOS PWA standalone where iOS itself shrinks the layout viewport for the keyboard, so `vv.height === innerHeight` and `keyboardOpen` stays `false` even though the keyboard IS up. Consumers OR `keyboardOpen || textInputFocused` to detect "keyboard probably open". The state-equality check inside the hook MUST include this field — historically it was missing, so on iOS PWA the hook silently dropped focus changes; the composer's `bottom` style stayed pinned to `env(safe-area-inset-bottom) + 6px` while the keyboard was open, the `--composer-clearance` ResizeObserver effect's deps fired stale values, and on close the last message overlapped the composer's top edge by ~4 px.
|
||||
- `height` — live `visualViewport.height` in pixels (or `null` if `visualViewport` is unavailable).
|
||||
- `offsetTop` — live `visualViewport.offsetTop` in pixels.
|
||||
|
||||
|
||||
+46
-1
@@ -17,6 +17,26 @@ Source files:
|
||||
5. Client calls `POST /api/livekit/token { channelId }` → gets JWT + LiveKit URL
|
||||
6. Client connects to LiveKit room with token
|
||||
|
||||
### Microphone pre-arm (iOS user-gesture discipline)
|
||||
|
||||
`utils/voice.joinVoiceChannel` fires `AudioContext.resume()` and `AudioManager.setInputDevice(inputDeviceId)` (which ends in `getUserMedia({audio:…})`) **synchronously inside** the click handler, before the `connectFn(channelId)` call. iOS Safari only surfaces the microphone permission prompt when `getUserMedia` is invoked from inside an active user-gesture; the original flow only acquired the mic in `useLiveKit`'s `syncMic` effect, which fires AFTER `room.connect()` resolves (token fetch + WS handshake) — many awaits past the gesture window. iOS PWA standalone is especially strict and would silently never surface the prompt; the user would see "Waiting for others to join…" indefinitely until they locked/unlocked the device (which iOS treats as a fresh activation).
|
||||
|
||||
Pre-arm is fire-and-forget: the mic acquisition runs in parallel with the LiveKit handshake, and `AudioManager.inputSwitchChain`'s serialization guarantees `useLiveKit.syncMic`'s subsequent call short-circuits on the already-acquired `currentStream` (no double prompt, no second `getUserMedia`).
|
||||
|
||||
### Listener mode (`micPermissionDenied`)
|
||||
|
||||
When the user denies the prompt (or has previously denied at the OS level), the pre-arm's `setInputDevice` rejects with `NotAllowedError`. The `voiceStore.micPermissionDenied` flag is set to `true` and the LiveKit connect proceeds anyway — the user appears in the voice channel as a connected participant who can hear others but has no microphone publication. `useLiveKit.syncMic` checks the flag at the top of its body and skips the publish branch entirely.
|
||||
|
||||
The flag clears via:
|
||||
- `requestMicPermission()` (in `utils/voice.ts`) — must be called from a user-gesture handler (button click). Clears `AudioManager.inputDenialError` cache, calls `setInputDevice` from a fresh activation. On success, sets `micPermissionDenied=false` and `useLiveKit.syncMic` re-fires (dep on `micPermissionDenied`) to publish the freshly acquired track.
|
||||
- `voiceStore.leaveVoice()` / `handleForceDisconnect()` / `resetSession()` / `reset()` — flag resets so the next join attempts a fresh prompt.
|
||||
|
||||
UI affordances:
|
||||
- **Mobile (`MobileVoiceFullScreen`):** banner below the header reads "Microphone access denied — You're listening only". A right-aligned "Allow microphone" button calls `requestMicPermission()`.
|
||||
- **Desktop (`VoiceControlBar`):** *(Future)* — same listener-mode state needs a parity affordance. Desktop is unaffected by the iOS gesture-window bug in practice (browsers there prompt on `getUserMedia` regardless of activation state), but if a desktop user denies the prompt, the same flow applies.
|
||||
|
||||
`AudioManager.inputDenialError` caches the most recent `NotAllowedError`. Subsequent `setInputDevice` calls re-throw the cached error rather than firing a second `getUserMedia` — iOS otherwise would queue a second prompt that has lost its activation, leading to a silent hang. Cleared by `AudioManager.clearInputDenial()` (called from `joinVoiceChannel`'s pre-arm and from `requestMicPermission`).
|
||||
|
||||
**Token grants (space channels):**
|
||||
- SPEAK → can publish MICROPHONE + CAMERA
|
||||
- STREAM → can publish SCREEN_SHARE + SCREEN_SHARE_AUDIO
|
||||
@@ -249,9 +269,34 @@ The "Share system audio" toggle in `ScreenSharePicker` adds an audio track to th
|
||||
|
||||
---
|
||||
|
||||
## Mobile Voice Rendering
|
||||
|
||||
Mobile (`MobileVoiceFullScreen`) renders the **same** `VoiceGrid` component as desktop. There is no mobile-specific tile component — the rendering, attach/detach, adaptive-stream subscription, focused-publisher layout, and context menus all come from the shared `VoiceGrid` / `VoiceUser` / `StreamTile` pipeline. The only mobile-specific addition is auto-focus on the first live screen-share publication (so phone users don't have to discover tap-to-focus).
|
||||
|
||||
See `docs/systems/mobile-ui.md` → "MobileVoiceFullScreen" for the auto-focus state machine, control-bar wiring, and layout sizing.
|
||||
|
||||
**Why the shared component path matters.**
|
||||
|
||||
- Local camera preview: `VoiceUser` attaches the local participant's `videoTrack` to a `<video muted>`. Mobile gets the self-preview "for free".
|
||||
- Remote cameras: `Track.attach(videoEl)` registers the element with LiveKit's `RemoteVideoTrack` adaptive-stream observer, so the SFU automatically picks the appropriate simulcast layer based on the painted tile size on the phone. No mobile-specific bitrate clamp is needed.
|
||||
- Screen-share: `StreamTile` lazily subscribes via `setStreamSubscription` only after the user taps "Watch Stream" (or auto-focus does so on mobile, which currently still requires the user to tap the in-tile "Watch Stream" CTA — auto-focus only sets the focused publisher; it does not auto-subscribe to bandwidth-heavy screen-share tracks).
|
||||
- Mute / deafen / speaking-ring overlays, watch/unwatch controls, local mute, volume sliders — identical between mobile and desktop.
|
||||
|
||||
**Screen-share button wiring on mobile.** `MobileVoiceFullScreen`'s screen-share button calls `handleScreenShareAction()` from `utils/voiceActions`, **not** `voiceStore.toggleScreenShare`. The store action only flips the `isScreenSharing` boolean and never calls `getDisplayMedia`. The canonical `handleScreenShareAction` is shared with desktop's `VoiceControlBar` and the keybind manager; it calls `startScreenShare(room)` / `stopScreenShare(room)` and broadcasts voice status to peers. iOS Safari does not support `getDisplayMedia` (the call rejects); this is a platform limitation. Android Chrome supports it and works.
|
||||
|
||||
---
|
||||
|
||||
## Voice Fullscreen
|
||||
|
||||
The fullscreen toggle in `VoiceControlBar` flips the `voiceFullscreen` flag in `uiStore`; an effect in `MainContent.tsx` calls `voiceContainerRef.current.requestFullscreen()` (and exits via `document.exitFullscreen()` when the flag clears). A second effect listens to `fullscreenchange` and reflects the actual `document.fullscreenElement` back into the store, so pressing Esc or system-level fullscreen-exit keeps state in sync. `voiceChatOpen && !voiceFullscreen` hides the side chat panel while fullscreen is active.
|
||||
The fullscreen toggle in `VoiceControlBar` flips the `voiceFullscreen` flag in `uiStore`; an effect in `MainContent.tsx` enters/exits the browser's Fullscreen API on `voiceContainerRef`. A second effect listens to `fullscreenchange` and reflects the actual document fullscreen element back into the store, so pressing Esc or system-level fullscreen-exit keeps state in sync. `voiceChatOpen && !voiceFullscreen` hides the side chat panel while fullscreen is active.
|
||||
|
||||
**Cross-browser API fallback.** iOS Safari (and iPadOS pre-16.4) does not implement the standard `Element.requestFullscreen()` on generic elements, so the enter-fullscreen effect probes for the API in this order:
|
||||
|
||||
1. `el.requestFullscreen()` — standard
|
||||
2. `el.webkitRequestFullscreen()` — older WebKit (some iPads, older Safari)
|
||||
3. Silent fall-through — pure iPhone Safari has neither API on a `<div>` (only `HTMLVideoElement.webkitEnterFullscreen()` works, which we cannot use for the multi-tile voice container)
|
||||
|
||||
When neither native API is available the effect returns without throwing; the `voiceFullscreen` flag still applies `h-screen` to `voiceContainerRef`, which acts as the in-page maximize fallback (chat panel hides, header fades, control bar stays). The exit path mirrors this with `document.exitFullscreen()` → `document.webkitExitFullscreen()` → no-op. Both paths are wrapped in try/catch so a Promise rejection (e.g. user cancels via Esc mid-transition) does not surface as an unhandled error. The `fullscreenchange` listener is registered for both `fullscreenchange` and `webkitfullscreenchange`. Before this fallback, calling the missing API directly threw `TypeError: requestFullscreen is not a function` on iPhone Safari, which surfaced as a full-screen error overlay when an iPhone user crossed the 768 px desktop breakpoint in landscape mode.
|
||||
|
||||
**Overlay portals:** While fullscreen is active the browser's Fullscreen API renders only descendants of `voiceContainerRef`. Every overlay reachable during a call (context menus on `StreamTile`/`VoiceUser`/`VoiceChannel`, tooltips on the control bar, `ConnectionInfoPopover`, `ScreenShareSettingsPopover`, `ConfirmDialog` invoked from voice context-menu actions, and `ScreenSharePicker`) portals through `usePortalContainer()` so it lands inside the fullscreen element. Adding new overlays that can be opened from inside the call must follow the same contract — see `docs/systems/design-system.md` Surface Material Tiers.
|
||||
|
||||
|
||||
@@ -32,6 +32,17 @@ export class AudioManager {
|
||||
private rnnoiseReady = false;
|
||||
private keepAliveOscillator: OscillatorNode | null = null;
|
||||
|
||||
// Cached `getUserMedia` denial. After a NotAllowedError, subsequent
|
||||
// `setInputDevice` calls (e.g. `useLiveKit.syncMic` racing the user's
|
||||
// tap on a denial prompt) re-throw the cached error WITHOUT issuing a
|
||||
// second `getUserMedia` — iOS Safari otherwise queues a second permission
|
||||
// prompt that has lost its user-gesture activation, which on iOS PWA
|
||||
// standalone leads to a permanently hung silent prompt. The cache is
|
||||
// cleared by `clearInputDenial()` (called from the user-gesture-driven
|
||||
// `requestMicPermission` retry path in `utils/voice.ts`) so a fresh user
|
||||
// gesture can re-attempt cleanly.
|
||||
private inputDenialError: Error | null = null;
|
||||
|
||||
// Subscribers notified when the *upstream* getUserMedia track ends unexpectedly
|
||||
// (hardware unplug, OS-level revoke, system audio service crash). Distinct from
|
||||
// the published mic track's `onended` — the published track is a clone of the
|
||||
@@ -209,6 +220,12 @@ export class AudioManager {
|
||||
return this.currentStream;
|
||||
}
|
||||
|
||||
// Re-throw cached denial without firing a second `getUserMedia`.
|
||||
// See the `inputDenialError` field comment for rationale.
|
||||
if (this.inputDenialError) {
|
||||
throw this.inputDenialError;
|
||||
}
|
||||
|
||||
try {
|
||||
if (this.currentStream) {
|
||||
// Detach our `onended` handlers BEFORE stopping. `.stop()` synchronously
|
||||
@@ -248,6 +265,11 @@ export class AudioManager {
|
||||
};
|
||||
|
||||
const newStream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
// Successful acquisition — clear any cached denial so next swap is
|
||||
// unimpeded. (Most commonly hit when the user grants permission via
|
||||
// the explicit `requestMicPermission` retry path, but also covers
|
||||
// OS-level grants between calls.)
|
||||
this.inputDenialError = null;
|
||||
this.currentStream = newStream;
|
||||
this.currentInputDeviceId = deviceId;
|
||||
this.streamGeneration++;
|
||||
@@ -269,10 +291,29 @@ export class AudioManager {
|
||||
return this.currentStream;
|
||||
} catch (err) {
|
||||
console.error('[AudioManager] Failed to set input device:', err);
|
||||
// Cache permission denials so syncMic's racing call doesn't fire a
|
||||
// second `getUserMedia` while the user is still resolving the first
|
||||
// prompt (or on iOS PWA where the second prompt would silently
|
||||
// never surface).
|
||||
if (err instanceof Error && err.name === 'NotAllowedError') {
|
||||
this.inputDenialError = err;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the cached `getUserMedia` denial so the next `setInputDevice`
|
||||
* call attempts a fresh acquisition. Called from
|
||||
* `utils/voice.requestMicPermission` (always invoked from a user
|
||||
* gesture) to re-arm the path after a denial. Without this, the cache
|
||||
* would suppress the retry and the user would be stuck in listener
|
||||
* mode for the rest of the session.
|
||||
*/
|
||||
clearInputDenial(): void {
|
||||
this.inputDenialError = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches an `onended` listener to every audio track in the supplied stream.
|
||||
* The listener identity-checks against `this.currentStream` so it only fires
|
||||
|
||||
@@ -615,9 +615,26 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
||||
// `z-[110]` keeps the bubble above any in-chat overlays (mention popover,
|
||||
// staged-attachment tiles) but below modals (`z-[300]+`).
|
||||
const isMobile = useUIStore((s) => s.isMobile);
|
||||
const { keyboardOpen } = useVisualViewportInset();
|
||||
const { keyboardOpen, textInputFocused } = useVisualViewportInset();
|
||||
// iOS PWA standalone shrinks the *layout viewport* itself for the keyboard
|
||||
// (interactive-widget=resizes-content / native standalone behavior), so
|
||||
// `vv.height` matches `innerHeight` and the height-delta-inferred
|
||||
// `keyboardOpen` stays false even though the keyboard IS up. Focus state is
|
||||
// the robust fallback: a text input being focused means the keyboard is up.
|
||||
// - keyboardOpen true (Android Chrome): MobileShell already shrunk to
|
||||
// `vv.height`; composer at `bottom: 0` lands on the keyboard top.
|
||||
// - keyboardOpen false but textInputFocused true (iOS PWA): layout viewport
|
||||
// already shrunk by iOS; composer at `bottom: 4px` lands ~4 px above the
|
||||
// keyboard top — the tight visual gap the user wants.
|
||||
// - both false: composer 6 px above the home indicator, the rest state.
|
||||
const composerStyle: React.CSSProperties | undefined = isMobile
|
||||
? { bottom: keyboardOpen ? '0px' : 'calc(env(safe-area-inset-bottom) + 6px)' }
|
||||
? {
|
||||
bottom: keyboardOpen
|
||||
? '0px'
|
||||
: textInputFocused
|
||||
? '4px'
|
||||
: 'calc(env(safe-area-inset-bottom) + 6px)',
|
||||
}
|
||||
: undefined;
|
||||
const composerClass =
|
||||
'absolute left-2 right-2 z-[110] glass-bubble rounded-[14px]' +
|
||||
@@ -709,7 +726,7 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
||||
// offset between renders. The ResizeObserver itself is what catches
|
||||
// continuous textarea-autosize growth; these deps just ensure we're
|
||||
// attached to the live element after a remount.
|
||||
}, [composerEl, isMobile, keyboardOpen, chatReplyTo, stagedTransfers.length]);
|
||||
}, [composerEl, isMobile, keyboardOpen, textInputFocused, chatReplyTo, stagedTransfers.length]);
|
||||
|
||||
// Combined ref: keep `popoverAnchorRef` populated (InputPopover / mention
|
||||
// popover anchor + scroll-into-view targets) AND notify the
|
||||
|
||||
@@ -65,24 +65,85 @@ export function MainContent() {
|
||||
setSearchOpen(false);
|
||||
}, [currentChannelId]);
|
||||
|
||||
// Handle actual browser fullscreen API
|
||||
useEffect(() => {
|
||||
const handleFullscreenChange = () => {
|
||||
setVoiceFullscreen(!!document.fullscreenElement);
|
||||
// Handle actual browser fullscreen API.
|
||||
//
|
||||
// iOS Safari (and iPadOS pre-16.4) does NOT implement the standard
|
||||
// `Element.requestFullscreen()` on generic elements. Older WebKit exposes a
|
||||
// `webkitRequestFullscreen` variant; pure iPhone has neither for a `<div>`
|
||||
// (only `HTMLVideoElement.webkitEnterFullscreen()` works, which we cannot use
|
||||
// for the multi-tile voice container). On those platforms we still want the
|
||||
// toggle to work as an in-page maximize: the `voiceFullscreen` flag flips the
|
||||
// container to `h-screen` so the call view fills the viewport — that's the
|
||||
// graceful fallback. Calling a missing API directly threw
|
||||
// `TypeError: requestFullscreen is not a function` on iPhone, taking down
|
||||
// the whole voice UI; the guards below contain the platform difference.
|
||||
type FullscreenCapableElement = HTMLElement & {
|
||||
webkitRequestFullscreen?: () => Promise<void> | void;
|
||||
};
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
}, [setVoiceFullscreen]);
|
||||
type FullscreenCapableDocument = Document & {
|
||||
webkitFullscreenElement?: Element | null;
|
||||
webkitExitFullscreen?: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
const getFullscreenElement = useCallback((): Element | null => {
|
||||
const d = document as FullscreenCapableDocument;
|
||||
return document.fullscreenElement ?? d.webkitFullscreenElement ?? null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (voiceFullscreen && voiceContainerRef.current && !document.fullscreenElement) {
|
||||
voiceContainerRef.current.requestFullscreen().catch(err => {
|
||||
console.error('Error attempting to enable full-screen mode:', err);
|
||||
});
|
||||
} else if (!voiceFullscreen && document.fullscreenElement) {
|
||||
document.exitFullscreen().catch(() => {});
|
||||
const handleFullscreenChange = () => {
|
||||
setVoiceFullscreen(!!getFullscreenElement());
|
||||
};
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||
document.addEventListener('webkitfullscreenchange', handleFullscreenChange);
|
||||
return () => {
|
||||
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
document.removeEventListener('webkitfullscreenchange', handleFullscreenChange);
|
||||
};
|
||||
}, [setVoiceFullscreen, getFullscreenElement]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = voiceContainerRef.current as FullscreenCapableElement | null;
|
||||
const d = document as FullscreenCapableDocument;
|
||||
const inFullscreen = !!getFullscreenElement();
|
||||
|
||||
const enter = async () => {
|
||||
if (!el) return;
|
||||
try {
|
||||
if (typeof el.requestFullscreen === 'function') {
|
||||
await el.requestFullscreen();
|
||||
} else if (typeof el.webkitRequestFullscreen === 'function') {
|
||||
await Promise.resolve(el.webkitRequestFullscreen());
|
||||
}
|
||||
}, [voiceFullscreen]);
|
||||
// If neither API exists (iPhone Safari / PWA standalone for non-video
|
||||
// elements), fall through silently — the `voiceFullscreen` flag still
|
||||
// applies `h-screen` to give an in-page maximize, which is the
|
||||
// documented graceful fallback.
|
||||
} catch (err) {
|
||||
// Permission denied, user gesture missing, or platform refusal. Keep
|
||||
// the in-page fallback (h-screen) without surfacing a console error.
|
||||
console.warn('Native fullscreen unavailable, using in-page fallback:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const exit = async () => {
|
||||
try {
|
||||
if (typeof document.exitFullscreen === 'function') {
|
||||
await document.exitFullscreen();
|
||||
} else if (typeof d.webkitExitFullscreen === 'function') {
|
||||
await Promise.resolve(d.webkitExitFullscreen());
|
||||
}
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
};
|
||||
|
||||
if (voiceFullscreen && el && !inFullscreen) {
|
||||
void enter();
|
||||
} else if (!voiceFullscreen && inFullscreen) {
|
||||
void exit();
|
||||
}
|
||||
}, [voiceFullscreen, getFullscreenElement]);
|
||||
|
||||
// 2. LOGIC AND EARLY RETURNS
|
||||
const channel = channels.find(c => c.id === currentChannelId);
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* Verifies the mobile voice fullscreen renders the same VoiceGrid pipeline
|
||||
* as desktop, so cameras (local + remote) and screen-share tiles appear in
|
||||
* the participant grid.
|
||||
*
|
||||
* jsdom can't host a real LiveKit Room, so we test at the rendering layer:
|
||||
* - Seed `voiceStore.participants` with synthetic ParticipantInfo entries.
|
||||
* - Mount MobileVoiceFullScreen.
|
||||
* - Assert the DOM contains the right number of <video> elements (one per
|
||||
* user-tile-with-camera + one per stream-tile).
|
||||
*
|
||||
* If anyone re-introduces the avatar-only mobile grid, this test fails.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
// jsdom doesn't ship ResizeObserver; useGridLayout needs it.
|
||||
if (typeof globalThis.ResizeObserver === 'undefined') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).ResizeObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
}
|
||||
import { MobileVoiceFullScreen } from './MobileVoiceFullScreen';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { useSpaceStore } from '../../stores/spaceStore';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
|
||||
// LiveKit client imports drag in heavy stuff; track.attach on a dead track
|
||||
// throws in jsdom, so stub the parts that touch DOM.
|
||||
vi.mock('../../hooks/useWebSocket', () => ({
|
||||
wsSend: vi.fn(),
|
||||
useWebSocket: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useLiveKit', async (importOriginal) => {
|
||||
const actual: any = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
getActiveRoom: () => null,
|
||||
setStreamSubscription: vi.fn(),
|
||||
setCameraSubscription: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// AudioManager is a singleton with browser-only deps.
|
||||
vi.mock('../../audio/AudioManager', () => ({
|
||||
AudioManager: { getInstance: () => ({ resumeContext: vi.fn() }) },
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/voiceActions', async () => ({
|
||||
handleCameraAction: vi.fn(),
|
||||
handleScreenShareAction: vi.fn(),
|
||||
}));
|
||||
|
||||
function makeFakeMediaStreamTrack(kind: 'video' | 'audio'): MediaStreamTrack {
|
||||
// Minimal stand-in. VoiceGrid's deriveGridTiles only inspects
|
||||
// p.videoTrack?.readyState; VoiceUser/StreamTile attach via
|
||||
// lkVideoTrack/lkScreenTrack which we mock as null below.
|
||||
const t = {
|
||||
kind,
|
||||
readyState: 'live',
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
getSettings: () => ({ height: 720, frameRate: 30 }),
|
||||
stop: vi.fn(),
|
||||
} as unknown as MediaStreamTrack;
|
||||
return t;
|
||||
}
|
||||
|
||||
function makeParticipant(overrides: Partial<any>): any {
|
||||
return {
|
||||
identity: '1:alice',
|
||||
userId: '1',
|
||||
username: 'alice',
|
||||
homeUserId: null,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
isCameraOn: false,
|
||||
isScreenSharing: false,
|
||||
isLocal: false,
|
||||
audioTrack: null,
|
||||
videoTrack: null,
|
||||
screenTrack: null,
|
||||
screenAudioTrack: null,
|
||||
lkVideoTrack: null,
|
||||
lkScreenTrack: null,
|
||||
cachedUser: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset relevant store slices.
|
||||
useVoiceStore.setState({
|
||||
currentVoiceChannelId: 'channel-A',
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
isCameraOn: false,
|
||||
isScreenSharing: false,
|
||||
participants: [],
|
||||
voiceUsers: new Map(),
|
||||
voiceUserStates: new Map(),
|
||||
spaceMutedUserIds: new Set(),
|
||||
spaceDeafenedUserIds: new Set(),
|
||||
permissionMutedUserIds: new Set(),
|
||||
speakingParticipantIds: new Set(),
|
||||
focusedParticipantId: null,
|
||||
activeDmCall: null,
|
||||
participantMutes: new Map(),
|
||||
streamMutes: new Map(),
|
||||
watchingStreams: new Set(),
|
||||
unwatchedCameras: new Set(),
|
||||
});
|
||||
useSpaceStore.setState({
|
||||
channels: [{ id: 'channel-A', name: 'general', type: 'voice' } as any],
|
||||
dmChannels: [],
|
||||
spaces: [],
|
||||
channelToSpaceMap: new Map([['channel-A', 'space-A']]),
|
||||
members: [],
|
||||
} as any);
|
||||
useAuthStore.setState({
|
||||
user: { id: '1', username: 'alice', displayName: 'Alice' },
|
||||
} as any);
|
||||
});
|
||||
|
||||
function renderScreen() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<MobileVoiceFullScreen />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('MobileVoiceFullScreen', () => {
|
||||
it('renders the VoiceGrid waiting state when no participants', () => {
|
||||
const { container } = renderScreen();
|
||||
// No <video> until participants exist.
|
||||
expect(container.querySelectorAll('video').length).toBe(0);
|
||||
// Waiting copy or "0 connected".
|
||||
expect(container.textContent).toMatch(/0 connected/);
|
||||
});
|
||||
|
||||
it('renders a video element for the local user when their camera is on', () => {
|
||||
useVoiceStore.setState({
|
||||
participants: [
|
||||
makeParticipant({
|
||||
identity: '1:alice',
|
||||
userId: '1',
|
||||
username: 'alice',
|
||||
isLocal: true,
|
||||
isCameraOn: true,
|
||||
videoTrack: makeFakeMediaStreamTrack('video'),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const { container } = renderScreen();
|
||||
// One user-tile, one <video> for the local camera.
|
||||
const videos = container.querySelectorAll('video');
|
||||
expect(videos.length).toBe(1);
|
||||
// Local video must be muted to prevent echo.
|
||||
expect(videos[0]).toHaveAttribute('autoplay');
|
||||
expect((videos[0] as HTMLVideoElement).muted).toBe(true);
|
||||
});
|
||||
|
||||
it('renders a video element for a remote user with camera on', () => {
|
||||
useVoiceStore.setState({
|
||||
participants: [
|
||||
makeParticipant({
|
||||
identity: '1:alice',
|
||||
userId: '1',
|
||||
username: 'alice',
|
||||
isLocal: true,
|
||||
}),
|
||||
makeParticipant({
|
||||
identity: '2:bob',
|
||||
userId: '2',
|
||||
username: 'bob',
|
||||
isLocal: false,
|
||||
isCameraOn: true,
|
||||
videoTrack: makeFakeMediaStreamTrack('video'),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const { container } = renderScreen();
|
||||
// Alice (no camera) → avatar tile with no <video>.
|
||||
// Bob (camera on) → <video> tile.
|
||||
expect(container.querySelectorAll('video').length).toBe(1);
|
||||
expect(container.textContent).toMatch(/2 connected/);
|
||||
});
|
||||
|
||||
it('renders an extra StreamTile when a participant publishes screen-share', () => {
|
||||
useVoiceStore.setState({
|
||||
participants: [
|
||||
makeParticipant({
|
||||
identity: '1:alice',
|
||||
userId: '1',
|
||||
username: 'alice',
|
||||
isLocal: true,
|
||||
}),
|
||||
makeParticipant({
|
||||
identity: '2:bob',
|
||||
userId: '2',
|
||||
username: 'bob',
|
||||
isLocal: false,
|
||||
isScreenSharing: true,
|
||||
screenTrack: makeFakeMediaStreamTrack('video'),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const { container } = renderScreen();
|
||||
// The grid should now contain Bob's user tile + a separate StreamTile
|
||||
// (avatar placeholder until Watch Stream is tapped). Look for the LIVE
|
||||
// badge in the StreamTile.
|
||||
expect(container.textContent).toMatch(/LIVE/);
|
||||
expect(container.textContent).toMatch(/is streaming/i);
|
||||
});
|
||||
|
||||
it('auto-focuses the first live screen-share publication on mount', async () => {
|
||||
useVoiceStore.setState({
|
||||
participants: [
|
||||
makeParticipant({
|
||||
identity: '1:alice',
|
||||
userId: '1',
|
||||
username: 'alice',
|
||||
isLocal: true,
|
||||
}),
|
||||
makeParticipant({
|
||||
identity: '2:bob',
|
||||
userId: '2',
|
||||
username: 'bob',
|
||||
isLocal: false,
|
||||
isScreenSharing: true,
|
||||
screenTrack: makeFakeMediaStreamTrack('video'),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
renderScreen();
|
||||
|
||||
// Auto-focus runs in a useEffect. Flush.
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(useVoiceStore.getState().focusedParticipantId).toBe('2:bob:stream');
|
||||
});
|
||||
|
||||
it('clears focus on unmount so re-entering the screen is a clean slate', async () => {
|
||||
useVoiceStore.setState({
|
||||
participants: [
|
||||
makeParticipant({
|
||||
identity: '2:bob',
|
||||
userId: '2',
|
||||
username: 'bob',
|
||||
isLocal: false,
|
||||
isScreenSharing: true,
|
||||
screenTrack: makeFakeMediaStreamTrack('video'),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const { unmount } = renderScreen();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(useVoiceStore.getState().focusedParticipantId).toBe('2:bob:stream');
|
||||
|
||||
unmount();
|
||||
expect(useVoiceStore.getState().focusedParticipantId).toBeNull();
|
||||
});
|
||||
|
||||
it('renders a screen-share button that calls the canonical handler', async () => {
|
||||
const { handleScreenShareAction } = await import('../../utils/voiceActions');
|
||||
useVoiceStore.setState({
|
||||
participants: [
|
||||
makeParticipant({
|
||||
identity: '1:alice',
|
||||
userId: '1',
|
||||
username: 'alice',
|
||||
isLocal: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const { container } = renderScreen();
|
||||
const btn = container.querySelector(
|
||||
'button[aria-label="Share screen"], button[aria-label="Stop sharing screen"]',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(btn).toBeTruthy();
|
||||
btn?.click();
|
||||
expect(handleScreenShareAction).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,33 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { useSpaceStore } from '../../stores/spaceStore';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { useContextMenuStore, type ContextMenuItem } from '../../stores/contextMenuStore';
|
||||
import { buildVoiceModMenuItems, VolumeSliderItem } from '../voice/voiceMenuItems';
|
||||
import { wsSend } from '../../hooks/useWebSocket';
|
||||
import { getChannelOrigin } from '../../stores/spaceStore';
|
||||
import { handleCameraAction } from '../../utils/voiceActions';
|
||||
import {
|
||||
handleCameraAction,
|
||||
handleScreenShareAction,
|
||||
} from '../../utils/voiceActions';
|
||||
import { VoiceGrid } from '../voice/VoiceGrid';
|
||||
import { deriveGridTiles } from '../../hooks/useLiveKit';
|
||||
import { requestMicPermission } from '../../utils/voice';
|
||||
|
||||
/**
|
||||
* Full-screen mobile voice/video call view.
|
||||
*
|
||||
* Renders the same `VoiceGrid` as desktop so cameras and screen-share tracks
|
||||
* subscribe + attach via the canonical `Track.attach()` pipeline. The grid
|
||||
* supports tap-to-focus (single tile takes the bulk of the viewport, others
|
||||
* collapse into a bottom strip) — feature parity with desktop's focused mode.
|
||||
*
|
||||
* Auto-focus on screen-share: when the user is not already focused on a
|
||||
* specific tile, the first available screen-share tile is auto-focused so
|
||||
* mobile users don't need to discover the tap-to-focus affordance to watch a
|
||||
* stream. Auto-focus is mobile-only behaviour; desktop preserves its
|
||||
* "render-everything-then-let-the-user-pick" pattern.
|
||||
*/
|
||||
export function MobileVoiceFullScreen() {
|
||||
const popMobileScreen = useUIStore((s) => s.popMobileScreen);
|
||||
const pushMobileScreen = useUIStore((s) => s.pushMobileScreen);
|
||||
@@ -21,24 +39,209 @@ export function MobileVoiceFullScreen() {
|
||||
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
|
||||
const toggleMute = useVoiceStore((s) => s.toggleMic);
|
||||
const toggleDeafen = useVoiceStore((s) => s.toggleDeafen);
|
||||
const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare);
|
||||
const leaveVoice = useVoiceStore((s) => s.leaveVoice);
|
||||
const voiceUsers = useVoiceStore((s) => s.voiceUsers);
|
||||
const voiceUserStates = useVoiceStore((s) => s.voiceUserStates);
|
||||
const participants = useVoiceStore((s) => s.participants);
|
||||
const spaceMutedUserIds = useVoiceStore((s) => s.spaceMutedUserIds);
|
||||
const spaceDeafenedUserIds = useVoiceStore((s) => s.spaceDeafenedUserIds);
|
||||
const permissionMutedUserIds = useVoiceStore((s) => s.permissionMutedUserIds);
|
||||
const focusedParticipantId = useVoiceStore((s) => s.focusedParticipantId);
|
||||
const setFocusedParticipant = useVoiceStore((s) => s.setFocusedParticipant);
|
||||
|
||||
const channels = useSpaceStore((s) => s.channels);
|
||||
const dmChannels = useSpaceStore((s) => s.dmChannels);
|
||||
const spaces = useSpaceStore((s) => s.spaces);
|
||||
const channelToSpaceMap = useSpaceStore((s) => s.channelToSpaceMap);
|
||||
const members = useSpaceStore((s) => s.members);
|
||||
|
||||
const authUser = useAuthStore((s) => s.user);
|
||||
|
||||
const openContextMenu = useContextMenuStore((s) => s.open);
|
||||
const cameraDeviceId = useVoiceStore((s) => s.cameraDeviceId);
|
||||
const setCameraDeviceId = useVoiceStore((s) => s.setCameraDeviceId);
|
||||
const micPermissionDenied = useVoiceStore((s) => s.micPermissionDenied);
|
||||
|
||||
// ── In-call camera switcher ───────────────────────────────────────────────
|
||||
// On mobile, users typically have a front+back camera and need to flip
|
||||
// mid-call. Desktop exposes per-device selection in user settings, but
|
||||
// navigating away from the call screen mid-call breaks the flow.
|
||||
//
|
||||
// Mechanism: `setCameraDeviceId(deviceId)` writes to voiceStore; the
|
||||
// canonical `useLiveKit syncCamera` effect picks that up and calls
|
||||
// `room.switchActiveDevice('videoinput', target)` for an in-place hot-swap
|
||||
// (no republish). This is the same flow desktop's settings panel uses —
|
||||
// see docs/systems/voice.md "Hot-swap mid-call".
|
||||
const [cameraDevices, setCameraDevices] = useState<MediaDeviceInfo[]>([]);
|
||||
const [cameraPickerOpen, setCameraPickerOpen] = useState(false);
|
||||
const cameraPickerAnchorRef = useRef<HTMLDivElement>(null);
|
||||
const cameraPickerPopupRef = useRef<HTMLDivElement>(null);
|
||||
const [cameraPickerRect, setCameraPickerRect] = useState<{ left: number; bottom: number } | null>(null);
|
||||
|
||||
// Enumerate available video inputs. Pure passive — `enumerateDevices`
|
||||
// does NOT light the camera LED. Labels are populated only after an
|
||||
// active camera grant; before that they fall back to "Camera N".
|
||||
// We re-enumerate on every `devicechange` (e.g. AirPods connect, USB
|
||||
// camera plugged in).
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const enumerate = async () => {
|
||||
try {
|
||||
const all = await navigator.mediaDevices.enumerateDevices();
|
||||
if (cancelled) return;
|
||||
const seen = new Set<string>();
|
||||
const cams: MediaDeviceInfo[] = [];
|
||||
for (const d of all) {
|
||||
if (d.kind !== 'videoinput') continue;
|
||||
if (seen.has(d.deviceId)) continue;
|
||||
seen.add(d.deviceId);
|
||||
cams.push(d);
|
||||
}
|
||||
setCameraDevices(cams);
|
||||
} catch {
|
||||
if (!cancelled) setCameraDevices([]);
|
||||
}
|
||||
};
|
||||
enumerate();
|
||||
const onChange = () => enumerate();
|
||||
navigator.mediaDevices.addEventListener('devicechange', onChange);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
navigator.mediaDevices.removeEventListener('devicechange', onChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Click-outside to close the picker. Listens to mousedown AND touchstart
|
||||
// because iOS Safari does not synthesize mousedown reliably from a single
|
||||
// tap (same pattern as MobileVoiceJoinSheet's picker).
|
||||
useEffect(() => {
|
||||
if (!cameraPickerOpen) return;
|
||||
const onPointerDown = (e: MouseEvent | TouchEvent) => {
|
||||
const target = e.target as Node;
|
||||
const inAnchor = cameraPickerAnchorRef.current?.contains(target) ?? false;
|
||||
const inPopup = cameraPickerPopupRef.current?.contains(target) ?? false;
|
||||
if (!inAnchor && !inPopup) setCameraPickerOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onPointerDown);
|
||||
document.addEventListener('touchstart', onPointerDown, { passive: true });
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onPointerDown);
|
||||
document.removeEventListener('touchstart', onPointerDown);
|
||||
};
|
||||
}, [cameraPickerOpen]);
|
||||
|
||||
// Pin the popup to the anchor's screen rect on open, reposition on
|
||||
// resize/scroll. Using `bottom` makes the popup expand upward from the
|
||||
// chevron — natural for a control bar at the screen bottom.
|
||||
useEffect(() => {
|
||||
if (!cameraPickerOpen) {
|
||||
setCameraPickerRect(null);
|
||||
return;
|
||||
}
|
||||
const anchorBtn = cameraPickerAnchorRef.current?.querySelector('button');
|
||||
if (!anchorBtn) return;
|
||||
const update = () => {
|
||||
const r = anchorBtn.getBoundingClientRect();
|
||||
setCameraPickerRect({
|
||||
left: r.left,
|
||||
bottom: window.innerHeight - r.top + 6,
|
||||
});
|
||||
};
|
||||
update();
|
||||
window.addEventListener('resize', update);
|
||||
window.addEventListener('scroll', update, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', update);
|
||||
window.removeEventListener('scroll', update, true);
|
||||
};
|
||||
}, [cameraPickerOpen]);
|
||||
|
||||
// Close the picker if the camera turns off (the trigger is hidden anyway,
|
||||
// but stale popup state would briefly flash if the camera toggles off
|
||||
// between renders).
|
||||
useEffect(() => {
|
||||
if (!isCameraOn && cameraPickerOpen) setCameraPickerOpen(false);
|
||||
}, [isCameraOn, cameraPickerOpen]);
|
||||
|
||||
const handlePickCamera = useCallback(
|
||||
(deviceId: string | null) => {
|
||||
setCameraDeviceId(deviceId);
|
||||
setCameraPickerOpen(false);
|
||||
},
|
||||
[setCameraDeviceId],
|
||||
);
|
||||
|
||||
const showCameraSwitcher = isCameraOn && cameraDevices.length > 1;
|
||||
|
||||
// Track whether the user has explicitly chosen focus (manual tap) vs.
|
||||
// auto-focus we set on screen-share publish. Once the user manually
|
||||
// focuses or unfocuses anything during this screen's lifetime, we stop
|
||||
// auto-focusing.
|
||||
const userTouchedFocusRef = useRef(false);
|
||||
const lastAutoFocusedKeyRef = useRef<string | null>(null);
|
||||
|
||||
// Pre-compute tiles for screen-share auto-focus detection. Cheap (same
|
||||
// derivation VoiceGrid runs).
|
||||
const tiles = useMemo(() => deriveGridTiles(participants), [participants]);
|
||||
|
||||
// Auto-focus screen-share on mobile.
|
||||
// - Trigger only when the user has not manually changed focus during this
|
||||
// screen lifetime AND the current focus is either null or a stale tile
|
||||
// that no longer exists.
|
||||
// - Picks the first live screen-share tile.
|
||||
useEffect(() => {
|
||||
if (userTouchedFocusRef.current) return;
|
||||
|
||||
const liveStreamTiles = tiles.filter(
|
||||
(t) => t.kind === 'stream' && t.screenTrack?.readyState === 'live',
|
||||
);
|
||||
if (liveStreamTiles.length === 0) return;
|
||||
|
||||
const firstStreamKey = liveStreamTiles[0]?.key;
|
||||
if (!firstStreamKey) return;
|
||||
|
||||
// Already focused on this stream — nothing to do.
|
||||
if (focusedParticipantId === firstStreamKey) {
|
||||
lastAutoFocusedKeyRef.current = firstStreamKey;
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't override an existing manual focus on a still-valid tile. The
|
||||
// VoiceGrid effect already clears focus when the focused tile vanishes,
|
||||
// so reaching here with a non-null focusedParticipantId means the user
|
||||
// is intentionally focused on something else (cleared by us only if it
|
||||
// matches the previous auto-focus).
|
||||
if (
|
||||
focusedParticipantId &&
|
||||
focusedParticipantId !== lastAutoFocusedKeyRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFocusedParticipant(firstStreamKey);
|
||||
lastAutoFocusedKeyRef.current = firstStreamKey;
|
||||
}, [tiles, focusedParticipantId, setFocusedParticipant]);
|
||||
|
||||
// Wrap setFocusedParticipant so we can flag user-initiated focus changes.
|
||||
// Replace the store action *transparently* via a subscription is fragile;
|
||||
// instead we wrap by intercepting through a custom click layer below. The
|
||||
// VoiceGrid uses the store's setFocusedParticipant directly for clicks —
|
||||
// we can't override that without forking VoiceGrid. Workaround: mark the
|
||||
// flag whenever focusedParticipantId changes to a value other than what
|
||||
// we auto-focused.
|
||||
useEffect(() => {
|
||||
if (focusedParticipantId === null) {
|
||||
// User dismissed focus (or VoiceGrid cleared it on stale). Either way,
|
||||
// the user has now interacted; do not re-auto-focus the same stream.
|
||||
if (lastAutoFocusedKeyRef.current !== null) {
|
||||
userTouchedFocusRef.current = true;
|
||||
}
|
||||
} else if (focusedParticipantId !== lastAutoFocusedKeyRef.current) {
|
||||
// User picked a different tile — manual interaction.
|
||||
userTouchedFocusRef.current = true;
|
||||
}
|
||||
}, [focusedParticipantId]);
|
||||
|
||||
// Reset focus state when leaving the screen / call.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// On unmount, clear focus so re-entering is a clean slate.
|
||||
useVoiceStore.getState().setFocusedParticipant(null);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!currentVoiceChannelId) {
|
||||
popMobileScreen();
|
||||
@@ -51,57 +254,34 @@ export function MobileVoiceFullScreen() {
|
||||
|
||||
if (isDmCall) {
|
||||
const dmId = currentVoiceChannelId.replace('dm-', '');
|
||||
const dm = dmChannels.find(d => d.id === dmId);
|
||||
const dm = dmChannels.find((d) => d.id === dmId);
|
||||
if (dm) {
|
||||
const others = dm.members.filter(m => m.id !== authUser?.id);
|
||||
channelName = others.map(m => m.displayName ?? m.username).join(', ');
|
||||
const others = dm.members.filter((m) => m.id !== authUser?.id);
|
||||
channelName = others.map((m) => m.displayName ?? m.username).join(', ');
|
||||
}
|
||||
} else {
|
||||
const ch = channels.find(c => c.id === currentVoiceChannelId);
|
||||
const ch = channels.find((c) => c.id === currentVoiceChannelId);
|
||||
if (ch) {
|
||||
channelName = ch.name;
|
||||
const spaceId = channelToSpaceMap.get(ch.id);
|
||||
const space = spaceId ? spaces.find(s => s.id === spaceId) : null;
|
||||
const space = spaceId ? spaces.find((s) => s.id === spaceId) : null;
|
||||
if (space) spaceName = space.name;
|
||||
}
|
||||
}
|
||||
|
||||
const participantIds = voiceUsers.get(currentVoiceChannelId) || [];
|
||||
|
||||
// Resolve participant display info from members list or DM members
|
||||
const getParticipantInfo = (userId: string) => {
|
||||
const member = members.find(m => m.userId === userId);
|
||||
if (member) {
|
||||
return {
|
||||
name: member.nickname ?? member.user?.displayName ?? member.user?.username ?? userId,
|
||||
avatar: member.user?.avatar ? `/api/uploads/${member.user.avatar}` : null,
|
||||
avatarColor: member.user?.avatarColor ?? null,
|
||||
};
|
||||
}
|
||||
// Check DM members
|
||||
for (const dm of dmChannels) {
|
||||
const dmMember = dm.members.find(m => m.id === userId);
|
||||
if (dmMember) {
|
||||
return {
|
||||
name: dmMember.displayName ?? dmMember.username,
|
||||
avatar: dmMember.avatar ? `/api/uploads/${dmMember.avatar}` : null,
|
||||
avatarColor: dmMember.avatarColor,
|
||||
};
|
||||
}
|
||||
}
|
||||
// Fall back to LiveKit participant metadata
|
||||
const participant = participants.find(p => p.userId === userId);
|
||||
if (participant?.username) {
|
||||
return { name: participant.username, avatar: null, avatarColor: null };
|
||||
}
|
||||
return { name: userId, avatar: null, avatarColor: null };
|
||||
};
|
||||
|
||||
const handleDisconnect = () => {
|
||||
const { activeDmCall, disconnectFn, federatedCallId, callOrigin } = useVoiceStore.getState();
|
||||
const { activeDmCall, disconnectFn, federatedCallId, callOrigin } =
|
||||
useVoiceStore.getState();
|
||||
if (activeDmCall) {
|
||||
const origin = callOrigin || getChannelOrigin(activeDmCall.dmChannelId);
|
||||
wsSend({ type: 'dm_call_end', dmChannelId: activeDmCall.dmChannelId, federatedCallId }, origin);
|
||||
wsSend(
|
||||
{
|
||||
type: 'dm_call_end',
|
||||
dmChannelId: activeDmCall.dmChannelId,
|
||||
federatedCallId,
|
||||
},
|
||||
origin,
|
||||
);
|
||||
useVoiceStore.getState().setActiveDmCall(null);
|
||||
} else if (currentVoiceChannelId) {
|
||||
wsSend({ type: 'voice_leave' }, getChannelOrigin(currentVoiceChannelId));
|
||||
@@ -111,160 +291,147 @@ export function MobileVoiceFullScreen() {
|
||||
popMobileScreen();
|
||||
};
|
||||
|
||||
const handleParticipantContextMenu = (e: React.MouseEvent, userId: string) => {
|
||||
if (userId === authUser?.id || !currentVoiceChannelId || isDmCall) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const modItems = buildVoiceModMenuItems(userId, currentVoiceChannelId);
|
||||
const items: ContextMenuItem[] = [...modItems];
|
||||
|
||||
if (modItems.length > 0) {
|
||||
items.push({ key: 'mod-end-sep', type: 'separator' });
|
||||
}
|
||||
|
||||
// Local mute checkbox
|
||||
items.push({
|
||||
key: 'mute-user',
|
||||
type: 'checkbox',
|
||||
label: 'Mute User',
|
||||
subscribe: useVoiceStore.subscribe,
|
||||
getChecked: () => useVoiceStore.getState().participantMutes.get(userId) ?? false,
|
||||
onChange: (checked) => useVoiceStore.getState().setParticipantMute(userId, checked),
|
||||
});
|
||||
|
||||
items.push({ key: 'vol-sep', type: 'separator' });
|
||||
|
||||
// Volume slider
|
||||
items.push({
|
||||
key: 'volume',
|
||||
type: 'custom',
|
||||
render: () => <VolumeSliderItem userId={userId} />,
|
||||
});
|
||||
|
||||
if (items.length === 0) return;
|
||||
openContextMenu({ x: e.clientX, y: e.clientY }, items);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-surface-base">
|
||||
{/* Header */}
|
||||
<header className="h-12 flex items-center gap-2 px-3 border-b border-border-soft shrink-0">
|
||||
<button onClick={popMobileScreen} className="w-8 h-8 flex items-center justify-center text-txt-secondary hover:text-txt-primary">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
<button
|
||||
onClick={popMobileScreen}
|
||||
className="w-8 h-8 flex items-center justify-center text-txt-secondary hover:text-txt-primary"
|
||||
aria-label="Collapse call"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M19.5 8.25l-7.5 7.5-7.5-7.5"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-sm font-semibold text-txt-primary truncate">{channelName}</h1>
|
||||
{spaceName && <p className="text-[11px] text-txt-tertiary truncate">{spaceName}</p>}
|
||||
<h1 className="text-sm font-semibold text-txt-primary truncate">
|
||||
{channelName}
|
||||
</h1>
|
||||
{spaceName && (
|
||||
<p className="text-[11px] text-txt-tertiary truncate">{spaceName}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-txt-tertiary">{participantIds.length} connected</span>
|
||||
<span className="text-xs text-txt-tertiary">
|
||||
{participants.length} connected
|
||||
</span>
|
||||
{!isDmCall && (
|
||||
<button
|
||||
onClick={() => pushMobileScreen('members')}
|
||||
className="w-8 h-8 flex items-center justify-center text-txt-secondary hover:text-txt-primary"
|
||||
aria-label="View members"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Participant grid */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className={`grid gap-3 ${
|
||||
participantIds.length <= 2 ? 'grid-cols-1' : 'grid-cols-2'
|
||||
}`}>
|
||||
{participantIds.map(userId => {
|
||||
const info = getParticipantInfo(userId);
|
||||
const wsStatus = voiceUserStates.get(userId);
|
||||
const participant = participants.find(p => p.userId === userId);
|
||||
const isMe = userId === authUser?.id;
|
||||
|
||||
// Resolve mute/deafen: local state for self, LiveKit participant then WS fallback for others
|
||||
const isUserMuted = isMe
|
||||
? isMuted
|
||||
: (participant?.isMuted ?? wsStatus?.isMuted ?? false);
|
||||
const isUserDeafened = isMe
|
||||
? isDeafened
|
||||
: (participant?.isDeafened ?? wsStatus?.isDeafened ?? false);
|
||||
|
||||
// Server-enforced states
|
||||
const spaceId = !isDmCall && currentVoiceChannelId
|
||||
? channelToSpaceMap.get(currentVoiceChannelId)
|
||||
: undefined;
|
||||
const isSpaceMuted = spaceId ? spaceMutedUserIds.has(`${spaceId}:${userId}`) : false;
|
||||
const isSpaceDeafened = spaceId ? spaceDeafenedUserIds.has(`${spaceId}:${userId}`) : false;
|
||||
const isPermissionMuted = spaceId ? permissionMutedUserIds.has(`${spaceId}:${userId}`) : false;
|
||||
|
||||
// Any muted indicator: self-mute, server mute, or permission mute
|
||||
const showMuted = isUserMuted || isSpaceMuted || isPermissionMuted;
|
||||
// Any deafened indicator: self-deafen or server deafen
|
||||
const showDeafened = isUserDeafened || isSpaceDeafened;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={userId}
|
||||
data-context-menu
|
||||
className={`rounded-xl bg-surface-channel p-4 flex flex-col items-center gap-3 ${
|
||||
participantIds.length <= 2 ? 'py-8' : 'py-4'
|
||||
}`}
|
||||
onContextMenu={(e) => handleParticipantContextMenu(e, userId)}
|
||||
{/* Mic-permission denial banner. Surfaces only when the user joined
|
||||
voice without granting microphone access (most common on iOS PWA
|
||||
where the permission prompt missed its user-gesture window, and
|
||||
the user denied or dismissed). The retry button is the second
|
||||
user-gesture entry-point — it calls `getUserMedia` synchronously
|
||||
inside the click handler so iOS surfaces the prompt cleanly. On
|
||||
success the flag clears and `useLiveKit syncMic` re-fires to
|
||||
publish the freshly acquired mic track. */}
|
||||
{micPermissionDenied && (
|
||||
<div className="mx-2 mt-2 px-3 py-2.5 rounded-lg bg-accent-amber/10 border border-accent-amber/30 flex items-center gap-3 shrink-0">
|
||||
<svg
|
||||
className="w-5 h-5 text-accent-amber shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.75}
|
||||
>
|
||||
<div className="relative">
|
||||
<Avatar
|
||||
src={info.avatar}
|
||||
name={info.name}
|
||||
avatarColor={info.avatarColor}
|
||||
size={participantIds.length <= 2 ? 80 : 56}
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 18.75a6 6 0 006-6v-1.5m-6 7.5a6 6 0 01-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M3 3l18 18"
|
||||
/>
|
||||
{(showMuted || showDeafened) && (
|
||||
<div className="absolute -bottom-1 -right-1 w-6 h-6 rounded-full bg-accent-rose/90 flex items-center justify-center">
|
||||
{showDeafened ? (
|
||||
<svg className="w-3.5 h-3.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.114 5.636a9 9 0 010 12.728M16.463 8.288a5.25 5.25 0 010 7.424M6.75 8.25l4.72-4.72a.75.75 0 011.28.53v15.88a.75.75 0 01-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.01 9.01 0 012.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 3l18 18" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3.5 h-3.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 18.75a6 6 0 006-6v-1.5m-6 7.5a6 6 0 01-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 01-3-3V4.5a3 3 0 116 0v8.25a3 3 0 01-3 3z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 3l18 18" />
|
||||
</svg>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[12px] font-medium text-accent-amber">
|
||||
Microphone access denied
|
||||
</p>
|
||||
<p className="text-[11px] text-txt-tertiary leading-tight mt-0.5">
|
||||
You're listening only — others can't hear you.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void requestMicPermission();
|
||||
}}
|
||||
className="text-[11px] font-medium px-3 py-1.5 rounded-md bg-accent-amber/20 text-accent-amber hover:bg-accent-amber/30 transition-colors shrink-0"
|
||||
>
|
||||
Allow microphone
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-txt-primary font-medium truncate max-w-full">
|
||||
{info.name}{isMe ? ' (You)' : ''}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{participantIds.length === 0 && (
|
||||
<div className="flex items-center justify-center h-40 text-txt-tertiary text-sm">
|
||||
No one else is here yet
|
||||
</div>
|
||||
)}
|
||||
{/* Participant grid — VoiceGrid handles attach/detach, tap-to-focus,
|
||||
screen-share tiles, mute overlays, context menus. Identical
|
||||
rendering pipeline to desktop. */}
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<VoiceGrid participants={participants} />
|
||||
</div>
|
||||
|
||||
{/* Control bar */}
|
||||
<div className="glass-bubble mx-2 mb-2 rounded-2xl flex items-center justify-center gap-4 px-4 py-3 shrink-0"
|
||||
<div
|
||||
className="glass-bubble mx-2 mb-2 rounded-2xl flex items-center justify-center gap-4 px-4 py-3 shrink-0"
|
||||
style={{ marginBottom: 'calc(0.5rem + env(safe-area-inset-bottom))' }}
|
||||
>
|
||||
{/* Mute */}
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className={`w-12 h-12 rounded-full flex items-center justify-center transition-colors ${
|
||||
isMuted ? 'bg-accent-rose/20 text-accent-rose' : 'bg-surface-elevated text-txt-primary hover:bg-interactive-hover'
|
||||
isMuted
|
||||
? 'bg-accent-rose/20 text-accent-rose'
|
||||
: 'bg-surface-elevated text-txt-primary hover:bg-interactive-hover'
|
||||
}`}
|
||||
aria-label={isMuted ? 'Unmute' : 'Mute'}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 18.75a6 6 0 006-6v-1.5m-6 7.5a6 6 0 01-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 01-3-3V4.5a3 3 0 116 0v8.25a3 3 0 01-3 3z" />
|
||||
{isMuted && <path strokeLinecap="round" strokeLinejoin="round" d="M3 3l18 18" />}
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 18.75a6 6 0 006-6v-1.5m-6 7.5a6 6 0 01-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 01-3-3V4.5a3 3 0 116 0v8.25a3 3 0 01-3 3z"
|
||||
/>
|
||||
{isMuted && (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3 3l18 18"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
@@ -272,36 +439,124 @@ export function MobileVoiceFullScreen() {
|
||||
<button
|
||||
onClick={toggleDeafen}
|
||||
className={`w-12 h-12 rounded-full flex items-center justify-center transition-colors ${
|
||||
isDeafened ? 'bg-accent-rose/20 text-accent-rose' : 'bg-surface-elevated text-txt-primary hover:bg-interactive-hover'
|
||||
isDeafened
|
||||
? 'bg-accent-rose/20 text-accent-rose'
|
||||
: 'bg-surface-elevated text-txt-primary hover:bg-interactive-hover'
|
||||
}`}
|
||||
aria-label={isDeafened ? 'Undeafen' : 'Deafen'}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.114 5.636a9 9 0 010 12.728M16.463 8.288a5.25 5.25 0 010 7.424M6.75 8.25l4.72-4.72a.75.75 0 011.28.53v15.88a.75.75 0 01-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.01 9.01 0 012.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75z" />
|
||||
{isDeafened && <path strokeLinecap="round" strokeLinejoin="round" d="M3 3l18 18" />}
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M19.114 5.636a9 9 0 010 12.728M16.463 8.288a5.25 5.25 0 010 7.424M6.75 8.25l4.72-4.72a.75.75 0 011.28.53v15.88a.75.75 0 01-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.01 9.01 0 012.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75z"
|
||||
/>
|
||||
{isDeafened && (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3 3l18 18"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Camera */}
|
||||
{/* Camera (with in-call switcher chevron when multiple cameras exist
|
||||
and the camera is currently on). The chevron sits in a small
|
||||
attached pill above the bottom-right corner of the camera button —
|
||||
visible only when relevant so single-camera devices are unaffected. */}
|
||||
<div className="relative" ref={cameraPickerAnchorRef}>
|
||||
<button
|
||||
onClick={handleCameraAction}
|
||||
className={`w-12 h-12 rounded-full flex items-center justify-center transition-colors ${
|
||||
isCameraOn ? 'bg-accent-mint/20 text-accent-mint' : 'bg-surface-elevated text-txt-primary hover:bg-interactive-hover'
|
||||
isCameraOn
|
||||
? 'bg-accent-mint/20 text-accent-mint'
|
||||
: 'bg-surface-elevated text-txt-primary hover:bg-interactive-hover'
|
||||
}`}
|
||||
aria-label={isCameraOn ? 'Turn camera off' : 'Turn camera on'}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9.75a2.25 2.25 0 002.25-2.25V7.5a2.25 2.25 0 00-2.25-2.25H4.5A2.25 2.25 0 002.25 7.5v9A2.25 2.25 0 004.5 18.75z" />
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9.75a2.25 2.25 0 002.25-2.25V7.5a2.25 2.25 0 00-2.25-2.25H4.5A2.25 2.25 0 002.25 7.5v9A2.25 2.25 0 004.5 18.75z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Screen share */}
|
||||
{showCameraSwitcher && (
|
||||
<button
|
||||
onClick={toggleScreenShare}
|
||||
className={`w-12 h-12 rounded-full flex items-center justify-center transition-colors ${
|
||||
isScreenSharing ? 'bg-accent-mint/20 text-accent-mint' : 'bg-surface-elevated text-txt-primary hover:bg-interactive-hover'
|
||||
}`}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCameraPickerOpen((v) => !v);
|
||||
}}
|
||||
aria-label="Switch camera"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={cameraPickerOpen}
|
||||
className="absolute -top-1 -right-1 w-6 h-6 rounded-full bg-surface-elevated text-txt-primary flex items-center justify-center shadow-md border border-border-soft active:scale-95 transition-transform"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a9 9 0 01-9 9m0 0a9 9 0 01-9-9" />
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
{/* Camera-flip icon: arrows around a camera */}
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M4 7h3l1.5-2h7L17 7h3v12H4V7z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9 13a3 3 0 003 3m3-3a3 3 0 00-3-3M9 13l-1.5-1.5M9 13l1.5-1.5M15 13l1.5 1.5M15 13l-1.5 1.5"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Screen share — uses canonical handleScreenShareAction so the
|
||||
getDisplayMedia call actually fires (and propagates errors via
|
||||
voiceActions). The previous voiceStore.toggleScreenShare flipped
|
||||
only the boolean and never started capture. */}
|
||||
<button
|
||||
onClick={handleScreenShareAction}
|
||||
className={`w-12 h-12 rounded-full flex items-center justify-center transition-colors ${
|
||||
isScreenSharing
|
||||
? 'bg-accent-mint/20 text-accent-mint'
|
||||
: 'bg-surface-elevated text-txt-primary hover:bg-interactive-hover'
|
||||
}`}
|
||||
aria-label={
|
||||
isScreenSharing ? 'Stop sharing screen' : 'Share screen'
|
||||
}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a9 9 0 01-9 9m0 0a9 9 0 01-9-9"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
@@ -309,12 +564,75 @@ export function MobileVoiceFullScreen() {
|
||||
<button
|
||||
onClick={handleDisconnect}
|
||||
className="w-12 h-12 rounded-full bg-accent-rose flex items-center justify-center text-white hover:bg-accent-rose/80 transition-colors"
|
||||
aria-label="Disconnect from call"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" />
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Camera picker popup — portaled to document.body so the upward
|
||||
expansion isn't clipped by the control bar's glass-bubble.
|
||||
Anchored to the chevron via getBoundingClientRect (recomputed on
|
||||
resize / scroll). max-height + overflow-y-auto + iOS scroll
|
||||
momentum keep every entry reachable on devices with many cameras. */}
|
||||
{showCameraSwitcher &&
|
||||
cameraPickerOpen &&
|
||||
cameraPickerRect &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={cameraPickerPopupRef}
|
||||
role="menu"
|
||||
aria-label="Select camera"
|
||||
className="fixed z-[60] rounded-md bg-surface-elevated border border-border-hard py-1 shadow-lg overflow-y-auto"
|
||||
style={{
|
||||
left: cameraPickerRect.left,
|
||||
bottom: cameraPickerRect.bottom,
|
||||
minWidth: 180,
|
||||
maxWidth: 260,
|
||||
maxHeight: 'min(50vh, 320px)',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={cameraDeviceId === null}
|
||||
onClick={() => handlePickCamera(null)}
|
||||
className={`w-full text-left px-3 py-2 text-[12px] truncate ${
|
||||
cameraDeviceId === null ? 'text-txt-primary' : 'text-txt-secondary'
|
||||
} active:bg-interactive-hover`}
|
||||
>
|
||||
Auto (system default)
|
||||
</button>
|
||||
{cameraDevices.map((d, i) => (
|
||||
<button
|
||||
key={d.deviceId}
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={cameraDeviceId === d.deviceId}
|
||||
onClick={() => handlePickCamera(d.deviceId)}
|
||||
className={`w-full text-left px-3 py-2 text-[12px] truncate ${
|
||||
cameraDeviceId === d.deviceId ? 'text-txt-primary' : 'text-txt-secondary'
|
||||
} active:bg-interactive-hover`}
|
||||
>
|
||||
{d.label || `Camera ${i + 1}`}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -340,6 +340,7 @@ export function StreamTile({ tile, large }: StreamTileProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
data-context-menu
|
||||
className={`relative bg-surface-base rounded-xl overflow-hidden flex items-center justify-center group transition-all duration-200 ring-1 ring-white/[0.06] hover:ring-white/10 ${
|
||||
'h-full w-full'
|
||||
}`}
|
||||
|
||||
@@ -141,6 +141,7 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
data-context-menu
|
||||
className={`relative bg-surface-base rounded-xl overflow-hidden flex items-center justify-center group transition-all duration-200 ${
|
||||
isSpeaking
|
||||
? 'ring-[3px] ring-status-online shadow-[0_0_12px_rgba(134,239,172,0.25)]'
|
||||
|
||||
@@ -210,6 +210,7 @@ export function useLiveKit() {
|
||||
const inputVolume = useVoiceStore((s) => s.inputVolume);
|
||||
const inputDeviceId = useVoiceStore((s) => s.inputDeviceId);
|
||||
const cameraDeviceId = useVoiceStore((s) => s.cameraDeviceId);
|
||||
const micPermissionDenied = useVoiceStore((s) => s.micPermissionDenied);
|
||||
const echoCancellation = useVoiceStore((s) => s.echoCancellation);
|
||||
const noiseSuppression = useVoiceStore((s) => s.noiseSuppression);
|
||||
const autoGainControl = useVoiceStore((s) => s.autoGainControl);
|
||||
@@ -388,6 +389,19 @@ export function useLiveKit() {
|
||||
const micPub = r.localParticipant.getTrackPublications()
|
||||
.find(p => p.source === Track.Source.Microphone);
|
||||
|
||||
// Mic permission denied — user joined as a listener. Tear down any
|
||||
// stale publication (defensive: should not exist on first join, but
|
||||
// covers the edge where permission was revoked mid-call) and skip
|
||||
// the publish branch entirely. Re-attempt is gated behind the
|
||||
// `requestMicPermission` user-gesture path which clears the flag
|
||||
// and triggers this effect to re-run via its dep.
|
||||
if (micPermissionDenied) {
|
||||
if (micPub?.track) {
|
||||
await r.localParticipant.unpublishTrack(micPub.track as LocalAudioTrack).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If effectively muted or deafened, mute the track in-place (keep it published)
|
||||
if (effectiveMuted || effectiveDeafened) {
|
||||
if (micPub?.track && !micPub.isMuted) {
|
||||
@@ -399,7 +413,24 @@ export function useLiveKit() {
|
||||
// Not muted — ensure the AudioManager pipeline is on the right device
|
||||
// and at the right volume, then republish if the published track is
|
||||
// stale or missing.
|
||||
try {
|
||||
await audioManager.setInputDevice(inputDeviceId);
|
||||
} catch (err: any) {
|
||||
// Late denial (e.g. iOS standalone PWA where the pre-arm in
|
||||
// `joinVoiceChannel` ran inside the gesture but the prompt is
|
||||
// delivered out-of-band; the user denies after `room.connect()`
|
||||
// has already succeeded). Promote to the listener-mode flag so
|
||||
// we don't loop forever trying to re-acquire.
|
||||
if (err?.name === 'NotAllowedError') {
|
||||
useVoiceStore.getState().setMicPermissionDenied(true);
|
||||
useUIStore.getState().addToast(
|
||||
'Microphone access denied. You joined as a listener — tap "Allow microphone" to grant access.',
|
||||
'warning',
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
audioManager.setInputVolume(inputVolume);
|
||||
await republishMicrophone(r, lastMicGenRef);
|
||||
} catch (err) {
|
||||
@@ -417,7 +448,7 @@ export function useLiveKit() {
|
||||
return () => {
|
||||
unsubscribeResume();
|
||||
};
|
||||
}, [isMuted, isDeafened, spaceMutedUserIds, spaceDeafenedUserIds, permissionMutedUserIds, inputDeviceId, inputVolume, isConnected, echoCancellation, noiseSuppression, autoGainControl, rnnoiseEnabled]);
|
||||
}, [isMuted, isDeafened, spaceMutedUserIds, spaceDeafenedUserIds, permissionMutedUserIds, inputDeviceId, inputVolume, isConnected, echoCancellation, noiseSuppression, autoGainControl, rnnoiseEnabled, micPermissionDenied]);
|
||||
|
||||
// Subscribe to upstream-input-track-end events from AudioManager whenever a
|
||||
// room is connected. The published mic track is a clone of a WebAudio
|
||||
|
||||
@@ -60,6 +60,16 @@ export interface VisualViewportInset {
|
||||
value: string;
|
||||
/** True if the soft keyboard is occluding the bottom of the layout viewport. */
|
||||
keyboardOpen: boolean;
|
||||
/**
|
||||
* True if a text-entry element currently has focus. Used as a focus-based
|
||||
* fallback signal for the iOS PWA case where `interactive-widget=resizes-content`
|
||||
* (or iOS's native standalone behavior) shrinks the layout viewport itself
|
||||
* for the keyboard — `vv.height` ends up matching `innerHeight`, so
|
||||
* `keyboardOpen` (which infers from height delta) stays false even though
|
||||
* the keyboard IS up. Consumers that need a "is the keyboard most likely
|
||||
* up" signal should OR `keyboardOpen || textInputFocused`.
|
||||
*/
|
||||
textInputFocused: boolean;
|
||||
/**
|
||||
* Live `visualViewport.height` in pixels, or `null` if `visualViewport` is
|
||||
* unavailable. Consumers that want to size a container to the visible
|
||||
@@ -77,12 +87,16 @@ export interface VisualViewportInset {
|
||||
const FALLBACK: VisualViewportInset = {
|
||||
value: 'env(safe-area-inset-bottom)',
|
||||
keyboardOpen: false,
|
||||
textInputFocused: false,
|
||||
height: null,
|
||||
offsetTop: null,
|
||||
};
|
||||
|
||||
export function useVisualViewportInset(): VisualViewportInset {
|
||||
const [inset, setInset] = useState<VisualViewportInset>(FALLBACK);
|
||||
// Mutable ref so `measure()` reads the latest focus state without React
|
||||
// re-renders racing the visualViewport update path.
|
||||
const textInputFocusedRef = { current: false } as { current: boolean };
|
||||
|
||||
useEffect(() => {
|
||||
const vv = window.visualViewport;
|
||||
@@ -104,12 +118,14 @@ export function useVisualViewportInset(): VisualViewportInset {
|
||||
? {
|
||||
value: `${Math.round(occlusion)}px`,
|
||||
keyboardOpen: true,
|
||||
textInputFocused: textInputFocusedRef.current,
|
||||
height: vv.height,
|
||||
offsetTop: vv.offsetTop,
|
||||
}
|
||||
: {
|
||||
value: 'env(safe-area-inset-bottom)',
|
||||
keyboardOpen: false,
|
||||
textInputFocused: textInputFocusedRef.current,
|
||||
height: vv.height,
|
||||
offsetTop: vv.offsetTop,
|
||||
};
|
||||
@@ -117,9 +133,24 @@ export function useVisualViewportInset(): VisualViewportInset {
|
||||
// Functional update + shallow compare so identical re-measurements
|
||||
// don't churn React state every animation frame during keyboard
|
||||
// transitions.
|
||||
//
|
||||
// ALL returned fields must be in this comparison, otherwise an
|
||||
// observable change to one of them is silently dropped — the historical
|
||||
// bug was `textInputFocused` being absent here. On iOS PWA standalone
|
||||
// the layout viewport is shrunk natively for the keyboard, so
|
||||
// `vv.height` matches `innerHeight` and `keyboardOpen`/`value` stay
|
||||
// unchanged across the transition. The only signal that flips is
|
||||
// `textInputFocused`. Without it in the compare, consumers
|
||||
// (`MessageInput`'s `--composer-clearance` effect, the composer's
|
||||
// inline `bottom` style) never see the focus change propagate, the
|
||||
// composer stayed pinned above the home indicator while the keyboard
|
||||
// was actually up, and on close the clearance variable was computed
|
||||
// off a stale `bottom` value — surfacing as a -4 px overlap between
|
||||
// the last message and the composer's top edge.
|
||||
setInset((prev) =>
|
||||
prev.value === next.value &&
|
||||
prev.keyboardOpen === next.keyboardOpen &&
|
||||
prev.textInputFocused === next.textInputFocused &&
|
||||
prev.height === next.height &&
|
||||
prev.offsetTop === next.offsetTop
|
||||
? prev
|
||||
@@ -187,6 +218,11 @@ export function useVisualViewportInset(): VisualViewportInset {
|
||||
tag === 'TEXTAREA' ||
|
||||
(t as HTMLElement).isContentEditable === true;
|
||||
if (!editable) return;
|
||||
// Track focus state — used as a fallback signal in the iOS PWA case
|
||||
// where `vv.height` doesn't shrink for the keyboard (because iOS
|
||||
// native-shifts the layout viewport instead). `focusin` → focused;
|
||||
// `focusout` → unfocused. Capture phase listener so we see all events.
|
||||
textInputFocusedRef.current = e.type === 'focusin';
|
||||
// Immediate measure + a polling window for laggy iOS PWA event flows.
|
||||
update();
|
||||
startPolling(600);
|
||||
|
||||
@@ -135,6 +135,16 @@ interface VoiceState {
|
||||
clearVoiceUsersForOrigin: (origin: string) => void;
|
||||
leaveVoice: () => void;
|
||||
handleForceDisconnect: () => void;
|
||||
// Mic permission state — set when getUserMedia rejects with NotAllowedError
|
||||
// (most commonly in iOS PWA standalone, where the gesture-window discipline
|
||||
// is strict). Consumers (`useLiveKit` syncMic) skip publishing the mic
|
||||
// track when this is true; the user appears in voice as a connected
|
||||
// participant who can hear others but is effectively muted at the source
|
||||
// (no track ever published, server cannot un-mute remotely). UI surfaces
|
||||
// a "Grant microphone access" affordance to retry from a fresh user
|
||||
// gesture; on success the flag clears and `useLiveKit` republishes.
|
||||
micPermissionDenied: boolean;
|
||||
setMicPermissionDenied: (denied: boolean) => void;
|
||||
// Gesture-aware connect/disconnect refs — registered by AppLayout from useLiveKit()
|
||||
connectFn: ((channelId: string, isDm?: boolean) => Promise<void>) | null;
|
||||
disconnectFn: (() => Promise<void>) | null;
|
||||
@@ -153,6 +163,7 @@ export const useVoiceStore = create<VoiceState>()(
|
||||
isDeafened: false,
|
||||
isCameraOn: false,
|
||||
isScreenSharing: false,
|
||||
micPermissionDenied: false,
|
||||
participants: [],
|
||||
speakingParticipantIds: new Set(),
|
||||
speakingUserIds: new Set(),
|
||||
@@ -533,6 +544,8 @@ export const useVoiceStore = create<VoiceState>()(
|
||||
spaceMutedUserIds: new Set(),
|
||||
spaceDeafenedUserIds: new Set(),
|
||||
permissionMutedUserIds: new Set(),
|
||||
// Mic permission gate
|
||||
micPermissionDenied: false,
|
||||
}),
|
||||
|
||||
clearVoiceUsersForOrigin: (origin: string) => {
|
||||
@@ -589,6 +602,10 @@ export const useVoiceStore = create<VoiceState>()(
|
||||
unwatchedCameras: new Set(),
|
||||
streamWatchers: new Map(),
|
||||
voiceUsers,
|
||||
// Reset mic permission flag on leave so the next join attempts a
|
||||
// fresh getUserMedia (the user may have granted permission via
|
||||
// OS settings while disconnected).
|
||||
micPermissionDenied: false,
|
||||
};
|
||||
});
|
||||
},
|
||||
@@ -599,6 +616,9 @@ export const useVoiceStore = create<VoiceState>()(
|
||||
setConnectFn: (fn) => set({ connectFn: fn }),
|
||||
setDisconnectFn: (fn) => set({ disconnectFn: fn }),
|
||||
|
||||
// Mic permission state setter — see interface comment.
|
||||
setMicPermissionDenied: (denied) => set({ micPermissionDenied: denied }),
|
||||
|
||||
// Force disconnect: clear local connection state but do NOT touch voiceUsers.
|
||||
// Used for involuntary disconnects (identity collision, server shutdown, etc.)
|
||||
// where the server is the authority on who's actually in voice.
|
||||
@@ -628,6 +648,7 @@ export const useVoiceStore = create<VoiceState>()(
|
||||
watchingStreams: new Set(),
|
||||
unwatchedCameras: new Set(),
|
||||
streamWatchers: new Map(),
|
||||
micPermissionDenied: false,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -669,6 +690,7 @@ export const useVoiceStore = create<VoiceState>()(
|
||||
spaceMutedUserIds: new Set(),
|
||||
spaceDeafenedUserIds: new Set(),
|
||||
permissionMutedUserIds: new Set(),
|
||||
micPermissionDenied: false,
|
||||
}),
|
||||
}),
|
||||
{
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useVoiceStore } from '../stores/voiceStore';
|
||||
import { getChannelOrigin, getMyUserIdForOrigin, useSpaceStore } from '../stores/spaceStore';
|
||||
import { wsSend } from '../hooks/useWebSocket';
|
||||
import { AudioManager } from '../audio/AudioManager';
|
||||
import { useUIStore } from '../stores/uiStore';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Effective-state helpers — single source of truth for broadcasts
|
||||
@@ -66,6 +68,30 @@ export function broadcastDeafenViaLiveKit(): void {
|
||||
* this sends an explicit voice_leave to Instance A first so it
|
||||
* broadcasts a leave event and the client cleans up stale voice state.
|
||||
*
|
||||
* **iOS user-gesture discipline.** `getUserMedia({audio:…})` is fired
|
||||
* synchronously here (before any await crosses the gesture boundary) so
|
||||
* iOS Safari surfaces the microphone permission prompt immediately on
|
||||
* the user's tap. The previous flow only acquired the mic in the
|
||||
* `useLiveKit` `syncMic` effect, which fires AFTER `room.connect()`
|
||||
* (token fetch + WS handshake) completes — many awaits past the
|
||||
* activation window. iOS PWA standalone is especially strict and would
|
||||
* silently never surface the prompt; the user appeared stuck on
|
||||
* "Waiting for others to join…" until they locked/unlocked the device,
|
||||
* which iOS treats as a fresh activation context that finally allowed
|
||||
* the queued prompt to surface.
|
||||
*
|
||||
* **Denial path.** If the user denies the prompt (NotAllowedError),
|
||||
* `voiceStore.micPermissionDenied` is set to true and we proceed with
|
||||
* the LiveKit connect anyway. The user appears in the voice channel
|
||||
* normally, can hear other participants, but no microphone track is
|
||||
* ever published — `useLiveKit.syncMic` skips the publish branch when
|
||||
* the flag is set. UI surfaces a "Grant microphone access" affordance
|
||||
* (`MobileVoiceFullScreen`, `VoiceControlBar`) that retries
|
||||
* `getUserMedia` from a fresh user gesture; on success the flag clears
|
||||
* and `useLiveKit.republishMicrophone` is called directly. The flag
|
||||
* resets to `false` automatically on `leaveVoice()` /
|
||||
* `handleForceDisconnect()` so a rejoin attempts a fresh prompt.
|
||||
*
|
||||
* @param channelId The channel to join.
|
||||
* @param connectFn The LiveKit connect function, obtained from
|
||||
* `useVoiceStore.getState().connectFn`. When provided the
|
||||
@@ -96,6 +122,49 @@ export function joinVoiceChannel(
|
||||
const myNewId = getMyUserIdForOrigin(getChannelOrigin(channelId));
|
||||
if (myNewId) addVoiceUser(channelId, myNewId);
|
||||
|
||||
// Pre-arm the microphone INSIDE the user-gesture context. This must
|
||||
// happen before `connectFn` so the call to `setInputDevice` (which is
|
||||
// routed through `inputSwitchChain.then(...)` and ends in
|
||||
// `getUserMedia`) is invoked while the activation window is still open.
|
||||
// Reset the prior denial flag so a re-attempt isn't pre-vetoed by
|
||||
// syncMic, and clear AudioManager's cached denial so the next
|
||||
// `getUserMedia` actually fires (rather than re-throwing the cached
|
||||
// error from a previous denial in this session).
|
||||
const voiceState = useVoiceStore.getState();
|
||||
voiceState.setMicPermissionDenied(false);
|
||||
const audioManager = AudioManager.getInstance();
|
||||
audioManager.clearInputDenial();
|
||||
// Resume the AudioContext synchronously inside the gesture too — iOS
|
||||
// requires `AudioContext.resume()` to be invoked from a user
|
||||
// activation. Fire-and-forget; `useLiveKit.connect` also calls this
|
||||
// and will await the same context.
|
||||
audioManager.resumeContext().catch((err) => {
|
||||
console.warn('[voice] AudioContext resume failed:', err);
|
||||
});
|
||||
// Fire-and-forget. `setInputDevice` is internally serialized via
|
||||
// `inputSwitchChain` so the later syncMic call short-circuits to the
|
||||
// already-acquired stream rather than re-prompting. On denial we
|
||||
// record the flag — syncMic will then skip the publish branch.
|
||||
audioManager.setInputDevice(voiceState.inputDeviceId).catch((err: unknown) => {
|
||||
const name = err instanceof Error ? err.name : '';
|
||||
if (name === 'NotAllowedError') {
|
||||
useVoiceStore.getState().setMicPermissionDenied(true);
|
||||
useUIStore.getState().addToast(
|
||||
'Microphone access denied. You joined as a listener — tap "Allow microphone" to grant access.',
|
||||
'warning',
|
||||
);
|
||||
} else if (name === 'NotFoundError') {
|
||||
// No mic hardware available. Proceed as listener.
|
||||
useVoiceStore.getState().setMicPermissionDenied(true);
|
||||
useUIStore.getState().addToast(
|
||||
'No microphone detected. You joined as a listener.',
|
||||
'info',
|
||||
);
|
||||
} else {
|
||||
console.error('[voice] Mic pre-arm failed:', err);
|
||||
}
|
||||
});
|
||||
|
||||
// Direct connection within gesture context
|
||||
if (connectFn) {
|
||||
connectFn(channelId).catch((err) => {
|
||||
@@ -105,3 +174,49 @@ export function joinVoiceChannel(
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-attempt microphone permission acquisition after a previous denial.
|
||||
* Must be called from a user-gesture handler (button click, etc.) for iOS
|
||||
* Safari to actually surface the permission prompt. On success, clears the
|
||||
* `micPermissionDenied` flag and the next `useLiveKit` syncMic tick (or an
|
||||
* external `republishMicrophone` call) publishes the freshly acquired
|
||||
* stream.
|
||||
*
|
||||
* Returns `true` when the mic was acquired, `false` on any error
|
||||
* (NotAllowedError, NotFoundError, etc.).
|
||||
*/
|
||||
export async function requestMicPermission(): Promise<boolean> {
|
||||
const audioManager = AudioManager.getInstance();
|
||||
const inputDeviceId = useVoiceStore.getState().inputDeviceId;
|
||||
// Clear AudioManager's cached denial so the next `setInputDevice` call
|
||||
// actually fires `getUserMedia` instead of re-throwing the cached error.
|
||||
audioManager.clearInputDenial();
|
||||
try {
|
||||
// Resume context first — iOS may have suspended it during the denied
|
||||
// state.
|
||||
await audioManager.resumeContext();
|
||||
await audioManager.setInputDevice(inputDeviceId);
|
||||
useVoiceStore.getState().setMicPermissionDenied(false);
|
||||
return true;
|
||||
} catch (err: unknown) {
|
||||
const name = err instanceof Error ? err.name : '';
|
||||
if (name === 'NotAllowedError') {
|
||||
useUIStore.getState().addToast(
|
||||
'Microphone permission still denied. Open Settings → Safari to grant access.',
|
||||
'warning',
|
||||
);
|
||||
} else if (name === 'NotFoundError') {
|
||||
useUIStore.getState().addToast(
|
||||
'No microphone detected.',
|
||||
'warning',
|
||||
);
|
||||
} else {
|
||||
useUIStore.getState().addToast(
|
||||
'Could not access the microphone.',
|
||||
'warning',
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user