teamsiso/src/TeamsISO.App/ViewModels/GlobalSettingsViewModel.cs

457 lines
19 KiB
C#
Raw Normal View History

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;
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\<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);
}
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));
/// <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\&lt;date&gt;</c>.
/// </summary>
public string RecordingDirectory
{
get => _recordingDirectory;
set
{
if (SetField(ref _recordingDirectory, value) && _recordIsosToDisk)
_controller.SetRecording(true, value);
}
}
/// <summary>
/// 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.
/// </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);
else srv.Stop();
_toast?.Show(value
? $"Control surface listening on http://127.0.0.1:{_controlSurfacePort}/"
: "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;
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}/");
}
}
/// <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);
else bridge.Stop();
_toast?.Show(value
? $"OSC bridge listening on udp://127.0.0.1:{_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);
_toast?.Show($"OSC bridge restarted on udp://127.0.0.1:{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>
/// 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");
}
}