feat(calls): redesign the DM voice-call rail with a loudspeaker toggle, live duration and explicit accept/decline, dropping the deafen control

This commit is contained in:
heaven 2026-06-05 01:07:08 +03:00
parent 0aaecbbe2e
commit 0ff06e577b
27 changed files with 980 additions and 528 deletions

View file

@ -127,15 +127,22 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- DM voice calls: mic + audio routing. Capacitor auto-requests at getUserMedia time. --> <!-- RECORD_AUDIO: DM voice calls; Capacitor auto-requests it at
getUserMedia time. MODIFY_AUDIO_SETTINGS authorizes loudspeaker/
earpiece routing (AudioManager.setCommunicationDevice /
setSpeakerphoneOn) — NOTE: that routing plugin is NOT yet
implemented (no AudioManager code exists today); the grant is
pre-declared for the planned speaker-toggle plugin. -->
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<!-- Required to unblock NotificationCompat.CallStyle on API 31+: NMS's <!-- Required for NotificationCompat.CallStyle on API 31+: NMS's
checkDisqualifyingFeatures rejects CallStyle notifications without checkDisqualifyingFeatures rejects CallStyle notifications without
FSI/FGS/UIJ, throwing IllegalArgumentException on its own handler FSI/FGS/UIJ. We DO call setFullScreenIntent(launchPI, true) in
thread (silent to the app). Declaring the permission flips VojoFirebaseMessagingService.postIncomingCallNotification (the
FLAG_FSI_REQUESTED_BUT_DENIED so the gate passes, even though we incoming-ring path) — that both satisfies the CallStyle gate and
never call setFullScreenIntent(). See ADR 2.5-heads-up. --> drives the over-lockscreen wakeup. On API 34+ the system also
requires the user-granted special app-op, surfaced to the user via
FullScreenIntentPlugin / FullScreenIntentPrompt. -->
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" /> <uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<!-- DM call lock-screen retention: CallForegroundService keeps the call <!-- DM call lock-screen retention: CallForegroundService keeps the call
process foregrounded under lock so AppOps doesn't revoke RECORD_AUDIO process foregrounded under lock so AppOps doesn't revoke RECORD_AUDIO

View file

@ -0,0 +1,170 @@
package chat.vojo.app;
import android.content.Context;
import android.media.AudioDeviceInfo;
import android.media.AudioManager;
import android.os.Build;
import android.util.Log;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import java.util.List;
/**
* JS Android bridge for in-call audio OUTPUT routing (loudspeaker earpiece)
* during a DM voice call.
*
* WHY THIS EXISTS. Call audio is owned by Chromium's WebRTC stack inside the
* Capacitor System WebView. For a getUserMedia voice call that stack puts the
* session in MODE_IN_COMMUNICATION and routes to the EARPIECE by default, and
* there is no in-WebView lever to move it the Audio Output Devices API
* (setSinkId / selectAudioOutput) is unimplemented on Android WebView. So the
* only way to give the user a «громкая связь / loudspeaker» toggle is to reach
* the platform AudioManager natively and flip the output device on top of the
* session the WebView already owns.
*
* COEXISTENCE RULE (critical). The WebView's WebRTC is the single owner of the
* audio session: it already called setMode(MODE_IN_COMMUNICATION) and acquired
* audio focus. This plugin therefore ONLY flips the output device it does NOT
* call setMode(), does NOT request audio focus, and does NOT start its own ADM.
* Two owners of the same route produce ghost echo / half-muted audio. Mirrors
* the route-only slice of element-android's DefaultAudioDeviceRouter without
* taking ownership of the session.
*
* API split:
* - API 31+ (S): speaker-on = AudioManager.setCommunicationDevice() to the
* TYPE_BUILTIN_SPEAKER from getAvailableCommunicationDevices(); speaker-off
* = clearCommunicationDevice() so the platform auto-routes to a connected
* headset (wired/BT/USB) or the earpiece. clearCommunicationDevice() also
* restores the platform default on call end.
* - API < 31: legacy AudioManager.setSpeakerphoneOn(boolean). Deprecated but
* the only option pre-S; works because the WebView already set
* MODE_IN_COMMUNICATION.
*
* NOTE (on-device verification pending): some OEM WebView builds resist
* app-side routing once they own the session. setSpeaker resolves with the
* route the plugin OBSERVES after the call (getRoute re-read), so the JS side
* can trust the resolved value rather than assuming the request took.
*/
@CapacitorPlugin(name = "AudioRoute")
public class AudioRoutePlugin extends Plugin {
private static final String TAG = "AudioRoute";
private AudioManager am() {
Context ctx = getContext();
if (ctx == null) return null;
return (AudioManager) ctx.getSystemService(Context.AUDIO_SERVICE);
}
/**
* setSpeaker({ on: boolean }) { speaker: boolean }
* Flips the call output to the built-in speaker (on) or earpiece (off).
* Resolves with the route observed AFTER the change so JS state tracks reality.
*/
@PluginMethod
public void setSpeaker(PluginCall call) {
Boolean on = call.getBoolean("on", Boolean.TRUE);
AudioManager audio = am();
if (audio == null) {
call.reject("no_audio_manager");
return;
}
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (Boolean.TRUE.equals(on)) {
AudioDeviceInfo speaker =
findCommunicationDevice(audio, AudioDeviceInfo.TYPE_BUILTIN_SPEAKER);
if (speaker != null) {
boolean ok = audio.setCommunicationDevice(speaker);
Log.d(TAG, "setCommunicationDevice speaker ok=" + ok);
} else {
Log.w(TAG, "no builtin speaker device");
}
} else {
// Speaker OFF: hand routing back to the platform rather than
// forcing TYPE_BUILTIN_EARPIECE. Auto-selection prefers a
// connected wired / Bluetooth / USB headset and falls back
// to the earpiece forcing the earpiece would yank audio
// off a headset the user is actually wearing.
audio.clearCommunicationDevice();
Log.d(TAG, "clearCommunicationDevice (speaker off -> headset/earpiece)");
}
} else {
// Legacy: relies on the WebView having set MODE_IN_COMMUNICATION.
// setSpeakerphoneOn(false) lets the system keep a wired headset.
audio.setSpeakerphoneOn(Boolean.TRUE.equals(on));
Log.d(TAG, "setSpeakerphoneOn " + on);
}
} catch (Throwable t) {
Log.e(TAG, "setSpeaker failed", t);
call.reject("set_speaker_failed: " + t.getClass().getSimpleName());
return;
}
JSObject ret = new JSObject();
ret.put("speaker", isSpeakerOn(audio));
call.resolve(ret);
}
/**
* getRoute() { speaker: boolean }
* Reads the currently active output route.
*/
@PluginMethod
public void getRoute(PluginCall call) {
AudioManager audio = am();
if (audio == null) {
call.reject("no_audio_manager");
return;
}
JSObject ret = new JSObject();
ret.put("speaker", isSpeakerOn(audio));
call.resolve(ret);
}
/**
* clear() void
* Restores the platform-default communication route on call end so the
* next call / app doesn't inherit a forced speaker. Mandatory teardown.
*/
@PluginMethod
public void clear(PluginCall call) {
AudioManager audio = am();
if (audio == null) {
call.resolve();
return;
}
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
audio.clearCommunicationDevice();
} else {
audio.setSpeakerphoneOn(false);
}
Log.d(TAG, "clear: route restored to default");
} catch (Throwable t) {
Log.w(TAG, "clear failed", t);
}
call.resolve();
}
private static AudioDeviceInfo findCommunicationDevice(AudioManager audio, int type) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return null;
List<AudioDeviceInfo> devices = audio.getAvailableCommunicationDevices();
for (AudioDeviceInfo dev : devices) {
if (dev.getType() == type) return dev;
}
return null;
}
private static boolean isSpeakerOn(AudioManager audio) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
AudioDeviceInfo cur = audio.getCommunicationDevice();
return cur != null && cur.getType() == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER;
}
return audio.isSpeakerphoneOn();
}
}

View file

@ -67,6 +67,7 @@ public class MainActivity extends BridgeActivity {
// super.onCreate would make the plugin invisible to JS until the next relaunch. // super.onCreate would make the plugin invisible to JS until the next relaunch.
registerPlugin(FullScreenIntentPlugin.class); registerPlugin(FullScreenIntentPlugin.class);
registerPlugin(CallForegroundPlugin.class); registerPlugin(CallForegroundPlugin.class);
registerPlugin(AudioRoutePlugin.class);
registerPlugin(LaunchSplashPlugin.class); registerPlugin(LaunchSplashPlugin.class);
registerPlugin(ShareTargetPlugin.class); registerPlugin(ShareTargetPlugin.class);
registerPlugin(PollingPlugin.class); registerPlugin(PollingPlugin.class);

View file

@ -425,15 +425,20 @@
"start": "Start call", "start": "Start call",
"join": "Join call", "join": "Join call",
"unavailable": "Calls are unavailable", "unavailable": "Calls are unavailable",
"busy_other_room": "You are already in a call",
"incoming": "Incoming call…", "incoming": "Incoming call…",
"answer": "Answer", "incoming_label": "Incoming call",
"accept": "Accept",
"decline": "Decline", "decline": "Decline",
"unknown_caller": "Unknown caller", "unknown_caller": "Unknown caller",
"ctl_mic": "Mic",
"ctl_speaker": "Speaker",
"ctl_camera": "Camera",
"ctl_screen": "Screen",
"ctl_end": "End",
"mic_off": "Turn Off Microphone", "mic_off": "Turn Off Microphone",
"mic_on": "Turn On Microphone", "mic_on": "Turn On Microphone",
"sound_off": "Turn Off Sound", "speaker_off": "Turn Off Speaker",
"sound_on": "Turn On Sound", "speaker_on": "Turn On Speaker",
"camera_off": "Stop Camera", "camera_off": "Stop Camera",
"camera_on": "Start Camera", "camera_on": "Start Camera",
"screenshare_off": "Stop Screenshare", "screenshare_off": "Stop Screenshare",
@ -444,6 +449,7 @@
"in_call": "In call", "in_call": "In call",
"in_call_count": "{{count}} in call", "in_call_count": "{{count}} in call",
"connecting": "Connecting…", "connecting": "Connecting…",
"calling": "Calling…",
"open_call_room": "Open call room", "open_call_room": "Open call room",
"bubble_outgoing": "Outgoing call", "bubble_outgoing": "Outgoing call",
"bubble_incoming": "Incoming call", "bubble_incoming": "Incoming call",

View file

@ -429,15 +429,20 @@
"start": "Позвонить", "start": "Позвонить",
"join": "Присоединиться", "join": "Присоединиться",
"unavailable": "Звонки недоступны", "unavailable": "Звонки недоступны",
"busy_other_room": "Вы уже в другом звонке",
"incoming": "Входящий звонок…", "incoming": "Входящий звонок…",
"answer": "Ответить", "incoming_label": "Входящий звонок",
"accept": "Принять",
"decline": "Отклонить", "decline": "Отклонить",
"unknown_caller": "Неизвестный абонент", "unknown_caller": "Неизвестный абонент",
"ctl_mic": "Микрофон",
"ctl_speaker": "Динамик",
"ctl_camera": "Камера",
"ctl_screen": "Экран",
"ctl_end": "Завершить",
"mic_off": "Выключить микрофон", "mic_off": "Выключить микрофон",
"mic_on": "Включить микрофон", "mic_on": "Включить микрофон",
"sound_off": "Выключить звук", "speaker_off": "Выключить громкую связь",
"sound_on": "Включить звук", "speaker_on": "Включить громкую связь",
"camera_off": "Выключить камеру", "camera_off": "Выключить камеру",
"camera_on": "Включить камеру", "camera_on": "Включить камеру",
"screenshare_off": "Остановить показ экрана", "screenshare_off": "Остановить показ экрана",
@ -448,6 +453,7 @@
"in_call": "В звонке", "in_call": "В звонке",
"in_call_count": "{{count}} в звонке", "in_call_count": "{{count}} в звонке",
"connecting": "Соединение…", "connecting": "Соединение…",
"calling": "Вызов…",
"open_call_room": "Открыть чат звонка", "open_call_room": "Открыть чат звонка",
"bubble_outgoing": "Исходящий звонок", "bubble_outgoing": "Исходящий звонок",
"bubble_incoming": "Входящий звонок", "bubble_incoming": "Входящий звонок",

View file

@ -7,7 +7,6 @@ import {
useCallHangupEvent, useCallHangupEvent,
useCallJoined, useCallJoined,
useCallThemeSync, useCallThemeSync,
useCallMemberSoundSync,
} from '../hooks/useCallEmbed'; } from '../hooks/useCallEmbed';
import { callChatAtom, callEmbedAtom } from '../state/callEmbed'; import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
import { CallEmbed } from '../plugins/call'; import { CallEmbed } from '../plugins/call';
@ -19,7 +18,6 @@ function CallUtils({ embed }: { embed: CallEmbed }) {
const setCallEmbed = useSetAtom(callEmbedAtom); const setCallEmbed = useSetAtom(callEmbedAtom);
const store = useStore(); const store = useStore();
useCallMemberSoundSync(embed);
useCallThemeSync(embed); useCallThemeSync(embed);
const clearIfCurrent = useCallback(() => { const clearIfCurrent = useCallback(() => {
if (store.get(callEmbedAtom) !== embed) return; if (store.get(callEmbedAtom) !== embed) return;

View file

@ -0,0 +1,68 @@
import React from 'react';
import classNames from 'classnames';
import { Icon, IconSrc, Spinner } from 'folds';
import * as css from './styles.css';
export type CallActionTone = 'neutral' | 'accent' | 'muted' | 'danger';
const discToneClass: Record<CallActionTone, string | undefined> = {
neutral: undefined,
accent: css.CallActionDiscAccent,
muted: css.CallActionDiscMuted,
danger: css.CallActionDiscDanger,
};
type CallActionButtonProps = {
icon: IconSrc;
label: string;
// Spoken/long-form action (e.g. «Выключить микрофон») for screen readers;
// the visible `label` stays a short noun.
ariaLabel?: string;
tone?: CallActionTone;
disabled?: boolean;
busy?: boolean;
// Compact = web row: smaller disc, caption hidden (kept as the tooltip/aria).
compact?: boolean;
onClick: () => void;
};
// Vojo "Dawn" call-control affordance: a round icon disc with a caption below,
// in place of the stock folds IconButton + tooltip. Tone drives the disc
// colour (neutral / accent-on / muted / destructive).
export function CallActionButton({
icon,
label,
ariaLabel,
tone = 'neutral',
disabled,
busy,
compact,
onClick,
}: CallActionButtonProps) {
return (
<button
type="button"
className={classNames(css.CallActionButton, compact && css.CallActionButtonCompact)}
onClick={onClick}
disabled={disabled}
aria-label={ariaLabel ?? label}
title={compact ? ariaLabel ?? label : undefined}
>
<span
className={classNames(
css.CallActionDisc,
compact && css.CallActionDiscCompact,
discToneClass[tone]
)}
aria-hidden
>
{busy ? (
<Spinner size="200" variant="Secondary" />
) : (
<Icon size={compact ? '300' : '400'} src={icon} />
)}
</span>
{!compact && <span className={css.CallActionLabel}>{label}</span>}
</button>
);
}

View file

@ -1,21 +1,23 @@
import { Box, Icon, IconButton, IconSrc, Spinner, Text, Tooltip, TooltipProvider } from 'folds';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { useSetAtom, useStore } from 'jotai'; import { useSetAtom, useStore } from 'jotai';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { CallEmbed, useCallControlState } from '../../plugins/call'; import { CallEmbed, useCallControlState } from '../../plugins/call';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { callEmbedAtom } from '../../state/callEmbed'; import { callEmbedAtom } from '../../state/callEmbed';
import * as css from './styles.css';
import { CallActionButton } from './CallActionButton';
import { import {
CallHeadphoneIcon,
CallHeadphoneMuteIcon,
CallMicIcon, CallMicIcon,
CallMicMuteIcon, CallMicMuteIcon,
CallPhoneDownIcon, CallPhoneDownIcon,
CallScreenShareIcon, CallScreenShareIcon,
CallScreenShareMuteIcon, CallScreenShareMuteIcon,
CallSpeakerIcon,
CallSpeakerMuteIcon,
CallVideoIcon, CallVideoIcon,
CallVideoMuteIcon, CallVideoMuteIcon,
} from './callIcons'; } from './callIcons';
import { useCallSpeaker } from '../../hooks/useCallSpeaker';
// Fail-open window for widget hangup ack. If the widget is healthy, its // Fail-open window for widget hangup ack. If the widget is healthy, its
// im.vector.hangup / io.element.close action lands within a second and // im.vector.hangup / io.element.close action lands within a second and
@ -25,75 +27,18 @@ import {
// here to match the mobile-ish feel of our DM flow and PEER_LEAVE_GRACE_MS). // here to match the mobile-ish feel of our DM flow and PEER_LEAVE_GRACE_MS).
const HANGUP_TIMEOUT_MS = 8000; const HANGUP_TIMEOUT_MS = 8000;
// All controls share the same shape so the active-call pill reads as a
// single visual family with the IncomingCallStrip Decline/Answer buttons:
// pill-radius, soft fill, size 500 (~52-56px). Variant changes with state
// (Surface = on, Warning = muted/off, Critical = hangup, Success =
// camera/screenshare on).
const BUTTON_SIZE = '500' as const;
const BUTTON_RADII = 'Pill' as const;
const BUTTON_FILL = 'Soft' as const;
const ICON_SIZE = '300' as const;
type ToggleButtonProps = {
enabled: boolean;
onToggle: () => void | Promise<unknown>;
disabled?: boolean;
iconOn: IconSrc;
iconOff: IconSrc;
labelOn: string;
labelOff: string;
variantOn?: 'Surface' | 'Success';
variantOff?: 'Warning' | 'Surface';
};
function ToggleButton({
enabled,
onToggle,
disabled,
iconOn,
iconOff,
labelOn,
labelOff,
variantOn = 'Surface',
variantOff = 'Warning',
}: ToggleButtonProps) {
return (
<TooltipProvider
position="Top"
tooltip={
<Tooltip>
<Text size="T200">{enabled ? labelOn : labelOff}</Text>
</Tooltip>
}
>
{(anchorRef) => (
<IconButton
ref={anchorRef}
variant={enabled ? variantOn : variantOff}
fill={BUTTON_FILL}
radii={BUTTON_RADII}
size={BUTTON_SIZE}
onClick={() => onToggle()}
disabled={disabled}
aria-label={enabled ? labelOn : labelOff}
>
<Icon size={ICON_SIZE} src={enabled ? iconOn : iconOff} />
</IconButton>
)}
</TooltipProvider>
);
}
type CallControlProps = { type CallControlProps = {
callEmbed: CallEmbed; callEmbed: CallEmbed;
compact: boolean; compact: boolean;
callJoined: boolean; callJoined: boolean;
// native = phone app → big labelled discs; web → compact icon buttons.
native: boolean;
}; };
export function CallControl({ callEmbed, compact, callJoined }: CallControlProps) { export function CallControl({ callEmbed, compact, callJoined, native }: CallControlProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control); const { microphone, video, screenshare } = useCallControlState(callEmbed.control);
const { speaker, toggle: toggleSpeaker, available: speakerAvailable } = useCallSpeaker();
const setCallEmbed = useSetAtom(callEmbedAtom); const setCallEmbed = useSetAtom(callEmbedAtom);
const store = useStore(); const store = useStore();
@ -131,87 +76,68 @@ export function CallControl({ callEmbed, compact, callJoined }: CallControlProps
hangup(); hangup();
}; };
// Visibility mirrors the legacy strip: Mic + Sound always render, // Mic always renders. Speaker (громкая связь) only where the native route
// Video joins when the call isn't voice-only (still shown on mobile — // plugin can act (Android). Video joins when the call isn't voice-only —
// users want to be able to flip the camera on a phone), Screenshare // still shown on mobile so users can flip the camera on a phone; Screenshare
// is desktop-only because mobile WebRTC capture is meaningfully // is desktop-only because mobile WebRTC capture is gated natively by EC.
// different and Element Call gates it natively.
const showVideo = !callEmbed.voiceOnly; const showVideo = !callEmbed.voiceOnly;
const showScreenshare = !compact && !callEmbed.voiceOnly; const showScreenshare = !compact && !callEmbed.voiceOnly;
const buttonCompact = !native;
return ( return (
<Box shrink="No" alignItems="Center" gap="200"> <div className={native ? css.CallActions : css.CallActionsCompact}>
<ToggleButton <CallActionButton
enabled={microphone} icon={microphone ? CallMicIcon : CallMicMuteIcon}
onToggle={() => callEmbed.control.toggleMicrophone()} label={t('Call.ctl_mic')}
ariaLabel={microphone ? t('Call.mic_off') : t('Call.mic_on')}
tone={microphone ? 'neutral' : 'muted'}
disabled={!callJoined} disabled={!callJoined}
iconOn={CallMicIcon} compact={buttonCompact}
iconOff={CallMicMuteIcon} onClick={() => callEmbed.control.toggleMicrophone()}
labelOn={t('Call.mic_off')}
labelOff={t('Call.mic_on')}
/> />
<ToggleButton {speakerAvailable && (
enabled={sound} <CallActionButton
onToggle={() => callEmbed.control.toggleSound()} icon={speaker ? CallSpeakerIcon : CallSpeakerMuteIcon}
label={t('Call.ctl_speaker')}
ariaLabel={speaker ? t('Call.speaker_off') : t('Call.speaker_on')}
tone={speaker ? 'accent' : 'neutral'}
disabled={!callJoined} disabled={!callJoined}
iconOn={CallHeadphoneIcon} compact={buttonCompact}
iconOff={CallHeadphoneMuteIcon} onClick={toggleSpeaker}
labelOn={t('Call.sound_off')}
labelOff={t('Call.sound_on')}
/> />
)}
{showVideo && ( {showVideo && (
<ToggleButton <CallActionButton
enabled={video} icon={video ? CallVideoIcon : CallVideoMuteIcon}
onToggle={() => callEmbed.control.toggleVideo()} label={t('Call.ctl_camera')}
ariaLabel={video ? t('Call.camera_off') : t('Call.camera_on')}
tone={video ? 'accent' : 'neutral'}
disabled={!callJoined} disabled={!callJoined}
iconOn={CallVideoIcon} compact={buttonCompact}
iconOff={CallVideoMuteIcon} onClick={() => callEmbed.control.toggleVideo()}
labelOn={t('Call.camera_off')}
labelOff={t('Call.camera_on')}
variantOn="Success"
variantOff="Surface"
/> />
)} )}
{showScreenshare && ( {showScreenshare && (
<ToggleButton <CallActionButton
enabled={screenshare} icon={screenshare ? CallScreenShareIcon : CallScreenShareMuteIcon}
onToggle={() => callEmbed.control.toggleScreenshare()} label={t('Call.ctl_screen')}
ariaLabel={screenshare ? t('Call.screenshare_off') : t('Call.screenshare_on')}
tone={screenshare ? 'accent' : 'neutral'}
disabled={!callJoined} disabled={!callJoined}
iconOn={CallScreenShareIcon} compact={buttonCompact}
iconOff={CallScreenShareMuteIcon} onClick={() => callEmbed.control.toggleScreenshare()}
labelOn={t('Call.screenshare_off')}
labelOff={t('Call.screenshare_on')}
variantOn="Success"
variantOff="Surface"
/> />
)} )}
<TooltipProvider <CallActionButton
position="Top" icon={CallPhoneDownIcon}
tooltip={ label={t('Call.ctl_end')}
<Tooltip> ariaLabel={t('Call.end_call')}
<Text size="T200">{t('Call.end_call')}</Text> tone="danger"
</Tooltip> busy={exiting}
}
>
{(anchorRef) => (
<IconButton
ref={anchorRef}
variant="Critical"
fill={BUTTON_FILL}
radii={BUTTON_RADII}
size={BUTTON_SIZE}
onClick={handleHangup}
disabled={exiting} disabled={exiting}
aria-label={t('Call.end_call')} compact={buttonCompact}
> onClick={handleHangup}
{exiting ? ( />
<Spinner variant="Critical" fill="Soft" size="200" /> </div>
) : (
<Icon size={ICON_SIZE} src={CallPhoneDownIcon} />
)}
</IconButton>
)}
</TooltipProvider>
</Box>
); );
} }

View file

@ -1,26 +1,23 @@
import React from 'react'; import React, { useEffect, useState } from 'react';
import { Avatar, Box, Icon, Icons, Text } from 'folds'; import { Avatar, Box, Icon, Icons, Text } from 'folds';
import classNames from 'classnames'; import classNames from 'classnames';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import * as css from './styles.css'; import * as css from './styles.css';
import { CallControl } from './CallControl'; import { CallControl } from './CallControl';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { useCallMembers, useCallSession } from '../../hooks/useCall'; import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { CallEmbed } from '../../plugins/call/CallEmbed'; import { CallEmbed } from '../../plugins/call/CallEmbed';
import { useCallJoined } from '../../hooks/useCallEmbed'; import { useCallJoined } from '../../hooks/useCallEmbed';
import { useCallDuration, formatCallTimer } from '../../hooks/useCallDuration';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useRoomAvatar, useRoomName } from '../../hooks/useRoomMeta'; import { useRoomAvatar, useRoomName } from '../../hooks/useRoomMeta';
import { useRoomNavigate } from '../../hooks/useRoomNavigate'; import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { isNativePlatform } from '../../utils/capacitor';
import { UserAvatar } from '../../components/user-avatar'; import { UserAvatar } from '../../components/user-avatar';
import { RoomAvatar } from '../../components/room-avatar'; import { RoomAvatar } from '../../components/room-avatar';
import { getMemberDisplayName } from '../../utils/room'; import { getMemberDisplayName } from '../../utils/room';
import { import { getMxIdLocalPart, guessDmRoomUserId, mxcUrlToHttp } from '../../utils/matrix';
getMxIdLocalPart,
guessDmRoomUserId,
mxcUrlToHttp,
} from '../../utils/matrix';
type CallStatusProps = { type CallStatusProps = {
callEmbed: CallEmbed; callEmbed: CallEmbed;
@ -62,45 +59,47 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
// Defensive fallback chain — empty roomName + unresolved peer would // Defensive fallback chain — empty roomName + unresolved peer would
// otherwise collapse the H4 to "" and leave the pill anonymous. // otherwise collapse the H4 to "" and leave the pill anonymous.
const displayName = const displayName = (isOneOnOne && peerName) || roomName || peerUserId || room.roomId;
(isOneOnOne && peerName) ||
roomName ||
peerUserId ||
room.roomId;
// Sub-text is the live state: «Соединение…» until the widget reports // Sub-text walks the real call lifecycle so the pill reads like a phone
// joined, then either «В звонке» (1:1) or «N в звонке» (group). The // call: «Соединение…» until the widget reports joined → «Вызов…» while we
// pulsing dot is bound to `callJoined` so the user gets instant // wait for the other side to pick up (nobody else is live yet) → a live
// visual confirmation that LiveKit is up. // m:ss timer once both parties are connected. Group calls keep the «N в
// звонке» count and append the timer. The pulsing dot tracks `callJoined`.
//
// `everConnected` latches once ≥2 members are live and never un-latches for
// the life of this pill (the renderer keys it by roomId, so a new call gets
// a fresh latch). This keeps the timer counting through a transient peer
// drop/rejoin (LiveKit reconnect / key rotation) instead of snapping back to
// «Вызов…» and resetting to 0:00 — the call itself survives such blips via
// `useCallerAutoHangup`'s peer-leave grace.
const memberCount = callMembers.length; const memberCount = callMembers.length;
const [everConnected, setEverConnected] = useState(false);
useEffect(() => {
if (callJoined && memberCount >= 2) setEverConnected(true);
}, [callJoined, memberCount]);
const connected = callJoined && everConnected;
const duration = useCallDuration(connected);
const timer = duration !== null ? formatCallTimer(duration) : '';
let subText: string; let subText: string;
if (!callJoined) { if (!callJoined) {
subText = t('Call.connecting'); subText = t('Call.connecting');
} else if (isOneOnOne || memberCount <= 1) { } else if (!everConnected) {
subText = t('Call.in_call'); subText = t('Call.calling');
} else if (isOneOnOne) {
subText = timer || t('Call.in_call');
} else { } else {
subText = t('Call.in_call_count', { count: memberCount }); subText = timer
? `${t('Call.in_call_count', { count: memberCount })} · ${timer}`
: t('Call.in_call_count', { count: memberCount });
} }
return ( const native = isNativePlatform();
<Box
className={classNames(css.CallStatus, ContainerColor({ variant: 'Background' }))} const avatar = (
shrink="No" <Avatar className={native ? css.CallAvatar : css.CallAvatarCompact}>
direction="Row"
alignItems="Center"
gap="400"
>
<Box
as="button"
type="button"
grow="Yes"
alignItems="Center"
gap="400"
className={css.CallIdentityButton}
onClick={() => navigateRoom(room.roomId)}
aria-label={t('Call.open_call_room')}
>
<Avatar className={css.RingAvatar}>
{isOneOnOne ? ( {isOneOnOne ? (
<UserAvatar <UserAvatar
userId={peerUserId ?? ''} userId={peerUserId ?? ''}
@ -117,24 +116,66 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
/> />
)} )}
</Avatar> </Avatar>
<Box grow="Yes" direction="Column" gap="100" alignItems="Start"> );
const nameAndState = (
<Box
grow={native ? undefined : 'Yes'}
direction="Column"
gap="100"
alignItems={native ? 'Center' : 'Start'}
style={{ minWidth: 0, maxWidth: '100%' }}
>
<Text size="H4" truncate> <Text size="H4" truncate>
{displayName} {displayName}
</Text> </Text>
<Box alignItems="Center" gap="200"> <span className={css.StateLine}>
<span <span className={classNames(css.LiveDot, callJoined && css.LiveDotPulsing)} aria-hidden />
className={classNames(css.LiveDot, callJoined && css.LiveDotPulsing)} <Text as="span" size="T200" priority="300" truncate className={css.Timer}>
aria-hidden
/>
<Text size="T200" priority="300" truncate>
{subText} {subText}
</Text> </Text>
</span>
</Box> </Box>
</Box> );
</Box>
<Box shrink="No" alignItems="Center" gap="200"> // Native (phone): tall vertical Dawn card. Web (desktop): the original
<CallControl callJoined={callJoined} compact={compact} callEmbed={callEmbed} /> // compact horizontal row, same size as before.
</Box> if (native) {
return (
<Box className={classNames(css.CallCard, css.CallCardNative)} shrink="No" direction="Column">
<div className={css.CallCardInner}>
<button
type="button"
className={css.CallIdentityButton}
onClick={() => navigateRoom(room.roomId)}
aria-label={t('Call.open_call_room')}
>
{avatar}
{nameAndState}
</button>
<CallControl native callJoined={callJoined} compact={compact} callEmbed={callEmbed} />
</div>
</Box>
);
}
return (
<Box
className={classNames(css.CallCard, css.CallCardCompact)}
shrink="No"
direction="Row"
alignItems="Center"
>
<button
type="button"
className={css.CallIdentityButtonCompact}
onClick={() => navigateRoom(room.roomId)}
aria-label={t('Call.open_call_room')}
>
{avatar}
{nameAndState}
</button>
<CallControl native={false} callJoined={callJoined} compact={compact} callEmbed={callEmbed} />
</Box> </Box>
); );
} }

View file

@ -1,12 +1,12 @@
import React from 'react'; import React from 'react';
import { Avatar, Box, Icon, IconButton, Icons, Text, Tooltip, TooltipProvider } from 'folds'; import { Avatar, Box, Button, Icon, Icons, Text } from 'folds';
import classNames from 'classnames'; import classNames from 'classnames';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Room } from 'matrix-js-sdk'; import { Room } from 'matrix-js-sdk';
import { useSetAtom } from 'jotai'; import { useSetAtom } from 'jotai';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import * as css from './styles.css'; import * as css from './styles.css';
import { ContainerColor } from '../../styles/ContainerColor.css'; import { isNativePlatform } from '../../utils/capacitor';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { UserAvatar } from '../../components/user-avatar'; import { UserAvatar } from '../../components/user-avatar';
@ -85,15 +85,43 @@ export function IncomingCallStrip({ call, room }: IncomingCallStripProps) {
} }
}; };
return ( const native = isNativePlatform();
<Box
className={classNames(css.RingRow, ContainerColor({ variant: 'Background' }))} const buttons = (
shrink="No" <div className={classNames(css.RingButtons, native && css.RingButtonsFull)}>
direction="Row" <Button
alignItems="Center" className={native ? css.RingButtonGrow : undefined}
gap="500" variant="Success"
fill="Solid"
size={native ? '500' : '400'}
radii="400"
onClick={handleAnswer}
before={<Icon size="200" src={CallPhoneIcon} filled />}
>
<Text size={native ? 'B500' : 'B400'} truncate>
{t('Call.accept')}
</Text>
</Button>
<Button
className={native ? css.RingButtonGrow : undefined}
variant="Critical"
fill="Soft"
size={native ? '500' : '400'}
radii="400"
onClick={handleDecline}
before={<Icon size="200" src={CallPhoneDownIcon} />}
>
<Text size={native ? 'B500' : 'B400'} truncate>
{t('Call.decline')}
</Text>
</Button>
</div>
);
const avatar = (
<Avatar
className={classNames(native ? css.CallAvatar : css.CallAvatarCompact, css.CallerPulse)}
> >
<Avatar className={css.RingAvatar}>
<UserAvatar <UserAvatar
userId={senderId ?? ''} userId={senderId ?? ''}
src={avatarUrl} src={avatarUrl}
@ -101,60 +129,48 @@ export function IncomingCallStrip({ call, room }: IncomingCallStripProps) {
renderFallback={() => <Icon size="400" src={Icons.User} filled />} renderFallback={() => <Icon size="400" src={Icons.User} filled />}
/> />
</Avatar> </Avatar>
<Box grow="Yes" direction="Column" gap="100"> );
// Native (phone): tall vertical card with a full-width Decline/Accept pair.
// Web (desktop): the original compact row, same size as before.
if (native) {
return (
<Box className={classNames(css.CallCard, css.CallCardNative)} shrink="No" direction="Column">
<div className={css.CallCardInner}>
<div className={css.CallIdentity}>
{avatar}
<Box direction="Column" gap="100" alignItems="Center" style={{ maxWidth: '100%' }}>
<Text size="H4" truncate> <Text size="H4" truncate>
{displayName} {displayName}
</Text> </Text>
<Text size="T200" priority="300"> <span className={css.Eyebrow}>{t('Call.incoming_label')}</span>
{t('Call.incoming')}
</Text>
</Box>
<Box shrink="No" alignItems="Center" gap="300">
<TooltipProvider
position="Top"
tooltip={
<Tooltip>
<Text size="T200">{t('Call.decline')}</Text>
</Tooltip>
}
>
{(anchorRef) => (
<IconButton
ref={anchorRef}
variant="Critical"
fill="Soft"
radii="Pill"
size="500"
onClick={handleDecline}
aria-label={t('Call.decline')}
>
<Icon size="300" src={CallPhoneDownIcon} />
</IconButton>
)}
</TooltipProvider>
<TooltipProvider
position="Top"
tooltip={
<Tooltip>
<Text size="T200">{t('Call.answer')}</Text>
</Tooltip>
}
>
{(anchorRef) => (
<IconButton
ref={anchorRef}
variant="Success"
fill="Soft"
radii="Pill"
size="500"
onClick={handleAnswer}
aria-label={t('Call.answer')}
>
<Icon size="300" src={CallPhoneIcon} />
</IconButton>
)}
</TooltipProvider>
</Box> </Box>
</div>
{buttons}
</div>
</Box>
);
}
return (
<Box
className={classNames(css.CallCard, css.CallCardCompact)}
shrink="No"
direction="Row"
alignItems="Center"
>
<div className={css.CallIdentityCompact}>
{avatar}
<Box grow="Yes" direction="Column" gap="100" style={{ minWidth: 0 }}>
<Text size="H4" truncate>
{displayName}
</Text>
<Text size="T200" priority="300" truncate>
{t('Call.incoming')}
</Text>
</Box>
</div>
{buttons}
</Box> </Box>
); );
} }

