From b48056391a9ee6e5e328e1283c8493ba3ed7bfe7 Mon Sep 17 00:00:00 2001 From: lincube Date: Tue, 12 May 2026 18:49:04 +0800 Subject: [PATCH] =?UTF-8?q?fix.=E6=B6=88=E6=81=AF=E7=9B=92=E5=AD=90?= =?UTF-8?q?=E5=AA=92=E4=BD=93=E6=92=AD=E6=94=BE=E5=99=A8=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MusicControlServiceTests.cs | 125 +++++ LanMountainDesktop/Localization/en-US.json | 2 +- LanMountainDesktop/Localization/ja-JP.json | 2 +- LanMountainDesktop/Localization/ko-KR.json | 2 +- LanMountainDesktop/Localization/zh-CN.json | 2 +- .../Models/AppSettingsSnapshot.cs | 2 +- .../Services/IMusicControlService.cs | 189 +++++-- .../LinuxMprisMusicSessionProvider.cs | 477 ++++++++++++++++++ .../Settings/SettingsDomainServices.cs | 5 +- .../Services/WeatherLocationRefreshService.cs | 5 +- .../WindowsSmtcMusicControlService.cs | 70 ++- .../ViewModels/MusicControlViewModel.cs | 301 +++++++++++ .../WeatherSettingsPageViewModel.cs | 13 +- .../Components/MusicControlWidget.axaml.cs | 406 +++------------ .../Views/MainWindow.SettingsHardCut.Stubs.cs | 8 +- LanMountainDesktop/Views/MainWindow.axaml.cs | 2 +- .../WindowsIdentity/AppxManifest.xml | 1 + 17 files changed, 1202 insertions(+), 410 deletions(-) create mode 100644 LanMountainDesktop.Tests/MusicControlServiceTests.cs create mode 100644 LanMountainDesktop/Services/LinuxMprisMusicSessionProvider.cs create mode 100644 LanMountainDesktop/ViewModels/MusicControlViewModel.cs diff --git a/LanMountainDesktop.Tests/MusicControlServiceTests.cs b/LanMountainDesktop.Tests/MusicControlServiceTests.cs new file mode 100644 index 0000000..4b97e05 --- /dev/null +++ b/LanMountainDesktop.Tests/MusicControlServiceTests.cs @@ -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); +} diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json index deb72ef..db0b84b 100644 --- a/LanMountainDesktop/Localization/en-US.json +++ b/LanMountainDesktop/Localization/en-US.json @@ -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", diff --git a/LanMountainDesktop/Localization/ja-JP.json b/LanMountainDesktop/Localization/ja-JP.json index f6d54d1..6bce69e 100644 --- a/LanMountainDesktop/Localization/ja-JP.json +++ b/LanMountainDesktop/Localization/ja-JP.json @@ -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": "都市検索", diff --git a/LanMountainDesktop/Localization/ko-KR.json b/LanMountainDesktop/Localization/ko-KR.json index 4d7dd02..41cf02b 100644 --- a/LanMountainDesktop/Localization/ko-KR.json +++ b/LanMountainDesktop/Localization/ko-KR.json @@ -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": "도시 검색", diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json index 281b1f1..29fb92a 100644 --- a/LanMountainDesktop/Localization/zh-CN.json +++ b/LanMountainDesktop/Localization/zh-CN.json @@ -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": "城市搜索", diff --git a/LanMountainDesktop/Models/AppSettingsSnapshot.cs b/LanMountainDesktop/Models/AppSettingsSnapshot.cs index b283337..a081a2e 100644 --- a/LanMountainDesktop/Models/AppSettingsSnapshot.cs +++ b/LanMountainDesktop/Models/AppSettingsSnapshot.cs @@ -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; } diff --git a/LanMountainDesktop/Services/IMusicControlService.cs b/LanMountainDesktop/Services/IMusicControlService.cs index 307642b..f4e986b 100644 --- a/LanMountainDesktop/Services/IMusicControlService.cs +++ b/LanMountainDesktop/Services/IMusicControlService.cs @@ -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> GetSessionsAsync(CancellationToken cancellationToken = default); + + Task TogglePlayPauseAsync(string sessionId, CancellationToken cancellationToken = default); + + Task SkipNextAsync(string sessionId, CancellationToken cancellationToken = default); + + Task SkipPreviousAsync(string sessionId, CancellationToken cancellationToken = default); + + Task LaunchSourceAppAsync(string sessionId, CancellationToken cancellationToken = default); +} + public interface IMusicControlService { + event EventHandler? StateChanged; + Task GetCurrentStateAsync(CancellationToken cancellationToken = default); Task TogglePlayPauseAsync(CancellationToken cancellationToken = default); @@ -82,40 +135,116 @@ public interface IMusicControlService Task 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 GetCurrentStateAsync(CancellationToken cancellationToken = default) + { + var sessions = await _provider.GetSessionsAsync(cancellationToken).ConfigureAwait(false); + _currentState = SelectCurrentSession(sessions, _provider.Platform); + return _currentState; + } + + public Task TogglePlayPauseAsync(CancellationToken cancellationToken = default) + => ExecuteOnCurrentSessionAsync((sessionId, token) => _provider.TogglePlayPauseAsync(sessionId, token), cancellationToken); + + public Task SkipNextAsync(CancellationToken cancellationToken = default) + => ExecuteOnCurrentSessionAsync((sessionId, token) => _provider.SkipNextAsync(sessionId, token), cancellationToken); + + public Task SkipPreviousAsync(CancellationToken cancellationToken = default) + => ExecuteOnCurrentSessionAsync((sessionId, token) => _provider.SkipPreviousAsync(sessionId, token), cancellationToken); + + public Task LaunchSourceAppAsync(CancellationToken cancellationToken = default) + => ExecuteOnCurrentSessionAsync((sessionId, token) => _provider.LaunchSourceAppAsync(sessionId, token), cancellationToken); + + internal static MusicPlaybackState SelectCurrentSession(IReadOnlyList 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 ExecuteOnCurrentSessionAsync( + Func> 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 GetCurrentStateAsync(CancellationToken cancellationToken = default) - { - return Task.FromResult(MusicPlaybackState.Unsupported()); - } + public MusicPlatform Platform => MusicPlatform.Unknown; - public Task TogglePlayPauseAsync(CancellationToken cancellationToken = default) - { - return Task.FromResult(false); - } + public event EventHandler? SessionsChanged; - public Task SkipNextAsync(CancellationToken cancellationToken = default) - { - return Task.FromResult(false); - } + public Task> GetSessionsAsync(CancellationToken cancellationToken = default) + => Task.FromResult>([MusicPlaybackState.Unsupported()]); - public Task SkipPreviousAsync(CancellationToken cancellationToken = default) - { - return Task.FromResult(false); - } + public Task TogglePlayPauseAsync(string sessionId, CancellationToken cancellationToken = default) + => Task.FromResult(false); - public Task LaunchSourceAppAsync(CancellationToken cancellationToken = default) - { - return Task.FromResult(false); - } + public Task SkipNextAsync(string sessionId, CancellationToken cancellationToken = default) + => Task.FromResult(false); + + public Task SkipPreviousAsync(string sessionId, CancellationToken cancellationToken = default) + => Task.FromResult(false); + + public Task LaunchSourceAppAsync(string sessionId, CancellationToken cancellationToken = default) + => Task.FromResult(false); + + public void Dispose() + => SessionsChanged = null; } diff --git a/LanMountainDesktop/Services/LinuxMprisMusicSessionProvider.cs b/LanMountainDesktop/Services/LinuxMprisMusicSessionProvider.cs new file mode 100644 index 0000000..6096c2d --- /dev/null +++ b/LanMountainDesktop/Services/LinuxMprisMusicSessionProvider.cs @@ -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("\"(?(?:\\\\.|[^\"])*)\"", RegexOptions.Compiled); + private static readonly Regex Int64ValueRegex = new(@"int64\s+(?-?\d+)", RegexOptions.Compiled); + private static readonly Regex BooleanValueRegex = new(@"boolean\s+(?true|false)", RegexOptions.Compiled); + private static readonly Regex ArrayStringRegex = new(@"string\s+""(?(?:\\.|[^""])*)""", RegexOptions.Compiled); + + private readonly CancellationTokenSource _disposeCts = new(); + private readonly Dictionary _lastSeen = new(StringComparer.Ordinal); + + private IDisposable? _nameOwnerChangedWatcher; + + public MusicPlatform Platform => MusicPlatform.Linux; + + public event EventHandler? SessionsChanged; + + public async Task> 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(); + 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 TogglePlayPauseAsync(string sessionId, CancellationToken cancellationToken = default) + => CallPlayerMethodAsync(sessionId, "PlayPause", cancellationToken); + + public Task SkipNextAsync(string sessionId, CancellationToken cancellationToken = default) + => CallPlayerMethodAsync(sessionId, "Next", cancellationToken); + + public Task SkipPreviousAsync(string sessionId, CancellationToken cancellationToken = default) + => CallPlayerMethodAsync(sessionId, "Previous", cancellationToken); + + public async Task 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 ParseMetadata(string text) + { + var metadata = new Dictionary(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() + .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> 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 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 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 CallPlayerMethodAsync(string busName, string methodName, CancellationToken cancellationToken) + => CallMethodAsync(busName, $"org.mpris.MediaPlayer2.Player.{methodName}", cancellationToken); + + private static Task CallRootMethodAsync(string busName, string methodName, CancellationToken cancellationToken) + => CallMethodAsync(busName, $"org.mpris.MediaPlayer2.{methodName}", cancellationToken); + + private static async Task 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 RunDbusSendAsync(IReadOnlyList 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); +} diff --git a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs index b223a5f..84adf2b 100644 --- a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs +++ b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs @@ -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"; } } diff --git a/LanMountainDesktop/Services/WeatherLocationRefreshService.cs b/LanMountainDesktop/Services/WeatherLocationRefreshService.cs index 3830e92..6af73bd 100644 --- a/LanMountainDesktop/Services/WeatherLocationRefreshService.cs +++ b/LanMountainDesktop/Services/WeatherLocationRefreshService.cs @@ -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) diff --git a/LanMountainDesktop/Services/WindowsSmtcMusicControlService.cs b/LanMountainDesktop/Services/WindowsSmtcMusicControlService.cs index bdf5b54..5e21a28 100644 --- a/LanMountainDesktop/Services/WindowsSmtcMusicControlService.cs +++ b/LanMountainDesktop/Services/WindowsSmtcMusicControlService.cs @@ -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 GetCurrentStateAsync(CancellationToken cancellationToken = default) + public MusicPlatform Platform => MusicPlatform.Windows; + + public event EventHandler? SessionsChanged; + + public async Task> GetSessionsAsync(CancellationToken cancellationToken = default) + { + var state = await GetCurrentStateAsync(cancellationToken).ConfigureAwait(false); + return state.HasSession || !state.IsSupported + ? [state] + : []; + } + + private async Task 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 TogglePlayPauseAsync(CancellationToken cancellationToken = default) + public async Task 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 SkipNextAsync(CancellationToken cancellationToken = default) + public async Task 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 SkipPreviousAsync(CancellationToken cancellationToken = default) + public async Task 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 LaunchSourceAppAsync(CancellationToken cancellationToken = default) + public async Task LaunchSourceAppAsync(string sessionId, CancellationToken cancellationToken = default) { if (!IsRuntimeSupported()) { @@ -491,9 +523,18 @@ public sealed class WindowsSmtcMusicControlService : IMusicControlService return type? .GetMethods(BindingFlags.Public | BindingFlags.Static) .FirstOrDefault(method => - method.Name == "AsTask" && - method.IsGenericMethodDefinition && - method.GetParameters().Length == 1); + { + try + { + return method.Name == "AsTask" && + method.IsGenericMethodDefinition && + method.GetParameters().Length == 1; + } + catch + { + return false; + } + }); } private static MethodInfo? ResolveAsStreamForReadMethod() @@ -576,4 +617,7 @@ public sealed class WindowsSmtcMusicControlService : IMusicControlService _ => MusicPlaybackStatus.Unknown }; } + + public void Dispose() + => SessionsChanged = null; } diff --git a/LanMountainDesktop/ViewModels/MusicControlViewModel.cs b/LanMountainDesktop/ViewModels/MusicControlViewModel.cs new file mode 100644 index 0000000..d9c1493 --- /dev/null +++ b/LanMountainDesktop/ViewModels/MusicControlViewModel.cs @@ -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> 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); +} diff --git a/LanMountainDesktop/ViewModels/WeatherSettingsPageViewModel.cs b/LanMountainDesktop/ViewModels/WeatherSettingsPageViewModel.cs index fdd3a37..12315bd 100644 --- a/LanMountainDesktop/ViewModels/WeatherSettingsPageViewModel.cs +++ b/LanMountainDesktop/ViewModels/WeatherSettingsPageViewModel.cs @@ -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() diff --git a/LanMountainDesktop/Views/Components/MusicControlWidget.axaml.cs b/LanMountainDesktop/Views/Components/MusicControlWidget.axaml.cs index 3964c26..0ddac0a 100644 --- a/LanMountainDesktop/Views/Components/MusicControlWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/MusicControlWidget.axaml.cs @@ -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> command, - bool refreshAfterCommand = true, - bool requireActiveSession = true) - { - if (_isExecutingCommand - || !_currentState.IsSupported - || (requireActiveSession && !_currentState.HasSession)) - { - return; - } - - _isExecutingCommand = true; - ApplyActionButtonState(_currentState); - - try - { - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(4)); - _ = await command(cts.Token); - } - catch - { - // Ignore command transport errors and recover on next poll. - } - finally - { - _isExecutingCommand = false; - } - - if (refreshAfterCommand) - { - await RefreshStateAsync(); - } - } - - private async Task RefreshStateAsync() - { - if (!_isAttached || !_isOnActivePage || _isRefreshing) - { - return; - } - - _isRefreshing = true; - UpdateLanguageCode(); - - var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - var previous = Interlocked.Exchange(ref _refreshCts, cts); - previous?.Cancel(); - previous?.Dispose(); - - try - { - var state = await _musicControlService.GetCurrentStateAsync(cts.Token); - if (cts.IsCancellationRequested || !_isAttached) - { - return; - } - - _currentState = state; - ApplyState(state); - } - catch (OperationCanceledException) - { - // Ignore cancellation. - } - catch - { - var fallbackState = MusicPlaybackState.NoSession(isSupported: OperatingSystem.IsWindows()); - _currentState = fallbackState; - ApplyState(fallbackState); - } - finally - { - if (ReferenceEquals(_refreshCts, cts)) - { - _refreshCts = null; - } - - cts.Dispose(); - _isRefreshing = false; - } - } + private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e) + => Dispatcher.UIThread.Post(ApplyViewModel); private void UpdateRefreshTimerState() { @@ -288,109 +177,51 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget, _refreshTimer.Stop(); } - private void ApplyState(MusicPlaybackState state) + private void ApplyViewModel() { - var hasMediaSession = state.IsSupported && state.HasSession; + var state = _viewModel.State; + var cover = _viewModel.Cover; + var hasCover = cover is not null; - if (!state.IsSupported) + TitleTextBlock.Text = _viewModel.TitleText; + ArtistTextBlock.Text = _viewModel.ArtistText; + ArtistTextBlock.MaxLines = _viewModel.IsNoMedia ? 2 : 1; + SourceAppTextBlock.Text = _viewModel.SourceAppText; + StatusTextBlock.Text = _viewModel.StatusText; + PositionTextBlock.Text = _viewModel.PositionText; + DurationTextBlock.Text = _viewModel.DurationText; + PlaybackActivityIcon.IsVisible = _viewModel.IsPlaybackActive; + PlayPauseGlyphIcon.Symbol = _viewModel.IsPlaybackActive ? Symbol.Pause : Symbol.Play; + + PlayPauseButton.IsEnabled = _viewModel.CanPlayPause; + PreviousButton.IsEnabled = _viewModel.CanSkipPrevious; + NextButton.IsEnabled = _viewModel.CanSkipNext; + SourceAppButton.IsEnabled = _viewModel.CanLaunchSource; + QueueButton.IsEnabled = state.IsSupported; + FavoriteButton.IsEnabled = state.IsSupported; + + CoverImage.Source = cover; + BackdropCoverImage.Source = cover; + CoverImage.IsVisible = hasCover; + BackdropCoverImage.IsVisible = hasCover; + CoverFallbackGlyph.IsVisible = !hasCover; + + if (_viewModel.IsNoMedia) { - TitleTextBlock.Text = L("music.widget.unsupported", "Music control is only available on Windows"); - ArtistTextBlock.Text = L("music.widget.unsupported_hint", "SMTC backend is unavailable"); - SourceAppTextBlock.Text = L("music.widget.open_player", "Open player"); - StatusTextBlock.Text = "--"; - PositionTextBlock.Text = "00:00"; - DurationTextBlock.Text = "00:00"; - PlaybackActivityIcon.IsVisible = false; - PlayPauseGlyphIcon.Symbol = PlaySymbol; - UpdateProgressVisual(0, false); - SetCoverImage(null); ApplyNoMediaVisualTheme(); - ApplyActionButtonState(state); - UpdateSourceAppButtonTooltip(); - return; + } + else + { + ApplyActiveVisualTheme(); } - if (!state.HasSession) - { - TitleTextBlock.Text = L("music.widget.no_session", "No active media session"); - ArtistTextBlock.Text = L("music.widget.no_session_hint", "Open a player that supports SMTC"); - SourceAppTextBlock.Text = L("music.widget.open_player", "Open player"); - StatusTextBlock.Text = "--"; - PositionTextBlock.Text = "00:00"; - DurationTextBlock.Text = "00:00"; - PlaybackActivityIcon.IsVisible = false; - PlayPauseGlyphIcon.Symbol = PlaySymbol; - UpdateProgressVisual(0, false); - SetCoverImage(null); - ApplyNoMediaVisualTheme(); - ApplyActionButtonState(state); - UpdateSourceAppButtonTooltip(); - return; - } - - ApplyActiveVisualTheme(); - - var title = string.IsNullOrWhiteSpace(state.Title) - ? L("music.widget.unknown_title", "Unknown title") - : state.Title; - var subtitle = !string.IsNullOrWhiteSpace(state.Artist) - ? state.Artist - : !string.IsNullOrWhiteSpace(state.AlbumTitle) - ? state.AlbumTitle - : L("music.widget.unknown_artist", "Unknown artist"); - - TitleTextBlock.Text = title; - ArtistTextBlock.Text = subtitle; - SourceAppTextBlock.Text = string.IsNullOrWhiteSpace(state.SourceAppName) - ? L("music.widget.open_player", "Open player") - : state.SourceAppName; - StatusTextBlock.Text = ResolveStatusText(state.PlaybackStatus); - PlaybackActivityIcon.IsVisible = state.PlaybackStatus == MusicPlaybackStatus.Playing; - - var position = ClampToNonNegative(state.Position); - var duration = ClampToNonNegative(state.Duration); - var progressRatio = duration.TotalMilliseconds <= 1 - ? 0 - : Math.Clamp(position.TotalMilliseconds / duration.TotalMilliseconds, 0, 1); - - PositionTextBlock.Text = FormatTimeline(position); - DurationTextBlock.Text = duration.TotalMilliseconds > 1 - ? FormatTimeline(duration) - : "00:00"; - UpdateProgressVisual(progressRatio, hasMediaSession && duration.TotalMilliseconds <= 1); - - PlayPauseGlyphIcon.Symbol = state.PlaybackStatus == MusicPlaybackStatus.Playing - ? PauseSymbol - : PlaySymbol; - - SetCoverImage(state.ThumbnailBytes); - ApplyActionButtonState(state); + ApplyDynamicBackground(cover); + UpdateProgressVisual(_viewModel.ProgressRatio, _viewModel.IsProgressIndeterminate); UpdateSourceAppButtonTooltip(); } - private void ApplyActionButtonState(MusicPlaybackState state) - { - var canOperate = !_isExecutingCommand && state.IsSupported && state.HasSession; - var showNoSessionStyle = !_isExecutingCommand && state.IsSupported && !state.HasSession; - - PlayPauseButton.IsEnabled = canOperate - ? state.CanPlayPause - : showNoSessionStyle; - PreviousButton.IsEnabled = canOperate - ? state.CanSkipPrevious - : showNoSessionStyle; - NextButton.IsEnabled = canOperate - ? state.CanSkipNext - : showNoSessionStyle; - SourceAppButton.IsEnabled = !_isExecutingCommand && state.IsSupported; - QueueButton.IsEnabled = canOperate || showNoSessionStyle; - FavoriteButton.IsEnabled = canOperate || showNoSessionStyle; - } - private void ApplyNoMediaVisualTheme() { - ArtistTextBlock.MaxLines = 2; - DynamicBackgroundBase.Background = new SolidColorBrush(Color.Parse("#F0635D61")); DynamicGradientOverlay.Background = new LinearGradientBrush { @@ -444,8 +275,6 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget, private void ApplyActiveVisualTheme() { - ArtistTextBlock.MaxLines = 1; - CoverBorder.Background = new SolidColorBrush(Color.Parse("#3CFFFFFF")); CoverBorder.BorderBrush = new SolidColorBrush(Color.Parse("#77FFFFFF")); CoverFallbackGlyph.Symbol = Symbol.Album; @@ -460,49 +289,6 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget, SourceAppIcon.Foreground = new SolidColorBrush(Color.Parse("#F7FFFFFF")); } - private void UpdateLanguageCode() - { - try - { - var snapshot = _settingsService.Load(); - _languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode); - } - catch - { - _languageCode = "zh-CN"; - } - } - - private void CancelRefreshRequest() - { - var cts = Interlocked.Exchange(ref _refreshCts, null); - if (cts is null) - { - return; - } - - cts.Cancel(); - cts.Dispose(); - } - - private string ResolveStatusText(MusicPlaybackStatus status) - { - return status switch - { - MusicPlaybackStatus.Playing => L("music.widget.status.playing", "Playing"), - MusicPlaybackStatus.Paused => L("music.widget.status.paused", "Paused"), - MusicPlaybackStatus.Stopped => L("music.widget.status.stopped", "Stopped"), - MusicPlaybackStatus.Changing => L("music.widget.status.changing", "Changing"), - MusicPlaybackStatus.Opened => L("music.widget.status.opened", "Opened"), - _ => "--" - }; - } - - private string L(string key, string fallback) - { - return _localizationService.GetString(_languageCode, key, fallback); - } - private double ResolveScale() { var cellScale = Math.Clamp(_currentCellSize / 48d, 0.62, 2.1); @@ -515,84 +301,8 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget, return Math.Clamp(Math.Min(cellScale, Math.Min(widthScale, heightScale) * 1.05), 0.56, 2.0); } - private static TimeSpan ClampToNonNegative(TimeSpan value) - { - return value < TimeSpan.Zero ? TimeSpan.Zero : value; - } - - private static string FormatTimeline(TimeSpan value) - { - if (value.TotalHours >= 1) - { - return value.ToString(@"h\:mm\:ss", CultureInfo.InvariantCulture); - } - - return value.ToString(@"mm\:ss", CultureInfo.InvariantCulture); - } - - private void SetCoverImage(byte[]? thumbnailBytes) - { - DisposeCoverBitmap(); - - if (thumbnailBytes is null || thumbnailBytes.Length == 0) - { - CoverImage.Source = null; - BackdropCoverImage.Source = null; - CoverImage.IsVisible = false; - BackdropCoverImage.IsVisible = false; - CoverFallbackGlyph.IsVisible = true; - ApplyDynamicBackground(null); - return; - } - - try - { - using var stream = new MemoryStream(thumbnailBytes, writable: false); - _coverBitmap = new Bitmap(stream); - CoverImage.Source = _coverBitmap; - BackdropCoverImage.Source = _coverBitmap; - CoverImage.IsVisible = true; - BackdropCoverImage.IsVisible = true; - CoverFallbackGlyph.IsVisible = false; - ApplyDynamicBackground(_coverBitmap); - } - catch - { - CoverImage.Source = null; - BackdropCoverImage.Source = null; - CoverImage.IsVisible = false; - BackdropCoverImage.IsVisible = false; - CoverFallbackGlyph.IsVisible = true; - ApplyDynamicBackground(null); - } - } - - private void DisposeCoverBitmap() - { - if (_coverBitmap is null) - { - return; - } - - if (ReferenceEquals(CoverImage.Source, _coverBitmap)) - { - CoverImage.Source = null; - } - - if (ReferenceEquals(BackdropCoverImage.Source, _coverBitmap)) - { - BackdropCoverImage.Source = null; - } - - _coverBitmap.Dispose(); - _coverBitmap = null; - } - private void UpdateProgressVisual(double ratio, bool indeterminate) { - _progressRatio = Math.Clamp(ratio, 0, 1); - _isProgressIndeterminate = indeterminate; - if (ProgressTrackHost.Bounds.Width <= 0) { return; @@ -606,18 +316,18 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget, return; } - ProgressFillBorder.Width = trackWidth * _progressRatio; + ProgressFillBorder.Width = trackWidth * Math.Clamp(ratio, 0, 1); ProgressFillBorder.Opacity = 0.96; } private void UpdateSourceAppButtonTooltip() { - var sourceName = string.IsNullOrWhiteSpace(SourceAppTextBlock.Text) - ? L("music.widget.open_player", "Open player") - : SourceAppTextBlock.Text; - var statusText = string.IsNullOrWhiteSpace(StatusTextBlock.Text) || StatusTextBlock.Text == "--" + var sourceName = string.IsNullOrWhiteSpace(_viewModel.SourceAppText) + ? "Open player" + : _viewModel.SourceAppText; + var statusText = string.IsNullOrWhiteSpace(_viewModel.StatusText) || _viewModel.StatusText == "--" ? sourceName - : string.Create(CultureInfo.InvariantCulture, $"{sourceName} ({StatusTextBlock.Text})"); + : $"{sourceName} ({_viewModel.StatusText})"; ToolTip.SetTip(SourceAppButton, statusText); } diff --git a/LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs b/LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs index 0fc983a..adf5344 100644 --- a/LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs +++ b/LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs @@ -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; diff --git a/LanMountainDesktop/Views/MainWindow.axaml.cs b/LanMountainDesktop/Views/MainWindow.axaml.cs index 902e122..257d54e 100644 --- a/LanMountainDesktop/Views/MainWindow.axaml.cs +++ b/LanMountainDesktop/Views/MainWindow.axaml.cs @@ -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; diff --git a/LanMountainDesktop/WindowsIdentity/AppxManifest.xml b/LanMountainDesktop/WindowsIdentity/AppxManifest.xml index c16b26e..1e8c69d 100644 --- a/LanMountainDesktop/WindowsIdentity/AppxManifest.xml +++ b/LanMountainDesktop/WindowsIdentity/AppxManifest.xml @@ -37,5 +37,6 @@ +