Adds SourceType.Scoreboard with the same per-scene eye toggle, drag and presence as every other element — 'prominent on BRB, dialled back on Live' is geometry, not a second mechanism. Default geometry is the strip: 1920 wide, 56 tall, bottom edge. The output resolver returns the presenter's cached frame exactly like the chat box and alert box do, and every session start clears the tallies. Preview publication writes one shared WriteableBitmap on the UI thread (the pump thread must never touch one), refreshed only when a displayed value moves. Guard: ScoreboardLayerTests pins the enum member, the geometry constants, the resolver case and the session hook, so the layer cannot quietly become a special widget.
353 lines
12 KiB
C#
353 lines
12 KiB
C#
using System.ComponentModel;
|
|
using System.Globalization;
|
|
using System.Runtime.CompilerServices;
|
|
using System.Windows.Media;
|
|
using System.Windows.Media.Imaging;
|
|
|
|
namespace ytLive.Models;
|
|
|
|
public enum BorderAnimation
|
|
{
|
|
None,
|
|
Pulse,
|
|
Chase,
|
|
Rainbow,
|
|
Shimmer,
|
|
MarchingAnts,
|
|
Glow,
|
|
Electricity,
|
|
Sparkles
|
|
}
|
|
|
|
/// <summary>Whether an element's pixel content is fixed (bake once into the
|
|
/// compositor's cached base) or changes every frame (composite each tick).
|
|
/// See ai.md "SceneGraph component + baked-crust compositor" (TASK 31).</summary>
|
|
public enum ElementKind
|
|
{
|
|
/// <summary>Pixels never change at runtime (images, background art).</summary>
|
|
Static,
|
|
/// <summary>Pixels change every frame (webcam, live capture, chat, web source).</summary>
|
|
Dynamic
|
|
}
|
|
|
|
/// <summary>
|
|
/// A thing rendered in a scene: a multi-instance Source (image/background/text)
|
|
/// or a singleton resource's per-scene config (webcam). Carries the shared
|
|
/// layout + clip/mirror + border surface the preview pipeline renders from.
|
|
/// </summary>
|
|
public abstract class SceneElement : INotifyPropertyChanged
|
|
{
|
|
public event PropertyChangedEventHandler? PropertyChanged;
|
|
|
|
protected void Raise([CallerMemberName] string? name = null)
|
|
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
|
|
|
protected bool Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
|
|
{
|
|
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
|
|
field = value;
|
|
Raise(name);
|
|
return true;
|
|
}
|
|
|
|
public string Id { get; init; } = Guid.NewGuid().ToString();
|
|
|
|
private string _name = string.Empty;
|
|
public string Name { get => _name; set => Set(ref _name, value); }
|
|
|
|
/// <summary>Whether this element's pixels are fixed (Static) or change every
|
|
/// frame (Dynamic). The compositor bakes all Static layers below the first
|
|
/// Dynamic layer once (see <c>SceneGraph.GetSplitPoint</c>).</summary>
|
|
public virtual ElementKind Kind => ElementKind.Static;
|
|
|
|
/// <summary>True only for the transient zoom layer. Gates zoom-only UI:
|
|
/// hidden from the layers list, never selectable/draggable in the preview.</summary>
|
|
public virtual bool IsZoomLayer => false;
|
|
|
|
/// <summary>True only for singleton resources (webcam). Gates webcam-only UI.</summary>
|
|
public virtual bool IsWebcam => false;
|
|
|
|
/// <summary>True only for WebSource-type elements. Gates URI input visibility.</summary>
|
|
public virtual bool IsWebSource => false;
|
|
|
|
/// <summary>True for elements that can be dragged/resized in the preview (image, webcam).</summary>
|
|
public virtual bool IsDraggable => false;
|
|
|
|
/// <summary>True only for image sources — the raw preview renderer's discriminator.</summary>
|
|
public virtual bool IsImageSource => false;
|
|
|
|
/// <summary>True only for ChatBox-type sources. Gates chat overlay properties panel.</summary>
|
|
public virtual bool IsChatBox => false;
|
|
|
|
/// <summary>True only for Countdown-type sources. Gates timer properties panel.</summary>
|
|
public virtual bool IsCountdown => false;
|
|
|
|
/// <summary>True only for AlertBox-type sources. Gates stream alerts properties panel.</summary>
|
|
public virtual bool IsAlertBox => false;
|
|
public virtual bool IsTextOverlay => false;
|
|
|
|
/// <summary>The scoreboard strip: a per-scene, full-width bar.</summary>
|
|
public virtual bool IsScoreboard => false;
|
|
public virtual bool IsMediaSource => false;
|
|
public virtual bool IsLiveCapture => false;
|
|
public virtual bool IsBackground { get; set; }
|
|
|
|
private bool _useDefaultBackground = true;
|
|
/// <summary>When true, the shipped default background image is used and CustomBackgroundPath
|
|
/// is ignored (but preserved). Toggled by the pill-switch in the properties panel.</summary>
|
|
public bool UseDefaultBackground
|
|
{
|
|
get => _useDefaultBackground;
|
|
set => Set(ref _useDefaultBackground, value);
|
|
}
|
|
|
|
private string? _customBackgroundPath;
|
|
/// <summary>User-selected background image file path (FQFN). Null means use the app default.
|
|
/// Stored as a reference only — the image file is not copied into the DB.</summary>
|
|
public string? CustomBackgroundPath
|
|
{
|
|
get => _customBackgroundPath;
|
|
set => Set(ref _customBackgroundPath, value);
|
|
}
|
|
|
|
private bool _isVisible = true;
|
|
public bool IsVisible { get => _isVisible; set => Set(ref _isVisible, value); }
|
|
|
|
private bool _isEditing;
|
|
public bool IsEditing { get => _isEditing; set => Set(ref _isEditing, value); }
|
|
|
|
private ClipShape _clipShape = ClipShape.Traditional;
|
|
// Virtual so a derived element can CONSTRAIN the shape (the webcam is
|
|
// rect-only in the vertical tier's Chat layout — TASK 57) instead of every
|
|
// writer re-implementing the rule.
|
|
public virtual ClipShape ClipShape
|
|
{
|
|
get => _clipShape;
|
|
set
|
|
{
|
|
if (Set(ref _clipShape, value))
|
|
{
|
|
Raise(nameof(IsRectShape));
|
|
Raise(nameof(IsRoundShape));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>True when the element's clip is the plain rectangle — the shape a
|
|
/// camera has when none was ever picked (TASK 59: the properties panel shows
|
|
/// the shape as a CHOICE, and Rect is the selected default).</summary>
|
|
public bool IsRectShape
|
|
{
|
|
get => ClipShape != ClipShape.Round;
|
|
set
|
|
{
|
|
// Round -> rect (restores the pre-round rect, like the toggle does).
|
|
if (value && ClipShape == ClipShape.Round) ToggleClipShape();
|
|
Raise(nameof(IsRectShape));
|
|
}
|
|
}
|
|
|
|
/// <summary>True when the element's clip is a circle (TASK 59).</summary>
|
|
public bool IsRoundShape
|
|
{
|
|
get => ClipShape == ClipShape.Round;
|
|
set
|
|
{
|
|
// Rect -> round (stores the pre-round rect, like the toggle does).
|
|
if (value && ClipShape != ClipShape.Round) ToggleClipShape();
|
|
Raise(nameof(IsRoundShape));
|
|
}
|
|
}
|
|
|
|
// The rectangular dimensions before a Round toggle, so switching back restores
|
|
// the aspect instead of staying locked to the square Round used. Persisted so
|
|
// the restore survives a reload of a Round element.
|
|
private double? _rectWidth;
|
|
private double? _rectHeight;
|
|
|
|
public double? RectWidth { get => _rectWidth; set => Set(ref _rectWidth, value); }
|
|
public double? RectHeight { get => _rectHeight; set => Set(ref _rectHeight, value); }
|
|
|
|
public virtual void ToggleClipShape()
|
|
{
|
|
if (ClipShape == ClipShape.Traditional)
|
|
{
|
|
RectWidth = Width;
|
|
RectHeight = Height;
|
|
ClipShape = ClipShape.Round;
|
|
}
|
|
else
|
|
{
|
|
ClipShape = ClipShape.Traditional;
|
|
if (RectWidth is { } rw && RectHeight is { } rh)
|
|
{
|
|
Width = rw;
|
|
Height = rh;
|
|
RectWidth = null;
|
|
RectHeight = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
private bool _isMirrored;
|
|
public bool IsMirrored
|
|
{
|
|
get => _isMirrored;
|
|
set
|
|
{
|
|
if (Set(ref _isMirrored, value))
|
|
{
|
|
Raise(nameof(MirrorScale));
|
|
Raise(nameof(MirrorButtonText));
|
|
}
|
|
}
|
|
}
|
|
|
|
public double MirrorScale => IsMirrored ? -1 : 1;
|
|
public string MirrorButtonText => IsMirrored ? "Unmirror" : "Mirror";
|
|
|
|
// Live camera frames: the shared WriteableBitmap owned by CameraManager.
|
|
private WriteableBitmap? _videoImageSource;
|
|
public WriteableBitmap? VideoImageSource
|
|
{
|
|
get => _videoImageSource;
|
|
set
|
|
{
|
|
if (Set(ref _videoImageSource, value))
|
|
Raise(nameof(DisplaySource));
|
|
}
|
|
}
|
|
|
|
/// <summary>What the preview shows: live frames (webcam) or a static image (sources).</summary>
|
|
public abstract ImageSource? DisplaySource { get; }
|
|
|
|
// Position/transform (per-scene usage). The geometry setters are virtual so
|
|
// a derived element can CONSTRAIN them (the webcam clamps itself into the
|
|
// vertical tier's band — TASK 57) instead of every writer re-implementing
|
|
// the rule.
|
|
private double _x;
|
|
public virtual double X { get => _x; set => Set(ref _x, value); }
|
|
|
|
private double _y;
|
|
public virtual double Y { get => _y; set => Set(ref _y, value); }
|
|
|
|
private double _width;
|
|
public virtual double Width
|
|
{
|
|
get => _width;
|
|
set
|
|
{
|
|
if (Set(ref _width, value))
|
|
Raise(nameof(RoundBorderSize));
|
|
}
|
|
}
|
|
|
|
private double _height;
|
|
public virtual double Height
|
|
{
|
|
get => _height;
|
|
set
|
|
{
|
|
if (Set(ref _height, value))
|
|
Raise(nameof(RoundBorderSize));
|
|
}
|
|
}
|
|
|
|
private double _opacity = 1.0;
|
|
public double Opacity { get => _opacity; set => Set(ref _opacity, value); }
|
|
|
|
/// <summary>Round clip/border diameter = the shorter dimension (true circle).</summary>
|
|
public double RoundBorderSize => Math.Min(Width, Height);
|
|
|
|
// Border (OBS-style): #RRGGBB color, alpha opacity, pixel width. Off by default.
|
|
private string _borderColor = string.Empty;
|
|
public string BorderColor
|
|
{
|
|
get => _borderColor;
|
|
set
|
|
{
|
|
if (Set(ref _borderColor, value))
|
|
{
|
|
Raise(nameof(HasBorder));
|
|
Raise(nameof(BorderBrush));
|
|
Raise(nameof(BorderColorEntry));
|
|
Raise(nameof(BorderColorOptions));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>The border colour as the drop-down sees it: the palette's entry for the
|
|
/// current hex — with its NAME — or a nameless entry for a custom hex the creator
|
|
/// typed. Setting it writes the hex back (TASK 63).</summary>
|
|
public NamedColor BorderColorEntry
|
|
{
|
|
get => BorderColorPalette.EntryFor(BorderColor);
|
|
set => BorderColor = value.Hex;
|
|
}
|
|
|
|
/// <summary>The colours the border drop-down offers — the palette, plus the custom
|
|
/// colour the creator typed (so the drop-down always has a selection) (TASK 63).</summary>
|
|
public IReadOnlyList<NamedColor> BorderColorOptions
|
|
=> BorderColorPalette.OptionsFor(BorderColor);
|
|
|
|
private double _borderOpacity = 1.0;
|
|
public double BorderOpacity
|
|
{
|
|
get => _borderOpacity;
|
|
set
|
|
{
|
|
if (Set(ref _borderOpacity, value))
|
|
{
|
|
Raise(nameof(HasBorder));
|
|
Raise(nameof(BorderBrush));
|
|
}
|
|
}
|
|
}
|
|
|
|
private int _borderWidth;
|
|
public int BorderWidth
|
|
{
|
|
get => _borderWidth;
|
|
set
|
|
{
|
|
if (Set(ref _borderWidth, value))
|
|
{
|
|
Raise(nameof(HasBorder));
|
|
Raise(nameof(BorderBrush));
|
|
}
|
|
}
|
|
}
|
|
|
|
private BorderAnimation _borderAnimation = BorderAnimation.None;
|
|
public BorderAnimation BorderAnimation { get => _borderAnimation; set => Set(ref _borderAnimation, value); }
|
|
|
|
public bool HasBorder
|
|
{
|
|
get
|
|
{
|
|
if (BorderWidth <= 0) return false;
|
|
return TryGetBorderColor(out _, out _, out _);
|
|
}
|
|
}
|
|
|
|
public Brush? BorderBrush
|
|
{
|
|
get
|
|
{
|
|
if (!HasBorder || !TryGetBorderColor(out var r, out var g, out var b)) return null;
|
|
var alpha = (byte)Math.Round(Math.Clamp(BorderOpacity, 0, 1) * 255);
|
|
return new SolidColorBrush(Color.FromArgb(alpha, r, g, b));
|
|
}
|
|
}
|
|
|
|
public bool TryGetBorderColor(out byte r, out byte g, out byte b)
|
|
{
|
|
r = g = b = 0;
|
|
var hex = BorderColor.Trim().TrimStart('#');
|
|
if (hex.Length != 6) return false;
|
|
return byte.TryParse(hex.AsSpan(0, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out r)
|
|
&& byte.TryParse(hex.AsSpan(2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out g)
|
|
&& byte.TryParse(hex.AsSpan(4, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out b);
|
|
}
|
|
}
|