View file

@ -57,22 +57,20 @@ export const CallMicMuteIcon: IconSrc = () => (
</> </>
); );
export const CallHeadphoneIcon: IconSrc = () => ( // Loudspeaker «on» — cone + two sound waves. Paired with the variant
// flip (Success when active) so «громкая связь» reads at a glance.
export const CallSpeakerIcon: IconSrc = () => (
<> <>
<path d="M4 14a8 8 0 0 1 16 0" {...STROKE} /> <path d="M4 9h3l5-4v14l-5-4H4z" {...STROKE} />
<path d="M4 14v4a2 2 0 0 0 2 2h2v-7H6a2 2 0 0 0-2 2z" {...STROKE} /> <path d="M16 9a4 4 0 0 1 0 6" {...STROKE} />
<path d="M20 14v4a2 2 0 0 1-2 2h-2v-7h2a2 2 0 0 1 2 2z" {...STROKE} /> <path d="M18.5 6.5a8 8 0 0 1 0 11" {...STROKE} />
</> </>
); );
export const CallHeadphoneMuteIcon: IconSrc = () => ( // Speaker «off» (earpiece) — the same cone with the sound waves dropped,
<> // not a slash: audio still plays, just through the earpiece. The neutral
<path d="M4 14a8 8 0 0 1 16 0" {...STROKE} /> // Surface variant carries the «inactive» cue.
<path d="M4 14v4a2 2 0 0 0 2 2h2v-7H6a2 2 0 0 0-2 2z" {...STROKE} /> export const CallSpeakerMuteIcon: IconSrc = () => <path d="M4 9h3l5-4v14l-5-4H4z" {...STROKE} />;
<path d="M20 14v4a2 2 0 0 1-2 2h-2v-7h2a2 2 0 0 1 2 2z" {...STROKE} />
<path d="M3 3l18 18" {...SLASH} />
</>
);
export const CallVideoIcon: IconSrc = () => ( export const CallVideoIcon: IconSrc = () => (
<> <>
@ -124,8 +122,15 @@ export const CallPhoneIcon: IconSrc = () => (
// Same handset rotated 135° — universal «hang up / decline» glyph // Same handset rotated 135° — universal «hang up / decline» glyph
// (handset tilted off the cradle). Reused for Decline (IncomingCallStrip) // (handset tilted off the cradle). Reused for Decline (IncomingCallStrip)
// and Hangup (CallControl). // and Hangup (CallControl).
//
// The source handset fills a ~18×18 box whose centre sits at (12, 13), not
// (12, 12). Rotating it 135° about (12, 12) therefore (a) drifts the glyph
// off-centre and (b) pushes the rotated box (~25px diagonal) past the 24×24
// viewBox, so the corners clip. Fix: recentre the box on the viewBox centre,
// rotate, then scale to 0.82 so the rotated glyph fits with stroke margin.
// (SVG applies the transform list right-to-left.)
export const CallPhoneDownIcon: IconSrc = () => ( export const CallPhoneDownIcon: IconSrc = () => (
<g transform="rotate(135 12 12)"> <g transform="translate(12 12) rotate(135) scale(0.82) translate(-12 -13)">
<path <path
d="M5 4h4l2 5-3 2a12 12 0 0 0 6 6l2-3 5 2v4a2 2 0 0 1-2 2A17 17 0 0 1 3 6a2 2 0 0 1 2-2z" d="M5 4h4l2 5-3 2a12 12 0 0 0 6 6l2-3 5 2v4a2 2 0 0 1-2 2A17 17 0 0 1 3 6a2 2 0 0 1 2-2z"
{...STROKE} {...STROKE}

View file

@ -1,74 +1,147 @@
import { globalStyle, keyframes, style } from '@vanilla-extract/css'; import { globalStyle, keyframes, style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds'; import { color, config, toRem } from 'folds';
// === Incoming-call row + active-call pill === // === Vojo "Dawn" call rail ===
// //
// Both live inside the bottom horseshoe rail owned by // Both the incoming-ring card and the active-call card live inside the bottom
// `HorseshoeContainer`. The rail paints the rounded shell — the rows // horseshoe rail owned by `HorseshoeContainer`. The surface is EXACTLY the
// themselves stay flat, with hairline dividers between adjacent rows so // message-composer surface — `color.Surface.Container` (DAWN.bg2 #0d0e11 dark
// stacked entries (multiple concurrent rings, or a ring above the // / #ffffff light), the same token `StreamBubble` and the chat composer bind
// active-call pill) read as separate items inside the same card. // to (see src/index.css "matches the composer card"). Text colour is pinned to
// `Surface.OnContainer` so the caller name stays legible in both themes.
//
// Two layouts share that surface:
// * NATIVE (phone app) — a tall vertical card: avatar + name + state on top,
// big labelled round actions below.
// * WEB (desktop) — the original compact horizontal row, unchanged in size.
export const RingRow = style({ // Shared card surface (composer background + theme-correct text colour).
padding: toRem(20), export const CallCard = style({
backgroundColor: color.Surface.Container,
color: color.Surface.OnContainer,
}); });
export const RingAvatar = style({ // Hairline divider between two stacked cards (a ring above the active pill, or
width: toRem(64), // two concurrent rings) so they read as separate items inside one sheet.
height: toRem(64), globalStyle(`${CallCard} + ${CallCard}`, {
flexShrink: 0, borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
}); });
// Active-call pill. Vertical padding is intentionally smaller than // --- Native: tall vertical card ---
// `RingRow` (12 vs 20) — the pill is a secondary, persistent surface export const CallCardNative = style({
// and shouldn't visually outweigh an active ring row stacked above it. padding: `${config.space.S500} ${config.space.S400} ${config.space.S600}`,
// No border-top of its own; the divider is lifted to the cross-row
// `globalStyle` blocks below so each row is responsible only for its
// own padding.
export const CallStatus = style({
padding: `${toRem(12)} ${toRem(20)}`,
});
// Identity tap-target inside the pill — wraps the avatar+name+sub-text
// region into a single button that navigates back to the call room.
// Restores the affordance the legacy `CallRoomName` chip used to carry.
// Strips default `<button>` chrome so the visual is identical to a
// plain Box, but keeps native focus-ring + keyboard activation.
export const CallIdentityButton = style({
display: 'flex', display: 'flex',
flex: 1, flexDirection: 'column',
alignItems: 'center',
});
export const CallCardInner = style({
width: '100%',
maxWidth: toRem(460),
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: config.space.S500,
});
// --- Web: compact horizontal row (original size) ---
export const CallCardCompact = style({
padding: `${toRem(14)} ${toRem(20)}`,
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: config.space.S400,
});
// Identity block (native: stacked + centred).
export const CallIdentity = style({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: config.space.S300,
textAlign: 'center',
width: '100%',
minWidth: 0, minWidth: 0,
});
const identityButtonReset = {
background: 'transparent', background: 'transparent',
border: 'none', border: 'none',
padding: 0, padding: 0,
textAlign: 'left',
cursor: 'pointer', cursor: 'pointer',
color: 'inherit', color: 'inherit',
font: 'inherit', font: 'inherit',
};
export const CallIdentityButton = style([CallIdentity, identityButtonReset]);
// Identity block (web compact: avatar left, text column right, fills the row).
export const CallIdentityCompact = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: config.space.S300,
flexGrow: 1,
minWidth: 0,
}); });
// Hairline dividers between adjacent rows in the rail. `globalStyle` is export const CallIdentityButtonCompact = style([
// used for cross-class adjacency because vanilla-extract's `selectors` CallIdentityCompact,
// key requires `&` to be the *matched* element, which forbids selectors { ...identityButtonReset, textAlign: 'left' },
// like `${RingRow} + ${CallStatus}` from a single style block. ]);
//
// Order assumption: in the rail, RingRow rows precede CallStatus (see
// `HorseshoeContainer.tsx`). If a future contributor reorders these
// — or introduces a new row type — re-audit the divider rules below.
const rowDivider = `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`;
globalStyle(`${RingRow} + ${RingRow}`, { export const CallAvatar = style({
borderTop: rowDivider, width: toRem(72),
height: toRem(72),
flexShrink: 0,
}); });
globalStyle(`${RingRow} + ${CallStatus}`, { export const CallAvatarCompact = style({
borderTop: rowDivider, width: toRem(56),
height: toRem(56),
flexShrink: 0,
}); });
// Live indicator next to the active-call sub-text. Static red dot until // Pulsing ring on the incoming-call avatar — a soft teal halo that expands and
// the widget reports `joined`, then a subtle pulse — same visual // fades, the «ringing right now» cue. Matches the rail's orbit accent.
// language as the LiveKit SDK's «we're really live» state. Reduced const callerPulse = keyframes({
// motion stops the pulse but keeps the dot visible. '0%': { boxShadow: '0 0 0 0 rgba(91, 227, 197, 0.45)' },
'70%': { boxShadow: `0 0 0 ${toRem(14)} rgba(91, 227, 197, 0)` },
'100%': { boxShadow: '0 0 0 0 rgba(91, 227, 197, 0)' },
});
export const CallerPulse = style({
borderRadius: '50%',
animation: `${callerPulse} 1.8s ease-out infinite`,
'@media': {
'(prefers-reduced-motion: reduce)': {
animation: 'none',
},
},
});
// Small uppercase eyebrow under the name (Dawn label convention, native card).
export const Eyebrow = style({
textTransform: 'uppercase',
letterSpacing: '0.1em',
fontSize: toRem(11),
fontWeight: config.fontWeight.W600,
opacity: 0.55,
});
// State line: live dot + status text (Соединение / Вызов / m:ss timer).
export const StateLine = style({
display: 'flex',
alignItems: 'center',
gap: config.space.S200,
minWidth: 0,
});
export const Timer = style({
fontVariantNumeric: 'tabular-nums',
});
// Live indicator next to the active-call state.
const livePulse = keyframes({ const livePulse = keyframes({
'0%, 100%': { opacity: 1, transform: 'scale(1)' }, '0%, 100%': { opacity: 1, transform: 'scale(1)' },
'50%': { opacity: 0.55, transform: 'scale(0.85)' }, '50%': { opacity: 0.55, transform: 'scale(0.85)' },
@ -78,7 +151,7 @@ export const LiveDot = style({
width: toRem(8), width: toRem(8),
height: toRem(8), height: toRem(8),
borderRadius: '50%', borderRadius: '50%',
backgroundColor: color.Critical.Main, backgroundColor: color.Success.Main,
flexShrink: 0, flexShrink: 0,
}); });
@ -90,3 +163,121 @@ export const LiveDotPulsing = style({
}, },
}, },
}); });
// === Actions row + Vojo control buttons ===
// Native: centred row of round labelled controls, wrapping if a group call
// surfaces more than fits one line.
export const CallActions = style({
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'center',
gap: config.space.S500,
flexWrap: 'wrap',
width: '100%',
});
// Web: compact controls cluster on the right of the row.
export const CallActionsCompact = style({
display: 'flex',
alignItems: 'center',
gap: config.space.S200,
flexShrink: 0,
});
// A single control: round icon disc + caption below. Replaces the stock folds
// IconButton with the Vojo call-screen affordance.
export const CallActionButton = style({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: toRem(7),
background: 'transparent',
border: 'none',
padding: 0,
cursor: 'pointer',
font: 'inherit',
color: 'inherit',
width: toRem(72),
selectors: {
'&:disabled': {
opacity: 0.4,
cursor: 'default',
},
},
});
// Compact variant for the web row: no caption, narrower hit-target.
export const CallActionButtonCompact = style({
width: 'auto',
});
const DISC = toRem(56);
const DISC_COMPACT = toRem(42);
export const CallActionDisc = style({
width: DISC,
height: DISC,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: color.SurfaceVariant.ContainerActive,
color: color.SurfaceVariant.OnContainer,
transition: 'background-color 140ms ease, color 140ms ease, transform 90ms ease',
selectors: {
[`${CallActionButton}:active:not(:disabled) &`]: {
transform: 'scale(0.94)',
},
},
});
export const CallActionDiscCompact = style({
width: DISC_COMPACT,
height: DISC_COMPACT,
});
// Active toggle (loudspeaker on, camera on, screenshare on) — Vojo accent.
export const CallActionDiscAccent = style({
backgroundColor: color.Primary.Main,
color: color.Primary.OnMain,
});
// Muted state (mic off) — attention tint so the user notices they're muted.
export const CallActionDiscMuted = style({
backgroundColor: color.Critical.Container,
color: color.Critical.Main,
});
// Destructive (end / hangup) — solid red.
export const CallActionDiscDanger = style({
backgroundColor: color.Critical.Main,
color: color.Critical.OnMain,
});
export const CallActionLabel = style({
fontSize: toRem(11),
fontWeight: config.fontWeight.W500,
whiteSpace: 'nowrap',
opacity: 0.75,
});
// Big explicit answer / decline buttons (incoming card). Full-width pair on
// native; compact auto-width on web. Uses folds Button, so colours come from
// the Success / Critical variants — these just own the layout.
export const RingButtons = style({
display: 'flex',
alignItems: 'center',
gap: config.space.S300,
flexShrink: 0,
});
// Native: stretch the pair across the card.
export const RingButtonsFull = style({
width: '100%',
});
export const RingButtonGrow = style({
flexGrow: 1,
flexBasis: 0,
});

