feat(alerts): add scene-local recent ticker
Model the AlertBox and standalone ticker with shared settings; scene tickers render the in-memory active and queued events without saving history. Keep AlertBox content active-event-only. Ref: https://obsproject.com/kb/obs-studio-overview
This commit is contained in:
+29
-3
@@ -82,6 +82,9 @@
|
||||
<MenuItem Header="Stream Alerts" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.AlertBox}"
|
||||
IsEnabled="{Binding CanAddAlerts}"
|
||||
ToolTip="Celebration zone for Super Chat & memberships — one alert layer at a time"/>
|
||||
<MenuItem Header="Ticker" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.Ticker}"
|
||||
IsEnabled="{Binding CanAddTicker}"
|
||||
ToolTip="Recent alert feed — one independently configured ticker per scene"/>
|
||||
<!-- Privacy blur: Live scene only (creator ruling 2026-11-01). Sits
|
||||
above the desktop and below the brand logo; several allowed. -->
|
||||
<MenuItem Header="Blur" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.Blur}"
|
||||
@@ -219,6 +222,9 @@
|
||||
<Path Data="M6,16 V11 A6,6 0 0 1 18,11 V16 L20,18 H4 Z M10,20 H14" Stroke="{StaticResource Brush.Tertiary}" StrokeThickness="1.6" StrokeLineJoin="Round" Stretch="Uniform">
|
||||
<Path.Style><Style TargetType="Path"><Setter Property="Visibility" Value="Collapsed"/><Style.Triggers><DataTrigger Binding="{Binding IsAlertBox}" Value="True"><Setter Property="Visibility" Value="Visible"/></DataTrigger></Style.Triggers></Style></Path.Style>
|
||||
</Path>
|
||||
<Path Data="M3,6 H21 M3,12 H21 M3,18 H21" Stroke="{StaticResource Brush.Tertiary}" StrokeThickness="1.6" Stretch="Uniform">
|
||||
<Path.Style><Style TargetType="Path"><Setter Property="Visibility" Value="Collapsed"/><Style.Triggers><DataTrigger Binding="{Binding IsTicker}" Value="True"><Setter Property="Visibility" Value="Visible"/></DataTrigger></Style.Triggers></Style></Path.Style>
|
||||
</Path>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Grid.Column="1" Text="{Binding Name}" VerticalAlignment="Center"
|
||||
@@ -817,9 +823,9 @@
|
||||
<Border Style="{StaticResource PropertyDivider}"/>
|
||||
<TextBlock Text="Ticker" Style="{StaticResource PropertyHeader}"/>
|
||||
<DockPanel Margin="10,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Message ticker" Style="{StaticResource PropertyFieldLabel}" Width="84"/>
|
||||
<TextBlock DockPanel.Dock="Left" Text="Current event" Style="{StaticResource PropertyFieldLabel}" Width="84"/>
|
||||
<ToggleButton Style="{StaticResource PillToggle}"
|
||||
IsChecked="{Binding SelectedElement.AlertShowTicker, Mode=TwoWay}"
|
||||
IsChecked="{Binding SelectedElement.Ticker.IsEnabled, Mode=TwoWay}"
|
||||
HorizontalAlignment="Left"/>
|
||||
</DockPanel>
|
||||
|
||||
@@ -828,7 +834,7 @@
|
||||
same shape as the chat Font selector above. -->
|
||||
<DockPanel Margin="10,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Display" Style="{StaticResource PropertyFieldLabel}" Width="84"/>
|
||||
<ComboBox SelectedValue="{Binding SelectedElement.AlertDisplayMethod, Mode=TwoWay}"
|
||||
<ComboBox SelectedValue="{Binding SelectedElement.Ticker.DisplayMethod, Mode=TwoWay}"
|
||||
SelectedValuePath="Tag"
|
||||
Style="{StaticResource YtComboBox}"
|
||||
Width="140" VerticalAlignment="Center">
|
||||
@@ -847,6 +853,26 @@
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Per-scene ticker layer: same Ticker settings/renderer as the
|
||||
ticker embedded in Stream Alerts, but it reads the recent event feed. -->
|
||||
<StackPanel Visibility="{Binding SelectedElement.IsTicker, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
||||
<TextBlock Text="Recent Alert Ticker" Style="{StaticResource PropertyHeader}"/>
|
||||
<TextBlock Text="Shows current and queued alert events. This ticker's settings are independent in each scene."
|
||||
Style="{StaticResource PropertyHint}"/>
|
||||
<CheckBox Content="Show recent alerts" Style="{StaticResource PropertyPillCheck}" Margin="0,2,0,4"
|
||||
IsChecked="{Binding SelectedElement.Ticker.IsEnabled, Mode=TwoWay}"/>
|
||||
<DockPanel Margin="10,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Display" Style="{StaticResource PropertyFieldLabel}" Width="84"/>
|
||||
<ComboBox SelectedValue="{Binding SelectedElement.Ticker.DisplayMethod, Mode=TwoWay}"
|
||||
SelectedValuePath="Tag" Style="{StaticResource YtComboBox}"
|
||||
Width="140" VerticalAlignment="Center">
|
||||
<ComboBoxItem Content="Scroll" Tag="TickerScroll"/>
|
||||
<ComboBoxItem Content="Flash" Tag="Flash"/>
|
||||
<ComboBoxItem Content="Solid" Tag="Solid"/>
|
||||
</ComboBox>
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Background properties -->
|
||||
<StackPanel Margin="0,8,0,0"
|
||||
Visibility="{Binding SelectedElement.IsBackground, Converter={StaticResource BoolToVis}}">
|
||||
|
||||
@@ -152,6 +152,9 @@
|
||||
<DataTrigger Binding="{Binding IsAlertBox}" Value="True">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding IsTicker}" Value="True">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
</DataTrigger>
|
||||
<!-- The scoreboard strip is a frame-source too: without this trigger its
|
||||
Image stays collapsed and every property change looks inert. -->
|
||||
<DataTrigger Binding="{Binding IsScoreboard}" Value="True">
|
||||
|
||||
@@ -17,6 +17,10 @@ See [`schema.md`](../schema.md) for the memory-map conventions.
|
||||
| `SceneThumbnailStrip.xaml.cs` | Code-behind for the strip above |
|
||||
| `LeftPanel.xaml.cs` | Code-behind for the panel above |
|
||||
|
||||
The Add Layer menu exposes one standalone recent-alert Ticker per scene. The
|
||||
properties panel edits its independent `Ticker` settings; Stream Alerts retains
|
||||
its separate current-event ticker controls.
|
||||
|
||||
## Window ↔ control contract
|
||||
|
||||
The shell (`MainWindow.xaml(.cs)`) wires up each control and routes the few
|
||||
|
||||
+18
-22
@@ -1,31 +1,27 @@
|
||||
# HANDOFF — 2026-11-02 (Steam ownership check)
|
||||
# HANDOFF — 2026-11-02 (scene-local alert ticker)
|
||||
|
||||
## State
|
||||
|
||||
- `main`; local work unit, not pushed.
|
||||
- Distribution plan: one paid Steam app ($79 announced launch price, then $99) plus its associated
|
||||
free Demo. Same code; no subscription, DLC purchase, or GitHub binary.
|
||||
- `IEntitlement.IsAppOwned` feeds the app-wide `MainViewModel.IsAppOwned` flag. Debug uses a fake
|
||||
owned result; the fake is excluded from Release.
|
||||
- Release ownership uses `BIsSubscribedApp(paidAppId)` and fails closed when the ID or Steam is
|
||||
unavailable. **The paid app ID is not configured yet; the Steamworks application is pending.**
|
||||
- Steam's private-use clause is not, by itself, a proven blocker. EULA wording should expressly
|
||||
allow commercial/monetized streaming; OBS is the relevant Steam software precedent. See
|
||||
`MyMistakes.md` → Steam distribution.
|
||||
- `main`; ticker work is local and not pushed.
|
||||
- Standalone `SourceType.Ticker` is addable once per scene, has per-source settings, and shows the
|
||||
in-memory current + queued alert feed. It does not persist event history.
|
||||
- Stream Alerts keeps one box per layout; its ticker shares `Ticker` settings but shows only the
|
||||
active event. Existing per-Source columns persist the settings without a schema migration.
|
||||
- Focused build succeeded; the new integration test and all `AlertTickerPreviewDisplayTests` pass.
|
||||
|
||||
## Next action
|
||||
|
||||
Day 1 next: system-message strip. The queued Debug-only test-access rule also needs checking. Keep
|
||||
one integration test per change.
|
||||
Next queued unit: Day 1 system-message strip; also check the queued Debug-only test-access rule.
|
||||
Keep one integration test per change. Do not push absent a milestone/checkpoint request.
|
||||
|
||||
## Landmines
|
||||
|
||||
- The Demo has its own App ID. Ownership must check the paid app ID, never the Demo ID or test ID
|
||||
`480`.
|
||||
- No fake ownership code or developer panel in Release.
|
||||
- No Steam checkout in the app; Steam handles purchase and updates.
|
||||
- The paid app ID is not available yet; Release stays fail-closed until it is configured.
|
||||
- `verify.sh` clean app build passes; `EntitlementTests` passes 5/5. Full suite currently has 13–14
|
||||
UI/content failures; connection to this change is unconfirmed, investigate separately.
|
||||
- Bundled FFmpeg must stay outside the single-file exe (`ExcludeFromSingleFile="true"`).
|
||||
- Build with Windows `dotnet.exe`; run tests per class (the whole suite can hang in WASAPI teardown).
|
||||
- Build/test with Windows `dotnet.exe`; the full suite can hang in WASAPI teardown.
|
||||
- Focused ticker tests pass; clean app build is 0 warnings. Full suite reports 13–14 UI/content
|
||||
failures (508/521 or 507/521 pass), matching the prior baseline note; none is a ticker test. Their
|
||||
relationship to this change is unconfirmed, and `verify.sh` currently only allows two other tests.
|
||||
- The integration assertion is in `ytLive.Tests/AlertTickerPreviewDisplayTests.cs` and must use
|
||||
`LayoutPathOverride`/clear it in `finally` if it constructs `MainWindow`.
|
||||
- Scene tickers are dynamic per-element sources; only the AlertBox ticker uses the pump's global
|
||||
ticker overlay callback.
|
||||
- Bundled FFmpeg stays outside the single-file exe (`ExcludeFromSingleFile="true"`).
|
||||
|
||||
+25
-10
@@ -21,7 +21,9 @@ public enum SourceType
|
||||
AlertBox,
|
||||
/// <summary>A privacy blur: blurs the frame beneath it inside a rect or ellipse. Live scene
|
||||
/// only, always below the brand logo, several allowed (strongest wins on overlap).</summary>
|
||||
Blur
|
||||
Blur,
|
||||
/// <summary>A scene-local feed of recent alert events.</summary>
|
||||
Ticker
|
||||
}
|
||||
|
||||
/// <summary>Distinguishes how a media source presents its content (TASK 21).</summary>
|
||||
@@ -66,6 +68,19 @@ public enum AlertDisplayMethod
|
||||
/// </summary>
|
||||
public class Source : SceneElement
|
||||
{
|
||||
private readonly Ticker _ticker = new();
|
||||
|
||||
public Source()
|
||||
{
|
||||
_ticker.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(Ticker.IsEnabled)) Raise(nameof(AlertShowTicker));
|
||||
if (e.PropertyName == nameof(Ticker.DisplayMethod)) Raise(nameof(AlertDisplayMethod));
|
||||
};
|
||||
}
|
||||
|
||||
public Ticker Ticker => _ticker;
|
||||
|
||||
private SourceType _type;
|
||||
public SourceType Type
|
||||
{
|
||||
@@ -80,6 +95,7 @@ public class Source : SceneElement
|
||||
Raise(nameof(IsWebSource));
|
||||
Raise(nameof(IsTextOverlay));
|
||||
Raise(nameof(IsScoreboard));
|
||||
Raise(nameof(IsTicker));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,18 +104,19 @@ public class Source : SceneElement
|
||||
/// and sizes it in the preview, so it needs the same selection box, grab and resize handle
|
||||
/// (creator ruling 2026-11-01).</summary>
|
||||
public override bool IsDraggable => Type is SourceType.Image or SourceType.WebSource or SourceType.ChatBox
|
||||
or SourceType.AlertBox or SourceType.Blur;
|
||||
or SourceType.AlertBox or SourceType.Blur or SourceType.Ticker;
|
||||
|
||||
/// <summary>Live captures, chat, web sources and media are dynamic; static art is fixed.</summary>
|
||||
public override ElementKind Kind => Type is SourceType.DisplayCapture or SourceType.WindowCapture
|
||||
or SourceType.ChatBox or SourceType.WebSource or SourceType.MediaSource or SourceType.AlertBox
|
||||
or SourceType.Blur
|
||||
or SourceType.Blur or SourceType.Ticker
|
||||
? ElementKind.Dynamic : ElementKind.Static;
|
||||
|
||||
public override bool IsWebSource => Type == SourceType.WebSource;
|
||||
public override bool IsTextOverlay => Type == SourceType.TextOverlay;
|
||||
|
||||
public override bool IsScoreboard => Type == SourceType.Scoreboard;
|
||||
public bool IsTicker => Type == SourceType.Ticker;
|
||||
|
||||
/// <summary>The permanent brand logo layer (one per scene, directly above the background).
|
||||
/// Locked against removal; its image comes from the App Settings brand logo.</summary>
|
||||
@@ -156,7 +173,7 @@ public class Source : SceneElement
|
||||
/// VideoImageSource, and never displayed (the same shape as the invisible-alert defect).</summary>
|
||||
private bool IsFrameSource =>
|
||||
Type is SourceType.ChatBox or SourceType.WebSource or SourceType.MediaSource
|
||||
or SourceType.AlertBox or SourceType.Scoreboard;
|
||||
or SourceType.AlertBox or SourceType.Scoreboard or SourceType.Ticker;
|
||||
|
||||
public override ImageSource? DisplaySource => IsLiveCapture
|
||||
? (VideoImageSource ?? _imageSource)
|
||||
@@ -259,11 +276,10 @@ public class Source : SceneElement
|
||||
set => Set(ref _alertUseDefaultVideo, value);
|
||||
}
|
||||
|
||||
private bool _alertShowTicker = true;
|
||||
public bool AlertShowTicker
|
||||
{
|
||||
get => _alertShowTicker;
|
||||
set => Set(ref _alertShowTicker, value);
|
||||
get => _ticker.IsEnabled;
|
||||
set => _ticker.IsEnabled = value;
|
||||
}
|
||||
|
||||
private double _alertVideoVolume = 1.0;
|
||||
@@ -295,11 +311,10 @@ public class Source : SceneElement
|
||||
/// <summary>True once a file is chosen — gates the loop switch and the clear button.</summary>
|
||||
public bool HasAlertAudio => !string.IsNullOrWhiteSpace(_alertAudioPath);
|
||||
|
||||
private AlertDisplayMethod _alertDisplayMethod = AlertDisplayMethod.TickerScroll;
|
||||
public AlertDisplayMethod AlertDisplayMethod
|
||||
{
|
||||
get => _alertDisplayMethod;
|
||||
set => Set(ref _alertDisplayMethod, value);
|
||||
get => _ticker.DisplayMethod;
|
||||
set => _ticker.DisplayMethod = value;
|
||||
}
|
||||
|
||||
public override bool IsAlertBox => Type == SourceType.AlertBox;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace ytLive.Models;
|
||||
|
||||
public sealed class Ticker : INotifyPropertyChanged
|
||||
{
|
||||
private bool _isEnabled = true;
|
||||
private AlertDisplayMethod _displayMethod = AlertDisplayMethod.TickerScroll;
|
||||
|
||||
public bool IsEnabled
|
||||
{
|
||||
get => _isEnabled;
|
||||
set => Set(ref _isEnabled, value);
|
||||
}
|
||||
|
||||
public AlertDisplayMethod DisplayMethod
|
||||
{
|
||||
get => _displayMethod;
|
||||
set => Set(ref _displayMethod, value);
|
||||
}
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
private void Set<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
|
||||
{
|
||||
if (EqualityComparer<T>.Default.Equals(field, value)) return;
|
||||
field = value;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -7,7 +7,8 @@ Plain data types. No logic beyond what a property can carry. See
|
||||
|------|---------|
|
||||
| `Scene.cs` | A scene: `Name`, `IsHidden`, `IsChatScene`, `IsEditing`, **`HasBackdrop`** (Live-only policy flag, default off — only the canonical Live scene has one, see `SceneCatalog`), `Elements` collection (images + webcam config), `WebcamConfig` accessor |
|
||||
| `SceneCatalog.cs` | The five canonical scenes (Starting/Live/BRB/Chat/Ending) — the product, by name: `All`, `IsCanonical`, `Is(name, canonical)` (case-insensitive trim), `IsChat`, `HasBackdrop(name)` (true only for Live). Drives the empty-DB seed, the "+" missing-scenes menu, and `MainViewModel.EnforceBackdropPolicy` |
|
||||
| `Source.cs` | A source: `SourceType` enum (DisplayCapture/WindowCapture/Background/Image/TextOverlay/ChatBox/Countdown/WebSource/MediaSource/**AlertBox (TASK 43 — the celebration zone, one per layout)** — **Webcam removed**), `X`/`Y`/`Width`/`Height`, `Opacity`, `IsEnabled`, `ImageSource` (static) / `VideoImageSource` (live frames), `DisplaySource` (`IsLiveCapture` → video, else static), `ClipShape` enum (Traditional/Round), `IsMirrored` + `MirrorScale`, `MirrorButtonText`, asset identity, `IsImageSource` (XAML binds this, never `Type`), **backdrop surface**: `IsBackdrop` (permanent bottom layer, never removable), `CaptureKey` (persisted `monitor:<n>`/`window:<hwnd>`/`picker:<name>`), `IsLiveCapture` (`Type` is Display/WindowCapture) |
|
||||
| `Source.cs` | A source: `SourceType` enum (DisplayCapture/WindowCapture/Background/Image/TextOverlay/ChatBox/Countdown/WebSource/MediaSource/**AlertBox (one per layout)**/**Ticker (one per scene)**/Blur), `X`/`Y`/`Width`/`Height`, `Opacity`, `IsEnabled`, `ImageSource` (static) / `VideoImageSource` (live frames), `DisplaySource` (`IsLiveCapture` → video, else static), `ClipShape` enum (Traditional/Round), `IsMirrored` + `MirrorScale`, `MirrorButtonText`, asset identity, `IsImageSource` (XAML binds this, never `Type`), **backdrop surface**: `IsBackdrop` (permanent bottom layer, never removable), `CaptureKey` (persisted `monitor:<n>`/`window:<hwnd>`/`picker:<name>`), `IsLiveCapture` (`Type` is Display/WindowCapture); each source owns `Ticker` settings, with AlertBox compatibility properties persisted on that source row |
|
||||
| `Ticker.cs` | Shared per-source ticker settings: `IsEnabled` and `DisplayMethod`; one independent instance for each scene ticker and the AlertBox ticker |
|
||||
| `SceneElement.cs` | Base for anything placeable in a scene: shared layout/clip/mirror/border surface, `virtual IsWebcam`/`virtual IsImageSource`/`virtual IsZoomLayer` (TASK 52); `ToggleClipShape` + persisted `RectWidth`/`RectHeight` (pre-Round rect so round→rect restores after reload); **TASK 57:** the geometry setters (`X`/`Y`/`Width`/`Height`) are `virtual` so a derived element can CONSTRAIN every write (the webcam clamps itself into the vertical tier's band) instead of every writer re-implementing the rule; `ClipShape` + `ToggleClipShape()` are `virtual` too, so a derived element can CONSTRAIN the shape (the Chat scene's vertical camera is rect-only); **TASK 59:** `IsRectShape`/`IsRoundShape` — the shape as a CHOICE (the properties panel shows Rect | Circle with Rect selected by default), each a settable bool whose setter flips the shape through `ToggleClipShape()` and always re-raises, so the UI snaps back when a write is refused; **TASK 63:** `BorderColorEntry` (the palette entry for the current border colour — hex + NAME, a nameless entry for a custom hex; setting it writes the hex back) and `BorderColorOptions` (the palette + that entry), both raised when `BorderColor` changes — the drop-down binds to them |
|
||||
| `VerticalCameraLayout.cs` | **TASK 57 (the "vv" feature):** the vertical (9:16) tier's camera rules as pure math — `TileWidth`/`TileHeight` (270×152 master px = 480×270 in the 1080×1920 output), `Inset` (32 master px from the band's outer edge), `IsVerticalTier(QualityOption)` (portrait tier), `AreaFor(band, bottom)` (the band splits into two 607×540 areas), `MaxSizeFor(band)`, `SpotFor(band, w, h, centreX, centreY)` (the six spots — the area and column the tile's **centre** falls in, inset, never outside the band) and `DefaultSpot` (bottom-right, the corner the 16:9 default webcam uses), `FillsArea(band, width)` (the camera is wide enough to fill a whole area = "enlarged") and `SizeFor(band, requestedWidth)` (the Chat camera's TWO sizes - the small tile or one whole 607×540 area — whichever is nearer the requested width, so the resize handle has two detents) |
|
||||
| `ZoomSceneElement.cs` | **Transient zoom box (TASK 52, creator-corrected 2026-10-08):** carries the box rect AND the anchored `CaptureX`/`CaptureY` (the fixed box/2 capture window set on `ActivateZoom`; moving the box never touches it). `IsZoomActive` flips `Kind` Static→Dynamic (leaves the baked base while active); never persisted (LayoutStore save skips non-Source/non-WebcamSceneConfig rows), never shown in the layers list or the element template (`IsZoomLayer` gates both). The compositor SKIPS the active element in every layer loop and draws its 2x content LAST (layer n+1) via `ZoomMagnifier`. |
|
||||
|
||||
@@ -58,6 +58,8 @@ public sealed class AlertOverlayLayer : IDisposable
|
||||
|
||||
private ChatMessage? _current;
|
||||
private Source? _currentBox;
|
||||
private sealed record TickerFeed(string Text, double Duration);
|
||||
private volatile TickerFeed? _tickerFeed;
|
||||
private IAlertClipDecoder? _clip;
|
||||
private VideoFrame? _latestClipFrame;
|
||||
private bool _clipEof;
|
||||
@@ -66,8 +68,6 @@ public sealed class AlertOverlayLayer : IDisposable
|
||||
private int _clipHeight;
|
||||
private bool _clipSawFrame;
|
||||
private bool _clipAudioHeard;
|
||||
private string? _tickerText;
|
||||
private AlertDisplayMethod _tickerMethod = AlertDisplayMethod.TickerScroll;
|
||||
private double _elapsed;
|
||||
private IEnumerable<Scene>? _scenes;
|
||||
|
||||
@@ -139,6 +139,7 @@ public sealed class AlertOverlayLayer : IDisposable
|
||||
BeginClip();
|
||||
}
|
||||
_contentVersion++;
|
||||
RefreshTickerFeed();
|
||||
}
|
||||
|
||||
/// <summary>Advance the playback clock (the ticker's beat — and the test seam;
|
||||
@@ -172,6 +173,7 @@ public sealed class AlertOverlayLayer : IDisposable
|
||||
_current = null;
|
||||
_elapsed = 0;
|
||||
AdvanceToNext();
|
||||
RefreshTickerFeed();
|
||||
}
|
||||
_contentVersion++;
|
||||
return;
|
||||
@@ -184,6 +186,7 @@ public sealed class AlertOverlayLayer : IDisposable
|
||||
_elapsed = 0;
|
||||
BeginClip();
|
||||
_contentVersion++;
|
||||
RefreshTickerFeed();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -193,6 +196,7 @@ public sealed class AlertOverlayLayer : IDisposable
|
||||
_current = null;
|
||||
_elapsed = 0;
|
||||
AdvanceToNext();
|
||||
RefreshTickerFeed();
|
||||
}
|
||||
_contentVersion++;
|
||||
}
|
||||
@@ -249,27 +253,55 @@ public sealed class AlertOverlayLayer : IDisposable
|
||||
/// <see cref="VideoFrame.OriginX"/>/<see cref="VideoFrame.OriginY"/> instead of
|
||||
/// a hardcoded 0. <b>No alert box in the scene means no ticker at all</b> — there
|
||||
/// is no longer a global position for it to live in.</para></summary>
|
||||
public VideoFrame? AlertTickerFrame
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_current == null || string.IsNullOrWhiteSpace(_tickerText)) return null;
|
||||
var box = _currentBox;
|
||||
if (box == null) return null;
|
||||
public VideoFrame? AlertTickerFrame => AlertBoxTickerFrame(_currentBox);
|
||||
|
||||
var w = (int)Math.Max(1, Math.Round(box.Width));
|
||||
var h = (int)Math.Max(1, Math.Min(
|
||||
Math.Round(box.Height), AlertTickerRenderer.Height));
|
||||
var frame = AlertTickerRenderer.Render(
|
||||
_tickerText!, _elapsed, _tickerMethod, AlertSeconds(),
|
||||
TickerReadsPerAlert, w, h);
|
||||
return frame == null
|
||||
? null
|
||||
: new VideoFrame(frame.Width, frame.Height, frame.BgraPixels)
|
||||
{
|
||||
Placement = ((int)Math.Round(box.X), (int)Math.Round(box.Y)),
|
||||
};
|
||||
public VideoFrame? AlertBoxTickerFrame(Source? alertBox)
|
||||
{
|
||||
if (_current == null || alertBox is not { Type: SourceType.AlertBox }) return null;
|
||||
|
||||
var w = (int)Math.Max(1, Math.Round(alertBox.Width));
|
||||
var h = (int)Math.Max(1, Math.Min(
|
||||
Math.Round(alertBox.Height), AlertTickerRenderer.Height));
|
||||
var frame = AlertTickerRenderer.Render(
|
||||
alertBox.Ticker, ComposeTicker(_current), _elapsed, AlertSeconds(),
|
||||
TickerReadsPerAlert, w, h);
|
||||
return frame == null
|
||||
? null
|
||||
: new VideoFrame(frame.Width, frame.Height, frame.BgraPixels)
|
||||
{
|
||||
Placement = ((int)Math.Round(alertBox.X), (int)Math.Round(alertBox.Y)),
|
||||
};
|
||||
}
|
||||
|
||||
public VideoFrame? RenderSceneTicker(Source tickerSource)
|
||||
{
|
||||
if (tickerSource.Type != SourceType.Ticker || !tickerSource.Ticker.IsEnabled)
|
||||
return null;
|
||||
|
||||
var feed = _tickerFeed;
|
||||
if (feed == null) return null;
|
||||
return AlertTickerRenderer.Render(
|
||||
tickerSource.Ticker,
|
||||
feed.Text,
|
||||
_elapsed,
|
||||
feed.Duration,
|
||||
TickerReadsPerAlert,
|
||||
(int)Math.Max(1, Math.Round(tickerSource.Width)),
|
||||
(int)Math.Max(1, Math.Round(tickerSource.Height)));
|
||||
}
|
||||
|
||||
private void RefreshTickerFeed()
|
||||
{
|
||||
if (_current == null)
|
||||
{
|
||||
_tickerFeed = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var messages = new[] { _current }.Concat(_queue).ToArray();
|
||||
_tickerFeed = new TickerFeed(
|
||||
string.Join(" • ", messages.Select(ComposeTicker)),
|
||||
messages.Sum(DurationOf));
|
||||
}
|
||||
|
||||
/// <summary>How many times the marquee must cross the frame per alert, so the
|
||||
@@ -354,17 +386,11 @@ public sealed class AlertOverlayLayer : IDisposable
|
||||
private void BeginClip()
|
||||
{
|
||||
StopClip();
|
||||
_tickerText = null;
|
||||
if (_current == null) return;
|
||||
|
||||
var box = _scenes?.SelectMany(s => s.Elements).OfType<Source>()
|
||||
.FirstOrDefault(s => s.Type == SourceType.AlertBox);
|
||||
_currentBox = box;
|
||||
if (box != null && box.AlertShowTicker)
|
||||
{
|
||||
_tickerText = ComposeTicker(_current);
|
||||
_tickerMethod = box.AlertDisplayMethod;
|
||||
}
|
||||
|
||||
if (box == null) return;
|
||||
StartSoundByte(box);
|
||||
@@ -552,6 +578,9 @@ public sealed class AlertOverlayLayer : IDisposable
|
||||
};
|
||||
UpdatePreview(alertBox);
|
||||
}
|
||||
foreach (var ticker in scenes.SelectMany(s => s.Elements).OfType<Source>()
|
||||
.Where(s => s.Type == SourceType.Ticker))
|
||||
UpdateTickerPreview(ticker);
|
||||
}
|
||||
|
||||
private void RefreshAlertPreviews()
|
||||
@@ -566,6 +595,27 @@ public sealed class AlertOverlayLayer : IDisposable
|
||||
foreach (var alertBox in _scenes.SelectMany(s => s.Elements).OfType<Source>()
|
||||
.Where(s => s.Type == SourceType.AlertBox))
|
||||
UpdatePreview(alertBox);
|
||||
foreach (var ticker in _scenes.SelectMany(s => s.Elements).OfType<Source>()
|
||||
.Where(s => s.Type == SourceType.Ticker))
|
||||
UpdateTickerPreview(ticker);
|
||||
}
|
||||
|
||||
private void UpdateTickerPreview(Source ticker)
|
||||
{
|
||||
var frame = RenderSceneTicker(ticker);
|
||||
if (frame == null)
|
||||
{
|
||||
ticker.VideoImageSource = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var bitmap = ticker.VideoImageSource;
|
||||
if (bitmap == null || bitmap.PixelWidth != frame.Width || bitmap.PixelHeight != frame.Height)
|
||||
{
|
||||
bitmap = new WriteableBitmap(frame.Width, frame.Height, 96, 96, PixelFormats.Bgra32, null);
|
||||
ticker.VideoImageSource = bitmap;
|
||||
}
|
||||
bitmap.WritePixels(new Int32Rect(0, 0, frame.Width, frame.Height), frame.BgraPixels, frame.Stride, 0);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -574,6 +624,7 @@ public sealed class AlertOverlayLayer : IDisposable
|
||||
_ticker?.Stop();
|
||||
_queue.Clear();
|
||||
_current = null;
|
||||
_tickerFeed = null;
|
||||
StopClip();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,15 @@ public static class AlertTickerRenderer
|
||||
/// (0.5s on, 0.5s off — a 1Hz pulse for the whole alert).</summary>
|
||||
public const double FlashHalfSeconds = 0.5;
|
||||
|
||||
public static VideoFrame? Render(Ticker ticker, string? text, double elapsedSeconds,
|
||||
double alertSeconds, int readsPerAlert, int width, int height)
|
||||
{
|
||||
if (ticker == null) throw new ArgumentNullException(nameof(ticker));
|
||||
return ticker.IsEnabled
|
||||
? Render(text, elapsedSeconds, ticker.DisplayMethod, alertSeconds, readsPerAlert, width, height)
|
||||
: null;
|
||||
}
|
||||
|
||||
private static readonly object Gate = new();
|
||||
private static readonly Dictionary<string, VideoFrame> StripCache = new();
|
||||
|
||||
@@ -179,4 +188,4 @@ public static class AlertTickerRenderer
|
||||
}
|
||||
return new VideoFrame(pillW, Height, pixels);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ public sealed class FramePump : IDisposable
|
||||
private readonly Action<string>? _log;
|
||||
private readonly Func<TimeSpan, CancellationToken, Task> _pacingDelay;
|
||||
private readonly Func<(VideoFrame? Frame, SocialBarPosition Position)>? _socialBar;
|
||||
private readonly Func<VideoFrame?>? _alertTicker;
|
||||
private readonly Func<Scene, VideoFrame?>? _alertTicker;
|
||||
private readonly Func<VideoFrame?>? _brandFlash;
|
||||
private readonly TransitionService? _transition;
|
||||
private VideoFrame? _lastOutput;
|
||||
@@ -138,7 +138,7 @@ public sealed class FramePump : IDisposable
|
||||
Action<string>? log = null,
|
||||
Func<TimeSpan, CancellationToken, Task>? pacingDelay = null,
|
||||
Func<(VideoFrame? Frame, SocialBarPosition Position)>? socialBar = null,
|
||||
Func<VideoFrame?>? alertTicker = null,
|
||||
Func<Scene, VideoFrame?>? alertTicker = null,
|
||||
Func<VideoFrame?>? brandFlash = null,
|
||||
TransitionService? transition = null,
|
||||
SceneGraph? sceneGraph = null)
|
||||
@@ -486,7 +486,7 @@ public sealed class FramePump : IDisposable
|
||||
}
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
VideoFrame? tickerFrame = _alertTicker?.Invoke();
|
||||
VideoFrame? tickerFrame = _alertTicker?.Invoke(scene);
|
||||
tickerTicks += sw.ElapsedTicks;
|
||||
sw.Restart();
|
||||
VideoFrame? flashFrame = _brandFlash?.Invoke();
|
||||
|
||||
+3
-3
@@ -13,7 +13,7 @@ External-facing logic: YouTube API, persistence. See
|
||||
| `ChatBoxRenderer.cs` | Chat overlay rasterizer (WPF FormattedText → BGRA `VideoFrame`), live rows + Super Chat/member styling |
|
||||
| `ChatOverlayLayer.cs` | **The chat component (Commit G):** owns the message buffer, `ChatBoxRenderer`, fade/mock timers, per-source preview renders, live `RenderFrame`; raster-on-change cache pattern (OBS text-source contract) |
|
||||
| `AlertRenderer.cs` | **The alert rasterizer (TASK 43, 2026-09-24):** renders one event's celebration animation into a BGRA `VideoFrame` — SIX DISTINCT animations (SuperChat slide-up/shine/count-up, SuperSticker scale-pop, NewMember drop-in/flash-band, MemberGift slide-left/chip-fan, GiftReceived confetti scale-in, MemberMilestone rise/growth-bar), enter/hold/exit phases, brand line on every card. **Still the fallback** when no alert clip is playable (TASK 47) |
|
||||
| `AlertOverlayLayer.cs` | **The native alert-box component (TASK 43; video branch TASK 47):** event queue (cap 10) + 33ms dispatch ticker + `RenderFrame` cache-first live path + `UpdatePreview`; **`Advance(double)` is the deterministic test clock** (tests never start the timer); `ChatEventKind.None` rows (sub mentions) never enqueue; idle = transparent (null frame). Since TASK 47 a clip branch owns `IAlertClipDecoder` per play — ctor seams `(clipDecoderFactory, clipPathResolver, audioSink)`, fade in/out over `FadeDurationSeconds = 0.30` (straight-alpha copies + volume×fade audio), EOF freeze-frame → drain, and `AlertTickerFrame` composes the scrolling message strip |
|
||||
| `AlertOverlayLayer.cs` | **The native alert-box component (TASK 43; video branch TASK 47):** event queue (cap 10) + 33ms dispatch ticker + `RenderFrame` cache-first live path + `UpdatePreview`; **`Advance(double)` is the deterministic test clock** (tests never start the timer); `ChatEventKind.None` rows (sub mentions) never enqueue; idle = transparent (null frame). Since TASK 47 a clip branch owns `IAlertClipDecoder` per play — ctor seams `(clipDecoderFactory, clipPathResolver, audioSink)`, fade in/out over `FadeDurationSeconds = 0.30` (straight-alpha copies + volume×fade audio), EOF freeze-frame → drain. The shared ticker renderer serves the AlertBox's active event and scene-local `SourceType.Ticker` layers' current+queued feed (transient; no history) |
|
||||
| `AlertClipDecoder.cs` | **Per-play alert video decoder (TASK 47):** `IAlertClipDecoder` (Start/Stop/Dispose + `FrameAvailable`/`AudioReady`/`Completed`) + ffmpeg child emitting `rawvideo bgra` (`-vf scale=W:H`, read via `RawVideoFrameReader`) and `f32le -ar 48000 -ac 2` on a **carry-buffer** audio loop (float boundary straddles never drop samples), both real-time paced; bgra is opaque (layer owns alpha). One-shot lifetime — created per alert, disposed at drain; tests inject a fake |
|
||||
| `LayoutStore.cs` | SQLite persistence (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; assets stored as BLOBs keyed by SHA-256 content hash; save/open layout files; schema `user_version` 10 (`Source.ClipShape`/`IsMirrored` via `ALTER TABLE` for pre-v2 DBs; v3 = singleton `Webcam` + per-scene `WebcamSceneConfig`, migrated idempotently **without backfill** — the stale `Source.DeviceId` column remains but is no longer read/written; v4 = `WebcamSceneConfig.RectWidth`/`RectHeight`, the pre-Round rect for the round-to-rect restore; v5 = `Source.IsBackdrop` + `Source.CaptureKey`, the live-capture backdrop; v6 = `Scene.HasBackdrop` — Live-only policy, one-time backfill turns Starting/BRB/Chat/Ending off + drops their backdrop sources; v9→v10 (TASK 39) = `YppSnapshot` history table; all later columns stay additive guarded ALTERs — TASK 51 adds `WebcamSceneConfig.BackgroundEffect`/`BackgroundStrength`; **TASK 57 (the "vv" feature) adds the seven `WebcamSceneConfig.Vertical*` columns** (`VerticalX`/`VerticalY`/`VerticalWidth`/`VerticalHeight`/`VerticalClipShape`/`VerticalRectWidth`/`VerticalRectHeight` — the vertical tier's own camera placement per scene; NULL = the scene keeps its 16:9 placement. The X/Y/W/H/ClipShape columns stay the **16:9** layout: while the vertical tier is live the save writes the model's parked `Landscape*` values there and the live ones into the `Vertical*` columns); TASK 47 adds the five alert `Source` columns (`AlertVideoAssetId`/`AlertVideoPath`/`AlertUseDefaultVideo`/`AlertShowTicker`/`AlertVideoVolume`) without a version bump; `MainViewModel.NormalizeBackgrounds` re-normalizes on every load — TASK 25: one locked "Background" per canonical screen (static art everywhere, DisplayCapture on Live), duplicates dropped, index 0, orphaned `BackgroundUseDefault_*`/`BackgroundPath_*` Settings keys purged at save). Settings key/value table holds app state: `MicSourceName`, `ReusableStream` (TASK 9 — the cached reusable stream's id/address/name so the pump has its RTMP URL at startup), `AlertDefaultVideoAssetId` (TASK 47 — the stamped built-in alert clip's asset row; save's prune exempts it via UNION) |
|
||||
| `VideoFrame.cs` | Normalized CPU frame seam (`Width`/`Height`/tightly-packed BGRA `byte[]`) — the only pixel type the rest of the app knows about; future capture sources (screen, background-removed webcam) feed the same seam |
|
||||
@@ -49,7 +49,7 @@ External-facing logic: YouTube API, persistence. See
|
||||
| `Encoder/IFfmpegLocator.cs` | **TASK 4 ship step 2 seam**: resolves an absolute path to `ffmpeg.exe` (PATH first → cached `%APPDATA%\ytLlive\tools\ffmpeg.exe` → pinned BtbN LGPL-shared zip download). The encoder step and tests fake it |
|
||||
| `SocialValidator.cs` | `ISocialValidator` seam + `HttpSocialValidator` default: validates a social handle/URL by constructing the canonical profile URL and issuing an HTTP GET — the "act of validation" before adding to the bar. 200/redirect = exists; 404/connection failure = rejected. Fediverse `@user@domain` additionally does a best-effort nodeinfo lookup (`/.well-known/nodeinfo` → `software.name`) so the entry can show the instance's real logo; nodeinfo failure still validates (generic fediverse glyph). If the identity domain's nodeinfo is blocked (SSO) but the bare root 302s to the real instance (YunoHost default-app subdomains), the lookup follows the redirect and asks that host. Since the bar bug-fix branch the resolution **probes well-known subdomains** (`mastodon.`, `social.`, ... order in `FediverseSubdomainCandidates`) when both the identity domain and its redirect come up empty, bounded by a ~15s linked-CTS budget; `ResolveFediverseSoftwareAsync(domain, ct)` on the seam powers the load-time heal. Constructor takes optional `HttpClient` + `Action<string>` log for tests |
|
||||
| `Compositor/SocialBarRenderer.cs` | **Social bar output strip** (bar bug-fix branch): rasterizes the global social bar into a transparent straight-alpha BGRA8 `VideoFrame` strip (1920 wide, 40px content + 24px glow pad) via `RenderTargetBitmap` — one Path (logo) + one TextBlock (handle) per entry, white on transparent with the preview's green `#2ecc71` glow baked in (Pbgra32 → straight-alpha unpremultiply). UI thread only; the frame is immutable afterwards so the `FramePump` reads it from its own thread |
|
||||
| `Compositor/AlertTickerRenderer.cs` | **Alert message ticker strip (TASK 47):** marquee overlay rendered per call by pure byte-math onto a 1920×48 transparent BGRA frame — the text pill ("Author — Kind · amount") is rasterized once per text on the UI thread (cached, unpremultiplied straight alpha) and scrolled at 140px/s with a repeat-gap wrap, so the pump can call `Render(elapsed, text)` from its own thread without locking. **Dynamic overlay — never baked** (unlike the social bar) |
|
||||
| `Compositor/AlertTickerRenderer.cs` | **Shared ticker renderer (TASK 47):** marquee frame rendered per call by pure byte-math; the text pill is rasterized once per text on the UI thread (cached, unpremultiplied straight alpha). Supports AlertBox active-event tickers and ordinary scene-local ticker sources, with per-source `Ticker` settings. **Dynamic output — never baked** (unlike the social bar) |
|
||||
| `Encoder/EncoderOptions.cs` | One go-live's encoder config: full `RtmpUrl` (ingest + stream key), W×H/FPS/bitrate from the quality tier, `VideoEncoder` (null = probe), audio sample rate/channels, `GopSize` = FPS×4 (the ≤4s keyframe bound) |
|
||||
| `Encoder/IFfmpegEncoder.cs` | **TASK 4 ship step 3 seam**: start/feed/stop the live encoder; `HealthUpdated` (parsed `StreamHealth`), `ProcessFailed` on unexpected non-zero exit. Constructor-injected locator + process factory (tests fake both) |
|
||||
| `Encoder/FfmpegEncoder.cs` | The default encoder: spawn `ffmpeg.exe` (probe `-encoders` first → hardware NVENC/QSV/AMF, OpenH264 fallback — never libx264), feed raw BGRA frames into stdin, parse `-stats` lines into `StreamHealth`, graceful stop via stdin EOF + 10s kill watchdog. Constructed by `MainViewModel` since ship step 5 (`new FfmpegEncoder(new FfmpegLocator())`), driven by the `FramePump` |
|
||||
@@ -58,7 +58,7 @@ External-facing logic: YouTube API, persistence. See
|
||||
| `Encoder/FfmpegArgs.cs` | Pure FFmpeg command-line builder: rawvideo `pipe:0` input, `anullsrc` silent audio (WASAPI replaces it), H.264+AAC, closed GOP (`-g fps×4 -keyint_min -sc_threshold 0 -bf 0`, `yuv420p`), FLV → RTMP |
|
||||
| `Encoder/FfmpegProgressParser.cs` | Pure parser for `frame=/fps=/size=/time=/bitrate=` stats lines → `FfmpegProgress` |
|
||||
| `Encoder/FfmpegEncoderPicker.cs` | Pure H.264 encoder picker from `-encoders` output: NVENC → QSV → AMF → OpenH264; **never returns libx264** (GPL) |
|
||||
| `Encoder/FramePump.cs` | **TASK 4 ship step 5**: the live frame producer — while live it snapshots the active scene each tick, resolves every element to its latest frame (`Func<SceneElement, VideoFrame?>` resolver), composites it into the tier's output frame, and paces frames into the encoder at the tier's FPS. All collaborators constructor-injected seams; free of WPF and the capture managers. `StartAsync` never throws (failures log + `Failed`); no RTMP URL = encoder skipped; `StopAsync` stops the encoder before awaiting the loop (backpressure deadlock); `ProcessFailed` self-stops. Since the bar bug-fix branch it takes an optional `socialBar: Func<(VideoFrame? Frame, SocialBarPosition Position)>?` seam — a pre-rendered bar strip composited last (above the flash) at the top edge or `SourceRectHeight − bar height`. **TASK 47:** adds an `alertTicker: Func<VideoFrame?>?` ctor seam (parallel to `socialBar`), read per tick and threaded through `RenderScene`/`RenderFull` + **mixed into `BuildFullRenderSignature`** so the ticker scroll changes the cache key. See [`docs/ai/frame-pipeline.md`](../docs/ai/frame-pipeline.md) → "Live frame pipeline". **2026-10-16:** owns a `CompositeTimings` collector, passes it through `RenderScene`/`RenderFull`/`GetBakedBase`/`CompositeLayers`, logs `FramePump parts (N renders/5s): …` (per-stage breakdown, slowest first) beside the stats line each 5s window, and dumps the scene shape — `FramePump scene dump:` header + one line per element (z-index, name, kind, type, visibility, rect, opacity, clip) — at pump start and on every scene change |
|
||||
| `Encoder/FramePump.cs` | **TASK 4 ship step 5**: the live frame producer — while live it snapshots the active scene each tick, resolves every element to its latest frame (`Func<SceneElement, VideoFrame?>` resolver), composites it into the tier's output frame, and paces frames into the encoder at the tier's FPS. All collaborators constructor-injected seams; free of WPF and the capture managers. `StartAsync` never throws (failures log + `Failed`); no RTMP URL = encoder skipped; `StopAsync` stops the encoder before awaiting the loop (backpressure deadlock); `ProcessFailed` self-stops. Since the bar bug-fix branch it takes an optional `socialBar: Func<(VideoFrame? Frame, SocialBarPosition Position)>?` seam — a pre-rendered bar strip composited last (above the flash) at the top edge or `SourceRectHeight − bar height`. **TASK 47 + scene ticker:** the `alertTicker: Func<Scene, VideoFrame?>?` seam selects the active scene's AlertBox ticker, threaded through `RenderScene`/`RenderFull` + **mixed into `BuildFullRenderSignature`**; standalone tickers are resolved as per-element dynamic sources. See [`docs/ai/frame-pipeline.md`](../docs/ai/frame-pipeline.md) → "Live frame pipeline". **2026-10-16:** owns a `CompositeTimings` collector, passes it through `RenderScene`/`RenderFull`/`GetBakedBase`/`CompositeLayers`, logs `FramePump parts (N renders/5s): …` (per-stage breakdown, slowest first) beside the stats line each 5s window, and dumps the scene shape — `FramePump scene dump:` header + one line per element (z-index, name, kind, type, visibility, rect, opacity, clip) — at pump start and on every scene change |
|
||||
| `Encoder/FfmpegLocator.cs` | The default locator: PATH probe wins, then the cache, then pull+extract (`ffmpeg.exe` **+ the `libav*.dll` family** via a staging dir, failures via `AppLog`). Pinned to BtbN `autobuild-2026-08-09-13-03` **lgpl-shared** (dynamic linking = LGPL compliance without static-relink §6 material; no GPL libx264; NVENC/QSV/AMF + libopenh264 + native AAC — see `ai.md` → Licensing). Search dirs / tools dir / downloader constructor-injected for hermetic tests. Wired into the encoder since ship step 5 |
|
||||
| `INotificationService.cs` | **Toast seam (TASK 24)**: `Show(title, message, AppNotificationSeverity)` + `AppNotificationSeverity { Info, Success, Warning, Error }` + `Info/Success/Warning/Error` extension methods — non-blocking user-facing messages; VMs/services raise through this so failures surface without MessageBox blocking the UI thread |
|
||||
| `NotificationService.cs` | The default seam impl (Notification.Wpf 11.0.0, MIT): wraps a library `NotificationManager`; routes to the in-window `NotificationArea` named `ToastArea` (MainWindow bottom-right above the footer); lifetimes via the pure `BuildRequest` mapping + `Cards` tint table — Info/Success ~4s, Warning ~8s auto-dismiss, Error sticky (`NeverExpires()` = `TimeSpan.MaxValue`, never null); dark card tints matching the health-banner language; all Show calls marshal to the captured Dispatcher. See [`../ai.md`](../ai.md) "Toast notifications" |
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
> the creator picks their own clip (referenced from disk, never stored in the DB), the
|
||||
> clip fades in/out in ~0.3s (alpha envelope on the video + volume on the audio, mixed
|
||||
> into the live stream at unity — no duck), and an auto-composed **message ticker**
|
||||
> ("Funder — Super Chat · $10.00") scrolls across the very top of the frame, toggleable.
|
||||
> ("Funder — Super Chat · $10.00") appears inside the Stream Alerts box, toggleable.
|
||||
> The six TASK 43 animations remain the intrinsic fallback when no asset is playable.
|
||||
|
||||
## Provenance (recorded per the feature-provenance rule)
|
||||
@@ -25,8 +25,9 @@
|
||||
read-at-play clip lets the creator re-edit the file without a re-import). Decided
|
||||
with the creator: "never store the video in the layout." The built-in default is the
|
||||
one BLOB, stamped into the internal `Asset` table at startup.
|
||||
- **Creator rulings (asked once, held):** custom video + six-animation fallback
|
||||
(not either/or); ticker on top of the canvas (very top, full width); **no ducking** —
|
||||
- **Original creator rulings (asked once, held):** custom video + six-animation fallback
|
||||
(not either/or); ticker on top of the canvas (the placement was later corrected to
|
||||
inside Stream Alerts); **no ducking** —
|
||||
"don't lower my game audio for a tip" (the TASK 43 planner's auto-duck idea was
|
||||
rejected on the floor); a per-alert **Volume** slider instead.
|
||||
|
||||
@@ -309,3 +310,18 @@ Flash is on for half of each second; the panel shows and writes back the chosen
|
||||
risk by asserting all three alert fields together.
|
||||
|
||||
Full suite 339/339.
|
||||
|
||||
## Scene-local recent-alert ticker
|
||||
|
||||
The standalone `SourceType.Ticker` layer is addable once per scene. It shares the
|
||||
`Models/Ticker.cs` settings and `AlertTickerRenderer` with the AlertBox ticker, but
|
||||
each source has an independent `Ticker` object. The existing per-Source ticker DB
|
||||
columns persist both kinds without an alert-history table or schema migration. The
|
||||
standalone feed renders the active event followed by queued events; completed events
|
||||
are discarded. The AlertBox ticker remains limited to the active event and uses the
|
||||
active scene's AlertBox settings. Standalone tickers are ordinary dynamic sources in
|
||||
preview and output; the AlertBox ticker remains a positioned compositor overlay.
|
||||
|
||||
The integration guard in `AlertTickerPreviewDisplayTests` uses two scene tickers to
|
||||
prove settings are independent, then checks that the standalone feed differs from
|
||||
the AlertBox's active-only message while a second event is queued.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Linq;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
|
||||
namespace ytLive.ViewModels;
|
||||
@@ -44,8 +46,15 @@ public partial class MainViewModel
|
||||
/// Reuses one <see cref="System.Windows.Media.Imaging.WriteableBitmap"/> and
|
||||
/// overwrites its back buffer — a fresh bitmap every tick would churn the
|
||||
/// render cache for no reason.</summary>
|
||||
internal void PublishAlertTickerPreview(VideoFrame? frame)
|
||||
internal void PublishAlertTickerPreview(VideoFrame? incomingFrame)
|
||||
{
|
||||
var frame = incomingFrame;
|
||||
var previewScene = IsEditMode ? StagedScene : LiveScene ?? StagedScene;
|
||||
var alertBox = previewScene?.Elements.OfType<Source>()
|
||||
.FirstOrDefault(source => source.Type == SourceType.AlertBox && source.IsVisible);
|
||||
if (_alertLayer.IsPlaying)
|
||||
frame = _alertLayer.AlertBoxTickerFrame(alertBox);
|
||||
|
||||
if (frame == null)
|
||||
{
|
||||
if (_alertTickerBitmap != null)
|
||||
|
||||
@@ -44,6 +44,11 @@ public partial class MainViewModel : ViewModelBase
|
||||
=> StagedScene != null
|
||||
&& !Scenes.Any(s => s.Elements.OfType<Source>().Any(e => e.Type == SourceType.AlertBox));
|
||||
|
||||
/// <summary>A recent-alert ticker is an independent layer, one per scene.</summary>
|
||||
public bool CanAddTicker
|
||||
=> StagedScene != null
|
||||
&& !StagedScene.Elements.OfType<Source>().Any(e => e.Type == SourceType.Ticker);
|
||||
|
||||
/// <summary>Privacy blur is a Live-scene tool only (creator ruling 2026-11-01) — every other
|
||||
/// scene's art is authored, and there is no desktop there to hide.</summary>
|
||||
public bool CanAddBlur
|
||||
|
||||
@@ -55,6 +55,7 @@ public partial class MainViewModel
|
||||
OnPropertyChanged(nameof(CanChangeBackground));
|
||||
OnPropertyChanged(nameof(CanAddWebcam));
|
||||
OnPropertyChanged(nameof(CanAddYouTubeChat));
|
||||
OnPropertyChanged(nameof(CanAddTicker));
|
||||
OnPropertyChanged(nameof(CanShowWebcamInStagedScene));
|
||||
OnPropertyChanged(nameof(WebcamAddToolTip));
|
||||
OnPropertyChanged(nameof(IsCountdownAvailable));
|
||||
@@ -313,6 +314,7 @@ public partial class MainViewModel
|
||||
element.PropertyChanged -= OnElementPropertyChanged;
|
||||
OnPropertyChanged(nameof(CanAddWebcam));
|
||||
OnPropertyChanged(nameof(CanAddYouTubeChat));
|
||||
OnPropertyChanged(nameof(CanAddTicker));
|
||||
ScheduleSave();
|
||||
}
|
||||
|
||||
@@ -356,4 +358,4 @@ public partial class MainViewModel
|
||||
Scenes.Add(scene);
|
||||
StagedScene = scene;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ public partial class MainViewModel
|
||||
SourceType.WebSource => "Web Resource",
|
||||
SourceType.ChatBox => "YouTube Chat",
|
||||
SourceType.AlertBox => "Stream Alerts",
|
||||
SourceType.Ticker => "Ticker",
|
||||
SourceType.Scoreboard => "Scoreboard",
|
||||
_ => "Layer",
|
||||
};
|
||||
@@ -45,11 +46,12 @@ public partial class MainViewModel
|
||||
// The (+) menu only adds regular layers. The Background is a locked
|
||||
// per-screen layer managed by EnsureBackground/NormalizeBackgrounds —
|
||||
// never user-created, never deleted. YouTube Chat and Stream Alerts
|
||||
// are one-per-layout.
|
||||
// are one-per-layout; a ticker is one-per-scene.
|
||||
if (sourceType is SourceType.DisplayCapture or SourceType.WindowCapture or SourceType.Background)
|
||||
return;
|
||||
if ((sourceType == SourceType.ChatBox && !CanAddYouTubeChat)
|
||||
|| (sourceType == SourceType.AlertBox && !CanAddAlerts))
|
||||
|| (sourceType == SourceType.AlertBox && !CanAddAlerts)
|
||||
|| (sourceType == SourceType.Ticker && !CanAddTicker))
|
||||
return;
|
||||
|
||||
// Web resources use "Web Resource-N" naming (0-indexed per scene)
|
||||
@@ -73,11 +75,13 @@ public partial class MainViewModel
|
||||
: sourceType == SourceType.ChatBox ? 500
|
||||
: sourceType == SourceType.AlertBox ? 680
|
||||
: sourceType == SourceType.Scoreboard ? 1920
|
||||
: sourceType == SourceType.Ticker ? 1920
|
||||
: sourceType == SourceType.Blur ? 240 : 0,
|
||||
Height = sourceType == SourceType.WebSource ? 480
|
||||
: sourceType == SourceType.ChatBox ? 648
|
||||
: sourceType == SourceType.AlertBox ? 200
|
||||
: sourceType == SourceType.Scoreboard ? 56
|
||||
: sourceType == SourceType.Ticker ? 48
|
||||
: sourceType == SourceType.Blur ? 160 : 0,
|
||||
};
|
||||
|
||||
|
||||
@@ -405,7 +405,8 @@ public partial class MainViewModel : ViewModelBase
|
||||
encoderFactory: () => new FfmpegEncoder(new FfmpegLocator()),
|
||||
log: message => AppLog.Write(message),
|
||||
socialBar: () => (_socialBarFrame, _socials?.BarPosition ?? SocialBarPosition.Bottom),
|
||||
alertTicker: () => _alertLayer.AlertTickerFrame,
|
||||
alertTicker: scene => _alertLayer.AlertBoxTickerFrame(
|
||||
scene.Elements.OfType<Source>().FirstOrDefault(source => source.Type == SourceType.AlertBox && source.IsVisible)),
|
||||
brandFlash: BrandFlashFrame,
|
||||
transition: _transition,
|
||||
sceneGraph: _sceneGraph);
|
||||
@@ -639,6 +640,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
WebcamSceneConfig webcam => _cameraManager.GetLatestFrame(DeviceKeyForWebcam(webcam.WebcamId), EffectKeyFor(webcam)),
|
||||
Source { IsLiveCapture: true, CaptureKey: not null } live => _screenCaptureManager.GetLatestFrame(live.CaptureKey),
|
||||
Source { Type: SourceType.ChatBox } chatBox => RenderChatBox(chatBox),
|
||||
Source { Type: SourceType.Ticker } ticker => _alertLayer.RenderSceneTicker(ticker),
|
||||
Source { Type: SourceType.Scoreboard } => _scoreboardPresenter?.Frame,
|
||||
Source { Type: SourceType.AlertBox } alertBox => RenderAlertBox(alertBox),
|
||||
Source { Type: SourceType.WebSource } web => _webView2Manager?.GetLatestFrame(web.Id),
|
||||
|
||||
@@ -28,6 +28,9 @@ areas' docs in their own rows keeps this index small to read.
|
||||
| `MicPickerViewModel.cs` | Select Microphone dialog (voice source): async mic list (loading / has / none states), `UseRequested(MicrophoneDeviceInfo)`/`CancelRequested` |
|
||||
| `SocialsDialogViewModel.cs` | Social Media Site Promotion dialog (six fixed slots, `SocialSlotViewModel`): row 0 = YouTube sign-in gate (sign-in provider fills the channel, delete = sign-out); rows 1-5 validated entries / empty / freemium locks 2-5 (`isPremium` seam); `DetectService` → `ISocialValidator` on confirm (input snaps to text + real logo on success, red do-not + stays editable on failure); `CanSave` blocked while any slot edits/validates/errors; `BarEnabled` switch; commit via `CommittedEntries` + `BuildSocialsConfig()` (YouTube first) + `SaveRequested`/`CancelRequested`. WPF-free — the window owns the dialogs (delete confirm, sign-in gate) and calls `StartEdit`/`ConfirmEdit`/`DeleteSlotAsync`. |
|
||||
|
||||
| `MainViewModel.Sources.cs` | Source creation/removal; `AddSource` creates the standalone ticker layer with one-per-scene gating and its 1920×48 default geometry. |
|
||||
| `MainViewModel.AlertTicker.cs` | AlertBox ticker preview routing: selects the preview scene's AlertBox settings and publishes the positioned box-sized frame to PreviewPane. |
|
||||
|
||||
Related: [`Models/index.md`](../Models/index.md) for the data; [`Services/index.md`](../Services/index.md)
|
||||
for what the ViewModels drive; base class in [`Helpers/ViewModelBase.cs`](../Helpers/ViewModelBase.cs).
|
||||
|
||||
|
||||
+21
-11
@@ -98,16 +98,24 @@ 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 (announcement strip):** `AlertTickerFrame` (in the layer) composes
|
||||
- **Ticker settings and alert feed:** `Models/Ticker.cs` is shared by the
|
||||
AlertBox and standalone `SourceType.Ticker` layers. Each source owns a distinct
|
||||
settings instance (`IsEnabled` + `DisplayMethod`); the existing `AlertShowTicker`
|
||||
and `AlertDisplayMethod` columns persist those settings on their own Source row,
|
||||
so this is additive without a schema migration. The standalone ticker can be
|
||||
added once in each scene and shows the in-memory active event plus queued events.
|
||||
Completed events are not retained as history. The AlertBox ticker uses the same
|
||||
renderer/settings shape but shows only the active event.
|
||||
- **AlertBox ticker (announcement strip):** `AlertBoxTickerFrame` composes
|
||||
"Author — Kind · amount" and hands it to `Services/Compositor/AlertTickerRenderer.cs` —
|
||||
a 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` threaded from
|
||||
`FramePump._alertTicker` (`Func<VideoFrame?>` ctor seam) through
|
||||
`FramePump._alertTicker` (`Func<Scene, VideoFrame?>` ctor seam) through
|
||||
`RenderScene`/`RenderFull` and **mixed into `BuildFullRenderSignature`** so a scroll
|
||||
changes the cache key. Toggled by `AlertShowTicker`.
|
||||
changes the cache key. It reads the active scene's AlertBox settings.
|
||||
- **It draws INSIDE the Stream Alerts box (creator ruling 2026-09-26).** Was a global
|
||||
1920px bar pinned to the top edge: *"the ticker should appear over the stream alerts
|
||||
video, not over the entire preview window"*. The strip is now rendered at the
|
||||
@@ -135,14 +143,16 @@ published decisions (recorded in `TASKS/task-43-native-alerts.md`):
|
||||
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 ticker still needs its own preview element, not a `Source`.** It is a frame-pump
|
||||
producer, so it never rides a per-element `Image`; `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
|
||||
`AlertTickerElement` in `PreviewPane.xaml`, mirroring `SocialBarElement`. The rect
|
||||
is re-raised on **every** published frame, not just on a bitmap resize, so a box
|
||||
moved or resized mid-alert tracks live.
|
||||
- **AlertBox preview:** `tickerPreviewSink` publishes from
|
||||
`RefreshAlertPreviews()` (UI thread only — the pump thread must never touch a
|
||||
`WriteableBitmap`); the VM writes the box-sized frame into the reused bitmap bound
|
||||
to `AlertTickerElement` in `PreviewPane.xaml`. The rect is re-raised on every
|
||||
published frame so a box moved or resized mid-alert tracks live.
|
||||
- **Standalone ticker preview/output:** it is an ordinary dynamic Source with a
|
||||
per-element frame. `AlertOverlayLayer` updates each scene ticker's reusable
|
||||
`VideoImageSource` on its UI timer for preview; the output resolver renders the
|
||||
same source with that source's Ticker settings. It is not the AlertBox global
|
||||
ticker callback, so each scene's layer and settings remain independent.
|
||||
- **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
|
||||
|
||||
@@ -436,6 +436,68 @@ public sealed class AlertTickerPreviewDisplayTests
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SceneTickersKeepIndependentSettings_AndShowQueueWhileAlertBoxShowsActiveEvent()
|
||||
{
|
||||
_app.Run(() =>
|
||||
{
|
||||
const int width = 680;
|
||||
const int height = 48;
|
||||
var alertBox = new Source
|
||||
{
|
||||
Name = "Stream Alerts",
|
||||
Type = SourceType.AlertBox,
|
||||
Width = width,
|
||||
Height = height,
|
||||
};
|
||||
var tickerA = new Source
|
||||
{
|
||||
Name = "Ticker A",
|
||||
Type = SourceType.Ticker,
|
||||
Width = width,
|
||||
Height = height,
|
||||
};
|
||||
var tickerB = new Source
|
||||
{
|
||||
Name = "Ticker B",
|
||||
Type = SourceType.Ticker,
|
||||
Width = width,
|
||||
Height = height,
|
||||
};
|
||||
tickerA.Ticker.DisplayMethod = AlertDisplayMethod.Solid;
|
||||
tickerB.Ticker.DisplayMethod = AlertDisplayMethod.Flash;
|
||||
alertBox.Ticker.DisplayMethod = AlertDisplayMethod.Solid;
|
||||
var firstScene = new Scene { Name = "Starting", Elements = { alertBox, tickerA } };
|
||||
var secondScene = new Scene { Name = "BRB", Elements = { tickerB } };
|
||||
using var layer = new AlertOverlayLayer(new AlertRenderer(), tickerPreviewSink: _ => { });
|
||||
layer.UpdateConfiguredBoxes(new[] { firstScene, secondScene });
|
||||
layer.Enqueue(new ChatMessage
|
||||
{
|
||||
AuthorName = "Active",
|
||||
Kind = ChatEventKind.SuperChat,
|
||||
SuperChatDisplayString = "$10.00",
|
||||
});
|
||||
layer.Enqueue(new ChatMessage
|
||||
{
|
||||
AuthorName = "Queued",
|
||||
Kind = ChatEventKind.NewMember,
|
||||
});
|
||||
layer.Advance(0.7);
|
||||
|
||||
var activeAlert = layer.AlertBoxTickerFrame(alertBox);
|
||||
var recentFeed = layer.RenderSceneTicker(tickerA);
|
||||
|
||||
Assert.NotSame(tickerA.Ticker, tickerB.Ticker);
|
||||
Assert.Equal(AlertDisplayMethod.Solid, tickerA.Ticker.DisplayMethod);
|
||||
Assert.Equal(AlertDisplayMethod.Flash, tickerB.Ticker.DisplayMethod);
|
||||
Assert.NotNull(activeAlert);
|
||||
Assert.NotNull(recentFeed);
|
||||
Assert.Null(layer.RenderSceneTicker(tickerB));
|
||||
Assert.False(activeAlert!.BgraPixels.SequenceEqual(recentFeed!.BgraPixels),
|
||||
"the standalone feed must include the queued event while Stream Alerts shows only the active event");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>A short alert box must clip the strip's rows, not overrun the target
|
||||
/// buffer. The strip rasterises at its natural 48px, so a 20px-tall box used to be
|
||||
/// a heap-corrupting write once the strip was composited at the box's size.</summary>
|
||||
|
||||
Reference in New Issue
Block a user