Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions js/hang-demo/src/publish.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
Feel free to hard-code it if you have public access configured, like `url="https://relay.moq.dev/anon"`
NOTE: `http` performs an insecure certificate check. You must use `https` in production.
-->
<hang-publish url="%VITE_RELAY_URL%" name="me" controls captions>
<hang-publish url="%VITE_RELAY_URL%" name="me" controls>
<!-- It's optional to provide a video element to preview the outgoing media. -->
<video style="max-width: 100%; height: auto; border-radius: 4px; margin: 0 auto;" muted autoplay></video>
</hang-publish>
Expand All @@ -47,10 +47,9 @@ <h3>Tips:</h3>
</p>
<hr/>
<p>
If you pay close enough attention, you'll notice that captions are being automatically generated.
Use <code class="language-html">&lt;hang-publish captions&gt;</code> to enable captions generation.
This uses a VAD model to detect when you're speaking which is when the 🗣 emoji appears.
At the end of a sentence, we run another model to generate the caption which is available over the network.
The `captions` attribute controls whether captions are displayed.
</p>
<p>
The AI models are executed using <a href="https://huggingface.co/docs/transformers.js/en/index">Transformers.js</a>.
Expand Down
26 changes: 18 additions & 8 deletions js/hang/src/publish/element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,9 @@ export default class HangPublish extends HTMLElement {
const audio = effect.get(this.#audio);
if (!(audio instanceof Source.Microphone)) return;

const enabled = effect.get(this.broadcast.audio.enabled);
if (!enabled) return;

const devices = effect.get(audio.device.available);
if (!devices || devices.length < 2) return;

Expand All @@ -346,16 +349,18 @@ export default class HangPublish extends HTMLElement {
transform: "translateX(-50%)",
},
});
effect.event(select, "change", () => audio.device.preferred.set(select.value));
effect.event(select, "change", () => {
audio.device.preferred.set(select.value);
});

for (const device of devices) {
const option = DOM.create("option", { value: device.deviceId }, device.label);
DOM.render(effect, select, option);
}

effect.effect((effect) => {
const selected = effect.get(audio.device.selected);
select.value = selected?.deviceId ?? "";
const active = effect.get(audio.device.requested);
select.value = active ?? "";
});

const caret = DOM.create("span", { style: { fontSize: "0.75em", cursor: "pointer" } }, "▼");
Expand Down Expand Up @@ -416,6 +421,9 @@ export default class HangPublish extends HTMLElement {
const video = effect.get(this.#video);
if (!(video instanceof Source.Camera)) return;

const enabled = effect.get(this.broadcast.video.enabled);
if (!enabled) return;

const devices = effect.get(video.device.available);
if (!devices || devices.length < 2) return;

Expand All @@ -428,16 +436,18 @@ export default class HangPublish extends HTMLElement {
transform: "translateX(-50%)",
},
});
effect.event(select, "change", () => video.device.preferred.set(select.value));
effect.event(select, "change", () => {
video.device.preferred.set(select.value);
});

for (const device of devices) {
const option = DOM.create("option", { value: device.deviceId }, device.label);
DOM.render(effect, select, option);
}

effect.effect((effect) => {
const selected = effect.get(video.device.selected);
select.value = selected?.deviceId ?? "";
const requested = effect.get(video.device.requested);
select.value = requested ?? "";
});

