This commit is contained in:
lincube
2026-03-20 10:22:40 +08:00
parent 915739ff7b
commit aeae4be060
40 changed files with 1666 additions and 571 deletions

View File

@@ -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<MusicQueueItem> Items,
int CurrentIndex,
bool HasMoreItems)
{
public static MusicQueueState Unsupported()
{
return new MusicQueueState(false, Array.Empty<MusicQueueItem>(), -1, false);
}
public static MusicQueueState Empty()
{
return new MusicQueueState(true, Array.Empty<MusicQueueItem>(), -1, false);
}
}
@@ -80,6 +113,18 @@ public interface IMusicControlService
Task<bool> SkipPreviousAsync(CancellationToken cancellationToken = default);
Task<bool> LaunchSourceAppAsync(CancellationToken cancellationToken = default);
Task<bool> ToggleFavoriteAsync(CancellationToken cancellationToken = default);
Task<MusicQueueState> GetPlaybackQueueAsync(int maxItems = 20, CancellationToken cancellationToken = default);
event EventHandler<MusicPlaybackState>? PlaybackStateChanged;
event EventHandler<MusicQueueState>? QueueChanged;
void StartListening();
void StopListening();
}
public static class MusicControlServiceFactory
@@ -118,4 +163,25 @@ internal sealed class NoOpMusicControlService : IMusicControlService
{
return Task.FromResult(false);
}
public Task<bool> ToggleFavoriteAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult(false);
}
public Task<MusicQueueState> GetPlaybackQueueAsync(int maxItems = 20, CancellationToken cancellationToken = default)
{
return Task.FromResult(MusicQueueState.Unsupported());
}
public event EventHandler<MusicPlaybackState>? PlaybackStateChanged;
public event EventHandler<MusicQueueState>? QueueChanged;
public void StartListening()
{
}
public void StopListening()
{
}
}

View File

