mirror of
https://github.com/par274/sharpemu.git
synced 2026-07-30 22:49:53 +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;
|
namespace SharpEmu.GUI;
|
||||||
|
|
||||||
|
[Flags]
|
||||||
|
internal enum GameEntryChanges
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
Metadata = 1,
|
||||||
|
Cover = 2,
|
||||||
|
Background = 4,
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class GameEntry : INotifyPropertyChanged
|
public sealed class GameEntry : INotifyPropertyChanged
|
||||||
{
|
{
|
||||||
// Placeholder gradients for games without cover art, picked
|
// Placeholder gradients for games without cover art, picked
|
||||||
@@ -27,29 +36,39 @@ public sealed class GameEntry : INotifyPropertyChanged
|
|||||||
private Bitmap? _cover;
|
private Bitmap? _cover;
|
||||||
private IBrush? _placeholderBrush;
|
private IBrush? _placeholderBrush;
|
||||||
private long _sizeBytes;
|
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(
|
public GameEntry(
|
||||||
string name, string? titleId, string? version, string path, long sizeBytes,
|
string name, string? titleId, string? version, string path, long sizeBytes,
|
||||||
string? coverPath, string? backgroundPath)
|
string? coverPath, string? backgroundPath)
|
||||||
{
|
{
|
||||||
Name = name;
|
_name = name;
|
||||||
TitleId = titleId;
|
_titleId = titleId;
|
||||||
Version = version;
|
_version = version;
|
||||||
Path = path;
|
Path = path;
|
||||||
_sizeBytes = sizeBytes;
|
_sizeBytes = sizeBytes;
|
||||||
CoverPath = coverPath;
|
_coverPath = coverPath;
|
||||||
BackgroundPath = backgroundPath;
|
_backgroundPath = backgroundPath;
|
||||||
Initials = ComputeInitials(name);
|
_initials = ComputeInitials(name);
|
||||||
|
_coverStamp = FileStamp.Read(coverPath);
|
||||||
|
_backgroundStamp = FileStamp.Read(backgroundPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
public event PropertyChangedEventHandler? PropertyChanged;
|
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>
|
/// <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; }
|
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>
|
/// <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>
|
/// <summary>Path to the key art (pic0/pic1) shipped with the game, if found.</summary>
|
||||||
public string? BackgroundPath { get; }
|
public string? BackgroundPath => _backgroundPath;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Decoded key art used as the window backdrop while this game is
|
/// Decoded key art used as the window backdrop while this game is
|
||||||
@@ -86,7 +105,7 @@ public sealed class GameEntry : INotifyPropertyChanged
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public Bitmap? Background { get; set; }
|
public Bitmap? Background { get; set; }
|
||||||
|
|
||||||
public string Initials { get; }
|
public string Initials => _initials;
|
||||||
|
|
||||||
// Built lazily: brushes are AvaloniaObjects that must be created on the
|
// Built lazily: brushes are AvaloniaObjects that must be created on the
|
||||||
// UI thread, while GameEntry itself is constructed on the scan thread.
|
// 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>
|
/// <summary>Formatted install size badge shown in the launch bar.</summary>
|
||||||
public string SizeText => FormatSize(SizeBytes);
|
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)
|
private static string ComputeInitials(string name)
|
||||||
{
|
{
|
||||||
var initials = name
|
var initials = name
|
||||||
@@ -164,4 +251,29 @@ public sealed class GameEntry : INotifyPropertyChanged
|
|||||||
_ => $"{bytes} B",
|
_ => $"{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.SearchWatermark": "ابحث في المكتبة...",
|
||||||
"Library.AddFolder": "+ إضافة مجلد",
|
"Library.AddFolder": "+ إضافة مجلد",
|
||||||
"Library.Rescan": "⟳ إعادة الفحص",
|
|
||||||
"Library.OpenFile": "فتح ملف...",
|
"Library.OpenFile": "فتح ملف...",
|
||||||
|
|
||||||
"Library.Context.Launch": "تشغيل",
|
"Library.Context.Launch": "تشغيل",
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
"Library.SearchWatermark": "Pesquisar na biblioteca…",
|
"Library.SearchWatermark": "Pesquisar na biblioteca…",
|
||||||
"Library.AddFolder": "+ Adicionar pasta",
|
"Library.AddFolder": "+ Adicionar pasta",
|
||||||
"Library.Rescan": "⟳ Atualizar biblioteca",
|
|
||||||
"Library.OpenFile": "Abrir arquivo…",
|
"Library.OpenFile": "Abrir arquivo…",
|
||||||
|
|
||||||
"Library.Context.Launch": "Jogar",
|
"Library.Context.Launch": "Jogar",
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
"Library.SearchWatermark": "Bibliothek durchsuchen…",
|
"Library.SearchWatermark": "Bibliothek durchsuchen…",
|
||||||
"Library.AddFolder": "+ Spielordner hinzufügen",
|
"Library.AddFolder": "+ Spielordner hinzufügen",
|
||||||
"Library.Rescan": "⟳ Neu scannen",
|
|
||||||
"Library.OpenFile": "Datei öffnen…",
|
"Library.OpenFile": "Datei öffnen…",
|
||||||
|
|
||||||
"Library.Context.Launch": "Starten",
|
"Library.Context.Launch": "Starten",
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
"Library.SearchWatermark": "Søg i biblioteket…",
|
"Library.SearchWatermark": "Søg i biblioteket…",
|
||||||
"Library.AddFolder": "+ Tilføj mappe",
|
"Library.AddFolder": "+ Tilføj mappe",
|
||||||
"Library.Rescan": "⟳ Genindlæs",
|
|
||||||
"Library.OpenFile": "Åbn fil…",
|
"Library.OpenFile": "Åbn fil…",
|
||||||
|
|
||||||
"Library.Context.Launch": "Start",
|
"Library.Context.Launch": "Start",
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
"Library.SearchWatermark": "Search library…",
|
"Library.SearchWatermark": "Search library…",
|
||||||
"Library.AddFolder": "+ Add folder",
|
"Library.AddFolder": "+ Add folder",
|
||||||
"Library.Rescan": "⟳ Rescan",
|
|
||||||
"Library.OpenFile": "Open file…",
|
"Library.OpenFile": "Open file…",
|
||||||
|
|
||||||
"Library.Context.Launch": "Launch",
|
"Library.Context.Launch": "Launch",
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
"Library.SearchWatermark": "Buscar en la biblioteca…",
|
"Library.SearchWatermark": "Buscar en la biblioteca…",
|
||||||
"Library.AddFolder": "+ Añadir carpeta",
|
"Library.AddFolder": "+ Añadir carpeta",
|
||||||
"Library.Rescan": "⟳ Volver a escanear",
|
|
||||||
"Library.OpenFile": "Abrir archivo…",
|
"Library.OpenFile": "Abrir archivo…",
|
||||||
|
|
||||||
"Library.Context.Launch": "Iniciar",
|
"Library.Context.Launch": "Iniciar",
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
"Library.SearchWatermark": "Rechercher dans la bibliothèque…",
|
"Library.SearchWatermark": "Rechercher dans la bibliothèque…",
|
||||||
"Library.AddFolder": "+ Ajouter un dossier",
|
"Library.AddFolder": "+ Ajouter un dossier",
|
||||||
"Library.Rescan": "⟳ Analyser à nouveau",
|
|
||||||
"Library.OpenFile": "Ouvrir un fichier…",
|
"Library.OpenFile": "Ouvrir un fichier…",
|
||||||
|
|
||||||
"Library.Context.Launch": "Lancer",
|
"Library.Context.Launch": "Lancer",
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
"Library.SearchWatermark": "Keresés a könyvtárban",
|
"Library.SearchWatermark": "Keresés a könyvtárban",
|
||||||
"Library.AddFolder": "+ Mappa hozzáadása",
|
"Library.AddFolder": "+ Mappa hozzáadása",
|
||||||
"Library.Rescan": "⟳ Újrakeresés",
|
|
||||||
"Library.OpenFile": "Fájl megnyitása…",
|
"Library.OpenFile": "Fájl megnyitása…",
|
||||||
|
|
||||||
"Library.Context.Launch": "Inditás",
|
"Library.Context.Launch": "Inditás",
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
"Library.SearchWatermark": "Cerca nella libreria…",
|
"Library.SearchWatermark": "Cerca nella libreria…",
|
||||||
"Library.AddFolder": "+ Aggiungi cartella",
|
"Library.AddFolder": "+ Aggiungi cartella",
|
||||||
"Library.Rescan": "⟳ Riscansiona",
|
|
||||||
"Library.OpenFile": "Apri file…",
|
"Library.OpenFile": "Apri file…",
|
||||||
|
|
||||||
"Library.Context.Launch": "Avvia",
|
"Library.Context.Launch": "Avvia",
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
"Library.SearchWatermark": "ライブラリを検索…",
|
"Library.SearchWatermark": "ライブラリを検索…",
|
||||||
"Library.AddFolder": "+ フォルダーを追加",
|
"Library.AddFolder": "+ フォルダーを追加",
|
||||||
"Library.Rescan": "⟳ 再スキャン",
|
|
||||||
"Library.OpenFile": "ファイルを開く…",
|
"Library.OpenFile": "ファイルを開く…",
|
||||||
|
|
||||||
"Library.Context.Launch": "起動",
|
"Library.Context.Launch": "起動",
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
"Library.SearchWatermark": "라이브러리 검색…",
|
"Library.SearchWatermark": "라이브러리 검색…",
|
||||||
"Library.AddFolder": "+ 폴더 추가",
|
"Library.AddFolder": "+ 폴더 추가",
|
||||||
"Library.Rescan": "⟳ 다시 스캔",
|
|
||||||
"Library.OpenFile": "파일 열기…",
|
"Library.OpenFile": "파일 열기…",
|
||||||
|
|
||||||
"Library.Context.Launch": "실행",
|
"Library.Context.Launch": "실행",
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
"Library.SearchWatermark": "Zoeken in bibliotheek…",
|
"Library.SearchWatermark": "Zoeken in bibliotheek…",
|
||||||
"Library.AddFolder": "+ Map toevoegen",
|
"Library.AddFolder": "+ Map toevoegen",
|
||||||
"Library.Rescan": "⟳ Opnieuw scannen",
|
|
||||||
"Library.OpenFile": "Bestand openen…",
|
"Library.OpenFile": "Bestand openen…",
|
||||||
|
|
||||||
"Library.Context.Launch": "Starten",
|
"Library.Context.Launch": "Starten",
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
"Library.SearchWatermark": "Pesquisar biblioteca…",
|
"Library.SearchWatermark": "Pesquisar biblioteca…",
|
||||||
"Library.AddFolder": "+ Adicionar pasta",
|
"Library.AddFolder": "+ Adicionar pasta",
|
||||||
"Library.Rescan": "⟳ Reanalisar",
|
|
||||||
"Library.OpenFile": "Abrir ficheiro…",
|
"Library.OpenFile": "Abrir ficheiro…",
|
||||||
|
|
||||||
"Library.Context.Launch": "Iniciar",
|
"Library.Context.Launch": "Iniciar",
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
"Library.SearchWatermark": "Поиск…",
|
"Library.SearchWatermark": "Поиск…",
|
||||||
"Library.AddFolder": "+ Добавить папку",
|
"Library.AddFolder": "+ Добавить папку",
|
||||||
"Library.Rescan": "⟳ Сканировать",
|
|
||||||
"Library.OpenFile": "Открыть файл…",
|
"Library.OpenFile": "Открыть файл…",
|
||||||
|
|
||||||
"Library.Context.Launch": "Запустить",
|
"Library.Context.Launch": "Запустить",
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
"Library.SearchWatermark": "Kütüphanede ara…",
|
"Library.SearchWatermark": "Kütüphanede ara…",
|
||||||
"Library.AddFolder": "+ Klasör ekle",
|
"Library.AddFolder": "+ Klasör ekle",
|
||||||
"Library.Rescan": "⟳ Yeniden tara",
|
|
||||||
"Library.OpenFile": "Dosya aç…",
|
"Library.OpenFile": "Dosya aç…",
|
||||||
|
|
||||||
"Library.Context.Launch": "Başlat",
|
"Library.Context.Launch": "Başlat",
|
||||||
|
|||||||
@@ -80,7 +80,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
VerticalAlignment="Center">
|
VerticalAlignment="Center">
|
||||||
<TextBox x:Name="SearchBox" PlaceholderText="Search library…" Width="240" 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="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" />
|
<Button x:Name="OpenFileButton" Classes="ghost" Content="Open file…" VerticalAlignment="Center" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -41,15 +41,9 @@ public partial class MainWindow : Window
|
|||||||
private static readonly IBrush WarningLineBrush = new SolidColorBrush(Color.Parse("#E8B341"));
|
private static readonly IBrush WarningLineBrush = new SolidColorBrush(Color.Parse("#E8B341"));
|
||||||
private static readonly IBrush ErrorLineBrush = new SolidColorBrush(Color.Parse("#F2777C"));
|
private static readonly IBrush ErrorLineBrush = new SolidColorBrush(Color.Parse("#F2777C"));
|
||||||
private static readonly IBrush SuccessLineBrush = new SolidColorBrush(Color.Parse("#63D489"));
|
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 List<GameEntry> _allGames = new();
|
||||||
private readonly ObservableCollection<GameEntry> _visibleGames = new();
|
private readonly ObservableCollection<GameEntry> _visibleGames = new();
|
||||||
|
private readonly GameLibraryWatcher _libraryWatcher = new();
|
||||||
private readonly AvaloniaList<LogLine> _consoleLines = new();
|
private readonly AvaloniaList<LogLine> _consoleLines = new();
|
||||||
private readonly List<LogLine> _allConsoleLines = new();
|
private readonly List<LogLine> _allConsoleLines = new();
|
||||||
private readonly ConcurrentQueue<(string Line, bool IsError)> _pendingLines = new();
|
private readonly ConcurrentQueue<(string Line, bool IsError)> _pendingLines = new();
|
||||||
@@ -85,8 +79,10 @@ public partial class MainWindow : Window
|
|||||||
private string? _runningGameName;
|
private string? _runningGameName;
|
||||||
private string? _runningGameTitleId;
|
private string? _runningGameTitleId;
|
||||||
private long _runningSinceUnixSeconds;
|
private long _runningSinceUnixSeconds;
|
||||||
|
private int _libraryScanGeneration;
|
||||||
private int _detailLoadGeneration;
|
private int _detailLoadGeneration;
|
||||||
private int _backdropGeneration;
|
private int _backdropGeneration;
|
||||||
|
private bool _isClosing;
|
||||||
|
|
||||||
// Bundled key art shown whenever no game-specific backdrop applies; the
|
// Bundled key art shown whenever no game-specific backdrop applies; the
|
||||||
// plain window color remains the fallback when the asset fails to load.
|
// plain window color remains the fallback when the asset fails to load.
|
||||||
@@ -133,6 +129,7 @@ public partial class MainWindow : Window
|
|||||||
}
|
}
|
||||||
|
|
||||||
GameList.ItemsSource = _visibleGames;
|
GameList.ItemsSource = _visibleGames;
|
||||||
|
_libraryWatcher.RefreshRequested += OnLibraryRefreshRequested;
|
||||||
ConsoleList.ItemsSource = _consoleLines;
|
ConsoleList.ItemsSource = _consoleLines;
|
||||||
_consoleMirror = GuiConsoleMirror.Install((line, isError) =>
|
_consoleMirror = GuiConsoleMirror.Install((line, isError) =>
|
||||||
_pendingLines.Enqueue((line, isError)));
|
_pendingLines.Enqueue((line, isError)));
|
||||||
@@ -173,7 +170,6 @@ public partial class MainWindow : Window
|
|||||||
ConsoleSearchBox.TextChanged += (_, _) => RefreshVisibleConsoleLines();
|
ConsoleSearchBox.TextChanged += (_, _) => RefreshVisibleConsoleLines();
|
||||||
AddFolderButton.Click += async (_, _) => await AddFolderAsync();
|
AddFolderButton.Click += async (_, _) => await AddFolderAsync();
|
||||||
EmptyAddFolderButton.Click += async (_, _) => await AddFolderAsync();
|
EmptyAddFolderButton.Click += async (_, _) => await AddFolderAsync();
|
||||||
RescanButton.Click += async (_, _) => await RescanLibraryAsync();
|
|
||||||
OpenFileButton.Click += async (_, _) => await OpenFileAsync();
|
OpenFileButton.Click += async (_, _) => await OpenFileAsync();
|
||||||
LaunchButton.Click += (_, _) => LaunchSelected();
|
LaunchButton.Click += (_, _) => LaunchSelected();
|
||||||
ClearLogButton.Click += (_, _) => { _consoleLines.Clear(); _allConsoleLines.Clear(); };
|
ClearLogButton.Click += (_, _) => { _consoleLines.Clear(); _allConsoleLines.Clear(); };
|
||||||
@@ -619,7 +615,6 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
SearchBox.PlaceholderText = loc.Get("Library.SearchWatermark");
|
SearchBox.PlaceholderText = loc.Get("Library.SearchWatermark");
|
||||||
AddFolderButton.Content = loc.Get("Library.AddFolder");
|
AddFolderButton.Content = loc.Get("Library.AddFolder");
|
||||||
RescanButton.Content = loc.Get("Library.Rescan");
|
|
||||||
OpenFileButton.Content = loc.Get("Library.OpenFile");
|
OpenFileButton.Content = loc.Get("Library.OpenFile");
|
||||||
|
|
||||||
CtxLaunch.Header = loc.Get("Library.Context.Launch");
|
CtxLaunch.Header = loc.Get("Library.Context.Launch");
|
||||||
@@ -798,6 +793,10 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
private void OnWindowClosing()
|
private void OnWindowClosing()
|
||||||
{
|
{
|
||||||
|
_isClosing = true;
|
||||||
|
Interlocked.Increment(ref _libraryScanGeneration);
|
||||||
|
Interlocked.Increment(ref _detailLoadGeneration);
|
||||||
|
_libraryWatcher.Dispose();
|
||||||
_settings.Save();
|
_settings.Save();
|
||||||
_consoleFlushTimer.Stop();
|
_consoleFlushTimer.Stop();
|
||||||
_libraryBlurTimer.Stop();
|
_libraryBlurTimer.Stop();
|
||||||
@@ -1176,7 +1175,7 @@ public partial class MainWindow : Window
|
|||||||
}
|
}
|
||||||
|
|
||||||
var changed = false;
|
var changed = false;
|
||||||
if (!_settings.GameFolders.Contains(path, FilePathComparer))
|
if (!_settings.GameFolders.Contains(path, GameLibraryPath.Comparer))
|
||||||
{
|
{
|
||||||
_settings.GameFolders.Add(path);
|
_settings.GameFolders.Add(path);
|
||||||
changed = true;
|
changed = true;
|
||||||
@@ -1186,7 +1185,7 @@ public partial class MainWindow : Window
|
|||||||
// games beneath it that were removed from the library earlier.
|
// games beneath it that were removed from the library earlier.
|
||||||
var prefix = Path.TrimEndingDirectorySeparator(path) + Path.DirectorySeparatorChar;
|
var prefix = Path.TrimEndingDirectorySeparator(path) + Path.DirectorySeparatorChar;
|
||||||
changed |= _settings.ExcludedGames.RemoveAll(excluded =>
|
changed |= _settings.ExcludedGames.RemoveAll(excluded =>
|
||||||
excluded.StartsWith(prefix, FilePathComparison)) > 0;
|
excluded.StartsWith(prefix, GameLibraryPath.Comparison)) > 0;
|
||||||
|
|
||||||
if (changed)
|
if (changed)
|
||||||
{
|
{
|
||||||
@@ -1196,70 +1195,130 @@ public partial class MainWindow : Window
|
|||||||
await RescanLibraryAsync();
|
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 folders = _settings.GameFolders.ToArray();
|
||||||
var excluded = new HashSet<string>(_settings.ExcludedGames, FilePathComparer);
|
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");
|
StatusBarRight.Text = Localization.Instance.Get("Status.ScanningLibrary");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showLoadingState)
|
||||||
|
{
|
||||||
EmptyState.IsVisible = false;
|
EmptyState.IsVisible = false;
|
||||||
LoadingState.IsVisible = true;
|
LoadingState.IsVisible = true;
|
||||||
|
}
|
||||||
|
|
||||||
var games = await Task.Run(() => ScanFolders(folders, excluded));
|
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.Clear();
|
||||||
_allGames.AddRange(games);
|
_allGames.AddRange(reconciliation.Games);
|
||||||
RefreshVisibleGames();
|
RefreshVisibleGames(reconciliation.BackgroundsChanged);
|
||||||
LoadingState.IsVisible = false;
|
LoadingState.IsVisible = false;
|
||||||
LoadGameDetailsInBackground(games);
|
LoadGameDetailsInBackground(reconciliation.CoversToLoad, reconciliation.Games);
|
||||||
UpdateDiscordPresence();
|
UpdateDiscordPresence();
|
||||||
|
if (showProgress)
|
||||||
|
{
|
||||||
StatusBarRight.Text = folders.Length == 0
|
StatusBarRight.Text = folders.Length == 0
|
||||||
? Localization.Instance.Get("Status.AddFolderPrompt")
|
? Localization.Instance.Get("Status.AddFolderPrompt")
|
||||||
: Localization.Instance.Format("Status.LibraryScanned", games.Count, folders.Length);
|
: Localization.Instance.Format("Status.LibraryScanned", games.Count, folders.Length);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Enriches games off the UI thread — decodes cover art and totals each
|
/// Enriches games off the UI thread — decodes cover art and totals each
|
||||||
/// game's install folder size — posting results back as they become
|
/// game's install folder size — posting results back as they become
|
||||||
/// ready. A newer scan invalidates older loads.
|
/// ready. A newer scan invalidates older loads.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void LoadGameDetailsInBackground(IReadOnlyList<GameEntry> games)
|
private void LoadGameDetailsInBackground(
|
||||||
|
IReadOnlyList<GameEntry> coversToLoad,
|
||||||
|
IReadOnlyList<GameEntry> gamesToMeasure)
|
||||||
{
|
{
|
||||||
var generation = ++_detailLoadGeneration;
|
var generation = ++_detailLoadGeneration;
|
||||||
_ = Task.Run(() =>
|
_ = Task.Run(() =>
|
||||||
{
|
{
|
||||||
// Covers first: they are cheap and the most visible, so the grid
|
// Covers first: they are cheap and the most visible, so the grid
|
||||||
// fills with art before the (potentially slow) size pass runs.
|
// fills with art before the (potentially slow) size pass runs.
|
||||||
foreach (var game in games)
|
foreach (var game in coversToLoad)
|
||||||
{
|
{
|
||||||
if (generation != _detailLoadGeneration)
|
if (generation != _detailLoadGeneration)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (game.CoverPath is null)
|
var requestedCoverPath = game.CoverPath;
|
||||||
|
if (requestedCoverPath is null)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var stream = File.OpenRead(game.CoverPath);
|
using var stream = File.OpenRead(requestedCoverPath);
|
||||||
var bitmap = Bitmap.DecodeToWidth(stream, 312);
|
var bitmap = Bitmap.DecodeToWidth(stream, 312);
|
||||||
Dispatcher.UIThread.Post(() =>
|
Dispatcher.UIThread.Post(() =>
|
||||||
{
|
{
|
||||||
if (generation == _detailLoadGeneration)
|
if (generation == _detailLoadGeneration
|
||||||
|
&& _allGames.Contains(game)
|
||||||
|
&& string.Equals(
|
||||||
|
game.CoverPath,
|
||||||
|
requestedCoverPath,
|
||||||
|
StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
game.Cover = bitmap;
|
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)
|
if (generation != _detailLoadGeneration)
|
||||||
{
|
{
|
||||||
@@ -1271,7 +1330,7 @@ public partial class MainWindow : Window
|
|||||||
{
|
{
|
||||||
Dispatcher.UIThread.Post(() =>
|
Dispatcher.UIThread.Post(() =>
|
||||||
{
|
{
|
||||||
if (generation == _detailLoadGeneration)
|
if (generation == _detailLoadGeneration && _allGames.Contains(game))
|
||||||
{
|
{
|
||||||
game.SizeBytes = size;
|
game.SizeBytes = size;
|
||||||
}
|
}
|
||||||
@@ -1306,9 +1365,10 @@ public partial class MainWindow : Window
|
|||||||
total += file.Length;
|
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;
|
return total;
|
||||||
@@ -1317,7 +1377,7 @@ public partial class MainWindow : Window
|
|||||||
private static List<GameEntry> ScanFolders(IReadOnlyList<string> folders, IReadOnlySet<string> excludedPaths)
|
private static List<GameEntry> ScanFolders(IReadOnlyList<string> folders, IReadOnlySet<string> excludedPaths)
|
||||||
{
|
{
|
||||||
var games = new List<GameEntry>();
|
var games = new List<GameEntry>();
|
||||||
var seen = new HashSet<string>(FilePathComparer);
|
var seen = new HashSet<string>(GameLibraryPath.Comparer);
|
||||||
var enumeration = new EnumerationOptions
|
var enumeration = new EnumerationOptions
|
||||||
{
|
{
|
||||||
IgnoreInaccessible = true,
|
IgnoreInaccessible = true,
|
||||||
@@ -1347,8 +1407,10 @@ public partial class MainWindow : Window
|
|||||||
{
|
{
|
||||||
size = new FileInfo(fullPath).Length;
|
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);
|
var (title, titleId, version) = TryReadParamJson(fullPath);
|
||||||
@@ -1357,9 +1419,10 @@ public partial class MainWindow : Window
|
|||||||
FindCoverFor(fullPath), FindBackgroundFor(fullPath)));
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!_settings.ExcludedGames.Contains(game.Path, FilePathComparer))
|
if (!_settings.ExcludedGames.Contains(game.Path, GameLibraryPath.Comparer))
|
||||||
{
|
{
|
||||||
_settings.ExcludedGames.Add(game.Path);
|
_settings.ExcludedGames.Add(game.Path);
|
||||||
_settings.Save();
|
_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;
|
GameList.SelectedItem = null;
|
||||||
RefreshVisibleGames();
|
RefreshVisibleGames();
|
||||||
StatusBarRight.Text = Localization.Instance.Format("Status.RemovedFromLibrary", game.Name);
|
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 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)
|
foreach (var game in _allGames)
|
||||||
{
|
{
|
||||||
if (query.Length == 0 ||
|
if (query.Length == 0 ||
|
||||||
@@ -1625,22 +1690,38 @@ public partial class MainWindow : Window
|
|||||||
game.Path.Contains(query, StringComparison.OrdinalIgnoreCase) ||
|
game.Path.Contains(query, StringComparison.OrdinalIgnoreCase) ||
|
||||||
(game.TitleId?.Contains(query, StringComparison.OrdinalIgnoreCase) ?? false))
|
(game.TitleId?.Contains(query, StringComparison.OrdinalIgnoreCase) ?? false))
|
||||||
{
|
{
|
||||||
_visibleGames.Add(game);
|
desired.Add(game);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedPath is not null &&
|
GameLibraryReconciler.ReconcileVisibleGames(_visibleGames, desired);
|
||||||
_visibleGames.FirstOrDefault(g => g.Path.Equals(selectedPath, FilePathComparison))
|
|
||||||
is { } reselected)
|
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;
|
EmptyState.IsVisible = _visibleGames.Count == 0;
|
||||||
UpdateEmptyStateTexts();
|
UpdateEmptyStateTexts();
|
||||||
|
|
||||||
|
if (!ReferenceEquals(selectedBefore, selectedAfter))
|
||||||
|
{
|
||||||
UpdateSelectedGame();
|
UpdateSelectedGame();
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
UpdateSelectedGameTexts();
|
||||||
|
UpdateRunButtons();
|
||||||
|
if (selectedAfter is not null && backgroundsChanged?.Contains(selectedAfter) == true)
|
||||||
|
{
|
||||||
|
_ = UpdateBackdropAsync(selectedAfter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Refreshes the empty-state title/hint from the current language and
|
/// Refreshes the empty-state title/hint from the current language and
|
||||||
@@ -1861,7 +1942,8 @@ public partial class MainWindow : Window
|
|||||||
}
|
}
|
||||||
|
|
||||||
var resolvedTitleId = string.IsNullOrWhiteSpace(titleId)
|
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;
|
: titleId;
|
||||||
var effective = EffectiveLaunchSettings.Resolve(_settings, PerGameSettings.Load(resolvedTitleId));
|
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