fix.消息盒子媒体播放器组件服务修复

This commit is contained in:
lincube
2026-05-12 18:49:04 +08:00
parent 33c264f6dd
commit b48056391a
17 changed files with 1202 additions and 410 deletions

View 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);
}

View File

@@ -133,7 +133,7 @@
"settings.privacy.policy_hint_prefix": "For more details, please ",
"settings.privacy.view_policy": "view our privacy policy",
"settings.weather.title": "Weather",
"settings.weather.description": "Configure weather location, Xiaomi weather preview, and startup positioning behavior.",
"settings.weather.description": "Configure weather location, weather preview, and startup positioning behavior.",
"settings.weather.location_source_header": "Location Source",
"settings.weather.location_source_desc": "Choose how weather widgets resolve location.",
"settings.weather.mode_city_search": "City Search",

View File

@@ -131,7 +131,7 @@
"settings.privacy.policy_hint_prefix": "詳細については、",
"settings.privacy.view_policy": "プライバシーポリシーをご覧ください",
"settings.weather.title": "天気",
"settings.weather.description": "天気の場所、Xiaomi天気プレビュー、起動時の位置情報取得動作を設定します。",
"settings.weather.description": "天気の場所、天気プレビュー、起動時の位置情報取得動作を設定します。",
"settings.weather.location_source_header": "位置情報ソース",
"settings.weather.location_source_desc": "天気ウィジェットが場所を解決する方法を選択します。",
"settings.weather.mode_city_search": "都市検索",

View File

@@ -132,7 +132,7 @@
"settings.privacy.policy_hint_prefix": "자세한 내용은",
"settings.privacy.view_policy": "개인정보 처리방침 보기",
"settings.weather.title": "날씨",
"settings.weather.description": "날씨 위치, Xiaomi 날씨 미리보기 및 시작 시 위치 새로고침 동작을 구성합니다.",
"settings.weather.description": "날씨 위치, 날씨 미리보기 및 시작 시 위치 새로고침 동작을 구성합니다.",
"settings.weather.location_source_header": "위치 소스",
"settings.weather.location_source_desc": "날씨 컴포넌트가 현재 위치를 해석하는 방법을 선택합니다.",
"settings.weather.mode_city_search": "도시 검색",

View File

@@ -133,7 +133,7 @@
"settings.privacy.policy_hint_prefix": "了解更多详情,请",
"settings.privacy.view_policy": "查看我们的隐私政策",
"settings.weather.title": "天气",
"settings.weather.description": "配置天气位置、小米天气预览和启动时的位置刷新行为。",
"settings.weather.description": "配置天气位置、天气预览和启动时的位置刷新行为。",
"settings.weather.location_source_header": "位置来源",
"settings.weather.location_source_desc": "选择天气组件如何解析当前位置。",
"settings.weather.mode_city_search": "城市搜索",

View File

@@ -69,7 +69,7 @@ public sealed class AppSettingsSnapshot
public string WeatherExcludedAlerts { get; set; } = string.Empty;
public string WeatherIconPackId { get; set; } = "HyperOS3";
public string WeatherIconPackId { get; set; } = "DefaultWeather";
public bool WeatherNoTlsRequests { get; set; }

View File

