The paid delta is output quality, so the rule needs to be measurable rather than a word:
921,600 pixels and 30 fps, fail-closed. Models/OutputProfile.cs is that rule as pure
policy — a permitted tier passes through untouched (Pro, or the free tier's own 720p30),
a disallowed one resolves to the best tier the budget allows and carries ITS bitrate, so
a capped user is never offered an 8 Mbps budget for a 720p frame.
Applied once, in BuildEncoderOptions. That is genuinely one place: the stream and the
local recording are two output blocks of ONE ffmpeg process fed by one EncoderOptions,
so there is no second encoder to forget. (commercial.md claimed "both encoders — never
two checks"; the code is stronger than the doc, and the doc is corrected.)
Developer override, as asked: a "Developer mode — all resolutions" checkbox in App
Settings, bound to MainViewModel.DeveloperMode, which feeds HasProOutput. It is not
hidden in a release build — Controls/DeveloperPanel's Page and Compile items are removed
when Configuration != Debug, and OverlayHost adds it at runtime inside #if DEBUG, so a
shipped exe has no BAML for it at all. The setter also refuses the value when
DevBuild.IsDevelopment is false, which is now the single source for that question (the
scoreboard partial had its own duplicate const).
Two traps worth their lines: a `const bool` dev flag makes `if (!Flag) return;` an
unreachable-code warning in one configuration or the other (CS0162), so DevBuild uses
static readonly; and a Condition on an ItemGroup is invisible to a test reading
item.Attribute("Condition") — read the parent.
Tests: 14 pass across OutputProfileTests, AssemblyIdentityTests and StorePackagingTests.
The invariant test is the one that matters: iterate every shipped tier under a free
entitlement and assert none emits more than the budget, so a future 4K tier fails here
instead of shipping to everyone. App builds Debug and Release at 0 warnings.
592 lines
27 KiB
C#
592 lines
27 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;
|
|
// The scoreboard's scope is the session: every start clears it (creator ruling 2026-10-30).
|
|
StartScoreboardSession();
|
|
|
|
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;
|
|
LiveScene = StagedScene;
|
|
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);
|
|
_broadcastSeenLive = false;
|
|
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));
|
|
|
|
// ⛔ THE BINDING — the call whose absence is why nothing ever went live
|
|
// (2026-10-04). Sending `contentDetails.boundStreamId` on the insert does NOT
|
|
// bind: the insert is accepted, its sibling fields take effect, and the
|
|
// broadcast still comes back `bound=NONE`, sits in lifeCycleStatus `created`
|
|
// forever ("incomplete settings"), so `enableAutoStart` has no bound stream to
|
|
// fire from and close-out 403s `invalidTransition`. Meanwhile ffmpeg happily
|
|
// pushes ~99MB into the reusable stream, because a reusable stream accepts
|
|
// bytes for a broadcast that is not watching — which is why every symptom
|
|
// pointed away from the binding.
|
|
//
|
|
// Failure here is fatal to the session, not cosmetic: an unbound broadcast can
|
|
// never go live, so continuing would present a live-looking UI over a broadcast
|
|
// that is quietly never going to be seen.
|
|
var bindError = await _youtubeStream.BindBroadcastAsync(_currentBroadcastId, stream.Id);
|
|
if (bindError != null)
|
|
{
|
|
AppLog.Write($"Stream binding failed: {bindError}");
|
|
// Show the real reason. "Check the sign-in" would be wrong for five of the
|
|
// six documented bind failures, and would hide our own ordering bug behind
|
|
// a plausible excuse — see BindFailureMessage.
|
|
_notifications.Error("Couldn't go live", bindError);
|
|
StopStream(); // full rollback
|
|
return;
|
|
}
|
|
|
|
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.
|
|
LiveScene = StagedScene;
|
|
EditingActive = false;
|
|
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.
|
|
///
|
|
/// Also piggybacks the broadcast diagnostic (2026-10-04) — the health poll was
|
|
/// already running during a stream but only ever read the STREAM's health, which
|
|
/// is why two test runs could push 100MB to YouTube and still leave the app
|
|
/// unable to say whether the broadcast had ever left `ready`. Deliberately
|
|
/// self-limiting: it only logs while the broadcast has NOT reached `live`, so it
|
|
/// shouts exactly when something is wrong and goes quiet once healthy, rather than
|
|
/// adding a permanent per-tick line to the log.</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);
|
|
|
|
if (!string.IsNullOrWhiteSpace(_currentBroadcastId) && !_broadcastSeenLive)
|
|
{
|
|
var lifeCycle = await _youtubeStream.LogBroadcastDiagnosticsAsync(_currentBroadcastId, _reusableStream.Id);
|
|
if (lifeCycle is "live" or "liveStarting") _broadcastSeenLive = true;
|
|
}
|
|
}
|
|
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
|
|
? ThemeBrush.Hex("Brush.ErrorContainer")
|
|
: ThemeBrush.Hex("Brush.WarningContainer");
|
|
HealthIssueBanner = report.Text;
|
|
}
|
|
|
|
private async void StopStream()
|
|
{
|
|
var wasLive = IsLive;
|
|
var wasRecording = IsRecording;
|
|
|
|
StreamStatus = StreamStatus.Offline;
|
|
IsRecording = false;
|
|
LiveScene = null;
|
|
EditingActive = true;
|
|
// 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";
|
|
// Deactivate zoom when stopping
|
|
if (_zoomState.IsActive || _zoomState.IsMoving)
|
|
{
|
|
_zoomState.IsActive = false;
|
|
_zoomState.IsMoving = false;
|
|
OnPropertyChanged(nameof(IsZoomActive));
|
|
OnPropertyChanged(nameof(ShowZoomOverlay));
|
|
OnPropertyChanged(nameof(ZoomState));
|
|
RebuildQualityDimRects();
|
|
}
|
|
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.
|
|
var endedBroadcastId = wasLive ? _currentBroadcastId : null;
|
|
if (endedBroadcastId != null)
|
|
{
|
|
var closeOutError = await _youtubeStream.EndBroadcastAsync(endedBroadcastId);
|
|
if (closeOutError != null)
|
|
AppLog.Write($"End close-out: {closeOutError} — enableAutoStop will finish the broadcast");
|
|
}
|
|
|
|
_currentBroadcastId = null;
|
|
OnPropertyChanged(nameof(CurrentBroadcastId));
|
|
|
|
// Creator ruling 2026-10-09: a PRIVATE broadcast ends like a recording — Enter keeps
|
|
// it on YouTube, Escape deletes it.
|
|
if (endedBroadcastId != null)
|
|
await OfferToKeepPrivateBroadcastAsync(endedBroadcastId);
|
|
|
|
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).
|
|
//
|
|
// BOTH outputs (RTMP + local MP4) are encoded from this one option set, so the
|
|
// output-profile rule is applied here, once: a capped build cannot stream 1080p and
|
|
// record it too. See Models/OutputProfile.cs — pixels, not "720p".
|
|
private EncoderOptions? BuildEncoderOptions()
|
|
{
|
|
var url = _rtmpUrlProvider();
|
|
var quality = OutputProfile.Resolve(SelectedQuality, QualityOptions, HasProOutput);
|
|
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
|
|
? ThemeBrush.Hex(IsTestStream ? "Brush.TestContainer" : "Brush.Primary")
|
|
: ThemeBrush.Hex("Brush.SurfaceContainer");
|
|
PreviewGlowBrush = live
|
|
? ThemeBrush.Hex(IsTestStream ? "Brush.TestContainer" : "Brush.Primary")
|
|
: "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));
|
|
}
|
|
|
|
/// <summary>What happened to a finished broadcast at the keep-or-delete prompt.</summary>
|
|
internal enum PrivateBroadcastOutcome { NotPrivate, Kept, Deleted, DeleteFailed }
|
|
|
|
/// <summary>Test seam: answers the keep-or-delete prompt (title → keep?) instead of showing
|
|
/// <see cref="KeepBroadcastDialog"/>. Mirrors the other static overrides.</summary>
|
|
internal static Func<string, bool>? KeepBroadcastPromptOverride { get; set; }
|
|
|
|
private async Task OfferToKeepPrivateBroadcastAsync(string broadcastId)
|
|
{
|
|
var title = BroadcastForm.Title;
|
|
var (outcome, error) = await ClosePrivateBroadcastAsync(_youtubeStream, broadcastId,
|
|
() => KeepBroadcastPromptOverride?.Invoke(title) ?? new KeepBroadcastDialog(title).ShowDialog() == true);
|
|
switch (outcome)
|
|
{
|
|
case PrivateBroadcastOutcome.Kept:
|
|
AppLog.Write($"Private broadcast kept: {broadcastId}");
|
|
_notifications.Info("Broadcast kept", "It's on YouTube as a private video.");
|
|
break;
|
|
case PrivateBroadcastOutcome.Deleted:
|
|
AppLog.Write($"Private broadcast deleted by creator: {broadcastId}");
|
|
_notifications.Info("Broadcast deleted", "The broadcast was removed from YouTube.");
|
|
break;
|
|
case PrivateBroadcastOutcome.DeleteFailed:
|
|
AppLog.Write($"Private broadcast delete failed: {broadcastId}: {error}");
|
|
_notifications.Error("Couldn't delete the broadcast",
|
|
$"{error}. It's still on YouTube as a private video — you can delete it in YouTube Studio.");
|
|
break;
|
|
}
|
|
}
|
|
|
|
/// <summary>The keep-or-delete decision for a finished broadcast. Asks ONLY when YouTube
|
|
/// itself reports the broadcast as private (visibility can change mid-stream, so local state
|
|
/// isn't trusted); an unreadable status asks nothing and deletes nothing. Keep = leave it;
|
|
/// otherwise delete the video from YouTube.</summary>
|
|
internal static async Task<(PrivateBroadcastOutcome Outcome, string? Error)> ClosePrivateBroadcastAsync(
|
|
YouTubeStreamService youtube, string broadcastId, Func<bool> askKeep)
|
|
{
|
|
var privacy = await youtube.GetBroadcastPrivacyAsync(broadcastId);
|
|
if (!string.Equals(privacy, "private", StringComparison.OrdinalIgnoreCase))
|
|
return (PrivateBroadcastOutcome.NotPrivate, null);
|
|
if (askKeep()) return (PrivateBroadcastOutcome.Kept, null);
|
|
var error = await youtube.DeleteFinishedBroadcastAsync(broadcastId);
|
|
return error == null ? (PrivateBroadcastOutcome.Deleted, null) : (PrivateBroadcastOutcome.DeleteFailed, error);
|
|
}
|
|
}
|