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:
2026-11-02 10:47:48 -08:00
parent 6208cb9409
commit 20f059422b
20 changed files with 339 additions and 90 deletions
+10 -1
View File
@@ -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)
+5
View File
@@ -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
+3 -1
View File
@@ -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;
}
}
}
+6 -2
View File
@@ -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,
};
+3 -1
View File
@@ -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),
+3
View File
@@ -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).