feat(call): host half of the 0.25.0-lotus.2 fork changes

- io.lotus.request_state: when the fork's lotus handlers (re)register
  (an EC-side remount that doesn't unmount us) we re-send deafen, quality
  and the focus pin, and the decoration pusher re-pushes its roster —
  decorations and the pin no longer vanish for the rest of the call
  (element-call#17).
- focus_participant carries the per-device media id from call_state
  (speaking device preferred) so a multi-device user pins the right
  device (element-call#30).
- injectAudio returns the fork's reply; when it refuses with
  reason:"muted" the soundboard shows "Unmute your microphone…" instead
  of playing the clip locally as if it went out (element-call#13).

All backwards compatible with the 0.25.0-lotus.1 bundle (unknown action
is acked; missing reply fields default to "played").

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-13 01:26:34 -04:00
co-authored by Claude Opus 5
parent 039b74c2b9
commit f50f50be72
5 changed files with 72 additions and 6 deletions
@@ -71,7 +71,12 @@ function ParticipantMenu({
if (isFocused) {
callEmbed?.control.clearFocusParticipant();
} else {
callEmbed?.control.focusCameraParticipant(userId);
// [EC#30] Pass the fork's per-device media id when we have one (from
// io.lotus.call_state) so a multi-device user pins the active device.
const parts = callEmbed?.getLotusParticipants() ?? [];
const mine = parts.filter((p) => p.userId === userId);
const pick = mine.find((p) => p.speaking) ?? mine.find((p) => p.audioEnabled) ?? mine[0];
callEmbed?.control.focusCameraParticipant(userId, pick?.id ?? null);
}
};
+12 -1
View File
@@ -115,7 +115,18 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
try {
const url = await resolveClipObjectUrl(mx, flat.clip.url);
const vol = (flat.clip.volume / 100) * master;
callEmbed.control.injectAudio(url, vol);
const result = await callEmbed.control.injectAudio(url, vol);
if (!result.played) {
// [EC#13] Refused fork-side (only reason today: local mic muted) —
// don't play it locally either, or the user would think it went out.
setError(
result.reason === 'muted'
? 'Unmute your microphone to play a soundboard clip.'
: 'Could not play that clip.',
);
done();
return;
}
const audio = playClipLocally(url, vol);
if (audio) {
audio.addEventListener('ended', done, { once: true });
@@ -51,6 +51,11 @@ export function LotusDecorationPusher({ callEmbed }: { callEmbed: CallEmbed }):
pushTimer.current = setTimeout(push, 300);
}, [push]);
// [Gitea #17] The fork asks for a re-push when its decoration handler
// (re)registers; our map hasn't changed, so the change-driven push above
// would never fire on its own.
useEffect(() => callEmbed.onForkStateRequest(schedulePush), [callEmbed, schedulePush]);
const onResolve = useCallback(
(userId: string, url: string | null) => {
const prev = map.current.get(userId);
+27 -4
View File
@@ -38,6 +38,8 @@ export class CallControl extends EventEmitter implements CallControlState {
// instead of a one-way pin with no way back. null == no manual pin (speaker-follows).
private _focusedUserId: string | null = null;
private _focusedMediaId: string | null = null;
// 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
// pre-join send pends to a 10s widget timeout, mirroring the deafen gate).
@@ -183,6 +185,8 @@ export class CallControl extends EventEmitter implements CallControlState {
public resendForkState(): void {
this.sendDeafenState();
this.sendQuality();
// [Gitea #17] The pin lives fork-side and is dropped on a handler remount.
if (this._focusedUserId !== null) this.sendFocus(this._focusedUserId, this._focusedMediaId);
}
public startObserving() {
@@ -449,13 +453,23 @@ export class CallControl extends EventEmitter implements CallControlState {
return this._focusedUserId;
}
public focusCameraParticipant(userId: string): void {
private sendFocus(userId: string, id: string | null): void {
// [EC#30] `id` is the fork's per-device media id (userId:deviceId) taken
// from io.lotus.call_state, so a multi-device user pins the right device;
// the fork falls back to userId (preferring the speaking device) when absent.
this.call.transport
.send('io.lotus.focus_participant', id ? { userId, id } : { userId })
.catch(() => undefined);
}
public focusCameraParticipant(userId: string, id: string | null = null): void {
// [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
// alongside a screenshare (A5) — and it's version-stable. The fork always
// acks, so the promise resolves regardless.
this._focusedUserId = userId;
this.call.transport.send('io.lotus.focus_participant', { userId }).catch(() => undefined);
this._focusedMediaId = id;
this.sendFocus(userId, id);
// [Gitea #56] Notify state-update listeners so the menu can flip to "Unfocus camera".
this.emitStateUpdate();
}
@@ -466,6 +480,7 @@ export class CallControl extends EventEmitter implements CallControlState {
// dispose() calls this unconditionally on every call teardown.
if (this._focusedUserId === null) return;
this._focusedUserId = null;
this._focusedMediaId = null;
this.call.transport.send('io.lotus.focus_participant', { userId: null }).catch(() => undefined);
this.emitStateUpdate();
}
@@ -481,8 +496,16 @@ export class CallControl extends EventEmitter implements CallControlState {
* The local user does not hear their own published track, so callers should
* also play the clip locally for feedback.
*/
public injectAudio(url: string, volume = 1): void {
this.call.transport.send('io.lotus.inject_audio', { url, volume }).catch(() => undefined);
public injectAudio(url: string, volume = 1): Promise<{ played: boolean; reason?: string }> {
// [EC#13] The fork now refuses while the local mic is muted and replies
// { played:false, reason:"muted" }; older forks reply {} (treated as played).
return this.call.transport
.send<{ url: string; volume: number }, { played?: boolean; reason?: string }>(
'io.lotus.inject_audio',
{ url, volume },
)
.then((r) => ({ played: r?.played !== false, reason: r?.reason }))
.catch(() => ({ played: true }));
}
/**
+22
View File
@@ -68,6 +68,9 @@ export class CallEmbed {
private lotusCallStateListeners = new Set<() => void>();
// [Gitea #17] Listeners notified when the fork sends io.lotus.request_state.
private forkStateRequestListeners = new Set<() => void>();
public readonly control: CallControl;
private readonly container: HTMLElement;
@@ -373,6 +376,17 @@ export class CallEmbed {
// [lotus #2] Consume the fork's per-participant call-state stream. listenAction
// auto-replies {} so the fork's transport doesn't time out. Stored for the
// speaker/mute hooks (which prefer this over DOM scraping).
// [Gitea #17 / EC#17] The fork asks for a full state re-push whenever its
// lotus handlers (re)register — e.g. an EC-side InCallView remount that
// does not unmount us. Re-send the sticky fork state (deafen/quality/focus)
// and let the decoration pusher re-send its roster.
this.disposables.push(
this.listenAction('io.lotus.request_state', () => {
if (!this.joined) return;
this.control.resendForkState();
this.forkStateRequestListeners.forEach((l) => l());
}),
);
this.disposables.push(
this.listenAction('io.lotus.call_state', (evt) => {
const data = (evt.detail as { data?: { participants?: unknown } } | undefined)?.data;
@@ -706,6 +720,14 @@ export class CallEmbed {
}
/** [lotus #2] Subscribe to io.lotus.call_state updates. Returns an unsubscribe. */
/** [Gitea #17] Subscribe to the fork's io.lotus.request_state. Returns an unsubscribe. */
public onForkStateRequest(cb: () => void): () => void {
this.forkStateRequestListeners.add(cb);
return () => {
this.forkStateRequestListeners.delete(cb);
};
}
public onLotusCallState(cb: () => void): () => void {
this.lotusCallStateListeners.add(cb);
return () => {