feat: Add Avalonia launcher PoC with cross-platform UI
- Create LANCommander.Launcher.Avalonia project using Avalonia MVVM - Implement server selection, login, and game library views - Add library sidebar showing user's games - Add depot/games list view with game details - Integrate with LANCommander.Launcher.Services for: - DepotService, LibraryService, ImportService, MediaService - SDK authentication and connection clients - Fix async initialization pattern to prevent UI thread deadlocks - Add ConfigureAwait(false) to ServerConfigurationProvider.RefreshAsync - Add Microsoft.Extensions.Http package for HttpClient DI Key features: - Server discovery and connection - User authentication with credential persistence - Library import from server to local SQLite cache - Game icons and banners via MediaService - Navigation between library, depot, and game details
This commit is contained in:
parent
54a120f870
commit
8cf2139ccf
28 changed files with 1954 additions and 6 deletions
|
|
@ -111,13 +111,17 @@
|
|||
<ItemGroup Label="Microsoft Extensions">
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="9.0.8" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.9" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="9.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.9" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Systemd" Version="9.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.WindowsServices" Version="9.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="9.7.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Localization" Version="9.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.9" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="9.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="9.4.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Label="OpenTelemetry">
|
||||
|
|
@ -128,6 +132,13 @@
|
|||
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Label="Photino">
|
||||
<PackageVersion Include="AsyncImageLoader.Avalonia" Version="3.3.0" />
|
||||
<PackageVersion Include="Avalonia" Version="11.2.3" />
|
||||
<PackageVersion Include="Avalonia.Desktop" Version="11.2.3" />
|
||||
<PackageVersion Include="Avalonia.Themes.Fluent" Version="11.2.3" />
|
||||
<PackageVersion Include="Avalonia.Fonts.Inter" Version="11.2.3" />
|
||||
<PackageVersion Include="Avalonia.ReactiveUI" Version="11.2.3" />
|
||||
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
||||
<PackageVersion Include="Photino.Blazor" Version="4.0.13" />
|
||||
<PackageVersion Include="Photino.Blazor.CustomWindow" Version="1.3.1" />
|
||||
<PackageVersion Include="Photino.NET" Version="4.0.16" />
|
||||
|
|
|
|||
9
LANCommander.Launcher.Avalonia/App.axaml
Normal file
9
LANCommander.Launcher.Avalonia/App.axaml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="LANCommander.Launcher.Avalonia.App"
|
||||
RequestedThemeVariant="Dark">
|
||||
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
</Application.Styles>
|
||||
</Application>
|
||||
257
LANCommander.Launcher.Avalonia/App.axaml.cs
Normal file
257
LANCommander.Launcher.Avalonia/App.axaml.cs
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Data.Core.Plugins;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using LANCommander.Launcher.Avalonia.ViewModels;
|
||||
using LANCommander.Launcher.Avalonia.Views;
|
||||
using LANCommander.Launcher.Services.Extensions;
|
||||
using LANCommander.SDK;
|
||||
using LANCommander.SDK.Extensions;
|
||||
using LANCommander.SDK.Providers;
|
||||
using LANCommander.SDK.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace LANCommander.Launcher.Avalonia;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
public static IServiceProvider? Services { get; private set; }
|
||||
private static ILogger<App>? _logger;
|
||||
|
||||
public override void Initialize()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Configure services
|
||||
var services = new ServiceCollection();
|
||||
ConfigureServices(services);
|
||||
Services = services.BuildServiceProvider();
|
||||
|
||||
_logger = Services.GetRequiredService<ILogger<App>>();
|
||||
_logger.LogInformation("LANCommander Avalonia Launcher starting...");
|
||||
|
||||
// Remove Avalonia's built-in data validation plugin to avoid duplicate validations
|
||||
var dataValidationPlugins = BindingPlugins.DataValidators;
|
||||
for (var i = dataValidationPlugins.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (dataValidationPlugins[i] is DataAnnotationsValidationPlugin)
|
||||
dataValidationPlugins.RemoveAt(i);
|
||||
}
|
||||
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
desktop.ShutdownMode = ShutdownMode.OnMainWindowClose;
|
||||
|
||||
var mainViewModel = Services.GetRequiredService<MainWindowViewModel>();
|
||||
|
||||
var mainWindow = new MainWindow
|
||||
{
|
||||
DataContext = mainViewModel
|
||||
};
|
||||
|
||||
mainWindow.Closed += (sender, args) =>
|
||||
{
|
||||
_logger?.LogWarning("MainWindow Closed event fired");
|
||||
};
|
||||
|
||||
mainWindow.Closing += (sender, args) =>
|
||||
{
|
||||
_logger?.LogWarning("MainWindow Closing event fired");
|
||||
};
|
||||
|
||||
desktop.MainWindow = mainWindow;
|
||||
mainWindow.Show();
|
||||
|
||||
_logger.LogInformation("Main window created and shown, IsVisible={IsVisible}", mainWindow.IsVisible);
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
|
||||
// Perform async initialization AFTER framework initialization is complete
|
||||
// This ensures the window is shown and the message loop is running
|
||||
_ = InitializeApplicationAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogCritical(ex, "Fatal error during initialization");
|
||||
Console.Error.WriteLine($"Fatal error during initialization: {ex}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task InitializeApplicationAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger?.LogInformation("Starting async initialization...");
|
||||
|
||||
// Initialize application (same order as main Launcher/Program.cs)
|
||||
using (var scope = Services!.CreateScope())
|
||||
{
|
||||
var connectionClient = scope.ServiceProvider.GetRequiredService<IConnectionClient>();
|
||||
var settingsProvider = scope.ServiceProvider.GetRequiredService<SettingsProvider<Settings.Settings>>();
|
||||
var databaseContext = scope.ServiceProvider.GetRequiredService<Data.DatabaseContext>();
|
||||
|
||||
// Connect to server
|
||||
_logger?.LogInformation("Connecting to server...");
|
||||
await connectionClient.ConnectAsync().ConfigureAwait(false);
|
||||
|
||||
if (!await connectionClient.PingAsync().ConfigureAwait(false))
|
||||
{
|
||||
_logger?.LogWarning("Server not reachable, enabling offline mode");
|
||||
await connectionClient.EnableOfflineModeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Set default install directory if not configured
|
||||
if (settingsProvider.CurrentValue.Games.InstallDirectories.Length == 0)
|
||||
{
|
||||
_logger?.LogInformation("Setting default install directory");
|
||||
settingsProvider.Update(static s => s.Games.InstallDirectories = GetOSPlatform() switch
|
||||
{
|
||||
var platform when platform == OSPlatform.Windows => [Path.Combine(Path.GetPathRoot(AppContext.BaseDirectory) ?? "C:", "Games")],
|
||||
var platform when platform == OSPlatform.Linux => [Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Games")],
|
||||
var platform when platform == OSPlatform.OSX => [Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Games")],
|
||||
_ => throw new NotSupportedException("Unsupported OS platform")
|
||||
});
|
||||
}
|
||||
|
||||
// Run database migrations
|
||||
_logger?.LogInformation("Running database migrations...");
|
||||
await databaseContext.Database.MigrateAsync().ConfigureAwait(false);
|
||||
_logger?.LogInformation("Database migrations complete");
|
||||
}
|
||||
|
||||
// Initialize the view model on the UI thread
|
||||
var mainViewModel = Services!.GetRequiredService<MainWindowViewModel>();
|
||||
_logger?.LogInformation("Initializing view model...");
|
||||
await mainViewModel.InitializeAsync().ConfigureAwait(false);
|
||||
_logger?.LogInformation("View model initialized, application ready");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogCritical(ex, "Fatal error during async initialization");
|
||||
Console.Error.WriteLine($"Fatal error during async initialization: {ex}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ConfigureServices(IServiceCollection services)
|
||||
{
|
||||
// Configure logging to console and file
|
||||
var logDirectory = Path.Combine(AppPaths.GetConfigDirectory(), "Logs");
|
||||
Directory.CreateDirectory(logDirectory);
|
||||
var logFilePath = Path.Combine(logDirectory, $"avalonia-launcher-{DateTime.Now:yyyy-MM-dd}.log");
|
||||
|
||||
services.AddLogging(builder =>
|
||||
{
|
||||
builder.SetMinimumLevel(LogLevel.Debug);
|
||||
builder.AddConsole();
|
||||
builder.AddSimpleConsole(options =>
|
||||
{
|
||||
options.IncludeScopes = true;
|
||||
options.TimestampFormat = "[HH:mm:ss] ";
|
||||
});
|
||||
// Add file logging via a simple provider
|
||||
builder.AddProvider(new FileLoggerProvider(logFilePath));
|
||||
});
|
||||
|
||||
// Add HttpClient (required by SDK services)
|
||||
services.AddHttpClient();
|
||||
|
||||
// Configure settings from file (same as main launcher's AddSettings())
|
||||
var configurationBuilder = new ConfigurationBuilder();
|
||||
var configuration = configurationBuilder.ReadFromFile<Settings.Settings>();
|
||||
var refresher = configurationBuilder.ReadFromServer<Settings.Settings>(configuration);
|
||||
configuration = configurationBuilder.Build();
|
||||
|
||||
services.Configure<Settings.Settings>(configuration);
|
||||
services.AddSingleton(refresher); // Register without interface, same as main launcher
|
||||
|
||||
// Add SDK client and Launcher services
|
||||
services.AddLANCommanderClient<Settings.Settings>();
|
||||
services.AddLANCommanderLauncher();
|
||||
|
||||
// ViewModels
|
||||
services.AddSingleton<MainWindowViewModel>();
|
||||
}
|
||||
|
||||
private static OSPlatform GetOSPlatform()
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
return OSPlatform.Windows;
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
return OSPlatform.Linux;
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
return OSPlatform.OSX;
|
||||
throw new NotSupportedException("Unsupported OS platform");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simple file logger provider for debugging
|
||||
/// </summary>
|
||||
public class FileLoggerProvider : ILoggerProvider
|
||||
{
|
||||
private readonly string _filePath;
|
||||
private readonly object _lock = new();
|
||||
|
||||
public FileLoggerProvider(string filePath)
|
||||
{
|
||||
_filePath = filePath;
|
||||
}
|
||||
|
||||
public ILogger CreateLogger(string categoryName) => new FileLogger(_filePath, categoryName, _lock);
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
public class FileLogger : ILogger
|
||||
{
|
||||
private readonly string _filePath;
|
||||
private readonly string _categoryName;
|
||||
private readonly object _lock;
|
||||
|
||||
public FileLogger(string filePath, string categoryName, object lockObj)
|
||||
{
|
||||
_filePath = filePath;
|
||||
_categoryName = categoryName;
|
||||
_lock = lockObj;
|
||||
}
|
||||
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Debug;
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
if (!IsEnabled(logLevel)) return;
|
||||
|
||||
var message = $"[{DateTime.Now:HH:mm:ss}] [{logLevel}] [{_categoryName}] {formatter(state, exception)}";
|
||||
if (exception != null)
|
||||
message += Environment.NewLine + exception;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.AppendAllText(_filePath, message + Environment.NewLine);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore file write errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" />
|
||||
<PackageReference Include="Avalonia.Desktop" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" />
|
||||
<PackageReference Include="Avalonia.ReactiveUI" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LANCommander.Launcher.Services\LANCommander.Launcher.Services.csproj" />
|
||||
<ProjectReference Include="..\LANCommander.Launcher.Settings\LANCommander.Launcher.Settings.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
21
LANCommander.Launcher.Avalonia/Program.cs
Normal file
21
LANCommander.Launcher.Avalonia/Program.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
using Avalonia;
|
||||
using System;
|
||||
|
||||
namespace LANCommander.Launcher.Avalonia;
|
||||
|
||||
class Program
|
||||
{
|
||||
// Initialization code. Don't use any Avalonia, third-party APIs or any
|
||||
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
|
||||
// yet and stuff might break.
|
||||
[STAThread]
|
||||
public static void Main(string[] args) => BuildAvaloniaApp()
|
||||
.StartWithClassicDesktopLifetime(args);
|
||||
|
||||
// Avalonia configuration, don't remove; also used by visual designer.
|
||||
public static AppBuilder BuildAvaloniaApp()
|
||||
=> AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
.WithInterFont()
|
||||
.LogToTrace();
|
||||
}
|
||||
196
LANCommander.Launcher.Avalonia/ViewModels/GameDetailViewModel.cs
Normal file
196
LANCommander.Launcher.Avalonia/ViewModels/GameDetailViewModel.cs
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LANCommander.Launcher.Services;
|
||||
using LANCommander.SDK.Enums;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace LANCommander.Launcher.Avalonia.ViewModels;
|
||||
|
||||
public partial class GameDetailViewModel : ViewModelBase
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
|
||||
[ObservableProperty]
|
||||
private Guid _id;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _title = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _description = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string? _bannerPath;
|
||||
|
||||
[ObservableProperty]
|
||||
private string? _backgroundPath;
|
||||
|
||||
[ObservableProperty]
|
||||
private string? _iconPath;
|
||||
|
||||
[ObservableProperty]
|
||||
private DateTime _releasedOn;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _releaseYear = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _singleplayer;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _genres = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _developers = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _publishers = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _platforms = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _multiplayerModes = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _tags = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _hasMultiplayer;
|
||||
|
||||
public event EventHandler? BackRequested;
|
||||
|
||||
public GameDetailViewModel(IServiceProvider serviceProvider)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load game from local cache (Data.Models.Game)
|
||||
/// Used when selecting from the library sidebar
|
||||
/// </summary>
|
||||
public void LoadGame(Data.Models.Game game)
|
||||
{
|
||||
Id = game.Id;
|
||||
Title = game.Title ?? "Unknown";
|
||||
Description = game.Description ?? string.Empty;
|
||||
ReleasedOn = game.ReleasedOn ?? DateTime.MinValue;
|
||||
ReleaseYear = game.ReleasedOn?.Year > 1 ? game.ReleasedOn.Value.Year.ToString() : "Unknown";
|
||||
Singleplayer = game.Singleplayer;
|
||||
|
||||
// Get media paths from local storage
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var mediaService = scope.ServiceProvider.GetRequiredService<MediaService>();
|
||||
|
||||
BannerPath = GetLocalMediaPath(game.Media, MediaType.Cover, mediaService);
|
||||
BackgroundPath = GetLocalMediaPath(game.Media, MediaType.Background, mediaService);
|
||||
IconPath = GetLocalMediaPath(game.Media, MediaType.Icon, mediaService);
|
||||
|
||||
// Collections
|
||||
Genres = game.Genres != null
|
||||
? string.Join(", ", game.Genres.Select(g => g.Name))
|
||||
: string.Empty;
|
||||
|
||||
Developers = game.Developers != null
|
||||
? string.Join(", ", game.Developers.Select(d => d.Name))
|
||||
: string.Empty;
|
||||
|
||||
Publishers = game.Publishers != null
|
||||
? string.Join(", ", game.Publishers.Select(p => p.Name))
|
||||
: string.Empty;
|
||||
|
||||
Platforms = game.Platforms != null
|
||||
? string.Join(", ", game.Platforms.Select(p => p.Name))
|
||||
: string.Empty;
|
||||
|
||||
Tags = game.Tags != null
|
||||
? string.Join(", ", game.Tags.Select(t => t.Name))
|
||||
: string.Empty;
|
||||
|
||||
// Multiplayer info
|
||||
HasMultiplayer = game.MultiplayerModes != null && game.MultiplayerModes.Any();
|
||||
if (HasMultiplayer)
|
||||
{
|
||||
var modes = game.MultiplayerModes!
|
||||
.Select(m => m.Type.ToString())
|
||||
.Distinct();
|
||||
MultiplayerModes = string.Join(", ", modes);
|
||||
}
|
||||
else
|
||||
{
|
||||
MultiplayerModes = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load game from server API (SDK.Models.Game)
|
||||
/// Used when selecting from the depot/all games list
|
||||
/// </summary>
|
||||
public void LoadGame(SDK.Models.Game game)
|
||||
{
|
||||
Id = game.Id;
|
||||
Title = game.Title ?? "Unknown";
|
||||
Description = game.Description ?? string.Empty;
|
||||
ReleasedOn = game.ReleasedOn;
|
||||
ReleaseYear = game.ReleasedOn.Year > 1 ? game.ReleasedOn.Year.ToString() : "Unknown";
|
||||
Singleplayer = game.Singleplayer;
|
||||
|
||||
// For server games, we need to construct URLs or download media
|
||||
// For now, we won't have local paths - could use server URLs if needed
|
||||
BannerPath = null;
|
||||
BackgroundPath = null;
|
||||
IconPath = null;
|
||||
|
||||
// Collections
|
||||
Genres = game.Genres != null
|
||||
? string.Join(", ", game.Genres.Select(g => g.Name))
|
||||
: string.Empty;
|
||||
|
||||
Developers = game.Developers != null
|
||||
? string.Join(", ", game.Developers.Select(d => d.Name))
|
||||
: string.Empty;
|
||||
|
||||
Publishers = game.Publishers != null
|
||||
? string.Join(", ", game.Publishers.Select(p => p.Name))
|
||||
: string.Empty;
|
||||
|
||||
Platforms = game.Platforms != null
|
||||
? string.Join(", ", game.Platforms.Select(p => p.Name))
|
||||
: string.Empty;
|
||||
|
||||
Tags = game.Tags != null
|
||||
? string.Join(", ", game.Tags.Select(t => t.Name))
|
||||
: string.Empty;
|
||||
|
||||
// Multiplayer info
|
||||
HasMultiplayer = game.MultiplayerModes != null && game.MultiplayerModes.Any();
|
||||
if (HasMultiplayer)
|
||||
{
|
||||
var modes = game.MultiplayerModes!
|
||||
.Select(m => m.Type.ToString())
|
||||
.Distinct();
|
||||
MultiplayerModes = string.Join(", ", modes);
|
||||
}
|
||||
else
|
||||
{
|
||||
MultiplayerModes = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private string? GetLocalMediaPath(System.Collections.Generic.ICollection<Data.Models.Media>? mediaCollection, MediaType type, MediaService mediaService)
|
||||
{
|
||||
var media = mediaCollection?.FirstOrDefault(m => m.Type == type);
|
||||
if (media == null) return null;
|
||||
|
||||
var path = mediaService.GetImagePath(media);
|
||||
return mediaService.FileExists(media) ? path : null;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void GoBack()
|
||||
{
|
||||
BackRequested?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
219
LANCommander.Launcher.Avalonia/ViewModels/GamesListViewModel.cs
Normal file
219
LANCommander.Launcher.Avalonia/ViewModels/GamesListViewModel.cs
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LANCommander.Launcher.Data.Models;
|
||||
using LANCommander.Launcher.Models;
|
||||
using LANCommander.Launcher.Services;
|
||||
using LANCommander.SDK.Enums;
|
||||
using LANCommander.SDK.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace LANCommander.Launcher.Avalonia.ViewModels;
|
||||
|
||||
public partial class GamesListViewModel : ViewModelBase
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<GamesListViewModel> _logger;
|
||||
|
||||
// Store the depot items so we can access them when selecting a game
|
||||
private IEnumerable<ListItem>? _depotItems;
|
||||
|
||||
[ObservableProperty]
|
||||
private ObservableCollection<GameItemViewModel> _games = new();
|
||||
|
||||
[ObservableProperty]
|
||||
private string _statusMessage = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _isLoading;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _hasError;
|
||||
|
||||
[ObservableProperty]
|
||||
private GameItemViewModel? _selectedGame;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _searchText = string.Empty;
|
||||
|
||||
// Event now passes the SDK Game model fetched from server
|
||||
public event EventHandler<SDK.Models.Game>? GameSelected;
|
||||
|
||||
public GamesListViewModel(IServiceProvider serviceProvider)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = serviceProvider.GetRequiredService<ILogger<GamesListViewModel>>();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task LoadGamesInternalAsync()
|
||||
{
|
||||
IsLoading = true;
|
||||
HasError = false;
|
||||
StatusMessage = "Loading games...";
|
||||
Games.Clear();
|
||||
_logger.LogInformation("Loading games from depot...");
|
||||
|
||||
try
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
_logger.LogDebug("Created scope, resolving services...");
|
||||
|
||||
var depotService = scope.ServiceProvider.GetRequiredService<DepotService>();
|
||||
_logger.LogDebug("DepotService resolved");
|
||||
|
||||
var libraryService = scope.ServiceProvider.GetRequiredService<LibraryService>();
|
||||
_logger.LogDebug("LibraryService resolved");
|
||||
|
||||
var mediaService = scope.ServiceProvider.GetRequiredService<MediaService>();
|
||||
_logger.LogDebug("MediaService resolved");
|
||||
|
||||
_logger.LogDebug("Calling depotService.GetItemsAsync()...");
|
||||
_depotItems = await depotService.GetItemsAsync();
|
||||
_logger.LogDebug("Got {Count} depot items", _depotItems?.Count() ?? 0);
|
||||
|
||||
foreach (var item in _depotItems ?? [])
|
||||
{
|
||||
_logger.LogDebug("Processing depot item: Type={Type}, DataItem={DataItemType}",
|
||||
item.GetType().Name, item.DataItem?.GetType().Name ?? "null");
|
||||
|
||||
if (item.DataItem is SDK.Models.DepotGame depotGame)
|
||||
{
|
||||
var inLibrary = libraryService.IsInLibrary(depotGame.Id);
|
||||
var iconPath = await mediaService.GetImagePath(item.IconId);
|
||||
Games.Add(new GameItemViewModel(depotGame, iconPath, inLibrary));
|
||||
}
|
||||
}
|
||||
|
||||
StatusMessage = $"{Games.Count} games available";
|
||||
_logger.LogInformation("Loaded {Count} games from depot", Games.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load games from depot");
|
||||
StatusMessage = $"Failed to load games: {ex.Message}";
|
||||
HasError = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
public Task LoadGamesAsync() => LoadGamesInternalAsync();
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ViewGameDetailsAsync(GameItemViewModel? gameItem)
|
||||
{
|
||||
if (gameItem == null) return;
|
||||
|
||||
_logger.LogDebug("Viewing game details for {GameId}", gameItem.Id);
|
||||
|
||||
try
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
|
||||
// Fetch the full game details from the SERVER using GameClient
|
||||
// This is how the original Blazor launcher does it in DepotGameDetails.razor
|
||||
var gameClient = scope.ServiceProvider.GetRequiredService<GameClient>();
|
||||
_logger.LogDebug("Fetching game {GameId} from server...", gameItem.Id);
|
||||
|
||||
var game = await gameClient.GetAsync(gameItem.Id);
|
||||
|
||||
if (game != null)
|
||||
{
|
||||
_logger.LogDebug("Got game from server: {Title}", game.Title);
|
||||
GameSelected?.Invoke(this, game);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Game {GameId} not found on server", gameItem.Id);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to fetch game {GameId} from server", gameItem.Id);
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnSearchTextChanged(string value)
|
||||
{
|
||||
// Simple client-side filtering - in a real app you'd want to debounce this
|
||||
}
|
||||
}
|
||||
|
||||
public partial class GameItemViewModel : ViewModelBase
|
||||
{
|
||||
[ObservableProperty]
|
||||
private Guid _id;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _title = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _description = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _sortTitle = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private DateTime _releasedOn;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _singleplayer;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _genres = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _developers = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _publishers = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string? _iconPath;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _hasIcon;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _inLibrary;
|
||||
|
||||
public GameItemViewModel(SDK.Models.DepotGame game, string? iconPath = null, bool inLibrary = false)
|
||||
{
|
||||
Id = game.Id;
|
||||
Title = game.Title ?? "Unknown";
|
||||
Description = game.Description ?? string.Empty;
|
||||
SortTitle = game.SortTitle ?? game.Title ?? string.Empty;
|
||||
ReleasedOn = game.ReleasedOn;
|
||||
Singleplayer = game.Singleplayer;
|
||||
Genres = game.Genres != null ? string.Join(", ", game.Genres.Select(g => g.Name)) : string.Empty;
|
||||
Developers = game.Developers != null ? string.Join(", ", game.Developers.Select(d => d.Name)) : string.Empty;
|
||||
Publishers = game.Publishers != null ? string.Join(", ", game.Publishers.Select(p => p.Name)) : string.Empty;
|
||||
IconPath = iconPath;
|
||||
HasIcon = !string.IsNullOrEmpty(iconPath);
|
||||
InLibrary = inLibrary;
|
||||
}
|
||||
|
||||
public GameItemViewModel(Game game, string? iconPath = null, bool inLibrary = false)
|
||||
{
|
||||
Id = game.Id;
|
||||
Title = game.Title ?? "Unknown";
|
||||
Description = game.Description ?? string.Empty;
|
||||
SortTitle = game.SortTitle ?? game.Title ?? string.Empty;
|
||||
ReleasedOn = game.ReleasedOn ?? DateTime.MinValue;
|
||||
Singleplayer = game.Singleplayer;
|
||||
Genres = game.Genres != null ? string.Join(", ", game.Genres.Select(g => g.Name)) : string.Empty;
|
||||
Developers = game.Developers != null ? string.Join(", ", game.Developers.Select(d => d.Name)) : string.Empty;
|
||||
Publishers = game.Publishers != null ? string.Join(", ", game.Publishers.Select(p => p.Name)) : string.Empty;
|
||||
IconPath = iconPath;
|
||||
HasIcon = !string.IsNullOrEmpty(iconPath);
|
||||
InLibrary = inLibrary;
|
||||
}
|
||||
}
|
||||
95
LANCommander.Launcher.Avalonia/ViewModels/LoginViewModel.cs
Normal file
95
LANCommander.Launcher.Avalonia/ViewModels/LoginViewModel.cs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LANCommander.Launcher.Services;
|
||||
using LANCommander.SDK.Providers;
|
||||
using LANCommander.SDK.Services;
|
||||
|
||||
namespace LANCommander.Launcher.Avalonia.ViewModels;
|
||||
|
||||
public partial class LoginViewModel : ViewModelBase
|
||||
{
|
||||
private readonly IConnectionClient _connectionClient;
|
||||
private readonly AuthenticationService _authenticationService;
|
||||
private readonly SettingsProvider<Settings.Settings> _settingsProvider;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _username = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _password = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _statusMessage = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _isLoading;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _hasError;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _serverAddress = string.Empty;
|
||||
|
||||
public event EventHandler? LoginSucceeded;
|
||||
public event EventHandler? ChangeServerRequested;
|
||||
|
||||
public LoginViewModel(
|
||||
IConnectionClient connectionClient,
|
||||
AuthenticationService authenticationService,
|
||||
SettingsProvider<Settings.Settings> settingsProvider)
|
||||
{
|
||||
_connectionClient = connectionClient;
|
||||
_authenticationService = authenticationService;
|
||||
_settingsProvider = settingsProvider;
|
||||
|
||||
ServerAddress = _connectionClient.GetServerAddress()?.ToString() ?? "Not connected";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task LoginAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Username) || string.IsNullOrWhiteSpace(Password))
|
||||
{
|
||||
StatusMessage = "Please enter username and password";
|
||||
HasError = true;
|
||||
return;
|
||||
}
|
||||
|
||||
IsLoading = true;
|
||||
HasError = false;
|
||||
StatusMessage = "Logging in...";
|
||||
|
||||
try
|
||||
{
|
||||
var serverAddress = _connectionClient.GetServerAddress();
|
||||
if (serverAddress == null)
|
||||
{
|
||||
StatusMessage = "No server configured";
|
||||
HasError = true;
|
||||
return;
|
||||
}
|
||||
|
||||
await _authenticationService.Login(serverAddress, Username, Password);
|
||||
|
||||
StatusMessage = "Login successful!";
|
||||
LoginSucceeded?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Login failed: {ex.Message}";
|
||||
HasError = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ChangeServer()
|
||||
{
|
||||
ChangeServerRequested?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
111
LANCommander.Launcher.Avalonia/ViewModels/MainWindowViewModel.cs
Normal file
111
LANCommander.Launcher.Avalonia/ViewModels/MainWindowViewModel.cs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using LANCommander.Launcher.Services;
|
||||
using LANCommander.SDK.Providers;
|
||||
using LANCommander.SDK.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace LANCommander.Launcher.Avalonia.ViewModels;
|
||||
|
||||
public partial class MainWindowViewModel : ViewModelBase
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly IConnectionClient _connectionClient;
|
||||
private readonly AuthenticationService _authenticationService;
|
||||
private readonly SettingsProvider<Settings.Settings> _settingsProvider;
|
||||
|
||||
[ObservableProperty]
|
||||
private ViewModelBase _currentView;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _title = "LANCommander Launcher";
|
||||
|
||||
public ServerSelectionViewModel ServerSelectionViewModel { get; }
|
||||
public LoginViewModel LoginViewModel { get; }
|
||||
public ShellViewModel ShellViewModel { get; }
|
||||
|
||||
public MainWindowViewModel(
|
||||
IServiceProvider serviceProvider,
|
||||
IConnectionClient connectionClient,
|
||||
AuthenticationService authenticationService,
|
||||
SettingsProvider<Settings.Settings> settingsProvider)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_connectionClient = connectionClient;
|
||||
_authenticationService = authenticationService;
|
||||
_settingsProvider = settingsProvider;
|
||||
|
||||
ServerSelectionViewModel = new ServerSelectionViewModel(connectionClient, settingsProvider);
|
||||
LoginViewModel = new LoginViewModel(connectionClient, authenticationService, settingsProvider);
|
||||
ShellViewModel = new ShellViewModel(serviceProvider);
|
||||
|
||||
// Wire up navigation events
|
||||
ServerSelectionViewModel.ServerConnected += OnServerConnected;
|
||||
LoginViewModel.LoginSucceeded += OnLoginSucceeded;
|
||||
LoginViewModel.ChangeServerRequested += OnChangeServerRequested;
|
||||
ShellViewModel.LogoutRequested += OnLogoutRequested;
|
||||
|
||||
// Start with server selection
|
||||
_currentView = ServerSelectionViewModel;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
// Check if we have a saved server address and valid token
|
||||
var settings = _settingsProvider.CurrentValue;
|
||||
|
||||
if (settings.Authentication?.ServerAddress != null)
|
||||
{
|
||||
await _connectionClient.UpdateServerAddressAsync(settings.Authentication.ServerAddress.ToString());
|
||||
|
||||
if (_authenticationService.HasStoredCredentials())
|
||||
{
|
||||
try
|
||||
{
|
||||
// Try to login with stored credentials
|
||||
await _authenticationService.Login();
|
||||
|
||||
if (_connectionClient.IsConnected())
|
||||
{
|
||||
// Token is valid - go directly to shell
|
||||
CurrentView = ShellViewModel;
|
||||
await ShellViewModel.InitializeAsync();
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Token validation failed - continue to login
|
||||
}
|
||||
}
|
||||
|
||||
// We have a server but no valid token - go to login
|
||||
LoginViewModel.ServerAddress = settings.Authentication.ServerAddress.ToString();
|
||||
CurrentView = LoginViewModel;
|
||||
}
|
||||
// Otherwise stay on server selection (default)
|
||||
}
|
||||
|
||||
private void OnServerConnected(object? sender, EventArgs e)
|
||||
{
|
||||
LoginViewModel.ServerAddress = _connectionClient.GetServerAddress()?.ToString() ?? string.Empty;
|
||||
CurrentView = LoginViewModel;
|
||||
}
|
||||
|
||||
private async void OnLoginSucceeded(object? sender, EventArgs e)
|
||||
{
|
||||
CurrentView = ShellViewModel;
|
||||
await ShellViewModel.InitializeAsync();
|
||||
}
|
||||
|
||||
private void OnChangeServerRequested(object? sender, EventArgs e)
|
||||
{
|
||||
CurrentView = ServerSelectionViewModel;
|
||||
}
|
||||
|
||||
private void OnLogoutRequested(object? sender, EventArgs e)
|
||||
{
|
||||
CurrentView = LoginViewModel;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LANCommander.SDK.Providers;
|
||||
using LANCommander.SDK.Services;
|
||||
|
||||
namespace LANCommander.Launcher.Avalonia.ViewModels;
|
||||
|
||||
public partial class ServerSelectionViewModel : ViewModelBase
|
||||
{
|
||||
private readonly IConnectionClient _connectionClient;
|
||||
private readonly SettingsProvider<Settings.Settings> _settingsProvider;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _serverAddress = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _statusMessage = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _isLoading;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _hasError;
|
||||
|
||||
public event EventHandler? ServerConnected;
|
||||
|
||||
public ServerSelectionViewModel(
|
||||
IConnectionClient connectionClient,
|
||||
SettingsProvider<Settings.Settings> settingsProvider)
|
||||
{
|
||||
_connectionClient = connectionClient;
|
||||
_settingsProvider = settingsProvider;
|
||||
|
||||
// Load saved server address if available
|
||||
if (_settingsProvider.CurrentValue.Authentication?.ServerAddress != null)
|
||||
ServerAddress = _settingsProvider.CurrentValue.Authentication.ServerAddress.ToString();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ConnectAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ServerAddress))
|
||||
{
|
||||
StatusMessage = "Please enter a server address";
|
||||
HasError = true;
|
||||
return;
|
||||
}
|
||||
|
||||
IsLoading = true;
|
||||
HasError = false;
|
||||
StatusMessage = "Testing connection...";
|
||||
|
||||
try
|
||||
{
|
||||
await _connectionClient.UpdateServerAddressAsync(ServerAddress);
|
||||
var canConnect = await _connectionClient.PingAsync();
|
||||
|
||||
if (canConnect)
|
||||
{
|
||||
_settingsProvider.Update(s =>
|
||||
{
|
||||
s.Authentication.ServerAddress = new Uri(ServerAddress);
|
||||
});
|
||||
|
||||
StatusMessage = "Connected!";
|
||||
ServerConnected?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
else
|
||||
{
|
||||
StatusMessage = "Could not connect to server. Please check the address and try again.";
|
||||
HasError = true;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Connection failed: {ex.Message}";
|
||||
HasError = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
248
LANCommander.Launcher.Avalonia/ViewModels/ShellViewModel.cs
Normal file
248
LANCommander.Launcher.Avalonia/ViewModels/ShellViewModel.cs
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LANCommander.Launcher.Data.Models;
|
||||
using LANCommander.Launcher.Models;
|
||||
using LANCommander.Launcher.Services;
|
||||
using LANCommander.SDK.Enums;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace LANCommander.Launcher.Avalonia.ViewModels;
|
||||
|
||||
public partial class ShellViewModel : ViewModelBase
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<ShellViewModel> _logger;
|
||||
|
||||
[ObservableProperty]
|
||||
private ObservableCollection<LibraryItemViewModel> _libraryItems = new();
|
||||
|
||||
[ObservableProperty]
|
||||
private LibraryItemViewModel? _selectedLibraryItem;
|
||||
|
||||
[ObservableProperty]
|
||||
private ViewModelBase? _contentView;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _isLibraryLoading;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _isDepotSelected;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _statusMessage = string.Empty;
|
||||
|
||||
// Child view models
|
||||
public GamesListViewModel GamesListViewModel { get; private set; } = null!;
|
||||
public GameDetailViewModel GameDetailViewModel { get; private set; } = null!;
|
||||
|
||||
public event EventHandler? LogoutRequested;
|
||||
|
||||
public ShellViewModel(IServiceProvider serviceProvider)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = serviceProvider.GetRequiredService<ILogger<ShellViewModel>>();
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_logger.LogInformation("ShellViewModel initializing...");
|
||||
|
||||
// Create child view models with proper scoped services
|
||||
GamesListViewModel = new GamesListViewModel(_serviceProvider);
|
||||
GameDetailViewModel = new GameDetailViewModel(_serviceProvider);
|
||||
|
||||
// Wire up events from child view models
|
||||
GamesListViewModel.GameSelected += OnGameSelected;
|
||||
GameDetailViewModel.BackRequested += OnBackFromGameDetail;
|
||||
|
||||
// Import library from server and load data
|
||||
await ImportAndLoadAsync();
|
||||
|
||||
// Default to showing depot/games list
|
||||
ShowDepot();
|
||||
|
||||
_logger.LogInformation("ShellViewModel initialization complete");
|
||||
}
|
||||
|
||||
private async Task ImportAndLoadAsync()
|
||||
{
|
||||
IsLibraryLoading = true;
|
||||
StatusMessage = "Importing library...";
|
||||
_logger.LogInformation("Starting library import...");
|
||||
|
||||
try
|
||||
{
|
||||
// Use ImportService to import library data from server to local database
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var importService = scope.ServiceProvider.GetRequiredService<ImportService>();
|
||||
|
||||
_logger.LogDebug("ImportService resolved, calling ImportLibraryAsync...");
|
||||
await importService.ImportLibraryAsync();
|
||||
_logger.LogInformation("Library import complete");
|
||||
|
||||
// Now load from local database
|
||||
await LoadLibraryAsync();
|
||||
await GamesListViewModel.LoadGamesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Import failed");
|
||||
StatusMessage = $"Import failed: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLibraryLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task LoadLibraryAsync()
|
||||
{
|
||||
IsLibraryLoading = true;
|
||||
LibraryItems.Clear();
|
||||
_logger.LogInformation("Loading library items...");
|
||||
|
||||
try
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var libraryService = scope.ServiceProvider.GetRequiredService<LibraryService>();
|
||||
var mediaService = scope.ServiceProvider.GetRequiredService<MediaService>();
|
||||
|
||||
_logger.LogDebug("LibraryService and MediaService resolved");
|
||||
var items = await libraryService.GetItemsAsync();
|
||||
_logger.LogDebug("Got {Count} items from LibraryService", items?.Count() ?? 0);
|
||||
|
||||
foreach (var item in items ?? [])
|
||||
{
|
||||
if (item.DataItem is Game game)
|
||||
{
|
||||
var iconPath = GetIconPath(game, mediaService);
|
||||
LibraryItems.Add(new LibraryItemViewModel(game, iconPath));
|
||||
}
|
||||
}
|
||||
|
||||
StatusMessage = $"{LibraryItems.Count} games in library";
|
||||
_logger.LogInformation("Loaded {Count} games into library", LibraryItems.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load library");
|
||||
StatusMessage = $"Failed to load library: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLibraryLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private string? GetIconPath(Game game, MediaService mediaService)
|
||||
{
|
||||
var icon = game.Media?.FirstOrDefault(m => m.Type == MediaType.Icon);
|
||||
if (icon == null) return null;
|
||||
|
||||
var path = mediaService.GetImagePath(icon);
|
||||
return mediaService.FileExists(icon) ? path : null;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ShowDepot()
|
||||
{
|
||||
SelectedLibraryItem = null;
|
||||
IsDepotSelected = true;
|
||||
ContentView = GamesListViewModel;
|
||||
_logger.LogDebug("Showing depot view");
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task SelectLibraryItemAsync(LibraryItemViewModel? item)
|
||||
{
|
||||
if (item == null) return;
|
||||
|
||||
SelectedLibraryItem = item;
|
||||
IsDepotSelected = false;
|
||||
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var libraryService = scope.ServiceProvider.GetRequiredService<LibraryService>();
|
||||
|
||||
var listItem = await libraryService.GetItemAsync(item.Id);
|
||||
if (listItem?.DataItem is Game game)
|
||||
{
|
||||
GameDetailViewModel.LoadGame(game);
|
||||
ContentView = GameDetailViewModel;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RefreshAsync()
|
||||
{
|
||||
await ImportAndLoadAsync();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task LogoutAsync()
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var authService = scope.ServiceProvider.GetRequiredService<AuthenticationService>();
|
||||
await authService.Logout();
|
||||
|
||||
LogoutRequested?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
private void OnGameSelected(object? sender, SDK.Models.Game game)
|
||||
{
|
||||
// Check if game is in library and select it in the sidebar
|
||||
var libraryItem = LibraryItems.FirstOrDefault(li => li.Id == game.Id);
|
||||
if (libraryItem != null)
|
||||
{
|
||||
SelectedLibraryItem = libraryItem;
|
||||
}
|
||||
else
|
||||
{
|
||||
SelectedLibraryItem = null;
|
||||
}
|
||||
|
||||
IsDepotSelected = false;
|
||||
GameDetailViewModel.LoadGame(game);
|
||||
ContentView = GameDetailViewModel;
|
||||
}
|
||||
|
||||
private void OnBackFromGameDetail(object? sender, EventArgs e)
|
||||
{
|
||||
ShowDepot();
|
||||
}
|
||||
}
|
||||
|
||||
public partial class LibraryItemViewModel : ViewModelBase
|
||||
{
|
||||
[ObservableProperty]
|
||||
private Guid _id;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _title = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string? _iconPath;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _hasIcon;
|
||||
|
||||
public LibraryItemViewModel(Game game, string? iconPath = null)
|
||||
{
|
||||
Id = game.Id;
|
||||
Title = game.Title ?? "Unknown";
|
||||
IconPath = iconPath;
|
||||
HasIcon = !string.IsNullOrEmpty(iconPath);
|
||||
}
|
||||
|
||||
public LibraryItemViewModel(Guid id, string name)
|
||||
{
|
||||
Id = id;
|
||||
Title = name;
|
||||
HasIcon = false;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace LANCommander.Launcher.Avalonia.ViewModels;
|
||||
|
||||
public abstract partial class ViewModelBase : ObservableObject
|
||||
{
|
||||
}
|
||||
178
LANCommander.Launcher.Avalonia/Views/GameDetailView.axaml
Normal file
178
LANCommander.Launcher.Avalonia/Views/GameDetailView.axaml
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:LANCommander.Launcher.Avalonia.ViewModels"
|
||||
x:Class="LANCommander.Launcher.Avalonia.Views.GameDetailView"
|
||||
x:DataType="vm:GameDetailViewModel">
|
||||
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<!-- Header with Back Button -->
|
||||
<Border Grid.Row="0" Padding="16" Background="{DynamicResource SystemControlBackgroundChromeMediumBrush}">
|
||||
<DockPanel>
|
||||
<Button DockPanel.Dock="Left"
|
||||
Content="← Back to Depot"
|
||||
Command="{Binding GoBackCommand}"
|
||||
Padding="12,8" />
|
||||
<TextBlock Text="{Binding Title}"
|
||||
FontSize="20"
|
||||
FontWeight="Bold"
|
||||
VerticalAlignment="Center"
|
||||
Margin="16,0,0,0" />
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Main Content -->
|
||||
<ScrollViewer Grid.Row="1" HorizontalScrollBarVisibility="Disabled">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<!-- Banner/Cover Image -->
|
||||
<Border Grid.Row="0"
|
||||
Height="300"
|
||||
Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}">
|
||||
<Panel>
|
||||
<!-- Background image (if available) -->
|
||||
<Image Source="{Binding BackgroundPath}"
|
||||
Stretch="UniformToFill"
|
||||
Opacity="0.3"
|
||||
IsVisible="{Binding BackgroundPath, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
|
||||
<!-- Cover/Banner overlay -->
|
||||
<Border HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
CornerRadius="8"
|
||||
ClipToBounds="True"
|
||||
MaxHeight="280"
|
||||
BoxShadow="0 4 16 0 #40000000">
|
||||
<Image Source="{Binding BannerPath}"
|
||||
Stretch="Uniform"
|
||||
MaxHeight="280"
|
||||
IsVisible="{Binding BannerPath, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
</Border>
|
||||
|
||||
<!-- Fallback when no banner -->
|
||||
<Border HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Background="{DynamicResource SystemAccentColor}"
|
||||
CornerRadius="8"
|
||||
Padding="40,20"
|
||||
IsVisible="{Binding BannerPath, Converter={x:Static StringConverters.IsNullOrEmpty}}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="🎮" FontSize="48" HorizontalAlignment="Center" />
|
||||
<TextBlock Text="{Binding Title}"
|
||||
FontSize="24"
|
||||
FontWeight="Bold"
|
||||
HorizontalAlignment="Center"
|
||||
Foreground="White" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Panel>
|
||||
</Border>
|
||||
|
||||
<!-- Game Details -->
|
||||
<StackPanel Grid.Row="1" Margin="24" Spacing="16">
|
||||
<!-- Title and Year -->
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="{Binding Title}"
|
||||
FontSize="28"
|
||||
FontWeight="Bold" />
|
||||
<TextBlock Text="{Binding ReleaseYear}"
|
||||
FontSize="16"
|
||||
Opacity="0.7" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Quick Info Cards -->
|
||||
<WrapPanel Orientation="Horizontal">
|
||||
<!-- Developers -->
|
||||
<Border Margin="0,0,8,8"
|
||||
Padding="12,8"
|
||||
CornerRadius="4"
|
||||
Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}"
|
||||
IsVisible="{Binding Developers, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Text="👨💻" />
|
||||
<TextBlock Text="{Binding Developers}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Publishers -->
|
||||
<Border Margin="0,0,8,8"
|
||||
Padding="12,8"
|
||||
CornerRadius="4"
|
||||
Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}"
|
||||
IsVisible="{Binding Publishers, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Text="🏢" />
|
||||
<TextBlock Text="{Binding Publishers}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Singleplayer -->
|
||||
<Border Margin="0,0,8,8"
|
||||
Padding="12,8"
|
||||
CornerRadius="4"
|
||||
Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}"
|
||||
IsVisible="{Binding Singleplayer}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Text="👤" />
|
||||
<TextBlock Text="Singleplayer" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Multiplayer -->
|
||||
<Border Margin="0,0,8,8"
|
||||
Padding="12,8"
|
||||
CornerRadius="4"
|
||||
Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}"
|
||||
IsVisible="{Binding HasMultiplayer}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Text="👥" />
|
||||
<TextBlock Text="{Binding MultiplayerModes}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</WrapPanel>
|
||||
|
||||
<!-- Genres -->
|
||||
<StackPanel Spacing="8"
|
||||
IsVisible="{Binding Genres, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
<TextBlock Text="Genres" FontWeight="SemiBold" FontSize="14" Opacity="0.7" />
|
||||
<TextBlock Text="{Binding Genres}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Platforms -->
|
||||
<StackPanel Spacing="8"
|
||||
IsVisible="{Binding Platforms, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
<TextBlock Text="Platforms" FontWeight="SemiBold" FontSize="14" Opacity="0.7" />
|
||||
<TextBlock Text="{Binding Platforms}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Tags -->
|
||||
<StackPanel Spacing="8"
|
||||
IsVisible="{Binding Tags, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
<TextBlock Text="Tags" FontWeight="SemiBold" FontSize="14" Opacity="0.7" />
|
||||
<TextBlock Text="{Binding Tags}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Description -->
|
||||
<StackPanel Spacing="8"
|
||||
IsVisible="{Binding Description, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
<TextBlock Text="Description" FontWeight="SemiBold" FontSize="14" Opacity="0.7" />
|
||||
<TextBlock Text="{Binding Description}"
|
||||
TextWrapping="Wrap"
|
||||
LineHeight="24" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Placeholder for future actions -->
|
||||
<Border Margin="0,16,0,0"
|
||||
Padding="16"
|
||||
CornerRadius="8"
|
||||
Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Actions" FontWeight="SemiBold" />
|
||||
<TextBlock Text="Install, Play, and other game actions will be available here in a future update."
|
||||
Opacity="0.6"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
11
LANCommander.Launcher.Avalonia/Views/GameDetailView.axaml.cs
Normal file
11
LANCommander.Launcher.Avalonia/Views/GameDetailView.axaml.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
using Avalonia.Controls;
|
||||
|
||||
namespace LANCommander.Launcher.Avalonia.Views;
|
||||
|
||||
public partial class GameDetailView : UserControl
|
||||
{
|
||||
public GameDetailView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
129
LANCommander.Launcher.Avalonia/Views/GamesListView.axaml
Normal file
129
LANCommander.Launcher.Avalonia/Views/GamesListView.axaml
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:LANCommander.Launcher.Avalonia.ViewModels"
|
||||
x:Class="LANCommander.Launcher.Avalonia.Views.GamesListView"
|
||||
x:DataType="vm:GamesListViewModel">
|
||||
|
||||
<DockPanel>
|
||||
<!-- Header -->
|
||||
<Border DockPanel.Dock="Top" Padding="16" Background="{DynamicResource SystemControlBackgroundChromeMediumBrush}">
|
||||
<DockPanel>
|
||||
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="Refresh" Command="{Binding LoadGamesInternalCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel>
|
||||
<TextBlock Text="Depot - All Games" FontSize="20" FontWeight="Bold" />
|
||||
<TextBlock Text="{Binding StatusMessage}" Opacity="0.7" FontSize="12" />
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Search Bar -->
|
||||
<Border DockPanel.Dock="Top" Padding="16,8">
|
||||
<TextBox Watermark="Search games..."
|
||||
Text="{Binding SearchText}" />
|
||||
</Border>
|
||||
|
||||
<!-- Loading Indicator -->
|
||||
<Border DockPanel.Dock="Top"
|
||||
IsVisible="{Binding IsLoading}"
|
||||
Padding="16"
|
||||
HorizontalAlignment="Center">
|
||||
<TextBlock Text="Loading games..." />
|
||||
</Border>
|
||||
|
||||
<!-- Error Message -->
|
||||
<Border DockPanel.Dock="Top"
|
||||
IsVisible="{Binding HasError}"
|
||||
Padding="16"
|
||||
Margin="16,0"
|
||||
CornerRadius="4">
|
||||
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" Foreground="IndianRed" />
|
||||
</Border>
|
||||
|
||||
<!-- Games List -->
|
||||
<ListBox ItemsSource="{Binding Games}"
|
||||
SelectedItem="{Binding SelectedGame}"
|
||||
Padding="8"
|
||||
SelectionMode="Single">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate DataType="vm:GameItemViewModel">
|
||||
<Button Command="{Binding $parent[ListBox].((vm:GamesListViewModel)DataContext).ViewGameDetailsCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
Background="Transparent"
|
||||
Padding="0"
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Stretch"
|
||||
Cursor="Hand">
|
||||
<Border Padding="12" Margin="4" CornerRadius="8"
|
||||
Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}">
|
||||
<DockPanel>
|
||||
<!-- Game Icon -->
|
||||
<Border DockPanel.Dock="Left"
|
||||
Width="64" Height="64"
|
||||
CornerRadius="8"
|
||||
ClipToBounds="True"
|
||||
Margin="0,0,16,0">
|
||||
<Panel>
|
||||
<!-- Icon from local file -->
|
||||
<Image Source="{Binding IconPath}"
|
||||
Stretch="UniformToFill"
|
||||
IsVisible="{Binding HasIcon}" />
|
||||
|
||||
<!-- Fallback icon -->
|
||||
<Border Background="{DynamicResource SystemAccentColor}"
|
||||
IsVisible="{Binding !HasIcon}">
|
||||
<TextBlock Text="🎮"
|
||||
FontSize="24"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Border>
|
||||
</Panel>
|
||||
</Border>
|
||||
|
||||
<!-- Game Info -->
|
||||
<StackPanel Spacing="4" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding Title}"
|
||||
FontSize="16"
|
||||
FontWeight="SemiBold" />
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" Opacity="0.7">
|
||||
<TextBlock Text="{Binding ReleasedOn, StringFormat='{}{0:yyyy}'}" />
|
||||
<TextBlock Text="•"
|
||||
IsVisible="{Binding Developers, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
<TextBlock Text="{Binding Developers}"
|
||||
IsVisible="{Binding Developers, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Text="{Binding Genres}"
|
||||
FontSize="12"
|
||||
Opacity="0.6"
|
||||
IsVisible="{Binding Genres, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
|
||||
<!-- In Library indicator -->
|
||||
<Border Background="{DynamicResource SystemAccentColor}"
|
||||
CornerRadius="4"
|
||||
Padding="6,2"
|
||||
IsVisible="{Binding InLibrary}">
|
||||
<TextBlock Text="In Library" FontSize="10" Foreground="White" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Arrow indicator -->
|
||||
<TextBlock DockPanel.Dock="Right"
|
||||
Text="›"
|
||||
FontSize="24"
|
||||
Opacity="0.5"
|
||||
VerticalAlignment="Center"
|
||||
Margin="8,0" />
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</DockPanel>
|
||||
</UserControl>
|
||||
11
LANCommander.Launcher.Avalonia/Views/GamesListView.axaml.cs
Normal file
11
LANCommander.Launcher.Avalonia/Views/GamesListView.axaml.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
using Avalonia.Controls;
|
||||
|
||||
namespace LANCommander.Launcher.Avalonia.Views;
|
||||
|
||||
public partial class GamesListView : UserControl
|
||||
{
|
||||
public GamesListView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
67
LANCommander.Launcher.Avalonia/Views/LoginView.axaml
Normal file
67
LANCommander.Launcher.Avalonia/Views/LoginView.axaml
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:LANCommander.Launcher.Avalonia.ViewModels"
|
||||
x:Class="LANCommander.Launcher.Avalonia.Views.LoginView"
|
||||
x:DataType="vm:LoginViewModel">
|
||||
|
||||
<Border Padding="40" VerticalAlignment="Center" HorizontalAlignment="Center">
|
||||
<StackPanel Spacing="20" Width="400">
|
||||
<TextBlock Text="LANCommander"
|
||||
FontSize="32"
|
||||
FontWeight="Bold"
|
||||
HorizontalAlignment="Center" />
|
||||
|
||||
<TextBlock Text="Sign In"
|
||||
FontSize="18"
|
||||
HorizontalAlignment="Center"
|
||||
Opacity="0.7" />
|
||||
|
||||
<StackPanel Orientation="Horizontal"
|
||||
HorizontalAlignment="Center"
|
||||
Spacing="8"
|
||||
Opacity="0.6">
|
||||
<TextBlock Text="Connected to:" />
|
||||
<TextBlock Text="{Binding ServerAddress}" FontWeight="SemiBold" />
|
||||
<Button Content="Change"
|
||||
Command="{Binding ChangeServerCommand}"
|
||||
Padding="4,0"
|
||||
Classes="hyperlink" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="8" Margin="0,20,0,0">
|
||||
<TextBlock Text="Username" FontWeight="SemiBold" />
|
||||
<TextBox Watermark="Enter your username"
|
||||
Text="{Binding Username}"
|
||||
IsEnabled="{Binding !IsLoading}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Password" FontWeight="SemiBold" />
|
||||
<TextBox Watermark="Enter your password"
|
||||
PasswordChar="•"
|
||||
Text="{Binding Password}"
|
||||
IsEnabled="{Binding !IsLoading}" />
|
||||
</StackPanel>
|
||||
|
||||
<Button Command="{Binding LoginCommand}"
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Center"
|
||||
Classes="accent"
|
||||
Padding="12"
|
||||
IsEnabled="{Binding !IsLoading}">
|
||||
<Panel>
|
||||
<TextBlock Text="Sign In" IsVisible="{Binding !IsLoading}" />
|
||||
<TextBlock Text="Signing in..." IsVisible="{Binding IsLoading}" />
|
||||
</Panel>
|
||||
</Button>
|
||||
|
||||
<Border IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
|
||||
Padding="12"
|
||||
CornerRadius="4">
|
||||
<TextBlock Text="{Binding StatusMessage}"
|
||||
TextWrapping="Wrap"
|
||||
HorizontalAlignment="Center" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</UserControl>
|
||||
11
LANCommander.Launcher.Avalonia/Views/LoginView.axaml.cs
Normal file
11
LANCommander.Launcher.Avalonia/Views/LoginView.axaml.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
using Avalonia.Controls;
|
||||
|
||||
namespace LANCommander.Launcher.Avalonia.Views;
|
||||
|
||||
public partial class LoginView : UserControl
|
||||
{
|
||||
public LoginView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
27
LANCommander.Launcher.Avalonia/Views/MainWindow.axaml
Normal file
27
LANCommander.Launcher.Avalonia/Views/MainWindow.axaml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:LANCommander.Launcher.Avalonia.ViewModels"
|
||||
xmlns:views="using:LANCommander.Launcher.Avalonia.Views"
|
||||
x:Class="LANCommander.Launcher.Avalonia.Views.MainWindow"
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
Title="{Binding Title}"
|
||||
Width="1200"
|
||||
Height="800"
|
||||
MinWidth="900"
|
||||
MinHeight="600"
|
||||
WindowStartupLocation="CenterScreen">
|
||||
|
||||
<ContentControl Content="{Binding CurrentView}">
|
||||
<ContentControl.DataTemplates>
|
||||
<DataTemplate DataType="vm:ServerSelectionViewModel">
|
||||
<views:ServerSelectionView />
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="vm:LoginViewModel">
|
||||
<views:LoginView />
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="vm:ShellViewModel">
|
||||
<views:ShellView />
|
||||
</DataTemplate>
|
||||
</ContentControl.DataTemplates>
|
||||
</ContentControl>
|
||||
</Window>
|
||||
11
LANCommander.Launcher.Avalonia/Views/MainWindow.axaml.cs
Normal file
11
LANCommander.Launcher.Avalonia/Views/MainWindow.axaml.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
using Avalonia.Controls;
|
||||
|
||||
namespace LANCommander.Launcher.Avalonia.Views;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:LANCommander.Launcher.Avalonia.ViewModels"
|
||||
x:Class="LANCommander.Launcher.Avalonia.Views.ServerSelectionView"
|
||||
x:DataType="vm:ServerSelectionViewModel">
|
||||
|
||||
<Border Padding="40" VerticalAlignment="Center" HorizontalAlignment="Center">
|
||||
<StackPanel Spacing="20" Width="400">
|
||||
<TextBlock Text="LANCommander"
|
||||
FontSize="32"
|
||||
FontWeight="Bold"
|
||||
HorizontalAlignment="Center" />
|
||||
|
||||
<TextBlock Text="Connect to Server"
|
||||
FontSize="18"
|
||||
HorizontalAlignment="Center"
|
||||
Opacity="0.7" />
|
||||
|
||||
<StackPanel Spacing="8" Margin="0,20,0,0">
|
||||
<TextBlock Text="Server Address" FontWeight="SemiBold" />
|
||||
<TextBox Watermark="e.g., 192.168.1.100:5000 or lancommander.local"
|
||||
Text="{Binding ServerAddress}"
|
||||
IsEnabled="{Binding !IsLoading}" />
|
||||
</StackPanel>
|
||||
|
||||
<Button Command="{Binding ConnectCommand}"
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Center"
|
||||
Classes="accent"
|
||||
Padding="12"
|
||||
IsEnabled="{Binding !IsLoading}">
|
||||
<Panel>
|
||||
<TextBlock Text="Connect" IsVisible="{Binding !IsLoading}" />
|
||||
<TextBlock Text="Connecting..." IsVisible="{Binding IsLoading}" />
|
||||
</Panel>
|
||||
</Button>
|
||||
|
||||
<Border IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
|
||||
Padding="12"
|
||||
CornerRadius="4"
|
||||
Background="{DynamicResource SystemControlErrorTextForegroundBrush}">
|
||||
<TextBlock Text="{Binding StatusMessage}"
|
||||
TextWrapping="Wrap"
|
||||
HorizontalAlignment="Center" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</UserControl>
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
using Avalonia.Controls;
|
||||
|
||||
namespace LANCommander.Launcher.Avalonia.Views;
|
||||
|
||||
public partial class ServerSelectionView : UserControl
|
||||
{
|
||||
public ServerSelectionView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
124
LANCommander.Launcher.Avalonia/Views/ShellView.axaml
Normal file
124
LANCommander.Launcher.Avalonia/Views/ShellView.axaml
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:LANCommander.Launcher.Avalonia.ViewModels"
|
||||
xmlns:views="using:LANCommander.Launcher.Avalonia.Views"
|
||||
x:Class="LANCommander.Launcher.Avalonia.Views.ShellView"
|
||||
x:DataType="vm:ShellViewModel">
|
||||
|
||||
<Grid ColumnDefinitions="280,*">
|
||||
<!-- Left Sidebar -->
|
||||
<Border Grid.Column="0" Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}">
|
||||
<DockPanel>
|
||||
<!-- Header -->
|
||||
<Border DockPanel.Dock="Top" Padding="16" Background="{DynamicResource SystemControlBackgroundChromeMediumBrush}">
|
||||
<DockPanel>
|
||||
<Button DockPanel.Dock="Right"
|
||||
Content="⟳"
|
||||
Command="{Binding RefreshCommand}"
|
||||
ToolTip.Tip="Refresh"
|
||||
Padding="8,4" />
|
||||
<TextBlock Text="LANCommander"
|
||||
FontSize="20"
|
||||
FontWeight="Bold"
|
||||
VerticalAlignment="Center" />
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Navigation Buttons -->
|
||||
<StackPanel DockPanel.Dock="Top" Margin="8,8,8,0">
|
||||
<Button Command="{Binding ShowDepotCommand}"
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Left"
|
||||
Padding="12,10"
|
||||
Margin="0,0,0,4"
|
||||
Classes.selected="{Binding IsDepotSelected}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="12">
|
||||
<TextBlock Text="🏪" FontSize="16" />
|
||||
<TextBlock Text="Depot (All Games)" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Library Section Header -->
|
||||
<Border DockPanel.Dock="Top" Padding="16,16,16,8">
|
||||
<TextBlock Text="MY LIBRARY"
|
||||
FontSize="11"
|
||||
FontWeight="SemiBold"
|
||||
Opacity="0.5" />
|
||||
</Border>
|
||||
|
||||
<!-- Footer -->
|
||||
<Border DockPanel.Dock="Bottom" Padding="8" Background="{DynamicResource SystemControlBackgroundChromeMediumBrush}">
|
||||
<DockPanel>
|
||||
<Button DockPanel.Dock="Right"
|
||||
Content="Logout"
|
||||
Command="{Binding LogoutCommand}"
|
||||
Padding="8,4" />
|
||||
<TextBlock Text="{Binding StatusMessage}"
|
||||
Opacity="0.6"
|
||||
FontSize="11"
|
||||
VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Library Items List -->
|
||||
<ListBox ItemsSource="{Binding LibraryItems}"
|
||||
SelectedItem="{Binding SelectedLibraryItem}"
|
||||
Padding="4"
|
||||
Background="Transparent">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate DataType="vm:LibraryItemViewModel">
|
||||
<Button Command="{Binding $parent[ListBox].((vm:ShellViewModel)DataContext).SelectLibraryItemCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
Background="Transparent"
|
||||
Padding="8,6"
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Left"
|
||||
Cursor="Hand">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<!-- Icon -->
|
||||
<Border Width="24" Height="24"
|
||||
CornerRadius="4"
|
||||
ClipToBounds="True">
|
||||
<Panel>
|
||||
<Image Source="{Binding IconPath}"
|
||||
Stretch="UniformToFill"
|
||||
IsVisible="{Binding HasIcon}" />
|
||||
<Border Background="{DynamicResource SystemAccentColor}"
|
||||
IsVisible="{Binding !HasIcon}">
|
||||
<TextBlock Text="🎮"
|
||||
FontSize="12"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Border>
|
||||
</Panel>
|
||||
</Border>
|
||||
|
||||
<!-- Title -->
|
||||
<TextBlock Text="{Binding Title}"
|
||||
VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<Border Grid.Column="1">
|
||||
<ContentControl Content="{Binding ContentView}">
|
||||
<ContentControl.DataTemplates>
|
||||
<DataTemplate DataType="vm:GamesListViewModel">
|
||||
<views:GamesListView />
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="vm:GameDetailViewModel">
|
||||
<views:GameDetailView />
|
||||
</DataTemplate>
|
||||
</ContentControl.DataTemplates>
|
||||
</ContentControl>
|
||||
</Border>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
11
LANCommander.Launcher.Avalonia/Views/ShellView.axaml.cs
Normal file
11
LANCommander.Launcher.Avalonia/Views/ShellView.axaml.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
using Avalonia.Controls;
|
||||
|
||||
namespace LANCommander.Launcher.Avalonia.Views;
|
||||
|
||||
public partial class ShellView : UserControl
|
||||
{
|
||||
public ShellView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
20
LANCommander.Launcher.Avalonia/app.manifest
Normal file
20
LANCommander.Launcher.Avalonia/app.manifest
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="LANCommander.Launcher.Avalonia"/>
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
|
||||
<security>
|
||||
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}" />
|
||||
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}" />
|
||||
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}" />
|
||||
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}" />
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
|
|
@ -46,6 +46,7 @@ namespace LANCommander.Launcher.Models
|
|||
|
||||
public ListItem(Game game)
|
||||
{
|
||||
|
||||
Key = game.Id;
|
||||
Type = ListItemType.Game;
|
||||
Name = game.Title;
|
||||
|
|
|
|||
|
|
@ -57,9 +57,9 @@ public sealed class ServerConfigurationProvider : ConfigurationProvider
|
|||
|
||||
_source.Configuration.Bind(settings);
|
||||
|
||||
if (settings.Authentication.ServerAddress is null)
|
||||
{
|
||||
return;
|
||||
if (settings.Authentication.ServerAddress is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, settings.Authentication.ServerAddress.Join("/api/Settings"));
|
||||
|
|
@ -69,12 +69,13 @@ public sealed class ServerConfigurationProvider : ConfigurationProvider
|
|||
request.Headers.Add("Authorization", $"Bearer {settings.Authentication.Token.AccessToken}");
|
||||
}
|
||||
|
||||
var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
// Use ConfigureAwait(false) to prevent deadlocks when called from UI threads
|
||||
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
var payload = await JsonNode.ParseAsync(stream, cancellationToken: cancellationToken) ?? new JsonObject();
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var payload = await JsonNode.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false) ?? new JsonObject();
|
||||
|
||||
var prefix = "";
|
||||
var data = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
<File Path="Directory.Packages.props" />
|
||||
</Folder>
|
||||
<Folder Name="/LANCommander.Launcher/">
|
||||
<Project Path="LANCommander.Launcher.Avalonia\LANCommander.Launcher.Avalonia.csproj" />
|
||||
<Project Path="LANCommander.Launcher.CLI\LANCommander.Launcher.CLI.csproj" />
|
||||
<Project Path="LANCommander.Launcher.Data\LANCommander.Launcher.Data.csproj" />
|
||||
<Project Path="LANCommander.Launcher.Models\LANCommander.Launcher.Models.csproj" />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue