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.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
|
||||||
|
|
|
||||||
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.
|
// 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);
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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": "Входящий звонок",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
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 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}
|
||||||
disabled={!callJoined}
|
label={t('Call.ctl_speaker')}
|
||||||
iconOn={CallHeadphoneIcon}
|
ariaLabel={speaker ? t('Call.speaker_off') : t('Call.speaker_on')}
|
||||||
iconOff={CallHeadphoneMuteIcon}
|
tone={speaker ? 'accent' : 'neutral'}
|
||||||
labelOn={t('Call.sound_off')}
|
|
||||||
labelOff={t('Call.sound_on')}
|
|
||||||
/>
|
|
||||||
{showVideo && (
|
|
||||||
<ToggleButton
|
|
||||||
enabled={video}
|
|
||||||
onToggle={() => callEmbed.control.toggleVideo()}
|
|
||||||
disabled={!callJoined}
|
disabled={!callJoined}
|
||||||
iconOn={CallVideoIcon}
|
compact={buttonCompact}
|
||||||
iconOff={CallVideoMuteIcon}
|
onClick={toggleSpeaker}
|
||||||
labelOn={t('Call.camera_off')}
|
/>
|
||||||
labelOff={t('Call.camera_on')}
|
)}
|
||||||
variantOn="Success"
|
{showVideo && (
|
||||||
variantOff="Surface"
|
<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 && (
|
{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}
|
||||||
}
|
disabled={exiting}
|
||||||
>
|
compact={buttonCompact}
|
||||||
{(anchorRef) => (
|
onClick={handleHangup}
|
||||||
<IconButton
|
/>
|
||||||
ref={anchorRef}
|
</div>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,79 +59,123 @@ 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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<Box
|
<Box
|
||||||
className={classNames(css.CallStatus, ContainerColor({ variant: 'Background' }))}
|
className={classNames(css.CallCard, css.CallCardCompact)}
|
||||||
shrink="No"
|
shrink="No"
|
||||||
direction="Row"
|
direction="Row"
|
||||||
alignItems="Center"
|
alignItems="Center"
|
||||||
gap="400"
|
|
||||||
>
|
>
|
||||||
<Box
|
<button
|
||||||
as="button"
|
|
||||||
type="button"
|
type="button"
|
||||||
grow="Yes"
|
className={css.CallIdentityButtonCompact}
|
||||||
alignItems="Center"
|
|
||||||
gap="400"
|
|
||||||
className={css.CallIdentityButton}
|
|
||||||
onClick={() => navigateRoom(room.roomId)}
|
onClick={() => navigateRoom(room.roomId)}
|
||||||
aria-label={t('Call.open_call_room')}
|
aria-label={t('Call.open_call_room')}
|
||||||
>
|
>
|
||||||
<Avatar className={css.RingAvatar}>
|
{avatar}
|
||||||
{isOneOnOne ? (
|
{nameAndState}
|
||||||
<UserAvatar
|
</button>
|
||||||
userId={peerUserId ?? ''}
|
<CallControl native={false} callJoined={callJoined} compact={compact} callEmbed={callEmbed} />
|
||||||
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>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,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 (
|
return (
|
||||||
<Box
|
<Box
|
||||||
className={classNames(css.RingRow, ContainerColor({ variant: 'Background' }))}
|
className={classNames(css.CallCard, css.CallCardCompact)}
|
||||||
shrink="No"
|
shrink="No"
|
||||||
direction="Row"
|
direction="Row"
|
||||||
alignItems="Center"
|
alignItems="Center"
|
||||||
gap="500"
|
|
||||||
>
|
>
|
||||||
<Avatar className={css.RingAvatar}>
|
<div className={css.CallIdentityCompact}>
|
||||||
<UserAvatar
|
{avatar}
|
||||||
userId={senderId ?? ''}
|
<Box grow="Yes" direction="Column" gap="100" style={{ minWidth: 0 }}>
|
||||||
src={avatarUrl}
|
<Text size="H4" truncate>
|
||||||
alt={displayName}
|
{displayName}
|
||||||
renderFallback={() => <Icon size="400" src={Icons.User} filled />}
|
</Text>
|
||||||
/>
|
<Text size="T200" priority="300" truncate>
|
||||||
</Avatar>
|
{t('Call.incoming')}
|
||||||
<Box grow="Yes" direction="Column" gap="100">
|
</Text>
|
||||||
<Text size="H4" truncate>
|
</Box>
|
||||||
{displayName}
|
</div>
|
||||||
</Text>
|
{buttons}
|
||||||
<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>
|
|
||||||
</Box>
|
</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 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}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,16 +96,19 @@ 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>
|
|
||||||
{!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>
|
||||||
|
{!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>
|
</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,26 +131,30 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||||
>
|
>
|
||||||
<Menu>
|
<Menu>
|
||||||
<Box direction="Column" style={{ padding: config.space.S100 }}>
|
<Box direction="Column" style={{ padding: config.space.S100 }}>
|
||||||
<MenuItem
|
{!voiceOnly && (
|
||||||
size="300"
|
<>
|
||||||
variant="Surface"
|
<MenuItem
|
||||||
radii="300"
|
size="300"
|
||||||
onClick={handleSpotlightClick}
|
variant="Surface"
|
||||||
>
|
radii="300"
|
||||||
<Text size="B300" truncate>
|
onClick={handleSpotlightClick}
|
||||||
{spotlight ? 'Grid View' : 'Spotlight View'}
|
>
|
||||||
</Text>
|
<Text size="B300" truncate>
|
||||||
</MenuItem>
|
{spotlight ? 'Grid View' : 'Spotlight View'}
|
||||||
<MenuItem
|
</Text>
|
||||||
size="300"
|
</MenuItem>
|
||||||
variant="Surface"
|
<MenuItem
|
||||||
radii="300"
|
size="300"
|
||||||
onClick={handleReactionsClick}
|
variant="Surface"
|
||||||
>
|
radii="300"
|
||||||
<Text size="B300" truncate>
|
onClick={handleReactionsClick}
|
||||||
Reactions
|
>
|
||||||
</Text>
|
<Text size="B300" truncate>
|
||||||
</MenuItem>
|
Reactions
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<MenuItem
|
<MenuItem
|
||||||
size="300"
|
size="300"
|
||||||
variant="Surface"
|
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 = {
|
type VideoButtonProps = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
|
|
|
||||||
|
|
@ -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 ? (
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
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 { 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();
|
||||||
|
|
||||||
|
|
|
||||||
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();
|
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} />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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);
|
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 = {
|
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>;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue