Pause media tracks and show a message when reconnecting to MatrixRTC

This commit is contained in:
Robin
2025-08-15 18:38:52 +02:00
parent dc789e63f2
commit f08ae36f9e
6 changed files with 127 additions and 36 deletions

View File

@@ -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",

View File

@@ -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>
); );
}; };

View File

@@ -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} />

View File

@@ -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"

View File

@@ -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,
),
);
}
}
}
});
} }
} }

View File

@@ -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(),