@@ -1,9 +1,19 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
namespace LanMountainDesktop.Services;
public enum MusicPlatform
{
Unknown = 0,
Windows = 1,
Linux = 2
}
public enum MusicPlaybackStatus
{
Unknown = 0,
@@ -17,8 +27,11 @@ public enum MusicPlaybackStatus
public sealed record MusicPlaybackState(
bool IsSupported,
bool HasSession,
MusicPlatform Platform,
string SessionId,
string SourceAppId,
string SourceAppName,
string SourceExecutableOrBusName,
string Title,
string Artist,
string AlbumTitle,
@@ -28,15 +41,22 @@ public sealed record MusicPlaybackState(
MusicPlaybackStatus PlaybackStatus,
bool CanPlayPause,
bool CanSkipPrevious,
bool CanSkipNext)
bool CanSkipNext,
bool CanLaunch,
bool IsStale,
string StatusMessage,
DateTimeOffset UpdatedAtUtc)
{
public static MusicPlaybackState Unsupported()
public static MusicPlaybackState Unsupported(string statusMessage = "Music control is not supported on this platform.")
{
return new MusicPlaybackState(
IsSupported: false,
HasSession: false,
Platform: MusicPlatform.Unknown,
SessionId: string.Empty,
SourceAppId: string.Empty,
SourceAppName: string.Empty,
SourceExecutableOrBusName: string.Empty,
Title: string.Empty,
Artist: string.Empty,
AlbumTitle: string.Empty,
@@ -46,16 +66,26 @@ public sealed record MusicPlaybackState(
PlaybackStatus: MusicPlaybackStatus.Unknown,
CanPlayPause: false,
CanSkipPrevious: false,
CanSkipNext: false);
CanSkipNext: false,
CanLaunch: false,
IsStale: false,
StatusMessage: statusMessage,
UpdatedAtUtc: DateTimeOffset.UtcNow);
}
public static MusicPlaybackState NoSession(bool isSupported = true)
public static MusicPlaybackState NoSession(
bool isSupported = true,
MusicPlatform platform = MusicPlatform.Unknown,
string statusMessage = "No active media session.")
{
return new MusicPlaybackState(
IsSupported: isSupported,
HasSession: false,
Platform: platform,
SessionId: string.Empty,
SourceAppId: string.Empty,
SourceAppName: string.Empty,
SourceExecutableOrBusName: string.Empty,
Title: string.Empty,
Artist: string.Empty,
AlbumTitle: string.Empty,
@@ -65,12 +95,35 @@ public sealed record MusicPlaybackState(
PlaybackStatus: MusicPlaybackStatus.Unknown,
CanPlayPause: false,
CanSkipPrevious: false,
CanSkipNext: false);
CanSkipNext: false,
CanLaunch: false,
IsStale: false,
StatusMessage: statusMessage,
UpdatedAtUtc: DateTimeOffset.UtcNow);
}
}
public interface IMusicSessionProvider : IDisposable
{
MusicPlatform Platform { get; }
event EventHandler? SessionsChanged;
Task<IReadOnlyList<MusicPlaybackState>> GetSessionsAsync(CancellationToken cancellationToken = default);
Task<bool> TogglePlayPauseAsync(string sessionId, CancellationToken cancellationToken = default);
Task<bool> SkipNextAsync(string sessionId, CancellationToken cancellationToken = default);
Task<bool> SkipPreviousAsync(string sessionId, CancellationToken cancellationToken = default);
Task<bool> LaunchSourceAppAsync(string sessionId, CancellationToken cancellationToken = default);
}
public interface IMusicControlService
{
event EventHandler? StateChanged;
Task<MusicPlaybackState> GetCurrentStateAsync(CancellationToken cancellationToken = default);
Task<bool> TogglePlayPauseAsync(CancellationToken cancellationToken = default);
@@ -82,40 +135,116 @@ public interface IMusicControlService
Task<bool> LaunchSourceAppAsync(CancellationToken cancellationToken = default);
}
public sealed class MusicControlService : IMusicControlService, IDisposable
{
private readonly IMusicSessionProvider _provider;
private MusicPlaybackState _currentState = MusicPlaybackState.NoSession();
public MusicControlService(IMusicSessionProvider provider)
{
_provider = provider;
_provider.SessionsChanged += OnProviderSessionsChanged;
}
public event EventHandler? StateChanged;
public async Task<MusicPlaybackState> GetCurrentStateAsync(CancellationToken cancellationToken = default)
{
var sessions = await _provider.GetSessionsAsync(cancellationToken).ConfigureAwait(false);
_currentState = SelectCurrentSession(sessions, _provider.Platform);
return _currentState;
}
public Task<bool> TogglePlayPauseAsync(CancellationToken cancellationToken = default)
=> ExecuteOnCurrentSessionAsync((sessionId, token) => _provider.TogglePlayPauseAsync(sessionId, token), cancellationToken);
public Task<bool> SkipNextAsync(CancellationToken cancellationToken = default)
=> ExecuteOnCurrentSessionAsync((sessionId, token) => _provider.SkipNextAsync(sessionId, token), cancellationToken);
public Task<bool> SkipPreviousAsync(CancellationToken cancellationToken = default)
=> ExecuteOnCurrentSessionAsync((sessionId, token) => _provider.SkipPreviousAsync(sessionId, token), cancellationToken);
public Task<bool> LaunchSourceAppAsync(CancellationToken cancellationToken = default)
=> ExecuteOnCurrentSessionAsync((sessionId, token) => _provider.LaunchSourceAppAsync(sessionId, token), cancellationToken);
internal static MusicPlaybackState SelectCurrentSession(IReadOnlyList<MusicPlaybackState> sessions, MusicPlatform platform)
{
if (sessions.Count == 0)
{
return MusicPlaybackState.NoSession(isSupported: true, platform: platform);
}
return sessions
.OrderByDescending(session => session.PlaybackStatus == MusicPlaybackStatus.Playing)
.ThenByDescending(session => session.UpdatedAtUtc)
.First();
}
public void Dispose()
{
_provider.SessionsChanged -= OnProviderSessionsChanged;
_provider.Dispose();
}
private async Task<bool> ExecuteOnCurrentSessionAsync(
Func<string, CancellationToken, Task<bool>> command,
CancellationToken cancellationToken)
{
var state = _currentState.HasSession
? _currentState
: await GetCurrentStateAsync(cancellationToken).ConfigureAwait(false);
if (!state.IsSupported || !state.HasSession || string.IsNullOrWhiteSpace(state.SessionId))
{
return false;
}
return await command(state.SessionId, cancellationToken).ConfigureAwait(false);
}
private void OnProviderSessionsChanged(object? sender, EventArgs e)
=> StateChanged?.Invoke(this, EventArgs.Empty);
}
public static class MusicControlServiceFactory
{
public static IMusicControlService CreateDefault()
{
return OperatingSystem.IsWindows()
? new WindowsSmtcMusicControlService()
: new NoOpMusicControlService();
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return new MusicControlService(new WindowsSmtcMusicControlService());
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
return new MusicControlService(new LinuxMprisMusicSessionProvider());
}
return new MusicControlService(new NoOpMusicSessionProvider());
}
}
internal sealed class NoOpMusicControlService : IMusicControlService
internal sealed class NoOpMusicSessionProvider : IMusicSessionProvider
{
public Task<MusicPlaybackState> GetCurrentStateAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult(MusicPlaybackState.Unsupported());
}
public MusicPlatform Platform => MusicPlatform.Unknown;
public Task<bool> TogglePlayPauseAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult(false);
}
public event EventHandler? SessionsChanged;
public Task<bool> SkipNextAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult(false);
}
public Task<IReadOnlyList<MusicPlaybackState>> GetSessionsAsync(CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<MusicPlaybackState>>([MusicPlaybackState.Unsupported()]);
public Task<bool> SkipPreviousAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult(false);
}
public Task<bool> TogglePlayPauseAsync(string sessionId, CancellationToken cancellationToken = default)
=> Task.FromResult(false);
public Task<bool> LaunchSourceAppAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult(false);
}
public Task<bool> SkipNextAsync(string sessionId, CancellationToken cancellationToken = default)
=> Task.FromResult(false);
public Task<bool> SkipPreviousAsync(string sessionId, CancellationToken cancellationToken = default)
=> Task.FromResult(false);
public Task<bool> LaunchSourceAppAsync(string sessionId, CancellationToken cancellationToken = default)
=> Task.FromResult(false);
public void Dispose()
=> SessionsChanged = null;
}

View 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);
}

View File

@@ -668,9 +668,8 @@ internal sealed class WeatherSettingsService : IWeatherSettingsService, IDisposa
private static string NormalizeIconPackId(string? iconPackId)
{
return string.IsNullOrWhiteSpace(iconPackId)
? "HyperOS3"
: "HyperOS3";
_ = iconPackId;
return "DefaultWeather";
}
}

View File

@@ -125,9 +125,8 @@ public sealed class WeatherLocationRefreshService
private static string NormalizeIconPackId(string? iconPackId)
{
return string.IsNullOrWhiteSpace(iconPackId)
? "HyperOS3"
: "HyperOS3";
_ = iconPackId;
return "DefaultWeather";
}
private string BuildCoordinateDisplayName(string? languageCode, double latitude, double longitude)

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
@@ -9,7 +10,7 @@ using System.Threading.Tasks;
namespace LanMountainDesktop.Services;
public sealed class WindowsSmtcMusicControlService : IMusicControlService
public sealed class WindowsSmtcMusicControlService : IMusicSessionProvider
{
private static readonly Type? SessionManagerType = ResolveWinRtType("Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager");
private static readonly Type? AppInfoType = ResolveWinRtType("Windows.ApplicationModel.AppInfo");
@@ -27,11 +28,24 @@ public sealed class WindowsSmtcMusicControlService : IMusicControlService
private string _thumbnailKey = string.Empty;
private byte[]? _thumbnailBytesCache;
public async Task<MusicPlaybackState> GetCurrentStateAsync(CancellationToken cancellationToken = default)
public MusicPlatform Platform => MusicPlatform.Windows;
public event EventHandler? SessionsChanged;
public async Task<IReadOnlyList<MusicPlaybackState>> GetSessionsAsync(CancellationToken cancellationToken = default)
{
var state = await GetCurrentStateAsync(cancellationToken).ConfigureAwait(false);
return state.HasSession || !state.IsSupported
? [state]
: [];
}
private async Task<MusicPlaybackState> GetCurrentStateAsync(CancellationToken cancellationToken = default)
{
if (!IsRuntimeSupported())
{
return MusicPlaybackState.Unsupported();
return MusicPlaybackState.Unsupported(
"Windows media control is unavailable. Check the Windows version, WinRT runtime, and globalMediaControl capability.");
}
await _stateGate.WaitAsync(cancellationToken);
@@ -40,7 +54,7 @@ public sealed class WindowsSmtcMusicControlService : IMusicControlService
var session = await GetCurrentSessionAsync(cancellationToken);
if (session is null)
{
return MusicPlaybackState.NoSession(isSupported: true);
return MusicPlaybackState.NoSession(isSupported: true, platform: MusicPlatform.Windows);
}
var mediaProperties = await TryGetMediaPropertiesAsync(session, cancellationToken);
@@ -92,8 +106,11 @@ public sealed class WindowsSmtcMusicControlService : IMusicControlService
return new MusicPlaybackState(
IsSupported: true,
HasSession: true,
Platform: MusicPlatform.Windows,
SessionId: sourceAppId,
SourceAppId: sourceAppId,
SourceAppName: sourceAppName,
SourceExecutableOrBusName: sourceAppId,
Title: title,
Artist: artist,
AlbumTitle: albumTitle,
@@ -103,11 +120,26 @@ public sealed class WindowsSmtcMusicControlService : IMusicControlService
PlaybackStatus: MapPlaybackStatus(playbackStatusRaw),
CanPlayPause: canPlayPause,
CanSkipPrevious: canSkipPrevious,
CanSkipNext: canSkipNext);
CanSkipNext: canSkipNext,
CanLaunch: !string.IsNullOrWhiteSpace(sourceAppId),
IsStale: false,
StatusMessage: string.Empty,
UpdatedAtUtc: DateTimeOffset.UtcNow);
}
catch (UnauthorizedAccessException ex)
{
return MusicPlaybackState.Unsupported($"Windows media control permission or capability is missing: {ex.Message}");
}
catch (TargetInvocationException ex) when (ex.InnerException is UnauthorizedAccessException inner)
{
return MusicPlaybackState.Unsupported($"Windows media control permission or capability is missing: {inner.Message}");
}
catch
{
return MusicPlaybackState.NoSession(isSupported: true);
return MusicPlaybackState.NoSession(
isSupported: true,
platform: MusicPlatform.Windows,
statusMessage: "Windows media session was found but could not be read.");
}
finally
{
@@ -115,7 +147,7 @@ public sealed class WindowsSmtcMusicControlService : IMusicControlService
}
}
public async Task<bool> TogglePlayPauseAsync(CancellationToken cancellationToken = default)
public async Task<bool> TogglePlayPauseAsync(string sessionId, CancellationToken cancellationToken = default)
{
if (!IsRuntimeSupported())
{
@@ -153,7 +185,7 @@ public sealed class WindowsSmtcMusicControlService : IMusicControlService
return await AwaitBooleanWinRtOperationAsync(operation, cancellationToken);
}
public async Task<bool> SkipNextAsync(CancellationToken cancellationToken = default)
public async Task<bool> SkipNextAsync(string sessionId, CancellationToken cancellationToken = default)
{
if (!IsRuntimeSupported())
{
@@ -176,7 +208,7 @@ public sealed class WindowsSmtcMusicControlService : IMusicControlService
return await AwaitBooleanWinRtOperationAsync(InvokeMethod(session, "TrySkipNextAsync"), cancellationToken);
}
public async Task<bool> SkipPreviousAsync(CancellationToken cancellationToken = default)
public async Task<bool> SkipPreviousAsync(string sessionId, CancellationToken cancellationToken = default)
{
if (!IsRuntimeSupported())
{
@@ -199,7 +231,7 @@ public sealed class WindowsSmtcMusicControlService : IMusicControlService
return await AwaitBooleanWinRtOperationAsync(InvokeMethod(session, "TrySkipPreviousAsync"), cancellationToken);
}
public async Task<bool> LaunchSourceAppAsync(CancellationToken cancellationToken = default)
public async Task<bool> LaunchSourceAppAsync(string sessionId, CancellationToken cancellationToken = default)
{
if (!IsRuntimeSupported())
{
@@ -491,9 +523,18 @@ public sealed class WindowsSmtcMusicControlService : IMusicControlService
return type?
.GetMethods(BindingFlags.Public | BindingFlags.Static)
.FirstOrDefault(method =>
method.Name == "AsTask" &&
{
try
{
return method.Name == "AsTask" &&
method.IsGenericMethodDefinition &&
method.GetParameters().Length == 1);
method.GetParameters().Length == 1;
}
catch
{
return false;
}
});
}
private static MethodInfo? ResolveAsStreamForReadMethod()
@@ -576,4 +617,7 @@ public sealed class WindowsSmtcMusicControlService : IMusicControlService
_ => MusicPlaybackStatus.Unknown
};
}
public void Dispose()
=> SessionsChanged = null;
}

