Pause media tracks and show a message when reconnecting to MatrixRTC
This commit is contained in:
@@ -55,6 +55,7 @@
|
|||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
"reaction": "Reaction",
|
"reaction": "Reaction",
|
||||||
"reactions": "Reactions",
|
"reactions": "Reactions",
|
||||||
|
"reconnecting": "Reconnecting…",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"unencrypted": "Not encrypted",
|
"unencrypted": "Not encrypted",
|
||||||
"username": "Username",
|
"username": "Username",
|
||||||
|
|||||||
@@ -45,6 +45,12 @@ interface Props {
|
|||||||
* A supporting icon to display within the toast.
|
* A supporting icon to display within the toast.
|
||||||
*/
|
*/
|
||||||
Icon?: ComponentType<SVGAttributes<SVGElement>>;
|
Icon?: ComponentType<SVGAttributes<SVGElement>>;
|
||||||
|
/**
|
||||||
|
* Whether the toast should be portaled into the root of the document (rather
|
||||||
|
* than rendered in-place within the component tree).
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
portal?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,6 +62,7 @@ export const Toast: FC<Props> = ({
|
|||||||
autoDismiss,
|
autoDismiss,
|
||||||
children,
|
children,
|
||||||
Icon,
|
Icon,
|
||||||
|
portal = true,
|
||||||
}) => {
|
}) => {
|
||||||
const onOpenChange = useCallback(
|
const onOpenChange = useCallback(
|
||||||
(open: boolean) => {
|
(open: boolean) => {
|
||||||
@@ -71,29 +78,33 @@ export const Toast: FC<Props> = ({
|
|||||||
}
|
}
|
||||||
}, [open, autoDismiss, onDismiss]);
|
}, [open, autoDismiss, onDismiss]);
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<DialogOverlay
|
||||||
|
className={classNames(overlayStyles.bg, overlayStyles.animate)}
|
||||||
|
/>
|
||||||
|
<DialogContent aria-describedby={undefined} asChild>
|
||||||
|
<DialogClose
|
||||||
|
className={classNames(
|
||||||
|
overlayStyles.overlay,
|
||||||
|
overlayStyles.animate,
|
||||||
|
styles.toast,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<DialogTitle asChild>
|
||||||
|
<Text as="h3" size="sm" weight="semibold">
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
</DialogTitle>
|
||||||
|
{Icon && <Icon width={20} height={20} aria-hidden />}
|
||||||
|
</DialogClose>
|
||||||
|
</DialogContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogRoot open={open} onOpenChange={onOpenChange}>
|
<DialogRoot open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogPortal>
|
{portal ? <DialogPortal>{content}</DialogPortal> : content}
|
||||||
<DialogOverlay
|
|
||||||
className={classNames(overlayStyles.bg, overlayStyles.animate)}
|
|
||||||
/>
|
|
||||||
<DialogContent aria-describedby={undefined} asChild>
|
|
||||||
<DialogClose
|
|
||||||
className={classNames(
|
|
||||||
overlayStyles.overlay,
|
|
||||||
overlayStyles.animate,
|
|
||||||
styles.toast,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<DialogTitle asChild>
|
|
||||||
<Text as="h3" size="sm" weight="semibold">
|
|
||||||
{children}
|
|
||||||
</Text>
|
|
||||||
</DialogTitle>
|
|
||||||
{Icon && <Icon width={20} height={20} aria-hidden />}
|
|
||||||
</DialogClose>
|
|
||||||
</DialogContent>
|
|
||||||
</DialogPortal>
|
|
||||||
</DialogRoot>
|
</DialogRoot>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ import { useMediaDevices } from "../MediaDevicesContext.ts";
|
|||||||
import { EarpieceOverlay } from "./EarpieceOverlay.tsx";
|
import { EarpieceOverlay } from "./EarpieceOverlay.tsx";
|
||||||
import { useAppBarHidden, useAppBarSecondaryButton } from "../AppBar.tsx";
|
import { useAppBarHidden, useAppBarSecondaryButton } from "../AppBar.tsx";
|
||||||
import { useBehavior } from "../useBehavior.ts";
|
import { useBehavior } from "../useBehavior.ts";
|
||||||
|
import { Toast } from "../Toast.tsx";
|
||||||
|
|
||||||
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
||||||
|
|
||||||
@@ -313,6 +314,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
() => void toggleRaisedHand(),
|
() => void toggleRaisedHand(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const reconnecting = useBehavior(vm.reconnecting$);
|
||||||
const windowMode = useBehavior(vm.windowMode$);
|
const windowMode = useBehavior(vm.windowMode$);
|
||||||
const layout = useBehavior(vm.layout$);
|
const layout = useBehavior(vm.layout$);
|
||||||
const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$);
|
const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$);
|
||||||
@@ -766,6 +768,9 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The reconnecting toast cannot be dismissed
|
||||||
|
const onDismissReconnectingToast = useCallback(() => {}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={styles.inRoom}
|
className={styles.inRoom}
|
||||||
@@ -793,8 +798,15 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
{renderContent()}
|
{renderContent()}
|
||||||
<CallEventAudioRenderer vm={vm} muted={muteAllAudio} />
|
<CallEventAudioRenderer vm={vm} muted={muteAllAudio} />
|
||||||
<ReactionsAudioRenderer vm={vm} muted={muteAllAudio} />
|
<ReactionsAudioRenderer vm={vm} muted={muteAllAudio} />
|
||||||
|
<Toast
|
||||||
|
onDismiss={onDismissReconnectingToast}
|
||||||
|
open={reconnecting}
|
||||||
|
portal={false}
|
||||||
|
>
|
||||||
|
{t("common.reconnecting")}
|
||||||
|
</Toast>
|
||||||
<EarpieceOverlay
|
<EarpieceOverlay
|
||||||
show={earpieceMode}
|
show={earpieceMode && !reconnecting}
|
||||||
onBackToVideoPressed={audioOutputSwitcher?.switch}
|
onBackToVideoPressed={audioOutputSwitcher?.switch}
|
||||||
/>
|
/>
|
||||||
<ReactionsOverlay vm={vm} />
|
<ReactionsOverlay vm={vm} />
|
||||||
|
|||||||
@@ -256,7 +256,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
aria-disabled="false"
|
aria-disabled="false"
|
||||||
aria-labelledby="«r5»"
|
aria-labelledby="«r8»"
|
||||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||||
data-kind="primary"
|
data-kind="primary"
|
||||||
data-size="lg"
|
data-size="lg"
|
||||||
@@ -279,7 +279,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
aria-disabled="false"
|
aria-disabled="false"
|
||||||
aria-labelledby="«ra»"
|
aria-labelledby="«rd»"
|
||||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||||
data-kind="primary"
|
data-kind="primary"
|
||||||
data-size="lg"
|
data-size="lg"
|
||||||
@@ -301,7 +301,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
aria-labelledby="«rf»"
|
aria-labelledby="«ri»"
|
||||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||||
data-kind="secondary"
|
data-kind="secondary"
|
||||||
data-size="lg"
|
data-size="lg"
|
||||||
@@ -322,7 +322,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
aria-labelledby="«rk»"
|
aria-labelledby="«rn»"
|
||||||
class="_button_vczzf_8 endCall _has-icon_vczzf_57 _icon-only_vczzf_50 _destructive_vczzf_107"
|
class="_button_vczzf_8 endCall _has-icon_vczzf_57 _icon-only_vczzf_50 _destructive_vczzf_107"
|
||||||
data-kind="primary"
|
data-kind="primary"
|
||||||
data-size="lg"
|
data-size="lg"
|
||||||
@@ -348,7 +348,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
|||||||
class="toggle layout"
|
class="toggle layout"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
aria-labelledby="«rp»"
|
aria-labelledby="«rs»"
|
||||||
name="layout"
|
name="layout"
|
||||||
type="radio"
|
type="radio"
|
||||||
value="spotlight"
|
value="spotlight"
|
||||||
@@ -366,7 +366,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<input
|
<input
|
||||||
aria-labelledby="«ru»"
|
aria-labelledby="«r11»"
|
||||||
checked=""
|
checked=""
|
||||||
name="layout"
|
name="layout"
|
||||||
type="radio"
|
type="radio"
|
||||||
|
|||||||
@@ -394,6 +394,9 @@ function getRoomMemberFromRtcMember(
|
|||||||
|
|
||||||
// TODO: Move wayyyy more business logic from the call and lobby views into here
|
// TODO: Move wayyyy more business logic from the call and lobby views into here
|
||||||
export class CallViewModel extends ViewModel {
|
export class CallViewModel extends ViewModel {
|
||||||
|
private readonly userId = this.matrixRoom.client.getUserId();
|
||||||
|
private readonly deviceId = this.matrixRoom.client.getDeviceId();
|
||||||
|
|
||||||
public readonly localVideo$ = this.scope.behavior<LocalVideoTrack | null>(
|
public readonly localVideo$ = this.scope.behavior<LocalVideoTrack | null>(
|
||||||
observeTrackReference$(
|
observeTrackReference$(
|
||||||
this.livekitRoom.localParticipant,
|
this.livekitRoom.localParticipant,
|
||||||
@@ -487,10 +490,33 @@ export class CallViewModel extends ViewModel {
|
|||||||
// Handle room membership changes (and displayname updates)
|
// Handle room membership changes (and displayname updates)
|
||||||
fromEvent(this.matrixRoom, RoomStateEvent.Members),
|
fromEvent(this.matrixRoom, RoomStateEvent.Members),
|
||||||
).pipe(
|
).pipe(
|
||||||
startWith(this.matrixRTCSession.memberships),
|
startWith(null),
|
||||||
map(() => {
|
map(() => this.matrixRTCSession.memberships),
|
||||||
return this.matrixRTCSession.memberships;
|
);
|
||||||
}),
|
|
||||||
|
private readonly matrixRTCConnected$ = this.scope.behavior(
|
||||||
|
this.memberships$.pipe(
|
||||||
|
map((ms) =>
|
||||||
|
ms.some(
|
||||||
|
(m) => m.sender === this.userId && m.deviceId === this.deviceId,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
public readonly reconnecting$ = this.scope.behavior(
|
||||||
|
this.matrixRTCConnected$.pipe(
|
||||||
|
// We are reconnecting if we previously had some successful initial
|
||||||
|
// connection but are now disconnected
|
||||||
|
scan(
|
||||||
|
({ connectedPreviously, reconnecting }, connectedNow) => ({
|
||||||
|
connectedPreviously: connectedPreviously || connectedNow,
|
||||||
|
reconnecting: connectedPreviously && !connectedNow,
|
||||||
|
}),
|
||||||
|
{ connectedPreviously: false, reconnecting: false },
|
||||||
|
),
|
||||||
|
map(({ reconnecting }) => reconnecting),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -787,12 +813,13 @@ export class CallViewModel extends ViewModel {
|
|||||||
|
|
||||||
public readonly allOthersLeft$ = this.matrixUserChanges$.pipe(
|
public readonly allOthersLeft$ = this.matrixUserChanges$.pipe(
|
||||||
map(({ userIds, leftUserIds }) => {
|
map(({ userIds, leftUserIds }) => {
|
||||||
const userId = this.matrixRoom.client.getUserId();
|
if (!this.userId) {
|
||||||
if (!userId) {
|
logger.warn("Could not access user ID to compute allOthersLeft");
|
||||||
logger.warn("Could access client.getUserId to compute allOthersLeft");
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return userIds.size === 1 && userIds.has(userId) && leftUserIds.size > 0;
|
return (
|
||||||
|
userIds.size === 1 && userIds.has(this.userId) && leftUserIds.size > 0
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
startWith(false),
|
startWith(false),
|
||||||
distinctUntilChanged(),
|
distinctUntilChanged(),
|
||||||
@@ -1502,5 +1529,44 @@ export class CallViewModel extends ViewModel {
|
|||||||
>,
|
>,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
|
// Pause all media tracks when we're disconnected from MatrixRTC, because it
|
||||||
|
// can be an unpleasant surprise for the app to say 'reconnecting' and yet
|
||||||
|
// still be transmitting your media to others.
|
||||||
|
this.matrixRTCConnected$.pipe(this.scope.bind()).subscribe((connected) => {
|
||||||
|
const publications =
|
||||||
|
this.livekitRoom.localParticipant.trackPublications.values();
|
||||||
|
if (connected) {
|
||||||
|
for (const p of publications) {
|
||||||
|
if (p.track?.isUpstreamPaused === true) {
|
||||||
|
const kind = p.track.kind;
|
||||||
|
logger.log(`Reconnected to MatrixRTC; resuming ${kind} track`);
|
||||||
|
p.track
|
||||||
|
.resumeUpstream()
|
||||||
|
.catch((e) =>
|
||||||
|
logger.error(
|
||||||
|
`Failed to resume ${kind} track after MatrixRTC reconnection`,
|
||||||
|
e,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const p of publications) {
|
||||||
|
if (p.track?.isUpstreamPaused === false) {
|
||||||
|
const kind = p.track.kind;
|
||||||
|
logger.log(`Lost connection to MatrixRTC; pausing ${kind} track`);
|
||||||
|
p.track
|
||||||
|
.pauseUpstream()
|
||||||
|
.catch((e) =>
|
||||||
|
logger.error(
|
||||||
|
`Failed to pause ${kind} track after MatrixRTC connection loss`,
|
||||||
|
e,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,6 +233,7 @@ export function mockLocalParticipant(
|
|||||||
): LocalParticipant {
|
): LocalParticipant {
|
||||||
return {
|
return {
|
||||||
isLocal: true,
|
isLocal: true,
|
||||||
|
trackPublications: new Map(),
|
||||||
getTrackPublication: () =>
|
getTrackPublication: () =>
|
||||||
({}) as Partial<LocalTrackPublication> as LocalTrackPublication,
|
({}) as Partial<LocalTrackPublication> as LocalTrackPublication,
|
||||||
...mockEmitter(),
|
...mockEmitter(),
|
||||||
|
|||||||
Reference in New Issue
Block a user