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:
+111
-14
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user