Creator correction (2026-11-13): "OpenCode literally inserted Live and Private as two extra flat siblings
inside the outer pill bar, shoving Record to the right and making it look like five ungrouped toolbar
items". Correct — completion states were being added to the BAR instead of to a SLOT.
Now the bar is exactly three slots, and the bar's shape never changes:
[ Build Scenes ] [ YouTube ] [ Record ]
YouTube: ▶ YouTube -> [Live | Private | ✕] -> [● Start Live | ✕] (amber: next click goes on air)
-> [■ Stop Live]
Record: ◉ Record -> [● Start Recording | ✕] -> ● REC hh:mm:ss [■ Stop]
Build: enabled only while BOTH action slots are Idle; otherwise disabled at 40% with the tooltip
"Scene editing is locked while streaming or recording"
State lives in YouTubeSlotState / RecordSlotState, with one RaiseSlotStates() announcing every flag so no
caller can forget one. Esc and the ✕ cancel the slot you are in and nothing else.
This retires the BarWorld model from earlier today, along with TopBarActivityTests (superseded by
TopBarSlotTests, which pins all three flows, the lock, and cancel-to-Idle).
679 pass, 0 failed (the host-hang fallback in f4caea0 counted the per-test lines).
Scope justification: TopBarActivityTests.cs is deleted as part of this unit — it tested the model being
replaced; TopBarSlotTests is its successor.
732 lines
34 KiB
C#
732 lines
34 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);
|
|
if (!TryStartAudioPipe()) return;
|
|
IsRecording = true;
|
|
LiveScene = StagedScene;
|
|
await _framePump.StartAsync();
|
|
}
|
|
|
|
/// <summary>Starts the live audio pipe, or explains why it cannot and rolls the session back.
|
|
/// <para>This used to throw straight out of the Go Live button: the pipe server allows one instance,
|
|
/// so with a second LlamaCasty running on the same machine the click killed the app
|
|
/// ("All pipe instances are busy", 2026-11-06). A click must never be able to end the process.</para>
|
|
/// </summary>
|
|
private bool TryStartAudioPipe()
|
|
{
|
|
var pipeName = InstanceProfile.AudioPipeName(EncoderOptions.DefaultAudioPipeName);
|
|
try
|
|
{
|
|
_audioMixer.StartLive(pipeName);
|
|
AppLog.Write($"Audio pipe: {pipeName}");
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AppLog.Write(ex, "Audio pipe could not be started — session aborted");
|
|
_notifications.Error("Couldn't start the audio",
|
|
"Another copy of LlamaCasty is already using the audio pipe. Close it, then try again.");
|
|
StopStream();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <summary>Builds the Start Stream dialog's model from the Stream Info tab. Re-reads the store
|
|
/// first: the form is loaded once at startup, and a second LlamaCasty on the same profile (the dev
|
|
/// instance, or Release recording while Debug goes live) writes its edits straight to the store, so
|
|
/// this process's copy can be empty while the tab the creator filled in is not (2026-11-06).</summary>
|
|
internal GoLiveViewModel CreateGoLiveDialog()
|
|
{
|
|
BroadcastForm.Load();
|
|
var dialog = new GoLiveViewModel(() => SignInAsync(), _youtubeAuth.CurrentChannel)
|
|
{
|
|
StreamTitle = BroadcastForm.Title,
|
|
StreamDescription = BroadcastForm.Description,
|
|
};
|
|
AppLog.Write($"Go Live dialog: title='{dialog.StreamTitle}' description={dialog.StreamDescription.Length} chars");
|
|
return dialog;
|
|
}
|
|
|
|
private void BeginGoLive(bool alsoRecord)
|
|
{
|
|
var dialog = CreateGoLiveDialog();
|
|
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;
|
|
// TASK 3·20: the session report window starts here. Derived on demand from YouTube, never
|
|
// persisted, and reset when the session ends.
|
|
_sessionStartedAtUtc = DateTime.UtcNow;
|
|
ResetHealth(StreamStatus.Streaming);
|
|
if (!TryStartAudioPipe()) return;
|
|
_liveElapsed = TimeSpan.Zero;
|
|
_recordLength = TimeSpan.Zero;
|
|
StartStepDownWatch();
|
|
StartClipRing();
|
|
|
|
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>TASK 32·5 — is YouTube's ingest reachable right now? A false answer aborts go-live
|
|
/// before anything is created on YouTube: "I forgot the VPN" used to produce a broadcast that
|
|
/// nobody ever saw, and the app looked live while the push had nowhere to go.</summary>
|
|
internal async Task<bool> IngestPreflightAsync(string rtmpUrl)
|
|
{
|
|
var probe = IngestProbeOverride ?? new TcpIngestProbe();
|
|
if (await probe.CanReachAsync(rtmpUrl)) return true;
|
|
|
|
AppLog.Write("Ingest unreachable — go-live aborted before creating a broadcast",
|
|
AppLogMessageType.Error, AppLogErrorClass.Class1);
|
|
_notifications.Error("Can't reach YouTube's ingest",
|
|
"Nothing was created on YouTube. Check your network or VPN, then try again.");
|
|
StopStream(); // rollback: no ghost broadcast, no half-lit session
|
|
return false;
|
|
}
|
|
|
|
/// <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
|
|
{
|
|
// TASK 32·5 — prevention. With a cached ingest URL we probe BEFORE the first API call, so
|
|
// a dead network costs nothing and leaves nothing behind on YouTube. First run (no cached
|
|
// URL yet) probes once the stream is resolved, still before any broadcast is created.
|
|
var cachedIngest = _reusableStreamUrl;
|
|
if (!string.IsNullOrWhiteSpace(cachedIngest) && !await IngestPreflightAsync(cachedIngest))
|
|
return;
|
|
|
|
var stream = await _youtubeStream.GetOrCreateReusableStreamAsync();
|
|
if (stream == null)
|
|
{
|
|
AppLog.Write("Reusable stream unavailable; check the OAuth session",
|
|
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
|
_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);
|
|
|
|
if (string.IsNullOrWhiteSpace(cachedIngest) && !await IngestPreflightAsync(stream.RtmpUrl))
|
|
return;
|
|
|
|
_currentBroadcastId = await _youtubeStream.CreateBroadcast(
|
|
StreamTitle, StreamDescription, DateTime.UtcNow);
|
|
_broadcastSeenLive = false;
|
|
if (_currentBroadcastId == null)
|
|
{
|
|
AppLog.Write("Broadcast creation failed; check the OAuth session",
|
|
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
|
_notifications.Error("Couldn't go live", "YouTube rejected the broadcast — check the sign-in and try again.");
|
|
StopStream(); // full rollback
|
|
return;
|
|
}
|
|
AppLog.WriteSuccess($"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}",
|
|
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
|
// 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}",
|
|
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
|
_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;
|
|
ApplyEditLockForSession(starting: true);
|
|
await _framePump.StartAsync(); // never throws; failures log + surface via Failed
|
|
|
|
// Check promptly until YouTube confirms the broadcast is live. Health
|
|
// polling returns to its normal interval after that confirmation.
|
|
_healthPollTimer.Interval = TimeSpan.FromSeconds(2);
|
|
_healthPollTimer.Start();
|
|
_ = PollHealthAsync();
|
|
|
|
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",
|
|
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
|
}
|
|
}
|
|
catch (Exception chatEx)
|
|
{
|
|
AppLog.Write($"Chat start failed (non-fatal): {chatEx.Message}",
|
|
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
|
}
|
|
|
|
// 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}");
|
|
}
|
|
|
|
}
|
|
|
|
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",
|
|
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
|
}
|
|
else
|
|
{
|
|
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);
|
|
ApplyBroadcastLifecycleStatus(lifeCycle);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AppLog.Write($"Stream health poll failed: {ex.Message}",
|
|
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
/// <param name="afterFailure">True when the session is being torn down because something broke,
|
|
/// not because the creator pressed Stop. A failure clears both outputs (there is no session and no
|
|
/// choice worth keeping); a creator's Stop of a recording restores the Local MP4 pair.</param>
|
|
private async void StopStream(bool afterFailure = false)
|
|
{
|
|
var dispatcher = Application.Current?.Dispatcher;
|
|
if (dispatcher != null && !dispatcher.CheckAccess())
|
|
{
|
|
_ = dispatcher.BeginInvoke(new Action(() => StopStream()));
|
|
return;
|
|
}
|
|
|
|
CancelStreamRecovery(); // a stop wins over a relaunch in flight
|
|
StopStepDownWatch();
|
|
StopClipRing();
|
|
RemoveAttentionAlert("stream-recovery");
|
|
var wasLive = IsLive;
|
|
var wasRecording = IsRecording;
|
|
var wasRecordingOnly = IsRecording && !IsLive && !afterFailure;
|
|
|
|
StreamStatus = StreamStatus.Offline;
|
|
_sessionStartedAtUtc = null;
|
|
IsRecording = false;
|
|
LiveScene = null;
|
|
ApplyEditLockForSession(starting: 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 the session (creator ruling 2026-09-01): a lit pill with no session behind it is a
|
|
// lie. Since build 730 the pills are the chosen OUTPUT, not an armed intent, so a finished
|
|
// local recording returns to its idle pair — Local MP4 chosen, Start Recording beside it
|
|
// (creator's words) — while a finished stream clears both and asks again.
|
|
if (wasRecordingOnly)
|
|
{
|
|
RecordPillOn = true;
|
|
OnAirPillOn = false;
|
|
}
|
|
else
|
|
{
|
|
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",
|
|
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
|
}
|
|
|
|
_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 = EffectiveQuality(OutputProfile.Resolve(SelectedQuality, QualityOptions, HasProOutput));
|
|
// TASK 33: while a session is live, a step-down owns the emitted tier even though the dropdown
|
|
// still shows what the creator picked. Their intent is not edited by the policy.
|
|
if (_encoderTierOverride is { } landed)
|
|
quality = new QualityOption($"{landed.Width}p{landed.Fps}", landed.Fps,
|
|
landed.BitrateMbps, landed.Width, landed.Height);
|
|
var compositor = BuildCompositorOptions();
|
|
return new EncoderOptions
|
|
{
|
|
RtmpUrl = url ?? string.Empty,
|
|
StreamEnabled = OnAirPillOn,
|
|
RecordEnabled = RecordPillOn,
|
|
RecordPath = _activeRecordPath,
|
|
Width = quality.Width,
|
|
Height = quality.Height,
|
|
InputWidth = compositor.OutputWidth,
|
|
InputHeight = compositor.OutputHeight,
|
|
Fps = quality.Fps,
|
|
BitrateKbps = (int)Math.Round(quality.Bitrate * 1000),
|
|
// Same derivation as the mixer's StartLive call: a dev instance serves its own pipe.
|
|
AudioPipeName = InstanceProfile.AudioPipeName(EncoderOptions.DefaultAudioPipeName),
|
|
// TASK 65: null when clips are off, which leaves ffmpeg's command line untouched.
|
|
ClipBuffer = _clipRing,
|
|
};
|
|
}
|
|
|
|
// 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}", AppLogMessageType.Error, AppLogErrorClass.Class2);
|
|
void Handle()
|
|
{
|
|
var live = IsLive;
|
|
_notifications.Error(live ? "The stream pipeline stopped" : "Recording stopped", message);
|
|
StopStream(afterFailure: true);
|
|
}
|
|
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;
|
|
// TEST keeps its gold identity. For a normal ON-AIR session, green means
|
|
// YouTube has confirmed live; before then it keeps the existing red color.
|
|
TopBarBackground = live
|
|
? IsTestStream
|
|
? ThemeBrush.Hex("Brush.TestContainer")
|
|
: ThemeBrush.Hex(_youtubeBroadcastLive ? "Brush.Positive" : "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();
|
|
_healthPollTimer.Interval = TimeSpan.FromSeconds(30);
|
|
}
|
|
|
|
// 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));
|
|
OnPropertyChanged(nameof(RecordElapsedLabel));
|
|
}
|
|
|
|
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.2 : 1.0;
|
|
OnPropertyChanged(nameof(RecDotOpacity));
|
|
OnPropertyChanged(nameof(RecordElapsedLabel));
|
|
|
|
// TASK 55·3: the last useful warning before the encoder fails for a reason it won't explain.
|
|
if (IsRecording && !_lowDiskWarned && _recordFolder is { } folder
|
|
&& DiskSpaceGuard.FreeBytes(folder) is { } free
|
|
&& DiskSpaceGuard.ShouldWarnDuring(free))
|
|
{
|
|
_lowDiskWarned = true;
|
|
_notifications.Warning("Disk space is nearly gone",
|
|
"The recording may stop on its own. Free up space now, or stop and save what you have.");
|
|
}
|
|
}
|
|
|
|
/// <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,
|
|
// Closing the app cannot open a modal (WPF forbids ShowDialog while a window closes), so the
|
|
// safe default is KEEP: the broadcast stays on YouTube as a private video for the creator to
|
|
// deal with, instead of being deleted by a prompt nobody could answer.
|
|
() => _shuttingDown
|
|
|| (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}",
|
|
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
|
_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);
|
|
}
|
|
}
|