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; 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; // 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); } 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)); /// /// 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 (localhost:port) — Stream Deck / Companion / OSC bridges /// can hit it. Off by default; bound to 127.0.0.1 so LAN access requires explicit /// reconfiguration. Toggling reaches into App's owned ControlSurfaceServer. /// 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); else srv.Stop(); _toast?.Show(value ? $"Control surface listening on http://127.0.0.1:{_controlSurfacePort}/" : "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; if (!_controlSurfaceEnabled) return; var srv = (Application.Current as App)?.ControlSurface; srv?.Start(value); // Start is idempotent + handles port change _toast?.Show($"Control surface restarted on http://127.0.0.1:{value}/"); } } /// /// 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); else bridge.Stop(); _toast?.Show(value ? $"OSC bridge listening on udp://127.0.0.1:{_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); _toast?.Show($"OSC bridge restarted on udp://127.0.0.1:{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; } /// /// 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"); } }