Skip to content

Commit cae75ac

Browse files
kixelatedclaude
andauthored
watch: stop audio/video downloads when muted, paused, or off-screen (#1588)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 499e1ba commit cae75ac

4 files changed

Lines changed: 21 additions & 17 deletions

File tree

js/moq-boy/src/index.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -135,18 +135,19 @@ export class Game {
135135

136136
this.#signals.run(this.#runPixelBudget.bind(this));
137137

138-
// Video is enabled on the grid or when this game is expanded.
139138
const videoEnabled = new Moq.Signals.Signal(true);
140-
this.#signals.run(this.#runVideoEnabled.bind(this, videoEnabled));
141-
142139
this.videoDecoder = new Watch.Video.Decoder(this.videoSource, this.sync, { enabled: videoEnabled });
143140
this.#signals.cleanup(() => this.videoDecoder.close());
144141

145142
// Renderer needs a canvas — created by the UI layer, set via `canvas`.
146143
this.videoRenderer = new Watch.Video.Renderer(this.videoDecoder, { canvas: this.canvas });
147144
this.#signals.cleanup(() => this.videoRenderer.close());
148145

149-
// Audio pipeline — the emitter controls muted (volume) and paused (download).
146+
// Download on the grid or when expanded, but only while the tile is on-screen.
147+
// Reads the renderer's visibility, so it must run after the renderer exists.
148+
this.#signals.run(this.#runVideoEnabled.bind(this, videoEnabled));
149+
150+
// Audio pipeline — the emitter stops the download when muted or paused.
150151
this.audioDecoder = new Watch.Audio.Decoder(this.audioSource, this.sync);
151152
this.#signals.cleanup(() => this.audioDecoder.close());
152153

@@ -224,7 +225,9 @@ export class Game {
224225

225226
#runVideoEnabled(videoEnabled: Moq.Signals.Signal<boolean>, effect: Moq.Signals.Effect) {
226227
const exp = effect.get(this.expanded);
227-
videoEnabled.set(exp === undefined || exp === this.sessionId);
228+
const visible = effect.get(this.videoRenderer.output.visible);
229+
// On the grid or when expanded, but never download an off-screen tile.
230+
videoEnabled.set((exp === undefined || exp === this.sessionId) && visible);
228231
}
229232

230233
#runAudioPaused(audioPaused: Moq.Signals.Signal<boolean>, effect: Moq.Signals.Effect) {

js/watch/src/audio/decoder.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,6 @@ export class Decoder {
9494
const sampleRate = config.sampleRate;
9595
const channelCount = config.numberOfChannels;
9696

97-
// NOTE: We still create an AudioContext even when muted.
98-
// This way we can process the audio for visualizations.
99-
10097
const context = new AudioContext({
10198
latencyHint: "interactive", // We don't use real-time because of the buffer.
10299
sampleRate,

js/watch/src/audio/emitter.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ const FADE_TIME = 0.2;
66

77
type EmitterInput = {
88
volume: Getter<number>;
9+
10+
// Silences the audio and stops the download. Muted samples aren't worth the bandwidth,
11+
// and the decoder keeps the AudioContext warm so unmuting is still instant.
912
muted: Getter<boolean>;
1013

11-
// Similar to muted, but controls whether we download audio at all.
12-
// That way we can be "muted" but also download audio for visualizations.
14+
// Pauses playback, which also stops the download.
1315
paused: Getter<boolean>;
1416
};
1517

@@ -39,9 +41,10 @@ export class Emitter {
3941
this.input = {
4042
volume: getter(props?.volume ?? 0.5),
4143
muted: getter(props?.muted ?? false),
42-
paused: getter(props?.paused ?? props?.muted ?? false),
44+
paused: getter(props?.paused ?? false),
4345
};
4446

47+
// Only download while playing audible audio. Pausing or muting stops it.
4548
this.#signals.run((effect) => {
4649
const enabled = !effect.get(this.input.paused) && !effect.get(this.input.muted);
4750
this.#output.enabled.set(enabled);

js/watch/src/backend.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -199,17 +199,18 @@ export class MultiBackend {
199199
// Audio download follows the emitter's enable policy (paused/muted).
200200
effect.proxy(this.#audioEnabled, audioEmitter.output.enabled);
201201

202-
// Video download policy: when playing, follow visibility; when paused, fetch a
203-
// single preview frame then stop.
202+
// Video downloads while playing and on-screen. When paused, keep downloading only
203+
// until a frame is on the canvas, then stop: a cold paused start still shows a poster
204+
// instead of black, without streaming while paused. Read the rendered frame only in
205+
// the paused branch so playback doesn't re-run this every painted frame.
204206
effect.run((inner) => {
205-
const paused = inner.get(this.input.paused);
206207
const visible = inner.get(videoRenderer.output.visible);
207-
if (!paused) {
208+
if (!inner.get(this.input.paused)) {
208209
this.#videoEnabled.set(visible);
209210
return;
210211
}
211-
const frame = inner.get(videoDecoder.output.frame);
212-
this.#videoEnabled.set(!frame);
212+
const frame = inner.get(videoRenderer.output.frame);
213+
this.#videoEnabled.set(visible && !frame);
213214
});
214215
effect.cleanup(() => {
215216
this.#videoEnabled.set(false);

0 commit comments

Comments
 (0)