oficina / meet Public

Merge fix/glass-and-glow: glass controls and the speaking glow

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev bb7319e4d5ed66cedd9cca014a897f5f391c5ea4 parent 91432ecparent db3a52d
29 files changed, +1273 −270
  • Makefile +2 −1
  • internal/calls/static/calls.css +96 −46
  • internal/calls/static/room.js +94 −34
  • native/DESIGN.md +32 −0
  • native/README.md +6 −3
  • native/apple/Sources/CallsInterop/CallSnapshots.swift +79 −0
  • native/apple/Sources/CallsInterop/Interop.swift +6 −0
  • native/apple/Sources/CallsKit/AudioMeter.swift +153 −43
  • native/apple/Sources/CallsKit/Call.swift +15 −0
  • native/apple/Sources/CallsKit/CallActionStyle.swift +88 −54
  • native/apple/Sources/CallsKit/CallFixtures.swift +19 −0
  • native/apple/Sources/CallsKit/CallsAppearance.swift +3 −0
  • native/apple/Sources/CallsKit/DevicePanelAudio.swift +2 −2
  • native/apple/Sources/CallsKit/MacFullWindowCall.swift +16 −7
  • native/apple/Sources/CallsKit/MacVideoTile.swift +11 −8
  • native/apple/Sources/CallsKit/MiniWindow.swift +6 −5
  • native/apple/Sources/CallsKit/MutedBadge.swift +3 −3
  • native/apple/Sources/CallsKit/SpeakingGlow.swift +122 −0
  • native/apple/Tests/CallsKitTests/AudioLevelTests.swift +1 −1
  • native/apple/Tests/CallsKitTests/CallChromeTests.swift +18 −5
  • native/apple/Tests/CallsKitTests/SpeakingGlowTests.swift +195 −0
  • native/ios/Sources/CallsPhone.swift +11 −8
  • native/ios/Sources/PhoneCallFixture.swift +8 −2
  • native/ios/Sources/PhoneFullScreenCall.swift +4 −5
  • native/ios/UITests/CallsPhoneUITests.swift +29 −0
  • test/browser/call.mjs +98 −19
  • test/browser/native.mjs +2 −0
  • test/browser/room-glow.test.mjs +149 −0
  • test/browser/room-hearing.test.mjs +5 −24
