9761b1d4be
Creator 2026-09-26: "the made with llamacasty flash should appear in all scenes,
not just live" and "should also appear in recordings". The presenter was
Start()/Stop()-ed from UpdateLiveVisuals()'s IsLive branch, so a recording made
WITHOUT ever going live carried no credit at all -- the exact case the creator hit
while judging compositing from local recordings.
It is now Start()ed once in the MainViewModel ctor and never stopped on live-state
churn. One start covers every scene, the preview, the stream and the recording,
because go-live and local recording are the SAME FramePump: both
Streaming.Operations.cs:68 and :199 call StartAsync with the same brandFlash:
delegate. Verified by reading both call sites, not assumed -- there is no second
encoder path that needed a "redirect". The licence gate needs no live branch at
all: IsPremium's setter already pushes BrandFlashPresenter.Enabled from anywhere.
Fixed a trap that app-lifetime exposed: Enabled = false stops the presenter's
DispatcherTimer to cut the advertisement mid-credit. When Start() was per-go-live
the next go-live restarted it; with a single app-lifetime Start() nothing would,
so a key entered mid-session would leave the credit dead until the process was
restarted. The setter now restarts the timer when re-enabling while _running.
Tests (12 facts in BrandFlashOutputTests, +2):
Credit_IsComposited_WithNoLiveSession_AndSoARecordingCarriesIt -- drives a real
never-live VM past the 5s first-flash delay and asks the frame the pump would
composite. Uses a new internal AdvanceBrandFlash seam; because Advance only
advances the cadence while the presenter is running, a credit coming out
proves Start() happened at construction.
ADowngradeMidSession_RestartsTheCadenceTimer -- asserts the premium/downgrade
edge decision directly (IsCadenceTimerEnabled) instead of sleeping through a
30-60s interval, which a synchronous test body cannot observe.
Full suite 364/364.
473 lines
20 KiB
C#
473 lines
20 KiB
C#
using System;
|
|
using System.IO;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using System.Windows;
|
|
using Microsoft.Win32;
|
|
using ytLive.Helpers;
|
|
using ytLive.Models;
|
|
using ytLive.Services;
|
|
using ytLive.Services.Encoder;
|
|
|
|
namespace ytLive.ViewModels;
|
|
|
|
public partial class MainViewModel : ViewModelBase
|
|
{
|
|
|
|
/// <summary>Entry point for the primary Start button (and the start/stop hotkey):
|
|
/// routes to a record session or the YouTube go-live dialog by the pill state.</summary>
|
|
private void StartSession()
|
|
{
|
|
if (IsLive || IsRecording) return;
|
|
var streaming = OnAirPillOn;
|
|
var recording = RecordPillOn;
|
|
if (!streaming && !recording)
|
|
{
|
|
// Nothing armed = "record something" (local recording needs no account,
|
|
// 2026-09-01): a Start that silently no-ops is the dead end the design
|
|
// principle forbids. The pill lights so reality and intent agree.
|
|
RecordPillOn = true;
|
|
BeginRecordOnly();
|
|
return;
|
|
}
|
|
|
|
if (!streaming)
|
|
{
|
|
BeginRecordOnly();
|
|
return;
|
|
}
|
|
if (!IsConnected)
|
|
{
|
|
// ON-AIR armed signed-out: intent (the pill stayed lit) is reality-aware.
|
|
// A signed-out go-live would dead-end in the account dialog {DASH} so Start
|
|
// turns the intent into the sign-in face instead. Reality and intent meet
|
|
// at the account connection; the pill keeps its light either way.
|
|
_ = SignInAsync();
|
|
return;
|
|
}
|
|
BeginGoLive(recording);
|
|
}
|
|
|
|
/// <summary>Record-only (REC pill on, ON-AIR off): no YouTube dialog — recording
|
|
/// is local and works signed-out. Generates the auto-named file and starts the
|
|
/// pipeline immediately.</summary>
|
|
private async void BeginRecordOnly()
|
|
{
|
|
var path = StartRecordFile();
|
|
if (path == null)
|
|
{
|
|
_notifications.Error("Couldn't record", "No record output path could be created.");
|
|
return;
|
|
}
|
|
_recordStartTime = DateTime.Now;
|
|
_liveElapsed = TimeSpan.Zero;
|
|
_recordLength = TimeSpan.Zero;
|
|
ResetHealth(StreamStatus.Offline);
|
|
_audioMixer.StartLive(EncoderOptions.DefaultAudioPipeName);
|
|
IsRecording = true;
|
|
await _framePump.StartAsync();
|
|
}
|
|
|
|
private void BeginGoLive(bool alsoRecord)
|
|
{
|
|
var dialog = new GoLiveViewModel(() => SignInAsync(), _youtubeAuth.CurrentChannel)
|
|
{
|
|
StreamTitle = BroadcastForm.Title,
|
|
StreamDescription = BroadcastForm.Description,
|
|
};
|
|
var window = new ytLive.GoLiveWindow(dialog) { Owner = System.Windows.Application.Current.MainWindow };
|
|
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;
|
|
// Test is stream-world-only, so the ON-AIR intent is armed by the time we
|
|
// get here — but arm it explicitly anyway: the encoder output gate reads the
|
|
// pill (StreamEnabled = OnAirPillOn), and a test that never armed the pill
|
|
// boots a pump with zero outputs and dies on "At least one output required".
|
|
OnAirPillOn = true;
|
|
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)
|
|
{
|
|
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;
|
|
}
|
|
}
|
|
IsRecording = recordStarted;
|
|
|
|
_ = PrepareAndStartLiveAsync();
|
|
}
|
|
|
|
/// <summary>Creates the record file (auto-named <c>ty-…-0000.mp4</c> in the record
|
|
/// folder) and returns its path, or null when the folder can't be prepared.</summary>
|
|
|
|
/// <summary>One-click go-live (TASK 5): ensure the reusable stream, create a
|
|
/// broadcast bound to it, then start the frame pump. The pump reads the RTMP
|
|
/// URL once at startup (FramePump.StartAsync), so the stream URL must be set
|
|
/// before the pump starts — hence the ordering here. Failures surface as an
|
|
/// error health state, never a crash.</summary>
|
|
private async Task PrepareAndStartLiveAsync()
|
|
{
|
|
try
|
|
{
|
|
var stream = await _youtubeStream.GetOrCreateReusableStreamAsync();
|
|
if (stream == null)
|
|
{
|
|
AppLog.Write("Reusable stream unavailable; check the OAuth session");
|
|
_notifications.Error("Couldn't go live",
|
|
"The reusable stream is unavailable — check the YouTube sign-in and try again.");
|
|
StopStream(); // full rollback — audio loop + recording + pills, no Error-status zombie
|
|
return;
|
|
}
|
|
_reusableStream = stream;
|
|
_reusableStreamUrl = stream.RtmpUrl;
|
|
_layoutStore.SaveReusableStream(stream);
|
|
|
|
_currentBroadcastId = await _youtubeStream.CreateBroadcast(
|
|
StreamTitle, StreamDescription, DateTime.UtcNow, stream.Id);
|
|
if (_currentBroadcastId == null)
|
|
{
|
|
AppLog.Write("Broadcast creation failed; check the OAuth session");
|
|
_notifications.Error("Couldn't go live", "YouTube rejected the broadcast — check the sign-in and try again.");
|
|
StopStream(); // full rollback
|
|
return;
|
|
}
|
|
AppLog.Write($"Broadcast created: {_currentBroadcastId}");
|
|
OnPropertyChanged(nameof(CurrentBroadcastId));
|
|
|
|
if (string.IsNullOrWhiteSpace(StreamTitle) && string.IsNullOrWhiteSpace(StreamDescription))
|
|
{
|
|
BroadcastForm.IsDrawerOpen = true;
|
|
_notifications.Warning("Complete your broadcast details",
|
|
"Your title and description are empty — fill them in from the Text panel on the right.");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AppLog.Write($"Go-live preparation failed: {ex.Message}");
|
|
_notifications.Error("Couldn't go live", ex.Message);
|
|
StopStream(); // full rollback — a created broadcast (if any) gets the close-out
|
|
return;
|
|
}
|
|
|
|
// The pump is what pushes RTMP; enableAutoStart flips the broadcast ready→live
|
|
// only once frames flow. The liveChatId (snippet.liveChatId) does not exist
|
|
// before that (2026-09-25 diagnosis), so chat resolution runs AFTER the pump
|
|
// starts. Chat is non-fatal — if it fails, the stream continues without chat.
|
|
await _framePump.StartAsync(); // never throws; failures log + surface via Failed
|
|
|
|
string? liveChatId = null;
|
|
try
|
|
{
|
|
if (_currentBroadcastId != null)
|
|
liveChatId = await _youtubeStream.GetBroadcastLiveChatIdAsync(_currentBroadcastId);
|
|
if (liveChatId != null)
|
|
{
|
|
_youtubeChat.Start(liveChatId);
|
|
AppLog.Write($"Chat polling started: {liveChatId}");
|
|
}
|
|
else
|
|
{
|
|
AppLog.Write("Could not retrieve liveChatId; chat polling disabled");
|
|
}
|
|
}
|
|
catch (Exception chatEx)
|
|
{
|
|
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}");
|
|
}
|
|
|
|
// Health polling (TASK 5 item 3): poll immediately, then every 30s while
|
|
// live. Report-by-exception — a banner appears only on warning/error.
|
|
_healthPollTimer.Start();
|
|
_ = PollHealthAsync();
|
|
}
|
|
|
|
private void OnHealthPollTick(object? sender, EventArgs e) => _ = PollHealthAsync();
|
|
|
|
/// <summary>One liveStreams.list poll (TASK 5 item 3): updates the YouTube
|
|
/// health state + the report-by-exception banner. Never throws — a poll
|
|
/// failure logs and leaves the last state untouched.</summary>
|
|
private async Task PollHealthAsync()
|
|
{
|
|
if (_reusableStream == null || !IsLive) return;
|
|
try
|
|
{
|
|
var health = await _youtubeStream.GetStreamHealthAsync(_reusableStream.Id);
|
|
if (health == null)
|
|
{
|
|
AppLog.Write("Stream health poll failed; check the OAuth session");
|
|
return;
|
|
}
|
|
CurrentHealth.HealthStatus = health.HealthStatus;
|
|
CurrentHealth.HealthMessage = health.HealthStatus;
|
|
CurrentHealth.ConfigurationIssues.Clear();
|
|
CurrentHealth.ConfigurationIssues.AddRange(health.ConfigurationIssues);
|
|
OnPropertyChanged(nameof(CurrentHealth));
|
|
ApplyHealthIssue(health.ConfigurationIssues);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AppLog.Write($"Stream health poll failed: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
// Report-by-exception: good/ok/noData (or info-only issues) clear the banner;
|
|
// the first warning/error issue raises it, colored by the worst severity.
|
|
private void ApplyHealthIssue(List<StreamConfigurationIssue> issues)
|
|
{
|
|
var report = StreamHealthReporter.BannerFor(issues);
|
|
if (report.Text == null)
|
|
{
|
|
HealthIssueBanner = null;
|
|
HealthIssueBackground = "Transparent";
|
|
return;
|
|
}
|
|
|
|
HealthIssueBackground = report.IsError ? "#8f1f1f" : "#b8860b";
|
|
HealthIssueBanner = report.Text;
|
|
}
|
|
|
|
private async void StopStream()
|
|
{
|
|
var wasLive = IsLive;
|
|
var wasRecording = IsRecording;
|
|
|
|
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.
|
|
RecordPillOn = false;
|
|
OnAirPillOn = false;
|
|
WindowTitle = "LlamaCasty";
|
|
ResetHealth(StreamStatus.Offline);
|
|
// Stop chat polling first (TASK 9.4) — no more messages arriving while
|
|
// we're tearing down the stream.
|
|
_youtubeChat.Stop();
|
|
// Close the audio pipe FIRST so ffmpeg sees EOF on the audio input,
|
|
// then stop the pump (stdin EOF) — both inputs end and the encoder
|
|
// finalizes the output. Audio capture itself is always-on (preview
|
|
// monitoring); only the live/report pipe, the pump, and the session stop here.
|
|
_audioMixer.StopLive();
|
|
await _framePump.StopAsync();
|
|
|
|
// Proper close-out (TASK 9 decision 6, built 2026-09-01): transition(complete)
|
|
// AFTER the RTMP push is closed so no frames post-date the end — YouTube then
|
|
// finalizes the VOD immediately instead of leaving viewers on a frozen
|
|
// "stream offline" until enableAutoStop fires (~1 min). Log-only failure:
|
|
// the backstop still ends the broadcast; a stop never fails over cosmetics.
|
|
if (wasLive && _currentBroadcastId != null)
|
|
{
|
|
var closeOutError = await _youtubeStream.EndBroadcastAsync(_currentBroadcastId);
|
|
if (closeOutError != null)
|
|
AppLog.Write($"End close-out: {closeOutError} — enableAutoStop will finish the broadcast");
|
|
}
|
|
|
|
_currentBroadcastId = null;
|
|
OnPropertyChanged(nameof(CurrentBroadcastId));
|
|
|
|
if (wasRecording) await FinalizeRecordingAsync();
|
|
|
|
// Explicit sign-out only (TASK 18): stopping the stream does NOT clear
|
|
// the session/token — the creator signs out deliberately via the
|
|
// Logout / Change Account commands.
|
|
AppLog.Write(wasLive ? "Stream ended" : "Recording ended");
|
|
}
|
|
|
|
/// <summary>On stop the auto-named <c>ty-…-0000.mp4</c> file is renamed to carry
|
|
/// the real recording length (<c>ty-…-hh2mm2.mp4</c>), with a numeric suffix on
|
|
/// name collisions. Runs only after the pump has fully closed the file.</summary>
|
|
|
|
|
|
|
|
/// <summary>Falls back to the user's Downloads folder when no preference is set;
|
|
/// returns MyVideos if Downloads doesn't exist.</summary>
|
|
|
|
// Full encoder options for the current tier, or null when no RTMP URL is
|
|
// available — the pump then skips the encoder entirely (TASK 5 fills the seam).
|
|
private EncoderOptions? BuildEncoderOptions()
|
|
{
|
|
var url = _rtmpUrlProvider();
|
|
var quality = SelectedQuality;
|
|
return new EncoderOptions
|
|
{
|
|
RtmpUrl = url ?? string.Empty,
|
|
StreamEnabled = OnAirPillOn,
|
|
RecordEnabled = RecordPillOn,
|
|
RecordPath = _activeRecordPath,
|
|
Width = quality.Width,
|
|
Height = quality.Height,
|
|
Fps = quality.Fps,
|
|
BitrateKbps = (int)Math.Round(quality.Bitrate * 1000),
|
|
};
|
|
}
|
|
|
|
// internal: test seam (mirrors LayoutPathOverride) — the rollback path is
|
|
// unobservable without simulating a pump death; ytLive.Tests has InternalsVisibleTo.
|
|
internal void OnFramePumpFailed(object? sender, string message)
|
|
{
|
|
// No zombies (2026-09-01): a dead pump means NO session — the old handler
|
|
// toasted and (live-only) flipped StreamStatus, leaving a record-only session
|
|
// with IsRecording=true and no encoder (first real launch proved it). The
|
|
// failure now runs the NORMAL stop path on the UI thread (pills off, audio
|
|
// loop closed, recording finalized, close-out if live), then explains itself.
|
|
AppLog.Write($"Frame pump failed: {message}");
|
|
void Handle()
|
|
{
|
|
var live = IsLive;
|
|
_notifications.Error(live ? "The stream pipeline stopped" : "Recording stopped", message);
|
|
StopStream();
|
|
}
|
|
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
|
if (dispatcher != null && !dispatcher.CheckAccess())
|
|
dispatcher.BeginInvoke(Handle);
|
|
else
|
|
Handle();
|
|
}
|
|
|
|
// TASK 4 ship step 6: the encoder's parsed health (bitrate/FPS/dropped/
|
|
// duration) lands in the bottom bar. The stderr loop raises on a background
|
|
// thread — marshal to the UI thread like the audio level handlers.
|
|
private void OnFramePumpHealthUpdated(object? sender, StreamHealth health)
|
|
{
|
|
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
|
if (dispatcher != null && !dispatcher.CheckAccess())
|
|
dispatcher.BeginInvoke(() => ApplyHealth(health));
|
|
else
|
|
ApplyHealth(health);
|
|
}
|
|
|
|
private void ApplyHealth(StreamHealth health)
|
|
{
|
|
CurrentHealth.Status = health.Status;
|
|
CurrentHealth.CurrentBitrate = health.CurrentBitrate;
|
|
CurrentHealth.FPS = health.FPS;
|
|
CurrentHealth.DroppedFrames = health.DroppedFrames;
|
|
CurrentHealth.StreamDuration = health.StreamDuration;
|
|
CurrentHealth.LastError = health.LastError;
|
|
CurrentHealth.HealthMessage = health.HealthMessage;
|
|
OnPropertyChanged(nameof(CurrentHealth));
|
|
}
|
|
|
|
// Keeps the bottom-bar stats honest across sessions: dropped frames and the
|
|
// elapsed duration must not linger from a previous go-live (bitrate/FPS stay
|
|
// on the tier's targets — ApplyStreamQuality sets them on pick).
|
|
private void ResetHealth(StreamStatus status)
|
|
{
|
|
CurrentHealth.Status = status;
|
|
CurrentHealth.DroppedFrames = 0;
|
|
CurrentHealth.StreamDuration = TimeSpan.Zero;
|
|
CurrentHealth.LastError = null;
|
|
CurrentHealth.HealthStatus = null;
|
|
CurrentHealth.HealthMessage = null;
|
|
CurrentHealth.ConfigurationIssues.Clear();
|
|
HealthIssueBanner = null;
|
|
HealthIssueBackground = "Transparent";
|
|
OnPropertyChanged(nameof(CurrentHealth));
|
|
}
|
|
|
|
private void UpdateLiveVisuals()
|
|
{
|
|
var live = IsLive;
|
|
// 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) _healthPollTimer.Stop();
|
|
|
|
// NOTE: the branding credit is deliberately NOT started/stopped here.
|
|
// CREATOR RULING 2026-09-26 — it must appear in EVERY scene and in
|
|
// recordings, not only while streaming. It used to be gated on IsLive, which
|
|
// meant a recording made without ever going live carried no credit at all.
|
|
// The presenter is started once for the life of the app (MainViewModel ctor);
|
|
// the licence gate rides on BrandFlashPresenter.Enabled via IsPremium, so it
|
|
// needs no live branch — IsPremium's setter pushes it from any thread.
|
|
UpdateSessionTimer();
|
|
}
|
|
|
|
/// <summary>Runs the elapsed/rec-dot-pulse timer while a session is active —
|
|
/// live (Streaming) OR recording. Reset the elapsed when a new session starts.</summary>
|
|
private void UpdateSessionTimer()
|
|
{
|
|
var active = IsLive || IsRecording;
|
|
if (active)
|
|
{
|
|
_recDotPulse = 1.0;
|
|
_liveTimer.Start();
|
|
}
|
|
else
|
|
{
|
|
_liveTimer.Stop();
|
|
LiveElapsedText = "00:00:00";
|
|
_recDotPulse = 1.0;
|
|
}
|
|
OnPropertyChanged(nameof(RecDotOpacity));
|
|
OnPropertyChanged(nameof(LiveElapsedText));
|
|
}
|
|
|
|
private void OnLiveTimerTick(object? sender, EventArgs e)
|
|
{
|
|
_liveElapsed = _liveElapsed.Add(TimeSpan.FromSeconds(1));
|
|
LiveElapsedText = _liveElapsed.ToString(@"hh\:mm\:ss");
|
|
_recordLength = _liveElapsed;
|
|
_recDotPulse = _recDotPulse > 0.5 ? 0.35 : 1.0;
|
|
OnPropertyChanged(nameof(RecDotOpacity));
|
|
}
|
|
}
|