View file

@ -22,7 +22,6 @@ import {
ControlDivider, ControlDivider,
MicrophoneButton, MicrophoneButton,
ScreenShareButton, ScreenShareButton,
SoundButton,
VideoButton, VideoButton,
} from './Controls'; } from './Controls';
import { CallEmbed, useCallControlState } from '../../plugins/call'; import { CallEmbed, useCallControlState } from '../../plugins/call';
@ -46,9 +45,8 @@ export function CallControls({ callEmbed }: CallControlsProps) {
useCallback(() => controlRef.current, []) useCallback(() => controlRef.current, [])
); );
const { microphone, video, sound, screenshare, spotlight } = useCallControlState( const { microphone, video, screenshare, spotlight } = useCallControlState(callEmbed.control);
callEmbed.control const { voiceOnly } = callEmbed;
);
const [cords, setCords] = useState<RectCords>(); const [cords, setCords] = useState<RectCords>();
@ -98,8 +96,9 @@ export function CallControls({ callEmbed }: CallControlsProps) {
enabled={microphone} enabled={microphone}
onToggle={() => callEmbed.control.toggleMicrophone()} onToggle={() => callEmbed.control.toggleMicrophone()}
/> />
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
</Box> </Box>
{!voiceOnly && (
<>
{!compact && <ControlDivider />} {!compact && <ControlDivider />}
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200"> <Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
<VideoButton enabled={video} onToggle={() => callEmbed.control.toggleVideo()} /> <VideoButton enabled={video} onToggle={() => callEmbed.control.toggleVideo()} />
@ -108,6 +107,8 @@ export function CallControls({ callEmbed }: CallControlsProps) {
onToggle={() => callEmbed.control.toggleScreenshare()} onToggle={() => callEmbed.control.toggleScreenshare()}
/> />
</Box> </Box>
</>
)}
</Box> </Box>
{!compact && <ControlDivider />} {!compact && <ControlDivider />}
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}> <Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
@ -130,6 +131,8 @@ export function CallControls({ callEmbed }: CallControlsProps) {
> >
<Menu> <Menu>
<Box direction="Column" style={{ padding: config.space.S100 }}> <Box direction="Column" style={{ padding: config.space.S100 }}>
{!voiceOnly && (
<>
<MenuItem <MenuItem
size="300" size="300"
variant="Surface" variant="Surface"
@ -150,6 +153,8 @@ export function CallControls({ callEmbed }: CallControlsProps) {
Reactions Reactions
</Text> </Text>
</MenuItem> </MenuItem>
</>
)}
<MenuItem <MenuItem
size="300" size="300"
variant="Surface" variant="Surface"

View file

@ -44,43 +44,6 @@ export function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) {
); );
} }
type SoundButtonProps = {
enabled: boolean;
onToggle: () => void;
};
export function SoundButton({ enabled, onToggle }: SoundButtonProps) {
const { t } = useTranslation();
return (
<TooltipProvider
position="Top"
delay={500}
tooltip={
<Tooltip>
<Text size="T200">{enabled ? t('Call.sound_off') : t('Call.sound_on')}</Text>
</Tooltip>
}
>
{(anchorRef) => (
<IconButton
ref={anchorRef}
variant={enabled ? 'Surface' : 'Warning'}
fill="Soft"
radii="400"
size="400"
onClick={() => onToggle()}
outlined
>
<Icon
size="400"
src={enabled ? Icons.Headphone : Icons.HeadphoneMute}
filled={!enabled}
/>
</IconButton>
)}
</TooltipProvider>
);
}
type VideoButtonProps = { type VideoButtonProps = {
enabled: boolean; enabled: boolean;
onToggle: () => void; onToggle: () => void;

View file

@ -2,7 +2,7 @@ import React from 'react';
import { Box, Button, Icon, Icons, Spinner, Text } from 'folds'; import { Box, Button, Icon, Icons, Spinner, Text } from 'folds';
import { SequenceCard } from '../../components/sequence-card'; import { SequenceCard } from '../../components/sequence-card';
import * as css from './styles.css'; import * as css from './styles.css';
import { ChatButton, ControlDivider, MicrophoneButton, SoundButton, VideoButton } from './Controls'; import { ChatButton, ControlDivider, MicrophoneButton, VideoButton } from './Controls';
import { useIsOneOnOne, useRoom } from '../../hooks/useRoom'; import { useIsOneOnOne, useRoom } from '../../hooks/useRoom';
import { useCallEmbed, useCallJoined, useCallStart } from '../../hooks/useCallEmbed'; import { useCallEmbed, useCallJoined, useCallStart } from '../../hooks/useCallEmbed';
import { useCallPreferences } from '../../state/hooks/callPreferences'; import { useCallPreferences } from '../../state/hooks/callPreferences';
@ -23,8 +23,7 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) {
const disabled = inOtherCall || !canJoin; const disabled = inOtherCall || !canJoin;
const { microphone, video, sound, toggleMicrophone, toggleVideo, toggleSound } = const { microphone, video, toggleMicrophone, toggleVideo } = useCallPreferences();
useCallPreferences();
return ( return (
<SequenceCard <SequenceCard
@ -38,18 +37,17 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) {
> >
<Box shrink="No" alignItems="Inherit" justifyContent="SpaceBetween" gap="200"> <Box shrink="No" alignItems="Inherit" justifyContent="SpaceBetween" gap="200">
<MicrophoneButton enabled={microphone} onToggle={toggleMicrophone} /> <MicrophoneButton enabled={microphone} onToggle={toggleMicrophone} />
<SoundButton enabled={sound} onToggle={toggleSound} />
</Box> </Box>
<ControlDivider /> <ControlDivider />
<Box shrink="No" alignItems="Inherit" justifyContent="SpaceBetween" gap="200"> <Box shrink="No" alignItems="Inherit" justifyContent="SpaceBetween" gap="200">
<VideoButton enabled={video} onToggle={toggleVideo} /> {!isOneOnOne && <VideoButton enabled={video} onToggle={toggleVideo} />}
<ChatButton /> <ChatButton />
</Box> </Box>
<Box grow="Yes" direction="Column"> <Box grow="Yes" direction="Column">
<Button <Button
variant={disabled ? 'Secondary' : 'Success'} variant={disabled ? 'Secondary' : 'Success'}
fill={disabled ? 'Soft' : 'Solid'} fill={disabled ? 'Soft' : 'Solid'}
onClick={() => startCall(room, { microphone, video, sound })} onClick={() => startCall(room, { microphone, video })}
disabled={disabled || joining} disabled={disabled || joining}
before={ before={
joining ? ( joining ? (

View file

@ -24,15 +24,17 @@
// Android-only. // Android-only.
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useAtomValue } from 'jotai'; import { useAtomValue, useSetAtom } from 'jotai';
import { callEmbedAtom } from '../state/callEmbed'; import { callEmbedAtom, callSpeakerAtom } from '../state/callEmbed';
import { callForegroundService } from '../plugins/call/callForegroundService'; import { callForegroundService } from '../plugins/call/callForegroundService';
import { callAudioRoute } from '../plugins/call/callAudioRoute';
import { useCallJoined } from './useCallEmbed'; import { useCallJoined } from './useCallEmbed';
import { isAndroidPlatform } from '../utils/capacitor'; import { isAndroidPlatform } from '../utils/capacitor';
export const useAndroidCallForegroundSync = (): void => { export const useAndroidCallForegroundSync = (): void => {
const callEmbed = useAtomValue(callEmbedAtom); const callEmbed = useAtomValue(callEmbedAtom);
const joined = useCallJoined(callEmbed); const joined = useCallJoined(callEmbed);
const setSpeaker = useSetAtom(callSpeakerAtom);
useEffect(() => { useEffect(() => {
if (!isAndroidPlatform()) return undefined; if (!isAndroidPlatform()) return undefined;
@ -48,6 +50,11 @@ export const useAndroidCallForegroundSync = (): void => {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.warn('[call-fgs] stop failed', err); console.warn('[call-fgs] stop failed', err);
}); });
// Restore the platform-default audio route so the next call starts on
// the earpiece and no other app inherits a forced loudspeaker. The
// WebView owns the audio session; we only undo our output override.
callAudioRoute.clear();
setSpeaker(false);
}; };
}, [joined]); }, [joined, setSpeaker]);
}; };

View file

@ -0,0 +1,49 @@
import { useEffect, useRef, useState } from 'react';
// Live call timer. Starts ticking the moment `active` first turns true and
// returns elapsed milliseconds, or null while inactive. The start instant is
// latched in a ref so re-renders while `active` stays true don't restart the
// clock; it DOES reset to null when `active` goes false. Keeping `active`
// stable across a transient connection blip (so the timer doesn't reset
// mid-call) is the caller's job — see CallStatus's `everConnected` latch.
export const useCallDuration = (active: boolean): number | null => {
const [elapsed, setElapsed] = useState<number | null>(null);
const startRef = useRef<number | null>(null);
useEffect(() => {
if (!active) {
startRef.current = null;
setElapsed(null);
return undefined;
}
if (startRef.current === null) {
startRef.current = Date.now();
}
const tick = () => {
if (startRef.current !== null) {
setElapsed(Date.now() - startRef.current);
}
};
tick();
const id = window.setInterval(tick, 1000);
return () => window.clearInterval(id);
}, [active]);
return elapsed;
};
// Compact monospace-friendly call timer: `m:ss` under an hour, `h:mm:ss` over.
// Messenger convention (Telegram/WhatsApp) — not the verbose "N min N sec"
// used for the settled call-log bubble.
export const formatCallTimer = (ms: number): string => {
const total = Math.max(0, Math.floor(ms / 1000));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const seconds = total % 60;
const ss = seconds.toString().padStart(2, '0');
if (hours > 0) {
const mm = minutes.toString().padStart(2, '0');
return `${hours}:${mm}:${ss}`;
}
return `${minutes}:${ss}`;
};

View file

@ -12,7 +12,6 @@ import { ThemeKind, useTheme } from './useTheme';
import { callEmbedAtom } from '../state/callEmbed'; import { callEmbedAtom } from '../state/callEmbed';
import { useResizeObserver } from './useResizeObserver'; import { useResizeObserver } from './useResizeObserver';
import { CallControlState } from '../plugins/call/CallControlState'; import { CallControlState } from '../plugins/call/CallControlState';
import { useCallMembersChange, useCallSession } from './useCall';
import { CallPreferences } from '../state/callPreferences'; import { CallPreferences } from '../state/callPreferences';
const CallEmbedContext = createContext<CallEmbed | undefined>(undefined); const CallEmbedContext = createContext<CallEmbed | undefined>(undefined);
@ -50,7 +49,7 @@ export const createCallEmbed = (
const intent = CallEmbed.getIntent(dm, ongoing, voiceOnly); const intent = CallEmbed.getIntent(dm, ongoing, voiceOnly);
const widget = CallEmbed.getWidget(mx, room, intent, themeKind); const widget = CallEmbed.getWidget(mx, room, intent, themeKind);
const controlState = const controlState =
pref && new CallControlState(pref.microphone, voiceOnly ? false : pref.video, pref.sound); pref && new CallControlState(pref.microphone, voiceOnly ? false : pref.video);
const embed = new CallEmbed(mx, room, widget, container, controlState, voiceOnly); const embed = new CallEmbed(mx, room, widget, container, controlState, voiceOnly);
@ -107,14 +106,6 @@ export const useCallCloseEvent = (embed: CallEmbed, callback: () => void) => {
useClientWidgetApiEvent(embed.call, ElementWidgetActions.Close, callback); useClientWidgetApiEvent(embed.call, ElementWidgetActions.Close, callback);
}; };
export const useCallMemberSoundSync = (embed: CallEmbed) => {
const callSession = useCallSession(embed.room);
useCallMembersChange(
callSession,
useCallback(() => embed.control.applySound(), [embed])
);
};
export const useCallThemeSync = (embed: CallEmbed) => { export const useCallThemeSync = (embed: CallEmbed) => {
const theme = useTheme(); const theme = useTheme();

View file

@ -0,0 +1,28 @@
import { useAtom } from 'jotai';
import { useCallback } from 'react';
import { callSpeakerAtom } from '../state/callEmbed';
import { callAudioRoute } from '../plugins/call/callAudioRoute';
// In-call loudspeaker ⇄ earpiece toggle, backed by the native AudioRoute
// plugin. The atom holds the UI truth; `toggle` optimistically flips it then
// reconciles to the route the native side actually reports (an OEM WebView may
// refuse the request). `available` is false on web / iOS, where the OS owns
// output routing and the UI hides the control.
export const useCallSpeaker = (): {
speaker: boolean;
toggle: () => void;
available: boolean;
} => {
const [speaker, setSpeaker] = useAtom(callSpeakerAtom);
const toggle = useCallback(() => {
const next = !speaker;
setSpeaker(next);
callAudioRoute.setSpeaker(next).then(
(actual) => setSpeaker(actual),
() => undefined
);
}, [speaker, setSpeaker]);
return { speaker, toggle, available: callAudioRoute.available() };
};

View file

@ -1,16 +0,0 @@
import { useCallback } from 'react';
import { useSwitchOrStartDmCall } from './useSwitchOrStartDmCall';
export const useDmCallStart = () => {
const switchOrStartDmCall = useSwitchOrStartDmCall();
return useCallback(
(roomId: string) => {
switchOrStartDmCall(roomId).catch((err: unknown) => {
// eslint-disable-next-line no-console
console.warn('[dm-call] switch/start failed', err);
});
},
[switchOrStartDmCall]
);
};

View file

@ -30,5 +30,9 @@ export function CallStatusRenderer() {
const callEmbed = useCallEmbed(); const callEmbed = useCallEmbed();
if (!visible || !callEmbed) return null; if (!visible || !callEmbed) return null;
return <CallStatus callEmbed={callEmbed} />; // Key by roomId so a room-A→room-B call switch (the embed swaps without
// passing through undefined, so useCallJoined's `!embed` reset never fires)
// remounts the pill with fresh joined / duration state instead of carrying
// call A's "joined" + timer into call B's pre-join window.
return <CallStatus key={callEmbed.roomId} callEmbed={callEmbed} />;
} }

View file

@ -80,10 +80,6 @@ export class CallControl extends EventEmitter implements CallControlState {
return this.state.video; return this.state.video;
} }
public get sound(): boolean {
return this.state.sound;
}
public get screenshare(): boolean { public get screenshare(): boolean {
return this.state.screenshare; return this.state.screenshare;
} }
@ -97,7 +93,6 @@ export class CallControl extends EventEmitter implements CallControlState {
audio_enabled: this.microphone, audio_enabled: this.microphone,
video_enabled: this.video, video_enabled: this.video,
}); });
this.setSound(this.sound);
this.emitStateUpdate(); this.emitStateUpdate();
} }
@ -121,24 +116,10 @@ export class CallControl extends EventEmitter implements CallControlState {
this.onControlMutation(); this.onControlMutation();
} }
public applySound() {
this.setSound(this.sound);
}
private setMediaState(state: ElementMediaStatePayload) { private setMediaState(state: ElementMediaStatePayload) {
return this.call.transport.send(ElementWidgetActions.DeviceMute, state); return this.call.transport.send(ElementWidgetActions.DeviceMute, state);
} }
private setSound(sound: boolean): void {
const callDocument = this.iframe.contentDocument ?? this.iframe.contentWindow?.document;
if (callDocument) {
callDocument.querySelectorAll('audio').forEach((el) => {
// eslint-disable-next-line no-param-reassign
el.muted = !sound;
});
}
}
public onMediaState(evt: CustomEvent<ElementMediaStateDetail>) { public onMediaState(evt: CustomEvent<ElementMediaStateDetail>) {
const { data } = evt.detail; const { data } = evt.detail;
if (!data) return; if (!data) return;
@ -146,30 +127,19 @@ export class CallControl extends EventEmitter implements CallControlState {
const state = new CallControlState( const state = new CallControlState(
data.audio_enabled ?? this.microphone, data.audio_enabled ?? this.microphone,
data.video_enabled ?? this.video, data.video_enabled ?? this.video,
this.sound,
this.screenshare, this.screenshare,
this.spotlight this.spotlight
); );
this.state = state; this.state = state;
this.emitStateUpdate(); this.emitStateUpdate();
if (this.microphone && !this.sound) {
this.toggleSound();
}
} }
public onControlMutation() { public onControlMutation() {
const screenshare: boolean = this.screenshareButton?.getAttribute('data-kind') === 'primary'; const screenshare: boolean = this.screenshareButton?.getAttribute('data-kind') === 'primary';
const spotlight: boolean = this.spotlightButton?.checked ?? false; const spotlight: boolean = this.spotlightButton?.checked ?? false;
this.state = new CallControlState( this.state = new CallControlState(this.microphone, this.video, screenshare, spotlight);
this.microphone,
this.video,
this.sound,
screenshare,
spotlight
);
this.emitStateUpdate(); this.emitStateUpdate();
} }
@ -189,26 +159,6 @@ export class CallControl extends EventEmitter implements CallControlState {
return this.setMediaState(payload); return this.setMediaState(payload);
} }
public toggleSound() {
const sound = !this.sound;
this.setSound(sound);
const state = new CallControlState(
this.microphone,
this.video,
sound,
this.screenshare,
this.spotlight
);
this.state = state;
this.emitStateUpdate();
if (!this.sound && this.microphone) {
this.toggleMicrophone();
}
}
public toggleScreenshare() { public toggleScreenshare() {
this.screenshareButton?.click(); this.screenshareButton?.click();
} }

