From f50f50be720b4fe52ec9cee0da23310e90732c7b Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sun, 13 Sep 2026 01:26:34 -0400 Subject: [PATCH] feat(call): host half of the 0.25.0-lotus.2 fork changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/app/features/call-status/MemberGlance.tsx | 7 ++++- src/app/features/call/CallSoundboard.tsx | 13 +++++++- .../features/lotus/LotusDecorationPusher.tsx | 5 +++ src/app/plugins/call/CallControl.ts | 31 ++++++++++++++++--- src/app/plugins/call/CallEmbed.ts | 22 +++++++++++++ 5 files changed, 72 insertions(+), 6 deletions(-) diff --git a/src/app/features/call-status/MemberGlance.tsx b/src/app/features/call-status/MemberGlance.tsx index 7e803caea..1461a3ac5 100644 --- a/src/app/features/call-status/MemberGlance.tsx +++ b/src/app/features/call-status/MemberGlance.tsx @@ -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); } }; diff --git a/src/app/features/call/CallSoundboard.tsx b/src/app/features/call/CallSoundboard.tsx index 3fcdf60c1..6c81141a1 100644 --- a/src/app/features/call/CallSoundboard.tsx +++ b/src/app/features/call/CallSoundboard.tsx @@ -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 }); diff --git a/src/app/features/lotus/LotusDecorationPusher.tsx b/src/app/features/lotus/LotusDecorationPusher.tsx index b0a7b8e6a..99eb39886 100644 --- a/src/app/features/lotus/LotusDecorationPusher.tsx +++ b/src/app/features/lotus/LotusDecorationPusher.tsx @@ -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); diff --git a/src/app/plugins/call/CallControl.ts b/src/app/plugins/call/CallControl.ts index 447485cfc..cbf2ff894 100644 --- a/src/app/plugins/call/CallControl.ts +++ b/src/app/plugins/call/CallControl.ts @@ -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 })); } /** diff --git a/src/app/plugins/call/CallEmbed.ts b/src/app/plugins/call/CallEmbed.ts index cc54748b0..aec4d8efc 100644 --- a/src/app/plugins/call/CallEmbed.ts +++ b/src/app/plugins/call/CallEmbed.ts @@ -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 () => {