diff --git a/Directory.Build.props b/Directory.Build.props
index 3a012b7..327b321 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -4,6 +4,5 @@
net10.0
enable
enable
- $(DefaultItemExcludes);**\obj_audit\**
diff --git a/LanMountainDesktop.DesktopComponents.Runtime/ComponentTypographyLayoutModels.cs b/LanMountainDesktop.DesktopComponents.Runtime/ComponentTypographyLayoutModels.cs
deleted file mode 100644
index e9acce4..0000000
--- a/LanMountainDesktop.DesktopComponents.Runtime/ComponentTypographyLayoutModels.cs
+++ /dev/null
@@ -1,30 +0,0 @@
-using Avalonia;
-using Avalonia.Media;
-
-namespace LanMountainDesktop.DesktopComponents.Runtime;
-
-public readonly record struct ComponentAdaptiveTextLayout(
- double FontSize,
- FontWeight Weight,
- int MaxLines,
- double LineHeight,
- double OverflowScore,
- bool FitsCompletely,
- Size MeasuredSize)
-{
- public double MeasuredWidth => MeasuredSize.Width;
-
- public double MeasuredHeight => MeasuredSize.Height;
-}
-
-public readonly record struct ComponentBoxLayout(
- double Width,
- double Height,
- Thickness Margin,
- Thickness Padding)
-{
- public double Size => Math.Max(Width, Height);
-
- public bool IsSquare => Math.Abs(Width - Height) <= 0.001d;
-}
-
diff --git a/LanMountainDesktop.DesktopComponents.Runtime/ComponentTypographyLayoutService.cs b/LanMountainDesktop.DesktopComponents.Runtime/ComponentTypographyLayoutService.cs
deleted file mode 100644
index a8da07a..0000000
--- a/LanMountainDesktop.DesktopComponents.Runtime/ComponentTypographyLayoutService.cs
+++ /dev/null
@@ -1,314 +0,0 @@
-using Avalonia;
-using Avalonia.Controls;
-using Avalonia.Media;
-using System.Text;
-
-namespace LanMountainDesktop.DesktopComponents.Runtime;
-
-public static class ComponentTypographyLayoutService
-{
- public static Size MeasureTextSize(
- string? text,
- double fontSize,
- FontWeight weight,
- double maxWidth,
- double lineHeight,
- FontFamily? fontFamily = null)
- {
- var probe = new TextBlock
- {
- Text = NormalizeText(text),
- FontSize = Math.Max(1, fontSize),
- FontWeight = weight,
- TextWrapping = TextWrapping.Wrap,
- LineHeight = Math.Max(1, lineHeight)
- };
-
- if (fontFamily is not null)
- {
- probe.FontFamily = fontFamily;
- }
-
- probe.Measure(new Size(Math.Max(1, maxWidth), double.PositiveInfinity));
- return probe.DesiredSize;
- }
-
- public static double FitFontSize(
- string? text,
- double maxWidth,
- double maxHeight,
- int maxLines,
- double minFontSize,
- double maxFontSize,
- FontWeight weight,
- double lineHeightFactor,
- FontFamily? fontFamily = null)
- {
- var content = NormalizeText(text);
- var min = Math.Max(6, minFontSize);
- var max = Math.Max(min, maxFontSize);
- var low = min;
- var high = max;
- var best = min;
-
- for (var i = 0; i < 18; i++)
- {
- var candidate = (low + high) / 2d;
- var lineHeight = candidate * lineHeightFactor;
- var size = MeasureTextSize(content, candidate, weight, Math.Max(1, maxWidth), lineHeight, fontFamily);
- var lineCount = ResolveLineCount(size.Height, lineHeight);
- var fits = size.Height <= maxHeight + 0.6d && lineCount <= Math.Max(1, maxLines);
-
- if (fits)
- {
- best = candidate;
- low = candidate;
- }
- else
- {
- high = candidate;
- }
- }
-
- return best;
- }
-
- public static ComponentAdaptiveTextLayout FitAdaptiveTextLayout(
- string? text,
- double maxWidth,
- double maxHeight,
- int minLines,
- int maxLines,
- double minFontSize,
- double maxFontSize,
- IEnumerable? weightCandidates = null,
- double lineHeightFactor = 1.1d,
- FontFamily? fontFamily = null)
- {
- var content = NormalizeText(text);
- var safeMinLines = Math.Max(1, minLines);
- var safeMaxLines = Math.Max(safeMinLines, maxLines);
- var linesByHeight = ResolveMaxLinesByHeight(maxHeight, minFontSize, lineHeightFactor, safeMinLines, safeMaxLines);
-
- var candidates = weightCandidates?.ToArray();
- if (candidates is null || candidates.Length == 0)
- {
- candidates = new[] { FontWeight.Normal };
- }
-
- ComponentAdaptiveTextLayout? best = null;
- foreach (var weight in candidates)
- {
- for (var lineLimit = linesByHeight; lineLimit >= safeMinLines; lineLimit--)
- {
- var fontSize = FitFontSize(
- content,
- maxWidth,
- maxHeight,
- lineLimit,
- minFontSize,
- maxFontSize,
- weight,
- lineHeightFactor,
- fontFamily);
-
- var lineHeight = fontSize * lineHeightFactor;
- var measuredSize = MeasureTextSize(content, fontSize, weight, Math.Max(1, maxWidth), lineHeight, fontFamily);
- var measuredLineCount = ResolveLineCount(measuredSize.Height, lineHeight);
- var overflowLines = Math.Max(0, measuredLineCount - lineLimit);
- var overflowHeight = Math.Max(0, measuredSize.Height - maxHeight);
- var overflowScore = overflowLines * 1000d + overflowHeight;
- var candidate = new ComponentAdaptiveTextLayout(
- fontSize,
- weight,
- lineLimit,
- lineHeight,
- overflowScore,
- overflowLines == 0 && overflowHeight <= 0.6d,
- measuredSize);
-
- if (best is null || IsBetterAdaptiveTextCandidate(candidate, best.Value))
- {
- best = candidate;
- }
- }
- }
-
- if (best is not null)
- {
- return best.Value;
- }
-
- var fallbackFontSize = Math.Max(6, minFontSize);
- return new ComponentAdaptiveTextLayout(
- fallbackFontSize,
- FontWeight.Normal,
- safeMinLines,
- fallbackFontSize * lineHeightFactor,
- double.MaxValue,
- false,
- MeasureTextSize(content, fallbackFontSize, FontWeight.Normal, Math.Max(1, maxWidth), fallbackFontSize * lineHeightFactor, fontFamily));
- }
-
- public static int ResolveMaxLinesByHeight(
- double maxHeight,
- double minFontSize,
- double lineHeightFactor,
- int minLines,
- int maxLines)
- {
- var safeMinLines = Math.Max(1, minLines);
- var safeMaxLines = Math.Max(safeMinLines, maxLines);
- var lineHeight = Math.Max(1, Math.Max(6, minFontSize) * lineHeightFactor);
- var maxHeightWithTolerance = Math.Max(1, maxHeight + 0.6d);
- var linesByHeight = (int)Math.Floor(maxHeightWithTolerance / lineHeight);
- return Math.Clamp(linesByHeight, safeMinLines, safeMaxLines);
- }
-
- public static int ResolveLineCount(double measuredHeight, double lineHeight)
- {
- return Math.Max(1, (int)Math.Ceiling(measuredHeight / Math.Max(1, lineHeight)));
- }
-
- public static int EstimateDisplayUnits(
- double availableWidth,
- double unitWidth,
- double gapWidth = 0,
- double reservedWidth = 0,
- int minUnits = 1,
- int maxUnits = int.MaxValue)
- {
- var safeMinUnits = Math.Max(1, minUnits);
- var safeMaxUnits = Math.Max(safeMinUnits, maxUnits);
- var usableWidth = Math.Max(0, availableWidth - reservedWidth);
- var safeGapWidth = Math.Max(0, gapWidth);
- var raw = safeGapWidth > 0
- ? (usableWidth + safeGapWidth) / Math.Max(1, unitWidth + safeGapWidth)
- : usableWidth / Math.Max(1, unitWidth);
-
- return Math.Clamp((int)Math.Floor(raw), safeMinUnits, safeMaxUnits);
- }
-
- public static int CountTextDisplayUnits(string? text)
- {
- if (string.IsNullOrWhiteSpace(text))
- {
- return 0;
- }
-
- var total = 0;
- foreach (var rune in text.EnumerateRunes())
- {
- if (Rune.IsWhiteSpace(rune))
- {
- continue;
- }
-
- total += IsCjkRune(rune) ? 2 : 1;
- }
-
- return total;
- }
-
- public static ComponentBoxLayout ResolveBadgeBox(
- double availableWidth,
- double availableHeight,
- double preferredSizeScale = 0.42d,
- double minSize = 10,
- double maxSize = 24,
- double insetScale = 0.2d)
- {
- var edge = Math.Min(Math.Max(1, availableWidth), Math.Max(1, availableHeight));
- var size = Math.Clamp(edge * preferredSizeScale, minSize, maxSize);
- var inset = Math.Clamp(size * insetScale, 0, size * 0.35d);
- return new ComponentBoxLayout(size, size, new Thickness(0, inset, 0, 0), new Thickness(inset));
- }
-
- public static ComponentBoxLayout ResolveGlyphBox(
- double availableWidth,
- double availableHeight,
- double preferredSizeScale = 0.50d,
- double minSize = 12,
- double maxSize = 28,
- double insetScale = 0.18d)
- {
- var edge = Math.Min(Math.Max(1, availableWidth), Math.Max(1, availableHeight));
- var size = Math.Clamp(edge * preferredSizeScale, minSize, maxSize);
- var inset = Math.Clamp(size * insetScale, 0, size * 0.30d);
- return new ComponentBoxLayout(size, size, new Thickness(inset), new Thickness(inset));
- }
-
- private static string NormalizeText(string? text)
- {
- if (string.IsNullOrWhiteSpace(text))
- {
- return " ";
- }
-
- return string.Join(" ", text.Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
- }
-
- private static bool IsCjkRune(Rune rune)
- {
- var value = rune.Value;
- return (value >= 0x4E00 && value <= 0x9FFF) || // CJK Unified Ideographs
- (value >= 0x3400 && value <= 0x4DBF) || // CJK Unified Ideographs Extension A
- (value >= 0x20000 && value <= 0x2A6DF) || // CJK Unified Ideographs Extension B
- (value >= 0x2A700 && value <= 0x2B73F) || // CJK Unified Ideographs Extension C
- (value >= 0x2B740 && value <= 0x2B81F) || // CJK Unified Ideographs Extension D
- (value >= 0x2B820 && value <= 0x2CEAF) || // CJK Unified Ideographs Extension E/F
- (value >= 0xF900 && value <= 0xFAFF) || // CJK Compatibility Ideographs
- (value >= 0x2F800 && value <= 0x2FA1F) || // CJK Compatibility Ideographs Supplement
- (value >= 0x3040 && value <= 0x309F) || // Hiragana
- (value >= 0x30A0 && value <= 0x30FF) || // Katakana
- (value >= 0xAC00 && value <= 0xD7AF); // Hangul Syllables
- }
-
- private static bool IsBetterAdaptiveTextCandidate(ComponentAdaptiveTextLayout candidate, ComponentAdaptiveTextLayout best)
- {
- if (candidate.FitsCompletely && !best.FitsCompletely)
- {
- return true;
- }
-
- if (!candidate.FitsCompletely && best.FitsCompletely)
- {
- return false;
- }
-
- if (candidate.FitsCompletely && best.FitsCompletely)
- {
- if (candidate.FontSize > best.FontSize + 0.12d)
- {
- return true;
- }
-
- if (Math.Abs(candidate.FontSize - best.FontSize) <= 0.12d && candidate.MaxLines < best.MaxLines)
- {
- return true;
- }
-
- return false;
- }
-
- if (candidate.OverflowScore < best.OverflowScore - 0.2d)
- {
- return true;
- }
-
- if (Math.Abs(candidate.OverflowScore - best.OverflowScore) <= 0.2d &&
- candidate.FontSize > best.FontSize + 0.12d)
- {
- return true;
- }
-
- if (Math.Abs(candidate.OverflowScore - best.OverflowScore) <= 0.2d &&
- Math.Abs(candidate.FontSize - best.FontSize) <= 0.12d &&
- candidate.MaxLines > best.MaxLines)
- {
- return true;
- }
-
- return false;
- }
-}
diff --git a/LanMountainDesktop/Services/IMusicControlService.cs b/LanMountainDesktop/Services/IMusicControlService.cs
index 91cbc28..307642b 100644
--- a/LanMountainDesktop/Services/IMusicControlService.cs
+++ b/LanMountainDesktop/Services/IMusicControlService.cs
@@ -1,5 +1,4 @@
using System;
-using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
@@ -29,9 +28,7 @@ public sealed record MusicPlaybackState(
MusicPlaybackStatus PlaybackStatus,
bool CanPlayPause,
bool CanSkipPrevious,
- bool CanSkipNext,
- bool CanToggleFavorite,
- bool IsFavorite)
+ bool CanSkipNext)
{
public static MusicPlaybackState Unsupported()
{
@@ -49,9 +46,7 @@ public sealed record MusicPlaybackState(
PlaybackStatus: MusicPlaybackStatus.Unknown,
CanPlayPause: false,
CanSkipPrevious: false,
- CanSkipNext: false,
- CanToggleFavorite: false,
- IsFavorite: false);
+ CanSkipNext: false);
}
public static MusicPlaybackState NoSession(bool isSupported = true)
@@ -70,35 +65,7 @@ public sealed record MusicPlaybackState(
PlaybackStatus: MusicPlaybackStatus.Unknown,
CanPlayPause: false,
CanSkipPrevious: false,
- CanSkipNext: false,
- CanToggleFavorite: false,
- IsFavorite: false);
- }
-}
-
-public sealed record MusicQueueItem(
- string Id,
- string Title,
- string Artist,
- string AlbumTitle,
- byte[]? ThumbnailBytes,
- TimeSpan Duration,
- bool IsCurrentItem);
-
-public sealed record MusicQueueState(
- bool IsSupported,
- IReadOnlyList Items,
- int CurrentIndex,
- bool HasMoreItems)
-{
- public static MusicQueueState Unsupported()
- {
- return new MusicQueueState(false, Array.Empty(), -1, false);
- }
-
- public static MusicQueueState Empty()
- {
- return new MusicQueueState(true, Array.Empty(), -1, false);
+ CanSkipNext: false);
}
}
@@ -113,18 +80,6 @@ public interface IMusicControlService
Task SkipPreviousAsync(CancellationToken cancellationToken = default);
Task LaunchSourceAppAsync(CancellationToken cancellationToken = default);
-
- Task ToggleFavoriteAsync(CancellationToken cancellationToken = default);
-
- Task GetPlaybackQueueAsync(int maxItems = 20, CancellationToken cancellationToken = default);
-
- event EventHandler? PlaybackStateChanged;
-
- event EventHandler? QueueChanged;
-
- void StartListening();
-
- void StopListening();
}
public static class MusicControlServiceFactory
@@ -163,25 +118,4 @@ internal sealed class NoOpMusicControlService : IMusicControlService
{
return Task.FromResult(false);
}
-
- public Task ToggleFavoriteAsync(CancellationToken cancellationToken = default)
- {
- return Task.FromResult(false);
- }
-
- public Task GetPlaybackQueueAsync(int maxItems = 20, CancellationToken cancellationToken = default)
- {
- return Task.FromResult(MusicQueueState.Unsupported());
- }
-
- public event EventHandler? PlaybackStateChanged;
- public event EventHandler? QueueChanged;
-
- public void StartListening()
- {
- }
-
- public void StopListening()
- {
- }
}
diff --git a/LanMountainDesktop/Services/WindowsSmtcMusicControlService.cs b/LanMountainDesktop/Services/WindowsSmtcMusicControlService.cs
index 88d5290..bdf5b54 100644
--- a/LanMountainDesktop/Services/WindowsSmtcMusicControlService.cs
+++ b/LanMountainDesktop/Services/WindowsSmtcMusicControlService.cs
@@ -1,6 +1,5 @@
-using System;
+using System;
using System.Collections.Concurrent;
-using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
@@ -10,9 +9,8 @@ using System.Threading.Tasks;
namespace LanMountainDesktop.Services;
-public sealed class WindowsSmtcMusicControlService : IMusicControlService, IDisposable
+public sealed class WindowsSmtcMusicControlService : IMusicControlService
{
- // WinRT Type Resolution
private static readonly Type? SessionManagerType = ResolveWinRtType("Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager");
private static readonly Type? AppInfoType = ResolveWinRtType("Windows.ApplicationModel.AppInfo");
private static readonly MethodInfo? RequestSessionManagerAsyncMethod =
@@ -20,250 +18,15 @@ public sealed class WindowsSmtcMusicControlService : IMusicControlService, IDisp
private static readonly MethodInfo? AsTaskGenericMethodDefinition = ResolveAsTaskGenericMethod();
private static readonly MethodInfo? AsStreamForReadMethod = ResolveAsStreamForReadMethod();
- // Synchronization
private static readonly SemaphoreSlim ManagerLock = new(1, 1);
private static object? _sessionManager;
- // Instance State
private readonly ConcurrentDictionary _sourceAppNameCache = new(StringComparer.OrdinalIgnoreCase);
private readonly SemaphoreSlim _stateGate = new(1, 1);
- private readonly object _sessionLock = new();
- // Event State
- private object? _currentSession;
- private bool _isListening;
- private readonly List _eventHandlers = new();
-
- // Thumbnail Cache
private string _thumbnailKey = string.Empty;
private byte[]? _thumbnailBytesCache;
- // Events
- public event EventHandler? PlaybackStateChanged;
- public event EventHandler? QueueChanged;
-
- public void StartListening()
- {
- if (_isListening || !IsRuntimeSupported())
- {
- return;
- }
-
- _isListening = true;
- _ = InitializeSessionManagerAsync();
- }
-
- public void StopListening()
- {
- if (!_isListening)
- {
- return;
- }
-
- _isListening = false;
- UnsubscribeFromSessionEvents();
- }
-
- private async Task InitializeSessionManagerAsync()
- {
- try
- {
- var manager = await GetSessionManagerAsync(CancellationToken.None);
- if (manager is null)
- {
- return;
- }
-
- // Subscribe to CurrentSessionChanged event
- var currentSessionChangedEvent = SessionManagerType?.GetEvent("CurrentSessionChanged");
- if (currentSessionChangedEvent is not null)
- {
- var handler = CreateTypedEventHandler(
- currentSessionChangedEvent.EventHandlerType,
- OnCurrentSessionChanged);
- currentSessionChangedEvent.AddEventHandler(manager, handler);
- _eventHandlers.Add(handler);
- }
-
- // Get initial session and subscribe to its events
- await UpdateCurrentSessionAsync();
- }
- catch (Exception ex)
- {
- AppLogger.Warn("MusicControl", "Failed to initialize SMTC session manager", ex);
- }
- }
-
- private async Task OnCurrentSessionChanged(object? sender, object? args)
- {
- await UpdateCurrentSessionAsync();
- await RaisePlaybackStateChangedAsync();
- }
-
- private async Task UpdateCurrentSessionAsync()
- {
- lock (_sessionLock)
- {
- UnsubscribeFromSessionEvents();
- _currentSession = null;
- }
-
- var session = await GetCurrentSessionAsync(CancellationToken.None);
- if (session is null)
- {
- return;
- }
-
- lock (_sessionLock)
- {
- _currentSession = session;
- SubscribeToSessionEvents(session);
- }
- }
-
- private void SubscribeToSessionEvents(object session)
- {
- if (!_isListening)
- {
- return;
- }
-
- try
- {
- // MediaPropertiesChanged event
- var mediaPropertiesChanged = session.GetType().GetEvent("MediaPropertiesChanged");
- if (mediaPropertiesChanged is not null)
- {
- var handler = CreateTypedEventHandler(
- mediaPropertiesChanged.EventHandlerType,
- async (s, e) => await RaisePlaybackStateChangedAsync());
- mediaPropertiesChanged.AddEventHandler(session, handler);
- _eventHandlers.Add(handler);
- }
-
- // PlaybackInfoChanged event
- var playbackInfoChanged = session.GetType().GetEvent("PlaybackInfoChanged");
- if (playbackInfoChanged is not null)
- {
- var handler = CreateTypedEventHandler(
- playbackInfoChanged.EventHandlerType,
- async (s, e) => await RaisePlaybackStateChangedAsync());
- playbackInfoChanged.AddEventHandler(session, handler);
- _eventHandlers.Add(handler);
- }
-
- // TimelinePropertiesChanged event
- var timelinePropertiesChanged = session.GetType().GetEvent("TimelinePropertiesChanged");
- if (timelinePropertiesChanged is not null)
- {
- var handler = CreateTypedEventHandler(
- timelinePropertiesChanged.EventHandlerType,
- async (s, e) => await RaisePlaybackStateChangedAsync());
- timelinePropertiesChanged.AddEventHandler(session, handler);
- _eventHandlers.Add(handler);
- }
- }
- catch (Exception ex)
- {
- AppLogger.Warn("MusicControl", "Failed to subscribe to session events", ex);
- }
- }
-
- private void UnsubscribeFromSessionEvents()
- {
- if (_currentSession is null)
- {
- return;
- }
-
- try
- {
- var sessionType = _currentSession.GetType();
-
- // Remove MediaPropertiesChanged
- var mediaPropertiesChanged = sessionType.GetEvent("MediaPropertiesChanged");
- if (mediaPropertiesChanged is not null)
- {
- foreach (var handler in _eventHandlers)
- {
- try
- {
- mediaPropertiesChanged.RemoveEventHandler(_currentSession, handler);
- }
- catch { }
- }
- }
-
- // Remove PlaybackInfoChanged
- var playbackInfoChanged = sessionType.GetEvent("PlaybackInfoChanged");
- if (playbackInfoChanged is not null)
- {
- foreach (var handler in _eventHandlers)
- {
- try
- {
- playbackInfoChanged.RemoveEventHandler(_currentSession, handler);
- }
- catch { }
- }
- }
-
- // Remove TimelinePropertiesChanged
- var timelinePropertiesChanged = sessionType.GetEvent("TimelinePropertiesChanged");
- if (timelinePropertiesChanged is not null)
- {
- foreach (var handler in _eventHandlers)
- {
- try
- {
- timelinePropertiesChanged.RemoveEventHandler(_currentSession, handler);
- }
- catch { }
- }
- }
- }
- catch { }
-
- _eventHandlers.Clear();
- }
-
- private Delegate CreateTypedEventHandler(Type eventHandlerType, Func