mirror of
https://github.com/par274/sharpemu.git
synced 2026-07-14 12:56:14 +08:00
GUI: redesign library as a cover-art grid with game management (#48)
* GUI: redesign library as a cover-art grid with game management Replace the sidebar game list with a full-width grid of cover tiles. Cover art is loaded automatically from each game's sce_sys/icon0.png (pic0.png fallback) and decoded off the UI thread; games without art get a deterministic gradient placeholder with the title's initials. - New layout: search/scan toolbar, tile grid with hover and selection states, bottom launch bar with cover thumbnail, collapsible launch options and console panels (console auto-opens on launch) - Right-click context menu on tiles: launch, open game folder, copy path/title ID, remove from library - Removed games persist in an ExcludedGames settings list; re-adding a folder restores any removed games beneath it - Search now also matches title IDs - Fix: placeholder brushes were constructed on the scan thread, which throws in Avalonia and was silently swallowed, yielding empty scans * GUI: show full install folder size instead of eboot.bin size The library previously displayed the size of eboot.bin alone, which wildly understates a game's real footprint. The install folder is now totaled recursively in the existing background pass (after cover art, which is cheaper and more visible), and each tile updates live once its size is ready. * GUI: selection backdrop, smaller tiles, controller navigation Address review feedback on the library redesign: - Selecting a game fades its key art (sce_sys/pic0.png, pic1.png fallback) in as the window backdrop, dimmed by a gradient scrim; decoded off the UI thread and cached per entry - Cover tiles reduced from 156px to 128px - The library can be driven with a DualSense: d-pad/left stick moves the selection (hold-to-repeat, row-aware), Cross launches, Circle stops; input is ignored while the launcher window is unfocused. Reuses the pad HID reader by compile-linking its dependency-free sources instead of referencing all of SharpEmu.Libs
This commit is contained in:
@@ -11,7 +11,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<Application.Resources>
|
||||
<Color x:Key="SystemAccentColor">#7C5CFC</Color>
|
||||
|
||||
<SolidColorBrush x:Key="BgBrush" Color="#0D1017" />
|
||||
<LinearGradientBrush x:Key="BgBrush" StartPoint="0%,0%" EndPoint="100%,100%">
|
||||
<GradientStop Offset="0" Color="#12151F" />
|
||||
<GradientStop Offset="0.55" Color="#0D1017" />
|
||||
<GradientStop Offset="1" Color="#0B0D14" />
|
||||
</LinearGradientBrush>
|
||||
|
||||
<SolidColorBrush x:Key="ChromeBrush" Color="#090C12" />
|
||||
<SolidColorBrush x:Key="CardBrush" Color="#141924" />
|
||||
<SolidColorBrush x:Key="CardBorderBrush" Color="#232B3A" />
|
||||
@@ -24,6 +29,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<SolidColorBrush x:Key="DangerBrush" Color="#E5484D" />
|
||||
<SolidColorBrush x:Key="DangerHoverBrush" Color="#F2555A" />
|
||||
<SolidColorBrush x:Key="SuccessBrush" Color="#46C46B" />
|
||||
<SolidColorBrush x:Key="TileHoverBrush" Color="#1A2130" />
|
||||
<SolidColorBrush x:Key="TileSelectedBrush" Color="#212A3F" />
|
||||
</Application.Resources>
|
||||
|
||||
<Application.Styles>
|
||||
@@ -38,7 +45,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<Setter Property="Background" Value="{StaticResource CardBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
<Setter Property="Padding" Value="16" />
|
||||
</Style>
|
||||
|
||||
@@ -61,6 +68,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<Setter Property="Margin" Value="0,0,0,6" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBox">
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.accent">
|
||||
<Setter Property="Background" Value="{StaticResource AccentBrush}" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
@@ -98,6 +109,41 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ToggleButton.ghost">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
<Setter Property="Padding" Value="12,7" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
</Style>
|
||||
<Style Selector="ToggleButton.ghost:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
</Style>
|
||||
<Style Selector="ToggleButton.ghost:checked /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ContextMenu">
|
||||
<Setter Property="Background" Value="{StaticResource CardBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Padding" Value="6" />
|
||||
</Style>
|
||||
<Style Selector="ContextMenu MenuItem">
|
||||
<Setter Property="Padding" Value="10,7" />
|
||||
<Setter Property="CornerRadius" Value="7" />
|
||||
</Style>
|
||||
<Style Selector="ContextMenu Separator">
|
||||
<Setter Property="Background" Value="{StaticResource CardBorderBrush}" />
|
||||
<Setter Property="Height" Value="1" />
|
||||
<Setter Property="Margin" Value="8,4" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ListBox.console">
|
||||
<Setter Property="Background" Value="#0B0E14" />
|
||||
<Setter Property="FontFamily" Value="Cascadia Mono, Consolas, Courier New, monospace" />
|
||||
@@ -108,10 +154,44 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ListBox.library ListBoxItem">
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
<Setter Property="Margin" Value="4,2" />
|
||||
<Setter Property="Padding" Value="8,4" />
|
||||
<!-- Cover-art library grid -->
|
||||
<Style Selector="ListBox.tileGrid ListBoxItem">
|
||||
<Setter Property="Padding" Value="10" />
|
||||
<Setter Property="Margin" Value="5" />
|
||||
<Setter Property="CornerRadius" Value="14" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
<Setter Property="RenderTransform" Value="translateY(0px)" />
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.12" />
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style Selector="ListBox.tileGrid ListBoxItem:pointerover">
|
||||
<Setter Property="RenderTransform" Value="translateY(-3px)" />
|
||||
</Style>
|
||||
<Style Selector="ListBox.tileGrid ListBoxItem:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource TileHoverBrush}" />
|
||||
</Style>
|
||||
<Style Selector="ListBox.tileGrid ListBoxItem:selected /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource TileSelectedBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
|
||||
</Style>
|
||||
<Style Selector="ListBox.tileGrid ListBoxItem:selected:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource TileSelectedBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource AccentHoverBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.coverShadow">
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="BoxShadow" Value="0 6 14 0 #55000000" />
|
||||
</Style>
|
||||
<Style Selector="Border.coverClip">
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="ClipToBounds" Value="True" />
|
||||
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
||||
</Style>
|
||||
</Application.Styles>
|
||||
|
||||
|
||||
@@ -1,13 +1,147 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.ComponentModel;
|
||||
using Avalonia;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
|
||||
namespace SharpEmu.GUI;
|
||||
|
||||
public sealed record GameEntry(string Name, string? TitleId, string Path, long SizeBytes)
|
||||
public sealed class GameEntry : INotifyPropertyChanged
|
||||
{
|
||||
// Placeholder gradients for games without cover art, picked
|
||||
// deterministically from the game name so a game keeps its color.
|
||||
private static readonly (Color Start, Color End)[] PlaceholderPalette =
|
||||
{
|
||||
(Color.Parse("#5B4B8A"), Color.Parse("#2C2A4A")),
|
||||
(Color.Parse("#1F6E8C"), Color.Parse("#173B45")),
|
||||
(Color.Parse("#7A4069"), Color.Parse("#3B1C32")),
|
||||
(Color.Parse("#2D6A4F"), Color.Parse("#1B3A2B")),
|
||||
(Color.Parse("#8C5425"), Color.Parse("#4A2B12")),
|
||||
(Color.Parse("#4F6D9E"), Color.Parse("#263349")),
|
||||
(Color.Parse("#8A4B4B"), Color.Parse("#3F2222")),
|
||||
(Color.Parse("#3E7C7B"), Color.Parse("#1E3D3C")),
|
||||
};
|
||||
|
||||
private Bitmap? _cover;
|
||||
private IBrush? _placeholderBrush;
|
||||
private long _sizeBytes;
|
||||
|
||||
public GameEntry(
|
||||
string name, string? titleId, string path, long sizeBytes, string? coverPath, string? backgroundPath)
|
||||
{
|
||||
Name = name;
|
||||
TitleId = titleId;
|
||||
Path = path;
|
||||
_sizeBytes = sizeBytes;
|
||||
CoverPath = coverPath;
|
||||
BackgroundPath = backgroundPath;
|
||||
Initials = ComputeInitials(name);
|
||||
}
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public string? TitleId { get; }
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Total size of the game. Initially the eboot's own size from the scan;
|
||||
/// replaced with the full install folder size once computed in the
|
||||
/// background.
|
||||
/// </summary>
|
||||
public long SizeBytes
|
||||
{
|
||||
get => _sizeBytes;
|
||||
set
|
||||
{
|
||||
if (_sizeBytes == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_sizeBytes = value;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SizeBytes)));
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Detail)));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Path to the cover art image shipped with the game, if found.</summary>
|
||||
public string? CoverPath { get; }
|
||||
|
||||
/// <summary>Path to the key art (pic0/pic1) shipped with the game, if found.</summary>
|
||||
public string? BackgroundPath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Decoded key art used as the window backdrop while this game is
|
||||
/// selected. Loaded on demand and cached; not exposed via binding.
|
||||
/// </summary>
|
||||
public Bitmap? Background { get; set; }
|
||||
|
||||
public string Initials { get; }
|
||||
|
||||
// Built lazily: brushes are AvaloniaObjects that must be created on the
|
||||
// UI thread, while GameEntry itself is constructed on the scan thread.
|
||||
public IBrush PlaceholderBrush => _placeholderBrush ??= BuildPlaceholderBrush(Name);
|
||||
|
||||
/// <summary>Decoded cover art; loaded asynchronously after the library scan.</summary>
|
||||
public Bitmap? Cover
|
||||
{
|
||||
get => _cover;
|
||||
set
|
||||
{
|
||||
if (ReferenceEquals(_cover, value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_cover = value;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Cover)));
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HasCover)));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasCover => _cover is not null;
|
||||
|
||||
public string Detail => TitleId is not null
|
||||
? $"{TitleId} • {FormatSize(SizeBytes)}"
|
||||
: $"{FormatSize(SizeBytes)} • {Path}";
|
||||
: FormatSize(SizeBytes);
|
||||
|
||||
private static string ComputeInitials(string name)
|
||||
{
|
||||
var initials = name
|
||||
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Where(word => char.IsLetterOrDigit(word[0]))
|
||||
.Select(word => char.ToUpperInvariant(word[0]))
|
||||
.Take(2)
|
||||
.ToArray();
|
||||
|
||||
return initials.Length > 0 ? new string(initials) : "?";
|
||||
}
|
||||
|
||||
private static IBrush BuildPlaceholderBrush(string name)
|
||||
{
|
||||
var hash = 0;
|
||||
foreach (var ch in name)
|
||||
{
|
||||
hash = unchecked(hash * 31 + ch);
|
||||
}
|
||||
|
||||
var (start, end) = PlaceholderPalette[(int)((uint)hash % PlaceholderPalette.Length)];
|
||||
return new LinearGradientBrush
|
||||
{
|
||||
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
|
||||
EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
|
||||
GradientStops =
|
||||
{
|
||||
new GradientStop(start, 0),
|
||||
new GradientStop(end, 1),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatSize(long bytes)
|
||||
{
|
||||
|
||||
@@ -14,6 +14,9 @@ public sealed class GuiSettings
|
||||
|
||||
public List<string> GameFolders { get; set; } = new();
|
||||
|
||||
/// <summary>Eboot paths hidden from the library via "Remove from library".</summary>
|
||||
public List<string> ExcludedGames { get; set; } = new();
|
||||
|
||||
public string LogLevel { get; set; } = "Info";
|
||||
|
||||
public int ImportTraceLimit { get; set; }
|
||||
|
||||
@@ -7,8 +7,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="SharpEmu.GUI.MainWindow"
|
||||
Title="SharpEmu"
|
||||
Width="1280" Height="800"
|
||||
MinWidth="980" MinHeight="620"
|
||||
Width="1280" Height="820"
|
||||
MinWidth="980" MinHeight="640"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Background="{StaticResource BgBrush}"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
@@ -18,6 +18,28 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
<Grid RowDefinitions="44,*,32">
|
||||
|
||||
<!-- Selected-game backdrop: key art behind the main content, dimmed by
|
||||
a scrim so tiles and text stay readable. Fades on selection. -->
|
||||
<Panel Grid.Row="1" ClipToBounds="True">
|
||||
<Image x:Name="BackdropImage" Stretch="UniformToFill" Opacity="0"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<Image.Transitions>
|
||||
<Transitions>
|
||||
<DoubleTransition Property="Opacity" Duration="0:0:0.35" />
|
||||
</Transitions>
|
||||
</Image.Transitions>
|
||||
</Image>
|
||||
<Border>
|
||||
<Border.Background>
|
||||
<LinearGradientBrush StartPoint="0%,0%" EndPoint="0%,100%">
|
||||
<GradientStop Offset="0" Color="#730D1017" />
|
||||
<GradientStop Offset="0.55" Color="#BF0D1017" />
|
||||
<GradientStop Offset="1" Color="#EB0D1017" />
|
||||
</LinearGradientBrush>
|
||||
</Border.Background>
|
||||
</Border>
|
||||
</Panel>
|
||||
|
||||
<!-- Title bar -->
|
||||
<Grid x:Name="TitleBar" Grid.Row="0" Background="{StaticResource ChromeBrush}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10" Margin="16,0" VerticalAlignment="Center">
|
||||
@@ -30,119 +52,204 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</Grid>
|
||||
|
||||
<!-- Main content -->
|
||||
<Grid Grid.Row="1" Margin="14" ColumnDefinitions="360,14,*">
|
||||
<Grid Grid.Row="1" Margin="18,14,18,14" RowDefinitions="Auto,*,Auto,Auto">
|
||||
|
||||
<!-- Game library -->
|
||||
<Border Grid.Column="0" Classes="card" Padding="0">
|
||||
<Grid RowDefinitions="Auto,Auto,*,Auto">
|
||||
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="16,16,16,10">
|
||||
<TextBlock Classes="sectionTitle" Text="GAME LIBRARY" VerticalAlignment="Center" />
|
||||
<Border Grid.Column="1" Classes="pill">
|
||||
<TextBlock x:Name="GameCountText" Text="0" FontSize="11" Foreground="{StaticResource MutedBrush}" />
|
||||
</Border>
|
||||
<!-- Library toolbar -->
|
||||
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto,Auto" Margin="6,0,6,12">
|
||||
<TextBlock Grid.Column="0" Text="Library" FontSize="22" FontWeight="Bold" VerticalAlignment="Center" />
|
||||
<Border Grid.Column="1" Classes="pill" Margin="12,2,0,0" VerticalAlignment="Center">
|
||||
<TextBlock x:Name="GameCountText" Text="0 games" FontSize="11" Foreground="{StaticResource MutedBrush}" />
|
||||
</Border>
|
||||
<TextBox Grid.Column="3" x:Name="SearchBox" Watermark="Search library…" Width="280"
|
||||
VerticalAlignment="Center" Margin="0,0,12,0" />
|
||||
<Button Grid.Column="4" x:Name="AddFolderButton" Classes="ghost" Content="+ Add folder"
|
||||
VerticalAlignment="Center" Margin="0,0,8,0" />
|
||||
<Button Grid.Column="5" x:Name="RescanButton" Classes="ghost" Content="⟳ Rescan"
|
||||
VerticalAlignment="Center" Margin="0,0,8,0" />
|
||||
<Button Grid.Column="6" x:Name="OpenFileButton" Classes="ghost" Content="Open file…"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
|
||||
<!-- Cover-art grid -->
|
||||
<Panel Grid.Row="1">
|
||||
<ListBox x:Name="GameList" Classes="tileGrid" Background="Transparent"
|
||||
SelectionMode="Single" Padding="0">
|
||||
<ListBox.ContextMenu>
|
||||
<ContextMenu x:Name="GameContextMenu" Placement="Pointer">
|
||||
<MenuItem x:Name="CtxLaunch" Header="Launch" FontWeight="SemiBold">
|
||||
<MenuItem.Icon>
|
||||
<TextBlock Text="▶" FontSize="11" Foreground="{StaticResource AccentHoverBrush}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<MenuItem x:Name="CtxOpenFolder" Header="Open game folder">
|
||||
<MenuItem.Icon>
|
||||
<TextBlock Text="📂" FontSize="12" HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<Separator />
|
||||
<MenuItem x:Name="CtxCopyPath" Header="Copy path">
|
||||
<MenuItem.Icon>
|
||||
<TextBlock Text="⧉" FontSize="13" Foreground="{StaticResource MutedBrush}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<MenuItem x:Name="CtxCopyTitleId" Header="Copy title ID">
|
||||
<MenuItem.Icon>
|
||||
<TextBlock Text="⧉" FontSize="13" Foreground="{StaticResource MutedBrush}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<Separator />
|
||||
<MenuItem x:Name="CtxRemove" Header="Remove from library"
|
||||
Foreground="{StaticResource DangerHoverBrush}">
|
||||
<MenuItem.Icon>
|
||||
<TextBlock Text="✕" FontSize="12" Foreground="{StaticResource DangerHoverBrush}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
</ListBox.ContextMenu>
|
||||
<ListBox.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel />
|
||||
</ItemsPanelTemplate>
|
||||
</ListBox.ItemsPanel>
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Width="128" Height="186" Spacing="7">
|
||||
<Border Classes="coverShadow" Width="128" Height="128">
|
||||
<Border Classes="coverClip">
|
||||
<Panel>
|
||||
<Border Background="{Binding PlaceholderBrush}" IsVisible="{Binding !HasCover}">
|
||||
<TextBlock Text="{Binding Initials}" FontSize="36" FontWeight="Bold"
|
||||
Foreground="#E8ECF4" Opacity="0.85"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<Image Source="{Binding Cover}" Stretch="UniformToFill" IsVisible="{Binding HasCover}" />
|
||||
</Panel>
|
||||
</Border>
|
||||
</Border>
|
||||
<StackPanel Spacing="2">
|
||||
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="SemiBold"
|
||||
TextWrapping="Wrap" MaxLines="2" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Text="{Binding Detail}" FontSize="11" Foreground="{StaticResource MutedBrush}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<!-- Empty state -->
|
||||
<StackPanel x:Name="EmptyState" Spacing="10" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
IsVisible="False">
|
||||
<TextBlock Text="🎮" FontSize="44" HorizontalAlignment="Center" Opacity="0.7" />
|
||||
<TextBlock x:Name="EmptyStateTitle" Text="Your library is empty" FontSize="18" FontWeight="SemiBold"
|
||||
HorizontalAlignment="Center" />
|
||||
<TextBlock x:Name="EmptyStateHint" Text="Add a folder containing your games to get started."
|
||||
FontSize="13" Foreground="{StaticResource MutedBrush}" HorizontalAlignment="Center" />
|
||||
<Button x:Name="EmptyAddFolderButton" Classes="accent" Content="+ Add game folder"
|
||||
HorizontalAlignment="Center" Margin="0,8,0,0" />
|
||||
</StackPanel>
|
||||
</Panel>
|
||||
|
||||
<!-- Console (collapsible) -->
|
||||
<Border Grid.Row="2" x:Name="ConsolePanel" Classes="card" Padding="0" Height="240"
|
||||
Margin="0,12,0,0" IsVisible="False">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<Grid Grid.Row="0" ColumnDefinitions="*,Auto,Auto,Auto" Margin="16,12,16,8">
|
||||
<TextBlock Classes="sectionTitle" Text="CONSOLE" VerticalAlignment="Center" />
|
||||
<CheckBox Grid.Column="1" x:Name="AutoScrollCheck" Content="Auto-scroll" IsChecked="True"
|
||||
FontSize="12" Margin="0,0,12,0" />
|
||||
<Button Grid.Column="2" x:Name="CopyLogButton" Classes="ghost" Content="Copy" FontSize="12"
|
||||
Padding="10,4" Margin="0,0,8,0" />
|
||||
<Button Grid.Column="3" x:Name="ClearLogButton" Classes="ghost" Content="Clear" FontSize="12"
|
||||
Padding="10,4" />
|
||||
</Grid>
|
||||
|
||||
<TextBox Grid.Row="1" x:Name="SearchBox" Watermark="Search games…" Margin="12,0,12,8" CornerRadius="8" />
|
||||
|
||||
<ListBox Grid.Row="2" x:Name="GameList" Classes="library" Background="Transparent" Margin="8,0">
|
||||
<ListBox Grid.Row="1" x:Name="ConsoleList" Classes="console" BorderThickness="0,1,0,0"
|
||||
BorderBrush="{StaticResource CardBorderBrush}" CornerRadius="0,0,12,12">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Margin="4,4" Spacing="2">
|
||||
<TextBlock Text="{Binding Name}" FontSize="14" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Text="{Binding Detail}" FontSize="11" Foreground="{StaticResource MutedBrush}" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding Text}" Foreground="{Binding Brush}" TextWrapping="NoWrap" />
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<StackPanel Grid.Row="3" Orientation="Horizontal" Spacing="8" Margin="16,12,16,16">
|
||||
<Button x:Name="AddFolderButton" Classes="ghost" Content="+ Add folder" />
|
||||
<Button x:Name="RescanButton" Classes="ghost" Content="⟳ Rescan" />
|
||||
<Button x:Name="OpenFileButton" Classes="ghost" Content="Open file…" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Right column -->
|
||||
<Grid Grid.Column="2" RowDefinitions="Auto,12,Auto,12,*">
|
||||
<!-- Launch bar -->
|
||||
<Border Grid.Row="3" Classes="card" Margin="0,12,0,0" Padding="14">
|
||||
<StackPanel Spacing="14">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
|
||||
<!-- Selected game / launch -->
|
||||
<Border Grid.Row="0" Classes="card">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="6" VerticalAlignment="Center">
|
||||
<TextBlock x:Name="SelectedGameTitle" Text="No game selected" FontSize="20" FontWeight="Bold" TextTrimming="CharacterEllipsis" />
|
||||
<!-- Selected game cover thumbnail -->
|
||||
<Border Grid.Column="0" Classes="coverClip" Width="56" Height="56" CornerRadius="8"
|
||||
VerticalAlignment="Center">
|
||||
<Panel x:Name="SelectedCoverPanel">
|
||||
<Border Background="{Binding PlaceholderBrush, FallbackValue={x:Null}}"
|
||||
IsVisible="{Binding !HasCover, FallbackValue=False}">
|
||||
<TextBlock Text="{Binding Initials}" FontSize="20" FontWeight="Bold"
|
||||
Foreground="#E8ECF4" Opacity="0.85"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<Image Source="{Binding Cover}" Stretch="UniformToFill"
|
||||
IsVisible="{Binding HasCover, FallbackValue=False}" />
|
||||
</Panel>
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Column="1" Spacing="3" VerticalAlignment="Center" Margin="14,0,14,0">
|
||||
<TextBlock x:Name="SelectedGameTitle" Text="No game selected" FontSize="16" FontWeight="Bold"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock x:Name="SelectedGamePath" Text="Pick a game from the library, or open an eboot.bin directly."
|
||||
FontSize="12" Foreground="{StaticResource MutedBrush}" TextTrimming="CharacterEllipsis" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Ellipse x:Name="StatusDot" Width="9" Height="9" Fill="{StaticResource FaintBrush}" VerticalAlignment="Center" />
|
||||
<TextBlock x:Name="StatusText" Text="Idle" FontSize="12" Foreground="{StaticResource MutedBrush}" VerticalAlignment="Center" />
|
||||
FontSize="11" Foreground="{StaticResource MutedBrush}" TextTrimming="CharacterEllipsis" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="7">
|
||||
<Ellipse x:Name="StatusDot" Width="8" Height="8" Fill="{StaticResource FaintBrush}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock x:Name="StatusText" Text="Idle" FontSize="11" Foreground="{StaticResource MutedBrush}"
|
||||
VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center" Margin="16,0,0,0">
|
||||
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||
<ToggleButton x:Name="OptionsToggle" Classes="ghost" Content="⚙ Options" />
|
||||
<ToggleButton x:Name="ConsoleToggle" Classes="ghost" Content="≡ Console" />
|
||||
<Button x:Name="LaunchButton" Classes="accent" Content="▶ Launch" IsEnabled="False" />
|
||||
<Button x:Name="StopButton" Classes="danger" Content="■ Stop" IsEnabled="False" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Launch options -->
|
||||
<Border Grid.Row="2" Classes="card">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="sectionTitle" Text="LAUNCH OPTIONS" />
|
||||
<WrapPanel>
|
||||
<StackPanel Margin="0,0,24,0">
|
||||
<TextBlock Classes="fieldLabel" Text="CPU engine" />
|
||||
<ComboBox x:Name="CpuEngineBox" Width="150" SelectedIndex="0" CornerRadius="8">
|
||||
<ComboBoxItem Content="Native" />
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
<StackPanel Margin="0,0,24,0">
|
||||
<TextBlock Classes="fieldLabel" Text="Log level" />
|
||||
<ComboBox x:Name="LogLevelBox" Width="150" SelectedIndex="2" CornerRadius="8">
|
||||
<ComboBoxItem Content="Trace" />
|
||||
<ComboBoxItem Content="Debug" />
|
||||
<ComboBoxItem Content="Info" />
|
||||
<ComboBoxItem Content="Warning" />
|
||||
<ComboBoxItem Content="Error" />
|
||||
<ComboBoxItem Content="Critical" />
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
<StackPanel Margin="0,0,24,0">
|
||||
<TextBlock Classes="fieldLabel" Text="Import trace limit (0 = off)" />
|
||||
<NumericUpDown x:Name="TraceImportsBox" Width="170" Minimum="0" Maximum="4096" Increment="16"
|
||||
Value="0" FormatString="0" CornerRadius="8" />
|
||||
</StackPanel>
|
||||
<StackPanel>
|
||||
<TextBlock Classes="fieldLabel" Text="Strict dynlib resolution" />
|
||||
<ToggleSwitch x:Name="StrictToggle" OnContent="On" OffContent="Off" />
|
||||
</StackPanel>
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Console -->
|
||||
<Border Grid.Row="4" Classes="card" Padding="0">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<Grid Grid.Row="0" ColumnDefinitions="*,Auto,Auto,Auto" Margin="16,14,16,10">
|
||||
<TextBlock Classes="sectionTitle" Text="CONSOLE" VerticalAlignment="Center" />
|
||||
<CheckBox Grid.Column="1" x:Name="AutoScrollCheck" Content="Auto-scroll" IsChecked="True"
|
||||
FontSize="12" Margin="0,0,12,0" />
|
||||
<Button Grid.Column="2" x:Name="CopyLogButton" Classes="ghost" Content="Copy" FontSize="12"
|
||||
Padding="10,4" Margin="0,0,8,0" />
|
||||
<Button Grid.Column="3" x:Name="ClearLogButton" Classes="ghost" Content="Clear" FontSize="12"
|
||||
Padding="10,4" />
|
||||
</Grid>
|
||||
<ListBox Grid.Row="1" x:Name="ConsoleList" Classes="console" BorderThickness="0,1,0,0"
|
||||
BorderBrush="{StaticResource CardBorderBrush}" CornerRadius="0,0,10,10">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding Text}" Foreground="{Binding Brush}" TextWrapping="NoWrap" />
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
<!-- Launch options (collapsible) -->
|
||||
<WrapPanel x:Name="OptionsPanel" IsVisible="False">
|
||||
<StackPanel Margin="0,0,24,0">
|
||||
<TextBlock Classes="fieldLabel" Text="CPU engine" />
|
||||
<ComboBox x:Name="CpuEngineBox" Width="150" SelectedIndex="0" CornerRadius="8">
|
||||
<ComboBoxItem Content="Native" />
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
<StackPanel Margin="0,0,24,0">
|
||||
<TextBlock Classes="fieldLabel" Text="Log level" />
|
||||
<ComboBox x:Name="LogLevelBox" Width="150" SelectedIndex="2" CornerRadius="8">
|
||||
<ComboBoxItem Content="Trace" />
|
||||
<ComboBoxItem Content="Debug" />
|
||||
<ComboBoxItem Content="Info" />
|
||||
<ComboBoxItem Content="Warning" />
|
||||
<ComboBoxItem Content="Error" />
|
||||
<ComboBoxItem Content="Critical" />
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
<StackPanel Margin="0,0,24,0">
|
||||
<TextBlock Classes="fieldLabel" Text="Import trace limit (0 = off)" />
|
||||
<NumericUpDown x:Name="TraceImportsBox" Width="170" Minimum="0" Maximum="4096" Increment="16"
|
||||
Value="0" FormatString="0" CornerRadius="8" />
|
||||
</StackPanel>
|
||||
<StackPanel>
|
||||
<TextBlock Classes="fieldLabel" Text="Strict dynlib resolution" />
|
||||
<ToggleSwitch x:Name="StrictToggle" OnContent="On" OffContent="Off" />
|
||||
</StackPanel>
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<!-- Status bar -->
|
||||
|
||||
@@ -5,11 +5,17 @@ using System.Collections.Concurrent;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Diagnostics;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using SharpEmu.Libs.Pad;
|
||||
|
||||
namespace SharpEmu.GUI;
|
||||
|
||||
@@ -35,6 +41,16 @@ public partial class MainWindow : Window
|
||||
private string? _emulatorExePath;
|
||||
private bool _isRunning;
|
||||
private int _autoScrollTicks;
|
||||
private int _detailLoadGeneration;
|
||||
private int _backdropGeneration;
|
||||
|
||||
// Controller navigation state.
|
||||
private readonly DispatcherTimer _gamepadTimer;
|
||||
private uint _previousPadButtons;
|
||||
private long _navLeftNextAt;
|
||||
private long _navRightNextAt;
|
||||
private long _navUpNextAt;
|
||||
private long _navDownNextAt;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
@@ -59,15 +75,142 @@ public partial class MainWindow : Window
|
||||
GameList.DoubleTapped += (_, _) => LaunchSelected();
|
||||
SearchBox.TextChanged += (_, _) => RefreshVisibleGames();
|
||||
AddFolderButton.Click += async (_, _) => await AddFolderAsync();
|
||||
EmptyAddFolderButton.Click += async (_, _) => await AddFolderAsync();
|
||||
RescanButton.Click += async (_, _) => await RescanLibraryAsync();
|
||||
OpenFileButton.Click += async (_, _) => await OpenFileAsync();
|
||||
LaunchButton.Click += (_, _) => LaunchSelected();
|
||||
StopButton.Click += (_, _) => _emulator?.Stop();
|
||||
ClearLogButton.Click += (_, _) => _consoleLines.Clear();
|
||||
CopyLogButton.Click += async (_, _) => await CopyConsoleAsync();
|
||||
OptionsToggle.IsCheckedChanged += (_, _) => OptionsPanel.IsVisible = OptionsToggle.IsChecked == true;
|
||||
ConsoleToggle.IsCheckedChanged += (_, _) => ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true;
|
||||
|
||||
GameList.AddHandler(ContextRequestedEvent, OnGameContextRequested, RoutingStrategies.Tunnel);
|
||||
CtxLaunch.Click += (_, _) => LaunchSelected();
|
||||
CtxOpenFolder.Click += (_, _) => OpenSelectedGameFolder();
|
||||
CtxCopyPath.Click += async (_, _) =>
|
||||
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.Path, "Path");
|
||||
CtxCopyTitleId.Click += async (_, _) =>
|
||||
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.TitleId, "Title ID");
|
||||
CtxRemove.Click += (_, _) => RemoveSelectedFromLibrary();
|
||||
|
||||
Opened += async (_, _) => await OnOpenedAsync();
|
||||
Closing += (_, _) => OnWindowClosing();
|
||||
|
||||
DualSenseReader.EnsureStarted();
|
||||
_gamepadTimer = new DispatcherTimer
|
||||
{
|
||||
Interval = TimeSpan.FromMilliseconds(50),
|
||||
};
|
||||
_gamepadTimer.Tick += (_, _) => PollGamepad();
|
||||
_gamepadTimer.Start();
|
||||
}
|
||||
|
||||
// ---- Controller navigation ----
|
||||
|
||||
private void PollGamepad()
|
||||
{
|
||||
if (!DualSenseReader.TryGetState(out var pad))
|
||||
{
|
||||
_previousPadButtons = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsActive)
|
||||
{
|
||||
// Ignore input while the launcher is in the background (e.g. the
|
||||
// game window is focused and using the same controller).
|
||||
_previousPadButtons = pad.Buttons;
|
||||
return;
|
||||
}
|
||||
|
||||
var now = Environment.TickCount64;
|
||||
var left = (pad.Buttons & 0x0080) != 0 || pad.LeftX < 64;
|
||||
var right = (pad.Buttons & 0x0020) != 0 || pad.LeftX > 192;
|
||||
var up = (pad.Buttons & 0x0010) != 0 || pad.LeftY < 64;
|
||||
var down = (pad.Buttons & 0x0040) != 0 || pad.LeftY > 192;
|
||||
|
||||
if (ShouldNavigate(left, ref _navLeftNextAt, now))
|
||||
{
|
||||
MoveSelection(-1);
|
||||
}
|
||||
|
||||
if (ShouldNavigate(right, ref _navRightNextAt, now))
|
||||
{
|
||||
MoveSelection(1);
|
||||
}
|
||||
|
||||
if (ShouldNavigate(up, ref _navUpNextAt, now))
|
||||
{
|
||||
MoveSelection(-TilesPerRow());
|
||||
}
|
||||
|
||||
if (ShouldNavigate(down, ref _navDownNextAt, now))
|
||||
{
|
||||
MoveSelection(TilesPerRow());
|
||||
}
|
||||
|
||||
var pressed = pad.Buttons & ~_previousPadButtons;
|
||||
if ((pressed & 0x4000) != 0) // Cross
|
||||
{
|
||||
LaunchSelected();
|
||||
}
|
||||
|
||||
if ((pressed & 0x2000) != 0) // Circle
|
||||
{
|
||||
_emulator?.Stop();
|
||||
}
|
||||
|
||||
_previousPadButtons = pad.Buttons;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Edge-triggered with hold-to-repeat: fires on press, then repeats
|
||||
/// after 400ms at 130ms intervals while held.
|
||||
/// </summary>
|
||||
private static bool ShouldNavigate(bool held, ref long nextAt, long now)
|
||||
{
|
||||
if (!held)
|
||||
{
|
||||
nextAt = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (nextAt == 0)
|
||||
{
|
||||
nextAt = now + 400;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (now >= nextAt)
|
||||
{
|
||||
nextAt = now + 130;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void MoveSelection(int delta)
|
||||
{
|
||||
if (_visibleGames.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var index = GameList.SelectedIndex < 0
|
||||
? 0
|
||||
: Math.Clamp(GameList.SelectedIndex + delta, 0, _visibleGames.Count - 1);
|
||||
GameList.SelectedIndex = index;
|
||||
GameList.ScrollIntoView(index);
|
||||
}
|
||||
|
||||
private int TilesPerRow()
|
||||
{
|
||||
// Tile footprint: 128 content + 20 item padding + 10 item margin.
|
||||
const double TileOuterWidth = 158;
|
||||
var width = GameList.Bounds.Width;
|
||||
return width > TileOuterWidth ? (int)(width / TileOuterWidth) : 1;
|
||||
}
|
||||
|
||||
private async Task OnOpenedAsync()
|
||||
@@ -89,6 +232,7 @@ public partial class MainWindow : Window
|
||||
ReadControlsIntoSettings();
|
||||
_settings.Save();
|
||||
_consoleFlushTimer.Stop();
|
||||
_gamepadTimer.Stop();
|
||||
_emulator?.Dispose();
|
||||
}
|
||||
|
||||
@@ -188,9 +332,21 @@ public partial class MainWindow : Window
|
||||
return;
|
||||
}
|
||||
|
||||
var changed = false;
|
||||
if (!_settings.GameFolders.Contains(path, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
_settings.GameFolders.Add(path);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// Adding (or re-adding) a folder is an explicit signal to restore any
|
||||
// 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, StringComparison.OrdinalIgnoreCase)) > 0;
|
||||
|
||||
if (changed)
|
||||
{
|
||||
_settings.Save();
|
||||
}
|
||||
|
||||
@@ -200,19 +356,118 @@ public partial class MainWindow : Window
|
||||
private async Task RescanLibraryAsync()
|
||||
{
|
||||
var folders = _settings.GameFolders.ToArray();
|
||||
var excluded = new HashSet<string>(_settings.ExcludedGames, StringComparer.OrdinalIgnoreCase);
|
||||
StatusBarRight.Text = "Scanning library…";
|
||||
|
||||
var games = await Task.Run(() => ScanFolders(folders));
|
||||
var games = await Task.Run(() => ScanFolders(folders, excluded));
|
||||
|
||||
_allGames.Clear();
|
||||
_allGames.AddRange(games);
|
||||
RefreshVisibleGames();
|
||||
LoadGameDetailsInBackground(games);
|
||||
StatusBarRight.Text = folders.Length == 0
|
||||
? "Add a game folder to populate the library."
|
||||
: $"Library scanned: {games.Count} game(s) in {folders.Length} folder(s).";
|
||||
}
|
||||
|
||||
private static List<GameEntry> ScanFolders(IReadOnlyList<string> folders)
|
||||
/// <summary>
|
||||
/// Enriches games off the UI thread — decodes cover art and totals each
|
||||
/// 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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
if (generation != _detailLoadGeneration)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (game.CoverPath is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(game.CoverPath);
|
||||
var bitmap = Bitmap.DecodeToWidth(stream, 312);
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (generation == _detailLoadGeneration)
|
||||
{
|
||||
game.Cover = bitmap;
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// A missing or undecodable image keeps the placeholder.
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var game in games)
|
||||
{
|
||||
if (generation != _detailLoadGeneration)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var size = ComputeInstallSize(game.Path);
|
||||
if (size > 0)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (generation == _detailLoadGeneration)
|
||||
{
|
||||
game.SizeBytes = size;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Totals the size of the game's install folder (the directory holding
|
||||
/// the eboot), which is far more accurate than the eboot alone.
|
||||
/// </summary>
|
||||
private static long ComputeInstallSize(string ebootPath)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(ebootPath);
|
||||
if (directory is null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
long total = 0;
|
||||
try
|
||||
{
|
||||
var enumeration = new EnumerationOptions
|
||||
{
|
||||
IgnoreInaccessible = true,
|
||||
RecurseSubdirectories = true,
|
||||
};
|
||||
foreach (var file in new DirectoryInfo(directory).EnumerateFiles("*", enumeration))
|
||||
{
|
||||
total += file.Length;
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Fall back to whatever was accumulated so far.
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
private static List<GameEntry> ScanFolders(IReadOnlyList<string> folders, IReadOnlySet<string> excludedPaths)
|
||||
{
|
||||
var games = new List<GameEntry>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -235,7 +490,7 @@ public partial class MainWindow : Window
|
||||
foreach (var file in Directory.EnumerateFiles(folder, "eboot.bin", enumeration))
|
||||
{
|
||||
var fullPath = Path.GetFullPath(file);
|
||||
if (!seen.Add(fullPath))
|
||||
if (!seen.Add(fullPath) || excludedPaths.Contains(fullPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -250,7 +505,9 @@ public partial class MainWindow : Window
|
||||
}
|
||||
|
||||
var (title, titleId) = TryReadParamJson(fullPath);
|
||||
games.Add(new GameEntry(title ?? GameNameFor(fullPath), titleId, fullPath, size));
|
||||
games.Add(new GameEntry(
|
||||
title ?? GameNameFor(fullPath), titleId, fullPath, size,
|
||||
FindCoverFor(fullPath), FindBackgroundFor(fullPath)));
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
@@ -332,6 +589,56 @@ public partial class MainWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the cover art shipped with the game: sce_sys/icon0.png next to
|
||||
/// the executable (falling back to pic0.png).
|
||||
/// </summary>
|
||||
private static string? FindCoverFor(string ebootPath)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(ebootPath);
|
||||
if (directory is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sceSys = Path.Combine(directory, "sce_sys");
|
||||
foreach (var candidate in new[] { "icon0.png", "pic0.png" })
|
||||
{
|
||||
var coverPath = Path.Combine(sceSys, candidate);
|
||||
if (File.Exists(coverPath))
|
||||
{
|
||||
return coverPath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the key art shipped with the game (sce_sys/pic0.png, falling
|
||||
/// back to pic1.png), used as the window backdrop when selected.
|
||||
/// </summary>
|
||||
private static string? FindBackgroundFor(string ebootPath)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(ebootPath);
|
||||
if (directory is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sceSys = Path.Combine(directory, "sce_sys");
|
||||
foreach (var candidate in new[] { "pic0.png", "pic1.png" })
|
||||
{
|
||||
var backgroundPath = Path.Combine(sceSys, candidate);
|
||||
if (File.Exists(backgroundPath))
|
||||
{
|
||||
return backgroundPath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string GameNameFor(string ebootPath)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(ebootPath);
|
||||
@@ -339,26 +646,125 @@ public partial class MainWindow : Window
|
||||
return string.IsNullOrEmpty(name) ? Path.GetFileName(ebootPath) : name;
|
||||
}
|
||||
|
||||
// ---- Game context menu ----
|
||||
|
||||
/// <summary>
|
||||
/// Selects the tile under the pointer before its context menu opens, and
|
||||
/// suppresses the menu on empty grid space.
|
||||
/// </summary>
|
||||
private void OnGameContextRequested(object? sender, ContextRequestedEventArgs e)
|
||||
{
|
||||
var item = (e.Source as Visual)?.FindAncestorOfType<ListBoxItem>(includeSelf: true);
|
||||
if (item?.DataContext is not GameEntry game)
|
||||
{
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
GameList.SelectedItem = game;
|
||||
CtxLaunch.IsEnabled = !_isRunning;
|
||||
CtxCopyTitleId.IsEnabled = game.TitleId is not null;
|
||||
}
|
||||
|
||||
private void OpenSelectedGameFolder()
|
||||
{
|
||||
if (GameList.SelectedItem is not GameEntry game)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "explorer.exe",
|
||||
Arguments = $"/select,\"{game.Path}\"",
|
||||
UseShellExecute = false,
|
||||
});
|
||||
}
|
||||
else if (Path.GetDirectoryName(game.Path) is { } directory)
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = OperatingSystem.IsMacOS() ? "open" : "xdg-open",
|
||||
Arguments = $"\"{directory}\"",
|
||||
UseShellExecute = false,
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusBarRight.Text = $"Could not open folder: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CopyToClipboardAsync(string? text, string what)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text) || Clipboard is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await Clipboard.SetTextAsync(text);
|
||||
StatusBarRight.Text = $"{what} copied to clipboard.";
|
||||
}
|
||||
|
||||
private void RemoveSelectedFromLibrary()
|
||||
{
|
||||
if (GameList.SelectedItem is not GameEntry game)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_settings.ExcludedGames.Contains(game.Path, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
_settings.ExcludedGames.Add(game.Path);
|
||||
_settings.Save();
|
||||
}
|
||||
|
||||
_allGames.RemoveAll(g => string.Equals(g.Path, game.Path, StringComparison.OrdinalIgnoreCase));
|
||||
GameList.SelectedItem = null;
|
||||
RefreshVisibleGames();
|
||||
StatusBarRight.Text = $"Removed “{game.Name}” from the library. Re-add its folder to restore it.";
|
||||
}
|
||||
|
||||
private void RefreshVisibleGames()
|
||||
{
|
||||
var query = SearchBox.Text?.Trim() ?? string.Empty;
|
||||
var selected = GameList.SelectedItem as GameEntry;
|
||||
var selectedPath = (GameList.SelectedItem as GameEntry)?.Path;
|
||||
|
||||
_visibleGames.Clear();
|
||||
foreach (var game in _allGames)
|
||||
{
|
||||
if (query.Length == 0 ||
|
||||
game.Name.Contains(query, StringComparison.OrdinalIgnoreCase) ||
|
||||
game.Path.Contains(query, StringComparison.OrdinalIgnoreCase))
|
||||
game.Path.Contains(query, StringComparison.OrdinalIgnoreCase) ||
|
||||
(game.TitleId?.Contains(query, StringComparison.OrdinalIgnoreCase) ?? false))
|
||||
{
|
||||
_visibleGames.Add(game);
|
||||
}
|
||||
}
|
||||
|
||||
GameCountText.Text = _visibleGames.Count.ToString();
|
||||
if (selected is not null && _visibleGames.Contains(selected))
|
||||
GameCountText.Text = _visibleGames.Count == 1 ? "1 game" : $"{_visibleGames.Count} games";
|
||||
|
||||
if (selectedPath is not null &&
|
||||
_visibleGames.FirstOrDefault(g => g.Path.Equals(selectedPath, StringComparison.OrdinalIgnoreCase))
|
||||
is { } reselected)
|
||||
{
|
||||
GameList.SelectedItem = selected;
|
||||
GameList.SelectedItem = reselected;
|
||||
}
|
||||
|
||||
EmptyState.IsVisible = _visibleGames.Count == 0;
|
||||
if (_visibleGames.Count == 0)
|
||||
{
|
||||
var hasFilter = query.Length > 0;
|
||||
EmptyStateTitle.Text = hasFilter ? "No games match your search" : "Your library is empty";
|
||||
EmptyStateHint.Text = hasFilter
|
||||
? $"Nothing in the library matches “{query}”."
|
||||
: "Add a folder containing your games to get started.";
|
||||
EmptyAddFolderButton.IsVisible = !hasFilter;
|
||||
}
|
||||
|
||||
UpdateSelectedGame();
|
||||
@@ -370,16 +776,59 @@ public partial class MainWindow : Window
|
||||
{
|
||||
SelectedGameTitle.Text = game.Name;
|
||||
SelectedGamePath.Text = game.Path;
|
||||
SelectedCoverPanel.DataContext = game;
|
||||
_ = UpdateBackdropAsync(game);
|
||||
}
|
||||
else
|
||||
{
|
||||
SelectedGameTitle.Text = "No game selected";
|
||||
SelectedGamePath.Text = "Pick a game from the library, or open an eboot.bin directly.";
|
||||
SelectedCoverPanel.DataContext = null;
|
||||
_ = UpdateBackdropAsync(null);
|
||||
}
|
||||
|
||||
UpdateRunButtons();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fades the window backdrop to the selected game's key art. The image
|
||||
/// decodes off the UI thread and is cached on the entry; a newer
|
||||
/// selection cancels the fade-in of an older one.
|
||||
/// </summary>
|
||||
private async Task UpdateBackdropAsync(GameEntry? game)
|
||||
{
|
||||
var generation = ++_backdropGeneration;
|
||||
BackdropImage.Opacity = 0;
|
||||
|
||||
if (game?.BackgroundPath is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (game.Background is null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = game.BackgroundPath;
|
||||
game.Background = await Task.Run(() =>
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
return Bitmap.DecodeToWidth(stream, 1600);
|
||||
});
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return; // undecodable key art: keep the plain background
|
||||
}
|
||||
}
|
||||
|
||||
if (generation == _backdropGeneration)
|
||||
{
|
||||
BackdropImage.Source = game.Background;
|
||||
BackdropImage.Opacity = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Launching ----
|
||||
|
||||
private async Task OpenFileAsync()
|
||||
@@ -448,6 +897,7 @@ public partial class MainWindow : Window
|
||||
arguments.Add(ebootPath);
|
||||
|
||||
_consoleLines.Clear();
|
||||
ConsoleToggle.IsChecked = true;
|
||||
AppendConsoleLine($"$ SharpEmu {string.Join(' ', arguments)}", DimLineBrush);
|
||||
|
||||
var emulator = new EmulatorProcess();
|
||||
|
||||
@@ -25,4 +25,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<AvaloniaResource Include="..\..\assets\images\SharpEmu.ico" Link="Assets/SharpEmu.ico" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- The DualSense HID reader is shared with the emulator's pad HLE. It is
|
||||
dependency-free, so it is compiled in directly rather than pulling a
|
||||
reference to all of SharpEmu.Libs into the launcher. -->
|
||||
<ItemGroup>
|
||||
<Compile Include="..\SharpEmu.Libs\Pad\HidNative.cs" Link="Input/HidNative.cs" />
|
||||
<Compile Include="..\SharpEmu.Libs\Pad\DualSenseReader.cs" Link="Input/DualSenseReader.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user