mirror of
https://github.com/par274/sharpemu.git
synced 2026-07-30 14:39:42 +08:00
[GUI] add game library watcher & remove rescan button (#678)
This commit is contained in:
+124
-12
@@ -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;
|
||||
|
||||
/// <summary>Content version from sce_sys/param.json, e.g. "01.000.000".</summary>
|
||||
public string? Version { get; }
|
||||
public string? Version => _version;
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
@@ -75,10 +94,10 @@ public sealed class GameEntry : INotifyPropertyChanged
|
||||
}
|
||||
|
||||
/// <summary>Path to the cover art image shipped with the game, if found.</summary>
|
||||
public string? CoverPath { get; }
|
||||
public string? CoverPath => _coverPath;
|
||||
|
||||
/// <summary>Path to the key art (pic0/pic1) shipped with the game, if found.</summary>
|
||||
public string? BackgroundPath { get; }
|
||||
public string? BackgroundPath => _backgroundPath;
|
||||
|
||||
/// <summary>
|
||||
/// Decoded key art used as the window backdrop while this game is
|
||||
@@ -86,7 +105,7 @@ public sealed class GameEntry : INotifyPropertyChanged
|
||||
/// </summary>
|
||||
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
|
||||
/// <summary>Formatted install size badge shown in the launch bar.</summary>
|
||||
public string SizeText => FormatSize(SizeBytes);
|
||||
|
||||
/// <summary>
|
||||
/// Applies a fresh filesystem scan to this presentation object so its
|
||||
/// ListBox container and selection remain stable
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<GameEntry> Games,
|
||||
IReadOnlyList<GameEntry> CoversToLoad,
|
||||
IReadOnlySet<GameEntry> BackgroundsChanged);
|
||||
|
||||
/// <summary>
|
||||
/// Applies filesystem scan results while preserving presentation object
|
||||
/// identity for games that remain in the library
|
||||
/// </summary>
|
||||
internal static class GameLibraryReconciler
|
||||
{
|
||||
public static GameLibraryReconciliation Reconcile(
|
||||
IReadOnlyList<GameEntry> current,
|
||||
IReadOnlyList<GameEntry> scanned)
|
||||
{
|
||||
var existingByPath = current.ToDictionary(game => game.Path, GameLibraryPath.Comparer);
|
||||
var merged = new List<GameEntry>(scanned.Count);
|
||||
var coversToLoad = new List<GameEntry>();
|
||||
var backgroundsChanged = new HashSet<GameEntry>();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reorders, inserts and removes only the items needed to reach the desired
|
||||
/// visible sequence
|
||||
/// </summary>
|
||||
public static void ReconcileVisibleGames(
|
||||
IList<GameEntry> visible,
|
||||
IReadOnlyList<GameEntry> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
namespace SharpEmu.GUI;
|
||||
|
||||
/// <summary>
|
||||
/// Watches configured game folders and collapses filesystem bursts into one
|
||||
/// library refresh request
|
||||
/// </summary>
|
||||
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<FileSystemWatcher> _watchers = [];
|
||||
private Timer? _debounceTimer;
|
||||
private bool _disposed;
|
||||
|
||||
internal GameLibraryWatcher(TimeSpan? debounceInterval = null)
|
||||
{
|
||||
_debounceInterval = debounceInterval ?? DefaultDebounceInterval;
|
||||
}
|
||||
|
||||
public event EventHandler? RefreshRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the watched roots with the current configured game folders
|
||||
/// </summary>
|
||||
public void Watch(IReadOnlyList<string> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
"Library.SearchWatermark": "ابحث في المكتبة...",
|
||||
"Library.AddFolder": "+ إضافة مجلد",
|
||||
"Library.Rescan": "⟳ إعادة الفحص",
|
||||
"Library.OpenFile": "فتح ملف...",
|
||||
|
||||
"Library.Context.Launch": "تشغيل",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
"Library.SearchWatermark": "Search library…",
|
||||
"Library.AddFolder": "+ Add folder",
|
||||
"Library.Rescan": "⟳ Rescan",
|
||||
"Library.OpenFile": "Open file…",
|
||||
|
||||
"Library.Context.Launch": "Launch",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
"Library.SearchWatermark": "Cerca nella libreria…",
|
||||
"Library.AddFolder": "+ Aggiungi cartella",
|
||||
"Library.Rescan": "⟳ Riscansiona",
|
||||
"Library.OpenFile": "Apri file…",
|
||||
|
||||
"Library.Context.Launch": "Avvia",
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
"Library.SearchWatermark": "ライブラリを検索…",
|
||||
"Library.AddFolder": "+ フォルダーを追加",
|
||||
"Library.Rescan": "⟳ 再スキャン",
|
||||
"Library.OpenFile": "ファイルを開く…",
|
||||
|
||||
"Library.Context.Launch": "起動",
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
"Library.SearchWatermark": "라이브러리 검색…",
|
||||
"Library.AddFolder": "+ 폴더 추가",
|
||||
"Library.Rescan": "⟳ 다시 스캔",
|
||||
"Library.OpenFile": "파일 열기…",
|
||||
|
||||
"Library.Context.Launch": "실행",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
"Library.SearchWatermark": "Pesquisar biblioteca…",
|
||||
"Library.AddFolder": "+ Adicionar pasta",
|
||||
"Library.Rescan": "⟳ Reanalisar",
|
||||
"Library.OpenFile": "Abrir ficheiro…",
|
||||
|
||||
"Library.Context.Launch": "Iniciar",
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
"Library.SearchWatermark": "Поиск…",
|
||||
"Library.AddFolder": "+ Добавить папку",
|
||||
"Library.Rescan": "⟳ Сканировать",
|
||||
"Library.OpenFile": "Открыть файл…",
|
||||
|
||||
"Library.Context.Launch": "Запустить",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -80,7 +80,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
VerticalAlignment="Center">
|
||||
<TextBox x:Name="SearchBox" PlaceholderText="Search library…" Width="240" VerticalAlignment="Center" />
|
||||
<Button x:Name="AddFolderButton" Classes="ghost" Content="+ Add folder" VerticalAlignment="Center" />
|
||||
<Button x:Name="RescanButton" Classes="ghost" Content="⟳ Rescan" VerticalAlignment="Center" />
|
||||
<Button x:Name="OpenFileButton" Classes="ghost" Content="Open file…" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
@@ -41,15 +41,9 @@ public partial class MainWindow : Window
|
||||
private static readonly IBrush WarningLineBrush = new SolidColorBrush(Color.Parse("#E8B341"));
|
||||
private static readonly IBrush ErrorLineBrush = new SolidColorBrush(Color.Parse("#F2777C"));
|
||||
private static readonly IBrush SuccessLineBrush = new SolidColorBrush(Color.Parse("#63D489"));
|
||||
private static readonly StringComparer FilePathComparer = OperatingSystem.IsWindows()
|
||||
? StringComparer.OrdinalIgnoreCase
|
||||
: StringComparer.Ordinal;
|
||||
private static readonly StringComparison FilePathComparison = OperatingSystem.IsWindows()
|
||||
? StringComparison.OrdinalIgnoreCase
|
||||
: StringComparison.Ordinal;
|
||||
|
||||
private readonly List<GameEntry> _allGames = new();
|
||||
private readonly ObservableCollection<GameEntry> _visibleGames = new();
|
||||
private readonly GameLibraryWatcher _libraryWatcher = new();
|
||||
private readonly AvaloniaList<LogLine> _consoleLines = new();
|
||||
private readonly List<LogLine> _allConsoleLines = new();
|
||||
private readonly ConcurrentQueue<(string Line, bool IsError)> _pendingLines = new();
|
||||
@@ -85,8 +79,10 @@ public partial class MainWindow : Window
|
||||
private string? _runningGameName;
|
||||
private string? _runningGameTitleId;
|
||||
private long _runningSinceUnixSeconds;
|
||||
private int _libraryScanGeneration;
|
||||
private int _detailLoadGeneration;
|
||||
private int _backdropGeneration;
|
||||
private bool _isClosing;
|
||||
|
||||
// Bundled key art shown whenever no game-specific backdrop applies; the
|
||||
// plain window color remains the fallback when the asset fails to load.
|
||||
@@ -133,6 +129,7 @@ public partial class MainWindow : Window
|
||||
}
|
||||
|
||||
GameList.ItemsSource = _visibleGames;
|
||||
_libraryWatcher.RefreshRequested += OnLibraryRefreshRequested;
|
||||
ConsoleList.ItemsSource = _consoleLines;
|
||||
_consoleMirror = GuiConsoleMirror.Install((line, isError) =>
|
||||
_pendingLines.Enqueue((line, isError)));
|
||||
@@ -173,7 +170,6 @@ public partial class MainWindow : Window
|
||||
ConsoleSearchBox.TextChanged += (_, _) => RefreshVisibleConsoleLines();
|
||||
AddFolderButton.Click += async (_, _) => await AddFolderAsync();
|
||||
EmptyAddFolderButton.Click += async (_, _) => await AddFolderAsync();
|
||||
RescanButton.Click += async (_, _) => await RescanLibraryAsync();
|
||||
OpenFileButton.Click += async (_, _) => await OpenFileAsync();
|
||||
LaunchButton.Click += (_, _) => LaunchSelected();
|
||||
ClearLogButton.Click += (_, _) => { _consoleLines.Clear(); _allConsoleLines.Clear(); };
|
||||
@@ -619,7 +615,6 @@ public partial class MainWindow : Window
|
||||
|
||||
SearchBox.PlaceholderText = loc.Get("Library.SearchWatermark");
|
||||
AddFolderButton.Content = loc.Get("Library.AddFolder");
|
||||
RescanButton.Content = loc.Get("Library.Rescan");
|
||||
OpenFileButton.Content = loc.Get("Library.OpenFile");
|
||||
|
||||
CtxLaunch.Header = loc.Get("Library.Context.Launch");
|
||||
@@ -798,6 +793,10 @@ public partial class MainWindow : Window
|
||||
|
||||
private void OnWindowClosing()
|
||||
{
|
||||
_isClosing = true;
|
||||
Interlocked.Increment(ref _libraryScanGeneration);
|
||||
Interlocked.Increment(ref _detailLoadGeneration);
|
||||
_libraryWatcher.Dispose();
|
||||
_settings.Save();
|
||||
_consoleFlushTimer.Stop();
|
||||
_libraryBlurTimer.Stop();
|
||||
@@ -1176,7 +1175,7 @@ public partial class MainWindow : Window
|
||||
}
|
||||
|
||||
var changed = false;
|
||||
if (!_settings.GameFolders.Contains(path, FilePathComparer))
|
||||
if (!_settings.GameFolders.Contains(path, GameLibraryPath.Comparer))
|
||||
{
|
||||
_settings.GameFolders.Add(path);
|
||||
changed = true;
|
||||
@@ -1186,7 +1185,7 @@ public partial class MainWindow : Window
|
||||
// games beneath it that were removed from the library earlier.
|
||||
var prefix = Path.TrimEndingDirectorySeparator(path) + Path.DirectorySeparatorChar;
|
||||
changed |= _settings.ExcludedGames.RemoveAll(excluded =>
|
||||
excluded.StartsWith(prefix, FilePathComparison)) > 0;
|
||||
excluded.StartsWith(prefix, GameLibraryPath.Comparison)) > 0;
|
||||
|
||||
if (changed)
|
||||
{
|
||||
@@ -1196,25 +1195,72 @@ public partial class MainWindow : Window
|
||||
await RescanLibraryAsync();
|
||||
}
|
||||
|
||||
private async Task RescanLibraryAsync()
|
||||
private void OnLibraryRefreshRequested(object? sender, EventArgs args)
|
||||
{
|
||||
Dispatcher.UIThread.Post(
|
||||
() =>
|
||||
{
|
||||
if (!_isClosing)
|
||||
{
|
||||
_ = RescanLibraryFromWatcherAsync();
|
||||
}
|
||||
},
|
||||
DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
private async Task RescanLibraryFromWatcherAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await RescanLibraryAsync(showProgress: false);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[GUI][WARN] Automatic library refresh failed: {exception.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RescanLibraryAsync(bool showProgress = true)
|
||||
{
|
||||
Dispatcher.UIThread.VerifyAccess();
|
||||
|
||||
var scanGeneration = Interlocked.Increment(ref _libraryScanGeneration);
|
||||
var folders = _settings.GameFolders.ToArray();
|
||||
var excluded = new HashSet<string>(_settings.ExcludedGames, FilePathComparer);
|
||||
StatusBarRight.Text = Localization.Instance.Get("Status.ScanningLibrary");
|
||||
EmptyState.IsVisible = false;
|
||||
LoadingState.IsVisible = true;
|
||||
var excluded = new HashSet<string>(_settings.ExcludedGames, GameLibraryPath.Comparer);
|
||||
_libraryWatcher.Watch(folders);
|
||||
var showLoadingState = showProgress && _allGames.Count == 0;
|
||||
if (showProgress)
|
||||
{
|
||||
StatusBarRight.Text = Localization.Instance.Get("Status.ScanningLibrary");
|
||||
}
|
||||
|
||||
if (showLoadingState)
|
||||
{
|
||||
EmptyState.IsVisible = false;
|
||||
LoadingState.IsVisible = true;
|
||||
}
|
||||
|
||||
var games = await Task.Run(() => ScanFolders(folders, excluded));
|
||||
if (_isClosing || scanGeneration != Volatile.Read(ref _libraryScanGeneration))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Dispatcher.UIThread.VerifyAccess();
|
||||
var reconciliation = GameLibraryReconciler.Reconcile(_allGames, games);
|
||||
_allGames.Clear();
|
||||
_allGames.AddRange(games);
|
||||
RefreshVisibleGames();
|
||||
_allGames.AddRange(reconciliation.Games);
|
||||
RefreshVisibleGames(reconciliation.BackgroundsChanged);
|
||||
LoadingState.IsVisible = false;
|
||||
LoadGameDetailsInBackground(games);
|
||||
LoadGameDetailsInBackground(reconciliation.CoversToLoad, reconciliation.Games);
|
||||
UpdateDiscordPresence();
|
||||
StatusBarRight.Text = folders.Length == 0
|
||||
? Localization.Instance.Get("Status.AddFolderPrompt")
|
||||
: Localization.Instance.Format("Status.LibraryScanned", games.Count, folders.Length);
|
||||
if (showProgress)
|
||||
{
|
||||
StatusBarRight.Text = folders.Length == 0
|
||||
? Localization.Instance.Get("Status.AddFolderPrompt")
|
||||
: Localization.Instance.Format("Status.LibraryScanned", games.Count, folders.Length);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1222,44 +1268,57 @@ public partial class MainWindow : Window
|
||||
/// game's install folder size — posting results back as they become
|
||||
/// ready. A newer scan invalidates older loads.
|
||||
/// </summary>
|
||||
private void LoadGameDetailsInBackground(IReadOnlyList<GameEntry> games)
|
||||
private void LoadGameDetailsInBackground(
|
||||
IReadOnlyList<GameEntry> coversToLoad,
|
||||
IReadOnlyList<GameEntry> gamesToMeasure)
|
||||
{
|
||||
var generation = ++_detailLoadGeneration;
|
||||
_ = Task.Run(() =>
|
||||
{
|
||||
// Covers first: they are cheap and the most visible, so the grid
|
||||
// fills with art before the (potentially slow) size pass runs.
|
||||
foreach (var game in games)
|
||||
foreach (var game in coversToLoad)
|
||||
{
|
||||
if (generation != _detailLoadGeneration)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (game.CoverPath is null)
|
||||
var requestedCoverPath = game.CoverPath;
|
||||
if (requestedCoverPath is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(game.CoverPath);
|
||||
using var stream = File.OpenRead(requestedCoverPath);
|
||||
var bitmap = Bitmap.DecodeToWidth(stream, 312);
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (generation == _detailLoadGeneration)
|
||||
if (generation == _detailLoadGeneration
|
||||
&& _allGames.Contains(game)
|
||||
&& string.Equals(
|
||||
game.CoverPath,
|
||||
requestedCoverPath,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
game.Cover = bitmap;
|
||||
}
|
||||
else
|
||||
{
|
||||
bitmap.Dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (Exception)
|
||||
catch (Exception exception)
|
||||
{
|
||||
// A missing or undecodable image keeps the placeholder.
|
||||
Console.Error.WriteLine(
|
||||
$"[GUI][WARN] Could not load cover '{requestedCoverPath}': {exception.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var game in games)
|
||||
foreach (var game in gamesToMeasure)
|
||||
{
|
||||
if (generation != _detailLoadGeneration)
|
||||
{
|
||||
@@ -1271,7 +1330,7 @@ public partial class MainWindow : Window
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (generation == _detailLoadGeneration)
|
||||
if (generation == _detailLoadGeneration && _allGames.Contains(game))
|
||||
{
|
||||
game.SizeBytes = size;
|
||||
}
|
||||
@@ -1306,9 +1365,10 @@ public partial class MainWindow : Window
|
||||
total += file.Length;
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
catch (Exception exception)
|
||||
{
|
||||
// Fall back to whatever was accumulated so far.
|
||||
Console.Error.WriteLine(
|
||||
$"[GUI][WARN] Could not measure game folder '{directory}': {exception.Message}");
|
||||
}
|
||||
|
||||
return total;
|
||||
@@ -1317,7 +1377,7 @@ public partial class MainWindow : Window
|
||||
private static List<GameEntry> ScanFolders(IReadOnlyList<string> folders, IReadOnlySet<string> excludedPaths)
|
||||
{
|
||||
var games = new List<GameEntry>();
|
||||
var seen = new HashSet<string>(FilePathComparer);
|
||||
var seen = new HashSet<string>(GameLibraryPath.Comparer);
|
||||
var enumeration = new EnumerationOptions
|
||||
{
|
||||
IgnoreInaccessible = true,
|
||||
@@ -1347,8 +1407,10 @@ public partial class MainWindow : Window
|
||||
{
|
||||
size = new FileInfo(fullPath).Length;
|
||||
}
|
||||
catch (IOException)
|
||||
catch (IOException exception)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[GUI][WARN] Could not inspect executable '{fullPath}': {exception.Message}");
|
||||
}
|
||||
|
||||
var (title, titleId, version) = TryReadParamJson(fullPath);
|
||||
@@ -1357,9 +1419,10 @@ public partial class MainWindow : Window
|
||||
FindCoverFor(fullPath), FindBackgroundFor(fullPath)));
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
catch (Exception exception)
|
||||
{
|
||||
// Skip folders that fail to enumerate.
|
||||
Console.Error.WriteLine(
|
||||
$"[GUI][WARN] Could not scan game folder '{folder}': {exception.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1600,24 +1663,26 @@ public partial class MainWindow : Window
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_settings.ExcludedGames.Contains(game.Path, FilePathComparer))
|
||||
if (!_settings.ExcludedGames.Contains(game.Path, GameLibraryPath.Comparer))
|
||||
{
|
||||
_settings.ExcludedGames.Add(game.Path);
|
||||
_settings.Save();
|
||||
}
|
||||
|
||||
_allGames.RemoveAll(g => string.Equals(g.Path, game.Path, FilePathComparison));
|
||||
_allGames.RemoveAll(g =>
|
||||
string.Equals(g.Path, game.Path, GameLibraryPath.Comparison));
|
||||
GameList.SelectedItem = null;
|
||||
RefreshVisibleGames();
|
||||
StatusBarRight.Text = Localization.Instance.Format("Status.RemovedFromLibrary", game.Name);
|
||||
}
|
||||
|
||||
private void RefreshVisibleGames()
|
||||
private void RefreshVisibleGames(IReadOnlySet<GameEntry>? backgroundsChanged = null)
|
||||
{
|
||||
var query = SearchBox.Text?.Trim() ?? string.Empty;
|
||||
var selectedPath = (GameList.SelectedItem as GameEntry)?.Path;
|
||||
var selectedBefore = GameList.SelectedItem as GameEntry;
|
||||
var selectedPath = selectedBefore?.Path;
|
||||
var desired = new List<GameEntry>(_allGames.Count);
|
||||
|
||||
_visibleGames.Clear();
|
||||
foreach (var game in _allGames)
|
||||
{
|
||||
if (query.Length == 0 ||
|
||||
@@ -1625,21 +1690,37 @@ public partial class MainWindow : Window
|
||||
game.Path.Contains(query, StringComparison.OrdinalIgnoreCase) ||
|
||||
(game.TitleId?.Contains(query, StringComparison.OrdinalIgnoreCase) ?? false))
|
||||
{
|
||||
_visibleGames.Add(game);
|
||||
desired.Add(game);
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedPath is not null &&
|
||||
_visibleGames.FirstOrDefault(g => g.Path.Equals(selectedPath, FilePathComparison))
|
||||
is { } reselected)
|
||||
GameLibraryReconciler.ReconcileVisibleGames(_visibleGames, desired);
|
||||
|
||||
var selectedAfter = selectedPath is null
|
||||
? null
|
||||
: _visibleGames.FirstOrDefault(game =>
|
||||
game.Path.Equals(selectedPath, GameLibraryPath.Comparison));
|
||||
if (!ReferenceEquals(GameList.SelectedItem, selectedAfter))
|
||||
{
|
||||
GameList.SelectedItem = reselected;
|
||||
GameList.SelectedItem = selectedAfter;
|
||||
}
|
||||
|
||||
EmptyState.IsVisible = _visibleGames.Count == 0;
|
||||
UpdateEmptyStateTexts();
|
||||
|
||||
UpdateSelectedGame();
|
||||
if (!ReferenceEquals(selectedBefore, selectedAfter))
|
||||
{
|
||||
UpdateSelectedGame();
|
||||
}
|
||||
else
|
||||
{
|
||||
UpdateSelectedGameTexts();
|
||||
UpdateRunButtons();
|
||||
if (selectedAfter is not null && backgroundsChanged?.Contains(selectedAfter) == true)
|
||||
{
|
||||
_ = UpdateBackdropAsync(selectedAfter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1861,7 +1942,8 @@ public partial class MainWindow : Window
|
||||
}
|
||||
|
||||
var resolvedTitleId = string.IsNullOrWhiteSpace(titleId)
|
||||
? _allGames.FirstOrDefault(game => game.Path.Equals(ebootPath, FilePathComparison))?.TitleId
|
||||
? _allGames.FirstOrDefault(game =>
|
||||
game.Path.Equals(ebootPath, GameLibraryPath.Comparison))?.TitleId
|
||||
: titleId;
|
||||
var effective = EffectiveLaunchSettings.Resolve(_settings, PerGameSettings.Load(resolvedTitleId));
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Collections.ObjectModel;
|
||||
using SharpEmu.GUI;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.GUI;
|
||||
|
||||
public sealed class GameLibraryReconcilerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Reconcile_PreservesExistingEntriesAndAppliesMetadataChanges()
|
||||
{
|
||||
var retained = CreateGame("retained", name: "Old name", version: "01.000");
|
||||
var removed = CreateGame("removed");
|
||||
var scannedRetained = CreateGame(
|
||||
"retained",
|
||||
name: "New name",
|
||||
version: "02.000");
|
||||
var added = CreateGame("added");
|
||||
|
||||
var result = GameLibraryReconciler.Reconcile(
|
||||
[retained, removed],
|
||||
[scannedRetained, added]);
|
||||
|
||||
Assert.Equal(2, result.Games.Count);
|
||||
Assert.Same(retained, result.Games[0]);
|
||||
Assert.Same(added, result.Games[1]);
|
||||
Assert.Equal("New name", retained.Name);
|
||||
Assert.Equal("02.000", retained.Version);
|
||||
Assert.DoesNotContain(removed, result.Games);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reconcile_ChangedCoverAndBackgroundReloadsExistingEntry()
|
||||
{
|
||||
var retained = CreateGame(
|
||||
"retained",
|
||||
coverPath: AssetPath("old-cover.png"),
|
||||
backgroundPath: AssetPath("old-background.png"));
|
||||
var scanned = CreateGame(
|
||||
"retained",
|
||||
coverPath: AssetPath("new-cover.png"),
|
||||
backgroundPath: AssetPath("new-background.png"));
|
||||
|
||||
var result = GameLibraryReconciler.Reconcile([retained], [scanned]);
|
||||
|
||||
Assert.Same(retained, Assert.Single(result.Games));
|
||||
Assert.Same(retained, Assert.Single(result.CoversToLoad));
|
||||
Assert.Contains(retained, result.BackgroundsChanged);
|
||||
Assert.Equal(scanned.CoverPath, retained.CoverPath);
|
||||
Assert.Equal(scanned.BackgroundPath, retained.BackgroundPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReconcileVisibleGames_UsesMinimalIdentityPreservingChanges()
|
||||
{
|
||||
var first = CreateGame("first");
|
||||
var second = CreateGame("second");
|
||||
var removed = CreateGame("removed");
|
||||
var added = CreateGame("added");
|
||||
var visible = new ObservableCollection<GameEntry>
|
||||
{
|
||||
first,
|
||||
second,
|
||||
removed,
|
||||
};
|
||||
|
||||
GameLibraryReconciler.ReconcileVisibleGames(
|
||||
visible,
|
||||
[second, first, added]);
|
||||
|
||||
Assert.Equal([second, first, added], visible);
|
||||
Assert.Same(second, visible[0]);
|
||||
Assert.Same(first, visible[1]);
|
||||
Assert.Same(added, visible[2]);
|
||||
}
|
||||
|
||||
private static GameEntry CreateGame(
|
||||
string id,
|
||||
string? name = null,
|
||||
string? version = null,
|
||||
string? coverPath = null,
|
||||
string? backgroundPath = null)
|
||||
=> new(
|
||||
name ?? id,
|
||||
$"PPSA-{id}",
|
||||
version,
|
||||
GamePath(id),
|
||||
1,
|
||||
coverPath,
|
||||
backgroundPath);
|
||||
|
||||
private static string GamePath(string id)
|
||||
=> Path.GetFullPath(Path.Combine("library-tests", id, "eboot.bin"));
|
||||
|
||||
private static string AssetPath(string name)
|
||||
=> Path.GetFullPath(Path.Combine("library-tests", "assets", name));
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.GUI;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.GUI;
|
||||
|
||||
public sealed class GameLibraryWatcherTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Watch_FileCreation_RequestsRefresh()
|
||||
{
|
||||
var directory = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"sharpemu-library-watcher-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
try
|
||||
{
|
||||
using var watcher = new GameLibraryWatcher(TimeSpan.FromMilliseconds(25));
|
||||
var refreshRequested = new TaskCompletionSource(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
watcher.RefreshRequested += (_, _) => refreshRequested.TrySetResult();
|
||||
watcher.Watch([directory]);
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(directory, "eboot.bin"),
|
||||
"test");
|
||||
|
||||
await refreshRequested.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user