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:
@@ -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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user