View file

@ -3,22 +3,13 @@ export class CallControlState {
public readonly video: boolean; public readonly video: boolean;
public readonly sound: boolean;
public readonly screenshare: boolean; public readonly screenshare: boolean;
public readonly spotlight: boolean; public readonly spotlight: boolean;
constructor( constructor(microphone: boolean, video: boolean, screenshare = false, spotlight = false) {
microphone: boolean,
video: boolean,
sound: boolean,
screenshare = false,
spotlight = false
) {
this.microphone = microphone; this.microphone = microphone;
this.video = video; this.video = video;
this.sound = sound;
this.screenshare = screenshare; this.screenshare = screenshare;
this.spotlight = spotlight; this.spotlight = spotlight;
} }

View file

@ -0,0 +1,61 @@
// Typed wrapper around the native AudioRoute Capacitor plugin.
//
// The plugin (AudioRoutePlugin.java) flips the in-call audio OUTPUT between the
// loudspeaker and the earpiece during a DM voice call. This is the ONLY way to
// offer a «громкая связь» toggle: call audio is owned by the WebView's WebRTC
// stack, which routes to the earpiece by default with no in-WebView lever
// (setSinkId / selectAudioOutput are unimplemented on Android WebView).
//
// Android-only. On web / iOS the OS handles output routing, so every method is
// a no-op and `available()` returns false (the UI hides the toggle there).
import { registerPlugin } from '@capacitor/core';
import { isAndroidPlatform } from '../../utils/capacitor';
interface AudioRoutePlugin {
setSpeaker(options: { on: boolean }): Promise<{ speaker: boolean }>;
getRoute(): Promise<{ speaker: boolean }>;
clear(): Promise<void>;
}
const plugin = registerPlugin<AudioRoutePlugin>('AudioRoute');
export const callAudioRoute = {
// Whether an in-app speaker/earpiece toggle can do anything on this platform.
available(): boolean {
return isAndroidPlatform();
},
// Flip the call output to speaker (on) or earpiece (off). Resolves with the
// route the native side OBSERVES afterwards — some OEM WebViews resist
// app-side routing, so JS trusts the resolved value, not the request.
async setSpeaker(on: boolean): Promise<boolean> {
if (!isAndroidPlatform()) return on;
try {
const res = await plugin.setSpeaker({ on });
return res.speaker;
} catch {
// Plugin missing / threw — report the requested state so the UI doesn't
// get stuck, but the actual route is whatever WebRTC chose.
return on;
}
},
// Read the currently active output route (true = loudspeaker).
async getRoute(): Promise<boolean> {
if (!isAndroidPlatform()) return false;
try {
const res = await plugin.getRoute();
return res.speaker;
} catch {
return false;
}
},
// Restore the platform-default route. MUST be called on every call teardown
// (hangup / remote hangup / error) or the next call inherits a forced route.
clear(): Promise<void> {
if (!isAndroidPlatform()) return Promise.resolve();
return plugin.clear().catch(() => undefined);
},
};

View file

@ -18,3 +18,9 @@ export const callEmbedAtom = atom<CallEmbed | undefined, [CallEmbed | undefined]
); );
export const callChatAtom = atom<boolean>(false); export const callChatAtom = atom<boolean>(false);
// In-call loudspeaker state (true = громкая связь / speaker, false = earpiece).
// Android-only — driven by `useCallSpeaker` via the native AudioRoute plugin;
// reset to earpiece on every call teardown. Default false because a 1:1 voice
// call should start on the earpiece, like a phone call.
export const callSpeakerAtom = atom<boolean>(false);

