diff --git a/src/SharpEmu.GUI/GameEntry.cs b/src/SharpEmu.GUI/GameEntry.cs index b63e11cb..e0d2cc37 100644 --- a/src/SharpEmu.GUI/GameEntry.cs +++ b/src/SharpEmu.GUI/GameEntry.cs @@ -8,6 +8,15 @@ using Avalonia.Media.Imaging; namespace SharpEmu.GUI; +[Flags] +internal enum GameEntryChanges +{ + None = 0, + Metadata = 1, + Cover = 2, + Background = 4, +} + public sealed class GameEntry : INotifyPropertyChanged { // Placeholder gradients for games without cover art, picked @@ -27,29 +36,39 @@ public sealed class GameEntry : INotifyPropertyChanged private Bitmap? _cover; private IBrush? _placeholderBrush; private long _sizeBytes; + private string _name; + private string? _titleId; + private string? _version; + private string? _coverPath; + private string? _backgroundPath; + private string _initials; + private FileStamp _coverStamp; + private FileStamp _backgroundStamp; public GameEntry( string name, string? titleId, string? version, string path, long sizeBytes, string? coverPath, string? backgroundPath) { - Name = name; - TitleId = titleId; - Version = version; + _name = name; + _titleId = titleId; + _version = version; Path = path; _sizeBytes = sizeBytes; - CoverPath = coverPath; - BackgroundPath = backgroundPath; - Initials = ComputeInitials(name); + _coverPath = coverPath; + _backgroundPath = backgroundPath; + _initials = ComputeInitials(name); + _coverStamp = FileStamp.Read(coverPath); + _backgroundStamp = FileStamp.Read(backgroundPath); } public event PropertyChangedEventHandler? PropertyChanged; - public string Name { get; } + public string Name => _name; - public string? TitleId { get; } + public string? TitleId => _titleId; /// Content version from sce_sys/param.json, e.g. "01.000.000". - public string? Version { get; } + public string? Version => _version; public string Path { get; } @@ -75,10 +94,10 @@ public sealed class GameEntry : INotifyPropertyChanged } /// Path to the cover art image shipped with the game, if found. - public string? CoverPath { get; } + public string? CoverPath => _coverPath; /// Path to the key art (pic0/pic1) shipped with the game, if found. - public string? BackgroundPath { get; } + public string? BackgroundPath => _backgroundPath; /// /// Decoded key art used as the window backdrop while this game is @@ -86,7 +105,7 @@ public sealed class GameEntry : INotifyPropertyChanged /// public Bitmap? Background { get; set; } - public string Initials { get; } + public string Initials => _initials; // Built lazily: brushes are AvaloniaObjects that must be created on the // UI thread, while GameEntry itself is constructed on the scan thread. @@ -121,6 +140,74 @@ public sealed class GameEntry : INotifyPropertyChanged /// Formatted install size badge shown in the launch bar. public string SizeText => FormatSize(SizeBytes); + /// + /// Applies a fresh filesystem scan to this presentation object so its + /// ListBox container and selection remain stable + /// + internal GameEntryChanges UpdateFrom(GameEntry scanned) + { + if (!Path.Equals(scanned.Path, GameLibraryPath.Comparison)) + { + throw new ArgumentException("Only matching game paths can be reconciled", nameof(scanned)); + } + + var changes = GameEntryChanges.None; + if (!string.Equals(_name, scanned.Name, StringComparison.Ordinal)) + { + _name = scanned.Name; + _initials = ComputeInitials(scanned.Name); + _placeholderBrush = null; + RaisePropertyChanged(nameof(Name)); + RaisePropertyChanged(nameof(Initials)); + RaisePropertyChanged(nameof(PlaceholderBrush)); + changes |= GameEntryChanges.Metadata; + } + + if (!string.Equals(_titleId, scanned.TitleId, StringComparison.Ordinal)) + { + _titleId = scanned.TitleId; + RaisePropertyChanged(nameof(TitleId)); + RaisePropertyChanged(nameof(HasTitleId)); + changes |= GameEntryChanges.Metadata; + } + + if (!string.Equals(_version, scanned.Version, StringComparison.Ordinal)) + { + _version = scanned.Version; + RaisePropertyChanged(nameof(Version)); + RaisePropertyChanged(nameof(VersionText)); + RaisePropertyChanged(nameof(HasVersion)); + changes |= GameEntryChanges.Metadata; + } + + if (!string.Equals(_coverPath, scanned.CoverPath, StringComparison.Ordinal) + || _coverStamp != scanned._coverStamp) + { + _coverPath = scanned.CoverPath; + _coverStamp = scanned._coverStamp; + var previousCover = Cover; + Cover = null; + previousCover?.Dispose(); + changes |= GameEntryChanges.Cover; + } + + if (!string.Equals(_backgroundPath, scanned.BackgroundPath, StringComparison.Ordinal) + || _backgroundStamp != scanned._backgroundStamp) + { + _backgroundPath = scanned.BackgroundPath; + _backgroundStamp = scanned._backgroundStamp; + var previousBackground = Background; + Background = null; + previousBackground?.Dispose(); + changes |= GameEntryChanges.Background; + } + + return changes; + } + + private void RaisePropertyChanged(string propertyName) => + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + private static string ComputeInitials(string name) { var initials = name @@ -164,4 +251,29 @@ public sealed class GameEntry : INotifyPropertyChanged _ => $"{bytes} B", }; } + + private readonly record struct FileStamp(long Length, long LastWriteTimeUtcTicks) + { + public static FileStamp Read(string? path) + { + if (path is null) + { + return default; + } + + try + { + var file = new FileInfo(path); + return file.Exists + ? new FileStamp(file.Length, file.LastWriteTimeUtc.Ticks) + : default; + } + catch (Exception exception) when ( + exception is IOException + or UnauthorizedAccessException) + { + return default; + } + } + } } diff --git a/src/SharpEmu.GUI/GameLibraryPath.cs b/src/SharpEmu.GUI/GameLibraryPath.cs new file mode 100644 index 00000000..52ca46ef --- /dev/null +++ b/src/SharpEmu.GUI/GameLibraryPath.cs @@ -0,0 +1,17 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +namespace SharpEmu.GUI; + +internal static class GameLibraryPath +{ + public static StringComparer Comparer { get; } = + OperatingSystem.IsWindows() || OperatingSystem.IsMacOS() + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + + public static StringComparison Comparison { get; } = + OperatingSystem.IsWindows() || OperatingSystem.IsMacOS() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; +} diff --git a/src/SharpEmu.GUI/GameLibraryReconciler.cs b/src/SharpEmu.GUI/GameLibraryReconciler.cs new file mode 100644 index 00000000..caae283e --- /dev/null +++ b/src/SharpEmu.GUI/GameLibraryReconciler.cs @@ -0,0 +1,98 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +namespace SharpEmu.GUI; + +internal sealed record GameLibraryReconciliation( + IReadOnlyList Games, + IReadOnlyList CoversToLoad, + IReadOnlySet BackgroundsChanged); + +/// +/// Applies filesystem scan results while preserving presentation object +/// identity for games that remain in the library +/// +internal static class GameLibraryReconciler +{ + public static GameLibraryReconciliation Reconcile( + IReadOnlyList current, + IReadOnlyList scanned) + { + var existingByPath = current.ToDictionary(game => game.Path, GameLibraryPath.Comparer); + var merged = new List(scanned.Count); + var coversToLoad = new List(); + var backgroundsChanged = new HashSet(); + + foreach (var scannedGame in scanned) + { + if (!existingByPath.TryGetValue(scannedGame.Path, out var existing)) + { + merged.Add(scannedGame); + if (scannedGame.CoverPath is not null) + { + coversToLoad.Add(scannedGame); + } + + continue; + } + + var changes = existing.UpdateFrom(scannedGame); + merged.Add(existing); + if ((changes & GameEntryChanges.Cover) != 0 && existing.CoverPath is not null) + { + coversToLoad.Add(existing); + } + + if ((changes & GameEntryChanges.Background) != 0) + { + backgroundsChanged.Add(existing); + } + } + + return new GameLibraryReconciliation(merged, coversToLoad, backgroundsChanged); + } + + /// + /// Reorders, inserts and removes only the items needed to reach the desired + /// visible sequence + /// + public static void ReconcileVisibleGames( + IList visible, + IReadOnlyList desired) + { + for (var index = 0; index < desired.Count; index++) + { + var game = desired[index]; + if (index < visible.Count && ReferenceEquals(visible[index], game)) + { + continue; + } + + var existingIndex = -1; + for (var candidate = index + 1; candidate < visible.Count; candidate++) + { + if (ReferenceEquals(visible[candidate], game)) + { + existingIndex = candidate; + break; + } + } + + if (existingIndex >= 0) + { + var existing = visible[existingIndex]; + visible.RemoveAt(existingIndex); + visible.Insert(index, existing); + } + else + { + visible.Insert(index, game); + } + } + + while (visible.Count > desired.Count) + { + visible.RemoveAt(visible.Count - 1); + } + } +} diff --git a/src/SharpEmu.GUI/GameLibraryWatcher.cs b/src/SharpEmu.GUI/GameLibraryWatcher.cs new file mode 100644 index 00000000..204deb4c --- /dev/null +++ b/src/SharpEmu.GUI/GameLibraryWatcher.cs @@ -0,0 +1,147 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +namespace SharpEmu.GUI; + +/// +/// Watches configured game folders and collapses filesystem bursts into one +/// library refresh request +/// +internal sealed class GameLibraryWatcher : IDisposable +{ + private static readonly TimeSpan DefaultDebounceInterval = TimeSpan.FromMilliseconds(600); + + private readonly object _sync = new(); + private readonly TimeSpan _debounceInterval; + private readonly List _watchers = []; + private Timer? _debounceTimer; + private bool _disposed; + + internal GameLibraryWatcher(TimeSpan? debounceInterval = null) + { + _debounceInterval = debounceInterval ?? DefaultDebounceInterval; + } + + public event EventHandler? RefreshRequested; + + /// + /// Replaces the watched roots with the current configured game folders + /// + public void Watch(IReadOnlyList folders) + { + lock (_sync) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + foreach (var watcher in _watchers) + { + watcher.Dispose(); + } + + _watchers.Clear(); + _debounceTimer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + foreach (var folder in folders.Distinct(GameLibraryPath.Comparer)) + { + if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder)) + { + continue; + } + + try + { + var watcher = new FileSystemWatcher(folder) + { + IncludeSubdirectories = true, + NotifyFilter = NotifyFilters.FileName + | NotifyFilters.DirectoryName + | NotifyFilters.LastWrite + | NotifyFilters.Size, + }; + watcher.Created += OnFileSystemChanged; + watcher.Changed += OnFileSystemChanged; + watcher.Deleted += OnFileSystemChanged; + watcher.Renamed += OnFileSystemChanged; + watcher.Error += OnWatcherError; + watcher.EnableRaisingEvents = true; + _watchers.Add(watcher); + } + catch (Exception exception) when ( + exception is ArgumentException + or IOException + or UnauthorizedAccessException) + { + Console.Error.WriteLine( + $"[GUI][WARN] Could not watch game folder '{folder}': {exception.Message}"); + } + } + } + } + + private void OnFileSystemChanged(object sender, FileSystemEventArgs args) + => ScheduleRefresh(); + + private void OnWatcherError(object sender, ErrorEventArgs args) + { + Console.Error.WriteLine( + $"[GUI][WARN] Game library watcher reported an error: {args.GetException().Message}"); + ScheduleRefresh(); + } + + private void ScheduleRefresh() + { + lock (_sync) + { + if (_disposed) + { + return; + } + + if (_debounceTimer is null) + { + _debounceTimer = new Timer( + static state => ((GameLibraryWatcher)state!).RaiseRefreshRequested(), + this, + _debounceInterval, + Timeout.InfiniteTimeSpan); + } + else + { + _debounceTimer.Change(_debounceInterval, Timeout.InfiniteTimeSpan); + } + } + } + + private void RaiseRefreshRequested() + { + lock (_sync) + { + if (_disposed) + { + return; + } + } + + RefreshRequested?.Invoke(this, EventArgs.Empty); + } + + public void Dispose() + { + lock (_sync) + { + if (_disposed) + { + return; + } + + _disposed = true; + _debounceTimer?.Dispose(); + _debounceTimer = null; + foreach (var watcher in _watchers) + { + watcher.Dispose(); + } + + _watchers.Clear(); + } + } +} diff --git a/src/SharpEmu.GUI/Languages/ar.json b/src/SharpEmu.GUI/Languages/ar.json index 55101ce3..486bd353 100644 --- a/src/SharpEmu.GUI/Languages/ar.json +++ b/src/SharpEmu.GUI/Languages/ar.json @@ -8,7 +8,6 @@ "Library.SearchWatermark": "ابحث في المكتبة...", "Library.AddFolder": "+ إضافة مجلد", - "Library.Rescan": "⟳ إعادة الفحص", "Library.OpenFile": "فتح ملف...", "Library.Context.Launch": "تشغيل", diff --git a/src/SharpEmu.GUI/Languages/br.json b/src/SharpEmu.GUI/Languages/br.json index 950ef746..1918f386 100644 --- a/src/SharpEmu.GUI/Languages/br.json +++ b/src/SharpEmu.GUI/Languages/br.json @@ -8,7 +8,6 @@ "Library.SearchWatermark": "Pesquisar na biblioteca…", "Library.AddFolder": "+ Adicionar pasta", - "Library.Rescan": "⟳ Atualizar biblioteca", "Library.OpenFile": "Abrir arquivo…", "Library.Context.Launch": "Jogar", diff --git a/src/SharpEmu.GUI/Languages/de.json b/src/SharpEmu.GUI/Languages/de.json index 55f42792..0d366f2a 100644 --- a/src/SharpEmu.GUI/Languages/de.json +++ b/src/SharpEmu.GUI/Languages/de.json @@ -8,7 +8,6 @@ "Library.SearchWatermark": "Bibliothek durchsuchen…", "Library.AddFolder": "+ Spielordner hinzufügen", - "Library.Rescan": "⟳ Neu scannen", "Library.OpenFile": "Datei öffnen…", "Library.Context.Launch": "Starten", diff --git a/src/SharpEmu.GUI/Languages/dk.json b/src/SharpEmu.GUI/Languages/dk.json index 5336f235..c047ade1 100644 --- a/src/SharpEmu.GUI/Languages/dk.json +++ b/src/SharpEmu.GUI/Languages/dk.json @@ -8,7 +8,6 @@ "Library.SearchWatermark": "Søg i biblioteket…", "Library.AddFolder": "+ Tilføj mappe", - "Library.Rescan": "⟳ Genindlæs", "Library.OpenFile": "Åbn fil…", "Library.Context.Launch": "Start", diff --git a/src/SharpEmu.GUI/Languages/en.json b/src/SharpEmu.GUI/Languages/en.json index ce7d8a46..e4f4bd49 100644 --- a/src/SharpEmu.GUI/Languages/en.json +++ b/src/SharpEmu.GUI/Languages/en.json @@ -8,7 +8,6 @@ "Library.SearchWatermark": "Search library…", "Library.AddFolder": "+ Add folder", - "Library.Rescan": "⟳ Rescan", "Library.OpenFile": "Open file…", "Library.Context.Launch": "Launch", diff --git a/src/SharpEmu.GUI/Languages/es.json b/src/SharpEmu.GUI/Languages/es.json index 79f1d784..7267941e 100644 --- a/src/SharpEmu.GUI/Languages/es.json +++ b/src/SharpEmu.GUI/Languages/es.json @@ -8,7 +8,6 @@ "Library.SearchWatermark": "Buscar en la biblioteca…", "Library.AddFolder": "+ Añadir carpeta", - "Library.Rescan": "⟳ Volver a escanear", "Library.OpenFile": "Abrir archivo…", "Library.Context.Launch": "Iniciar", diff --git a/src/SharpEmu.GUI/Languages/fr.json b/src/SharpEmu.GUI/Languages/fr.json index da245776..47f7b08c 100644 --- a/src/SharpEmu.GUI/Languages/fr.json +++ b/src/SharpEmu.GUI/Languages/fr.json @@ -8,7 +8,6 @@ "Library.SearchWatermark": "Rechercher dans la bibliothèque…", "Library.AddFolder": "+ Ajouter un dossier", - "Library.Rescan": "⟳ Analyser à nouveau", "Library.OpenFile": "Ouvrir un fichier…", "Library.Context.Launch": "Lancer", diff --git a/src/SharpEmu.GUI/Languages/hu.json b/src/SharpEmu.GUI/Languages/hu.json index 7bcb8ac5..51803593 100644 --- a/src/SharpEmu.GUI/Languages/hu.json +++ b/src/SharpEmu.GUI/Languages/hu.json @@ -8,7 +8,6 @@ "Library.SearchWatermark": "Keresés a könyvtárban", "Library.AddFolder": "+ Mappa hozzáadása", - "Library.Rescan": "⟳ Újrakeresés", "Library.OpenFile": "Fájl megnyitása…", "Library.Context.Launch": "Inditás", diff --git a/src/SharpEmu.GUI/Languages/it.json b/src/SharpEmu.GUI/Languages/it.json index 7646ceb5..3f553f9f 100644 --- a/src/SharpEmu.GUI/Languages/it.json +++ b/src/SharpEmu.GUI/Languages/it.json @@ -8,7 +8,6 @@ "Library.SearchWatermark": "Cerca nella libreria…", "Library.AddFolder": "+ Aggiungi cartella", - "Library.Rescan": "⟳ Riscansiona", "Library.OpenFile": "Apri file…", "Library.Context.Launch": "Avvia", diff --git a/src/SharpEmu.GUI/Languages/ja.json b/src/SharpEmu.GUI/Languages/ja.json index 86decc7c..79b186fd 100644 --- a/src/SharpEmu.GUI/Languages/ja.json +++ b/src/SharpEmu.GUI/Languages/ja.json @@ -8,7 +8,6 @@ "Library.SearchWatermark": "ライブラリを検索…", "Library.AddFolder": "+ フォルダーを追加", - "Library.Rescan": "⟳ 再スキャン", "Library.OpenFile": "ファイルを開く…", "Library.Context.Launch": "起動", diff --git a/src/SharpEmu.GUI/Languages/ko.json b/src/SharpEmu.GUI/Languages/ko.json index 03af4c71..1b7d218a 100644 --- a/src/SharpEmu.GUI/Languages/ko.json +++ b/src/SharpEmu.GUI/Languages/ko.json @@ -8,7 +8,6 @@ "Library.SearchWatermark": "라이브러리 검색…", "Library.AddFolder": "+ 폴더 추가", - "Library.Rescan": "⟳ 다시 스캔", "Library.OpenFile": "파일 열기…", "Library.Context.Launch": "실행", diff --git a/src/SharpEmu.GUI/Languages/nl.json b/src/SharpEmu.GUI/Languages/nl.json index 54df184f..8c759f91 100644 --- a/src/SharpEmu.GUI/Languages/nl.json +++ b/src/SharpEmu.GUI/Languages/nl.json @@ -8,7 +8,6 @@ "Library.SearchWatermark": "Zoeken in bibliotheek…", "Library.AddFolder": "+ Map toevoegen", - "Library.Rescan": "⟳ Opnieuw scannen", "Library.OpenFile": "Bestand openen…", "Library.Context.Launch": "Starten", diff --git a/src/SharpEmu.GUI/Languages/pt.json b/src/SharpEmu.GUI/Languages/pt.json index ba757fa5..62242f50 100644 --- a/src/SharpEmu.GUI/Languages/pt.json +++ b/src/SharpEmu.GUI/Languages/pt.json @@ -8,7 +8,6 @@ "Library.SearchWatermark": "Pesquisar biblioteca…", "Library.AddFolder": "+ Adicionar pasta", - "Library.Rescan": "⟳ Reanalisar", "Library.OpenFile": "Abrir ficheiro…", "Library.Context.Launch": "Iniciar", diff --git a/src/SharpEmu.GUI/Languages/ru.json b/src/SharpEmu.GUI/Languages/ru.json index 924a7f1b..59117b1f 100644 --- a/src/SharpEmu.GUI/Languages/ru.json +++ b/src/SharpEmu.GUI/Languages/ru.json @@ -8,7 +8,6 @@ "Library.SearchWatermark": "Поиск…", "Library.AddFolder": "+ Добавить папку", - "Library.Rescan": "⟳ Сканировать", "Library.OpenFile": "Открыть файл…", "Library.Context.Launch": "Запустить", diff --git a/src/SharpEmu.GUI/Languages/tr.json b/src/SharpEmu.GUI/Languages/tr.json index 5132220c..dfa139d7 100644 --- a/src/SharpEmu.GUI/Languages/tr.json +++ b/src/SharpEmu.GUI/Languages/tr.json @@ -8,7 +8,6 @@ "Library.SearchWatermark": "Kütüphanede ara…", "Library.AddFolder": "+ Klasör ekle", - "Library.Rescan": "⟳ Yeniden tara", "Library.OpenFile": "Dosya aç…", "Library.Context.Launch": "Başlat", diff --git a/src/SharpEmu.GUI/MainWindow.axaml b/src/SharpEmu.GUI/MainWindow.axaml index 31169e47..79ba92f7 100644 --- a/src/SharpEmu.GUI/MainWindow.axaml +++ b/src/SharpEmu.GUI/MainWindow.axaml @@ -80,7 +80,6 @@ SPDX-License-Identifier: GPL-2.0-or-later VerticalAlignment="Center">