diff --git a/src/SharpEmu.GUI/App.axaml b/src/SharpEmu.GUI/App.axaml index 1638b9d..8b73f44 100644 --- a/src/SharpEmu.GUI/App.axaml +++ b/src/SharpEmu.GUI/App.axaml @@ -11,7 +11,12 @@ SPDX-License-Identifier: GPL-2.0-or-later #7C5CFC - + + + + + + @@ -24,6 +29,8 @@ SPDX-License-Identifier: GPL-2.0-or-later + + @@ -38,7 +45,7 @@ SPDX-License-Identifier: GPL-2.0-or-later - + @@ -61,6 +68,10 @@ SPDX-License-Identifier: GPL-2.0-or-later + + + + + + + + + + - + + + + + + + diff --git a/src/SharpEmu.GUI/GameEntry.cs b/src/SharpEmu.GUI/GameEntry.cs index ea27d3b..c13732a 100644 --- a/src/SharpEmu.GUI/GameEntry.cs +++ b/src/SharpEmu.GUI/GameEntry.cs @@ -1,13 +1,147 @@ // Copyright (C) 2026 SharpEmu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later +using System.ComponentModel; +using Avalonia; +using Avalonia.Media; +using Avalonia.Media.Imaging; + namespace SharpEmu.GUI; -public sealed record GameEntry(string Name, string? TitleId, string Path, long SizeBytes) +public sealed class GameEntry : INotifyPropertyChanged { + // Placeholder gradients for games without cover art, picked + // deterministically from the game name so a game keeps its color. + private static readonly (Color Start, Color End)[] PlaceholderPalette = + { + (Color.Parse("#5B4B8A"), Color.Parse("#2C2A4A")), + (Color.Parse("#1F6E8C"), Color.Parse("#173B45")), + (Color.Parse("#7A4069"), Color.Parse("#3B1C32")), + (Color.Parse("#2D6A4F"), Color.Parse("#1B3A2B")), + (Color.Parse("#8C5425"), Color.Parse("#4A2B12")), + (Color.Parse("#4F6D9E"), Color.Parse("#263349")), + (Color.Parse("#8A4B4B"), Color.Parse("#3F2222")), + (Color.Parse("#3E7C7B"), Color.Parse("#1E3D3C")), + }; + + private Bitmap? _cover; + private IBrush? _placeholderBrush; + private long _sizeBytes; + + public GameEntry( + string name, string? titleId, string path, long sizeBytes, string? coverPath, string? backgroundPath) + { + Name = name; + TitleId = titleId; + Path = path; + _sizeBytes = sizeBytes; + CoverPath = coverPath; + BackgroundPath = backgroundPath; + Initials = ComputeInitials(name); + } + + public event PropertyChangedEventHandler? PropertyChanged; + + public string Name { get; } + + public string? TitleId { get; } + + public string Path { get; } + + /// + /// Total size of the game. Initially the eboot's own size from the scan; + /// replaced with the full install folder size once computed in the + /// background. + /// + public long SizeBytes + { + get => _sizeBytes; + set + { + if (_sizeBytes == value) + { + return; + } + + _sizeBytes = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SizeBytes))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Detail))); + } + } + + /// Path to the cover art image shipped with the game, if found. + public string? CoverPath { get; } + + /// Path to the key art (pic0/pic1) shipped with the game, if found. + public string? BackgroundPath { get; } + + /// + /// Decoded key art used as the window backdrop while this game is + /// selected. Loaded on demand and cached; not exposed via binding. + /// + public Bitmap? Background { get; set; } + + public string Initials { get; } + + // Built lazily: brushes are AvaloniaObjects that must be created on the + // UI thread, while GameEntry itself is constructed on the scan thread. + public IBrush PlaceholderBrush => _placeholderBrush ??= BuildPlaceholderBrush(Name); + + /// Decoded cover art; loaded asynchronously after the library scan. + public Bitmap? Cover + { + get => _cover; + set + { + if (ReferenceEquals(_cover, value)) + { + return; + } + + _cover = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Cover))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HasCover))); + } + } + + public bool HasCover => _cover is not null; + public string Detail => TitleId is not null ? $"{TitleId} • {FormatSize(SizeBytes)}" - : $"{FormatSize(SizeBytes)} • {Path}"; + : FormatSize(SizeBytes); + + private static string ComputeInitials(string name) + { + var initials = name + .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(word => char.IsLetterOrDigit(word[0])) + .Select(word => char.ToUpperInvariant(word[0])) + .Take(2) + .ToArray(); + + return initials.Length > 0 ? new string(initials) : "?"; + } + + private static IBrush BuildPlaceholderBrush(string name) + { + var hash = 0; + foreach (var ch in name) + { + hash = unchecked(hash * 31 + ch); + } + + var (start, end) = PlaceholderPalette[(int)((uint)hash % PlaceholderPalette.Length)]; + return new LinearGradientBrush + { + StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative), + EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative), + GradientStops = + { + new GradientStop(start, 0), + new GradientStop(end, 1), + }, + }; + } private static string FormatSize(long bytes) { diff --git a/src/SharpEmu.GUI/GuiSettings.cs b/src/SharpEmu.GUI/GuiSettings.cs index bc31717..5ffaf3b 100644 --- a/src/SharpEmu.GUI/GuiSettings.cs +++ b/src/SharpEmu.GUI/GuiSettings.cs @@ -14,6 +14,9 @@ public sealed class GuiSettings public List GameFolders { get; set; } = new(); + /// Eboot paths hidden from the library via "Remove from library". + public List ExcludedGames { get; set; } = new(); + public string LogLevel { get; set; } = "Info"; public int ImportTraceLimit { get; set; } diff --git a/src/SharpEmu.GUI/MainWindow.axaml b/src/SharpEmu.GUI/MainWindow.axaml index 2ebd422..3d8ad76 100644 --- a/src/SharpEmu.GUI/MainWindow.axaml +++ b/src/SharpEmu.GUI/MainWindow.axaml @@ -7,8 +7,8 @@ SPDX-License-Identifier: GPL-2.0-or-later xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" x:Class="SharpEmu.GUI.MainWindow" Title="SharpEmu" - Width="1280" Height="800" - MinWidth="980" MinHeight="620" + Width="1280" Height="820" + MinWidth="980" MinHeight="640" WindowStartupLocation="CenterScreen" Background="{StaticResource BgBrush}" ExtendClientAreaToDecorationsHint="True" @@ -18,6 +18,28 @@ SPDX-License-Identifier: GPL-2.0-or-later + + + + + + + + + + + + + + + + + + + + @@ -30,119 +52,204 @@ SPDX-License-Identifier: GPL-2.0-or-later - + - - - - - - - - + + + + + + + +