fix(calls): "Focus camera" toggles to "Unfocus"; pin cleared on dispose; drop dead setPipMode
clearFocusParticipant() had no callers, so a spotlight pin was permanent. CallControl now tracks focusedUserId, the member menu toggles, and dispose() clears the pin. Removes _pipMode/setPipMode (never read). Fixes #56 Fixes #59 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { Box, config, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
|
import { Box, config, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
|
||||||
import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
|
import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
|
||||||
import React, { useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { Room } from 'matrix-js-sdk';
|
import { Room } from 'matrix-js-sdk';
|
||||||
import { UserAvatar } from '../../components/user-avatar';
|
import { UserAvatar } from '../../components/user-avatar';
|
||||||
@@ -12,8 +12,31 @@ import { StackedAvatar } from '../../components/stacked-avatar';
|
|||||||
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { CallEmbed } from '../../plugins/call/CallEmbed';
|
import { CallEmbed } from '../../plugins/call/CallEmbed';
|
||||||
|
import { CallControlEvent } from '../../plugins/call/CallControl';
|
||||||
import * as css from './styles.css';
|
import * as css from './styles.css';
|
||||||
|
|
||||||
|
// [Gitea #56] Subscribes to CallControl's focus pin so the menu can render a
|
||||||
|
// "Focus camera" / "Unfocus camera" toggle instead of a one-way pin.
|
||||||
|
function useFocusedUserId(callEmbed?: CallEmbed): string | null {
|
||||||
|
const control = callEmbed?.control;
|
||||||
|
const [focusedUserId, setFocusedUserId] = useState<string | null>(control?.focusedUserId ?? null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!control) {
|
||||||
|
setFocusedUserId(null);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
setFocusedUserId(control.focusedUserId);
|
||||||
|
const handleUpdate = () => setFocusedUserId(control.focusedUserId);
|
||||||
|
control.on(CallControlEvent.StateUpdate, handleUpdate);
|
||||||
|
return () => {
|
||||||
|
control.off(CallControlEvent.StateUpdate, handleUpdate);
|
||||||
|
};
|
||||||
|
}, [control]);
|
||||||
|
|
||||||
|
return focusedUserId;
|
||||||
|
}
|
||||||
|
|
||||||
type ParticipantMenuProps = {
|
type ParticipantMenuProps = {
|
||||||
anchor: RectCords;
|
anchor: RectCords;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -33,15 +56,23 @@ function ParticipantMenu({
|
|||||||
profileCords,
|
profileCords,
|
||||||
}: ParticipantMenuProps) {
|
}: ParticipantMenuProps) {
|
||||||
const openUserProfile = useOpenUserRoomProfile();
|
const openUserProfile = useOpenUserRoomProfile();
|
||||||
|
const focusedUserId = useFocusedUserId(callEmbed);
|
||||||
|
const isFocused = focusedUserId === userId;
|
||||||
|
|
||||||
const handleViewProfile = () => {
|
const handleViewProfile = () => {
|
||||||
onClose();
|
onClose();
|
||||||
openUserProfile(room.roomId, undefined, userId, profileCords, 'Top');
|
openUserProfile(room.roomId, undefined, userId, profileCords, 'Top');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// [Gitea #56] Toggle: focusing the already-focused participant clears the
|
||||||
|
// pin and returns EC to speaker-follows, instead of leaving no way back.
|
||||||
const handleFocusCamera = () => {
|
const handleFocusCamera = () => {
|
||||||
onClose();
|
onClose();
|
||||||
callEmbed?.control.focusCameraParticipant(userId);
|
if (isFocused) {
|
||||||
|
callEmbed?.control.clearFocusParticipant();
|
||||||
|
} else {
|
||||||
|
callEmbed?.control.focusCameraParticipant(userId);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -78,7 +109,7 @@ function ParticipantMenu({
|
|||||||
before={<Icon size="100" src={Icons.VideoCamera} />}
|
before={<Icon size="100" src={Icons.VideoCamera} />}
|
||||||
onClick={handleFocusCamera}
|
onClick={handleFocusCamera}
|
||||||
>
|
>
|
||||||
<Text size="B300">Focus camera</Text>
|
<Text size="B300">{isFocused ? 'Unfocus camera' : 'Focus camera'}</Text>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
<MenuItem
|
<MenuItem
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||||||
// re-observe pass so a busy EC re-render doesn't thrash the control observer.
|
// re-observe pass so a busy EC re-render doesn't thrash the control observer.
|
||||||
private bodyMutationTimer?: ReturnType<typeof setTimeout>;
|
private bodyMutationTimer?: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
private _pipMode = false;
|
// [Gitea #56] Tracks the participant currently pinned via focusCameraParticipant(),
|
||||||
|
// so callers (MemberGlance) can render a "Focus camera" / "Unfocus camera" toggle
|
||||||
|
// instead of a one-way pin with no way back. null == no manual pin (speaker-follows).
|
||||||
|
private _focusedUserId: string | null = null;
|
||||||
|
|
||||||
// C-M3: last quality payload requested via setQuality(). Held so we can (re)send
|
// C-M3: last quality payload requested via setQuality(). Held so we can (re)send
|
||||||
// it once joined (io.lotus.set_quality must not be sent before call-join — a
|
// it once joined (io.lotus.set_quality must not be sent before call-join — a
|
||||||
@@ -422,10 +425,6 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||||||
this.spotlightButton?.click();
|
this.spotlightButton?.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
public setPipMode(pip: boolean) {
|
|
||||||
this._pipMode = pip;
|
|
||||||
}
|
|
||||||
|
|
||||||
public toggleReactions() {
|
public toggleReactions() {
|
||||||
this.reactionsButton?.click();
|
this.reactionsButton?.click();
|
||||||
}
|
}
|
||||||
@@ -446,17 +445,29 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||||||
* participant has their camera off and EC didn't render a video tile for
|
* participant has their camera off and EC didn't render a video tile for
|
||||||
* them yet).
|
* them yet).
|
||||||
*/
|
*/
|
||||||
|
public get focusedUserId(): string | null {
|
||||||
|
return this._focusedUserId;
|
||||||
|
}
|
||||||
|
|
||||||
public focusCameraParticipant(userId: string): void {
|
public focusCameraParticipant(userId: string): void {
|
||||||
// [lotus #4] Pin the participant via the fork's widget action instead of
|
// [lotus #4] Pin the participant via the fork's widget action instead of
|
||||||
// DOM-poking tiles. EC's layout honors it — including surfacing the camera
|
// DOM-poking tiles. EC's layout honors it — including surfacing the camera
|
||||||
// alongside a screenshare (A5) — and it's version-stable. The fork always
|
// alongside a screenshare (A5) — and it's version-stable. The fork always
|
||||||
// acks, so the promise resolves regardless.
|
// acks, so the promise resolves regardless.
|
||||||
|
this._focusedUserId = userId;
|
||||||
this.call.transport.send('io.lotus.focus_participant', { userId }).catch(() => undefined);
|
this.call.transport.send('io.lotus.focus_participant', { userId }).catch(() => undefined);
|
||||||
|
// [Gitea #56] Notify state-update listeners so the menu can flip to "Unfocus camera".
|
||||||
|
this.emitStateUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** [lotus #4] Clear any manual spotlight pin and return to speaker-follows. */
|
/** [lotus #4] Clear any manual spotlight pin and return to speaker-follows. */
|
||||||
public clearFocusParticipant(): void {
|
public clearFocusParticipant(): void {
|
||||||
|
// [Gitea #56] No-op (and no redundant widget send) if nothing is pinned —
|
||||||
|
// dispose() calls this unconditionally on every call teardown.
|
||||||
|
if (this._focusedUserId === null) return;
|
||||||
|
this._focusedUserId = null;
|
||||||
this.call.transport.send('io.lotus.focus_participant', { userId: null }).catch(() => undefined);
|
this.call.transport.send('io.lotus.focus_participant', { userId: null }).catch(() => undefined);
|
||||||
|
this.emitStateUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -502,6 +513,8 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||||||
clearTimeout(this.bodyMutationTimer);
|
clearTimeout(this.bodyMutationTimer);
|
||||||
this.bodyMutationTimer = undefined;
|
this.bodyMutationTimer = undefined;
|
||||||
}
|
}
|
||||||
|
// [Gitea #56] Don't let a manual focus pin outlive the call.
|
||||||
|
this.clearFocusParticipant();
|
||||||
this.bodyMutationObserver.disconnect();
|
this.bodyMutationObserver.disconnect();
|
||||||
this.controlMutationObserver.disconnect();
|
this.controlMutationObserver.disconnect();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user