diff --git a/Controls/LeftPanel.xaml b/Controls/LeftPanel.xaml
index 222f12a..17b3117 100644
--- a/Controls/LeftPanel.xaml
+++ b/Controls/LeftPanel.xaml
@@ -484,6 +484,21 @@
HorizontalAlignment="Left"/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/HANDOFF.md b/HANDOFF.md
index 4d98451..ebd7153 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -1,4 +1,4 @@
-# HANDOFF — 2026-09-26, end of session
+# HANDOFF — 2026-09-26, end of session (take four: ticker display methods)
## Where we are
`main`, **5 local commits ahead of `origin/main` (= `ecb329e`), NOTHING PUSHED.**
@@ -10,7 +10,23 @@ Last pushed commit is still `ecb329e`. Do not push without the creator saying so
| `7b940b6` | poller-thread marshal crash fix |
| `80038ff` | silent-decoder fallback + diagnostics |
| `aea0670` | video pacing via `FfmpegFrameRateProbe` |
-| (last, unpushed) | **the real alert fix: `PreviewPane.xaml` `IsAlertBox` trigger** + 2 Good Dog tests + resolver/preview diagnostics |
+| `e2ecc24` | **the real alert fix: `PreviewPane.xaml` `IsAlertBox` trigger** + 2 Good Dog tests + resolver/preview diagnostics |
+| (last, unpushed) | **alert announcement strip: visible in the preview + 3 display methods** (Scroll / Flash / Solid), paced in reads-per-alert |
+
+## THE ANSWER, take two (do not re-derive this)
+The **ticker was output-only**. `AlertTickerFrame` existed solely as a frame-pump
+callback (blitted at 0,0 into the OUTPUT). `PreviewPane.xaml` had no element for it
+because the strip is master-width and GLOBAL, not a `Source`, so it cannot ride a
+per-element `Image`. Nothing was broken in the renderer — there was simply no consumer
+in the preview. Fixed with a `tickerPreviewSink` on `AlertOverlayLayer` (published from
+`RefreshAlertPreviews()`, UI thread only) + `MainViewModel.AlertTickerImageSource` +
+a global `AlertTickerElement` in `PreviewPane.xaml` mirroring `SocialBarElement`.
+
+The marquee was also **unreadably paced**: a fixed `140px/s` needed ~17s per pass, so a
+10s alert showed the message barely once, entering from the right and never crossing. It
+is now paced in **reads per alert** (`TickerReadsPerAlert = 3` inside the alert's own
+length), which is the incumbent's own unit (Streamlabs: "Alert Duration" + "Text Delay",
+never a scroll-speed slider).
## THE ANSWER (do not re-derive this)
The alert video was **never drawn**, never mis-decoded. Build 90's diagnostics showed
@@ -36,7 +52,7 @@ MSB3027 — the creator must close ytLive first. That is normal, not a broken bu
"/mnt/c/Program Files/dotnet/dotnet.exe" build "C:\Users\gramp\Documents\Code\projects\ytLive\ytLive.csproj" --no-restore
"/mnt/c/Program Files/dotnet/dotnet.exe" vstest "C:\Users\gramp\Documents\Code\projects\ytLive\ytLive.Tests\bin\Debug\net8.0-windows10.0.19041.0\ytLive.Tests.dll"
```
-Last full suite: **324/324 pass**. Run it with ytLive CLOSED or the RealMouseDrag env
+Last full suite: **339/339 pass** (15 new facts for the ticker feature). Run it with ytLive CLOSED or the RealMouseDrag env
flake fires.
## Queued next (creator's words, 2026-09-26) — the Good Dog queue
@@ -46,11 +62,32 @@ flake fires.
2. **Test pull-out chat input: 20px right padding** — the text box is clipped at the right
edge of the div; needs 20px padding against the div's right side.
3. **Test pull-out chat input: Enter inserts a newline**, it must not send.
-4. Verify the alert video live on the next build (the fix is in; nobody has SEEN it play
- yet). This is the confirmation step for the commit above.
+4. ~~Verify the alert video live~~ — **DONE, the creator confirmed it plays.**
+5. **Verify the three ticker display methods live** (Scroll / Flash / Solid) and pick a
+ default. Flash is 0.5s on / 0.5s off; Solid is centred; Scroll does 3 passes per 10s
+ alert. Nothing here has been SEEN on screen yet — the tests prove pixels and bindings,
+ not the look.
+6. **Decide whether the once-per-second alert diagnostics stay.** `ViewModels/MainViewModel.cs`
+ (`ResolveOutputFrame`) and `Services/AlertOverlayLayer.cs` (`UpdatePreview`) still log
+ `Alert preview:` / `Output resolver:` once a second while an alert plays. They earned
+ their keep; they are noise in normal use. Creator's call.
## Landmines / facts worth keeping
- The pinned BtbN ffmpeg has **no libx264** — generate test clips with `-c:v mpeg4`.
+- **A bound `ItemsSource` ComboBox in `LeftPanel.xaml` breaks
+ `LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder`** (it
+ injects PHYSICAL mouse input, so a load-time re-measure moves the rows out from under
+ the cursor). Use inline ``s like the chat Font selector does. If a UI-only
+ change breaks a real-input test, suspect the control's binding STYLE first, and bisect
+ with `git checkout `.
+- The `Source` row is read back by **POSITIONAL** index in `LayoutStore.Load.cs`
+ (`GetInt32(32..34)`). Append new Source columns to the END of the SELECT, or a
+ mid-list insert silently shifts a neighbour instead of failing to load. New columns use
+ the idempotent `PRAGMA table_info` guard in `LayoutStore.Migrations.cs` (no
+ `user_version` bump needed).
+- **A seamless marquee never goes blank** (the wrapped copy enters from the right as the
+ pill clears the left), so an "empty frame" cannot count a pass — count the pill's
+ leading edge resetting to the right edge.
- `SceneCompositor.Render` fills its base with **opaque black**, so "any non-zero byte"
is not proof a layer painted. Compare against an idle render, or count COLOURFUL px.
- `sqlite3` on WSL: `/home/gramps/android-sdk/platform-tools/sqlite3`. Extract a BLOB with
diff --git a/Models/Source.cs b/Models/Source.cs
index bda4598..59dfab2 100644
--- a/Models/Source.cs
+++ b/Models/Source.cs
@@ -40,6 +40,19 @@ public enum ClipShape
Round
}
+/// How an alert box presents its announcement strip (TASK 47). The strip is
+/// the full-width pill naming the event ("Viewer — Super Chat · $12.34").
+public enum AlertDisplayMethod
+{
+ /// Marquee: the pill scrolls across the top edge, paced so the text is
+ /// read times per alert.
+ TickerScroll,
+ /// Pulse: the static pill blinks on and off for the whole alert.
+ Flash,
+ /// Static: the pill sits centred and still for the whole alert.
+ Solid
+}
+
///
/// A multi-instance scene object: image/background/text (screen/window later).
/// The webcam is NOT a Source — it's a singleton resource whose per-scene usage
@@ -218,6 +231,13 @@ public class Source : SceneElement
set => Set(ref _alertVideoVolume, Math.Clamp(value, 0.0, 1.0));
}
+ private AlertDisplayMethod _alertDisplayMethod = AlertDisplayMethod.TickerScroll;
+ public AlertDisplayMethod AlertDisplayMethod
+ {
+ get => _alertDisplayMethod;
+ set => Set(ref _alertDisplayMethod, value);
+ }
+
public bool IsAlertBox => Type == SourceType.AlertBox;
// Chat box config (TASK 3.18)
diff --git a/MyMistakes.md b/MyMistakes.md
index 552e7bb..bb2cd3d 100644
--- a/MyMistakes.md
+++ b/MyMistakes.md
@@ -943,3 +943,49 @@ and the compositor resolver. The box was blank because `PreviewPane.xaml`'s per-
the honest test asserts on the CONSUMER (is the Image Visible / are the canvas pixels
different), never on the producer's own counters.
- Corollary: never trust "the data arrived" as a root cause. Ask what draws it.
+
+**RULE (6) — a per-element `Image` is the wrong home for a GLOBAL overlay, and an
+output-only producer is invisible by construction.** Same hunt, one layer up
+(2026-09-26, after the `IsAlertBox` fix): the creator confirmed the clip plays, then
+asked "where is the scrolling text? I do not see it." The ticker was healthy the whole
+time — `AlertTickerRenderer` produced a 1920x48 frame and `FramePump` blitted it at
+(0,0) — but it existed ONLY as a frame-pump callback (`alertTicker: () =>
+_alertLayer.AlertTickerFrame`). `PreviewPane.xaml` had no element for it, because the
+strip is master-width, not a `Source`, so it can't ride a per-element `Image`. Two
+lessons:
+- **When a feature's output is global (full frame width, a fixed overlay slot), it needs
+ its own root element + its own VM-published `WriteableBitmap`, exactly like
+ `SocialBarElement` — not a `Source`.** A global overlay that only a background thread
+ consumes will reach the stream and never the creator's preview, and no amount of
+ producer-side logging will show it: there is no consumer to be unhealthy.
+- **Ask "who displays this?" while designing, not after the report.** RULE (5) says
+ audit the consumer when a picture is missing. RULE (6) is the earlier lesson: the
+ consumer may not exist yet, and that is invisible from the producer side.
+
+**RULE (7) — pace a marquee in READS PER ALERT, never in pixels per second.** The ticker
+shipped at a fixed `PixelsPerSecond = 140`, which sounds reasonable and was wrong: one
+full pass took ~17s, so the 10s default clip showed the announcement barely once,
+entering from the right and never crossing. The number that matches the creator's intent
+("readable three times") is **passes per alert**, divided into the alert's own length —
+the same convention the incumbent uses (Streamlabs exposes "Alert Duration: choose how
+long your alert stays on your stream" and "Text Delay", never a scroll-speed slider;
+https://support.streamlabs.com/hc/en-us/articles/52499995174299-Setting-up-Your-Streamlabs-Alerts).
+So: `readsPerAlert / alertSeconds` => a pass length => a speed derived from it. Corollary
+that cost a test rewrite: **a seamless marquee can never go blank**, so "the frame went
+empty" is NOT a valid way to count passes — count the pill's leading edge resetting to
+the right edge instead. And phase-start the run half a frame in, or the first published
+frame is blank (the strip sits exactly off the right edge) and the preview opens on
+nothing.
+
+**RULE (8) — a bound `ItemsSource` ComboBox can break an unrelated real-mouse test.**
+`LeftPanel.xaml` gained a display selector written as
+`ItemsSource="{Binding ...}" + DisplayMemberPath + SelectedValuePath`; it compiled, my
+feature's tests passed, and it broke `LayerReorderPersistenceTests.RealMouseDrag_OnThe
+LayerList_PersistsTheReorder` — a test that injects PHYSICAL mouse input
+(`SetCursorPos` + `mouse_event`). A data-bound selection resolves during load and
+re-measures the panel under the test's feet, so the real cursor lands on the wrong row.
+Rewriting it as inline ``s (the shape the chat Font selector already uses
+in the same panel) fixed it with no other change. LESSON: **when a UI-only change breaks a
+real-input test, the control's data-binding style is the suspect, not the feature** — and
+bisect by reverting ONE file (`git checkout Controls/LeftPanel.xaml`) before theorising.
+Prefer the idiom already in the file you are editing.
diff --git a/Services/AlertOverlayLayer.cs b/Services/AlertOverlayLayer.cs
index f316562..18775b6 100644
--- a/Services/AlertOverlayLayer.cs
+++ b/Services/AlertOverlayLayer.cs
@@ -52,6 +52,7 @@ public sealed class AlertOverlayLayer : IDisposable
private readonly Func? _clipDecoderFactory;
private readonly Func? _clipPathResolver;
private readonly Action? _audioSink;
+ private readonly Action? _tickerPreviewSink;
private readonly Queue _queue = new();
private readonly DispatcherTimer? _ticker;
@@ -66,6 +67,7 @@ public sealed class AlertOverlayLayer : IDisposable
private bool _clipSawFrame;
private bool _clipAudioHeard;
private string? _tickerText;
+ private AlertDisplayMethod _tickerMethod = AlertDisplayMethod.TickerScroll;
private double _elapsed;
private IEnumerable? _scenes;
@@ -79,12 +81,14 @@ public sealed class AlertOverlayLayer : IDisposable
AlertRenderer renderer,
Func? clipDecoderFactory = null,
Func? clipPathResolver = null,
- Action? audioSink = null)
+ Action? audioSink = null,
+ Action? tickerPreviewSink = null)
{
_renderer = renderer;
_clipDecoderFactory = clipDecoderFactory;
_clipPathResolver = clipPathResolver;
_audioSink = audioSink;
+ _tickerPreviewSink = tickerPreviewSink;
if (Application.Current != null)
{
_ticker = new DispatcherTimer { Interval = TickInterval };
@@ -234,19 +238,32 @@ public sealed class AlertOverlayLayer : IDisposable
}
/// The global ticker strip for the stream output (top edge, full
- /// master width): a marquee of "viewer — event" while an alert is playing
- /// and the config has the ticker on, else null. Pure byte-math per call so
- /// the frame pump can read it on any thread (the text strip is rasterized
- /// once on the UI thread and cached).
+ /// master width) and the preview pane: a marquee, a pulse or a static pill
+ /// naming the event while an alert plays, else null. Pure byte-math per call so
+ /// the frame pump can read it on any thread (the text strip is rasterized once
+ /// on the UI thread and cached).
public VideoFrame? AlertTickerFrame
{
get
{
if (_current == null || string.IsNullOrWhiteSpace(_tickerText)) return null;
- return AlertTickerRenderer.Render(_tickerText!, _elapsed);
+ return AlertTickerRenderer.Render(_tickerText!, _elapsed, _tickerMethod, AlertSeconds());
}
}
+ /// How many times the marquee must cross the frame per alert, so the
+ /// announcement is actually readable inside a 10s clip (2026-09-26: the old
+ /// fixed 140px/s took ~17s per pass, so a 10s alert showed it barely once).
+ internal const int TickerReadsPerAlert = 3;
+
+ /// Assumed alert length for the marquee rhythm. The clip's real length
+ /// isn't known until it EOFs, and the shipped default clip is 10s — the number
+ /// the creator's "three reads" target is stated against.
+ internal const double AssumedAlertSeconds = 10.0;
+
+ private double AlertSeconds() => _clip != null ? AssumedAlertSeconds : DurationOf(_current!);
+
+
private VideoFrame? RenderClipFrame(Source alertBox)
{
var frame = _latestClipFrame;
@@ -289,7 +306,10 @@ public sealed class AlertOverlayLayer : IDisposable
.FirstOrDefault(s => s.Type == SourceType.AlertBox);
_currentBox = box;
if (box != null && box.AlertShowTicker)
+ {
_tickerText = ComposeTicker(_current);
+ _tickerMethod = box.AlertDisplayMethod;
+ }
if (box == null || _clipDecoderFactory == null) return;
var path = _clipPathResolver?.Invoke(box);
@@ -478,6 +498,12 @@ public sealed class AlertOverlayLayer : IDisposable
private void RefreshAlertPreviews()
{
+ // The announcement strip is a GLOBAL 1920px overlay, not a per-box frame, so
+ // it gets its own sink: until this existed the strip went only to the frame
+ // pump and was therefore invisible in the preview pane the creator watches
+ // (2026-09-26 — "I do not see the scrolling text").
+ _tickerPreviewSink?.Invoke(AlertTickerFrame);
+
if (_scenes == null) return;
foreach (var alertBox in _scenes.SelectMany(s => s.Elements).OfType()
.Where(s => s.Type == SourceType.AlertBox))
diff --git a/Services/Compositor/AlertTickerRenderer.cs b/Services/Compositor/AlertTickerRenderer.cs
index 978923e..28bf907 100644
--- a/Services/Compositor/AlertTickerRenderer.cs
+++ b/Services/Compositor/AlertTickerRenderer.cs
@@ -4,6 +4,7 @@ using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
+using ytLive.Models;
namespace ytLive.Services.Compositor;
@@ -26,34 +27,64 @@ public static class AlertTickerRenderer
/// Strip height (pill + vertical padding).
public const int Height = 48;
- private const int PixelsPerSecond = 140;
private const int RepeatGap = 120;
private const int CacheLimit = 48;
+ /// How long each blink half lasts in
+ /// (0.5s on, 0.5s off — a 1Hz pulse for the whole alert).
+ public const double FlashHalfSeconds = 0.5;
+
private static readonly object Gate = new();
private static readonly Dictionary StripCache = new();
- /// A marquee frame of scrolled
- /// into its loop, or null for empty text.
- /// Thread-safe: reads the cached raster and does row copies only.
- public static VideoFrame? Render(string? text, double elapsedSeconds)
+ /// A frame of presented per
+ /// at into the alert,
+ /// or null when there is nothing to show (no text, or a Flash in its off half).
+ /// Thread-safe: reads the cached raster and does row copies only.
+ /// paces itself from
+ /// over rather
+ /// than a fixed pixels-per-second: the old constant (140px/s ≈ a 17s pass) meant
+ /// a 10s alert showed the text barely once, entering from the right and never
+ /// crossing. The creator's target is three readable passes per alert.
+ public static VideoFrame? Render(string? text, double elapsedSeconds,
+ AlertDisplayMethod method = AlertDisplayMethod.TickerScroll,
+ double alertSeconds = 0.0, int readsPerAlert = 3)
{
if (string.IsNullOrWhiteSpace(text)) return null;
var strip = GetStrip(text!);
if (strip == null) return null;
- var sw = strip.Width;
- var cycle = Width + sw + RepeatGap;
- var pos = (int)(elapsedSeconds * PixelsPerSecond) % cycle;
- var x = Width - pos;
+ if (method == AlertDisplayMethod.Flash
+ && elapsedSeconds % (FlashHalfSeconds * 2) >= FlashHalfSeconds)
+ return null; // off half of the pulse
+
+ var x = method == AlertDisplayMethod.TickerScroll
+ ? ScrollX(strip.Width, elapsedSeconds, alertSeconds, readsPerAlert)
+ : (Width - strip.Width) / 2; // Solid + Flash: centred, still
var pixels = new byte[Width * Height * 4];
CopyStrip(pixels, strip, x);
- if (x < 0)
+ if (method == AlertDisplayMethod.TickerScroll && x + strip.Width <= 0)
CopyStrip(pixels, strip, x + Width + RepeatGap); // wrapped tail enters from the right
return new VideoFrame(Width, Height, pixels);
}
+ /// Left edge of the pill for a marquee that must complete
+ /// passes in :
+ /// one pass (off the right edge, across, out of sight) takes that long.
+ /// The run is phase-started half a frame in, so the very first frame of an
+ /// alert already shows the announcement instead of a blank strip sitting exactly
+ /// off the right edge.
+ private static int ScrollX(int stripWidth, double elapsedSeconds, double alertSeconds, int readsPerAlert)
+ {
+ var cycle = Width + stripWidth + RepeatGap;
+ var pass = Math.Max(0.5, alertSeconds) / Math.Max(1, readsPerAlert);
+ var speed = cycle / pass;
+ var pos = (Width / 2 + (int)(elapsedSeconds * speed)) % cycle;
+ return Width - pos;
+ }
+
+
private static void CopyStrip(byte[] dst, VideoFrame strip, int dstX)
{
if (dstX >= Width || dstX + strip.Width <= 0) return;
diff --git a/Services/LayoutStore.Load.cs b/Services/LayoutStore.Load.cs
index da45603..3bbe946 100644
--- a/Services/LayoutStore.Load.cs
+++ b/Services/LayoutStore.Load.cs
@@ -100,7 +100,8 @@ public partial class LayoutStore : IDisposable
ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages,
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor,
WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState,
- AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume, SortOrder
+ AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume, SortOrder,
+ AlertDisplayMethod
FROM Source ORDER BY SortOrder
""";
using var reader = cmd.ExecuteReader();
@@ -151,6 +152,7 @@ public partial class LayoutStore : IDisposable
AlertUseDefaultVideo = reader.IsDBNull(32) ? true : reader.GetInt32(32) != 0,
AlertShowTicker = reader.IsDBNull(33) ? true : reader.GetInt32(33) != 0,
AlertVideoVolume = reader.IsDBNull(34) ? 1.0 : reader.GetDouble(34),
+ AlertDisplayMethod = reader.IsDBNull(36) ? AlertDisplayMethod.TickerScroll : (AlertDisplayMethod)reader.GetInt32(36),
};
if (!sourcesByScene.TryGetValue(sceneId, out var list))
sourcesByScene[sceneId] = list = new List<(Source, int)>();
diff --git a/Services/LayoutStore.Migrations.cs b/Services/LayoutStore.Migrations.cs
index 6e65ca2..41f3d7b 100644
--- a/Services/LayoutStore.Migrations.cs
+++ b/Services/LayoutStore.Migrations.cs
@@ -169,6 +169,13 @@ public partial class LayoutStore : IDisposable
cmd.ExecuteNonQuery();
}
+ if (!columns.Contains("AlertDisplayMethod"))
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "ALTER TABLE Source ADD COLUMN AlertDisplayMethod INTEGER NOT NULL DEFAULT 0;";
+ cmd.ExecuteNonQuery();
+ }
+
if (!columns.Contains("IsMirrored"))
{
using var cmd = _connection.CreateCommand();
diff --git a/Services/LayoutStore.Save.cs b/Services/LayoutStore.Save.cs
index dba805c..1d42e1b 100644
--- a/Services/LayoutStore.Save.cs
+++ b/Services/LayoutStore.Save.cs
@@ -89,14 +89,16 @@ public partial class LayoutStore : IDisposable
ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages,
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor,
WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState,
- AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume)
+ AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume,
+ AlertDisplayMethod)
VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
$x, $y, $w, $h, $opacity, $monitor,
$clip, $mirrored, $isBackground, $captureKey, $showDesktop, $sort,
$chatFontSize, $chatFontColor, $chatBgColor, $chatMaxMsgs,
$chatTimestamps, $chatBadges, $chatMemberColor, $chatSuperChatColor,
$webUri, $mediaPath, $mediaIsLooping, $mediaVolume, $mediaPlaybackState,
- $alertVideoAssetId, $alertVideoPath, $alertUseDefaultVideo, $alertShowTicker, $alertVideoVolume)
+ $alertVideoAssetId, $alertVideoPath, $alertUseDefaultVideo, $alertShowTicker, $alertVideoVolume,
+ $alertDisplayMethod)
""";
cmd.Transaction = tx;
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
@@ -135,6 +137,7 @@ public partial class LayoutStore : IDisposable
var alertUseDefaultVideoP = cmd.Parameters.Add("$alertUseDefaultVideo", SqliteType.Integer);
var alertShowTickerP = cmd.Parameters.Add("$alertShowTicker", SqliteType.Integer);
var alertVideoVolumeP = cmd.Parameters.Add("$alertVideoVolume", SqliteType.Real);
+ var alertDisplayMethodP = cmd.Parameters.Add("$alertDisplayMethod", SqliteType.Integer);
// Source and WebcamSceneConfig share ONE z-order space per scene:
// each element gets its index within scene.Elements regardless of
@@ -182,6 +185,7 @@ public partial class LayoutStore : IDisposable
alertUseDefaultVideoP.Value = source.AlertUseDefaultVideo ? 1 : 0;
alertShowTickerP.Value = source.AlertShowTicker ? 1 : 0;
alertVideoVolumeP.Value = source.AlertVideoVolume;
+ alertDisplayMethodP.Value = (int)source.AlertDisplayMethod;
cmd.ExecuteNonQuery();
}
}
diff --git a/TASKS.md b/TASKS.md
index 9e1898b..243a436 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -62,7 +62,7 @@
| 44 | TEST-tab chat fix: resolve liveChatId from snippet (not contentDetails) + poll until the broadcast is live | ✅ Done (2026-09-25); full suite 318/318 | [`TASKS/task-44-test-chat-fix.md`](TASKS/task-44-test-chat-fix.md) |
| 45 | TEST-tab chat fix #2: insert body must declare `snippet.type` (400 MISSING_REQUIRED_FIELD) | ✅ Done (2026-09-25) | [`TASKS/task-45-chat-insert-type.md`](TASKS/task-45-chat-insert-type.md) |
| 46 | Drawers: click outside the rail closes whichever is open — TEST added to the existing Stream Settings + YPP dismiss behavior | ✅ Done (2026-09-25) | [`TASKS/task-46-drawer-click-outside-close.md`](TASKS/task-46-drawer-click-outside-close.md) |
-| 47 | Alert box video: built-in/custom alert clip (+ six-animation fallback) with read-time fade in/out + ticker + alert volume in the live mix | ✅ Done (2026-09-26) | [`TASKS/task-47-alert-videos.md`](TASKS/task-47-alert-videos.md) |
+| 47 | Alert box video: built-in/custom alert clip (+ six-animation fallback) with read-time fade in/out + ticker (now in the preview too, 3 display methods) + alert volume in the live mix | ✅ Done (2026-09-26) | [`TASKS/task-47-alert-videos.md`](TASKS/task-47-alert-videos.md) |
---
@@ -121,6 +121,13 @@
enableAutoStart; YouTube only populates the id on live broadcasts — official GetLiveChatId.java
sample lists broadcastStatus=active). Fixed by reading `snippet.liveChatId` and polling with a
bounded retry after the frame pump starts; chat stays non-fatal.
+- **TASK 47 take four (2026-09-26)** — the announcement strip was **output-only** (a
+ frame-pump callback with no preview consumer, since a master-width global overlay cannot
+ ride a per-element `Image`) and the marquee was paced at a fixed `140px/s` (~17s per
+ pass, so a 10s alert showed the text once). Now: a `tickerPreviewSink` publishes it to a
+ global `AlertTickerElement` in `PreviewPane.xaml` (mirroring `SocialBarElement`), and the
+ creator picks **Scroll / Flash / Solid** in the panel, with Scroll paced in *reads per
+ alert* (3 inside a 10s alert) rather than px/s. 15 new facts; suite 339/339.
- **TASK 47 — Alert box video** — **DONE 2026-09-26** (`TASKS/task-47-alert-videos.md`):
the TASK 43 alert box now plays a **video** on every alert — a shipped built-in mp4
(`Assets/alert-default.mp4`, stamped into the `Asset` table at startup) that the creator can
diff --git a/TASKS/task-47-alert-videos.md b/TASKS/task-47-alert-videos.md
index ff723f6..d5bbf0e 100644
--- a/TASKS/task-47-alert-videos.md
+++ b/TASKS/task-47-alert-videos.md
@@ -63,11 +63,21 @@
same `FadeDurationSeconds = 0.30` envelope → drain (StopClip + dispose, idle null
again). Ticker: `AlertTickerFrame` composes "Author — Kind · amount" from the live
message and scrolls it.
-- **`Services/Compositor/AlertTickerRenderer.cs`** — marquee strip: 1920×48 transparent
+- **`Services/Compositor/AlertTickerRenderer.cs`** — announcement strip: 1920×48 transparent
BGRA, text-pill rasterized once per text on the UI thread (cached, unpremultiplied),
- then pure byte-math per call (scroll `pos = elapsed × 140px/s % cycle`, pill drawn
- straight-alpha over transparent with a repeat-gap copy), so the pump can call it from
- its own thread every tick with no locking.
+ then pure byte-math per call, so the pump can call it from its own thread every tick
+ with no locking. Three display methods (`Source.AlertDisplayMethod`, creator-set in the
+ panel):
+ - `TickerScroll` — marquee. Paced in **reads per alert**, not px/s: one pass takes
+ `alertSeconds / TickerReadsPerAlert(3)`, and the speed is derived from that, so the
+ 10s default clip carries the announcement across three times. The old fixed
+ `140px/s` needed ~17s per pass, i.e. barely one sighting in a 10s alert.
+ - `Flash` — the static pill blinks `0.5s` on / `0.5s` off (1Hz) for the whole alert;
+ the off half returns null so the preview collapses too.
+ - `Solid` — the static pill centred and still for the whole alert.
+ The run is phase-started half a frame in so the first published frame isn't blank,
+ and the wrap copies in from the right the instant the pill clears the left (a
+ seamless loop — the frame never goes empty).
- **Ticker threading** — dynamic overlay like the social bar, but **never baked**
(the social bar IS baked into `BakeStaticBase`; the ticker is a per-frame marquee):
`SceneCompositor.Render` + `CompositeLayers` take a trailing `tickerFrame` param
@@ -81,7 +91,11 @@
it. The layer applies `volume × fade` per sample before forwarding.
- **UI — Stream Alerts section** (LeftPanel, visible only on an `AlertBox`):
"Built-in video" pill; custom path box + Browse…; "Reset to built-in"; "Message
- ticker" pill; "Volume" slider with % readout. Wiring: `BrowseAlertVideoCommand` +
+ ticker" pill; **"Display" selector (Scroll / Flash / Solid)**; "Volume" slider with %
+ readout. The selector is inline ``s bound by `SelectedValuePath="Tag"`,
+ the same shape as the chat Font selector in this panel — NOT a bound `ItemsSource`
+ (see RULE (8) in `MyMistakes.md`: a data-bound selection re-measures the panel and
+ breaks the real-mouse layer-drag test). Wiring: `BrowseAlertVideoCommand` +
`ResetAlertVideoCommand` (CanExecute gates on `Source { Type: AlertBox }`); browse =
OpenFileDialog (mp4/mov/webm), result writes `AlertVideoPath` + switches the pill off.
@@ -255,3 +269,43 @@ Two Good Dog tests, because the fakes had been hiding this:
the fix, green after.
Full suite 324/324.
+
+## Take four (2026-09-26) — the ticker was output-only, and unreadably paced
+
+The creator confirmed the clip fix ("the video plays now"), then asked two things:
+**"where is the scrolling text? I do not see it"** and whether ~3 reads inside the
+10-second default alert would be readable. Answer: yes, three is right — and the text
+was invisible for a structural reason, not a rendering fault.
+
+**Bug 1 — the strip had no consumer in the preview.** `AlertTickerFrame` existed only as
+a frame-pump callback, blitted into the OUTPUT at (0,0). `PreviewPane.xaml` had no
+element for it because the strip is master-width and global, not a `Source`, so it
+cannot ride a per-element `Image` (the trap RULE (5) records, one layer up — RULE (6)).
+Fix: `tickerPreviewSink` on the ctor + `AlertOverlayLayer` (UI-thread `DispatcherTimer`
+only, never the pump thread), the layer publishes from `RefreshAlertPreviews()`, and
+`MainViewModel` writes it into one reused `WriteableBitmap`
+(`AlertTickerImageSource` / `AlertTickerVisible`) bound to a new global
+`AlertTickerElement` in `PreviewPane.xaml`, mirroring `SocialBarElement`.
+
+**Bug 2 — a fixed px/s cannot express "three reads".** See the renderer note above.
+Incumbent convention agrees: Streamlabs exposes *Alert Duration* ("choose how long your
+alert stays on your stream") and *Text Delay*, never a scroll-speed slider
+(https://support.streamlabs.com/hc/en-us/articles/52499995174299-Setting-up-Your-Streamlabs-Alerts),
+so the unit is repeats-per-alert, not pixels-per-second.
+
+**Persistence** — `Source.AlertDisplayMethod INTEGER NOT NULL DEFAULT 0` via the
+idempotent `PRAGMA table_info` migration (no `user_version` bump: the guard is the
+column's presence). The column is appended **last** in the `SELECT` on purpose — the
+reader is POSITIONAL (`GetInt32(32..34)`), so inserting anywhere but the end would
+silently shift a neighbour rather than fail to load.
+
+**Tests** (`ytLive.Tests/AlertTickerPreviewDisplayTests.cs`, RealApp STA host):
+the pane draws the strip and collapses when the alert ends; the layer publishes a real
+1920x48 frame for all three methods (and nothing at all when the ticker is off); three
+passes are counted in 10s by the pill's leading edge resetting (a seamless marquee never
+blanks, so "blank frame" cannot count a pass); Solid is byte-identical at every moment;
+Flash is on for half of each second; the panel shows and writes back the chosen method.
+`LayoutStorePersistenceTests.AlertDisplayMethod_RoundTrips` guards the positional-reader
+risk by asserting all three alert fields together.
+
+Full suite 339/339.
diff --git a/ViewModels/MainViewModel.AlertTicker.cs b/ViewModels/MainViewModel.AlertTicker.cs
new file mode 100644
index 0000000..7026818
--- /dev/null
+++ b/ViewModels/MainViewModel.AlertTicker.cs
@@ -0,0 +1,61 @@
+using ytLive.Services;
+
+namespace ytLive.ViewModels;
+
+public partial class MainViewModel
+{
+ /// The alert announcement strip as the preview pane shows it: a
+ /// full-master-width bitmap, or null when no alert is playing (or a Flash is in
+ /// its off half). Mirrors the social bar's global-overlay pattern — the strip is
+ /// NOT a per-source frame, so it can't ride the alert box's
+ /// VideoImageSource.
+ private System.Windows.Media.Imaging.WriteableBitmap? _alertTickerBitmap;
+
+ public System.Windows.Media.Imaging.WriteableBitmap? AlertTickerImageSource
+ {
+ get => _alertTickerBitmap;
+ private set
+ {
+ _alertTickerBitmap = value;
+ OnPropertyChanged(nameof(AlertTickerImageSource));
+ }
+ }
+
+ public bool AlertTickerVisible => _alertTickerBitmap != null;
+
+ /// Publishes one ticker frame to the preview pane. Called on the UI
+ /// thread from the alert layer's own tick, so the WPF bitmap write is legal.
+ /// Reuses one and
+ /// overwrites its back buffer — a fresh bitmap every tick would churn the
+ /// render cache for no reason.
+ internal void PublishAlertTickerPreview(VideoFrame? frame)
+ {
+ if (frame == null)
+ {
+ if (_alertTickerBitmap != null)
+ {
+ AlertTickerImageSource = null;
+ OnPropertyChanged(nameof(AlertTickerVisible));
+ }
+ return;
+ }
+
+ if (_alertTickerBitmap == null
+ || _alertTickerBitmap.PixelWidth != frame.Width
+ || _alertTickerBitmap.PixelHeight != frame.Height)
+ {
+ AlertTickerImageSource = new System.Windows.Media.Imaging.WriteableBitmap(
+ frame.Width, frame.Height, 96, 96,
+ System.Windows.Media.PixelFormats.Bgra32, null);
+ OnPropertyChanged(nameof(AlertTickerVisible));
+ }
+
+ var target = _alertTickerBitmap!;
+ target.WritePixels(
+ new System.Windows.Int32Rect(0, 0, frame.Width, frame.Height),
+ frame.BgraPixels, frame.Stride, 0);
+ // The bitmap instance is unchanged, so the pane's Source binding still
+ // points at it — nudge it so WPF re-reads the back buffer this tick.
+ OnPropertyChanged(nameof(AlertTickerImageSource));
+ }
+}
diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs
index 33800ea..caa2758 100644
--- a/ViewModels/MainViewModel.cs
+++ b/ViewModels/MainViewModel.cs
@@ -316,7 +316,8 @@ public partial class MainViewModel : ViewModelBase
new AlertRenderer(),
clipDecoderFactory: AlertClipDecoderFor,
clipPathResolver: ResolveAlertClipPath,
- audioSink: _audioMixer.EnqueueAlertAudio);
+ audioSink: _audioMixer.EnqueueAlertAudio,
+ tickerPreviewSink: PublishAlertTickerPreview);
_rtmpUrlProvider = () => _reusableStreamUrl; // TASK 5: the reusable stream's ingest URL
_framePump = new FramePump(
diff --git a/ai.md b/ai.md
index f0bab3d..b73edb9 100644
--- a/ai.md
+++ b/ai.md
@@ -1630,16 +1630,34 @@ published decisions (recorded in `TASKS/task-43-native-alerts.md`):
EOF → freeze-frame → fade-out → drain (the clip branch of `Advance` **clears
`_current` before `AdvanceToNext`** — the miss that the TASK 47 test caught). Audio
forwards to the mixer sink scaled by `volume × fade`.
-- **Alert ticker:** `AlertTickerFrame` (in the layer) composes "Author — Kind · amount"
- and hands it to `Services/Compositor/AlertTickerRenderer.cs` — a 1920×48 marquee strip
- rasterized **on the UI thread once per text** (cached, unpremultiplied straight-alpha
- pill) then scrolled by pure byte-math per call (`140px/s`, repeat-gap wrap), so the
- pump calls it from its own thread with no locking. **It is a dynamic overlay, NEVER
- baked** (the social bar IS baked into `BakeStaticBase`): `SceneCompositor.Render` +
+- **Alert ticker (announcement strip):** `AlertTickerFrame` (in the layer) composes
+ "Author — Kind · amount" and hands it to `Services/Compositor/AlertTickerRenderer.cs` —
+ a 1920×48 strip rasterized **on the UI thread once per text** (cached, unpremultiplied
+ straight-alpha pill) then positioned by pure byte-math per call, so the pump calls it
+ from its own thread with no locking. **It is a dynamic overlay, NEVER baked** (the
+ social bar IS baked into `BakeStaticBase`): `SceneCompositor.Render` +
`CompositeLayers` take a trailing `tickerFrame` (blitted last at 0,0) threaded from
`FramePump._alertTicker` (`Func` ctor seam) through
`RenderScene`/`RenderFull` and **mixed into `BuildFullRenderSignature`** so a scroll
changes the cache key. Toggled by `AlertShowTicker`.
+ - **Three display methods** (`Source.AlertDisplayMethod`, DB column, panel "Display"
+ selector): `TickerScroll` (marquee), `Flash` (0.5s on / 0.5s off), `Solid` (centred,
+ still). The marquee is paced in **reads per alert** — `TickerReadsPerAlert = 3`
+ passes inside the alert's own length, speed derived from that — **never px/s**; the
+ old fixed `140px/s` took ~17s per pass, so a 10s alert showed the message once.
+ Same convention as the incumbent (Streamlabs: "Alert Duration" + "Text Delay", not a
+ scroll-speed slider). Flash's off half returns null.
+ - **A global overlay needs its own preview element, not a `Source`.** The strip is
+ master-width, so it can't ride a per-element `Image`; a frame-pump-only producer
+ reaches the stream and never the creator's preview. `AlertOverlayLayer` takes a
+ `tickerPreviewSink` and publishes from `RefreshAlertPreviews()` (UI thread only —
+ the pump thread must never touch a `WriteableBitmap`); the VM writes it into one
+ reused `WriteableBitmap` (`AlertTickerImageSource`/`AlertTickerVisible`) bound to a
+ global `AlertTickerElement` in `PreviewPane.xaml`, mirroring `SocialBarElement`.
+ - **Two measurement traps here:** a seamless marquee never goes blank (a wrapped copy
+ enters as the pill clears), so count a pass by the pill's leading edge resetting, not
+ by an empty frame; and the run is phase-started half a frame in, or the first
+ published frame is blank (the pill sits exactly off the right edge).
- **Alert audio under the master limiter:** `AudioMixer.EnqueueAlertAudio` writes a
dedicated 8s stereo-48k ring (`AlertBufferSeconds`), drained in `FillAndMix` before the
limiter and added at **unity — never ducked** (creator ruling: "don't lower my game
diff --git a/ytLive.Tests/AlertTickerPreviewDisplayTests.cs b/ytLive.Tests/AlertTickerPreviewDisplayTests.cs
new file mode 100644
index 0000000..389f144
--- /dev/null
+++ b/ytLive.Tests/AlertTickerPreviewDisplayTests.cs
@@ -0,0 +1,356 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Media;
+using Microsoft.Data.Sqlite;
+using Xunit;
+using ytLive.Models;
+using ytLive.Services;
+using ytLive.Services.Compositor;
+using ytLive.ViewModels;
+
+namespace ytLive.Tests;
+
+///
+/// TASK 47 Good Dog (take four, 2026-09-26): the alert announcement strip must be
+/// DRAWN by the preview pane, in all three display methods, at a pace that is
+/// actually readable.
+/// The bug: the strip is a global 1920x48 overlay that existed only as a frame-
+/// pump callback (alertTicker: () => _alertLayer.AlertTickerFrame), so it
+/// reached the stream and never the pane the creator watches — the same class of
+/// defect as the missing IsAlertBox trigger, one layer up: something is produced and
+/// consumed, but nothing ever displays it. The fix is a preview sink on the layer
+/// plus a global AlertTickerElement in PreviewPane.xaml.
+///
+[Collection("RealApp")]
+public sealed class AlertTickerPreviewDisplayTests
+{
+ private readonly RealAppHost _app;
+
+ public AlertTickerPreviewDisplayTests(RealAppHost app) => _app = app;
+
+ private const string TickerText = "Funder — Super Chat$10.00";
+ private const double ClipSeconds = 10.0;
+
+ // ---------- the pane actually draws the strip ----------
+
+ [Fact]
+ public void PreviewPane_DrawsTheTickerStrip_AndCollapsesWhenTheAlertEnds()
+ {
+ _app.Run(() =>
+ {
+ var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-test-{Guid.NewGuid():N}.db");
+ MainViewModel.LayoutPathOverride = tempDb;
+ var window = new MainWindow();
+ var vm = (MainViewModel)window.DataContext;
+ try
+ {
+ window.Show();
+ window.UpdateLayout();
+
+ var frame = AlertTickerRenderer.Render(
+ TickerText, 0.2, AlertDisplayMethod.TickerScroll, ClipSeconds);
+ Assert.NotNull(frame);
+
+ vm.PublishAlertTickerPreview(frame);
+ window.UpdateLayout();
+
+ var pane = (UserControl)window.FindName("PreviewPane")!;
+ var holder = Find(pane, e => e is FrameworkElement f
+ && f.Name == "AlertTickerElement") as FrameworkElement;
+ Assert.True(holder != null,
+ "PreviewPane.xaml has no AlertTickerElement — the strip is global, so it needs its own root, not a Source's Image");
+ Assert.Equal(Visibility.Visible, holder!.Visibility);
+
+ var image = Find(holder, e => e is Image) as Image;
+ Assert.True(image != null && image.Source != null,
+ "the alert ticker element never bound a bitmap, so the strip is never rendered");
+ Assert.IsType(image!.Source);
+
+ // The alert ends: the strip must go away again, not sit on top of
+ // the next scene forever.
+ vm.PublishAlertTickerPreview(null);
+ window.UpdateLayout();
+ Assert.Equal(Visibility.Collapsed, holder.Visibility);
+ }
+ finally
+ {
+ window.Close();
+ MainViewModel.LayoutPathOverride = null;
+ SqliteConnection.ClearAllPools();
+ try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
+ }
+ });
+ }
+
+ // ---------- the layer actually publishes to that sink ----------
+
+ [Fact]
+ public void AlertLayer_PublishesARealTickerFrameToThePreviewSink()
+ {
+ _app.Run(() =>
+ {
+ foreach (var method in new[]
+ {
+ AlertDisplayMethod.TickerScroll,
+ AlertDisplayMethod.Flash,
+ AlertDisplayMethod.Solid,
+ })
+ {
+ var published = new List();
+ var layer = new AlertOverlayLayer(new AlertRenderer(),
+ tickerPreviewSink: f => published.Add(f));
+ try
+ {
+ var box = new Source
+ {
+ Name = "Stream Alerts",
+ Type = SourceType.AlertBox,
+ AlertShowTicker = true,
+ AlertDisplayMethod = method,
+ };
+ var scene = new Scene { Name = "Live" };
+ scene.Elements.Add(box);
+
+ // The production entry from the chat feed.
+ layer.OnMessageReceived(new ChatMessage
+ {
+ AuthorName = "Funder",
+ Kind = ChatEventKind.SuperChat,
+ SuperChatDisplayString = "$10.00",
+ }, new[] { scene });
+
+ var frame = published.LastOrDefault();
+ Assert.True(frame != null, $"{method}: the layer published no ticker frame to the preview");
+ Assert.Equal(1920, frame!.Width);
+ Assert.Equal(48, frame.Height);
+ }
+ finally
+ {
+ layer.Dispose();
+ }
+ }
+ });
+ }
+
+ [Fact]
+ public void AlertLayer_TickerOff_StaysOffThePreviewSink()
+ {
+ _app.Run(() =>
+ {
+ var published = new List();
+ var layer = new AlertOverlayLayer(new AlertRenderer(),
+ tickerPreviewSink: f => published.Add(f));
+ try
+ {
+ var box = new Source
+ {
+ Name = "Stream Alerts",
+ Type = SourceType.AlertBox,
+ AlertShowTicker = false, // creator's existing "Message ticker" switch
+ AlertDisplayMethod = AlertDisplayMethod.Solid,
+ };
+ var scene = new Scene { Name = "Live" };
+ scene.Elements.Add(box);
+
+ layer.OnMessageReceived(new ChatMessage
+ {
+ AuthorName = "Funder",
+ Kind = ChatEventKind.SuperChat,
+ SuperChatDisplayString = "$10.00",
+ }, new[] { scene });
+
+ Assert.NotEmpty(published);
+ Assert.All(published, f => Assert.Null(f));
+ }
+ finally
+ {
+ layer.Dispose();
+ }
+ });
+ }
+
+ // ---------- the three methods behave as specified ----------
+
+ [Fact]
+ public void TickerScroll_CompletesThreeCrossingsInTheTenSecondAlert()
+ {
+ // The old constant (140px/s) needed ~17s per pass, so a 10s alert showed the
+ // message barely once, entering from the right and never crossing. This is
+ // the creator's "readable three times" requirement.
+ //
+ // The marquee is SEAMLESS — a new copy enters from the right the instant the
+ // old one clears the left — so the frame is never blank and "empty frame"
+ // can't count a pass. Count the pill's leading edge instead: it sweeps left
+ // across one pass, then snaps back to the right edge to start the next.
+ var resets = 0;
+ var previousEdge = -1;
+ for (var t = 0.0; t < ClipSeconds; t += 1.0 / 60.0)
+ {
+ var frame = AlertTickerRenderer.Render(
+ TickerText, t, AlertDisplayMethod.TickerScroll, ClipSeconds);
+ Assert.NotNull(frame);
+ var edge = LeftmostVisibleColumn(frame!);
+ Assert.True(edge >= 0, $"the marquee drew nothing at t={t:0.00}s");
+ if (previousEdge >= 0 && edge > previousEdge) resets++;
+ previousEdge = edge;
+ }
+
+ // Resets are the pass boundaries. The old fixed 140px/s produced zero of them
+ // inside a 10s alert (one pass took ~17s), which is the bug this pins.
+ Assert.True(resets >= 3,
+ $"expected at least three readable passes in {ClipSeconds}s, counted {resets}");
+ }
+
+ private static int LeftmostVisibleColumn(VideoFrame frame)
+ {
+ for (var x = 0; x < frame.Width; x++)
+ for (var y = 0; y < frame.Height; y++)
+ if (frame.BgraPixels[(y * frame.Stride) + (x * 4) + 3] != 0)
+ return x;
+ return -1;
+ }
+
+ [Fact]
+ public void TickerScroll_MidPassFrame_CarriesVisiblePixels()
+ {
+ // The empty frame at t=0 is the strip still sitting exactly on the right
+ // edge; a pass must actually put the announcement on screen.
+ var frame = AlertTickerRenderer.Render(
+ TickerText, 1.0, AlertDisplayMethod.TickerScroll, ClipSeconds);
+ Assert.NotNull(frame);
+ Assert.True(frame!.BgraPixels.Any(b => b != 0), "the marquee never drew anything");
+ }
+
+ [Fact]
+ public void Solid_IsIdenticalAtEveryMoment()
+ {
+ var first = AlertTickerRenderer.Render(TickerText, 0.0, AlertDisplayMethod.Solid, ClipSeconds);
+ var last = AlertTickerRenderer.Render(TickerText, 9.9, AlertDisplayMethod.Solid, ClipSeconds);
+ Assert.NotNull(first);
+ Assert.Equal(first!.BgraPixels, last!.BgraPixels);
+ }
+
+ [Fact]
+ public void Flash_IsOnForHalfOfEachSecond()
+ {
+ Assert.NotNull(AlertTickerRenderer.Render(TickerText, 0.2, AlertDisplayMethod.Flash, ClipSeconds));
+ Assert.Null(AlertTickerRenderer.Render(TickerText, 0.7, AlertDisplayMethod.Flash, ClipSeconds));
+ Assert.NotNull(AlertTickerRenderer.Render(TickerText, 1.2, AlertDisplayMethod.Flash, ClipSeconds));
+ }
+
+ [Fact]
+ public void Render_IgnoresEmptyTextAndDefaultsToScroll()
+ {
+ Assert.Null(AlertTickerRenderer.Render(null, 1.0));
+ Assert.Null(AlertTickerRenderer.Render(" ", 1.0));
+ }
+
+ // ---------- the properties panel offers the three methods ----------
+
+ [Theory]
+ [InlineData(AlertDisplayMethod.TickerScroll, "Scroll")]
+ [InlineData(AlertDisplayMethod.Flash, "Flash")]
+ [InlineData(AlertDisplayMethod.Solid, "Solid")]
+ public void PropertiesPanel_ShowsTheChosenDisplayMethod(AlertDisplayMethod method, string label)
+ {
+ _app.Run(() =>
+ {
+ var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-test-{Guid.NewGuid():N}.db");
+ MainViewModel.LayoutPathOverride = tempDb;
+ var window = new MainWindow();
+ var vm = (MainViewModel)window.DataContext;
+ try
+ {
+ window.Show();
+ var box = new Source
+ {
+ Name = "Stream Alerts",
+ Type = SourceType.AlertBox,
+ AlertDisplayMethod = method,
+ };
+ vm.StagedScene!.Elements.Add(box);
+ vm.SelectedElement = box;
+ window.UpdateLayout();
+
+ var panel = (UserControl)window.FindName("LeftPanel")!;
+ var combo = FindDisplayComboBox(panel);
+ Assert.True(combo != null,
+ "the alert properties panel has no display-method selector");
+ Assert.Equal(label, ((ComboBoxItem)combo!.SelectedItem!).Content);
+ }
+ finally
+ {
+ window.Close();
+ MainViewModel.LayoutPathOverride = null;
+ SqliteConnection.ClearAllPools();
+ try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
+ }
+ });
+ }
+
+ [Fact]
+ public void PropertiesPanel_WritesTheChosenDisplayMethodBackToTheModel()
+ {
+ _app.Run(() =>
+ {
+ var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-test-{Guid.NewGuid():N}.db");
+ MainViewModel.LayoutPathOverride = tempDb;
+ var window = new MainWindow();
+ var vm = (MainViewModel)window.DataContext;
+ try
+ {
+ window.Show();
+ var box = new Source
+ {
+ Name = "Stream Alerts",
+ Type = SourceType.AlertBox,
+ AlertDisplayMethod = AlertDisplayMethod.TickerScroll,
+ };
+ vm.StagedScene!.Elements.Add(box);
+ vm.SelectedElement = box;
+ window.UpdateLayout();
+
+ var panel = (UserControl)window.FindName("LeftPanel")!;
+ var combo = FindDisplayComboBox(panel)!;
+ var solid = combo.Items.OfType().Single(i => (string)i.Content == "Solid");
+
+ combo.SelectedItem = solid;
+ window.UpdateLayout();
+
+ // The selector is bound by string ("Solid" -> the enum), so prove the
+ // round trip rather than trusting the XAML.
+ Assert.Equal(AlertDisplayMethod.Solid, box.AlertDisplayMethod);
+ }
+ finally
+ {
+ window.Close();
+ MainViewModel.LayoutPathOverride = null;
+ SqliteConnection.ClearAllPools();
+ try { File.Delete(tempDb); } catch { /* best-effore cleanup */ }
+ }
+ });
+ }
+
+ /// The alert display selector is the ComboBox offering exactly
+ /// Scroll / Flash / Solid (the chat Font selector offers font names instead).
+ private static ComboBox? FindDisplayComboBox(DependencyObject parent)
+ => Find(parent, e => e is ComboBox c
+ && c.Items.OfType().Any(i => (string)i.Content == "Scroll")
+ && c.Items.OfType().Any(i => (string)i.Content == "Solid")) as ComboBox;
+
+ private static DependencyObject? Find(DependencyObject parent, Func predicate)
+ {
+ for (var i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
+ {
+ var child = VisualTreeHelper.GetChild(parent, i);
+ if (predicate(child)) return child;
+ var found = Find(child, predicate);
+ if (found != null) return found;
+ }
+ return null;
+ }
+}
diff --git a/ytLive.Tests/LayoutStorePersistenceTests.cs b/ytLive.Tests/LayoutStorePersistenceTests.cs
index 73e4a9f..d779c75 100644
--- a/ytLive.Tests/LayoutStorePersistenceTests.cs
+++ b/ytLive.Tests/LayoutStorePersistenceTests.cs
@@ -15,6 +15,55 @@ namespace ytLive.Tests;
///
public class LayoutStorePersistenceTests
{
+ /// TASK 47: the alert announcement display method must survive a
+ /// restart. This one is easy to get quietly wrong — the Source row is read back
+ /// by POSITIONAL index, so a column inserted anywhere but the end silently
+ /// shifts a neighbour (e.g. AlertVideoVolume) rather than failing to load.
+ /// 's neighbour settings are the
+ /// canary: all three alert fields are asserted together.
+ [Theory]
+ [InlineData(AlertDisplayMethod.TickerScroll)]
+ [InlineData(AlertDisplayMethod.Flash)]
+ [InlineData(AlertDisplayMethod.Solid)]
+ public void AlertDisplayMethod_RoundTrips(AlertDisplayMethod method)
+ {
+ var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db");
+ try
+ {
+ using (var store = new LayoutStore(path))
+ store.Save(new[]
+ {
+ new Scene
+ {
+ Name = SceneCatalog.Live,
+ Elements =
+ {
+ new Source
+ {
+ Name = "Stream Alerts",
+ Type = SourceType.AlertBox,
+ AlertShowTicker = true,
+ AlertVideoVolume = 0.25,
+ AlertDisplayMethod = method,
+ },
+ },
+ },
+ }, null, null);
+
+ using var reopened = new LayoutStore(path);
+ var box = reopened.Load().SelectMany(s => s.Elements).OfType()
+ .Single(s => s.Type == SourceType.AlertBox);
+ Assert.Equal(method, box.AlertDisplayMethod);
+ Assert.True(box.AlertShowTicker);
+ Assert.Equal(0.25, box.AlertVideoVolume, 3);
+ }
+ finally
+ {
+ SqliteConnection.ClearAllPools();
+ try { File.Delete(path); } catch { /* best-effort cleanup */ }
+ }
+ }
+
// The reusable stream cache (TASK 5): the RTMP URL must survive restarts so
// the frame pump has it the moment go-live starts, without a YouTube round-trip.
[Fact]