const caret = DOM.create("span", { style: { fontSize: "0.75em", cursor: "pointer" } }, "▼");
Expand Down Expand Up @@ -520,9 +530,9 @@ export default class HangPublish extends HTMLElement {
} else if (!audio && !video) {
container.textContent = "🟡\u00A0Select Source";
} else if (!audio && video) {
container.textContent = "🟡\u00A0Video Only";
container.textContent = "🟢\u00A0Video Only";
} else if (audio && !video) {
container.textContent = "🟡\u00A0Audio Only";
container.textContent = "🟢\u00A0Audio Only";
} else if (audio && video) {
container.textContent = "🟢\u00A0Live";
}
Expand Down
13 changes: 7 additions & 6 deletions js/hang/src/publish/source/camera.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,13 @@ export class Camera {
const enabled = effect.get(this.enabled);
if (!enabled) return;

const device = effect.get(this.device.selected);
if (!device) return;

console.log("requesting camera", device);

const device = effect.get(this.device.requested);
const constraints = effect.get(this.constraints) ?? {};

// Build final constraints with device selection
const finalConstraints: MediaTrackConstraints = {
...constraints,
deviceId: { exact: device.deviceId },
deviceId: device ? { exact: device } : undefined,
};

effect.spawn(async (cancel) => {
Expand All @@ -57,9 +53,14 @@ export class Camera {
const stream = await Promise.race([media, cancel]);
if (!stream) return;

this.device.permission.set(true);

const track = stream.getVideoTracks()[0] as VideoStreamTrack | undefined;
if (!track) return;

const settings = track.getSettings();

effect.set(this.device.active, settings.deviceId);
effect.set(this.stream, track, undefined);
});
}
Expand Down
95 changes: 56 additions & 39 deletions js/hang/src/publish/source/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,21 @@ export class Device<Kind extends "audio" | "video"> {
readonly available: Getter<MediaDeviceInfo[] | undefined> = this.#devices;

// The default device based on heuristics.
#default = new Signal<MediaDeviceInfo | undefined>(undefined);
readonly default: Getter<MediaDeviceInfo | undefined> = this.#default;
#default = new Signal<string | undefined>(undefined);
readonly default: Getter<string | undefined> = this.#default;

// Use the preferred deviceId if available.
// The deviceId that we want to use, otherwise use the default device.
preferred: Signal<string | undefined>;

// The device that is currently selected.
#selected = new Signal<MediaDeviceInfo | undefined>(undefined);
readonly selected: Getter<MediaDeviceInfo | undefined> = this.#selected;
// The device that we are actually using.
active = new Signal<string | undefined>(undefined);

// Whether we have permission to enumerate devices.
permission = new Signal<boolean>(false);

// The device we want to use next. (preferred ?? default)
#requested = new Signal<string | undefined>(undefined);
requested: Getter<string | undefined> = this.#requested;

signals = new Effect();

Expand All @@ -29,23 +35,38 @@ export class Device<Kind extends "audio" | "video"> {
this.preferred = Signal.from(props?.preferred);

this.signals.effect((effect) => {
// Reload the devices when they change.
effect.event(navigator.mediaDevices, "devicechange", effect.reload.bind(effect));
effect.spawn(this.#runDevices.bind(this, effect));
effect.spawn(this.#run.bind(this, effect));
effect.event(navigator.mediaDevices, "devicechange", () => effect.reload());
});

this.signals.effect(this.#runSelected.bind(this));
this.signals.effect(this.#runRequested.bind(this));
}

async #runDevices(effect: Effect, cancel: Promise<void>) {
async #run(effect: Effect, cancel: Promise<void>) {
// Force a reload of the devices list if we don't have permission.
// We still try anyway.
effect.get(this.permission);

// Ignore permission errors for now.
let devices = await Promise.race([navigator.mediaDevices.enumerateDevices().catch(() => undefined), cancel]);
if (devices === undefined) return;
if (!devices) return; // cancelled, keep stale values

devices = devices.filter((d) => d.kind === `${this.kind}input`);

// An empty deviceId means no permissions, or at the very least, no useful information.
if (devices.some((d) => d.deviceId === "")) {
console.warn(`no ${this.kind} permission`);
this.#devices.set(undefined);
this.#default.set(undefined);
return;
}

// Assume we have permission now.
this.permission.set(true);

Comment on lines +56 to +66
Copy link
Contributor

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Permission detection is likely wrong; check label, not deviceId, and reset permission=false.

Browsers typically hide labels (empty string) pre-permission while deviceId may still be non-empty. Also, ensure permission is explicitly set to false on this branch to avoid stale true.

Apply:

-    // An empty deviceId means no permissions, or at the very least, no useful information.
-    if (devices.some((d) => d.deviceId === "")) {
-      console.warn(`no ${this.kind} permission`);
-      this.#devices.set(undefined);
-      this.#default.set(undefined);
-      return;
-    }
+    // No permission typically => empty labels (deviceIds may still be non-empty).
+    if (devices.length && devices.every((d) => d.label === "")) {
+      console.warn(`no ${this.kind} permission`);
+      this.permission.set(false);
+      this.#devices.set(undefined);
+      this.#default.set(undefined);
+      return;
+    }
 
-    // Assume we have permission now.
-    this.permission.set(true);
+    // Assume we have permission now.
+    this.permission.set(true);

To confirm cross-browser behavior:


🌐 Web query:

What does enumerateDevices() return before permission is granted across Chrome, Firefox, and Safari in 2025? Specifically, are deviceId or label empty?

💡 Result:

Short answer

  • label: empty on all major browsers (Chrome, Firefox, Safari) unless the page has an active MediaStream or persistent permission. [1][2]
  • deviceId: in current browsers it must not expose stable identifiers to origins that lack permission — Chrome and Firefox return opaque/empty IDs before permission; Safari/WebKit has had bugs and regressions that in some cases expose non‑empty deviceId/label when permissions are partially denied (so behavior may be inconsistent on some Safari versions). See citations. [1][3][4]

Sources

  1. MDN — MediaDeviceInfo / label (labels blank unless active stream or persistent permission). [1]
  2. MDN — MediaDeviceInfo (deviceId description + privacy note). [1]
  3. Discussion / bug & community reports showing deviceId is empty before getUserMedia (fingerprinting/privacy changes). [3]
  4. WebKit bug report showing Safari sometimes returns deviceId/label unexpectedly when permissions are mixed/changed. [4]

If you want, I can run a quick live test snippet for your current browser (or provide code) and report the exact values returned on your machine.


Use labels for permission detection and explicitly reset permission=false
Replace the deviceId check with a label-based check and ensure permission is cleared on the “no permission” branch. For example, in js/hang/src/publish/source/device.ts (lines 56-66):

-    // An empty deviceId means no permissions, or at the very least, no useful information.
-    if (devices.some((d) => d.deviceId === "")) {
+    // No permission typically → all labels are blank (deviceIds may still be opaque or non-empty in some browsers).
+    if (devices.length && devices.every((d) => d.label === "")) {
       console.warn(`no ${this.kind} permission`);
-      this.#devices.set(undefined);
-      this.#default.set(undefined);
+      this.permission.set(false);
+      this.#devices.set(undefined);
+      this.#default.set(undefined);
       return;
     }

-    // Assume we have permission now.
-    this.permission.set(true);
+    // Assume we have permission now.
+    this.permission.set(true);

This covers Chrome/Firefox behavior (labels are blank pre-grant) and avoids stale true values.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// An empty deviceId means no permissions, or at the very least, no useful information.
if (devices.some((d) => d.deviceId === "")) {
console.warn(`no ${this.kind} permission`);
this.#devices.set(undefined);
this.#default.set(undefined);
return;
}
// Assume we have permission now.
this.permission.set(true);
// No permission typically → all labels are blank (deviceIds may still be opaque or non-empty in some browsers).
if (devices.length && devices.every((d) => d.label === "")) {
console.warn(`no ${this.kind} permission`);
this.permission.set(false);
this.#devices.set(undefined);
this.#default.set(undefined);
return;
}
// Assume we have permission now.
this.permission.set(true);
🤖 Prompt for AI Agents
In js/hang/src/publish/source/device.ts around lines 56 to 66, replace the
current deviceId-based permission detection with a label-based check (use
devices.some(d => d.label === "")) because labels are blank pre-grant in some
browsers, and in the "no permission" branch explicitly clear the permission flag
by calling this.permission.set(false) (alongside this.#devices.set(undefined)
and this.#default.set(undefined)); only set this.permission.set(true) after the
label check confirms permission granted (i.e., in the else branch).

// No devices found, but we have permission I think?
if (!devices.length) {
console.warn(`no ${this.kind} devices found`);
return;
}

// Chrome seems to have a "default" deviceId that we also need to filter out, but can be used to help us find the default device.
Expand Down Expand Up @@ -77,44 +98,40 @@ export class Device<Kind extends "audio" | "video"> {
}
}

console.debug("all devices", devices);
console.debug("default device", defaultDevice);
if (!defaultDevice) {
// Still nothing, then use the top one.
defaultDevice = devices.at(0);
}

effect.set(this.#devices, devices, []);
effect.set(this.#default, defaultDevice, undefined);
}
console.debug(`all ${this.kind} devices`, devices);
console.debug(`default ${this.kind} device`, defaultDevice);

#runSelected(effect: Effect) {
const available = effect.get(this.available);
if (!available) return;
this.#devices.set(devices);
this.#default.set(defaultDevice?.deviceId);
}

#runRequested(effect: Effect) {
const preferred = effect.get(this.preferred);
if (preferred) {
// Use the preferred deviceId if available.
const device = available.find((d) => d.deviceId === preferred);
if (device) {
effect.set(this.#selected, device);
return;
}

console.warn("preferred device not available, using default");
if (preferred && effect.get(this.#devices)?.some((d) => d.deviceId === preferred)) {
// Use the preferred device if it's in our devices list.
this.#requested.set(preferred);
} else {
// Otherwise use the default device.
this.#requested.set(effect.get(this.default));
}

// NOTE: The default device might change, and with no (valid) preference, we should switch to it.
const defaultDevice = effect.get(this.default);
effect.set(this.#selected, defaultDevice);
}

// Manually request permission for the device, ignore the result.
request() {
// Manually request permission for the device, ignoring the result.
requestPermission() {
navigator.mediaDevices
.getUserMedia({ [this.kind]: true })
.catch(() => undefined)
.then((stream) => {
stream?.getTracks().forEach((track) => {
this.permission.set(true);
stream.getTracks().forEach((track) => {
track.stop();
});
});
})
.catch(() => undefined);
}

close() {
Expand Down
13 changes: 9 additions & 4 deletions js/hang/src/publish/source/microphone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,12 @@ export class Microphone {
const enabled = effect.get(this.enabled);
if (!enabled) return;

const device = effect.get(this.device.selected);
if (!device) return;
const device = effect.get(this.device.requested);

const constraints = effect.get(this.constraints) ?? {};
const finalConstraints: MediaTrackConstraints = {
...constraints,
deviceId: { exact: device.deviceId },
deviceId: device ? { exact: device } : undefined,
};

effect.spawn(async (cancel) => {
Expand All @@ -55,10 +54,16 @@ export class Microphone {
const stream = await Promise.race([media, cancel]);
if (!stream) return;

// Success, we can enumerate devices now.
this.device.permission.set(true);

const track = stream.getAudioTracks()[0] as AudioStreamTrack | undefined;
if (!track) return;

effect.set(this.stream, track, undefined);
const settings = track.getSettings();

effect.set(this.device.active, settings.deviceId);
effect.set(this.stream, track);
});
}

Expand Down
Loading