diff --git a/CLAUDE.md b/CLAUDE.md index d5debc8db..8e56a2c65 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,7 +115,11 @@ npm run build:native-dev # build the dev native core (needs ZOOM_SDK_DI dotnet build native-shell/CoreVideoPro.WinUI/CoreVideoPro.WinUI.csproj -c Release -p:Platform=x64 ``` -`ZOOM_SDK_DIR` must point at the staged Zoom SDK x64 dir for the full core. `npm run app` +`ZOOM_SDK_DIR` must point at the staged Zoom SDK x64 dir for the full core: **Meeting +SDK 7.1.5.43953** since Comms (talkback controller), e.g. +`C:\Users\walla\Downloads\zoom-sdk-windows-7.1.5.43953\zoom-sdk-windows-7.1.5.43953\x64`. +`scripts/stage-zoom-sdk.ps1` restages `native-core/zoom-runtime` (a 7.0.5 runtime lacks +the talkback header, so it is replaced; do not copy an old worktree's runtime). `npm run app` runs `scripts/app.ps1`; the dev launcher is `scripts/run-studio.ps1` (now respects a pre-set `COREVIDEO_ZOOM_ENGINE_PATH`). @@ -196,6 +200,7 @@ phrase they quote. |---|---| | [`docs/reference/beta-release-runbook.md`](docs/reference/beta-release-runbook.md) | cutting, tagging or publishing a beta | | [`docs/reference/browser-sources.md`](docs/reference/browser-sources.md) | browser (URL) sources, WebView2 host | +| [`docs/reference/comms-talkback.md`](docs/reference/comms-talkback.md) | the **Comms** page (Zoom talkback, the ZComms station): engine `EngineComms` kept byte-identical with CoreVideo Edge, core relay `ZoomCommsRelay` (comms-* answered lock-free, key-ups urgent), `zoom-comms-state` facts, `CommsViewModel` / `CommsPage` / `TalkKey`, leases + release-all, the **Private comms** toggle (OFF default; ON = silent virtual mic via MicGuard, OFF leaves the operator muted), on air = `StudioAirBrush`, `scripts/probe-comms.mjs` | | [`docs/reference/core-on-air-policy.md`](docs/reference/core-on-air-policy.md) | handshake, Engine on, Record or Join gating; a stub adapter left in a real core | | [`docs/reference/core-restart-drill.md`](docs/reference/core-restart-drill.md) | core crash recovery, respawn, Zoom rejoin | | [`docs/reference/d3d-device-loss.md`](docs/reference/d3d-device-loss.md) | D3D devices, shared textures, device-removed handling | diff --git a/docs/operator-validation-runbook.md b/docs/operator-validation-runbook.md index 27b78628d..8087783ef 100644 --- a/docs/operator-validation-runbook.md +++ b/docs/operator-validation-runbook.md @@ -19,7 +19,7 @@ Current native proof surface: | Node.js 22+ | `node --version` | | Visual Studio 2022+ or 2026+ (C++ workload) | `scripts/test-native.ps1` finds `VsDevCmd.bat` | | CMake | `cmake --version` | -| Zoom Meeting SDK **7.0.5** x64 | Archive with `x64/h/zoom_sdk.h` and `x64/lib/sdk.lib` | +| Zoom Meeting SDK **7.1.5** x64 | Archive with `x64/h/zoom_sdk.h` and `x64/lib/sdk.lib` | | FFmpeg runtime for RTMP (optional) | `ffmpeg.exe` on `PATH`, or `COREVIDEO_FFMPEG_BIN_DIR` points to a `bin` folder with `avformat*.dll` | ## 2. Stage Zoom SDK @@ -27,9 +27,9 @@ Current native proof surface: ```powershell cd C:\path\to\CoreVideoPro # Repo-local SDK (auto-discovered when present): -# ZoomSDK\zoom-sdk-windows-7.0.5.39292\x64 +# ZoomSDK\zoom-sdk-windows-7.1.5.43953\x64 .\scripts\stage-zoom-sdk.ps1 -# Or override: $env:ZOOM_SDK_DIR = "C:\path\to\zoom-sdk-windows-7.0.5.39292\x64" +# Or override: $env:ZOOM_SDK_DIR = "C:\path\to\zoom-sdk-windows-7.1.5.43953\x64" ``` Staged target: `native-core/zoom-runtime/windows/x64`. Settings -> Zoom readiness should show SDK runtime checks passing after a dev build. diff --git a/docs/reference/comms-talkback.md b/docs/reference/comms-talkback.md new file mode 100644 index 000000000..869742712 --- /dev/null +++ b/docs/reference/comms-talkback.md @@ -0,0 +1,158 @@ +# Comms: Zoom talkback (the ZComms station in Pro) + +_Added 2026-11-09 (branch `codex/comms-talkback`). Design shared with CoreVideo +Edge: Edge `docs/superpowers/specs/2026-11-09-comms-design.md`._ + +The **Comms** page lets the operator cue talent over Zoom talkback from Pro +itself: one grid of person cells where a cell IS a talk key, ALL CALL above it, +LATCH / EDIT TALENT / SETTINGS / LEAVE below, lamps LINK / MTG MIC / CHANNEL / +TX and a one-line ops strip. It is the ZComms talkback station in Pro's look. +Intercom-in-Zoom is a product pillar (marketing positioning); keep it native. + +## Where it runs (forced by Zoom) + +Talkback is sent by a participant that is **host or co-host**, and the SDK is +one participant per process. So the station is Pro's own Zoom engine +(`native/zoom-engine`, `EngineComms` + `engine/comms/**`), the same participant +that receives video. Consequences: + +- Pro's Zoom account must be host or co-host; the page says so otherwise and + video keeps working. +- The engine builds against **Meeting SDK 7.1.5** (talkback controller). +- Guests need the Zoom desktop/mobile client (web cannot receive talkback; + they are listed as NO TALKBACK with the fix) and must be in the station's + room (no cross-breakout delivery; this slice is not breakout-aware). +- **Pro joins are never capture-only**, so the meeting mic follows the + operator's **Private comms** toggle (strip button; owner ruling 2026-11-10; + persisted as `ProductionOutputPreferences.CommsPrivate`, **OFF by default**): + - **OFF**: the operator's own mic is left exactly as they set it. Talkback is + delivered only while the meeting mic is OPEN (ZComms law 1), and the room + hears that mic too. MTG MIC shows it; a muted mic is said in the ops log. + - **ON**: the meeting mic becomes the never-fed silent virtual mic, exactly + Edge's capture-only path (`comms/mic_guard`): the operator is muted at once, + the virtual mic is installed only with `HasRawdataLicense()`, the mic is + unmuted ONLY after `onMicInitialize`, no initialise within 5 s = stay muted + with a "Talkback is silent: …" reason, a drop = release ownership then mute. + The room never hears the operator; they speak only through talkback. + - **ON -> OFF**: `setExternalAudioSource(nullptr)` and the operator is left + **MUTED**, never auto-unmuted; the ops log says so. A meeting ending also + removes the silent mic on a non-capture-only station. + - The shell re-sends the preference whenever a report disagrees (a new engine + starts OFF), at most once per 2 s. The state reports `private` (Edge, always + capture-only, always reports `true`; `comms-private` changes nothing there). +- Comms failures never touch subscriptions, ISO, raw media or outputs. + +## Data path + +``` +CommsPage (WinUI) ── CommsViewModel ── IZoomCommsTransport (MediaCoreBridgeService) + │ press/release, digits, Space │ SendCommsAsync(verb, payload) + ▼ ▼ +MediaCoreSupervisor.SendCommsAsync ──stdin──▶ core JsonRpcServer (comms-*: LOCK-FREE, + no coreMutex, no assembly slot) + → MediaCore::zoomCommsCommandUnlocked + → ZoomEngineRuntime::commsCommand + (ZoomCommsRelay validates; sender queue) + ──pipe──▶ engine comms_* line +engine comms_state ──pipe──▶ ZoomEngineRuntime (re-keyed camelCase, held latest) + → drainFrameEvents "zoom-comms-state" (zoom pump, no coreMutex; repeated 1/s + while held because that lane may drop) ──stdout──▶ supervisor + → ZoomCommsStateReceived → StudioViewModel (UI thread) → CommsViewModel.ApplyComms +``` + +`comms-key`, `comms-latch`, `comms-release-all` and `comms-private` are **urgent** in the core +mailbox (`CommandMailbox::urgent`) and on the EngineHost wire, so a key-up is +never refused for capacity. + +## Wire contract (shell ↔ core) + +Requests are ordinary core requests: `{"id","type":,"payload":{...}}`. +The verbs and their fields are **CoreVideo Edge's** (`docs/PROTOCOL.md` +"Comms" in Edge), relayed by the same code (`src/modules/ZoomCommsRelay` is +Edge's `CommsRelay` verbatim): + +| type | payload | +|---|---| +| `comms-key` | `{"slot":0..15}` or `{"all":true}`, `"on":bool`, optional `"leaseMs":0..60000` | +| `comms-latch` | same as key, no lease | +| `comms-assign` | `{"slot","participantId","on"}` | +| `comms-mic` / `comms-sidetone-out` | `{"device":""}` (`""` = system default) | +| `comms-sidetone` / `comms-tone` | `{"on":bool}` | +| `comms-gain` | `{"db":-20..20}` | +| `comms-release-all` | `{}`: every held key and ALL CALL up; latches stay | +| `comms-private` | `{"on":bool}`: Private comms (above). OFF is never refused | + +Answer: `{"ok":true,"type":}` or failure code `comms-refused` with the +operator text in `error.message` (shown verbatim in the ops strip). Releasing +(`on:false`, release-all) is never refused, even with no engine. + +State: unsolicited `{"type":"zoom-comms-state","comms":{...}}`; `comms` is the +engine's last `comms_state` re-keyed to camelCase (Edge's state object: +`available`, `reason`, `lamps{link,meetingMic,channel,tx}`, `room`, +`channels[16]{slot,label,listeners,ready,keyed,latched,room,reachOk,reachDark}`, +`roster[]{participantId,name,talkback,room,channels}`, `mic{device,devices, +peakDbfs,gainDb,sidetone,sidetoneDevice,sidetoneDevices,tone}`, +`stats{framesSent,channelSends,underruns,sendErrors}`, `log[]`, `private`). +`"comms":null` = the report was dropped (Leave, engine gone, core restart); +the shell then shows no key as live. Parsed by `ZoomCommsProtocol`. + +**Deviations from Edge's node protocol**: arguments ride in `payload` (Pro's +request convention) instead of top-level fields; the state arrives as its own +`zoom-comms-state` fact instead of a `comms` member of a state line. Names, +fields and values are identical. + +## Leases and release + +A held key is **leased** (Edge contract): every press sends `leaseMs:1500` +and the shell re-sends it every 500 ms while held (`StudioViewModel.Comms.cs` +DispatcherQueueTimer → `CommsViewModel.RenewLeasesAsync`). A hung shell +therefore drops its keys in 1.5 s. Every held key goes up when: focus leaves +the page, the window deactivates (`MainWindow.OnWindowActivated`), the +operator navigates to another tab, the meeting ends, or the report is dropped. +Shell shutdown sends `comms-release-all` (`PrepareForShutdown` → `ShutdownComms`). +If the shell dies, the core's stdin closes, the core exits and the engine +process goes with it (talkback ends with the participant). + +## UI rules (do not regress) + +- `TalkKey` (Controls): press = down, release/capture-loss/cancel = up, with + pointer capture; **Click and Command are never used**; hover/pressed visual + states are suppressed so the colour is always the engine's truth; Space and + Enter bubble to the page. +- Never rebuild a control under the operator's finger: cells, people, device + pickers and the ops history are `ObservableCollectionSync`-synced (no + `Reset`), and only on a structure-signature change; values update in place. + Tests pin it (`CommsViewModelTests`). +- A cell is never "on air" unless the engine reports it keyed **and** someone + confirmed in the channel. Keyed with nobody = armed. +- Digits 1-9 (first nine reachable cells) and Space (ALL CALL) are + **panel-scoped** (`CommsPage` PreviewKeyDown: focus inside the page, not in a + text field, no modifier, auto-repeat ignored). Never global. +- Colours are Pro tokens only (`CommsBrushConverter`): **on air = filled + `StudioAirBrush`** (Pro's "air red - on-air, record") with + `StudioBackgroundBrush` text; armed = `StudioWarningBrush` outline and text; + ready = `StudioTextBrush`; idle = `StudioTextSecondaryBrush`; unreachable = + `StudioDimBrush`; lamps on = `StudioLiveBrush`. +- Explicit states: not in a meeting; waiting for the engine's report; not up + (verbatim reason, host/co-host hint); desk. + +## Testing + +- Native: `CommsRelay*`, `CommsCore*`, `CommsProtocol*`, `MicGuard*`, + `ZCommsPorted*`, `ZoomEngineRuntime.Comms*` in `corevideo-native-tests` + (the shim takes ONE `--gtest_filter` pattern). +- Shell: `CommsViewModelTests` (WinUI.Tests): the JSON each action sends, + leases, release-all, structure gates, states, colour tokens. +- End to end without Zoom: `scripts/probe-comms.mjs` drives the real core + binary over stdio against `corevideo-zoom-engine-fake` (join, wait for the + bank, key/latch/assign/tone, read the `zoom-comms-state` facts). +- Rendered page: `CoreVideoPro.WinUI.exe --verify-comms-page ` + (`CommsPageProbe`, offscreen and never activated, no core) renders the real + `CommsPage` through not-in-meeting, not-host, desk and edit+settings states, + checks key count/width and the on-air/armed tokens, and writes + `comms-*.png` beside the report; part of `scripts/test-studio-layout.ps1`. + An `x:DataType` that disagrees with the bound item type fail-fasts this page + as `0xc000027b` (seen once in development with the ops history). +- **Not proven**: real talkback against Zoom (needs a live meeting with this + account as co-host and desktop-client guests), audio at the guest, the 7.1.5 + raw video/ISO path in a live take. diff --git a/docs/zoom-windows-sdk-packaging.md b/docs/zoom-windows-sdk-packaging.md index c70b678c0..95ffbe602 100644 --- a/docs/zoom-windows-sdk-packaging.md +++ b/docs/zoom-windows-sdk-packaging.md @@ -4,12 +4,12 @@ CoreVideo Pro's Windows helper must package the Zoom Meeting SDK beside the nati Verified local SDK archive: -- Source: `C:\Users\walla\Downloads\zoom-sdk-windows-7.0.5.39292.zip` -- Version: `7.0.5.39292` +- Source: `C:\Users\walla\Downloads\zoom-sdk-windows-7.1.5.43953.zip` +- Version: `7.1.5.43953` - Target architecture: `x64` -- Runtime source: `zoom-sdk-windows-7.0.5.39292/x64/bin/*` -- Headers: `zoom-sdk-windows-7.0.5.39292/x64/h/**/*` -- Import library: `zoom-sdk-windows-7.0.5.39292/x64/lib/sdk.lib` +- Runtime source: `zoom-sdk-windows-7.1.5.43953/x64/bin/*` +- Headers: `zoom-sdk-windows-7.1.5.43953/x64/h/**/*` +- Import library: `zoom-sdk-windows-7.1.5.43953/x64/lib/sdk.lib` - Helper runtime target: `native-core/zoom-runtime/windows/x64` Required files for the first real media-path spike: @@ -52,7 +52,7 @@ Embedded OAuth identity (same broker as [CoreVideo plugin](https://github.com/ia Stage the SDK beside the helper target: ```powershell -$env:ZOOM_SDK_DIR = "C:\path\to\zoom-sdk-windows-7.0.5.39292\x64" +$env:ZOOM_SDK_DIR = "C:\path\to\zoom-sdk-windows-7.1.5.43953\x64" .\scripts\stage-zoom-sdk.ps1 ``` diff --git a/native-shell/CoreVideoPro.MediaCore.Tests/ZoomCommsProtocolTests.cs b/native-shell/CoreVideoPro.MediaCore.Tests/ZoomCommsProtocolTests.cs new file mode 100644 index 000000000..e6e86a8d3 --- /dev/null +++ b/native-shell/CoreVideoPro.MediaCore.Tests/ZoomCommsProtocolTests.cs @@ -0,0 +1,58 @@ +using System.Text.Json; +using CoreVideoPro.MediaCore.Services; +using Xunit; + +namespace CoreVideoPro.MediaCore.Tests; + +/// Comms wire format between the shell and the core (docs/reference/comms-talkback.md). +public sealed class ZoomCommsProtocolTests +{ + [Fact] + public void VerbsAreEdgesPlusReleaseAll() + { + Assert.Equal( + ["comms-assign", "comms-gain", "comms-key", "comms-latch", "comms-mic", "comms-private", "comms-release-all", + "comms-sidetone", "comms-sidetone-out", "comms-tone"], + ZoomCommsProtocol.Verbs.Order(StringComparer.Ordinal)); + } + + [Fact] + public void ARefusalCarriesTheCoreMessageVerbatim() + { + using var refused = JsonDocument.Parse( + """{"id":"7","ok":false,"error":{"code":"comms-refused","message":"Join a meeting first."}}"""); + var r = ZoomCommsProtocol.ParseReply(refused); + Assert.False(r.Ok); + Assert.Equal("Join a meeting first.", r.Error); + + using var accepted = JsonDocument.Parse("""{"id":"8","ok":true,"type":"comms-key"}"""); + Assert.True(ZoomCommsProtocol.ParseReply(accepted).Ok); + } + + [Fact] + public void AStateFactWithAnIdIsAResponseNotAFact() + { + Assert.False(ZoomCommsProtocol.TryParseStateLine("""{"id":"1","type":"zoom-comms-state","comms":null}""", out _)); + Assert.False(ZoomCommsProtocol.TryParseStateLine("""{"type":"zoom-comms-state","comms":7}""", out _)); + Assert.True(ZoomCommsProtocol.TryParseStateLine("""{"type":"zoom-comms-state","comms":{"available":false,"reason":"Not in a meeting."}}""", out var u)); + Assert.False(u.State!.Available); + Assert.Equal("Not in a meeting.", u.State.Reason); + Assert.Empty(u.State.Channels); + Assert.Null(u.State.Mic.PeakDbfs); // nothing unmeasured is invented + Assert.False(u.State.Private); + Assert.True(ZoomCommsProtocol.TryParseStateLine("""{"type":"zoom-comms-state","comms":{"available":true,"private":true}}""", out var p)); + Assert.True(p.State!.Private); + } + + [Theory] + [InlineData("comms-key")] + [InlineData("comms-latch")] + [InlineData("comms-release-all")] + [InlineData("comms-private")] + public void KeyUpsUseTheReservedLane(string type) => + Assert.True(EngineHostWire.IsUrgent(JsonSerializer.SerializeToElement(new { type }))); + + [Fact] + public void CommsVerbsAreMutationsNotObservations() => + Assert.False(EngineHostWire.IsObservation(JsonSerializer.SerializeToElement(new { type = "comms-key" }))); +} diff --git a/native-shell/CoreVideoPro.MediaCore/Models/ZoomCommsModels.cs b/native-shell/CoreVideoPro.MediaCore/Models/ZoomCommsModels.cs new file mode 100644 index 000000000..fb703cd82 --- /dev/null +++ b/native-shell/CoreVideoPro.MediaCore/Models/ZoomCommsModels.cs @@ -0,0 +1,44 @@ +namespace CoreVideoPro.MediaCore.Models; + +// Comms (Zoom talkback, docs/reference/comms-talkback.md): the core's +// "zoom-comms-state" fact, i.e. the Zoom engine's last comms_state re-keyed to +// camelCase. Same shape as CoreVideo Edge's `comms` state object (EdgeComms), +// so the two apps read one contract. Every value is what the engine reported; +// nothing here is inferred by the shell. +public sealed record ZoomCommsChannel(int Slot, string? Label, int Listeners, bool Ready, bool Keyed, bool Latched, + string? Room, int ReachOk, int ReachDark); + +public sealed record ZoomCommsPerson(uint ParticipantId, string Name, bool Talkback, string? Room, IReadOnlyList Channels); + +public sealed record ZoomCommsMic(string? Device, IReadOnlyList Devices, double? PeakDbfs, int GainDb, bool Sidetone, + string? SidetoneDevice, IReadOnlyList SidetoneDevices, bool Tone); + +public sealed record ZoomCommsState( + bool Available, + string? Reason, + bool LampLink, + bool LampMeetingMic, + bool LampChannel, + bool LampTx, + string? Room, + IReadOnlyList Channels, + IReadOnlyList Roster, + ZoomCommsMic Mic, + long FramesSent, + long ChannelSends, + long Underruns, + long SendErrors, + IReadOnlyList Log, + // Private comms applied by the engine: the meeting mic is the silent + // virtual mic (Pro's toggle, or a capture-only station, always private). + bool Private = false); + +/// One "zoom-comms-state" fact. null = the core dropped +/// the report (Leave, engine gone): the shell must stop showing any key as live. +public sealed record ZoomCommsUpdate(ZoomCommsState? State); + +/// The core's answer to one comms-* request. is operator text. +public sealed record ZoomCommsCommandResult(bool Ok, string? Error) +{ + public static readonly ZoomCommsCommandResult Success = new(true, null); +} diff --git a/native-shell/CoreVideoPro.MediaCore/Services/EngineHostProtocol.cs b/native-shell/CoreVideoPro.MediaCore/Services/EngineHostProtocol.cs index 32bb88802..6779a7109 100644 --- a/native-shell/CoreVideoPro.MediaCore/Services/EngineHostProtocol.cs +++ b/native-shell/CoreVideoPro.MediaCore/Services/EngineHostProtocol.cs @@ -53,7 +53,9 @@ public static async Task WriteAsync(Stream stream, T message, CancellationTok } public static bool IsUrgentType(string? type) => type is "stop-recording-session" or "stop-encoder-session" or - "stop-program-output" or "zoom-leave" or "zoom-cancel" or "zoom-stop-capture"; + "stop-program-output" or "zoom-leave" or "zoom-cancel" or "zoom-stop-capture" or + // Comms talk keys: a key-up must never queue behind ordinary work. + "comms-key" or "comms-latch" or "comms-release-all" or "comms-private"; // Never let a mixed batch smuggle ordinary work into reserved capacity. public static bool IsUrgent(JsonElement payload) diff --git a/native-shell/CoreVideoPro.MediaCore/Services/MediaCoreBridgeService.cs b/native-shell/CoreVideoPro.MediaCore/Services/MediaCoreBridgeService.cs index 7e570bbbf..ffed6b97f 100644 --- a/native-shell/CoreVideoPro.MediaCore/Services/MediaCoreBridgeService.cs +++ b/native-shell/CoreVideoPro.MediaCore/Services/MediaCoreBridgeService.cs @@ -2,7 +2,7 @@ namespace CoreVideoPro.MediaCore.Services; -public sealed class MediaCoreBridgeService : IMediaCoreBridge, IGradePreviewTransport, ISourcePreviewTransport +public sealed class MediaCoreBridgeService : IMediaCoreBridge, IGradePreviewTransport, ISourcePreviewTransport, IZoomCommsTransport { private readonly MediaCoreSupervisor _supervisor; private readonly MediaCoreSyncScheduler _syncScheduler = new(); @@ -54,6 +54,8 @@ public MediaCoreBridgeService(MediaCoreSupervisor? supervisor = null, TimeProvid _supervisor.StatusChanged += status => StatusChanged?.Invoke(status); _supervisor.ProfileChanged += profile => { + // A new core has no talkback report yet: never let the old one keep a key lit. + ZoomCommsStateReceived?.Invoke(new ZoomCommsUpdate(null)); ReleaseRosterBarrierForRespawnedCore(); ProfileChanged?.Invoke(profile); }; @@ -62,6 +64,7 @@ public MediaCoreBridgeService(MediaCoreSupervisor? supervisor = null, TimeProvid _supervisor.ZoomRosterFactReceived += QueueRosterFact; _supervisor.ZoomVideoFrameReceived += frame => ZoomVideoFrameReceived?.Invoke(frame); _supervisor.ZoomSourceFormatReceived += fact => ZoomSourceFormatReceived?.Invoke(fact); + _supervisor.ZoomCommsStateReceived += update => ZoomCommsStateReceived?.Invoke(update); _supervisor.ProgramFramePreviewReceived += preview => ProgramFramePreviewReceived?.Invoke(preview); _supervisor.ProgramSharedTextureReceived += texture => ProgramSharedTextureReceived?.Invoke(texture); _supervisor.PreviewSharedTextureReceived += texture => PreviewSharedTextureReceived?.Invoke(texture); @@ -80,6 +83,9 @@ public MediaCoreBridgeService(MediaCoreSupervisor? supervisor = null, TimeProvid public event Action? OutputLifecycleChanged; public event Action? ZoomVideoFrameReceived; public event Action? ZoomSourceFormatReceived; + public event Action? ZoomCommsStateReceived; + public Task SendCommsAsync(string verb, IReadOnlyDictionary payload, + CancellationToken cancellationToken = default) => _supervisor.SendCommsAsync(verb, payload, cancellationToken); public event Action? ProgramFramePreviewReceived; public event Action? ProgramSharedTextureReceived; public event Action? PreviewSharedTextureReceived; diff --git a/native-shell/CoreVideoPro.MediaCore/Services/MediaCoreSupervisor.cs b/native-shell/CoreVideoPro.MediaCore/Services/MediaCoreSupervisor.cs index d68f74387..1383889d1 100644 --- a/native-shell/CoreVideoPro.MediaCore/Services/MediaCoreSupervisor.cs +++ b/native-shell/CoreVideoPro.MediaCore/Services/MediaCoreSupervisor.cs @@ -97,6 +97,8 @@ public MediaCoreSupervisor(MediaCoreSupervisorOptions? options = null) public event Action? ZoomRosterFactReceived; public event Action? ZoomVideoFrameReceived; public event Action? ZoomSourceFormatReceived; + /// Comms (Zoom talkback): the core's latest-wins comms report (State null = dropped). + public event Action? ZoomCommsStateReceived; public event Action? ProgramFramePreviewReceived; public event Action? ProgramSharedTextureReceived; public event Action? PreviewSharedTextureReceived; @@ -663,6 +665,27 @@ public async Task LeaveZoomAsync(CancellationToken cancellat /// meeting. The returned snapshot carries the engine-reported /// . /// + /// + /// Comms (Zoom talkback): one comms-* request. The core answers it without + /// coreMutex (the engine runtime validates and queues the engine line), so a + /// talk key never waits on a production sync. A refusal carries operator text. + /// + public async Task SendCommsAsync(string verb, IReadOnlyDictionary payload, + CancellationToken cancellationToken = default) + { + if (!ZoomCommsProtocol.Verbs.Contains(verb)) + throw new ArgumentException($"Not a Comms verb: {verb}", nameof(verb)); + var response = await SendAsync( + new Dictionary + { + ["id"] = NextId(), + ["type"] = verb, + ["payload"] = new Dictionary(payload) + }, + cancellationToken, timeoutMs: 5000).ConfigureAwait(false); + using (response) return ZoomCommsProtocol.ParseReply(response); + } + public async Task StopZoomCaptureAsync(CancellationToken cancellationToken = default) { var response = await SendAsync( @@ -1285,6 +1308,12 @@ private async Task ReadStdoutLoopAsync(Process process) continue; } + if (ZoomCommsProtocol.TryParseStateLine(line, out var commsUpdate)) + { + try { ZoomCommsStateReceived?.Invoke(commsUpdate); } catch { } + continue; + } + var sourceFormat = CoreProtocolParser.TryParseZoomSourceFormatFact(line); if (sourceFormat is not null) { diff --git a/native-shell/CoreVideoPro.MediaCore/Services/ZoomCommsProtocol.cs b/native-shell/CoreVideoPro.MediaCore/Services/ZoomCommsProtocol.cs new file mode 100644 index 000000000..4bc3edbfd --- /dev/null +++ b/native-shell/CoreVideoPro.MediaCore/Services/ZoomCommsProtocol.cs @@ -0,0 +1,127 @@ +using System.Text.Json; +using CoreVideoPro.MediaCore.Models; + +namespace CoreVideoPro.MediaCore.Services; + +/// +/// Comms (Zoom talkback) wire format between the shell and the media core +/// (docs/reference/comms-talkback.md). Requests are ordinary core requests whose +/// type is one of with the arguments in payload; +/// the core answers ok or comms-refused with operator text. State arrives +/// unsolicited as {"type":"zoom-comms-state","comms":{...}|null}. +/// The verbs and the state object are CoreVideo Edge's (docs/PROTOCOL.md "Comms"). +/// +public static class ZoomCommsProtocol +{ + public static readonly IReadOnlySet Verbs = new HashSet(StringComparer.Ordinal) + { + "comms-key", "comms-latch", "comms-assign", "comms-mic", + "comms-sidetone-out", "comms-sidetone", "comms-tone", "comms-gain", "comms-release-all", "comms-private", + }; + + public static bool TryParseStateLine(string line, out ZoomCommsUpdate update) + { + update = new ZoomCommsUpdate(null); + // Cheap reject first: the same stdout carries large thumbnails. + if (line.Length > 1_048_576 || !line.Contains("\"zoom-comms-state\"", StringComparison.Ordinal)) return false; + try + { + using var document = JsonDocument.Parse(line); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object || root.TryGetProperty("id", out _) || + !root.TryGetProperty("type", out var type) || type.ValueKind != JsonValueKind.String || + type.GetString() != "zoom-comms-state" || !root.TryGetProperty("comms", out var comms)) + return false; + if (comms.ValueKind == JsonValueKind.Null) return true; + if (comms.ValueKind != JsonValueKind.Object) return false; + update = new ZoomCommsUpdate(ParseState(comms)); + return true; + } + catch (Exception error) when (error is JsonException or InvalidOperationException or FormatException) + { + return false; + } + } + + public static ZoomCommsState ParseState(JsonElement cm) + { + var lamps = Obj(cm, "lamps"); + var mic = Obj(cm, "mic"); + var stats = Obj(cm, "stats"); + var channels = cm.TryGetProperty("channels", out var ca) && ca.ValueKind == JsonValueKind.Array + ? ca.EnumerateArray().Where(c => c.ValueKind == JsonValueKind.Object).Select(c => new ZoomCommsChannel( + (int)L(c, "slot"), SN(c, "label"), (int)L(c, "listeners"), B(c, "ready"), B(c, "keyed"), B(c, "latched"), + SN(c, "room"), (int)L(c, "reachOk"), (int)L(c, "reachDark"))).ToList() + : new List(); + var roster = cm.TryGetProperty("roster", out var ra) && ra.ValueKind == JsonValueKind.Array + ? ra.EnumerateArray().Where(r => r.ValueKind == JsonValueKind.Object).Select(r => new ZoomCommsPerson( + (uint)Math.Max(0, L(r, "participantId")), SN(r, "name") ?? "", B(r, "talkback"), SN(r, "room"), + r.TryGetProperty("channels", out var rc) && rc.ValueKind == JsonValueKind.Array + ? rc.EnumerateArray().Where(x => x.ValueKind == JsonValueKind.Number).Select(x => (int)x.GetDouble()).ToList() + : new List())).ToList() + : new List(); + var hasMic = mic.ValueKind == JsonValueKind.Object; + var hasStats = stats.ValueKind == JsonValueKind.Object; + return new ZoomCommsState( + B(cm, "available"), SN(cm, "reason"), + B(lamps, "link"), B(lamps, "meetingMic"), B(lamps, "channel"), B(lamps, "tx"), + SN(cm, "room"), channels, roster, + new ZoomCommsMic( + hasMic ? SN(mic, "device") : null, hasMic ? Strings(mic, "devices") : [], + hasMic ? DN(mic, "peakDbfs") : null, hasMic ? (int)L(mic, "gainDb") : 0, B(mic, "sidetone"), + hasMic ? SN(mic, "sidetoneDevice") : null, hasMic ? Strings(mic, "sidetoneDevices") : [], B(mic, "tone")), + hasStats ? L(stats, "framesSent") : 0, hasStats ? L(stats, "channelSends") : 0, + hasStats ? L(stats, "underruns") : 0, hasStats ? L(stats, "sendErrors") : 0, + Strings(cm, "log"), + B(cm, "private")); + } + + /// The core's reply to a comms-* request. + public static ZoomCommsCommandResult ParseReply(JsonDocument response) + { + var root = response.RootElement; + if (root.TryGetProperty("ok", out var ok) && ok.ValueKind == JsonValueKind.True) return ZoomCommsCommandResult.Success; + if (root.TryGetProperty("error", out var error)) + { + if (error.ValueKind == JsonValueKind.Object && error.TryGetProperty("message", out var message) && + message.ValueKind == JsonValueKind.String && message.GetString() is { Length: > 0 } text) + return new ZoomCommsCommandResult(false, text); + if (error.ValueKind == JsonValueKind.String && error.GetString() is { Length: > 0 } plain) + return new ZoomCommsCommandResult(false, plain); + } + return new ZoomCommsCommandResult(false, "The media core refused the Comms request."); + } + + private static JsonElement Obj(JsonElement e, string name) => + e.ValueKind == JsonValueKind.Object && e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Object ? v : default; + + private static bool B(JsonElement e, string name) => + e.ValueKind == JsonValueKind.Object && e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.True; + + private static long L(JsonElement e, string name) => + e.ValueKind == JsonValueKind.Object && e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number + ? (long)v.GetDouble() : 0; + + private static double? DN(JsonElement e, string name) => + e.ValueKind == JsonValueKind.Object && e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number + ? v.GetDouble() : null; + + private static string? SN(JsonElement e, string name) => + e.ValueKind == JsonValueKind.Object && e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String + ? v.GetString() : null; + + private static IReadOnlyList Strings(JsonElement e, string name) => + e.ValueKind == JsonValueKind.Object && e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Array + ? v.EnumerateArray().Where(x => x.ValueKind == JsonValueKind.String).Select(x => x.GetString()!).ToList() + : []; +} + +/// The shell's seam for Comms: the state stream and the verbs. Implemented by +/// ; tests supply a fake. +public interface IZoomCommsTransport +{ + event Action? ZoomCommsStateReceived; + + Task SendCommsAsync(string verb, IReadOnlyDictionary payload, + CancellationToken cancellationToken = default); +} diff --git a/native-shell/CoreVideoPro.WinUI.Tests/CommsViewModelTests.cs b/native-shell/CoreVideoPro.WinUI.Tests/CommsViewModelTests.cs new file mode 100644 index 000000000..db9347d2b --- /dev/null +++ b/native-shell/CoreVideoPro.WinUI.Tests/CommsViewModelTests.cs @@ -0,0 +1,483 @@ +using System.Text.Json; +using CoreVideoPro.MediaCore.Models; +using CoreVideoPro.MediaCore.Services; +using CoreVideoPro.WinUI.Converters; +using CoreVideoPro.WinUI.ViewModels.Comms; +using Xunit; + +namespace CoreVideoPro.WinUI.Tests; + +/// +/// The Comms page view model (docs/reference/comms-talkback.md): the JSON each action sends, +/// the screen states, and the structure gate that keeps a held key alive across updates. +/// Mirrors CoreVideo Edge's CommsViewModelTests so the two apps keep one behaviour. +/// +public sealed class CommsViewModelTests +{ + private sealed class FakeTransport : IZoomCommsTransport + { + public event Action? ZoomCommsStateReceived; + public List<(string Verb, string Json)> Sent { get; } = new(); + public ZoomCommsCommandResult Reply { get; set; } = ZoomCommsCommandResult.Success; + + public Task SendCommsAsync(string verb, IReadOnlyDictionary payload, + CancellationToken cancellationToken = default) + { + Sent.Add((verb, JsonSerializer.Serialize(payload.OrderBy(p => p.Key).ToDictionary(p => p.Key, p => p.Value)))); + return Task.FromResult(Reply); + } + + public void Raise(ZoomCommsUpdate update) => ZoomCommsStateReceived?.Invoke(update); + } + + private static ZoomCommsState Parse(string json) + { + using var doc = JsonDocument.Parse(json); + return ZoomCommsProtocol.ParseState(doc.RootElement); + } + + private static string Channel(int slot, string? label = null, int listeners = 0, bool ready = true, bool keyed = false, + bool latched = false, string? room = null, int reachOk = -1, int reachDark = 0) => + $$"""{"slot":{{slot}},"label":{{(label is null ? "null" : $"\"{label}\"")}},"listeners":{{listeners}},"ready":{{(ready ? "true" : "false")}},"keyed":{{(keyed ? "true" : "false")}},"latched":{{(latched ? "true" : "false")}},"room":{{(room is null ? "null" : $"\"{room}\"")}},"reachOk":{{(reachOk < 0 ? listeners : reachOk)}},"reachDark":{{reachDark}}}"""; + + private static string Desk(string channels, string roster = "[]", string extra = "") => + $$"""{"available":true,"reason":null,"lamps":{"link":true,"meetingMic":true,"channel":true,"tx":false},"room":null,"channels":[{{channels}}],"roster":{{roster}},"mic":{"device":"Mic A","devices":["Mic A","Mic B"],"peakDbfs":-24,"gainDb":0,"sidetone":true,"sidetoneDevice":"Phones","sidetoneDevices":["Phones","Speakers"],"tone":false},"stats":{"framesSent":10,"channelSends":4,"underruns":0,"sendErrors":0},"log":["a","b"]{{extra}}}"""; + + private static (CommsViewModel Vm, FakeTransport Transport) Build(string? comms = null, bool inMeeting = true) + { + var transport = new FakeTransport(); + var vm = new CommsViewModel(transport); + vm.ApplyMeeting(inMeeting, "Meeting 123 456 7890"); + if (comms is not null) vm.ApplyComms(new ZoomCommsUpdate(Parse(comms))); + return (vm, transport); + } + + private static readonly string TwoPeople = Desk(Channel(0, "Ana", 1) + "," + Channel(1, "Ben", 1) + "," + Channel(2)); + + [Fact] + public void ParsesTheCoreCommsFact() + { + Assert.True(ZoomCommsProtocol.TryParseStateLine( + """{"type":"zoom-comms-state","comms":""" + TwoPeople + "}", out var update)); + Assert.NotNull(update.State); + Assert.Equal(3, update.State!.Channels.Count); + Assert.Equal("Ana", update.State.Channels[0].Label); + Assert.True(update.State.LampMeetingMic); + Assert.Equal(-24, update.State.Mic.PeakDbfs); + Assert.Equal(["Phones", "Speakers"], update.State.Mic.SidetoneDevices); + + Assert.True(ZoomCommsProtocol.TryParseStateLine("""{"type":"zoom-comms-state","comms":null}""", out var cleared)); + Assert.Null(cleared.State); + Assert.False(ZoomCommsProtocol.TryParseStateLine("""{"id":"7","type":"zoom-comms-state","comms":null}""", out _)); + Assert.False(ZoomCommsProtocol.TryParseStateLine("""{"type":"zoom-source-format"}""", out _)); + } + + [Fact] + public void ScreenStatesAreExplicit() + { + var (vm, _) = Build(inMeeting: false); + Assert.True(vm.NotInMeeting); + Assert.False(vm.DeskVisible); + Assert.Equal("Not in a meeting", vm.RailText); + + vm.ApplyMeeting(true, "Meeting 123 456 7890"); + Assert.True(vm.Waiting); // in the meeting, no report yet: waiting, not unavailable + Assert.False(vm.Unavailable); + + vm.ApplyComms(new ZoomCommsUpdate(Parse( + """{"available":false,"reason":"Make this account host or co-host to bring talkback up.","lamps":{"link":true}}"""))); + Assert.True(vm.Unavailable); + Assert.True(vm.HostHintVisible); + Assert.True(vm.LampLink); // lamps are machine truth even when refused + Assert.False(vm.LampChannel); + Assert.Equal("Make this account host or co-host to bring talkback up.", vm.UnavailableText); + + vm.ApplyComms(new ZoomCommsUpdate(Parse(TwoPeople))); + Assert.True(vm.DeskVisible); + Assert.True(vm.GridVisible); + Assert.Equal("Meeting 123 456 7890", vm.RailText); + + vm.ApplyComms(new ZoomCommsUpdate(null)); // the core dropped the report + Assert.True(vm.Waiting); + Assert.Empty(vm.Cells); + } + + [Fact] + public void OnlyOccupiedNamedOrKeyedChannelsAreCells() + { + var (vm, _) = Build(Desk(Channel(0, "Ana", 1) + "," + Channel(1) + "," + Channel(2, null, 0, keyed: true) + "," + Channel(3))); + Assert.Equal([0, 2], vm.Cells.Select(c => c.Slot)); + Assert.Equal("CH 3", vm.Cells[1].Name); + } + + [Fact] + public async Task HoldingACellKeysItWithALeaseAndReleasingUnkeys() + { + var (vm, t) = Build(TwoPeople); + await vm.PressAsync(vm.Cells[0]); + await vm.PressAsync(vm.Cells[0]); // a second press while held sends nothing + await vm.ReleaseAsync(vm.Cells[0]); + await vm.ReleaseAsync(vm.Cells[0]); // a second release sends nothing + Assert.Equal( + [("comms-key", """{"leaseMs":1500,"on":true,"slot":0}"""), ("comms-key", """{"on":false,"slot":0}""")], + t.Sent); + } + + [Fact] + public void OnAirComesFromTheEngineOnly() + { + var (vm, _) = Build(TwoPeople); + _ = vm.PressAsync(vm.Cells[0]); + Assert.Equal(CommsTone.Ready, vm.Cells[0].Tone); // pressed, but the engine has not said keyed + Assert.Equal("ready", vm.Cells[0].StateText); + vm.ApplyComms(new ZoomCommsUpdate(Parse(Desk(Channel(0, "Ana", 1, keyed: true) + "," + Channel(1, "Ben", 1))))); + Assert.Equal(CommsTone.Hot, vm.Cells[0].Tone); + Assert.Equal("on air", vm.Cells[0].StateText); + vm.ApplyComms(new ZoomCommsUpdate(Parse(Desk(Channel(0, "Ana", 0, keyed: true) + "," + Channel(1, "Ben", 1))))); + Assert.Equal(CommsTone.Armed, vm.Cells[0].Tone); // keyed, nobody hearing: never hot + Assert.Equal("nobody in channel", vm.Cells[0].StateText); + } + + [Fact] + public async Task LatchModeTogglesInsteadOfHolding() + { + var (vm, t) = Build(TwoPeople); + vm.ToggleLatchModeCommand.Execute(null); + await vm.PressAsync(vm.Cells[1]); + await vm.ReleaseAsync(vm.Cells[1]); // release does nothing in latch mode + vm.ApplyComms(new ZoomCommsUpdate(Parse(Desk(Channel(0, "Ana", 1) + "," + Channel(1, "Ben", 1, keyed: true, latched: true))))); + Assert.Equal("on air · latched", vm.Cells[1].StateText); + await vm.PressAsync(vm.Cells[1]); + Assert.Equal( + [("comms-latch", """{"on":true,"slot":1}"""), ("comms-latch", """{"on":false,"slot":1}""")], + t.Sent); + } + + [Fact] + public async Task AllCallKeysEveryChannelAndLatchesAll() + { + var (vm, t) = Build(TwoPeople); + await vm.AllPressAsync(); + await vm.AllPressAsync(); + await vm.AllReleaseAsync(); + vm.LatchMode = true; + await vm.AllPressAsync(); + Assert.Equal( + [ + ("comms-key", """{"all":true,"leaseMs":1500,"on":true}"""), + ("comms-key", """{"all":true,"on":false}"""), + ("comms-latch", """{"all":true,"on":true}"""), + ], + t.Sent); + vm.ApplyComms(new ZoomCommsUpdate(Parse(Desk(Channel(0, "Ana", 1, keyed: true) + "," + Channel(1, "Ben", 0, keyed: true))))); + Assert.True(vm.AllHot); + Assert.Equal("ALL CALL · ON AIR", vm.AllText); + } + + [Fact] + public void AnUpdateThatKeepsTheSameCellsNeverRebuildsThem() + { + var (vm, _) = Build(TwoPeople); + var first = vm.Cells[0]; + var version = vm.CellStructureVersion; + var changes = 0; + vm.Cells.CollectionChanged += (_, _) => changes++; + _ = vm.PressAsync(first); + // Level, keyed and listener changes are values, not structure. + vm.ApplyComms(new ZoomCommsUpdate(Parse(Desk(Channel(0, "Ana", 1, keyed: true) + "," + Channel(1, "Ben", 1) + "," + Channel(2))))); + vm.ApplyComms(new ZoomCommsUpdate(Parse(Desk(Channel(0, "Ana", 0, keyed: true) + "," + Channel(1, "Ben", 1) + "," + Channel(2))))); + Assert.Same(first, vm.Cells[0]); + Assert.Equal(version, vm.CellStructureVersion); + Assert.Equal(0, changes); + Assert.True(first.Held); + } + + [Fact] + public void AStructureChangeKeepsSurvivingCellsAndTheirHold() + { + var (vm, _) = Build(TwoPeople); + var ben = vm.Cells[1]; + _ = vm.PressAsync(ben); + var resets = 0; + vm.Cells.CollectionChanged += (_, e) => { if (e.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Reset) resets++; }; + // Ana leaves channel 0: the set changes, Ben's key must survive with its hold. + vm.ApplyComms(new ZoomCommsUpdate(Parse(Desk(Channel(0) + "," + Channel(1, "Ben", 1) + "," + Channel(2))))); + Assert.Single(vm.Cells); + Assert.Same(ben, vm.Cells[0]); + Assert.True(ben.Held); + Assert.Equal(0, resets); + } + + [Fact] + public async Task ReleaseAllReleasesEveryHeldKey() + { + var (vm, t) = Build(TwoPeople); + await vm.PressAsync(vm.Cells[0]); + await vm.AllPressAsync(); + t.Sent.Clear(); + await vm.ReleaseAllAsync(); + Assert.Equal( + [("comms-key", """{"on":false,"slot":0}"""), ("comms-key", """{"all":true,"on":false}""")], + t.Sent); + Assert.False(vm.AnyHeld); + } + + [Fact] + public async Task AHeldKeyRenewsItsLeaseAndAReleasedOneDoesNot() + { + var (vm, t) = Build(TwoPeople); + await vm.PressAsync(vm.Cells[1]); + t.Sent.Clear(); + await vm.RenewLeasesAsync(); + Assert.Equal([("comms-key", """{"leaseMs":1500,"on":true,"slot":1}""")], t.Sent); + await vm.ReleaseAsync(vm.Cells[1]); + t.Sent.Clear(); + await vm.RenewLeasesAsync(); + Assert.Empty(t.Sent); + } + + [Fact] + public async Task LatchedChannelsAreNotLeased() + { + var (vm, t) = Build(TwoPeople); + vm.LatchMode = true; + await vm.PressAsync(vm.Cells[0]); + t.Sent.Clear(); + await vm.RenewLeasesAsync(); + Assert.Empty(t.Sent); + Assert.False(vm.AnyHeld); + } + + [Fact] + public async Task ShutdownSendsReleaseAll() + { + var (vm, t) = Build(TwoPeople); + await vm.PressAsync(vm.Cells[0]); + t.Sent.Clear(); + await vm.SendReleaseAllAsync(); + Assert.Equal([("comms-release-all", "{}")], t.Sent); + Assert.False(vm.AnyHeld); + } + + [Fact] + public async Task LeavingTheMeetingForgetsHeldKeysLocally() + { + var (vm, t) = Build(TwoPeople); + await vm.PressAsync(vm.Cells[0]); + vm.ApplyMeeting(false, null); + Assert.False(vm.AnyHeld); + Assert.True(vm.NotInMeeting); + t.Sent.Clear(); + await vm.RenewLeasesAsync(); + Assert.Empty(t.Sent); + } + + [Fact] + public void SomeoneInAnotherRoomCannotBeKeyed() + { + var (vm, t) = Build(Desk(Channel(0, "Ana", 1, room: "Green room") + "," + Channel(1, "Ben", 1))); + var ana = vm.Cells[0]; + Assert.True(ana.Off); + Assert.Equal(CommsTone.Off, ana.Tone); + Assert.Equal("in Green room", ana.StateText); + _ = vm.PressAsync(ana); + Assert.Empty(t.Sent); + Assert.Same(vm.Cells[1], vm.CellForDigit(1)); // digits skip unreachable cells + Assert.Equal("1", vm.Cells[1].KeyHint); + } + + [Fact] + public void AGroupSaysHowManyListenAndWhoIsElsewhere() + { + var (vm, _) = Build(Desk(Channel(4, null, 3, reachOk: 3, reachDark: 2))); + Assert.Equal("3 listening · 2 elsewhere", vm.Cells[0].StateText); + Assert.Equal("CH 5", vm.Cells[0].Name); + } + + [Fact] + public async Task EditTalentChipsAssignAndExplainWebClients() + { + var roster = """[{"participantId":201,"name":"Ana","talkback":true,"room":null,"channels":[0]},{"participantId":203,"name":"Wes","talkback":false,"room":null,"channels":[]}]"""; + var (vm, t) = Build(Desk(Channel(0, "Ana", 1) + "," + Channel(1), roster)); + vm.ToggleEditCommand.Execute(null); + Assert.True(vm.EditVisible); + Assert.False(vm.GridVisible); + Assert.Equal(2, vm.People.Count); + var ana = vm.People[0]; + Assert.Equal(2, ana.Chips.Count); // one chip per channel + Assert.True(ana.Chips[0].On); + Assert.Equal("Take off Ana", ana.Chips[0].Tip); + Assert.False(ana.Chips[1].On); + Assert.Equal("Put on CH 2 (empty)", ana.Chips[1].Tip); + var wes = vm.People[1]; + Assert.True(wes.NoTalkback); + Assert.Empty(wes.Chips); + Assert.Contains("web client", CommsViewModel.WebClientWhy); + + await ((CommunityToolkit.Mvvm.Input.IAsyncRelayCommand)ana.Chips[1].Command).ExecuteAsync(null); + await vm.LastSend; + Assert.Equal([("comms-assign", """{"on":true,"participantId":201,"slot":1}""")], t.Sent); + } + + [Fact] + public void PeopleRowsAreRebuiltOnlyWhenTheRosterChanges() + { + var roster = """[{"participantId":201,"name":"Ana","talkback":true,"room":null,"channels":[0]}]"""; + var (vm, _) = Build(Desk(Channel(0, "Ana", 1), roster)); + var row = vm.People[0]; + var version = vm.PeopleStructureVersion; + vm.ApplyComms(new ZoomCommsUpdate(Parse(Desk(Channel(0, "Ana", 1, keyed: true), roster)))); + Assert.Equal(version, vm.PeopleStructureVersion); + Assert.Same(row, vm.People[0]); + } + + [Fact] + public async Task SettingsSendTheirVerbs() + { + var (vm, t) = Build(TwoPeople); + vm.SelectedMic = "Mic B"; + await vm.LastSend; + vm.SelectedOutput = "Speakers"; + await vm.LastSend; + await vm.SidetoneCommand.ExecuteAsync(null); + await vm.ToneCommand.ExecuteAsync(null); + await vm.GainUpCommand.ExecuteAsync(null); + await vm.GainDownCommand.ExecuteAsync(null); + Assert.Equal( + [ + ("comms-mic", """{"device":"Mic B"}"""), + ("comms-sidetone-out", """{"device":"Speakers"}"""), + ("comms-sidetone", """{"on":false}"""), + ("comms-tone", """{"on":true}"""), + ("comms-gain", """{"db":1}"""), + ("comms-gain", """{"db":-1}"""), + ], + t.Sent); + Assert.Equal("Mic A", vm.SelectedMic); // still the engine's until it reports the switch + } + + [Fact] + public void ApplyingStateNeverSendsADeviceChangeOrRebuildsThePickers() + { + var (vm, t) = Build(TwoPeople); + var micChanges = 0; + vm.Mics.CollectionChanged += (_, _) => micChanges++; + vm.ApplyComms(new ZoomCommsUpdate(Parse(TwoPeople))); + vm.ApplyComms(new ZoomCommsUpdate(Parse(TwoPeople.Replace("\"peakDbfs\":-24", "\"peakDbfs\":-12")))); + Assert.Empty(t.Sent); + Assert.Equal(0, micChanges); + Assert.Equal(["Mic A", "Mic B"], vm.Mics); + } + + [Fact] + public async Task ARefusalIsShownVerbatim() + { + var (vm, t) = Build(TwoPeople); + t.Reply = new ZoomCommsCommandResult(false, "Comms is not available: Not in a meeting."); + await vm.PressAsync(vm.Cells[0]); + Assert.Equal("Comms is not available: Not in a meeting.", vm.Error); + Assert.True(vm.ErrorVisible); + t.Reply = ZoomCommsCommandResult.Success; + await vm.ReleaseAsync(vm.Cells[0]); + Assert.Null(vm.Error); + } + + [Fact] + public void MeterLightsOnlyFromAMeasuredLevel() + { + var (vm, _) = Build(TwoPeople); // -24 dBFS: half of 12 segments + Assert.Equal(6, vm.Meter.Count(s => s.Lit)); + vm.ApplyComms(new ZoomCommsUpdate(Parse(TwoPeople.Replace("\"peakDbfs\":-24", "\"peakDbfs\":null")))); + Assert.Equal(0, vm.Meter.Count(s => s.Lit)); + Assert.Equal("Microphone level not measured", vm.MicLevelName); + } + + [Fact] + public void OpsHistoryIsNewestFirstAndSyncedInPlace() + { + var (vm, _) = Build(TwoPeople); + Assert.Equal("b", vm.OpsLine); + Assert.Equal(["b", "a"], vm.OpsLines.Select(e => e.Text)); + var changes = 0; + vm.OpsLines.CollectionChanged += (_, _) => changes++; + vm.ApplyComms(new ZoomCommsUpdate(Parse(TwoPeople))); // same log: nothing moves + Assert.Equal(0, changes); + } + + [Fact] + public async Task PrivateCommsIsOffByDefaultAndNothingIsSent() + { + var (vm, t) = Build(TwoPeople); + Assert.False(vm.PrivatePreference); + Assert.False(vm.PrivateReported); + await vm.LastSend; + Assert.DoesNotContain(t.Sent, s => s.Verb == "comms-private"); + } + + [Fact] + public async Task TogglingPrivateSendsItsVerbAndAsksTheShellToPersist() + { + var (vm, t) = Build(TwoPeople); + var saved = new List(); + vm.PrivatePreferenceChanged += saved.Add; + await vm.TogglePrivateCommand.ExecuteAsync(null); + await vm.TogglePrivateCommand.ExecuteAsync(null); + Assert.Equal([("comms-private", """{"on":true}"""), ("comms-private", """{"on":false}""")], t.Sent); + Assert.Equal([true, false], saved); + } + + [Fact] + public async Task PrivateIsReassertedWhenAReportDisagreesButNeverAtTheReportRate() + { + long now = 10_000; + var transport = new FakeTransport(); + var vm = new CommsViewModel(transport, () => now); + vm.RestorePrivatePreference(true); // saved ON; a fresh engine starts OFF + vm.ApplyMeeting(true, null); + Assert.Empty(transport.Sent); // restoring sends nothing by itself + vm.ApplyComms(new ZoomCommsUpdate(Parse(TwoPeople))); + await vm.LastSend; + Assert.Equal([("comms-private", """{"on":true}""")], transport.Sent); + for (var i = 0; i < 10; i++) { now += 100; vm.ApplyComms(new ZoomCommsUpdate(Parse(TwoPeople))); } + Assert.Single(transport.Sent); // 1 s of disagreeing reports: still one send + now += CommsViewModel.PrivateResendMs; + vm.ApplyComms(new ZoomCommsUpdate(Parse(TwoPeople))); + Assert.Equal(2, transport.Sent.Count); + vm.ApplyComms(new ZoomCommsUpdate(Parse(TwoPeople.Replace("\"log\":", "\"private\":true,\"log\":")))); + now += 10 * CommsViewModel.PrivateResendMs; + vm.ApplyComms(new ZoomCommsUpdate(Parse(TwoPeople.Replace("\"log\":", "\"private\":true,\"log\":")))); + Assert.Equal(2, transport.Sent.Count); // agreed: quiet + Assert.True(vm.PrivateReported); + Assert.EndsWith("· private comms", vm.RailText); + } + + [Fact] + public async Task OutsideAMeetingTheToggleOnlyRecordsTheChoice() + { + var (vm, t) = Build(inMeeting: false); + await vm.TogglePrivateCommand.ExecuteAsync(null); + Assert.True(vm.PrivatePreference); + Assert.Empty(t.Sent); + } + + [Theory] + [InlineData("https://zoom.us/j/1234567890?pwd=abc", "Meeting 123 456 7890")] + [InlineData("https://us02web.zoom.us/j/12345678901", "Meeting 123 4567 8901")] + [InlineData("123 456 7890", "Meeting 123 456 7890")] + [InlineData("", null)] + [InlineData("https://zoom.us/my/someone", null)] + public void MeetingLabel(string url, string? expected) => Assert.Equal(expected, CommsViewModel.MeetingLabelFor(url)); + + [Fact] + public void OnAirUsesProsAirRedAndNoRawColours() + { + Assert.Equal("StudioAirBrush", CommsBrushConverter.ForTone(CommsTone.Hot, "cell-bg")); + Assert.Equal("StudioBackgroundBrush", CommsBrushConverter.ForTone(CommsTone.Hot, "cell-fg")); + Assert.Equal("StudioWarningBrush", CommsBrushConverter.ForTone(CommsTone.Armed, "cell-border")); + Assert.NotEqual("StudioAirBrush", CommsBrushConverter.ForTone(CommsTone.Armed, "cell-bg")); + Assert.Equal("StudioLiveBrush", CommsBrushConverter.ForBool(true, "lamp")); + foreach (var tone in Enum.GetValues()) + foreach (var part in new[] { "cell-bg", "cell-border", "cell-fg", "meter" }) + Assert.StartsWith("Studio", CommsBrushConverter.ForTone(tone, part)); + } +} diff --git a/native-shell/CoreVideoPro.WinUI/Controls/TalkKey.cs b/native-shell/CoreVideoPro.WinUI/Controls/TalkKey.cs new file mode 100644 index 000000000..d16bef120 --- /dev/null +++ b/native-shell/CoreVideoPro.WinUI/Controls/TalkKey.cs @@ -0,0 +1,95 @@ +using System; +using Microsoft.UI.Input; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; +using Windows.System; + +namespace CoreVideoPro.WinUI.Controls; + +/// +/// A talk key (Comms page): press = key down, release (or losing the pointer) = key up. +/// A Button's Click fires on release, which is the opposite of what a talk key needs, so +/// the pointer is handled here (with capture, so dragging off the key still releases it) +/// and Click/Command are never used. Space and Enter are left to bubble: Space is ALL CALL +/// on the Comms page (ZComms model), and a focused key must not swallow it. Same contract +/// as CoreVideo Edge's TalkKey; styled as an ordinary Pro button. +/// +public sealed class TalkKey : Button +{ + private bool _down; + private uint? _pointerId; + + public TalkKey() + { + DefaultStyleKey = typeof(Button); + } + + /// Raised once per press (never repeated while held). + public event EventHandler? Pressed; + + /// Raised once per release, including capture loss and cancel. + public event EventHandler? Released; + + /// True between Pressed and Released. + public bool IsDown => _down; + + protected override void OnPointerPressed(PointerRoutedEventArgs e) + { + e.Handled = true; + if (!IsEnabled) return; + var point = e.GetCurrentPoint(this); + if (e.Pointer.PointerDeviceType == PointerDeviceType.Mouse && !point.Properties.IsLeftButtonPressed) return; + if (_down) return; + if (CapturePointer(e.Pointer)) _pointerId = e.Pointer.PointerId; + _down = true; + Pressed?.Invoke(this, EventArgs.Empty); + } + + protected override void OnPointerReleased(PointerRoutedEventArgs e) + { + e.Handled = true; + Up(); + if (_pointerId == e.Pointer.PointerId) ReleasePointerCapture(e.Pointer); + _pointerId = null; + } + + protected override void OnPointerCaptureLost(PointerRoutedEventArgs e) + { + _pointerId = null; + Up(); + } + + protected override void OnPointerCanceled(PointerRoutedEventArgs e) + { + _pointerId = null; + Up(); + } + + // No pointer-over / pressed visual states: the key's colour is the engine's truth + // (keyed, listeners), never the template's hover or pressed brush. + protected override void OnPointerEntered(PointerRoutedEventArgs e) => e.Handled = true; + protected override void OnPointerExited(PointerRoutedEventArgs e) => e.Handled = true; + + protected override void OnKeyDown(KeyRoutedEventArgs e) + { + if (e.Key is VirtualKey.Space or VirtualKey.Enter) return; // bubble to the page + base.OnKeyDown(e); + } + + protected override void OnKeyUp(KeyRoutedEventArgs e) + { + if (e.Key is VirtualKey.Space or VirtualKey.Enter) return; + base.OnKeyUp(e); + } + + /// Forces the key up (the page releases everything on deactivate). + public void ForceUp() => Up(); + + private void Up() + { + if (!_down) return; + _down = false; + Released?.Invoke(this, EventArgs.Empty); + } +} diff --git a/native-shell/CoreVideoPro.WinUI/Converters/CommsBrushConverter.cs b/native-shell/CoreVideoPro.WinUI/Converters/CommsBrushConverter.cs new file mode 100644 index 000000000..8b0553966 --- /dev/null +++ b/native-shell/CoreVideoPro.WinUI/Converters/CommsBrushConverter.cs @@ -0,0 +1,83 @@ +using CoreVideoPro.WinUI.ViewModels.Comms; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml.Media; + +namespace CoreVideoPro.WinUI.Converters; + +/// +/// Comms page colours, resolved from Pro's own theme tokens (App.xaml / StudioTheme.xaml); +/// no raw colours. On air follows Pro's existing semantics: StudioAirBrush is "air red - +/// on-air, record", so a key that is keyed with a confirmed listener is filled air red +/// (text in StudioBackgroundBrush, ~4.9:1). Keyed-but-nobody-there is the warning amber +/// outline; ready is primary text; idle is secondary text; unreachable is dim. +/// The value is a or a bool (lamps, toggles); the parameter names +/// the part being painted. +/// +public sealed class CommsBrushConverter : IValueConverter +{ + public object? Convert(object value, Type targetType, object parameter, string language) + { + var part = parameter as string ?? ""; + var key = value switch + { + CommsTone tone => ForTone(tone, part), + bool on => ForBool(on, part), + _ => "StudioDimBrush", + }; + return Resolve(key); + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) => + throw new NotSupportedException(); + + /// The token key for a tone and part (pure; unit-tested). + public static string ForTone(CommsTone tone, string part) => part switch + { + "cell-bg" => tone switch + { + CommsTone.Hot => "StudioAirBrush", + CommsTone.Off => "StudioFieldBrush", + _ => "StudioSurfaceBrush", + }, + "cell-border" => tone switch + { + CommsTone.Hot => "StudioAirBrush", + CommsTone.Armed => "StudioWarningBrush", + CommsTone.Off => "StudioLine2Brush", + _ => "StudioBorderBrush", + }, + "cell-fg" => tone switch + { + CommsTone.Hot => "StudioBackgroundBrush", + CommsTone.Armed => "StudioWarningBrush", + CommsTone.Ready => "StudioTextBrush", + CommsTone.Off => "StudioDimBrush", + _ => "StudioTextSecondaryBrush", + }, + "meter" => tone switch + { + CommsTone.Hot => "StudioAirBrush", + CommsTone.Armed => "StudioWarningBrush", + _ => "StudioLiveBrush", + }, + _ => "StudioDimBrush", + }; + + public static string ForBool(bool on, string part) => part switch + { + "lamp" => on ? "StudioLiveBrush" : "StudioDimBrush", + "lamp-text" => on ? "StudioTextBrush" : "StudioDimBrush", + "toggle-border" => on ? "StudioAccentBrush" : "StudioBorderBrush", + "toggle-bg" => on ? "StudioSurfaceRaisedBrush" : "StudioSurfaceBrush", + "chip-bg" => on ? "StudioAccentBrush" : "StudioSurfaceBrush", + "chip-fg" => on ? "StudioBackgroundBrush" : "StudioTextBrush", + "busy-border" => on ? "StudioWarningBrush" : "StudioBorderBrush", + "warn-text" => on ? "StudioWarningBrush" : "StudioTextBrush", + "meter-lit" => on ? "StudioTextBrush" : "StudioDimBrush", + _ => "StudioDimBrush", + }; + + private static Brush? Resolve(string key) => + Application.Current?.Resources.TryGetValue(key, out var brush) == true ? brush as Brush : null; +} diff --git a/native-shell/CoreVideoPro.WinUI/MainWindow.xaml.cs b/native-shell/CoreVideoPro.WinUI/MainWindow.xaml.cs index 2717e1bca..e73c71adf 100644 --- a/native-shell/CoreVideoPro.WinUI/MainWindow.xaml.cs +++ b/native-shell/CoreVideoPro.WinUI/MainWindow.xaml.cs @@ -672,6 +672,8 @@ private void OnWindowActivated(object sender, WindowActivatedEventArgs args) } WindowChromeService.Apply(this, _appWindow); + // Comms talk keys never stay held when the operator switches windows. + if (args.WindowActivationState == WindowActivationState.Deactivated) RootContent.ReleaseCommsKeys(); } private void OnRootContentLoaded(object sender, RoutedEventArgs args) diff --git a/native-shell/CoreVideoPro.WinUI/Models/ProductionModels.cs b/native-shell/CoreVideoPro.WinUI/Models/ProductionModels.cs index 92ac714bb..717673ed0 100644 --- a/native-shell/CoreVideoPro.WinUI/Models/ProductionModels.cs +++ b/native-shell/CoreVideoPro.WinUI/Models/ProductionModels.cs @@ -198,7 +198,8 @@ public enum StudioTab Audio, Media, Automation, - OhgShow + OhgShow, + Comms } // C5a: one slot in the selected channel's visible insert rack. Built-ins run diff --git a/native-shell/CoreVideoPro.WinUI/Program.cs b/native-shell/CoreVideoPro.WinUI/Program.cs index 7137828a4..f661f5876 100644 --- a/native-shell/CoreVideoPro.WinUI/Program.cs +++ b/native-shell/CoreVideoPro.WinUI/Program.cs @@ -21,7 +21,8 @@ public static void Main(string[] args) var destinationProbe = args.Length == 2 && args[0] == "--verify-stream-destinations"; var graphicsProbe = args.Length == 2 && args[0] == "--verify-graphics-workspace"; var studioProbe = args.Length == 2 && args[0] == "--verify-studio-layout"; - var settingsProbe = studioProbe || graphicsProbe || destinationProbe || (args.Length == 2 && args[0] == "--verify-operator-settings"); + var commsProbe = args.Length == 2 && args[0] == "--verify-comms-page"; + var settingsProbe = studioProbe || graphicsProbe || destinationProbe || commsProbe || (args.Length == 2 && args[0] == "--verify-operator-settings"); var gradeProbe = args.Length == 3 && args[0] == "--verify-source-grade"; var meterProbe = args.Length == 3 && args[0] == "--verify-audio-meters"; if (engineHostStudioProbe || engineHostProbe || runtimeProbe || settingsProbe || gradeProbe || meterProbe || inspectProbe || inspectNativeProbe) @@ -61,7 +62,7 @@ public static void Main(string[] args) if (engineHostStudioProbe) new App(null, engineHostStudioConfig: args[1]); else if (inspectNativeProbe) new App(null, inspectProbe: new Services.SourceInspectVideoProbe(args[2], args[1], thumbnailIdentityProbe)); else if (inspectProbe) new App(null,inspectProbe: new Services.SourceInspectVideoProbe(args[1])); - else if (settingsProbe) new App(null, new Services.OperatorSettingsProbe(args[1], destinationProbe, graphicsProbe, studioProbe)); + else if (settingsProbe) new App(null, new Services.OperatorSettingsProbe(args[1], destinationProbe, graphicsProbe, studioProbe, commsProbe)); else if (gradeProbe) new App(null,gradeProbe: new Services.SourceGradeWorkspaceProbe(args[1],args[2])); else if (meterProbe) new App(new Services.AudioMeterStressProbe(probeSeconds, args[2])); else new App(); diff --git a/native-shell/CoreVideoPro.WinUI/Services/CommsPageProbe.cs b/native-shell/CoreVideoPro.WinUI/Services/CommsPageProbe.cs new file mode 100644 index 000000000..8bb7de87c --- /dev/null +++ b/native-shell/CoreVideoPro.WinUI/Services/CommsPageProbe.cs @@ -0,0 +1,146 @@ +using System.Text.Json; +using CoreVideoPro.MediaCore.Models; +using CoreVideoPro.MediaCore.Services; +using CoreVideoPro.WinUI.Controls; +using CoreVideoPro.WinUI.ViewModels.Comms; +using CoreVideoPro.WinUI.Views; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using Windows.Graphics; + +namespace CoreVideoPro.WinUI.Services; + +/// +/// Compiled-XAML check of the Comms page (docs/reference/comms-talkback.md), run by +/// CoreVideoPro.WinUI.exe --verify-comms-page <report.json>. Same rules as the other +/// layout probes: an offscreen, never-activated window; no media core, no meeting, no input +/// injection. Feeds the real fixture states through a recording +/// transport and writes review PNGs next to the report. +/// +internal static class CommsPageProbe +{ + private sealed class RecordingTransport : IZoomCommsTransport + { + public event Action? ZoomCommsStateReceived { add { } remove { } } + public List Sent { get; } = new(); + public Task SendCommsAsync(string verb, IReadOnlyDictionary payload, + CancellationToken cancellationToken = default) + { + Sent.Add(verb + " " + JsonSerializer.Serialize(payload)); + return Task.FromResult(ZoomCommsCommandResult.Success); + } + } + + private const string Desk = """ + {"available":true,"reason":null,"lamps":{"link":true,"meetingMic":true,"channel":true,"tx":true},"room":null, + "channels":[ + {"slot":0,"label":"Ana Lucia Ferreira","listeners":1,"ready":true,"keyed":true,"latched":false,"room":null,"reachOk":1,"reachDark":0}, + {"slot":1,"label":"Ben","listeners":1,"ready":true,"keyed":false,"latched":false,"room":null,"reachOk":1,"reachDark":0}, + {"slot":2,"label":"Chloe","listeners":0,"ready":true,"keyed":true,"latched":true,"room":null,"reachOk":0,"reachDark":0}, + {"slot":3,"label":"Dev","listeners":1,"ready":true,"keyed":false,"latched":false,"room":"Green room","reachOk":0,"reachDark":1}, + {"slot":4,"label":null,"listeners":3,"ready":true,"keyed":false,"latched":false,"room":null,"reachOk":3,"reachDark":1}, + {"slot":5,"label":"Eve","listeners":0,"ready":false,"keyed":false,"latched":false,"room":null,"reachOk":0,"reachDark":0}, + {"slot":6,"label":null,"listeners":0,"ready":true,"keyed":false,"latched":false,"room":null,"reachOk":0,"reachDark":0}], + "roster":[ + {"participantId":201,"name":"Ana Lucia Ferreira","talkback":true,"room":null,"channels":[0,4]}, + {"participantId":202,"name":"Ben","talkback":true,"room":null,"channels":[1,4]}, + {"participantId":205,"name":"Wes","talkback":false,"room":null,"channels":[]}], + "mic":{"device":"Microphone (USB Audio)","devices":["Microphone (USB Audio)","Headset Mic"],"peakDbfs":-18,"gainDb":3, + "sidetone":true,"sidetoneDevice":"Headphones (USB Audio)","sidetoneDevices":["Headphones (USB Audio)","Speakers"],"tone":false}, + "stats":{"framesSent":51200,"channelSends":8800,"underruns":0,"sendErrors":2}, + "log":["21:40:02 requesting 16 talkback channels","21:40:03 station up -- 16 channels","21:40:08 Ana Lucia Ferreira -> CH 1","21:40:15 audio flowing into CH 1"]} + """; + + internal static async Task CheckAsync(Window window, List checks, string reviewDirectory) + { + var transport = new RecordingTransport(); + var vm = new CommsViewModel(transport); + var page = new CommsPage { ViewModel = vm }; + var host = new Border + { + Padding = new Thickness(20), + Background = (Brush)Application.Current.Resources["StudioPanelBrush"], + Child = page, + }; + window.Content = host; + window.AppWindow.MoveAndResize(new RectInt32(-20000, -20000, 1360, 768)); + window.AppWindow.Show(false); + await Eventually(() => page.IsLoaded); + + vm.ApplyMeeting(false, null); + await Settle(); + Require(Visible(page, "Not in a meeting"), "Not-in-meeting state is not shown"); + await ReviewImageWriter.SaveAsync(host, Path.Combine(reviewDirectory, "comms-not-in-meeting.png")); + checks.Add("not in a meeting: explicit state, no desk"); + + vm.ApplyMeeting(true, "Meeting 123 456 7890"); + vm.ApplyComms(Parse("""{"available":false,"reason":"Make this account host or co-host to bring talkback up.","lamps":{"link":true,"meetingMic":false,"channel":false,"tx":false}}""")); + await Settle(); + Require(Visible(page, "Talkback is not up"), "Unavailable state is not shown"); + Require(Visible(page, "Make this account host or co-host to bring talkback up."), "Verbatim reason is not shown"); + await ReviewImageWriter.SaveAsync(host, Path.Combine(reviewDirectory, "comms-not-host.png")); + checks.Add("not host: verbatim reason, host hint, lamps still truthful"); + + vm.ApplyComms(Parse(Desk)); + await Settle(); + var keys = Descendants(page).OfType().Where(k => k.Tag is CommsCellViewModel && k.ActualWidth > 0).ToList(); + Require(keys.Count == 6, $"Expected 6 talk keys (occupied/named/keyed channels), found {keys.Count}"); + Require(keys.All(k => k.ActualWidth >= 190), "A talk key is narrower than the 200 px auto-fill minimum"); + var hot = keys.Single(k => ((CommsCellViewModel)k.Tag).Slot == 0); + Require(ReferenceEquals(hot.Background, Application.Current.Resources["StudioAirBrush"]), "On-air key is not StudioAirBrush"); + var armed = keys.Single(k => ((CommsCellViewModel)k.Tag).Slot == 2); + Require(ReferenceEquals(armed.BorderBrush, Application.Current.Resources["StudioWarningBrush"]), "Armed key is not outlined StudioWarningBrush"); + Require(!ReferenceEquals(armed.Background, Application.Current.Resources["StudioAirBrush"]), "A key with nobody listening shows on air"); + checks.Add("desk: 6 keys in an auto-fill grid; on air = StudioAirBrush, armed = warning outline, never red without a listener"); + await ReviewImageWriter.SaveAsync(host, Path.Combine(reviewDirectory, "comms-desk.png")); + var privateButton = Descendants(page).OfType + + diff --git a/native-shell/CoreVideoPro.WinUI/Views/CommsPage.xaml.cs b/native-shell/CoreVideoPro.WinUI/Views/CommsPage.xaml.cs new file mode 100644 index 000000000..26f66ebf7 --- /dev/null +++ b/native-shell/CoreVideoPro.WinUI/Views/CommsPage.xaml.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.Generic; +using System.Windows.Input; +using CoreVideoPro.WinUI.Controls; +using CoreVideoPro.WinUI.ViewModels.Comms; +using Microsoft.UI.Input; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; +using Windows.System; +using Windows.UI.Core; + +namespace CoreVideoPro.WinUI.Views; + +/// +/// Comms page (docs/reference/comms-talkback.md). Panel-scoped keys (ZComms model): digits +/// 1-9 key the first nine reachable cells, Space = ALL CALL, both momentary (or toggles in +/// Latch). Never global: only while focus is inside this page, never while typing in a +/// field, never with a modifier, key auto-repeat ignored; every held key goes up when focus +/// leaves the page, the window deactivates (MainWindow) or the operator navigates away +/// (StudioViewModel). +/// +public sealed partial class CommsPage : UserControl +{ + private readonly HashSet _down = new(); + + public CommsPage() + { + InitializeComponent(); + PreviewKeyDown += OnPreviewKeyDown; + PreviewKeyUp += OnPreviewKeyUp; + LosingFocus += OnLosingFocus; + } + + public CommsViewModel? ViewModel + { + get => (CommsViewModel?)GetValue(ViewModelProperty); + set => SetValue(ViewModelProperty, value); + } + + public static readonly DependencyProperty ViewModelProperty = + DependencyProperty.Register(nameof(ViewModel), typeof(CommsViewModel), typeof(CommsPage), new PropertyMetadata(null)); + + /// The existing Zoom Leave (Settings page); Comms never leaves on its own. + public ICommand? LeaveCommand + { + get => (ICommand?)GetValue(LeaveCommandProperty); + set => SetValue(LeaveCommandProperty, value); + } + + public static readonly DependencyProperty LeaveCommandProperty = + DependencyProperty.Register(nameof(LeaveCommand), typeof(ICommand), typeof(CommsPage), new PropertyMetadata(null)); + + // x:Bind helpers. + public static string OnOff(bool on) => on ? "on" : "off"; + public static double SegmentOpacity(bool lit) => lit ? 1.0 : 0.18; + + /// Called by the workspace when the Comms tab becomes visible, so the digit and + /// Space keys work without a click first (our own window only; never another app). + public void FocusDesk() => Focus(FocusState.Programmatic); + + // ------------------------------------------------------------ pointer keys + + private void OnCellPressed(object sender, EventArgs e) + { + if (sender is TalkKey { Tag: CommsCellViewModel cell }) _ = ViewModel?.PressAsync(cell); + } + + private void OnCellReleased(object sender, EventArgs e) + { + if (sender is TalkKey { Tag: CommsCellViewModel cell }) _ = ViewModel?.ReleaseAsync(cell); + } + + private void OnAllPressed(object sender, EventArgs e) => _ = ViewModel?.AllPressAsync(); + private void OnAllReleased(object sender, EventArgs e) => _ = ViewModel?.AllReleaseAsync(); + + // ------------------------------------------------------------ pickers + + private void OnMicChosen(object sender, SelectionChangedEventArgs e) + { + if (ViewModel is { } vm && MicPicker.SelectedItem is string device) vm.SelectedMic = device; + } + + private void OnOutputChosen(object sender, SelectionChangedEventArgs e) + { + if (ViewModel is { } vm && OutputPicker.SelectedItem is string device) vm.SelectedOutput = device; + } + + // ------------------------------------------------------------ keyboard + + private static bool Typing(object? source) => + source is TextBox or ComboBox or ComboBoxItem or PasswordBox or AutoSuggestBox or NumberBox or RichEditBox; + + private static bool ModifierDown() + { + static bool Down(VirtualKey k) => + InputKeyboardSource.GetKeyStateForCurrentThread(k).HasFlag(CoreVirtualKeyStates.Down); + return Down(VirtualKey.Control) || Down(VirtualKey.Menu) || Down(VirtualKey.Shift) || + Down(VirtualKey.LeftWindows) || Down(VirtualKey.RightWindows); + } + + private static int Digit(VirtualKey key) => + key is >= VirtualKey.Number1 and <= VirtualKey.Number9 ? key - VirtualKey.Number0 + : key is >= VirtualKey.NumberPad1 and <= VirtualKey.NumberPad9 ? key - VirtualKey.NumberPad0 : 0; + + private void OnPreviewKeyDown(object sender, KeyRoutedEventArgs e) + { + if (ViewModel is not { DeskVisible: true } vm || Typing(e.OriginalSource) || ModifierDown()) return; + var digit = Digit(e.Key); + if (e.Key != VirtualKey.Space && digit == 0) return; + e.Handled = true; + if (!_down.Add(e.Key)) return; // auto-repeat must not re-toggle a latch + if (e.Key == VirtualKey.Space) + { + _ = vm.AllPressAsync(); + return; + } + if (vm.CellForDigit(digit) is { } cell) _ = vm.PressAsync(cell); + } + + private void OnPreviewKeyUp(object sender, KeyRoutedEventArgs e) + { + var wasDown = _down.Remove(e.Key); + if (ViewModel is not { } vm || !wasDown) return; + if (e.Key == VirtualKey.Space) + { + e.Handled = true; + _ = vm.AllReleaseAsync(); + return; + } + if (Digit(e.Key) is var d and > 0 && vm.CellForDigit(d) is { } cell) + { + e.Handled = true; + _ = vm.ReleaseAsync(cell); + } + } + + // Focus moving out of the page releases everything (it never moves on its own while + // a key is held by the pointer: the TalkKey keeps capture). + private void OnLosingFocus(UIElement sender, LosingFocusEventArgs args) + { + if (args.NewFocusedElement is DependencyObject next && IsInside(next)) return; + ReleaseEverything(); + } + + private bool IsInside(DependencyObject element) + { + for (var node = element; node is not null; node = Microsoft.UI.Xaml.Media.VisualTreeHelper.GetParent(node)) + if (ReferenceEquals(node, this)) return true; + return false; + } + + /// Every held key up (focus left, window deactivated, page hidden). + public void ReleaseEverything() + { + _down.Clear(); + AllCallKey.ForceUp(); + _ = ViewModel?.ReleaseAllAsync(); + } +} diff --git a/native-shell/CoreVideoPro.WinUI/Views/StudioWorkspace.xaml b/native-shell/CoreVideoPro.WinUI/Views/StudioWorkspace.xaml index 5820eb2a4..b1f82ab92 100644 --- a/native-shell/CoreVideoPro.WinUI/Views/StudioWorkspace.xaml +++ b/native-shell/CoreVideoPro.WinUI/Views/StudioWorkspace.xaml @@ -220,6 +220,12 @@ BorderBrush="{x:Bind ViewModel.AudioTabChrome.BorderBrush, Mode=OneWay}" Foreground="{x:Bind ViewModel.AudioTabChrome.Foreground, Mode=OneWay}" Command="{x:Bind ViewModel.SelectTabCommand}" CommandParameter="audio" /> +