View 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);
}

View File

@@ -345,7 +345,7 @@ public sealed partial class WeatherSettingsPageViewModel : ViewModelBase
selected.Longitude,
AutoRefreshLocation,
ExcludedAlerts ?? string.Empty,
"HyperOS3",
"DefaultWeather",
NoTlsRequests,
SearchKeyword?.Trim() ?? string.Empty);
@@ -527,7 +527,7 @@ public sealed partial class WeatherSettingsPageViewModel : ViewModelBase
private void RefreshLocalizedText()
{
PageTitle = L("settings.weather.title", "Weather");
PageDescription = L("settings.weather.description", "Configure weather location, automatic positioning, and Xiaomi weather preview.");
PageDescription = L("settings.weather.description", "Configure weather location, weather preview, and startup positioning behavior.");
PreviewHeader = L("settings.weather.preview_panel_header", "Weather Preview");
PreviewDescription = L("settings.weather.preview_panel_desc", "Refresh and verify current weather service status.");
LocationSourceHeader = L("settings.weather.location_source_header", "Location Source");
@@ -629,7 +629,7 @@ public sealed partial class WeatherSettingsPageViewModel : ViewModelBase
Longitude,
AutoRefreshLocation,
ExcludedAlerts ?? string.Empty,
"HyperOS3",
"DefaultWeather",
NoTlsRequests,
SearchKeyword?.Trim() ?? string.Empty);
}
@@ -646,7 +646,7 @@ public sealed partial class WeatherSettingsPageViewModel : ViewModelBase
SelectedSearchResult.Longitude,
AutoRefreshLocation,
ExcludedAlerts ?? string.Empty,
"HyperOS3",
"DefaultWeather",
NoTlsRequests,
SearchKeyword?.Trim() ?? string.Empty);
}
@@ -705,8 +705,9 @@ public sealed partial class WeatherSettingsPageViewModel : ViewModelBase
return weatherText.Trim();
}
return XiaomiWeatherCodeMapper.ResolveDisplayText(weatherCode, _languageCode)
?? L("settings.weather.preview_unknown", "Unknown");
return weatherCode.HasValue
? string.Format(CultureInfo.InvariantCulture, "Weather {0}", weatherCode.Value)
: L("settings.weather.preview_unknown", "Unknown");
}
private CultureInfo ResolveCulture()

