diff --git a/Directory.Build.props b/Directory.Build.props
index 327b321..3a012b7 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -4,5 +4,6 @@
net10.0
enable
enable
+ $(DefaultItemExcludes);**\obj_audit\**
diff --git a/LanMountainDesktop.DesktopComponents.Runtime/ComponentTypographyLayoutModels.cs b/LanMountainDesktop.DesktopComponents.Runtime/ComponentTypographyLayoutModels.cs
new file mode 100644
index 0000000..e9acce4
--- /dev/null
+++ b/LanMountainDesktop.DesktopComponents.Runtime/ComponentTypographyLayoutModels.cs
@@ -0,0 +1,30 @@
+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
new file mode 100644
index 0000000..a8da07a
--- /dev/null
+++ b/LanMountainDesktop.DesktopComponents.Runtime/ComponentTypographyLayoutService.cs
@@ -0,0 +1,314 @@
+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 307642b..91cbc28 100644
--- a/LanMountainDesktop/Services/IMusicControlService.cs
+++ b/LanMountainDesktop/Services/IMusicControlService.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
@@ -28,7 +29,9 @@ public sealed record MusicPlaybackState(
MusicPlaybackStatus PlaybackStatus,
bool CanPlayPause,
bool CanSkipPrevious,
- bool CanSkipNext)
+ bool CanSkipNext,
+ bool CanToggleFavorite,
+ bool IsFavorite)
{
public static MusicPlaybackState Unsupported()
{
@@ -46,7 +49,9 @@ public sealed record MusicPlaybackState(
PlaybackStatus: MusicPlaybackStatus.Unknown,
CanPlayPause: false,
CanSkipPrevious: false,
- CanSkipNext: false);
+ CanSkipNext: false,
+ CanToggleFavorite: false,
+ IsFavorite: false);
}
public static MusicPlaybackState NoSession(bool isSupported = true)
@@ -65,7 +70,35 @@ public sealed record MusicPlaybackState(
PlaybackStatus: MusicPlaybackStatus.Unknown,
CanPlayPause: false,
CanSkipPrevious: false,
- CanSkipNext: 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);
}
}
@@ -80,6 +113,18 @@ 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
@@ -118,4 +163,25 @@ 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 bdf5b54..88d5290 100644
--- a/LanMountainDesktop/Services/WindowsSmtcMusicControlService.cs
+++ b/LanMountainDesktop/Services/WindowsSmtcMusicControlService.cs
@@ -1,5 +1,6 @@
-using System;
+using System;
using System.Collections.Concurrent;
+using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
@@ -9,8 +10,9 @@ using System.Threading.Tasks;
namespace LanMountainDesktop.Services;
-public sealed class WindowsSmtcMusicControlService : IMusicControlService
+public sealed class WindowsSmtcMusicControlService : IMusicControlService, IDisposable
{
+ // 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 =
@@ -18,15 +20,250 @@ public sealed class WindowsSmtcMusicControlService : IMusicControlService
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