diff --git a/src/TeamsISO.App/PresetsDialog.xaml b/src/TeamsISO.App/PresetsDialog.xaml
new file mode 100644
index 0000000..188c1e3
--- /dev/null
+++ b/src/TeamsISO.App/PresetsDialog.xaml
@@ -0,0 +1,209 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/TeamsISO.App/Services/OperatorPresetStore.cs b/src/TeamsISO.App/Services/OperatorPresetStore.cs
new file mode 100644
index 0000000..73050c0
--- /dev/null
+++ b/src/TeamsISO.App/Services/OperatorPresetStore.cs
@@ -0,0 +1,273 @@
+using System.IO;
+using System.Text.Json;
+
+namespace TeamsISO.App.Services;
+
+///
+/// Persistent named snapshots of which participants should have ISOs enabled and
+/// what their custom output names are. Useful for recurring shows: an operator
+/// can save the assignment they spent 5 minutes setting up, and on the next
+/// meeting load the same preset and auto-enable everyone whose display name
+/// matches.
+///
+/// Persisted as JSON at %LOCALAPPDATA%\TeamsISO\presets.json. We key by
+/// participant rather than Id
+/// because the Id is freshly generated for every meeting (Teams' NDI source
+/// identity isn't stable across sessions); display name is the operator's
+/// natural identifier and is what they see in the UI anyway.
+///
+public static class OperatorPresetStore
+{
+ ///
+ /// Test-only override for the presets file path. Tests set this to a temp
+ /// path so they don't pollute the operator's real %LOCALAPPDATA% store.
+ /// Null in production.
+ /// in the project file grants the test assembly access.
+ ///
+ internal static string? PathOverride { get; set; }
+
+ private static string PresetsPath =>
+ PathOverride ?? Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "TeamsISO",
+ "presets.json");
+
+ ///
+ /// One operator preset: a name, when it was saved, and a list of
+ /// per-participant assignments keyed by display name.
+ ///
+ public sealed record Preset(
+ string Name,
+ DateTimeOffset SavedAt,
+ IReadOnlyList Assignments);
+
+ ///
+ /// Single participant's assignment within a preset. Both fields are stable
+ /// across meetings; is the join key when applying.
+ ///
+ public sealed record Assignment(
+ string DisplayName,
+ string? CustomOutputName,
+ bool Enabled);
+
+ ///
+ /// On-disk shape: a list of presets indexed by name. Wrapped in an object so
+ /// we can grow the schema (versioning, defaults, last-used) without breaking
+ /// existing files. +
+ /// drive the "auto-apply on startup" feature; reading older files (which lack
+ /// these fields) falls back to default values via the records' default ctor.
+ ///
+ private sealed record File(
+ int Version,
+ IReadOnlyList Presets,
+ string? LastAppliedName = null,
+ bool AutoApplyOnStartup = false);
+
+ ///
+ /// Operator-level preferences that travel inside the same JSON envelope as the
+ /// presets themselves. Currently used for the "auto-apply last preset on launch"
+ /// feature so the host can decide on startup whether to silently re-apply the
+ /// most recent preset and which one to apply.
+ ///
+ public sealed record StartupPreference(string? LastAppliedName, bool AutoApplyOnStartup);
+
+ /// Returns all stored presets, oldest first. Empty list if no file exists.
+ public static IReadOnlyList LoadAll() => LoadFile().Presets ?? Array.Empty();
+
+ ///
+ /// Returns the operator's startup preference (which preset, if any, should be
+ /// auto-applied on launch). Defaults to (null, false) when no file exists
+ /// or the file predates the field — older preset.json files deserialize cleanly
+ /// because both fields are optional with default values.
+ ///
+ public static StartupPreference GetStartupPreference()
+ {
+ var file = LoadFile();
+ return new StartupPreference(file.LastAppliedName, file.AutoApplyOnStartup);
+ }
+
+ ///
+ /// Records that was just successfully applied. Combined
+ /// with , drives the auto-apply-on-launch flow.
+ /// Preserves the rest of the file (presets, AutoApplyOnStartup flag) intact.
+ ///
+ public static void MarkApplied(string name)
+ {
+ var file = LoadFile();
+ WriteFile(file with { LastAppliedName = name });
+ }
+
+ ///
+ /// Toggles whether the host should auto-apply
+ /// on next launch. Independent of so the operator can flip
+ /// the toggle without losing the most-recent name.
+ ///
+ public static void SetAutoApplyOnStartup(bool enabled)
+ {
+ var file = LoadFile();
+ WriteFile(file with { AutoApplyOnStartup = enabled });
+ }
+
+ ///
+ /// Adds (or replaces) a preset by name. Atomic write: writes to a temp file
+ /// then File.Replace so a crash mid-write doesn't corrupt the existing file.
+ /// Preserves across writes.
+ ///
+ public static void Save(Preset preset)
+ {
+ var file = LoadFile();
+ var presets = (file.Presets ?? Array.Empty())
+ .Where(p => !string.Equals(p.Name, preset.Name, StringComparison.OrdinalIgnoreCase))
+ .Append(preset)
+ .OrderBy(p => p.Name, StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+ WriteFile(file with { Presets = presets });
+ }
+
+ ///
+ /// Removes a preset by name. No-op if not present. If the deleted preset was
+ /// the last-applied one, clears that field so we don't try to re-apply a missing
+ /// preset on next launch.
+ ///
+ public static void Delete(string name)
+ {
+ var file = LoadFile();
+ var existing = file.Presets ?? Array.Empty();
+ var remaining = existing
+ .Where(p => !string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase))
+ .ToArray();
+ if (remaining.Length == existing.Count) return; // not present
+
+ var clearedLastApplied =
+ string.Equals(file.LastAppliedName, name, StringComparison.OrdinalIgnoreCase)
+ ? null
+ : file.LastAppliedName;
+
+ WriteFile(file with { Presets = remaining, LastAppliedName = clearedLastApplied });
+ }
+
+ /// Looks up a preset by name (case-insensitive). Null if not present.
+ public static Preset? Find(string name) =>
+ LoadAll().FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase));
+
+ ///
+ /// Bundle format for the export/import surface. Wraps the preset list with
+ /// a version stamp + an export timestamp so a future-format-aware importer
+ /// can migrate the data. We deliberately export a flat preset list — not
+ /// the full envelope — because StartupPreference is
+ /// machine-local (operator A's "auto-apply Friday Show" shouldn't follow
+ /// the bundle to operator B's machine).
+ ///
+ public sealed record Bundle(
+ string Schema,
+ DateTimeOffset ExportedAt,
+ IReadOnlyList Presets)
+ {
+ public const string CurrentSchema = "teamsiso-presets-bundle/v1";
+ }
+
+ ///
+ /// Serialize every preset to a JSON string suitable for writing to disk.
+ /// The shape is human-readable (WriteIndented) so an operator can diff
+ /// two bundles in their editor.
+ ///
+ public static string ExportAllAsJson()
+ {
+ var bundle = new Bundle(
+ Schema: Bundle.CurrentSchema,
+ ExportedAt: DateTimeOffset.Now,
+ Presets: LoadAll());
+ return JsonSerializer.Serialize(bundle, new JsonSerializerOptions { WriteIndented = true });
+ }
+
+ ///
+ /// Result of an import attempt — counts so the UI can toast a clear summary.
+ ///
+ public sealed record ImportResult(int Added, int Overwritten, int Skipped, string? Error)
+ {
+ public static ImportResult Failed(string error) => new(0, 0, 0, error);
+ }
+
+ ///
+ /// Import a bundle JSON. Per-preset name collision policy is determined by
+ /// : when true, identically-named presets in the
+ /// bundle replace local ones; when false they're skipped. Returns counts
+ /// so the caller can toast a "added X, overwrote Y, skipped Z" summary.
+ ///
+ public static ImportResult ImportBundle(string json, bool overwrite)
+ {
+ Bundle? bundle;
+ try
+ {
+ bundle = JsonSerializer.Deserialize(json);
+ }
+ catch (Exception ex)
+ {
+ return ImportResult.Failed("Could not parse bundle: " + ex.Message);
+ }
+ if (bundle is null || bundle.Presets is null)
+ return ImportResult.Failed("Bundle was empty or malformed.");
+
+ var existingNames = new HashSet(
+ LoadAll().Select(p => p.Name),
+ StringComparer.OrdinalIgnoreCase);
+
+ var added = 0;
+ var overwritten = 0;
+ var skipped = 0;
+
+ foreach (var p in bundle.Presets)
+ {
+ if (existingNames.Contains(p.Name))
+ {
+ if (!overwrite) { skipped++; continue; }
+ overwritten++;
+ }
+ else
+ {
+ added++;
+ }
+ try { Save(p); }
+ catch
+ {
+ // One bad preset shouldn't abort the rest. Count as skipped so
+ // the user knows their import wasn't 100% clean.
+ if (overwrite && existingNames.Contains(p.Name)) overwritten--;
+ else added--;
+ skipped++;
+ }
+ }
+
+ return new ImportResult(added, overwritten, skipped, null);
+ }
+
+ private static File LoadFile()
+ {
+ try
+ {
+ if (!System.IO.File.Exists(PresetsPath))
+ return new File(1, Array.Empty());
+ var json = System.IO.File.ReadAllText(PresetsPath);
+ return JsonSerializer.Deserialize(json) ?? new File(1, Array.Empty());
+ }
+ catch
+ {
+ return new File(1, Array.Empty());
+ }
+ }
+
+ private static void WriteFile(File file)
+ {
+ var dir = Path.GetDirectoryName(PresetsPath);
+ if (!string.IsNullOrEmpty(dir))
+ Directory.CreateDirectory(dir);
+
+ var json = JsonSerializer.Serialize(file, new JsonSerializerOptions { WriteIndented = true });
+ var temp = PresetsPath + ".tmp";
+ System.IO.File.WriteAllText(temp, json);
+ if (System.IO.File.Exists(PresetsPath))
+ System.IO.File.Replace(temp, PresetsPath, destinationBackupFileName: null);
+ else
+ System.IO.File.Move(temp, PresetsPath);
+ }
+}