Skip to content

feat(client): expose blocked autoplay audio state and explicit resume API#2187

Merged
jdimovska merged 3 commits intomainfrom
autoplay-policy-observable
Apr 6, 2026
Merged

feat(client): expose blocked autoplay audio state and explicit resume API#2187
jdimovska merged 3 commits intomainfrom
autoplay-policy-observable

Conversation

@jdimovska
Copy link
Copy Markdown
Contributor

@jdimovska jdimovska commented Apr 6, 2026

💡 Overview

This PR introduces a way to recover when incoming audio is blocked by browser autoplay rules. It tracks blocked audio, exposes that state to consumers, and adds call.resumeAudio() so they can retry playback after a user interaction. It also includes a test for the case where the audio stream is removed before playback resumes.

📝 Implementation notes

  • invoke call.resumeAudio() for explicit retry from a user gesture
  • use useIsAutoplayBlocked() from call state hooks to detect blocked incoming audio

🎫 Ticket: https://linear.app/stream/issue/REACT-941/audio-autoplay-policy-observable

📑 Docs: https://github.com/GetStream/docs-content/pull/1155

Summary by CodeRabbit

  • New Features

    • Detects browser autoplay-blocked audio and exposes a boolean observable to track it.
    • Provides a public resume operation to retry playback for blocked audio elements.
    • Adds a React hook to surface autoplay-blocked state for UI integration.
  • Tests

    • Adds tests covering detection, recovery, and cleanup of autoplay-blocked audio flows.

@jdimovska jdimovska requested a review from oliverlaz April 6, 2026 13:29
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 6, 2026

⚠️ No Changeset found

Latest commit: c9c931b

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 6, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f9250bd3-408b-4a77-80e9-d3f3783e59d8

📥 Commits

Reviewing files that changed from the base of the PR and between fe502ba and c9c931b.

📒 Files selected for processing (1)
  • packages/client/src/helpers/DynascaleManager.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/client/src/helpers/DynascaleManager.ts

📝 Walkthrough

Walkthrough

DynascaleManager now detects audio playback failures caused by browser autoplay policies, exposes an observable autoplayBlocked$, and can retry playback via resumeAudio(). Call forwards resumeAudio(), and a React hook exposes the blocked state to components; tests cover detection, recovery, and cleanup.

Changes

Cohort / File(s) Summary
Dynascale audio management
packages/client/src/helpers/DynascaleManager.ts
Added tracking of autoplay-blocked HTMLAudioElements via a BehaviorSubject, exposed autoplayBlocked$, helper add/remove methods, updated play error handling to detect NotAllowedError, dispose() cleanup, and added resumeAudio() to retry playback and update the blocked set.
Call API
packages/client/src/Call.ts
Added public resumeAudio() method that delegates to this.dynascaleManager.resumeAudio().
React hook
packages/react-bindings/src/hooks/callStateHooks.ts
Added exported useIsAutoplayBlocked() hook that reads call.dynascaleManager.autoplayBlocked$ via useObservableValue.
Tests
packages/client/src/helpers/__tests__/DynascaleManager.test.ts
Added tests using fake timers covering NotAllowedError detection, autoplayBlocked$ transitions, resumeAudio() retry behavior, and cleanup when audioStream is unset.

Sequence Diagram

sequenceDiagram
    participant App as Application
    participant DM as DynascaleManager
    participant Audio as Audio Element
    participant Browser as Browser Autoplay Policy

    App->>DM: bindAudioElement(element, source)
    DM->>Audio: set srcObject = source
    DM->>Audio: play()
    Audio->>Browser: request playback
    Browser-->>Audio: NotAllowedError (autoplay blocked)
    Audio-->>DM: Promise rejection
    DM->>DM: addBlockedAudioElement(element)
    DM-->>App: autoplayBlocked$ = true

    Note over App: after user interaction

    App->>DM: resumeAudio()
    DM->>Audio: play() for blocked elements
    Audio->>Browser: request playback
    Browser-->>Audio: success
    Audio-->>DM: Promise resolved
    DM->>DM: removeBlockedAudioElement(element)
    DM-->>App: autoplayBlocked$ = false
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 I tracked the quiet elements in a tidy set,

When browsers hush the sound, I do not fret—
I nudge them once more with a gentle cue,
resumeAudio calls — and music returns anew. 🎵

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main changes: exposing blocked autoplay audio state and adding an explicit resume API.
Description check ✅ Passed The description includes all required sections with comprehensive implementation details, ticket reference, and documentation link.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch autoplay-policy-observable

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
packages/client/src/helpers/DynascaleManager.ts (1)

682-707: Consider handling errors more granularly in resumeAudio().

The current implementation catches all errors and adds the element back to the blocked set. However, errors other than NotAllowedError (e.g., AbortError from rapid play/pause sequences) may not indicate the element is truly blocked by autoplay policy.

