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.policy_hint_prefix": "For more details, please ",
|
||||||
"settings.privacy.view_policy": "view our privacy policy",
|
"settings.privacy.view_policy": "view our privacy policy",
|
||||||
"settings.weather.title": "Weather",
|
"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_header": "Location Source",
|
||||||
"settings.weather.location_source_desc": "Choose how weather widgets resolve location.",
|
"settings.weather.location_source_desc": "Choose how weather widgets resolve location.",
|
||||||
"settings.weather.mode_city_search": "City Search",
|
"settings.weather.mode_city_search": "City Search",
|
||||||
|
|||||||
@@ -131,7 +131,7 @@
|
|||||||
"settings.privacy.policy_hint_prefix": "詳細については、",
|
"settings.privacy.policy_hint_prefix": "詳細については、",
|
||||||
"settings.privacy.view_policy": "プライバシーポリシーをご覧ください",
|
"settings.privacy.view_policy": "プライバシーポリシーをご覧ください",
|
||||||
"settings.weather.title": "天気",
|
"settings.weather.title": "天気",
|
||||||
"settings.weather.description": "天気の場所、Xiaomi天気プレビュー、起動時の位置情報取得動作を設定します。",
|
"settings.weather.description": "天気の場所、天気プレビュー、起動時の位置情報取得動作を設定します。",
|
||||||
"settings.weather.location_source_header": "位置情報ソース",
|
"settings.weather.location_source_header": "位置情報ソース",
|
||||||
"settings.weather.location_source_desc": "天気ウィジェットが場所を解決する方法を選択します。",
|
"settings.weather.location_source_desc": "天気ウィジェットが場所を解決する方法を選択します。",
|
||||||
"settings.weather.mode_city_search": "都市検索",
|
"settings.weather.mode_city_search": "都市検索",
|
||||||
|
|||||||
@@ -132,7 +132,7 @@
|
|||||||
"settings.privacy.policy_hint_prefix": "자세한 내용은",
|
"settings.privacy.policy_hint_prefix": "자세한 내용은",
|
||||||
"settings.privacy.view_policy": "개인정보 처리방침 보기",
|
"settings.privacy.view_policy": "개인정보 처리방침 보기",
|
||||||
"settings.weather.title": "날씨",
|
"settings.weather.title": "날씨",
|
||||||
"settings.weather.description": "날씨 위치, Xiaomi 날씨 미리보기 및 시작 시 위치 새로고침 동작을 구성합니다.",
|
"settings.weather.description": "날씨 위치, 날씨 미리보기 및 시작 시 위치 새로고침 동작을 구성합니다.",
|
||||||
"settings.weather.location_source_header": "위치 소스",
|
"settings.weather.location_source_header": "위치 소스",
|
||||||
"settings.weather.location_source_desc": "날씨 컴포넌트가 현재 위치를 해석하는 방법을 선택합니다.",
|
"settings.weather.location_source_desc": "날씨 컴포넌트가 현재 위치를 해석하는 방법을 선택합니다.",
|
||||||
"settings.weather.mode_city_search": "도시 검색",
|
"settings.weather.mode_city_search": "도시 검색",
|
||||||
|
|||||||
@@ -133,7 +133,7 @@
|
|||||||
"settings.privacy.policy_hint_prefix": "了解更多详情,请",
|
"settings.privacy.policy_hint_prefix": "了解更多详情,请",
|
||||||
"settings.privacy.view_policy": "查看我们的隐私政策",
|
"settings.privacy.view_policy": "查看我们的隐私政策",
|
||||||
"settings.weather.title": "天气",
|
"settings.weather.title": "天气",
|
||||||
"settings.weather.description": "配置天气位置、小米天气预览和启动时的位置刷新行为。",
|
"settings.weather.description": "配置天气位置、天气预览和启动时的位置刷新行为。",
|
||||||
"settings.weather.location_source_header": "位置来源",
|
"settings.weather.location_source_header": "位置来源",
|
||||||
"settings.weather.location_source_desc": "选择天气组件如何解析当前位置。",
|
"settings.weather.location_source_desc": "选择天气组件如何解析当前位置。",
|
||||||
"settings.weather.mode_city_search": "城市搜索",
|
"settings.weather.mode_city_search": "城市搜索",
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ public sealed class AppSettingsSnapshot
|
|||||||
|
|
||||||
public string WeatherExcludedAlerts { get; set; } = string.Empty;
|
public string WeatherExcludedAlerts { get; set; } = string.Empty;
|
||||||
|
|
||||||
public string WeatherIconPackId { get; set; } = "HyperOS3";
|
public string WeatherIconPackId { get; set; } = "DefaultWeather";
|
||||||
|
|
||||||
public bool WeatherNoTlsRequests { get; set; }
|
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;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Services;
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
public enum MusicPlatform
|
||||||
|
{
|
||||||
|
Unknown = 0,
|
||||||
|
Windows = 1,
|
||||||
|
Linux = 2
|
||||||
|
}
|
||||||
|
|
||||||
public enum MusicPlaybackStatus
|
public enum MusicPlaybackStatus
|
||||||
{
|
{
|
||||||
Unknown = 0,
|
Unknown = 0,
|
||||||
@@ -17,8 +27,11 @@ public enum MusicPlaybackStatus
|
|||||||
public sealed record MusicPlaybackState(
|
public sealed record MusicPlaybackState(
|
||||||
bool IsSupported,
|
bool IsSupported,
|
||||||
bool HasSession,
|
bool HasSession,
|
||||||
|
MusicPlatform Platform,
|
||||||
|
string SessionId,
|
||||||
string SourceAppId,
|
string SourceAppId,
|
||||||
string SourceAppName,
|
string SourceAppName,
|
||||||
|
string SourceExecutableOrBusName,
|
||||||
string Title,
|
string Title,
|
||||||
string Artist,
|
string Artist,
|
||||||
string AlbumTitle,
|
string AlbumTitle,
|
||||||
@@ -28,15 +41,22 @@ public sealed record MusicPlaybackState(
|
|||||||
MusicPlaybackStatus PlaybackStatus,
|
MusicPlaybackStatus PlaybackStatus,
|
||||||
bool CanPlayPause,
|
bool CanPlayPause,
|
||||||
bool CanSkipPrevious,
|
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(
|
return new MusicPlaybackState(
|
||||||
IsSupported: false,
|
IsSupported: false,
|
||||||
HasSession: false,
|
HasSession: false,
|
||||||
|
Platform: MusicPlatform.Unknown,
|
||||||
|
SessionId: string.Empty,
|
||||||
SourceAppId: string.Empty,
|
SourceAppId: string.Empty,
|
||||||
SourceAppName: string.Empty,
|
SourceAppName: string.Empty,
|
||||||
|
SourceExecutableOrBusName: string.Empty,
|
||||||
Title: string.Empty,
|
Title: string.Empty,
|
||||||
Artist: string.Empty,
|
Artist: string.Empty,
|
||||||
AlbumTitle: string.Empty,
|
AlbumTitle: string.Empty,
|
||||||
@@ -46,16 +66,26 @@ public sealed record MusicPlaybackState(
|
|||||||
PlaybackStatus: MusicPlaybackStatus.Unknown,
|
PlaybackStatus: MusicPlaybackStatus.Unknown,
|
||||||
CanPlayPause: false,
|
CanPlayPause: false,
|
||||||
CanSkipPrevious: 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(
|
return new MusicPlaybackState(
|
||||||
IsSupported: isSupported,
|
IsSupported: isSupported,
|
||||||
HasSession: false,
|
HasSession: false,
|
||||||
|
Platform: platform,
|
||||||
|
SessionId: string.Empty,
|
||||||
SourceAppId: string.Empty,
|
SourceAppId: string.Empty,
|
||||||
SourceAppName: string.Empty,
|
SourceAppName: string.Empty,
|
||||||
|
SourceExecutableOrBusName: string.Empty,
|
||||||
Title: string.Empty,
|
Title: string.Empty,
|
||||||
Artist: string.Empty,
|
Artist: string.Empty,
|
||||||
AlbumTitle: string.Empty,
|
AlbumTitle: string.Empty,
|
||||||
@@ -65,12 +95,35 @@ public sealed record MusicPlaybackState(
|
|||||||
PlaybackStatus: MusicPlaybackStatus.Unknown,
|
PlaybackStatus: MusicPlaybackStatus.Unknown,
|
||||||
CanPlayPause: false,
|
CanPlayPause: false,
|
||||||
CanSkipPrevious: 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
|
public interface IMusicControlService
|
||||||
{
|
{
|
||||||
|
event EventHandler? StateChanged;
|
||||||
|
|
||||||
Task<MusicPlaybackState> GetCurrentStateAsync(CancellationToken cancellationToken = default);
|
Task<MusicPlaybackState> GetCurrentStateAsync(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<bool> TogglePlayPauseAsync(CancellationToken cancellationToken = default);
|
Task<bool> TogglePlayPauseAsync(CancellationToken cancellationToken = default);
|
||||||
@@ -82,40 +135,116 @@ public interface IMusicControlService
|
|||||||
Task<bool> LaunchSourceAppAsync(CancellationToken cancellationToken = default);
|
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 class MusicControlServiceFactory
|
||||||
{
|
{
|
||||||
public static IMusicControlService CreateDefault()
|
public static IMusicControlService CreateDefault()
|
||||||
{
|
{
|
||||||
return OperatingSystem.IsWindows()
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||||
? new WindowsSmtcMusicControlService()
|
{
|
||||||
: new NoOpMusicControlService();
|
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)
|
public MusicPlatform Platform => MusicPlatform.Unknown;
|
||||||
{
|
|
||||||
return Task.FromResult(MusicPlaybackState.Unsupported());
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<bool> TogglePlayPauseAsync(CancellationToken cancellationToken = default)
|
public event EventHandler? SessionsChanged;
|
||||||
{
|
|
||||||
return Task.FromResult(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<bool> SkipNextAsync(CancellationToken cancellationToken = default)
|
public Task<IReadOnlyList<MusicPlaybackState>> GetSessionsAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
=> Task.FromResult<IReadOnlyList<MusicPlaybackState>>([MusicPlaybackState.Unsupported()]);
|
||||||
return Task.FromResult(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<bool> SkipPreviousAsync(CancellationToken cancellationToken = default)
|
public Task<bool> TogglePlayPauseAsync(string sessionId, CancellationToken cancellationToken = default)
|
||||||
{
|
=> Task.FromResult(false);
|
||||||
return Task.FromResult(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<bool> LaunchSourceAppAsync(CancellationToken cancellationToken = default)
|
public Task<bool> SkipNextAsync(string sessionId, CancellationToken cancellationToken = default)
|
||||||
{
|
=> Task.FromResult(false);
|
||||||
return 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)
|
private static string NormalizeIconPackId(string? iconPackId)
|
||||||
{
|
{
|
||||||
return string.IsNullOrWhiteSpace(iconPackId)
|
_ = iconPackId;
|
||||||
? "HyperOS3"
|
return "DefaultWeather";
|
||||||
: "HyperOS3";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -125,9 +125,8 @@ public sealed class WeatherLocationRefreshService
|
|||||||
|
|
||||||
private static string NormalizeIconPackId(string? iconPackId)
|
private static string NormalizeIconPackId(string? iconPackId)
|
||||||
{
|
{
|
||||||
return string.IsNullOrWhiteSpace(iconPackId)
|
_ = iconPackId;
|
||||||
? "HyperOS3"
|
return "DefaultWeather";
|
||||||
: "HyperOS3";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private string BuildCoordinateDisplayName(string? languageCode, double latitude, double longitude)
|
private string BuildCoordinateDisplayName(string? languageCode, double latitude, double longitude)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
@@ -9,7 +10,7 @@ using System.Threading.Tasks;
|
|||||||
|
|
||||||
namespace LanMountainDesktop.Services;
|
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? SessionManagerType = ResolveWinRtType("Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager");
|
||||||
private static readonly Type? AppInfoType = ResolveWinRtType("Windows.ApplicationModel.AppInfo");
|
private static readonly Type? AppInfoType = ResolveWinRtType("Windows.ApplicationModel.AppInfo");
|
||||||
@@ -27,11 +28,24 @@ public sealed class WindowsSmtcMusicControlService : IMusicControlService
|
|||||||
private string _thumbnailKey = string.Empty;
|
private string _thumbnailKey = string.Empty;
|
||||||
private byte[]? _thumbnailBytesCache;
|
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())
|
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);
|
await _stateGate.WaitAsync(cancellationToken);
|
||||||
@@ -40,7 +54,7 @@ public sealed class WindowsSmtcMusicControlService : IMusicControlService
|
|||||||
var session = await GetCurrentSessionAsync(cancellationToken);
|
var session = await GetCurrentSessionAsync(cancellationToken);
|
||||||
if (session is null)
|
if (session is null)
|
||||||
{
|
{
|
||||||
return MusicPlaybackState.NoSession(isSupported: true);
|
return MusicPlaybackState.NoSession(isSupported: true, platform: MusicPlatform.Windows);
|
||||||
}
|
}
|
||||||
|
|
||||||
var mediaProperties = await TryGetMediaPropertiesAsync(session, cancellationToken);
|
var mediaProperties = await TryGetMediaPropertiesAsync(session, cancellationToken);
|
||||||
@@ -92,8 +106,11 @@ public sealed class WindowsSmtcMusicControlService : IMusicControlService
|
|||||||
return new MusicPlaybackState(
|
return new MusicPlaybackState(
|
||||||
IsSupported: true,
|
IsSupported: true,
|
||||||
HasSession: true,
|
HasSession: true,
|
||||||
|
Platform: MusicPlatform.Windows,
|
||||||
|
SessionId: sourceAppId,
|
||||||
SourceAppId: sourceAppId,
|
SourceAppId: sourceAppId,
|
||||||
SourceAppName: sourceAppName,
|
SourceAppName: sourceAppName,
|
||||||
|
SourceExecutableOrBusName: sourceAppId,
|
||||||
Title: title,
|
Title: title,
|
||||||
Artist: artist,
|
Artist: artist,
|
||||||
AlbumTitle: albumTitle,
|
AlbumTitle: albumTitle,
|
||||||
@@ -103,11 +120,26 @@ public sealed class WindowsSmtcMusicControlService : IMusicControlService
|
|||||||
PlaybackStatus: MapPlaybackStatus(playbackStatusRaw),
|
PlaybackStatus: MapPlaybackStatus(playbackStatusRaw),
|
||||||
CanPlayPause: canPlayPause,
|
CanPlayPause: canPlayPause,
|
||||||
CanSkipPrevious: canSkipPrevious,
|
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
|
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
|
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())
|
if (!IsRuntimeSupported())
|
||||||
{
|
{
|
||||||
@@ -153,7 +185,7 @@ public sealed class WindowsSmtcMusicControlService : IMusicControlService
|
|||||||
return await AwaitBooleanWinRtOperationAsync(operation, cancellationToken);
|
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())
|
if (!IsRuntimeSupported())
|
||||||
{
|
{
|
||||||
@@ -176,7 +208,7 @@ public sealed class WindowsSmtcMusicControlService : IMusicControlService
|
|||||||
return await AwaitBooleanWinRtOperationAsync(InvokeMethod(session, "TrySkipNextAsync"), cancellationToken);
|
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())
|
if (!IsRuntimeSupported())
|
||||||
{
|
{
|
||||||
@@ -199,7 +231,7 @@ public sealed class WindowsSmtcMusicControlService : IMusicControlService
|
|||||||
return await AwaitBooleanWinRtOperationAsync(InvokeMethod(session, "TrySkipPreviousAsync"), cancellationToken);
|
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())
|
if (!IsRuntimeSupported())
|
||||||
{
|
{
|
||||||
@@ -491,9 +523,18 @@ public sealed class WindowsSmtcMusicControlService : IMusicControlService
|
|||||||
return type?
|
return type?
|
||||||
.GetMethods(BindingFlags.Public | BindingFlags.Static)
|
.GetMethods(BindingFlags.Public | BindingFlags.Static)
|
||||||
.FirstOrDefault(method =>
|
.FirstOrDefault(method =>
|
||||||
method.Name == "AsTask" &&
|
{
|
||||||
method.IsGenericMethodDefinition &&
|
try
|
||||||
method.GetParameters().Length == 1);
|
{
|
||||||
|
return method.Name == "AsTask" &&
|
||||||
|
method.IsGenericMethodDefinition &&
|
||||||
|
method.GetParameters().Length == 1;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static MethodInfo? ResolveAsStreamForReadMethod()
|
private static MethodInfo? ResolveAsStreamForReadMethod()
|
||||||
@@ -576,4 +617,7 @@ public sealed class WindowsSmtcMusicControlService : IMusicControlService
|
|||||||
_ => MusicPlaybackStatus.Unknown
|
_ => 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,
|
selected.Longitude,
|
||||||
AutoRefreshLocation,
|
AutoRefreshLocation,
|
||||||
ExcludedAlerts ?? string.Empty,
|
ExcludedAlerts ?? string.Empty,
|
||||||
"HyperOS3",
|
"DefaultWeather",
|
||||||
NoTlsRequests,
|
NoTlsRequests,
|
||||||
SearchKeyword?.Trim() ?? string.Empty);
|
SearchKeyword?.Trim() ?? string.Empty);
|
||||||
|
|
||||||
@@ -527,7 +527,7 @@ public sealed partial class WeatherSettingsPageViewModel : ViewModelBase
|
|||||||
private void RefreshLocalizedText()
|
private void RefreshLocalizedText()
|
||||||
{
|
{
|
||||||
PageTitle = L("settings.weather.title", "Weather");
|
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");
|
PreviewHeader = L("settings.weather.preview_panel_header", "Weather Preview");
|
||||||
PreviewDescription = L("settings.weather.preview_panel_desc", "Refresh and verify current weather service status.");
|
PreviewDescription = L("settings.weather.preview_panel_desc", "Refresh and verify current weather service status.");
|
||||||
LocationSourceHeader = L("settings.weather.location_source_header", "Location Source");
|
LocationSourceHeader = L("settings.weather.location_source_header", "Location Source");
|
||||||
@@ -629,7 +629,7 @@ public sealed partial class WeatherSettingsPageViewModel : ViewModelBase
|
|||||||
Longitude,
|
Longitude,
|
||||||
AutoRefreshLocation,
|
AutoRefreshLocation,
|
||||||
ExcludedAlerts ?? string.Empty,
|
ExcludedAlerts ?? string.Empty,
|
||||||
"HyperOS3",
|
"DefaultWeather",
|
||||||
NoTlsRequests,
|
NoTlsRequests,
|
||||||
SearchKeyword?.Trim() ?? string.Empty);
|
SearchKeyword?.Trim() ?? string.Empty);
|
||||||
}
|
}
|
||||||
@@ -646,7 +646,7 @@ public sealed partial class WeatherSettingsPageViewModel : ViewModelBase
|
|||||||
SelectedSearchResult.Longitude,
|
SelectedSearchResult.Longitude,
|
||||||
AutoRefreshLocation,
|
AutoRefreshLocation,
|
||||||
ExcludedAlerts ?? string.Empty,
|
ExcludedAlerts ?? string.Empty,
|
||||||
"HyperOS3",
|
"DefaultWeather",
|
||||||
NoTlsRequests,
|
NoTlsRequests,
|
||||||
SearchKeyword?.Trim() ?? string.Empty);
|
SearchKeyword?.Trim() ?? string.Empty);
|
||||||
}
|
}
|
||||||
@@ -705,8 +705,9 @@ public sealed partial class WeatherSettingsPageViewModel : ViewModelBase
|
|||||||
return weatherText.Trim();
|
return weatherText.Trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
return XiaomiWeatherCodeMapper.ResolveDisplayText(weatherCode, _languageCode)
|
return weatherCode.HasValue
|
||||||
?? L("settings.weather.preview_unknown", "Unknown");
|
? string.Format(CultureInfo.InvariantCulture, "Weather {0}", weatherCode.Value)
|
||||||
|
: L("settings.weather.preview_unknown", "Unknown");
|
||||||
}
|
}
|
||||||
|
|
||||||
private CultureInfo ResolveCulture()
|
private CultureInfo ResolveCulture()
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
using System.ComponentModel;
|
||||||
using System.IO;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
@@ -14,48 +11,37 @@ using Avalonia.Threading;
|
|||||||
using FluentIcons.Common;
|
using FluentIcons.Common;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
using LanMountainDesktop.Theme;
|
using LanMountainDesktop.Theme;
|
||||||
|
using LanMountainDesktop.ViewModels;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Views.Components;
|
namespace LanMountainDesktop.Views.Components;
|
||||||
|
|
||||||
public partial class MusicControlWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget
|
public partial class MusicControlWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget
|
||||||
{
|
{
|
||||||
private const Symbol PlaySymbol = Symbol.Play;
|
|
||||||
private const Symbol PauseSymbol = Symbol.Pause;
|
|
||||||
|
|
||||||
private readonly DispatcherTimer _refreshTimer = new()
|
private readonly DispatcherTimer _refreshTimer = new()
|
||||||
{
|
{
|
||||||
Interval = TimeSpan.FromSeconds(2.4)
|
Interval = TimeSpan.FromSeconds(2.4)
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly IMusicControlService _musicControlService = MusicControlServiceFactory.CreateDefault();
|
private readonly MusicControlViewModel _viewModel = new();
|
||||||
private readonly MonetColorService _monetColorService = 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 double _currentCellSize = 48;
|
||||||
private bool _isAttached;
|
private bool _isAttached;
|
||||||
private bool _isOnActivePage = true;
|
private bool _isOnActivePage = true;
|
||||||
private bool _isRefreshing;
|
|
||||||
private bool _isExecutingCommand;
|
|
||||||
private double _progressRatio;
|
|
||||||
private bool _isProgressIndeterminate;
|
|
||||||
|
|
||||||
public MusicControlWidget()
|
public MusicControlWidget()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
DataContext = _viewModel;
|
||||||
|
|
||||||
_refreshTimer.Tick += OnRefreshTimerTick;
|
_refreshTimer.Tick += OnRefreshTimerTick;
|
||||||
|
_viewModel.PropertyChanged += OnViewModelPropertyChanged;
|
||||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||||
SizeChanged += OnSizeChanged;
|
SizeChanged += OnSizeChanged;
|
||||||
|
|
||||||
ApplyCellSize(_currentCellSize);
|
ApplyCellSize(_currentCellSize);
|
||||||
ApplyDynamicBackground(null);
|
ApplyViewModel();
|
||||||
ApplyState(MusicPlaybackState.NoSession(isSupported: OperatingSystem.IsWindows()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ApplyCellSize(double cellSize)
|
public void ApplyCellSize(double cellSize)
|
||||||
@@ -123,7 +109,7 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget,
|
|||||||
NextIcon.FontSize = Math.Clamp(18 * scale, 13, 24);
|
NextIcon.FontSize = Math.Clamp(18 * scale, 13, 24);
|
||||||
FavoriteIcon.FontSize = Math.Clamp(16 * scale, 11, 21);
|
FavoriteIcon.FontSize = Math.Clamp(16 * scale, 11, 21);
|
||||||
|
|
||||||
UpdateProgressVisual(_progressRatio, _isProgressIndeterminate);
|
UpdateProgressVisual(_viewModel.ProgressRatio, _viewModel.IsProgressIndeterminate);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
|
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
|
||||||
@@ -135,7 +121,7 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget,
|
|||||||
|
|
||||||
if (!wasOnActivePage && _isOnActivePage && _isAttached)
|
if (!wasOnActivePage && _isOnActivePage && _isAttached)
|
||||||
{
|
{
|
||||||
_ = RefreshStateAsync();
|
_ = _viewModel.RefreshAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,7 +131,7 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget,
|
|||||||
UpdateRefreshTimerState();
|
UpdateRefreshTimerState();
|
||||||
if (_isOnActivePage)
|
if (_isOnActivePage)
|
||||||
{
|
{
|
||||||
_ = RefreshStateAsync();
|
_ = _viewModel.RefreshAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,125 +139,28 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget,
|
|||||||
{
|
{
|
||||||
_isAttached = false;
|
_isAttached = false;
|
||||||
UpdateRefreshTimerState();
|
UpdateRefreshTimerState();
|
||||||
CancelRefreshRequest();
|
|
||||||
DisposeCoverBitmap();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||||
{
|
=> ApplyCellSize(_currentCellSize);
|
||||||
ApplyCellSize(_currentCellSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void OnRefreshTimerTick(object? sender, EventArgs e)
|
private async void OnRefreshTimerTick(object? sender, EventArgs e)
|
||||||
{
|
=> await _viewModel.RefreshAsync();
|
||||||
await RefreshStateAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void OnPlayPauseButtonClick(object? sender, RoutedEventArgs e)
|
private async void OnPlayPauseButtonClick(object? sender, RoutedEventArgs e)
|
||||||
{
|
=> await _viewModel.TogglePlayPauseAsync();
|
||||||
await ExecuteCommandAsync(token => _musicControlService.TogglePlayPauseAsync(token));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void OnPreviousButtonClick(object? sender, RoutedEventArgs e)
|
private async void OnPreviousButtonClick(object? sender, RoutedEventArgs e)
|
||||||
{
|
=> await _viewModel.SkipPreviousAsync();
|
||||||
await ExecuteCommandAsync(token => _musicControlService.SkipPreviousAsync(token));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void OnNextButtonClick(object? sender, RoutedEventArgs e)
|
private async void OnNextButtonClick(object? sender, RoutedEventArgs e)
|
||||||
{
|
=> await _viewModel.SkipNextAsync();
|
||||||
await ExecuteCommandAsync(token => _musicControlService.SkipNextAsync(token));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void OnSourceAppButtonClick(object? sender, RoutedEventArgs e)
|
private async void OnSourceAppButtonClick(object? sender, RoutedEventArgs e)
|
||||||
{
|
=> await _viewModel.LaunchSourceAsync();
|
||||||
await ExecuteCommandAsync(
|
|
||||||
token => _musicControlService.LaunchSourceAppAsync(token),
|
|
||||||
refreshAfterCommand: false,
|
|
||||||
requireActiveSession: false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ExecuteCommandAsync(
|
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
Func<CancellationToken, Task<bool>> command,
|
=> Dispatcher.UIThread.Post(ApplyViewModel);
|
||||||
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 UpdateRefreshTimerState()
|
private void UpdateRefreshTimerState()
|
||||||
{
|
{
|
||||||
@@ -288,109 +177,51 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget,
|
|||||||
_refreshTimer.Stop();
|
_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();
|
ApplyNoMediaVisualTheme();
|
||||||
ApplyActionButtonState(state);
|
}
|
||||||
UpdateSourceAppButtonTooltip();
|
else
|
||||||
return;
|
{
|
||||||
|
ApplyActiveVisualTheme();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!state.HasSession)
|
ApplyDynamicBackground(cover);
|
||||||
{
|
UpdateProgressVisual(_viewModel.ProgressRatio, _viewModel.IsProgressIndeterminate);
|
||||||
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);
|
|
||||||
UpdateSourceAppButtonTooltip();
|
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()
|
private void ApplyNoMediaVisualTheme()
|
||||||
{
|
{
|
||||||
ArtistTextBlock.MaxLines = 2;
|
|
||||||
|
|
||||||
DynamicBackgroundBase.Background = new SolidColorBrush(Color.Parse("#F0635D61"));
|
DynamicBackgroundBase.Background = new SolidColorBrush(Color.Parse("#F0635D61"));
|
||||||
DynamicGradientOverlay.Background = new LinearGradientBrush
|
DynamicGradientOverlay.Background = new LinearGradientBrush
|
||||||
{
|
{
|
||||||
@@ -444,8 +275,6 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget,
|
|||||||
|
|
||||||
private void ApplyActiveVisualTheme()
|
private void ApplyActiveVisualTheme()
|
||||||
{
|
{
|
||||||
ArtistTextBlock.MaxLines = 1;
|
|
||||||
|
|
||||||
CoverBorder.Background = new SolidColorBrush(Color.Parse("#3CFFFFFF"));
|
CoverBorder.Background = new SolidColorBrush(Color.Parse("#3CFFFFFF"));
|
||||||
CoverBorder.BorderBrush = new SolidColorBrush(Color.Parse("#77FFFFFF"));
|
CoverBorder.BorderBrush = new SolidColorBrush(Color.Parse("#77FFFFFF"));
|
||||||
CoverFallbackGlyph.Symbol = Symbol.Album;
|
CoverFallbackGlyph.Symbol = Symbol.Album;
|
||||||
@@ -460,49 +289,6 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget,
|
|||||||
SourceAppIcon.Foreground = new SolidColorBrush(Color.Parse("#F7FFFFFF"));
|
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()
|
private double ResolveScale()
|
||||||
{
|
{
|
||||||
var cellScale = Math.Clamp(_currentCellSize / 48d, 0.62, 2.1);
|
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);
|
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)
|
private void UpdateProgressVisual(double ratio, bool indeterminate)
|
||||||
{
|
{
|
||||||
_progressRatio = Math.Clamp(ratio, 0, 1);
|
|
||||||
_isProgressIndeterminate = indeterminate;
|
|
||||||
|
|
||||||
if (ProgressTrackHost.Bounds.Width <= 0)
|
if (ProgressTrackHost.Bounds.Width <= 0)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -606,18 +316,18 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget,
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ProgressFillBorder.Width = trackWidth * _progressRatio;
|
ProgressFillBorder.Width = trackWidth * Math.Clamp(ratio, 0, 1);
|
||||||
ProgressFillBorder.Opacity = 0.96;
|
ProgressFillBorder.Opacity = 0.96;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateSourceAppButtonTooltip()
|
private void UpdateSourceAppButtonTooltip()
|
||||||
{
|
{
|
||||||
var sourceName = string.IsNullOrWhiteSpace(SourceAppTextBlock.Text)
|
var sourceName = string.IsNullOrWhiteSpace(_viewModel.SourceAppText)
|
||||||
? L("music.widget.open_player", "Open player")
|
? "Open player"
|
||||||
: SourceAppTextBlock.Text;
|
: _viewModel.SourceAppText;
|
||||||
var statusText = string.IsNullOrWhiteSpace(StatusTextBlock.Text) || StatusTextBlock.Text == "--"
|
var statusText = string.IsNullOrWhiteSpace(_viewModel.StatusText) || _viewModel.StatusText == "--"
|
||||||
? sourceName
|
? sourceName
|
||||||
: string.Create(CultureInfo.InvariantCulture, $"{sourceName} ({StatusTextBlock.Text})");
|
: $"{sourceName} ({_viewModel.StatusText})";
|
||||||
ToolTip.SetTip(SourceAppButton, statusText);
|
ToolTip.SetTip(SourceAppButton, statusText);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -180,10 +180,16 @@ public partial class MainWindow : Window
|
|||||||
_weatherLongitude = snapshot.WeatherLongitude;
|
_weatherLongitude = snapshot.WeatherLongitude;
|
||||||
_weatherAutoRefreshLocation = snapshot.WeatherAutoRefreshLocation;
|
_weatherAutoRefreshLocation = snapshot.WeatherAutoRefreshLocation;
|
||||||
_weatherExcludedAlertsRaw = snapshot.WeatherExcludedAlerts ?? string.Empty;
|
_weatherExcludedAlertsRaw = snapshot.WeatherExcludedAlerts ?? string.Empty;
|
||||||
_weatherIconPackId = string.IsNullOrWhiteSpace(snapshot.WeatherIconPackId) ? "HyperOS3" : snapshot.WeatherIconPackId;
|
_weatherIconPackId = NormalizeWeatherIconPackId(snapshot.WeatherIconPackId);
|
||||||
_weatherNoTlsRequests = snapshot.WeatherNoTlsRequests;
|
_weatherNoTlsRequests = snapshot.WeatherNoTlsRequests;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string NormalizeWeatherIconPackId(string? iconPackId)
|
||||||
|
{
|
||||||
|
_ = iconPackId;
|
||||||
|
return "DefaultWeather";
|
||||||
|
}
|
||||||
|
|
||||||
private void InitializeAutoStartWithWindowsSetting(AppSettingsSnapshot snapshot)
|
private void InitializeAutoStartWithWindowsSetting(AppSettingsSnapshot snapshot)
|
||||||
{
|
{
|
||||||
_autoStartWithWindows = snapshot.AutoStartWithWindows;
|
_autoStartWithWindows = snapshot.AutoStartWithWindows;
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ public partial class MainWindow : Window
|
|||||||
private double _weatherLongitude = 116.4074;
|
private double _weatherLongitude = 116.4074;
|
||||||
private bool _weatherAutoRefreshLocation;
|
private bool _weatherAutoRefreshLocation;
|
||||||
private string _weatherExcludedAlertsRaw = string.Empty;
|
private string _weatherExcludedAlertsRaw = string.Empty;
|
||||||
private string _weatherIconPackId = "HyperOS3";
|
private string _weatherIconPackId = "DefaultWeather";
|
||||||
private bool _weatherNoTlsRequests;
|
private bool _weatherNoTlsRequests;
|
||||||
private bool _autoStartWithWindows;
|
private bool _autoStartWithWindows;
|
||||||
private bool _suppressAutoStartToggleEvents;
|
private bool _suppressAutoStartToggleEvents;
|
||||||
|
|||||||
@@ -37,5 +37,6 @@
|
|||||||
<Capabilities>
|
<Capabilities>
|
||||||
<rescap:Capability Name="runFullTrust" />
|
<rescap:Capability Name="runFullTrust" />
|
||||||
<uap5:Capability Name="userNotificationListener" />
|
<uap5:Capability Name="userNotificationListener" />
|
||||||
|
<uap5:Capability Name="globalMediaControl" />
|
||||||
</Capabilities>
|
</Capabilities>
|
||||||
</Package>
|
</Package>
|
||||||
|
|||||||
Reference in New Issue
Block a user