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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions docs/qa/sources-controls-2026-10-09.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Sources control audit — #590

The owner requested moving per-guest Role and On dropout into the upper Sources
area before removing the duplicate Zoom feed health panel. This change puts an
Options button beside the source picker on assigned Zoom rows. Its flyout names
the guest and exposes the existing role and dropout commands. Roles remain
meeting-scoped; dropout preferences retain the same canonical `zoom:` key and
storage. An unavailable assigned guest can still edit their saved dropout policy;
role assignment waits for the guest to return.

| Visible control / information | Before | After / operator task |
|---|---|---|
| Ten input assignments, grouped source picker, offline warning | Upper inspector | Retained; discover and assign sources without an automatic replacement |
| In show, display name, input number/type | Upper inspector | Retained; identify and prepare the show |
| Negotiated Format, Rec, state, tally, thumbnail, meter | Existing Inspector projection | Retained unchanged; no second status snapshot |
| Audio pairing, Grade, Unassign | Assigned inspector row | Retained; prepare or detach that input |
| Zoom Role and On dropout | Duplicate lower feed-health list | Guest Options beside the upper source picker |
| Zoom diagnostic summary and recommendations | Sources and Diagnostics | Sources duplicate removed; full feed diagnostics remain in the health view |
| Capture refresh, discovery, signal/state, sync offset, Connect/Take offline | Half-width device section | Retained in a full-width section below the inspector |
| Browser URL and size/add controls | Device section | Retained; create browser inputs |
| SRT add/remove, mode, host, port, latency, stream ID/passphrase | Ingest section | Retained; configure receivers |
| RTMP add/remove, listener URL/runtime state | Ingest section | Retained; configure receivers |
| Preferred Zoom camera max and setup guidance | Inspector header | Retained; maximum-resolution UX remains a separate issue (#681) |

The flyout installs its options and selects a member of that list before attaching
write handlers. It does not use phased SelectedValue bindings inside recycled
rows (the old feed list documented a crash on that path). Every edit checks both
the original editor object and its guest identity against the current button Tag.
Reassignment, unassignment, or container recycling disables stale options and
rejects their write instead of changing another guest. Opening a flyout does not
write preferences. Unknown values remain unselected rather than showing a
different saved value as if it were current.

## Local evidence

Release WinUI build and all 1,607 WinUI tests pass, including the new assignment/
availability test. The existing roster and preferences tests continue to cover
assignment serialization and source-policy storage.

`--verify-operator-settings <report.json>` now additionally tests the actual WinUI
flyout controls in its owned offscreen process: current role/black selection,
no opening write-back, explicit edits reaching only the original guest, rejection
after reassignment, unavailable-guest saved policy, and recycled container rejection
even when the replacement editor has the same guest ID. It materializes the real
compiled Sources ten-row template at window widths 1,100 and 900 pixels with
synthetic Zoom/camera/media assignments, checks Options visibility and row identity,
and verifies layout does not alter those assignments. Six checks pass in
`operator-ux-587/artifacts/sources-layout-window-probe.json`.

This is synthetic XAML and model evidence, not a visual operator acceptance or
live media run. The probe never starts a core or joins a meeting. The installed
user app was preserved.

## Acceptance still required

Use a packaged build with real Zoom/capture/media inputs at the owner's normal
window sizes. Review the relocated controls, ten-row scrolling, capture list and
keyboard use. Change Role and On dropout, verify the production behavior, restart
and confirm source assignments and saved dropout values. The issue stays open
until that acceptance and merge; the weekend bake remains separate.
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,29 @@ namespace CoreVideoPro.WinUI.Tests;

public sealed class ShowInputSlotViewModelTests
{
[Fact]
public void ZoomPolicyVisibilityFollowsAssignmentWithoutDiscardingUnavailableGuest()
{
var slot = new ShowInputSlot { SlotNumber = 1 };
var editor = new ShowInputSlotViewModel(slot, () => { });
var changes = new List<string?>();
editor.PropertyChanged += (_, args) => changes.Add(args.PropertyName);
Assert.False(editor.ShowSourcePolicies);
slot.Kind = ShowInputKind.ZoomParticipant;
slot.ParticipantId = "guest-42";
Assert.True(editor.ShowSourcePolicies);
Assert.Contains(nameof(editor.ShowSourcePolicies), changes);
editor.RefreshSourceOptions([], []);
Assert.True(editor.IsSourceMissing);
Assert.True(editor.ShowSourcePolicies);
Assert.Equal("zoom:guest-42", editor.SourceId);
editor.Unassign();
Assert.False(editor.ShowSourcePolicies);
slot.Kind = ShowInputKind.UvcWebcam;
slot.CaptureDeviceId = "camera";
Assert.False(editor.ShowSourcePolicies);
}

[Fact]
public void ChangingKindRefreshesSourceOptionsForSelectedInputType()
{
Expand Down
75 changes: 75 additions & 0 deletions native-shell/CoreVideoPro.WinUI/Services/OperatorSettingsProbe.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Diagnostics;
using System.Text.Json;
using CoreVideoPro.MediaCore.Services;
using CoreVideoPro.WinUI.Models;
using CoreVideoPro.WinUI.ViewModels;
using CoreVideoPro.WinUI.Views;
using Microsoft.UI.Xaml;
Expand Down Expand Up @@ -47,6 +48,10 @@ internal async Task RunAsync()
window.AppWindow.Resize(new SizeInt32(900, 640));
health.IsExpanded = false;
checks.Add("Settings health collapse and narrow window resize");
Require(Descendants(page).OfType<ToggleSwitch>().Any(t => t.Name == "OhgShowOption" && !t.IsOn), "Optional OHG workspace control is missing or not off by default");
checks.Add("Settings exposes the default-off optional OHG workspace control");
await CheckSourceOptions(window, checks);
await CheckSourcesLayout(window, checks);
}
catch (Exception ex) { error = ex.ToString(); }
finally
Expand All @@ -57,6 +62,76 @@ internal async Task RunAsync()
window.Close();
}
}
private static async Task CheckSourcesLayout(Window window, List<string> checks)
{
var page = new SourcesInputsPage();
window.Content = page;
await Eventually(() => page.IsLoaded);
var repeater = (ItemsRepeater)page.FindName("ShowInputEditorsRepeater");
var slots = Enumerable.Range(1, 10).Select(number => new ShowInputSlot { SlotNumber = number }).ToArray();
slots[0].Kind = ShowInputKind.ZoomParticipant; slots[0].ParticipantId = "synthetic-a";
slots[1].Kind = ShowInputKind.UvcWebcam; slots[1].CaptureDeviceId = "synthetic-camera";
slots[2].Kind = ShowInputKind.Media; slots[2].ParticipantId = "media:synthetic-clip";
var editors = slots.Select(slot => new ShowInputSlotViewModel(slot, () => { })).ToArray();
repeater.ItemsSource = editors;
foreach (var width in new[] { 1100, 900 })
{
window.AppWindow.Resize(new SizeInt32(width, 760));
page.UpdateLayout();
for (var i = 0; i < 10; ++i)
{
var element = repeater.GetOrCreateElement(i);
element.UpdateLayout();
var button = Descendants(element).OfType<Button>().Single(b => b.Name == "SourceOptionsButton");
Require(ReferenceEquals(button.Tag, editors[i]), "Source options template has a stale slot identity");
Require(button.Visibility == (i == 0 ? Visibility.Visible : Visibility.Collapsed), "Source options appeared for a non-Zoom assignment");
}
}
Require(slots[0].ParticipantId == "synthetic-a" && slots[1].CaptureDeviceId == "synthetic-camera" && slots[2].ParticipantId == "media:synthetic-clip", "Layout changed source assignments");
checks.Add("compiled ten-row Sources template at 1100 and 900 pixels preserves synthetic Zoom/capture/media assignments and Zoom-only options");
}
private static async Task CheckSourceOptions(Window window, List<string> checks)
{
var slot = new ShowInputSlot { SlotNumber = 1, Kind = ShowInputKind.ZoomParticipant, ParticipantId = "synthetic-a" };
var editor = new ShowInputSlotViewModel(slot, () => { });
var owner = new Button { Content = "Options", Tag = editor };
window.Content = owner;
await Eventually(() => owner.IsLoaded);
var writes = new List<string>();
RouteSelectOption[] policies = [new() { Value = "hold", Label = "Hold last frame" }, new() { Value = "black", Label = "Black" }];
var flyout = SourcePolicyFlyout.Create(owner, editor, ProductionRoleService.AssignmentOptions,
policies, "host", "black", true,
(id, value) => writes.Add($"role:{id}:{value}"), (id, value) => writes.Add($"policy:{id}:{value}"));
flyout.ShowAt(owner);
var panel = (StackPanel)flyout.Content;
await Eventually(() => panel.IsLoaded);
var combos = panel.Children.OfType<ComboBox>().ToArray();
Require(combos[0].SelectedValue as string == "host" && combos[1].SelectedValue as string == "black", "Options lost current role or saved policy");
Require(writes.Count == 0, "Opening options wrote back initial selections");
combos[0].SelectedValue = "reader";
combos[1].SelectedValue = "hold";
Require(writes.SequenceEqual(new[] { "role:synthetic-a:reader", "policy:synthetic-a:hold" }), "Options did not target their original guest");
checks.Add("real source options preserve selections and write only explicit edits to the selected guest");
slot.ParticipantId = "synthetic-b";
combos[1].SelectedValue = "black";
Require(writes.Count == 2 && !combos[1].IsEnabled, "Reassignment redirected an open source policy edit");
flyout.Hide();
checks.Add("reassigned slot rejects edits from its old open flyout");

// An ItemsRepeater can replace Tag without changing the old slot object.
var recycled = SourcePolicyFlyout.Create(owner, editor, ProductionRoleService.AssignmentOptions,
policies, "", "black", false, (_, _) => writes.Add("unexpected-role"), (_, _) => writes.Add("unexpected-policy"));
recycled.ShowAt(owner);
var recycledPanel = (StackPanel)recycled.Content;
await Eventually(() => recycledPanel.IsLoaded);
var recycledCombos = recycledPanel.Children.OfType<ComboBox>().ToArray();
Require(!recycledCombos[0].IsEnabled && recycledCombos[1].SelectedValue as string == "black", "Unavailable guest lost saved policy or has an editable role");
owner.Tag = new ShowInputSlotViewModel(new ShowInputSlot { SlotNumber = 2, Kind = ShowInputKind.ZoomParticipant, ParticipantId = "synthetic-b" }, () => { });
recycledCombos[1].SelectedValue = "hold";
Require(writes.Count == 2 && !recycledCombos[1].IsEnabled, "Recycled row redirected an old flyout edit");
recycled.Hide();
checks.Add("unavailable guest retains saved policy; recycled container rejects stale edits even for the same participant");
}
private static IEnumerable<DependencyObject> Descendants(DependencyObject root)
{
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(root); ++i)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,8 @@ public string SelectedUnifiedSourceLabel
/// dead controls.</summary>
public bool IsAssigned => _slot.IsAssigned;

