From 1160221e8dcfc8f9c7d3b5c93dc264895faad81e Mon Sep 17 00:00:00 2001 From: Dinesh Solanki <15937452+dineshsolanki@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:09:35 +0530 Subject: [PATCH 01/19] Add new overlay definitions and enhance poster icon configuration - Introduced multiple new overlay definitions including "Alternate", "Faelpessoal", "Legacy", "Liaher", and "Windows 11" with respective properties and configurations. - Updated the PreviewerViewModel to load available overlays dynamically. - Enhanced PosterIconConfigViewModel to manage overlay selection with a new OverlayItemViewModel for better data binding. - Implemented a dynamic poster icon renderer that builds its visual tree based on the selected overlay definition. - Refactored the poster icon configuration view to utilize an ItemsControl for overlay selection, improving UI scalability and maintainability. --- FoliCon/FoliCon.csproj | 8 + FoliCon/Models/Constants/GlobalVariables.cs | 45 +- FoliCon/Models/Data/OverlayCatalog.cs | 33 ++ FoliCon/Models/Data/OverlayLayerConfig.cs | 178 +++++++ .../Models/Data/PosterOverlayDefinition.cs | 45 ++ FoliCon/Models/Usings.cs | 3 +- FoliCon/Modules/Overlays/IOverlayProvider.cs | 46 ++ FoliCon/Modules/Overlays/OverlayConstants.cs | 56 +++ FoliCon/Modules/Overlays/OverlayProvider.cs | 198 ++++++++ FoliCon/Modules/Overlays/OverlayValidator.cs | 186 +++++++ FoliCon/Modules/utils/IconUtils.cs | 38 +- .../Resources/Overlays/alternate/overlay.json | 54 +++ .../faelpessoal-horizontal/overlay.json | 53 ++ .../Overlays/faelpessoal/overlay.json | 58 +++ .../Resources/Overlays/legacy/overlay.json | 51 ++ .../Resources/Overlays/liaher/overlay.json | 56 +++ .../Resources/Overlays/windows11/overlay.json | 51 ++ FoliCon/ViewModels/PreviewerViewModel.cs | 47 +- .../ViewModels/posterIconConfigViewModel.cs | 80 ++- FoliCon/Views/DynamicPosterIcon.xaml | 6 + FoliCon/Views/DynamicPosterIcon.xaml.cs | 454 ++++++++++++++++++ FoliCon/Views/posterIconConfig.xaml | 120 ++--- 22 files changed, 1737 insertions(+), 129 deletions(-) create mode 100644 FoliCon/Models/Data/OverlayCatalog.cs create mode 100644 FoliCon/Models/Data/OverlayLayerConfig.cs create mode 100644 FoliCon/Models/Data/PosterOverlayDefinition.cs create mode 100644 FoliCon/Modules/Overlays/IOverlayProvider.cs create mode 100644 FoliCon/Modules/Overlays/OverlayConstants.cs create mode 100644 FoliCon/Modules/Overlays/OverlayProvider.cs create mode 100644 FoliCon/Modules/Overlays/OverlayValidator.cs create mode 100644 FoliCon/Resources/Overlays/alternate/overlay.json create mode 100644 FoliCon/Resources/Overlays/faelpessoal-horizontal/overlay.json create mode 100644 FoliCon/Resources/Overlays/faelpessoal/overlay.json create mode 100644 FoliCon/Resources/Overlays/legacy/overlay.json create mode 100644 FoliCon/Resources/Overlays/liaher/overlay.json create mode 100644 FoliCon/Resources/Overlays/windows11/overlay.json create mode 100644 FoliCon/Views/DynamicPosterIcon.xaml create mode 100644 FoliCon/Views/DynamicPosterIcon.xaml.cs diff --git a/FoliCon/FoliCon.csproj b/FoliCon/FoliCon.csproj index cfd20962..bf8d2db1 100644 --- a/FoliCon/FoliCon.csproj +++ b/FoliCon/FoliCon.csproj @@ -101,6 +101,14 @@ dineshsolanki.github.io/folicon/ true 10.0.26100.0 + + + + + + + + diff --git a/FoliCon/Models/Constants/GlobalVariables.cs b/FoliCon/Models/Constants/GlobalVariables.cs index 3e6e0d8b..afde0546 100644 --- a/FoliCon/Models/Constants/GlobalVariables.cs +++ b/FoliCon/Models/Constants/GlobalVariables.cs @@ -3,29 +3,40 @@ [Localizable(false)] internal static class GlobalVariables { + private static IOverlayProvider? _overlayProvider; - public static IconOverlay IconOverlayType() - { - return IconOverlayTypeString switch - { - "Legacy" => IconOverlay.Legacy, - "Alternate" => IconOverlay.Alternate, - "Liaher" => IconOverlay.Liaher, - "Faelpessoal" => IconOverlay.Faelpessoal, - "FaelpessoalHorizontal" => IconOverlay.FaelpessoalHorizontal, - "Windows11" => IconOverlay.Windows11, - _ => IconOverlay.Alternate - }; - } - - public const string mediaInfoFile = "info.folicon"; + /// + /// Gets or creates the static overlay provider instance. + /// + public static IOverlayProvider OverlayProvider => _overlayProvider ??= new OverlayProvider(); - private static string IconOverlayTypeString + /// + /// Returns the active overlay string ID from the persisted tracker setting. + /// + public static string ActiveOverlayId { get { var data = Services.Tracker.Store.GetData("PosterIconConfigViewModel"); - return data.TryGetValue("p.IconOverlay", out var value) ? value.ToString() : IconOverlay.Liaher.ToString(); + if (!data.TryGetValue("p.IconOverlay", out var value)) return OverlayConstants.DefaultOverlayId; + var strValue = value.ToString(); + return strValue switch + { + "Legacy" => "legacy", + "Alternate" => "alternate", + "Liaher" => "liaher", + "Faelpessoal" => "faelpessoal", + "FaelpessoalHorizontal" => "faelpessoal-horizontal", + "Windows11" => "windows11", + _ => strValue + }; } } + + /// + /// Returns the active overlay definition. + /// + public static PosterOverlayDefinition GetActiveOverlay() => OverlayProvider.ResolveActiveOverlayOrDefault(ActiveOverlayId); + + public const string mediaInfoFile = "info.folicon"; } diff --git a/FoliCon/Models/Data/OverlayCatalog.cs b/FoliCon/Models/Data/OverlayCatalog.cs new file mode 100644 index 00000000..ac312942 --- /dev/null +++ b/FoliCon/Models/Data/OverlayCatalog.cs @@ -0,0 +1,33 @@ +namespace FoliCon.Models.Data; + +/// +/// Represents the auto-generated catalog.json from the FoliCon-Overlays repository. +/// +[Localizable(false)] +public class OverlayCatalog +{ + public int SchemaVersion { get; set; } + public DateTime GeneratedAt { get; set; } + public List Overlays { get; set; } = []; +} + +/// +/// A single overlay entry in the catalog. +/// +[Localizable(false)] +public class OverlayCatalogEntry +{ + public string Id { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public string Author { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public string OverlayVersion { get; set; } = string.Empty; + public string[] Tags { get; set; } = []; + public string PreviewUrl { get; set; } = string.Empty; + public string OverlayBaseUrl { get; set; } = string.Empty; + public string OverlayPath { get; set; } = string.Empty; + public long SizeBytes { get; set; } + public string Sha256 { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} diff --git a/FoliCon/Models/Data/OverlayLayerConfig.cs b/FoliCon/Models/Data/OverlayLayerConfig.cs new file mode 100644 index 00000000..bf1ecdff --- /dev/null +++ b/FoliCon/Models/Data/OverlayLayerConfig.cs @@ -0,0 +1,178 @@ +namespace FoliCon.Models.Data; + +/// +/// Defines a base or front image layer in an overlay. +/// +[Localizable(false)] +public class LayerDefinition +{ + /// + /// Relative path to the image file within the overlay folder. + /// + public string ImagePath { get; set; } = string.Empty; + + /// + /// WPF Thickness string: "left,top,right,bottom". Supports negative values. + /// + public string Margin { get; set; } = "0,0,0,0"; +} + +/// +/// Configuration for the poster image layer within the overlay. +/// +[Localizable(false)] +public class PosterConfig +{ + /// + /// WPF Thickness string: "left,top,right,bottom". + /// Applied to the Border when clip/mask is used, or directly to the Image otherwise. + /// + public string Margin { get; set; } = "0,0,0,0"; + + /// + /// CornerRadius string: single value or "tl,tr,br,bl". + /// "0" means no clipping. + /// + public string ClipRadius { get; set; } = "0"; + + /// + /// Explicit clip rectangle as "x,y,width,height". + /// When set, overrides the calculated clip from Margin. + /// Matches the original XAML RectangleGeometry Rect values exactly. + /// + public string? ClipRect { get; set; } + + /// + /// Optional WPF Thickness for the poster Image inside the Border (when clip/mask is active). + /// Some overlays need a small margin on the inner Image (e.g. "0,0,0,-1"). + /// When null, the inner Image has no margin and fills the Border content area. + /// + public string? PosterInnerMargin { get; set; } + + /// + /// Optional relative path to an opacity mask image within the overlay folder. + /// + public string? OpacityMaskPath { get; set; } +} + +/// +/// Configuration for the rating badge (shield + text). +/// +[Localizable(false)] +public class RatingConfig +{ + /// + /// WPF Thickness string for the shield image margin. + /// + public string ShieldMargin { get; set; } = "160,97,6,5"; + + /// + /// WPF Thickness string for the rating text margin. + /// + public string TextMargin { get; set; } = "189,30,21,24"; + + public double FontSize { get; set; } = 25; + public string FontFamily { get; set; } = "Castellar"; + + /// + /// Optional path to a bundled .ttf/.otf font file within the overlay folder. + /// + public string? FontSource { get; set; } + + /// + /// System font to use if the primary font is not available. + /// + public string FontFallback { get; set; } = "Segoe UI"; + + /// + /// Width of the rating text block. + /// + public double TextWidth { get; set; } = 55; + + /// + /// Height of the rating text block. + /// + public double TextHeight { get; set; } = 46; + + /// + /// Horizontal alignment of the rating text. + /// + public string TextHorizontalAlignment { get; set; } = "Center"; + + /// + /// Vertical alignment of the rating text. + /// + public string TextVerticalAlignment { get; set; } = "Center"; +} + +/// +/// Configuration for the title text (optional). +/// +[Localizable(false)] +public class TitleConfig +{ + public bool IsVisible { get; set; } + + /// + /// WPF Thickness string for the title text margin. + /// + public string Margin { get; set; } = "0,0,0,0"; + + /// + /// Rotation angle in degrees (0-360). + /// + public double RotationAngle { get; set; } + + /// + /// Rotation origin as normalized point "x,y" (0.0–1.0). + /// + public string RotationOrigin { get; set; } = "0.5,0.5"; + + public string FontFamily { get; set; } = "Cormorant"; + + /// + /// Optional path to a bundled .ttf/.otf font file within the overlay folder. + /// + public string? FontSource { get; set; } + + /// + /// System font to use if the primary font is not available. + /// + public string FontFallback { get; set; } = "Segoe UI"; + + /// + /// Text foreground color (WPF color name or hex). + /// + public string Foreground { get; set; } = "White"; + + /// + /// Text trimming mode: None, WordEllipsis, CharacterEllipsis. + /// + public string Trimming { get; set; } = "WordEllipsis"; + + /// + /// Text wrapping mode: NoWrap, Wrap, WrapWithOverflow. + /// + public string Wrapping { get; set; } = "Wrap"; + + /// + /// Visual container for the title: Root or RatingGrid. + /// Root preserves the default behavior for community overlays. + /// + public string Container { get; set; } = "Root"; + + /// + /// Grid row used when Container is RatingGrid. + /// + public int GridRow { get; set; } + + /// + /// Horizontal alignment of the title text. + /// + public string HorizontalAlignment { get; set; } = "Left"; + + /// + /// Vertical alignment of the title text. + /// + public string VerticalAlignment { get; set; } = "Top"; +} diff --git a/FoliCon/Models/Data/PosterOverlayDefinition.cs b/FoliCon/Models/Data/PosterOverlayDefinition.cs new file mode 100644 index 00000000..3125ad84 --- /dev/null +++ b/FoliCon/Models/Data/PosterOverlayDefinition.cs @@ -0,0 +1,45 @@ +namespace FoliCon.Models.Data; + +/// +/// Defines a poster icon overlay package — the JSON schema for overlay.json files. +/// +[Localizable(false)] +public class PosterOverlayDefinition +{ + public int SchemaVersion { get; set; } = 1; + public string Id { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public string Author { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public string OverlayVersion { get; set; } = "1.0.0"; + public string[] Tags { get; set; } = []; + public bool IsBuiltIn { get; set; } + + // Canvas compatibility — defaults preserve existing compiled overlay coordinates + public double DesignWidth { get; set; } = 265; + public double DesignHeight { get; set; } = 256; + public string RootMargin { get; set; } = "0,0,0,-11"; + public double RenderWidth { get; set; } = 256; + public double RenderHeight { get; set; } = 256; + + // Layer definitions + public LayerDefinition? BaseLayer { get; set; } + public LayerDefinition? FrontLayer { get; set; } + + /// + /// Explicit z-order of children in the root Grid. + /// Valid values: "base", "poster", "front", "rating", "title". + /// If null, defaults to ["base","poster","front","rating","title"]. + /// Must match the original compiled XAML child order exactly. + /// + public string[]? LayerOrder { get; set; } + + // Poster image configuration + public PosterConfig Poster { get; set; } = new(); + + // Rating badge configuration + public RatingConfig Rating { get; set; } = new(); + + // Title configuration (optional) + public TitleConfig Title { get; set; } = new(); +} diff --git a/FoliCon/Models/Usings.cs b/FoliCon/Models/Usings.cs index 76d73e6e..c9e21feb 100644 --- a/FoliCon/Models/Usings.cs +++ b/FoliCon/Models/Usings.cs @@ -13,6 +13,7 @@ global using FoliCon.Modules.TMDB; global using FoliCon.Modules.UI; global using FoliCon.Modules.utils; +global using FoliCon.Modules.Overlays; global using FoliCon.ViewModels; global using GongSolutions.Wpf.DragDrop; global using NLog; @@ -84,4 +85,4 @@ global using static Vanara.PInvoke.Gdi32; global using static Vanara.PInvoke.Shell32; -global using MessageBox = HandyControl.Controls.MessageBox; \ No newline at end of file +global using MessageBox = HandyControl.Controls.MessageBox; diff --git a/FoliCon/Modules/Overlays/IOverlayProvider.cs b/FoliCon/Modules/Overlays/IOverlayProvider.cs new file mode 100644 index 00000000..a4705cde --- /dev/null +++ b/FoliCon/Modules/Overlays/IOverlayProvider.cs @@ -0,0 +1,46 @@ +using FoliCon.Models.Data; + +namespace FoliCon.Modules.Overlays; + +/// +/// Provides access to all available overlay definitions (built-in + user-installed). +/// This is the single source of truth for resolving overlay IDs into validated definitions. +/// +public interface IOverlayProvider +{ + /// + /// Returns all available overlays: built-in first, then user-installed. + /// + IReadOnlyList GetAllOverlays(); + + /// + /// Returns only user-installed overlays. + /// + IReadOnlyList GetUserOverlays(); + + /// + /// Gets an overlay definition by its ID. Returns null if not found. + /// + PosterOverlayDefinition? GetOverlayById(string id); + + /// + /// Resolves the active overlay ID to a validated definition. + /// Falls back to the default built-in overlay if the ID is missing, corrupt, or not installed. + /// + PosterOverlayDefinition ResolveActiveOverlayOrDefault(string? activeOverlayId); + + /// + /// Returns true if an overlay with the given ID is installed. + /// + bool IsOverlayInstalled(string id); + + /// + /// Gets the full path to an overlay's folder. + /// + string GetOverlayFolderPath(string id); + + /// + /// Reloads overlays from disk. Called after install/uninstall. + /// + void Refresh(); +} diff --git a/FoliCon/Modules/Overlays/OverlayConstants.cs b/FoliCon/Modules/Overlays/OverlayConstants.cs new file mode 100644 index 00000000..483af863 --- /dev/null +++ b/FoliCon/Modules/Overlays/OverlayConstants.cs @@ -0,0 +1,56 @@ +namespace FoliCon.Modules.Overlays; + +/// +/// Constants for the overlay plugin system. +/// +[Localizable(false)] +internal static class OverlayConstants +{ + /// + /// Subfolder under %AppData% where user-installed overlays are stored. + /// + public const string OverlaysFolder = "Overlays"; + + /// + /// Subfolder under %LocalAppData% for catalog cache. + /// + public const string CacheFolder = "OverlayCache"; + + /// + /// Maximum size in bytes for a single image asset (2 MB). + /// + public const long MaxImageSizeBytes = 2 * 1024 * 1024; + + /// + /// Maximum total size in bytes for an overlay package folder (5 MB). + /// + public const long MaxOverlayPackageSizeBytes = 5 * 1024 * 1024; + + /// + /// Maximum supported schema version by this app version. + /// + public const int AppSupportedSchemaVersion = 1; + + /// + /// Built-in overlay IDs that cannot be overridden by community overlays. + /// + public static readonly HashSet BuiltInOverlayIds = + [ + "legacy", "alternate", "liaher", "faelpessoal", "faelpessoal-horizontal", "windows11" + ]; + + /// + /// Default overlay ID when no selection is found or active overlay is invalid. + /// + public const string DefaultOverlayId = "liaher"; + + /// + /// JSON file name for the overlay definition within an overlay folder. + /// + public const string OverlayJsonFileName = "overlay.json"; + + /// + /// JSON file name for the catalog. + /// + public const string CatalogFileName = "catalog.json"; +} diff --git a/FoliCon/Modules/Overlays/OverlayProvider.cs b/FoliCon/Modules/Overlays/OverlayProvider.cs new file mode 100644 index 00000000..e7975c6a --- /dev/null +++ b/FoliCon/Modules/Overlays/OverlayProvider.cs @@ -0,0 +1,198 @@ +using FoliCon.Models.Data; + +namespace FoliCon.Modules.Overlays; + +/// +/// Loads and manages overlay definitions from built-in resources and user-installed folders. +/// +[Localizable(false)] +public class OverlayProvider : IOverlayProvider +{ + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + + private readonly string _userOverlaysPath; + private List _builtInOverlays = []; + private List _userOverlays = []; + + public OverlayProvider() + { + _userOverlaysPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "FoliCon", OverlayConstants.OverlaysFolder); + + LoadBuiltInOverlays(); + LoadUserOverlays(); + } + + public IReadOnlyList GetAllOverlays() + { + return _builtInOverlays.Concat(_userOverlays).ToList().AsReadOnly(); + } + + public IReadOnlyList GetUserOverlays() + { + return _userOverlays.AsReadOnly(); + } + + public PosterOverlayDefinition? GetOverlayById(string id) + { + return GetAllOverlays().FirstOrDefault(o => + string.Equals(o.Id, id, StringComparison.OrdinalIgnoreCase)); + } + + public PosterOverlayDefinition ResolveActiveOverlayOrDefault(string? activeOverlayId) + { + if (!string.IsNullOrWhiteSpace(activeOverlayId)) + { + var overlay = GetOverlayById(activeOverlayId); + if (overlay != null) + return overlay; + + Logger.Warn("Active overlay '{ActiveId}' not found. Falling back to default.", activeOverlayId); + } + + var defaultOverlay = GetOverlayById(OverlayConstants.DefaultOverlayId); + if (defaultOverlay != null) + return defaultOverlay; + + Logger.Error("Default overlay '{DefaultId}' not found. Using first available overlay.", OverlayConstants.DefaultOverlayId); + return GetAllOverlays().FirstOrDefault() ?? CreateFallbackDefinition(); + } + + public bool IsOverlayInstalled(string id) + { + return GetOverlayById(id) != null; + } + + public string GetOverlayFolderPath(string id) + { + // Check built-in first + if (OverlayConstants.BuiltInOverlayIds.Contains(id, StringComparer.OrdinalIgnoreCase)) + { + return Path.Combine("Resources", "Overlays", id); + } + + return Path.Combine(_userOverlaysPath, id); + } + + public void Refresh() + { + LoadUserOverlays(); + } + + private void LoadBuiltInOverlays() + { + _builtInOverlays.Clear(); + + foreach (var id in OverlayConstants.BuiltInOverlayIds) + { + try + { + // .NET SDK converts hyphens to underscores in embedded resource names + var resourceName = $"FoliCon.Resources.Overlays.{id.Replace('-', '_')}.{OverlayConstants.OverlayJsonFileName}"; + var json = LoadEmbeddedResource(resourceName); + if (json != null) + { + var definition = JsonConvert.DeserializeObject(json); + if (definition != null) + { + definition.IsBuiltIn = true; + // Built-in overlays use embedded resource paths for images + // The DynamicPosterIcon will resolve these via GetResourcePath + _builtInOverlays.Add(definition); + } + } + } + catch (Exception ex) + { + Logger.Error(ex, "Failed to load built-in overlay '{Id}'", id); + } + } + + Logger.Info("Loaded {Count} built-in overlays", _builtInOverlays.Count); + } + + private void LoadUserOverlays() + { + _userOverlays.Clear(); + + if (!Directory.Exists(_userOverlaysPath)) + { + Logger.Debug("User overlays directory does not exist: {Path}", _userOverlaysPath); + return; + } + + var overlayFolders = Directory.GetDirectories(_userOverlaysPath); + foreach (var folder in overlayFolders) + { + var jsonPath = Path.Combine(folder, OverlayConstants.OverlayJsonFileName); + if (!File.Exists(jsonPath)) + { + Logger.Warn("Overlay folder '{Folder}' missing {JsonFile}", folder, OverlayConstants.OverlayJsonFileName); + continue; + } + + try + { + var json = File.ReadAllText(jsonPath); + var definition = JsonConvert.DeserializeObject(json); + if (definition == null) + { + Logger.Warn("Failed to deserialize overlay from '{Path}'", jsonPath); + continue; + } + + // Reject community overlays that try to use built-in IDs + if (OverlayConstants.BuiltInOverlayIds.Contains(definition.Id, StringComparer.OrdinalIgnoreCase)) + { + Logger.Warn("Community overlay at '{Path}' uses reserved built-in ID '{Id}'. Skipping.", folder, definition.Id); + continue; + } + + // Schema version check + if (definition.SchemaVersion > OverlayConstants.AppSupportedSchemaVersion) + { + Logger.Warn("Overlay '{Id}' requires schema v{Version}, app supports v{AppVersion}. Skipping.", + definition.Id, definition.SchemaVersion, OverlayConstants.AppSupportedSchemaVersion); + continue; + } + + // Validate + var errors = OverlayValidator.Validate(folder, definition); + if (errors.Count > 0) + { + Logger.Warn("Overlay '{Id}' failed validation: {Errors}", definition.Id, string.Join("; ", errors)); + continue; + } + + _userOverlays.Add(definition); + } + catch (Exception ex) + { + Logger.Error(ex, "Failed to load overlay from '{Path}'", jsonPath); + } + } + + Logger.Info("Loaded {Count} user overlays", _userOverlays.Count); + } + + private static string? LoadEmbeddedResource(string resourceName) + { + var assembly = Assembly.GetExecutingAssembly(); + using var stream = assembly.GetManifestResourceStream(resourceName); + if (stream == null) return null; + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } + + private static PosterOverlayDefinition CreateFallbackDefinition() + { + Logger.Error("No overlays available. Creating minimal fallback definition."); + return new PosterOverlayDefinition + { + Id = "fallback", + DisplayName = "Fallback", + IsBuiltIn = true + }; + } +} diff --git a/FoliCon/Modules/Overlays/OverlayValidator.cs b/FoliCon/Modules/Overlays/OverlayValidator.cs new file mode 100644 index 00000000..66447956 --- /dev/null +++ b/FoliCon/Modules/Overlays/OverlayValidator.cs @@ -0,0 +1,186 @@ +using FoliCon.Models.Data; + +namespace FoliCon.Modules.Overlays; + +/// +/// Validates overlay.json files and overlay package folders. +/// +[Localizable(false)] +public static class OverlayValidator +{ + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + + /// + /// Validates an overlay definition and its assets in the given folder. + /// Returns a list of validation errors (empty if valid). + /// + public static List Validate(string overlayFolder, PosterOverlayDefinition definition) + { + var errors = new List(); + + // Schema version check + if (definition.SchemaVersion > OverlayConstants.AppSupportedSchemaVersion) + { + errors.Add($"Overlay '{definition.Id}' requires schema v{definition.SchemaVersion}, " + + $"app supports v{OverlayConstants.AppSupportedSchemaVersion}. Skipping."); + return errors; // Don't continue — app doesn't understand this schema + } + + // Required fields + if (string.IsNullOrWhiteSpace(definition.Id)) + errors.Add("Overlay 'id' is required."); + if (string.IsNullOrWhiteSpace(definition.DisplayName)) + errors.Add("Overlay 'displayName' is required."); + + // ID safety + if (!string.IsNullOrWhiteSpace(definition.Id) && !IsValidId(definition.Id)) + errors.Add($"Overlay 'id' '{definition.Id}' contains invalid characters. Use lowercase alphanumeric and hyphens."); + + // Validate base layer + if (definition.BaseLayer != null) + { + ValidateLayer(overlayFolder, definition.BaseLayer, "baseLayer", errors); + } + + // Validate front layer + if (definition.FrontLayer != null) + { + ValidateLayer(overlayFolder, definition.FrontLayer, "frontLayer", errors); + } + + // Validate poster config + if (definition.Poster != null) + { + ValidateMargin(definition.Poster.Margin, "poster.margin", errors); + if (definition.Poster.OpacityMaskPath != null) + { + var maskPath = Path.Combine(overlayFolder, definition.Poster.OpacityMaskPath); + if (!File.Exists(maskPath)) + errors.Add($"poster.opacityMaskPath '{definition.Poster.OpacityMaskPath}' file not found."); + else + ValidateImageSize(maskPath, "poster.opacityMask", errors); + } + } + + // Validate rating config + if (definition.Rating != null) + { + ValidateMargin(definition.Rating.ShieldMargin, "rating.shieldMargin", errors); + ValidateMargin(definition.Rating.TextMargin, "rating.textMargin", errors); + } + + // Validate title config + if (definition.Title != null && definition.Title.IsVisible) + { + ValidateMargin(definition.Title.Margin, "title.margin", errors); + } + + // Validate rotation origin + if (definition.Title != null && !string.IsNullOrWhiteSpace(definition.Title.RotationOrigin)) + { + ValidateRotationOrigin(definition.Title.RotationOrigin, errors); + } + + // Validate total package size + ValidatePackageSize(overlayFolder, errors); + + return errors; + } + + private static void ValidateLayer(string overlayFolder, LayerDefinition layer, string prefix, List errors) + { + if (string.IsNullOrWhiteSpace(layer.ImagePath)) + { + errors.Add($"{prefix}.imagePath is required when layer is defined."); + return; + } + + var imagePath = Path.Combine(overlayFolder, layer.ImagePath); + if (!File.Exists(imagePath)) + { + errors.Add($"{prefix}.imagePath '{layer.ImagePath}' file not found."); + return; + } + + ValidateImageSize(imagePath, prefix, errors); + ValidateMargin(layer.Margin, $"{prefix}.margin", errors); + } + + private static void ValidateImageSize(string imagePath, string prefix, List errors) + { + try + { + var fileInfo = new FileInfo(imagePath); + if (fileInfo.Length > OverlayConstants.MaxImageSizeBytes) + { + errors.Add($"{prefix} image '{imagePath}' exceeds maximum size " + + $"({fileInfo.Length / 1024.0 / 1024.0:F1} MB > {OverlayConstants.MaxImageSizeBytes / 1024.0 / 1024.0:F0} MB)."); + } + } + catch (Exception ex) + { + Logger.Warn(ex, "Failed to check image size for {ImagePath}", imagePath); + } + } + + private static void ValidateMargin(string margin, string field, List errors) + { + if (string.IsNullOrWhiteSpace(margin)) return; + + var parts = margin.Split(','); + if (parts.Length < 1 || parts.Length > 4) + { + errors.Add($"{field} '{margin}' is not a valid Thickness string. Expected 1-4 numeric values."); + return; + } + + foreach (var part in parts) + { + if (!double.TryParse(part.Trim(), CultureInfo.InvariantCulture, out _)) + { + errors.Add($"{field} contains non-numeric value '{part.Trim()}'."); + } + } + } + + private static void ValidateRotationOrigin(string origin, List errors) + { + var parts = origin.Split(','); + if (parts.Length != 2) + { + errors.Add($"title.rotationOrigin '{origin}' must be 'x,y' with exactly 2 values."); + return; + } + + foreach (var part in parts) + { + if (!double.TryParse(part.Trim(), CultureInfo.InvariantCulture, out var val) || val < 0.0 || val > 1.0) + { + errors.Add($"title.rotationOrigin value '{part.Trim()}' must be between 0.0 and 1.0."); + } + } + } + + private static void ValidatePackageSize(string overlayFolder, List errors) + { + try + { + var totalSize = Directory.GetFiles(overlayFolder, "*", SearchOption.AllDirectories) + .Sum(f => new FileInfo(f).Length); + if (totalSize > OverlayConstants.MaxOverlayPackageSizeBytes) + { + errors.Add($"Overlay folder exceeds maximum total size " + + $"({totalSize / 1024.0 / 1024.0:F1} MB > {OverlayConstants.MaxOverlayPackageSizeBytes / 1024.0 / 1024.0:F0} MB)."); + } + } + catch (Exception ex) + { + Logger.Warn(ex, "Failed to calculate package size for {OverlayFolder}", overlayFolder); + } + } + + private static bool IsValidId(string id) + { + return Regex.IsMatch(id, @"^[a-z0-9]([a-z0-9\-]*[a-z0-9])?$"); + } +} diff --git a/FoliCon/Modules/utils/IconUtils.cs b/FoliCon/Modules/utils/IconUtils.cs index f339fa7f..a048b221 100644 --- a/FoliCon/Modules/utils/IconUtils.cs +++ b/FoliCon/Modules/utils/IconUtils.cs @@ -24,7 +24,7 @@ public static async Task MakeIco(string iconMode, string selectedFolder, Li progressCallback.Report(extractionProgress); var lockObj = new object(); - var iconOverlay = GlobalVariables.IconOverlayType(); + var activeOverlay = GlobalVariables.GetActiveOverlay(); await Parallel.ForEachAsync(pickedListDataTable, async (item, _) => { var parent = Directory.GetParent(item.Folder); @@ -36,7 +36,7 @@ await Parallel.ForEachAsync(pickedListDataTable, async (item, _) => TryDeleteExistingIco(targetFile, forceOverwrite); var created = await TryCreateIconFromPng(pngFilePath, targetFile, forceOverwrite, item, - iconMode, ratingVisibility, mockupVisibility, iconOverlay); + iconMode, ratingVisibility, mockupVisibility, activeOverlay); ApplyFolderIcon(targetFile, folderName, parentFolder); @@ -73,7 +73,7 @@ private static void TryDeleteExistingIco(string targetFile, bool forceOverwrite) } private static async Task TryCreateIconFromPng(string pngFilePath, string targetFile, bool forceOverwrite, - PickedListItem item, string iconMode, string ratingVisibility, string mockupVisibility, IconOverlay iconOverlay) + PickedListItem item, string iconMode, string ratingVisibility, string mockupVisibility, PosterOverlayDefinition? overlayDefinition) { if (!FileUtils.FileExists(pngFilePath) || (FileUtils.FileExists(targetFile) && !forceOverwrite)) { @@ -81,7 +81,7 @@ private static async Task TryCreateIconFromPng(string pngFilePath, string } var iconProperties = new IconProperties(iconMode, pngFilePath, item.Rating, ratingVisibility, mockupVisibility, item.Title); - await BuildFolderIco(iconProperties, iconOverlay); + await BuildFolderIco(iconProperties, overlayDefinition); Logger.Info("Icon Created for Folder: {Folder}", item.FolderName); Logger.Debug("Deleting PNG File: {PngFilePath}", pngFilePath); @@ -108,7 +108,7 @@ private static void ApplyFolderIcon(string targetFile, string folderName, string /// Show rating or NOT /// Is Cover Mockup visible. /// Title of the media. - private static async Task BuildFolderIco(IconProperties iconProperties, IconOverlay iconOverlay) + private static async Task BuildFolderIco(IconProperties iconProperties, PosterOverlayDefinition? overlayDefinition) { Logger.Debug("Converting From PNG to ICO, {IconProperties}", iconProperties); var filmFolderPath = iconProperties.FilmFolderPath; @@ -136,31 +136,9 @@ private static async Task BuildFolderIco(IconProperties iconProperties, IconOver var mediaTitle = iconProperties.MediaTitle; // Use dedicated STA renderer to avoid WPF PackagePart race conditions // while still maintaining parallel processing through async queueing - icon = iconOverlay switch - { - IconOverlay.Legacy => await StaRenderer.Default.EnqueueRender(() => - new Views.PosterIcon(new PosterIcon(filmFolderPath, rating, ratingVisibility, mockupVisibility)) - .RenderToBitmap()), - IconOverlay.Alternate => await StaRenderer.Default.EnqueueRender(() => - new PosterIconAlt(new PosterIcon(filmFolderPath, rating, ratingVisibility, mockupVisibility)) - .RenderToBitmap()), - IconOverlay.Liaher => await StaRenderer.Default.EnqueueRender(() => - new PosterIconLiaher(new PosterIcon(filmFolderPath, rating, ratingVisibility, mockupVisibility)) - .RenderToBitmap()), - IconOverlay.Faelpessoal => await StaRenderer.Default.EnqueueRender(() => new PosterIconFaelpessoal(new PosterIcon( - filmFolderPath, rating, - ratingVisibility, mockupVisibility, mediaTitle)).RenderToBitmap()), - IconOverlay.FaelpessoalHorizontal => await StaRenderer.Default.EnqueueRender(() => new PosterIconFaelpessoalHorizontal( - new PosterIcon( - filmFolderPath, rating, - ratingVisibility, mockupVisibility, mediaTitle)).RenderToBitmap()), - IconOverlay.Windows11 => await StaRenderer.Default.EnqueueRender(() => - new PosterIconWindows11(new PosterIcon(filmFolderPath, rating, ratingVisibility, mockupVisibility)) - .RenderToBitmap()), - _ => await StaRenderer.Default.EnqueueRender(() => - new Views.PosterIcon(new PosterIcon(filmFolderPath, rating, ratingVisibility, mockupVisibility)) - .RenderToBitmap()) - }; + icon = await StaRenderer.Default.EnqueueRender(() => + new DynamicPosterIcon(overlayDefinition, new PosterIcon(filmFolderPath, rating, ratingVisibility, mockupVisibility, mediaTitle)) + .RenderToBitmap()); } Logger.Info("Converting PNG to ICO for Folder: {FilmFolderPath}", filmFolderPath); PngToIcoService.Convert(icon, filmFolderPath.Replace("png", "ico")); diff --git a/FoliCon/Resources/Overlays/alternate/overlay.json b/FoliCon/Resources/Overlays/alternate/overlay.json new file mode 100644 index 00000000..3e8428ea --- /dev/null +++ b/FoliCon/Resources/Overlays/alternate/overlay.json @@ -0,0 +1,54 @@ +{ + "schemaVersion": 1, + "id": "alternate", + "displayName": "Alternate", + "author": "FoliCon", + "description": "DVD case style overlay with base and front cover layers", + "overlayVersion": "1.0.0", + "tags": ["dvd", "classic", "physical"], + "designWidth": 265, + "designHeight": 256, + "rootMargin": "0,0,0,-11", + "renderWidth": 256, + "renderHeight": 256, + "layerOrder": ["base", "poster", "rating", "front"], + "baseLayer": { + "imagePath": "/Resources/poster_mockups/dvd/mockup2base.png", + "margin": "30,14,48,15" + }, + "frontLayer": { + "imagePath": "/Resources/poster_mockups/dvd/mockup cover cropped.png", + "margin": "16,14,35,15" + }, + "poster": { + "margin": "31,42,50,19", + "clipRadius": "0", + "opacityMaskPath": null + }, + "rating": { + "shieldMargin": "160,97,6,5", + "textMargin": "189,30,21,24", + "fontSize": 25, + "fontFamily": "Castellar", + "fontSource": null, + "fontFallback": "Segoe UI", + "textWidth": 55, + "textHeight": 46, + "textHorizontalAlignment": "Center", + "textVerticalAlignment": "Center" + }, + "title": { + "isVisible": false, + "margin": "0,0,0,0", + "rotationAngle": 0, + "rotationOrigin": "0.5,0.5", + "fontFamily": "Cormorant", + "fontSource": null, + "fontFallback": "Segoe UI", + "foreground": "White", + "trimming": "WordEllipsis", + "wrapping": "Wrap", + "horizontalAlignment": "Left", + "verticalAlignment": "Top" + } +} diff --git a/FoliCon/Resources/Overlays/faelpessoal-horizontal/overlay.json b/FoliCon/Resources/Overlays/faelpessoal-horizontal/overlay.json new file mode 100644 index 00000000..0c50ca35 --- /dev/null +++ b/FoliCon/Resources/Overlays/faelpessoal-horizontal/overlay.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 1, + "id": "faelpessoal-horizontal", + "displayName": "Faelpessoal Horizontal", + "author": "FoliCon", + "description": "Horizontal variant of Faelpessoal for landscape posters", + "overlayVersion": "1.0.0", + "tags": ["horizontal", "landscape", "text"], + "designWidth": 265, + "designHeight": 256, + "rootMargin": "0,0,0,-11", + "renderWidth": 256, + "renderHeight": 256, + "layerOrder": ["base", "poster", "title", "rating"], + "baseLayer": { + "imagePath": "/Resources/poster_mockups/faelpessoal/mockup faelpessoal base horizontal.png", + "margin": "-8,0,0,10" + }, + "frontLayer": null, + "poster": { + "margin": "1,65,9,34", + "clipRadius": "7", + "clipRect": "0,0,255,164", + "posterInnerMargin": null, + "opacityMaskPath": null + }, + "rating": { + "shieldMargin": "183,97,-17,5", + "textMargin": "209,30,0,0", + "fontSize": 25, + "fontFamily": "Castellar", + "fontSource": null, + "fontFallback": "Segoe UI", + "textWidth": 55, + "textHeight": 46, + "textHorizontalAlignment": "Left", + "textVerticalAlignment": "Top" + }, + "title": { + "isVisible": true, + "margin": "28,36,155,197", + "rotationAngle": 0, + "rotationOrigin": "0.5,0.5", + "fontFamily": "Cormorant", + "fontSource": null, + "fontFallback": "Segoe UI", + "foreground": "White", + "trimming": "WordEllipsis", + "wrapping": "Wrap", + "horizontalAlignment": "Left", + "verticalAlignment": "Top" + } +} diff --git a/FoliCon/Resources/Overlays/faelpessoal/overlay.json b/FoliCon/Resources/Overlays/faelpessoal/overlay.json new file mode 100644 index 00000000..0a351d26 --- /dev/null +++ b/FoliCon/Resources/Overlays/faelpessoal/overlay.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 1, + "id": "faelpessoal", + "displayName": "Faelpessoal", + "author": "FoliCon", + "description": "Faelpessoal style overlay with rotated title text on the spine", + "overlayVersion": "1.0.0", + "tags": ["spine", "text", "rotated"], + "designWidth": 265, + "designHeight": 256, + "rootMargin": "0,0,0,-11", + "renderWidth": 256, + "renderHeight": 256, + "layerOrder": ["base", "poster", "front", "title", "rating"], + "baseLayer": { + "imagePath": "/Resources/poster_mockups/faelpessoal/Mockup faelpessoal base.png", + "margin": "-6,0,-2,10" + }, + "frontLayer": { + "imagePath": "/Resources/poster_mockups/liaher/mockup liaher front.png", + "margin": "-9,-18,16,2" + }, + "poster": { + "margin": "27,4,53,27", + "clipRadius": "7", + "clipRect": "0,0,186,267", + "posterInnerMargin": "0,0,0,-1", + "opacityMaskPath": null + }, + "rating": { + "shieldMargin": "160,97,6,5", + "textMargin": "189,30,21,24", + "fontSize": 25, + "fontFamily": "Castellar", + "fontSource": null, + "fontFallback": "Segoe UI", + "textWidth": 55, + "textHeight": 46, + "textHorizontalAlignment": "Center", + "textVerticalAlignment": "Center" + }, + "title": { + "isVisible": true, + "margin": "190,14,-2,53", + "rotationAngle": 90, + "rotationOrigin": "0.5,0.5", + "fontFamily": "Cormorant", + "fontSource": null, + "fontFallback": "Segoe UI", + "foreground": "White", + "trimming": "WordEllipsis", + "wrapping": "Wrap", + "container": "RatingGrid", + "gridRow": 1, + "horizontalAlignment": "Stretch", + "verticalAlignment": "Stretch" + } +} diff --git a/FoliCon/Resources/Overlays/legacy/overlay.json b/FoliCon/Resources/Overlays/legacy/overlay.json new file mode 100644 index 00000000..a13264ea --- /dev/null +++ b/FoliCon/Resources/Overlays/legacy/overlay.json @@ -0,0 +1,51 @@ +{ + "schemaVersion": 1, + "id": "legacy", + "displayName": "Legacy", + "author": "FoliCon", + "description": "Original simple poster overlay with a minimal frame", + "overlayVersion": "1.0.0", + "tags": ["classic", "simple"], + "designWidth": 265, + "designHeight": 256, + "rootMargin": "0,0,0,-11", + "renderWidth": 256, + "renderHeight": 256, + "layerOrder": ["poster", "front", "rating"], + "baseLayer": null, + "frontLayer": { + "imagePath": "/Resources/poster_mockups/simple/PosterMockup.png", + "margin": "3,-40,16,-40" + }, + "poster": { + "margin": "56,16,47,22", + "clipRadius": "0", + "opacityMaskPath": null + }, + "rating": { + "shieldMargin": "160,97,6,5", + "textMargin": "189,30,21,24", + "fontSize": 25, + "fontFamily": "Castellar", + "fontSource": null, + "fontFallback": "Segoe UI", + "textWidth": 55, + "textHeight": 46, + "textHorizontalAlignment": "Center", + "textVerticalAlignment": "Center" + }, + "title": { + "isVisible": false, + "margin": "0,0,0,0", + "rotationAngle": 0, + "rotationOrigin": "0.5,0.5", + "fontFamily": "Cormorant", + "fontSource": null, + "fontFallback": "Segoe UI", + "foreground": "White", + "trimming": "WordEllipsis", + "wrapping": "Wrap", + "horizontalAlignment": "Left", + "verticalAlignment": "Top" + } +} diff --git a/FoliCon/Resources/Overlays/liaher/overlay.json b/FoliCon/Resources/Overlays/liaher/overlay.json new file mode 100644 index 00000000..1e2e2fc8 --- /dev/null +++ b/FoliCon/Resources/Overlays/liaher/overlay.json @@ -0,0 +1,56 @@ +{ + "schemaVersion": 1, + "id": "liaher", + "displayName": "Liaher", + "author": "FoliCon", + "description": "Liaher style overlay with rounded corner poster clip", + "overlayVersion": "1.0.0", + "tags": ["rounded", "classic"], + "designWidth": 265, + "designHeight": 256, + "rootMargin": "0,0,0,-11", + "renderWidth": 256, + "renderHeight": 256, + "layerOrder": ["base", "poster", "front", "rating"], + "baseLayer": { + "imagePath": "/Resources/poster_mockups/liaher/mockup liaher base.png", + "margin": "-8,0,0,10" + }, + "frontLayer": { + "imagePath": "/Resources/poster_mockups/liaher/mockup liaher front.png", + "margin": "0,-6,8,5" + }, + "poster": { + "margin": "34,10,43,21", + "clipRadius": "8", + "clipRect": "0,0,188,236", + "posterInnerMargin": null, + "opacityMaskPath": null + }, + "rating": { + "shieldMargin": "160,97,6,5", + "textMargin": "189,30,21,24", + "fontSize": 25, + "fontFamily": "Castellar", + "fontSource": null, + "fontFallback": "Segoe UI", + "textWidth": 55, + "textHeight": 46, + "textHorizontalAlignment": "Center", + "textVerticalAlignment": "Center" + }, + "title": { + "isVisible": false, + "margin": "0,0,0,0", + "rotationAngle": 0, + "rotationOrigin": "0.5,0.5", + "fontFamily": "Cormorant", + "fontSource": null, + "fontFallback": "Segoe UI", + "foreground": "White", + "trimming": "WordEllipsis", + "wrapping": "Wrap", + "horizontalAlignment": "Left", + "verticalAlignment": "Top" + } +} diff --git a/FoliCon/Resources/Overlays/windows11/overlay.json b/FoliCon/Resources/Overlays/windows11/overlay.json new file mode 100644 index 00000000..29228529 --- /dev/null +++ b/FoliCon/Resources/Overlays/windows11/overlay.json @@ -0,0 +1,51 @@ +{ + "schemaVersion": 1, + "id": "windows11", + "displayName": "Windows 11", + "author": "FoliCon", + "description": "Windows 11 style overlay with opacity mask effect", + "overlayVersion": "1.0.0", + "tags": ["modern", "windows11", "opacity"], + "designWidth": 265, + "designHeight": 256, + "rootMargin": "0,0,0,-11", + "renderWidth": 256, + "renderHeight": 256, + "layerOrder": ["base", "poster", "rating"], + "baseLayer": { + "imagePath": "/Resources/poster_mockups/win11/base.png", + "margin": "5,0,5,10" + }, + "frontLayer": null, + "poster": { + "margin": "5,58,5,32", + "clipRadius": "0", + "opacityMaskPath": "/Resources/poster_mockups/win11/front.png" + }, + "rating": { + "shieldMargin": "183,97,-17,5", + "textMargin": "209,30,0,0", + "fontSize": 25, + "fontFamily": "Castellar", + "fontSource": null, + "fontFallback": "Segoe UI", + "textWidth": 55, + "textHeight": 46, + "textHorizontalAlignment": "Left", + "textVerticalAlignment": "Top" + }, + "title": { + "isVisible": false, + "margin": "0,0,0,0", + "rotationAngle": 0, + "rotationOrigin": "0.5,0.5", + "fontFamily": "Cormorant", + "fontSource": null, + "fontFallback": "Segoe UI", + "foreground": "White", + "trimming": "WordEllipsis", + "wrapping": "Wrap", + "horizontalAlignment": "Left", + "verticalAlignment": "Top" + } +} diff --git a/FoliCon/ViewModels/PreviewerViewModel.cs b/FoliCon/ViewModels/PreviewerViewModel.cs index 219348e6..eacac837 100644 --- a/FoliCon/ViewModels/PreviewerViewModel.cs +++ b/FoliCon/ViewModels/PreviewerViewModel.cs @@ -8,30 +8,38 @@ public class PreviewerViewModel : BindableBase, IDialogAware { private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); - - public PreviewerViewModel() + + public PreviewerViewModel(DialogCloseListener requestClose) { - Logger.Debug("PosterIconConfigViewModel created"); + RequestClose = requestClose; + Logger.Debug("PreviewerViewModel created"); PosterIconInstance = new PosterIcon { Rating = Rating }; SelectImageCommand = new DelegateCommand(SelectImage); + + // Load all available overlays from the provider + OverlayDefinitions = []; + LoadOverlays(); } - + private PosterIcon _posterIconInstance; private string _rating = "3.5"; public string Title => Lang.Previewer; private string _mediaTitle = Lang.MadeWithFoliCon; private bool _ratingVisibility = true; private bool _overlayVisibility = true; - + public PosterIcon PosterIconInstance { get => _posterIconInstance; private set => SetProperty(ref _posterIconInstance, value); } + + private ObservableCollection OverlayDefinitions { get; } + public string Rating { get => _rating; @@ -51,7 +59,7 @@ public string MediaTitle PosterIconInstance.MediaTitle = value; } } - + public bool RatingVisibility { get => _ratingVisibility; @@ -61,7 +69,7 @@ public bool RatingVisibility PosterIconInstance.RatingVisibility = UiUtils.BooleanToVisibility(value).ToString(); } } - + public bool OverlayVisibility { get => _overlayVisibility; @@ -71,9 +79,26 @@ public bool OverlayVisibility PosterIconInstance.MockupVisibility = UiUtils.BooleanToVisibility(value).ToString(); } } - + public DelegateCommand SelectImageCommand { get; set; } - + + private void LoadOverlays() + { + try + { + var provider = GlobalVariables.OverlayProvider; + var allOverlays = provider.GetAllOverlays(); + OverlayDefinitions.Clear(); + foreach (var overlay in allOverlays) + { + OverlayDefinitions.Add(overlay); + } + } + catch (Exception ex) + { + Logger.Error(ex, "Failed to load overlays for previewer"); + } + } private void SelectImage() { @@ -95,8 +120,8 @@ private void SelectImage() PosterIconInstance = rt; Logger.Info("Image selected: {FileName}", fileDialog.FileName); } - - + + #region DialogMethods public DialogCloseListener RequestClose { get; } protected virtual void CloseDialog(string parameter) diff --git a/FoliCon/ViewModels/posterIconConfigViewModel.cs b/FoliCon/ViewModels/posterIconConfigViewModel.cs index 313964f2..52b593ed 100644 --- a/FoliCon/ViewModels/posterIconConfigViewModel.cs +++ b/FoliCon/ViewModels/posterIconConfigViewModel.cs @@ -14,27 +14,71 @@ public class PosterIconConfigViewModel : BindableBase, IDialogAware public PosterIconConfigViewModel() { Logger.Debug("PosterIconConfigViewModel created"); + + // Initialize collections BEFORE tracker restores persisted values + AvailableOverlays = []; + Services.Tracker.Configure() .Property(p => p.IconOverlay, defaultValue: Models.Enums.IconOverlay.Liaher.ToString()) .PersistOn(nameof(PropertyChanged)); Services.Tracker.Track(this); Logger.Info("Current IconOverlay is {IconOverlay}", IconOverlay); + + // Load available overlays from the provider + LoadOverlays(); + IconOverlayChangedCommand = new DelegateCommand(delegate(object parameter) { Logger.Info("Icon overlay changed to {Parameter}", parameter); IconOverlay = (string)parameter; - }); } public string IconOverlay { get => _iconOverlay; - set => SetProperty(ref _iconOverlay, value); + set + { + if (!SetProperty(ref _iconOverlay, value) || AvailableOverlays == null) return; + // Update IsActive on overlay items + foreach (var item in AvailableOverlays) + { + item.IsActive = string.Equals(item.OverlayId, value, StringComparison.OrdinalIgnoreCase); + } + } } + public ObservableCollection AvailableOverlays { get; } + public string Title => Lang.SelectPosterIconOverlay; + private void LoadOverlays() + { + try + { + var provider = GlobalVariables.OverlayProvider; + var allOverlays = provider.GetAllOverlays(); + + AvailableOverlays.Clear(); + foreach (var overlay in allOverlays) + { + var item = new OverlayItemViewModel + { + OverlayId = overlay.Id, + DisplayName = overlay.DisplayName, + IsBuiltIn = overlay.IsBuiltIn, + IsActive = string.Equals(overlay.Id, IconOverlay, StringComparison.OrdinalIgnoreCase), + Tags = overlay.Tags + }; + AvailableOverlays.Add(item); + } + } + catch (Exception ex) + { + Logger.Error(ex, "Failed to load overlays"); + } + } + #region DialogMethods public DialogCloseListener RequestClose { get; } @@ -64,3 +108,35 @@ public virtual void OnDialogOpened(IDialogParameters parameters) #endregion DialogMethods } + +/// +/// ViewModel for an individual overlay item in the config dialog. +/// +[Localizable(false)] +public class OverlayItemViewModel : BindableBase +{ + public string OverlayId { get; init; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public bool IsBuiltIn { get; set; } + public string[] Tags { get; set; } = []; + + public bool IsActive + { + get; + set => SetProperty(ref field, value); + } + + /// + /// Returns the demo icon path for built-in overlays. + /// + public string DemoIconPath => OverlayId switch + { + "legacy" => "/Resources/mockup_demos/simple/PosterIcon.ico", + "alternate" => "/Resources/mockup_demos/dvd/PosterIconAlt.ico", + "liaher" => "/Resources/mockup_demos/liaher/PosterIconLiaher.ico", + "faelpessoal" => "/Resources/mockup_demos/faelpessoal/PosterIconFaelpessoal.ico", + "faelpessoal-horizontal" => "/Resources/mockup_demos/faelpessoal/PosterIconFaelpessoalHorizontal.ico", + "windows11" => "/Resources/mockup_demos/windows11/PosterIconWindows11.ico", + _ => "/Resources/icons/NoPosterAvailable.png" + }; +} diff --git a/FoliCon/Views/DynamicPosterIcon.xaml b/FoliCon/Views/DynamicPosterIcon.xaml new file mode 100644 index 00000000..d90b1a8a --- /dev/null +++ b/FoliCon/Views/DynamicPosterIcon.xaml @@ -0,0 +1,6 @@ + + diff --git a/FoliCon/Views/DynamicPosterIcon.xaml.cs b/FoliCon/Views/DynamicPosterIcon.xaml.cs new file mode 100644 index 00000000..e054bcf3 --- /dev/null +++ b/FoliCon/Views/DynamicPosterIcon.xaml.cs @@ -0,0 +1,454 @@ +using FoliCon.Models.Data; +using Brush = System.Windows.Media.Brush; +using Brushes = System.Windows.Media.Brushes; +using Point = System.Windows.Point; +using FontFamily = System.Windows.Media.FontFamily; + +namespace FoliCon.Views; + +/// +/// Generic data-driven poster icon renderer that builds its visual tree +/// from a PosterOverlayDefinition at runtime. +/// +public partial class DynamicPosterIcon : PosterIconBase +{ + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + + private const string ShieldImagePath = "/Resources/rating_mockup/shield.png"; + + private static readonly string[] DefaultLayerOrder = ["base", "poster", "front", "rating", "title"]; + + public DynamicPosterIcon(PosterOverlayDefinition definition, object dataContext) + : base(dataContext) + { + InitializeComponent(); + DataContext = dataContext; + Width = definition.DesignWidth; + Height = definition.DesignHeight; + BuildVisualTree(definition, dataContext); + } + + private void BuildVisualTree(PosterOverlayDefinition definition, object dataContext) + { + var rootMargin = ParseThickness(definition.RootMargin); + var rootGrid = new Grid { Margin = rootMargin }; + + // Cache created elements by key + var elements = new Dictionary(); + + // --- Base Layer --- + if (definition.BaseLayer != null && !string.IsNullOrEmpty(definition.BaseLayer.ImagePath)) + { + var baseImage = CreateLayerImage(definition.BaseLayer.ImagePath, definition.BaseLayer.Margin); + if (baseImage != null) + { + baseImage.SetBinding(VisibilityProperty, new Binding("MockupVisibility") { Source = dataContext }); + elements["base"] = baseImage; + } + } + + // --- Poster Image (with optional clip and opacity mask) --- + var posterElement = CreatePosterElement(definition); + if (posterElement != null) + elements["poster"] = posterElement; + + // --- Front Layer --- + if (definition.FrontLayer != null && !string.IsNullOrEmpty(definition.FrontLayer.ImagePath)) + { + var frontImage = CreateLayerImage(definition.FrontLayer.ImagePath, definition.FrontLayer.Margin); + if (frontImage != null) + { + frontImage.SetBinding(VisibilityProperty, new Binding("MockupVisibility") { Source = dataContext }); + elements["front"] = frontImage; + } + } + + // --- Title Text --- + TextBlock? titleBlock = null; + var titleInRatingGrid = false; + var titleGridRow = 0; + if (definition.Title != null && definition.Title.IsVisible) + { + titleBlock = CreateTitleText(definition.Title, dataContext); + titleInRatingGrid = titleBlock != null && + string.Equals(definition.Title.Container, "RatingGrid", StringComparison.OrdinalIgnoreCase); + titleGridRow = definition.Title.GridRow; + } + + // --- Rating Grid --- + elements["rating"] = CreateRatingGrid( + definition.Rating, + dataContext, + titleInRatingGrid ? titleBlock : null, + titleGridRow); + + if (titleBlock != null && !titleInRatingGrid) + elements["title"] = titleBlock; + + // --- Add children in the order specified by LayerOrder (matches original XAML z-order) --- + var layerOrder = definition.LayerOrder ?? DefaultLayerOrder; + foreach (var key in layerOrder) + { + if (elements.TryGetValue(key, out var element)) + rootGrid.Children.Add(element); + } + + Content = rootGrid; + } + + private Image? CreateLayerImage(string imagePath, string margin) + { + try + { + // No Stretch — matches original XAML where base/front images have no Stretch attribute. + // WPF default is Stretch.Uniform (natural size, aspect ratio preserved). + var image = new Image + { + Source = ResolveImageSource(imagePath), + Margin = ParseThickness(margin) + }; + RenderOptions.SetBitmapScalingMode(image, BitmapScalingMode.HighQuality); + return image; + } + catch (Exception ex) + { + Logger.Warn(ex, "Failed to create layer image for '{ImagePath}'", imagePath); + return null; + } + } + + private UIElement? CreatePosterElement(PosterOverlayDefinition definition) + { + var hasClip = definition.Poster.ClipRadius != "0" && + !string.IsNullOrWhiteSpace(definition.Poster.ClipRadius); + var hasOpacityMask = !string.IsNullOrEmpty(definition.Poster.OpacityMaskPath); + + // No clip and no opacity mask — plain Image with margin (direct child of root Grid) + if (!hasClip && !hasOpacityMask) + { + var posterImage = new Image + { + Source = GetPosterImageSource(), + Stretch = Stretch.Fill, + Margin = ParseThickness(definition.Poster.Margin) + }; + RenderOptions.SetBitmapScalingMode(posterImage, BitmapScalingMode.HighQuality); + return posterImage; + } + + // Opacity mask only (no clip) — plain Image with margin and OpacityMask, no Border wrapper. + // Matches original XAML where Windows11 poster is a direct Image in the Grid. + if (!hasClip && hasOpacityMask) + { + var posterImage = new Image + { + Source = GetPosterImageSource(), + Stretch = Stretch.Fill, + Margin = ParseThickness(definition.Poster.Margin) + }; + posterImage.OpacityMask = new ImageBrush(ResolveImageSource(definition.Poster.OpacityMaskPath!)) + { + Stretch = Stretch.Fill + }; + RenderOptions.SetBitmapScalingMode(posterImage, BitmapScalingMode.HighQuality); + return posterImage; + } + + // Clip (with optional opacity mask) — wrap in Border. + // The Border gets the margin and clip; the Image inside may have its own margin (PosterInnerMargin). + var border = new Border + { + Background = Brushes.Transparent, + Margin = ParseThickness(definition.Poster.Margin) + }; + + var posterImageInner = new Image + { + Source = GetPosterImageSource(), + Stretch = Stretch.Fill + }; + + // Some overlays need a small margin on the inner Image (e.g. faelpessoal "0,0,0,-1") + if (!string.IsNullOrWhiteSpace(definition.Poster.PosterInnerMargin)) + posterImageInner.Margin = ParseThickness(definition.Poster.PosterInnerMargin); + + RenderOptions.SetBitmapScalingMode(posterImageInner, BitmapScalingMode.HighQuality); + + var cornerRadius = ParseCornerRadius(definition.Poster.ClipRadius); + border.CornerRadius = cornerRadius; + + // Use explicit ClipRect if provided, otherwise calculate from margins. + if (!string.IsNullOrWhiteSpace(definition.Poster.ClipRect)) + { + var rectParts = definition.Poster.ClipRect!.Split(','); + if (rectParts.Length == 4) + { + var rx = ParseDouble(rectParts[0]); + var ry = ParseDouble(rectParts[1]); + var rw = ParseDouble(rectParts[2]); + var rh = ParseDouble(rectParts[3]); + border.Clip = new RectangleGeometry( + new Rect(rx, ry, rw, rh), + cornerRadius.TopLeft, cornerRadius.TopLeft); + } + } + else + { + // Fallback: calculate from design dimensions and margins + var rootMargin = ParseThickness(definition.RootMargin); + var posterMargin = ParseThickness(definition.Poster.Margin); + var clipWidth = definition.DesignWidth - posterMargin.Left - posterMargin.Right; + var effectiveHeight = definition.DesignHeight + + Math.Abs(Math.Min(0, rootMargin.Top)) + + Math.Abs(Math.Min(0, rootMargin.Bottom)); + var clipHeight = effectiveHeight - posterMargin.Top - posterMargin.Bottom; + + border.Clip = new RectangleGeometry( + new Rect(0, 0, clipWidth, clipHeight), + cornerRadius.TopLeft, cornerRadius.TopLeft); + } + + if (hasOpacityMask) + { + posterImageInner.OpacityMask = new ImageBrush(ResolveImageSource(definition.Poster.OpacityMaskPath!)) + { + Stretch = Stretch.Fill + }; + } + + border.Child = posterImageInner; + return border; + } + + private Grid CreateRatingGrid( + RatingConfig rating, + object dataContext, + TextBlock? titleBlock = null, + int titleGridRow = 0) + { + var grid = new Grid(); + grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); + grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(100) }); + grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(100) }); + + if (titleBlock != null) + { + Grid.SetRow(titleBlock, Math.Clamp(titleGridRow, 0, grid.RowDefinitions.Count - 1)); + grid.Children.Add(titleBlock); + } + + var shield = new Image + { + Source = ResolveImageSource(ShieldImagePath), + Margin = ParseThickness(rating.ShieldMargin) + }; + RenderOptions.SetBitmapScalingMode(shield, BitmapScalingMode.HighQuality); + Panel.SetZIndex(shield, 2); + Grid.SetRow(shield, 1); + Grid.SetRowSpan(shield, 2); + shield.SetBinding(VisibilityProperty, new Binding("RatingVisibility") { Source = dataContext }); + grid.Children.Add(shield); + + var ratingText = new TextBlock + { + // Use font name directly — WPF's built-in fallback chain handles missing fonts, + // matching the original XAML behavior exactly. + FontFamily = new FontFamily(rating.FontFamily), + FontStyle = FontStyles.Italic, + FontSize = rating.FontSize, + Foreground = Brushes.Black, + Width = rating.TextWidth, + Height = rating.TextHeight, + Margin = ParseThickness(rating.TextMargin) + }; + Panel.SetZIndex(ratingText, 3); + Grid.SetRow(ratingText, 2); + ratingText.HorizontalAlignment = ParseHorizontalAlignment(rating.TextHorizontalAlignment); + ratingText.VerticalAlignment = ParseVerticalAlignment(rating.TextVerticalAlignment); + ratingText.SetBinding(VisibilityProperty, new Binding("RatingVisibility") { Source = dataContext }); + ratingText.SetBinding(TextBlock.TextProperty, new Binding("Rating") { Source = dataContext }); + grid.Children.Add(ratingText); + + return grid; + } + + private TextBlock? CreateTitleText(TitleConfig title, object dataContext) + { + try + { + var textBlock = new TextBlock + { + // Use font name directly — WPF's built-in fallback chain handles missing fonts. + FontFamily = new FontFamily(title.FontFamily), + Foreground = ParseBrush(title.Foreground), + Margin = ParseThickness(title.Margin), + HorizontalAlignment = ParseHorizontalAlignment(title.HorizontalAlignment), + VerticalAlignment = ParseVerticalAlignment(title.VerticalAlignment) + }; + + textBlock.SetBinding(TextBlock.TextProperty, new Binding("MediaTitle") { Source = dataContext }); + textBlock.SetBinding(VisibilityProperty, new Binding("MockupVisibility") { Source = dataContext }); + + textBlock.TextWrapping = title.Wrapping switch + { + "Wrap" => TextWrapping.Wrap, + "WrapWithOverflow" => TextWrapping.WrapWithOverflow, + _ => TextWrapping.NoWrap + }; + + textBlock.TextTrimming = title.Trimming switch + { + "WordEllipsis" => TextTrimming.WordEllipsis, + "CharacterEllipsis" => TextTrimming.CharacterEllipsis, + _ => TextTrimming.None + }; + + if (Math.Abs(title.RotationAngle) > 0.01) + { + var origin = ParsePoint(title.RotationOrigin); + textBlock.RenderTransformOrigin = origin; + // Use full TransformGroup matching original XAML structure: + // ScaleTransform + SkewTransform + RotateTransform + TranslateTransform + var transformGroup = new TransformGroup(); + transformGroup.Children.Add(new ScaleTransform()); + transformGroup.Children.Add(new SkewTransform()); + transformGroup.Children.Add(new RotateTransform(title.RotationAngle)); + transformGroup.Children.Add(new TranslateTransform()); + textBlock.RenderTransform = transformGroup; + } + + return textBlock; + } + catch (Exception ex) + { + Logger.Warn(ex, "Failed to create title text"); + return null; + } + } + + private static ImageSource ResolveImageSource(string path) + { + if (path.StartsWith("/", StringComparison.Ordinal)) + { + // Use explicit pack URI with assembly name — works on any thread + // (relative URIs depend on Application.Current.BaseUri which may not + // be available on the StaRenderer's background STA thread). + var packUri = new Uri($"pack://application:,,,/FoliCon;component{path}", UriKind.Absolute); + var bitmap = new BitmapImage(); + bitmap.BeginInit(); + bitmap.UriSource = packUri; + bitmap.CacheOption = BitmapCacheOption.OnLoad; + bitmap.EndInit(); + bitmap.Freeze(); + return bitmap; + } + + var fullPath = Path.IsPathRooted(path) ? path : Path.Combine(AppContext.BaseDirectory, path); + if (File.Exists(fullPath)) + { + var bitmap = new BitmapImage(); + bitmap.BeginInit(); + bitmap.UriSource = new Uri(fullPath, UriKind.Absolute); + bitmap.CacheOption = BitmapCacheOption.OnLoad; + bitmap.EndInit(); + bitmap.Freeze(); + return bitmap; + } + + var resourcePath = FileUtils.GetResourcePath(path); + if (File.Exists(resourcePath)) + { + var bytes = File.ReadAllBytes(resourcePath); + using var stream = new MemoryStream(bytes); + return (ImageSource)new ImageSourceConverter().ConvertFrom(stream); + } + + throw new FileNotFoundException($"Image not found: {path}"); + } + + private ImageSource? GetPosterImageSource() + { + if (DataContext is FoliCon.Models.Data.PosterIcon posterIcon) + return posterIcon.FolderJpg; + return null; + } + + #region Parsing Helpers + + private static Thickness ParseThickness(string margin) + { + if (string.IsNullOrWhiteSpace(margin)) + return new Thickness(0); + + var parts = margin.Split(','); + return parts.Length switch + { + 1 => new Thickness(ParseDouble(parts[0])), + 2 => new Thickness(ParseDouble(parts[0]), ParseDouble(parts[1]), + ParseDouble(parts[0]), ParseDouble(parts[1])), + 3 => new Thickness(ParseDouble(parts[0]), ParseDouble(parts[1]), + ParseDouble(parts[2]), ParseDouble(parts[1])), + 4 => new Thickness(ParseDouble(parts[0]), ParseDouble(parts[1]), + ParseDouble(parts[2]), ParseDouble(parts[3])), + _ => new Thickness(0) + }; + } + + private static double ParseDouble(string value) => + double.TryParse(value.Trim(), CultureInfo.InvariantCulture, out var result) ? result : 0; + + private static CornerRadius ParseCornerRadius(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return new CornerRadius(0); + + var parts = value.Split(','); + return parts.Length switch + { + 1 => new CornerRadius(ParseDouble(parts[0])), + 4 => new CornerRadius(ParseDouble(parts[0]), ParseDouble(parts[1]), + ParseDouble(parts[2]), ParseDouble(parts[3])), + _ => new CornerRadius(ParseDouble(parts[0])) + }; + } + + private static Point ParsePoint(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return new Point(0.5, 0.5); + + var parts = value.Split(','); + if (parts.Length == 2 && + double.TryParse(parts[0].Trim(), CultureInfo.InvariantCulture, out var x) && + double.TryParse(parts[1].Trim(), CultureInfo.InvariantCulture, out var y)) + return new Point(x, y); + + return new Point(0.5, 0.5); + } + + private static Brush ParseBrush(string color) + { + try { return (Brush)new BrushConverter().ConvertFromString(color); } + catch { return Brushes.White; } + } + + private static HorizontalAlignment ParseHorizontalAlignment(string value) => + value?.ToLowerInvariant() switch + { + "center" => HorizontalAlignment.Center, + "right" => HorizontalAlignment.Right, + "stretch" => HorizontalAlignment.Stretch, + _ => HorizontalAlignment.Left + }; + + private static VerticalAlignment ParseVerticalAlignment(string value) => + value?.ToLowerInvariant() switch + { + "center" => VerticalAlignment.Center, + "bottom" => VerticalAlignment.Bottom, + "stretch" => VerticalAlignment.Stretch, + _ => VerticalAlignment.Top + }; + + #endregion +} diff --git a/FoliCon/Views/posterIconConfig.xaml b/FoliCon/Views/posterIconConfig.xaml index a169529b..30f4ca27 100644 --- a/FoliCon/Views/posterIconConfig.xaml +++ b/FoliCon/Views/posterIconConfig.xaml @@ -9,6 +9,7 @@ xmlns:hc="https://handyorg.github.io/handycontrol" xmlns:extension="clr-namespace:FoliCon.Modules.Extension" xmlns:convertor="clr-namespace:FoliCon.Modules.Convertor" + xmlns:sys="clr-namespace:System.Windows;assembly=PresentationFramework" mc:Ignorable="d" prism:ViewModelLocator.AutoWireViewModel="True" d:DataContext="{d:DesignInstance modules:PosterIconConfigViewModel }"> @@ -19,9 +20,13 @@ @@ -32,71 +37,50 @@ Foreground="Transparent" BorderThickness="0" Background="{DynamicResource RegionBrush}" HorizontalContentAlignment="Stretch" HorizontalAlignment="Center"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + +