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.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",

View File

@@ -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": "都市検索",

View File

@@ -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": "도시 검색",

View File

@@ -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": "城市搜索",

View File

@@ -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; }

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

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) private static string NormalizeIconPackId(string? iconPackId)
{ {
return string.IsNullOrWhiteSpace(iconPackId) _ = iconPackId;
? "HyperOS3" return "DefaultWeather";
: "HyperOS3";
} }
} }

View File

@@ -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)

View File

@@ -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;
} }

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, 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()

View File

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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>