diff --git a/MapWizard.Desktop/DependencyInjection/ServiceCollectionExtensions.cs b/MapWizard.Desktop/DependencyInjection/ServiceCollectionExtensions.cs index ff82b35..776882d 100644 --- a/MapWizard.Desktop/DependencyInjection/ServiceCollectionExtensions.cs +++ b/MapWizard.Desktop/DependencyInjection/ServiceCollectionExtensions.cs @@ -58,6 +58,7 @@ public static void AddCommonServices(this IServiceCollection collection) collection.AddSingleton(); collection.AddScoped(); collection.AddScoped(); + collection.AddSingleton(); collection.AddSingleton(); collection.AddSingleton(); collection.AddSingleton(); diff --git a/MapWizard.Desktop/MapWizard.Desktop.csproj b/MapWizard.Desktop/MapWizard.Desktop.csproj index a7199ec..eff509f 100644 --- a/MapWizard.Desktop/MapWizard.Desktop.csproj +++ b/MapWizard.Desktop/MapWizard.Desktop.csproj @@ -29,7 +29,6 @@ - @@ -62,8 +61,8 @@ --> - - + + diff --git a/MapWizard.Desktop/Models/OsuNowPlaying.cs b/MapWizard.Desktop/Models/OsuNowPlaying.cs new file mode 100644 index 0000000..7db92b9 --- /dev/null +++ b/MapWizard.Desktop/Models/OsuNowPlaying.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using Avalonia.Media.Imaging; + +namespace MapWizard.Desktop.Models; + +public enum OsuClient +{ + Stable, + Lazer +} + +/// +/// Snapshot of the beatmap currently open in an osu! client. Instances are immutable; a new one is +/// published whenever the open beatmap changes. The owner disposes once the +/// snapshot is replaced. +/// +public sealed class OsuNowPlaying( + OsuClient client, + string mapsetDirectory, + IReadOnlyList beatmapPaths, + string artist, + string title, + string? difficulty, + string creator, + Bitmap? background) : IDisposable +{ + public OsuClient Client { get; } = client; + public string MapsetDirectory { get; } = mapsetDirectory; + + /// + /// The open difficulty for stable; every difficulty of the mounted set for lazer, which does not + /// expose which one is being edited. + /// + public IReadOnlyList BeatmapPaths { get; } = beatmapPaths; + + public string Artist { get; } = artist; + public string Title { get; } = title; + public string? Difficulty { get; } = difficulty; + public string Creator { get; } = creator; + public Bitmap? Background { get; } = background; + + public bool HasBackground => Background is not null; + public string ClientLabel => Client == OsuClient.Lazer ? "osu!lazer" : "osu!stable"; + + public string Headline => Difficulty is null ? $"{Artist} - {Title}" : $"{Artist} - {Title} [{Difficulty}]"; + + public string Byline => Client == OsuClient.Lazer && BeatmapPaths.Count > 1 + ? $"mapped by {Creator} · {BeatmapPaths.Count} difficulties" + : $"mapped by {Creator}"; + + public string ToolTip => $"{Headline}\n{Byline}\n{ClientLabel}"; + + public void Dispose() => Background?.Dispose(); +} diff --git a/MapWizard.Desktop/Services/MemoryService/FallbackClientIpc.cs b/MapWizard.Desktop/Services/MemoryService/FallbackClientIpc.cs index 886cbb3..2ef28c9 100644 --- a/MapWizard.Desktop/Services/MemoryService/FallbackClientIpc.cs +++ b/MapWizard.Desktop/Services/MemoryService/FallbackClientIpc.cs @@ -12,6 +12,7 @@ namespace MapWizard.Desktop.Services.MemoryService; internal static class FallbackClientIpc { private const string PipeName = "mtipc"; + private const string LinuxSocketPath = $"/tmp/{PipeName}.sock"; private static readonly Lock Sync = new(); private static NamedPipeClientStream? _pipeClient; private static Socket? _socketClient; @@ -23,6 +24,12 @@ public static bool TryReadBeatmapPath(out string? beatmapPath, out string? error beatmapPath = null; error = null; + if (!IsEndpointAvailable()) + { + error = "No fallback client is listening."; + return false; + } + try { using var reader = SendMessage(MessageType.ReadBeatmap); @@ -62,6 +69,12 @@ public static bool TryReadEditorTime(out int timestamp, out string? error) timestamp = 0; error = null; + if (!IsEndpointAvailable()) + { + error = "No fallback client is listening."; + return false; + } + try { using var reader = SendMessage(MessageType.EditorTime); @@ -77,6 +90,30 @@ public static bool TryReadEditorTime(out int timestamp, out string? error) } } + /// + /// Checks for the client's endpoint without connecting, so callers (including background polling) + /// skip the connect timeout and exception logging when no fallback client is running. + /// Pipes are enumerated rather than probed because opening one would consume a server instance. + /// + private static bool IsEndpointAvailable() + { + try + { + if (OperatingSystem.IsWindows()) + { + return Directory.EnumerateFiles(@"\\.\pipe\") + .Any(path => string.Equals(Path.GetFileName(path), PipeName, StringComparison.OrdinalIgnoreCase)); + } + + return File.Exists(LinuxSocketPath); + } + catch (Exception) + { + // Enumeration failures should not hide a working endpoint; let the connect attempt decide. + return true; + } + } + private static BinaryReader SendMessage(MessageType messageType) => OperatingSystem.IsWindows() ? SendMessageWindows(messageType) : SendMessageLinux(messageType); @@ -205,7 +242,7 @@ private static void EnsureConnectionLinux() _socketClient?.Dispose(); _socketClient = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.IP); - _socketClient.Connect(new UnixDomainSocketEndPoint($"/tmp/{PipeName}.sock")); + _socketClient.Connect(new UnixDomainSocketEndPoint(LinuxSocketPath)); using var reader = SendMessage(MessageType.Hello); if (reader.ReadInt32() != 1337) diff --git a/MapWizard.Desktop/Services/MemoryService/OsuMemoryReaderService.cs b/MapWizard.Desktop/Services/MemoryService/OsuMemoryReaderService.cs index 4f919ba..e64fc89 100644 --- a/MapWizard.Desktop/Services/MemoryService/OsuMemoryReaderService.cs +++ b/MapWizard.Desktop/Services/MemoryService/OsuMemoryReaderService.cs @@ -1,361 +1,216 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Runtime.Versioning; +using System.Threading; using MapWizard.Desktop.Enums; using MapWizard.Desktop.Models; using MapWizard.Desktop.Utils; using OsuMemoryDataProvider; using OsuMemoryDataProvider.OsuMemoryModels.Direct; -using OsuWineMemReader; +using ProcessMemoryDataFinder; namespace MapWizard.Desktop.Services.MemoryService; public class OsuMemoryReaderService(ISettingsService settingsService, ISongLibraryService songLibraryService) : IOsuMemoryReaderService { - [SupportedOSPlatform("windows")] - [SupportedOSPlatform("linux")] - public Result GetBeatmapPath() - { - if (!OperatingSystem.IsLinux() && !OperatingSystem.IsWindows()) - { - return new Result() - { - Value = null, - Status = ResultStatus.Error, - ErrorMessage = "This feature is not yet supported on your operating system." - }; - } + /// + /// How long to wait for the reader's background process watcher to attach on first use. + /// + private static readonly TimeSpan AttachTimeout = TimeSpan.FromSeconds(2); + + /// + /// The structured reader is a shared instance; background polling and user actions must not + /// read through it concurrently. + /// + private static readonly Lock ReaderLock = new(); + + /// + /// Under Wine there is no kernel32 to query process bitness, so the check is skipped (the + /// default options would throw inside the reader's process watcher). + /// + private static ProcessTargetOptions StableProcessTarget { get; } = OperatingSystem.IsWindows() + ? new ProcessTargetOptions(OsuStableInstallLocator.ProcessName) + : new ProcessTargetOptions(OsuStableInstallLocator.ProcessName, Target64Bit: null); - return OperatingSystem.IsWindows() ? GetBeatmapWindows() : GetBeatmapLinux(); - } - - public Result GetCurrentTimestamp() + public Result GetBeatmapPath() { if (!OperatingSystem.IsWindows() && !OperatingSystem.IsLinux()) { - return new Result - { - Value = 0, - Status = ResultStatus.Error, - ErrorMessage = "Timestamp reading is currently supported only on Windows and Linux." - }; - } - - if (FallbackClientIpc.TryReadEditorTime(out var timestamp, out var ipcError)) - { - return new Result - { - Value = timestamp, - Status = ResultStatus.Success, - ErrorMessage = null - }; + return Error("This feature is not yet supported on your operating system."); } - return new Result - { - Value = 0, - Status = ResultStatus.Error, - ErrorMessage = $"Unable to read current timestamp from fallback IPC. {ipcError}" - }; - } - - [SupportedOSPlatform("linux")] - private static Result GetBeatmapLinux() - { - var memoryResult = GetBeatmapLinuxFromMemory(); + var memoryResult = GetBeatmapFromMemory(); if (memoryResult.Status == ResultStatus.Success && !string.IsNullOrWhiteSpace(memoryResult.Value)) { return memoryResult; } - - var fallbackIpcResult = GetBeatmapLinuxFromFallbackIpc(); + + var fallbackIpcResult = GetBeatmapFromFallbackIpc(); if (fallbackIpcResult.Status == ResultStatus.Success && !string.IsNullOrWhiteSpace(fallbackIpcResult.Value)) { return fallbackIpcResult; } - return new Result - { - Value = null, - Status = ResultStatus.Error, - ErrorMessage = $"Memory read failed and fallback IPC path could not be resolved." - }; + return Error("Memory read failed and fallback IPC path could not be resolved."); } - private static Result GetBeatmapLinuxFromMemory() + public Result GetCurrentTimestamp() { - var options = new OsuMemory.OsuMemoryOptions() + if (!OperatingSystem.IsWindows() && !OperatingSystem.IsLinux()) { - WriteToFile = false, - RunOnce = true - }; + return Error("Timestamp reading is currently supported only on Windows and Linux."); + } - var running = true; - try + if (FallbackClientIpc.TryReadEditorTime(out var timestamp, out var ipcError)) { - OsuMemory.StartBeatmapPathReading(ref running, out var beatmapPath, options); - - return new Result() + return new Result { - Value = beatmapPath, + Value = timestamp, Status = ResultStatus.Success, ErrorMessage = null }; } - catch (Exception ex) - { - MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex); - return new Result() - { - Value = null, - Status = ResultStatus.Error, - ErrorMessage = ex.Message - }; - } + + return Error($"Unable to read current timestamp from fallback IPC. {ipcError}"); } - - [SupportedOSPlatform("linux")] - private static Result GetBeatmapLinuxFromFallbackIpc() + + private Result GetBeatmapFromMemory() { - if (!FallbackClientIpc.TryReadBeatmapPath(out var beatmapPath, out var ipcError)) + CurrentBeatmap currentBeatmap; + try { - return new Result + if (!OsuStableInstallLocator.IsRunning()) { - Value = null, - Status = ResultStatus.Error, - ErrorMessage = ipcError ?? "Unable to read beatmap path from fallback IPC." - }; - } + return Error("osu!stable is not running."); + } - if (string.IsNullOrWhiteSpace(beatmapPath)) - { - return new Result + lock (ReaderLock) { - Value = null, - Status = ResultStatus.Error, - ErrorMessage = "Fallback IPC returned an empty beatmap path." - }; - } + var reader = StructuredOsuMemoryReader.GetInstance(StableProcessTarget); + if (!WaitForAttach(reader)) + { + return Error("Unable to attach to the osu! process."); + } - if (!File.Exists(beatmapPath)) - { - return new Result - { - Value = null, - Status = ResultStatus.Error, - ErrorMessage = "Beatmap file from fallback IPC does not exist." - }; + currentBeatmap = new CurrentBeatmap(); + if (!reader.TryRead(currentBeatmap)) + { + return Error("Unable to read current beatmap."); + } + } } - - return new Result - { - Value = Path.GetFullPath(beatmapPath), - Status = ResultStatus.Success, - ErrorMessage = null - }; - } - - [SupportedOSPlatform("windows")] - private Result GetBeatmapWindows() - { - var memoryResult = GetBeatmapWindowsFromMemory(); - if (memoryResult.Status == ResultStatus.Success && !string.IsNullOrWhiteSpace(memoryResult.Value)) + catch (Exception ex) { - return memoryResult; + MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex); + return Error(ex.Message); } - var fallbackIpcResult = GetBeatmapWindowsFromFallbackIpc(); - if (fallbackIpcResult.Status == ResultStatus.Success && !string.IsNullOrWhiteSpace(fallbackIpcResult.Value)) + if (string.IsNullOrEmpty(currentBeatmap.OsuFileName) || string.IsNullOrEmpty(currentBeatmap.FolderName)) { - return fallbackIpcResult; + return Error("No beatmap is currently loaded."); } - return new Result - { - Value = null, - Status = ResultStatus.Error, - ErrorMessage = $"Memory read failed and fallback IPC path could not be resolved." - }; - } - - [SupportedOSPlatform("windows")] - private Result GetBeatmapWindowsFromMemory() - { - var reader = StructuredOsuMemoryReader.GetInstance(null); - - if (reader == null) + // Prefer the configured/detected Songs folder, but fall back to the one the running client + // uses in case the settings point at a different install. + foreach (var songsFolder in EnumerateSongsFolders()) { - return new Result() + var beatmapPath = Path.Combine(songsFolder, currentBeatmap.FolderName, currentBeatmap.OsuFileName); + if (!OperatingSystem.IsWindows()) { - Value = null, - Status = ResultStatus.Error, - ErrorMessage = "Unable to create reader instance." - }; - } + beatmapPath = beatmapPath.Replace('\\', '/'); + } - var currentBeatmap = new CurrentBeatmap(); - if (!reader.TryRead(currentBeatmap)) - { - return new Result() + if (File.Exists(beatmapPath)) { - Value = null, - Status = ResultStatus.Error, - ErrorMessage = "Unable to read current beatmap." - }; + return Success(Path.GetFullPath(beatmapPath)); + } } - if (string.IsNullOrEmpty(currentBeatmap.OsuFileName)) - { - return new Result() - { - Value = null, - Status = ResultStatus.Error, - ErrorMessage = "No beatmap is currently loaded." - }; - } + return Error("Beatmap file does not exist. Check configured Songs folder in Settings."); + } - var songsFolder = GetSongsFolderWindows(); - if (string.IsNullOrWhiteSpace(songsFolder)) + private static bool WaitForAttach(StructuredOsuMemoryReader reader) + { + var stopwatch = Stopwatch.StartNew(); + while (!reader.CanRead) { - return new Result + if (stopwatch.Elapsed >= AttachTimeout) { - Value = null, - Status = ResultStatus.Error, - ErrorMessage = "Unable to resolve Songs folder." - }; - } - - var beatmapPath = Path.Combine(songsFolder, currentBeatmap.FolderName, currentBeatmap.OsuFileName); + return false; + } - if (!File.Exists(beatmapPath)) - { - return new Result() - { - Value = null, - Status = ResultStatus.Error, - ErrorMessage = "Beatmap file does not exist." - }; + Thread.Sleep(25); } - return new Result() - { - Value = beatmapPath, - Status = ResultStatus.Success, - ErrorMessage = null - }; + return true; } [SupportedOSPlatform("windows")] - private Result GetBeatmapWindowsFromFallbackIpc() + [SupportedOSPlatform("linux")] + private Result GetBeatmapFromFallbackIpc() { if (!FallbackClientIpc.TryReadBeatmapPath(out var beatmapPath, out var ipcError)) { - return new Result - { - Value = null, - Status = ResultStatus.Error, - ErrorMessage = ipcError ?? "Unable to read beatmap path from fallback IPC." - }; + return Error(ipcError ?? "Unable to read beatmap path from fallback IPC."); } if (string.IsNullOrWhiteSpace(beatmapPath)) { - return new Result - { - Value = null, - Status = ResultStatus.Error, - ErrorMessage = "Fallback IPC returned an empty beatmap path." - }; + return Error("Fallback IPC returned an empty beatmap path."); } beatmapPath = beatmapPath.Trim().Trim('"'); - var songsFolder = GetSongsFolderWindows(); - - var candidates = new[] + if (File.Exists(beatmapPath)) { - beatmapPath, - !Path.IsPathRooted(beatmapPath) && !string.IsNullOrWhiteSpace(songsFolder) - ? Path.Combine(songsFolder, beatmapPath) - : string.Empty - }; + return Success(Path.GetFullPath(beatmapPath)); + } - foreach (var candidate in candidates) + if (!Path.IsPathRooted(beatmapPath)) { - if (string.IsNullOrWhiteSpace(candidate)) + foreach (var songsFolder in EnumerateSongsFolders()) { - continue; - } - - var fullCandidate = Path.GetFullPath(candidate); - if (File.Exists(fullCandidate)) - { - return new Result + var candidate = Path.Combine(songsFolder, beatmapPath); + if (File.Exists(candidate)) { - Value = fullCandidate, - Status = ResultStatus.Success, - ErrorMessage = null - }; + return Success(Path.GetFullPath(candidate)); + } } } - if (!File.Exists(beatmapPath)) - { - return new Result - { - Value = null, - Status = ResultStatus.Error, - ErrorMessage = "Beatmap file from fallback IPC does not exist. Check configured Songs folder in Settings." - }; - } - - return new Result - { - Value = Path.GetFullPath(beatmapPath), - Status = ResultStatus.Success, - ErrorMessage = null - }; + return Error( + "Beatmap file from fallback IPC does not exist. Check configured Songs folder in Settings."); } - [SupportedOSPlatform("windows")] - private string GetSongsFolderWindows() + private IEnumerable EnumerateSongsFolders() { var configuredOrDetected = SongsPathResolver.ResolveSongsPath(settingsService, songLibraryService); if (!string.IsNullOrWhiteSpace(configuredOrDetected)) { - return configuredOrDetected; + yield return configuredOrDetected; } - return GetRunningSongsFolderWindows(); + var running = OsuStableInstallLocator.TryGetRunningSongsFolder(); + if (!string.IsNullOrWhiteSpace(running) && + !string.Equals(running, configuredOrDetected, StringComparison.Ordinal)) + { + yield return running; + } } - [SupportedOSPlatform("windows")] - private static string GetRunningSongsFolderWindows() + private static Result Success(string value) => new() { - var processes = Process.GetProcessesByName("osu!"); - if (processes.Length <= 0) return string.Empty; - - var path = processes[0].Modules[0].FileName; - - var basePath = path.Remove(path.LastIndexOf('\\')); - - var configPath = $"osu!.{Environment.UserName}.cfg"; - var configFile = File.OpenRead(Path.Combine(basePath, configPath)); - using var reader = new StreamReader(configFile); - while (reader.ReadLine() is { } line) - { - if (!line.StartsWith("BeatmapDirectory")) continue; - - var customPath = line.Split('=')[1].Trim(); - return Path.IsPathRooted(customPath) ? customPath : Path.Combine(basePath, customPath); - } - - if (!string.IsNullOrEmpty(path)) - { - path = Path.Combine(path, "Songs"); - } + Value = value, + Status = ResultStatus.Success, + ErrorMessage = null + }; - return path; - } + private static Result Error(string message) => new() + { + Value = default, + Status = ResultStatus.Error, + ErrorMessage = message + }; } diff --git a/MapWizard.Desktop/Services/MemoryService/OsuNowPlayingMonitor.cs b/MapWizard.Desktop/Services/MemoryService/OsuNowPlayingMonitor.cs new file mode 100644 index 0000000..b7b5574 --- /dev/null +++ b/MapWizard.Desktop/Services/MemoryService/OsuNowPlayingMonitor.cs @@ -0,0 +1,225 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Media.Imaging; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using MapWizard.Desktop.Enums; +using MapWizard.Desktop.Models; +using MapWizard.Desktop.Utils; + +namespace MapWizard.Desktop.Services.MemoryService; + +/// +/// Keeps track of the beatmap open in osu!, preferring an osu!lazer external-edit mount over the +/// beatmap selected in osu!stable. Polls in the background and publishes a new +/// on the UI thread only when the open beatmap changes. +/// +public sealed partial class OsuNowPlayingMonitor( + IOsuMemoryReaderService osuMemoryReaderService, + ILazerLookupService lazerLookupService) : ObservableObject, IDisposable +{ + private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(2); + private const int BackgroundDecodeWidth = 320; + + private readonly SemaphoreSlim _refreshGate = new(1, 1); + private CancellationTokenSource? _pollCts; + private string? _currentKey; + private string? _lastLoggedError; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasCurrent))] + private OsuNowPlaying? _current; + + public bool HasCurrent => Current is not null; + + /// + /// The beatmap selected in osu!stable, tracked separately because gives + /// priority to osu!lazer. + /// + public string? StableBeatmapPath { get; private set; } + + public void Start() + { + if (_pollCts is not null) + { + return; + } + + _pollCts = new CancellationTokenSource(); + _ = PollAsync(_pollCts.Token); + } + + public async Task RefreshAsync(CancellationToken cancellationToken = default) + { + await _refreshGate.WaitAsync(cancellationToken); + try + { + var probe = await Task.Run(Probe, cancellationToken); + StableBeatmapPath = probe.StableBeatmapPath; + if (probe.Key == _currentKey) + { + return; + } + + _currentKey = probe.Key; + await Dispatcher.UIThread.InvokeAsync(() => + { + var previous = Current; + Current = probe.Snapshot; + previous?.Dispose(); + }); + } + finally + { + _refreshGate.Release(); + } + } + + public void Dispose() + { + _pollCts?.Cancel(); + _pollCts?.Dispose(); + _pollCts = null; + Current?.Dispose(); + } + + private async Task PollAsync(CancellationToken cancellationToken) + { + using var timer = new PeriodicTimer(PollInterval); + try + { + do + { + try + { + await RefreshAsync(cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + LogOnce(ex); + } + } while (await timer.WaitForNextTickAsync(cancellationToken)); + } + catch (OperationCanceledException) + { + // Shutting down. + } + } + + private ProbeResult Probe() + { + var stableResult = osuMemoryReaderService.GetBeatmapPath(); + var stablePath = stableResult.Status == ResultStatus.Success && !string.IsNullOrWhiteSpace(stableResult.Value) + ? stableResult.Value + : null; + + var lazerResult = lazerLookupService.GetSessionState(); + if (lazerResult.Status == ResultStatus.Success && + lazerResult.Value?.MountedBeatmapPaths is { Count: > 0 } mountedPaths) + { + var key = BuildKey(OsuClient.Lazer, mountedPaths[0], Path.GetDirectoryName(mountedPaths[0])); + return new ProbeResult(key, stablePath, key == _currentKey + ? null + : TryBuildSnapshot(OsuClient.Lazer, mountedPaths)); + } + + if (stablePath is not null) + { + var key = BuildKey(OsuClient.Stable, stablePath, stablePath); + return new ProbeResult(key, stablePath, key == _currentKey + ? null + : TryBuildSnapshot(OsuClient.Stable, [stablePath])); + } + + return new ProbeResult(null, null, null); + } + + /// + /// Identifies what is open, including the last write time so edits saved from MapWizard or the + /// client (e.g. new metadata) refresh the snapshot. + /// + private static string BuildKey(OsuClient client, string path, string? timestampPath) + { + var lastWrite = DateTime.MinValue; + try + { + if (timestampPath is not null) + { + lastWrite = File.GetLastWriteTimeUtc(timestampPath); + } + } + catch (Exception) + { + // A missing timestamp only means a change may be picked up one poll later. + } + + return $"{client}|{path}|{lastWrite.Ticks}"; + } + + private OsuNowPlaying? TryBuildSnapshot(OsuClient client, IReadOnlyList beatmapPaths) + { + try + { + var primaryPath = beatmapPaths[0]; + var metadata = BeatmapCardInfoReader.Read(primaryPath); + var artist = StringValueUtils.FirstNonEmpty(metadata.Artist, metadata.ArtistUnicode, "Unknown Artist"); + var title = StringValueUtils.FirstNonEmpty(metadata.Title, metadata.TitleUnicode, "Unknown Title"); + var creator = StringValueUtils.FirstNonEmpty(metadata.Creator, "Unknown Mapper"); + + // Lazer mounts the whole set and does not say which difficulty is open. + var difficulty = client == OsuClient.Lazer && beatmapPaths.Count > 1 + ? null + : StringValueUtils.FirstNonEmpty(metadata.Version, "Unknown Difficulty"); + + return new OsuNowPlaying( + client, + Path.GetDirectoryName(primaryPath) ?? string.Empty, + beatmapPaths, + artist, + title, + difficulty, + creator, + TryDecodeBackground(primaryPath, metadata.BackgroundFilename)); + } + catch (Exception ex) + { + LogOnce(ex); + return null; + } + } + + private static Bitmap? TryDecodeBackground(string beatmapPath, string? backgroundFilename) + { + try + { + var backgroundPath = MapsetAssetPathUtils.ResolveRelativePathFromBeatmap(beatmapPath, backgroundFilename); + return backgroundPath is not null && File.Exists(backgroundPath) + ? ArtworkBitmapUtils.DecodePreview(backgroundPath, BackgroundDecodeWidth) + : null; + } + catch (Exception) + { + // Unsupported or corrupt artwork falls back to the placeholder. + return null; + } + } + + /// + /// Polling repeats every couple of seconds, so a persistent failure is only logged once. + /// + private void LogOnce(Exception ex) + { + if (ex.Message == _lastLoggedError) + { + return; + } + + _lastLoggedError = ex.Message; + MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex); + } + + private sealed record ProbeResult(string? Key, string? StableBeatmapPath, OsuNowPlaying? Snapshot); +} diff --git a/MapWizard.Desktop/Services/MemoryService/OsuStableInstallLocator.cs b/MapWizard.Desktop/Services/MemoryService/OsuStableInstallLocator.cs new file mode 100644 index 0000000..95494fd --- /dev/null +++ b/MapWizard.Desktop/Services/MemoryService/OsuStableInstallLocator.cs @@ -0,0 +1,274 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; + +namespace MapWizard.Desktop.Services.MemoryService; + +/// +/// Resolves the Songs folder of the currently running osu!stable process, both natively on Windows +/// and under Wine on Linux (where the Windows-side paths have to be mapped through the Wine prefix). +/// +internal static class OsuStableInstallLocator +{ + /// + /// Process name as seen by . Wine names the + /// process after the full executable file name, so the extension is kept on Linux. + /// + public static string ProcessName => OperatingSystem.IsWindows() ? "osu!" : "osu!.exe"; + + public static bool IsRunning() + { + var processes = Process.GetProcessesByName(ProcessName); + foreach (var process in processes) + { + process.Dispose(); + } + + return processes.Length > 0; + } + + public static string? TryGetRunningSongsFolder() + { + var processes = Process.GetProcessesByName(ProcessName); + try + { + if (processes.Length == 0) + { + return null; + } + + var process = processes[0]; + if (OperatingSystem.IsWindows()) + { + var installPath = Path.GetDirectoryName(process.MainModule?.FileName); + return string.IsNullOrWhiteSpace(installPath) ? null : ResolveSongsFolder(installPath, winePrefix: null); + } + + if (OperatingSystem.IsLinux()) + { + var winePrefix = TryReadWinePrefix(process.Id); + var installPath = TryGetWineInstallPath(process.Id, winePrefix); + return installPath is null ? null : ResolveSongsFolder(installPath, winePrefix); + } + + return null; + } + catch (Exception ex) + { + MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex); + return null; + } + finally + { + foreach (var process in processes) + { + process.Dispose(); + } + } + } + + private static string? ResolveSongsFolder(string installPath, string? winePrefix) + { + var beatmapDirectory = TryReadBeatmapDirectory(installPath); + if (string.IsNullOrWhiteSpace(beatmapDirectory)) + { + return ResolveCaseInsensitive(installPath, "Songs"); + } + + if (OperatingSystem.IsWindows()) + { + var songsPath = Path.IsPathRooted(beatmapDirectory) + ? beatmapDirectory + : Path.Combine(installPath, beatmapDirectory); + return Directory.Exists(songsPath) ? Path.GetFullPath(songsPath) : null; + } + + return IsWindowsDrivePath(beatmapDirectory) + ? TryMapWinePath(beatmapDirectory, winePrefix) + : ResolveCaseInsensitive(installPath, beatmapDirectory); + } + + private static string? TryReadBeatmapDirectory(string installPath) + { + foreach (var configPath in EnumerateUserConfigs(installPath)) + { + try + { + foreach (var line in File.ReadLines(configPath)) + { + if (!line.StartsWith("BeatmapDirectory", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var separatorIndex = line.IndexOf('='); + return separatorIndex < 0 ? null : line[(separatorIndex + 1)..].Trim().Trim('"'); + } + } + catch (Exception ex) + { + MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex); + } + } + + return null; + } + + /// + /// Yields the current user's config first (Wine uses the Unix user name as the Windows one), + /// then any other per-user config in the install folder. + /// + private static IEnumerable EnumerateUserConfigs(string installPath) + { + var preferred = Path.Combine(installPath, $"osu!.{Environment.UserName}.cfg"); + if (File.Exists(preferred)) + { + yield return preferred; + } + + IEnumerable others; + try + { + others = Directory.EnumerateFiles(installPath, "osu!.*.cfg", SearchOption.TopDirectoryOnly).ToList(); + } + catch (Exception ex) + { + MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex); + yield break; + } + + foreach (var config in others) + { + if (!string.Equals(config, preferred, StringComparison.Ordinal)) + { + yield return config; + } + } + } + + /// + /// Wine maps the executable image from its Unix path, so /proc/pid/maps is the most direct + /// source. Falls back to translating the Windows-style argv[0] through the Wine prefix. + /// + private static string? TryGetWineInstallPath(int pid, string? winePrefix) + { + try + { + foreach (var line in File.ReadLines($"/proc/{pid}/maps")) + { + var pathStart = line.IndexOf('/'); + if (pathStart < 0 || !line.EndsWith("/osu!.exe", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var installPath = Path.GetDirectoryName(line[pathStart..]); + if (Directory.Exists(installPath)) + { + return installPath; + } + } + } + catch (Exception ex) + { + MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex); + } + + try + { + var executable = File.ReadAllText($"/proc/{pid}/cmdline").Split('\0')[0]; + var executablePath = IsWindowsDrivePath(executable) ? TryMapWinePath(executable, winePrefix) : executable; + var installPath = Path.GetDirectoryName(executablePath); + return Directory.Exists(installPath) ? installPath : null; + } + catch (Exception ex) + { + MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex); + return null; + } + } + + private static string? TryReadWinePrefix(int pid) + { + try + { + const string key = "WINEPREFIX="; + var entry = File.ReadAllText($"/proc/{pid}/environ") + .Split('\0') + .FirstOrDefault(variable => variable.StartsWith(key, StringComparison.Ordinal)); + if (entry is not null) + { + return entry[key.Length..]; + } + } + catch (Exception ex) + { + MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex); + } + + var defaultPrefix = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".wine"); + return Directory.Exists(defaultPrefix) ? defaultPrefix : null; + } + + private static bool IsWindowsDrivePath(string path) => + path.Length >= 2 && char.IsAsciiLetter(path[0]) && path[1] == ':'; + + /// + /// Translates X:\some\path to its Unix location via $WINEPREFIX/dosdevices/x:. + /// + private static string? TryMapWinePath(string windowsPath, string? winePrefix) + { + if (winePrefix is null) + { + return null; + } + + var drivePath = Path.Combine(winePrefix, "dosdevices", $"{char.ToLowerInvariant(windowsPath[0])}:"); + if (!Directory.Exists(drivePath)) + { + return null; + } + + var driveRoot = new DirectoryInfo(drivePath).ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? drivePath; + return ResolveCaseInsensitive(driveRoot, windowsPath[2..]); + } + + /// + /// Combines with a Windows-style relative path, matching each segment + /// case-insensitively like Wine does. Returns when a segment is missing. + /// + private static string? ResolveCaseInsensitive(string root, string relativePath) + { + var current = root; + foreach (var segment in relativePath.Split(['\\', '/'], StringSplitOptions.RemoveEmptyEntries)) + { + var exact = Path.Combine(current, segment); + if (Path.Exists(exact)) + { + current = exact; + continue; + } + + try + { + var match = new DirectoryInfo(current).EnumerateFileSystemInfos() + .FirstOrDefault(entry => string.Equals(entry.Name, segment, StringComparison.OrdinalIgnoreCase)); + if (match is null) + { + return null; + } + + current = match.FullName; + } + catch (Exception ex) + { + MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex); + return null; + } + } + + return Path.Exists(current) ? Path.GetFullPath(current) : null; + } +} diff --git a/MapWizard.Desktop/Utils/MapPickerDialogUtils.cs b/MapWizard.Desktop/Utils/MapPickerDialogUtils.cs index 9918229..a88f71e 100644 --- a/MapWizard.Desktop/Utils/MapPickerDialogUtils.cs +++ b/MapWizard.Desktop/Utils/MapPickerDialogUtils.cs @@ -22,6 +22,7 @@ public static class MapPickerDialogUtils ISongLibraryService songLibraryService, IFilesService filesService, ILazerLookupService lazerLookupService, + IOsuMemoryReaderService osuMemoryReaderService, ISettingsService settingsService, string featureName, bool allowMultiple, @@ -40,6 +41,7 @@ public static class MapPickerDialogUtils songLibraryService, filesService, lazerLookupService, + osuMemoryReaderService, songsPath, allowMultiple, preferredMapsetDirectoryPath); diff --git a/MapWizard.Desktop/ViewModels/ComboColourStudioViewModel.cs b/MapWizard.Desktop/ViewModels/ComboColourStudioViewModel.cs index 60d2b93..b92ae0b 100644 --- a/MapWizard.Desktop/ViewModels/ComboColourStudioViewModel.cs +++ b/MapWizard.Desktop/ViewModels/ComboColourStudioViewModel.cs @@ -722,6 +722,7 @@ private bool EnsureOriginSelected() songLibraryService, filesService, lazerLookupService, + osuMemoryReaderService, settingsService, "Combo Colour Studio", allowMultiple, diff --git a/MapWizard.Desktop/ViewModels/HitSoundCopierViewModel.cs b/MapWizard.Desktop/ViewModels/HitSoundCopierViewModel.cs index 71b92c3..50aec96 100644 --- a/MapWizard.Desktop/ViewModels/HitSoundCopierViewModel.cs +++ b/MapWizard.Desktop/ViewModels/HitSoundCopierViewModel.cs @@ -267,6 +267,7 @@ private void SetDestinationBeatmaps(IReadOnlyCollection beatmapPaths) songLibraryService, filesService, lazerLookupService, + osuMemoryReaderService, settingsService, "HitSound Copier", allowMultiple, diff --git a/MapWizard.Desktop/ViewModels/HitSoundVisualizerViewModel.cs b/MapWizard.Desktop/ViewModels/HitSoundVisualizerViewModel.cs index 074b959..3cf5237 100644 --- a/MapWizard.Desktop/ViewModels/HitSoundVisualizerViewModel.cs +++ b/MapWizard.Desktop/ViewModels/HitSoundVisualizerViewModel.cs @@ -2087,6 +2087,7 @@ private bool ShouldPreservePlaybackPosition(string nextBeatmapPath) songLibraryService, filesService, lazerLookupService, + osuMemoryReaderService, settingsService, "HitSound Editor", allowMultiple, diff --git a/MapWizard.Desktop/ViewModels/MainWindowViewModel.cs b/MapWizard.Desktop/ViewModels/MainWindowViewModel.cs index 5ea9541..530b348 100644 --- a/MapWizard.Desktop/ViewModels/MainWindowViewModel.cs +++ b/MapWizard.Desktop/ViewModels/MainWindowViewModel.cs @@ -10,6 +10,7 @@ using MapWizard.Desktop.Extensions; using MapWizard.Desktop.Models; using MapWizard.Desktop.Services; +using MapWizard.Desktop.Services.MemoryService; using Microsoft.Extensions.DependencyInjection; namespace MapWizard.Desktop.ViewModels @@ -18,6 +19,11 @@ public partial class MainWindowViewModel : ObservableObject { public INotificationService NotificationService { get; } + /// + /// Beatmap currently open in osu!. Shown in the title bar and inherited by beatmap panels. + /// + public OsuNowPlayingMonitor NowPlaying { get; } + private readonly IServiceProvider _services; private HitSoundCopierViewModel? _hitSoundCopierViewModel; private HitSoundVisualizerViewModel? _hitSoundVisualizerViewModel; @@ -60,8 +66,10 @@ public MainWindowViewModel( SettingsViewModel settingsViewModel, IUpdateService updateService, INotificationService notificationService, + OsuNowPlayingMonitor nowPlaying, IServiceProvider services) { + NowPlaying = nowPlaying; _services = services; NotificationService = notificationService; _welcomePageViewModel = welcomePageViewModel; @@ -72,6 +80,7 @@ public MainWindowViewModel( SetPage(NavigationPage.Welcome); settingsViewModel.Initialize(); + nowPlaying.Start(); } /// diff --git a/MapWizard.Desktop/ViewModels/MapCleanerViewModel.cs b/MapWizard.Desktop/ViewModels/MapCleanerViewModel.cs index 1bb2e30..7b986c1 100644 --- a/MapWizard.Desktop/ViewModels/MapCleanerViewModel.cs +++ b/MapWizard.Desktop/ViewModels/MapCleanerViewModel.cs @@ -322,6 +322,7 @@ private void ClearOriginBeatmapHeader() songLibraryService, filesService, lazerLookupService, + osuMemoryReaderService, settingsService, "Map Cleaner", allowMultiple, diff --git a/MapWizard.Desktop/ViewModels/MetadataManagerViewModel.cs b/MapWizard.Desktop/ViewModels/MetadataManagerViewModel.cs index 4b469b2..eb18b8a 100644 --- a/MapWizard.Desktop/ViewModels/MetadataManagerViewModel.cs +++ b/MapWizard.Desktop/ViewModels/MetadataManagerViewModel.cs @@ -417,6 +417,7 @@ private void SetDestinationBeatmaps(IReadOnlyCollection beatmapPaths) songLibraryService, filesService, lazerLookupService, + osuMemoryReaderService, settingsService, "Metadata Manager", allowMultiple, diff --git a/MapWizard.Desktop/ViewModels/SongSelectDialogViewModel.cs b/MapWizard.Desktop/ViewModels/SongSelectDialogViewModel.cs index 95b3a9a..82f24af 100644 --- a/MapWizard.Desktop/ViewModels/SongSelectDialogViewModel.cs +++ b/MapWizard.Desktop/ViewModels/SongSelectDialogViewModel.cs @@ -23,6 +23,7 @@ public partial class SongSelectDialogViewModel( ISongLibraryService songLibraryService, IFilesService filesService, ILazerLookupService lazerLookupService, + IOsuMemoryReaderService osuMemoryReaderService, string songsPath, bool allowMultipleSelection, string? preferredMapsetDirectoryPath = null) : ViewModelBase, IDisposable @@ -79,6 +80,17 @@ public partial class SongSelectDialogViewModel( [NotifyPropertyChangedFor(nameof(ShowEmptyState))] private SongMapsetCardViewModel? _mountedLazerMapset; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasOpenInStableMapset))] + [NotifyPropertyChangedFor(nameof(ShowEmptyState))] + private SongMapsetCardViewModel? _openInStableMapset; + + /// + /// Folder of ; excluded from the regular list so the pinned card + /// is not shown twice. + /// + private string? _openInStableDirectoryPath; + public bool AllowMultipleSelection { get; } = allowMultipleSelection; public bool HasSongsPath => !string.IsNullOrWhiteSpace(songsPath); public bool HasMoreResults => _filteredCursor < _filteredDirectoryEntries.Count; @@ -86,6 +98,7 @@ public partial class SongSelectDialogViewModel( public bool ShowEmptyState => !ShowBusyBox && VisibleMapsets.Count == 0 && !HasMountedLazerMapset && + !HasOpenInStableMapset && !ShowNotFoundBadge; public bool ShowNotFoundBadge => !ShowBusyBox && !HasMoreResults && @@ -94,6 +107,7 @@ public partial class SongSelectDialogViewModel( public bool CanConfirmSelection => AllowMultipleSelection && SelectedDifficultyCount > 0; public bool CanReloadLibrary => !IsBusyAreaActive && !IsLoading && !IsSearchPending; public bool HasMountedLazerMapset => MountedLazerMapset is not null; + public bool HasOpenInStableMapset => OpenInStableMapset is not null; public event Action>? SelectionSubmitted; @@ -142,6 +156,7 @@ public async Task InitializeAsync(CancellationToken cancellationToken = default) try { await LoadMountedLazerMapsetAsync(cancellationToken); + await LoadOpenInStableMapsetAsync(cancellationToken); if (!HasSongsPath) { @@ -157,6 +172,7 @@ public async Task InitializeAsync(CancellationToken cancellationToken = default) _mapsetDirectoryEntries.Clear(); _mapsetDirectoryEntries.AddRange(_mapsetDirectories.Select(path => new MapsetDirectoryEntry(path, Path.GetFileName(path)))); + ExcludePinnedStableMapsetEntry(); PrioritizePreferredMapsetEntry(); if (_mapsetDirectories.Count == 0) @@ -247,6 +263,11 @@ private void ToggleMapsetExpansion(SongMapsetCardViewModel mapset) MountedLazerMapset.IsExpanded = false; } + if (!ReferenceEquals(OpenInStableMapset, mapset) && OpenInStableMapset is not null) + { + OpenInStableMapset.IsExpanded = false; + } + mapset.IsExpanded = !mapset.IsExpanded; } @@ -646,6 +667,78 @@ private async Task LoadMountedLazerMapsetAsync(CancellationToken cancellationTok } } + /// + /// Pins the mapset of the beatmap selected in osu!stable, mirroring the mounted osu!lazer card. + /// + private async Task LoadOpenInStableMapsetAsync(CancellationToken cancellationToken) + { + OpenInStableMapset = null; + _openInStableDirectoryPath = null; + + try + { + var stableResult = await Task.Run(osuMemoryReaderService.GetBeatmapPath, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + if (stableResult.Status != ResultStatus.Success || string.IsNullOrWhiteSpace(stableResult.Value)) + { + return; + } + + var openBeatmapPath = Path.GetFullPath(stableResult.Value); + var directory = NormalizeDirectoryPath(Path.GetDirectoryName(openBeatmapPath)); + if (directory is null || !Directory.Exists(directory)) + { + return; + } + + var mapset = await songLibraryService.LoadMapsetAsync( + directory, + cancellationToken, + bypassCache: true); + cancellationToken.ThrowIfCancellationRequested(); + if (mapset is null) + { + return; + } + + var stableViewModel = CreateMapsetCardViewModel( + directory, + mapset, + isPreferredMapset: IsPreferredMapsetDirectory(directory), + openInStableBeatmapPath: openBeatmapPath); + stableViewModel.SetBackgroundActive(true); + _openInStableDirectoryPath = directory; + OpenInStableMapset = stableViewModel; + EvictExcessMapsetCards(); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex); + OpenInStableMapset = null; + _openInStableDirectoryPath = null; + } + } + + private void ExcludePinnedStableMapsetEntry() + { + if (_openInStableDirectoryPath is null) + { + return; + } + + _mapsetDirectoryEntries.RemoveAll(entry => string.Equals( + NormalizeDirectoryPath(entry.DirectoryPath), + _openInStableDirectoryPath, + StringComparison.OrdinalIgnoreCase)); + } + + private bool IsPinnedMapset(SongMapsetCardViewModel mapset) => + ReferenceEquals(mapset, MountedLazerMapset) || ReferenceEquals(mapset, OpenInStableMapset); + private void UpdateStatusMessage() { if (_mapsetDirectories.Count == 0) @@ -679,18 +772,19 @@ private SongMapsetCardViewModel CreateMapsetCardViewModel( string directoryPath, SongMapsetInfo mapset, bool isPreferredMapset, - bool isMountedInLazer = false) + bool isMountedInLazer = false, + string? openInStableBeatmapPath = null) { - var mapsetViewModel = new SongMapsetCardViewModel(mapset, isPreferredMapset, isMountedInLazer) + var mapsetViewModel = new SongMapsetCardViewModel(mapset, isPreferredMapset, isMountedInLazer, openInStableBeatmapPath) { Owner = this }; - // The mounted lazer mapset can share a directory with an already cached card; + // A pinned mapset can share a directory with an already cached card; // retire the orphaned entry so its difficulty VMs are released. if (_mapsetViewModelCache.TryGetValue(directoryPath, out var existingCard) && !_visibleDirectoryPaths.Contains(directoryPath) && - !ReferenceEquals(existingCard, MountedLazerMapset)) + !IsPinnedMapset(existingCard)) { _mapsetViewModelCache.Remove(directoryPath); existingCard.Dispose(); @@ -714,7 +808,7 @@ private SongMapsetCardViewModel CreateMapsetCardViewModel( /// /// Bounds the card view-model cache. Cards are tracked in insertion order and the - /// oldest entries that are neither the mounted lazer mapset nor currently listed + /// oldest entries that are neither pinned (lazer mount / open in stable) nor currently listed /// get disposed. Selection state survives eviction because it is tracked in /// and rehydrated when a card is recreated. /// @@ -731,7 +825,7 @@ private void EvictExcessMapsetCards() } if (_visibleDirectoryPaths.Contains(candidatePath) || - ReferenceEquals(candidateCard, MountedLazerMapset)) + IsPinnedMapset(candidateCard)) { continue; } @@ -936,7 +1030,7 @@ private void DeactivateAllCachedBackgrounds() { foreach (var mapset in _mapsetViewModelCache.Values) { - mapset.SetBackgroundActive(ReferenceEquals(mapset, MountedLazerMapset)); + mapset.SetBackgroundActive(IsPinnedMapset(mapset)); } } @@ -958,6 +1052,8 @@ private void ClearMapsetViewModelCache() { DeactivateAllCachedBackgrounds(); MountedLazerMapset = null; + OpenInStableMapset = null; + _openInStableDirectoryPath = null; foreach (var mapset in _mapsetViewModelCache.Values) { @@ -997,7 +1093,8 @@ public partial class SongMapsetCardViewModel : ObservableObject, IDisposable public SongMapsetCardViewModel( SongMapsetInfo mapset, bool isPreferredMapset, - bool isMountedInLazer = false) + bool isMountedInLazer = false, + string? openInStableBeatmapPath = null) { Artist = mapset.Artist; Title = mapset.Title; @@ -1006,11 +1103,16 @@ public SongMapsetCardViewModel( _backgroundImagePath = mapset.BackgroundImagePath; IsPreferredMapset = isPreferredMapset; IsMountedInLazer = isMountedInLazer; + IsOpenInStable = openInStableBeatmapPath is not null; Difficulties = new ObservableCollection(mapset.Difficulties .OrderByDescending(difficulty => difficulty.LastEditUtc) .Select(difficulty => new SongDifficultyItemViewModel(difficulty) { - Mapset = this + Mapset = this, + IsOpenInOsu = openInStableBeatmapPath is not null && string.Equals( + Path.GetFullPath(difficulty.OsuFilePath), + openInStableBeatmapPath, + StringComparison.OrdinalIgnoreCase) })); foreach (var difficulty in Difficulties) { @@ -1022,6 +1124,7 @@ public SongMapsetCardViewModel( [ObservableProperty] private bool _isExpanded; [ObservableProperty] private bool _isPreferredMapset; public bool IsMountedInLazer { get; } + public bool IsOpenInStable { get; } /// /// The owning song select view model. Set at construction so the card can bind to @@ -1189,6 +1292,11 @@ public partial class SongDifficultyItemViewModel(SongDifficultyInfo difficulty) public string Name { get; } = difficulty.Name; public string OsuFilePath { get; } = difficulty.OsuFilePath; + /// + /// The difficulty currently selected in osu!stable. + /// + public bool IsOpenInOsu { get; init; } + /// /// The owning mapset card. Set at construction so difficulty chips can bind to the /// dialog's selection command with compiled bindings (NativeAOT-safe). diff --git a/MapWizard.Desktop/Views/Controls/ArtworkStrip.axaml b/MapWizard.Desktop/Views/Controls/ArtworkStrip.axaml new file mode 100644 index 0000000..53c744d --- /dev/null +++ b/MapWizard.Desktop/Views/Controls/ArtworkStrip.axaml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MapWizard.Desktop/Views/Controls/ArtworkStrip.axaml.cs b/MapWizard.Desktop/Views/Controls/ArtworkStrip.axaml.cs new file mode 100644 index 0000000..3a7b89c --- /dev/null +++ b/MapWizard.Desktop/Views/Controls/ArtworkStrip.axaml.cs @@ -0,0 +1,191 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using MapWizard.Desktop.Services; + +namespace MapWizard.Desktop.Views.Controls; + +/// +/// Two-piece banner: a slanted neutral strap on the left (icon or label) and a body drawn over +/// beatmap artwork. On hover the strap slides away and the artwork grows into its space, leaving +/// more room for the body. +/// +public partial class ArtworkStrip : UserControl +{ + private static readonly TimeSpan RevealDuration = TimeSpan.FromMilliseconds(260); + + /// + /// Extra strap width hidden past the left edge, so the slant never exposes the artwork there. + /// + private const double StrapOverhang = 20; + + private const double StrapSkewDegrees = -20; + + /// + /// Width of the highlight drawn on the strap's slanted edge. + /// + private const double StrapEdgeWidth = 2; + + /// + /// How far the body layers start under the strap, enough to cover its slanted edge. + /// + private const double BodyUnderlap = 10; + + /// + /// Zoom applied to the artwork when fully collapsed, so it visibly comes forward on hover. + /// + private const double HoverArtworkZoom = 0.08; + + public static readonly StyledProperty ArtworkProperty = + AvaloniaProperty.Register(nameof(Artwork)); + + public static readonly StyledProperty StrapContentProperty = + AvaloniaProperty.Register(nameof(StrapContent)); + + public static readonly StyledProperty BodyContentProperty = + AvaloniaProperty.Register(nameof(BodyContent)); + + public static readonly StyledProperty StrapWidthProperty = + AvaloniaProperty.Register(nameof(StrapWidth), 36); + + public static readonly StyledProperty CollapseStrapOnHoverProperty = + AvaloniaProperty.Register(nameof(CollapseStrapOnHover), true); + + // 1 = strap fully shown, 0 = collapsed. Animated per frame rather than through Avalonia + // transitions, which skipped the first change on each control. + private double _reveal = 1; + private double _animationFrom = 1; + private double _animationTarget = 1; + private TimeSpan? _animationStart; + private bool _isAnimating; + + public ArtworkStrip() + { + InitializeComponent(); + ApplyReveal(); + } + + public IImage? Artwork + { + get => GetValue(ArtworkProperty); + set => SetValue(ArtworkProperty, value); + } + + public object? StrapContent + { + get => GetValue(StrapContentProperty); + set => SetValue(StrapContentProperty, value); + } + + public object? BodyContent + { + get => GetValue(BodyContentProperty); + set => SetValue(BodyContentProperty, value); + } + + public double StrapWidth + { + get => GetValue(StrapWidthProperty); + set => SetValue(StrapWidthProperty, value); + } + + public bool CollapseStrapOnHover + { + get => GetValue(CollapseStrapOnHoverProperty); + set => SetValue(CollapseStrapOnHoverProperty, value); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == IsPointerOverProperty || change.Property == CollapseStrapOnHoverProperty) + { + AnimateRevealTo(CollapseStrapOnHover && IsPointerOver ? 0 : 1); + } + else if (change.Property == StrapWidthProperty || change.Property == BoundsProperty) + { + ApplyReveal(); + } + } + + private void AnimateRevealTo(double target) + { + var topLevel = TopLevel.GetTopLevel(this); + if (AppearanceSettings.ReducedMotion || topLevel is null) + { + _isAnimating = false; + _reveal = target; + ApplyReveal(); + return; + } + + // Restart from wherever the strap currently is, so reversing mid-hover stays smooth. + _animationFrom = _reveal; + _animationTarget = target; + _animationStart = null; + if (!_isAnimating) + { + _isAnimating = true; + topLevel.RequestAnimationFrame(OnAnimationFrame); + } + } + + private void OnAnimationFrame(TimeSpan timestamp) + { + if (!_isAnimating) + { + return; + } + + _animationStart ??= timestamp; + var progress = Math.Clamp((timestamp - _animationStart.Value) / RevealDuration, 0, 1); + var eased = 1 - Math.Pow(1 - progress, 3); + _reveal = _animationFrom + (_animationTarget - _animationFrom) * eased; + ApplyReveal(); + + if (progress < 1 && TopLevel.GetTopLevel(this) is { } topLevel) + { + topLevel.RequestAnimationFrame(OnAnimationFrame); + return; + } + + _isAnimating = false; + } + + private void ApplyReveal() + { + StrapHost.Width = StrapWidth * _reveal; + Strap.Width = StrapWidth + StrapOverhang; + + // The slant leans the strap's top edge past its host; slide it that much further out as it + // collapses so no sliver of strap or highlight is left in the corner. + var height = Bounds.Height > 0 ? Bounds.Height : 42; + var slantReach = height / 2 * Math.Tan(Math.Abs(StrapSkewDegrees) * Math.PI / 180) + StrapEdgeWidth; + Strap.RenderTransform = new TransformGroup + { + Children = + { + new SkewTransform(StrapSkewDegrees, 0), + new TranslateTransform(-slantReach * (1 - _reveal), 0) + } + }; + StrapPresenter.Width = StrapWidth; + StrapPresenter.Opacity = _reveal; + + // Hand the strap's width to the body so the control keeps its size: the text slides + // left and the artwork fills the freed space instead of the whole control shrinking. + BodyPresenter.Margin = new Thickness(12, 0, 14 + StrapWidth * (1 - _reveal), 0); + Shade.Opacity = 0.6 + 0.4 * _reveal; + + // Keep the artwork out from under the strap's rounded corner; stacked layers there leave + // an anti-aliasing fringe along the clip. + var bodyInset = new Thickness(Math.Max(0, StrapWidth * _reveal - BodyUnderlap), 0, 0, 0); + BodyBase.Margin = bodyInset; + BodyArtwork.Margin = bodyInset; + var zoom = 1 + HoverArtworkZoom * (1 - _reveal); + BodyArtwork.RenderTransform = new ScaleTransform(zoom, zoom); + Shade.Margin = bodyInset; + } +} diff --git a/MapWizard.Desktop/Views/Controls/BeatmapSelectionPanel.axaml b/MapWizard.Desktop/Views/Controls/BeatmapSelectionPanel.axaml index 4ba718e..b565ec0 100644 --- a/MapWizard.Desktop/Views/Controls/BeatmapSelectionPanel.axaml +++ b/MapWizard.Desktop/Views/Controls/BeatmapSelectionPanel.axaml @@ -136,12 +136,35 @@ + + + + + + + + + + + + + + + + + + + + + + + +