TASK 5 items 1-3: reusable stream, health banner, docs refresh

- Reusable stream: GetOrCreateReusableStreamAsync, cached via LayoutStore, bound at broadcast insert
- Health monitoring: GetStreamHealthAsync polls liveStreams.status, report-by-exception banner
- Go-live order: ensure stream → create broadcast → then start pump (URL must exist first)
- Full broadcast form scoped (TASK 5 items 6-7) in TASKS.md + ai.md
- TASK 23 trimmed to technical scope, TASK 26 added (marcom/launch kit)
- Confidential files: MONETIZATION.md + MARCOM.md (gitignored, not committed)
- .gitignore updated for confidential business files
- HANDOFF.md updated with session state and next steps
This commit is contained in:
2026-08-16 17:48:37 -07:00
parent 92f1471ab4
commit 0fac1e6a5a
16 changed files with 810 additions and 140 deletions
+111 -14
View File
@@ -28,6 +28,7 @@ public class MainViewModel : ViewModelBase
private readonly DispatcherTimer _liveTimer;
private readonly DispatcherTimer _volumeFlashTimer;
private readonly DispatcherTimer _gameVolumeFlashTimer;
private readonly DispatcherTimer _healthPollTimer;
private Scene? _activeScene;
private SceneElement? _selectedElement;
@@ -63,6 +64,10 @@ public class MainViewModel : ViewModelBase
private string _streamDescription = string.Empty;
private string _streamVisibility = "Private";
private string? _currentBroadcastId;
private string? _reusableStreamUrl;
private ReusableStream? _reusableStream;
private string? _healthIssueBanner;
private string _healthIssueBackground = "Transparent";
private string _windowTitle = "ytLlive";
private string _topBarBackground = "#16213e";
private string _previewGlowBrush = "Transparent";
@@ -778,6 +783,23 @@ public class MainViewModel : ViewModelBase
set => SetProperty(ref _currentHealth, value);
}
/// <summary>Report-by-exception YouTube health (TASK 5 item 3): non-null only
/// while a warning/error configuration issue exists — the banner's text.
/// good/ok/noData keep it null so nothing renders.</summary>
public string? HealthIssueBanner
{
get => _healthIssueBanner;
private set => SetProperty(ref _healthIssueBanner, value);
}
/// <summary>Banner background: app red (#8f1f1f) for an error issue, amber
/// (#b8860b) for a warning. Transparent when no banner is shown.</summary>
public string HealthIssueBackground
{
get => _healthIssueBackground;
private set => SetProperty(ref _healthIssueBackground, value);
}
public string StreamTitle
{
get => _streamTitle;
@@ -1097,7 +1119,6 @@ public class MainViewModel : ViewModelBase
_liveTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
_liveTimer.Tick += OnLiveTimerTick;
_volumeFlashTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) };
_volumeFlashTimer.Tick += (_, _) => EndVolumeFlash();
@@ -1112,6 +1133,9 @@ public class MainViewModel : ViewModelBase
_brandFlashOffTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(750) };
_brandFlashOffTimer.Tick += (_, _) => { _brandFlashOffTimer.Stop(); BrandFlashActive = false; };
_healthPollTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(30) };
_healthPollTimer.Tick += OnHealthPollTick;
Scenes.CollectionChanged += OnScenesChanged;
AddSceneCommand = new RelayCommand(name => AddScene(name as string ?? string.Empty));
@@ -1201,7 +1225,7 @@ public class MainViewModel : ViewModelBase
_screenCaptureManager.CaptureFailed += (key, message) =>
AppLog.Write($"ScreenCaptureManager: capture '{key}' failed: {message}");
_rtmpUrlProvider = () => null; // TASK 5: the reusable stream's ingest URL
_rtmpUrlProvider = () => _reusableStreamUrl; // TASK 5: the reusable stream's ingest URL
_framePump = new FramePump(
sceneProvider: () => ActiveScene,
frameResolver: ResolveOutputFrame,
@@ -1214,6 +1238,10 @@ public class MainViewModel : ViewModelBase
_framePump.HealthUpdated += OnFramePumpHealthUpdated;
LoadLayout();
// The cached reusable stream gives the pump its RTMP URL the moment go-live
// starts (the pump reads the URL once at startup — no waiting on YouTube).
_reusableStream = _layoutStore.LoadReusableStream();
_reusableStreamUrl = _reusableStream?.RtmpUrl;
_ = LoadSavedSessionAsync();
AppLog.Write("MainViewModel ctor end");
}
@@ -2129,35 +2157,98 @@ public class MainViewModel : ViewModelBase
StreamStatus = StreamStatus.Streaming;
ResetHealth(StreamStatus.Streaming);
_audioMixer.StartLive(EncoderOptions.DefaultAudioPipeName);
_ = CreateBroadcastAsync();
_ = _framePump.StartAsync(); // never throws; failures log + surface via Failed
_ = PrepareAndStartLiveAsync();
}
}
/// <summary>Creates the YouTube broadcast (always private — enforced in the
/// stream service) and remembers its ID for TASK 5's bind/transition. A
/// failure surfaces as an error health state, never a crash.</summary>
private async Task CreateBroadcastAsync()
/// <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");
StreamStatus = StreamStatus.Error;
return;
}
_reusableStream = stream;
_reusableStreamUrl = stream.RtmpUrl;
_layoutStore.SaveReusableStream(stream);
_currentBroadcastId = await _youtubeStream.CreateBroadcast(
StreamTitle, StreamDescription, DateTime.UtcNow);
StreamTitle, StreamDescription, DateTime.UtcNow, stream.Id);
if (_currentBroadcastId == null)
{
AppLog.Write("Broadcast creation failed; check the OAuth session");
StreamStatus = StreamStatus.Error;
return;
}
else
{
AppLog.Write($"Broadcast created: {_currentBroadcastId}");
}
AppLog.Write($"Broadcast created: {_currentBroadcastId}");
}
catch (Exception ex)
{
AppLog.Write($"Broadcast creation failed: {ex.Message}");
AppLog.Write($"Go-live preparation failed: {ex.Message}");
StreamStatus = StreamStatus.Error;
return;
}
await _framePump.StartAsync(); // never throws; failures log + surface via Failed
// 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 void StopStream()
@@ -2329,6 +2420,11 @@ public class MainViewModel : ViewModelBase
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));
}
@@ -2351,6 +2447,7 @@ public class MainViewModel : ViewModelBase
else
{
_liveTimer.Stop();
_healthPollTimer.Stop();
_brandFlashTimer.Stop();
_brandFlashOffTimer.Stop();
BrandFlashActive = false;