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:
parent
0aaecbbe2e
commit
0ff06e577b
27 changed files with 980 additions and 528 deletions
|
|
@ -127,15 +127,22 @@
|
|||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<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.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
|
||||
FSI/FGS/UIJ, throwing IllegalArgumentException on its own handler
|
||||
thread (silent to the app). Declaring the permission flips
|
||||
FLAG_FSI_REQUESTED_BUT_DENIED so the gate passes, even though we
|
||||
never call setFullScreenIntent(). See ADR 2.5-heads-up. -->
|
||||
FSI/FGS/UIJ. We DO call setFullScreenIntent(launchPI, true) in
|
||||
VojoFirebaseMessagingService.postIncomingCallNotification (the
|
||||
incoming-ring path) — that both satisfies the CallStyle gate and
|
||||
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" />
|
||||
<!-- DM call lock-screen retention: CallForegroundService keeps the call
|
||||
process foregrounded under lock so AppOps doesn't revoke RECORD_AUDIO
|
||||
|
|
|
|||
170
android/app/src/main/java/chat/vojo/app/AudioRoutePlugin.java
Normal file
170
android/app/src/main/java/chat/vojo/app/AudioRoutePlugin.java
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -67,6 +67,7 @@ public class MainActivity extends BridgeActivity {
|
|||
// super.onCreate would make the plugin invisible to JS until the next relaunch.
|
||||
registerPlugin(FullScreenIntentPlugin.class);
|
||||
registerPlugin(CallForegroundPlugin.class);
|
||||
registerPlugin(AudioRoutePlugin.class);
|
||||
registerPlugin(LaunchSplashPlugin.class);
|
||||
registerPlugin(ShareTargetPlugin.class);
|
||||
registerPlugin(PollingPlugin.class);
|
||||
|
|
|
|||
|
|
@ -425,15 +425,20 @@
|
|||
"start": "Start call",
|
||||
"join": "Join call",
|
||||
"unavailable": "Calls are unavailable",
|
||||
"busy_other_room": "You are already in a call",
|
||||
"incoming": "Incoming call…",
|
||||
"answer": "Answer",
|
||||
"incoming_label": "Incoming call",
|
||||
"accept": "Accept",
|
||||
"decline": "Decline",
|
||||
"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_on": "Turn On Microphone",
|
||||
"sound_off": "Turn Off Sound",
|
||||
"sound_on": "Turn On Sound",
|
||||
"speaker_off": "Turn Off Speaker",
|
||||
"speaker_on": "Turn On Speaker",
|
||||
"camera_off": "Stop Camera",
|
||||
"camera_on": "Start Camera",
|
||||
"screenshare_off": "Stop Screenshare",
|
||||
|
|
@ -444,6 +449,7 @@
|
|||
"in_call": "In call",
|
||||
"in_call_count": "{{count}} in call",
|
||||
"connecting": "Connecting…",
|
||||
"calling": "Calling…",
|
||||
"open_call_room": "Open call room",
|
||||
"bubble_outgoing": "Outgoing call",
|
||||
"bubble_incoming": "Incoming call",
|
||||
|
|
|
|||
|
|
@ -429,15 +429,20 @@
|
|||
"start": "Позвонить",
|
||||
"join": "Присоединиться",
|
||||
"unavailable": "Звонки недоступны",
|
||||
"busy_other_room": "Вы уже в другом звонке",
|
||||
"incoming": "Входящий звонок…",
|
||||
"answer": "Ответить",
|
||||
"incoming_label": "Входящий звонок",
|
||||
"accept": "Принять",
|
||||
"decline": "Отклонить",
|
||||
"unknown_caller": "Неизвестный абонент",
|
||||
"ctl_mic": "Микрофон",
|
||||
"ctl_speaker": "Динамик",
|
||||
"ctl_camera": "Камера",
|
||||
"ctl_screen": "Экран",
|
||||
"ctl_end": "Завершить",
|
||||
"mic_off": "Выключить микрофон",
|
||||
"mic_on": "Включить микрофон",
|
||||
"sound_off": "Выключить звук",
|
||||
"sound_on": "Включить звук",
|
||||
"speaker_off": "Выключить громкую связь",
|
||||
"speaker_on": "Включить громкую связь",
|
||||
"camera_off": "Выключить камеру",
|
||||
"camera_on": "Включить камеру",
|
||||
"screenshare_off": "Остановить показ экрана",
|
||||
|
|
@ -448,6 +453,7 @@
|
|||
"in_call": "В звонке",
|
||||
"in_call_count": "{{count}} в звонке",
|
||||
"connecting": "Соединение…",
|
||||
"calling": "Вызов…",
|
||||
"open_call_room": "Открыть чат звонка",
|
||||
"bubble_outgoing": "Исходящий звонок",
|
||||
"bubble_incoming": "Входящий звонок",
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import {
|
|||
useCallHangupEvent,
|
||||
useCallJoined,
|
||||
useCallThemeSync,
|
||||
useCallMemberSoundSync,
|
||||
} from '../hooks/useCallEmbed';
|
||||
import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
|
||||
import { CallEmbed } from '../plugins/call';
|
||||
|
|
@ -19,7 +18,6 @@ function CallUtils({ embed }: { embed: CallEmbed }) {
|
|||
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||
const store = useStore();
|
||||
|
||||
useCallMemberSoundSync(embed);
|
||||
useCallThemeSync(embed);
|
||||
const clearIfCurrent = useCallback(() => {
|
||||
if (store.get(callEmbedAtom) !== embed) return;
|
||||
|
|
|
|||
68
src/app/features/call-status/CallActionButton.tsx
Normal file
68
src/app/features/call-status/CallActionButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,21 +1,23 @@
|
|||
import { Box, Icon, IconButton, IconSrc, Spinner, Text, Tooltip, TooltipProvider } from 'folds';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useSetAtom, useStore } from 'jotai';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CallEmbed, useCallControlState } from '../../plugins/call';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { callEmbedAtom } from '../../state/callEmbed';
|
||||
import * as css from './styles.css';
|
||||
import { CallActionButton } from './CallActionButton';
|
||||
import {
|
||||
CallHeadphoneIcon,
|
||||
CallHeadphoneMuteIcon,
|
||||
CallMicIcon,
|
||||
CallMicMuteIcon,
|
||||
CallPhoneDownIcon,
|
||||
CallScreenShareIcon,
|
||||
CallScreenShareMuteIcon,
|
||||
CallSpeakerIcon,
|
||||
CallSpeakerMuteIcon,
|
||||
CallVideoIcon,
|
||||
CallVideoMuteIcon,
|
||||
} from './callIcons';
|
||||
import { useCallSpeaker } from '../../hooks/useCallSpeaker';
|
||||
|
||||
// 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
|
||||
|
|
@ -25,75 +27,18 @@ import {
|
|||
// here to match the mobile-ish feel of our DM flow and PEER_LEAVE_GRACE_MS).
|
||||
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 = {
|
||||
callEmbed: CallEmbed;
|
||||
compact: 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 { 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 store = useStore();
|
||||
|
||||
|
|
@ -131,87 +76,68 @@ export function CallControl({ callEmbed, compact, callJoined }: CallControlProps
|
|||
hangup();
|
||||
};
|
||||
|
||||
// Visibility mirrors the legacy strip: Mic + Sound always render,
|
||||
// Video joins when the call isn't voice-only (still shown on mobile —
|
||||
// users want to be able to flip the camera on a phone), Screenshare
|
||||
// is desktop-only because mobile WebRTC capture is meaningfully
|
||||
// different and Element Call gates it natively.
|
||||
// Mic always renders. Speaker (громкая связь) only where the native route
|
||||
// plugin can act (Android). Video joins when the call isn't voice-only —
|
||||
// still shown on mobile so users can flip the camera on a phone; Screenshare
|
||||
// is desktop-only because mobile WebRTC capture is gated natively by EC.
|
||||
const showVideo = !callEmbed.voiceOnly;
|
||||
const showScreenshare = !compact && !callEmbed.voiceOnly;
|
||||
const buttonCompact = !native;
|
||||
|
||||
return (
|
||||
<Box shrink="No" alignItems="Center" gap="200">
|
||||
<ToggleButton
|
||||
enabled={microphone}
|
||||
onToggle={() => callEmbed.control.toggleMicrophone()}
|
||||
<div className={native ? css.CallActions : css.CallActionsCompact}>
|
||||
<CallActionButton
|
||||
icon={microphone ? CallMicIcon : CallMicMuteIcon}
|
||||
label={t('Call.ctl_mic')}
|
||||
ariaLabel={microphone ? t('Call.mic_off') : t('Call.mic_on')}
|
||||
tone={microphone ? 'neutral' : 'muted'}
|
||||
disabled={!callJoined}
|
||||
iconOn={CallMicIcon}
|
||||
iconOff={CallMicMuteIcon}
|
||||
labelOn={t('Call.mic_off')}
|
||||
labelOff={t('Call.mic_on')}
|
||||
compact={buttonCompact}
|
||||
onClick={() => callEmbed.control.toggleMicrophone()}
|
||||
/>
|
||||
<ToggleButton
|
||||
enabled={sound}
|
||||
onToggle={() => callEmbed.control.toggleSound()}
|
||||
disabled={!callJoined}
|
||||
iconOn={CallHeadphoneIcon}
|
||||
iconOff={CallHeadphoneMuteIcon}
|
||||
labelOn={t('Call.sound_off')}
|
||||
labelOff={t('Call.sound_on')}
|
||||
/>
|
||||
{showVideo && (
|
||||
<ToggleButton
|
||||
enabled={video}
|
||||
onToggle={() => callEmbed.control.toggleVideo()}
|
||||
{speakerAvailable && (
|
||||
<CallActionButton
|
||||
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}
|
||||
iconOn={CallVideoIcon}
|
||||
iconOff={CallVideoMuteIcon}
|
||||
labelOn={t('Call.camera_off')}
|
||||
labelOff={t('Call.camera_on')}
|
||||
variantOn="Success"
|
||||
variantOff="Surface"
|
||||
compact={buttonCompact}
|
||||
onClick={toggleSpeaker}
|
||||
/>
|
||||
)}
|
||||
{showVideo && (
|
||||
<CallActionButton
|
||||
icon={video ? CallVideoIcon : CallVideoMuteIcon}
|
||||
label={t('Call.ctl_camera')}
|
||||
ariaLabel={video ? t('Call.camera_off') : t('Call.camera_on')}
|
||||
tone={video ? 'accent' : 'neutral'}
|
||||
disabled={!callJoined}
|
||||
compact={buttonCompact}
|
||||
onClick={() => callEmbed.control.toggleVideo()}
|
||||
/>
|
||||
)}
|
||||
{showScreenshare && (
|
||||
<ToggleButton
|
||||
enabled={screenshare}
|
||||
onToggle={() => callEmbed.control.toggleScreenshare()}
|
||||
<CallActionButton
|
||||
icon={screenshare ? CallScreenShareIcon : CallScreenShareMuteIcon}
|
||||
label={t('Call.ctl_screen')}
|
||||
ariaLabel={screenshare ? t('Call.screenshare_off') : t('Call.screenshare_on')}
|
||||
tone={screenshare ? 'accent' : 'neutral'}
|
||||
disabled={!callJoined}
|
||||
iconOn={CallScreenShareIcon}
|
||||
iconOff={CallScreenShareMuteIcon}
|
||||
labelOn={t('Call.screenshare_off')}
|
||||
labelOff={t('Call.screenshare_on')}
|
||||
variantOn="Success"
|
||||
variantOff="Surface"
|
||||
compact={buttonCompact}
|
||||
onClick={() => callEmbed.control.toggleScreenshare()}
|
||||
/>
|
||||
)}
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text size="T200">{t('Call.end_call')}</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(anchorRef) => (
|
||||
<IconButton
|
||||
ref={anchorRef}
|
||||
variant="Critical"
|
||||
fill={BUTTON_FILL}
|
||||
radii={BUTTON_RADII}
|
||||
size={BUTTON_SIZE}
|
||||
onClick={handleHangup}
|
||||
disabled={exiting}
|
||||
aria-label={t('Call.end_call')}
|
||||
>
|
||||
{exiting ? (
|
||||
<Spinner variant="Critical" fill="Soft" size="200" />
|
||||
) : (
|
||||
<Icon size={ICON_SIZE} src={CallPhoneDownIcon} />
|
||||
)}
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
</Box>
|
||||
<CallActionButton
|
||||
icon={CallPhoneDownIcon}
|
||||
label={t('Call.ctl_end')}
|
||||
ariaLabel={t('Call.end_call')}
|
||||
tone="danger"
|
||||
busy={exiting}
|
||||
disabled={exiting}
|
||||
compact={buttonCompact}
|
||||
onClick={handleHangup}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,23 @@
|
|||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Avatar, Box, Icon, Icons, Text } from 'folds';
|
||||
import classNames from 'classnames';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import * as css from './styles.css';
|
||||
import { CallControl } from './CallControl';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { CallEmbed } from '../../plugins/call/CallEmbed';
|
||||
import { useCallJoined } from '../../hooks/useCallEmbed';
|
||||
import { useCallDuration, formatCallTimer } from '../../hooks/useCallDuration';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useRoomAvatar, useRoomName } from '../../hooks/useRoomMeta';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
import { isNativePlatform } from '../../utils/capacitor';
|
||||
import { UserAvatar } from '../../components/user-avatar';
|
||||
import { RoomAvatar } from '../../components/room-avatar';
|
||||
import { getMemberDisplayName } from '../../utils/room';
|
||||
import {
|
||||
getMxIdLocalPart,
|
||||
guessDmRoomUserId,
|
||||
mxcUrlToHttp,
|
||||
} from '../../utils/matrix';
|
||||
import { getMxIdLocalPart, guessDmRoomUserId, mxcUrlToHttp } from '../../utils/matrix';
|
||||
|
||||
type CallStatusProps = {
|
||||
callEmbed: CallEmbed;
|
||||
|
|
@ -62,79 +59,123 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
|
|||
|
||||
// Defensive fallback chain — empty roomName + unresolved peer would
|
||||
// otherwise collapse the H4 to "" and leave the pill anonymous.
|
||||
const displayName =
|
||||
(isOneOnOne && peerName) ||
|
||||
roomName ||
|
||||
peerUserId ||
|
||||
room.roomId;
|
||||
const displayName = (isOneOnOne && peerName) || roomName || peerUserId || room.roomId;
|
||||
|
||||
// Sub-text is the live state: «Соединение…» until the widget reports
|
||||
// joined, then either «В звонке» (1:1) or «N в звонке» (group). The
|
||||
// pulsing dot is bound to `callJoined` so the user gets instant
|
||||
// visual confirmation that LiveKit is up.
|
||||
// Sub-text walks the real call lifecycle so the pill reads like a phone
|
||||
// call: «Соединение…» until the widget reports joined → «Вызов…» while we
|
||||
// wait for the other side to pick up (nobody else is live yet) → a live
|
||||
// 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 [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;
|
||||
if (!callJoined) {
|
||||
subText = t('Call.connecting');
|
||||
} else if (isOneOnOne || memberCount <= 1) {
|
||||
subText = t('Call.in_call');
|
||||
} else if (!everConnected) {
|
||||
subText = t('Call.calling');
|
||||
} else if (isOneOnOne) {
|
||||
subText = timer || t('Call.in_call');
|
||||
} 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 });
|
||||
}
|
||||
|
||||
const native = isNativePlatform();
|
||||
|
||||
const avatar = (
|
||||
<Avatar className={native ? css.CallAvatar : css.CallAvatarCompact}>
|
||||
{isOneOnOne ? (
|
||||
<UserAvatar
|
||||
userId={peerUserId ?? ''}
|
||||
src={avatarUrl}
|
||||
alt={displayName}
|
||||
renderFallback={() => <Icon size="400" src={Icons.User} filled />}
|
||||
/>
|
||||
) : (
|
||||
<RoomAvatar
|
||||
roomId={room.roomId}
|
||||
src={avatarUrl}
|
||||
alt={displayName}
|
||||
renderFallback={() => <Icon size="400" src={Icons.Hash} filled />}
|
||||
/>
|
||||
)}
|
||||
</Avatar>
|
||||
);
|
||||
|
||||
const nameAndState = (
|
||||
<Box
|
||||
grow={native ? undefined : 'Yes'}
|
||||
direction="Column"
|
||||
gap="100"
|
||||
alignItems={native ? 'Center' : 'Start'}
|
||||
style={{ minWidth: 0, maxWidth: '100%' }}
|
||||
>
|
||||
<Text size="H4" truncate>
|
||||
{displayName}
|
||||
</Text>
|
||||
<span className={css.StateLine}>
|
||||
<span className={classNames(css.LiveDot, callJoined && css.LiveDotPulsing)} aria-hidden />
|
||||
<Text as="span" size="T200" priority="300" truncate className={css.Timer}>
|
||||
{subText}
|
||||
</Text>
|
||||
</span>
|
||||
</Box>
|
||||
);
|
||||
|
||||
// Native (phone): tall vertical Dawn card. Web (desktop): the original
|
||||
// compact horizontal row, same size as before.
|
||||
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.CallStatus, ContainerColor({ variant: 'Background' }))}
|
||||
className={classNames(css.CallCard, css.CallCardCompact)}
|
||||
shrink="No"
|
||||
direction="Row"
|
||||
alignItems="Center"
|
||||
gap="400"
|
||||
>
|
||||
<Box
|
||||
as="button"
|
||||
<button
|
||||
type="button"
|
||||
grow="Yes"
|
||||
alignItems="Center"
|
||||
gap="400"
|
||||
className={css.CallIdentityButton}
|
||||
className={css.CallIdentityButtonCompact}
|
||||
onClick={() => navigateRoom(room.roomId)}
|
||||
aria-label={t('Call.open_call_room')}
|
||||
>
|
||||
<Avatar className={css.RingAvatar}>
|
||||
{isOneOnOne ? (
|
||||
<UserAvatar
|
||||
userId={peerUserId ?? ''}
|
||||
src={avatarUrl}
|
||||
alt={displayName}
|
||||
renderFallback={() => <Icon size="400" src={Icons.User} filled />}
|
||||
/>
|
||||
) : (
|
||||
<RoomAvatar
|
||||
roomId={room.roomId}
|
||||
src={avatarUrl}
|
||||
alt={displayName}
|
||||
renderFallback={() => <Icon size="400" src={Icons.Hash} filled />}
|
||||
/>
|
||||
)}
|
||||
</Avatar>
|
||||
<Box grow="Yes" direction="Column" gap="100" alignItems="Start">
|
||||
<Text size="H4" truncate>
|
||||
{displayName}
|
||||
</Text>
|
||||
<Box alignItems="Center" gap="200">
|
||||
<span
|
||||
className={classNames(css.LiveDot, callJoined && css.LiveDotPulsing)}
|
||||
aria-hidden
|
||||
/>
|
||||
<Text size="T200" priority="300" truncate>
|
||||
{subText}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box shrink="No" alignItems="Center" gap="200">
|
||||
<CallControl callJoined={callJoined} compact={compact} callEmbed={callEmbed} />
|
||||
</Box>
|
||||
{avatar}
|
||||
{nameAndState}
|
||||
</button>
|
||||
<CallControl native={false} callJoined={callJoined} compact={compact} callEmbed={callEmbed} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
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 { useTranslation } from 'react-i18next';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import * as css from './styles.css';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
import { isNativePlatform } from '../../utils/capacitor';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { UserAvatar } from '../../components/user-avatar';
|
||||
|
|
@ -85,76 +85,92 @@ export function IncomingCallStrip({ call, room }: IncomingCallStripProps) {
|
|||
}
|
||||
};
|
||||
|
||||
const native = isNativePlatform();
|
||||
|
||||
const buttons = (
|
||||
<div className={classNames(css.RingButtons, native && css.RingButtonsFull)}>
|
||||
<Button
|
||||
className={native ? css.RingButtonGrow : undefined}
|
||||
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)}
|
||||
>
|
||||
<UserAvatar
|
||||
userId={senderId ?? ''}
|
||||
src={avatarUrl}
|
||||
alt={displayName}
|
||||
renderFallback={() => <Icon size="400" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
);
|
||||
|
||||
// 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>
|
||||
{displayName}
|
||||
</Text>
|
||||
<span className={css.Eyebrow}>{t('Call.incoming_label')}</span>
|
||||
</Box>
|
||||
</div>
|
||||
{buttons}
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={classNames(css.RingRow, ContainerColor({ variant: 'Background' }))}
|
||||
className={classNames(css.CallCard, css.CallCardCompact)}
|
||||
shrink="No"
|
||||
direction="Row"
|
||||
alignItems="Center"
|
||||
gap="500"
|
||||
>
|
||||
<Avatar className={css.RingAvatar}>
|
||||
<UserAvatar
|
||||
userId={senderId ?? ''}
|
||||
src={avatarUrl}
|
||||
alt={displayName}
|
||||
renderFallback={() => <Icon size="400" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
<Box grow="Yes" direction="Column" gap="100">
|
||||
<Text size="H4" truncate>
|
||||
{displayName}
|
||||
</Text>
|
||||
<Text size="T200" priority="300">
|
||||
{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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 14v4a2 2 0 0 0 2 2h2v-7H6a2 2 0 0 0-2 2z" {...STROKE} />
|
||||
<path d="M20 14v4a2 2 0 0 1-2 2h-2v-7h2a2 2 0 0 1 2 2z" {...STROKE} />
|
||||
<path d="M4 9h3l5-4v14l-5-4H4z" {...STROKE} />
|
||||
<path d="M16 9a4 4 0 0 1 0 6" {...STROKE} />
|
||||
<path d="M18.5 6.5a8 8 0 0 1 0 11" {...STROKE} />
|
||||
</>
|
||||
);
|
||||
|
||||
export const CallHeadphoneMuteIcon: IconSrc = () => (
|
||||
<>
|
||||
<path d="M4 14a8 8 0 0 1 16 0" {...STROKE} />
|
||||
<path d="M4 14v4a2 2 0 0 0 2 2h2v-7H6a2 2 0 0 0-2 2z" {...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} />
|
||||
</>
|
||||
);
|
||||
// Speaker «off» (earpiece) — the same cone with the sound waves dropped,
|
||||
// not a slash: audio still plays, just through the earpiece. The neutral
|
||||
// Surface variant carries the «inactive» cue.
|
||||
export const CallSpeakerMuteIcon: IconSrc = () => <path d="M4 9h3l5-4v14l-5-4H4z" {...STROKE} />;
|
||||
|
||||
export const CallVideoIcon: IconSrc = () => (
|
||||
<>
|
||||
|
|
@ -124,8 +122,15 @@ export const CallPhoneIcon: IconSrc = () => (
|
|||
// Same handset rotated 135° — universal «hang up / decline» glyph
|
||||
// (handset tilted off the cradle). Reused for Decline (IncomingCallStrip)
|
||||
// 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 = () => (
|
||||
<g transform="rotate(135 12 12)">
|
||||
<g transform="translate(12 12) rotate(135) scale(0.82) translate(-12 -13)">
|
||||
<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"
|
||||
{...STROKE}
|
||||
|
|
|
|||
|
|
@ -1,74 +1,147 @@
|
|||
import { globalStyle, keyframes, style } from '@vanilla-extract/css';
|
||||
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
|
||||
// `HorseshoeContainer`. The rail paints the rounded shell — the rows
|
||||
// themselves stay flat, with hairline dividers between adjacent rows so
|
||||
// stacked entries (multiple concurrent rings, or a ring above the
|
||||
// active-call pill) read as separate items inside the same card.
|
||||
// Both the incoming-ring card and the active-call card live inside the bottom
|
||||
// horseshoe rail owned by `HorseshoeContainer`. The surface is EXACTLY the
|
||||
// message-composer surface — `color.Surface.Container` (DAWN.bg2 #0d0e11 dark
|
||||
// / #ffffff light), the same token `StreamBubble` and the chat composer bind
|
||||
// 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({
|
||||
padding: toRem(20),
|
||||
// Shared card surface (composer background + theme-correct text colour).
|
||||
export const CallCard = style({
|
||||
backgroundColor: color.Surface.Container,
|
||||
color: color.Surface.OnContainer,
|
||||
});
|
||||
|
||||
export const RingAvatar = style({
|
||||
width: toRem(64),
|
||||
height: toRem(64),
|
||||
flexShrink: 0,
|
||||
// Hairline divider between two stacked cards (a ring above the active pill, or
|
||||
// two concurrent rings) so they read as separate items inside one sheet.
|
||||
globalStyle(`${CallCard} + ${CallCard}`, {
|
||||
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||
});
|
||||
|
||||
// Active-call pill. Vertical padding is intentionally smaller than
|
||||
// `RingRow` (12 vs 20) — the pill is a secondary, persistent surface
|
||||
// and shouldn't visually outweigh an active ring row stacked above it.
|
||||
// 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({
|
||||
// --- Native: tall vertical card ---
|
||||
export const CallCardNative = style({
|
||||
padding: `${config.space.S500} ${config.space.S400} ${config.space.S600}`,
|
||||
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,
|
||||
});
|
||||
|
||||
const identityButtonReset = {
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
color: '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
|
||||
// used for cross-class adjacency because vanilla-extract's `selectors`
|
||||
// key requires `&` to be the *matched* element, which forbids selectors
|
||||
// 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}`;
|
||||
export const CallIdentityButtonCompact = style([
|
||||
CallIdentityCompact,
|
||||
{ ...identityButtonReset, textAlign: 'left' },
|
||||
]);
|
||||
|
||||
globalStyle(`${RingRow} + ${RingRow}`, {
|
||||
borderTop: rowDivider,
|
||||
export const CallAvatar = style({
|
||||
width: toRem(72),
|
||||
height: toRem(72),
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
globalStyle(`${RingRow} + ${CallStatus}`, {
|
||||
borderTop: rowDivider,
|
||||
export const CallAvatarCompact = style({
|
||||
width: toRem(56),
|
||||
height: toRem(56),
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
// Live indicator next to the active-call sub-text. Static red dot until
|
||||
// the widget reports `joined`, then a subtle pulse — same visual
|
||||
// language as the LiveKit SDK's «we're really live» state. Reduced
|
||||
// motion stops the pulse but keeps the dot visible.
|
||||
// Pulsing ring on the incoming-call avatar — a soft teal halo that expands and
|
||||
// fades, the «ringing right now» cue. Matches the rail's orbit accent.
|
||||
const callerPulse = keyframes({
|
||||
'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({
|
||||
'0%, 100%': { opacity: 1, transform: 'scale(1)' },
|
||||
'50%': { opacity: 0.55, transform: 'scale(0.85)' },
|
||||
|
|
@ -78,7 +151,7 @@ export const LiveDot = style({
|
|||
width: toRem(8),
|
||||
height: toRem(8),
|
||||
borderRadius: '50%',
|
||||
backgroundColor: color.Critical.Main,
|
||||
backgroundColor: color.Success.Main,
|
||||
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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ import {
|
|||
ControlDivider,
|
||||
MicrophoneButton,
|
||||
ScreenShareButton,
|
||||
SoundButton,
|
||||
VideoButton,
|
||||
} from './Controls';
|
||||
import { CallEmbed, useCallControlState } from '../../plugins/call';
|
||||
|
|
@ -46,9 +45,8 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||
useCallback(() => controlRef.current, [])
|
||||
);
|
||||
|
||||
const { microphone, video, sound, screenshare, spotlight } = useCallControlState(
|
||||
callEmbed.control
|
||||
);
|
||||
const { microphone, video, screenshare, spotlight } = useCallControlState(callEmbed.control);
|
||||
const { voiceOnly } = callEmbed;
|
||||
|
||||
const [cords, setCords] = useState<RectCords>();
|
||||
|
||||
|
|
@ -98,16 +96,19 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||
enabled={microphone}
|
||||
onToggle={() => callEmbed.control.toggleMicrophone()}
|
||||
/>
|
||||
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
|
||||
</Box>
|
||||
{!compact && <ControlDivider />}
|
||||
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
||||
<VideoButton enabled={video} onToggle={() => callEmbed.control.toggleVideo()} />
|
||||
<ScreenShareButton
|
||||
enabled={screenshare}
|
||||
onToggle={() => callEmbed.control.toggleScreenshare()}
|
||||
/>
|
||||
</Box>
|
||||
{!voiceOnly && (
|
||||
<>
|
||||
{!compact && <ControlDivider />}
|
||||
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
||||
<VideoButton enabled={video} onToggle={() => callEmbed.control.toggleVideo()} />
|
||||
<ScreenShareButton
|
||||
enabled={screenshare}
|
||||
onToggle={() => callEmbed.control.toggleScreenshare()}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
{!compact && <ControlDivider />}
|
||||
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
|
||||
|
|
@ -130,26 +131,30 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||
>
|
||||
<Menu>
|
||||
<Box direction="Column" style={{ padding: config.space.S100 }}>
|
||||
<MenuItem
|
||||
size="300"
|
||||
variant="Surface"
|
||||
radii="300"
|
||||
onClick={handleSpotlightClick}
|
||||
>
|
||||
<Text size="B300" truncate>
|
||||
{spotlight ? 'Grid View' : 'Spotlight View'}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
size="300"
|
||||
variant="Surface"
|
||||
radii="300"
|
||||
onClick={handleReactionsClick}
|
||||
>
|
||||
<Text size="B300" truncate>
|
||||
Reactions
|
||||
</Text>
|
||||
</MenuItem>
|
||||
{!voiceOnly && (
|
||||
<>
|
||||
<MenuItem
|
||||
size="300"
|
||||
variant="Surface"
|
||||
radii="300"
|
||||
onClick={handleSpotlightClick}
|
||||
>
|
||||
<Text size="B300" truncate>
|
||||
{spotlight ? 'Grid View' : 'Spotlight View'}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
size="300"
|
||||
variant="Surface"
|
||||
radii="300"
|
||||
onClick={handleReactionsClick}
|
||||
>
|
||||
<Text size="B300" truncate>
|
||||
Reactions
|
||||
</Text>
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
<MenuItem
|
||||
size="300"
|
||||
variant="Surface"
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
enabled: boolean;
|
||||
onToggle: () => void;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import React from 'react';
|
|||
import { Box, Button, Icon, Icons, Spinner, Text } from 'folds';
|
||||
import { SequenceCard } from '../../components/sequence-card';
|
||||
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 { useCallEmbed, useCallJoined, useCallStart } from '../../hooks/useCallEmbed';
|
||||
import { useCallPreferences } from '../../state/hooks/callPreferences';
|
||||
|
|
@ -23,8 +23,7 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) {
|
|||
|
||||
const disabled = inOtherCall || !canJoin;
|
||||
|
||||
const { microphone, video, sound, toggleMicrophone, toggleVideo, toggleSound } =
|
||||
useCallPreferences();
|
||||
const { microphone, video, toggleMicrophone, toggleVideo } = useCallPreferences();
|
||||
|
||||
return (
|
||||
<SequenceCard
|
||||
|
|
@ -38,18 +37,17 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) {
|
|||
>
|
||||
<Box shrink="No" alignItems="Inherit" justifyContent="SpaceBetween" gap="200">
|
||||
<MicrophoneButton enabled={microphone} onToggle={toggleMicrophone} />
|
||||
<SoundButton enabled={sound} onToggle={toggleSound} />
|
||||
</Box>
|
||||
<ControlDivider />
|
||||
<Box shrink="No" alignItems="Inherit" justifyContent="SpaceBetween" gap="200">
|
||||
<VideoButton enabled={video} onToggle={toggleVideo} />
|
||||
{!isOneOnOne && <VideoButton enabled={video} onToggle={toggleVideo} />}
|
||||
<ChatButton />
|
||||
</Box>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Button
|
||||
variant={disabled ? 'Secondary' : 'Success'}
|
||||
fill={disabled ? 'Soft' : 'Solid'}
|
||||
onClick={() => startCall(room, { microphone, video, sound })}
|
||||
onClick={() => startCall(room, { microphone, video })}
|
||||
disabled={disabled || joining}
|
||||
before={
|
||||
joining ? (
|
||||
|
|
|
|||
|
|
@ -24,15 +24,17 @@
|
|||
// Android-only.
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { callEmbedAtom } from '../state/callEmbed';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { callEmbedAtom, callSpeakerAtom } from '../state/callEmbed';
|
||||
import { callForegroundService } from '../plugins/call/callForegroundService';
|
||||
import { callAudioRoute } from '../plugins/call/callAudioRoute';
|
||||
import { useCallJoined } from './useCallEmbed';
|
||||
import { isAndroidPlatform } from '../utils/capacitor';
|
||||
|
||||
export const useAndroidCallForegroundSync = (): void => {
|
||||
const callEmbed = useAtomValue(callEmbedAtom);
|
||||
const joined = useCallJoined(callEmbed);
|
||||
const setSpeaker = useSetAtom(callSpeakerAtom);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAndroidPlatform()) return undefined;
|
||||
|
|
@ -48,6 +50,11 @@ export const useAndroidCallForegroundSync = (): void => {
|
|||
// eslint-disable-next-line no-console
|
||||
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]);
|
||||
};
|
||||
|
|
|
|||
49
src/app/hooks/useCallDuration.ts
Normal file
49
src/app/hooks/useCallDuration.ts
Normal 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}`;
|
||||
};
|
||||
|
|
@ -12,7 +12,6 @@ import { ThemeKind, useTheme } from './useTheme';
|
|||
import { callEmbedAtom } from '../state/callEmbed';
|
||||
import { useResizeObserver } from './useResizeObserver';
|
||||
import { CallControlState } from '../plugins/call/CallControlState';
|
||||
import { useCallMembersChange, useCallSession } from './useCall';
|
||||
import { CallPreferences } from '../state/callPreferences';
|
||||
|
||||
const CallEmbedContext = createContext<CallEmbed | undefined>(undefined);
|
||||
|
|
@ -50,7 +49,7 @@ export const createCallEmbed = (
|
|||
const intent = CallEmbed.getIntent(dm, ongoing, voiceOnly);
|
||||
const widget = CallEmbed.getWidget(mx, room, intent, themeKind);
|
||||
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);
|
||||
|
||||
|
|
@ -107,14 +106,6 @@ export const useCallCloseEvent = (embed: CallEmbed, callback: () => void) => {
|
|||
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) => {
|
||||
const theme = useTheme();
|
||||
|
||||
|
|
|
|||
28
src/app/hooks/useCallSpeaker.ts
Normal file
28
src/app/hooks/useCallSpeaker.ts
Normal 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() };
|
||||
};
|
||||
|
|
@ -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]
|
||||
);
|
||||
};
|
||||
|
|
@ -30,5 +30,9 @@ export function CallStatusRenderer() {
|
|||
const callEmbed = useCallEmbed();
|
||||
|
||||
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} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,10 +80,6 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||
return this.state.video;
|
||||
}
|
||||
|
||||
public get sound(): boolean {
|
||||
return this.state.sound;
|
||||
}
|
||||
|
||||
public get screenshare(): boolean {
|
||||
return this.state.screenshare;
|
||||
}
|
||||
|
|
@ -97,7 +93,6 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||
audio_enabled: this.microphone,
|
||||
video_enabled: this.video,
|
||||
});
|
||||
this.setSound(this.sound);
|
||||
this.emitStateUpdate();
|
||||
}
|
||||
|
||||
|
|
@ -121,24 +116,10 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||
this.onControlMutation();
|
||||
}
|
||||
|
||||
public applySound() {
|
||||
this.setSound(this.sound);
|
||||
}
|
||||
|
||||
private setMediaState(state: ElementMediaStatePayload) {
|
||||
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>) {
|
||||
const { data } = evt.detail;
|
||||
if (!data) return;
|
||||
|
|
@ -146,30 +127,19 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||
const state = new CallControlState(
|
||||
data.audio_enabled ?? this.microphone,
|
||||
data.video_enabled ?? this.video,
|
||||
this.sound,
|
||||
this.screenshare,
|
||||
this.spotlight
|
||||
);
|
||||
|
||||
this.state = state;
|
||||
this.emitStateUpdate();
|
||||
|
||||
if (this.microphone && !this.sound) {
|
||||
this.toggleSound();
|
||||
}
|
||||
}
|
||||
|
||||
public onControlMutation() {
|
||||
const screenshare: boolean = this.screenshareButton?.getAttribute('data-kind') === 'primary';
|
||||
const spotlight: boolean = this.spotlightButton?.checked ?? false;
|
||||
|
||||
this.state = new CallControlState(
|
||||
this.microphone,
|
||||
this.video,
|
||||
this.sound,
|
||||
screenshare,
|
||||
spotlight
|
||||
);
|
||||
this.state = new CallControlState(this.microphone, this.video, screenshare, spotlight);
|
||||
this.emitStateUpdate();
|
||||
}
|
||||
|
||||
|
|
@ -189,26 +159,6 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||
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() {
|
||||
this.screenshareButton?.click();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,22 +3,13 @@ export class CallControlState {
|
|||
|
||||
public readonly video: boolean;
|
||||
|
||||
public readonly sound: boolean;
|
||||
|
||||
public readonly screenshare: boolean;
|
||||
|
||||
public readonly spotlight: boolean;
|
||||
|
||||
constructor(
|
||||
microphone: boolean,
|
||||
video: boolean,
|
||||
sound: boolean,
|
||||
screenshare = false,
|
||||
spotlight = false
|
||||
) {
|
||||
constructor(microphone: boolean, video: boolean, screenshare = false, spotlight = false) {
|
||||
this.microphone = microphone;
|
||||
this.video = video;
|
||||
this.sound = sound;
|
||||
this.screenshare = screenshare;
|
||||
this.spotlight = spotlight;
|
||||
}
|
||||
|
|
|
|||
61
src/app/plugins/call/callAudioRoute.ts
Normal file
61
src/app/plugins/call/callAudioRoute.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
|
|
@ -18,3 +18,9 @@ export const callEmbedAtom = atom<CallEmbed | undefined, [CallEmbed | undefined]
|
|||
);
|
||||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import {
|
|||
export type CallPreferences = {
|
||||
microphone: boolean;
|
||||
video: boolean;
|
||||
sound: boolean;
|
||||
};
|
||||
|
||||
const CALL_PREFERENCES = 'callPreferences';
|
||||
|
|
@ -16,7 +15,6 @@ const CALL_PREFERENCES = 'callPreferences';
|
|||
const DEFAULT_PREFERENCES: CallPreferences = {
|
||||
microphone: true,
|
||||
video: false,
|
||||
sound: true,
|
||||
};
|
||||
|
||||
export type CallPreferencesAtom = WritableAtom<CallPreferences, [CallPreferences], undefined>;
|
||||
|
|
|
|||
|
|
@ -17,38 +17,21 @@ export const useCallPreferencesAtom = (): CallPreferencesAtom => {
|
|||
export const useCallPreferences = (): CallPreferences & {
|
||||
toggleMicrophone: () => void;
|
||||
toggleVideo: () => void;
|
||||
toggleSound: () => void;
|
||||
} => {
|
||||
const callPrefAtom = useCallPreferencesAtom();
|
||||
const [pref, setPref] = useAtom(callPrefAtom);
|
||||
|
||||
const toggleMicrophone = useCallback(() => {
|
||||
const microphone = !pref.microphone;
|
||||
|
||||
setPref({
|
||||
microphone,
|
||||
microphone: !pref.microphone,
|
||||
video: pref.video,
|
||||
sound: !pref.sound && microphone ? true : pref.sound,
|
||||
});
|
||||
}, [setPref, pref]);
|
||||
|
||||
const toggleVideo = useCallback(() => {
|
||||
const video = !pref.video;
|
||||
|
||||
setPref({
|
||||
microphone: pref.microphone,
|
||||
video,
|
||||
sound: pref.sound,
|
||||
});
|
||||
}, [setPref, pref]);
|
||||
|
||||
const toggleSound = useCallback(() => {
|
||||
const sound = !pref.sound;
|
||||
|
||||
setPref({
|
||||
microphone: !sound ? false : pref.microphone,
|
||||
video: pref.video,
|
||||
sound,
|
||||
video: !pref.video,
|
||||
});
|
||||
}, [setPref, pref]);
|
||||
|
||||
|
|
@ -56,6 +39,5 @@ export const useCallPreferences = (): CallPreferences & {
|
|||
...pref,
|
||||
toggleMicrophone,
|
||||
toggleVideo,
|
||||
toggleSound,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue