Some checks failed
CI / build-and-test (push) Failing after 26s
On a Windows host with both Ethernet (10.0.0.123) and Tailscale (169.254.83.107 link-local), the original first-hit-wins picker returned the Tailscale address — useless for the headless-host + thin-client scenario the LAN-reachable mode is designed for. New picker prefers physical NICs (Ethernet/GigabitEthernet/Wireless80211), skips Tunnel-typed virtuals, and ranks: physical-routable > virtual-routable > APIPA. Verified against this host: now returns 10.0.0.123 instead of 169.254.83.107.
576 lines
25 KiB
C#
576 lines
25 KiB
C#
using System.IO;
|
|
using System.Windows;
|
|
using TeamsISO.App.Services;
|
|
using TeamsISO.Engine.Controller;
|
|
using TeamsISO.Engine.Domain;
|
|
|
|
namespace TeamsISO.App.ViewModels;
|
|
|
|
/// <summary>
|
|
/// Bindings for the global settings panel: framerate, resolution, aspect, audio,
|
|
/// NDI groups (discovery + output), and the participant-list hide-Local toggle.
|
|
/// </summary>
|
|
public sealed class GlobalSettingsViewModel : ObservableObject
|
|
{
|
|
private readonly IIsoController _controller;
|
|
private readonly ToastViewModel? _toast;
|
|
private TargetFramerate _framerate;
|
|
private TargetResolution _resolution;
|
|
private AspectMode _aspect;
|
|
private AudioMode _audio;
|
|
private string _discoveryGroups;
|
|
private string _outputGroups;
|
|
private bool _hideLocalSelf = true;
|
|
private bool _autoDisableOnDeparture = false;
|
|
private bool _autoApplyLastPreset;
|
|
private bool _recordIsosToDisk;
|
|
private string _recordingDirectory = string.Empty;
|
|
private bool _controlSurfaceEnabled;
|
|
private int _controlSurfacePort = ControlSurfaceServer.DefaultPort;
|
|
private bool _oscBridgeEnabled;
|
|
private int _oscBridgePort = OscBridge.DefaultPort;
|
|
private bool _updateCheckOnLaunch = UpdateChecker.LaunchCheckEnabled;
|
|
private string _outputNameTemplate = TeamsISO.App.Services.OutputNameTemplate.Get();
|
|
private UIPreferences.SortMode _participantSort = UIPreferences.SortMode.JoinOrder;
|
|
private bool _minimizeToTray;
|
|
private bool _controlSurfaceLanReachable;
|
|
|
|
public GlobalSettingsViewModel(IIsoController controller, ToastViewModel? toast = null)
|
|
{
|
|
_controller = controller;
|
|
_toast = toast;
|
|
var current = controller.GlobalSettings;
|
|
_framerate = current.Framerate;
|
|
_resolution = current.Resolution;
|
|
_aspect = current.Aspect;
|
|
_audio = current.Audio;
|
|
|
|
var groups = controller.GroupSettings;
|
|
_discoveryGroups = groups.DiscoveryGroups ?? string.Empty;
|
|
_outputGroups = groups.OutputGroups ?? string.Empty;
|
|
|
|
// Restore persisted UI toggles so the operator's preference survives
|
|
// process restarts. UIPreferences keeps a tiny JSON file under
|
|
// %LOCALAPPDATA%\TeamsISO\ui-prefs.json — defaults match the original
|
|
// in-memory init (HideLocalSelf=true, AutoDisableOnDeparture=false).
|
|
var uiPrefs = UIPreferences.Load();
|
|
_hideLocalSelf = uiPrefs.HideLocalSelf;
|
|
_autoDisableOnDeparture = uiPrefs.AutoDisableOnDeparture;
|
|
_participantSort = uiPrefs.ParticipantSort;
|
|
_minimizeToTray = uiPrefs.MinimizeToTray;
|
|
_controlSurfaceLanReachable = uiPrefs.ControlSurfaceLanReachable;
|
|
|
|
// Bring the auto-apply flag in from the presets store so the checkbox
|
|
// reflects the user's prior choice when the settings panel opens.
|
|
try { _autoApplyLastPreset = OperatorPresetStore.GetStartupPreference().AutoApplyOnStartup; }
|
|
catch { /* best-effort — disk read failures shouldn't block UI startup */ }
|
|
|
|
// Default recording directory: %USERPROFILE%\Videos\TeamsISO\<today's date>.
|
|
// Operator can override via the textbox. Date in the path keeps recordings
|
|
// from a long-running show day organized without us having to scan + rotate.
|
|
_recordingDirectory = Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.MyVideos),
|
|
"TeamsISO",
|
|
DateTimeOffset.Now.ToString("yyyy-MM-dd"));
|
|
_recordIsosToDisk = controller.RecordingEnabled;
|
|
if (!string.IsNullOrEmpty(controller.RecordingDirectory))
|
|
_recordingDirectory = controller.RecordingDirectory;
|
|
|
|
ApplyCommand = new AsyncRelayCommand(ApplyAsync);
|
|
ApplyTranscoderTopologyCommand = new AsyncRelayCommand(ApplyTranscoderTopologyAsync);
|
|
ResetOutputDefaultsCommand = new RelayCommand(ResetOutputDefaults);
|
|
CopyControlSurfaceUrlCommand = new RelayCommand(() =>
|
|
{
|
|
try
|
|
{
|
|
System.Windows.Clipboard.SetText(ControlSurfaceUrl);
|
|
_toast?.Show($"Copied: {ControlSurfaceUrl}");
|
|
}
|
|
catch
|
|
{
|
|
// Clipboard occasionally errors when something else has it locked.
|
|
}
|
|
});
|
|
}
|
|
|
|
private void ResetOutputDefaults()
|
|
{
|
|
var confirm = MessageBox.Show(
|
|
"Reset framerate, resolution, aspect and audio to TeamsISO defaults?\n\n" +
|
|
"This won't touch your NDI group configuration or display toggles.",
|
|
"TeamsISO — Reset output defaults",
|
|
MessageBoxButton.YesNo,
|
|
MessageBoxImage.Question,
|
|
MessageBoxResult.No);
|
|
if (confirm != MessageBoxResult.Yes) return;
|
|
|
|
var defaults = FrameProcessingSettings.Default;
|
|
Framerate = defaults.Framerate;
|
|
Resolution = defaults.Resolution;
|
|
Aspect = defaults.Aspect;
|
|
Audio = defaults.Audio;
|
|
_toast?.Show("Output settings reset to defaults — click Apply Changes to commit");
|
|
}
|
|
|
|
public IEnumerable<TargetFramerate> AvailableFramerates => Enum.GetValues<TargetFramerate>();
|
|
public IEnumerable<TargetResolution> AvailableResolutions => Enum.GetValues<TargetResolution>();
|
|
public IEnumerable<AspectMode> AvailableAspectModes => Enum.GetValues<AspectMode>();
|
|
public IEnumerable<AudioMode> AvailableAudioModes => Enum.GetValues<AudioMode>();
|
|
|
|
public TargetFramerate Framerate { get => _framerate; set => SetField(ref _framerate, value); }
|
|
public TargetResolution Resolution { get => _resolution; set => SetField(ref _resolution, value); }
|
|
public AspectMode Aspect { get => _aspect; set => SetField(ref _aspect, value); }
|
|
public AudioMode Audio { get => _audio; set => SetField(ref _audio, value); }
|
|
|
|
/// <summary>NDI discovery group(s) — comma-separated. Empty = default (Public).</summary>
|
|
public string DiscoveryGroups { get => _discoveryGroups; set => SetField(ref _discoveryGroups, value); }
|
|
|
|
/// <summary>NDI output group(s) — comma-separated. Empty = default (Public).</summary>
|
|
public string OutputGroups { get => _outputGroups; set => SetField(ref _outputGroups, value); }
|
|
|
|
/// <summary>
|
|
/// Hide the user's own self-preview ("(Local)") from the participants list.
|
|
/// On by default — operators rarely want to ISO-route their own preview.
|
|
/// Read by <see cref="MainViewModel"/> when filtering the list it presents.
|
|
/// Persisted to <c>ui-prefs.json</c> via <see cref="UIPreferences"/>.
|
|
/// </summary>
|
|
public bool HideLocalSelf
|
|
{
|
|
get => _hideLocalSelf;
|
|
set
|
|
{
|
|
if (SetField(ref _hideLocalSelf, value)) PersistUiPrefs();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// When a participant leaves the meeting (their NDI source disappears),
|
|
/// automatically tear down their ISO pipeline. Off by default so transient
|
|
/// drops don't lose the operator's routing — but useful for clean
|
|
/// show-end behavior. Read by MainViewModel when reconciling departures.
|
|
/// Persisted to <c>ui-prefs.json</c>.
|
|
/// </summary>
|
|
public bool AutoDisableOnDeparture
|
|
{
|
|
get => _autoDisableOnDeparture;
|
|
set
|
|
{
|
|
if (SetField(ref _autoDisableOnDeparture, value)) PersistUiPrefs();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Available sort modes for the dropdown in DISPLAY settings.
|
|
/// </summary>
|
|
public IEnumerable<UIPreferences.SortMode> AvailableSortModes => Enum.GetValues<UIPreferences.SortMode>();
|
|
|
|
/// <summary>
|
|
/// How the participants DataGrid is sorted. Persisted across launches via
|
|
/// <see cref="UIPreferences"/>. Reaches into <see cref="MainViewModel.SetSortMode"/>
|
|
/// on Application.Current to actually apply the sort to the live view —
|
|
/// the settings VM doesn't directly know about the main VM but App holds
|
|
/// both and exposes the main window via its DataContext.
|
|
/// </summary>
|
|
public UIPreferences.SortMode ParticipantSort
|
|
{
|
|
get => _participantSort;
|
|
set
|
|
{
|
|
if (!SetField(ref _participantSort, value)) return;
|
|
PersistUiPrefs();
|
|
// Apply to the live view immediately. App.MainWindow.DataContext
|
|
// is the MainViewModel; cast and call.
|
|
var main = (Application.Current?.MainWindow?.DataContext) as MainViewModel;
|
|
main?.SetSortMode(value);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Minimize-to-tray behavior. When on, minimizing the main window hides
|
|
/// it from the taskbar and shows a tray icon (double-click to restore).
|
|
/// Right-click menu on the tray icon offers "Show", "Stop all ISOs", "Exit".
|
|
/// Useful for long unattended shows where the operator wants TeamsISO
|
|
/// running but invisible.
|
|
/// </summary>
|
|
public bool MinimizeToTray
|
|
{
|
|
get => _minimizeToTray;
|
|
set
|
|
{
|
|
if (!SetField(ref _minimizeToTray, value)) return;
|
|
PersistUiPrefs();
|
|
// Reach into the App-owned tray host. App constructs it after the
|
|
// main window exists, so the cast is safe at any time the settings
|
|
// panel is interactable.
|
|
var tray = (Application.Current as App)?.TrayIcon;
|
|
if (tray is not null) tray.Enabled = value;
|
|
_toast?.Show(value
|
|
? "Minimize-to-tray enabled — minimizing now hides the window"
|
|
: "Minimize-to-tray disabled");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Snapshot the current persistable UI state to disk. Called from any
|
|
/// <see cref="UIPreferences.Prefs"/>-backed setter. Best-effort — disk
|
|
/// failures don't surface to the operator (the in-memory state still
|
|
/// reflects their click for this session).
|
|
/// </summary>
|
|
private void PersistUiPrefs() =>
|
|
UIPreferences.Save(new UIPreferences.Prefs(
|
|
HideLocalSelf: _hideLocalSelf,
|
|
AutoDisableOnDeparture: _autoDisableOnDeparture,
|
|
ParticipantSort: _participantSort,
|
|
MinimizeToTray: _minimizeToTray,
|
|
ControlSurfaceLanReachable: _controlSurfaceLanReachable));
|
|
|
|
/// <summary>
|
|
/// Record each newly-enabled ISO's normalized output to disk under
|
|
/// <see cref="RecordingDirectory"/>. Already-running ISOs are not retroactively
|
|
/// recorded — the operator should disable + re-enable them. Outputs raw BGRA
|
|
/// + manifest.json + convert.cmd; running convert.cmd produces a final
|
|
/// H.264 .mkv via FFmpeg.
|
|
/// </summary>
|
|
public bool RecordIsosToDisk
|
|
{
|
|
get => _recordIsosToDisk;
|
|
set
|
|
{
|
|
if (SetField(ref _recordIsosToDisk, value))
|
|
{
|
|
_controller.SetRecording(value, _recordingDirectory);
|
|
_toast?.Show(value
|
|
? "Recording on — newly-enabled ISOs will write to disk"
|
|
: "Recording off");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Output root for recorder files. Each ISO writes a subdirectory keyed by
|
|
/// participant display name. Default: <c>%USERPROFILE%\Videos\TeamsISO\<date></c>.
|
|
/// </summary>
|
|
public string RecordingDirectory
|
|
{
|
|
get => _recordingDirectory;
|
|
set
|
|
{
|
|
if (SetField(ref _recordingDirectory, value) && _recordIsosToDisk)
|
|
_controller.SetRecording(true, value);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// REST control surface — Stream Deck / Companion / thin-client controllers.
|
|
/// Off by default. Bind address depends on <see cref="ControlSurfaceLanReachable"/>:
|
|
/// loopback (127.0.0.1) by default, or all-interfaces when LAN-reachable.
|
|
/// </summary>
|
|
public bool ControlSurfaceEnabled
|
|
{
|
|
get => _controlSurfaceEnabled;
|
|
set
|
|
{
|
|
if (!SetField(ref _controlSurfaceEnabled, value)) return;
|
|
var srv = (Application.Current as App)?.ControlSurface;
|
|
if (srv is null) return;
|
|
if (value) srv.Start(_controlSurfacePort, _controlSurfaceLanReachable);
|
|
else srv.Stop();
|
|
_toast?.Show(value
|
|
? $"Control surface listening on {ControlSurfaceUrl}"
|
|
: "Control surface stopped");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Port the control surface binds to. Editable while the surface is off; while on,
|
|
/// changing the port stops + restarts the listener on the new port.
|
|
/// </summary>
|
|
public int ControlSurfacePort
|
|
{
|
|
get => _controlSurfacePort;
|
|
set
|
|
{
|
|
if (!SetField(ref _controlSurfacePort, value)) return;
|
|
OnPropertyChanged(nameof(ControlSurfaceUrl));
|
|
if (!_controlSurfaceEnabled) return;
|
|
var srv = (Application.Current as App)?.ControlSurface;
|
|
srv?.Start(value, _controlSurfaceLanReachable);
|
|
_toast?.Show($"Control surface restarted on {ControlSurfaceUrl}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// LAN-reachable mode. When false (default), control surface binds to
|
|
/// 127.0.0.1 — only this machine. When true, binds to all interfaces so
|
|
/// a thin-client controller on a phone or another laptop can drive
|
|
/// TeamsISO. The OSC bridge follows suit if it's running.
|
|
///
|
|
/// Important: HttpListener requires either Administrator privilege OR a
|
|
/// one-time URL ACL reservation for non-loopback prefixes:
|
|
/// <code>netsh http add urlacl url=http://+:9755/ user=Everyone</code>
|
|
/// (run from an elevated PowerShell). Without that the listener throws
|
|
/// AccessDeniedException on Start; the failure surfaces as a logger
|
|
/// warning with the exact netsh command.
|
|
/// </summary>
|
|
public bool ControlSurfaceLanReachable
|
|
{
|
|
get => _controlSurfaceLanReachable;
|
|
set
|
|
{
|
|
if (!SetField(ref _controlSurfaceLanReachable, value)) return;
|
|
PersistUiPrefs();
|
|
OnPropertyChanged(nameof(ControlSurfaceUrl));
|
|
if (!_controlSurfaceEnabled) return;
|
|
var srv = (Application.Current as App)?.ControlSurface;
|
|
srv?.Start(_controlSurfacePort, value);
|
|
var osc = (Application.Current as App)?.OscBridge;
|
|
if (osc?.IsRunning == true) osc.Start(_oscBridgePort, value);
|
|
_toast?.Show(value
|
|
? $"Control surface now LAN-reachable: {ControlSurfaceUrl}"
|
|
: "Control surface now loopback-only");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Friendly URL of the running surface, for the settings panel + status
|
|
/// bar tooltip. Resolves to the first non-loopback IPv4 address when
|
|
/// LAN-reachable; loopback otherwise. Computed on demand because the
|
|
/// LAN IP may change between settings opens (Wi-Fi swap, VPN connect).
|
|
/// </summary>
|
|
public string ControlSurfaceUrl
|
|
{
|
|
get
|
|
{
|
|
var host = _controlSurfaceLanReachable
|
|
? GetLanIPv4() ?? "127.0.0.1"
|
|
: "127.0.0.1";
|
|
return $"http://{host}:{_controlSurfacePort}/ui";
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Best-effort routable IPv4 address suitable for showing the operator a
|
|
/// "paste me into the thin client" URL. Skips:
|
|
/// • loopback interfaces (127.x)
|
|
/// • tunnel/virtual interfaces (NetworkInterfaceType.Tunnel — e.g. WSL,
|
|
/// Hyper-V, Tailscale, OpenVPN-style virtuals)
|
|
/// • APIPA/link-local addresses (169.254.x — assigned when DHCP fails;
|
|
/// a host with one of these AND a real DHCP lease should pick the lease)
|
|
/// Prefers Ethernet/Wi-Fi over everything else, then falls back to the
|
|
/// first non-link-local non-loopback IPv4. Returns null only if no
|
|
/// usable address exists at all.
|
|
/// </summary>
|
|
private static string? GetLanIPv4()
|
|
{
|
|
try
|
|
{
|
|
string? linkLocalFallback = null;
|
|
string? otherFallback = null;
|
|
foreach (var ni in System.Net.NetworkInformation.NetworkInterface.GetAllNetworkInterfaces())
|
|
{
|
|
if (ni.OperationalStatus != System.Net.NetworkInformation.OperationalStatus.Up) continue;
|
|
if (ni.NetworkInterfaceType == System.Net.NetworkInformation.NetworkInterfaceType.Loopback) continue;
|
|
if (ni.NetworkInterfaceType == System.Net.NetworkInformation.NetworkInterfaceType.Tunnel) continue;
|
|
var isPhysical =
|
|
ni.NetworkInterfaceType == System.Net.NetworkInformation.NetworkInterfaceType.Ethernet ||
|
|
ni.NetworkInterfaceType == System.Net.NetworkInformation.NetworkInterfaceType.GigabitEthernet ||
|
|
ni.NetworkInterfaceType == System.Net.NetworkInformation.NetworkInterfaceType.Wireless80211;
|
|
|
|
foreach (var ua in ni.GetIPProperties().UnicastAddresses)
|
|
{
|
|
if (ua.Address.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) continue;
|
|
if (System.Net.IPAddress.IsLoopback(ua.Address)) continue;
|
|
var addr = ua.Address.ToString();
|
|
var isLinkLocal = addr.StartsWith("169.254.", StringComparison.Ordinal);
|
|
|
|
if (isPhysical && !isLinkLocal) return addr; // best
|
|
if (!isLinkLocal) otherFallback ??= addr; // routable but virtual NIC
|
|
if (isLinkLocal) linkLocalFallback ??= addr; // worst
|
|
}
|
|
}
|
|
return otherFallback ?? linkLocalFallback;
|
|
}
|
|
catch { /* best-effort */ }
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// OSC bridge over UDP — same command surface as the REST endpoints,
|
|
/// reachable from Companion / TouchOSC / lighting consoles. Off by default;
|
|
/// bound to 127.0.0.1 only.
|
|
/// </summary>
|
|
public bool OscBridgeEnabled
|
|
{
|
|
get => _oscBridgeEnabled;
|
|
set
|
|
{
|
|
if (!SetField(ref _oscBridgeEnabled, value)) return;
|
|
var bridge = (Application.Current as App)?.OscBridge;
|
|
if (bridge is null) return;
|
|
if (value) bridge.Start(_oscBridgePort, _controlSurfaceLanReachable);
|
|
else bridge.Stop();
|
|
var host = _controlSurfaceLanReachable ? "0.0.0.0" : "127.0.0.1";
|
|
_toast?.Show(value
|
|
? $"OSC bridge listening on udp://{host}:{_oscBridgePort}/"
|
|
: "OSC bridge stopped");
|
|
}
|
|
}
|
|
|
|
/// <summary>OSC bridge UDP port. Default 9000 (TouchOSC's default).</summary>
|
|
public int OscBridgePort
|
|
{
|
|
get => _oscBridgePort;
|
|
set
|
|
{
|
|
if (!SetField(ref _oscBridgePort, value)) return;
|
|
if (!_oscBridgeEnabled) return;
|
|
var bridge = (Application.Current as App)?.OscBridge;
|
|
bridge?.Start(value, _controlSurfaceLanReachable);
|
|
var host = _controlSurfaceLanReachable ? "0.0.0.0" : "127.0.0.1";
|
|
_toast?.Show($"OSC bridge restarted on udp://{host}:{value}/");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Output-name template applied when the operator enables an ISO without
|
|
/// a per-participant CustomName. Default <c>"TEAMSISO_{guid}"</c> matches
|
|
/// the engine's hard-coded behavior; switch to <c>"TEAMSISO_{name}"</c>
|
|
/// for human-readable NDI source names. See <see cref="OutputNameTemplate"/>
|
|
/// for the supported tokens.
|
|
/// </summary>
|
|
public string OutputNameTemplate
|
|
{
|
|
get => _outputNameTemplate;
|
|
set
|
|
{
|
|
if (SetField(ref _outputNameTemplate, value))
|
|
{
|
|
TeamsISO.App.Services.OutputNameTemplate.Set(value ?? string.Empty);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Background update check on launch. Throttled to once per 24h via a
|
|
/// timestamp file. When a newer release is found, surfaces a non-modal
|
|
/// banner with a "Get update" button. Off-by-default would be friendlier
|
|
/// for paranoid setups; on-by-default is friendlier for adoption.
|
|
/// </summary>
|
|
public bool UpdateCheckOnLaunch
|
|
{
|
|
get => _updateCheckOnLaunch;
|
|
set
|
|
{
|
|
if (SetField(ref _updateCheckOnLaunch, value))
|
|
{
|
|
UpdateChecker.LaunchCheckEnabled = value;
|
|
_toast?.Show(value
|
|
? "Update checks enabled — runs once per 24h on launch"
|
|
: "Update checks disabled");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// On launch, automatically re-apply the most recently applied operator preset.
|
|
/// Closes the loop on the recurring-show workflow: the operator clicks Apply
|
|
/// once, and from that point on TeamsISO restores the same routing on every
|
|
/// subsequent launch as soon as the matching participants come online.
|
|
/// Persisted to <c>presets.json</c>'s <c>AutoApplyOnStartup</c> field via
|
|
/// <see cref="OperatorPresetStore"/>.
|
|
/// </summary>
|
|
public bool AutoApplyLastPreset
|
|
{
|
|
get => _autoApplyLastPreset;
|
|
set
|
|
{
|
|
if (SetField(ref _autoApplyLastPreset, value))
|
|
{
|
|
try { OperatorPresetStore.SetAutoApplyOnStartup(value); }
|
|
catch { /* best-effort */ }
|
|
}
|
|
}
|
|
}
|
|
|
|
public AsyncRelayCommand ApplyCommand { get; }
|
|
|
|
/// <summary>
|
|
/// Copy the current control-surface URL to the clipboard. Operators on a
|
|
/// thin-client setup tap this, then paste into a phone browser. Bound to
|
|
/// a small button next to the LAN-reachable toggle.
|
|
/// </summary>
|
|
public RelayCommand CopyControlSurfaceUrlCommand { get; }
|
|
|
|
/// <summary>
|
|
/// Restore Output settings (framerate, resolution, aspect, audio) to the engine's
|
|
/// shipping defaults. Doesn't touch NDI groups (those are intentionally sticky —
|
|
/// the operator's transcoder topology is a per-machine setting that survives
|
|
/// preferences resets) and doesn't touch Display toggles. Confirms first.
|
|
/// </summary>
|
|
public RelayCommand ResetOutputDefaultsCommand { get; }
|
|
|
|
/// <summary>
|
|
/// One-click "set up the transcoder topology" — writes ndi-config.v1.json so all
|
|
/// local senders broadcast on a private group ("teamsiso-input") while local
|
|
/// receivers can see both that and "public", then sets the engine's discovery and
|
|
/// output groups to align (engine receives from the private group, emits on Public).
|
|
/// User has to restart Teams for the new ndi-config.v1.json to take effect there.
|
|
/// </summary>
|
|
public AsyncRelayCommand ApplyTranscoderTopologyCommand { get; }
|
|
|
|
private async Task ApplyAsync()
|
|
{
|
|
var settings = new FrameProcessingSettings(_framerate, _resolution, _aspect, _audio);
|
|
await _controller.SetGlobalSettingsAsync(settings, CancellationToken.None);
|
|
|
|
var groups = new NdiGroupSettings(
|
|
DiscoveryGroups: string.IsNullOrWhiteSpace(_discoveryGroups) ? null : _discoveryGroups.Trim(),
|
|
OutputGroups: string.IsNullOrWhiteSpace(_outputGroups) ? null : _outputGroups.Trim());
|
|
await _controller.SetGroupSettingsAsync(groups, CancellationToken.None);
|
|
|
|
_toast?.Show("Settings saved");
|
|
}
|
|
|
|
private async Task ApplyTranscoderTopologyAsync()
|
|
{
|
|
// 1. Update the machine-wide NDI config so Teams' raw broadcasts go to the
|
|
// private group instead of polluting Public.
|
|
var result = NdiAccessManagerConfig.ApplyTranscoderTopology();
|
|
if (!result.Success)
|
|
{
|
|
MessageBox.Show(
|
|
$"Could not write NDI Access Manager config.\n\n{result.ErrorMessage}\n\nPath: {result.ConfigPath}",
|
|
"TeamsISO — Apply transcoder topology",
|
|
MessageBoxButton.OK,
|
|
MessageBoxImage.Warning);
|
|
return;
|
|
}
|
|
|
|
// 2. Update the engine: receive only from the private group, emit on Public.
|
|
var ourGroups = new NdiGroupSettings(
|
|
DiscoveryGroups: NdiAccessManagerConfig.TranscoderInputGroup,
|
|
OutputGroups: "public");
|
|
await _controller.SetGroupSettingsAsync(ourGroups, CancellationToken.None);
|
|
|
|
// 3. Reflect the new values in the bound text boxes.
|
|
DiscoveryGroups = NdiAccessManagerConfig.TranscoderInputGroup;
|
|
OutputGroups = "public";
|
|
|
|
var backupNote = result.BackupPath is null
|
|
? "No prior NDI config existed; a fresh one was created."
|
|
: $"A backup of your prior NDI config was saved to:\n{result.BackupPath}";
|
|
|
|
MessageBox.Show(
|
|
"Transcoder topology applied. ✓\n\n" +
|
|
"• Local senders (Teams, etc.) will broadcast on group 'teamsiso-input'.\n" +
|
|
"• Local receivers will see both 'public' and 'teamsiso-input'.\n" +
|
|
"• TeamsISO will discover from 'teamsiso-input' and re-emit on 'public'.\n\n" +
|
|
"RESTART Microsoft Teams for the new NDI config to take effect there.\n\n" +
|
|
backupNote,
|
|
"TeamsISO — Apply transcoder topology",
|
|
MessageBoxButton.OK,
|
|
MessageBoxImage.Information);
|
|
|
|
_toast?.Show("Transcoder topology applied — restart Teams to take effect");
|
|
}
|
|
}
|