diff --git a/Makefile b/Makefile
index eb72cda..eb52588 100644
--- a/Makefile
+++ b/Makefile
@@ -130,7 +130,7 @@ ci: vet fmt-check test migration-check
# -count=1 because call.mjs is not an input to Go's test cache: node
# opens it, not the test, so an edited browser script would otherwise be
# answered with a cached pass from before the edit.
-# The five .test.mjs files run first because they need no browser at
+# The .test.mjs files run first because they need no browser at
# all: they lift functions out of room.js and chat.js by name — pure
# ones for their answers, whole loops for what they do — and check
# §5.1's glare decision against the W3C truth table; §6's DOM safety,
@@ -150,6 +150,7 @@ browser:
node --test test/browser/room-mute.test.mjs
node --test test/browser/room-space.test.mjs
node --test test/browser/room-hearing.test.mjs
+ node --test test/browser/room-glow.test.mjs
node --test test/browser/room-lane.test.mjs
node --test test/browser/media.test.mjs
node --test test/browser/audio.test.mjs
diff --git a/internal/calls/static/calls.css b/internal/calls/static/calls.css
index 40aacc5..4b0960a 100644
--- a/internal/calls/static/calls.css
+++ b/internal/calls/static/calls.css
@@ -409,41 +409,7 @@
#controls.calls-controls .calls-toggle-state { font-size: inherit; }
}
-/* Spec §15.20: the level meter beside a tile's name. Five bars of
- rising height, lit from the left as the level rises — a shape, not a
- colour, so a lit bar is told from an unlit one by its opacity and
- position as well as its tone. The steps are discrete and there is no
- transition on them at all: a meter updated five times a second has
- nothing to animate, and so nothing to turn off for reduced motion. */
-.calls-meter {
- display: inline-flex;
- align-items: flex-end;
- gap: 2px;
- block-size: 0.9em;
- flex: none;
-}
-.calls-meter[hidden] { display: none; }
-.calls-meter i {
- display: block;
- inline-size: 3px;
- border-radius: 1px;
- background: currentColor;
- opacity: 0.25;
-}
-.calls-meter i:nth-child(1) { block-size: 20%; }
-.calls-meter i:nth-child(2) { block-size: 40%; }
-.calls-meter i:nth-child(3) { block-size: 60%; }
-.calls-meter i:nth-child(4) { block-size: 80%; }
-.calls-meter i:nth-child(5) { block-size: 100%; }
-.calls-meter[data-level='1'] i:nth-child(-n + 1),
-.calls-meter[data-level='2'] i:nth-child(-n + 2),
-.calls-meter[data-level='3'] i:nth-child(-n + 3),
-.calls-meter[data-level='4'] i:nth-child(-n + 4),
-.calls-meter[data-level='5'] i:nth-child(-n + 5) {
- opacity: 1;
-}
-
-/* What a tile says about its audio when the meter cannot: no sound,
+/* What a tile says about its audio when the glow cannot: no sound,
sound blocked, a microphone picking nothing up. Words, with the
weight as a second cue, in the caption's own pill. The ⓘ is a native
disclosure, so it is a keyboard control with no script of its own,
@@ -481,17 +447,6 @@
font-weight: 600;
}
-/* Someone speaking: a ring inside the tile's edge. A second cue only —
- the meter beside the name is the first — and drawn with an inset
- shadow so the grid does not move. It fades only for people who have
- not asked for reduced motion. */
-.calls-tile-speaking {
- box-shadow: inset 0 0 0 3px var(--rst-accent, currentColor);
-}
-@media (prefers-reduced-motion: no-preference) {
- .calls-tile { transition: box-shadow 150ms ease-out; }
-}
-
/* The page's own "Click anywhere to turn on sound", above the call. */
.calls-sound-banner {
display: flex;
@@ -541,3 +496,98 @@
color: var(--rst-text);
font-weight: 600;
}
+
+/* 0.3.7: one glass language over the picture, as the native apps have
+ it, and no hairline edges anywhere. A surface is black at 62 % over a
+ blurred, saturated backdrop; the black does the work, so white on it
+ is 6.2:1 even over a white wall and where backdrop-filter is missing,
+ and there a near-opaque black stands in for the blur. */
+.calls-tile-name,
+#controls.calls-controls {
+ background: rgb(0 0 0 / 0.62);
+ color: #fff;
+ -webkit-backdrop-filter: blur(18px) saturate(1.6);
+ backdrop-filter: blur(18px) saturate(1.6);
+}
+@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
+ .calls-tile-name,
+ #controls.calls-controls { background: rgb(18 18 18 / 0.92); }
+}
+.calls-tile-name { border-radius: 999px; }
+.calls-tile-name:has(.calls-tile-hear:not([hidden]), .calls-tile-notice:not([hidden])) {
+ border-radius: 14px;
+}
+.calls-tile-name button,
+.calls-tile-name a {
+ color: #fff;
+ border: 0;
+ background: rgb(255 255 255 / 0.14);
+}
+
+/* The speaking glow behind the name, which replaced the ring round the
+ tile and the meter beside the name. room.js sets --glow (0..1, how
+ loudly they are speaking, 0 when they are not) and --glow-t, the
+ transition's duration: quick rising, slower falling, so it breathes
+ with the voice. A box-shadow, so nothing moves when it lights; the
+ registered property is what lets the number itself be transitioned,
+ and a browser without @property steps five times a second instead.
+ The teal is the accent's hue lit up (#2ECCDB). With reduced motion,
+ room.js holds one steady value and nothing is transitioned. */
+@property --glow {
+ syntax: '<number>';
+ inherits: true;
+ initial-value: 0;
+}
+.calls-tile-name {
+ --glow: 0;
+ box-shadow:
+ 0 0 calc(4px + 16px * var(--glow)) calc(1px + 4px * var(--glow)) rgb(46 204 219 / calc(0.9 * var(--glow))),
+ 0 0 0 calc(1.5px * var(--glow)) rgb(46 204 219 / calc(0.5 * var(--glow)));
+}
+@media (prefers-reduced-motion: no-preference) {
+ .calls-tile-name { transition: --glow var(--glow-t, 300ms) ease-out; }
+}
+
+/* The room's control bar: one glass pane the buttons sit on, and the
+ buttons borderless on it — white glyph and word, a light wash under
+ the pointer, and a filled capsule only for a state: microphone or
+ camera off in amber (the slashed word says it too), sharing or a
+ panel open in near-white with dark text, Leave in red. The state is
+ also the word in .calls-toggle-state and aria-pressed, so never
+ colour alone. estilo's ring for [aria-pressed] is replaced by the
+ capsule. */
+#controls.calls-controls {
+ padding: 8px;
+ border-radius: 28px;
+ gap: 4px;
+}
+#controls.calls-controls button {
+ background: transparent;
+ border: 0;
+ box-shadow: none;
+ color: #fff;
+ border-radius: 999px;
+}
+#controls.calls-controls button:hover { background: rgb(255 255 255 / 0.1); }
+#controls.calls-controls button:active { background: rgb(255 255 255 / 0.18); }
+#controls.calls-controls button:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
+#controls.calls-controls #mute[aria-pressed='true'],
+#controls.calls-controls #camera[aria-pressed='true'] {
+ background: #a64d05;
+ color: #fff;
+}
+#controls.calls-controls #share[aria-pressed='true'],
+#controls.calls-controls [aria-expanded='true'] {
+ background: #f0f0f0;
+ color: #1a1a1a;
+}
+#controls.calls-controls #leave,
+#controls.calls-controls #leave:hover {
+ background: #d62924;
+ color: #fff;
+}
+#controls.calls-controls #leave:active { background: #b3201c; }
+
+.calls-tile-name .calls-tile-note { color: #fff; }
+/* No hairline round a tile either: the picture's own edge is the edge. */
+.calls-tile { border: 0; }
diff --git a/internal/calls/static/room.js b/internal/calls/static/room.js
index 5ce6733..5dc5ab5 100644
--- a/internal/calls/static/room.js
+++ b/internal/calls/static/room.js
@@ -237,8 +237,21 @@
const SPEAK_ON = 0.04;
const SPEAK_OFF = 0.015;
const SPEAK_HOLD = 4;
- // How many bars a meter has.
- const METER_STEPS = 5;
+ // The speaking glow behind a tile's name, which replaced the ring round
+ // the tile and the meter beside the name (0.3.7), as the native apps
+ // have it (SpeakingGlow.swift): a reading's brightness by decibels
+ // between GLOW_QUIET_DB and GLOW_LOUD_DB, never under GLOW_FLOOR while
+ // lit; lit only once sound at SPEAK_ON has kept up for GLOW_ONSET ms, so
+ // a click never lights it; rising over GLOW_ATTACK ms and falling over
+ // GLOW_RELEASE ms, which is a CSS transition on --glow; and one steady
+ // GLOW_STEADY for anyone who has asked for reduced motion.
+ const GLOW_QUIET_DB = -42;
+ const GLOW_LOUD_DB = -12;
+ const GLOW_FLOOR = 0.3;
+ const GLOW_ONSET = 150;
+ const GLOW_ATTACK = 50;
+ const GLOW_RELEASE = 300;
+ const GLOW_STEADY = 0.6;
// §4.3's limit on the list the hub will take.
const MAX_NO_SOUND = 15;
// §5.4: the tile key a peer's screen gets, built by shareKey.
@@ -4343,6 +4356,10 @@
silent: false, // flat for NO_SOUND_AFTER while it counted
speaking: false,
speakHold: 0,
+ glowLit: false, // the speaking glow is lit (judgeGlow)
+ glow: 0, // where its brightness is heading, 0..1
+ loudFrom: 0, // the first and latest loud readings since
+ loudLast: 0, // speaking began, or 0
notice: '', // the remote notice last drawn, for the live region
};
}
@@ -4412,21 +4429,54 @@
return rec.speaking;
}
- // levelStep turns a level into how many of the meter's bars are lit.
- // Decibels, because that is how loudness is heard: -60 dBFS and under
- // is one bar, 0 dBFS is all of them, and silence is none — so any
- // sound at all lights something, and a flat stream is visibly flat.
- // Pure, and lifted by name in the node test.
- function levelStep(level) {
- if (!(level >= FLAT_LEVEL)) return 0;
+ // glowIntensity is a level's brightness for the glow, 0..1: decibels,
+ // because that is how loudness is heard, from nothing at GLOW_QUIET_DB
+ // to all of it at GLOW_LOUD_DB. Pure, and lifted by name in the node
+ // test.
+ function glowIntensity(level) {
+ if (!(level > 0)) return 0;
const db = 20 * Math.log10(Math.min(1, level));
- return Math.max(1, Math.min(METER_STEPS, Math.ceil((db + 60) / 12)));
+ return Math.max(0, Math.min(1, (db - GLOW_QUIET_DB) / (GLOW_LOUD_DB - GLOW_QUIET_DB)));
+ }
+
+ // judgeGlow is the glow's gate and target, taken after judgeSpeaking
+ // on the same reading: lit while speaking once loud readings have kept
+ // up for GLOW_ONSET ms, and then as bright as the reading, over the
+ // floor. Returns the target brightness. Pure over the record, and
+ // lifted by name in the node test.
+ function judgeGlow(rec, level, now) {
+ if (!rec.speaking) {
+ rec.loudFrom = 0;
+ rec.loudLast = 0;
+ rec.glowLit = false;
+ } else if (level >= SPEAK_ON) {
+ if (!rec.loudFrom) rec.loudFrom = now;
+ rec.loudLast = now;
+ }
+ if (rec.speaking && !rec.glowLit && rec.loudFrom && rec.loudLast - rec.loudFrom >= GLOW_ONSET) {
+ rec.glowLit = true;
+ }
+ rec.glow = rec.glowLit ? GLOW_FLOOR + (1 - GLOW_FLOOR) * glowIntensity(level) : 0;
+ return rec.glow;
+ }
+
+ // glowStyle is what a name label is given for a glow: the value for
+ // --glow, as a string with two places so an unchanged glow writes
+ // nothing, and the transition's duration for getting there — the
+ // attack going up, the release coming down, and none at all under
+ // reduced motion, where a lit glow is one steady brightness. Pure, and
+ // lifted by name in the node test.
+ function glowStyle(lit, target, before, reduced) {
+ const value = reduced ? (lit ? GLOW_STEADY : 0) : lit ? target : 0;
+ const time = reduced ? 0 : value > before ? GLOW_ATTACK : GLOW_RELEASE;
+ return { value: value.toFixed(2), time: time + 'ms' };
}
// hearingShown is what a tile says about its audio, in order: the
- // Muted badge, when the person says so (a meter beside it would only
+ // Muted badge, when the person says so (a glow beside it would only
// argue); "Sound blocked", when this browser will not play; "No sound",
- // when the five-second rule says so; and otherwise the meter. The same
+ // when the five-second rule says so; and otherwise the live sound, the
+ // glow behind the name ('meter', the state's name from before). The same
// order for our own tile, which is never blocked. Pure, and lifted by
// name in the node test.
function hearingShown(media, blocked, silent) {
@@ -4475,18 +4525,19 @@
'input chosen, a microphone muted on the device itself, or an audio interface sending a ' +
'different input. Calls lets them know.';
- // hearingMarks builds one tile's hearing parts: the meter beside the
- // name, the sentence with its ⓘ, and the line for a peer's notice.
- // The meter is aria-hidden — a level moving five times a second is
- // noise to a screen reader — and everything it means that matters is
- // said in the sentence instead.
+ // reducedMotion: the person has asked for less movement, so the glow
+ // holds one brightness rather than following the voice.
+ function reducedMotion() {
+ return typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
+ }
+
+ // hearingMarks builds one tile's hearing parts: the sentence with its
+ // ⓘ, and the line for a peer's notice. The live sound is the glow on
+ // the name label itself (drawHearing), which is a box-shadow and so
+ // nothing a screen reader hears — a level moving five times a second is
+ // noise to one — and everything it means that matters is said in the
+ // sentence instead.
function hearingMarks() {
- const meter = document.createElement('span');
- meter.className = 'calls-meter';
- meter.setAttribute('aria-hidden', 'true');
- meter.dataset.level = '0';
- meter.hidden = true;
- for (let i = 0; i < METER_STEPS; i++) meter.append(document.createElement('i'));
const box = document.createElement('div');
box.className = 'calls-tile-hear';
box.hidden = true;
@@ -4502,7 +4553,7 @@
const notice = document.createElement('span');
notice.className = 'calls-tile-notice';
notice.hidden = true;
- return { meter, box, text, why, notice, kind: '' };
+ return { box, text, why, notice, kind: '', glow: '0.00' };
}
// drawHearing brings one tile's hearing parts up to date. Called from
@@ -4519,10 +4570,15 @@
const blocked = !self && refusedTiles.has(key);
const shown = hearingShown(media, blocked, !!(rec && rec.silent));
t.hearShown = shown;
- h.meter.hidden = shown !== 'meter';
- const step = String(shown === 'meter' && rec ? levelStep(rec.level) : 0);
- if (h.meter.dataset.level !== step) h.meter.dataset.level = step;
- t.figure.classList.toggle('calls-tile-speaking', shown === 'meter' && !!(rec && rec.speaking));
+ const lit = shown === 'meter' && !!(rec && rec.glowLit);
+ const glow = glowStyle(lit, rec ? rec.glow : 0, Number(h.glow), reducedMotion());
+ if (h.glow !== glow.value) {
+ h.glow = glow.value;
+ t.label.style.setProperty('--glow-t', glow.time);
+ t.label.style.setProperty('--glow', glow.value);
+ }
+ const litText = lit ? 'on' : 'off';
+ if (t.label.dataset.glow !== litText) t.label.dataset.glow = litText;
const name = self ? '' : names.get(key) || 'Someone';
let kind = '';
let text = '';
@@ -4588,6 +4644,7 @@
rec.packets = s.packets;
if (s.grew) rec.packetAt = now;
judgeSpeaking(rec, s.level);
+ judgeGlow(rec, s.level, now);
return s;
}
@@ -4616,6 +4673,8 @@
}
if (!report) {
rec.level = 0;
+ rec.speaking = false;
+ judgeGlow(rec, 0, now);
judgeSilence(rec, now, false, false, false);
} else {
const s = sampleInto(rec, report, now);
@@ -4645,6 +4704,7 @@
} else {
selfHearing.level = 0;
selfHearing.speaking = false;
+ judgeGlow(selfHearing, 0, now);
judgeSilence(selfHearing, now, false, false, false);
}
const key = noSoundList().join(',');
@@ -5026,7 +5086,6 @@
if (marks) {
nameEl.after(marks.muted);
figure.append(marks.off);
- marks.muted.after(hear.meter);
// After Play, so the caption's buttons keep their order.
label.append(hear.box, hear.notice);
}
@@ -5914,9 +5973,10 @@
shown: t.hearShown,
level: rec ? rec.level : 0,
peak: rec ? rec.peak : 0,
- bars: Number(t.hear.meter.dataset.level) || 0,
silent: !!(rec && rec.silent),
- speaking: t.figure.classList.contains('calls-tile-speaking'),
+ // The glow behind the name as drawn: lit, and its --glow.
+ speaking: t.label.dataset.glow === 'on',
+ glow: Number(t.hear.glow) || 0,
text: t.hear.box.hidden ? '' : t.hear.text.textContent,
notice: t.hear.notice.hidden ? '' : t.hear.notice.textContent,
};
@@ -6114,9 +6174,9 @@
media: localMedia(),
remoteMedia: Object.fromEntries(remoteMedia),
// §15.20, by tile key ('self' for ours): what the tile is saying
- // about its audio ('muted', 'blocked', 'silent' or 'meter'), the
- // level as last read and the loudest seen, the meter's bars, the
- // speaking highlight, and the notice a peer's own report put
+ // about its audio ('muted', 'blocked', 'silent' or 'meter', which
+ // is live sound), the level as last read and the loudest seen,
+ // the speaking glow (lit, and its brightness), and the notice a peer's own report put
// there. Levels are numbers between 0 and 1 and say nothing
// about what was said.
hearing: hearingState(),
diff --git a/native/DESIGN.md b/native/DESIGN.md
index 91d7222..b25df2a 100644
--- a/native/DESIGN.md
+++ b/native/DESIGN.md
@@ -161,3 +161,35 @@ Snapshots: `CallsInterop --call-snapshots <dir>` (also part of
iPhone's come from `CallsPhoneUITests.testFullScreenCallSnapshots` against the
debug build's `-CallsCallFixture` calls (`CallFixture`), written to
`CALLS_SNAPSHOT_DIR` or `/tmp/immersive-call`.
+
+## Glass and the speaking glow · 2026-09-25 (0.3.7)
+
+Supersedes the hairline edges and button tiles of 0.3.5's **Over the picture**.
+
+- **One glass language, no hairlines.** Every surface over the picture — the
+ bar, the title pill, notices, name labels, the iPhone's round buttons and the
+ mini window's tab — is borderless glass: Liquid Glass on macOS 26 and iOS 26,
+ the ultra-thin material before, with black at 62 % on it
+ (`CallOverlay.scrim`). Rendered over white the glass measures #5B5B5B, so
+ white on it is 6.8:1 (6.2:1 from the black alone). The environment value
+ `callOverlayGlass` turns the glass off to show the fallback.
+- **Buttons are glyphs on the pane**: white, a light wash under the pointer,
+ and a capsule only for a state — near-white for on (sharing, a panel open),
+ amber with the slashed symbol for microphone or camera off, red for Leave.
+- **Devices** over the picture is opaque and dark (`CallOverlay.panelFill`)
+ with the dark appearance's labels: 16.7:1 primary and 5.1:1 secondary as
+ rendered. It holds the controls up while open and never fades with them.
+- **The speaking glow** replaces the white ring round a tile and the meter
+ beside the name, on Mac, iPhone, web and the mini window's tab: the accent's
+ hue lit up (`CallsAppearance.glow`) as a halo behind the name label, bright
+ as the voice (`SpeakingGlow`, and `judgeGlow`/`glowStyle` in room.js). The
+ halo redraws on a timeline only until it arrives; on the web it is a
+ registered `--glow` transitioned by CSS. "No sound from …" and the microphone
+ warnings stay as words by the name; the Devices panel keeps a compact meter.
+
+Snapshots: `CallsInterop --glass-snapshots <dir>` renders real windows below
+the desktop and captures them with `screencapture -l` (an off-screen bitmap
+draws no Liquid Glass); the iPhone's are
+`CallsPhoneUITests.testGlowAndGlassSnapshots` (`-CallsFixtureSpeaking`,
+`-CallsFixtureNoGlass`).
+
diff --git a/native/README.md b/native/README.md
index 4378e5f..d3dded6 100644
--- a/native/README.md
+++ b/native/README.md
@@ -107,11 +107,14 @@ while sharing; the panel itself shows one warning line only when something is
wrong (a stretched or broken-up signal, echo cancellation off, noise reduction
bypassed, processing refused — `AudioWarning`).
-**Sound is measured, not inferred** (spec §15.20). On 2026-09-25 Tony, in a
+**Sound is measured, not inferred** (spec §15.20). Since 0.3.7 a tile shows it
+as the speaking glow behind the name (`SpeakingGlow`: -42 to -12 dBFS over a
+0.3 floor, lit once speech has kept up 0.15 s, 50 ms attack, 300 ms release,
+a steady 0.6 under Reduce Motion) in place of the white ring and the meter. On 2026-09-25 Tony, in a
browser, could not hear Paul on the Mac app, and Paul's panel looked fine
because nothing on it measured sound. Now:
-- **Your tile** and the panel's **Microphone level** meter what is sent — the
+- **Your tile's glow** and the panel's **Microphone level** meter what is sent — the
capture hook's level after noise reduction (`CaptureLevel`, read at 10 Hz).
Unmuted and capturing, with nothing reaching the hook above -90 dBFS
*before* the suppressor for five seconds, the tile says "Your microphone
@@ -120,7 +123,7 @@ because nothing on it measured sound. Now:
- **Everyone else's tile** meters what arrives from them: a renderer on their
remote audio track (`RemoteAudioTap`), which sees each decoded frame before
the output volume, read at 5 Hz. Muted, the tile shows the Muted badge and no
- meter. Reporting themselves unmuted, connected, with audio bytes still rising
+ glow. Reporting themselves unmuted, connected, with audio bytes still rising
(inbound-rtp, one `statistics(for:)` per receiver a second) and the decoded
peak under -85 dBFS for five seconds, it says "No sound from Bea" instead,
and the app reports those people as `noSoundFrom` through the bridge. A thin
diff --git a/native/apple/Sources/CallsInterop/CallSnapshots.swift b/native/apple/Sources/CallsInterop/CallSnapshots.swift
index 5ce2c9c..add0cb4 100644
--- a/native/apple/Sources/CallsInterop/CallSnapshots.swift
+++ b/native/apple/Sources/CallsInterop/CallSnapshots.swift
@@ -52,4 +52,83 @@ import SwiftUI
try await shot("contrast-busy-hidden", .focus, shown: false, backdrop: .busy)
}
}
+
+ /// The glass and the glow as the window server draws them (0.3.7). An
+ /// off-screen bitmap has no Liquid Glass and no backdrop to blur, so
+ /// these are real windows, parked below the desktop where nobody sees
+ /// them, and captured by `screencapture -l`: the control bar over black,
+ /// white and a busy frame, in glass and in the material older systems
+ /// draw; the Devices panel; the names glowing at four levels; the tab.
+ static func saveGlass(to folder: URL) async throws {
+ NSApplication.shared.setActivationPolicy(.prohibited)
+ try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
+ let size = CGSize(width: 1000, height: 640)
+ func shot(
+ _ name: String, _ fixture: CallFixture, grid: Bool = false, devices: Bool = false,
+ glass: Bool = true, backdrop: FixtureBackdrop? = nil, me: FixtureSelf? = nil,
+ speaking: FixtureSpeaking? = nil
+ ) async throws {
+ let call = Call(playAudio: false)
+ fixture.apply(to: call)
+ me?.apply(to: call)
+ speaking?.apply(to: call)
+ try await renderLive(
+ MacCallView(
+ call: call, grid: grid, showingDevices: devices, style: .fullWindow,
+ controlsShown: true, leave: {}, rejoin: {}
+ )
+ .tint(CallsAppearance.accent)
+ .environment(\.fixtureBackdrop, backdrop)
+ .environment(\.callOverlayGlass, glass),
+ size: size, to: folder.appendingPathComponent("mac-\(name).png"))
+ }
+ for glass in [true, false] {
+ let kind = glass ? "glass" : "fallback"
+ for backdrop in FixtureBackdrop.allCases {
+ try await shot("bar-\(kind)-\(backdrop.rawValue)", .focus, glass: glass, backdrop: backdrop)
+ }
+ try await shot("bar-\(kind)-busy-muted", .focus, glass: glass, backdrop: .busy, me: .muted)
+ try await shot("bar-\(kind)-busy-sharing", .focus, glass: glass, backdrop: .busy, me: .sharing)
+ try await shot("devices-\(kind)", .focus, devices: true, glass: glass, backdrop: .busy)
+ }
+ for level in FixtureSpeaking.allCases {
+ try await shot("glow-\(level.rawValue)", .grid, grid: true, speaking: level)
+ try await shot("glow-focus-\(level.rawValue)", .focus, speaking: level)
+ }
+ for level in [FixtureSpeaking.silent, .high] {
+ let call = Call(playAudio: false)
+ CallFixture.focus.apply(to: call)
+ level.apply(to: call)
+ try await renderLive(
+ ZStack {
+ FixtureBackdrop.busy.view
+ MiniWindowSnapshot.tab(call: call)
+ },
+ size: CGSize(width: MiniWindowTab.size.width + 40, height: MiniWindowTab.size.height + 40),
+ to: folder.appendingPathComponent("mac-tab-\(level.rawValue).png"))
+ }
+ }
+
+ /// A real window below the desktop, captured by the window server.
+ static func renderLive(_ content: some View, size: CGSize, to file: URL) async throws {
+ let view = NSHostingView(rootView: content.environment(\.colorScheme, .dark))
+ view.frame.size = size
+ let window = NSWindow(
+ contentRect: NSRect(origin: CGPoint(x: 0, y: 0), size: size),
+ styleMask: [.borderless], backing: .buffered, defer: false)
+ window.isReleasedWhenClosed = false
+ window.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.desktopWindow)) - 1)
+ window.appearance = NSAppearance(named: .darkAqua)
+ window.contentView = view
+ window.orderBack(nil)
+ try await Task.sleep(for: .milliseconds(600))
+ view.layoutSubtreeIfNeeded()
+ let capture = Process()
+ capture.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture")
+ capture.arguments = ["-x", "-o", "-l", String(window.windowNumber), file.path]
+ try capture.run()
+ capture.waitUntilExit()
+ window.close()
+ guard capture.terminationStatus == 0 else { throw CocoaError(.fileWriteUnknown) }
+ }
}
diff --git a/native/apple/Sources/CallsInterop/Interop.swift b/native/apple/Sources/CallsInterop/Interop.swift
index 1ce3a5a..8e341c6 100644
--- a/native/apple/Sources/CallsInterop/Interop.swift
+++ b/native/apple/Sources/CallsInterop/Interop.swift
@@ -30,6 +30,12 @@ struct Input: Decodable {
} catch { exit(1) }
return
}
+ if CommandLine.arguments.count == 3, CommandLine.arguments[1] == "--glass-snapshots" {
+ do {
+ try await CallSnapshots.saveGlass(to: URL(fileURLWithPath: CommandLine.arguments[2]))
+ } catch { exit(1) }
+ return
+ }
if CommandLine.arguments.count == 2, CommandLine.arguments[1] == "--audio-selftest" {
exit(await AudioSelfTestHarness.run() ? 0 : 1)
}
diff --git a/native/apple/Sources/CallsKit/AudioMeter.swift b/native/apple/Sources/CallsKit/AudioMeter.swift
index c8e48d6..7f7840f 100644
--- a/native/apple/Sources/CallsKit/AudioMeter.swift
+++ b/native/apple/Sources/CallsKit/AudioMeter.swift
@@ -1,14 +1,17 @@
import Combine
import SwiftUI
-// Spec §15.20 on the tiles: a meter for every voice, measured where it can
-// be — what you send on your own tile, what arrives on everyone else's —
-// and a sentence in place of the meter when the sound has gone flat.
+// Spec §15.20 on the tiles: every voice measured where it can be — what
+// you send on your own tile, what arrives on everyone else's — shown as
+// the speaking glow behind the name (0.3.7; see SpeakingGlow), and a
+// sentence in its place when the sound has gone flat.
//
// The levels live in small observable objects of their own, one per tile,
-// rather than on `Call`: a meter that changes a few times a second must not
-// re-render the whole call view each time. Each object publishes only when
-// what it shows changes — a meter step, a line appearing — not per reading.
+// rather than on `Call`: a level that changes a few times a second must
+// not re-render the whole call view each time. Each object publishes only
+// when what it shows changes — a step, a line appearing — not per
+// reading. The glow has an object of its own (GlowLevel), so only the
+// glow redraws as it breathes.
/// Your own microphone, for your tile.
@MainActor public final class MicAudio: ObservableObject {
@@ -22,6 +25,8 @@ import SwiftUI
/// The microphone has more than one input, so the ⓘ points at the
/// Input picker.
@Published public private(set) var channels = false
+ /// The glow behind your name.
+ public let glow = GlowLevel()
public init() {}
@@ -41,6 +46,8 @@ import SwiftUI
@Published public private(set) var speaking = false
/// Their report about you: blocked, or not hearing you.
@Published public private(set) var notices: [String] = []
+ /// The glow behind their name.
+ public let glow = GlowLevel()
public init() {}
@@ -51,6 +58,34 @@ import SwiftUI
}
}
+/// One name's speaking glow, observed alone: only the glow redraws as it
+/// follows a voice. Publishes when the glow's target or lit state moves by
+/// something visible (SpeakingGlow.update says when), a few times a
+/// second at most while someone speaks and not at all in silence; between
+/// those the view works the brightness out for itself.
+@MainActor public final class GlowLevel: ObservableObject {
+ @Published public private(set) var glow = SpeakingGlow()
+ private var rule = SpeakingGlow()
+
+ public init() {}
+
+ func update(level: Float, speaking: Bool, now: Double) {
+ if rule.update(level: level, speaking: speaking, now: now) { glow = rule }
+ }
+
+ /// Takes another glow's state whole: the mini window's tab showing the
+ /// loudest person speaking.
+ func follow(_ other: SpeakingGlow) {
+ rule = other
+ if glow != other { glow = other }
+ }
+
+ func reset() { follow(SpeakingGlow()) }
+
+ /// Holds the glow at a level, for the snapshots.
+ @_spi(Fixtures) public func showFixture(level: Float) { follow(.fixture(level: level)) }
+}
+
/// Every meter in a call, fed by `Call` (see `Call.watchLevels`).
@MainActor public final class AudioLevels {
public let mic = MicAudio()
@@ -59,6 +94,8 @@ import SwiftUI
private var micWatch = MicWatch()
/// Anyone other than you speaking, for the mini window's tab.
public let anyoneSpeaking = SpeakingNow()
+ /// The glow of the loudest of them, for the same tab.
+ public let othersGlow = GlowLevel()
public init() {}
@@ -77,6 +114,7 @@ import SwiftUI
mic.show(
step: micWatch.step, muted: sample.muted, speaking: micWatch.speaking,
silent: micWatch.silent, source: source, channels: channels)
+ mic.glow.update(level: sample.sentPeak, speaking: micWatch.speaking, now: now)
if micWatch.silent, !wasSilent { AudioAnnouncer.say(AudioWords.micSilent) }
}
@@ -92,19 +130,24 @@ import SwiftUI
let model = peer(id)
let fresh = notices.filter { !model.notices.contains($0) }
model.show(state: state, speaking: watch.speaking, notices: notices)
+ model.glow.update(level: sample.level, speaking: watch.speaking, now: now)
if state == .noSound, before != .noSound { AudioAnnouncer.say(AudioWords.noSound(from: name)) }
for line in fresh { AudioAnnouncer.say(line) }
return state
}
/// Forgets everyone not in `present`, and says whether anyone left is
- /// speaking.
+ /// speaking, and how brightly the loudest of them glows.
func settle(present: Set<String>) {
for id in Array(watches.keys) where !present.contains(id) {
watches.removeValue(forKey: id)
peers[id]?.show(state: .meter(0), speaking: false, notices: [])
+ peers[id]?.glow.reset()
}
anyoneSpeaking.set(watches.values.contains { $0.speaking })
+ let loudest = watches.keys.compactMap { peers[$0]?.glow.glow }
+ .filter(\.lit).max { $0.target < $1.target }
+ othersGlow.follow(loudest ?? SpeakingGlow())
}
/// The people whose sound has gone flat, in a stable order: what this
@@ -116,9 +159,14 @@ import SwiftUI
func reset() {
micWatch = MicWatch()
mic.show(step: 0, muted: false, speaking: false, silent: false, source: "", channels: false)
- for id in Array(watches.keys) { peers[id]?.show(state: .meter(0), speaking: false, notices: []) }
+ mic.glow.reset()
+ for id in Array(watches.keys) {
+ peers[id]?.show(state: .meter(0), speaking: false, notices: [])
+ peers[id]?.glow.reset()
+ }
watches = [:]
anyoneSpeaking.set(false)
+ othersGlow.reset()
}
}
@@ -141,13 +189,24 @@ import SwiftUI
public enum TileAudio {
case you(MicAudio)
case peer(PeerAudio, name: String)
+
+ /// The glow behind the tile's name.
+ @MainActor public var glow: GlowLevel {
+ switch self {
+ case .you(let mic): mic.glow
+ case .peer(let peer, _): peer.glow
+ }
+ }
}
/// A tile's sound, read out of its model as plain values.
public struct TileAudioInfo: Equatable {
public enum Status: Equatable {
+ /// Muted: the name label says so, and there is nothing to glow.
case none
- case meter(Int)
+ /// Sound is live: the glow behind the name carries it.
+ case live
+ /// "No sound from Bea", in place of the glow.
case noSound(String)
}
public var status: Status
@@ -168,7 +227,7 @@ public struct TileAudioInfo: Equatable {
@MainActor static func you(_ mic: MicAudio) -> TileAudioInfo {
TileAudioInfo(
- status: mic.muted ? .none : .meter(mic.step), speaking: mic.speaking,
+ status: mic.muted ? .none : .live, speaking: mic.speaking,
notices: mic.silent ? [AudioWords.micSilent] : [],
detail: mic.silent
? AudioWords.micSilentDetail(source: mic.source, channels: mic.channels) : nil)
@@ -179,7 +238,7 @@ public struct TileAudioInfo: Equatable {
switch audio.state {
case .muted: status = .none
case .noSound: status = .noSound(name)
- case .meter(let step): status = .meter(step)
+ case .meter: status = .live
}
return TileAudioInfo(
status: status, speaking: audio.speaking, notices: audio.notices,
@@ -217,23 +276,29 @@ public struct TileAudioReader<Content: View>: View {
}
}
-/// A small level meter: bars that light from the left. Steps, not a
-/// continuous level, so it never animates frame by frame, and with Reduce
-/// Motion it does not animate at all. Hidden from assistive technology:
-/// the words beside it carry anything worth saying.
+/// A small level meter: bars that light from the left, in the Calls teal.
+/// Steps, not a continuous level, so it never animates frame by frame,
+/// and with Reduce Motion it does not animate at all. The device panel's
+/// "Microphone level" row, for setting up; the tiles have the glow.
+/// Hidden from assistive technology: the words beside it carry anything
+/// worth saying.
public struct AudioLevelBars: View {
let step: Int
var height: CGFloat = 12
@Environment(\.accessibilityReduceMotion) private var reduceMotion
+ @Environment(\.colorScheme) private var scheme
public init(step: Int, height: CGFloat = 12) {
self.step = step
self.height = height
}
public var body: some View {
+ // The glow's teal on a dark panel; the deeper accent on a light sheet,
+ // where the lit one would be too pale to tell from the unlit.
+ let lit = scheme == .dark ? CallsAppearance.glow : CallsAppearance.accent
HStack(alignment: .bottom, spacing: 2) {
ForEach(0..<MeterScale.steps, id: \.self) { bar in
- RoundedRectangle(cornerRadius: 1)
- .fill(bar < step ? Color.white : CallOverlay.secondaryText.opacity(0.4))
+ Capsule()
+ .fill(bar < step ? lit : Color.secondary.opacity(0.35))
.frame(width: 3, height: height * CGFloat(bar + 2) / CGFloat(MeterScale.steps + 1))
}
}
@@ -243,23 +308,17 @@ public struct AudioLevelBars: View {
}
}
-/// The sound beside a tile's name: the meter, or "No sound from Bea" with
-/// its ⓘ. Nothing while muted — the name label already says Muted.
+/// What a tile says beside its name about its sound: "No sound from Bea"
+/// with its ⓘ, or nothing — live sound is the glow behind the name, and
+/// muted is the name label's own mark.
public struct TileAudioStatus: View {
let info: TileAudioInfo?
public init(_ info: TileAudioInfo?) { self.info = info }
public var body: some View {
- switch info?.status {
- case .meter(let step)?:
- AudioLevelBars(step: step)
- .padding(.horizontal, 7).padding(.vertical, 5)
- .callOverlaySurface(Capsule())
- case .noSound(let name)?:
+ if case .noSound(let name)? = info?.status {
AudioLine(
text: AudioWords.noSound(from: name), systemImage: "speaker.slash.fill",
detail: info?.detail)
- default:
- EmptyView()
}
}
}
@@ -320,25 +379,69 @@ struct AudioLine: View {
}
extension View {
- /// The speaking highlight round a tile: a white ring, on and off in
- /// steps (see SpeakingGate), and without a fade under Reduce Motion.
- public func speakingRing(_ on: Bool, cornerRadius: CGFloat) -> some View {
- modifier(SpeakingRing(on: on, cornerRadius: cornerRadius))
+ /// The speaking glow behind a name label (0.3.7): the Calls teal as a
+ /// soft halo round `shape`, lit while that person speaks and as bright
+ /// as their voice, from `glow` (nil draws nothing: a screen's label).
+ /// Behind the label and outside its layout, so nothing moves when it
+ /// lights; no border, no flash.
+ public func speakingGlow<S: Shape>(_ glow: GlowLevel?, in shape: S) -> some View {
+ background {
+ if let glow { SpeakingGlowHalo(level: glow, shape: shape) }
+ }
}
}
-private struct SpeakingRing: ViewModifier {
- let on: Bool
- let cornerRadius: CGFloat
+/// The halo itself. It redraws only while the glow is moving: a
+/// timeline that runs from a new reading until the glow has arrived
+/// where that reading sent it (`SpeakingGlow.settles`), at up to 30 Hz,
+/// and stops. In silence it draws nothing and nothing runs. With Reduce
+/// Motion there is no timeline at all: a steady glow while lit.
+struct SpeakingGlowHalo<S: Shape>: View {
+ @ObservedObject var level: GlowLevel
+ let shape: S
@Environment(\.accessibilityReduceMotion) private var reduceMotion
- func body(content: Content) -> some View {
- content.overlay {
- RoundedRectangle(cornerRadius: cornerRadius)
- .strokeBorder(Color.white.opacity(on ? 0.85 : 0), lineWidth: 3)
- .animation(reduceMotion ? nil : .easeOut(duration: 0.15), value: on)
- .allowsHitTesting(false)
+
+ var body: some View {
+ let glow = level.glow
+ if reduceMotion {
+ halo(glow.value(at: 0, reduceMotion: true))
+ } else {
+ TimelineView(GlowSchedule(until: Self.date(uptime: glow.settles))) { _ in
+ halo(glow.value(at: ProcessInfo.processInfo.systemUptime))
+ }
}
}
+
+ /// Spread and brightness both follow the value: a faint close halo for
+ /// a quiet voice, a wider brighter one for a loud one.
+ @ViewBuilder private func halo(_ value: Double) -> some View {
+ if value > 0.01 {
+ let v = CGFloat(value)
+ ZStack {
+ shape.fill(CallsAppearance.glow.opacity(0.35 + 0.45 * value))
+ .padding(-(2 + 6 * v)).blur(radius: 4 + 8 * v)
+ shape.stroke(CallsAppearance.glow.opacity(0.5 * value), lineWidth: 2 + 2 * v)
+ .blur(radius: 2)
+ }
+ .allowsHitTesting(false)
+ .accessibilityHidden(true)
+ }
+ }
+
+ private static func date(uptime: Double) -> Date {
+ Date().addingTimeInterval(uptime - ProcessInfo.processInfo.systemUptime)
+ }
+}
+
+/// Every thirtieth of a second from now until `until`, then nothing.
+struct GlowSchedule: TimelineSchedule {
+ let until: Date
+ func entries(from start: Date, mode: TimelineScheduleMode) -> [Date] {
+ guard until > start else { return [start] }
+ let every = mode == .lowFrequency ? 0.25 : 1.0 / 30
+ let count = min(Int(until.timeIntervalSince(start) / every) + 2, 300)
+ return (0..<count).map { start.addingTimeInterval(Double($0) * every) }
+ }
}
/// A tile's accessibility label with its sound said after the name.
@@ -347,14 +450,21 @@ public func tileAccessibilityLabel(_ label: String, muted: Bool, audio: TileAudi
}
/// A waveform while someone is speaking, and nothing otherwise: the mini
-/// window's tab. Hidden from assistive technology, which would hear it
-/// flicker on and off with every sentence.
+/// window's tab, with the same glow behind it as a name has. Hidden from
+/// assistive technology, which would hear it flicker on and off with
+/// every sentence.
public struct SpeakingGlyph: View {
@ObservedObject var speaking: SpeakingNow
- public init(speaking: SpeakingNow) { self.speaking = speaking }
+ let glow: GlowLevel?
+ public init(speaking: SpeakingNow, glow: GlowLevel? = nil) {
+ self.speaking = speaking
+ self.glow = glow
+ }
public var body: some View {
Image(systemName: "waveform")
.font(.caption.bold()).foregroundStyle(.white)
+ .frame(width: 22, height: 22)
+ .speakingGlow(glow, in: Circle())
.opacity(speaking.value ? 1 : 0)
.help("Someone is speaking")
.accessibilityHidden(true)
diff --git a/native/apple/Sources/CallsKit/Call.swift b/native/apple/Sources/CallsKit/Call.swift
index 68e0ea0..9f959a9 100644
--- a/native/apple/Sources/CallsKit/Call.swift
+++ b/native/apple/Sources/CallsKit/Call.swift
@@ -769,6 +769,9 @@ private final class Events: NSObject, BridgeListenerProtocol {
micPeakSinceSample = 0
values["noSoundFrom"] = UInt64(levels.noSoundFrom.count)
values["micSilent"] = levels.mic.silent ? 1 : 0
+ // The speaking glow behind your own name (0.3.7): lit while what is
+ // sent is speech that has kept up.
+ values["micGlow"] = levels.mic.glow.glow.lit ? 1 : 0
return values
}
}
@@ -804,4 +807,16 @@ extension Call {
self.cameraOff = cameraOff
self.sharingScreen = sharingScreen
}
+
+ /// Everyone's glow, and yours, held at one level, for the snapshots.
+ @_spi(Fixtures) public func showFixtureSpeaking(level: Float) {
+ levels.mic.glow.showFixture(level: level)
+ // Nobody muted glows: muted, the speaking gate is shut.
+ if muted { levels.mic.glow.reset() }
+ for peer in state?.peers ?? [] where peer.peer != state?.peer && !isMuted(peer.peer) {
+ levels.peer(peer.peer).glow.showFixture(level: level)
+ }
+ levels.othersGlow.showFixture(level: level)
+ levels.anyoneSpeaking.set(levels.othersGlow.glow.lit)
+ }
}
diff --git a/native/apple/Sources/CallsKit/CallActionStyle.swift b/native/apple/Sources/CallsKit/CallActionStyle.swift
index 979f80e..8653a85 100644
--- a/native/apple/Sources/CallsKit/CallActionStyle.swift
+++ b/native/apple/Sources/CallsKit/CallActionStyle.swift
@@ -41,64 +41,91 @@ public struct CallActionStyle: ButtonStyle {
/// The look of everything the full-window call puts over its video — the
/// control bar and its buttons, the title pill, the name labels and the
-/// notices — as one system. It is always dark, whatever the system
-/// appearance, because what is under it is always video, and video can be
-/// anything from a black room to a white wall.
+/// notices — as one system (0.3.7): borderless glass panes, dark whatever
+/// the system appearance, because what is under them is always video, and
+/// video can be anything from a black room to a white wall.
///
-/// Each surface is black at `scrim` over a blur. The blur only softens;
-/// the black does the work, so the contrast holds even where the blur is
-/// not drawn (an off-screen snapshot) and even over pure white, the worst
-/// case: white under 70 % black is #4D4D4D, so white on the bar is 8.5:1
-/// at worst and 21:1 at best (over black). The ratios below are measured
-/// against that worst case; `CallOverlayContrastTests` holds them.
+/// Each surface is Liquid Glass where the system has it (macOS 26, iOS
+/// 26), and the ultra-thin material before that, with black at `scrim`
+/// laid on it. The glass and the blur only soften; the black does the
+/// work, so the contrast holds over anything and even where neither is
+/// drawn: white under 62 % black is #616161, so white on a surface is
+/// 6.2:1 at worst and 21:1 at best (over black). Rendered, the glass over
+/// white measures #5B5B5B (6.8:1). No hairline edges anywhere: a surface
+/// is told from the picture by its tone, and from black video by the
+/// glass's own light. `CallOverlayContrastTests` holds the ratios.
public enum CallOverlay {
- /// The black laid over whatever the surface sits on.
- public static let scrim: Double = 0.7
- /// A resting button's fill: white, lightly. Over the worst-case bar it
- /// is #696969, and a white symbol on it is 5.5:1.
- public static let buttonFill = Color.white.opacity(0.16)
- /// A hairline round a resting button and round the surfaces, so their
- /// edge shows over black too, where the scrim alone would vanish.
- public static let edge = Color.white.opacity(0.18)
+ /// The black laid over the glass or material.
+ public static let scrim: Double = 0.62
+ /// A resting button has no fill of its own: a white symbol on the pane.
+ /// Under the pointer it lightens by this, and pressed by `pressFill`;
+ /// white on the hovered pane is still 4.9:1 at worst.
+ public static let hoverFill = Color.white.opacity(0.1)
+ public static let pressFill = Color.white.opacity(0.18)
/// A control that is on — a panel open, the screen being shared: a
- /// near-white fill with a near-black symbol, 15:1 between them and 7.5:1
- /// from the bar at its lightest.
+ /// near-white capsule with a near-black symbol, 15:1 between them and
+ /// 6.8:1 from the pane at its lightest.
public static let onFill = Color(white: 0.94)
public static let onSymbol = Color(white: 0.1)
- /// Microphone or camera off: amber, with the slashed symbol in white
- /// (5.7:1). A warning that others cannot hear or see you, and distinct
- /// from Leave's red and from the white of a control that is on.
+ /// Microphone or camera off: an amber capsule, with the slashed symbol
+ /// in white (5.7:1). A warning that others cannot hear or see you, and
+ /// distinct from Leave's red and from the white of a control that is on.
public static let offFill = Color(red: 0.65, green: 0.30, blue: 0.02)
- /// Leave and End call: a red deep enough for white on it to read (5.0:1;
- /// the system red would be 3.4:1).
+ /// Leave and End call: a red capsule deep enough for white on it to read
+ /// (5.0:1; the system red would be 3.4:1).
public static let leaveFill = Color(red: 0.84, green: 0.16, blue: 0.14)
/// The unread count on Chat: white on violet, 6.0:1.
public static let badgeFill = Color(red: 0.49, green: 0.23, blue: 0.85)
- /// Secondary text on a surface: the status line, a quiet notice. 6.0:1
+ /// Secondary text on a surface: the status line, a quiet notice. 5.0:1
/// over the worst-case surface.
- public static let secondaryText = Color.white.opacity(0.78)
+ public static let secondaryText = Color.white.opacity(0.85)
/// A notice that must be seen (you are sharing your screen): a light
- /// amber, 4.9:1 over the worst-case surface.
- public static let alertText = Color(red: 1.0, green: 0.72, blue: 0.35)
+ /// amber, 4.6:1 over the worst-case surface.
+ public static let alertText = Color(red: 1.0, green: 0.85, blue: 0.6)
+ /// A panel over the picture that is read rather than glanced at (the
+ /// Devices panel): opaque, so nothing of the video shows through the
+ /// text. The dark appearance's own label colours sit on it at full
+ /// contrast (primary 17:1, secondary 7.9:1).
+ public static let panelFill = Color(white: 0.12)
}
-/// A surface over the picture: black at `CallOverlay.scrim` over a dark
-/// blur, with a hairline edge. Dark whatever the system appearance; only
-/// the background is made dark, so a sheet or popover the content opens
-/// keeps the person's own appearance.
+private struct CallOverlayGlassKey: EnvironmentKey {
+ static let defaultValue = true
+}
+
+extension EnvironmentValues {
+ /// Whether the overlay's surfaces may use Liquid Glass where the system
+ /// has it. True in the apps; the snapshots turn it off to show the
+ /// material every older system draws instead.
+ public var callOverlayGlass: Bool {
+ get { self[CallOverlayGlassKey.self] }
+ set { self[CallOverlayGlassKey.self] = newValue }
+ }
+}
+
+/// A surface over the picture: borderless glass — Liquid Glass on macOS
+/// and iOS 26, the ultra-thin material before — with black at
+/// `CallOverlay.scrim` on it. Only the background is made dark, so a sheet
+/// or popover the content opens keeps the person's own appearance.
public struct CallOverlaySurface<S: InsettableShape>: ViewModifier {
let shape: S
+ @Environment(\.callOverlayGlass) private var glass
public init(_ shape: S) { self.shape = shape }
public func body(content: Content) -> some View {
- content
- .background {
- ZStack {
- shape.fill(.ultraThinMaterial)
- shape.fill(Color.black.opacity(CallOverlay.scrim))
+ if glass, #available(macOS 26, iOS 26, *) {
+ content
+ .background(shape.fill(Color.black.opacity(CallOverlay.scrim)))
+ .glassEffect(.regular, in: shape)
+ } else {
+ content
+ .background {
+ ZStack {
+ shape.fill(.ultraThinMaterial)
+ shape.fill(Color.black.opacity(CallOverlay.scrim))
+ }
+ .environment(\.colorScheme, .dark)
}
- .environment(\.colorScheme, .dark)
- }
- .overlay(shape.strokeBorder(CallOverlay.edge, lineWidth: 1))
+ }
}
}
@@ -109,12 +136,14 @@ extension View {
}
}
-/// A button over the picture, in one of four states. The state is always
-/// the symbol as well as the fill: a muted microphone is slashed and amber,
-/// a control that is on is white with a dark symbol.
+/// A button over the picture, in one of four states. At rest it is only a
+/// white symbol on the pane, lightening under the pointer; a filled
+/// capsule appears behind it only in a state that needs one, and the state
+/// is always the symbol as well as the fill: a muted microphone is slashed
+/// and amber, a control that is on is white with a dark symbol.
public struct CallOverlayButtonStyle: ButtonStyle {
public enum Tone: Sendable {
- /// Resting: a white symbol on a light fill.
+ /// Resting: a white symbol on the pane.
case normal
/// On: a panel open, the screen being shared.
case on
@@ -149,25 +178,30 @@ private struct OverlayButtonBody<Label: View>: View {
let round: Bool
let pressed: Bool
let enabled: Bool
+ @State private var hovering = false
var body: some View {
- let shape = RoundedRectangle(cornerRadius: round ? size / 2 : 14, style: .continuous)
+ // Leave is wider than it is tall, so it reads as a capsule and not as
+ // one more round state behind a symbol.
+ let width = tone == .leave && !round ? size * 1.4 : size
label.labelStyle(.iconOnly)
.font(round ? .title2.weight(.semibold) : .title3.weight(.medium))
- .frame(width: size, height: size)
+ .frame(width: width, height: size)
.foregroundStyle(tone == .on ? CallOverlay.onSymbol : .white)
- .background(fill, in: shape)
- .overlay { if tone == .normal { shape.strokeBorder(CallOverlay.edge, lineWidth: 1) } }
- .opacity(!enabled ? 0.45 : pressed ? 0.7 : 1)
- .contentShape(shape)
+ .background(fill, in: Capsule())
+ .opacity(!enabled ? 0.45 : pressed && tone != .normal ? 0.75 : 1)
+ .contentShape(Capsule())
+ .onHover { hovering = $0 }
}
private var fill: Color {
switch tone {
- case .normal: CallOverlay.buttonFill
- case .on: CallOverlay.onFill
- case .off: CallOverlay.offFill
- case .leave: CallOverlay.leaveFill
+ case .normal:
+ guard enabled else { return .clear }
+ return pressed ? CallOverlay.pressFill : hovering ? CallOverlay.hoverFill : .clear
+ case .on: return CallOverlay.onFill
+ case .off: return CallOverlay.offFill
+ case .leave: return CallOverlay.leaveFill
}
}
}
diff --git a/native/apple/Sources/CallsKit/CallFixtures.swift b/native/apple/Sources/CallsKit/CallFixtures.swift
index 3754da1..26ab720 100644
--- a/native/apple/Sources/CallsKit/CallFixtures.swift
+++ b/native/apple/Sources/CallsKit/CallFixtures.swift
@@ -45,6 +45,25 @@ import SwiftUI
}
}
+/// How loudly everyone is speaking, for the snapshots of the glow behind
+/// the names: silent, and low, middling and loud speech.
+@_spi(Fixtures) public enum FixtureSpeaking: String, CaseIterable, Sendable {
+ case silent, low, mid, high
+
+ public var decibels: Float {
+ switch self {
+ case .silent: -60
+ case .low: -29
+ case .mid: -21
+ case .high: -12
+ }
+ }
+
+ @MainActor public func apply(to call: Call) {
+ call.showFixtureSpeaking(level: pow(10, decibels / 20))
+ }
+}
+
/// What the snapshots put behind the call's overlay in place of video, to
/// show that the controls read over anything: black, white, and a busy
/// frame with light and dark and colour and fine detail in it.
diff --git a/native/apple/Sources/CallsKit/CallsAppearance.swift b/native/apple/Sources/CallsKit/CallsAppearance.swift
index 887232f..d7f64f6 100644
--- a/native/apple/Sources/CallsKit/CallsAppearance.swift
+++ b/native/apple/Sources/CallsKit/CallsAppearance.swift
@@ -3,5 +3,8 @@ import SwiftUI
/// Calls's first native palette; app-specific until a second app adopts it.
public enum CallsAppearance {
public static let accent = Color(red: 0.03, green: 0.49, blue: 0.54)
+ /// The accent's hue lit up, for the speaking glow: the accent itself is
+ /// a deep teal that would read as a shadow over video, not a light.
+ public static let glow = Color(red: 0.18, green: 0.80, blue: 0.86)
public static let background = Color.primary.opacity(0.035)
}
diff --git a/native/apple/Sources/CallsKit/DevicePanelAudio.swift b/native/apple/Sources/CallsKit/DevicePanelAudio.swift
index 2c06cee..91550af 100644
--- a/native/apple/Sources/CallsKit/DevicePanelAudio.swift
+++ b/native/apple/Sources/CallsKit/DevicePanelAudio.swift
@@ -14,9 +14,9 @@ public struct MicLevelRow: View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Text("Microphone level").font(.subheadline)
+ // The tiles' glow is for a call; setting up wants to see the
+ // level itself, so the panel keeps a compact meter, borderless.
AudioLevelBars(step: mic.muted ? 0 : mic.step, height: 14)
- .padding(.horizontal, 6).padding(.vertical, 4)
- .background(Color.black.opacity(0.75), in: Capsule())
Text(words).font(.footnote).foregroundStyle(.secondary)
}
if mic.silent {
diff --git a/native/apple/Sources/CallsKit/MacFullWindowCall.swift b/native/apple/Sources/CallsKit/MacFullWindowCall.swift
index b8a6f0e..e6b3895 100644
--- a/native/apple/Sources/CallsKit/MacFullWindowCall.swift
+++ b/native/apple/Sources/CallsKit/MacFullWindowCall.swift
@@ -230,7 +230,6 @@
compact: true, backdrop: Self.tileBackdrop, audio: call.selfAudio
)
.frame(width: pip.width, height: pip.height)
- .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(.white.opacity(0.18)))
.shadow(color: .black.opacity(0.35), radius: 8, y: 3)
.position(x: frame.midX + pipDrag.width, y: frame.midY + pipDrag.height)
.gesture(
@@ -388,8 +387,16 @@
.frame(height: max(80, min(devicesHeight, size.height - devicesTop - bottom - 44)))
}
.frame(width: Self.panelWidth)
- .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
- .shadow(color: .black.opacity(0.25), radius: 12, y: 4)
+ // Read, not glanced at, and over video: opaque and dark, with the
+ // dark appearance's full-contrast labels, whatever the system's
+ // appearance (a light material over a picture was grey on grey).
+ // It is not part of the controls' fade: it holds them up while
+ // open (`.menuOpen`) and carries no opacity of its own.
+ .foregroundStyle(.white)
+ .background(CallOverlay.panelFill, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
+ .environment(\.colorScheme, .dark)
+ .shadow(color: .black.opacity(0.35), radius: 14, y: 4)
+ .accessibilityIdentifier("devicesPanel")
.padding(.leading, 16).padding(.bottom, bottom)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
.transition(panelTransition(edge: .bottom))
@@ -430,8 +437,9 @@
private var bar: some View {
controls(fullWindow: true)
.padding(10)
- .callOverlaySurface(RoundedRectangle(cornerRadius: 20, style: .continuous))
- .shadow(color: .black.opacity(0.3), radius: 12, y: 4)
+ // One glass pane, no edge, the buttons sitting on it.
+ .callOverlaySurface(Capsule())
+ .shadow(color: .black.opacity(0.25), radius: 12, y: 4)
.frame(maxWidth: 760)
.background(
GeometryReader { bar in
@@ -461,8 +469,9 @@
}
}
- /// A notice over the picture: a capsule of the overlay's dark surface,
- /// with white text and dark-appearance controls, whatever the system's.
+ /// A notice over the picture: a capsule of the overlay's borderless
+ /// glass, with white text and dark-appearance controls, whatever the
+ /// system's.
struct NoticeSurface: ViewModifier {
func body(content: Content) -> some View {
content.font(.callout).foregroundStyle(.white)
diff --git a/native/apple/Sources/CallsKit/MacVideoTile.swift b/native/apple/Sources/CallsKit/MacVideoTile.swift
index 2bb58cd..0648f67 100644
--- a/native/apple/Sources/CallsKit/MacVideoTile.swift
+++ b/native/apple/Sources/CallsKit/MacVideoTile.swift
@@ -27,7 +27,7 @@
/// does not fill the tile. A tile among others on the full-window
/// stage is a shade lighter than the stage, so it reads as a tile.
var backdrop = Color.black
- /// The sound on this tile (spec §15.20): your microphone's meter, or a
+ /// The sound on this tile (spec §15.20): your microphone's glow, or a
/// remote person's with "No sound from" and their notices. Nil for a
/// screen.
var audio: TileAudio? = nil
@@ -56,20 +56,23 @@
VStack(alignment: .leading, spacing: 6) {
TileAudioNotices(info)
HStack(spacing: 6) {
- if compact {
- TileNameLabel(label: label, muted: muted)
- } else {
- Text(label).font(.callout.bold()).foregroundStyle(.white)
- .lineLimit(1).padding(.horizontal, 10).padding(.vertical, 6)
- .background(.black.opacity(0.65), in: Capsule())
+ Group {
+ if compact {
+ TileNameLabel(label: label, muted: muted)
+ } else {
+ Text(label).font(.callout.bold()).foregroundStyle(.white)
+ .lineLimit(1).padding(.horizontal, 10).padding(.vertical, 6)
+ .callOverlaySurface(Capsule())
+ }
}
+ // Speaking is the glow behind the name, as bright as the voice.
+ .speakingGlow(audio?.glow, in: Capsule())
TileAudioStatus(info)
}
}
.padding(compact ? 8 : 10).padding(.bottom, compact ? labelLift : 0)
}
.overlay(alignment: .topLeading) { if muted, !compact { MutedBadge().padding(10) } }
- .speakingRing(info?.speaking == true, cornerRadius: cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
.accessibilityElement(children: .ignore)
.accessibilityLabel(tileAccessibilityLabel(label, muted: muted, audio: info))
diff --git a/native/apple/Sources/CallsKit/MiniWindow.swift b/native/apple/Sources/CallsKit/MiniWindow.swift
index d0ac85c..43654cb 100644
--- a/native/apple/Sources/CallsKit/MiniWindow.swift
+++ b/native/apple/Sources/CallsKit/MiniWindow.swift
@@ -446,7 +446,7 @@
if call.muted, !showingControls {
Label("Muted", systemImage: "mic.slash.fill").labelStyle(.iconOnly)
.font(.callout.bold()).foregroundStyle(.white)
- .padding(8).background(.black.opacity(0.65), in: Circle())
+ .padding(8).callOverlaySurface(Circle())
.accessibilityLabel("Your microphone is muted")
}
Spacer()
@@ -485,7 +485,8 @@
/// the call is with. Click it to open the mini window again, drag it out
/// to open it there, double-click it for the whole call.
///
- /// A small waveform shows while someone else is speaking, from the
+ /// A small waveform shows while someone else is speaking, with the
+ /// speaking glow behind it following the loudest of them, from the
/// receive meters (spec §15.20), which read five times a second — quick
/// enough to follow speech, where the five-second audio readout was not.
struct MiniTabView: View {
@@ -510,7 +511,7 @@
Text(caption).font(.callout.weight(.medium)).lineLimit(1).truncationMode(.tail)
.foregroundStyle(.white)
Spacer(minLength: 0)
- SpeakingGlyph(speaking: call.levels.anyoneSpeaking)
+ SpeakingGlyph(speaking: call.levels.anyoneSpeaking, glow: call.levels.othersGlow)
}
.padding(.horizontal, 7)
.frame(width: MiniWindowTab.size.width, height: MiniWindowTab.size.height)
@@ -520,9 +521,9 @@
click: { count in count >= 2 ? actions.open() : actions.expand() },
dropped: { _ in actions.tabReleased() }, resized: {})
)
- .background(Color(white: 0.12).opacity(0.94))
+ // The call's glass, borderless, as over the picture.
+ .callOverlaySurface(Capsule())
.clipShape(Capsule())
- .overlay(Capsule().strokeBorder(.white.opacity(0.15)))
.accessibilityElement(children: .ignore)
.accessibilityAddTraits(.isButton)
.accessibilityLabel("Calls, \(caption)")
diff --git a/native/apple/Sources/CallsKit/MutedBadge.swift b/native/apple/Sources/CallsKit/MutedBadge.swift
index 498ef98..e734fa9 100644
--- a/native/apple/Sources/CallsKit/MutedBadge.swift
+++ b/native/apple/Sources/CallsKit/MutedBadge.swift
@@ -11,7 +11,7 @@ public struct MutedBadge: View {
Label("Muted", systemImage: "mic.slash.fill")
.font(.caption.bold()).foregroundStyle(.white)
.padding(.horizontal, 8).padding(.vertical, 4)
- .background(.black.opacity(0.65), in: Capsule())
+ .callOverlaySurface(Capsule())
.accessibilityHidden(true)
}
}
@@ -48,8 +48,8 @@ public struct TileNameLabel: View {
.font(.caption.weight(.semibold)).foregroundStyle(.white)
.fixedSize()
.padding(.horizontal, 7).padding(.vertical, 3)
- // The overlay's dark surface, as the controls have: white on it is
- // 8.5:1 even over a white wall.
+ // The overlay's glass, as the controls have: white on it is 6.2:1
+ // even over a white wall.
.callOverlaySurface(Capsule())
.accessibilityHidden(true)
}
diff --git a/native/apple/Sources/CallsKit/SpeakingGlow.swift b/native/apple/Sources/CallsKit/SpeakingGlow.swift
new file mode 100644
index 0000000..3af8a57
--- /dev/null
+++ b/native/apple/Sources/CallsKit/SpeakingGlow.swift
@@ -0,0 +1,122 @@
+import Foundation
+
+// 0.3.7: the speaking glow. The white ring round a tile and the meter
+// beside a name were two marks for one thing; they are now one — a soft
+// glow in the Calls teal behind the name label, lit while that person is
+// speaking and breathing with their voice. This is the rule behind it,
+// on the caller's clock, with nothing about views or the main actor in it:
+// what a reading makes the glow aim for, and how bright it is at any
+// moment on the way there.
+//
+// The levels arrive a few times a second (Call.watchLevels: 10 Hz for
+// your microphone, 5 Hz for everyone else). Between readings the glow is
+// an exponential approach from where it was to where it is going — quick
+// rising (`attack`), slower falling (`release`) — so a view can ask for
+// the value at any instant without being told anything new, and a
+// reading that changes nothing visible changes nothing at all.
+
+/// The glow behind one name: whether it is lit, where its brightness is
+/// heading, and how bright it is at a given time.
+public struct SpeakingGlow: Equatable, Sendable {
+ /// The level at which a lit glow is at its dimmest, and the level at
+ /// which it is brightest. Speech peaks sit around -20 to -6 dBFS; the
+ /// bottom is where the speaking highlight lets go (SpeakingGate.offDb).
+ public static let quietDb: Float = SpeakingGate.offDb
+ public static let loudDb: Float = -12
+ /// The least a lit glow shows, so it dims between syllables but does
+ /// not go out while someone is still talking.
+ public static let floor: Double = 0.3
+ /// Time constants, in seconds: how quickly the glow rises to a louder
+ /// reading and falls to a quieter one (63 % of the way in that time).
+ public static let attack: Double = 0.05
+ public static let release: Double = 0.3
+ /// How long sound at a speaking level must keep up before the glow
+ /// lights: the gap between the first and the latest loud reading in one
+ /// stretch of speaking. A click, a cough or a door is one reading and
+ /// never lights it; a word is several.
+ public static let onset: Double = 0.15
+ /// With Reduce Motion: one brightness while lit, and no movement.
+ public static let steady: Double = 0.6
+ /// A change of target smaller than this is not worth a redraw.
+ public static let resolution: Double = 0.02
+ /// Closer than this to its target, the glow has arrived.
+ static let arrived: Double = 0.005
+
+ /// Lit: speaking, per SpeakingGate, and past the onset.
+ public private(set) var lit = false
+ /// Where the brightness is heading, 0...1.
+ public private(set) var target: Double = 0
+ /// The brightness at `at`, where the approach to `target` began.
+ private var from: Double = 0
+ private var at: Double = 0
+ private var loudFrom: Double?
+ private var loudLast: Double?
+
+ public init() {}
+
+ /// A reading's brightness, 0...1, by decibels between `quietDb` and
+ /// `loudDb` — before the floor a lit glow never goes under.
+ public static func intensity(level: Float) -> Double {
+ let db = MeterScale.decibels(level)
+ guard db.isFinite else { return 0 }
+ let x = Double((db - quietDb) / (loudDb - quietDb))
+ return min(1, max(0, x))
+ }
+
+ /// Takes one reading: the level, whether the person is speaking per
+ /// the speaking gate, and when. Returns whether what is drawn changes,
+ /// so the caller publishes only then.
+ @discardableResult
+ public mutating func update(level: Float, speaking: Bool, now: Double) -> Bool {
+ if !speaking {
+ loudFrom = nil
+ loudLast = nil
+ } else if MeterScale.decibels(level) >= SpeakingGate.onDb {
+ if loudFrom == nil { loudFrom = now }
+ loudLast = now
+ }
+ var nowLit = false
+ if speaking {
+ if lit {
+ nowLit = true
+ } else if let first = loudFrom, let last = loudLast {
+ nowLit = last - first >= Self.onset
+ }
+ }
+ let next = nowLit ? Self.floor + (1 - Self.floor) * Self.intensity(level: level) : 0
+ if nowLit == lit, abs(next - target) < Self.resolution { return false }
+ from = value(at: now)
+ at = now
+ target = next
+ lit = nowLit
+ return true
+ }
+
+ /// The brightness at `now`, 0...1. With Reduce Motion, `steady` while
+ /// lit and nothing otherwise: no approach, so nothing moves.
+ public func value(at now: Double, reduceMotion: Bool = false) -> Double {
+ if reduceMotion { return lit ? Self.steady : 0 }
+ let elapsed = max(0, now - at)
+ let tau = target > from ? Self.attack : Self.release
+ return target + (from - target) * exp(-elapsed / tau)
+ }
+
+ /// When the glow will have arrived at its target, on the same clock:
+ /// the moment a view can stop redrawing it.
+ public var settles: Double {
+ let gap = abs(from - target)
+ guard gap > Self.arrived else { return at }
+ let tau = target > from ? Self.attack : Self.release
+ return at + tau * log(gap / Self.arrived)
+ }
+
+ /// A glow held at `level`'s brightness, for the snapshots.
+ @_spi(Fixtures) public static func fixture(level: Float) -> SpeakingGlow {
+ var glow = SpeakingGlow()
+ let on = MeterScale.decibels(level) >= SpeakingGate.onDb
+ glow.lit = on
+ glow.target = on ? floor + (1 - floor) * intensity(level: level) : 0
+ glow.from = glow.target
+ return glow
+ }
+}
diff --git a/native/apple/Tests/CallsKitTests/AudioLevelTests.swift b/native/apple/Tests/CallsKitTests/AudioLevelTests.swift
index 61ff778..ed82243 100644
--- a/native/apple/Tests/CallsKitTests/AudioLevelTests.swift
+++ b/native/apple/Tests/CallsKitTests/AudioLevelTests.swift
@@ -210,7 +210,7 @@ final class AudioLevelTests: XCTestCase {
peer.show(state: .muted, speaking: false, notices: [])
let muted = TileAudioInfo.peer(peer, name: "Bea")
- XCTAssertEqual(muted.status, .none, "muted: the badge, and no meter")
+ XCTAssertEqual(muted.status, .none, "muted: the badge, and no glow")
XCTAssertEqual(tileAccessibilityLabel("Bea", muted: true, audio: muted), "Bea, muted")
let you = MicAudio()
diff --git a/native/apple/Tests/CallsKitTests/CallChromeTests.swift b/native/apple/Tests/CallsKitTests/CallChromeTests.swift
index 3d453a6..e174ddb 100644
--- a/native/apple/Tests/CallsKitTests/CallChromeTests.swift
+++ b/native/apple/Tests/CallsKitTests/CallChromeTests.swift
@@ -314,15 +314,17 @@ final class CallOverlayContrastTests: XCTestCase {
XCTAssertEqual(ratio((1, 1, 1), (0x77 / 255.0, 0x77 / 255.0, 0x77 / 255.0)), 4.48, accuracy: 0.01)
let white: RGB = (1, 1, 1)
for (name, video) in [("white", white), ("black", (0, 0, 0)), ("grey", (0.2, 0.2, 0.2))] as [(String, RGB)] {
+ // The black alone, as if neither glass nor blur were drawn: the
+ // rendered glass over white measures darker than this (#5B5B5B).
let surface = over(Color.black.opacity(CallOverlay.scrim), video)
- let resting = over(CallOverlay.buttonFill, surface)
+ let hovered = over(CallOverlay.hoverFill, surface)
let checks: [(String, Double, Double)] = [
- ("white on the surface", ratio(white, surface), 7),
- ("white symbol on a resting button", ratio(white, resting), 4.5),
+ ("white on the surface (a resting glyph, a name, the title)", ratio(white, surface), 6),
+ ("a white glyph under the pointer", ratio(white, hovered), 4.5),
("secondary text", ratio(over(CallOverlay.secondaryText, surface), surface), 4.5),
("alert text", ratio(rgb(CallOverlay.alertText).0, surface), 4.5),
- ("an on button against the bar", ratio(rgb(CallOverlay.onFill).0, surface), 3),
- ("an on button's symbol", ratio(rgb(CallOverlay.onSymbol).0, rgb(CallOverlay.onFill).0), 4.5),
+ ("an on capsule against the pane", ratio(rgb(CallOverlay.onFill).0, surface), 3),
+ ("an on capsule's symbol", ratio(rgb(CallOverlay.onSymbol).0, rgb(CallOverlay.onFill).0), 4.5),
("white on amber (off)", ratio(white, rgb(CallOverlay.offFill).0), 4.5),
("white on Leave's red", ratio(white, rgb(CallOverlay.leaveFill).0), 4.5),
("the unread badge", ratio(white, rgb(CallOverlay.badgeFill).0), 4.5),
@@ -332,6 +334,17 @@ final class CallOverlayContrastTests: XCTestCase {
}
}
}
+
+ /// The Devices panel over the picture: opaque, so the video never
+ /// enters into it, with the dark appearance's own label colours.
+ func testTheDevicesPanelIsReadable() {
+ let panel = rgb(CallOverlay.panelFill).0
+ XCTAssertEqual(rgb(CallOverlay.panelFill).1, 1, "opaque")
+ XCTAssertGreaterThanOrEqual(ratio((1, 1, 1), panel), 15, "primary text")
+ // secondaryLabelColor in the dark appearance: white at 55 %.
+ let secondary = over(Color.white.opacity(0.55), panel)
+ XCTAssertGreaterThanOrEqual(ratio(secondary, panel), 4.5, "secondary text")
+ }
}
final class PiPLayoutTests: XCTestCase {
diff --git a/native/apple/Tests/CallsKitTests/SpeakingGlowTests.swift b/native/apple/Tests/CallsKitTests/SpeakingGlowTests.swift
new file mode 100644
index 0000000..1034510
--- /dev/null
+++ b/native/apple/Tests/CallsKitTests/SpeakingGlowTests.swift
@@ -0,0 +1,195 @@
+import XCTest
+
+@_spi(Fixtures) @testable import CallsKit
+
+/// The speaking glow's rule (0.3.7) on a clock the tests own: what lights
+/// it and what does not, how quickly it rises and falls, silence, and
+/// Reduce Motion.
+final class SpeakingGlowTests: XCTestCase {
+ private func level(db: Float) -> Float { pow(10, db / 20) }
+
+ /// Feeds a gate and a glow the same readings, as AudioLevels does.
+ private struct Voice {
+ var gate = SpeakingGate()
+ var glow = SpeakingGlow()
+ mutating func hear(db: Float, at now: Double) {
+ let level = db.isFinite ? pow(10, db / 20) : 0
+ let speaking = gate.update(level: level, now: now)
+ glow.update(level: level, speaking: speaking, now: now)
+ }
+ }
+
+ func testBrightnessFollowsDecibels() {
+ XCTAssertEqual(SpeakingGlow.intensity(level: 0), 0)
+ XCTAssertEqual(SpeakingGlow.intensity(level: level(db: -60)), 0)
+ XCTAssertEqual(SpeakingGlow.intensity(level: level(db: SpeakingGlow.quietDb)), 0, accuracy: 1e-6)
+ XCTAssertEqual(SpeakingGlow.intensity(level: level(db: -27)), 0.5, accuracy: 1e-3)
+ XCTAssertEqual(SpeakingGlow.intensity(level: level(db: SpeakingGlow.loudDb)), 1, accuracy: 1e-6)
+ XCTAssertEqual(SpeakingGlow.intensity(level: 1), 1)
+ var last = 0.0
+ for db in stride(from: Float(-90), through: 0, by: 1) {
+ let x = SpeakingGlow.intensity(level: level(db: db))
+ XCTAssertGreaterThanOrEqual(x, last, "louder is never dimmer")
+ last = x
+ }
+ }
+
+ func testSilenceIsNoGlow() {
+ var voice = Voice()
+ for t in stride(from: 0.0, through: 5, by: 0.1) {
+ voice.hear(db: -.infinity, at: t)
+ XCTAssertFalse(voice.glow.lit)
+ XCTAssertEqual(voice.glow.value(at: t), 0)
+ XCTAssertEqual(voice.glow.value(at: t, reduceMotion: true), 0)
+ }
+ // A quiet room's tone, under the speaking level, is not speech either.
+ for t in stride(from: 5.0, through: 10, by: 0.1) { voice.hear(db: -55, at: t) }
+ XCTAssertFalse(voice.glow.lit)
+ }
+
+ func testABriefNoiseDoesNotLightIt() {
+ // One loud reading — a click, a cough, a door — then quiet. The gate
+ // calls that speaking for its hangover; the glow waits for more.
+ var voice = Voice()
+ voice.hear(db: -8, at: 0)
+ XCTAssertTrue(voice.gate.speaking, "the gate is on at once")
+ for t in stride(from: 0.1, through: 2, by: 0.1) {
+ voice.hear(db: -70, at: t)
+ XCTAssertFalse(voice.glow.lit, "at \(t)s")
+ XCTAssertEqual(voice.glow.value(at: t), 0)
+ }
+ // Two clicks a second and a half apart are two brief noises, not a
+ // voice, read at the peers' five times a second.
+ voice.hear(db: -8, at: 3)
+ for t in stride(from: 3.2, through: 4.2, by: 0.2) { voice.hear(db: -70, at: t) }
+ voice.hear(db: -8, at: 4.4)
+ for t in stride(from: 4.6, through: 5.4, by: 0.2) {
+ voice.hear(db: -70, at: t)
+ XCTAssertFalse(voice.glow.lit, "at \(t)s")
+ }
+ }
+
+ func testSpeechLightsItOnceItKeepsUp() {
+ var voice = Voice()
+ voice.hear(db: -20, at: 0)
+ XCTAssertFalse(voice.glow.lit, "one reading is not yet a voice")
+ voice.hear(db: -20, at: 0.1)
+ XCTAssertFalse(voice.glow.lit)
+ voice.hear(db: -20, at: 0.2)
+ XCTAssertTrue(voice.glow.lit, "sound at a speaking level for \(SpeakingGlow.onset)s is")
+ XCTAssertEqual(
+ voice.glow.target,
+ SpeakingGlow.floor + (1 - SpeakingGlow.floor) * SpeakingGlow.intensity(level: level(db: -20)),
+ accuracy: 1e-9)
+ // Between syllables it dims but stays lit, as the gate does.
+ voice.hear(db: -45, at: 0.3)
+ XCTAssertTrue(voice.glow.lit)
+ XCTAssertEqual(voice.glow.target, SpeakingGlow.floor, accuracy: 1e-9)
+ // And goes out when the gate lets go.
+ for t in stride(from: 0.4, through: 1.2, by: 0.1) { voice.hear(db: -70, at: t) }
+ XCTAssertFalse(voice.glow.lit)
+ XCTAssertEqual(voice.glow.target, 0)
+ }
+
+ func testItRisesQuicklyAndFallsSlowly() {
+ var glow = SpeakingGlow()
+ let loud = level(db: -12)
+ glow.update(level: loud, speaking: true, now: 0)
+ glow.update(level: loud, speaking: true, now: 0.15)
+ XCTAssertTrue(glow.lit)
+ XCTAssertEqual(glow.value(at: 0.15), 0, accuracy: 1e-9, "from where it was")
+ // Attack: 63 % of the way in one time constant, all of it in five.
+ XCTAssertEqual(
+ glow.value(at: 0.15 + SpeakingGlow.attack), 1 - exp(-1), accuracy: 1e-6)
+ XCTAssertGreaterThan(glow.value(at: 0.15 + 5 * SpeakingGlow.attack), 0.99)
+ XCTAssertLessThan(glow.settles, 0.15 + 6 * SpeakingGlow.attack, "and the view stops redrawing")
+
+ // Release: silence at 1 s, and it fades over hundreds of milliseconds,
+ // not at once and not at the attack's pace.
+ glow.update(level: 0, speaking: false, now: 1)
+ XCTAssertFalse(glow.lit)
+ XCTAssertEqual(glow.value(at: 1), 1, accuracy: 1e-3)
+ XCTAssertEqual(glow.value(at: 1 + SpeakingGlow.release), exp(-1), accuracy: 1e-3)
+ XCTAssertGreaterThan(glow.value(at: 1 + SpeakingGlow.attack), 0.8, "falls slower than it rises")
+ XCTAssertLessThan(glow.value(at: 1 + 5 * SpeakingGlow.release), 0.01)
+ XCTAssertGreaterThan(glow.settles, 1 + 4 * SpeakingGlow.release)
+ XCTAssertLessThan(glow.settles, 1 + 7 * SpeakingGlow.release)
+ XCTAssertTrue((0.25...0.4).contains(SpeakingGlow.release))
+ XCTAssertEqual(SpeakingGlow.attack, 0.05)
+ }
+
+ func testAReadingMidwayCarriesOnFromWhereItWas() {
+ var glow = SpeakingGlow()
+ glow.update(level: level(db: -12), speaking: true, now: 0)
+ glow.update(level: level(db: -12), speaking: true, now: 0.2)
+ let midway = glow.value(at: 0.225)
+ XCTAssertTrue(glow.update(level: level(db: -40), speaking: true, now: 0.225))
+ XCTAssertEqual(glow.value(at: 0.225), midway, accuracy: 1e-9, "no jump when the target moves")
+ }
+
+ func testAReadingThatChangesNothingVisibleIsNotAChange() {
+ var glow = SpeakingGlow()
+ XCTAssertFalse(glow.update(level: 0, speaking: false, now: 0), "silence stays silence")
+ glow.update(level: level(db: -20), speaking: true, now: 1)
+ XCTAssertTrue(glow.update(level: level(db: -20), speaking: true, now: 1.2), "lights")
+ XCTAssertFalse(
+ glow.update(level: level(db: -20.1), speaking: true, now: 1.3), "a tenth of a dB is not a redraw")
+ }
+
+ func testReduceMotionIsOneSteadyGlow() {
+ var glow = SpeakingGlow()
+ glow.update(level: level(db: -40), speaking: true, now: 0)
+ XCTAssertEqual(glow.value(at: 0, reduceMotion: true), 0, "not lit yet: nothing")
+ glow.update(level: level(db: -10), speaking: true, now: 0)
+ glow.update(level: level(db: -10), speaking: true, now: 0.2)
+ for (db, t) in [(-10, 0.3), (-35, 0.4), (-20, 0.5)] as [(Float, Double)] {
+ glow.update(level: level(db: db), speaking: true, now: t)
+ for dt in [0, 0.01, 0.1, 1.0] {
+ XCTAssertEqual(glow.value(at: t + dt, reduceMotion: true), SpeakingGlow.steady)
+ }
+ }
+ glow.update(level: 0, speaking: false, now: 1)
+ XCTAssertEqual(glow.value(at: 1, reduceMotion: true), 0, "out at once, no fade")
+ }
+
+ func testTheFixtureHoldsALevel() {
+ XCTAssertFalse(SpeakingGlow.fixture(level: level(db: -60)).lit)
+ let mid = SpeakingGlow.fixture(level: level(db: -20))
+ XCTAssertTrue(mid.lit)
+ XCTAssertEqual(mid.value(at: 0), mid.value(at: 100), "still")
+ }
+
+ @MainActor func testTheTilesAndTheTabGlow() {
+ let levels = AudioLevels()
+ let unmuted = PeerMedia(muted: false, cameraOff: false)
+ func tick(_ t: Double, bea: Float, cy: Float) {
+ levels.updatePeer(
+ "bea", name: "Bea",
+ sample: PeerAudioSample(reported: unmuted, connected: true, arriving: true, level: level(db: bea)),
+ notices: [], now: t)
+ levels.updatePeer(
+ "cy", name: "Cy",
+ sample: PeerAudioSample(reported: unmuted, connected: true, arriving: true, level: level(db: cy)),
+ notices: [], now: t)
+ levels.settle(present: ["bea", "cy"])
+ }
+ for t in stride(from: 0.0, through: 1, by: 0.2) { tick(t, bea: -15, cy: -60) }
+ XCTAssertTrue(levels.peer("bea").glow.glow.lit)
+ XCTAssertFalse(levels.peer("cy").glow.glow.lit)
+ XCTAssertEqual(levels.othersGlow.glow, levels.peer("bea").glow.glow, "the tab follows Bea")
+ levels.settle(present: ["cy"])
+ XCTAssertFalse(levels.peer("bea").glow.glow.lit, "someone who left does not glow")
+ XCTAssertFalse(levels.othersGlow.glow.lit)
+
+ // Your own tile, and muting puts it out.
+ let mic = { (muted: Bool, db: Float) in
+ MicSample(muted: muted, capturing: true, inputPeak: self.level(db: db), inputBuffers: 10, sentPeak: self.level(db: db))
+ }
+ for t in stride(from: 0.0, through: 0.5, by: 0.1) {
+ levels.updateMic(mic(false, -15), now: t, source: "", channels: false)
+ }
+ XCTAssertTrue(levels.mic.glow.glow.lit)
+ levels.updateMic(mic(true, -15), now: 0.6, source: "", channels: false)
+ XCTAssertFalse(levels.mic.glow.glow.lit)
+ }
+}
diff --git a/native/ios/Sources/CallsPhone.swift b/native/ios/Sources/CallsPhone.swift
index d6b4391..9b1f839 100644
--- a/native/ios/Sources/CallsPhone.swift
+++ b/native/ios/Sources/CallsPhone.swift
@@ -396,7 +396,7 @@ struct PhoneVideoTile: View {
/// Behind a picture that is not there: a shade lighter than the stage
/// for a tile among others, so it reads as a tile.
var backdrop = Color.black
- /// The sound on this tile (spec §15.20): your microphone's meter, or a
+ /// The sound on this tile (spec §15.20): your microphone's glow, or a
/// remote person's with "No sound from" and their notices. Nil for a
/// screen.
var audio: TileAudio? = nil
@@ -425,20 +425,23 @@ struct PhoneVideoTile: View {
VStack(alignment: .leading, spacing: 4) {
TileAudioNotices(info)
HStack(spacing: 5) {
- if compact {
- TileNameLabel(label: label, muted: muted)
- } else {
- Text(label).font(.callout.bold()).foregroundStyle(.white).lineLimit(1)
- .padding(.horizontal, 10).padding(.vertical, 6)
- .background(.black.opacity(0.65), in: Capsule())
+ Group {
+ if compact {
+ TileNameLabel(label: label, muted: muted)
+ } else {
+ Text(label).font(.callout.bold()).foregroundStyle(.white).lineLimit(1)
+ .padding(.horizontal, 10).padding(.vertical, 6)
+ .callOverlaySurface(Capsule())
+ }
}
+ // Speaking is the glow behind the name, as bright as the voice.
+ .speakingGlow(audio?.glow, in: Capsule())
TileAudioStatus(info)
}
}
.padding(compact ? 6 : 8).padding(compact ? labelInsets : EdgeInsets())
}
.overlay(alignment: .topLeading) { if muted, !compact { MutedBadge().padding(8) } }
- .speakingRing(info?.speaking == true, cornerRadius: cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
.accessibilityElement(children: .ignore)
.accessibilityLabel(tileAccessibilityLabel(label, muted: muted, audio: info))
diff --git a/native/ios/Sources/PhoneCallFixture.swift b/native/ios/Sources/PhoneCallFixture.swift
index b38a236..05bef8a 100644
--- a/native/ios/Sources/PhoneCallFixture.swift
+++ b/native/ios/Sources/PhoneCallFixture.swift
@@ -8,8 +8,11 @@
/// optionally `-CallsFixtureControls shown|hidden` to pin the controls,
/// `-CallsFixtureMore YES` to open the controls menu, `-CallsFixtureDark
/// YES`, `-CallsFixtureClassic YES`, `-CallsFixtureBackdrop black|white|busy`
- /// for a frame in place of the stage, and `-CallsFixtureSelf
- /// muted|cameraOff|sharing` for your own state.
+ /// for a frame in place of the stage, `-CallsFixtureSelf
+ /// muted|cameraOff|sharing` for your own state, `-CallsFixtureSpeaking
+ /// silent|low|mid|high` for everyone's speaking glow, and
+ /// `-CallsFixtureNoGlass YES` for the material older systems draw in
+ /// place of Liquid Glass.
struct PhoneCallFixture: View {
static var requested: CallFixture? {
UserDefaults.standard.string(forKey: "CallsCallFixture").flatMap(CallFixture.init(rawValue:))
@@ -33,6 +36,7 @@
.environment(
\.fixtureBackdrop,
defaults.string(forKey: "CallsFixtureBackdrop").flatMap(FixtureBackdrop.init(rawValue:)))
+ .environment(\.callOverlayGlass, !defaults.bool(forKey: "CallsFixtureNoGlass"))
.preferredColorScheme(defaults.bool(forKey: "CallsFixtureDark") ? .dark : .light)
.onAppear {
guard !applied else { return }
@@ -40,6 +44,8 @@
fixture.apply(to: call)
UserDefaults.standard.string(forKey: "CallsFixtureSelf")
.flatMap(FixtureSelf.init(rawValue:))?.apply(to: call)
+ UserDefaults.standard.string(forKey: "CallsFixtureSpeaking")
+ .flatMap(FixtureSpeaking.init(rawValue:))?.apply(to: call)
}
}
}
diff --git a/native/ios/Sources/PhoneFullScreenCall.swift b/native/ios/Sources/PhoneFullScreenCall.swift
index 93cf394..856c147 100644
--- a/native/ios/Sources/PhoneFullScreenCall.swift
+++ b/native/ios/Sources/PhoneFullScreenCall.swift
@@ -184,7 +184,6 @@ extension PhoneCall {
compact: true, backdrop: Self.tileBackdrop, audio: call.selfAudio
)
.frame(width: pip.width, height: pip.height)
- .overlay(RoundedRectangle(cornerRadius: 14).strokeBorder(.white.opacity(0.2)))
.shadow(color: .black.opacity(0.35), radius: 8, y: 3)
.position(x: frame.midX + pipDrag.width, y: frame.midY + pipDrag.height)
.onTapGesture { chrome.tapped() }
@@ -336,8 +335,8 @@ extension PhoneCall {
}
}
-/// A round call button over the picture: the overlay's dark surface with
-/// a white symbol, or filled for End call's red and a muted microphone's
+/// A round call button over the picture: a borderless glass circle with a
+/// white symbol, or filled for End call's red and a muted microphone's
/// amber (see CallOverlay).
struct RoundCallButtonStyle: ButtonStyle {
let tone: CallOverlayButtonStyle.Tone
@@ -359,8 +358,8 @@ struct RoundCallButtonStyle: ButtonStyle {
}
}
-/// A notice over the picture: the overlay's dark surface, white text, and
-/// dark-appearance controls whatever the system's.
+/// A notice over the picture: the overlay's borderless glass, white text,
+/// and dark-appearance controls whatever the system's.
struct PhoneNoticeSurface: ViewModifier {
func body(content: Content) -> some View {
content.font(.callout).foregroundStyle(.white)
diff --git a/native/ios/UITests/CallsPhoneUITests.swift b/native/ios/UITests/CallsPhoneUITests.swift
index 07b7ce2..e100302 100644
--- a/native/ios/UITests/CallsPhoneUITests.swift
+++ b/native/ios/UITests/CallsPhoneUITests.swift
@@ -272,6 +272,35 @@ final class CallsPhoneUITests: XCTestCase {
}
}
+ /// 0.3.7: the names glowing at four levels, in the grid and in focus,
+ /// and the glass over black, white and a busy frame, in Liquid Glass and
+ /// in the material older systems draw instead.
+ @MainActor func testGlowAndGlassSnapshots() throws {
+ XCUIDevice.shared.orientation = .portrait
+ for level in ["silent", "low", "mid", "high"] {
+ for fixture in ["grid", "focus"] {
+ let app = launchCall(
+ fixture, ["-CallsFixtureControls", "shown", "-CallsFixtureSpeaking", level], dark: true)
+ XCTAssertTrue(app.buttons["leave"].waitForExistence(timeout: 15))
+ Thread.sleep(forTimeInterval: 1)
+ try snapshot(app, "glow-\(fixture)-\(level)")
+ }
+ }
+ for glass in [true, false] {
+ for backdrop in ["black", "white", "busy"] {
+ let app = launchCall(
+ "notices",
+ [
+ "-CallsFixtureControls", "shown", "-CallsFixtureBackdrop", backdrop,
+ "-CallsMicBesideMenu", "YES", "-CallsFixtureNoGlass", glass ? "NO" : "YES",
+ ], dark: true)
+ XCTAssertTrue(app.buttons["leave"].waitForExistence(timeout: 15))
+ Thread.sleep(forTimeInterval: 1)
+ try snapshot(app, "bar-\(glass ? "glass" : "fallback")-\(backdrop)")
+ }
+ }
+ }
+
/// Landscape keeps both bar buttons on screen, clear of the Dynamic
/// Island's side.
@MainActor func testLandscapeKeepsTheBarOnScreen() throws {
diff --git a/test/browser/call.mjs b/test/browser/call.mjs
index 194c835..66f8c47 100644
--- a/test/browser/call.mjs
+++ b/test/browser/call.mjs
@@ -1202,16 +1202,65 @@ const refuseFirstPlay = () => {
};
};
-// meterBars is the meter beside a tile's name as it is drawn: whether it
-// is showing, and how many bars are lit.
-function meterBars(page, key) {
+// glowOf is the speaking glow on a tile's name label as it is drawn:
+// whether it is lit, its --glow, and the shadow the browser computed from
+// it — plus whether anything of the ring or the meter it replaced is left
+// on the tile.
+function glowOf(page, key) {
return page.evaluate((k) => {
- const m = document.querySelector('#tiles figure[data-tile="' + k + '"] .calls-meter');
- if (!m) return null;
- return { shown: !m.hidden && m.getClientRects().length > 0, bars: Number(m.dataset.level) || 0 };
+ const figure = document.querySelector('#tiles figure[data-tile="' + k + '"]');
+ const label = figure && figure.querySelector('.calls-tile-name');
+ if (!label) return null;
+ const style = getComputedStyle(label);
+ return {
+ lit: label.dataset.glow === 'on',
+ glow: Number(style.getPropertyValue('--glow')) || 0,
+ shadow: style.boxShadow,
+ // The old ring was an inset shadow on the figure (a floating self
+ // view's own drop shadow is not one).
+ ring: figure.classList.contains('calls-tile-speaking') || /inset/.test(getComputedStyle(figure).boxShadow),
+ meter: !!figure.querySelector('.calls-meter'),
+ };
}, key);
}
+// speakInto replaces a page's microphone on every connection with a
+// steady tone, loud enough to be speech to the glow's gate. Chromium's
+// fake microphone only beeps, and a beep is exactly the brief noise the
+// glow is built to ignore. stopSpeaking puts the microphone back.
+function speakInto(page) {
+ return page.evaluate(async () => {
+ const ctx = new AudioContext();
+ const osc = ctx.createOscillator();
+ const gain = ctx.createGain();
+ gain.gain.value = 0.3;
+ osc.frequency.value = 220;
+ const dest = ctx.createMediaStreamDestination();
+ osc.connect(gain).connect(dest);
+ osc.start();
+ await ctx.resume();
+ const tone = dest.stream.getAudioTracks()[0];
+ window.__toneRestore = [];
+ for (const pc of window.__pcs || []) {
+ for (const sn of pc.getSenders()) {
+ if (sn.track && sn.track.kind === 'audio') {
+ window.__toneRestore.push([sn, sn.track]);
+ await sn.replaceTrack(tone);
+ }
+ }
+ }
+ window.__toneCtx = ctx;
+ return window.__toneRestore.length;
+ });
+}
+function stopSpeaking(page) {
+ return page.evaluate(async () => {
+ for (const [sn, track] of window.__toneRestore || []) await sn.replaceTrack(track);
+ if (window.__toneCtx) await window.__toneCtx.close();
+ window.__toneRestore = [];
+ });
+}
+
function mediaLive(page) {
return page.evaluate(() => ((document.getElementById('media-live') || {}).textContent || '').trim());
}
@@ -1495,22 +1544,49 @@ const run = {
const h = await hearingOf(pageB, a);
return h && h.peak > 0 && h.shown === 'meter' ? h : false;
}, 15000);
- check(heard, 'B’s meter for A reads a level above zero (' + JSON.stringify(heard || (await hearingOf(pageB, a))) + ')');
- const lit = await until(async () => {
- const m = await meterBars(pageB, a);
- return m && m.shown && m.bars > 0 ? m : false;
- }, 10000);
- check(lit, 'the meter beside A’s name on B lights at least one bar (' + JSON.stringify(lit || (await meterBars(pageB, a))) + ')');
+ check(heard, 'B reads a level above zero for A (' + JSON.stringify(heard || (await hearingOf(pageB, a))) + ')');
+ // The glow replaced the ring and the meter (0.3.7): neither is drawn.
+ const plain = await glowOf(pageB, a);
+ check(plain && !plain.ring && !plain.meter,
+ 'no ring round A’s tile and no meter beside the name on B (' + JSON.stringify(plain) + ')');
const self = await until(async () => {
const h = await hearingOf(pageA, 'self');
return h && h.peak > 0 ? h : false;
}, 15000);
- check(self, 'A’s own meter reads its microphone (' + JSON.stringify(self || (await hearingOf(pageA, 'self'))) + ')');
+ check(self, 'A’s own tile reads its microphone (' + JSON.stringify(self || (await hearingOf(pageA, 'self'))) + ')');
+ // A speaks — a steady tone rather than the fake microphone's beep —
+ // and A's name glows on B, and on A's own tile.
+ const toned = await speakInto(pageA);
+ check(toned > 0, 'A’s microphone is replaced by a tone (' + toned + ' senders)');
+ const lit = await until(async () => {
+ const g = await glowOf(pageB, a);
+ const h = await hearingOf(pageB, a);
+ return g && g.lit && g.glow > 0 && h && h.speaking && h.glow > 0 && g.shadow !== 'none' ? { g, h } : false;
+ }, 10000);
+ check(lit, 'A’s name glows on B while A speaks (' + JSON.stringify(lit || (await glowOf(pageB, a))) + ')');
const selfLit = await until(async () => {
- const m = await meterBars(pageA, 'self');
- return m && m.shown && m.bars > 0 ? m : false;
+ const g = await glowOf(pageA, 'self');
+ return g && g.lit && g.glow > 0 ? g : false;
+ }, 10000);
+ check(selfLit, 'and on A’s own tile (' + JSON.stringify(selfLit || (await glowOf(pageA, 'self'))) + ')');
+ // For a look at it: CALLS_GLOW_SNAPSHOTS=<dir> keeps B's room, glowing.
+ if (process.env.CALLS_GLOW_SNAPSHOTS) {
+ await pageB.screenshot({ path: process.env.CALLS_GLOW_SNAPSHOTS + '/web-room-glowing.png', fullPage: true });
+ }
+ const ringless = await glowOf(pageB, a);
+ check(ringless && !ringless.ring, 'speaking draws no ring round the tile (' + JSON.stringify(ringless) + ')');
+ // Silence — the track disabled, so packets carry nothing — puts it
+ // out, over the release rather than at once. (The fake microphone's
+ // beep, back, would light it again: Chromium's audioLevel lingers.)
+ await pageA.evaluate(() => {
+ for (const [sn] of window.__toneRestore || []) if (sn.track) sn.track.enabled = false;
+ });
+ const out = await until(async () => {
+ const g = await glowOf(pageB, a);
+ return g && !g.lit && g.glow === 0 && g.shadow.startsWith('rgba(46, 204, 219, 0)') ? g : false;
}, 10000);
- check(selfLit, 'and A’s own tile shows it (' + JSON.stringify(selfLit || (await meterBars(pageA, 'self'))) + ')');
+ await stopSpeaking(pageA);
+ check(out, 'and it goes out when A stops (' + JSON.stringify(out || (await glowOf(pageB, a))) + ')');
// A beeping microphone is not a silent one, on either side, for
// longer than the rule's five seconds.
@@ -1527,8 +1603,11 @@ const run = {
return h && h.shown === 'muted' && m && m.muted ? { h, m } : false;
}, 10000);
check(muted, 'B shows A as Muted once A mutes (' + JSON.stringify(muted || (await hearingOf(pageB, a))) + ')');
- const meterGone = await meterBars(pageB, a);
- check(meterGone && !meterGone.shown, 'the meter gives way to the badge (' + JSON.stringify(meterGone) + ')');
+ const glowGone = await until(async () => {
+ const g = await glowOf(pageB, a);
+ return g && !g.lit && g.glow === 0 ? g : false;
+ }, 3000);
+ check(glowGone, 'muted, nothing glows (' + JSON.stringify(glowGone || (await glowOf(pageB, a))) + ')');
const mutedQuiet = await stayed(pageB, 6500, (s) =>
s.hearing[a] && s.hearing[a].shown === 'muted' && s.hearing[a].text === '' && !s.noSoundFrom.length);
check(!mutedQuiet, 'a muted peer is never "No sound from" (' + JSON.stringify(mutedQuiet && mutedQuiet.hearing[a]) + ')');
@@ -1540,7 +1619,7 @@ const run = {
const h = await hearingOf(pageB, a);
return h && h.shown === 'meter' ? h : false;
}, 10000);
- check(back, 'unmuting brings the meter back (' + JSON.stringify(back || (await hearingOf(pageB, a))) + ')');
+ check(back, 'unmuting brings live sound back (' + JSON.stringify(back || (await hearingOf(pageB, a))) + ')');
// Silence without muting: the microphone track is disabled under the
// call, so packets keep flowing and carry nothing, and A's report
diff --git a/test/browser/native.mjs b/test/browser/native.mjs
index abd8d74..d98bd65 100644
--- a/test/browser/native.mjs
+++ b/test/browser/native.mjs
@@ -143,6 +143,8 @@ try {
await wait(() => sample?.stats.micPeakPermille > 10, "the app's own meter sees what it sends");
assert.equal(sample.stats.micSilent, 0, "a live synthetic microphone is not reported as picking nothing up");
console.log(`PASS the app's microphone meter reads what it sends, with no silence warning (noSoundFrom ${sample.stats.noSoundFrom})`);
+ await wait(() => sample?.stats.micGlow === 1, "the app's own name glows while its synthetic voice speaks");
+ console.log('PASS the speaking glow behind the app\'s own name lights for its voice');
}
// Choose the system default on the audio device module during the live
// call, then refuse an unavailable microphone. Both must leave media and
diff --git a/test/browser/room-glow.test.mjs b/test/browser/room-glow.test.mjs
new file mode 100644
index 0000000..80d5790
--- /dev/null
+++ b/test/browser/room-glow.test.mjs
@@ -0,0 +1,149 @@
+// room-glow.test.mjs — room.js's speaking glow (0.3.7), which replaced the
+// ring round a tile and the meter beside its name, gated without a
+// browser.
+//
+// node --test test/browser/room-glow.test.mjs
+//
+// The same trick as room-hearing.test.mjs: the functions and the
+// constants they read are lifted out of the shipped room.js by name, so a
+// retuned constant or a renamed function fails here rather than passing
+// against a copy.
+//
+// glowIntensity a level to the glow's brightness, by decibels
+// judgeGlow the gate (speaking, and loud for long enough) and the
+// target brightness over the floor
+// glowStyle what a label is given: --glow, and the transition's
+// duration (attack up, release down, none when reduced)
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+
+const roomJS = fileURLToPath(new URL('../../internal/calls/static/room.js', import.meta.url));
+const src = readFileSync(roomJS, 'utf8');
+const cssFile = fileURLToPath(new URL('../../internal/calls/static/calls.css', import.meta.url));
+const css = readFileSync(cssFile, 'utf8');
+
+function lift(source, name) {
+ const start = source.indexOf('function ' + name + '(');
+ assert.notEqual(start, -1, name + ' is not in room.js any more — this test is stale, not passing');
+ let depth = 0;
+ for (let i = source.indexOf('{', start); i < source.length; i++) {
+ if (source[i] === '{') depth += 1;
+ else if (source[i] === '}') {
+ depth -= 1;
+ if (depth === 0) return source.slice(start, i + 1);
+ }
+ }
+ assert.fail('unbalanced braces reading ' + name + ' out of room.js');
+}
+
+function liftConst(name) {
+ const m = src.match(new RegExp('const ' + name + ' = (-?[0-9.]+);'));
+ assert.ok(m, name + ' is not a numeric const in room.js any more');
+ return 'const ' + name + ' = ' + m[1] + ';';
+}
+
+const NAMES = ['SPEAK_ON', 'SPEAK_OFF', 'SPEAK_HOLD', 'GLOW_QUIET_DB', 'GLOW_LOUD_DB', 'GLOW_FLOOR',
+ 'GLOW_ONSET', 'GLOW_ATTACK', 'GLOW_RELEASE', 'GLOW_STEADY'];
+const CONSTS = NAMES.map(liftConst).join('\n');
+// eslint-disable-next-line no-new-func
+const c = new Function(CONSTS + '; return {' + NAMES.join(',') + '};')();
+const build = (name) =>
+ // eslint-disable-next-line no-new-func
+ new Function(CONSTS + '\n' + lift(src, 'judgeSpeaking') + '\n' + lift(src, name) + '; return ' + name + ';')();
+const glowIntensity = build('glowIntensity');
+const glowStyle = build('glowStyle');
+// judgeGlow reads glowIntensity; hearingRecord is the record it works on.
+// eslint-disable-next-line no-new-func
+const judge = new Function(CONSTS + '\n' + ['judgeSpeaking', 'glowIntensity', 'judgeGlow', 'hearingRecord']
+ .map((n) => lift(src, n)).join('\n') + '; return { judgeSpeaking, judgeGlow, hearingRecord };')();
+
+const db = (d) => Math.pow(10, d / 20);
+// One poll, as sampleInto takes it: the speaking gate, then the glow.
+const hear = (rec, level, now) => {
+ judge.judgeSpeaking(rec, level);
+ return judge.judgeGlow(rec, level, now);
+};
+
+test('the constants are the native app’s', () => {
+ // SpeakingGlow.swift: -42 to -12 dBFS, a 0.3 floor, 150 ms onset,
+ // 50 ms attack, 300 ms release, 0.6 steady.
+ assert.equal(c.GLOW_QUIET_DB, -42);
+ assert.equal(c.GLOW_LOUD_DB, -12);
+ assert.equal(c.GLOW_FLOOR, 0.3);
+ assert.equal(c.GLOW_ONSET, 150);
+ assert.equal(c.GLOW_ATTACK, 50);
+ assert.ok(c.GLOW_RELEASE >= 250 && c.GLOW_RELEASE <= 400);
+ assert.equal(c.GLOW_STEADY, 0.6);
+});
+
+test('glowIntensity: silence is none, loud is all, louder is never dimmer', () => {
+ assert.equal(glowIntensity(0), 0);
+ assert.equal(glowIntensity(undefined), 0);
+ assert.equal(glowIntensity(NaN), 0);
+ assert.equal(glowIntensity(db(-60)), 0);
+ assert.ok(Math.abs(glowIntensity(db(-27)) - 0.5) < 1e-9);
+ assert.ok(Math.abs(glowIntensity(db(-12)) - 1) < 1e-9);
+ assert.equal(glowIntensity(5), 1);
+ let last = 0;
+ for (let l = 0.0001; l <= 1; l *= 1.3) {
+ const x = glowIntensity(l);
+ assert.ok(x >= last);
+ last = x;
+ }
+});
+
+test('silence never lights it', () => {
+ const rec = judge.hearingRecord();
+ for (let t = 1000; t < 6000; t += 200) assert.equal(hear(rec, 0, t), 0);
+ for (let t = 6000; t < 9000; t += 200) assert.equal(hear(rec, db(-50), t), 0, 'a quiet room is not a voice');
+ assert.equal(rec.glowLit, false);
+});
+
+test('a brief noise does not light it; a voice that keeps up does', () => {
+ const rec = judge.hearingRecord();
+ // A click: one loud poll. The speaking gate holds for its hangover;
+ // the glow waits for a second loud poll that never comes.
+ assert.equal(hear(rec, db(-6), 1000), 0);
+ assert.equal(rec.speaking, true);
+ for (let t = 1200; t <= 3000; t += 200) assert.equal(hear(rec, 0, t), 0, 'at ' + t);
+ assert.equal(rec.glowLit, false);
+ // Speech: loud polls 200 ms apart light it on the second.
+ assert.equal(hear(rec, db(-20), 4000), 0);
+ const lit = hear(rec, db(-20), 4200);
+ assert.equal(rec.glowLit, true);
+ assert.ok(Math.abs(lit - (c.GLOW_FLOOR + (1 - c.GLOW_FLOOR) * glowIntensity(db(-20)))) < 1e-9);
+ // Between syllables it dims to the floor, still lit.
+ assert.equal(hear(rec, db(-45), 4400), c.GLOW_FLOOR);
+ assert.equal(rec.glowLit, true);
+ // And goes out once the speaking gate lets go.
+ let t = 4600;
+ while (rec.speaking) hear(rec, 0, (t += 200));
+ assert.equal(rec.glowLit, false);
+ assert.equal(rec.glow, 0);
+});
+
+test('glowStyle: attack going up, release coming down, a steady glow under reduced motion', () => {
+ assert.deepEqual(glowStyle(true, 0.8, 0, false), { value: '0.80', time: c.GLOW_ATTACK + 'ms' });
+ assert.deepEqual(glowStyle(true, 0.3, 0.8, false), { value: '0.30', time: c.GLOW_RELEASE + 'ms' });
+ assert.deepEqual(glowStyle(false, 0.9, 0.3, false), { value: '0.00', time: c.GLOW_RELEASE + 'ms' },
+ 'not lit is nothing, whatever the target');
+ for (const target of [0.3, 0.55, 1]) {
+ assert.deepEqual(glowStyle(true, target, 0.1, true), { value: '0.60', time: '0ms' }, 'reduced: one value');
+ }
+ assert.deepEqual(glowStyle(false, 0, 0.6, true), { value: '0.00', time: '0ms' }, 'reduced: off at once');
+ // Two places, so an unchanged glow writes nothing to the style.
+ assert.equal(glowStyle(true, 0.41234, 0, false).value, '0.41');
+});
+
+test('calls.css draws the glow from --glow and has no ring or meter left', () => {
+ assert.match(css, /@property --glow/);
+ assert.match(css, /\.calls-tile-name \{\s*--glow: 0;\s*box-shadow:[^}]*var\(--glow\)/);
+ assert.match(css, /transition: --glow var\(--glow-t/);
+ assert.match(css, /@media \(prefers-reduced-motion: no-preference\) \{\s*\.calls-tile-name \{ transition/,
+ 'the transition is only for people who have not asked for less motion');
+ assert.doesNotMatch(css, /\.calls-tile-speaking/, 'the ring is gone');
+ assert.doesNotMatch(css, /\.calls-meter/, 'the meter is gone');
+ assert.doesNotMatch(src, /calls-meter|calls-tile-speaking/, 'and room.js draws neither');
+});
diff --git a/test/browser/room-hearing.test.mjs b/test/browser/room-hearing.test.mjs
index fe0f7b0..74d26fc 100644
--- a/test/browser/room-hearing.test.mjs
+++ b/test/browser/room-hearing.test.mjs
@@ -12,8 +12,9 @@
// whether it was sound at all
// judgeSilence the five-second rule, and what it refuses to count
// judgeSpeaking the speaking highlight's hysteresis
-// levelStep a level to the meter's bars
-// hearingShown muted, then blocked, then no sound, then the meter
+// hearingShown muted, then blocked, then no sound, then live sound
+//
+// The speaking glow that replaced the meter has room-glow.test.mjs.
// remoteNotice what a peer's own report says about hearing us
import { test } from 'node:test';
import assert from 'node:assert/strict';
@@ -45,9 +46,9 @@ function liftConst(name) {
return 'const ' + name + ' = ' + m[1] + ';';
}
-const CONSTS = ['FLAT_LEVEL', 'NO_SOUND_AFTER', 'SPEAK_ON', 'SPEAK_OFF', 'SPEAK_HOLD', 'METER_STEPS']
+const CONSTS = ['FLAT_LEVEL', 'NO_SOUND_AFTER', 'SPEAK_ON', 'SPEAK_OFF', 'SPEAK_HOLD']
.map(liftConst).join('\n');
-const consts = new Function(CONSTS + '; return { FLAT_LEVEL, NO_SOUND_AFTER, SPEAK_HOLD, METER_STEPS };')();
+const consts = new Function(CONSTS + '; return { FLAT_LEVEL, NO_SOUND_AFTER, SPEAK_HOLD };')();
const build = (name) =>
// eslint-disable-next-line no-new-func
@@ -56,7 +57,6 @@ const build = (name) =>
const audioSample = build('audioSample');
const judgeSilence = build('judgeSilence');
const judgeSpeaking = build('judgeSpeaking');
-const levelStep = build('levelStep');
const hearingShown = build('hearingShown');
const remoteNotice = build('remoteNotice');
const hearingRecord = build('hearingRecord');
@@ -67,25 +67,6 @@ test('the thresholds are the ones the spec names', () => {
assert.equal(consts.NO_SOUND_AFTER, 5000);
});
-test('levelStep: silence is no bars, any sound at least one, full scale all', () => {
- assert.equal(levelStep(0), 0);
- assert.equal(levelStep(0.00005), 0);
- assert.equal(levelStep(undefined), 0);
- assert.equal(levelStep(NaN), 0);
- assert.equal(levelStep(0.0001), 1, '-80 dBFS is sound, and shows');
- assert.equal(levelStep(0.001), 1, '-60 dBFS');
- assert.equal(levelStep(0.01), 2, '-40 dBFS');
- assert.equal(levelStep(0.1), 4, '-20 dBFS');
- assert.equal(levelStep(1), consts.METER_STEPS);
- assert.equal(levelStep(5), consts.METER_STEPS, 'never more bars than the meter has');
- let last = 0;
- for (let l = 0.0001; l <= 1; l *= 1.5) {
- const s = levelStep(l);
- assert.ok(s >= last, 'louder is never fewer bars');
- last = s;
- }
-});
-
test('audioSample prefers audioLevel, and falls back to energy over time', () => {
const first = audioSample(null, { audioLevel: 0.2, packetsReceived: 10, totalAudioEnergy: 1, totalSamplesDuration: 2 });
assert.equal(first.level, 0.2);