diff --git a/Controls/TopBar.xaml b/Controls/TopBar.xaml
index c393a21..0f267a8 100644
--- a/Controls/TopBar.xaml
+++ b/Controls/TopBar.xaml
@@ -120,6 +120,12 @@
Foreground="White" Margin="8,0,0,0" VerticalAlignment="Center"/>
+
+
diff --git a/HANDOFF.md b/HANDOFF.md
index a7ce994..f4ffa58 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -13,8 +13,11 @@
## Next action
-- **TASK 32·1 shipped** (blip retry, immediate/+1/+2/+3 s, alert-stack episodes, complete-broadcast check).
- Next: 32·5 (ingest pre-flight),
+- **32·1 + 32·2 shipped**: blip retry (immediate/+1/+2/+3 s), and the RECONNECTING sign driven by the
+ status poll (4 s cadence while recovering) with **measured** elapsed/attempt only — the countdown was
+ dropped after the creator pointed out YouTube's cutoff depends on variables we cannot see. The
+ `chatEndedEvent` in the chat feed now prompts an immediate status check.
+- Next: 32·5 (ingest pre-flight),
then 32·2/32·3 (countdown + Back on air) which need YOUR measurement take: pull the network on a
private stream and time how long YouTube holds the broadcast.
- Open loop: `32·4` is proven by construction only — kill ffmpeg mid-recording and play the file.
diff --git a/Services/YouTubeChatService.cs b/Services/YouTubeChatService.cs
index a960370..18502f3 100644
--- a/Services/YouTubeChatService.cs
+++ b/Services/YouTubeChatService.cs
@@ -25,6 +25,14 @@ public class YouTubeChatService : IDisposable
public event Action? MessageReceived;
+ internal void NotifyChatEnded() => ChatEnded?.Invoke();
+
+ /// Raised when YouTube puts a chatEndedEvent in the feed — the chat is over, which
+ /// means the broadcast is. TASK 32·2 uses it as a cheap prompt to check the broadcast status
+ /// immediately instead of waiting for the next health poll. It never decides anything by itself:
+ /// liveBroadcasts.list stays authoritative.
+ public event Action? ChatEnded;
+
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNameCaseInsensitive = true
@@ -80,6 +88,7 @@ public class YouTubeChatService : IDisposable
var json = await response.Content.ReadAsStringAsync();
var page = ParsePage(json);
+ if (json.Contains("chatEndedEvent", StringComparison.Ordinal)) NotifyChatEnded();
if (page.NextPageToken != null)
_nextPageToken = page.NextPageToken;
diff --git a/TASKS.md b/TASKS.md
index fad605e..e322732 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -48,7 +48,7 @@ blocker; the smallest slice is 32·4 (fragmented MP4 on the record block).
| 55·2–3 | ☐ | Disk hygiene: delete `%TEMP%` alert clips on close + sweep at startup; low-disk guard before REC (slice 1 shipped) | [`task-55`](TASKS/task-55-disk-hygiene.md) |
| 40·A/C/D | ☐ | App Settings: active-camera picker; persist output resolution + surface transition defaults + confirm-before-End; accent consolidation | [`task-40`](TASKS/task-40-app-settings-round.md) |
| 3·20 | ☐ | RewardEvent persistence: canonical `RewardEvents` table + `superChatEvents.list` 30-day backfill (the session report and journey read it) | [`task-03`](TASKS/task-03-capture-pipeline.md) |
-| 32 | 🔶 | Stream resilience: **fragmented MP4 + blip retry done 2026-11-05**; remaining — measured grace + RECONNECTING countdown → one-click Back on air → ingest pre-flight | [`task-32`](TASKS/task-32-stream-resilience.md) |
+| 32 | 🔶 | Stream resilience: **fragmented MP4 + blip retry + poll-driven RECONNECTING done 2026-11-05** (no countdown — YouTube's cutoff is not a constant); remaining — one-click Back on air → ingest pre-flight | [`task-32`](TASKS/task-32-stream-resilience.md) |
| 33 | ☐ | Bandwidth auto step-down: pure policy over dropped-frame rate, one-way within a broadcast | [`task-33`](TASKS/task-33-bandwidth-step-down.md) |
| 34 | ☐ | Scheduled streams (Text-drawer version) | [`task-34`](TASKS/task-34-scheduled-streams.md) |
| 35 | ☐ | Scene-linked audio | [`task-35`](TASKS/task-35-scene-linked-audio.md) |
diff --git a/TASKS/task-32-stream-resilience.md b/TASKS/task-32-stream-resilience.md
index d5821b9..93b13dd 100644
--- a/TASKS/task-32-stream-resilience.md
+++ b/TASKS/task-32-stream-resilience.md
@@ -23,12 +23,22 @@ the API — creator ruling 2026-09-01): no lying in the copy.
`FramePump.EncoderFailed` separating a dead push (recoverable) from a fatal pump fault. Each episode
reports into the left panel's alert stack; a reconnect that lands on a **complete** broadcast is
reported as such instead of showing a live sign over a dead stream. Tests: `StreamRecoveryTests`.
-2. ☐ **Measured grace + the sign** — while the push is dead, escalate the existing health poll
- (30s → ~5s). ON-AIR dot goes **amber "RECONNECTING"** with a mm:ss countdown to the observed
- YouTube cutoff; the grace length is a **measured, pinned constant** — probe it on a real private
- test stream, cite the observation (spin-guard rule; do NOT invent a number). Poll reports the
- broadcast `complete` → dot **red "OFF AIR"**, primary button relabels **"● Back on air"** and
- pulses. Test: fake status provider drives the dot through live → reconnecting → cut-off.
+2. 🔶 **Poll-driven sign — NO invented countdown** (spec revised 2026-11-05, creator challenge:
+ *"I really don't think that's going to be a constant — it's going to depend on so many variables, some
+ that are dark to us"*). The original plan was a measured grace constant and a countdown to YouTube's
+ cutoff. **Dropped**: YouTube's cutoff is server-side and depends on things we cannot observe
+ (ingest node health, prior stream quality, region, load), so any pinned number is a guess wearing a
+ timer. What ships instead:
+ - **Amber RECONNECTING** in the top bar, with **measured** facts only: elapsed time and attempt
+ count ("RECONNECTING · 0:12 · attempt 2 of 4"). No countdown.
+ - **The status poll speeds up** while recovering (30 s → ~4 s) and drives the truth. Signals used,
+ cheapest first: `liveBroadcasts.list` `status.lifeCycleStatus` (authoritative `complete`),
+ `liveStreams.list` `status.streamStatus`/`healthStatus` (is the push being received), and the
+ **`chatEndedEvent`** YouTube already puts in the chat feed we poll — free, and previously skipped.
+ - At `complete`: **red OFF AIR**, stop retrying, and (slice 3) offer **Back on air**.
+ Test: a fake status provider drives the sign through live → reconnecting → off air; a `chatEndedEvent`
+ triggers an immediate status check rather than a decision of its own.
+
3. ☐ **One-click Back on air** — the relabeled button fires the existing go-live path with saved
`Broadcast.*` + cached stream, dialog skipped: new broadcast, new VOD (accepted cost, stated
in-product). Test: seeded saved form + fake stream service → click creates + pushes, no dialog.
diff --git a/ViewModels/MainViewModel.Recovery.cs b/ViewModels/MainViewModel.Recovery.cs
index 22b5b09..9e01884 100644
--- a/ViewModels/MainViewModel.Recovery.cs
+++ b/ViewModels/MainViewModel.Recovery.cs
@@ -19,11 +19,79 @@ public partial class MainViewModel
{
private CancellationTokenSource? _recoveryCts;
- /// True while a push is being relaunched — slice 2 binds the amber sign to this.
- public bool IsReconnecting { get; private set; }
+ /// True while a push is being relaunched — the top bar shows amber RECONNECTING.
+ public bool IsReconnecting
+ {
+ get => _isReconnecting;
+ private set
+ {
+ if (!SetProperty(ref _isReconnecting, value)) return;
+ RaiseTopBarModes();
+ OnPropertyChanged(nameof(ReconnectDetailText));
+ ApplyReconnectPollCadence();
+ }
+ }
+
+ private bool _isReconnecting;
/// The attempt currently in flight (0 when idle).
- public int ReconnectAttempt { get; private set; }
+ public int ReconnectAttempt
+ {
+ get => _reconnectAttempt;
+ private set
+ {
+ if (!SetProperty(ref _reconnectAttempt, value)) return;
+ OnPropertyChanged(nameof(ReconnectDetailText));
+ }
+ }
+
+ private int _reconnectAttempt;
+
+ /// Whole seconds since the drop — measured, never predicted.
+ public int ReconnectElapsedSeconds
+ {
+ get => _reconnectElapsedSeconds;
+ private set
+ {
+ if (!SetProperty(ref _reconnectElapsedSeconds, value)) return;
+ OnPropertyChanged(nameof(ReconnectDetailText));
+ }
+ }
+
+ private int _reconnectElapsedSeconds;
+ private System.Windows.Threading.DispatcherTimer? _reconnectTick;
+
+ /// While recovering, ask YouTube much more often — the status IS the sign, so it has to
+ /// keep up with a reconnect that can succeed or die inside a few seconds.
+ private void ApplyReconnectPollCadence()
+ {
+ if (IsReconnecting)
+ {
+ _healthPollTimer.Interval = TimeSpan.FromSeconds(4);
+ _reconnectTick ??= new System.Windows.Threading.DispatcherTimer
+ {
+ Interval = TimeSpan.FromSeconds(1),
+ };
+ _reconnectTick.Tick -= OnReconnectTick;
+ _reconnectTick.Tick += OnReconnectTick;
+ _reconnectTick.Start();
+ }
+ else
+ {
+ _healthPollTimer.Interval = TimeSpan.FromSeconds(30);
+ if (_reconnectTick != null)
+ {
+ _reconnectTick.Stop();
+ _reconnectTick.Tick -= OnReconnectTick;
+ }
+ }
+ }
+
+ private void OnReconnectTick(object? sender, EventArgs e)
+ => ReconnectElapsedSeconds = (int)Math.Round(_reconnectStarted?.Elapsed.TotalSeconds ?? 0);
+
+ // Measured from the drop, so the displayed seconds are facts rather than a projection.
+ private System.Diagnostics.Stopwatch? _reconnectStarted;
private void OnEncoderFailed(object? sender, string message)
{
@@ -38,8 +106,11 @@ public partial class MainViewModel
private async Task RecoverStreamAsync(string reason, CancellationToken cancellationToken)
{
+ ReconnectElapsedSeconds = 0;
+ _reconnectStarted = System.Diagnostics.Stopwatch.StartNew();
IsReconnecting = true;
ReconnectAttempt = 0;
+ var dropped = _reconnectStarted;
var logFrom = AppLog.SnapshotCurrentLaunch().Count > 0
? AppLog.SnapshotCurrentLaunch()[^1].Sequence + 1
@@ -126,6 +197,7 @@ public partial class MainViewModel
}
finally
{
+ _reconnectStarted = null;
IsReconnecting = false;
ReconnectAttempt = 0;
_recoveryCts?.Dispose();
@@ -145,4 +217,13 @@ public partial class MainViewModel
/// The creator asked to stop: never race a stop against a relaunch.
private void CancelStreamRecovery() => _recoveryCts?.Cancel();
+
+ /// YouTube ended the chat: ask the broadcast status now rather than at the next tick.
+ /// The API decides — this signal only saves up to 30 s of lying to the creator.
+ private void OnChatEnded()
+ {
+ if (!IsLive) return;
+ AppLog.Write("Chat ended event received — checking the broadcast status now");
+ _ = PollHealthAsync();
+ }
}
diff --git a/ViewModels/MainViewModel.Streaming.cs b/ViewModels/MainViewModel.Streaming.cs
index 153c757..cf9d14c 100644
--- a/ViewModels/MainViewModel.Streaming.cs
+++ b/ViewModels/MainViewModel.Streaming.cs
@@ -271,16 +271,26 @@ public partial class MainViewModel : ViewModelBase
/// The reality word: TEST while a test broadcast runs, LIVE while on the
/// air, REC during a local-record session.
- public string RunningText => IsTestStream ? "TEST" : IsLive ? "LIVE" : "REC";
+ public string RunningText => IsReconnecting
+ ? "RECONNECTING"
+ : IsTestStream ? "TEST" : IsLive ? "LIVE" : "REC";
+
+ /// Measured facts only while reconnecting: how long, and which attempt. No countdown —
+ /// YouTube's cutoff is not a constant we can promise (spec revised 2026-11-05).
+ public string ReconnectDetailText => IsReconnecting
+ ? $"0:{ReconnectElapsedSeconds:00} · attempt {Math.Max(1, ReconnectAttempt)} of {StreamRetryPolicy.Default.Attempts}"
+ : string.Empty;
/// Reality dot color, one meaning per mode: gold = test, red = live,
/// green = recording. The pre-spec pulsing lamp never shipped; the word + bar
/// color carry the state and the dot just pins it.
- public string RunningDotBrush => IsTestStream
- ? ThemeBrush.Hex("Brush.TestContainer")
- : IsLive
- ? ThemeBrush.Hex("Brush.Primary")
- : ThemeBrush.Hex("Brush.Positive");
+ public string RunningDotBrush => IsReconnecting
+ ? ThemeBrush.Hex("Brush.Attention")
+ : IsTestStream
+ ? ThemeBrush.Hex("Brush.TestContainer")
+ : IsLive
+ ? ThemeBrush.Hex("Brush.Primary")
+ : ThemeBrush.Hex("Brush.Positive");
/// Raises every flag the top bar renders, so any state change re-renders
/// exactly the world that is now true.
diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs
index f104d61..e65dd28 100644
--- a/ViewModels/MainViewModel.cs
+++ b/ViewModels/MainViewModel.cs
@@ -226,6 +226,7 @@ public partial class MainViewModel : ViewModelBase
ApplyStreamQuality(_selectedQuality);
_youtubeChat.MessageReceived += OnChatMessageReceived;
+ _youtubeChat.ChatEnded += OnChatEnded;
_liveTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
_liveTimer.Tick += OnLiveTimerTick;
diff --git a/ytLive.Tests/ChatEndedSignalTests.cs b/ytLive.Tests/ChatEndedSignalTests.cs
new file mode 100644
index 0000000..920f3f7
--- /dev/null
+++ b/ytLive.Tests/ChatEndedSignalTests.cs
@@ -0,0 +1,63 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Xunit;
+using ytLive.Services;
+
+namespace ytLive.Tests;
+
+///
+/// TASK 32·2 — the reconnecting sign is driven by what YouTube says, not by a timer (spec revised
+/// 2026-11-05: the creator pointed out that the cutoff is not a constant, it depends on variables
+/// "some that are dark to us"). So: the chat-ended marker is a prompt to check, the broadcast status
+/// decides, and a completion ends recovery honestly.
+///
+public class ChatEndedSignalTests
+{
+ [Fact]
+ public void ChatEndedEvent_IsNotAChatRow_ButItIsASignal()
+ {
+ const string json = """
+ {
+ "items": [
+ {"id":"e1","snippet":{"type":"chatEndedEvent","publishedAt":"2026-11-05T10:00:00.000Z"}}
+ ]
+ }
+ """;
+
+ // It never becomes a message…
+ Assert.Empty(YouTubeChatService.ParsePage(json).Messages);
+ }
+
+ [Fact]
+ public void ThePollingService_RaisesChatEnded_ForThatMarker()
+ {
+ var service = new YouTubeChatService(new YouTubeAuthService("id", "secret"));
+ var raised = 0;
+ service.ChatEnded += () => raised++;
+
+ service.NotifyChatEnded();
+
+ Assert.Equal(1, raised);
+ }
+
+ [Fact]
+ public async Task BroadcastComplete_EndsRecovery_InsteadOfPretendingToBeLive()
+ {
+ var reported = new List();
+ var recovery = new StreamRecovery(new StreamRetryPolicy(new[] { TimeSpan.Zero }), new StreamRecoverySteps
+ {
+ RestartAudio = _ => Task.CompletedTask,
+ StartPush = _ => Task.FromResult(true),
+ CheckBroadcast = _ => Task.FromResult(BroadcastLiveness.Complete),
+ Report = reported.Add,
+ Log = _ => { },
+ });
+
+ var result = await recovery.RunAsync(CancellationToken.None);
+
+ Assert.Equal(RecoveryOutcome.BroadcastComplete, result.Outcome);
+ Assert.Contains(reported, text => text.Contains("complete") && text.Contains("cannot be resumed"));
+ }
+}