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 BarEdge _scoreboardPreferredEdge = BarEdge.Bottom; /// The edge the creator asked for. It is only a PREFERENCE: the resolver gives the /// scoreboard the edge the social bar does not hold (creator ruling, resolved at render time). public BarEdge ScoreboardEdge { get => _scoreboardPreferredEdge; set { if (!SetProperty(ref _scoreboardPreferredEdge, value)) return; RefreshScoreboardPreviews(); } } public IReadOnlyList ScoreboardEdgeOptions { get; } = new[] { BarEdge.Top, BarEdge.Bottom }; /// How fast the ticker travels, in pixels per second. Persisted like the rest of the /// strip's look: it is a readability choice, and re-dialling it every launch would be a tax. public double ScoreboardScrollSpeed { get => _scoreboardPresenter?.PixelsPerSecond ?? ScoreboardPresenter.DefaultPixelsPerSecond; set { var clamped = Math.Clamp(value, ScoreboardPresenter.MinPixelsPerSecond, ScoreboardPresenter.MaxPixelsPerSecond); if (_scoreboardPresenter != null) _scoreboardPresenter.PixelsPerSecond = clamped; _layoutStore.SaveScoreboardScrollSpeed((int)Math.Round(clamped)); OnPropertyChanged(nameof(ScoreboardScrollSpeed)); } } 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 => !_scoreboardSimulate // dev data is made up: never poll for it && _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)); } /// One tick of the marquee: advance the window and republish. Runs only while the strip /// is actually shown, so an idle/hidden scoreboard costs nothing. private void TickScoreboard() { if (_scoreboardPresenter is null) return; if (!_scoreboardEnabled && !_scoreboardSimulate) return; _scoreboardPresenter.Advance(0.033); RefreshScoreboardPreviews(); } /// 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 box follows what was RENDERED (one or two lines), and its edge comes // from the SHARED rule: the social bar keeps its side, the scoreboard takes the other. var socialEdge = _socials?.BarPosition == SocialBarPosition.Top ? BarEdge.Top : BarEdge.Bottom; var (_, resolved) = ScoreboardLayout.ResolveEdges( socialVisible: SocialBarVisible, socialPreferred: socialEdge, scoreboardVisible: true, scoreboardPreferred: _scoreboardPreferredEdge); source.Width = frame.Width; source.Height = frame.Height; source.Y = resolved == BarEdge.Top ? ScoreboardEdgeMargin : 1080 - frame.Height - ScoreboardEdgeMargin; _scoreboardPreviewSource = source; } } }