using System.IO; using System.Windows; using TeamsISO.App.Services; using TeamsISO.Engine.Controller; using TeamsISO.Engine.Domain; namespace TeamsISO.App.ViewModels; /// /// Bindings for the global settings panel: framerate, resolution, aspect, audio, /// NDI groups (discovery + output), and the participant-list hide-Local toggle. /// 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\. // 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 AvailableFramerates => Enum.GetValues(); public IEnumerable AvailableResolutions => Enum.GetValues(); public IEnumerable AvailableAspectModes => Enum.GetValues(); public IEnumerable AvailableAudioModes => Enum.GetValues(); 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); } /// NDI discovery group(s) — comma-separated. Empty = default (Public). public string DiscoveryGroups { get => _discoveryGroups; set => SetField(ref _discoveryGroups, value); } /// NDI output group(s) — comma-separated. Empty = default (Public). public string OutputGroups { get => _outputGroups; set => SetField(ref _outputGroups, value); } /// /// 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 when filtering the list it presents. /// Persisted to ui-prefs.json via . /// public bool HideLocalSelf { get => _hideLocalSelf; set { if (SetField(ref _hideLocalSelf, value)) PersistUiPrefs(); } } /// /// 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 ui-prefs.json. /// public bool AutoDisableOnDeparture { get => _autoDisableOnDeparture; set { if (SetField(ref _autoDisableOnDeparture, value)) PersistUiPrefs(); } } /// /// Available sort modes for the dropdown in DISPLAY settings. /// public IEnumerable AvailableSortModes => Enum.GetValues(); /// /// How the participants DataGrid is sorted. Persisted across launches via /// . Reaches into /// 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. /// 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); } } /// /// 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. /// 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"); } } /// /// Snapshot the current persistable UI state to disk. Called from any /// -backed setter. Best-effort — disk /// failures don't surface to the operator (the in-memory state still /// reflects their click for this session). /// private void PersistUiPrefs() => UIPreferences.Save(new UIPreferences.Prefs( HideLocalSelf: _hideLocalSelf, AutoDisableOnDeparture: _autoDisableOnDeparture, ParticipantSort: _participantSort, MinimizeToTray: _minimizeToTray, ControlSurfaceLanReachable: _controlSurfaceLanReachable)); /// /// Record each newly-enabled ISO's normalized output to disk under /// . 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. /// 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"); } } } /// /// Output root for recorder files. Each ISO writes a subdirectory keyed by /// participant display name. Default: %USERPROFILE%\Videos\TeamsISO\<date>. /// public string RecordingDirectory { get => _recordingDirectory; set { if (SetField(ref _recordingDirectory, value) && _recordIsosToDisk) _controller.SetRecording(true, value); } } /// /// REST control surface — Stream Deck / Companion / thin-client controllers. /// Off by default. Bind address depends on : /// loopback (127.0.0.1) by default, or all-interfaces when LAN-reachable. /// 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"); } } /// /// 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. /// 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}"); } } /// /// 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: /// netsh http add urlacl url=http://+:9755/ user=Everyone /// (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. /// 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"); } } /// /// 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). /// public string ControlSurfaceUrl { get { var host = _controlSurfaceLanReachable ? GetLanIPv4() ?? "127.0.0.1" : "127.0.0.1"; return $"http://{host}:{_controlSurfacePort}/ui"; } } /// /// 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. /// 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; } /// /// 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. /// 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"); } } /// OSC bridge UDP port. Default 9000 (TouchOSC's default). 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}/"); } } /// /// Output-name template applied when the operator enables an ISO without /// a per-participant CustomName. Default "TEAMSISO_{guid}" matches /// the engine's hard-coded behavior; switch to "TEAMSISO_{name}" /// for human-readable NDI source names. See /// for the supported tokens. /// public string OutputNameTemplate { get => _outputNameTemplate; set { if (SetField(ref _outputNameTemplate, value)) { TeamsISO.App.Services.OutputNameTemplate.Set(value ?? string.Empty); } } } /// /// 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. /// 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"); } } } /// /// 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 presets.json's AutoApplyOnStartup field via /// . /// public bool AutoApplyLastPreset { get => _autoApplyLastPreset; set { if (SetField(ref _autoApplyLastPreset, value)) { try { OperatorPresetStore.SetAutoApplyOnStartup(value); } catch { /* best-effort */ } } } } public AsyncRelayCommand ApplyCommand { get; } /// /// 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. /// public RelayCommand CopyControlSurfaceUrlCommand { get; } /// /// 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. /// public RelayCommand ResetOutputDefaultsCommand { get; } /// /// 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. /// 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"); } }