- {t(deniedMessageKey)} · {t(deniedHintKey)}
+ {t(deniedMessageKey)} · {t(deniedHintKey, { site: window.location.host })}
{vis ? null : (
{t(deniedMessageKey)}
-
{t(deniedHintKey)}
+
{t(deniedHintKey, { site: window.location.host })}
)}
{codeOnlyDenied && (
diff --git a/src/client/sfu/publisher.ts b/src/client/sfu/publisher.ts
index b31e1e6..ba8fc8a 100644
--- a/src/client/sfu/publisher.ts
+++ b/src/client/sfu/publisher.ts
@@ -54,6 +54,7 @@ export type SfuPublisherFailureStage =
export class SfuPublisher {
private peer: SfuPeer | null = null;
private video: MediaStreamTrack | null = null;
+ private sourceVideo: MediaStreamTrack | null = null;
private audio: MediaStreamTrack | null = null;
private videoSender: RTCRtpSender | null = null;
private audioSender: RTCRtpSender | null = null;
@@ -144,13 +145,15 @@ export class SfuPublisher {
let video: MediaStreamTrack | null = null;
try {
this.failureStage = "source";
- video = this.ownTrack(cloneSenderVideoTrack(requiredVideo(stream)));
+ const sourceVideo = requiredVideo(stream);
+ video = this.ownTrack(cloneSenderVideoTrack(sourceVideo));
await applyVideoCaptureProfile(video, profile);
if (this.peer !== peer) {
this.releaseTrack(video);
return false;
}
this.video = video;
+ this.sourceVideo = sourceVideo;
this.audio = this.ownTrack(stream.getAudioTracks()[0]?.clone() ?? null);
this.setPaused(this.paused);
const dimensions = video.getSettings();
@@ -223,35 +226,41 @@ export class SfuPublisher {
const audioSender = this.audioSender;
if (!peer || !profile || !previousVideo || !videoSender || !audioSender)
return false;
- const nextVideo = this.ownTrack(
- cloneSenderVideoTrack(requiredVideo(stream)),
- );
+ const sourceVideo = requiredVideo(stream);
+ const videoChanged = sourceVideo !== this.sourceVideo;
+ const nextVideo = videoChanged
+ ? this.ownTrack(cloneSenderVideoTrack(sourceVideo)) : previousVideo;
const nextAudio = this.ownTrack(
stream.getAudioTracks()[0]?.clone() ?? null,
);
let retained = false;
- this.resetStats();
+ const previousStartup = this.startupPending;
+ const previousBaseline = this.startupFramesBaseline;
+ if (videoChanged) this.resetStats();
try {
- await applyVideoCaptureProfile(nextVideo, profile);
+ if (videoChanged) await applyVideoCaptureProfile(nextVideo, profile);
if (this.peer !== peer) return false;
nextVideo.enabled = !this.paused;
if (nextAudio) {
nextAudio.enabled = !this.paused;
nextAudio.contentHint = "music";
}
- await videoSender.replaceTrack(nextVideo);
+ if (videoChanged) await videoSender.replaceTrack(nextVideo);
await audioSender.replaceTrack(nextAudio);
if (this.peer !== peer) return false;
this.video = nextVideo;
this.audio = nextAudio;
- this.startupPending = needsStartupVideoProfile(profile);
- this.startupFramesBaseline = null;
- await this.configure(profile);
+ if (videoChanged) {
+ this.startupPending = needsStartupVideoProfile(profile);
+ this.startupFramesBaseline = null;
+ }
+ await this.configure(profile, videoChanged);
if (this.peer !== peer) return false;
if (!peer.send({ kind: "media", media: this.media(profile) }))
throw new Error("SFU signaling is unavailable");
retained = true;
- this.releaseTrack(previousVideo);
+ this.sourceVideo = sourceVideo;
+ if (videoChanged) this.releaseTrack(previousVideo);
this.releaseTrack(previousAudio);
this.setPaused(this.paused);
return true;
@@ -260,10 +269,14 @@ export class SfuPublisher {
if (this.peer !== peer) return false;
this.video = previousVideo;
this.audio = previousAudio;
+ if (videoChanged) {
+ this.startupPending = previousStartup;
+ this.startupFramesBaseline = previousBaseline;
+ }
try {
- await videoSender.replaceTrack(previousVideo);
+ if (videoChanged) await videoSender.replaceTrack(previousVideo);
await audioSender.replaceTrack(previousAudio);
- await this.configure(profile);
+ await this.configure(profile, videoChanged);
this.videoWarning = { key: "host.fail.sfuSwitch" };
return false;
} catch {
@@ -272,7 +285,7 @@ export class SfuPublisher {
}
} finally {
if (!retained) {
- this.releaseTrack(nextVideo);
+ if (videoChanged) this.releaseTrack(nextVideo);
this.releaseTrack(nextAudio);
}
}
@@ -345,6 +358,7 @@ export class SfuPublisher {
peer?.close();
for (const track of this.ownedTracks) this.releaseTrack(track);
this.video = null;
+ this.sourceVideo = null;
this.audio = null;
this.videoSender = null;
this.audioSender = null;
diff --git a/src/client/styles.css b/src/client/styles.css
index 2468a04..814a013 100644
--- a/src/client/styles.css
+++ b/src/client/styles.css
@@ -42,6 +42,7 @@
--radius: 16px;
--control-radius: 18px;
--control-edge: color-mix(in srgb, var(--ink) 62%, var(--paper));
+ --control-hover: var(--wall-2);
--control-shadow: 0 3px 0 color-mix(in srgb, var(--ink) 13%, transparent);
--spring: cubic-bezier(.3, 1.45, .45, 1);
--font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", "PingFang SC",
@@ -55,6 +56,7 @@ html[data-theme="dark"] {
--wall-2: #0e1620;
--ink: #dde8f3;
--paper: #22303e;
+ --control-hover: #2b3d4f;
--frame: #0a111a;
--action: #4a84f2;
--action-deep: #3a70d8;
@@ -483,15 +485,17 @@ body.lr-theater-open .lr-top { visibility: hidden; }
}
.lr-source-picker-refresh { margin-left: auto; }
.lr-source-picker-head > .lr-comic-tip-wrap:has(> .lr-source-picker-refresh) { margin-left: auto; }
-.lr-source-picker-refresh:hover,
-.lr-source-picker-close:hover { background: rgba(255, 255, 255, .1); }
-.lr-source-picker-refresh:active,
-.lr-source-picker-close:active { transform: scale(.92); }
+.lr-source-picker-refresh:is(:hover, :focus-visible):not(:disabled),
+.lr-source-picker-close:is(:hover, :focus-visible):not(:disabled) { background: rgba(255, 255, 255, .1); }
+.lr-source-picker-refresh:active:not(:disabled),
+.lr-source-picker-close:active:not(:disabled) { transform: scale(.92); }
.lr-source-picker-tabs {
display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 4px;
- border-bottom: 1px solid rgba(223, 232, 242, .35);
+ padding: 4px;
+ border: 1px solid rgba(223, 232, 242, .24);
+ border-radius: 16px;
+ background: rgba(0, 0, 0, .12);
}
.lr-source-picker-tabs > span { min-width: 0; }
.lr-source-picker-tabs button {
@@ -503,7 +507,7 @@ body.lr-theater-open .lr-top { visibility: hidden; }
gap: 7px;
padding: 8px 4px;
border: 0;
- border-bottom: 3px solid transparent;
+ border-radius: 12px;
background: transparent;
color: #9aa8bb;
font-size: 12px;
@@ -511,10 +515,13 @@ body.lr-theater-open .lr-top { visibility: hidden; }
transition: color 120ms ease, background 120ms ease;
}
.lr-source-picker-tabs button svg { flex: 0 0 auto; }
-.lr-source-picker-tabs button:hover { background: rgba(255, 255, 255, .06); }
+@media (max-width: 520px) {
+ .lr-source-picker-tabs button { flex-direction: column; gap: 3px; padding-inline: 0; font-size: 11px; }
+}
+.lr-source-picker-tabs button:is(:hover, :focus-visible):not(:disabled):not([aria-selected="true"]) { background: rgba(255, 255, 255, .1); }
.lr-source-picker-tabs button[aria-selected="true"] {
- border-bottom-color: #9de8bf;
- color: #9de8bf;
+ background: #dfe8f2;
+ color: #22303e;
}
.lr-source-picker-body {
display: flex;
@@ -550,13 +557,13 @@ body.lr-theater-open .lr-top { visibility: hidden; }
text-align: left;
transition: transform 150ms var(--spring), border-color 120ms ease, background 120ms ease;
}
-.lr-source-option:hover,
-.lr-source-option:focus-visible {
+.lr-source-option:is(:hover, :focus-visible):not(:disabled) {
border-color: var(--action);
background: rgba(47, 111, 237, .2);
transform: translateY(-2px);
}
-.lr-source-option:active { transform: scale(.98); }
+.lr-source-option:active:not(:disabled) { transform: scale(.98); }
+.lr-source-option[aria-current="true"] { border-color: #dfe8f2; background: rgba(223, 232, 242, .18); }
.lr-source-option:not(.is-browser) {
display: grid;
align-items: stretch;
@@ -587,8 +594,7 @@ body.lr-theater-open .lr-top { visibility: hidden; }
}
.lr-source-option-preview {
width: 100%;
- aspect-ratio: 16 / 9;
- height: auto;
+ height: 144px;
overflow: hidden;
border-radius: 8px;
background: rgba(255, 255, 255, .1);
@@ -791,8 +797,8 @@ body.lr-theater-open .lr-top { visibility: hidden; }
align-items: center;
gap: 12px;
padding: 15px 18px;
- border: 2.5px solid var(--ink);
- border-radius: var(--radius);
+ border: 1px solid var(--control-edge);
+ border-radius: 24px;
background: var(--paper);
}
.lr-row.is-sub { background: var(--wall-2); border-color: transparent; }
@@ -807,10 +813,8 @@ body.lr-theater-open .lr-top { visibility: hidden; }
gap: 8px;
}
.lr-host-identity-slot,
-.lr-host-diagnostics-slot,
-.lr-host-share-slot { flex: 0 0 auto; }
-.lr-host-diagnostics-slot,
-.lr-host-share-slot {
+.lr-host-diagnostics-slot { flex: 0 0 auto; }
+.lr-host-diagnostics-slot {
padding-inline-start: 10px;
border-inline-start: 2.5px solid var(--wall-2);
}
@@ -879,6 +883,10 @@ html[data-theme="dark"] .lr-viewer-host-slot svg { color: #9bb0c5; }
.lr-source-option-hint > .lr-source-option { width: 100%; height: 100%; }
.lr-invite-hint { width: 100%; min-width: 0; max-width: 340px; flex: 1 1 240px; }
.lr-invite-hint > .lr-invite-url { width: 100%; }
+.lr-invite-field { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; flex: 1 1 360px; min-width: 0; }
+.lr-invite-field > .lr-invite-hint { flex: 1 1 180px; max-width: none; }
+.lr-invite-field .lr-invite-url { max-width: none; }
+.lr-invite-field > .lr-row-group { flex: none; width: auto; }
.lr-spacer { flex: 1; }
.lr-divider { width: 2.5px; align-self: stretch; border-radius: 2px; background: var(--wall-2); }
.lr-row.is-sub .lr-divider { background: #c3d4cb; }
@@ -955,7 +963,7 @@ html[data-theme="dark"] .lr-invite-url { background: #1a2634; border-color: #334
transition: transform 260ms var(--spring), background-color 140ms ease, border-color 140ms ease;
}
.lr-btn:is(:hover, :focus-visible):not(:disabled):not([aria-disabled="true"]) { transform: translateY(-2px); }
-.lr-btn:is(:hover, :focus-visible):not(:disabled):not([aria-disabled="true"]):not(.is-primary):not(.is-on):not(.is-danger) { background: var(--wall-2); border-color: var(--ink); }
+.lr-btn:is(:hover, :focus-visible):not(:disabled):not([aria-disabled="true"]):not(.is-primary):not(.is-on):not(.is-danger) { background: var(--control-hover); border-color: var(--ink); }
.lr-btn:active:not(:disabled):not([aria-disabled="true"]) { transform: translateY(2px) scale(.95); box-shadow: none; transition-duration: 80ms; }
.lr-btn.is-primary { border-color: var(--action-deep); background: var(--action-deep); color: #fff; }
.lr-btn.is-primary:is(:hover, :focus-visible):not(:disabled):not([aria-disabled="true"]) { background: color-mix(in srgb, var(--action-deep) 88%, #000); }
@@ -965,6 +973,14 @@ html[data-theme="dark"] .lr-invite-url { background: #1a2634; border-color: #334
.lr-btn .lr-cap { margin-left: 2px; font-size: 12.5px; font-weight: 600; }
.lr-cap { font-size: 12.5px; font-weight: 600; color: inherit; white-space: nowrap; }
+/* Sharing and local playback have distinct owners, but one control grammar. */
+:where(.lr-media-controls) .lr-btn {
+ min-width: 44px; height: 44px; padding: 0 12px;
+ border: 0; border-radius: 12px; box-shadow: none;
+ background: transparent; color: inherit;
+}
+.lr-media-controls .lr-btn.is-danger { color: var(--danger); }
+
/* Diagnostics stay quiet until explicitly enabled; keep the shared input feedback. */
.lr-header-diagnostic { display: inline-flex; }
.lr-header-diagnostic .lr-btn { min-width: 40px; width: 40px; height: 40px; padding: 0; box-shadow: none; }
@@ -992,6 +1008,7 @@ html[data-theme="dark"] .lr-invite-url { background: #1a2634; border-color: #334
.lr-chip .lr-cap { font-family: var(--font); letter-spacing: 0; }
.lr-chip small { margin-left: 5px; font-size: 10.5px; opacity: .6; }
.lr-chip .lr-audio-rate {
+ opacity: .85;
min-width: 3ch;
margin-left: 0;
text-align: center;
@@ -1131,6 +1148,7 @@ html[data-theme="dark"] .lr-invite-url { background: #1a2634; border-color: #334
.lr-welcome-cipher i { width: 3px; height: 3px; background: #d7982b; transform: rotate(45deg); }
.lr-tv-overlay > .lr-welcome { position: absolute; top: clamp(20px, 3vw, 32px); left: 18px; right: 18px; width: auto; font-size: clamp(13px, 1.5vw, 16px); }
.lr-tv-overlay:has(> .lr-join-panel) > .lr-welcome { display: none; }
+.lr-tv-overlay:has(> .lr-join-panel) { position: relative; grid-template-columns: minmax(0, 1fr); }
.lr-welcome-catalog { margin-block: 0 24px; }
.lr-welcome-catalog summary { cursor: pointer; width: fit-content; padding-block: 12px; font-weight: 650; }
.lr-welcome-catalog ol { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr)); gap: 12px 32px; padding-inline-start: 28px; }
@@ -1519,14 +1537,21 @@ html[data-theme="dark"] .lr-door-glyph { color: #7d92a8; }
.lr-client-lan-option strong { font-size: 13px; overflow-wrap: anywhere; }
.lr-client-lan-option small { font: 12px var(--mono); }
.lr-client-lan-option:has(:focus-visible) { outline: 3px solid var(--action); outline-offset: 1px; }
-/* Only the wrapped access field spans the panel width; a wrapped pill or
- button keeps its own size so the grid can center it. */
-.lr-client-launch-panel > .lr-comic-tip-wrap:has(> .lr-client-access) {
- width: min(460px, 100%);
-}
-.lr-client-launch-panel > .lr-comic-tip-wrap > .lr-client-access {
- width: 100%;
+.lr-client-access-options { width: min(460px, 100%); min-width: 0; }
+.lr-client-access-options > summary {
+ display: flex; align-items: center; justify-content: center; gap: 8px;
+ width: fit-content; max-width: 100%; min-height: 40px; margin: 0 auto; padding: 8px 12px;
+ border-radius: 12px; color: var(--muted); font-size: 12px; font-weight: 600; cursor: pointer;
+ list-style: none; transition: background-color 140ms ease, color 140ms ease;
}
+.lr-client-access-options > summary::-webkit-details-marker { display: none; }
+.lr-client-access-options > summary:is(:hover, :focus-visible) { background: var(--control-hover); color: var(--ink); }
+.lr-client-access-options > summary:focus-visible { outline: 3px solid var(--action); outline-offset: 2px; }
+.lr-client-access-options > summary > svg:last-child { transition: transform 160ms ease; }
+.lr-client-access-options[open] > summary > svg:last-child { transform: rotate(180deg); }
+.lr-client-access-set { display: inline-flex; align-items: center; gap: 4px; color: var(--ink); }
+.lr-client-access-options .lr-client-access { width: 100%; margin-top: 8px; }
+.lr-client-access-hint { margin: 8px 0 0; color: var(--muted); font-size: 12px; line-height: 1.6; }
.lr-join {
flex: 1;
@@ -1536,12 +1561,14 @@ html[data-theme="dark"] .lr-door-glyph { color: #7d92a8; }
}
.lr-join-panel {
display: grid;
+ grid-template-columns: minmax(0, 1fr);
width: 100%;
min-width: 0;
max-width: 100%;
justify-items: center;
gap: 20px;
}
+.lr-join-site { max-width: 100%; white-space: normal; overflow-wrap: anywhere; text-align: center; }
.lr-join-door { color: var(--ink); }
.lr-dials-wrap.is-shake { animation: lr-shake .4s ease; }
@keyframes lr-shake {
@@ -1550,7 +1577,7 @@ html[data-theme="dark"] .lr-door-glyph { color: #7d92a8; }
50% { transform: translateX(6px); }
75% { transform: translateX(-4px); }
}
-.lr-dials-wrap { position: relative; }
+.lr-dials-wrap { position: relative; max-width: 100%; }
.lr-dials-wrap:focus-within { outline: 3px solid var(--action); outline-offset: 2px; border-radius: 14px; }
.lr-dials { display: flex; gap: 12px; }
.lr-dial {
@@ -1648,7 +1675,6 @@ html[data-theme="dark"] .lr-join-go:disabled { background: #1a2634; color: #4860
/* ---------- dark theme refinements ---------- */
-html[data-theme="dark"] .lr-btn:is(:hover, :focus-visible):not(:disabled):not([aria-disabled="true"]):not(.is-primary):not(.is-on):not(.is-danger) { background: #2b3d4f; }
html[data-theme="dark"] .lr-btn.is-on { background: var(--ink); color: #22303e; }
html[data-theme="dark"] .lr-btn.is-danger:is(:hover, :focus-visible):not(:disabled):not([aria-disabled="true"]) { background: #3d2626; }
html[data-theme="dark"] .lr-chip:is(:hover, :focus-visible):not(:disabled):not([aria-disabled="true"]):not(.is-selected),
@@ -1700,8 +1726,7 @@ html[data-theme="dark"] .lr-switch::after { background: var(--ink); }
margin-left: 0;
}
.lr-host-identity-slot,
- .lr-host-diagnostics-slot,
- .lr-host-share-slot { width: 100%; }
+ .lr-host-diagnostics-slot { width: 100%; }
.lr-viewer-personal-controls {
width: 100%;
margin-left: 0;
@@ -1718,7 +1743,6 @@ html[data-theme="dark"] .lr-switch::after { background: var(--ink); }
.lr-input.lr-name-editor { width: min(136px, 40vw); }
.lr-group-actions { display: flex; width: 100%; gap: 8px; min-width: 0; }
.lr-host-diagnostics-slot,
- .lr-host-share-slot,
.lr-viewer-actions-slot {
padding-inline-start: 0;
border-inline-start: 0;
@@ -1735,6 +1759,7 @@ html[data-theme="dark"] .lr-switch::after { background: var(--ink); }
.lr-viewer-self-slot .lr-name-tag { flex: 0 1 auto; }
.lr-toggle { flex: 1; }
.lr-invite-hint, .lr-invite-url { flex: 0 1 auto; max-width: none; min-height: 44px; }
+ .lr-invite-field { flex: none; width: 100%; }
.lr-door-body { padding: 12px; }
.lr-dial { width: 54px; height: 66px; font-size: 28px; }
.lr-tv-screen.has-entry { aspect-ratio: auto; min-height: 292px; }
@@ -1760,6 +1785,78 @@ html[data-theme="dark"] .lr-switch::after { background: var(--ink); }
margin: 10px auto 0;
}
.lr-stage-notices:empty { display: none; }
+.lr-sharing-settings { width: min(820px, 100%); margin: 0 auto; }
+.lr-sharing-panel {
+ margin-top: 14px; padding: 22px; background: var(--paper);
+ border: 1px solid var(--control-edge); border-radius: 24px; box-shadow: var(--control-shadow);
+ animation: lr-sharing-enter 180ms ease-out;
+}
+.lr-sharing-columns { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: 28px; }
+.lr-sharing-section { display: grid; align-content: start; gap: 18px; min-width: 0; }
+.lr-sharing-section + .lr-sharing-section { padding-inline-start: 24px; border-inline-start: 1px solid var(--wall-2); }
+.lr-sharing-section h2 { display: flex; align-items: center; gap: 8px; font-size: 15px; }
+.lr-sharing-section .lr-door-group { gap: 8px; }
+.lr-sharing-panel .lr-sharing-section .lr-door-glyph { color: var(--muted); }
+.lr-sharing-section .lr-door-glyph .lr-cap { white-space: normal; }
+.lr-sharing-limits { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
+.lr-sharing-panel .lr-slider { width: 100%; }
+.lr-sharing-panel .lr-slider output { min-width: 5ch; }
+.lr-sharing-section .lr-tiles { gap: 8px; }
+.lr-sharing-section .lr-tiles > .lr-comic-tip-wrap { flex: 1; min-width: 0; }
+.lr-sharing-section .lr-tile { width: 100%; min-width: 0; padding: 8px 5px; gap: 2px; border-radius: 14px; }
+.lr-sharing-section .lr-tile > svg { height: 28px; }
+.lr-sharing-section .lr-slider input { width: 100%; min-width: 0; }
+.lr-sharing-section .lr-row-group { gap: 7px; }
+.lr-sharing-section .lr-chip { min-height: 38px; padding: 7px 11px; }
+.lr-sharing-technical { margin-top: 20px; padding-top: 12px; border-top: 1px solid var(--wall-2); }
+.lr-sharing-technical > summary { width: fit-content; padding: 7px 3px; color: var(--muted); cursor: pointer; font-size: 12px; }
+.lr-sharing-technical > summary > svg { vertical-align: middle; margin-inline-end: 6px; }
+.lr-sharing-technical > div { display: grid; gap: 18px; padding-top: 16px; }
+@keyframes lr-sharing-enter { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: translateY(0); } }
+@media (prefers-reduced-motion: reduce) { .lr-sharing-panel { animation: none; } }
+@media (max-width: 680px) {
+ .lr-sharing-panel { padding: 16px; border-radius: 20px; }
+ .lr-sharing-columns { grid-template-columns: 1fr; gap: 22px; }
+ .lr-sharing-limits { grid-template-columns: 1fr; }
+ .lr-sharing-section + .lr-sharing-section { padding: 20px 0 0; border-inline-start: 0; border-top: 1px solid var(--wall-2); }
+}
+.lr-host-share-controls {
+ display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 4px;
+ width: fit-content; max-width: 100%; margin: 12px auto 0; padding: 6px;
+ background: var(--paper); color: var(--ink); border: 1px solid var(--control-edge);
+ border-radius: 20px; box-shadow: 0 3px 0 color-mix(in srgb, var(--ink) 8%, transparent);
+}
+.lr-host-share-controls > .lr-comic-tip-wrap:has(#host-stop-share) { margin-inline-start: 4px; padding-inline-start: 8px; border-inline-start: 1px solid var(--control-edge); }
+.lr-microphone-settings { min-width: 0; max-width: 440px; gap: 6px; }
+.lr-microphone-settings .lr-capture-device { padding: 0; }
+.lr-microphone-settings > label { display: flex; align-items: center; justify-content: space-between; gap: 12px; font-size: 12px; color: var(--muted); }
+.lr-microphone-settings > .lr-comic-tip-wrap { display: flex; width: 100%; }
+.lr-microphone-settings input { width: 100%; min-width: 32px; height: 36px; padding: 0; accent-color: var(--action); cursor: pointer; }
+.lr-microphone-settings input:disabled { cursor: default; }
+.lr-microphone-settings output { min-width: 4ch; font-variant-numeric: tabular-nums; text-align: right; }
+.lr-capture-device { display: grid; gap: 6px; min-width: 0; padding: 4px 10px 10px; }
+.lr-capture-device label { font-size: 12px; padding: 0; color: var(--muted); }
+.lr-capture-device-controls { display: flex; align-items: center; gap: 6px; min-width: 0; }
+.lr-capture-device select {
+ flex: 1; min-width: 0; width: 100%; min-height: 40px; padding: 8px 12px;
+ border: 1px solid var(--control-edge); border-radius: 12px;
+ background: var(--wall); color: var(--ink); font: inherit; font-size: 13px; cursor: pointer;
+ transition: background-color 140ms ease, border-color 140ms ease;
+}
+.lr-capture-device select:is(:hover, :focus-visible):not(:disabled) { background: var(--control-hover); border-color: var(--ink); }
+.lr-capture-device .lr-device-refresh { min-width: 40px; width: 40px; height: 40px; padding: 0; border-width: 1px; }
+.lr-capture-device select:focus-visible { outline: 2px solid var(--action); outline-offset: 2px; }
+.lr-capture-device select:disabled { opacity: .55; cursor: default; }
+.lr-capture-device small { color: var(--muted); font-size: 11px; line-height: 1.5; }
+.lr-source-picker-body > .lr-capture-device { margin-bottom: 10px; }
+.lr-source-picker .lr-capture-device :is(label, small) { color: #9aa8bb; }
+@media (max-width: 580px) {
+ .lr-host-share-controls { gap: 2px; }
+ .lr-host-share-controls .lr-btn { width: 44px; padding: 0; }
+ .lr-host-share-controls .lr-cap { display: none; }
+ .lr-host-share-controls .lr-btn[aria-controls] { width: auto; min-width: 44px; height: auto; min-height: 44px; grid-auto-flow: row; gap: 2px; padding: 4px 8px; }
+ .lr-host-share-controls .lr-btn[aria-controls] .lr-cap { display: inline; max-width: 9ch; white-space: normal; font-size: 11px; line-height: 1.3; text-align: center; }
+}
.lr-room.is-theater .lr-stage-notices {
position: absolute;
z-index: 3;
@@ -1999,7 +2096,7 @@ svg[data-comic-motion="still"] .lr-person-eyes > ellipse {
transition-duration: 0.01ms !important;
}
.lr-tv-static { animation: none; opacity: .35; }
- .lr-btn, .lr-chip, .lr-tile, .lr-client-mode, .lr-toggle button, .lr-lang > button, .lr-language-menu button { transform: none !important; }
+ .lr-btn, .lr-chip, .lr-tile, .lr-client-mode, .lr-toggle button, .lr-lang > button, .lr-language-menu button, .lr-source-option, .lr-source-picker-refresh, .lr-source-picker-close { transform: none !important; }
.lr-route-edge.is-pending { animation: none; }
}
diff --git a/src/client/ui/icons.tsx b/src/client/ui/icons.tsx
index 2d2372a..225eedd 100644
--- a/src/client/ui/icons.tsx
+++ b/src/client/ui/icons.tsx
@@ -10,6 +10,9 @@ import { useEffect, useRef, type ReactNode } from "react";
import { bindSvgReplayOnPointerEnter } from "./animation";
const PATHS = {
+ camera: { body: (<>
>) },
+ microphone: { body: (<>
>) },
+ microphoneOff: { body: (<>
>) },
couch: { body: (<>
>) },
gamepad: { body: (<>
>) },
save: { body: (<>
>) },
diff --git a/src/client/ui/visual-kinds.ts b/src/client/ui/visual-kinds.ts
index 6563876..50bac2d 100644
--- a/src/client/ui/visual-kinds.ts
+++ b/src/client/ui/visual-kinds.ts
@@ -51,6 +51,10 @@ export type Set1Kind =
| "hint-switch-source"
| "hint-reconnect"
| "hint-capture-browser"
+ | "hint-capture-camera"
+ | "hint-microphone-on"
+ | "hint-microphone-off"
+ | "hint-microphone-volume"
| "hint-capture-window"
| "hint-capture-display";
diff --git a/src/client/webrtc/host-peer.ts b/src/client/webrtc/host-peer.ts
index d7d6185..bd7f674 100644
--- a/src/client/webrtc/host-peer.ts
+++ b/src/client/webrtc/host-peer.ts
@@ -233,7 +233,8 @@ export class HostPeer {
return false;
}
- const nextVideoTrack = cloneSenderVideoTrack(nextSourceVideoTrack);
+ const videoChanged = this.stream.getVideoTracks()[0] !== nextSourceVideoTrack;
+ const nextVideoTrack = videoChanged ? cloneSenderVideoTrack(nextSourceVideoTrack) : previousVideoTrack;
let retainedNextVideoTrack = false;
const nextAudioTrack = nextStream.getAudioTracks()[0] ?? null;
const nextAudioDirection = nextAudioTrack ? "sendonly" : "inactive";
@@ -244,19 +245,21 @@ export class HostPeer {
this.replacementAudioTrack = nextAudioTrack;
this.applyPausedState(nextVideoTrack, nextAudioTrack);
const previousAudioTrack = audioSender.track;
- this.statsSamplingBlocked = true;
- this.statsAccumulator = createStatsAccumulator();
+ if (videoChanged) {
+ this.statsSamplingBlocked = true;
+ this.statsAccumulator = createStatsAccumulator();
+ }
try {
try {
- await videoSender.replaceTrack(nextVideoTrack);
+ if (videoChanged) await videoSender.replaceTrack(nextVideoTrack);
await audioSender.replaceTrack(nextAudioTrack);
if (audioDirectionChanged) {
audioTransceiver.direction = nextAudioDirection;
}
} catch (error) {
const [videoRollback] = await Promise.allSettled([
- videoSender.replaceTrack(previousVideoTrack),
+ videoChanged ? videoSender.replaceTrack(previousVideoTrack) : Promise.resolve(),
audioSender.replaceTrack(previousAudioTrack),
]);
if (audioDirectionChanged) {
@@ -276,34 +279,36 @@ export class HostPeer {
this.senderVideoTrack = nextVideoTrack;
this.stream = nextStream;
retainedNextVideoTrack = true;
- this.pooledVideo?.dispose();
- this.pooledVideo = null;
- this.attachVideoPool(nextSourceVideoTrack);
- if (previousVideoTrack !== this.encodedOutput?.track) previousVideoTrack.stop();
- this.startupVideoProfilePending = needsStartupVideoProfile(
- this.desiredProfile,
- );
- this.startupFramesBaseline = null;
- this.snapshot = { ...this.snapshot, metrics: { ...EMPTY_METRICS } };
+ if (videoChanged) {
+ this.pooledVideo?.dispose();
+ this.pooledVideo = null;
+ this.attachVideoPool(nextSourceVideoTrack);
+ if (previousVideoTrack !== this.encodedOutput?.track) previousVideoTrack.stop();
+ this.startupVideoProfilePending = needsStartupVideoProfile(this.desiredProfile);
+ this.startupFramesBaseline = null;
+ this.snapshot = { ...this.snapshot, metrics: { ...EMPTY_METRICS } };
+ }
await this.configureSender(videoSender, audioSender, {
profile: startupVideoProfile(this.desiredProfile),
profileRevision: this.profileRevision,
- video: this.connection.connectionState === "connected",
+ video: videoChanged && this.connection.connectionState === "connected",
audio: true,
});
this.snapshot = { ...this.snapshot, error: null };
this.emit();
return !audioDirectionChanged || (await this.createOffer(false));
} finally {
- if (!retainedNextVideoTrack) {
+ if (videoChanged && !retainedNextVideoTrack) {
nextVideoTrack.stop();
}
if (this.replacementVideoTrack === nextVideoTrack) {
this.replacementVideoTrack = null;
this.replacementAudioTrack = null;
}
- this.statsAccumulator = createStatsAccumulator();
- this.statsSamplingBlocked = false;
+ if (videoChanged) {
+ this.statsAccumulator = createStatsAccumulator();
+ this.statsSamplingBlocked = false;
+ }
}
});
}
diff --git a/tests/browser-gate-harness.test.ts b/tests/browser-gate-harness.test.ts
new file mode 100644
index 0000000..f9919cd
--- /dev/null
+++ b/tests/browser-gate-harness.test.ts
@@ -0,0 +1,8 @@
+import { expect, it } from "vitest";
+import { join } from "node:path";
+import { launchChrome } from "../scripts/browser-gate-harness";
+
+it("reports a missing browser executable through awaited startup", async () => {
+ await expect(launchChrome(join(import.meta.dirname, "missing-browser.exe"), 9222,
+ join(import.meta.dirname, "unused-profile"))).rejects.toMatchObject({ code: "ENOENT" });
+});
diff --git a/tests/camera-previews.test.ts b/tests/camera-previews.test.ts
new file mode 100644
index 0000000..b2019ab
--- /dev/null
+++ b/tests/camera-previews.test.ts
@@ -0,0 +1,125 @@
+import { afterEach, expect, test, vi } from "vitest";
+import { loadCameraPreviews, type CameraPreview } from "../src/client/media/camera-previews";
+
+function setup({ permission = true, frames = true } = {}) {
+ const tracks: { id: string; readyState: string; stop: ReturnType
}[] = [];
+ const videos: Video[] = [];
+ const devices = ["front", "rear"].map(id => ({ kind: "videoinput", deviceId: id, label: `${id} camera` }));
+ const enumerateDevices = vi.fn(async () => permission ? devices : []);
+ const stream = (id: string) => {
+ const track = { id, kind: "video", label: `${id} camera`, readyState: "live",
+ getSettings: () => ({ deviceId: id }), stop: vi.fn(() => { track.readyState = "ended"; }) };
+ tracks.push(track);
+ return { getTracks: () => [track], getVideoTracks: () => [track] } as unknown as MediaStream;
+ };
+ const getUserMedia = vi.fn(async (options: MediaStreamConstraints) => {
+ expect(options.audio).toBe(false);
+ expect(tracks.filter(track => track.readyState === "live")).toHaveLength(0);
+ permission = true;
+ const id = (options.video as MediaTrackConstraints).deviceId as ConstrainDOMStringParameters | undefined;
+ return stream(id?.exact as string ?? "rear");
+ });
+ class Video extends EventTarget {
+ muted = false;
+ playsInline = false;
+ srcObject: MediaStream | null = null;
+ readyState = 0;
+ videoWidth = 1920;
+ videoHeight = 1080;
+ pause = vi.fn();
+ play = vi.fn(async () => {
+ if (frames) { this.readyState = 2; this.dispatchEvent(new Event("loadeddata")); }
+ });
+ }
+ const createElement = vi.fn((tag: string) => {
+ if (tag === "video") { const video = new Video(); videos.push(video); return video; }
+ return { width: 0, height: 0, getContext: () => ({ drawImage: vi.fn() }), toDataURL: () => "data:image/jpeg;preview" };
+ });
+ vi.stubGlobal("navigator", { mediaDevices: { getUserMedia, enumerateDevices } });
+ vi.stubGlobal("document", { createElement });
+ const publish = vi.fn<(sources: CameraPreview[]) => void>();
+ return { tracks, videos, devices, stream, getUserMedia, enumerateDevices, createElement, publish };
+}
+
+afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); });
+
+test("camera thumbnails use serial video-only inputs and release every input", async () => {
+ const { tracks, videos, publish, getUserMedia } = setup();
+ await loadCameraPreviews(new AbortController().signal, publish);
+ expect(getUserMedia).toHaveBeenCalledTimes(2);
+ expect(tracks.every(track => track.stop.mock.calls.length === 1)).toBe(true);
+ expect(videos.every(video => video.muted && video.playsInline && video.srcObject === null)).toBe(true);
+ expect(publish.mock.lastCall?.[0].every(source => !!source.preview)).toBe(true);
+ // Earlier renders are immutable while later thumbnails arrive.
+ expect(publish.mock.calls[0]![0].every(source => source.preview === null)).toBe(true);
+});
+
+test("permission bootstrap retains its first frame without reopening that camera", async () => {
+ const { tracks, publish, getUserMedia } = setup({ permission: false });
+ await loadCameraPreviews(new AbortController().signal, publish);
+ expect(getUserMedia).toHaveBeenCalledTimes(2);
+ expect(tracks.map(track => track.id)).toEqual(["rear", "front"]);
+ expect(publish.mock.lastCall?.[0].map(source => source.id)).toEqual(["front", "rear"]);
+ expect(tracks.every(track => track.readyState === "ended")).toBe(true);
+});
+
+test("late permission results after cancellation are stopped and never published", async () => {
+ const { getUserMedia, stream, tracks, publish } = setup({ permission: false });
+ let grant!: (value: MediaStream) => void;
+ getUserMedia.mockImplementationOnce(() => new Promise(resolve => { grant = resolve; }));
+ const request = new AbortController();
+ const pending = loadCameraPreviews(request.signal, publish);
+ const cancelled = expect(pending).rejects.toMatchObject({ name: "AbortError" });
+ await vi.waitFor(() => expect(grant).toBeDefined());
+ request.abort();
+ grant(stream("late"));
+ await cancelled;
+ expect(tracks[0]!.stop).toHaveBeenCalledOnce();
+ expect(publish).not.toHaveBeenCalled();
+});
+
+test("cancelling a frame wait immediately releases it and never opens the next camera", async () => {
+ const { tracks, videos, publish, getUserMedia } = setup({ frames: false });
+ const request = new AbortController();
+ const pending = loadCameraPreviews(request.signal, publish);
+ const cancelled = expect(pending).rejects.toMatchObject({ name: "AbortError" });
+ await vi.waitFor(() => expect(videos).toHaveLength(1));
+ const published = publish.mock.calls.length;
+ request.abort();
+ expect(tracks[0]!.stop).toHaveBeenCalledOnce();
+ await cancelled;
+ expect(getUserMedia).toHaveBeenCalledOnce();
+ expect(publish).toHaveBeenCalledTimes(published);
+ expect(videos[0]!.srcObject).toBeNull();
+});
+
+test("an active camera is borrowed without opening or stopping any camera", async () => {
+ const { stream, getUserMedia, publish, tracks } = setup();
+ const active = { srcObject: stream("front"), readyState: 2, videoWidth: 1280, videoHeight: 720 } as HTMLVideoElement;
+ await loadCameraPreviews(new AbortController().signal, publish, active);
+ expect(getUserMedia).not.toHaveBeenCalled();
+ expect(tracks[0]!.stop).not.toHaveBeenCalled();
+ expect(publish.mock.lastCall?.[0].map(source => !!source.preview)).toEqual([true, false]);
+});
+
+test("a busy camera keeps its identity and does not suppress other choices", async () => {
+ const { publish, tracks, getUserMedia } = setup();
+ getUserMedia.mockRejectedValueOnce(new DOMException("Busy", "NotReadableError"));
+ await loadCameraPreviews(new AbortController().signal, publish);
+ expect(publish.mock.lastCall?.[0].map(source => [source.id, !!source.preview])).toEqual([["front", false], ["rear", true]]);
+ expect(tracks[0]!.stop).toHaveBeenCalledOnce();
+});
+
+test("missing frames are bounded and an already cancelled picker requests nothing", async () => {
+ vi.useFakeTimers();
+ const { videos, tracks, getUserMedia, publish } = setup({ frames: false });
+ const pending = loadCameraPreviews(new AbortController().signal, publish);
+ await vi.runAllTimersAsync();
+ await pending;
+ expect(tracks.every(track => track.readyState === "ended")).toBe(true);
+ expect(videos.every(video => video.srcObject === null)).toBe(true);
+ getUserMedia.mockClear();
+ const request = new AbortController(); request.abort();
+ await expect(loadCameraPreviews(request.signal, publish)).rejects.toMatchObject({ name: "AbortError" });
+ expect(getUserMedia).not.toHaveBeenCalled();
+});
diff --git a/tests/copy.test.ts b/tests/copy.test.ts
index 41d8117..3e4002c 100644
--- a/tests/copy.test.ts
+++ b/tests/copy.test.ts
@@ -73,9 +73,9 @@ describe("copy catalog", () => {
expect(t("en", "join.title")).toBe("Join a room");
expect(t("zh", "host.advanced.route.peerOnly")).toBe("隐私模式");
expect(t("en", "host.advanced.route.peerOnly")).toBe("Privacy mode");
- expect(t("zh", "host.advanced.route.natPrediction")).toBe("NAT 穿透");
+ expect(t("zh", "host.advanced.route.natPrediction")).toBe("NAT 穿透增强");
expect(t("en", "host.advanced.route.natPrediction")).toBe(
- "NAT traversal",
+ "Enhanced NAT traversal",
);
expect(isCopyKey("viewer.reconnect")).toBe(true);
expect(isCopyKey("Reconnect media")).toBe(false);
diff --git a/tests/host-audio.test.ts b/tests/host-audio.test.ts
new file mode 100644
index 0000000..954e3df
--- /dev/null
+++ b/tests/host-audio.test.ts
@@ -0,0 +1,191 @@
+import { afterEach, expect, test, vi } from "vitest";
+import { HostAudio } from "../src/client/media/host-audio";
+import { captureBrowserSource, QUALITY_PROFILES } from "../src/client/media/quality";
+
+class Track {
+ enabled = true;
+ readyState = "live";
+ onended: (() => void) | null = null;
+ constructor(readonly kind: string) {}
+ stop = vi.fn(() => { this.readyState = "ended"; });
+}
+class Stream {
+ constructor(private tracks: Track[]) {}
+ getTracks() { return this.tracks; }
+ getAudioTracks() { return this.tracks.filter((track) => track.kind === "audio"); }
+ getVideoTracks() { return this.tracks.filter((track) => track.kind === "video"); }
+}
+const media = (...tracks: Track[]) => new Stream(tracks) as unknown as MediaStream;
+
+function setup() {
+ const contexts: Context[] = [];
+ class Context {
+ output = new Stream([new Track("audio")]);
+ inputs: Stream[] = [];
+ nodes: { connect: ReturnType; disconnect: ReturnType }[] = [];
+ currentTime = 0;
+ gain = { gain: { value: 1, setTargetAtTime: vi.fn() }, connect: vi.fn(), disconnect: vi.fn() };
+ constructor() { contexts.push(this); }
+ createMediaStreamDestination() { return { stream: this.output }; }
+ createGain() { return this.gain; }
+ createMediaStreamSource(stream: Stream) {
+ this.inputs.push(stream);
+ const node = { connect: vi.fn(), disconnect: vi.fn() };
+ this.nodes.push(node);
+ return node;
+ }
+ resume = vi.fn(async () => {});
+ close = vi.fn(async () => {});
+ }
+ vi.stubGlobal("MediaStream", Stream);
+ vi.stubGlobal("AudioContext", Context);
+ const getUserMedia = vi.fn();
+ vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } });
+ return { contexts, getUserMedia };
+}
+afterEach(() => vi.unstubAllGlobals());
+
+test("camera is a bounded video source and never implicitly requests a microphone", async () => {
+ const { getUserMedia } = setup();
+ const video = new Track("video");
+ const source = media(video);
+ getUserMedia.mockResolvedValue(source);
+ expect(await captureBrowserSource(QUALITY_PROFILES["1080p30"], "camera")).toBe(source);
+ expect(getUserMedia).toHaveBeenCalledWith({ audio: false, video: {
+ facingMode: { ideal: "environment" }, width: { ideal: 1920 }, height: { ideal: 1080 },
+ frameRate: { ideal: 30, max: 30 },
+ } });
+});
+
+test("mixing and source changes retain one audio track, microphone intent and the capture-owned video", async () => {
+ const { contexts, getUserMedia } = setup();
+ const video = new Track("video"), sound = new Track("audio"), microphone = new Track("audio");
+ const change = vi.fn();
+ const audio = new HostAudio(media(video, sound), change);
+ getUserMedia.mockResolvedValue(media(microphone));
+ const mixed = (await audio.toggleMicrophone())!;
+ expect(mixed.getVideoTracks()).toEqual([video]);
+ expect(mixed.getAudioTracks()).toHaveLength(1);
+ await audio.toggleMicrophone();
+ expect(microphone.enabled).toBe(false);
+ expect(change).toHaveBeenLastCalledWith(false);
+ const camera = new Track("video");
+ const replaced = audio.attach(media(camera));
+ expect(audio.sourceStream.getAudioTracks()).toHaveLength(0);
+ expect(replaced.getAudioTracks()).toEqual(mixed.getAudioTracks());
+ expect(replaced.getVideoTracks()).toEqual([camera]);
+ expect(sound.stop).toHaveBeenCalledOnce();
+ expect(video.stop).not.toHaveBeenCalled(); // Host owns picture retirement.
+ await audio.toggleMicrophone();
+ microphone.stop(); microphone.onended!();
+ expect(getUserMedia).toHaveBeenCalledOnce(); // Mute/unmute must not open a second microphone.
+ expect(change).toHaveBeenLastCalledWith(false);
+ expect(mixed.getAudioTracks()[0].readyState).toBe("live");
+ expect(camera.stop).not.toHaveBeenCalled();
+ audio.dispose();
+ expect(contexts[0].close).toHaveBeenCalledOnce();
+ expect(mixed.getAudioTracks()[0].readyState).toBe("ended");
+});
+
+test("source audio facts are independent of mixed voice, mute, volume and share pause", async () => {
+ const { getUserMedia } = setup();
+ const camera = new Track("video"), microphone = new Track("audio");
+ const source = media(camera);
+ const audio = new HostAudio(source, vi.fn());
+ getUserMedia.mockResolvedValue(media(microphone));
+ const mixed = (await audio.toggleMicrophone())!;
+ expect(mixed.getAudioTracks()).toHaveLength(1);
+ expect(audio.sourceStream).toBe(source);
+ expect(audio.sourceStream.getAudioTracks()).toHaveLength(0);
+ await audio.toggleMicrophone();
+ audio.setMicrophoneVolume(0);
+ mixed.getTracks().forEach(track => { track.enabled = false; });
+ expect(audio.sourceStream.getAudioTracks()).toHaveLength(0);
+ const sourceSound = new Track("audio");
+ sourceSound.enabled = false;
+ const nextSource = media(new Track("video"), sourceSound);
+ audio.attach(nextSource);
+ expect(audio.sourceStream).toBe(nextSource);
+ expect(audio.sourceStream.getAudioTracks()).toHaveLength(1);
+ expect(microphone.enabled).toBe(false);
+ audio.dispose();
+ expect(sourceSound.stop).toHaveBeenCalledOnce();
+});
+
+test("microphone denial leaves source audio/video alone; late permission cannot revive a retired share", async () => {
+ const { contexts, getUserMedia } = setup();
+ const video = new Track("video"), sound = new Track("audio");
+ const changed = vi.fn();
+ const audio = new HostAudio(media(video, sound), changed);
+ getUserMedia.mockRejectedValueOnce(new DOMException("Denied", "NotAllowedError"));
+ await expect(audio.toggleMicrophone()).rejects.toThrow("Denied");
+ expect(contexts[0].close).toHaveBeenCalledOnce();
+ expect(video.stop).not.toHaveBeenCalled();
+ expect(sound.stop).not.toHaveBeenCalled();
+ let grant!: (stream: MediaStream) => void;
+ getUserMedia.mockImplementationOnce(() => new Promise((resolve) => { grant = resolve; }));
+ const request = audio.toggleMicrophone();
+ audio.dispose();
+ const lateMicrophone = new Track("audio");
+ grant(media(lateMicrophone));
+ expect(await request).toBeNull();
+ expect(lateMicrophone.stop).toHaveBeenCalledOnce();
+ expect(changed).not.toHaveBeenCalled();
+ expect(contexts[1].close).toHaveBeenCalledOnce();
+});
+
+test("microphone volume changes only the microphone input without replacing the shared output", async () => {
+ const { contexts, getUserMedia } = setup();
+ const sound = new Track("audio"), microphone = new Track("audio");
+ const audio = new HostAudio(media(new Track("video"), sound), vi.fn());
+ audio.setMicrophoneVolume(0.5);
+ getUserMedia.mockResolvedValue(media(microphone));
+ const mixed = (await audio.toggleMicrophone())!;
+ const context = contexts[0];
+ expect(context.gain.gain.value).toBe(0.5);
+ expect(context.nodes[0].connect).not.toHaveBeenCalledWith(context.gain);
+ expect(context.nodes[1].connect).toHaveBeenCalledWith(context.gain);
+ audio.setMicrophoneVolume(1.5);
+ expect(context.gain.gain.setTargetAtTime).toHaveBeenLastCalledWith(1.5, 0, 0.01);
+ audio.setMicrophoneVolume(Number.NaN);
+ expect(context.gain.gain.setTargetAtTime).toHaveBeenCalledOnce();
+ await audio.toggleMicrophone();
+ const changed = audio.attach(media(new Track("video")));
+ expect(changed.getAudioTracks()).toEqual(mixed.getAudioTracks());
+ expect(microphone.enabled).toBe(false);
+ expect(getUserMedia).toHaveBeenCalledOnce();
+ audio.dispose();
+ expect(context.gain.disconnect).toHaveBeenCalledOnce();
+});
+
+test("switching microphone preserves output and leaves the old input on failure", async () => {
+ const { getUserMedia } = setup();
+ const video = new Track("video"), first = new Track("audio"), second = new Track("audio");
+ const audio = new HostAudio(media(video), vi.fn());
+ getUserMedia.mockResolvedValueOnce(media(first));
+ const output = (await audio.setMicrophone(true, "first"))!;
+ getUserMedia.mockRejectedValueOnce(new DOMException("missing", "NotFoundError"));
+ await expect(audio.setMicrophone(true, "missing")).rejects.toThrow("missing");
+ expect(first.stop).not.toHaveBeenCalled();
+ expect(video.stop).not.toHaveBeenCalled();
+ getUserMedia.mockResolvedValueOnce(media(second));
+ const replacement = await audio.setMicrophone(true, "second");
+ expect(getUserMedia).toHaveBeenLastCalledWith({ audio: expect.objectContaining({ deviceId: { exact: "second" } }) });
+ expect(replacement).toBeNull(); // No transport update for an input-only change.
+ expect(output.getAudioTracks()[0].readyState).toBe("live");
+ expect(first.stop).toHaveBeenCalledOnce();
+ first.onended!(); // Retired input cannot mute its replacement.
+ expect(second.enabled).toBe(true);
+ await audio.setMicrophone(false, "first");
+ expect(second.stop).toHaveBeenCalledOnce();
+ expect(getUserMedia).toHaveBeenCalledTimes(3); // Selecting while muted never captures.
+ audio.dispose();
+});
+
+test("camera selection requests one exact device without microphone permission", async () => {
+ const { getUserMedia } = setup();
+ getUserMedia.mockResolvedValue(media(new Track("video")));
+ await captureBrowserSource(QUALITY_PROFILES["1080p30"], "camera", "front-camera");
+ expect(getUserMedia).toHaveBeenCalledWith({ audio: false, video: expect.objectContaining({ deviceId: { exact: "front-camera" } }) });
+ expect(getUserMedia.mock.calls[0][0].video).not.toHaveProperty("facingMode");
+});
diff --git a/tests/host-peer-tracks.test.ts b/tests/host-peer-tracks.test.ts
index 5295fd5..bf6fb98 100644
--- a/tests/host-peer-tracks.test.ts
+++ b/tests/host-peer-tracks.test.ts
@@ -862,6 +862,30 @@ describe("HostPeer source replacement", () => {
expect(nextSenderVideo?.stop).toHaveBeenCalledOnce();
});
+ it.each([false, true])("keeps the video sender and quality when only audio changes (failure=%s)", async (fail) => {
+ const video = createTrack("video", "same-video");
+ const peer = createPeer(createStream(video, null));
+ await peer.start();
+ await acceptPeerAnswer(peer);
+ const connection = FakePeerConnection.latest!;
+ const videoSender = connection.senders[0]!;
+ const audioSender = connection.senders[1]!;
+ const clone = videoSender.track!;
+ const parametersBefore = videoSender.setParameters.mock.calls.length;
+ const offersBefore = connection.createOfferCallCount;
+ audioSender.failNextReplace = fail;
+ const audio = createTrack("audio", "mixed-audio");
+ expect(await peer.replaceStream(createStream(video, audio))).toBe(!fail);
+ expect(videoSender.replaceTrack).not.toHaveBeenCalled();
+ expect(videoSender.setParameters).toHaveBeenCalledTimes(parametersBefore);
+ expect(videoSender.track).toBe(clone);
+ expect(clone.stop).not.toHaveBeenCalled();
+ expect(video.clone).toHaveBeenCalledOnce();
+ expect(audioSender.track).toBe(fail ? null : audio);
+ expect(connection.createOfferCallCount).toBe(offersBefore + (fail ? 0 : 1));
+ peer.dispose();
+ });
+
it("keeps capture controls on the sender-owned video clone", async () => {
const sourceVideo = createConfiguredVideoTrack(
"capture-video",
diff --git a/tests/host-quality-ownership.test.ts b/tests/host-quality-ownership.test.ts
index aa030bc..af0ccb8 100644
--- a/tests/host-quality-ownership.test.ts
+++ b/tests/host-quality-ownership.test.ts
@@ -18,12 +18,12 @@ const source = ts.createSourceFile("HostPage.tsx", readFileSync(
new URL("../src/client/pages/HostPage.tsx", import.meta.url), "utf8",
), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const owners = new Set(["changeQuality", "commitQuality", "handleSignalMessage",
- "switchNativeSource", "finishSourceSwitch", "recoverBrowserFanout", "disposeNativeShare",
+ "switchSource", "watchCaptureEnd", "switchNativeSource", "finishSourceSwitch", "replaceBrowserStream", "recoverBrowserFanout", "disposeNativeShare",
"acquireNativeClient", "requestSharing", "startNativeShare", "startBrowserNativeIngress",
"ownNativeClient", "discardNativeClient", "releaseUnusedNativeClient", "closeCaptureSourcePicker",
"openCaptureSourcePicker", "startBrowserShareFromPicker", "startNativeShareFromPicker",
- "startSharing", "beginRoomMutation", "finishRoomMutation",
- "startPeer", "reconcileHostChildren", "setNotice", "setNoticeKey", "setNoticeError", "setNoticeErrorKey", "endSharing", "copyInvite", "isCurrentRoomAuthority"]);
+ "startSharing", "beginRoomMutation", "finishRoomMutation", "setCaptureError", "changeMicrophone", "toggleSharingPause",
+ "startPeer", "reconcileHostChildren", "setNotice", "setNoticeKey", "setNoticeError", "setNoticeErrorKey", "endSharing", "copyRoomLink", "isCurrentRoomAuthority"]);
const functions: string[] = [];
function collect(node: ts.Node): void {
if (ts.isFunctionDeclaration(node) && node.name && owners.has(node.name.text)) {
@@ -59,13 +59,14 @@ function fixture(launchedByClient = true) {
isConnected: vi.fn(() => true) };
const client = { updateShare: vi.fn(async () => undefined), replaceShareSource: vi.fn(async () => undefined),
startShare: vi.fn(async (): Promise<{ audio: boolean; codec: "h264" }> => ({ audio: false, codec: "h264" })),
+ onEvent: vi.fn((_listener: (event: unknown) => void) => () => undefined),
close: vi.fn(), onClose: vi.fn(() => () => undefined),
health: { nativeMedia: { video: true, hardwareH264: true, softwareVP8: true } },
captureOptions: vi.fn(async () => []), sources: vi.fn(async () => []),
stopReceive: vi.fn(async () => undefined), stopShare: vi.fn(async () => undefined) };
const route = { updateProfile: vi.fn(async () => true), resyncAuthoritative: vi.fn(async (): Promise => undefined) };
const state = {
- debugError, debugEvent, debugOperation, NativeCompatibilityError, isCapturePermissionFailure,
+ debugError, debugEvent, debugOperation, NativeCompatibilityError, isCapturePermissionFailure, DOMException,
launchedByClient, NativeClient: { connect: vi.fn(async (): Promise => null) },
nativeClientConnectRef: ref | null>(null),
ownNativeClient: vi.fn(), setJoiningRoom: vi.fn(), startSharing: vi.fn(), openCaptureSourcePicker: vi.fn(),
@@ -77,14 +78,16 @@ function fixture(launchedByClient = true) {
setNativeSources: vi.fn(), setShowCaptureBorder: vi.fn(), defaultNativeCapturePath: () => ({ adapterIndex: 0, encoderIndex: 0 }),
roomMutationRef: ref