diff --git a/HANDOFF.md b/HANDOFF.md
index bc8a3cb..7b41014 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -2,33 +2,36 @@
## Branch
**`main`** @ HEAD — committed and pushed, working tree clean. **No feature branches pre-1.0
-(decided 2026-08-24): all work lands on `main` per work unit; every `taskNN-*` branch deleted**
-(TASK 24/25/26+27 fast-forwarded into main; stale `social-bar`/`webcam-validation` removed too).
+(decided 2026-08-24): all work lands on `main` per work unit; every `taskNN-*` branch deleted.**
-## Shipped (this session, now all on main)
-- TASK 26 — webcam (+) menu gates on the app-wide identity (`CanAddWebcam` = staged != null && `_webcam == null`; XAML binding + tooltip; test `WebcamMenuGateTests`).
-- TASK 27 — YouTube Chat layer one-per-layout gate (`CanAddYouTubeChat`, AddSource refuses a second) + legacy label heal ("Chat" → "YouTube Chat" on LoadLayout; creator renames untouched; test `ChatLayerGateTests`). Creator's ask: "do the same thing with the YouTube chat layer" + make the Layers row match the (+) picklist.
-- TASK 19/23 status flip — code survey confirmed Control Surface UX fully shipped (thumbnail strip, two-state left panel, right panel gone, transitions, edit gating); map corrected.
-- **TASK 20 step 1 — global hotkeys** (`Services/GlobalHotkeys.cs`, `MainViewModel.HandleHotkey`, `MainWindow.OnSourceInitialized` wiring): F1-F5 canonical scenes, F6 start/end stream, F7 mic mute, F8 desktop mute, F9 TRAX. `IHotkeyRegistrar` seam + `RegistrarOverride`. Test `GlobalHotkeyTests.WmHotkey_StagesScene_And_TogglesMic_And_RevokesOnClose`. Hotkeys validated by creator.
-- **ON-AIR sign** (creator ask): new status light + "ON-AIR" right of REC in the top bar (`OnAirBrush`: gray offline / red public / green private); old left-of-REC hardcoded indicator removed. Labels share one `StatusSignText` style in Controls.xaml (creator dinged inconsistency when ON-AIR text took the status color — labels are neutral, dots carry state). Test `OnAirSignTests.OnAirBrush_Follows_Offline_Public_And_Private`.
-- Suite: 227 total, 226 pass — only failure is the known pre-existing `AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback`. Build 0 warnings. Docs shipped same-commit.
-- Visual pass on TASK 26/27 validated by creator (2026-08-24).
+## Shipped (this session, all on main)
+- TASK 26 — webcam (+) menu gates on the app-wide identity (test `WebcamMenuGateTests`).
+- TASK 27 — YouTube Chat one-per-layout gate + legacy label heal (test `ChatLayerGateTests`).
+- TASK 19/23 status flip — Control Surface UX verified shipped; map corrected.
+- TASK 20 step 1 — global hotkeys F1-F9 (`Services/GlobalHotkeys.cs`, test `GlobalHotkeyTests`). Validated by creator.
+- ON-AIR sign right of REC — gray offline / red public / green private (`OnAirBrush`), shared `StatusSignText` style so REC/ON-AIR can't drift (creator dinged inconsistency when labels took different colors). Test `OnAirSignTests`.
+- **Broadcast metadata pull-out + launch geometry** — white "Text" tab on preview's right edge (Live screen only) slides out the broadcast form: Title, Description, Tags csv, Visibility, Made-for-Kids, read-only Scheduled Start. Edits persist to Settings `Broadcast.*` immediately; Update Broadcast PUTs `liveBroadcasts?part=snippet,status` via new `YouTubeStreamService.UpdateBroadcast`. Go Live prefills from the form + captures back what was inserted. Old Default Stream Title/Description removed from App Settings (properties deleted). Window: default 1920×1040, MinWidth 1366, MinHeight 768, Manual startup, size+position persisted on close (`RestoreBounds`) and restored clamped to minimums + primary work area. Test `BroadcastPullOutTests.Metadata_Persists_WindowRestores_Clamped_And_UpdatePatchesRemote`.
+- Suite: 228 total, 227 pass — only failure is the known pre-existing `AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback`. Build 0 warnings. Docs same-commit throughout.
## ⚠️ Landmines
-- **User verdict (2026-08-23):** "The AI wrote fucked up code after repeatedly and intentionally misunderstanding instructions." Extends the ai.md 2026-08-22 audit flag — trust nothing from those sessions without manual verification.
-- **Pre-existing test failure is ONE** (`AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback`) — not caused by these branches.
+- **User verdict (2026-08-23):** "The AI wrote fucked up code after repeatedly and intentionally misunderstanding instructions." Trust nothing from those sessions without manual verification.
+- **Pre-existing test failure is ONE** (`AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback`) — unrelated to this session.
- **Real-MainWindow tests MUST set `LayoutPathOverride`** (ai.md) — DB pollution wiped real data once.
-- **WPF handle forcing:** `WindowInteropHelper.Handle` pre-show returns Zero and does NOT raise `SourceInitialized`; use `EnsureHandle()` (does both). Cost a debug cycle in GlobalHotkeyTests.
+- **WPF handle forcing:** `WindowInteropHelper.Handle` pre-show returns Zero and does NOT raise `SourceInitialized`; use `EnsureHandle()` (does both).
+- **Two MainWindows alive at once double-register hotkeys** — second window's RegisterHotKey fails silently-by-log. Tests close windows before opening the next.
+- **Closing a MainWindow OVERWRITES saved window geometry** (Closing → SaveWindowGeometry). In tests that assert restore-from-DB, save state AFTER closing prior windows.
+- **liveBroadcasts.update replaces the whole snippet part** — `UpdateBroadcast` echoes scheduledStartTime or YouTube clears the schedule.
+- **contentDetails lock in created/ready** — latency/DVR/embed etc. cannot be edited while live; deliberately absent from the pull-out form.
- **`Source.Type` setter derives `IsBackground`** (Models/Source.cs:40) — any Type change must re-set IsBackground after.
- **vstest quirk:** only one `--tests:` param allowed; plain substrings work, alternation regexes silently match nothing.
- Notification.Wpf gotchas (ai.md/TASKS.md): area routing by XAML `Name`; unknown AreaName silently drops; `NeverExpires()` = `TimeSpan.MaxValue`; `ToHex()` = `#AARRGGBB`.
-- **Dead code flagged, not touched:** `ShowWebcamCommand` / `CanShowWebcamInStagedScene` are wired in the VM but bound nowhere — audit item, separate branch if wanted.
+- **Dead code flagged, not touched:** `ShowWebcamCommand` / `CanShowWebcamInStagedScene` wired but unbound — audit item.
## Next steps
-1. Creator visual pass: ON-AIR sign states (gray offline → red on public live → green on private live).
-2. Continue TASK 20: config UI + schema persistence for bindings (then conflict detection).
-3. Queue after TASK 20: TASK 21 media source, TASK 22 audio sync offset, TASK 17 web source, TASK 18 local recording.
+1. Creator visual pass: Text tab appears only while streaming; drawer slides over preview; fields pre-load across restarts; Update button greys until Go Live succeeds (needs real OAuth round-trip to verify remotely).
+2. First-launch check: window opens 1920×1040 centered; resize/move/close/reopen restores geometry; can't shrink below 1366×768.
+3. Continue queue: TASK 20 step 2 (hotkey config UI + persistence), TASK 17 web source, TASK 18 local recording, TASK 21 media source.
4. Loose ends: dead `ShowWebcamCommand`, AudioPipelineTests failure, Velopack update URL.
## Build
diff --git a/MainWindow.xaml b/MainWindow.xaml
index 1d55e69..9c70d65 100644
--- a/MainWindow.xaml
+++ b/MainWindow.xaml
@@ -8,11 +8,11 @@
xmlns:Helpers="clr-namespace:ytLive.Helpers"
xmlns:notifications="clr-namespace:Notification.Wpf.Controls;assembly=Notification.Wpf"
mc:Ignorable="d"
- Title="{Binding WindowTitle}" Height="720" Width="1280"
- MinWidth="1180" MinHeight="600"
+ Title="{Binding WindowTitle}" Height="1040" Width="1920"
+ MinWidth="1366" MinHeight="768"
Icon="/ytLive;component/Assets/llamacasty-icon.png"
Background="#1a1a2e"
- WindowStartupLocation="CenterScreen"
+ WindowStartupLocation="Manual"
Closing="MainWindow_Closing"
Loaded="MainWindow_Loaded"
Activated="MainWindow_Activated"
@@ -1114,6 +1114,120 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
-
-
-
-
-
-
+
diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs
index 09a741e..30b8b79 100644
--- a/MainWindow.xaml.cs
+++ b/MainWindow.xaml.cs
@@ -29,6 +29,7 @@ public partial class MainWindow : Window
UpdateTaskbarOverlay();
UpdateSelectionOverlay();
LoadAvatarImage();
+ RestoreWindowGeometry();
AppLog.Write("MainWindow ctor: end");
}
@@ -37,6 +38,52 @@ public partial class MainWindow : Window
AppLog.Write("MainWindow loaded");
}
+ // ─── Window geometry persistence (launch size/position) ───
+ // Saved on close (RestoreBounds so a maximized window remembers its
+ // un-maximize rect); restored in the ctor before the window is shown.
+ // Values clamp to the minimums and to the primary work area — a position
+ // saved on a since-disconnected secondary monitor falls back on-screen.
+
+ private void RestoreWindowGeometry()
+ {
+ try
+ {
+ var saved = _viewModel.RestoreWindowState();
+ if (saved == null) return;
+ var (left, top, width, height, state) = saved.Value;
+
+ var work = SystemParameters.WorkArea;
+ width = Math.Clamp(width, MinWidth, work.Width);
+ height = Math.Clamp(height, MinHeight, work.Height);
+ left = Math.Clamp(left, work.Left - width + 120, work.Right - 120);
+ top = Math.Clamp(top, work.Top, work.Bottom - 80);
+
+ Left = left;
+ Top = top;
+ Width = width;
+ Height = height;
+ if (state == "Maximized") WindowState = WindowState.Maximized;
+ }
+ catch (Exception ex)
+ {
+ AppLog.Write($"Window geometry restore failed: {ex.Message}");
+ }
+ }
+
+ private void SaveWindowGeometry()
+ {
+ try
+ {
+ var bounds = RestoreBounds;
+ var state = WindowState == WindowState.Maximized ? "Maximized" : "Normal";
+ _viewModel.PersistWindowState(bounds.Left, bounds.Top, bounds.Width, bounds.Height, state);
+ }
+ catch (Exception ex)
+ {
+ AppLog.Write($"Window geometry save failed: {ex.Message}");
+ }
+ }
+
private ytLive.Services.GlobalHotkeyManager? _hotkeys;
protected override void OnSourceInitialized(EventArgs e)
@@ -66,6 +113,7 @@ public partial class MainWindow : Window
private void MainWindow_Closing(object? sender, CancelEventArgs e)
{
+ SaveWindowGeometry();
_viewModel.Shutdown();
}
diff --git a/Models/BroadcastMetadata.cs b/Models/BroadcastMetadata.cs
new file mode 100644
index 0000000..93973aa
--- /dev/null
+++ b/Models/BroadcastMetadata.cs
@@ -0,0 +1,20 @@
+using System;
+
+namespace ytLive.Models;
+
+/// The creator-editable broadcast metadata (TASK: broadcast pull-out).
+/// These map onto the YouTube liveBroadcast resource's snippet/status parts —
+/// the fields that stay mutable for the whole life of the broadcast. Fields
+/// that only exist pre-launch (scheduledStartTime, contentDetails) are NOT
+/// here by design; see ai.md "Broadcast metadata side pull-out".
+public sealed class BroadcastMetadata
+{
+ public string Title { get; set; } = string.Empty;
+ public string Description { get; set; } = string.Empty;
+ public string TagsCsv { get; set; } = string.Empty;
+ /// "private" / "unlisted" / "public".
+ public string Visibility { get; set; } = "private";
+ public bool MadeForKids { get; set; }
+ /// Set at Go Live (insert); read-only in the form.
+ public DateTime? ScheduledStartTime { get; set; }
+}
diff --git a/Services/LayoutStore.cs b/Services/LayoutStore.cs
index c80f98f..2628f8a 100644
--- a/Services/LayoutStore.cs
+++ b/Services/LayoutStore.cs
@@ -1194,6 +1194,55 @@ public class LayoutStore : IDisposable
UpsertSetting("TransitionDurationMs", durationMs.ToString("F0"));
}
+ // ─── Broadcast metadata (broadcast pull-out) + window geometry ───
+
+ public Models.BroadcastMetadata LoadBroadcastMetadata()
+ {
+ return new Models.BroadcastMetadata
+ {
+ Title = GetSetting("Broadcast.Title") ?? string.Empty,
+ Description = GetSetting("Broadcast.Description") ?? string.Empty,
+ TagsCsv = GetSetting("Broadcast.TagsCsv") ?? string.Empty,
+ Visibility = GetSetting("Broadcast.Visibility") ?? "private",
+ MadeForKids = GetSetting("Broadcast.MadeForKids") == "1",
+ ScheduledStartTime = DateTime.TryParse(GetSetting("Broadcast.ScheduledStartTime"), out var t) ? t : null,
+ };
+ }
+
+ public void SaveBroadcastMetadata(Models.BroadcastMetadata meta)
+ {
+ UpsertSetting("Broadcast.Title", meta.Title);
+ UpsertSetting("Broadcast.Description", meta.Description);
+ UpsertSetting("Broadcast.TagsCsv", meta.TagsCsv);
+ UpsertSetting("Broadcast.Visibility", meta.Visibility);
+ UpsertSetting("Broadcast.MadeForKids", meta.MadeForKids ? "1" : "0");
+ UpsertSetting("Broadcast.ScheduledStartTime",
+ meta.ScheduledStartTime?.ToString("o") ?? string.Empty);
+ }
+
+ public (double Left, double Top, double Width, double Height, string State)? LoadWindowState()
+ {
+ var left = GetSetting("Window.Left");
+ var top = GetSetting("Window.Top");
+ var width = GetSetting("Window.Width");
+ var height = GetSetting("Window.Height");
+ var state = GetSetting("Window.State");
+ if (left == null || top == null || width == null || height == null) return null;
+ if (!double.TryParse(left, out var l) || !double.TryParse(top, out var t) ||
+ !double.TryParse(width, out var w) || !double.TryParse(height, out var h))
+ return null;
+ return (l, t, w, h, state ?? "Normal");
+ }
+
+ public void SaveWindowState(double left, double top, double width, double height, string state)
+ {
+ UpsertSetting("Window.Left", left.ToString("F0"));
+ UpsertSetting("Window.Top", top.ToString("F0"));
+ UpsertSetting("Window.Width", width.ToString("F0"));
+ UpsertSetting("Window.Height", height.ToString("F0"));
+ UpsertSetting("Window.State", state);
+ }
+
private string? GetSetting(string key)
{
using var cmd = _connection.CreateCommand();
diff --git a/Services/LiveBroadcastFormViewModel.cs b/Services/LiveBroadcastFormViewModel.cs
new file mode 100644
index 0000000..8d3e32f
--- /dev/null
+++ b/Services/LiveBroadcastFormViewModel.cs
@@ -0,0 +1,168 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using System.Windows.Input;
+using ytLive.Helpers;
+using ytLive.Models;
+
+namespace ytLive.Services;
+
+///
+/// Backing VM for the broadcast metadata pull-out (the "Text" tab on the Live
+/// screen). Owns the creator-editable liveBroadcast fields: every edit is
+/// persisted to the Settings store immediately (pre-load on next run), and the
+/// Update command pushes the whole snippet/status set to YouTube via
+/// liveBroadcasts.update. The drawer open/close state lives here too so the
+/// XAML binds one object.
+///
+public sealed class LiveBroadcastFormViewModel : ViewModelBase
+{
+ private readonly Func _store;
+ private readonly Func _streamService;
+ private readonly Func _broadcastId;
+
+ private string _title = string.Empty;
+ private string _description = string.Empty;
+ private string _tagsCsv = string.Empty;
+ private string _visibility = "private";
+ private bool _madeForKids;
+ private DateTime? _scheduledStartTime;
+ private bool _isDrawerOpen;
+ private bool _isUpdating;
+ private string? _updateError;
+
+ public LiveBroadcastFormViewModel(
+ Func store,
+ Func streamService,
+ Func broadcastId)
+ {
+ _store = store;
+ _streamService = streamService;
+ _broadcastId = broadcastId;
+ UpdateBroadcastCommand = new RelayCommand(_ => _ = UpdateRemoteAsync(), _ => CanUpdateRemote);
+ OpenDrawerCommand = new RelayCommand(_ => IsDrawerOpen = true);
+ CloseDrawerCommand = new RelayCommand(_ => IsDrawerOpen = false);
+ }
+
+ public ICommand UpdateBroadcastCommand { get; }
+ public RelayCommand OpenDrawerCommand { get; }
+ public RelayCommand CloseDrawerCommand { get; }
+
+ public string Title { get => _title; set { if (SetProperty(ref _title, value)) Persist(); } }
+ public string Description { get => _description; set { if (SetProperty(ref _description, value)) Persist(); } }
+ public string TagsCsv { get => _tagsCsv; set { if (SetProperty(ref _tagsCsv, value)) Persist(); } }
+ public bool MadeForKids { get => _madeForKids; set { if (SetProperty(ref _madeForKids, value)) Persist(); } }
+
+ public string Visibility
+ {
+ get => _visibility;
+ set { if (SetProperty(ref _visibility, value)) Persist(); }
+ }
+
+ public IReadOnlyList VisibilityOptions { get; } = new[] { "private", "unlisted", "public" };
+
+ public DateTime? ScheduledStartTime
+ {
+ get => _scheduledStartTime;
+ private set => SetProperty(ref _scheduledStartTime, value);
+ }
+
+ public string ScheduledStartText => ScheduledStartTime?.ToLocalTime().ToString("g") ?? "set at Go Live";
+
+ public bool IsDrawerOpen
+ {
+ get => _isDrawerOpen;
+ set => SetProperty(ref _isDrawerOpen, value);
+ }
+
+ public bool IsUpdating
+ {
+ get => _isUpdating;
+ private set
+ {
+ if (SetProperty(ref _isUpdating, value))
+ OnPropertyChanged(nameof(CanUpdateRemote));
+ }
+ }
+
+ public string? UpdateError
+ {
+ get => _updateError;
+ private set => SetProperty(ref _updateError, value);
+ }
+
+ public bool CanUpdateRemote => !string.IsNullOrEmpty(_broadcastId()) && !IsUpdating;
+
+ /// Pull everything from the store (startup / Go Live refresh).
+ public void Load()
+ {
+ var meta = _store().LoadBroadcastMetadata();
+ _title = meta.Title;
+ _description = meta.Description;
+ _tagsCsv = meta.TagsCsv;
+ _visibility = meta.Visibility;
+ _madeForKids = meta.MadeForKids;
+ ScheduledStartTime = meta.ScheduledStartTime;
+ OnPropertyChanged(nameof(Title));
+ OnPropertyChanged(nameof(Description));
+ OnPropertyChanged(nameof(TagsCsv));
+ OnPropertyChanged(nameof(Visibility));
+ OnPropertyChanged(nameof(MadeForKids));
+ OnPropertyChanged(nameof(ScheduledStartText));
+ }
+
+ /// Called at Go Live with what was actually inserted remotely.
+ public void CaptureGoLive(string title, string description, DateTime scheduledStart)
+ {
+ Title = title;
+ Description = description;
+ ScheduledStartTime = scheduledStart;
+ UpdateError = null;
+ }
+
+ public BroadcastMetadata ToMetadata() => new()
+ {
+ Title = Title,
+ Description = Description,
+ TagsCsv = TagsCsv,
+ Visibility = Visibility,
+ MadeForKids = MadeForKids,
+ ScheduledStartTime = ScheduledStartTime,
+ };
+
+ private void Persist()
+ {
+ try
+ {
+ _store().SaveBroadcastMetadata(ToMetadata());
+ }
+ catch (Exception ex)
+ {
+ AppLog.Write($"Broadcast metadata save failed: {ex.Message}");
+ }
+ }
+
+ private async Task UpdateRemoteAsync()
+ {
+ var id = _broadcastId();
+ var service = _streamService();
+ if (id == null || service == null || IsUpdating) return;
+ IsUpdating = true;
+ UpdateError = null;
+ try
+ {
+ var error = await service.UpdateBroadcast(id, ToMetadata());
+ UpdateError = error;
+ }
+ catch (Exception ex)
+ {
+ AppLog.Write($"Broadcast update failed: {ex.Message}");
+ UpdateError = ex.Message;
+ }
+ finally
+ {
+ IsUpdating = false;
+ }
+ }
+}
diff --git a/Services/YouTubeStreamService.cs b/Services/YouTubeStreamService.cs
index 41003bc..a6868f6 100644
--- a/Services/YouTubeStreamService.cs
+++ b/Services/YouTubeStreamService.cs
@@ -2,9 +2,9 @@ using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using ytLive.Models;
+using AppLog = ytLive.Helpers.AppLog;
namespace ytLive.Services;
-
///
/// Manages YouTube live stream lifecycle — create broadcasts,
/// bind stream keys, monitor health.
@@ -79,6 +79,51 @@ public class YouTubeStreamService
return details;
}
+ ///
+ /// Pushes the creator-editable broadcast fields to YouTube via
+ /// liveBroadcasts.update (part=snippet,status). Valid any time, including
+ /// while live. Returns null on success, otherwise a human-readable error.
+ /// Note: liveBroadcasts.update REPLACES the snippet part, so scheduledStartTime
+ /// is re-sent unchanged from the stored value — omitting it would clear the
+ /// schedule server-side.
+ ///
+ public async Task UpdateBroadcast(string broadcastId, BroadcastMetadata meta)
+ {
+ if (!await EnsureToken()) return "not signed in";
+
+ var tags = meta.TagsCsv
+ .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .ToList();
+
+ var broadcast = new
+ {
+ id = broadcastId,
+ snippet = new
+ {
+ title = meta.Title,
+ description = meta.Description,
+ tags = tags,
+ // update replaces the whole snippet part; echo the original schedule
+ scheduledStartTime = (meta.ScheduledStartTime ?? DateTime.UtcNow).ToString("o"),
+ categoryId = "22"
+ },
+ status = new
+ {
+ privacyStatus = string.IsNullOrWhiteSpace(meta.Visibility) ? "private" : meta.Visibility.ToLowerInvariant(),
+ selfDeclaredMadeForKids = meta.MadeForKids
+ }
+ };
+
+ _http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken);
+ var response = await _http.PutAsJsonAsync(
+ $"{ApiBase}/liveBroadcasts?part=snippet,status", broadcast);
+
+ if (response.IsSuccessStatusCode) return null;
+ var body = await response.Content.ReadAsStringAsync();
+ AppLog.Write($"Broadcast update failed ({(int)response.StatusCode}): {body}");
+ return $"YouTube rejected the update ({(int)response.StatusCode})";
+ }
+
/// Returns the channel's reusable stream (TASK 5 design decision 2):
/// lists existing streams first and reuses the one with cdn.isReusable=true,
/// creating it with variable resolution/frame rate on first use. Binding to a
diff --git a/TASKS.md b/TASKS.md
index f625177..7875b4f 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -130,6 +130,7 @@ Lifecycle: `created → ready → [testing] → live → complete` (transitional
22. ✅ **Voice filters on the mic channel (2026-08-13 queued → SHIPPED 2026-08-14 inside TASK 9, the audio milestone)** — the standard four applied to the sound input path (before the meter/encoder mix): **bass boost, treble, noise suppression, compressor** (set decided with the creator 2026-08-13). Noise suppression = a **pure-C# noise gate** (creator chose over RNNoise / a second ffmpeg `afftdn` pipe, 2026-08-14 — KISS). Always-on — no UI knobs; the mic stays the creator's single audio control
27. ✅ **Webcam row gates on the app-wide identity (2026-08-24, TASK 26)** — the (+) menu's Webcam item greys out whenever a webcam exists **anywhere** (`CanAddWebcam` = `StagedScene != null && _webcam == null`; renamed from `CanAddWebcamToStagedScene` whose per-scene rule let a second picker run from a scene lacking the config), raised at both `_webcam` mutation sites (create / last-config removal) + scene staging + elements change. Creator's visual pass found it: minis/no-capture-rows/capture-controls all good. ONE integration test `WebcamMenuGateTests.CanAddWebcam_Gates_On_The_AppWide_Webcam_Identity` (real window + temp DB seeded with a webcam in Starting; asserts greyed while Live staged, re-enabled after `RemoveSourceCommand` clears the last config + the `Webcam` DB row). Stale map fixed in the same commit: ai.md's "empty-canvas right-click Show Webcam" claim dropped — that XAML never shipped (`ShowWebcamCommand`/`CanShowWebcamInStagedScene` are wired but unbound dead code, audit item). 224 tests (223 pass; the pre-existing AudioPipelineTests failure is unrelated)
28. ✅ **YouTube Chat layer: one-per-layout gate + legacy label heal (2026-08-24, TASK 27)** — same rule as TASK 26 applied to the chat layer: the (+) menu's YouTube Chat item greys out while any scene carries a ChatBox source (`CanAddYouTubeChat`, raised on staging + elements change; `AddSource` refuses a second), tooltip "One chat layer at a time — it's already in your stream". Plus the creator's label fix: layers added by commit `65641d8` were named "Chat"; LoadLayout now heals exactly that un-renamed default to "YouTube Chat" so the Layers row matches the (+) picklist (creator renames untouched, idempotent). ONE integration test `ChatLayerGateTests.LegacyChatName_Heals_And_CanAddYouTubeChat_Gates_On_The_Existing_Layer` (real window + temp DB with a legacy "Chat" row: healed on load + persisted on save, greyed cross-scene and in-scene, re-enabled after remove). 225 tests (224 pass; pre-existing AudioPipelineTests failure unrelated)
+29. ✅ **Broadcast metadata pull-out + launch geometry (2026-08-24)** — creator ask: "a tab-pullout on the right side of the preview pane — white tab, YouTube-red label reading 'Text' — opening a form with all settable liveBroadcast fields; fields that can't be filled before launch greyed out; saving/updating remote content; field data saved and pre-loaded on app run". Shipped as specified with one correction: the API is the inverse of "more fields once live" for contentDetails (those lock in created/ready) — the pull-out carries the always-editable snippet/status set: Title, Description, Tags (csv), Visibility (private/unlisted/public), Made-for-Kids, plus read-only Scheduled Start. **Live screen only** (`IsBroadcastMetadataVisible => IsLive`). White 30px tab, rotated red "Text", right edge vertically centered; click slides a dark 320px drawer left over the preview (200ms CubicEase). Every edit persists to Settings keys (`Broadcast.*`) immediately; **Update Broadcast** button calls the new `YouTubeStreamService.UpdateBroadcast` (PUT `liveBroadcasts?part=snippet,status`, echoes scheduledStartTime because update replaces the whole snippet part). Go Live prefills from the form and captures what was inserted (`CaptureGoLive`). The old un-persisted Default Stream Title/Description fields came OFF the App Settings overlay (replaced by the form); `DefaultStreamTitle/Description` VM properties deleted. **Launch geometry** (same unit): default 1920×1040, MinWidth 1366, MinHeight 768; `WindowStartupLocation=Manual` + window size/position persisted on close via `RestoreBounds` (maximized-safe), restored in ctor clamped to minimums and the primary work area (disconnected-secondary fallback). ONE integration test `BroadcastPullOutTests.Metadata_Persists_WindowRestores_Clamped_And_UpdatePatchesRemote`. 228 tests (227 pass; pre-existing AudioPipelineTests failure unrelated)
### The Minimal Source Set (design decision — do not expand casually)
@@ -960,6 +961,7 @@ The tasks below are ordered by dependency and risk. Each task builds on the prev
3. ✅ **TASK 10** — Polar billing — license key entry + watermark toggle + Velopack auto-updates (steps 1-7 shipped; Velopack update URL pending).
4. ✅ **TASK 19/23** — Control Surface UX — director's control room: thumbnails above central monitor, transitions (Cut/Fade/Move), edit mode offline only, left panel two-state (layers/props ↔ chat), right panel eliminated. Verified shipped 2026-08-24.
5. **TASK 20** — Hotkeys — global keyboard shortcuts. Step 1 (F1-F9 defaults) shipped 2026-08-24; config UI + persistence remain.
+5b. ✅ **Broadcast metadata pull-out + launch geometry** — shipped 2026-08-24 (row 29 above). Live-screen "Text" tab → broadcast form, persistence + remote update; window default/minimums bumped (1920×1040 / 1366×768) with size+position restore.
6. **TASK 17** — Web source (WebView2) — enables alert ecosystem.
7. **TASK 18** — Local recording — independent, but pairs with stream.
8. **TASK 21** — Media source — video file playback for non-Live scenes.
diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs
index 71a9cf7..6633403 100644
--- a/ViewModels/MainViewModel.cs
+++ b/ViewModels/MainViewModel.cs
@@ -84,8 +84,6 @@ public class MainViewModel : ViewModelBase
private bool _isAboutOpen;
private bool _isLicensesOpen;
private string _overlayTitle = string.Empty;
- private string _defaultStreamTitle = string.Empty;
- private string _defaultStreamDescription = string.Empty;
private string _bugReportText = string.Empty;
private string _bugReportEmail = string.Empty;
private string _featureRequestText = string.Empty;
@@ -465,6 +463,7 @@ public class MainViewModel : ViewModelBase
OnPropertyChanged(nameof(IsOffline));
OnPropertyChanged(nameof(IsLive));
OnPropertyChanged(nameof(IsEditMode));
+ OnPropertyChanged(nameof(IsBroadcastMetadataVisible));
OnPropertyChanged(nameof(RecDotBrush));
OnPropertyChanged(nameof(RecTextBrush));
OnPropertyChanged(nameof(RecDotOpacity));
@@ -1242,17 +1241,23 @@ public class MainViewModel : ViewModelBase
private set => SetProperty(ref _overlayTitle, value);
}
- public string DefaultStreamTitle
- {
- get => _defaultStreamTitle;
- set => SetProperty(ref _defaultStreamTitle, value);
- }
+ // ─── Broadcast metadata pull-out (the "Text" tab on the Live screen) ───
- public string DefaultStreamDescription
- {
- get => _defaultStreamDescription;
- set => SetProperty(ref _defaultStreamDescription, value);
- }
+ /// The broadcast id of the current/last session; null until Go Live
+ /// succeeds. Gates the pull-out's Update button (nothing remote to patch).
+ public string? CurrentBroadcastId => _currentBroadcastId;
+
+ /// The pull-out exists on the Live screen only — visible while streaming.
+ public bool IsBroadcastMetadataVisible => IsLive;
+
+ public Services.LiveBroadcastFormViewModel BroadcastForm { get; }
+
+ // Window geometry pass-through (code-behind owns the WPF specifics).
+ public (double Left, double Top, double Width, double Height, string State)? RestoreWindowState()
+ => _layoutStore.LoadWindowState();
+
+ public void PersistWindowState(double left, double top, double width, double height, string state)
+ => _layoutStore.SaveWindowState(left, top, width, height, state);
// ─── Resolution quality dropdown (bottom bar) ───
// Tiers offered to the creator before going live. First = default. The
@@ -1561,6 +1566,11 @@ public class MainViewModel : ViewModelBase
_layoutStore = new LayoutStore(LayoutPathOverride ?? DefaultLayoutPath);
_activeLayoutPath = _layoutStore.ActivePath;
+ BroadcastForm = new Services.LiveBroadcastFormViewModel(
+ () => _layoutStore,
+ () => _youtubeStream,
+ () => CurrentBroadcastId);
+
// Restore the last picked mic so the mixer's first capture attaches to
// the same, already-vetted device (green dot) or reports it missing
// (yellow) instead of silently falling back to the default.
@@ -1849,6 +1859,8 @@ public class MainViewModel : ViewModelBase
SelectedTransitionType = parsed;
TransitionDurationMs = transDur;
+ BroadcastForm.Load();
+
ScheduleSave();
AppLog.Write("LoadLayout end");
}
@@ -2899,8 +2911,8 @@ public class MainViewModel : ViewModelBase
{
var dialog = new GoLiveViewModel(() => SignInAsync(), _youtubeAuth.CurrentChannel)
{
- StreamTitle = DefaultStreamTitle,
- StreamDescription = DefaultStreamDescription,
+ StreamTitle = BroadcastForm.Title,
+ StreamDescription = BroadcastForm.Description,
};
var window = new ytLive.GoLiveWindow(dialog) { Owner = System.Windows.Application.Current.MainWindow };
if (window.ShowDialog() == true)
@@ -2908,6 +2920,7 @@ public class MainViewModel : ViewModelBase
StreamTitle = dialog.StreamTitle;
StreamDescription = dialog.StreamDescription;
StreamVisibility = dialog.Visibility; // always "Private" (ship step 7)
+ BroadcastForm.CaptureGoLive(dialog.StreamTitle, dialog.StreamDescription, DateTime.UtcNow);
WindowTitle = string.IsNullOrWhiteSpace(dialog.StreamTitle)
? "LlamaCasty"
: $"{dialog.StreamTitle} — LlamaCasty";
@@ -2950,6 +2963,7 @@ public class MainViewModel : ViewModelBase
return;
}
AppLog.Write($"Broadcast created: {_currentBroadcastId}");
+ OnPropertyChanged(nameof(CurrentBroadcastId));
// Start chat polling (TASK 9.4): fetch the liveChatId from the broadcast's
// contentDetails, then start the poll loop. Chat is non-fatal — if it fails,
@@ -3048,6 +3062,7 @@ public class MainViewModel : ViewModelBase
_audioMixer.StopLive();
_ = _framePump.StopAsync();
_currentBroadcastId = null;
+ OnPropertyChanged(nameof(CurrentBroadcastId));
// Graceful end completes the session = signs out (the DPAPI token is
// cleared so the next Start Stream requires a fresh sign-in). A crash
// never runs this, so the token survives and the creator stays signed in.
diff --git a/ai.md b/ai.md
index 2f66b0e..b59c8f9 100644
--- a/ai.md
+++ b/ai.md
@@ -161,6 +161,7 @@ The AI hallucinated through multiple commits tonight on background/scene propert
- TASK 10: Polar billing — license key entry, offline entitlement, brand flash gating, Velopack auto-updates
- TASK 19/23 merged: Control Surface UX — director's control room, transitions (Cut/Fade/Move), thumbnails above central monitor, edit mode offline only, right panel eliminated
- TASK 20: Hotkeys (global keyboard shortcuts) — the single biggest UX gap
+ - Broadcast metadata pull-out + launch geometry — shipped 2026-08-24 ("Text" tab on the Live screen, see its section below)
- TASK 21: Media source (video file playback) — starting soon videos, BRB loops
- TASK 22: Audio sync offset — lip-sync correction for USB mics/capture cards
- These were identified through competitive analysis and are table-stakes for any streaming software in 2025-2026. Full details in `TASKS.md`.
@@ -192,6 +193,22 @@ The app is a **director's control surface**, not an editor. This replaces the OB
- `MainViewModel.HandleHotkey` maps ids to `TransitionToScene`/start-end commands (honoring `CanExecute`)/mute toggles/`TraxLeftClick`.
- Tests: `RegistrarOverride` static seam mirrors `LayoutPathOverride`. **Landmine:** forcing a WPF window's handle with `WindowInteropHelper.Handle` does NOT raise `SourceInitialized` and returns Zero pre-show — use `EnsureHandle()` in tests; it both creates the HWND and fires the event.
+### Broadcast metadata pull-out + launch geometry (2026-08-24)
+
+**The "Text" tab** (Live screen only): a white 30px tab with rotated YouTube-red "Text" sits on the right edge of the preview, vertically centered, visible only while `IsLive` (`IsBroadcastMetadataVisible`). Click slides out a 320px dark drawer over the preview's right edge (200ms CubicEase on `Width`, `BroadcastForm.IsDrawerOpen` drives it; ✕ closes).
+
+**Fields — the always-editable snippet/status set** (⚠️ creator premise inverted: contentDetails fields like latency/DVR/embed are editable ONLY in created/ready, NOT while live; they are deliberately absent):
+- Title / Description / Tags (comma-separated string → `snippet.tags[]`) / Visibility ComboBox (private/unlisted/public) / Made-for-Kids CheckBox — all live-editable pre- and post-launch
+- Scheduled Start is read-only ("set at Go Live")
+
+**Plumbing:** `Models/BroadcastMetadata` POCO; `Services/LiveBroadcastFormViewModel` (owns field state, persists every edit to Settings keys `Broadcast.*` immediately via `Func` seam — survives layout re-pointing; owns drawer state; `UpdateBroadcastCommand` gated by `CurrentBroadcastId != null && !IsUpdating`). `YouTubeStreamService.UpdateBroadcast` PUTs `liveBroadcasts?part=snippet,status` and **must echo scheduledStartTime — update replaces the whole snippet part**, omitting it clears the schedule. Go Live prefills the dialog from the form (`BroadcastForm.Title/Description`) and calls `CaptureGoLive(title, description, now)` on confirm. `MainViewModel.CurrentBroadcastId` exposes the private `_currentBroadcastId`; notified on create/end.
+
+**The old Default Stream Title/Description** (un-persisted, App Settings overlay) are DELETED — the form owns those fields end-to-end; Go Live prefill routes through it.
+
+**Launch geometry:** default **1920×1040**, MinWidth **1366**, MinHeight **768**, `WindowStartupLocation=Manual`. Window size/position persist on close via `RestoreBounds` (+ WindowState Normal/Maximized; Minimized treated as Normal) into Settings keys `Window.*`; restored in the MainWindow ctor BEFORE show, clamped to the minimums and the primary `SystemParameters.WorkArea` (a position saved on a since-disconnected secondary monitor falls back on-screen). Multi-monitor restore is not supported yet.
+
+Test seam note: `YouTubeAuthService.SetSession()` is public — tests inject a channel with future `TokenExpiry` so `EnsureToken()` passes offline; pair with an `HttpClient(new StubHandler())`.
+
### Screen backdrop capture (TASK 3 ship task #1) — model reworked by TASK 25
**The Background is a locked per-screen layer (TASK 25): exactly one per canonical
diff --git a/ytLive.Tests/BroadcastPullOutTests.cs b/ytLive.Tests/BroadcastPullOutTests.cs
new file mode 100644
index 0000000..e2a0b84
--- /dev/null
+++ b/ytLive.Tests/BroadcastPullOutTests.cs
@@ -0,0 +1,149 @@
+using System;
+using System.Net;
+using System.Net.Http;
+using System.Text.Json;
+using Xunit;
+using ytLive.Models;
+using ytLive.Services;
+
+namespace ytLive.Tests;
+
+///
+/// TASK: broadcast metadata pull-out + launch geometry. One integration slice:
+/// (1) pull-out edits persist immediately to the Settings store and re-load,
+/// (2) a real MainWindow restores saved size/position, clamping off-screen or
+/// below-minimum values into the primary work area, (3) UpdateBroadcast PATCHes
+/// liveBroadcasts?part=snippet,status with the full editable field set.
+///
+[Collection("RealApp")]
+public sealed class BroadcastPullOutTests
+{
+ public BroadcastPullOutTests(RealAppHost app) => _app = app;
+
+ private readonly RealAppHost _app;
+
+ [Fact]
+ public void Metadata_Persists_WindowRestores_Clamped_And_UpdatePatchesRemote()
+ {
+ _app.Run(Run);
+ }
+
+ private sealed class StubHandler : HttpMessageHandler
+ {
+ public HttpRequestMessage? Request;
+ public string? Body;
+
+ protected override Task SendAsync(
+ HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ Request = request;
+ Body = request.Content?.ReadAsStringAsync(cancellationToken).GetAwaiter().GetResult();
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("{\"id\":\"bc123\"}", System.Text.Encoding.UTF8, "application/json"),
+ });
+ }
+ }
+
+ private void Run()
+ {
+ var tempDb = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"ytLlive-pullout-{Guid.NewGuid():N}.db");
+ ViewModels.MainViewModel.LayoutPathOverride = tempDb;
+ MainWindow? window = default;
+ try
+ {
+ using (var schema = new LayoutStore(tempDb)) { }
+ Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
+
+ var vm = new ViewModels.MainViewModel();
+
+ vm.BroadcastForm.Title = "Hello";
+ vm.BroadcastForm.Description = "World";
+ vm.BroadcastForm.TagsCsv = "a, b ,c";
+ vm.BroadcastForm.Visibility = "public";
+ vm.BroadcastForm.MadeForKids = true;
+
+ using (var reader = new LayoutStore(tempDb))
+ {
+ var meta = reader.LoadBroadcastMetadata();
+ Assert.Equal("Hello", meta.Title);
+ Assert.Equal("World", meta.Description);
+ Assert.Equal("a, b ,c", meta.TagsCsv);
+ Assert.Equal("public", meta.Visibility);
+ Assert.True(meta.MadeForKids);
+ }
+
+ vm.BroadcastForm.Load();
+ Assert.Equal("Hello", vm.BroadcastForm.Title);
+
+ vm.StreamStatus = StreamStatus.Streaming;
+ Assert.True(vm.IsBroadcastMetadataVisible);
+ vm.StreamStatus = StreamStatus.Offline;
+ Assert.False(vm.IsBroadcastMetadataVisible);
+
+ using (var writer = new LayoutStore(tempDb))
+ writer.SaveWindowState(100, 50, 1500, 900, "Normal");
+
+ window = new MainWindow();
+ Assert.Equal(1500, window.Width);
+ Assert.Equal(900, window.Height);
+ Assert.Equal(100, window.Left);
+ Assert.Equal(50, window.Top);
+
+ window.Close();
+ window = null;
+
+ using (var writer = new LayoutStore(tempDb))
+ writer.SaveWindowState(-99999, -99999, 800, 500, "Normal");
+
+ window = new MainWindow();
+ var work = System.Windows.SystemParameters.WorkArea;
+ Assert.Equal(1366, window.Width);
+ Assert.Equal(768, window.Height);
+ Assert.InRange(window.Left, work.Left - window.Width + 120, work.Right - 120);
+ Assert.InRange(window.Top, work.Top, work.Bottom - 80);
+
+ var auth = new YouTubeAuthService("test-client", "test-secret");
+ auth.SetSession(new YouTubeChannel
+ {
+ AccessToken = "token",
+ TokenExpiry = DateTime.UtcNow.AddHours(1),
+ });
+ var stub = new StubHandler();
+ var service = new YouTubeStreamService(auth, new HttpClient(stub));
+
+ var error = service.UpdateBroadcast("bc123", new BroadcastMetadata
+ {
+ Title = "T",
+ Description = "D",
+ TagsCsv = "x,y",
+ Visibility = "Public",
+ MadeForKids = true,
+ ScheduledStartTime = new DateTime(2026, 8, 24, 12, 0, 0, DateTimeKind.Utc),
+ }).GetAwaiter().GetResult();
+
+ Assert.Null(error);
+ Assert.NotNull(stub.Request);
+ Assert.Equal(HttpMethod.Put, stub.Request!.Method);
+ Assert.Equal("https://www.googleapis.com/youtube/v3/liveBroadcasts?part=snippet,status",
+ stub.Request.RequestUri!.ToString());
+
+ using var doc = JsonDocument.Parse(stub.Body!);
+ var root = doc.RootElement;
+ Assert.Equal("bc123", root.GetProperty("id").GetString());
+ Assert.Equal("T", root.GetProperty("snippet").GetProperty("title").GetString());
+ Assert.Equal(2, root.GetProperty("snippet").GetProperty("tags").GetArrayLength());
+ Assert.Equal("public", root.GetProperty("status").GetProperty("privacyStatus").GetString());
+ Assert.True(root.GetProperty("status").GetProperty("selfDeclaredMadeForKids").GetBoolean());
+ Assert.Equal(new DateTime(2026, 8, 24, 12, 0, 0, DateTimeKind.Utc).ToString("o"),
+ root.GetProperty("snippet").GetProperty("scheduledStartTime").GetString());
+ }
+ finally
+ {
+ window?.Close();
+ ViewModels.MainViewModel.LayoutPathOverride = null;
+ Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
+ try { System.IO.File.Delete(tempDb); } catch { /* best-effort cleanup */ }
+ }
+ }
+}