using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using System.Windows.Media; using System.Windows.Media.Imaging; using System.Windows.Threading; using ytLive.Helpers; using ytLive.Models; using ytLive.Services; using ytLive.Services.Compositor; namespace ytLive.ViewModels; /// The scoreboard strip: session tallies rendered as a full-width bar, per scene. /// /// It is a layer, so a creator gets the same eye toggle, /// drag and per-scene presence as every other element — "prominent on BRB, dialled back on Live" is /// geometry, not a second mechanism. public partial class MainViewModel { private readonly ScoreboardRecorder _scoreboard = new(); private ScoreboardTiles _scoreboardTiles = ScoreboardTiles.Default; private ScoreboardStyle _scoreboardStyle = new(); private ScoreboardPresenter? _scoreboardPresenter; private WriteableBitmap? _scoreboardPreview; /// Gap between the strip and the frame edge. private const double ScoreboardEdgeMargin = 8; private Source? _scoreboardPreviewSource; internal ScoreboardRecorder ScoreboardRecorder => _scoreboard; private bool _scoreboardEnabled = true; /// The scoreboard's master switch. Off hides the strip in EVERY scene (the per-scene /// eye stays independent) and — the point of the switch — stops all polling: a scoreboard nobody /// can see must not spend quota. public bool ScoreboardEnabled { get => _scoreboardEnabled; set { if (!SetProperty(ref _scoreboardEnabled, value)) return; _layoutStore.SaveScoreboardEnabled(value); RefreshScoreboardPreviews(); OnPropertyChanged(nameof(ScoreboardPollsEnabled)); } } #if DEBUG private const bool DevelopmentBuild = true; #else private const bool DevelopmentBuild = false; #endif /// Can the scoreboard be configured at all? Signed-in creators yes; in a DEBUG build /// always, so the strip can be previewed and judged before there is an account (creator, /// 2026-10-30). public bool CanUseScoreboard => IsConnected || IsDevelopmentBuild; /// True in a DEBUG build. Used only to expose development-only controls, never to /// change behaviour — the shipped binary differs by their absence, not by a code path. public bool IsDevelopmentBuild => DevelopmentBuild; private bool _scoreboardSimulate; /// DEBUG-only: push a representative set of events so the strip is visible in the /// preview before any real stream. Off clears back to an empty session. public bool ScoreboardSimulate { get => _scoreboardSimulate; set { if (!SetProperty(ref _scoreboardSimulate, value)) return; if (value) ScoreboardSample.Inject(_scoreboard); else _scoreboard.StartSession(); // empty session: "nothing yet" RefreshScoreboardPreviews(); OnPropertyChanged(nameof(ScoreboardFrame)); } } /// What the cadence policy asks: the master switch AND at least one scene showing it. internal bool ScoreboardPollsEnabled => _scoreboardEnabled && Scenes.Any(scene => scene.Elements.OfType().Any(s => s.IsScoreboard && s.IsVisible && s.IsEnabled)); internal ScoreboardTiles ScoreboardTilesState => _scoreboardTiles; /// Composited strip frame for the output path (cached by the presenter). internal VideoFrame? ScoreboardFrame => _scoreboardPresenter?.Frame; internal void StartScoreboardSession() { _scoreboard.StartSession(); RefreshScoreboardPreviews(); } internal void EndScoreboardSession() { _scoreboard.EndSession(); RefreshScoreboardPreviews(); } /// One Scoreboard layer in every scene, healed on load — "available to all scenes" /// (creator ruling 2026-10-30). Per-scene visibility is the ordinary eye toggle. internal void EnsureScoreboards() { if (ScoreboardHealer.Ensure(Scenes)) { foreach (var scene in Scenes) _sceneGraph.InvalidateBake(scene); RefreshScoreboardPreviews(); OnPropertyChanged(nameof(LayerCount)); } } private void InitScoreboard() { _scoreboardPresenter = new ScoreboardPresenter(_scoreboard, _scoreboardStyle) { // placeholder so SetTiles below drives the first render }; _scoreboardPresenter.SetTiles(_scoreboardTiles); _scoreboardPresenter.SetStyle(_scoreboardStyle); _scoreboard.Changed += RefreshScoreboardPreviews; } /// Snapshot of the strip presenters as themed properties, for the properties panel /// (unit 7) — kept here so the panel binds one object instead of six primitives. internal ScoreboardStyle ScoreboardStyleState => _scoreboardStyle; // ─── Properties panel surface (per layout: one scoreboard look) ──────────── public string ScoreboardTextColor { get => _scoreboardStyle.TextColor; set { _scoreboardStyle = _scoreboardStyle with { TextColor = value }; PushStyle(); } } public bool ScoreboardBackgroundEnabled { get => _scoreboardStyle.BackgroundEnabled; set { _scoreboardStyle = _scoreboardStyle with { BackgroundEnabled = value }; PushStyle(); } } public string ScoreboardBackgroundColor { get => _scoreboardStyle.BackgroundColor; set { _scoreboardStyle = _scoreboardStyle with { BackgroundColor = value }; PushStyle(); } } public double ScoreboardBackgroundOpacity { get => _scoreboardStyle.BackgroundOpacity; set { _scoreboardStyle = _scoreboardStyle with { BackgroundOpacity = Math.Clamp(value, 0, 1) }; PushStyle(); } } public double ScoreboardFontSize { get => _scoreboardStyle.FontSize; set { _scoreboardStyle = _scoreboardStyle with { FontSize = Math.Clamp(value, ScoreboardLayout.FloorFontSize, ScoreboardLayout.MaxFontSize), }; PushStyle(); } } /// The colours offered for text and plate: the app's one palette. public IReadOnlyList ScoreboardColorOptions => BorderColorPalette.All; public bool ScoreboardShowSubscribers { get => _scoreboardTiles.Subscribers; set => SetTile(t => t.Subscribers = value); } public bool ScoreboardShowWatchHours { get => _scoreboardTiles.WatchHours; set => SetTile(t => t.WatchHours = value); } public bool ScoreboardShowSuperChats { get => _scoreboardTiles.SuperChats; set => SetTile(t => t.SuperChats = value); } public bool ScoreboardShowNewMembers { get => _scoreboardTiles.NewMembers; set => SetTile(t => t.NewMembers = value); } public bool ScoreboardShowMilestones { get => _scoreboardTiles.Milestones; set => SetTile(t => t.Milestones = value); } public bool ScoreboardShowLastMember { get => _scoreboardTiles.LastMember; set => SetTile(t => t.LastMember = value); } public bool ScoreboardShowLastSuperChat { get => _scoreboardTiles.LastSuperChat; set => SetTile(t => t.LastSuperChat = value); } public bool ScoreboardShowLastGift { get => _scoreboardTiles.LastGift; set => SetTile(t => t.LastGift = value); } private void SetTile(Action change) { change(_scoreboardTiles); _scoreboardPresenter?.SetTiles(_scoreboardTiles); RefreshScoreboardPreviews(); OnPropertyChanged(nameof(ScoreboardTilesState)); } private void PushStyle() { _scoreboardPresenter?.SetStyle(_scoreboardStyle); RefreshScoreboardPreviews(); OnPropertyChanged(nameof(ScoreboardStyleState)); } /// Publishes the strip to every scoreboard layer's preview element. The pump thread /// must never touch a WriteableBitmap, so this runs on the UI thread only. private void RefreshScoreboardPreviews() { // Dev simulation OVERRIDES the master switch (creator ruling): the whole point of the dev // toggle is to prove the strip without touching product settings. var frame = (_scoreboardEnabled || _scoreboardSimulate) ? _scoreboardPresenter?.Frame : null; foreach (var scene in Scenes) foreach (var source in scene.Elements.OfType().Where(s => s.IsScoreboard)) { if (frame is null) { source.VideoImageSource = null; continue; } if (_scoreboardPreview is null || _scoreboardPreview.PixelWidth != frame.Width || _scoreboardPreview.PixelHeight != frame.Height) { _scoreboardPreview = new WriteableBitmap(frame.Width, frame.Height, 96, 96, PixelFormats.Bgra32, null); } _scoreboardPreview.WritePixels(new System.Windows.Int32Rect(0, 0, frame.Width, frame.Height), frame.BgraPixels, frame.Width * 4, 0); source.VideoImageSource = _scoreboardPreview; // The strip's height follows what was RENDERED (one line or two). A fixed 56px box // squashed a wrapped strip instead of showing it, and its Y follows from the edge. if (Math.Abs(source.Height - frame.Height) > 0.5) { source.Width = frame.Width; source.Height = frame.Height; source.Y = _scoreboardStyle is var _ && source.Y > 540 ? 1080 - frame.Height - ScoreboardEdgeMargin : ScoreboardEdgeMargin; } _scoreboardPreviewSource = source; } } }