diff --git a/joinly/providers/browser/camera_feed.py b/joinly/providers/browser/camera_feed.py index e6695f0..d0120ed 100644 --- a/joinly/providers/browser/camera_feed.py +++ b/joinly/providers/browser/camera_feed.py @@ -140,6 +140,26 @@ } }""" +# Interrupted: dots scatter outward from center and fade +_FX_INTERRUPTED = """\ +function fxInterrupted(ctx, cx, y, t, alpha) { + const N = 5, r = H * 0.007; + for (let i = 0; i < N; i++) { + const angle = (i / N) * Math.PI * 2 + t * 1.5; + const p = (t * 2.5 + i / N) % 1; + const spread = H * 0.01 + p * H * 0.04; + const dx = Math.cos(angle) * spread; + const dy = Math.sin(angle) * spread * 0.5; + const fade = (1 - p) * alpha; + if (fade < 0.01) continue; + ctx.globalAlpha = fade; + ctx.fillStyle = '#ffffff'; + ctx.beginPath(); + ctx.arc(cx + dx, y + dy, r * (1 - p * 0.5), 0, Math.PI * 2); + ctx.fill(); + } +}""" + # Thinking: rotating arc segments with soft glow around the logo _FX_THINKING = """\ function fxThinking(ctx, cx, cy, logoW, logoH, t, alpha) { @@ -284,6 +304,7 @@ {fx_typing} {fx_share} {fx_reading} + {fx_interrupted} {fx_thinking} {fx_busy} @@ -291,6 +312,7 @@ send_chat_message: fxTyping, get_chat_history: fxReading, get_participants: fxReading, + interrupted: fxInterrupted, busy: fxBusy, }}; @@ -484,6 +506,7 @@ async def install(self, meeting_page: Page) -> None: fx_typing=_FX_TYPING, fx_share=_FX_SHARE, fx_reading=_FX_READING, + fx_interrupted=_FX_INTERRUPTED, fx_thinking=_FX_THINKING, fx_busy=_FX_BUSY, ) diff --git a/joinly/providers/browser/meeting_provider.py b/joinly/providers/browser/meeting_provider.py index d5e23f8..ad058ea 100644 --- a/joinly/providers/browser/meeting_provider.py +++ b/joinly/providers/browser/meeting_provider.py @@ -30,6 +30,7 @@ MeetingChatHistory, MeetingParticipant, ProviderNotSupportedError, + SpeechInterruptedError, UIAnimationContent, UIHtmlContent, UIUpdate, @@ -210,6 +211,16 @@ async def _action_guard( finally: self._camera_feed.set_effect(None) + @asynccontextmanager + async def speech_guard(self) -> AsyncIterator[None]: + """Context manager that sets the interrupted camera status on interruption.""" + try: + yield + except SpeechInterruptedError: + self._camera_feed.set_effect("interrupted") + self._camera_feed.set_effect(None) + raise + async def _get_platform_controller(self, url: str) -> BrowserPlatformController: """Get the platform-specific meeting controller based on the URL. diff --git a/joinly/session.py b/joinly/session.py index 51001cf..f00b107 100644 --- a/joinly/session.py +++ b/joinly/session.py @@ -129,7 +129,12 @@ async def speak_text(self, text: str) -> None: Args: text (str): The text to be spoken. """ - await self._speech_controller.speak_text(text) + guard = getattr(self._meeting_provider, "speech_guard", None) + if guard is not None: + async with guard(): + await self._speech_controller.speak_text(text) + else: + await self._speech_controller.speak_text(text) async def send_chat_message(self, message: str) -> None: """Send a chat message in the meeting.