View file

@ -8,7 +8,6 @@ import {
export type CallPreferences = { export type CallPreferences = {
microphone: boolean; microphone: boolean;
video: boolean; video: boolean;
sound: boolean;
}; };
const CALL_PREFERENCES = 'callPreferences'; const CALL_PREFERENCES = 'callPreferences';
@ -16,7 +15,6 @@ const CALL_PREFERENCES = 'callPreferences';
const DEFAULT_PREFERENCES: CallPreferences = { const DEFAULT_PREFERENCES: CallPreferences = {
microphone: true, microphone: true,
video: false, video: false,
sound: true,
}; };
export type CallPreferencesAtom = WritableAtom<CallPreferences, [CallPreferences], undefined>; export type CallPreferencesAtom = WritableAtom<CallPreferences, [CallPreferences], undefined>;

View file

@ -17,38 +17,21 @@ export const useCallPreferencesAtom = (): CallPreferencesAtom => {
export const useCallPreferences = (): CallPreferences & { export const useCallPreferences = (): CallPreferences & {
toggleMicrophone: () => void; toggleMicrophone: () => void;
toggleVideo: () => void; toggleVideo: () => void;
toggleSound: () => void;
} => { } => {
const callPrefAtom = useCallPreferencesAtom(); const callPrefAtom = useCallPreferencesAtom();
const [pref, setPref] = useAtom(callPrefAtom); const [pref, setPref] = useAtom(callPrefAtom);
const toggleMicrophone = useCallback(() => { const toggleMicrophone = useCallback(() => {
const microphone = !pref.microphone;
setPref({ setPref({
microphone, microphone: !pref.microphone,
video: pref.video, video: pref.video,
sound: !pref.sound && microphone ? true : pref.sound,
}); });
}, [setPref, pref]); }, [setPref, pref]);
const toggleVideo = useCallback(() => { const toggleVideo = useCallback(() => {
const video = !pref.video;
setPref({ setPref({
microphone: pref.microphone, microphone: pref.microphone,
video, video: !pref.video,
sound: pref.sound,
});
}, [setPref, pref]);
const toggleSound = useCallback(() => {
const sound = !pref.sound;
setPref({
microphone: !sound ? false : pref.microphone,
video: pref.video,
sound,
}); });
}, [setPref, pref]); }, [setPref, pref]);
@ -56,6 +39,5 @@ export const useCallPreferences = (): CallPreferences & {
...pref, ...pref,
toggleMicrophone, toggleMicrophone,
toggleVideo, toggleVideo,
toggleSound,
}; };
}; };