♻️ Suggested refinement to only re-block on NotAllowedError
   resumeAudio = async () => {
     const blocked = new Set<HTMLAudioElement>();
     await Promise.all(
       Array.from(
         getCurrentValue(this.blockedAudioElementsSubject),
         async (el) => {
           try {
             if (el.srcObject) {
               await el.play();
             }
-          } catch {
-            this.logger.warn(`Can't resume audio for element: `, el);
-            blocked.add(el);
+          } catch (e) {
+            this.logger.warn(`Can't resume audio for element: `, el, e);
+            // Only re-add if still blocked by autoplay policy
+            if (e instanceof DOMException && e.name === 'NotAllowedError') {
+              blocked.add(el);
+            }
           }
         },
       ),
     );
 
     setCurrentValue(this.blockedAudioElementsSubject, blocked);
   };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/client/src/helpers/DynascaleManager.ts` around lines 682 - 707, In
resumeAudio(), don't treat all exceptions as autoplay-blocked: capture the
caught error (e.g., const err = e) inside the async loop in resumeAudio and only
add the element back into blockedAudioElementsSubject when the error indicates
an autoplay block (e.g., err.name === 'NotAllowedError'); for other errors (like
'AbortError') log at debug/trace with this.logger.debug/trace and do not
re-block the element; update references to blockedAudioElementsSubject,
resumeAudio, and this.logger.warn accordingly so only true NotAllowedError cases
are retained in the blocked set.
packages/react-bindings/src/hooks/callStateHooks.ts (1)

499-509: Consider adding defensive handling for missing call context.

The hook uses useCall() as Call without checking if call is defined. While other hooks in this file follow the same pattern, calling this hook outside a <StreamCall> provider will throw when accessing call.dynascaleManager. Consider returning a safe default value when call is undefined, similar to how useCallState() returns an empty CallState.

♻️ Suggested defensive null check
 export const useIsAutoplayBlocked = (): boolean => {
-  const call = useCall() as Call;
-  return useObservableValue(call.dynascaleManager.autoplayBlocked$);
+  const call = useCall();
+  const autoplayBlocked$ = useMemo(
+    () => call?.dynascaleManager.autoplayBlocked$ ?? of(false),
+    [call],
+  );
+  return useObservableValue(autoplayBlocked$);
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-bindings/src/hooks/callStateHooks.ts` around lines 499 - 509,
The hook useIsAutoplayBlocked assumes useCall() returns a Call and directly
accesses call.dynascaleManager.autoplayBlocked$, which will throw if called
outside the provider; update useIsAutoplayBlocked to defensively check for a
missing call (useCall() result) and return a safe default (e.g., false) when
call is undefined, otherwise return
useObservableValue(call.dynascaleManager.autoplayBlocked$); keep the function
signature the same and reference useIsAutoplayBlocked and
call.dynascaleManager.autoplayBlocked$ when making the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/client/src/helpers/DynascaleManager.ts`:
- Around line 682-707: In resumeAudio(), don't treat all exceptions as
autoplay-blocked: capture the caught error (e.g., const err = e) inside the
async loop in resumeAudio and only add the element back into
blockedAudioElementsSubject when the error indicates an autoplay block (e.g.,
err.name === 'NotAllowedError'); for other errors (like 'AbortError') log at
debug/trace with this.logger.debug/trace and do not re-block the element; update
references to blockedAudioElementsSubject, resumeAudio, and this.logger.warn
accordingly so only true NotAllowedError cases are retained in the blocked set.

In `@packages/react-bindings/src/hooks/callStateHooks.ts`:
- Around line 499-509: The hook useIsAutoplayBlocked assumes useCall() returns a
Call and directly accesses call.dynascaleManager.autoplayBlocked$, which will
throw if called outside the provider; update useIsAutoplayBlocked to defensively
check for a missing call (useCall() result) and return a safe default (e.g.,
false) when call is undefined, otherwise return
useObservableValue(call.dynascaleManager.autoplayBlocked$); keep the function
signature the same and reference useIsAutoplayBlocked and
call.dynascaleManager.autoplayBlocked$ when making the change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 213e9f00-f25c-4d6c-89f7-cab06fd9f878

📥 Commits

Reviewing files that changed from the base of the PR and between 5c642ce and fe502ba.

📒 Files selected for processing (4)
  • packages/client/src/Call.ts
  • packages/client/src/helpers/DynascaleManager.ts
  • packages/client/src/helpers/__tests__/DynascaleManager.test.ts
  • packages/react-bindings/src/hooks/callStateHooks.ts

@jdimovska jdimovska merged commit adbec63 into main Apr 6, 2026
39 of 45 checks passed
@jdimovska jdimovska deleted the autoplay-policy-observable branch April 6, 2026 14:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants