Files
LlamaCasty/ViewModels/MainViewModel.BrandLogo.cs
T
gramps 9528128ca7 feat(settings): brand-logo thumbnail with a hover enlargement
The creator (2026-10-30): the brand-logo field in App Settings showed only a file NAME, so a
myopic user could not confirm which picture was set. Now a 28x28 thumbnail sits left of the
field showing the actual image, and hovering it opens an enlarged copy (up to 420x320) beside
the panel with the file name underneath. A click collapses it; moving the pointer away closes
it, unless the pointer walked onto the popup itself, where it stays so the creator can look
closely.

Mechanics:
- `MainViewModel.BrandLogoPreviewImage` decodes the stored asset ONCE and freezes it, so the
  thumbnail and the enlargement share one bitmap and no dispatcher hop is needed. A corrupt
  image returns null instead of taking the dialog down.
- The thumbnail is hidden when no image is set (NotNullToVis), so there is nothing to hover and
  no empty frame can appear.
- The popup is a separate window: WPF refuses to open one until its placement target is in a
  live visual tree, which is why the test hosts the control in a real window rather than
  asserting on a detached control.

Guard: BrandLogoPreviewTests — the wiring (image binding, the no-image gate, the four gesture
handlers, the readable enlargement size) and the control's open/close behaviour.
2026-10-30 08:39:42 -07:00

126 lines
4.3 KiB
C#

using System.IO;
using System.Windows.Media;
using System.Windows.Media.Imaging;
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 ImageSource? _brandLogoPreviewImage;
private bool _brandLogoPreviewLoaded;
/// <summary>The current brand-logo image, for the thumbnail beside the file field in App
/// Settings (the creator, 2026-10-30: a myopic user cannot tell what a file *name* looks
/// like). Null until an image is set.</summary>
public ImageSource? BrandLogoPreviewImage
{
get
{
if (!_brandLogoPreviewLoaded) RefreshBrandLogoPreview();
return _brandLogoPreviewImage;
}
}
internal void RefreshBrandLogoPreview()
{
_brandLogoPreviewLoaded = true;
_brandLogoPreviewImage = LoadBrandLogoImage(_layoutStore.GetBrandLogoAssetId());
OnPropertyChanged(nameof(BrandLogoPreviewImage));
}
/// <summary>Decoded once and frozen: the thumbnail and the enlarged preview share it, and a
/// frozen bitmap can be handed to any thread without a dispatcher round-trip.</summary>
private ImageSource? LoadBrandLogoImage(string? assetId)
{
if (string.IsNullOrWhiteSpace(assetId)) return null;
var bytes = _layoutStore.GetAssetBytes(assetId);
if (bytes is not { Length: > 0 }) return null;
try
{
var image = new BitmapImage();
image.BeginInit();
image.CacheOption = BitmapCacheOption.OnLoad;
image.StreamSource = new MemoryStream(bytes);
image.EndInit();
image.Freeze();
return image;
}
catch (Exception)
{
return null; // a corrupt image must not take the dialog down with it
}
}
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();
RefreshBrandLogoPreview();
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);
}
}
}