Brand logo: one locked image layer per scene above the background, image from App Settings
- Source.IsBrandLogo persisted (additive migration); layer healed on load, removal blocked - Image stored as an asset; settings keep the asset id and file name - App Settings: Brand Logo file field with Browse - Default placement top-left; per-scene eye uses the existing element visibility Test: BrandLogoTests; layout/background tests pass (34). Not pushed.
This commit is contained in:
@@ -56,6 +56,17 @@
|
||||
<ComboBoxItem Content="Phone (Vertical Rectangle)" Tag="{x:Static Models:ZoomShape.VerticalRectangle}"/>
|
||||
<ComboBoxItem Content="Tablet (Horizontal Rectangle)" Tag="{x:Static Models:ZoomShape.HorizontalRectangle}"/>
|
||||
</ComboBox>
|
||||
<TextBlock Text="Brand Logo" Style="{StaticResource YtLabel}" Margin="0,12,0,4"/>
|
||||
<Grid Margin="0,0,0,4">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox Style="{StaticResource YtTextBox}" Text="{Binding BrandLogoFileDisplay, Mode=OneWay}"
|
||||
IsReadOnly="True" Foreground="#a0a0b0"/>
|
||||
<Button Grid.Column="1" Content="Browse…" Command="{Binding BrowseBrandLogoCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}" Margin="8,0,0,0"/>
|
||||
</Grid>
|
||||
<TextBlock Text="Scene Transition" Style="{StaticResource YtLabel}" Margin="0,12,0,4"/>
|
||||
<ComboBox Style="{StaticResource YtComboBox}" SelectedValue="{Binding SelectedTransitionType}" SelectedValuePath="Tag">
|
||||
<ComboBoxItem Content="Cut" Tag="{x:Static services:TransitionType.Cut}"/>
|
||||
|
||||
@@ -88,6 +88,10 @@ public class Source : SceneElement
|
||||
public override bool IsWebSource => Type == SourceType.WebSource;
|
||||
public override bool IsTextOverlay => Type == SourceType.TextOverlay;
|
||||
|
||||
/// <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>
|
||||
public bool IsBrandLogo { get; set; }
|
||||
|
||||
/// <summary>The permanent scene background (live desktop/game capture). Never removable/reorderable.</summary>
|
||||
private bool _isBackground;
|
||||
public override bool IsBackground { get => _isBackground; set => Set(ref _isBackground, value); }
|
||||
|
||||
@@ -102,7 +102,7 @@ public partial class LayoutStore : IDisposable
|
||||
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor,
|
||||
WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState,
|
||||
AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume, SortOrder,
|
||||
AlertDisplayMethod
|
||||
AlertDisplayMethod, IsBrandLogo
|
||||
FROM Source ORDER BY SortOrder
|
||||
""";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
@@ -154,6 +154,7 @@ public partial class LayoutStore : IDisposable
|
||||
AlertShowTicker = reader.IsDBNull(33) ? true : reader.GetInt32(33) != 0,
|
||||
AlertVideoVolume = reader.IsDBNull(34) ? 1.0 : reader.GetDouble(34),
|
||||
AlertDisplayMethod = reader.IsDBNull(36) ? AlertDisplayMethod.TickerScroll : (AlertDisplayMethod)reader.GetInt32(36),
|
||||
IsBrandLogo = reader.GetInt32(reader.GetOrdinal("IsBrandLogo")) != 0,
|
||||
};
|
||||
if (!sourcesByScene.TryGetValue(sceneId, out var list))
|
||||
sourcesByScene[sceneId] = list = new List<(Source, int)>();
|
||||
|
||||
@@ -199,6 +199,13 @@ public partial class LayoutStore : IDisposable
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!columns.Contains("IsBrandLogo"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN IsBrandLogo INTEGER NOT NULL DEFAULT 0;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!columns.Contains("CaptureKey"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
|
||||
@@ -92,7 +92,7 @@ public partial class LayoutStore : IDisposable
|
||||
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor,
|
||||
WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState,
|
||||
AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume,
|
||||
AlertDisplayMethod)
|
||||
AlertDisplayMethod, IsBrandLogo)
|
||||
VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
|
||||
$x, $y, $w, $h, $opacity, $monitor,
|
||||
$clip, $mirrored, $isBackground, $captureKey, $showDesktop, $sort,
|
||||
@@ -100,7 +100,7 @@ public partial class LayoutStore : IDisposable
|
||||
$chatTimestamps, $chatBadges, $chatMemberColor, $chatSuperChatColor,
|
||||
$webUri, $mediaPath, $mediaIsLooping, $mediaVolume, $mediaPlaybackState,
|
||||
$alertVideoAssetId, $alertVideoPath, $alertUseDefaultVideo, $alertShowTicker, $alertVideoVolume,
|
||||
$alertDisplayMethod)
|
||||
$alertDisplayMethod, $isBrandLogo)
|
||||
""";
|
||||
cmd.Transaction = tx;
|
||||
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
|
||||
@@ -140,6 +140,7 @@ public partial class LayoutStore : IDisposable
|
||||
var alertShowTickerP = cmd.Parameters.Add("$alertShowTicker", SqliteType.Integer);
|
||||
var alertVideoVolumeP = cmd.Parameters.Add("$alertVideoVolume", SqliteType.Real);
|
||||
var alertDisplayMethodP = cmd.Parameters.Add("$alertDisplayMethod", SqliteType.Integer);
|
||||
var isBrandLogoP = cmd.Parameters.Add("$isBrandLogo", SqliteType.Integer);
|
||||
|
||||
// Source and WebcamSceneConfig share ONE z-order space per scene:
|
||||
// each element gets its index within scene.Elements regardless of
|
||||
@@ -188,6 +189,7 @@ public partial class LayoutStore : IDisposable
|
||||
alertShowTickerP.Value = source.AlertShowTicker ? 1 : 0;
|
||||
alertVideoVolumeP.Value = source.AlertVideoVolume;
|
||||
alertDisplayMethodP.Value = (int)source.AlertDisplayMethod;
|
||||
isBrandLogoP.Value = source.IsBrandLogo ? 1 : 0;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,4 +348,20 @@ public partial class LayoutStore : IDisposable
|
||||
|
||||
/// <summary>The stored on-air image's Asset id, or null when never stamped.</summary>
|
||||
public string? GetOnAirImageId() => GetSetting(OnAirImageKey);
|
||||
|
||||
private const string BrandLogoAssetKey = "BrandLogoAssetId";
|
||||
private const string BrandLogoNameKey = "BrandLogoFileName";
|
||||
|
||||
public string? GetBrandLogoAssetId() => GetSetting(BrandLogoAssetKey);
|
||||
|
||||
public string? GetBrandLogoFileName() => GetSetting(BrandLogoNameKey);
|
||||
|
||||
/// <summary>Stores the brand logo image as an asset and records its file name for display.</summary>
|
||||
public string SetBrandLogo(byte[] data, string fileName)
|
||||
{
|
||||
var id = UpsertAsset(data, 0, 0);
|
||||
UpsertSetting(BrandLogoAssetKey, id);
|
||||
UpsertSetting(BrandLogoNameKey, fileName);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -458,7 +458,8 @@ These were explicitly ruled out:
|
||||
- ✅ **Transition picker** in App Settings (type + duration), saved with the layout (`TransitionPickerTests`).
|
||||
- ✅ **Wipe** — CPU blend, left-to-right reveal (`WipeTransitionTests`). Direction setting not yet added.
|
||||
- ☐ **Move** — persistent layers (per-layer "carry across scenes" flag), per-scene placement, fixed z band.
|
||||
- ☐ **Brand logo** — permanent "Brand logo" layer above background in every scene; file in App Settings
|
||||
(imported to the asset table); per-scene eye toggle; top-left default.
|
||||
- ✅ **Brand logo** — one locked "Brand logo" image layer per scene, directly above the background; file in
|
||||
App Settings (imported to the asset table, the setting keeps the asset id); per-scene eye toggle; top-left
|
||||
default. Tests: `BrandLogoTests`. On-screen check pending.
|
||||
- ☐ **Cube** — GPU path gated on capability; CPU path only if it holds 60fps.
|
||||
- Gear: right-click opens App Settings (built, on-screen check pending). Left-click menu stays for now.
|
||||
|
||||
@@ -438,6 +438,7 @@ public partial class MainViewModel
|
||||
StampDefaultBackgroundAsset(background, scene.Name);
|
||||
}
|
||||
SeedOnAirImage();
|
||||
EnsureBrandLogos();
|
||||
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
|
||||
OnPropertyChanged(nameof(LayerCount));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.IO;
|
||||
using System.Windows.Input;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// The permanent brand logo: one image layer in every scene, directly above the background.
|
||||
/// Its image comes from App Settings; each scene keeps its own visibility (the eye toggle).
|
||||
/// </summary>
|
||||
public partial class MainViewModel
|
||||
{
|
||||
internal const double BrandLogoX = 32;
|
||||
internal const double BrandLogoY = 32;
|
||||
internal const double BrandLogoWidth = 240;
|
||||
internal const double BrandLogoHeight = 120;
|
||||
|
||||
private ICommand? _browseBrandLogo;
|
||||
|
||||
public ICommand BrowseBrandLogoCommand => _browseBrandLogo ??= new RelayCommand(_ => BrowseBrandLogo());
|
||||
|
||||
public string BrandLogoFileDisplay => _layoutStore.GetBrandLogoFileName() ?? "None";
|
||||
|
||||
private void BrowseBrandLogo()
|
||||
{
|
||||
var dialog = new Microsoft.Win32.OpenFileDialog
|
||||
{
|
||||
Title = "Choose brand logo",
|
||||
Filter = "Images|*.png;*.jpg;*.jpeg;*.bmp;*.gif",
|
||||
};
|
||||
if (dialog.ShowDialog() != true) return;
|
||||
_layoutStore.SetBrandLogo(File.ReadAllBytes(dialog.FileName), Path.GetFileName(dialog.FileName));
|
||||
EnsureBrandLogos();
|
||||
OnPropertyChanged(nameof(BrandLogoFileDisplay));
|
||||
ScheduleSave();
|
||||
}
|
||||
|
||||
internal static Source CreateBrandLogo(string? assetId) => new()
|
||||
{
|
||||
Name = "Brand logo",
|
||||
Type = SourceType.Image,
|
||||
IsBrandLogo = true,
|
||||
IsEnabled = true,
|
||||
AssetId = assetId,
|
||||
X = BrandLogoX,
|
||||
Y = BrandLogoY,
|
||||
Width = BrandLogoWidth,
|
||||
Height = BrandLogoHeight,
|
||||
};
|
||||
|
||||
/// <summary>Exactly one brand logo per scene, placed directly above the background, carrying
|
||||
/// the current App Settings image.</summary>
|
||||
internal void EnsureBrandLogos()
|
||||
{
|
||||
var assetId = _layoutStore.GetBrandLogoAssetId();
|
||||
foreach (var scene in Scenes)
|
||||
{
|
||||
var logos = scene.Elements.OfType<Source>().Where(s => s.IsBrandLogo).ToList();
|
||||
foreach (var extra in logos.Skip(1))
|
||||
scene.Elements.Remove(extra);
|
||||
var logo = logos.FirstOrDefault();
|
||||
if (logo == null)
|
||||
{
|
||||
var background = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
|
||||
var index = background == null ? 0 : scene.Elements.IndexOf(background) + 1;
|
||||
logo = CreateBrandLogo(assetId);
|
||||
scene.Elements.Insert(index, logo);
|
||||
}
|
||||
else
|
||||
{
|
||||
logo.AssetId = assetId;
|
||||
}
|
||||
_sceneGraph.InvalidateBake(scene);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -237,11 +237,11 @@ public partial class MainViewModel
|
||||
// session when the last using scene lets go). The webcam identity survives —
|
||||
// it's the app-level default, so removing a scene's placement re-enables Add
|
||||
// for the same camera instead of dropping it.
|
||||
private void RemoveElement(SceneElement? element)
|
||||
internal void RemoveElement(SceneElement? element)
|
||||
{
|
||||
var scene = StagedScene;
|
||||
if (scene == null || element == null) return;
|
||||
if (element is Source { IsBackground: true }) return;
|
||||
if (element is Source { IsBackground: true } or Source { IsBrandLogo: true }) return;
|
||||
if (element is WebcamSceneConfig && _webcam != null)
|
||||
{
|
||||
if (SelectedElement == element)
|
||||
|
||||
@@ -1296,3 +1296,12 @@ persisted chat or reward payloads."
|
||||
Guard: `ytLive.Tests/TransitionFadeTests.cs`.
|
||||
- **Planned next (see TASKS.md):** transition picker in App Settings, Wipe, Move (persistent layers),
|
||||
brand logo layer, GPU cube (capability-gated).
|
||||
|
||||
## Brand logo (2026-10-27)
|
||||
|
||||
- One locked image layer per scene, `Source.IsBrandLogo` (persisted column, additive migration), named
|
||||
"Brand logo", directly above the background. Healed on load by `EnsureBrandLogos`, which also copies the
|
||||
App Settings image into every scene's layer. Removal is blocked like the background.
|
||||
- The image lives in the asset table; the settings table keeps its asset id and file name
|
||||
(`BrandLogoAssetId` / `BrandLogoFileName`). Eye visibility is the layer's ordinary per-scene `IsVisible`.
|
||||
- Default placement: top-left (32,32), 240x120 master px.
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The brand logo is one locked image layer per scene, directly above the background.
|
||||
/// </summary>
|
||||
[Collection("RealApp")]
|
||||
public sealed class BrandLogoTests
|
||||
{
|
||||
private readonly RealAppHost _app;
|
||||
|
||||
public BrandLogoTests(RealAppHost app) => _app = app;
|
||||
|
||||
[Fact]
|
||||
public void Every_Scene_Gets_One_Brand_Logo_Directly_Above_The_Background()
|
||||
{
|
||||
_app.Run(Run);
|
||||
}
|
||||
|
||||
private static void Run()
|
||||
{
|
||||
var tempDb = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"ytLlive-brand-{Guid.NewGuid():N}.db");
|
||||
MainViewModel.LayoutPathOverride = tempDb;
|
||||
try
|
||||
{
|
||||
using (var schema = new LayoutStore(tempDb)) { }
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
|
||||
var vm = new MainViewModel();
|
||||
|
||||
foreach (var scene in vm.Scenes)
|
||||
{
|
||||
var logos = scene.Elements.OfType<Source>().Where(s => s.IsBrandLogo).ToList();
|
||||
Assert.Single(logos);
|
||||
var index = scene.Elements.IndexOf(logos[0]);
|
||||
var background = scene.Elements.OfType<Source>().First(s => s.IsBackground);
|
||||
Assert.Equal(scene.Elements.IndexOf(background) + 1, index);
|
||||
}
|
||||
|
||||
var staged = vm.StagedScene!;
|
||||
var logo = staged.Elements.OfType<Source>().First(s => s.IsBrandLogo);
|
||||
vm.RemoveElement(logo);
|
||||
Assert.Contains(logo, staged.Elements);
|
||||
}
|
||||
finally
|
||||
{
|
||||
MainViewModel.LayoutPathOverride = null;
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
try { System.IO.File.Delete(tempDb); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user