feat(stream): TASK 41 Test Stream mode — private test broadcast + TEST drawer
Test button next to Start runs the real private-only go-live pipeline as a session variant (IsTestStream): BeginTestStream -> extracted StartStreamingSession(alsoRecord:false) shared with the dialog path — no Go Live dialog, no 'last live' stamp, no recording. Gold top bar + TEST badge + 'End Test' button face; TEST drawer (third right-rail pull-out, three-way exclusivity) auto-opens once the liveChatId resolves. Chat tooling: Mock Chat Input sends a REAL liveChat/messages.insert (YouTubeStreamService.InsertChatMessageAsync) that round-trips the real ~2s poll and renders through the live overlay; simulated Subscriber/New Member/Super Chat events inject through the poller's MessageReceived seam (YouTubeChatService.InjectSimulatedMessage, ChatMessage.IsSimulated) — the insert API only creates textMessageEvent, so non-text events are local-only by design (ref: developers.google.com/youtube/v3/live/docs/liveChatMessages/insert). Scopes youtube + youtube.force-ssl already cover insert; no re-consent. Good Dog: ytLive.Tests/TestStreamTests.cs — coordinator protocol with fake HTTP + real-window drawer exclusivity/gate. 0 warnings; 314/314 pass (clean run; intermittent host abort is the pre-existing WinRT-webcam flake). Docs ride in the same change: TASKS.md row 41, TASKS/task-41-test-stream-mode.md, ai.md (YouTube Live API constraints), HANDOFF rewrite.
This commit is contained in:
@@ -648,6 +648,104 @@
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
<!-- The Test-stream drawer (below YPP on the rail) -->
|
||||
<Border Background="#16213e" ClipToBounds="True">
|
||||
<Border.Style>
|
||||
<Style TargetType="Border">
|
||||
<Setter Property="Width" Value="0"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding TestSession.IsDrawerOpen}" Value="True">
|
||||
<DataTrigger.EnterActions>
|
||||
<BeginStoryboard>
|
||||
<Storyboard>
|
||||
<DoubleAnimation Storyboard.TargetProperty="Width"
|
||||
To="320" Duration="0:0:0.2">
|
||||
<DoubleAnimation.EasingFunction>
|
||||
<CubicEase EasingMode="EaseOut"/>
|
||||
</DoubleAnimation.EasingFunction>
|
||||
</DoubleAnimation>
|
||||
</Storyboard>
|
||||
</BeginStoryboard>
|
||||
</DataTrigger.EnterActions>
|
||||
<DataTrigger.ExitActions>
|
||||
<BeginStoryboard>
|
||||
<Storyboard>
|
||||
<DoubleAnimation Storyboard.TargetProperty="Width"
|
||||
To="0" Duration="0:0:0.2">
|
||||
<DoubleAnimation.EasingFunction>
|
||||
<CubicEase EasingMode="EaseOut"/>
|
||||
</DoubleAnimation.EasingFunction>
|
||||
</DoubleAnimation>
|
||||
</Storyboard>
|
||||
</BeginStoryboard>
|
||||
</DataTrigger.ExitActions>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Border.Style>
|
||||
<Grid Width="320" Margin="14,10,14,12">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Grid Grid.Row="0" Margin="0,0,0,8">
|
||||
<TextBlock Text="TEST STREAM" Style="{StaticResource SectionHeader}"
|
||||
HorizontalAlignment="Left"/>
|
||||
<Button HorizontalAlignment="Right" VerticalAlignment="Top"
|
||||
Background="Transparent" BorderThickness="0" Cursor="Hand"
|
||||
Command="{Binding TestSession.CloseDrawerCommand}"
|
||||
ToolTip="Close">
|
||||
<Button.Template>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<ContentPresenter/>
|
||||
</ControlTemplate>
|
||||
</Button.Template>
|
||||
<TextBlock Text="✕" Foreground="#a0a0b0" FontSize="14" FontWeight="Bold"/>
|
||||
</Button>
|
||||
</Grid>
|
||||
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding TestSession.SessionCaption}"
|
||||
Foreground="#d0d0e0" FontSize="13" TextWrapping="Wrap" Margin="0,0,0,8"/>
|
||||
<TextBlock Text="{Binding TestSession.ChatIdLine}"
|
||||
Foreground="#888899" FontSize="11"
|
||||
Visibility="{Binding TestSession.IsTestSession, Converter={StaticResource BoolToVis}}"/>
|
||||
|
||||
<TextBlock Text="MOCK CHAT INPUT" Style="{StaticResource YtLabel}" Margin="0,10,0,3"/>
|
||||
<TextBlock Text="Sends a real live chat message — it round-trips through the actual chat poll and renders on your chat overlay."
|
||||
Foreground="#a0a0b0" FontSize="11" TextWrapping="Wrap" Margin="0,0,0,4"/>
|
||||
<TextBox Style="{StaticResource YtTextBox}" Margin="0,0,0,6"
|
||||
Text="{Binding TestSession.SendMessageText, UpdateSourceTrigger=PropertyChanged}"/>
|
||||
<Button Style="{StaticResource YtButton}" Content="Send Message"
|
||||
Command="{Binding TestSession.SendTestMessageCommand}"/>
|
||||
|
||||
<TextBlock Text="SIMULATED EVENTS" Style="{StaticResource YtLabel}" Margin="0,14,0,3"/>
|
||||
<TextBlock Text="Fired into your chat overlay locally — YouTube's insert API only creates text, so these never reach YouTube."
|
||||
Foreground="#a0a0b0" FontSize="11" TextWrapping="Wrap" Margin="0,0,0,4"/>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
|
||||
<Button Style="{StaticResource YtButtonSecondary}" Content="Subscriber" Padding="10,4" FontSize="11"
|
||||
Margin="0,0,6,0" Command="{Binding TestSession.SimulateSubscriberCommand}"/>
|
||||
<Button Style="{StaticResource YtButtonSecondary}" Content="New Member" Padding="10,4" FontSize="11"
|
||||
Margin="0,0,6,0" Command="{Binding TestSession.SimulateMemberCommand}"/>
|
||||
</StackPanel>
|
||||
<Button Style="{StaticResource YtButtonSecondary}" Content="Super Chat (5.00)" Padding="10,4" FontSize="11"
|
||||
HorizontalAlignment="Left" Command="{Binding TestSession.SimulateSuperChatCommand}"/>
|
||||
|
||||
<TextBlock Text="{Binding TestSession.LastEventText}"
|
||||
Foreground="#22c55e" FontSize="11" TextWrapping="Wrap" Margin="0,10,0,0"
|
||||
Visibility="{Binding TestSession.LastEventText, Converter={StaticResource NotNullToVis}}"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
<StackPanel Grid.Row="2" Margin="0,10,0,0">
|
||||
<Button Style="{StaticResource YtButton}" Content="End Test"
|
||||
Command="{Binding TestSession.EndTestCommand}"
|
||||
Visibility="{Binding TestSession.IsTestSession, Converter={StaticResource BoolToVis}}"/>
|
||||
<TextBlock Text="Test broadcasts are private — nobody is notified, and it runs through the exact same pipeline as a real live."
|
||||
Foreground="#666677" FontSize="11" TextWrapping="Wrap" Margin="0,6,0,0"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
<!-- The stacked white tabs -->
|
||||
<StackPanel Orientation="Vertical" HorizontalAlignment="Right" VerticalAlignment="Stretch">
|
||||
<Border Width="30" VerticalAlignment="Center" Background="#ffffff"
|
||||
@@ -678,6 +776,20 @@
|
||||
</TextBlock.LayoutTransform>
|
||||
</TextBlock>
|
||||
</Border>
|
||||
<Border Width="30" VerticalAlignment="Center" Background="#ffffff"
|
||||
CornerRadius="4,0,0,4" Padding="0,10" Cursor="Hand" Margin="0,4,0,0"
|
||||
ToolTip="Test stream — mock chat input and simulated events">
|
||||
<Border.InputBindings>
|
||||
<MouseBinding Gesture="LeftClick"
|
||||
Command="{Binding TestSession.ToggleDrawerCommand}"/>
|
||||
</Border.InputBindings>
|
||||
<TextBlock Text="TEST" Foreground="#9c6f1c" FontWeight="Bold" FontSize="15"
|
||||
HorizontalAlignment="Center">
|
||||
<TextBlock.LayoutTransform>
|
||||
<RotateTransform Angle="90"/>
|
||||
</TextBlock.LayoutTransform>
|
||||
</TextBlock>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
@@ -58,6 +58,11 @@
|
||||
<TextBlock Text="{Binding LiveElapsedText}" Foreground="White" FontSize="14"
|
||||
FontFamily="Consolas" VerticalAlignment="Center" Margin="14,0,0,0"
|
||||
Visibility="{Binding IsLive, Converter={StaticResource BoolToVis}}"/>
|
||||
<Border Background="#9c6f1c" CornerRadius="3" Padding="6,1" Margin="10,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
Visibility="{Binding IsTestStream, Converter={StaticResource BoolToVis}}">
|
||||
<TextBlock Text="TEST" FontSize="11" FontWeight="Bold" Foreground="White"/>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Three-state top bar: logged-out / idle / live -->
|
||||
@@ -92,6 +97,14 @@
|
||||
Visibility="Collapsed"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
<!-- Test button (TASK 41): one-click private test broadcast — the Test
|
||||
drawer (right rail, TEST tab) hosts the mock chat input + simulated
|
||||
events. Visible only signed-in and idle. -->
|
||||
<Button Content="Test" Style="{StaticResource YtButtonSecondary}" Padding="12,4" FontSize="11"
|
||||
Margin="0,0,8,0" VerticalAlignment="Center"
|
||||
Command="{Binding StartTestCommand}"
|
||||
ToolTip="Start a private test broadcast to check your chat overlay and chat tools."
|
||||
Visibility="{Binding ShowTestButton, Converter={StaticResource BoolToVis}}"/>
|
||||
<!-- Primary Start button: the always-present idle action (sign-in moved
|
||||
into its context menu — a bare bar after stopping was a dead end) -->
|
||||
<Button Content="{Binding PrimaryStartButtonLabel}" Style="{StaticResource YtButton}"
|
||||
|
||||
+56
-49
@@ -1,70 +1,77 @@
|
||||
# HANDOFF — 2026-09-22 (TASK 39 shipped+pushed; TASK 40 App Settings round plan SAVED in `4d9188a`, uncommitted here)
|
||||
# HANDOFF — 2026-09-22 (TASK 41 Test Stream mode shipped; TASK 40 plan queued)
|
||||
|
||||
## Branch / Commit State
|
||||
|
||||
`main` HEAD = **`4d9188a` — docs(plan): TASK 40 App Settings round — saved plan**. Pushed state
|
||||
at `197ee81`. **Working tree DIRTY — ONE docs work unit:**
|
||||
at `197ee81`. **Working tree DIRTY — TASK 41 implementation in progress** (code + docs staged for
|
||||
a single scope-checked commit; HANDOFF rewrite rides in a final docs commit):
|
||||
|
||||
```
|
||||
?? HANDOFF.md (this rewrite)
|
||||
?? HANDOFF.md (this rewrite)
|
||||
M Models/YouTube.cs
|
||||
M Services/YouTubeStreamService.cs
|
||||
M Services/YouTubeChatService.cs
|
||||
?? Services/TestSessionViewModel.cs
|
||||
M ViewModels/MainViewModel.cs
|
||||
M ViewModels/MainViewModel.Ypp.cs
|
||||
M ViewModels/MainViewModel.Account.cs
|
||||
M ViewModels/MainViewModel.Streaming.cs
|
||||
M ViewModels/MainViewModel.Streaming.Operations.cs
|
||||
M Controls/TopBar.xaml
|
||||
M Controls/PreviewPane.xaml
|
||||
?? ytLive.Tests/TestStreamTests.cs
|
||||
M TASKS.md
|
||||
?? TASKS/task-41-test-stream-mode.md
|
||||
M ai.md
|
||||
```
|
||||
Build impact: none — docs only.
|
||||
|
||||
## What's in flight — TASK 40: App Settings round (plan saved 2026-09-22)
|
||||
Build impact: main + tests build clean, **0 warnings**; **314/314 tests pass** (313 prior + 1 new
|
||||
Good Dog). A flaky real-camera startup test aborted one earlier full-suite run (hardware-dependent,
|
||||
unrelated to this change); the clean rerun passes fully.
|
||||
|
||||
Creator (this session) scoped three asks and answered four decision questions. Full plan:
|
||||
**`TASKS/task-40-app-settings-round.md`** (created + committed in `4d9188a`). Four units, one
|
||||
Good Dog test + one scope-checked commit each:
|
||||
## What shipped — TASK 41: Test Stream mode (2026-09-22)
|
||||
|
||||
- **Unit A — App Settings "Active Web Camera"** (ask 1): new section under "Default Location
|
||||
for Recordings" — **Change…** opens a sub-menu of a *fresh* webcam enumeration incl. vcams
|
||||
(adopt+base-lock if `_webcam == null`, else existing `SwapWebcamIdentityAsync`); **Tune…**
|
||||
opens a `CameraConfigDialog` of the device's `VideoDeviceController` MediaDeviceControls
|
||||
(Auto + Min/Max/Step sliders, **live-only**, no persistence). Tech risk to verify at impl:
|
||||
whether MediaDeviceControl writes work under `SharedReadOnly` (ladder: probe capture → running
|
||||
source's controller → read-only + note; spin-guard = research before a 3rd guess).
|
||||
- **Unit B — gear** (ask 2): move from BottomBar lower-left to **TopBar right of the wordmark,
|
||||
gap ≈ 3 letters**; left-click = App Settings, right-click = Bug/Feature/About.
|
||||
- **Unit C** (ask 3): persist default output resolution (currently resets every launch), surface
|
||||
default scene transition + duration (persisted but editable NOWHERE), Confirm-before-End toggle.
|
||||
- **Unit D — accent/theme** (ask 3, split out as its own unit): 69 hardcoded accent sites across
|
||||
15 files → consolidate into a `Themes/Controls.xaml` accent brush (DynamicResource) + picker +
|
||||
`App.AccentColor` Settings key; `Source.ChatMemberColor` stays per-source.
|
||||
Full record: **`TASKS/task-41-test-stream-mode.md`**. Decisions locked with the creator:
|
||||
chat-overlay-events-only (no stinger/TTS — stays TASK 3 16/17/20), TEST drawer in the right rail,
|
||||
hybrid simulation (real text insert + local synthetic events). Key design: since every broadcast is
|
||||
already private-only (`CreateBroadcast` hardcodes `privacyStatus=private`), "Test" is a **session
|
||||
variant**: `BeginTestStream` → extracted-shared `StartStreamingSession(alsoRecord:false)` (no
|
||||
dialog, no "last live" stamp, no recording). `IsTestStream` = gold top bar (`#9c6f1c`) + TEST badge
|
||||
+ "End Test" button face. `TestSession.BeginTest(broadcastId, liveChatId)` opens the TEST drawer
|
||||
once the liveChatId resolves. Mock Chat Input = real `liveChat/messages.insert` round-trip;
|
||||
simulated events inject through the poller's `MessageReceived` seam marked `IsSimulated` (hard API
|
||||
fact: insert only creates text — non-text events are local-only by design). Three-way drawer
|
||||
exclusivity (Stream Settings / YPP / TEST) in `MainViewModel.Ypp.cs`.
|
||||
|
||||
## ⚠️ Open action items / landmines
|
||||
**Good Dog** — `ytLive.Tests/TestStreamTests.cs` (one `[Fact]`): coordinator protocol w/ fake HTTP
|
||||
(insert POST body asserted; simulated shapes verified) + real-window exclusivity/gate. Note: the
|
||||
coordinator section runs on the test thread directly (pure services, no WPF), the window section via
|
||||
`RealAppHost.Run` — deviates from YppPullOutTests' all-in-one-Run shape for debuggable stacks.
|
||||
|
||||
- **The app may still be RUNNING** (a `ytLive` instance locked `bin\…\ytLive.dll` earlier today)
|
||||
— in-place `dotnet build` fails with MSB3021/MSB3027 until it's closed. Workaround that worked:
|
||||
build with `-p:OutDir=C:\Users\gramp\AppData\Local\Temp\ytLive-buildNN\ -p:UseAppHost=false`
|
||||
and run vstest against the temp dll. Close the app before Unit-A final verification.
|
||||
- **Unit A SharedReadOnly control-write risk** is the one genuinely uncertain technical point —
|
||||
do NOT hand-wave it; run the mitigation ladder and (if needed) the spin-guard research.
|
||||
- **`subscriberCount` rounded to 3 sig figs** by Google (YPP numbers are coarse). Carried.
|
||||
- **Slice 2 (YPP Analytics) needs Google re-consent** (`yt-analytics.readonly`) — do NOT merge
|
||||
with any TASK 40 unit.
|
||||
## ⚠️ Around the task
|
||||
|
||||
- **Manual verification still owed** (needs a real run, Windows): Test button → gold face + badge +
|
||||
auto-open TEST drawer; send a message → appears on a ChatBox overlay after ~2s; simulated rows
|
||||
render text/member/SuperChat styled; End Test closes it all out cleanly (transition complete).
|
||||
- The app may be **RUNNING** — in-place build fails MSB3021/MSB3027 until closed (temp-OutDir
|
||||
workaround in ai.md). Close it before the manual pass.
|
||||
- The full-suite flake earlier: real-camera startup test under the test host (hardware, pre-existing
|
||||
behavior; unrelated to TASK 41).
|
||||
- LayerReorderPersistenceTests.RealMouseDrag flaked once pre-TASK-38 (fullscreen game); unrelated.
|
||||
- Polar product `d105dfa1…` still `$99/yr` — must become one-time before launch (carried,
|
||||
`TASKS/task-10-monetization.md`). `MARCOM.md`/`MONETIZATION.md` gitignored — never commit.
|
||||
- `subscriberCount` rounded to 3 sig figs (YPP); carried. YPP slice 2 needs re-consent — do NOT
|
||||
merge with other units.
|
||||
- Polar product `d105dfa1…` still `$99/yr` — must become one-time before launch (carried).
|
||||
`MARCOM.md`/`MONETIZATION.md` gitignored — never commit.
|
||||
|
||||
## Next step
|
||||
|
||||
The user has a **new task spec waiting** — take it, then execute TASK 40 units A → B → C → D per
|
||||
the saved plan (each: declared file list → code → Good Dog test → `./scripts/scope-check.sh` →
|
||||
commit with docs in the SAME commit → push only on the user's say-so). HANDOFF rewrite is due
|
||||
when this docs work unit commits.
|
||||
Commit TASK 41 (scope-check the declared list first), then **TASK 40 App Settings round is queued**
|
||||
(`TASKS/task-40-app-settings-round.md`, units A → B → C → D, one Good Dog each). Unit A's
|
||||
SharedReadOnly control-write question is the one genuinely uncertain technical point — run its
|
||||
mitigation ladder, spin-guard research before a third guess. Push only on the user's say-so.
|
||||
|
||||
## Critical working rules (unchanged, still binding)
|
||||
|
||||
- **Good Dog = ONE integration test per change.**
|
||||
- **Scope lock:** declare the file list before editing; `./scripts/scope-check.sh` before commit.
|
||||
- WSL builds use the Windows dotnet host (`/mnt/c/Program Files/dotnet/dotnet.exe`, quoted paths).
|
||||
- ai.md + code in the SAME commit; HANDOFF rewritten at session end.
|
||||
|
||||
## Where the machine facts live
|
||||
|
||||
- `%APPDATA%\ytLive\ytLive.db` = real layout DB (`layout.db` is a 0-byte legacy file); schema
|
||||
`user_version = 10`. sqlite3 at `/root/android-sdk/platform-tools/sqlite3`.
|
||||
- ffmpeg/ffprobe: `/mnt/c/Program Files/Krita (x64)/bin/` with Windows paths.
|
||||
- `ai.md` §Screen backdrop capture = authoritative backdrop doc (incl. TASK 38);
|
||||
§Monetization awareness = monetization roadmap (TASK 39 state, YPP thresholds).
|
||||
- Business/marcom: `MONETIZATION.md` + `MARCOM.md` (gitignored).
|
||||
- WSL builds use the Windows dotnet host (`/mnt/c/Program Files/dotnet/dotnet.exe`, quoted paths).
|
||||
@@ -31,4 +31,9 @@ public class ChatMessage
|
||||
public string? SuperChatCurrency { get; set; }
|
||||
public bool IsMember { get; set; }
|
||||
public string? MembershipLevel { get; set; }
|
||||
|
||||
/// <summary>TASK 41: the Test Stream dock injects synthetic member / SuperChat /
|
||||
/// subscriber events locally (YouTube's insert API only supports text), so the
|
||||
/// buffer can label them honestly — simulated events never reach YouTube.</summary>
|
||||
public bool IsSimulated { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Input;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 41 — the Test Stream pull-out (the "TEST" tab at the bottom of the Live
|
||||
/// screen's right rail). Drives a private test broadcast's chat tooling:
|
||||
/// Mock Chat Input sends a REAL liveChat/messages.insert (round-tripping through
|
||||
/// the normal ~2s poll and the live overlay path), and the simulated-event buttons
|
||||
/// inject synthetic member / SuperChat / subscriber rows through the exact
|
||||
/// MessageReceived seam the real poll uses — local-only, never sent to YouTube
|
||||
/// (the insert API only creates text). The broadcast itself runs through the real
|
||||
/// private go-live pipeline; this VM only owns the dock.
|
||||
/// </summary>
|
||||
public sealed class TestSessionViewModel : ViewModelBase
|
||||
{
|
||||
private readonly YouTubeStreamService _stream;
|
||||
private readonly YouTubeChatService _chat;
|
||||
private readonly Action? _onEndStream;
|
||||
|
||||
private bool _isDrawerOpen;
|
||||
private bool _isTestSession;
|
||||
private string? _broadcastId;
|
||||
private string? _liveChatId;
|
||||
private string _sendMessageText = string.Empty;
|
||||
private bool _isSending;
|
||||
private string? _lastEventText;
|
||||
private int _eventCounter;
|
||||
|
||||
/// <summary>Fake viewer names for simulated events — several backs make the
|
||||
/// rows look like a real muted room.</summary>
|
||||
private static readonly string[] SimulatedViewers = { "PixelPasta", "NightskyNick", "LunaGlow", "CactusFox" };
|
||||
|
||||
public TestSessionViewModel(
|
||||
YouTubeStreamService stream,
|
||||
YouTubeChatService chat,
|
||||
Action? onEndStream = null)
|
||||
{
|
||||
_stream = stream;
|
||||
_chat = chat;
|
||||
_onEndStream = onEndStream;
|
||||
|
||||
SendTestMessageCommand = new RelayCommand(_ => _ = SendTestMessageAsync(), _ => CanSendTestMessage);
|
||||
SimulateSubscriberCommand = new RelayCommand(_ => Simulate(SimulatedSubscriber()));
|
||||
SimulateMemberCommand = new RelayCommand(_ => Simulate(SimulatedMember()));
|
||||
SimulateSuperChatCommand = new RelayCommand(_ => Simulate(SimulatedSuperChat()));
|
||||
EndTestCommand = new RelayCommand(_ => _onEndStream?.Invoke());
|
||||
ToggleDrawerCommand = new RelayCommand(_ => IsDrawerOpen = !IsDrawerOpen);
|
||||
CloseDrawerCommand = new RelayCommand(_ => IsDrawerOpen = false);
|
||||
}
|
||||
|
||||
public ICommand SendTestMessageCommand { get; }
|
||||
public RelayCommand SimulateSubscriberCommand { get; }
|
||||
public RelayCommand SimulateMemberCommand { get; }
|
||||
public RelayCommand SimulateSuperChatCommand { get; }
|
||||
public RelayCommand EndTestCommand { get; }
|
||||
public RelayCommand ToggleDrawerCommand { get; }
|
||||
public RelayCommand CloseDrawerCommand { get; }
|
||||
|
||||
public bool IsDrawerOpen
|
||||
{
|
||||
get => _isDrawerOpen;
|
||||
set => SetProperty(ref _isDrawerOpen, value);
|
||||
}
|
||||
|
||||
/// <summary>Whether a test broadcast is currently live (ended by BeginTest /
|
||||
/// EndTestSilently; EndTestCommand asks the app to stop, which routes back here).</summary>
|
||||
public bool IsTestSession
|
||||
{
|
||||
get => _isTestSession;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _isTestSession, value))
|
||||
{
|
||||
OnPropertyChanged(nameof(SessionCaption));
|
||||
OnPropertyChanged(nameof(ChatIdLine));
|
||||
OnPropertyChanged(nameof(CanSendTestMessage));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public string? BroadcastId => _broadcastId;
|
||||
|
||||
public string ChatIdLine => _liveChatId == null ? "liveChatId: —" : $"liveChatId: {_liveChatId}";
|
||||
|
||||
public string SessionCaption => IsTestSession
|
||||
? "Test stream is live — private to you, zero notifications, real chat."
|
||||
: "No test in progress — hit the Test button in the top bar to start a private broadcast and check your chat overlay.";
|
||||
|
||||
public string SendMessageText
|
||||
{
|
||||
get => _sendMessageText;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _sendMessageText, value))
|
||||
OnPropertyChanged(nameof(CanSendTestMessage));
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsSending
|
||||
{
|
||||
get => _isSending;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _isSending, value))
|
||||
OnPropertyChanged(nameof(CanSendTestMessage));
|
||||
}
|
||||
}
|
||||
|
||||
public bool CanSendTestMessage => IsTestSession && !IsSending && _liveChatId != null
|
||||
&& !string.IsNullOrWhiteSpace(SendMessageText);
|
||||
|
||||
/// <summary>Feedback line for the last send / simulated event (null = nothing yet).</summary>
|
||||
public string? LastEventText
|
||||
{
|
||||
get => _lastEventText;
|
||||
private set => SetProperty(ref _lastEventText, value);
|
||||
}
|
||||
|
||||
/// <summary>Called once by MainViewModel when the test broadcast is live and its
|
||||
/// liveChatId resolved — opens the dock so the chat tools are immediately usable.</summary>
|
||||
public void BeginTest(string broadcastId, string? liveChatId)
|
||||
{
|
||||
_broadcastId = broadcastId;
|
||||
_liveChatId = string.IsNullOrWhiteSpace(liveChatId) ? null : liveChatId;
|
||||
SendMessageText = string.Empty;
|
||||
LastEventText = _liveChatId == null
|
||||
? "Chat polling couldn't start — Mock Chat Input is disabled."
|
||||
: null;
|
||||
IsTestSession = true;
|
||||
IsDrawerOpen = true;
|
||||
}
|
||||
|
||||
/// <summary>Called by MainViewModel's stop path when the broadcast ends — the
|
||||
/// dock forgets the ended session (idempotent).</summary>
|
||||
public void EndTestSilently()
|
||||
{
|
||||
_broadcastId = null;
|
||||
_liveChatId = null;
|
||||
IsTestSession = false;
|
||||
IsSending = false;
|
||||
}
|
||||
|
||||
/// <summary>Mock Chat Input: real liveChat/messages.insert. The inserted text
|
||||
/// arrives through the normal poll and renders via the live overlay path — the
|
||||
/// whole point is that the round-trip is the real one.</summary>
|
||||
public async Task SendTestMessageAsync()
|
||||
{
|
||||
if (!CanSendTestMessage || _liveChatId == null) return;
|
||||
var text = SendMessageText.Trim();
|
||||
IsSending = true;
|
||||
SendMessageText = string.Empty;
|
||||
LastEventText = null;
|
||||
try
|
||||
{
|
||||
var error = await _stream.InsertChatMessageAsync(_liveChatId, text);
|
||||
LastEventText = error == null
|
||||
? $"Sent — “{text}” should appear on the chat overlay within a couple of seconds."
|
||||
: $"Couldn't send: {error}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write($"Test chat message send failed: {ex.Message}");
|
||||
LastEventText = "Couldn't send that message — try again.";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsSending = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void Simulate(ChatMessage message)
|
||||
{
|
||||
if (!IsTestSession) return;
|
||||
_chat.InjectSimulatedMessage(message);
|
||||
_eventCounter++;
|
||||
LastEventText = $"Fired #{_eventCounter}: {Describe(message)} — renders on any visible chat overlay.";
|
||||
AppLog.Write($"Test chat simulation {_eventCounter} injected");
|
||||
}
|
||||
|
||||
/// <summary>Subscriptions aren't chat events on YouTube, so a "subscriber" row
|
||||
/// is a plain text-shaped message carrying the count — honest: the chat overlay
|
||||
/// shows it as a regular row, which is exactly what a sub looks like in chat.</summary>
|
||||
private ChatMessage SimulatedSubscriber()
|
||||
{
|
||||
var author = NextViewer();
|
||||
return NewSimulated(author, "New subscriber — welcome to the herd! 🦙 (simulated subscribe)");
|
||||
}
|
||||
|
||||
private ChatMessage SimulatedMember()
|
||||
{
|
||||
var author = NextViewer();
|
||||
var message = NewSimulated(author, $"🎉 {author} became a member!");
|
||||
message.IsMember = true;
|
||||
message.MembershipLevel = "New member";
|
||||
return message;
|
||||
}
|
||||
|
||||
private ChatMessage SimulatedSuperChat()
|
||||
{
|
||||
var author = NextViewer();
|
||||
var message = NewSimulated(author, "Keep the streams flowing, llama power 🦙");
|
||||
message.IsSuperChat = true;
|
||||
message.SuperChatAmount = 5.00;
|
||||
message.SuperChatCurrency = "$";
|
||||
return message;
|
||||
}
|
||||
|
||||
private ChatMessage NewSimulated(string author, string text)
|
||||
{
|
||||
return new ChatMessage
|
||||
{
|
||||
AuthorName = author,
|
||||
Message = text,
|
||||
Timestamp = DateTime.Now,
|
||||
IsSimulated = true,
|
||||
};
|
||||
}
|
||||
|
||||
private string NextViewer() => SimulatedViewers[_eventCounter % SimulatedViewers.Length];
|
||||
|
||||
private static string Describe(ChatMessage m)
|
||||
{
|
||||
if (m.IsSuperChat) return $"{m.AuthorName} pledged {m.SuperChatCurrency}{m.SuperChatAmount:0.00} in a Super Chat";
|
||||
if (m.IsMember) return $"{m.AuthorName} just became a member ({m.MembershipLevel})";
|
||||
return $"{m.AuthorName} subscribed";
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,14 @@ public class YouTubeChatService : IDisposable
|
||||
_pollTimer = null;
|
||||
}
|
||||
|
||||
/// <summary>TASK 41: injects a synthetic message through the exact seam the real
|
||||
/// poll uses (MessageReceived), so a simulated member / SuperChat / subscriber
|
||||
/// event renders through the whole overlay pipeline. YouTube's insert API only
|
||||
/// creates text messages — these events are local-only by design and never
|
||||
/// reach YouTube (marked <see cref="ChatMessage.IsSimulated"/>).</summary>
|
||||
public void InjectSimulatedMessage(ChatMessage message)
|
||||
=> MessageReceived?.Invoke(message);
|
||||
|
||||
private async Task Poll()
|
||||
{
|
||||
if (!_isRunning || _liveChatId == null || _auth.CurrentChannel == null) return;
|
||||
|
||||
@@ -258,6 +258,39 @@ public class YouTubeStreamService
|
||||
return health;
|
||||
}
|
||||
|
||||
/// <summary>TASK 41 — mock chat input: posts a real text message into the live
|
||||
/// chat via <c>liveChat/messages.insert</c>. Requires the same OAuth scopes the
|
||||
/// app already holds (youtube.force-ssl). Returns itself or a human-readable
|
||||
/// error; never throws. The inserted message round-trips back through the normal
|
||||
/// chat poll (~2s) and renders through the live overlay path.</summary>
|
||||
public async Task<string?> InsertChatMessageAsync(string liveChatId, string messageText)
|
||||
{
|
||||
if (!await EnsureToken()) return "not signed in";
|
||||
|
||||
var body = new
|
||||
{
|
||||
snippet = new
|
||||
{
|
||||
liveChatId,
|
||||
textMessageDetails = new { messageText }
|
||||
}
|
||||
};
|
||||
|
||||
_http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken);
|
||||
var response = await _http.PostAsJsonAsync(
|
||||
$"{ApiBase}/liveChat/messages?part=snippet", body);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
AppLog.Write($"Test chat message inserted into {liveChatId}");
|
||||
return null;
|
||||
}
|
||||
|
||||
var errorBody = await response.Content.ReadAsStringAsync();
|
||||
AppLog.Write($"Chat message insert failed ({(int)response.StatusCode}): {errorBody}");
|
||||
return $"YouTube rejected the message ({(int)response.StatusCode})";
|
||||
}
|
||||
|
||||
/// <summary>Returns the liveChatId for a broadcast by fetching its contentDetails.
|
||||
/// The liveChatId is needed to poll chat messages. Called after broadcast creation
|
||||
/// in PrepareAndStartLiveAsync.</summary>
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
| 38 | Capture Window… backdrop (in-app window picker) | ✅ Done — shipped 2026-09-21 | [`TASKS/task-38-window-backdrop.md`](TASKS/task-38-window-backdrop.md) |
|
||||
| 39 | YPP journey tracker (slice 1: current-scope data) | 🔶 Slice 1 ✅ — 2026-09-22; slice 2 (Analytics + ETA) queued | [`TASKS/task-39-ypp-journey-tracker.md`](TASKS/task-39-ypp-journey-tracker.md) |
|
||||
| 40 | App Settings round: active-camera picker + config dialog, gear move, defaults, accent | ☐ Queued (2026-09-22) — plan saved | [`TASKS/task-40-app-settings-round.md`](TASKS/task-40-app-settings-round.md) |
|
||||
| 41 | Test Stream mode: private test broadcast + TEST drawer (mock chat input + simulated events) | ✅ Done (2026-09-22) | [`TASKS/task-41-test-stream-mode.md`](TASKS/task-41-test-stream-mode.md) |
|
||||
|
||||
---
|
||||
|
||||
@@ -88,6 +89,13 @@
|
||||
and a `current` set carrying the build-id; a saved `current` is honored only by the same build-id,
|
||||
otherwise defaults load — enables one-click revert to defaults too. Capture needed work OUT of
|
||||
current scope here; do not bolt it onto an in-flight change.
|
||||
- **TASK 41 — Test Stream mode** — DONE 2026-09-22 (`TASKS/task-41-test-stream-mode.md`): a Test
|
||||
button next to Start runs the real private-only go-live pipeline with `IsTestStream` set, and the
|
||||
TEST drawer (right rail, third tab) hosts Mock Chat Input (real `liveChat/messages.insert` → ~2s
|
||||
poll round-trip renders on the overlay) + simulated Subscriber/New Member/Super Chat events that
|
||||
inject through the poller's `MessageReceived` seam (`IsSimulated` — YouTube's insert API only
|
||||
creates text, so these are local-only by design; styled member/SuperChat rows validate the chat
|
||||
overlay). Real stinger/TTS alert widgets stay out of scope (TASK 3 items 16/17/20).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# TASK 41 — Test Stream mode: private test broadcast + TEST drawer
|
||||
|
||||
**Status:** ✅ Done (2026-09-22) — one scope-checked work unit, one Good Dog integration test,
|
||||
314/314 suite green, 0 warnings.
|
||||
|
||||
## Creator ask (2026-09-22)
|
||||
|
||||
"YouTube Test Stream & Chat Integration": a **Test/Preview** button beside Start that runs a
|
||||
private broadcast via the API, monitors stream/ingest, activates the chat endpoint (liveChatId),
|
||||
and provides a **chat simulator**: mock chat input (`liveChatMessages.insert`) + a toggle to
|
||||
inject mock chat, with alert/event triggers for subscriber, membership, and Super Chat.
|
||||
|
||||
## Locked decisions (user answers, 2026-09-22)
|
||||
|
||||
- **Chat-overlay events only** — simulated events validate the existing chat overlay styling
|
||||
(text / member `memberColor` / SuperChat `superChatColor` rows). Dedicated stinger/TTS alert
|
||||
widgets are NOT part of this task — they stay queued under **TASK 3 items 16/17/20**.
|
||||
- **TEST drawer in the right rail** — third white tab below YPP, three-way one-open-at-a-time
|
||||
exclusivity, auto-opens when a test broadcast boots.
|
||||
- **Hybrid simulation** — text = REAL `liveChat/messages.insert` (round-trips the real 2s poll
|
||||
and renders through the live overlay path); member/SuperChat/subscriber = **local injection** at
|
||||
the poller's `MessageReceived` seam (marked `IsSimulated`). YouTube's insert API only creates
|
||||
`textMessageEvent`s — non-text events are local-only by design.
|
||||
|
||||
## Facts the build sits on (verified in code, 2026-09-22 research pass)
|
||||
|
||||
- **The app is already private-only go-live** — `YouTubeStreamService.CreateBroadcast` hardcodes
|
||||
`privacyStatus="private"` + `enableAutoStart/Stop` + `low latency`, no monitor stream
|
||||
(`enableMonitorStream=false`), and the Go Live dialog is locked to Private. "Test" is therefore a
|
||||
**session variant over the real pipeline**, not a new broadcast mode.
|
||||
- The **liveChatId flow already exists**: `GetBroadcastLiveChatIdAsync` → `_youtubeChat.Start(liveChatId)`
|
||||
in `PrepareAndStartLiveAsync` — the "chat endpoint activation" the ask describes.
|
||||
- OAuth scope already includes `youtube` + `youtube.force-ssl` (`YouTubeAuthService.cs:18`) —
|
||||
enough for `liveChat/messages.insert`; no re-consent.
|
||||
- Chat = **polling** (`YouTubeChatService`, 2s): no webhook exists or is planned.
|
||||
- No alert system exists anywhere in the codebase (grep verified) — hence the scope decision above.
|
||||
- Existing mock-chat *preview* (`ChatOverlayLayer.StartMockChatPreview`) is static placeholders,
|
||||
not live-injectable — separate from this task.
|
||||
|
||||
## What shipped (TASK 41, 2026-09-22)
|
||||
|
||||
- **`Controls/TopBar.xaml`** — **Test** button (secondary style) left of Start; visible only when
|
||||
`ShowTestButton` (offline ∧ not recording ∧ signed-in); **TEST** amber badge while
|
||||
`IsTestStream` (next to the elapsed clock).
|
||||
- **`Controls/PreviewPane.xaml`** — **TEST** tab (gold text, below YPP on the rail) + TEST drawer:
|
||||
session caption / liveChatId line, **Mock Chat Input** (TextBox + Send), **SIMULATED EVENTS**
|
||||
(Subscriber / New Member / Super Chat buttons), live feedback line, footer "End Test" + the
|
||||
"private, same pipeline as a real live" note; drawer exclusivity wired three-way.
|
||||
- **`Services/TestSessionViewModel.cs`** (NEW) — the TEST drawer VM: `BeginTest(broadcastId,
|
||||
liveChatId)` (auto-opens the dock once the liveChatId resolves), `SendTestMessageAsync` → real
|
||||
insert, `Simulate{Subscriber,Member,SuperChat}Command` → `InjectSimulatedMessage`, `EndTestCommand`
|
||||
→ hands to `MainViewModel.StopStream`, `EndTestSilently` (idempotent, called on any stop/
|
||||
rollback). Fully testable — takes `YouTubeStreamService` + `YouTubeChatService` via ctor.
|
||||
- **`Services/YouTubeStreamService.InsertChatMessageAsync(liveChatId, text)`** —
|
||||
`POST liveChat/messages?part=snippet`; returns string error or null; never throws.
|
||||
- **`Services/YouTubeChatService.InjectSimulatedMessage(ChatMessage)`** — raises `MessageReceived`,
|
||||
the exact seam the real poll uses, so simulations flow through the whole overlay pipeline;
|
||||
marked `ChatMessage.IsSimulated`.
|
||||
- **`ViewModels/MainViewModel.*`** — `IsTestStream` (internal setter, test seam like `IsRecording`);
|
||||
`BeginTestStream()` → the shared `StartStreamingSession(alsoRecord:false)` extracted from
|
||||
`BeginGoLive` (no dialog — a Test needs no confirmation, every broadcast is already private; no
|
||||
`BroadcastForm.CaptureGoLive` "last live" stamp; no recording). `StopStream` clears the face and
|
||||
forgets the drawer session. `EndButtonText` = "End Test" while in a test. Gold top bar + glow
|
||||
(`#9c6f1c`) identify a rehearsal vs the live red.
|
||||
- **Drawer exclusivity** (this task): opening TEST collapses Stream Settings + YPP and vice versa
|
||||
(wired in `MainViewModel.Ypp.cs`).
|
||||
|
||||
## Good Dog — ONE integration test
|
||||
|
||||
`ytLive.Tests/TestStreamTests.cs` — one `[Fact]`, two sections:
|
||||
1. **Coordinator (no network, `RecordingHandler`-faked HTTP):** idle → `BeginTest` opens the dock
|
||||
+ enables chat; Mock Chat Input emits the insert (POST body asserted for the liveChatId +
|
||||
messageText); Subscriber/New Member/Super Chat simulations arrive through `MessageReceived`
|
||||
marked `IsSimulated` with the right shapes (member level, `$5.00` Super Chat, plain subscriber
|
||||
row); End hands back to the app; `EndTestSilently` clears.
|
||||
2. **Real window (temp DB):** TEST tab/drawer exist, three-way drawer exclusivity, Test button
|
||||
gate follows sign-in deterministically.
|
||||
|
||||
## Follow-ups (NOT in this task)
|
||||
|
||||
- Real stinger/TTS alert widgets → TASK 3 items 16/17/20.
|
||||
- Test-mode health-panel depth (per-broadcast status inside the drawer) if ever wanted.
|
||||
@@ -22,6 +22,7 @@ public partial class MainViewModel
|
||||
if (!value) OnAirPillOn = false;
|
||||
OnPropertyChanged(nameof(PrimaryStartButtonLabel));
|
||||
OnPropertyChanged(nameof(ShowPrimaryStartButton));
|
||||
OnPropertyChanged(nameof(ShowTestButton));
|
||||
OnPropertyChanged(nameof(AccountStatusLightToolTip));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,39 +76,63 @@ public partial class MainViewModel : ViewModelBase
|
||||
StreamDescription = BroadcastForm.Description,
|
||||
};
|
||||
var window = new ytLive.GoLiveWindow(dialog) { Owner = System.Windows.Application.Current.MainWindow };
|
||||
if (window.ShowDialog() == true)
|
||||
if (window.ShowDialog() != true) return;
|
||||
|
||||
StreamTitle = dialog.StreamTitle;
|
||||
StreamDescription = dialog.StreamDescription;
|
||||
StreamVisibility = dialog.Visibility; // always "Private" (ship step 7)
|
||||
BroadcastForm.CaptureGoLive(dialog.StreamTitle, dialog.StreamDescription, DateTime.UtcNow);
|
||||
StartStreamingSession(alsoRecord);
|
||||
}
|
||||
|
||||
/// <summary>TASK 41 — Test stream: one click, no dialog. Every broadcast is
|
||||
/// already private-only (ship step 7), so there is nothing to confirm; the test
|
||||
/// session reuses the ENTIRE real go-live pipeline (reusable stream, broadcast
|
||||
/// create, RTMP push, chat poll) with IsTestStream flipped so the face says TEST
|
||||
/// and the Test drawer opens once the liveChatId resolves. No "last live" stamp:
|
||||
/// a rehearsal isn't a broadcast the creator took on the record.</summary>
|
||||
private void BeginTestStream()
|
||||
{
|
||||
if (!CanStartTest) return;
|
||||
IsTestStream = true; // before StreamStatus so the visuals initialize gold
|
||||
StreamTitle = BroadcastForm.Title;
|
||||
StreamDescription = BroadcastForm.Description;
|
||||
StreamVisibility = "Private";
|
||||
StartStreamingSession(alsoRecord: false);
|
||||
}
|
||||
|
||||
/// <summary>Boots the streaming face of ANY session (dialog go-live or test):
|
||||
/// status streaming, audio live, timer reset, optional recording, then the async
|
||||
/// broadcast preparation. The test path skips BroadcastForm.CaptureGoLive by
|
||||
/// design and never records.</summary>
|
||||
private void StartStreamingSession(bool alsoRecord)
|
||||
{
|
||||
WindowTitle = string.IsNullOrWhiteSpace(StreamTitle)
|
||||
? "LlamaCasty"
|
||||
: $"{StreamTitle} — LlamaCasty";
|
||||
StreamStatus = StreamStatus.Streaming;
|
||||
ResetHealth(StreamStatus.Streaming);
|
||||
_audioMixer.StartLive(EncoderOptions.DefaultAudioPipeName);
|
||||
_liveElapsed = TimeSpan.Zero;
|
||||
_recordLength = TimeSpan.Zero;
|
||||
|
||||
var recordStarted = false;
|
||||
if (alsoRecord)
|
||||
{
|
||||
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";
|
||||
StreamStatus = StreamStatus.Streaming;
|
||||
ResetHealth(StreamStatus.Streaming);
|
||||
_audioMixer.StartLive(EncoderOptions.DefaultAudioPipeName);
|
||||
_liveElapsed = TimeSpan.Zero;
|
||||
_recordLength = TimeSpan.Zero;
|
||||
|
||||
var recordStarted = false;
|
||||
if (alsoRecord)
|
||||
if (StartRecordFile() == null)
|
||||
{
|
||||
if (StartRecordFile() == null)
|
||||
{
|
||||
_notifications.Error("Couldn't record",
|
||||
"No record output path could be created — streaming continues without recording.");
|
||||
}
|
||||
else
|
||||
{
|
||||
_recordStartTime = DateTime.Now;
|
||||
recordStarted = true;
|
||||
}
|
||||
_notifications.Error("Couldn't record",
|
||||
"No record output path could be created — streaming continues without recording.");
|
||||
}
|
||||
else
|
||||
{
|
||||
_recordStartTime = DateTime.Now;
|
||||
recordStarted = true;
|
||||
}
|
||||
IsRecording = recordStarted;
|
||||
|
||||
_ = PrepareAndStartLiveAsync();
|
||||
}
|
||||
IsRecording = recordStarted;
|
||||
|
||||
_ = PrepareAndStartLiveAsync();
|
||||
}
|
||||
|
||||
/// <summary>Creates the record file (auto-named <c>ty-…-0000.mp4</c> in the record
|
||||
@@ -158,9 +182,10 @@ public partial class MainViewModel : ViewModelBase
|
||||
// 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,
|
||||
// the stream continues without on-panel chat.
|
||||
string? liveChatId = null;
|
||||
try
|
||||
{
|
||||
var liveChatId = await _youtubeStream.GetBroadcastLiveChatIdAsync(_currentBroadcastId);
|
||||
liveChatId = await _youtubeStream.GetBroadcastLiveChatIdAsync(_currentBroadcastId);
|
||||
if (liveChatId != null)
|
||||
{
|
||||
_youtubeChat.Start(liveChatId);
|
||||
@@ -175,6 +200,14 @@ public partial class MainViewModel : ViewModelBase
|
||||
{
|
||||
AppLog.Write($"Chat start failed (non-fatal): {chatEx.Message}");
|
||||
}
|
||||
|
||||
// TASK 41: a test broadcast hands its session to the Test drawer once
|
||||
// the liveChatId resolves — Mock Chat Input + simulated events need it.
|
||||
if (IsTestStream && _currentBroadcastId != null)
|
||||
{
|
||||
TestSession.BeginTest(_currentBroadcastId, liveChatId);
|
||||
AppLog.Write($"Test session dock open for {_currentBroadcastId}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -244,6 +277,10 @@ public partial class MainViewModel : ViewModelBase
|
||||
|
||||
StreamStatus = StreamStatus.Offline;
|
||||
IsRecording = false;
|
||||
// TASK 41: a stopped test broadcast forgets its drawer session (idempotent)
|
||||
// and drops the TEST face — a test never outlives its run.
|
||||
TestSession.EndTestSilently();
|
||||
IsTestStream = false;
|
||||
// Stop ends EVERYTHING (creator ruling 2026-09-01): a lit pill with no session
|
||||
// behind it is a lie — the stuck REC pill after the failed 2026-09-01 recording
|
||||
// attempt was the report. Intent resets with reality; re-arming is one click.
|
||||
@@ -379,8 +416,10 @@ public partial class MainViewModel : ViewModelBase
|
||||
private void UpdateLiveVisuals()
|
||||
{
|
||||
var live = IsLive;
|
||||
TopBarBackground = live ? "#e94560" : "#16213e";
|
||||
PreviewGlowBrush = live ? "#e94560" : "Transparent";
|
||||
// A live TEST broadcast wears gold, not the live red — the TEST badge +
|
||||
// gold are the "this is a rehearsal" cue (TASK 41).
|
||||
TopBarBackground = live ? IsTestStream ? "#9c6f1c" : "#e94560" : "#16213e";
|
||||
PreviewGlowBrush = live ? IsTestStream ? "#9c6f1c" : "#e94560" : "Transparent";
|
||||
PreviewGlowThickness = live ? new Thickness(3) : new Thickness(0);
|
||||
|
||||
if (live)
|
||||
|
||||
@@ -41,6 +41,13 @@ public partial class MainViewModel : ViewModelBase
|
||||
private bool _onAirPillOn;
|
||||
private bool _isRecording;
|
||||
|
||||
/// <summary>TASK 41: the current session is a private TEST broadcast — the real
|
||||
/// go-live pipeline runs (broadcast create, reusable stream, RTMP push, chat
|
||||
/// poll), but the visuals say TEST (top-bar gold + badge), no "last live" stamp
|
||||
/// is written, and the Test drawer is open. One flag flips the whole face of the
|
||||
/// session; cleared on stop / rollback.</summary>
|
||||
private bool _isTestStream;
|
||||
|
||||
/// <summary>Resolved record folder — the configured choice, or Downloads (0th case).</summary>
|
||||
|
||||
|
||||
@@ -64,6 +71,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
OnPropertyChanged(nameof(ShowPrimaryStartButton));
|
||||
OnPropertyChanged(nameof(ShowEndStreamButton));
|
||||
OnPropertyChanged(nameof(EndButtonText));
|
||||
OnPropertyChanged(nameof(ShowTestButton));
|
||||
OnPropertyChanged(nameof(AccountStatusLightToolTip));
|
||||
OnPropertyChanged(nameof(ShowChatInactiveMessage));
|
||||
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
|
||||
@@ -126,6 +134,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
OnPropertyChanged(nameof(ShowEndStreamButton));
|
||||
OnPropertyChanged(nameof(ShowPrimaryStartButton));
|
||||
OnPropertyChanged(nameof(EndButtonText));
|
||||
OnPropertyChanged(nameof(ShowTestButton));
|
||||
UpdateSessionTimer();
|
||||
}
|
||||
}
|
||||
@@ -154,8 +163,35 @@ public partial class MainViewModel : ViewModelBase
|
||||
public bool ShowEndStreamButton => IsLive || IsRecording;
|
||||
|
||||
/// <summary>The one button whose label changes: while recording it reads
|
||||
/// "Stop Recording", while live "End Stream".</summary>
|
||||
public string EndButtonText => IsRecording && !IsLive ? "Stop Recording" : "End Stream";
|
||||
/// "Stop Recording", while live "End Stream" — and while a test broadcast runs,
|
||||
/// "End Test" so the creator knows exactly what they're stopping.</summary>
|
||||
public string EndButtonText =>
|
||||
IsRecording && !IsLive ? "Stop Recording"
|
||||
: IsTestStream ? "End Test"
|
||||
: "End Stream";
|
||||
|
||||
/// <summary>TASK 41 — the Test button (top bar, left of Start): a one-click
|
||||
/// private test broadcast. Only meaningful signed-in and idle — it disappears
|
||||
/// the moment a session (live OR recording) starts.</summary>
|
||||
public bool ShowTestButton => IsOffline && !IsRecording && IsConnected;
|
||||
|
||||
public bool CanStartTest => ShowTestButton;
|
||||
|
||||
/// <summary>TASK 41 — the current session is a test broadcast (drives the TEST
|
||||
/// badge + gold top bar + "End Test" face). Internal setter is a test seam like
|
||||
/// IsRecording: <see cref="BeginTestStream"/> flips it before <see cref="StreamStatus"/>
|
||||
/// goes Streaming so the visuals initialize amber, and the stop/rollback path
|
||||
/// clears it.</summary>
|
||||
public bool IsTestStream
|
||||
{
|
||||
get => _isTestStream;
|
||||
internal set
|
||||
{
|
||||
if (!SetProperty(ref _isTestStream, value)) return;
|
||||
OnPropertyChanged(nameof(EndButtonText));
|
||||
UpdateLiveVisuals();
|
||||
}
|
||||
}
|
||||
|
||||
public StreamHealth CurrentHealth
|
||||
{
|
||||
@@ -330,6 +366,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
}
|
||||
|
||||
public ICommand StartStreamCommand { get; }
|
||||
public ICommand StartTestCommand { get; }
|
||||
public ICommand EndStreamCommand { get; }
|
||||
public ICommand SignInCommand { get; }
|
||||
public ICommand ChooseRecordFolderCommand { get; }
|
||||
|
||||
@@ -12,6 +12,11 @@ public partial class MainViewModel
|
||||
/// on the Live screen's right edge (TASK 39).</summary>
|
||||
public Services.YppTrackerViewModel Ypp { get; private set; } = null!;
|
||||
|
||||
/// <summary>The Test Stream pull-out backing VM — the TEST drawer at the
|
||||
/// bottom of the right rail (TASK 41): mock chat input (real insert →
|
||||
/// poll round-trip) + locally-simulated member / SuperChat / subscriber rows.</summary>
|
||||
public Services.TestSessionViewModel TestSession { get; private set; } = null!;
|
||||
|
||||
private void InitYpp()
|
||||
{
|
||||
Ypp = new YppTrackerViewModel(
|
||||
@@ -20,17 +25,36 @@ public partial class MainViewModel
|
||||
() => IsConnected,
|
||||
() => _youtubeAuth.CurrentChannel?.DisplayName);
|
||||
|
||||
// One drawer at a time: the two pull-outs share the preview's right edge,
|
||||
// so opening one collapses the other (both directions).
|
||||
TestSession = new TestSessionViewModel(
|
||||
_youtubeStream,
|
||||
_youtubeChat,
|
||||
onEndStream: StopStream);
|
||||
|
||||
// One drawer at a time: the three pull-outs share the preview's right edge,
|
||||
// so opening one collapses the other two (all directions).
|
||||
BroadcastForm.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(LiveBroadcastFormViewModel.IsDrawerOpen) && BroadcastForm.IsDrawerOpen)
|
||||
{
|
||||
Ypp.IsDrawerOpen = false;
|
||||
TestSession.IsDrawerOpen = false;
|
||||
}
|
||||
};
|
||||
Ypp.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(YppTrackerViewModel.IsDrawerOpen) && Ypp.IsDrawerOpen)
|
||||
{
|
||||
BroadcastForm.IsDrawerOpen = false;
|
||||
TestSession.IsDrawerOpen = false;
|
||||
}
|
||||
};
|
||||
TestSession.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(TestSessionViewModel.IsDrawerOpen) && TestSession.IsDrawerOpen)
|
||||
{
|
||||
BroadcastForm.IsDrawerOpen = false;
|
||||
Ypp.IsDrawerOpen = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -219,6 +219,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
OpenPremiumCommand = new RelayCommand(_ => OpenUrl(PremiumUrl), _ => IsPremiumAvailable);
|
||||
OpenLicensesCommand = new RelayCommand(_ => ShowLicenses());
|
||||
StartStreamCommand = new RelayCommand(_ => StartSession());
|
||||
StartTestCommand = new RelayCommand(_ => BeginTestStream(), _ => CanStartTest);
|
||||
SignInCommand = new RelayCommand(_ => _ = SignInAsync(), _ => !IsConnected);
|
||||
EndStreamCommand = new RelayCommand(_ => StopStream(), _ => IsLive || IsRecording);
|
||||
ChooseRecordFolderCommand = new RelayCommand(_ => ChooseRecordFolder());
|
||||
|
||||
@@ -1528,3 +1528,20 @@ These are the hard facts behind every decision. Full list in `TASKS.md`.
|
||||
mono/stereo only. YouTube flags violations via health status.
|
||||
- **Broadcast ID == Video ID** — one ID tracks status, health, and the auto-created VOD
|
||||
(`recordFromStart` + `enableDvr` default true).
|
||||
- **Test Stream mode (TASK 41, SHIPPED 2026-09-22)** — every broadcast is private-only anyway
|
||||
(see the visibility lock above), so "Test" is a **session variant over the real go-live
|
||||
pipeline**, not a new mode: `BeginTestStream` → `StartStreamingSession(alsoRecord:false)`
|
||||
(the dialog path was extracted into this shared boot — no Go Live dialog for a Test, no
|
||||
`BroadcastForm.CaptureGoLive` "last live" stamp, no recording). `IsTestStream` flips the face:
|
||||
gold top bar + glow (`#9c6f1c`), a **TEST** badge, and the End button reads **"End Test"**;
|
||||
`StopStream` clears it in every path (including pump-failure rollback). Once the liveChatId
|
||||
resolves, `TestSession.BeginTest(broadcastId, liveChatId)` opens the **TEST drawer** (third
|
||||
right-rail pull-out, three-way one-open-at-a-time exclusivity wired in `MainViewModel.Ypp.cs`).
|
||||
Drawer tooling: **Mock Chat Input** = a REAL `liveChat/messages.insert` (`YouTubeStreamService.InsertChatMessageAsync`)
|
||||
that round-trips through the real ~2s poll and renders through the live overlay path;
|
||||
**simulated Subscriber / New Member / Super Chat** = `YouTubeChatService.InjectSimulatedMessage`,
|
||||
which raises the poller's `MessageReceived` seam, marked `ChatMessage.IsSimulated`. **API fact
|
||||
(researched + hard constraint): `liveChat/messages.insert` only creates `textMessageEvent`s —
|
||||
SuperChats / memberships / sponsorships cannot be inserted via any API**; simulated non-text
|
||||
events are local-only by design and never reach YouTube. Scopes `youtube` + `youtube.force-ssl`
|
||||
already cover insert. Real stinger/TTS alert widgets remain TASK 3 items 16/17/20.
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 41 Good Dog: Test Stream mode. Two sections under one [Fact]:
|
||||
/// (1) the Test drawer's chat tooling through the coordinator VM (pure services, fake
|
||||
/// HTTP — no network, no WPF): BeginTest opens the dock, Mock Chat Input emits a real
|
||||
/// liveChat/messages.insert (POST body asserted), simulated member / SuperChat /
|
||||
/// subscriber events arrive through the MessageReceived seam marked IsSimulated,
|
||||
/// End asks the app to stop, EndTestSilently clears the session; (2) a real MainWindow
|
||||
/// (temp-DB + RealAppHost, mirrors YppPullOutTests) enforces three-way one-open-at-a-time
|
||||
/// drawer exclusivity and the Test-button sign-in gate.
|
||||
/// </summary>
|
||||
[Collection("RealApp")]
|
||||
public sealed class TestStreamTests
|
||||
{
|
||||
private readonly RealAppHost _app;
|
||||
|
||||
public TestStreamTests(RealAppHost app) => _app = app;
|
||||
|
||||
private sealed class RecordingHandler : HttpMessageHandler
|
||||
{
|
||||
public string? LastBody;
|
||||
public string ResponseBody = """{"id":"msg-1"}""";
|
||||
public readonly List<string> Requests = new();
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
Requests.Add($"{request.Method.Method} {request.RequestUri}");
|
||||
LastBody = request.Content?.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(ResponseBody, Encoding.UTF8, "application/json"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static YouTubeAuthService CreateAuthed()
|
||||
{
|
||||
var auth = new YouTubeAuthService("test-id", "test-secret");
|
||||
auth.SetSession(new YouTubeChannel
|
||||
{
|
||||
AccessToken = "acc-123",
|
||||
TokenExpiry = DateTime.UtcNow.AddHours(1),
|
||||
});
|
||||
return auth;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TestStream_DockTooling_And_ThreeWayDrawerExclusivity()
|
||||
{
|
||||
RunCoordinator();
|
||||
_app.Run(RunWindow);
|
||||
}
|
||||
|
||||
private void RunCoordinator()
|
||||
{
|
||||
var handler = new RecordingHandler();
|
||||
var stream = new YouTubeStreamService(CreateAuthed(), new HttpClient(handler));
|
||||
var chat = new YouTubeChatService(CreateAuthed());
|
||||
var received = new List<ChatMessage>();
|
||||
chat.MessageReceived += received.Add;
|
||||
|
||||
var ended = false;
|
||||
var vm = new TestSessionViewModel(stream, chat, onEndStream: () => ended = true);
|
||||
|
||||
Assert.False(vm.IsTestSession, "idle until a test broadcast hands in");
|
||||
Assert.False(vm.IsDrawerOpen);
|
||||
Assert.False(vm.CanSendTestMessage);
|
||||
|
||||
// The boot path: MainViewModel opens the dock once the liveChatId resolves.
|
||||
vm.BeginTest("BC-TEST", "Cg0KC0NOTIFTEST");
|
||||
Assert.True(vm.IsTestSession);
|
||||
Assert.True(vm.IsDrawerOpen, "BeginTest opens the dock so it's immediately usable");
|
||||
Assert.Contains("Cg0KC0NOTIFTEST", vm.ChatIdLine);
|
||||
Assert.False(vm.CanSendTestMessage, "no draft text yet — an empty send is disabled");
|
||||
|
||||
// Mock Chat Input → a REAL liveChat/messages.insert (POST body asserted).
|
||||
vm.SendMessageText = "hello test chat";
|
||||
Assert.True(vm.CanSendTestMessage);
|
||||
vm.SendTestMessageAsync().GetAwaiter().GetResult();
|
||||
Assert.NotNull(handler.LastBody);
|
||||
Assert.Contains("\"liveChatId\":\"Cg0KC0NOTIFTEST\"", handler.LastBody);
|
||||
Assert.Contains("\"messageText\":\"hello test chat\"", handler.LastBody);
|
||||
Assert.Empty(received);
|
||||
Assert.Contains("hello test chat", vm.LastEventText);
|
||||
|
||||
// Simulated events arrive through the poller's MessageReceived seam,
|
||||
// marked simulated so the buffer stays honest (they never reach YouTube).
|
||||
vm.SimulateMemberCommand.Execute(null);
|
||||
vm.SimulateSuperChatCommand.Execute(null);
|
||||
vm.SimulateSubscriberCommand.Execute(null);
|
||||
Assert.Equal(3, received.Count);
|
||||
Assert.All(received, m => Assert.True(m.IsSimulated));
|
||||
|
||||
var member = received[0];
|
||||
Assert.True(member.IsMember);
|
||||
Assert.Equal("New member", member.MembershipLevel);
|
||||
|
||||
var superChat = received[1];
|
||||
Assert.True(superChat.IsSuperChat);
|
||||
Assert.Equal(5.00, superChat.SuperChatAmount, 2);
|
||||
Assert.Equal("$", superChat.SuperChatCurrency);
|
||||
|
||||
var subscriber = received[2];
|
||||
Assert.False(subscriber.IsMember);
|
||||
Assert.False(subscriber.IsSuperChat);
|
||||
Assert.Contains("subscriber", subscriber.Message, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// The End Test button hands back to the app (MainViewModel.StopStream).
|
||||
vm.EndTestCommand.Execute(null);
|
||||
Assert.True(ended);
|
||||
|
||||
// The app's stop path forgets the session — idempotent.
|
||||
vm.EndTestSilently();
|
||||
vm.EndTestSilently();
|
||||
Assert.False(vm.IsTestSession);
|
||||
Assert.False(vm.CanSendTestMessage);
|
||||
}
|
||||
|
||||
private void RunWindow()
|
||||
{
|
||||
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-test-{Guid.NewGuid():N}.db");
|
||||
MainViewModel.LayoutPathOverride = tempDb;
|
||||
MainWindow? window = default;
|
||||
try
|
||||
{
|
||||
using (var schema = new LayoutStore(tempDb)) { }
|
||||
SqliteConnection.ClearAllPools();
|
||||
|
||||
// ─── Section 2: real window — three-way exclusivity + gate ───
|
||||
window = new MainWindow();
|
||||
var model = (MainViewModel)window.DataContext;
|
||||
Assert.False(model.BroadcastForm.IsDrawerOpen);
|
||||
Assert.False(model.Ypp.IsDrawerOpen);
|
||||
Assert.False(model.TestSession.IsDrawerOpen);
|
||||
|
||||
// Test-button gate follows the sign-in state deterministically (the
|
||||
// initial state is environment-dependent — a dev machine may hold a real
|
||||
// saved OAuth session).
|
||||
model.IsConnected = false;
|
||||
Assert.False(model.CanStartTest);
|
||||
Assert.False(model.ShowTestButton);
|
||||
model.IsConnected = true;
|
||||
Assert.True(model.CanStartTest);
|
||||
Assert.True(model.ShowTestButton);
|
||||
|
||||
model.TestSession.ToggleDrawerCommand.Execute(null);
|
||||
Assert.True(model.TestSession.IsDrawerOpen);
|
||||
Assert.False(model.BroadcastForm.IsDrawerOpen, "opening TEST must leave Stream Settings closed");
|
||||
Assert.False(model.Ypp.IsDrawerOpen, "opening TEST must leave YPP closed");
|
||||
|
||||
model.BroadcastForm.ToggleDrawerCommand.Execute(null);
|
||||
Assert.True(model.BroadcastForm.IsDrawerOpen);
|
||||
Assert.False(model.TestSession.IsDrawerOpen, "opening Stream Settings must close TEST");
|
||||
|
||||
model.Ypp.ToggleDrawerCommand.Execute(null);
|
||||
Assert.True(model.Ypp.IsDrawerOpen);
|
||||
Assert.False(model.TestSession.IsDrawerOpen, "opening YPP must leave TEST closed");
|
||||
Assert.False(model.BroadcastForm.IsDrawerOpen);
|
||||
|
||||
model.TestSession.ToggleDrawerCommand.Execute(null);
|
||||
Assert.True(model.TestSession.IsDrawerOpen);
|
||||
model.TestSession.CloseDrawerCommand.Execute(null);
|
||||
Assert.False(model.TestSession.IsDrawerOpen);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window?.Close();
|
||||
MainViewModel.LayoutPathOverride = null;
|
||||
SqliteConnection.ClearAllPools();
|
||||
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user