View File

@@ -1,9 +1,6 @@
using System;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using System.ComponentModel;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
@@ -14,48 +11,37 @@ using Avalonia.Threading;
using FluentIcons.Common;
using LanMountainDesktop.Services;
using LanMountainDesktop.Theme;
using LanMountainDesktop.ViewModels;
namespace LanMountainDesktop.Views.Components;
public partial class MusicControlWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget
{
private const Symbol PlaySymbol = Symbol.Play;
private const Symbol PauseSymbol = Symbol.Pause;
private readonly DispatcherTimer _refreshTimer = new()
{
Interval = TimeSpan.FromSeconds(2.4)
};
private readonly IMusicControlService _musicControlService = MusicControlServiceFactory.CreateDefault();
private readonly MusicControlViewModel _viewModel = new();
private readonly MonetColorService _monetColorService = new();
private LanMountainDesktop.PluginSdk.ISettingsService _settingsService = LanMountainDesktop.Services.Settings.HostSettingsFacadeProvider.GetOrCreate().Settings;
private readonly LocalizationService _localizationService = new();
private CancellationTokenSource? _refreshCts;
private Bitmap? _coverBitmap;
private MusicPlaybackState _currentState = MusicPlaybackState.NoSession(isSupported: true);
private string _languageCode = "zh-CN";
private double _currentCellSize = 48;
private bool _isAttached;
private bool _isOnActivePage = true;
private bool _isRefreshing;
private bool _isExecutingCommand;
private double _progressRatio;
private bool _isProgressIndeterminate;
public MusicControlWidget()
{
InitializeComponent();
DataContext = _viewModel;
_refreshTimer.Tick += OnRefreshTimerTick;
_viewModel.PropertyChanged += OnViewModelPropertyChanged;
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
ApplyCellSize(_currentCellSize);
ApplyDynamicBackground(null);
ApplyState(MusicPlaybackState.NoSession(isSupported: OperatingSystem.IsWindows()));
ApplyViewModel();
}
public void ApplyCellSize(double cellSize)
@@ -123,7 +109,7 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget,
NextIcon.FontSize = Math.Clamp(18 * scale, 13, 24);
FavoriteIcon.FontSize = Math.Clamp(16 * scale, 11, 21);
UpdateProgressVisual(_progressRatio, _isProgressIndeterminate);
UpdateProgressVisual(_viewModel.ProgressRatio, _viewModel.IsProgressIndeterminate);
}
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
@@ -135,7 +121,7 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget,
if (!wasOnActivePage && _isOnActivePage && _isAttached)
{
_ = RefreshStateAsync();
_ = _viewModel.RefreshAsync();
}
}
@@ -145,7 +131,7 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget,
UpdateRefreshTimerState();
if (_isOnActivePage)
{
_ = RefreshStateAsync();
_ = _viewModel.RefreshAsync();
}
}
@@ -153,125 +139,28 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget,
{
_isAttached = false;
UpdateRefreshTimerState();
CancelRefreshRequest();
DisposeCoverBitmap();
}
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
{
ApplyCellSize(_currentCellSize);
}
=> ApplyCellSize(_currentCellSize);
private async void OnRefreshTimerTick(object? sender, EventArgs e)
{
await RefreshStateAsync();
}
=> await _viewModel.RefreshAsync();
private async void OnPlayPauseButtonClick(object? sender, RoutedEventArgs e)
{
await ExecuteCommandAsync(token => _musicControlService.TogglePlayPauseAsync(token));
}
=> await _viewModel.TogglePlayPauseAsync();
private async void OnPreviousButtonClick(object? sender, RoutedEventArgs e)
{
await ExecuteCommandAsync(token => _musicControlService.SkipPreviousAsync(token));
}
=> await _viewModel.SkipPreviousAsync();
private async void OnNextButtonClick(object? sender, RoutedEventArgs e)
{
await ExecuteCommandAsync(token => _musicControlService.SkipNextAsync(token));
}
=> await _viewModel.SkipNextAsync();
private async void OnSourceAppButtonClick(object? sender, RoutedEventArgs e)
{
await ExecuteCommandAsync(
token => _musicControlService.LaunchSourceAppAsync(token),
refreshAfterCommand: false,
requireActiveSession: false);
}
=> await _viewModel.LaunchSourceAsync();
private async Task ExecuteCommandAsync(
Func<CancellationToken, Task<bool>> command,
bool refreshAfterCommand = true,
bool requireActiveSession = true)
{
if (_isExecutingCommand
|| !_currentState.IsSupported
|| (requireActiveSession && !_currentState.HasSession))
{
return;
}
_isExecutingCommand = true;
ApplyActionButtonState(_currentState);
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(4));
_ = await command(cts.Token);
}
catch
{
// Ignore command transport errors and recover on next poll.
}
finally
{
_isExecutingCommand = false;
}
if (refreshAfterCommand)
{
await RefreshStateAsync();
}
}
private async Task RefreshStateAsync()
{
if (!_isAttached || !_isOnActivePage || _isRefreshing)
{
return;
}
_isRefreshing = true;
UpdateLanguageCode();
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var previous = Interlocked.Exchange(ref _refreshCts, cts);
previous?.Cancel();
previous?.Dispose();
try
{
var state = await _musicControlService.GetCurrentStateAsync(cts.Token);
if (cts.IsCancellationRequested || !_isAttached)
{
return;
}
_currentState = state;
ApplyState(state);
}
catch (OperationCanceledException)
{
// Ignore cancellation.
}
catch
{
var fallbackState = MusicPlaybackState.NoSession(isSupported: OperatingSystem.IsWindows());
_currentState = fallbackState;
ApplyState(fallbackState);
}
finally
{
if (ReferenceEquals(_refreshCts, cts))
{
_refreshCts = null;
}
cts.Dispose();
_isRefreshing = false;
}
}
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
=> Dispatcher.UIThread.Post(ApplyViewModel);
private void UpdateRefreshTimerState()
{
@@ -288,109 +177,51 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget,
_refreshTimer.Stop();
}
private void ApplyState(MusicPlaybackState state)
private void ApplyViewModel()
{
var hasMediaSession = state.IsSupported && state.HasSession;
var state = _viewModel.State;
var cover = _viewModel.Cover;
var hasCover = cover is not null;
if (!state.IsSupported)
TitleTextBlock.Text = _viewModel.TitleText;
ArtistTextBlock.Text = _viewModel.ArtistText;
ArtistTextBlock.MaxLines = _viewModel.IsNoMedia ? 2 : 1;
SourceAppTextBlock.Text = _viewModel.SourceAppText;
StatusTextBlock.Text = _viewModel.StatusText;
PositionTextBlock.Text = _viewModel.PositionText;
DurationTextBlock.Text = _viewModel.DurationText;
PlaybackActivityIcon.IsVisible = _viewModel.IsPlaybackActive;
PlayPauseGlyphIcon.Symbol = _viewModel.IsPlaybackActive ? Symbol.Pause : Symbol.Play;
PlayPauseButton.IsEnabled = _viewModel.CanPlayPause;
PreviousButton.IsEnabled = _viewModel.CanSkipPrevious;
NextButton.IsEnabled = _viewModel.CanSkipNext;
SourceAppButton.IsEnabled = _viewModel.CanLaunchSource;
QueueButton.IsEnabled = state.IsSupported;
FavoriteButton.IsEnabled = state.IsSupported;
CoverImage.Source = cover;
BackdropCoverImage.Source = cover;
CoverImage.IsVisible = hasCover;
BackdropCoverImage.IsVisible = hasCover;
CoverFallbackGlyph.IsVisible = !hasCover;
if (_viewModel.IsNoMedia)
{
TitleTextBlock.Text = L("music.widget.unsupported", "Music control is only available on Windows");
ArtistTextBlock.Text = L("music.widget.unsupported_hint", "SMTC backend is unavailable");
SourceAppTextBlock.Text = L("music.widget.open_player", "Open player");
StatusTextBlock.Text = "--";
PositionTextBlock.Text = "00:00";
DurationTextBlock.Text = "00:00";
PlaybackActivityIcon.IsVisible = false;
PlayPauseGlyphIcon.Symbol = PlaySymbol;
UpdateProgressVisual(0, false);
SetCoverImage(null);
ApplyNoMediaVisualTheme();
ApplyActionButtonState(state);
UpdateSourceAppButtonTooltip();
return;
}
if (!state.HasSession)
else
{
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();
}
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;
ApplyDynamicBackground(cover);
UpdateProgressVisual(_viewModel.ProgressRatio, _viewModel.IsProgressIndeterminate);
UpdateSourceAppButtonTooltip();
}
private void ApplyNoMediaVisualTheme()
{
ArtistTextBlock.MaxLines = 2;
DynamicBackgroundBase.Background = new SolidColorBrush(Color.Parse("#F0635D61"));
DynamicGradientOverlay.Background = new LinearGradientBrush
{
@@ -444,8 +275,6 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget,
private void ApplyActiveVisualTheme()
{
ArtistTextBlock.MaxLines = 1;
CoverBorder.Background = new SolidColorBrush(Color.Parse("#3CFFFFFF"));
CoverBorder.BorderBrush = new SolidColorBrush(Color.Parse("#77FFFFFF"));
CoverFallbackGlyph.Symbol = Symbol.Album;
@@ -460,49 +289,6 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget,
SourceAppIcon.Foreground = new SolidColorBrush(Color.Parse("#F7FFFFFF"));
}
private void UpdateLanguageCode()
{
try
{
var snapshot = _settingsService.Load();
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
}
catch
{
_languageCode = "zh-CN";
}
}
private void CancelRefreshRequest()
{
var cts = Interlocked.Exchange(ref _refreshCts, null);
if (cts is null)
{
return;
}
cts.Cancel();
cts.Dispose();
}
private string ResolveStatusText(MusicPlaybackStatus status)
{
return status switch
{
MusicPlaybackStatus.Playing => L("music.widget.status.playing", "Playing"),
MusicPlaybackStatus.Paused => L("music.widget.status.paused", "Paused"),
MusicPlaybackStatus.Stopped => L("music.widget.status.stopped", "Stopped"),
MusicPlaybackStatus.Changing => L("music.widget.status.changing", "Changing"),
MusicPlaybackStatus.Opened => L("music.widget.status.opened", "Opened"),
_ => "--"
};
}
private string L(string key, string fallback)
{
return _localizationService.GetString(_languageCode, key, fallback);
}
private double ResolveScale()
{
var cellScale = Math.Clamp(_currentCellSize / 48d, 0.62, 2.1);
@@ -515,84 +301,8 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget,
return Math.Clamp(Math.Min(cellScale, Math.Min(widthScale, heightScale) * 1.05), 0.56, 2.0);
}
private static TimeSpan ClampToNonNegative(TimeSpan value)
{
return value < TimeSpan.Zero ? TimeSpan.Zero : value;
}
private static string FormatTimeline(TimeSpan value)
{
if (value.TotalHours >= 1)
{
return value.ToString(@"h\:mm\:ss", CultureInfo.InvariantCulture);
}
return value.ToString(@"mm\:ss", CultureInfo.InvariantCulture);
}
private void SetCoverImage(byte[]? thumbnailBytes)
{
DisposeCoverBitmap();
if (thumbnailBytes is null || thumbnailBytes.Length == 0)
{
CoverImage.Source = null;
BackdropCoverImage.Source = null;
CoverImage.IsVisible = false;
BackdropCoverImage.IsVisible = false;
CoverFallbackGlyph.IsVisible = true;
ApplyDynamicBackground(null);
return;
}
try
{
using var stream = new MemoryStream(thumbnailBytes, writable: false);
_coverBitmap = new Bitmap(stream);
CoverImage.Source = _coverBitmap;
BackdropCoverImage.Source = _coverBitmap;
CoverImage.IsVisible = true;
BackdropCoverImage.IsVisible = true;
CoverFallbackGlyph.IsVisible = false;
ApplyDynamicBackground(_coverBitmap);
}
catch
{
CoverImage.Source = null;
BackdropCoverImage.Source = null;
CoverImage.IsVisible = false;
BackdropCoverImage.IsVisible = false;
CoverFallbackGlyph.IsVisible = true;
ApplyDynamicBackground(null);
}
}
private void DisposeCoverBitmap()
{
if (_coverBitmap is null)
{
return;
}
if (ReferenceEquals(CoverImage.Source, _coverBitmap))
{
CoverImage.Source = null;
}
if (ReferenceEquals(BackdropCoverImage.Source, _coverBitmap))
{
BackdropCoverImage.Source = null;
}
_coverBitmap.Dispose();
_coverBitmap = null;
}
private void UpdateProgressVisual(double ratio, bool indeterminate)
{
_progressRatio = Math.Clamp(ratio, 0, 1);
_isProgressIndeterminate = indeterminate;
if (ProgressTrackHost.Bounds.Width <= 0)
{
return;
@@ -606,18 +316,18 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget,
return;
}
ProgressFillBorder.Width = trackWidth * _progressRatio;
ProgressFillBorder.Width = trackWidth * Math.Clamp(ratio, 0, 1);
ProgressFillBorder.Opacity = 0.96;
}
private void UpdateSourceAppButtonTooltip()
{
var sourceName = string.IsNullOrWhiteSpace(SourceAppTextBlock.Text)
? L("music.widget.open_player", "Open player")
: SourceAppTextBlock.Text;
var statusText = string.IsNullOrWhiteSpace(StatusTextBlock.Text) || StatusTextBlock.Text == "--"
var sourceName = string.IsNullOrWhiteSpace(_viewModel.SourceAppText)
? "Open player"
: _viewModel.SourceAppText;
var statusText = string.IsNullOrWhiteSpace(_viewModel.StatusText) || _viewModel.StatusText == "--"
? sourceName
: string.Create(CultureInfo.InvariantCulture, $"{sourceName} ({StatusTextBlock.Text})");
: $"{sourceName} ({_viewModel.StatusText})";
ToolTip.SetTip(SourceAppButton, statusText);
}

View File

@@ -180,10 +180,16 @@ public partial class MainWindow : Window
_weatherLongitude = snapshot.WeatherLongitude;
_weatherAutoRefreshLocation = snapshot.WeatherAutoRefreshLocation;
_weatherExcludedAlertsRaw = snapshot.WeatherExcludedAlerts ?? string.Empty;
_weatherIconPackId = string.IsNullOrWhiteSpace(snapshot.WeatherIconPackId) ? "HyperOS3" : snapshot.WeatherIconPackId;
_weatherIconPackId = NormalizeWeatherIconPackId(snapshot.WeatherIconPackId);
_weatherNoTlsRequests = snapshot.WeatherNoTlsRequests;
}
private static string NormalizeWeatherIconPackId(string? iconPackId)
{
_ = iconPackId;
return "DefaultWeather";
}
private void InitializeAutoStartWithWindowsSetting(AppSettingsSnapshot snapshot)
{
_autoStartWithWindows = snapshot.AutoStartWithWindows;

View File

@@ -167,7 +167,7 @@ public partial class MainWindow : Window
private double _weatherLongitude = 116.4074;
private bool _weatherAutoRefreshLocation;
private string _weatherExcludedAlertsRaw = string.Empty;
private string _weatherIconPackId = "HyperOS3";
private string _weatherIconPackId = "DefaultWeather";
private bool _weatherNoTlsRequests;
private bool _autoStartWithWindows;
private bool _suppressAutoStartToggleEvents;

View File

@@ -37,5 +37,6 @@
<Capabilities>
<rescap:Capability Name="runFullTrust" />
<uap5:Capability Name="userNotificationListener" />
<uap5:Capability Name="globalMediaControl" />
</Capabilities>
</Package>