TASK 47 take four: alert ticker visible in the preview + 3 display methods
The creator confirmed the clip fix ("the video plays now"), then asked where the
scrolling text was — it was invisible, for a structural reason. AlertTickerFrame
existed only as a frame-pump callback blitted 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 wrong in the renderer: there was no
consumer in the preview. Same class of defect as the missing IsAlertBox trigger, one
layer up (MyMistakes RULE 5/6).
- tickerPreviewSink on AlertOverlayLayer, published from RefreshAlertPreviews() so it
is always the UI thread; MainViewModel.AlertTicker writes it into one reused
WriteableBitmap bound to a new global AlertTickerElement, mirroring SocialBarElement.
- Source.AlertDisplayMethod + panel "Display" selector: TickerScroll / Flash / Solid.
Flash pulses 0.5s on / 0.5s off for the whole alert; Solid is centred and still.
- The marquee was also unreadable: a fixed 140px/s took ~17s per pass, so a 10s alert
showed the text once, entering from the right and never crossing. Paced in reads per
alert instead (TickerReadsPerAlert = 3 inside the alert's own length, speed derived
from it) — never px/s. Research (websearch: how do OBS/Streamlabs/StreamElements
alert boxes present announcement timing?) settled the unit: 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).
Run is phase-started half a frame in so the first frame isn't blank.
- Persistence: AlertDisplayMethod INTEGER NOT NULL DEFAULT 0 via the idempotent
table_info migration, appended LAST in the SELECT because the Source reader is
positional (GetInt32(32..34)) — a mid-list insert would silently shift a neighbour.
Tests: 15 new facts (suite 339/339). RealApp STA host: the pane draws the strip and
collapses at alert end; the layer publishes a real 1920x48 frame for all three methods
and nothing when the ticker is off; three passes counted in 10s by the pill's leading
edge resetting (a seamless marquee never blanks, so an empty frame cannot count a pass);
Solid byte-identical at every moment; Flash on for half of each second; the panel shows
and writes back the choice; the DB round-trips all three alert fields together.
Incidental finding: a bound ItemsSource ComboBox in LeftPanel.xaml broke
LayerReorderPersistenceTests.RealMouseDrag (that test injects PHYSICAL mouse input, so a
load-time re-measure moves the rows out from under the cursor). Rewritten as inline
ComboBoxItems, the shape the chat Font selector already uses in that panel. Recorded as
MyMistakes RULE (8).
This commit is contained in:
@@ -52,6 +52,7 @@ public sealed class AlertOverlayLayer : IDisposable
|
||||
private readonly Func<string, int, int, IAlertClipDecoder>? _clipDecoderFactory;
|
||||
private readonly Func<Source, string?>? _clipPathResolver;
|
||||
private readonly Action<AudioSample>? _audioSink;
|
||||
private readonly Action<VideoFrame?>? _tickerPreviewSink;
|
||||
private readonly Queue<ChatMessage> _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<Scene>? _scenes;
|
||||
|
||||
@@ -79,12 +81,14 @@ public sealed class AlertOverlayLayer : IDisposable
|
||||
AlertRenderer renderer,
|
||||
Func<string, int, int, IAlertClipDecoder>? clipDecoderFactory = null,
|
||||
Func<Source, string?>? clipPathResolver = null,
|
||||
Action<AudioSample>? audioSink = null)
|
||||
Action<AudioSample>? audioSink = null,
|
||||
Action<VideoFrame?>? 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
|
||||
}
|
||||
|
||||
/// <summary>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).</summary>
|
||||
/// 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).</summary>
|
||||
public VideoFrame? AlertTickerFrame
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_current == null || string.IsNullOrWhiteSpace(_tickerText)) return null;
|
||||
return AlertTickerRenderer.Render(_tickerText!, _elapsed);
|
||||
return AlertTickerRenderer.Render(_tickerText!, _elapsed, _tickerMethod, AlertSeconds());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>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).</summary>
|
||||
internal const int TickerReadsPerAlert = 3;
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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<Source>()
|
||||
.Where(s => s.Type == SourceType.AlertBox))
|
||||
|
||||
Reference in New Issue
Block a user