@@ -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<string, string> _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<Delegate> _eventHandlers = new();
// Thumbnail Cache
private string _thumbnailKey = string.Empty;
private byte[]? _thumbnailBytesCache;
// Events
public event EventHandler<MusicPlaybackState>? PlaybackStateChanged;
public event EventHandler<MusicQueueState>? 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<object?, object?, Task> asyncAction)
{
// Create a delegate that wraps the async action
var handler = new EventHandler<object>((sender, args) =>
{
_ = asyncAction(sender, args);
});
return handler;
}
private async Task RaisePlaybackStateChangedAsync()
{
try
{
var state = await GetCurrentStateAsync(CancellationToken.None);
PlaybackStateChanged?.Invoke(this, state);
}
catch (Exception ex)
{
AppLogger.Warn("MusicControl", "Failed to raise playback state changed event", ex);
}
}
private async Task RaiseQueueChangedAsync()
{
try
{
var queue = await GetPlaybackQueueAsync(20, CancellationToken.None);
QueueChanged?.Invoke(this, queue);
}
catch (Exception ex)
{
AppLogger.Warn("MusicControl", "Failed to raise queue changed event", ex);
}
}
public async Task<MusicPlaybackState> GetCurrentStateAsync(CancellationToken cancellationToken = default)
{
if (!IsRuntimeSupported())
@@ -56,10 +293,17 @@ public sealed class WindowsSmtcMusicControlService : IMusicControlService
var canSkipNext = ReadBoolProperty(controls, "IsNextEnabled");
var canSkipPrevious = ReadBoolProperty(controls, "IsPreviousEnabled");
// Check for AutoRepeatModeChange and ShuffleEnabledChange support (indicates advanced SMTC)
var canToggleFavorite = ReadBoolProperty(controls, "IsChannelDownEnabled") || ReadBoolProperty(controls, "IsChannelUpEnabled");
// Try to get IsFavorite from mediaProperties (some apps support this)
var isFavorite = ReadBoolProperty(mediaProperties, "IsFavorite");
var sourceAppId = ReadStringProperty(session, "SourceAppUserModelId");
var sourceAppName = await ResolveSourceAppDisplayNameAsync(sourceAppId, cancellationToken);
var timeline = InvokeMethod(session, "GetTimelineProperties");
// Use async method to get timeline properties
var timeline = await TryGetTimelinePropertiesAsync(session, cancellationToken);
var position = ReadTimeSpanProperty(timeline, "Position");
var start = ReadTimeSpanProperty(timeline, "StartTime");
var end = ReadTimeSpanProperty(timeline, "EndTime");
@@ -103,7 +347,9 @@ public sealed class WindowsSmtcMusicControlService : IMusicControlService
PlaybackStatus: MapPlaybackStatus(playbackStatusRaw),
CanPlayPause: canPlayPause,
CanSkipPrevious: canSkipPrevious,
CanSkipNext: canSkipNext);
CanSkipNext: canSkipNext,
CanToggleFavorite: canToggleFavorite,
IsFavorite: isFavorite);
}
catch
{
@@ -199,6 +445,113 @@ public sealed class WindowsSmtcMusicControlService : IMusicControlService
return await AwaitBooleanWinRtOperationAsync(InvokeMethod(session, "TrySkipPreviousAsync"), cancellationToken);
}
public async Task<bool> ToggleFavoriteAsync(CancellationToken cancellationToken = default)
{
if (!IsRuntimeSupported())
{
return false;
}
var session = await GetCurrentSessionAsync(cancellationToken);
if (session is null)
{
return false;
}
// Try to toggle favorite using RateAndReview (some apps support this)
try
{
var playbackInfo = GetPropertyValue(session, "PlaybackInfo") ?? InvokeMethod(session, "GetPlaybackInfo");
var controls = GetPropertyValue(playbackInfo, "Controls");
// Check if RateAndReview is supported
if (ReadBoolProperty(controls, "IsRateEnabled"))
{
var operation = InvokeMethod(session, "TryRateAsync");
return await AwaitBooleanWinRtOperationAsync(operation, cancellationToken);
}
// Fallback: Try ChannelUp/ChannelDown as favorite toggle
if (ReadBoolProperty(controls, "IsChannelUpEnabled"))
{
var operation = InvokeMethod(session, "TryChannelUpAsync");
return await AwaitBooleanWinRtOperationAsync(operation, cancellationToken);
}
}
catch { }
return false;
}
public async Task<MusicQueueState> GetPlaybackQueueAsync(int maxItems = 20, CancellationToken cancellationToken = default)
{
if (!IsRuntimeSupported())
{
return MusicQueueState.Unsupported();
}
var session = await GetCurrentSessionAsync(cancellationToken);
if (session is null)
{
return MusicQueueState.Empty();
}
try
{
// Try to get playback queue using GetPlaybackInfo
var playbackInfo = GetPropertyValue(session, "PlaybackInfo") ?? InvokeMethod(session, "GetPlaybackInfo");
// Check if shuffle/repeat controls exist (indicates queue support)
var controls = GetPropertyValue(playbackInfo, "Controls");
var canShuffle = ReadBoolProperty(controls, "IsShuffleEnabled");
var canRepeat = ReadBoolProperty(controls, "IsRepeatEnabled");
// Since SMTC doesn't expose the actual queue directly, we'll return a simplified state
// indicating whether queue navigation is supported
var items = new List<MusicQueueItem>();
// Try to get current media properties as the current item
var mediaProperties = await TryGetMediaPropertiesAsync(session, cancellationToken);
if (mediaProperties is not null)
{
var title = ReadStringProperty(mediaProperties, "Title");
var artist = ReadStringProperty(mediaProperties, "Artist");
var albumTitle = ReadStringProperty(mediaProperties, "AlbumTitle");
var thumbnailBytes = await ResolveThumbnailBytesAsync(
mediaProperties,
ReadStringProperty(session, "SourceAppUserModelId"),
title, artist, albumTitle,
cancellationToken);
// Get duration
var timeline = await TryGetTimelinePropertiesAsync(session, cancellationToken);
var duration = ReadTimeSpanProperty(timeline, "EndTime") - ReadTimeSpanProperty(timeline, "StartTime");
items.Add(new MusicQueueItem(
Id: "current",
Title: title,
Artist: artist,
AlbumTitle: albumTitle,
ThumbnailBytes: thumbnailBytes,
Duration: duration > TimeSpan.Zero ? duration : TimeSpan.Zero,
IsCurrentItem: true));
}
// If shuffle or repeat is supported, we assume there's a queue
var hasMoreItems = canShuffle || canRepeat || ReadBoolProperty(controls, "IsNextEnabled");
return new MusicQueueState(
IsSupported: true,
Items: items,
CurrentIndex: 0,
HasMoreItems: hasMoreItems);
}
catch
{
return MusicQueueState.Empty();
}
}
public async Task<bool> LaunchSourceAppAsync(CancellationToken cancellationToken = default)
{
if (!IsRuntimeSupported())
@@ -259,6 +612,20 @@ public sealed class WindowsSmtcMusicControlService : IMusicControlService
return await AwaitWinRtOperationAsync(operation, cancellationToken);
}
private async Task<object?> TryGetTimelinePropertiesAsync(object session, CancellationToken cancellationToken)
{
// Use the async method TryGetTimelinePropertiesAsync if available
var tryGetTimelineMethod = session.GetType().GetMethod("TryGetTimelinePropertiesAsync");
if (tryGetTimelineMethod is not null)
{
var operation = tryGetTimelineMethod.Invoke(session, null);
return await AwaitWinRtOperationAsync(operation, cancellationToken);
}
// Fallback to synchronous method
return InvokeMethod(session, "GetTimelineProperties");
}
private async Task<byte[]?> ResolveThumbnailBytesAsync(
object? mediaProperties,
string sourceAppId,
@@ -576,4 +943,11 @@ public sealed class WindowsSmtcMusicControlService : IMusicControlService
_ => MusicPlaybackStatus.Unknown
};
}
public void Dispose()
{
StopListening();
_stateGate.Dispose();
ManagerLock.Dispose();
}
}