lotus(#4): focus-participant spotlight via widget action
Adds io.lotus.focus_participant (toWidget): the host can pin a participant to the spotlight by Matrix user id (or clear with userId:null), via a manual override injected into CallViewModel.spotlightSpeaker$. Replaces cinny's fragile DOM .click() tile-selector focus hack. Extracts the action enum into lotusActions.ts (no circular import) and allow-lists Lotus toWidget actions in initializeWidget. Additive: no-op unless the host sends the action. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
444af5f7ac
commit
9d7784b9bc
@@ -0,0 +1,26 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Lotus Guild
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom widget actions exchanged between the Lotus host (cinny) and this fork.
|
||||||
|
* Kept dependency-free so both `widget.ts` and `lotus/*` can import it without
|
||||||
|
* a circular dependency.
|
||||||
|
*/
|
||||||
|
export enum LotusWidgetActions {
|
||||||
|
/** fromWidget: in-call per-participant speaking / mute state. */
|
||||||
|
CallState = "io.lotus.call_state",
|
||||||
|
/** toWidget: pin/spotlight (or clear, with userId=null) a participant. */
|
||||||
|
FocusParticipant = "io.lotus.focus_participant",
|
||||||
|
/** toWidget: mix an audio clip into the local published mic track. */
|
||||||
|
InjectAudio = "io.lotus.inject_audio",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** toWidget Lotus actions that must be allow-listed in `initializeWidget`. */
|
||||||
|
export const LOTUS_TO_WIDGET_ACTIONS: LotusWidgetActions[] = [
|
||||||
|
LotusWidgetActions.FocusParticipant,
|
||||||
|
LotusWidgetActions.InjectAudio,
|
||||||
|
];
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Lotus Guild
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type IWidgetApiRequest } from "matrix-widget-api";
|
||||||
|
|
||||||
|
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||||
|
import { widget } from "../widget";
|
||||||
|
import { LotusWidgetActions } from "./lotusActions";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the host's `io.lotus.focus_participant` toWidget action (#4): pin a
|
||||||
|
* participant to the spotlight by Matrix user id, or clear it with
|
||||||
|
* `{ userId: null }`. This replaces cinny's old DOM `.click()` tile-selector
|
||||||
|
* hack with a real, layout-aware spotlight override.
|
||||||
|
*
|
||||||
|
* No effect unless the host actually sends the action, so registering the
|
||||||
|
* handler whenever we're a widget is safe. Returns a teardown function.
|
||||||
|
*/
|
||||||
|
export function startLotusFocus(vm: CallViewModel): () => void {
|
||||||
|
const w = widget;
|
||||||
|
if (!w) return () => undefined;
|
||||||
|
|
||||||
|
const handler = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
||||||
|
// Always reply so the host transport doesn't time out.
|
||||||
|
void w.api.transport.reply(ev.detail, {});
|
||||||
|
const data = ev.detail.data as { userId?: unknown } | undefined;
|
||||||
|
const userId = typeof data?.userId === "string" ? data.userId : null;
|
||||||
|
vm.setManualSpotlight(userId);
|
||||||
|
};
|
||||||
|
|
||||||
|
w.lazyActions.on(LotusWidgetActions.FocusParticipant, handler);
|
||||||
|
return () =>
|
||||||
|
w.lazyActions.off(LotusWidgetActions.FocusParticipant, handler);
|
||||||
|
}
|
||||||
@@ -17,16 +17,9 @@ Please see LICENSE in the repository root for full details.
|
|||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
|
||||||
import { widget } from "../widget";
|
import { widget } from "../widget";
|
||||||
|
import { LotusWidgetActions } from "./lotusActions";
|
||||||
|
|
||||||
/** Custom widget actions used between the Lotus host and this fork. */
|
export { LotusWidgetActions } from "./lotusActions";
|
||||||
export enum LotusWidgetActions {
|
|
||||||
/** fromWidget: in-call per-participant speaking / mute state. */
|
|
||||||
CallState = "io.lotus.call_state",
|
|
||||||
/** toWidget: pin/spotlight (or clear) a participant. */
|
|
||||||
FocusParticipant = "io.lotus.focus_participant",
|
|
||||||
/** toWidget: mix an audio clip into the local published mic track. */
|
|
||||||
InjectAudio = "io.lotus.inject_audio",
|
|
||||||
}
|
|
||||||
|
|
||||||
let cachedParams: URLSearchParams | undefined;
|
let cachedParams: URLSearchParams | undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { HeaderStyle, useUrlParams } from "../UrlParams";
|
|||||||
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
|
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
|
||||||
import { widget } from "../widget";
|
import { widget } from "../widget";
|
||||||
import { startLotusCallState } from "../lotus/lotusCallState";
|
import { startLotusCallState } from "../lotus/lotusCallState";
|
||||||
|
import { startLotusFocus } from "../lotus/lotusFocus";
|
||||||
import styles from "./InCallView.module.css";
|
import styles from "./InCallView.module.css";
|
||||||
import { GridTile } from "../tile/GridTile";
|
import { GridTile } from "../tile/GridTile";
|
||||||
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
|
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
|
||||||
@@ -283,6 +284,9 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
// [lotus] Stream per-participant speaking/mute state to the host (cinny) over
|
// [lotus] Stream per-participant speaking/mute state to the host (cinny) over
|
||||||
// the widget API when opted in via lotusCallState=1. No-op otherwise.
|
// the widget API when opted in via lotusCallState=1. No-op otherwise.
|
||||||
useEffect(() => startLotusCallState(vm), [vm]);
|
useEffect(() => startLotusCallState(vm), [vm]);
|
||||||
|
// [lotus] Handle the host's io.lotus.focus_participant action to pin a
|
||||||
|
// participant to the spotlight (#4). No-op unless the host sends it.
|
||||||
|
useEffect(() => startLotusFocus(vm), [vm]);
|
||||||
|
|
||||||
const fatalCallError = useBehavior(vm.fatalError$);
|
const fatalCallError = useBehavior(vm.fatalError$);
|
||||||
// Stop the rendering and throw for the error boundary
|
// Stop the rendering and throw for the error boundary
|
||||||
|
|||||||
@@ -255,6 +255,11 @@ export interface CallViewModel {
|
|||||||
* Callback to toggle screen sharing. If null, screen sharing is not possible.
|
* Callback to toggle screen sharing. If null, screen sharing is not possible.
|
||||||
*/
|
*/
|
||||||
toggleScreenSharing: (() => void) | null;
|
toggleScreenSharing: (() => void) | null;
|
||||||
|
/**
|
||||||
|
* [lotus] Pin a participant to the spotlight by Matrix user id (#4
|
||||||
|
* focus-participant). Pass null to clear and restore speaker-follows.
|
||||||
|
*/
|
||||||
|
setManualSpotlight: (userId: string | null) => void;
|
||||||
/**
|
/**
|
||||||
* Whether we are sharing our screen.
|
* Whether we are sharing our screen.
|
||||||
*/
|
*/
|
||||||
@@ -897,7 +902,7 @@ export function createCallViewModel$(
|
|||||||
merge(userHangup$, widgetHangup$).pipe(map(() => "user" as const)),
|
merge(userHangup$, widgetHangup$).pipe(map(() => "user" as const)),
|
||||||
).pipe(scope.share);
|
).pipe(scope.share);
|
||||||
|
|
||||||
const spotlightSpeaker$ = scope.behavior<UserMediaViewModel | undefined>(
|
const autoSpotlightSpeaker$ = scope.behavior<UserMediaViewModel | undefined>(
|
||||||
userMedia$.pipe(
|
userMedia$.pipe(
|
||||||
switchMap((mediaItems) =>
|
switchMap((mediaItems) =>
|
||||||
mediaItems.length === 0
|
mediaItems.length === 0
|
||||||
@@ -933,6 +938,29 @@ export function createCallViewModel$(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// [lotus] Manual spotlight override (#4 focus-participant widget action).
|
||||||
|
// When set to a userId still present in the call, that participant is
|
||||||
|
// spotlighted instead of the auto-selected active speaker; null restores the
|
||||||
|
// default speaker-follows behaviour. Defaults to null, so without the host
|
||||||
|
// using io.lotus.focus_participant this is a no-op.
|
||||||
|
const manualSpotlightUserId$ = new BehaviorSubject<string | null>(null);
|
||||||
|
const spotlightSpeaker$ = scope.behavior<UserMediaViewModel | undefined>(
|
||||||
|
combineLatest([
|
||||||
|
autoSpotlightSpeaker$,
|
||||||
|
manualSpotlightUserId$,
|
||||||
|
userMedia$,
|
||||||
|
]).pipe(
|
||||||
|
map(([auto, manualUserId, mediaItems]) => {
|
||||||
|
if (manualUserId !== null) {
|
||||||
|
const pinned = mediaItems.find((m) => m.userId === manualUserId);
|
||||||
|
if (pinned) return pinned;
|
||||||
|
}
|
||||||
|
return auto;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
const grid$ = scope.behavior<UserMediaViewModel[]>(
|
const grid$ = scope.behavior<UserMediaViewModel[]>(
|
||||||
userMedia$.pipe(
|
userMedia$.pipe(
|
||||||
switchMap((mediaItems) => {
|
switchMap((mediaItems) => {
|
||||||
@@ -1699,6 +1727,8 @@ export function createCallViewModel$(
|
|||||||
join: localMembership.requestJoinAndPublish,
|
join: localMembership.requestJoinAndPublish,
|
||||||
leave: localMembership.requestDisconnect,
|
leave: localMembership.requestDisconnect,
|
||||||
toggleScreenSharing: toggleScreenSharing,
|
toggleScreenSharing: toggleScreenSharing,
|
||||||
|
setManualSpotlight: (userId: string | null): void =>
|
||||||
|
manualSpotlightUserId$.next(userId),
|
||||||
sharingScreen$: sharingScreen$,
|
sharingScreen$: sharingScreen$,
|
||||||
|
|
||||||
tapScreen: (): void => screenTap$.next(),
|
tapScreen: (): void => screenTap$.next(),
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { LazyEventEmitter } from "./LazyEventEmitter";
|
|||||||
import { getUrlParams } from "./UrlParams";
|
import { getUrlParams } from "./UrlParams";
|
||||||
import { Config } from "./config/Config";
|
import { Config } from "./config/Config";
|
||||||
import { ElementCallReactionEventType } from "./reactions";
|
import { ElementCallReactionEventType } from "./reactions";
|
||||||
|
import { LOTUS_TO_WIDGET_ACTIONS } from "./lotus/lotusActions";
|
||||||
|
|
||||||
// Subset of the actions in element-web
|
// Subset of the actions in element-web
|
||||||
export enum ElementWidgetActions {
|
export enum ElementWidgetActions {
|
||||||
@@ -103,6 +104,8 @@ export const initializeWidget = (
|
|||||||
ElementWidgetActions.JoinCall,
|
ElementWidgetActions.JoinCall,
|
||||||
ElementWidgetActions.HangupCall,
|
ElementWidgetActions.HangupCall,
|
||||||
ElementWidgetActions.DeviceMute,
|
ElementWidgetActions.DeviceMute,
|
||||||
|
// [lotus] custom toWidget actions handled by the fork (focus, audio-inject)
|
||||||
|
...LOTUS_TO_WIDGET_ACTIONS,
|
||||||
].forEach((action) => {
|
].forEach((action) => {
|
||||||
api.on(`action:${action}`, (ev: CustomEvent<IWidgetApiRequest>) => {
|
api.on(`action:${action}`, (ev: CustomEvent<IWidgetApiRequest>) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
|
|||||||
Reference in New Issue
Block a user