root cause (proven, not inferred): contentDetails.boundStreamId on liveBroadcasts.insert does NOT bind the broadcast. Our diagnostic reads lifeCycle=created privacy=private autoStart=true bound=NONE boundMatches=NO-BINDING | streamStatus=active created is Google's "incomplete settings" state, so enableAutoStart has no bound stream to fire from; close-out then 403s invalidTransition because created -> complete is not a legal edge. A reusable stream accepts bytes for a broadcast that isn't watching it, so a perfectly healthy 99MB RTMP session is exactly what an unbound broadcast looks like -- every observable pointed away from the binding. Google's migration guide states the rule outright: a liveBroadcast must be bound to exactly one stream before it can start, and is not bound at creation. That was in the docs read during the original design and was not applied. Shipped: - BindBroadcastAsync -> POST liveBroadcasts/bind?part=id&id&streamId (no body), called from PrepareAndStartLiveAsync right after CreateBroadcast, before the pump. Failure is fatal to the session: an unbound broadcast can never go live, so a live-looking UI over one is a lie. - boundStreamId removed from the insert body, and the streamId parameter removed from CreateBroadcast. A parameter that looks meaningful but cannot affect the outcome is what made the misdiagnosis possible. - Replaced CreateBroadcast_With_StreamId_Binds_Reusable_Stream_At_Insert, which asserted the disproven behaviour and therefore laundered a guess into a guarantee, with tests for the omitted field and the real bind endpoint. Error messages (creator ruling 2026-10-04): bind failures are not all "check the sign-in". Six documented reasons exist and only one is auth; BindFailureMessage gives each its own text, and the fallback passes the raw reason through verbatim so an unmapped reason stays greppable. liveBroadcastBindingNotAllowed matters most -- it means our ordering is wrong, and a generic re-auth message would hide a bug in our own code behind a plausible excuse. See MyMistakes.md. ai.md: also corrects the TASK 44 claim that liveChatId is only populated once the broadcast is live. The diagnostic shows it present while lifecycle is created. That claim was inferred from a sample's active-status guard, which is not a spec. Suite 395/395, 0 warnings.
513 lines
22 KiB
C#
513 lines
22 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);
|
|
_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.
|
|
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 ? "#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));
|
|
}
|
|
}
|