From 801219ec465497804107f7071d0914763f9519b2 Mon Sep 17 00:00:00 2001 From: gramps Date: Fri, 14 Aug 2026 09:20:03 -0700 Subject: [PATCH] =?UTF-8?q?TASK=204=20ship=20step=207:=20one-click=20go=20?= =?UTF-8?q?live=20+=20private-only=20enforcement=20(req=208)=20=E2=80=94?= =?UTF-8?q?=20the=20Go=20Live=20dialog=20is=20locked=20to=20Private=20(GoL?= =?UTF-8?q?iveViewModel.Visibility=20is=20a=20get-only=20string,=20no=20dr?= =?UTF-8?q?opdown;=20settings'=20dead=20Default=20Visibility=20dropdown=20?= =?UTF-8?q?+=20Visibilities/DefaultStreamVisibility=20removed);=20YouTubeS?= =?UTF-8?q?treamService.CreateBroadcast=20always=20sends=20privacyStatus?= =?UTF-8?q?=3D'private'=20and=20gained=20an=20injectable=20HttpClient=3F?= =?UTF-8?q?=20http=20=3D=20null=20seam;=20BeginGoLive=20fires=20CreateBroa?= =?UTF-8?q?dcastAsync=20which=20stashes=20=5FcurrentBroadcastId=20for=20TA?= =?UTF-8?q?SK=205's=20bind/transition=20(failure=20=E2=86=92=20StreamStatu?= =?UTF-8?q?s.Error=20via=20AppLog,=20never=20a=20crash)=20alongside=20the?= =?UTF-8?q?=20frame=20pump,=20and=20StopStream=20clears=20it;=20the=20REC?= =?UTF-8?q?=20sign=20shows=20a=20dark-red=20PRIVATE=20badge=20next=20to=20?= =?UTF-8?q?REC=20whenever=20the=20live=20stream=20is=20private=20(IsLivePr?= =?UTF-8?q?ivate)=20=E2=80=94=20the=20broadcast-insert=20integration=20tes?= =?UTF-8?q?t=20asserts=20the=20request=20body=20carries=20privacyStatus=20?= =?UTF-8?q?private=20(RecordingHandler=20seam)=20+=20a=20no-session=20guar?= =?UTF-8?q?d=20test=20=E2=80=94=20173=20tests=20passing,=200=20warnings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- GoLiveWindow.xaml | 10 ++- HANDOFF.md | 80 ++++++++++------------- MainWindow.xaml | 12 ++-- Services/YouTubeStreamService.cs | 9 ++- Services/index.md | 2 +- TASKS.md | 2 +- ViewModels/GoLiveViewModel.cs | 14 ++-- ViewModels/MainViewModel.cs | 42 ++++++++---- ViewModels/index.md | 4 +- ai.md | 10 +++ ytLive.Tests/YouTubeStreamServiceTests.cs | 65 ++++++++++++++++++ 11 files changed, 167 insertions(+), 83 deletions(-) create mode 100644 ytLive.Tests/YouTubeStreamServiceTests.cs diff --git a/GoLiveWindow.xaml b/GoLiveWindow.xaml index 91b826d..83afe74 100644 --- a/GoLiveWindow.xaml +++ b/GoLiveWindow.xaml @@ -99,9 +99,13 @@ VerticalScrollBarVisibility="Auto"/> - + + + + diff --git a/HANDOFF.md b/HANDOFF.md index 36646aa..5221ab4 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -5,49 +5,42 @@ > a problem. Conventions: [`schema.md`](schema.md). Rewrite this file at session > end, compaction, or any interruption. -## Session state (last updated: 2026-08-13) +## Session state (last updated: 2026-08-14) - **Branch:** `main`, in sync with `origin/main`. -- **TASK 8 (new, after TASK 7 shipped):** `AudioLevelMeter.ToDisplay` gained **+10 dB input - amplification** so the meters use the full bar — speech peaks (~0.2 RMS) now read ~0.93 (red) - and normal speech (~0.05) ~0.73 (yellow) at maxed volume; ≤0.001 linear still reads 0 (idle - never shows noise). One knob shared by the mic bar and game bar; `× MicVolume` untouched. - Tests updated + new `ToDisplay_Pushes_Speech_Peaks_Into_Red_At_Maxed_Volume`. **Uncommitted** - (pending user review): `Services/Audio/AudioLevelMeter.cs`, `ytLive.Tests/AudioMixerTests.cs`, - `ai.md`, `Services/index.md`, `ViewModels/index.md`, `TASKS.md`, `HANDOFF.md`. Meter tests: - 7/7 passing, build 0 warnings. -- **This session (TASK 7 — UI polish batch, gramps's 6-point review):** - 1. **Scenes list cleaned:** the per-row edit/trash/visibility icons and the inline - rename TextBox are gone. Scenes are pure selection rows; `IsHidden` stays - persisted and still dims a hidden row to 45%. Removed dead surface: - `EditSceneCommand`/`RemoveSceneCommand`/`ToggleSceneVisibilityCommand` + - `BeginEditScene`/`ToggleSceneVisibility`/`RemoveScene` handlers + `Scene.IsEditing`. - 2. **Sources list upgraded:** each row now has edit + visibility eye + trash. New - `EditElementCommand` (`SceneElement.IsEditing` → inline rename TextBox, Enter/Esc/ - lost-focus commits) and `ToggleElementVisibilityCommand` (flips `SceneElement.IsVisible`); - the eye style now binds `IsVisible` and hidden rows dim to 45%. - 3. **Duplicate naming:** shared `NextSourceName(scene, baseName)` → `Image`, `Image2`, - `Image3`… (no space), next free number derived from actual names so deletions never - collide. Used by both `AddSource` and `AddReusedImage`. - 4. **Social bar:** `MaxWidth=200` + `CharacterEllipsis` removed from BOTH - `SocialBarRenderer.cs` and the preview DataTemplate — full validated handle renders. - 5. **Panels:** left 220 / right 300 fixed widths are deliberate — panels never re-layout - on resize; the preview absorbs it. No change. - 6. **Focus-loss capture lag:** recorded in `ai.md` as a known OS limit (DWM/WGC - throttling when unfocused + GPU readback contention + the `_framePending` / - `DispatcherPriority.Render` gates). NOT an in-app throttle; deferred by user decision. -- **Test infra change:** the two real-WPF-App tests (round-clip + new source-naming) now - share `RealAppHost` — a dedicated STA thread owning the single `App` — via the `RealApp` - serial collection. WPF allows exactly one `Application` per AppDomain; never add a test - that calls `new App()` directly again — marshal onto `RealAppHost` instead. -- **Uncommitted:** `MainWindow.xaml` + `MainWindow.xaml.cs` (rows), `ViewModels/MainViewModel.cs` - (commands + naming), `Models/SceneElement.cs` (+`IsEditing`), `Models/Scene.cs` (−`IsEditing`), - `Services/Compositor/SocialBarRenderer.cs` (no truncation), `ytLive.Tests/SourceNamingTests.cs` - (new), `ytLive.Tests/RealAppCollection.cs` (new), `ytLive.Tests/RoundClipInteractionTests.cs` - (now uses the shared host), `TASKS.md` (TASK 7 ✅), `ai.md`, `ViewModels/index.md`, `HANDOFF`. -- **Verified:** build 0 warnings / 0 errors; **170/170 tests pass** (169 + the naming test). +- **TASK 8 — meter scaling amplification:** SHIPPED + pushed (`0b71b03`). `AudioLevelMeter.ToDisplay` + adds **+10 dB input amplification** before the −60..0 dBFS log map: speech peaks (~0.2 RMS) read + ~0.93 (red) and normal speech (~0.05) ~0.73 (yellow) at maxed volume; ≤0.001 linear still reads 0 + (never idles on background noise). One knob shared by the mic + game bars; `× MicVolume` untouched. +- **TASK 4 ship step 7 — one-click go live + private-only enforcement: SHIPPED** (this session; the + final open TASK 4 requirement). Double-enforced privacy: + 1. **Dialog locked to Private** — `GoLiveViewModel.Visibility` is a get-only `"Private"`, the + visibility ComboBox is gone (replaced by a "Streams always start Private" note); settings' dead + "Default Visibility" dropdown + `MainViewModel.Visibilities`/`DefaultStreamVisibility` removed. + 2. **Service forces private** — `YouTubeStreamService.CreateBroadcast` always sends + `privacyStatus="private"`; ctor gained `HttpClient? http = null` seam for tests. + 3. **Wired into go-live** — `BeginGoLive` fires `CreateBroadcastAsync` (stores `_currentBroadcastId` + for TASK 5's bind/transition; failure → `StreamStatus.Error` via AppLog, never a crash) + + `_framePump.StartAsync()`; `StopStream` clears `_currentBroadcastId`. + 4. **PRIVATE badge** — dark-red bordered, next to REC in the top bar, `Visibility="{Binding + IsLivePrivate}"` (MainWindow.xaml ~110). + 5. **Integration test** — `ytLive.Tests/YouTubeStreamServiceTests.cs`: the broadcast-insert request + must carry `"privacyStatus":"private"` (RecordingHandler seam) + a no-session → null guard test. +- **Verified:** build 0 warnings / 0 errors; **173/173 tests pass** (171 + the 2 stream-service tests). +- **Uncommitted (this session's ship step 7 batch):** `GoLiveWindow.xaml`, `ViewModels/GoLiveViewModel.cs`, + `ViewModels/MainViewModel.cs`, `Services/YouTubeStreamService.cs`, `MainWindow.xaml`, + `ytLive.Tests/YouTubeStreamServiceTests.cs` (new), `TASKS.md`, `ai.md`, `ViewModels/index.md`, + `Services/index.md`, `HANDOFF.md`. **Commit + push before continuing.** +- **Next step:** commit + push ship step 7, then **TASK 5 — reusable `variable` stream** (create once + per channel, cache ingestion URL, bind to broadcast, feed `_rtmpUrlProvider` so the pump actually + pushes — `_currentBroadcastId` is already stashed for it). Also queued: task 21 (logo + About hub), + task 22 (voice filters). Nothing else queued — do not expand the task queue on your own. Optional, + not queued: rewriting the healed entry's `ProfileUrl` to `https://mastodon.llamachile.tube/@gramps` + (user must say the word). + - **Landmines:** - - Never add another test that constructs `new App()` — use `RealAppHost.Run(...)`. + - Never add another test that constructs `new App()` — use `RealAppHost.Run(...)` (shared STA host + for the one WPF App per AppDomain; round-clip + source-naming tests). - Never set a local `Canvas.SetTop` on the social bar — a local value permanently overrides `{Binding SocialBarTop}` (the `ClearValue` lesson from 5.5). - `AudioMixer` meter `Push` is unconditional **by design now**: `OnMicSample`/ @@ -78,13 +71,6 @@ (red); it goes green ONLY when the mixer's `MicConnected` fires, which comes strictly from the mic source's `Started` event raised after `StartRecording()` succeeds. Zero devices at startup → stays red and the mixer is never started; capture failure → yellow. -- **Next step:** commit + push this TASK 7 batch (one commit). Then the remaining TASK 4 - requirement is **ship step 7 — one-click go live + private-only enforcement** (honor the - dialog's chosen visibility / enforce `privacyStatus = "private"`; also fixes the REC sign's - private state — see round-3 note). Also queued: task 21 (logo + About hub), task 22 (voice - filters). Nothing else queued — do not expand the task queue on your own. Optional, not - queued: rewriting the healed entry's `ProfileUrl` to `https://mastodon.llamachile.tube/@gramps` - (user must say the word). - **Secret/DB/port facts live:** OAuth client id/secret in `Helpers/OAuthCredentials.cs`; OAuth session token in `Helpers/TokenStore.cs` (DPAPI → `%APPDATA%\ytLlive\ytLlive.auth`); diff --git a/MainWindow.xaml b/MainWindow.xaml index d46a109..6972a1c 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -105,6 +105,11 @@ Fill="{Binding RecDotBrush}" Opacity="{Binding RecDotOpacity}"/> + + + @@ -1064,12 +1069,7 @@ Text="{Binding DefaultStreamDescription, UpdateSourceTrigger=PropertyChanged}" VerticalScrollBarVisibility="Auto"/> - - - - diff --git a/Services/YouTubeStreamService.cs b/Services/YouTubeStreamService.cs index 0e88b75..50f07c9 100644 --- a/Services/YouTubeStreamService.cs +++ b/Services/YouTubeStreamService.cs @@ -12,12 +12,13 @@ namespace ytLive.Services; public class YouTubeStreamService { private readonly YouTubeAuthService _auth; - private readonly HttpClient _http = new(); + private readonly HttpClient _http; private const string ApiBase = "https://www.googleapis.com/youtube/v3"; - public YouTubeStreamService(YouTubeAuthService auth) + public YouTubeStreamService(YouTubeAuthService auth, HttpClient? http = null) { _auth = auth; + _http = http ?? new HttpClient(); } private async Task EnsureToken() @@ -43,7 +44,9 @@ public class YouTubeStreamService }, status = new { - privacyStatus = "public", + // Private-only by enforcement (ship step 7) — the Go Live dialog + // is locked to Private and the service refuses anything else. + privacyStatus = "private", selfDeclaredMadeForKids = false }, contentDetails = new diff --git a/Services/index.md b/Services/index.md index cd80b8d..5fccd28 100644 --- a/Services/index.md +++ b/Services/index.md @@ -6,7 +6,7 @@ External-facing logic: YouTube API, persistence. See | File | Purpose | |------|---------| | `YouTubeAuthService.cs` | OAuth2 via Google: loopback callback (`http://localhost:8765/oauth2/callback`), token exchange, refresh, channel fetch. Constructor takes optional `HttpClient` + `sessionChanged` callback (test seam + save hook); session persists via `Helpers/TokenStore` (DPAPI); `ClearSession()` signs out (called by `MainViewModel.StopStream` on End Livestream) | -| `YouTubeStreamService.cs` | Broadcast/stream management via the v3 API (`enableAutoStart/Stop`). **Not yet switched to the `variable` reusable stream** | +| `YouTubeStreamService.cs` | Broadcast/stream management via the v3 API (`enableAutoStart/Stop`). **`CreateBroadcast` always sends `privacyStatus = "private"`** (ship step 7 — private-only until v1; injectable `HttpClient? http = null` ctor seam so tests can fake the API and assert the forced privacy). **Not yet switched to the `variable` reusable stream** | | `YouTubeChatService.cs` | Polls `liveChat/messages`, raises `MessageReceived`; `IDisposable` | | `LayoutStore.cs` | SQLite persistence (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; assets stored as BLOBs keyed by SHA-256 content hash; save/open layout files; schema `user_version` 6 (`Source.ClipShape`/`IsMirrored` via `ALTER TABLE` for pre-v2 DBs; v3 = singleton `Webcam` + per-scene `WebcamSceneConfig`, migrated idempotently **without backfill** — the stale `Source.DeviceId` column remains but is no longer read/written; v4 = `WebcamSceneConfig.RectWidth`/`RectHeight`, the pre-Round rect for the round-to-rect restore; v5 = `Source.IsBackdrop` + `Source.CaptureKey`, the live-capture backdrop; v6 = `Scene.HasBackdrop` — Live-only policy, one-time backfill turns Starting/BRB/Chat/Ending off + drops their backdrop sources; `MainViewModel.EnforceBackdropPolicy` re-normalizes on every load) | | `VideoFrame.cs` | Normalized CPU frame seam (`Width`/`Height`/tightly-packed BGRA `byte[]`) — the only pixel type the rest of the app knows about; future capture sources (screen, background-removed webcam) feed the same seam | diff --git a/TASKS.md b/TASKS.md index 2317a69..0578516 100644 --- a/TASKS.md +++ b/TASKS.md @@ -205,7 +205,7 @@ Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four 4. ✅ **WASAPI audio capture SHIPPED** (2026-08-12) — NAudio loopback (desktop/game) + the picked mic feeding `AudioLevel`, so the realtime meter comes alive (see the ship step 4 plan below) 5. ✅ **Frame-pipeline wiring SHIPPED** (2026-08-12) — `CameraManager`/`ScreenCaptureManager` → compositor resolver → encoder, driven by a paced `FramePump` (see the ship step 5 plan below) 6. ✅ **Health stats SHIPPED** (2026-08-13) — `FramePump.HealthUpdated` (encoder's parsed bitrate/FPS/dropped/duration, already forwarded from `FfmpegEncoder.OnStderrLine`) now lands in the bottom bar: `MainViewModel.OnFramePumpHealthUpdated` marshals to the UI thread (the stderr loop raises on a background thread) and copies into `CurrentHealth` (the bottom bar's existing binding); `ResetHealth` zeroes dropped/duration on go-live and on End so stats never linger from a previous session (bitrate/FPS stay on the tier's targets). The bar lights up with real values once TASK 5 supplies the RTMP URL (until then the pump skips the encoder and the bar shows the tier's targets) -7. ☐ **One-click go live + private-only enforcement** — Go Live always creates/updates the broadcast with `privacyStatus = "private"` + PRIVATE badge (req 8, test-verifiable) +7. ✅ **One-click go live + private-only enforcement SHIPPED** (2026-08-14) — the Go Live dialog is **locked to Private** (no dropdown, `GoLiveViewModel.Visibility` is a get-only "Private"); `YouTubeStreamService.CreateBroadcast` **always sends `privacyStatus = "private"`** (dialog + service enforcement, requirement 8 — nothing can go out non-private) and gained an injectable `HttpClient? http = null` seam for tests; `BeginGoLive` now calls `CreateBroadcastAsync` and remembers `_currentBroadcastId` for TASK 5's bind/transition (failure → `StreamStatus.Error`, never a crash; `StopStream` clears the ID); the REC sign shows a **PRIVATE badge** (dark-red border, next to REC, `IsLivePrivate`) when the live stream is private; settings' dead "Default Visibility" dropdown + `MainViewModel.Visibilities`/`DefaultStreamVisibility` removed. The broadcast-insert integration test asserts the request body carries `"privacyStatus":"private"` (2 new tests → 173 passing, 0 warnings) The pipeline chain the encoder needs doesn't exist yet: **scene compositing** (the master 1920×1080 frame without the preview's editing chrome) → **audio capture** (WASAPI, feeds the meter) → **H.264+AAC encode** diff --git a/ViewModels/GoLiveViewModel.cs b/ViewModels/GoLiveViewModel.cs index 1b7157e..0e21a71 100644 --- a/ViewModels/GoLiveViewModel.cs +++ b/ViewModels/GoLiveViewModel.cs @@ -9,12 +9,16 @@ public class GoLiveViewModel : ViewModelBase private readonly Func> _signInProvider; private string _streamTitle = string.Empty; private string _streamDescription = string.Empty; - private string _visibility = "Public"; private bool _isSignedIn; private string _accountDisplayName = string.Empty; private string _accountAvatarUrl = string.Empty; private bool _isBusy; + /// Streams always start Private (ship step 7 — private-only by + /// enforcement); the creator can change visibility on YouTube after going + /// live. No dropdown — no dead-end option. + public string Visibility => "Private"; + public GoLiveViewModel(Func> signInProvider, YouTubeChannel? account) { _signInProvider = signInProvider; @@ -39,14 +43,6 @@ public class GoLiveViewModel : ViewModelBase set => SetProperty(ref _streamDescription, value); } - public string Visibility - { - get => _visibility; - set => SetProperty(ref _visibility, value); - } - - public string[] Visibilities { get; } = { "Public", "Unlisted", "Private" }; - public bool IsSignedIn { get => _isSignedIn; diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index d121bb9..15fb4ba 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -55,7 +55,8 @@ public class MainViewModel : ViewModelBase private StreamHealth _currentHealth = new(); private string _streamTitle = string.Empty; private string _streamDescription = string.Empty; - private string _streamVisibility = "Public"; + private string _streamVisibility = "Private"; + private string? _currentBroadcastId; private string _windowTitle = "ytLlive"; private string _topBarBackground = "#16213e"; private string _previewGlowBrush = "Transparent"; @@ -71,7 +72,6 @@ public class MainViewModel : ViewModelBase private string _overlayTitle = string.Empty; private string _defaultStreamTitle = string.Empty; private string _defaultStreamDescription = string.Empty; - private string _defaultStreamVisibility = "Public"; private string _bugReportText = string.Empty; private string _bugReportEmail = string.Empty; private string _featureRequestText = string.Empty; @@ -134,7 +134,6 @@ public class MainViewModel : ViewModelBase public ObservableCollection Scenes { get; } = new(); public ObservableCollection ChatMessages { get; } = new(); public ObservableCollection Displays { get; } = new(); - public string[] Visibilities { get; } = { "Public", "Unlisted", "Private" }; public Scene? ActiveScene { @@ -764,12 +763,6 @@ public class MainViewModel : ViewModelBase set => SetProperty(ref _defaultStreamDescription, value); } - public string DefaultStreamVisibility - { - get => _defaultStreamVisibility; - set => SetProperty(ref _defaultStreamVisibility, value); - } - // ─── Resolution quality dropdown (bottom bar) ─── // Tiers offered to the creator before going live. First = default. The // dropdown is disabled while live because YouTube stream resolution is @@ -1954,23 +1947,49 @@ public class MainViewModel : ViewModelBase { StreamTitle = DefaultStreamTitle, StreamDescription = DefaultStreamDescription, - Visibility = DefaultStreamVisibility, }; var window = new ytLive.GoLiveWindow(dialog) { Owner = System.Windows.Application.Current.MainWindow }; if (window.ShowDialog() == true) { StreamTitle = dialog.StreamTitle; StreamDescription = dialog.StreamDescription; - StreamVisibility = dialog.Visibility; + StreamVisibility = dialog.Visibility; // always "Private" (ship step 7) WindowTitle = string.IsNullOrWhiteSpace(dialog.StreamTitle) ? "ytLlive" : $"{dialog.StreamTitle} — ytLlive"; StreamStatus = StreamStatus.Streaming; ResetHealth(StreamStatus.Streaming); + _ = CreateBroadcastAsync(); _ = _framePump.StartAsync(); // never throws; failures log + surface via Failed } } + /// Creates the YouTube broadcast (always private — enforced in the + /// stream service) and remembers its ID for TASK 5's bind/transition. A + /// failure surfaces as an error health state, never a crash. + private async Task CreateBroadcastAsync() + { + try + { + _currentBroadcastId = await _youtubeStream.CreateBroadcast( + StreamTitle, StreamDescription, DateTime.UtcNow); + if (_currentBroadcastId == null) + { + AppLog.Write("Broadcast creation failed; check the OAuth session"); + StreamStatus = StreamStatus.Error; + } + else + { + AppLog.Write($"Broadcast created: {_currentBroadcastId}"); + } + } + catch (Exception ex) + { + AppLog.Write($"Broadcast creation failed: {ex.Message}"); + StreamStatus = StreamStatus.Error; + } + } + private void StopStream() { StreamStatus = StreamStatus.Offline; @@ -1979,6 +1998,7 @@ public class MainViewModel : ViewModelBase // Audio capture is always-on (preview monitoring); only the frame pump // and the session stop here. _ = _framePump.StopAsync(); + _currentBroadcastId = null; // 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/ViewModels/index.md b/ViewModels/index.md index 78ef6b6..344e477 100644 --- a/ViewModels/index.md +++ b/ViewModels/index.md @@ -4,8 +4,8 @@ MVVM layer. See [`schema.md`](../schema.md) for the memory-map conventions. | File | Purpose | |------|---------| -| `MainViewModel.cs` | The app brain: scenes/elements collections + commands, stream state (`IsLive`/`IsOffline`/`IsConnected`), chat feed, overlays, layout save/open, **resolution dropdown** (`QualityOptions`, `SelectedQuality`, `ResolutionHelp`, `ApplyStreamQuality`) that computes the output rect over the 1920×1080 master (`OutputRectX/Y/W/H`, `DimRects`, `IsOutputCropped`, `ResolutionBadgeText`). Auth: loads the saved DPAPI session at startup (`LoadSavedSessionAsync`), `SignInAsync` feeds the GoLive dialog; **End Livestream signs out** (`StopStream` clears session + token — crash-safe). **Connected account:** top-bar avatar/name (`AccountAvatarUrl`/`AccountDisplayName` via `SyncConnectedAccount`, cleared on End). **Element rows:** scenes are pure selection rows (no per-row icons); source rows carry edit/visibility/trash (`EditElementCommand` sets `element.IsEditing`, `ToggleElementVisibilityCommand` flips `IsVisible`, `RemoveSourceCommand`); duplicate resource names get a no-space incrementing suffix via shared `NextSourceName` (Image, Image2, Image3… — next free number derived from actual names, so deletions never collide; used by `AddSource` + `AddReusedImage`). **Audio (KISS — the mic is the creator's only audio control; capture SHIPPED, runs for the app's lifetime so the meters preview live):** the sound meter is a **READ-ONLY realtime level display**: fill = `Math.Min(1, AudioLevelMeter.ToDisplay((float)AudioLevel) * MicVolume)` (`MeterFillWidth`/`MeterBrush`, green→yellow→red, zone markers at 60%/80% — `ToDisplay` maps the raw linear RMS onto a −60..0 dBFS scale with +10 dB amplification so real speech/game levels occupy the bar and peaks hit red at maxed volume), where `AudioLevel` (live input, 0 with no input) is fed by the audio mixer once capture lands and `MicVolume` acts as a gain on ambient noise; while the slider is dragged the bar previews the slider position (`SetVolumeAdjusting`), returning to the live level on release (0 with no input — clicking the meter does nothing); `MicVolume` (default 0.8) + read-only `MicMuted`/`MicMuteText`/`ToggleMicMuteCommand` — **MicVolume drives MicMuted** (muted ⇔ volume 0): sliding to 0 flips the speaker to muted, sliding up from 0 clears it; muting stores the prior volume, unmuting restores it (default 0.8 if unknown) and flashes the meter to the restored position ~300ms (`BeginVolumeFlash`/`EndVolumeFlash`); `MicSourceName` = picked voice source, shown left-justified inside the meter bar (the fill runs at 75% opacity so the text + ruler markings show through); `OpenMicPickerCommand`/`PickMicrophone()` open the `MicPickerDialog` (a picked device takes effect immediately — `PickMicrophone` swaps the live source via `_audioMixer.RestartMic()`, loopback keeps running); the **MIC label is a button** (`OpenMicPickerCommand`) with a **status dot** (`MicStatus`, `Models/MicStatus`: green = `MicConnected` via the source's `Started` event, yellow = `MicFailed` — in use/unplugged, red = no mic device at startup; `MicStatusBrush`/`MicStatusToolTip`); capture starts once at startup (`StartMicCaptureAsync`) — NOT go-live (`BeginGoLive`/`StopStream` no longer touch the mixer) — zero devices = red dot + the mixer never starts. **Game audio bar** (desktop/game, **overlaid at the bottom of the preview window** — bottom-center chip, a mirror of the mic bar): `IsGameAudioBarVisible` (shown only while a full-screen game is producing sound — the VM polls `IGameAudioDetector` via the default `GameAudioDetector` every 250ms (`_gameAudioTimer`); the pure `GameAudioHysteresis` SHOWs after ~500ms of fullscreen+sound, HIDEs ~1s after leaving fullscreen, and **silence never hides an active bar**), `GameAudioLevel` (loopback meter via `LoopbackLevelChanged`, scaled by volume), `GameMuted`/`GameMuteText`/`ToggleGameMuteCommand`, `GameAudioVolume` (0..1 volume slider), `Begin/End/CancelGameVolumeFlash` (mirrors the mic bar's volume flash); the game speaker + slider share the mic bar's `PreviewMouseLeftButtonDown/Up` + `LostMouseCapture` code-behind pattern. **Webcam:** owns `CameraManager` (MediaCapture), `AddWebcamToActiveSceneAsync` (picker → default 480×270 bottom-right placement → acquire; the Windows camera picker ALWAYS opens so the creator chooses — never silently reuses the previous camera; picking a different camera swaps the app-wide identity via `SwapWebcamIdentityAsync`, same path as `ChangeWebcamAsync`; propagates the running shared bitmap via `CameraManager.GetPreviewBitmap` first, so a webcam added mid-session never renders a transparent container; greys out when the active scene already has a config via `CanAddWebcamToActiveScene`), `ChangeWebcamAsync` (device swap — `ReleaseAllAsync` old + re-acquire), `ShowWebcamInActiveScene` (reveal hidden config / empty-canvas right-click), `ReacquireWebcam` after layout load (re-propagates the shared bitmap to every config), `OnCameraPreviewBitmapChanged` forwards the shared bitmap into every `WebcamSceneConfig.VideoImageSource`; `SelectedElement` drives the preview overlay + `internal ClampWebcamToBounds(config, sceneName)` (per-scene size cap seam: 50%-per-dimension everywhere, half-screen-AREA in Chat via `MaxWebcamWidthFor`/`MaxWebcamHeightFor`). **Scenes (five-scene catalog):** empty DB seeds Starting/Live/BRB/Chat/Ending (`SceneCatalog.All`); `AddScene(name)` accepts only canonical, missing names; `MissingScenes`/`ShowAddScene` drive the "+" button (hidden once all five exist; its menu lists only the missing scenes via `AddSceneCommand`). **Screen backdrop (Live-only by policy):** owns `ScreenCaptureManager` + `ScreenCaptureSourceFactory` (WinRT GraphicsCapture), `internal static EnsureBackdrop` heals one per backdrop-enabled scene (gated on `Scene.HasBackdrop`), `ReacquireScreenCaptures`/`RefreshBackdropAutoCapture`/`NoteBackgroundWindow` key by full-screen game monitor or the **primary display** via `Win32FullScreenDetector` (`GetDisplays()`/`PrimaryMonitorIndex()`), `ChangeBackdropCaptureAsync` (OS picker) + `SetBackdropCapture(DisplayInfo)` (in-app "Capture Display"), `RedesignateBackdropAsync` re-targets all backdrops and releases orphaned sessions, `internal static EnforceBackdropPolicy` normalizes `Scene.HasBackdrop` by name on every load + strips lingering non-Live backdrops, `CanChangeBackdrop` gates the capture menu to Live (the only scene with a backdrop), `BackdropImage` hides the preview watermark (`ShowPreviewPlaceholder`). **REC sign (2026-08-13):** top-center indicator always visible — `RecDotBrush` (offline `#555555`, live `#e94560`, live-private `#8f1f1f`), `RecTextBrush` (dim offline, white live), `RecDotOpacity` (0.55 offline, pulsing 1.0/0.35 live via `_recDotPulse` flipped on the live tick), `IsLivePrivate` (`IsLive && StreamVisibility == "Private"`) — notified from the `StreamStatus` + `StreamVisibility` setters; `LiveIndicatorVisible`/`LivePulseOpacity` removed. **Mic mute icon:** a second 16px clickable glyph (mic, red + slash when muted) between the meter and the speaker on the mic bar — same `ToggleMicMuteCommand`. **Health stats (TASK 4 ship step 6, 2026-08-13):** `OnFramePumpHealthUpdated` marshals `FramePump.HealthUpdated` (encoder's parsed bitrate/FPS/dropped/duration — raised on the stderr thread) onto the UI thread into `CurrentHealth` (bottom bar bindings); `ResetHealth(status)` zeroes dropped/duration on go-live/End so stats never linger | -| `GoLiveViewModel.cs` | Start Stream dialog: **account row** (saved channel shown with Change Account, or Sign in to YouTube; Start gated on `IsSignedIn`/`IsBusy`) + title/description/visibility, start/cancel requests | +| `MainViewModel.cs` | The app brain: scenes/elements collections + commands, stream state (`IsLive`/`IsOffline`/`IsConnected`), chat feed, overlays, layout save/open, **resolution dropdown** (`QualityOptions`, `SelectedQuality`, `ResolutionHelp`, `ApplyStreamQuality`) that computes the output rect over the 1920×1080 master (`OutputRectX/Y/W/H`, `DimRects`, `IsOutputCropped`, `ResolutionBadgeText`). Auth: loads the saved DPAPI session at startup (`LoadSavedSessionAsync`), `SignInAsync` feeds the GoLive dialog; **go-live (ship step 7): `BeginGoLive` accepts the dialog → sets `StreamVisibility` (always "Private") → fires `CreateBroadcastAsync` (stores `_currentBroadcastId` for TASK 5's bind/transition; failure → `StreamStatus.Error`) + `_framePump.StartAsync()`**; **End Livestream signs out** (`StopStream` clears session + token + `_currentBroadcastId` — crash-safe). **Connected account:** top-bar avatar/name (`AccountAvatarUrl`/`AccountDisplayName` via `SyncConnectedAccount`, cleared on End). **Element rows:** scenes are pure selection rows (no per-row icons); source rows carry edit/visibility/trash (`EditElementCommand` sets `element.IsEditing`, `ToggleElementVisibilityCommand` flips `IsVisible`, `RemoveSourceCommand`); duplicate resource names get a no-space incrementing suffix via shared `NextSourceName` (Image, Image2, Image3… — next free number derived from actual names, so deletions never collide; used by `AddSource` + `AddReusedImage`). **Audio (KISS — the mic is the creator's only audio control; capture SHIPPED, runs for the app's lifetime so the meters preview live):** the sound meter is a **READ-ONLY realtime level display**: fill = `Math.Min(1, AudioLevelMeter.ToDisplay((float)AudioLevel) * MicVolume)` (`MeterFillWidth`/`MeterBrush`, green→yellow→red, zone markers at 60%/80% — `ToDisplay` maps the raw linear RMS onto a −60..0 dBFS scale with +10 dB amplification so real speech/game levels occupy the bar and peaks hit red at maxed volume), where `AudioLevel` (live input, 0 with no input) is fed by the audio mixer once capture lands and `MicVolume` acts as a gain on ambient noise; while the slider is dragged the bar previews the slider position (`SetVolumeAdjusting`), returning to the live level on release (0 with no input — clicking the meter does nothing); `MicVolume` (default 0.8) + read-only `MicMuted`/`MicMuteText`/`ToggleMicMuteCommand` — **MicVolume drives MicMuted** (muted ⇔ volume 0): sliding to 0 flips the speaker to muted, sliding up from 0 clears it; muting stores the prior volume, unmuting restores it (default 0.8 if unknown) and flashes the meter to the restored position ~300ms (`BeginVolumeFlash`/`EndVolumeFlash`); `MicSourceName` = picked voice source, shown left-justified inside the meter bar (the fill runs at 75% opacity so the text + ruler markings show through); `OpenMicPickerCommand`/`PickMicrophone()` open the `MicPickerDialog` (a picked device takes effect immediately — `PickMicrophone` swaps the live source via `_audioMixer.RestartMic()`, loopback keeps running); the **MIC label is a button** (`OpenMicPickerCommand`) with a **status dot** (`MicStatus`, `Models/MicStatus`: green = `MicConnected` via the source's `Started` event, yellow = `MicFailed` — in use/unplugged, red = no mic device at startup; `MicStatusBrush`/`MicStatusToolTip`); capture starts once at startup (`StartMicCaptureAsync`) — NOT go-live (`BeginGoLive`/`StopStream` no longer touch the mixer) — zero devices = red dot + the mixer never starts. **Game audio bar** (desktop/game, **overlaid at the bottom of the preview window** — bottom-center chip, a mirror of the mic bar): `IsGameAudioBarVisible` (shown only while a full-screen game is producing sound — the VM polls `IGameAudioDetector` via the default `GameAudioDetector` every 250ms (`_gameAudioTimer`); the pure `GameAudioHysteresis` SHOWs after ~500ms of fullscreen+sound, HIDEs ~1s after leaving fullscreen, and **silence never hides an active bar**), `GameAudioLevel` (loopback meter via `LoopbackLevelChanged`, scaled by volume), `GameMuted`/`GameMuteText`/`ToggleGameMuteCommand`, `GameAudioVolume` (0..1 volume slider), `Begin/End/CancelGameVolumeFlash` (mirrors the mic bar's volume flash); the game speaker + slider share the mic bar's `PreviewMouseLeftButtonDown/Up` + `LostMouseCapture` code-behind pattern. **Webcam:** owns `CameraManager` (MediaCapture), `AddWebcamToActiveSceneAsync` (picker → default 480×270 bottom-right placement → acquire; the Windows camera picker ALWAYS opens so the creator chooses — never silently reuses the previous camera; picking a different camera swaps the app-wide identity via `SwapWebcamIdentityAsync`, same path as `ChangeWebcamAsync`; propagates the running shared bitmap via `CameraManager.GetPreviewBitmap` first, so a webcam added mid-session never renders a transparent container; greys out when the active scene already has a config via `CanAddWebcamToActiveScene`), `ChangeWebcamAsync` (device swap — `ReleaseAllAsync` old + re-acquire), `ShowWebcamInActiveScene` (reveal hidden config / empty-canvas right-click), `ReacquireWebcam` after layout load (re-propagates the shared bitmap to every config), `OnCameraPreviewBitmapChanged` forwards the shared bitmap into every `WebcamSceneConfig.VideoImageSource`; `SelectedElement` drives the preview overlay + `internal ClampWebcamToBounds(config, sceneName)` (per-scene size cap seam: 50%-per-dimension everywhere, half-screen-AREA in Chat via `MaxWebcamWidthFor`/`MaxWebcamHeightFor`). **Scenes (five-scene catalog):** empty DB seeds Starting/Live/BRB/Chat/Ending (`SceneCatalog.All`); `AddScene(name)` accepts only canonical, missing names; `MissingScenes`/`ShowAddScene` drive the "+" button (hidden once all five exist; its menu lists only the missing scenes via `AddSceneCommand`). **Screen backdrop (Live-only by policy):** owns `ScreenCaptureManager` + `ScreenCaptureSourceFactory` (WinRT GraphicsCapture), `internal static EnsureBackdrop` heals one per backdrop-enabled scene (gated on `Scene.HasBackdrop`), `ReacquireScreenCaptures`/`RefreshBackdropAutoCapture`/`NoteBackgroundWindow` key by full-screen game monitor or the **primary display** via `Win32FullScreenDetector` (`GetDisplays()`/`PrimaryMonitorIndex()`), `ChangeBackdropCaptureAsync` (OS picker) + `SetBackdropCapture(DisplayInfo)` (in-app "Capture Display"), `RedesignateBackdropAsync` re-targets all backdrops and releases orphaned sessions, `internal static EnforceBackdropPolicy` normalizes `Scene.HasBackdrop` by name on every load + strips lingering non-Live backdrops, `CanChangeBackdrop` gates the capture menu to Live (the only scene with a backdrop), `BackdropImage` hides the preview watermark (`ShowPreviewPlaceholder`). **REC sign (2026-08-13):** top-center indicator always visible — `RecDotBrush` (offline `#555555`, live `#e94560`, live-private `#8f1f1f`), `RecTextBrush` (dim offline, white live), `RecDotOpacity` (0.55 offline, pulsing 1.0/0.35 live via `_recDotPulse` flipped on the live tick), `IsLivePrivate` (`IsLive && StreamVisibility == "Private"`) — notified from the `StreamStatus` + `StreamVisibility` setters; `LiveIndicatorVisible`/`LivePulseOpacity` removed. **Mic mute icon:** a second 16px clickable glyph (mic, red + slash when muted) between the meter and the speaker on the mic bar — same `ToggleMicMuteCommand`. **Health stats (TASK 4 ship step 6, 2026-08-13):** `OnFramePumpHealthUpdated` marshals `FramePump.HealthUpdated` (encoder's parsed bitrate/FPS/dropped/duration — raised on the stderr thread) onto the UI thread into `CurrentHealth` (bottom bar bindings); `ResetHealth(status)` zeroes dropped/duration on go-live/End so stats never linger | +| `GoLiveViewModel.cs` | Start Stream dialog: **account row** (saved channel shown with Change Account, or Sign in to YouTube; Start gated on `IsSignedIn`/`IsBusy`) + title/description + **locked Visibility (always "Private" — ship step 7, no dropdown)**, start/cancel requests | | `ReuseImageViewModel.cs` | Add Image dialog: candidate list (`ReuseImageCandidate`), reuse/new/cancel | | `CameraPickerViewModel.cs` | Add Webcam dialog: async camera list (loading / has / none states), `UseRequested(CameraDeviceInfo)`/`CancelRequested` | | `MicPickerViewModel.cs` | Select Microphone dialog (voice source): async mic list (loading / has / none states), `UseRequested(MicrophoneDeviceInfo)`/`CancelRequested` | diff --git a/ai.md b/ai.md index cf6ecb2..070aa7a 100644 --- a/ai.md +++ b/ai.md @@ -587,6 +587,16 @@ These are the hard facts behind every decision. Full list in `TASKS.md`. `enableAutoStart=true`, `enableAutoStop=true`, `enableMonitorStream=false`, `selfDeclaredMadeForKids=false`, `latencyPreference=low`. The encoder starting brings YouTube live. `enableMonitorStream=false` is what lets us skip the testing stage. +- **Private-only go live (ship step 7, shipped 2026-08-14)** — until v1, go-live is locked to private + streams so a software error can never publish something public. Double-enforced: the Go Live dialog + is locked to Private (`GoLiveViewModel.Visibility` is a get-only `"Private"`, no dropdown — the + settings "Default Visibility" dropdown + `MainViewModel.Visibilities`/`DefaultStreamVisibility` were + removed as dead-ends) AND `YouTubeStreamService.CreateBroadcast` always sends `privacyStatus="private"` + (injectable `HttpClient? http = null` seam; the broadcast-insert integration test asserts the body + carries `"privacyStatus":"private"`). `BeginGoLive` fires `CreateBroadcastAsync` and remembers + `_currentBroadcastId` for TASK 5's bind/transition (failure → `StreamStatus.Error`, never a crash); + `StopStream` clears it. The REC sign shows a dark-red **PRIVATE badge** (next to REC, `IsLivePrivate`) + whenever the live stream is private. - **Variable reusable stream** — `liveStreams.insert` once per channel with `cdn.resolution=variable`, `cdn.frameRate=variable`, `isReusable=true`; cache the ingestion URL + stream name and reuse for every broadcast. Any quality tier works without recreating the stream, diff --git a/ytLive.Tests/YouTubeStreamServiceTests.cs b/ytLive.Tests/YouTubeStreamServiceTests.cs new file mode 100644 index 0000000..7fea4ba --- /dev/null +++ b/ytLive.Tests/YouTubeStreamServiceTests.cs @@ -0,0 +1,65 @@ +using System.Net; +using System.Text; +using Xunit; +using ytLive.Models; +using ytLive.Services; + +namespace ytLive.Tests; + +/// +/// The one integration test for ship step 7 (private-only go live): the +/// broadcast-insert request must carry privacyStatus "private" — the dialog is +/// locked to Private and the service must never send anything else. +/// +public class YouTubeStreamServiceTests +{ + private sealed class RecordingHandler : HttpMessageHandler + { + public string? LastBody; + public string ResponseBody = """{"id":"BC123","snippet":{"title":"t"}}"""; + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + LastBody = request.Content?.ReadAsStringAsync().GetAwaiter().GetResult(); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(ResponseBody, Encoding.UTF8, "application/json"), + }); + } + } + + private static YouTubeAuthService CreateAuth() => new("test-id", "test-secret"); + + [Fact] + public async Task CreateBroadcast_Always_Sends_Private_PrivacyStatus() + { + var handler = new RecordingHandler(); + var auth = CreateAuth(); + auth.SetSession(new YouTubeChannel + { + AccessToken = "acc-123", + TokenExpiry = DateTime.UtcNow.AddHours(1), + }); + var service = new YouTubeStreamService(auth, new HttpClient(handler)); + + var id = await service.CreateBroadcast("Test Stream", "A description", DateTime.UtcNow); + + Assert.Equal("BC123", id); + Assert.NotNull(handler.LastBody); + Assert.Contains("\"privacyStatus\":\"private\"", handler.LastBody); + } + + [Fact] + public async Task CreateBroadcast_Without_Session_Returns_Null() + { + var handler = new RecordingHandler(); + var auth = CreateAuth(); // no session + var service = new YouTubeStreamService(auth, new HttpClient(handler)); + + var id = await service.CreateBroadcast("Test Stream", "A description", DateTime.UtcNow); + + Assert.Null(id); + Assert.Null(handler.LastBody); // never reached the API + } +}