Skip to content

feat(publish): capture="release" closes devices while muted or invisible - #7746

Merged
kixelated merged 1 commit into
mainfrom
feat/publish-capture-release
Nov 4, 2026
Merged

kixelated merged 1 commit into
mainfrom
feat/publish-capture-release

Conversation

@kixelated

Copy link
Copy Markdown
Collaborator

Problem

Since moq-dev/moq#7157, <moq-publish muted> and invisible stop only the encoder and keep the microphone and camera open. That makes unmuting instant, which push-to-talk wants, but the browser's capture indicator stays lit while muted or hidden. A page whose users expect muting to turn the indicator off now has no element-level way to get that back. The only route left is to drop the element and build the pipeline from the library.

Approach

Add a capture attribute and property to <moq-publish>, "hold" (default) or "release" (js/publish/src/element.ts):

  • hold is today's behavior: the sources and the audio capture follow only the element's connection gate.
  • release gates each source on its own track again, the way it worked before feat(publish)!: muted and invisible keep capture open #7157. Source.Microphone and Audio.Capture close while muted, and Source.Camera closes while invisible. A Source.Screen or Source.File carries both tracks, so it stops only once both are off. Resuming reopens the device.

Encoding, the announce gate and the preview blanking still key off muted/invisible, so they behave the same under either mode. An invalid value is refused like the other enumerated attributes.

Docs: the attribute tables in js/publish/README.md and doc/lib/js/publish.md, plus the Unreleased MIGRATE.md entry, which now points at capture="release" instead of the library.

Test (test/interop/clients/js/publish-ui.browser.ts): with capture="release", a camera that starts muted and hidden opens neither device. Muting ends only the microphone track, and unmuting opens a new one. Hiding ends only the camera track.

Impact

  • @moq/publish/element: new capture attribute/property and controls.capture signal, plus the exported CaptureMode type. The default is unchanged, so this is additive.
  • Wire: none.

Alternatives

  • A boolean release attribute. Shorter, but it reads like a version and leaves no room for another policy. I chose the enumerated capture="hold" | "release", which matches announce and preview. Other names I considered: idle="hold" | "release", release-devices.
  • Per-device release from markup (for example capture="release-audio"). No consumer needs it yet; the library already offers it.
  • Revert feat(publish)!: muted and invisible keep capture open #7157's default. That was decided for push-to-talk (quest/m1/push-to-talk/README.md), so hold stays the default.

Checks

  • just ci against origin/main: passed.
  • bun test ./publish-ui.browser.ts in local Chromium (new test, the device-holding test, and the idle-controls test): 3 pass.

Follow-ups

None.

Review

  • Reviewer: Codex (quest review), gpt-6.1-sol, medium effort.
  • Head: fb9daaf.
  • Findings: none.

🤖 Generated with Claude Code

(Written by Claude Opus 5.5)

`<moq-publish muted>` and `invisible` keep capture open by default, so
unmuting is instant but the browser's capture indicator stays lit. The
new `capture` attribute picks that per element: `hold` (default) keeps
the device open, and `release` closes the microphone while muted and the
camera while invisible, stopping a screen share or file once both are
off. Clearing `source` or detaching still releases everything.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 14 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 4 included reviews currently available.

Learn how review limits work.

Review configuration:

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 6e355ec1-5a5a-4531-aaf2-e25eefa8dd70
Commits

Reviewing files that changed from the base of the PR and between 9ac02f9 and fb9daaf.

Files selected for processing: 6
  • doc/lib/js/publish.md
  • js/publish/MIGRATE.md
  • js/publish/README.md
  • js/publish/api/element.api.md
  • js/publish/src/element.ts
  • test/interop/clients/js/publish-ui.browser.ts
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

CodeRabbit Verify

  • Run CodeRabbit Verify to test the changes in this pull request.

@kixelated kixelated left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Automated review by review (OpenAI)

Reviewed head fb9daaf.

P2: Make screen-share resumption gesture-aware. js/publish/src/element.ts:505–508 now disables the screen source when both tracks are off, ending its share. If an application later clears muted or invisible programmatically without a user gesture, Source.Screen attempts getDisplayMedia again, which requires user activation; it catches the failure and leaves the source empty, with no retry while the gate remains enabled. The advertised automatic resumption therefore silently fails. Please provide a user-gesture restart path and surface the need to reselect the screen, and document the browser prompt requirement; add a test for release followed by a non-gesture resume.

Verification: GitHub-only static review; I did not run browser tests. The PR reports just ci and three local Chromium tests passing.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ℹ️ No blocking issues. Two small test-coverage gaps.

Reviewed changes

  • capture attribute and CaptureMode — parseCapture, the controls.capture signal, attribute observation/reflection, and the getter/setter, with api/element.api.md regenerated.
  • Per-track capture gates — new #videoCapture/#audioCapture/#eitherCapture signals split capture from encoding: Camera/Microphone gate on their own track under release, Screen/File on either, and Audio.Capture moves to #audioCapture.
  • Docs — attribute tables in doc/lib/js/publish.md and js/publish/README.md, plus the MIGRATE.md entry pointing at capture="release".
  • Test — new publish-ui.browser.ts case for a camera releasing its devices while muted/hidden.

I traced the wiring: hold reduces to the old #enabled gate, so the default path is unchanged, and getter(signal) preserves reactivity into each source. The implementation is sound.

ℹ️ New capture attribute is not exercised by the invalid-value test table

capture refuses an invalid value exactly like source/preview/backend/announce, but it is absent from the parametrized refusal table in publish-ui.browser.ts, so the new parser's throw/suspend/recover path has no coverage.

Technical details
# Add capture to the invalid-refusal table

## Affected sites
- `test/interop/clients/js/publish-ui.browser.ts:559` — the `cases` array enumerates moq-publish `source`, `preview`, `backend`, and `announce`; `capture` is missing.

## Required outcome
- An invalid `capture` value throws from the setter, suspends the element, retains the bad markup attribute, and recovers when corrected, matching the other enumerated attributes.

## Suggested approach (optional)
- Add `["moq-publish", "capture", "sometimes", "capture", "hold"]` to `cases`.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

Comment on lines +396 to +400
expect(
await publication.evaluate(({ publish }) =>
[publish.sources.audio.peek(), publish.sources.video.peek()].map((source) => source?.in.enabled.peek()),
),
).toEqual([false, false]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
expect(
await publication.evaluate(({ publish }) =>
[publish.sources.audio.peek(), publish.sources.video.peek()].map((source) => source?.in.enabled.peek()),
),
).toEqual([false, false]);
expect(
await publication.evaluate(({ publish }) => {
const audio = publish.sources.audio.peek();
const video = publish.sources.video.peek();
return {
inputs: [audio?.in.enabled.peek(), video?.in.enabled.peek()],
outputs: [audio?.out.source.peek(), video?.out.source.peek()],
};
}),
).toEqual({ inputs: [false, false], outputs: [undefined, undefined] });

@kixelated
kixelated added this pull request to the merge queue Nov 3, 2026
Merged via the queue into main with commit 89280fe Nov 4, 2026
6 checks passed
@kixelated
kixelated deleted the feat/publish-capture-release branch November 4, 2026 00:49
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.

1 participant