public bool ShowSourcePolicies => Kind == ShowInputKind.ZoomParticipant && !string.IsNullOrWhiteSpace(ParticipantId);

/// <summary>Editable display name for the assigned source. Defaults to the derived
/// Zoom/UVC/asset name; setting it stores the operator override (feeding the auto
/// lower-thirds and multiview labels). Setting it blank resets to the derived name.</summary>
Expand Down Expand Up @@ -442,6 +444,7 @@ public void RefreshSourceOptions(
OnPropertyChanged(nameof(RowOpacity));
OnPropertyChanged(nameof(KindLabel));
OnPropertyChanged(nameof(IsAssigned));
OnPropertyChanged(nameof(ShowSourcePolicies));
OnPropertyChanged(nameof(AudioDeviceOptions));
OnPropertyChanged(nameof(SelectedSourceId));
OnPropertyChanged(nameof(IsSourcePickerEnabled));
Expand Down Expand Up @@ -477,6 +480,7 @@ private void OnSlotPropertyChanged()
OnPropertyChanged(nameof(ShowSourceUnavailableWarning));
OnPropertyChanged(nameof(RowOpacity));
OnPropertyChanged(nameof(IsAssigned));
OnPropertyChanged(nameof(ShowSourcePolicies));
OnPropertyChanged(nameof(ParticipantId));
OnPropertyChanged(nameof(CaptureDeviceId));
OnPropertyChanged(nameof(AudioDeviceId));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace CoreVideoPro.WinUI.ViewModels;

public sealed partial class StudioViewModel
{
// Read the saved policy even for an assigned guest currently out of the room.
public string SourceDropoutPolicy(string sourceId) =>
_sourceDropoutPolicies.TryGetValue(sourceId, out var policy) ? policy : "hold";
}
68 changes: 68 additions & 0 deletions native-shell/CoreVideoPro.WinUI/Views/SourcePolicyFlyout.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
using CoreVideoPro.WinUI.Models;
using CoreVideoPro.WinUI.ViewModels;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;

namespace CoreVideoPro.WinUI.Views;

// One editor per opening, with a captured participant identity. A recycled or
// reassigned slot must never redirect an already-open operator edit.
internal static class SourcePolicyFlyout
{
internal static Flyout Create(
Button owner, ShowInputSlotViewModel editor,
IReadOnlyList<RouteSelectOption> roles, IReadOnlyList<RouteSelectOption> policies,
string role, string policy, bool participantAvailable,
Action<string, string> setRole, Action<string, string> setPolicy)
{
var participantId = editor.ParticipantId!;
var notice = new TextBlock
{
Text = participantAvailable ? "Role applies to this meeting. Dropout policy is saved." :
"Guest is unavailable. Role can be changed when they return; dropout policy is saved.",
TextWrapping = TextWrapping.Wrap, FontSize = 12
};
var roleCombo = MakeCombo("Role", roles, role);
roleCombo.IsEnabled = participantAvailable;
var policyCombo = MakeCombo("On dropout", policies, policy);
var panel = new StackPanel { Width = 300, Spacing = 12 };
panel.Children.Add(new TextBlock { Text = editor.DisplayName, FontSize = 16, TextWrapping = TextWrapping.Wrap });
panel.Children.Add(roleCombo);
panel.Children.Add(policyCombo);
panel.Children.Add(notice);
var flyout = new Flyout { Content = panel };

bool StillTargetsGuest() => ReferenceEquals(owner.Tag, editor) &&
editor.ShowSourcePolicies && string.Equals(editor.ParticipantId, participantId, StringComparison.Ordinal);
void Apply(ComboBox combo, Action<string, string> write)
{
if (!StillTargetsGuest())
{
roleCombo.IsEnabled = policyCombo.IsEnabled = false;
notice.Text = "Source assignment changed. Close these options and reopen them for the current source.";
return;
}
if (combo.SelectedValue is string value) write(participantId, value);
}
// Attach after ItemsSource and selection exist: no initial write-back,
// no phased SelectedValue binding on a recycled ItemsRepeater container.
roleCombo.SelectionChanged += (_, _) => Apply(roleCombo, setRole);
policyCombo.SelectionChanged += (_, _) => Apply(policyCombo, setPolicy);
return flyout;
}

private static ComboBox MakeCombo(string header, IReadOnlyList<RouteSelectOption> options, string selected)
{
var combo = new ComboBox
{
Header = header, HorizontalAlignment = HorizontalAlignment.Stretch,
DisplayMemberPath = nameof(RouteSelectOption.Label), SelectedValuePath = nameof(RouteSelectOption.Value),
ItemsSource = options
};
var match = options.FirstOrDefault(option => string.Equals(option.Value, selected, StringComparison.Ordinal));
// Unknown selections remain unselected, rather than claiming a different
// saved value. SelectedItem is a member of the already-installed list.
combo.SelectedItem = match;
return combo;
}
}
Loading
Loading