mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
fix.消息盒子媒体播放器组件服务修复
This commit is contained in:
125
LanMountainDesktop.Tests/MusicControlServiceTests.cs
Normal file
125
LanMountainDesktop.Tests/MusicControlServiceTests.cs
Normal file
@@ -0,0 +1,125 @@
|
||||
using LanMountainDesktop.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class MusicControlServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void SelectCurrentSession_PrefersPlayingSession()
|
||||
{
|
||||
var olderPlaying = CreateState("playing", MusicPlaybackStatus.Playing, DateTimeOffset.UtcNow.AddMinutes(-10));
|
||||
var newerPaused = CreateState("paused", MusicPlaybackStatus.Paused, DateTimeOffset.UtcNow);
|
||||
|
||||
var selected = MusicControlService.SelectCurrentSession([newerPaused, olderPlaying], MusicPlatform.Windows);
|
||||
|
||||
Assert.Equal("playing", selected.SessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectCurrentSession_UsesMostRecentWhenNothingPlaying()
|
||||
{
|
||||
var older = CreateState("older", MusicPlaybackStatus.Paused, DateTimeOffset.UtcNow.AddMinutes(-10));
|
||||
var newer = CreateState("newer", MusicPlaybackStatus.Stopped, DateTimeOffset.UtcNow);
|
||||
|
||||
var selected = MusicControlService.SelectCurrentSession([older, newer], MusicPlatform.Linux);
|
||||
|
||||
Assert.Equal("newer", selected.SessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseMetadata_MapsCommonMprisFields()
|
||||
{
|
||||
const string metadata = """
|
||||
array [
|
||||
dict entry(
|
||||
string "xesam:title"
|
||||
variant string "Song Title"
|
||||
)
|
||||
dict entry(
|
||||
string "xesam:artist"
|
||||
variant array [
|
||||
string "Artist A"
|
||||
string "Artist B"
|
||||
]
|
||||
)
|
||||
dict entry(
|
||||
string "xesam:album"
|
||||
variant string "Album"
|
||||
)
|
||||
dict entry(
|
||||
string "mpris:length"
|
||||
variant int64 185000000
|
||||
)
|
||||
]
|
||||
""";
|
||||
|
||||
var parsed = LinuxMprisMusicSessionProvider.ParseMetadata(metadata);
|
||||
|
||||
Assert.Equal("Song Title", parsed["xesam:title"]);
|
||||
Assert.Equal("Artist A, Artist B", parsed["xesam:artist"]);
|
||||
Assert.Equal("Album", parsed["xesam:album"]);
|
||||
Assert.Equal("185000000", parsed["mpris:length"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapMprisSession_ConvertsStatusCapabilitiesAndDuration()
|
||||
{
|
||||
const string metadata = """
|
||||
dict entry(
|
||||
string "xesam:title"
|
||||
variant string "Track"
|
||||
)
|
||||
dict entry(
|
||||
string "mpris:length"
|
||||
variant int64 120000000
|
||||
)
|
||||
""";
|
||||
|
||||
var state = LinuxMprisMusicSessionProvider.MapMprisSession(
|
||||
"org.mpris.MediaPlayer2.spotify",
|
||||
"Spotify",
|
||||
"Playing",
|
||||
metadata,
|
||||
positionMicroseconds: 30_000_000,
|
||||
canPlay: true,
|
||||
canPause: true,
|
||||
canGoNext: true,
|
||||
canGoPrevious: false,
|
||||
canControl: true,
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.True(state.HasSession);
|
||||
Assert.Equal(MusicPlatform.Linux, state.Platform);
|
||||
Assert.Equal(MusicPlaybackStatus.Playing, state.PlaybackStatus);
|
||||
Assert.Equal(TimeSpan.FromSeconds(30), state.Position);
|
||||
Assert.Equal(TimeSpan.FromSeconds(120), state.Duration);
|
||||
Assert.True(state.CanPlayPause);
|
||||
Assert.True(state.CanSkipNext);
|
||||
Assert.False(state.CanSkipPrevious);
|
||||
}
|
||||
|
||||
private static MusicPlaybackState CreateState(string sessionId, MusicPlaybackStatus status, DateTimeOffset updatedAt)
|
||||
=> new(
|
||||
IsSupported: true,
|
||||
HasSession: true,
|
||||
Platform: MusicPlatform.Windows,
|
||||
SessionId: sessionId,
|
||||
SourceAppId: sessionId,
|
||||
SourceAppName: sessionId,
|
||||
SourceExecutableOrBusName: sessionId,
|
||||
Title: sessionId,
|
||||
Artist: string.Empty,
|
||||
AlbumTitle: string.Empty,
|
||||
ThumbnailBytes: null,
|
||||
Position: TimeSpan.Zero,
|
||||
Duration: TimeSpan.Zero,
|
||||
PlaybackStatus: status,
|
||||
CanPlayPause: true,
|
||||
CanSkipPrevious: true,
|
||||
CanSkipNext: true,
|
||||
CanLaunch: true,
|
||||
IsStale: false,
|
||||
StatusMessage: string.Empty,
|
||||
UpdatedAtUtc: updatedAt);
|
||||
}
|
||||
@@ -133,7 +133,7 @@
|
||||
"settings.privacy.policy_hint_prefix": "For more details, please ",
|
||||
"settings.privacy.view_policy": "view our privacy policy",
|
||||
"settings.weather.title": "Weather",
|
||||
"settings.weather.description": "Configure weather location, Xiaomi weather preview, and startup positioning behavior.",
|
||||
"settings.weather.description": "Configure weather location, weather preview, and startup positioning behavior.",
|
||||
"settings.weather.location_source_header": "Location Source",
|
||||
"settings.weather.location_source_desc": "Choose how weather widgets resolve location.",
|
||||
"settings.weather.mode_city_search": "City Search",
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
"settings.privacy.policy_hint_prefix": "詳細については、",
|
||||
"settings.privacy.view_policy": "プライバシーポリシーをご覧ください",
|
||||
"settings.weather.title": "天気",
|
||||
"settings.weather.description": "天気の場所、Xiaomi天気プレビュー、起動時の位置情報取得動作を設定します。",
|
||||
"settings.weather.description": "天気の場所、天気プレビュー、起動時の位置情報取得動作を設定します。",
|
||||
"settings.weather.location_source_header": "位置情報ソース",
|
||||
"settings.weather.location_source_desc": "天気ウィジェットが場所を解決する方法を選択します。",
|
||||
"settings.weather.mode_city_search": "都市検索",
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
"settings.privacy.policy_hint_prefix": "자세한 내용은",
|
||||
"settings.privacy.view_policy": "개인정보 처리방침 보기",
|
||||
"settings.weather.title": "날씨",
|
||||
"settings.weather.description": "날씨 위치, Xiaomi 날씨 미리보기 및 시작 시 위치 새로고침 동작을 구성합니다.",
|
||||
"settings.weather.description": "날씨 위치, 날씨 미리보기 및 시작 시 위치 새로고침 동작을 구성합니다.",
|
||||
"settings.weather.location_source_header": "위치 소스",
|
||||
"settings.weather.location_source_desc": "날씨 컴포넌트가 현재 위치를 해석하는 방법을 선택합니다.",
|
||||
"settings.weather.mode_city_search": "도시 검색",
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
"settings.privacy.policy_hint_prefix": "了解更多详情,请",
|
||||
"settings.privacy.view_policy": "查看我们的隐私政策",
|
||||
"settings.weather.title": "天气",
|
||||
"settings.weather.description": "配置天气位置、小米天气预览和启动时的位置刷新行为。",
|
||||
"settings.weather.description": "配置天气位置、天气预览和启动时的位置刷新行为。",
|
||||
"settings.weather.location_source_header": "位置来源",
|
||||
"settings.weather.location_source_desc": "选择天气组件如何解析当前位置。",
|
||||
"settings.weather.mode_city_search": "城市搜索",
|
||||
|
||||
@@ -69,7 +69,7 @@ public sealed class AppSettingsSnapshot
|
||||
|
||||
public string WeatherExcludedAlerts { get; set; } = string.Empty;
|
||||
|
||||
public string WeatherIconPackId { get; set; } = "HyperOS3";
|
||||
public string WeatherIconPackId { get; set; } = "DefaultWeather";
|
||||
|
||||
public bool WeatherNoTlsRequests { get; set; }
|
||||
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public enum MusicPlatform
|
||||
{
|
||||
Unknown = 0,
|
||||
Windows = 1,
|
||||
Linux = 2
|
||||
}
|
||||
|
||||
public enum MusicPlaybackStatus
|
||||
{
|
||||
Unknown = 0,
|
||||
@@ -17,8 +27,11 @@ public enum MusicPlaybackStatus
|
||||
public sealed record MusicPlaybackState(
|
||||
bool IsSupported,
|
||||
bool HasSession,
|
||||
MusicPlatform Platform,
|
||||
string SessionId,
|
||||
string SourceAppId,
|
||||
string SourceAppName,
|
||||
string SourceExecutableOrBusName,
|
||||
string Title,
|
||||
string Artist,
|
||||
string AlbumTitle,
|
||||
@@ -28,15 +41,22 @@ public sealed record MusicPlaybackState(
|
||||
MusicPlaybackStatus PlaybackStatus,
|
||||
bool CanPlayPause,
|
||||
bool CanSkipPrevious,
|
||||
bool CanSkipNext)
|
||||
bool CanSkipNext,
|
||||
bool CanLaunch,
|
||||
bool IsStale,
|
||||
string StatusMessage,
|
||||
DateTimeOffset UpdatedAtUtc)
|
||||
{
|
||||
public static MusicPlaybackState Unsupported()
|
||||
public static MusicPlaybackState Unsupported(string statusMessage = "Music control is not supported on this platform.")
|
||||
{
|
||||
return new MusicPlaybackState(
|
||||
IsSupported: false,
|
||||
HasSession: false,
|
||||
Platform: MusicPlatform.Unknown,
|
||||
SessionId: string.Empty,
|
||||
SourceAppId: string.Empty,
|
||||
SourceAppName: string.Empty,
|
||||
SourceExecutableOrBusName: string.Empty,
|
||||
Title: string.Empty,
|
||||
Artist: string.Empty,
|
||||
AlbumTitle: string.Empty,
|
||||
@@ -46,16 +66,26 @@ public sealed record MusicPlaybackState(
|
||||
PlaybackStatus: MusicPlaybackStatus.Unknown,
|
||||
CanPlayPause: false,
|
||||
CanSkipPrevious: false,
|
||||
CanSkipNext: false);
|
||||
CanSkipNext: false,
|
||||
CanLaunch: false,
|
||||
IsStale: false,
|
||||
StatusMessage: statusMessage,
|
||||
UpdatedAtUtc: DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
public static MusicPlaybackState NoSession(bool isSupported = true)
|
||||
public static MusicPlaybackState NoSession(
|
||||
bool isSupported = true,
|
||||
MusicPlatform platform = MusicPlatform.Unknown,
|
||||
string statusMessage = "No active media session.")
|
||||
{
|
||||
return new MusicPlaybackState(
|
||||
IsSupported: isSupported,
|
||||
HasSession: false,
|
||||
Platform: platform,
|
||||
SessionId: string.Empty,
|
||||
SourceAppId: string.Empty,
|
||||
SourceAppName: string.Empty,
|
||||
SourceExecutableOrBusName: string.Empty,
|
||||
Title: string.Empty,
|
||||
Artist: string.Empty,
|
||||
AlbumTitle: string.Empty,
|
||||
@@ -65,12 +95,35 @@ public sealed record MusicPlaybackState(
|
||||
PlaybackStatus: MusicPlaybackStatus.Unknown,
|
||||
CanPlayPause: false,
|
||||
CanSkipPrevious: false,
|
||||
CanSkipNext: false);
|
||||
CanSkipNext: false,
|
||||
CanLaunch: false,
|
||||
IsStale: false,
|
||||
StatusMessage: statusMessage,
|
||||
UpdatedAtUtc: DateTimeOffset.UtcNow);
|
||||
}
|
||||
}
|
||||
|
||||
public interface IMusicSessionProvider : IDisposable
|
||||
{
|
||||
MusicPlatform Platform { get; }
|
||||
|
||||
event EventHandler? SessionsChanged;
|
||||
|
||||
Task<IReadOnlyList<MusicPlaybackState>> GetSessionsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> TogglePlayPauseAsync(string sessionId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> SkipNextAsync(string sessionId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> SkipPreviousAsync(string sessionId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> LaunchSourceAppAsync(string sessionId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IMusicControlService
|
||||
{
|
||||
event EventHandler? StateChanged;
|
||||
|
||||
Task<MusicPlaybackState> GetCurrentStateAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> TogglePlayPauseAsync(CancellationToken cancellationToken = default);
|
||||
@@ -82,40 +135,116 @@ public interface IMusicControlService
|
||||
Task<bool> LaunchSourceAppAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class MusicControlService : IMusicControlService, IDisposable
|
||||
{
|
||||
private readonly IMusicSessionProvider _provider;
|
||||
private MusicPlaybackState _currentState = MusicPlaybackState.NoSession();
|
||||
|
||||
public MusicControlService(IMusicSessionProvider provider)
|
||||
{
|
||||
_provider = provider;
|
||||
_provider.SessionsChanged += OnProviderSessionsChanged;
|
||||
}
|
||||
|
||||
public event EventHandler? StateChanged;
|
||||
|
||||
public async Task<MusicPlaybackState> GetCurrentStateAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sessions = await _provider.GetSessionsAsync(cancellationToken).ConfigureAwait(false);
|
||||
_currentState = SelectCurrentSession(sessions, _provider.Platform);
|
||||
return _currentState;
|
||||
}
|
||||
|
||||
public Task<bool> TogglePlayPauseAsync(CancellationToken cancellationToken = default)
|
||||
=> ExecuteOnCurrentSessionAsync((sessionId, token) => _provider.TogglePlayPauseAsync(sessionId, token), cancellationToken);
|
||||
|
||||
public Task<bool> SkipNextAsync(CancellationToken cancellationToken = default)
|
||||
=> ExecuteOnCurrentSessionAsync((sessionId, token) => _provider.SkipNextAsync(sessionId, token), cancellationToken);
|
||||
|
||||
public Task<bool> SkipPreviousAsync(CancellationToken cancellationToken = default)
|
||||
=> ExecuteOnCurrentSessionAsync((sessionId, token) => _provider.SkipPreviousAsync(sessionId, token), cancellationToken);
|
||||
|
||||
public Task<bool> LaunchSourceAppAsync(CancellationToken cancellationToken = default)
|
||||
=> ExecuteOnCurrentSessionAsync((sessionId, token) => _provider.LaunchSourceAppAsync(sessionId, token), cancellationToken);
|
||||
|
||||
internal static MusicPlaybackState SelectCurrentSession(IReadOnlyList<MusicPlaybackState> sessions, MusicPlatform platform)
|
||||
{
|
||||
if (sessions.Count == 0)
|
||||
{
|
||||
return MusicPlaybackState.NoSession(isSupported: true, platform: platform);
|
||||
}
|
||||
|
||||
return sessions
|
||||
.OrderByDescending(session => session.PlaybackStatus == MusicPlaybackStatus.Playing)
|
||||
.ThenByDescending(session => session.UpdatedAtUtc)
|
||||
.First();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_provider.SessionsChanged -= OnProviderSessionsChanged;
|
||||
_provider.Dispose();
|
||||
}
|
||||
|
||||
private async Task<bool> ExecuteOnCurrentSessionAsync(
|
||||
Func<string, CancellationToken, Task<bool>> command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var state = _currentState.HasSession
|
||||
? _currentState
|
||||
: await GetCurrentStateAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!state.IsSupported || !state.HasSession || string.IsNullOrWhiteSpace(state.SessionId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return await command(state.SessionId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private void OnProviderSessionsChanged(object? sender, EventArgs e)
|
||||
=> StateChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
public static class MusicControlServiceFactory
|
||||
{
|
||||
public static IMusicControlService CreateDefault()
|
||||
{
|
||||
return OperatingSystem.IsWindows()
|
||||
? new WindowsSmtcMusicControlService()
|
||||
: new NoOpMusicControlService();
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
return new MusicControlService(new WindowsSmtcMusicControlService());
|
||||
}
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
{
|
||||
return new MusicControlService(new LinuxMprisMusicSessionProvider());
|
||||
}
|
||||
|
||||
return new MusicControlService(new NoOpMusicSessionProvider());
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class NoOpMusicControlService : IMusicControlService
|
||||
internal sealed class NoOpMusicSessionProvider : IMusicSessionProvider
|
||||
{
|
||||
public Task<MusicPlaybackState> GetCurrentStateAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(MusicPlaybackState.Unsupported());
|
||||
}
|
||||
public MusicPlatform Platform => MusicPlatform.Unknown;
|
||||
|
||||
public Task<bool> TogglePlayPauseAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
public event EventHandler? SessionsChanged;
|
||||
|
||||
public Task<bool> SkipNextAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
public Task<IReadOnlyList<MusicPlaybackState>> GetSessionsAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<MusicPlaybackState>>([MusicPlaybackState.Unsupported()]);
|
||||
|
||||
public Task<bool> SkipPreviousAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
public Task<bool> TogglePlayPauseAsync(string sessionId, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(false);
|
||||
|
||||
public Task<bool> LaunchSourceAppAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
public Task<bool> SkipNextAsync(string sessionId, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(false);
|
||||
|
||||
public Task<bool> SkipPreviousAsync(string sessionId, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(false);
|
||||
|
||||
public Task<bool> LaunchSourceAppAsync(string sessionId, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(false);
|
||||
|
||||
public void Dispose()
|
||||
=> SessionsChanged = null;
|
||||
}
|
||||
|
||||
477
LanMountainDesktop/Services/LinuxMprisMusicSessionProvider.cs
Normal file
477
LanMountainDesktop/Services/LinuxMprisMusicSessionProvider.cs
Normal file
@@ -0,0 +1,477 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Tmds.DBus.Protocol;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
internal sealed class LinuxMprisMusicSessionProvider : IMusicSessionProvider
|
||||
{
|
||||
private const string MprisPrefix = "org.mpris.MediaPlayer2.";
|
||||
private static readonly Regex StringValueRegex = new("\"(?<value>(?:\\\\.|[^\"])*)\"", RegexOptions.Compiled);
|
||||
private static readonly Regex Int64ValueRegex = new(@"int64\s+(?<value>-?\d+)", RegexOptions.Compiled);
|
||||
private static readonly Regex BooleanValueRegex = new(@"boolean\s+(?<value>true|false)", RegexOptions.Compiled);
|
||||
private static readonly Regex ArrayStringRegex = new(@"string\s+""(?<value>(?:\\.|[^""])*)""", RegexOptions.Compiled);
|
||||
|
||||
private readonly CancellationTokenSource _disposeCts = new();
|
||||
private readonly Dictionary<string, DateTimeOffset> _lastSeen = new(StringComparer.Ordinal);
|
||||
|
||||
private IDisposable? _nameOwnerChangedWatcher;
|
||||
|
||||
public MusicPlatform Platform => MusicPlatform.Linux;
|
||||
|
||||
public event EventHandler? SessionsChanged;
|
||||
|
||||
public async Task<IReadOnlyList<MusicPlaybackState>> GetSessionsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!OperatingSystem.IsLinux())
|
||||
{
|
||||
return [MusicPlaybackState.Unsupported("Linux MPRIS is only available on Linux.")];
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("DBUS_SESSION_BUS_ADDRESS")))
|
||||
{
|
||||
return [MusicPlaybackState.Unsupported("DBUS_SESSION_BUS_ADDRESS is not set; MPRIS cannot be reached.")];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await EnsureSignalWatchAsync(cancellationToken).ConfigureAwait(false);
|
||||
var names = await ListMprisNamesAsync(cancellationToken).ConfigureAwait(false);
|
||||
var sessions = new List<MusicPlaybackState>();
|
||||
foreach (var name in names)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var session = await ReadSessionAsync(name, cancellationToken).ConfigureAwait(false);
|
||||
if (session is not null)
|
||||
{
|
||||
sessions.Add(session);
|
||||
}
|
||||
}
|
||||
|
||||
return sessions;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
return [MusicPlaybackState.Unsupported($"Linux MPRIS read failed: {ex.Message}")];
|
||||
}
|
||||
}
|
||||
|
||||
public Task<bool> TogglePlayPauseAsync(string sessionId, CancellationToken cancellationToken = default)
|
||||
=> CallPlayerMethodAsync(sessionId, "PlayPause", cancellationToken);
|
||||
|
||||
public Task<bool> SkipNextAsync(string sessionId, CancellationToken cancellationToken = default)
|
||||
=> CallPlayerMethodAsync(sessionId, "Next", cancellationToken);
|
||||
|
||||
public Task<bool> SkipPreviousAsync(string sessionId, CancellationToken cancellationToken = default)
|
||||
=> CallPlayerMethodAsync(sessionId, "Previous", cancellationToken);
|
||||
|
||||
public async Task<bool> LaunchSourceAppAsync(string sessionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (await CallRootMethodAsync(sessionId, "Raise", cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var desktopEntry = sessionId.StartsWith(MprisPrefix, StringComparison.Ordinal)
|
||||
? sessionId[MprisPrefix.Length..].Split('.', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()
|
||||
: sessionId;
|
||||
return !string.IsNullOrWhiteSpace(desktopEntry) && TryLaunchDesktopEntry(desktopEntry);
|
||||
}
|
||||
|
||||
internal static MusicPlaybackState MapMprisSession(
|
||||
string busName,
|
||||
string identity,
|
||||
string playbackStatus,
|
||||
string metadataText,
|
||||
long positionMicroseconds,
|
||||
bool canPlay,
|
||||
bool canPause,
|
||||
bool canGoNext,
|
||||
bool canGoPrevious,
|
||||
bool canControl,
|
||||
DateTimeOffset lastSeen)
|
||||
{
|
||||
var metadata = ParseMetadata(metadataText);
|
||||
var title = metadata.TryGetValue("xesam:title", out var mappedTitle) ? mappedTitle : string.Empty;
|
||||
var album = metadata.TryGetValue("xesam:album", out var mappedAlbum) ? mappedAlbum : string.Empty;
|
||||
var artist = metadata.TryGetValue("xesam:artist", out var mappedArtist) ? mappedArtist : string.Empty;
|
||||
var artUrl = metadata.TryGetValue("mpris:artUrl", out var mappedArtUrl) ? mappedArtUrl : string.Empty;
|
||||
var duration = metadata.TryGetValue("mpris:length", out var lengthText) &&
|
||||
long.TryParse(lengthText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var lengthUs) &&
|
||||
lengthUs > 0
|
||||
? TimeSpan.FromMilliseconds(lengthUs / 1000d)
|
||||
: TimeSpan.Zero;
|
||||
var position = positionMicroseconds > 0
|
||||
? TimeSpan.FromMilliseconds(positionMicroseconds / 1000d)
|
||||
: TimeSpan.Zero;
|
||||
if (duration > TimeSpan.Zero && position > duration)
|
||||
{
|
||||
position = duration;
|
||||
}
|
||||
|
||||
var displayName = string.IsNullOrWhiteSpace(identity)
|
||||
? SimplifyBusName(busName)
|
||||
: identity.Trim();
|
||||
var thumbnailBytes = TryReadArtUrlBytes(artUrl);
|
||||
|
||||
return new MusicPlaybackState(
|
||||
IsSupported: true,
|
||||
HasSession: true,
|
||||
Platform: MusicPlatform.Linux,
|
||||
SessionId: busName,
|
||||
SourceAppId: SimplifyBusName(busName),
|
||||
SourceAppName: displayName,
|
||||
SourceExecutableOrBusName: busName,
|
||||
Title: title,
|
||||
Artist: artist,
|
||||
AlbumTitle: album,
|
||||
ThumbnailBytes: thumbnailBytes,
|
||||
Position: position,
|
||||
Duration: duration,
|
||||
PlaybackStatus: MapPlaybackStatus(playbackStatus),
|
||||
CanPlayPause: canControl && (canPlay || canPause),
|
||||
CanSkipPrevious: canControl && canGoPrevious,
|
||||
CanSkipNext: canControl && canGoNext,
|
||||
CanLaunch: true,
|
||||
IsStale: false,
|
||||
StatusMessage: string.Empty,
|
||||
UpdatedAtUtc: lastSeen);
|
||||
}
|
||||
|
||||
internal static Dictionary<string, string> ParseMetadata(string text)
|
||||
{
|
||||
var metadata = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return metadata;
|
||||
}
|
||||
|
||||
var keys = new[] { "xesam:title", "xesam:artist", "xesam:album", "mpris:length", "mpris:artUrl" };
|
||||
foreach (var key in keys)
|
||||
{
|
||||
var keyIndex = text.IndexOf($"\"{key}\"", StringComparison.Ordinal);
|
||||
if (keyIndex < 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var tail = text[keyIndex..];
|
||||
var nextEntryIndex = tail.IndexOf("dict entry", key.Length + 2, StringComparison.Ordinal);
|
||||
if (nextEntryIndex > 0)
|
||||
{
|
||||
tail = tail[..nextEntryIndex];
|
||||
}
|
||||
if (key == "mpris:length")
|
||||
{
|
||||
var intMatch = Int64ValueRegex.Match(tail);
|
||||
if (intMatch.Success)
|
||||
{
|
||||
metadata[key] = intMatch.Groups["value"].Value;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key == "xesam:artist")
|
||||
{
|
||||
var values = ArrayStringRegex.Matches(tail)
|
||||
.Cast<Match>()
|
||||
.Select(match => Unescape(match.Groups["value"].Value))
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Take(3)
|
||||
.ToArray();
|
||||
if (values.Length > 0)
|
||||
{
|
||||
metadata[key] = string.Join(", ", values);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
var valueMatches = StringValueRegex.Matches(tail);
|
||||
if (valueMatches.Count >= 2)
|
||||
{
|
||||
metadata[key] = Unescape(valueMatches[1].Groups["value"].Value);
|
||||
}
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_disposeCts.Cancel();
|
||||
_nameOwnerChangedWatcher?.Dispose();
|
||||
_disposeCts.Dispose();
|
||||
}
|
||||
|
||||
private async Task EnsureSignalWatchAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_nameOwnerChangedWatcher is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await DBusConnection.Session.ConnectAsync().ConfigureAwait(false);
|
||||
_nameOwnerChangedWatcher = await DBusConnection.Session.WatchSignalAsync(
|
||||
"org.freedesktop.DBus",
|
||||
"/org/freedesktop/DBus",
|
||||
"org.freedesktop.DBus",
|
||||
"NameOwnerChanged",
|
||||
ex =>
|
||||
{
|
||||
if (ex is null || !ActionException.IsObserverDisposed(ex))
|
||||
{
|
||||
SessionsChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
},
|
||||
this,
|
||||
emitOnCapturedContext: false,
|
||||
ObserverFlags.None).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_nameOwnerChangedWatcher = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<string>> ListMprisNamesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await DBusConnection.Session.ConnectAsync().ConfigureAwait(false);
|
||||
var names = await DBusConnection.Session.ListServicesAsync().ConfigureAwait(false);
|
||||
return names
|
||||
.Where(name => name.StartsWith(MprisPrefix, StringComparison.Ordinal))
|
||||
.OrderBy(name => name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private async Task<MusicPlaybackState?> ReadSessionAsync(string busName, CancellationToken cancellationToken)
|
||||
{
|
||||
var identity = await GetPropertyTextAsync(busName, "org.mpris.MediaPlayer2", "Identity", cancellationToken).ConfigureAwait(false);
|
||||
var playbackStatus = await GetPropertyTextAsync(busName, "org.mpris.MediaPlayer2.Player", "PlaybackStatus", cancellationToken).ConfigureAwait(false);
|
||||
var metadata = await GetPropertyTextAsync(busName, "org.mpris.MediaPlayer2.Player", "Metadata", cancellationToken).ConfigureAwait(false);
|
||||
var positionText = await GetPropertyTextAsync(busName, "org.mpris.MediaPlayer2.Player", "Position", cancellationToken).ConfigureAwait(false);
|
||||
var canPlayText = await GetPropertyTextAsync(busName, "org.mpris.MediaPlayer2.Player", "CanPlay", cancellationToken).ConfigureAwait(false);
|
||||
var canPauseText = await GetPropertyTextAsync(busName, "org.mpris.MediaPlayer2.Player", "CanPause", cancellationToken).ConfigureAwait(false);
|
||||
var canGoNextText = await GetPropertyTextAsync(busName, "org.mpris.MediaPlayer2.Player", "CanGoNext", cancellationToken).ConfigureAwait(false);
|
||||
var canGoPreviousText = await GetPropertyTextAsync(busName, "org.mpris.MediaPlayer2.Player", "CanGoPrevious", cancellationToken).ConfigureAwait(false);
|
||||
var canControlText = await GetPropertyTextAsync(busName, "org.mpris.MediaPlayer2.Player", "CanControl", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var lastSeen = DateTimeOffset.UtcNow;
|
||||
_lastSeen[busName] = lastSeen;
|
||||
|
||||
return MapMprisSession(
|
||||
busName,
|
||||
ExtractFirstString(identity),
|
||||
ExtractFirstString(playbackStatus),
|
||||
metadata,
|
||||
ExtractFirstInt64(positionText),
|
||||
ExtractBool(canPlayText),
|
||||
ExtractBool(canPauseText),
|
||||
ExtractBool(canGoNextText),
|
||||
ExtractBool(canGoPreviousText),
|
||||
ExtractBool(canControlText, defaultValue: true),
|
||||
lastSeen);
|
||||
}
|
||||
|
||||
private static async Task<string> GetPropertyTextAsync(
|
||||
string busName,
|
||||
string interfaceName,
|
||||
string propertyName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await RunDbusSendAsync(
|
||||
[
|
||||
"--session",
|
||||
"--print-reply",
|
||||
$"--dest={busName}",
|
||||
"/org/mpris/MediaPlayer2",
|
||||
"org.freedesktop.DBus.Properties.Get",
|
||||
$"string:{interfaceName}",
|
||||
$"string:{propertyName}"
|
||||
],
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Task<bool> CallPlayerMethodAsync(string busName, string methodName, CancellationToken cancellationToken)
|
||||
=> CallMethodAsync(busName, $"org.mpris.MediaPlayer2.Player.{methodName}", cancellationToken);
|
||||
|
||||
private static Task<bool> CallRootMethodAsync(string busName, string methodName, CancellationToken cancellationToken)
|
||||
=> CallMethodAsync(busName, $"org.mpris.MediaPlayer2.{methodName}", cancellationToken);
|
||||
|
||||
private static async Task<bool> CallMethodAsync(string busName, string methodName, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = await RunDbusSendAsync(
|
||||
[
|
||||
"--session",
|
||||
"--print-reply",
|
||||
$"--dest={busName}",
|
||||
"/org/mpris/MediaPlayer2",
|
||||
methodName
|
||||
],
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string> RunDbusSendAsync(IReadOnlyList<string> arguments, CancellationToken cancellationToken)
|
||||
{
|
||||
using var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "dbus-send",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
|
||||
foreach (var argument in arguments)
|
||||
{
|
||||
process.StartInfo.ArgumentList.Add(argument);
|
||||
}
|
||||
|
||||
if (!process.Start())
|
||||
{
|
||||
throw new InvalidOperationException("Failed to start dbus-send.");
|
||||
}
|
||||
|
||||
var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
|
||||
var errorTask = process.StandardError.ReadToEndAsync(cancellationToken);
|
||||
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
||||
var output = await outputTask.ConfigureAwait(false);
|
||||
var error = await errorTask.ConfigureAwait(false);
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
throw new InvalidOperationException(string.IsNullOrWhiteSpace(error) ? $"dbus-send exited with {process.ExitCode}." : error.Trim());
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
private static bool TryLaunchDesktopEntry(string desktopEntry)
|
||||
{
|
||||
var normalized = desktopEntry.EndsWith(".desktop", StringComparison.Ordinal)
|
||||
? desktopEntry
|
||||
: $"{desktopEntry}.desktop";
|
||||
var candidates = new[]
|
||||
{
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local/share/applications", normalized),
|
||||
Path.Combine("/usr/share/applications", normalized)
|
||||
};
|
||||
|
||||
var desktopFile = candidates.FirstOrDefault(File.Exists);
|
||||
if (desktopFile is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var execLine = File.ReadLines(desktopFile)
|
||||
.FirstOrDefault(line => line.StartsWith("Exec=", StringComparison.Ordinal));
|
||||
if (string.IsNullOrWhiteSpace(execLine))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var command = Regex.Replace(execLine[5..], @"\s+%[fFuUdDnNickvm]", string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(command))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "/bin/sh",
|
||||
ArgumentList = { "-lc", command },
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
});
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ExtractFirstString(string text)
|
||||
{
|
||||
var match = StringValueRegex.Match(text);
|
||||
return match.Success ? Unescape(match.Groups["value"].Value) : string.Empty;
|
||||
}
|
||||
|
||||
private static long ExtractFirstInt64(string text)
|
||||
{
|
||||
var match = Int64ValueRegex.Match(text);
|
||||
return match.Success && long.TryParse(match.Groups["value"].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)
|
||||
? value
|
||||
: 0;
|
||||
}
|
||||
|
||||
private static bool ExtractBool(string text, bool defaultValue = false)
|
||||
{
|
||||
var match = BooleanValueRegex.Match(text);
|
||||
return match.Success
|
||||
? string.Equals(match.Groups["value"].Value, "true", StringComparison.OrdinalIgnoreCase)
|
||||
: defaultValue;
|
||||
}
|
||||
|
||||
private static MusicPlaybackStatus MapPlaybackStatus(string status)
|
||||
=> status.Trim() switch
|
||||
{
|
||||
"Playing" => MusicPlaybackStatus.Playing,
|
||||
"Paused" => MusicPlaybackStatus.Paused,
|
||||
"Stopped" => MusicPlaybackStatus.Stopped,
|
||||
_ => MusicPlaybackStatus.Unknown
|
||||
};
|
||||
|
||||
private static string SimplifyBusName(string busName)
|
||||
=> busName.StartsWith(MprisPrefix, StringComparison.Ordinal)
|
||||
? busName[MprisPrefix.Length..].Split('.', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? busName
|
||||
: busName;
|
||||
|
||||
private static byte[]? TryReadArtUrlBytes(string artUrl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(artUrl) ||
|
||||
!Uri.TryCreate(artUrl, UriKind.Absolute, out var uri) ||
|
||||
!string.Equals(uri.Scheme, Uri.UriSchemeFile, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return File.Exists(uri.LocalPath) ? File.ReadAllBytes(uri.LocalPath) : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Unescape(string value)
|
||||
=> value
|
||||
.Replace("\\\"", "\"", StringComparison.Ordinal)
|
||||
.Replace("\\n", "\n", StringComparison.Ordinal)
|
||||
.Replace("\\\\", "\\", StringComparison.Ordinal);
|
||||
}
|
||||
@@ -668,9 +668,8 @@ internal sealed class WeatherSettingsService : IWeatherSettingsService, IDisposa
|
||||
|
||||
private static string NormalizeIconPackId(string? iconPackId)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(iconPackId)
|
||||
? "HyperOS3"
|
||||
: "HyperOS3";
|
||||
_ = iconPackId;
|
||||
return "DefaultWeather";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -125,9 +125,8 @@ public sealed class WeatherLocationRefreshService
|
||||
|
||||
private static string NormalizeIconPackId(string? iconPackId)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(iconPackId)
|
||||
? "HyperOS3"
|
||||
: "HyperOS3";
|
||||
_ = iconPackId;
|
||||
return "DefaultWeather";
|
||||
}
|
||||
|
||||
private string BuildCoordinateDisplayName(string? languageCode, double latitude, double longitude)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
@@ -9,7 +10,7 @@ using System.Threading.Tasks;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed class WindowsSmtcMusicControlService : IMusicControlService
|
||||
public sealed class WindowsSmtcMusicControlService : IMusicSessionProvider
|
||||
{
|
||||
private static readonly Type? SessionManagerType = ResolveWinRtType("Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager");
|
||||
private static readonly Type? AppInfoType = ResolveWinRtType("Windows.ApplicationModel.AppInfo");
|
||||
@@ -27,11 +28,24 @@ public sealed class WindowsSmtcMusicControlService : IMusicControlService
|
||||
private string _thumbnailKey = string.Empty;
|
||||
private byte[]? _thumbnailBytesCache;
|
||||
|
||||
public async Task<MusicPlaybackState> GetCurrentStateAsync(CancellationToken cancellationToken = default)
|
||||
public MusicPlatform Platform => MusicPlatform.Windows;
|
||||
|
||||
public event EventHandler? SessionsChanged;
|
||||
|
||||
public async Task<IReadOnlyList<MusicPlaybackState>> GetSessionsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var state = await GetCurrentStateAsync(cancellationToken).ConfigureAwait(false);
|
||||
return state.HasSession || !state.IsSupported
|
||||
? [state]
|
||||
: [];
|
||||
}
|
||||
|
||||
private async Task<MusicPlaybackState> GetCurrentStateAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!IsRuntimeSupported())
|
||||
{
|
||||
return MusicPlaybackState.Unsupported();
|
||||
return MusicPlaybackState.Unsupported(
|
||||
"Windows media control is unavailable. Check the Windows version, WinRT runtime, and globalMediaControl capability.");
|
||||
}
|
||||
|
||||
await _stateGate.WaitAsync(cancellationToken);
|
||||
@@ -40,7 +54,7 @@ public sealed class WindowsSmtcMusicControlService : IMusicControlService
|
||||
var session = await GetCurrentSessionAsync(cancellationToken);
|
||||
if (session is null)
|
||||
{
|
||||
return MusicPlaybackState.NoSession(isSupported: true);
|
||||
return MusicPlaybackState.NoSession(isSupported: true, platform: MusicPlatform.Windows);
|
||||
}
|
||||
|
||||
var mediaProperties = await TryGetMediaPropertiesAsync(session, cancellationToken);
|
||||
@@ -92,8 +106,11 @@ public sealed class WindowsSmtcMusicControlService : IMusicControlService
|
||||
return new MusicPlaybackState(
|
||||
IsSupported: true,
|
||||
HasSession: true,
|
||||
Platform: MusicPlatform.Windows,
|
||||
SessionId: sourceAppId,
|
||||
SourceAppId: sourceAppId,
|
||||
SourceAppName: sourceAppName,
|
||||
SourceExecutableOrBusName: sourceAppId,
|
||||
Title: title,
|
||||
Artist: artist,
|
||||
AlbumTitle: albumTitle,
|
||||
@@ -103,11 +120,26 @@ public sealed class WindowsSmtcMusicControlService : IMusicControlService
|
||||
PlaybackStatus: MapPlaybackStatus(playbackStatusRaw),
|
||||
CanPlayPause: canPlayPause,
|
||||
CanSkipPrevious: canSkipPrevious,
|
||||
CanSkipNext: canSkipNext);
|
||||
CanSkipNext: canSkipNext,
|
||||
CanLaunch: !string.IsNullOrWhiteSpace(sourceAppId),
|
||||
IsStale: false,
|
||||
StatusMessage: string.Empty,
|
||||
UpdatedAtUtc: DateTimeOffset.UtcNow);
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
return MusicPlaybackState.Unsupported($"Windows media control permission or capability is missing: {ex.Message}");
|
||||
}
|
||||
catch (TargetInvocationException ex) when (ex.InnerException is UnauthorizedAccessException inner)
|
||||
{
|
||||
return MusicPlaybackState.Unsupported($"Windows media control permission or capability is missing: {inner.Message}");
|
||||
}
|
||||
catch
|
||||
{
|
||||
return MusicPlaybackState.NoSession(isSupported: true);
|
||||
return MusicPlaybackState.NoSession(
|
||||
isSupported: true,
|
||||
platform: MusicPlatform.Windows,
|
||||
statusMessage: "Windows media session was found but could not be read.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -115,7 +147,7 @@ public sealed class WindowsSmtcMusicControlService : IMusicControlService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> TogglePlayPauseAsync(CancellationToken cancellationToken = default)
|
||||
public async Task<bool> TogglePlayPauseAsync(string sessionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!IsRuntimeSupported())
|
||||
{
|
||||
@@ -153,7 +185,7 @@ public sealed class WindowsSmtcMusicControlService : IMusicControlService
|
||||
return await AwaitBooleanWinRtOperationAsync(operation, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> SkipNextAsync(CancellationToken cancellationToken = default)
|
||||
public async Task<bool> SkipNextAsync(string sessionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!IsRuntimeSupported())
|
||||
{
|
||||
@@ -176,7 +208,7 @@ public sealed class WindowsSmtcMusicControlService : IMusicControlService
|
||||
return await AwaitBooleanWinRtOperationAsync(InvokeMethod(session, "TrySkipNextAsync"), cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> SkipPreviousAsync(CancellationToken cancellationToken = default)
|
||||
public async Task<bool> SkipPreviousAsync(string sessionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!IsRuntimeSupported())
|
||||
{
|
||||
@@ -199,7 +231,7 @@ public sealed class WindowsSmtcMusicControlService : IMusicControlService
|
||||
return await AwaitBooleanWinRtOperationAsync(InvokeMethod(session, "TrySkipPreviousAsync"), cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> LaunchSourceAppAsync(CancellationToken cancellationToken = default)
|
||||
public async Task<bool> LaunchSourceAppAsync(string sessionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!IsRuntimeSupported())
|
||||
{
|
||||
@@ -491,9 +523,18 @@ public sealed class WindowsSmtcMusicControlService : IMusicControlService
|
||||
return type?
|
||||
.GetMethods(BindingFlags.Public | BindingFlags.Static)
|
||||
.FirstOrDefault(method =>
|
||||
method.Name == "AsTask" &&
|
||||
method.IsGenericMethodDefinition &&
|
||||
method.GetParameters().Length == 1);
|
||||
{
|
||||
try
|
||||
{
|
||||
return method.Name == "AsTask" &&
|
||||
method.IsGenericMethodDefinition &&
|
||||
method.GetParameters().Length == 1;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static MethodInfo? ResolveAsStreamForReadMethod()
|
||||
@@ -576,4 +617,7 @@ public sealed class WindowsSmtcMusicControlService : IMusicControlService
|
||||
_ => MusicPlaybackStatus.Unknown
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
=> SessionsChanged = null;
|
||||
}
|
||||
|
||||
301
LanMountainDesktop/ViewModels/MusicControlViewModel.cs
Normal file
301
LanMountainDesktop/ViewModels/MusicControlViewModel.cs
Normal file
@@ -0,0 +1,301 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Media.Imaging;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
|
||||
namespace LanMountainDesktop.ViewModels;
|
||||
|
||||
public sealed partial class MusicControlViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
private readonly IMusicControlService _musicControlService;
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly LocalizationService _localizationService;
|
||||
|
||||
private CancellationTokenSource? _refreshCts;
|
||||
private Bitmap? _coverBitmap;
|
||||
private bool _isExecutingCommand;
|
||||
private string _languageCode = "zh-CN";
|
||||
|
||||
[ObservableProperty] private MusicPlaybackState _state = MusicPlaybackState.NoSession(isSupported: true);
|
||||
[ObservableProperty] private string _titleText = string.Empty;
|
||||
[ObservableProperty] private string _artistText = string.Empty;
|
||||
[ObservableProperty] private string _sourceAppText = string.Empty;
|
||||
[ObservableProperty] private string _statusText = "--";
|
||||
[ObservableProperty] private string _positionText = "00:00";
|
||||
[ObservableProperty] private string _durationText = "00:00";
|
||||
[ObservableProperty] private double _progressRatio;
|
||||
[ObservableProperty] private bool _isProgressIndeterminate;
|
||||
[ObservableProperty] private bool _isPlaybackActive;
|
||||
[ObservableProperty] private bool _isNoMedia;
|
||||
[ObservableProperty] private bool _canPlayPause;
|
||||
[ObservableProperty] private bool _canSkipPrevious;
|
||||
[ObservableProperty] private bool _canSkipNext;
|
||||
[ObservableProperty] private bool _canLaunchSource;
|
||||
[ObservableProperty] private Bitmap? _cover;
|
||||
|
||||
public MusicControlViewModel()
|
||||
: this(
|
||||
MusicControlServiceFactory.CreateDefault(),
|
||||
HostSettingsFacadeProvider.GetOrCreate().Settings,
|
||||
new LocalizationService())
|
||||
{
|
||||
}
|
||||
|
||||
internal MusicControlViewModel(
|
||||
IMusicControlService musicControlService,
|
||||
ISettingsService settingsService,
|
||||
LocalizationService localizationService)
|
||||
{
|
||||
_musicControlService = musicControlService;
|
||||
_settingsService = settingsService;
|
||||
_localizationService = localizationService;
|
||||
_musicControlService.StateChanged += OnServiceStateChanged;
|
||||
ApplyState(MusicPlaybackState.NoSession(isSupported: OperatingSystem.IsWindows() || OperatingSystem.IsLinux()));
|
||||
}
|
||||
|
||||
public async Task RefreshAsync()
|
||||
{
|
||||
UpdateLanguageCode();
|
||||
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var previous = Interlocked.Exchange(ref _refreshCts, cts);
|
||||
previous?.Cancel();
|
||||
previous?.Dispose();
|
||||
|
||||
try
|
||||
{
|
||||
var state = await _musicControlService.GetCurrentStateAsync(cts.Token).ConfigureAwait(false);
|
||||
if (!cts.IsCancellationRequested)
|
||||
{
|
||||
ApplyState(state);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ApplyState(MusicPlaybackState.NoSession(
|
||||
isSupported: true,
|
||||
platform: OperatingSystem.IsLinux() ? MusicPlatform.Linux : MusicPlatform.Windows,
|
||||
statusMessage: ex.Message));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (ReferenceEquals(_refreshCts, cts))
|
||||
{
|
||||
_refreshCts = null;
|
||||
}
|
||||
|
||||
cts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public Task TogglePlayPauseAsync()
|
||||
=> ExecuteCommandAsync(token => _musicControlService.TogglePlayPauseAsync(token));
|
||||
|
||||
public Task SkipPreviousAsync()
|
||||
=> ExecuteCommandAsync(token => _musicControlService.SkipPreviousAsync(token));
|
||||
|
||||
public Task SkipNextAsync()
|
||||
=> ExecuteCommandAsync(token => _musicControlService.SkipNextAsync(token));
|
||||
|
||||
public Task LaunchSourceAsync()
|
||||
=> ExecuteCommandAsync(token => _musicControlService.LaunchSourceAppAsync(token), refreshAfterCommand: false, requireActiveSession: false);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
var cts = Interlocked.Exchange(ref _refreshCts, null);
|
||||
cts?.Cancel();
|
||||
cts?.Dispose();
|
||||
_musicControlService.StateChanged -= OnServiceStateChanged;
|
||||
if (_musicControlService is IDisposable disposable)
|
||||
{
|
||||
disposable.Dispose();
|
||||
}
|
||||
|
||||
SetCover(null);
|
||||
}
|
||||
|
||||
private async Task ExecuteCommandAsync(
|
||||
Func<CancellationToken, Task<bool>> command,
|
||||
bool refreshAfterCommand = true,
|
||||
bool requireActiveSession = true)
|
||||
{
|
||||
if (_isExecutingCommand ||
|
||||
!State.IsSupported ||
|
||||
(requireActiveSession && !State.HasSession))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isExecutingCommand = true;
|
||||
UpdateCommandAvailability(State);
|
||||
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(4));
|
||||
_ = await command(cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isExecutingCommand = false;
|
||||
}
|
||||
|
||||
if (refreshAfterCommand)
|
||||
{
|
||||
await RefreshAsync().ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
UpdateCommandAvailability(State);
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyState(MusicPlaybackState state)
|
||||
{
|
||||
State = state;
|
||||
IsNoMedia = !state.IsSupported || !state.HasSession;
|
||||
|
||||
if (!state.IsSupported)
|
||||
{
|
||||
TitleText = L("music.widget.unsupported", "Music control is not supported on this platform");
|
||||
ArtistText = string.IsNullOrWhiteSpace(state.StatusMessage)
|
||||
? L("music.widget.unsupported_hint", "Media backend is unavailable")
|
||||
: state.StatusMessage;
|
||||
SourceAppText = L("music.widget.open_player", "Open player");
|
||||
StatusText = "--";
|
||||
PositionText = "00:00";
|
||||
DurationText = "00:00";
|
||||
ProgressRatio = 0;
|
||||
IsProgressIndeterminate = false;
|
||||
IsPlaybackActive = false;
|
||||
SetCover(null);
|
||||
UpdateCommandAvailability(state);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.HasSession)
|
||||
{
|
||||
TitleText = L("music.widget.no_session", "No active media session");
|
||||
ArtistText = string.IsNullOrWhiteSpace(state.StatusMessage)
|
||||
? L("music.widget.no_session_hint", "Open a player that supports system media sessions")
|
||||
: state.StatusMessage;
|
||||
SourceAppText = L("music.widget.open_player", "Open player");
|
||||
StatusText = "--";
|
||||
PositionText = "00:00";
|
||||
DurationText = "00:00";
|
||||
ProgressRatio = 0;
|
||||
IsProgressIndeterminate = false;
|
||||
IsPlaybackActive = false;
|
||||
SetCover(null);
|
||||
UpdateCommandAvailability(state);
|
||||
return;
|
||||
}
|
||||
|
||||
TitleText = string.IsNullOrWhiteSpace(state.Title)
|
||||
? L("music.widget.unknown_title", "Unknown title")
|
||||
: state.Title;
|
||||
ArtistText = !string.IsNullOrWhiteSpace(state.Artist)
|
||||
? state.Artist
|
||||
: !string.IsNullOrWhiteSpace(state.AlbumTitle)
|
||||
? state.AlbumTitle
|
||||
: L("music.widget.unknown_artist", "Unknown artist");
|
||||
SourceAppText = string.IsNullOrWhiteSpace(state.SourceAppName)
|
||||
? L("music.widget.open_player", "Open player")
|
||||
: state.SourceAppName;
|
||||
StatusText = ResolveStatusText(state.PlaybackStatus);
|
||||
IsPlaybackActive = state.PlaybackStatus == MusicPlaybackStatus.Playing;
|
||||
|
||||
var position = ClampToNonNegative(state.Position);
|
||||
var duration = ClampToNonNegative(state.Duration);
|
||||
PositionText = FormatTimeline(position);
|
||||
DurationText = duration.TotalMilliseconds > 1 ? FormatTimeline(duration) : "00:00";
|
||||
ProgressRatio = duration.TotalMilliseconds <= 1
|
||||
? 0
|
||||
: Math.Clamp(position.TotalMilliseconds / duration.TotalMilliseconds, 0, 1);
|
||||
IsProgressIndeterminate = duration.TotalMilliseconds <= 1;
|
||||
SetCover(state.ThumbnailBytes);
|
||||
UpdateCommandAvailability(state);
|
||||
}
|
||||
|
||||
private void UpdateCommandAvailability(MusicPlaybackState state)
|
||||
{
|
||||
var canOperate = !_isExecutingCommand && state.IsSupported && state.HasSession;
|
||||
var noSessionButSupported = !_isExecutingCommand && state.IsSupported && !state.HasSession;
|
||||
CanPlayPause = canOperate ? state.CanPlayPause : noSessionButSupported;
|
||||
CanSkipPrevious = canOperate ? state.CanSkipPrevious : noSessionButSupported;
|
||||
CanSkipNext = canOperate ? state.CanSkipNext : noSessionButSupported;
|
||||
CanLaunchSource = !_isExecutingCommand && state.IsSupported && (state.CanLaunch || !state.HasSession);
|
||||
}
|
||||
|
||||
private void SetCover(byte[]? thumbnailBytes)
|
||||
{
|
||||
Bitmap? next = null;
|
||||
if (thumbnailBytes is { Length: > 0 })
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = new MemoryStream(thumbnailBytes, writable: false);
|
||||
next = new Bitmap(stream);
|
||||
}
|
||||
catch
|
||||
{
|
||||
next?.Dispose();
|
||||
next = null;
|
||||
}
|
||||
}
|
||||
|
||||
var old = _coverBitmap;
|
||||
_coverBitmap = next;
|
||||
Cover = next;
|
||||
old?.Dispose();
|
||||
}
|
||||
|
||||
private void UpdateLanguageCode()
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = _settingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_languageCode = "zh-CN";
|
||||
}
|
||||
}
|
||||
|
||||
private string ResolveStatusText(MusicPlaybackStatus status)
|
||||
=> status switch
|
||||
{
|
||||
MusicPlaybackStatus.Playing => L("music.widget.status.playing", "Playing"),
|
||||
MusicPlaybackStatus.Paused => L("music.widget.status.paused", "Paused"),
|
||||
MusicPlaybackStatus.Stopped => L("music.widget.status.stopped", "Stopped"),
|
||||
MusicPlaybackStatus.Changing => L("music.widget.status.changing", "Changing"),
|
||||
MusicPlaybackStatus.Opened => L("music.widget.status.opened", "Opened"),
|
||||
_ => "--"
|
||||
};
|
||||
|
||||
private string L(string key, string fallback)
|
||||
=> _localizationService.GetString(_languageCode, key, fallback);
|
||||
|
||||
private void OnServiceStateChanged(object? sender, EventArgs e)
|
||||
=> _ = RefreshAsync();
|
||||
|
||||
private static TimeSpan ClampToNonNegative(TimeSpan value)
|
||||
=> value < TimeSpan.Zero ? TimeSpan.Zero : value;
|
||||
|
||||
private static string FormatTimeline(TimeSpan value)
|
||||
=> value.TotalHours >= 1
|
||||
? value.ToString(@"h\:mm\:ss", CultureInfo.InvariantCulture)
|
||||
: value.ToString(@"mm\:ss", CultureInfo.InvariantCulture);
|
||||
}
|
||||
@@ -345,7 +345,7 @@ public sealed partial class WeatherSettingsPageViewModel : ViewModelBase
|
||||
selected.Longitude,
|
||||
AutoRefreshLocation,
|
||||
ExcludedAlerts ?? string.Empty,
|
||||
"HyperOS3",
|
||||
"DefaultWeather",
|
||||
NoTlsRequests,
|
||||
SearchKeyword?.Trim() ?? string.Empty);
|
||||
|
||||
@@ -527,7 +527,7 @@ public sealed partial class WeatherSettingsPageViewModel : ViewModelBase
|
||||
private void RefreshLocalizedText()
|
||||
{
|
||||
PageTitle = L("settings.weather.title", "Weather");
|
||||
PageDescription = L("settings.weather.description", "Configure weather location, automatic positioning, and Xiaomi weather preview.");
|
||||
PageDescription = L("settings.weather.description", "Configure weather location, weather preview, and startup positioning behavior.");
|
||||
PreviewHeader = L("settings.weather.preview_panel_header", "Weather Preview");
|
||||
PreviewDescription = L("settings.weather.preview_panel_desc", "Refresh and verify current weather service status.");
|
||||
LocationSourceHeader = L("settings.weather.location_source_header", "Location Source");
|
||||
@@ -629,7 +629,7 @@ public sealed partial class WeatherSettingsPageViewModel : ViewModelBase
|
||||
Longitude,
|
||||
AutoRefreshLocation,
|
||||
ExcludedAlerts ?? string.Empty,
|
||||
"HyperOS3",
|
||||
"DefaultWeather",
|
||||
NoTlsRequests,
|
||||
SearchKeyword?.Trim() ?? string.Empty);
|
||||
}
|
||||
@@ -646,7 +646,7 @@ public sealed partial class WeatherSettingsPageViewModel : ViewModelBase
|
||||
SelectedSearchResult.Longitude,
|
||||
AutoRefreshLocation,
|
||||
ExcludedAlerts ?? string.Empty,
|
||||
"HyperOS3",
|
||||
"DefaultWeather",
|
||||
NoTlsRequests,
|
||||
SearchKeyword?.Trim() ?? string.Empty);
|
||||
}
|
||||
@@ -705,8 +705,9 @@ public sealed partial class WeatherSettingsPageViewModel : ViewModelBase
|
||||
return weatherText.Trim();
|
||||
}
|
||||
|
||||
return XiaomiWeatherCodeMapper.ResolveDisplayText(weatherCode, _languageCode)
|
||||
?? L("settings.weather.preview_unknown", "Unknown");
|
||||
return weatherCode.HasValue
|
||||
? string.Format(CultureInfo.InvariantCulture, "Weather {0}", weatherCode.Value)
|
||||
: L("settings.weather.preview_unknown", "Unknown");
|
||||
}
|
||||
|
||||
private CultureInfo ResolveCulture()
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.ComponentModel;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
@@ -14,48 +11,37 @@ using Avalonia.Threading;
|
||||
using FluentIcons.Common;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Theme;
|
||||
using LanMountainDesktop.ViewModels;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class MusicControlWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget
|
||||
{
|
||||
private const Symbol PlaySymbol = Symbol.Play;
|
||||
private const Symbol PauseSymbol = Symbol.Pause;
|
||||
|
||||
private readonly DispatcherTimer _refreshTimer = new()
|
||||
{
|
||||
Interval = TimeSpan.FromSeconds(2.4)
|
||||
};
|
||||
|
||||
private readonly IMusicControlService _musicControlService = MusicControlServiceFactory.CreateDefault();
|
||||
private readonly MusicControlViewModel _viewModel = new();
|
||||
private readonly MonetColorService _monetColorService = new();
|
||||
private LanMountainDesktop.PluginSdk.ISettingsService _settingsService = LanMountainDesktop.Services.Settings.HostSettingsFacadeProvider.GetOrCreate().Settings;
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
|
||||
private CancellationTokenSource? _refreshCts;
|
||||
private Bitmap? _coverBitmap;
|
||||
private MusicPlaybackState _currentState = MusicPlaybackState.NoSession(isSupported: true);
|
||||
private string _languageCode = "zh-CN";
|
||||
private double _currentCellSize = 48;
|
||||
private bool _isAttached;
|
||||
private bool _isOnActivePage = true;
|
||||
private bool _isRefreshing;
|
||||
private bool _isExecutingCommand;
|
||||
private double _progressRatio;
|
||||
private bool _isProgressIndeterminate;
|
||||
|
||||
public MusicControlWidget()
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContext = _viewModel;
|
||||
|
||||
_refreshTimer.Tick += OnRefreshTimerTick;
|
||||
_viewModel.PropertyChanged += OnViewModelPropertyChanged;
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
SizeChanged += OnSizeChanged;
|
||||
|
||||
ApplyCellSize(_currentCellSize);
|
||||
ApplyDynamicBackground(null);
|
||||
ApplyState(MusicPlaybackState.NoSession(isSupported: OperatingSystem.IsWindows()));
|
||||
ApplyViewModel();
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
@@ -123,7 +109,7 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget,
|
||||
NextIcon.FontSize = Math.Clamp(18 * scale, 13, 24);
|
||||
FavoriteIcon.FontSize = Math.Clamp(16 * scale, 11, 21);
|
||||
|
||||
UpdateProgressVisual(_progressRatio, _isProgressIndeterminate);
|
||||
UpdateProgressVisual(_viewModel.ProgressRatio, _viewModel.IsProgressIndeterminate);
|
||||
}
|
||||
|
||||
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
|
||||
@@ -135,7 +121,7 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget,
|
||||
|
||||
if (!wasOnActivePage && _isOnActivePage && _isAttached)
|
||||
{
|
||||
_ = RefreshStateAsync();
|
||||
_ = _viewModel.RefreshAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,7 +131,7 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget,
|
||||
UpdateRefreshTimerState();
|
||||
if (_isOnActivePage)
|
||||
{
|
||||
_ = RefreshStateAsync();
|
||||
_ = _viewModel.RefreshAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,125 +139,28 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget,
|
||||
{
|
||||
_isAttached = false;
|
||||
UpdateRefreshTimerState();
|
||||
CancelRefreshRequest();
|
||||
DisposeCoverBitmap();
|
||||
}
|
||||
|
||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||
{
|
||||
ApplyCellSize(_currentCellSize);
|
||||
}
|
||||
=> ApplyCellSize(_currentCellSize);
|
||||
|
||||
private async void OnRefreshTimerTick(object? sender, EventArgs e)
|
||||
{
|
||||
await RefreshStateAsync();
|
||||
}
|
||||
=> await _viewModel.RefreshAsync();
|
||||
|
||||
private async void OnPlayPauseButtonClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
await ExecuteCommandAsync(token => _musicControlService.TogglePlayPauseAsync(token));
|
||||
}
|
||||
=> await _viewModel.TogglePlayPauseAsync();
|
||||
|
||||
private async void OnPreviousButtonClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
await ExecuteCommandAsync(token => _musicControlService.SkipPreviousAsync(token));
|
||||
}
|
||||
=> await _viewModel.SkipPreviousAsync();
|
||||
|
||||
private async void OnNextButtonClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
await ExecuteCommandAsync(token => _musicControlService.SkipNextAsync(token));
|
||||
}
|
||||
=> await _viewModel.SkipNextAsync();
|
||||
|
||||
private async void OnSourceAppButtonClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
await ExecuteCommandAsync(
|
||||
token => _musicControlService.LaunchSourceAppAsync(token),
|
||||
refreshAfterCommand: false,
|
||||
requireActiveSession: false);
|
||||
}
|
||||
=> await _viewModel.LaunchSourceAsync();
|
||||
|
||||
private async Task ExecuteCommandAsync(
|
||||
Func<CancellationToken, Task<bool>> command,
|
||||
bool refreshAfterCommand = true,
|
||||
bool requireActiveSession = true)
|
||||
{
|
||||
if (_isExecutingCommand
|
||||
|| !_currentState.IsSupported
|
||||
|| (requireActiveSession && !_currentState.HasSession))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isExecutingCommand = true;
|
||||
ApplyActionButtonState(_currentState);
|
||||
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(4));
|
||||
_ = await command(cts.Token);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore command transport errors and recover on next poll.
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isExecutingCommand = false;
|
||||
}
|
||||
|
||||
if (refreshAfterCommand)
|
||||
{
|
||||
await RefreshStateAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshStateAsync()
|
||||
{
|
||||
if (!_isAttached || !_isOnActivePage || _isRefreshing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isRefreshing = true;
|
||||
UpdateLanguageCode();
|
||||
|
||||
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var previous = Interlocked.Exchange(ref _refreshCts, cts);
|
||||
previous?.Cancel();
|
||||
previous?.Dispose();
|
||||
|
||||
try
|
||||
{
|
||||
var state = await _musicControlService.GetCurrentStateAsync(cts.Token);
|
||||
if (cts.IsCancellationRequested || !_isAttached)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_currentState = state;
|
||||
ApplyState(state);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Ignore cancellation.
|
||||
}
|
||||
catch
|
||||
{
|
||||
var fallbackState = MusicPlaybackState.NoSession(isSupported: OperatingSystem.IsWindows());
|
||||
_currentState = fallbackState;
|
||||
ApplyState(fallbackState);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (ReferenceEquals(_refreshCts, cts))
|
||||
{
|
||||
_refreshCts = null;
|
||||
}
|
||||
|
||||
cts.Dispose();
|
||||
_isRefreshing = false;
|
||||
}
|
||||
}
|
||||
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
=> Dispatcher.UIThread.Post(ApplyViewModel);
|
||||
|
||||
private void UpdateRefreshTimerState()
|
||||
{
|
||||
@@ -288,109 +177,51 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget,
|
||||
_refreshTimer.Stop();
|
||||
}
|
||||
|
||||
private void ApplyState(MusicPlaybackState state)
|
||||
private void ApplyViewModel()
|
||||
{
|
||||
var hasMediaSession = state.IsSupported && state.HasSession;
|
||||
var state = _viewModel.State;
|
||||
var cover = _viewModel.Cover;
|
||||
var hasCover = cover is not null;
|
||||
|
||||
if (!state.IsSupported)
|
||||
TitleTextBlock.Text = _viewModel.TitleText;
|
||||
ArtistTextBlock.Text = _viewModel.ArtistText;
|
||||
ArtistTextBlock.MaxLines = _viewModel.IsNoMedia ? 2 : 1;
|
||||
SourceAppTextBlock.Text = _viewModel.SourceAppText;
|
||||
StatusTextBlock.Text = _viewModel.StatusText;
|
||||
PositionTextBlock.Text = _viewModel.PositionText;
|
||||
DurationTextBlock.Text = _viewModel.DurationText;
|
||||
PlaybackActivityIcon.IsVisible = _viewModel.IsPlaybackActive;
|
||||
PlayPauseGlyphIcon.Symbol = _viewModel.IsPlaybackActive ? Symbol.Pause : Symbol.Play;
|
||||
|
||||
PlayPauseButton.IsEnabled = _viewModel.CanPlayPause;
|
||||
PreviousButton.IsEnabled = _viewModel.CanSkipPrevious;
|
||||
NextButton.IsEnabled = _viewModel.CanSkipNext;
|
||||
SourceAppButton.IsEnabled = _viewModel.CanLaunchSource;
|
||||
QueueButton.IsEnabled = state.IsSupported;
|
||||
FavoriteButton.IsEnabled = state.IsSupported;
|
||||
|
||||
CoverImage.Source = cover;
|
||||
BackdropCoverImage.Source = cover;
|
||||
CoverImage.IsVisible = hasCover;
|
||||
BackdropCoverImage.IsVisible = hasCover;
|
||||
CoverFallbackGlyph.IsVisible = !hasCover;
|
||||
|
||||
if (_viewModel.IsNoMedia)
|
||||
{
|
||||
TitleTextBlock.Text = L("music.widget.unsupported", "Music control is only available on Windows");
|
||||
ArtistTextBlock.Text = L("music.widget.unsupported_hint", "SMTC backend is unavailable");
|
||||
SourceAppTextBlock.Text = L("music.widget.open_player", "Open player");
|
||||
StatusTextBlock.Text = "--";
|
||||
PositionTextBlock.Text = "00:00";
|
||||
DurationTextBlock.Text = "00:00";
|
||||
PlaybackActivityIcon.IsVisible = false;
|
||||
PlayPauseGlyphIcon.Symbol = PlaySymbol;
|
||||
UpdateProgressVisual(0, false);
|
||||
SetCoverImage(null);
|
||||
ApplyNoMediaVisualTheme();
|
||||
ApplyActionButtonState(state);
|
||||
UpdateSourceAppButtonTooltip();
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
ApplyActiveVisualTheme();
|
||||
}
|
||||
|
||||
if (!state.HasSession)
|
||||
{
|
||||
TitleTextBlock.Text = L("music.widget.no_session", "No active media session");
|
||||
ArtistTextBlock.Text = L("music.widget.no_session_hint", "Open a player that supports SMTC");
|
||||
SourceAppTextBlock.Text = L("music.widget.open_player", "Open player");
|
||||
StatusTextBlock.Text = "--";
|
||||
PositionTextBlock.Text = "00:00";
|
||||
DurationTextBlock.Text = "00:00";
|
||||
PlaybackActivityIcon.IsVisible = false;
|
||||
PlayPauseGlyphIcon.Symbol = PlaySymbol;
|
||||
UpdateProgressVisual(0, false);
|
||||
SetCoverImage(null);
|
||||
ApplyNoMediaVisualTheme();
|
||||
ApplyActionButtonState(state);
|
||||
UpdateSourceAppButtonTooltip();
|
||||
return;
|
||||
}
|
||||
|
||||
ApplyActiveVisualTheme();
|
||||
|
||||
var title = string.IsNullOrWhiteSpace(state.Title)
|
||||
? L("music.widget.unknown_title", "Unknown title")
|
||||
: state.Title;
|
||||
var subtitle = !string.IsNullOrWhiteSpace(state.Artist)
|
||||
? state.Artist
|
||||
: !string.IsNullOrWhiteSpace(state.AlbumTitle)
|
||||
? state.AlbumTitle
|
||||
: L("music.widget.unknown_artist", "Unknown artist");
|
||||
|
||||
TitleTextBlock.Text = title;
|
||||
ArtistTextBlock.Text = subtitle;
|
||||
SourceAppTextBlock.Text = string.IsNullOrWhiteSpace(state.SourceAppName)
|
||||
? L("music.widget.open_player", "Open player")
|
||||
: state.SourceAppName;
|
||||
StatusTextBlock.Text = ResolveStatusText(state.PlaybackStatus);
|
||||
PlaybackActivityIcon.IsVisible = state.PlaybackStatus == MusicPlaybackStatus.Playing;
|
||||
|
||||
var position = ClampToNonNegative(state.Position);
|
||||
var duration = ClampToNonNegative(state.Duration);
|
||||
var progressRatio = duration.TotalMilliseconds <= 1
|
||||
? 0
|
||||
: Math.Clamp(position.TotalMilliseconds / duration.TotalMilliseconds, 0, 1);
|
||||
|
||||
PositionTextBlock.Text = FormatTimeline(position);
|
||||
DurationTextBlock.Text = duration.TotalMilliseconds > 1
|
||||
? FormatTimeline(duration)
|
||||
: "00:00";
|
||||
UpdateProgressVisual(progressRatio, hasMediaSession && duration.TotalMilliseconds <= 1);
|
||||
|
||||
PlayPauseGlyphIcon.Symbol = state.PlaybackStatus == MusicPlaybackStatus.Playing
|
||||
? PauseSymbol
|
||||
: PlaySymbol;
|
||||
|
||||
SetCoverImage(state.ThumbnailBytes);
|
||||
ApplyActionButtonState(state);
|
||||
ApplyDynamicBackground(cover);
|
||||
UpdateProgressVisual(_viewModel.ProgressRatio, _viewModel.IsProgressIndeterminate);
|
||||
UpdateSourceAppButtonTooltip();
|
||||
}
|
||||
|
||||
private void ApplyActionButtonState(MusicPlaybackState state)
|
||||
{
|
||||
var canOperate = !_isExecutingCommand && state.IsSupported && state.HasSession;
|
||||
var showNoSessionStyle = !_isExecutingCommand && state.IsSupported && !state.HasSession;
|
||||
|
||||
PlayPauseButton.IsEnabled = canOperate
|
||||
? state.CanPlayPause
|
||||
: showNoSessionStyle;
|
||||
PreviousButton.IsEnabled = canOperate
|
||||
? state.CanSkipPrevious
|
||||
: showNoSessionStyle;
|
||||
NextButton.IsEnabled = canOperate
|
||||
? state.CanSkipNext
|
||||
: showNoSessionStyle;
|
||||
SourceAppButton.IsEnabled = !_isExecutingCommand && state.IsSupported;
|
||||
QueueButton.IsEnabled = canOperate || showNoSessionStyle;
|
||||
FavoriteButton.IsEnabled = canOperate || showNoSessionStyle;
|
||||
}
|
||||
|
||||
private void ApplyNoMediaVisualTheme()
|
||||
{
|
||||
ArtistTextBlock.MaxLines = 2;
|
||||
|
||||
DynamicBackgroundBase.Background = new SolidColorBrush(Color.Parse("#F0635D61"));
|
||||
DynamicGradientOverlay.Background = new LinearGradientBrush
|
||||
{
|
||||
@@ -444,8 +275,6 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget,
|
||||
|
||||
private void ApplyActiveVisualTheme()
|
||||
{
|
||||
ArtistTextBlock.MaxLines = 1;
|
||||
|
||||
CoverBorder.Background = new SolidColorBrush(Color.Parse("#3CFFFFFF"));
|
||||
CoverBorder.BorderBrush = new SolidColorBrush(Color.Parse("#77FFFFFF"));
|
||||
CoverFallbackGlyph.Symbol = Symbol.Album;
|
||||
@@ -460,49 +289,6 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget,
|
||||
SourceAppIcon.Foreground = new SolidColorBrush(Color.Parse("#F7FFFFFF"));
|
||||
}
|
||||
|
||||
private void UpdateLanguageCode()
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = _settingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_languageCode = "zh-CN";
|
||||
}
|
||||
}
|
||||
|
||||
private void CancelRefreshRequest()
|
||||
{
|
||||
var cts = Interlocked.Exchange(ref _refreshCts, null);
|
||||
if (cts is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
cts.Cancel();
|
||||
cts.Dispose();
|
||||
}
|
||||
|
||||
private string ResolveStatusText(MusicPlaybackStatus status)
|
||||
{
|
||||
return status switch
|
||||
{
|
||||
MusicPlaybackStatus.Playing => L("music.widget.status.playing", "Playing"),
|
||||
MusicPlaybackStatus.Paused => L("music.widget.status.paused", "Paused"),
|
||||
MusicPlaybackStatus.Stopped => L("music.widget.status.stopped", "Stopped"),
|
||||
MusicPlaybackStatus.Changing => L("music.widget.status.changing", "Changing"),
|
||||
MusicPlaybackStatus.Opened => L("music.widget.status.opened", "Opened"),
|
||||
_ => "--"
|
||||
};
|
||||
}
|
||||
|
||||
private string L(string key, string fallback)
|
||||
{
|
||||
return _localizationService.GetString(_languageCode, key, fallback);
|
||||
}
|
||||
|
||||
private double ResolveScale()
|
||||
{
|
||||
var cellScale = Math.Clamp(_currentCellSize / 48d, 0.62, 2.1);
|
||||
@@ -515,84 +301,8 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget,
|
||||
return Math.Clamp(Math.Min(cellScale, Math.Min(widthScale, heightScale) * 1.05), 0.56, 2.0);
|
||||
}
|
||||
|
||||
private static TimeSpan ClampToNonNegative(TimeSpan value)
|
||||
{
|
||||
return value < TimeSpan.Zero ? TimeSpan.Zero : value;
|
||||
}
|
||||
|
||||
private static string FormatTimeline(TimeSpan value)
|
||||
{
|
||||
if (value.TotalHours >= 1)
|
||||
{
|
||||
return value.ToString(@"h\:mm\:ss", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
return value.ToString(@"mm\:ss", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private void SetCoverImage(byte[]? thumbnailBytes)
|
||||
{
|
||||
DisposeCoverBitmap();
|
||||
|
||||
if (thumbnailBytes is null || thumbnailBytes.Length == 0)
|
||||
{
|
||||
CoverImage.Source = null;
|
||||
BackdropCoverImage.Source = null;
|
||||
CoverImage.IsVisible = false;
|
||||
BackdropCoverImage.IsVisible = false;
|
||||
CoverFallbackGlyph.IsVisible = true;
|
||||
ApplyDynamicBackground(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = new MemoryStream(thumbnailBytes, writable: false);
|
||||
_coverBitmap = new Bitmap(stream);
|
||||
CoverImage.Source = _coverBitmap;
|
||||
BackdropCoverImage.Source = _coverBitmap;
|
||||
CoverImage.IsVisible = true;
|
||||
BackdropCoverImage.IsVisible = true;
|
||||
CoverFallbackGlyph.IsVisible = false;
|
||||
ApplyDynamicBackground(_coverBitmap);
|
||||
}
|
||||
catch
|
||||
{
|
||||
CoverImage.Source = null;
|
||||
BackdropCoverImage.Source = null;
|
||||
CoverImage.IsVisible = false;
|
||||
BackdropCoverImage.IsVisible = false;
|
||||
CoverFallbackGlyph.IsVisible = true;
|
||||
ApplyDynamicBackground(null);
|
||||
}
|
||||
}
|
||||
|
||||
private void DisposeCoverBitmap()
|
||||
{
|
||||
if (_coverBitmap is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (ReferenceEquals(CoverImage.Source, _coverBitmap))
|
||||
{
|
||||
CoverImage.Source = null;
|
||||
}
|
||||
|
||||
if (ReferenceEquals(BackdropCoverImage.Source, _coverBitmap))
|
||||
{
|
||||
BackdropCoverImage.Source = null;
|
||||
}
|
||||
|
||||
_coverBitmap.Dispose();
|
||||
_coverBitmap = null;
|
||||
}
|
||||
|
||||
private void UpdateProgressVisual(double ratio, bool indeterminate)
|
||||
{
|
||||
_progressRatio = Math.Clamp(ratio, 0, 1);
|
||||
_isProgressIndeterminate = indeterminate;
|
||||
|
||||
if (ProgressTrackHost.Bounds.Width <= 0)
|
||||
{
|
||||
return;
|
||||
@@ -606,18 +316,18 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget,
|
||||
return;
|
||||
}
|
||||
|
||||
ProgressFillBorder.Width = trackWidth * _progressRatio;
|
||||
ProgressFillBorder.Width = trackWidth * Math.Clamp(ratio, 0, 1);
|
||||
ProgressFillBorder.Opacity = 0.96;
|
||||
}
|
||||
|
||||
private void UpdateSourceAppButtonTooltip()
|
||||
{
|
||||
var sourceName = string.IsNullOrWhiteSpace(SourceAppTextBlock.Text)
|
||||
? L("music.widget.open_player", "Open player")
|
||||
: SourceAppTextBlock.Text;
|
||||
var statusText = string.IsNullOrWhiteSpace(StatusTextBlock.Text) || StatusTextBlock.Text == "--"
|
||||
var sourceName = string.IsNullOrWhiteSpace(_viewModel.SourceAppText)
|
||||
? "Open player"
|
||||
: _viewModel.SourceAppText;
|
||||
var statusText = string.IsNullOrWhiteSpace(_viewModel.StatusText) || _viewModel.StatusText == "--"
|
||||
? sourceName
|
||||
: string.Create(CultureInfo.InvariantCulture, $"{sourceName} ({StatusTextBlock.Text})");
|
||||
: $"{sourceName} ({_viewModel.StatusText})";
|
||||
ToolTip.SetTip(SourceAppButton, statusText);
|
||||
}
|
||||
|
||||
|
||||
@@ -180,10 +180,16 @@ public partial class MainWindow : Window
|
||||
_weatherLongitude = snapshot.WeatherLongitude;
|
||||
_weatherAutoRefreshLocation = snapshot.WeatherAutoRefreshLocation;
|
||||
_weatherExcludedAlertsRaw = snapshot.WeatherExcludedAlerts ?? string.Empty;
|
||||
_weatherIconPackId = string.IsNullOrWhiteSpace(snapshot.WeatherIconPackId) ? "HyperOS3" : snapshot.WeatherIconPackId;
|
||||
_weatherIconPackId = NormalizeWeatherIconPackId(snapshot.WeatherIconPackId);
|
||||
_weatherNoTlsRequests = snapshot.WeatherNoTlsRequests;
|
||||
}
|
||||
|
||||
private static string NormalizeWeatherIconPackId(string? iconPackId)
|
||||
{
|
||||
_ = iconPackId;
|
||||
return "DefaultWeather";
|
||||
}
|
||||
|
||||
private void InitializeAutoStartWithWindowsSetting(AppSettingsSnapshot snapshot)
|
||||
{
|
||||
_autoStartWithWindows = snapshot.AutoStartWithWindows;
|
||||
|
||||
@@ -167,7 +167,7 @@ public partial class MainWindow : Window
|
||||
private double _weatherLongitude = 116.4074;
|
||||
private bool _weatherAutoRefreshLocation;
|
||||
private string _weatherExcludedAlertsRaw = string.Empty;
|
||||
private string _weatherIconPackId = "HyperOS3";
|
||||
private string _weatherIconPackId = "DefaultWeather";
|
||||
private bool _weatherNoTlsRequests;
|
||||
private bool _autoStartWithWindows;
|
||||
private bool _suppressAutoStartToggleEvents;
|
||||
|
||||
@@ -37,5 +37,6 @@
|
||||
<Capabilities>
|
||||
<rescap:Capability Name="runFullTrust" />
|
||||
<uap5:Capability Name="userNotificationListener" />
|
||||
<uap5:Capability Name="globalMediaControl" />
|
||||
</Capabilities>
|
||||
</Package>
|
||||
|
||||
Reference in New Issue
Block a user