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]