TASK 4 ship step 7: one-click go live + private-only enforcement (req 8) — the Go Live dialog is locked to Private (GoLiveViewModel.Visibility is a get-only string, no dropdown; settings' dead Default Visibility dropdown + Visibilities/DefaultStreamVisibility removed); YouTubeStreamService.CreateBroadcast always sends privacyStatus='private' and gained an injectable HttpClient? http = null seam; BeginGoLive fires CreateBroadcastAsync which stashes _currentBroadcastId for TASK 5's bind/transition (failure → StreamStatus.Error via AppLog, never a crash) alongside the frame pump, and StopStream clears it; the REC sign shows a dark-red PRIVATE badge next to REC whenever the live stream is private (IsLivePrivate) — the broadcast-insert integration test asserts the request body carries privacyStatus private (RecordingHandler seam) + a no-session guard test — 173 tests passing, 0 warnings
This commit is contained in:
+7
-3
@@ -99,9 +99,13 @@
|
|||||||
VerticalScrollBarVisibility="Auto"/>
|
VerticalScrollBarVisibility="Auto"/>
|
||||||
|
|
||||||
<TextBlock Text="Visibility" Style="{StaticResource YtLabel}"/>
|
<TextBlock Text="Visibility" Style="{StaticResource YtLabel}"/>
|
||||||
<ComboBox Style="{StaticResource YtComboBox}" Margin="0,0,0,12"
|
<StackPanel Orientation="Horizontal" Margin="0,0,0,12">
|
||||||
ItemsSource="{Binding Visibilities}"
|
<TextBlock Text="Private" Foreground="#e0e0e0" FontSize="14"
|
||||||
SelectedItem="{Binding Visibility}"/>
|
VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Text="Streams always start Private — change it on YouTube after going live."
|
||||||
|
Foreground="#a0a0b0" FontSize="12" TextWrapping="Wrap"
|
||||||
|
Margin="10,0,0,0" VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- ACTIONS -->
|
<!-- ACTIONS -->
|
||||||
|
|||||||
+33
-47
@@ -5,49 +5,42 @@
|
|||||||
> a problem. Conventions: [`schema.md`](schema.md). Rewrite this file at session
|
> a problem. Conventions: [`schema.md`](schema.md). Rewrite this file at session
|
||||||
> end, compaction, or any interruption.
|
> 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`.
|
- **Branch:** `main`, in sync with `origin/main`.
|
||||||
- **TASK 8 (new, after TASK 7 shipped):** `AudioLevelMeter.ToDisplay` gained **+10 dB input
|
- **TASK 8 — meter scaling amplification:** SHIPPED + pushed (`0b71b03`). `AudioLevelMeter.ToDisplay`
|
||||||
amplification** so the meters use the full bar — speech peaks (~0.2 RMS) now read ~0.93 (red)
|
adds **+10 dB input amplification** before the −60..0 dBFS log map: speech peaks (~0.2 RMS) read
|
||||||
and normal speech (~0.05) ~0.73 (yellow) at maxed volume; ≤0.001 linear still reads 0 (idle
|
~0.93 (red) and normal speech (~0.05) ~0.73 (yellow) at maxed volume; ≤0.001 linear still reads 0
|
||||||
never shows noise). One knob shared by the mic bar and game bar; `× MicVolume` untouched.
|
(never idles on background noise). One knob shared by the mic + game bars; `× MicVolume` untouched.
|
||||||
Tests updated + new `ToDisplay_Pushes_Speech_Peaks_Into_Red_At_Maxed_Volume`. **Uncommitted**
|
- **TASK 4 ship step 7 — one-click go live + private-only enforcement: SHIPPED** (this session; the
|
||||||
(pending user review): `Services/Audio/AudioLevelMeter.cs`, `ytLive.Tests/AudioMixerTests.cs`,
|
final open TASK 4 requirement). Double-enforced privacy:
|
||||||
`ai.md`, `Services/index.md`, `ViewModels/index.md`, `TASKS.md`, `HANDOFF.md`. Meter tests:
|
1. **Dialog locked to Private** — `GoLiveViewModel.Visibility` is a get-only `"Private"`, the
|
||||||
7/7 passing, build 0 warnings.
|
visibility ComboBox is gone (replaced by a "Streams always start Private" note); settings' dead
|
||||||
- **This session (TASK 7 — UI polish batch, gramps's 6-point review):**
|
"Default Visibility" dropdown + `MainViewModel.Visibilities`/`DefaultStreamVisibility` removed.
|
||||||
1. **Scenes list cleaned:** the per-row edit/trash/visibility icons and the inline
|
2. **Service forces private** — `YouTubeStreamService.CreateBroadcast` always sends
|
||||||
rename TextBox are gone. Scenes are pure selection rows; `IsHidden` stays
|
`privacyStatus="private"`; ctor gained `HttpClient? http = null` seam for tests.
|
||||||
persisted and still dims a hidden row to 45%. Removed dead surface:
|
3. **Wired into go-live** — `BeginGoLive` fires `CreateBroadcastAsync` (stores `_currentBroadcastId`
|
||||||
`EditSceneCommand`/`RemoveSceneCommand`/`ToggleSceneVisibilityCommand` +
|
for TASK 5's bind/transition; failure → `StreamStatus.Error` via AppLog, never a crash) +
|
||||||
`BeginEditScene`/`ToggleSceneVisibility`/`RemoveScene` handlers + `Scene.IsEditing`.
|
`_framePump.StartAsync()`; `StopStream` clears `_currentBroadcastId`.
|
||||||
2. **Sources list upgraded:** each row now has edit + visibility eye + trash. New
|
4. **PRIVATE badge** — dark-red bordered, next to REC in the top bar, `Visibility="{Binding
|
||||||
`EditElementCommand` (`SceneElement.IsEditing` → inline rename TextBox, Enter/Esc/
|
IsLivePrivate}"` (MainWindow.xaml ~110).
|
||||||
lost-focus commits) and `ToggleElementVisibilityCommand` (flips `SceneElement.IsVisible`);
|
5. **Integration test** — `ytLive.Tests/YouTubeStreamServiceTests.cs`: the broadcast-insert request
|
||||||
the eye style now binds `IsVisible` and hidden rows dim to 45%.
|
must carry `"privacyStatus":"private"` (RecordingHandler seam) + a no-session → null guard test.
|
||||||
3. **Duplicate naming:** shared `NextSourceName(scene, baseName)` → `Image`, `Image2`,
|
- **Verified:** build 0 warnings / 0 errors; **173/173 tests pass** (171 + the 2 stream-service tests).
|
||||||
`Image3`… (no space), next free number derived from actual names so deletions never
|
- **Uncommitted (this session's ship step 7 batch):** `GoLiveWindow.xaml`, `ViewModels/GoLiveViewModel.cs`,
|
||||||
collide. Used by both `AddSource` and `AddReusedImage`.
|
`ViewModels/MainViewModel.cs`, `Services/YouTubeStreamService.cs`, `MainWindow.xaml`,
|
||||||
4. **Social bar:** `MaxWidth=200` + `CharacterEllipsis` removed from BOTH
|
`ytLive.Tests/YouTubeStreamServiceTests.cs` (new), `TASKS.md`, `ai.md`, `ViewModels/index.md`,
|
||||||
`SocialBarRenderer.cs` and the preview DataTemplate — full validated handle renders.
|
`Services/index.md`, `HANDOFF.md`. **Commit + push before continuing.**
|
||||||
5. **Panels:** left 220 / right 300 fixed widths are deliberate — panels never re-layout
|
- **Next step:** commit + push ship step 7, then **TASK 5 — reusable `variable` stream** (create once
|
||||||
on resize; the preview absorbs it. No change.
|
per channel, cache ingestion URL, bind to broadcast, feed `_rtmpUrlProvider` so the pump actually
|
||||||
6. **Focus-loss capture lag:** recorded in `ai.md` as a known OS limit (DWM/WGC
|
pushes — `_currentBroadcastId` is already stashed for it). Also queued: task 21 (logo + About hub),
|
||||||
throttling when unfocused + GPU readback contention + the `_framePending` /
|
task 22 (voice filters). Nothing else queued — do not expand the task queue on your own. Optional,
|
||||||
`DispatcherPriority.Render` gates). NOT an in-app throttle; deferred by user decision.
|
not queued: rewriting the healed entry's `ProfileUrl` to `https://mastodon.llamachile.tube/@gramps`
|
||||||
- **Test infra change:** the two real-WPF-App tests (round-clip + new source-naming) now
|
(user must say the word).
|
||||||
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).
|
|
||||||
- **Landmines:**
|
- **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
|
- Never set a local `Canvas.SetTop` on the social bar — a local value permanently
|
||||||
overrides `{Binding SocialBarTop}` (the `ClearValue` lesson from 5.5).
|
overrides `{Binding SocialBarTop}` (the `ClearValue` lesson from 5.5).
|
||||||
- `AudioMixer` meter `Push` is unconditional **by design now**: `OnMicSample`/
|
- `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
|
(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
|
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.
|
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`;
|
- **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`);
|
OAuth session token in `Helpers/TokenStore.cs` (DPAPI → `%APPDATA%\ytLlive\ytLlive.auth`);
|
||||||
|
|||||||
+6
-6
@@ -105,6 +105,11 @@
|
|||||||
Fill="{Binding RecDotBrush}" Opacity="{Binding RecDotOpacity}"/>
|
Fill="{Binding RecDotBrush}" Opacity="{Binding RecDotOpacity}"/>
|
||||||
<TextBlock Text="REC" FontSize="16" FontWeight="Bold"
|
<TextBlock Text="REC" FontSize="16" FontWeight="Bold"
|
||||||
Foreground="{Binding RecTextBrush}" VerticalAlignment="Center" Margin="8,0,0,0"/>
|
Foreground="{Binding RecTextBrush}" VerticalAlignment="Center" Margin="8,0,0,0"/>
|
||||||
|
<Border Background="#8f1f1f" CornerRadius="3" Padding="6,1" Margin="8,0,0,0"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Visibility="{Binding IsLivePrivate, Converter={StaticResource BoolToVis}}">
|
||||||
|
<TextBlock Text="PRIVATE" FontSize="11" FontWeight="Bold" Foreground="White"/>
|
||||||
|
</Border>
|
||||||
<TextBlock Text="{Binding LiveElapsedText}" Foreground="White" FontSize="14"
|
<TextBlock Text="{Binding LiveElapsedText}" Foreground="White" FontSize="14"
|
||||||
FontFamily="Consolas" VerticalAlignment="Center" Margin="14,0,0,0"
|
FontFamily="Consolas" VerticalAlignment="Center" Margin="14,0,0,0"
|
||||||
Visibility="{Binding IsLive, Converter={StaticResource BoolToVis}}"/>
|
Visibility="{Binding IsLive, Converter={StaticResource BoolToVis}}"/>
|
||||||
@@ -1064,12 +1069,7 @@
|
|||||||
Text="{Binding DefaultStreamDescription, UpdateSourceTrigger=PropertyChanged}"
|
Text="{Binding DefaultStreamDescription, UpdateSourceTrigger=PropertyChanged}"
|
||||||
VerticalScrollBarVisibility="Auto"/>
|
VerticalScrollBarVisibility="Auto"/>
|
||||||
|
|
||||||
<TextBlock Text="Default Visibility" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
|
<TextBlock Text="These prefill the Start Stream dialog. Streams always start Private — change visibility on YouTube after going live."
|
||||||
<ComboBox Style="{StaticResource YtComboBox}" HorizontalAlignment="Left" MinWidth="140"
|
|
||||||
ItemsSource="{Binding Visibilities}"
|
|
||||||
SelectedItem="{Binding DefaultStreamVisibility}"/>
|
|
||||||
|
|
||||||
<TextBlock Text="These prefill the Start Stream dialog. Scene and stream settings are stored on the stream itself."
|
|
||||||
Style="{StaticResource YtLabel}" Margin="0,16,0,0" TextWrapping="Wrap"/>
|
Style="{StaticResource YtLabel}" Margin="0,16,0,0" TextWrapping="Wrap"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
|||||||
@@ -12,12 +12,13 @@ namespace ytLive.Services;
|
|||||||
public class YouTubeStreamService
|
public class YouTubeStreamService
|
||||||
{
|
{
|
||||||
private readonly YouTubeAuthService _auth;
|
private readonly YouTubeAuthService _auth;
|
||||||
private readonly HttpClient _http = new();
|
private readonly HttpClient _http;
|
||||||
private const string ApiBase = "https://www.googleapis.com/youtube/v3";
|
private const string ApiBase = "https://www.googleapis.com/youtube/v3";
|
||||||
|
|
||||||
public YouTubeStreamService(YouTubeAuthService auth)
|
public YouTubeStreamService(YouTubeAuthService auth, HttpClient? http = null)
|
||||||
{
|
{
|
||||||
_auth = auth;
|
_auth = auth;
|
||||||
|
_http = http ?? new HttpClient();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<bool> EnsureToken()
|
private async Task<bool> EnsureToken()
|
||||||
@@ -43,7 +44,9 @@ public class YouTubeStreamService
|
|||||||
},
|
},
|
||||||
status = new
|
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
|
selfDeclaredMadeForKids = false
|
||||||
},
|
},
|
||||||
contentDetails = new
|
contentDetails = new
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ External-facing logic: YouTube API, persistence. See
|
|||||||
| File | Purpose |
|
| 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) |
|
| `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` |
|
| `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) |
|
| `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 |
|
| `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 |
|
||||||
|
|||||||
@@ -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)
|
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)
|
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)
|
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
|
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**
|
without the preview's editing chrome) → **audio capture** (WASAPI, feeds the meter) → **H.264+AAC encode**
|
||||||
|
|||||||
@@ -9,12 +9,16 @@ public class GoLiveViewModel : ViewModelBase
|
|||||||
private readonly Func<Task<YouTubeChannel?>> _signInProvider;
|
private readonly Func<Task<YouTubeChannel?>> _signInProvider;
|
||||||
private string _streamTitle = string.Empty;
|
private string _streamTitle = string.Empty;
|
||||||
private string _streamDescription = string.Empty;
|
private string _streamDescription = string.Empty;
|
||||||
private string _visibility = "Public";
|
|
||||||
private bool _isSignedIn;
|
private bool _isSignedIn;
|
||||||
private string _accountDisplayName = string.Empty;
|
private string _accountDisplayName = string.Empty;
|
||||||
private string _accountAvatarUrl = string.Empty;
|
private string _accountAvatarUrl = string.Empty;
|
||||||
private bool _isBusy;
|
private bool _isBusy;
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
public string Visibility => "Private";
|
||||||
|
|
||||||
public GoLiveViewModel(Func<Task<YouTubeChannel?>> signInProvider, YouTubeChannel? account)
|
public GoLiveViewModel(Func<Task<YouTubeChannel?>> signInProvider, YouTubeChannel? account)
|
||||||
{
|
{
|
||||||
_signInProvider = signInProvider;
|
_signInProvider = signInProvider;
|
||||||
@@ -39,14 +43,6 @@ public class GoLiveViewModel : ViewModelBase
|
|||||||
set => SetProperty(ref _streamDescription, value);
|
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
|
public bool IsSignedIn
|
||||||
{
|
{
|
||||||
get => _isSignedIn;
|
get => _isSignedIn;
|
||||||
|
|||||||
+31
-11
@@ -55,7 +55,8 @@ public class MainViewModel : ViewModelBase
|
|||||||
private StreamHealth _currentHealth = new();
|
private StreamHealth _currentHealth = new();
|
||||||
private string _streamTitle = string.Empty;
|
private string _streamTitle = string.Empty;
|
||||||
private string _streamDescription = string.Empty;
|
private string _streamDescription = string.Empty;
|
||||||
private string _streamVisibility = "Public";
|
private string _streamVisibility = "Private";
|
||||||
|
private string? _currentBroadcastId;
|
||||||
private string _windowTitle = "ytLlive";
|
private string _windowTitle = "ytLlive";
|
||||||
private string _topBarBackground = "#16213e";
|
private string _topBarBackground = "#16213e";
|
||||||
private string _previewGlowBrush = "Transparent";
|
private string _previewGlowBrush = "Transparent";
|
||||||
@@ -71,7 +72,6 @@ public class MainViewModel : ViewModelBase
|
|||||||
private string _overlayTitle = string.Empty;
|
private string _overlayTitle = string.Empty;
|
||||||
private string _defaultStreamTitle = string.Empty;
|
private string _defaultStreamTitle = string.Empty;
|
||||||
private string _defaultStreamDescription = string.Empty;
|
private string _defaultStreamDescription = string.Empty;
|
||||||
private string _defaultStreamVisibility = "Public";
|
|
||||||
private string _bugReportText = string.Empty;
|
private string _bugReportText = string.Empty;
|
||||||
private string _bugReportEmail = string.Empty;
|
private string _bugReportEmail = string.Empty;
|
||||||
private string _featureRequestText = string.Empty;
|
private string _featureRequestText = string.Empty;
|
||||||
@@ -134,7 +134,6 @@ public class MainViewModel : ViewModelBase
|
|||||||
public ObservableCollection<Scene> Scenes { get; } = new();
|
public ObservableCollection<Scene> Scenes { get; } = new();
|
||||||
public ObservableCollection<ChatMessage> ChatMessages { get; } = new();
|
public ObservableCollection<ChatMessage> ChatMessages { get; } = new();
|
||||||
public ObservableCollection<DisplayInfo> Displays { get; } = new();
|
public ObservableCollection<DisplayInfo> Displays { get; } = new();
|
||||||
public string[] Visibilities { get; } = { "Public", "Unlisted", "Private" };
|
|
||||||
|
|
||||||
public Scene? ActiveScene
|
public Scene? ActiveScene
|
||||||
{
|
{
|
||||||
@@ -764,12 +763,6 @@ public class MainViewModel : ViewModelBase
|
|||||||
set => SetProperty(ref _defaultStreamDescription, value);
|
set => SetProperty(ref _defaultStreamDescription, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public string DefaultStreamVisibility
|
|
||||||
{
|
|
||||||
get => _defaultStreamVisibility;
|
|
||||||
set => SetProperty(ref _defaultStreamVisibility, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Resolution quality dropdown (bottom bar) ───
|
// ─── Resolution quality dropdown (bottom bar) ───
|
||||||
// Tiers offered to the creator before going live. First = default. The
|
// Tiers offered to the creator before going live. First = default. The
|
||||||
// dropdown is disabled while live because YouTube stream resolution is
|
// dropdown is disabled while live because YouTube stream resolution is
|
||||||
@@ -1954,23 +1947,49 @@ public class MainViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
StreamTitle = DefaultStreamTitle,
|
StreamTitle = DefaultStreamTitle,
|
||||||
StreamDescription = DefaultStreamDescription,
|
StreamDescription = DefaultStreamDescription,
|
||||||
Visibility = DefaultStreamVisibility,
|
|
||||||
};
|
};
|
||||||
var window = new ytLive.GoLiveWindow(dialog) { Owner = System.Windows.Application.Current.MainWindow };
|
var window = new ytLive.GoLiveWindow(dialog) { Owner = System.Windows.Application.Current.MainWindow };
|
||||||
if (window.ShowDialog() == true)
|
if (window.ShowDialog() == true)
|
||||||
{
|
{
|
||||||
StreamTitle = dialog.StreamTitle;
|
StreamTitle = dialog.StreamTitle;
|
||||||
StreamDescription = dialog.StreamDescription;
|
StreamDescription = dialog.StreamDescription;
|
||||||
StreamVisibility = dialog.Visibility;
|
StreamVisibility = dialog.Visibility; // always "Private" (ship step 7)
|
||||||
WindowTitle = string.IsNullOrWhiteSpace(dialog.StreamTitle)
|
WindowTitle = string.IsNullOrWhiteSpace(dialog.StreamTitle)
|
||||||
? "ytLlive"
|
? "ytLlive"
|
||||||
: $"{dialog.StreamTitle} — ytLlive";
|
: $"{dialog.StreamTitle} — ytLlive";
|
||||||
StreamStatus = StreamStatus.Streaming;
|
StreamStatus = StreamStatus.Streaming;
|
||||||
ResetHealth(StreamStatus.Streaming);
|
ResetHealth(StreamStatus.Streaming);
|
||||||
|
_ = CreateBroadcastAsync();
|
||||||
_ = _framePump.StartAsync(); // never throws; failures log + surface via Failed
|
_ = _framePump.StartAsync(); // never throws; failures log + surface via Failed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
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()
|
private void StopStream()
|
||||||
{
|
{
|
||||||
StreamStatus = StreamStatus.Offline;
|
StreamStatus = StreamStatus.Offline;
|
||||||
@@ -1979,6 +1998,7 @@ public class MainViewModel : ViewModelBase
|
|||||||
// Audio capture is always-on (preview monitoring); only the frame pump
|
// Audio capture is always-on (preview monitoring); only the frame pump
|
||||||
// and the session stop here.
|
// and the session stop here.
|
||||||
_ = _framePump.StopAsync();
|
_ = _framePump.StopAsync();
|
||||||
|
_currentBroadcastId = null;
|
||||||
// Graceful end completes the session = signs out (the DPAPI token is
|
// Graceful end completes the session = signs out (the DPAPI token is
|
||||||
// cleared so the next Start Stream requires a fresh sign-in). A crash
|
// 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.
|
// never runs this, so the token survives and the creator stays signed in.
|
||||||
|
|||||||
+2
-2
File diff suppressed because one or more lines are too long
@@ -587,6 +587,16 @@ These are the hard facts behind every decision. Full list in `TASKS.md`.
|
|||||||
`enableAutoStart=true`, `enableAutoStop=true`, `enableMonitorStream=false`,
|
`enableAutoStart=true`, `enableAutoStop=true`, `enableMonitorStream=false`,
|
||||||
`selfDeclaredMadeForKids=false`, `latencyPreference=low`. The encoder starting brings YouTube live.
|
`selfDeclaredMadeForKids=false`, `latencyPreference=low`. The encoder starting brings YouTube live.
|
||||||
`enableMonitorStream=false` is what lets us skip the testing stage.
|
`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
|
- **Variable reusable stream** — `liveStreams.insert` once per channel with
|
||||||
`cdn.resolution=variable`, `cdn.frameRate=variable`, `isReusable=true`; cache the ingestion URL +
|
`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,
|
stream name and reuse for every broadcast. Any quality tier works without recreating the stream,
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using Xunit;
|
||||||
|
using ytLive.Models;
|
||||||
|
using ytLive.Services;
|
||||||
|
|
||||||
|
namespace ytLive.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public class YouTubeStreamServiceTests
|
||||||
|
{
|
||||||
|
private sealed class RecordingHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
public string? LastBody;
|
||||||
|
public string ResponseBody = """{"id":"BC123","snippet":{"title":"t"}}""";
|
||||||
|
|
||||||
|
protected override Task<HttpResponseMessage> 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user