feat: complete 2.0.7.12 platform overhaul

This commit is contained in:
2026-08-16 19:33:03 +08:00
parent 73555cd04c
commit c9fa6f7a88
159 changed files with 13243 additions and 2539 deletions
+16 -2
View File
@@ -88,10 +88,18 @@ public static class AppServices
provider.GetService<ILogService>()));
services.AddSingleton<IRemoteMediaResolver, RemoteMediaResolver>();
services.AddSingleton<IMusicCredentialStore>(provider => new DpapiMusicCredentialStore(provider.GetRequiredService<AppPaths>()));
services.AddSingleton<IMusicProvider>(provider => new NeteaseMusicProvider(
services.AddSingleton<NeteaseMusicProvider>(provider => new NeteaseMusicProvider(
provider.GetRequiredService<IMusicCredentialStore>(),
provider.GetService<ILogService>()));
services.AddSingleton<KugouMusicProvider>(provider => new KugouMusicProvider(
provider.GetRequiredService<IMusicCredentialStore>()));
services.AddSingleton<IMusicProviderRegistry>(provider => new MusicProviderRegistry(
[
provider.GetRequiredService<NeteaseMusicProvider>(),
provider.GetRequiredService<KugouMusicProvider>()
]));
services.AddSingleton<MusicSessionStore>();
services.AddSingleton<IMusicHistoryStore, MusicHistoryStore>();
services.AddSingleton<IMusicPlaybackService, WindowsMusicPlaybackService>();
services.AddSingleton<IIndependentWindowHostLauncher>(provider => new IndependentWindowHostLauncher(provider.GetService<ILogService>()));
services.AddSingleton<IToolLinkNavigationService>(provider => new ToolLinkNavigationService(
@@ -102,6 +110,7 @@ public static class AppServices
provider.GetRequiredService<AppPaths>(),
provider.GetService<ILogService>()));
services.AddSingleton<IReferenceDataService, ReferenceDataService>();
services.AddSingleton<IHardwareRankingService, HardwareRankingService>();
services.AddSingleton<ISystemMetricsService, SystemMetricsService>();
services.AddSingleton<ITitleWeatherService>(provider => new TitleWeatherService(
provider.GetRequiredService<IHttpService>(),
@@ -112,6 +121,7 @@ public static class AppServices
provider.GetRequiredService<IHardwareInfoService>(),
provider.GetService<ILogService>()));
services.AddSingleton<IDesktopOverlayService, DesktopOverlayService>();
services.AddSingleton<IMusicLyricsCoordinator, MusicLyricsCoordinator>();
services.AddSingleton<IPrivilegedOperationBroker>(provider => new PrivilegedOperationBroker(
provider.GetRequiredService<AppPaths>(),
provider.GetService<ILogService>()));
@@ -125,10 +135,14 @@ public static class AppServices
provider.GetService<ILogService>(),
provider.GetService<ISettingsService>()));
services.AddSingleton<IBuiltinReferenceToolCatalog, BuiltinReferenceToolCatalog>();
services.AddSingleton<IWindowsToolsService>(provider => new WindowsToolsService(
provider.GetRequiredService<AppPaths>(),
provider.GetRequiredService<IPrivilegedOperationBroker>()));
services.AddSingleton<INexNativeToolService>(provider => new NexNativeToolService(
provider.GetRequiredService<ISystemMetricsService>(),
provider.GetRequiredService<IHardwareInfoService>(),
provider.GetRequiredService<IPrivilegedOperationBroker>()));
provider.GetRequiredService<IPrivilegedOperationBroker>(),
provider.GetRequiredService<IWindowsToolsService>()));
services.AddSingleton<IBuiltinReferenceToolService>(provider => new BuiltinReferenceToolService(
provider.GetService<ILogService>(),
provider.GetService<ISettingsService>(),
@@ -0,0 +1,217 @@
using YMhut.Box.Core.Music;
namespace YMhut.Box.WinUI.Services;
public interface IMusicLyricsCoordinator : IDisposable
{
event EventHandler? LyricsChanged;
MusicSong? Song { get; }
TimedLyrics? Lyrics { get; }
int ActiveLineIndex { get; }
int ActiveWordIndex { get; }
Task RefreshAsync(CancellationToken cancellationToken = default);
}
/// <summary>
/// Keeps lyric state alive while the music page is not visible so the desktop overlay follows playback.
/// </summary>
public sealed class MusicLyricsCoordinator : IMusicLyricsCoordinator
{
private readonly IMusicPlaybackService _playback;
private readonly IMusicProviderRegistry _providers;
private readonly IDesktopOverlayService _overlay;
private readonly object _sync = new();
private CancellationTokenSource? _loadCts;
private string _songKey = string.Empty;
private TimedLyrics? _lyrics;
private int _activeLineIndex = -1;
private int _activeWordIndex = -1;
private bool _disposed;
public MusicLyricsCoordinator(
IMusicPlaybackService playback,
IMusicProviderRegistry providers,
IDesktopOverlayService overlay)
{
_playback = playback;
_providers = providers;
_overlay = overlay;
_playback.CurrentSongChanged += Playback_CurrentSongChanged;
_playback.StateChanged += Playback_StateChanged;
}
public event EventHandler? LyricsChanged;
public MusicSong? Song => _playback.CurrentSong;
public TimedLyrics? Lyrics
{
get
{
lock (_sync) return _lyrics;
}
}
public int ActiveLineIndex
{
get
{
lock (_sync) return _activeLineIndex;
}
}
public int ActiveWordIndex
{
get
{
lock (_sync) return _activeWordIndex;
}
}
public async Task RefreshAsync(CancellationToken cancellationToken = default)
{
var song = _playback.CurrentSong;
var key = song is null ? string.Empty : ToKey(song);
lock (_sync)
{
if (string.Equals(key, _songKey, StringComparison.Ordinal) && _lyrics is not null)
{
UpdateActiveLineUnsafe(_playback.Position);
return;
}
}
CancellationTokenSource? previous;
CancellationTokenSource current;
lock (_sync)
{
previous = _loadCts;
_loadCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
current = _loadCts;
_songKey = key;
_lyrics = null;
_activeLineIndex = -1;
_activeWordIndex = -1;
}
previous?.Cancel();
previous?.Dispose();
if (song is null)
{
SetOverlayPlaceholder();
LyricsChanged?.Invoke(this, EventArgs.Empty);
return;
}
// Replace the previous song immediately; a slow lyric request must not leave stale desktop lyrics visible.
SetOverlayPlaceholder();
LyricsChanged?.Invoke(this, EventArgs.Empty);
try
{
var provider = _providers.GetRequired(song.Provider);
var lyrics = await provider.GetLyricsAsync(song.Id, current.Token).ConfigureAwait(false);
lock (_sync)
{
if (_disposed || !ReferenceEquals(current, _loadCts) || !string.Equals(_songKey, key, StringComparison.Ordinal)) return;
_lyrics = lyrics;
UpdateActiveLineUnsafe(_playback.Position);
}
LyricsChanged?.Invoke(this, EventArgs.Empty);
}
catch (OperationCanceledException) when (current.IsCancellationRequested)
{
}
catch
{
lock (_sync)
{
if (!ReferenceEquals(current, _loadCts)) return;
_lyrics = new TimedLyrics([], string.Empty, null, null);
}
SetOverlayPlaceholder();
LyricsChanged?.Invoke(this, EventArgs.Empty);
}
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_playback.CurrentSongChanged -= Playback_CurrentSongChanged;
_playback.StateChanged -= Playback_StateChanged;
lock (_sync)
{
_loadCts?.Cancel();
_loadCts?.Dispose();
_loadCts = null;
}
}
private void Playback_CurrentSongChanged(object? sender, EventArgs e)
=> _ = RefreshAsync();
private void Playback_StateChanged(object? sender, EventArgs e)
{
var changed = false;
lock (_sync)
{
changed = UpdateActiveLineUnsafe(_playback.Position);
}
if (changed) LyricsChanged?.Invoke(this, EventArgs.Empty);
}
private bool UpdateActiveLineUnsafe(TimeSpan position)
{
if (_lyrics is null || _lyrics.Lines.Count == 0) return false;
var lineIndex = -1;
for (var index = 0; index < _lyrics.Lines.Count; index++)
{
if (_lyrics.Lines[index].Start <= position) lineIndex = index;
else break;
}
if (lineIndex < 0)
{
if (_activeLineIndex < 0 && _activeWordIndex < 0) return false;
_activeLineIndex = -1;
_activeWordIndex = -1;
SetOverlayPlaceholder();
return true;
}
var line = _lyrics.Lines[lineIndex];
var wordIndex = -1;
for (var index = 0; index < line.Words.Count; index++)
{
if (line.Words[index].Start <= position) wordIndex = index;
else break;
}
if (lineIndex == _activeLineIndex && wordIndex == _activeWordIndex) return false;
_activeLineIndex = lineIndex;
_activeWordIndex = wordIndex;
var song = _playback.CurrentSong;
_overlay.SetDesktopLyrics(
song?.Name ?? "YMhut Music",
song?.Artist ?? string.Empty,
line.Text,
line.Translation);
return true;
}
private void SetOverlayPlaceholder()
{
var song = _playback.CurrentSong;
_overlay.SetDesktopLyrics(
song?.Name ?? "YMhut Music",
song?.Artist ?? string.Empty,
song is null ? "播放歌曲后显示桌面歌词" : "纯音乐,请欣赏");
}
private static string ToKey(MusicSong song) => $"{song.Provider}:{song.Id}";
}
@@ -52,6 +52,16 @@ public sealed class WebView2EnvironmentFactory(
await Task.WhenAll(profiles.Select(profile => CreateAsync(profile))).ConfigureAwait(false);
}
public Task<CoreWebView2Environment> CreateEphemeralAsync(
string userDataFolder,
bool? hardwareAccelerationEnabled = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(userDataFolder);
var resolvedUserDataFolder = Path.GetFullPath(userDataFolder);
var useHardwareAcceleration = hardwareAccelerationEnabled ?? settingsService.Current.HardwareAccelerationEnabled;
return CreateEnvironmentCoreAsync(resolvedUserDataFolder, useHardwareAcceleration);
}
private static async Task<CoreWebView2Environment> CreateEnvironmentCoreAsync(
string resolvedUserDataFolder,
bool useHardwareAcceleration)
@@ -1,14 +1,17 @@
using Windows.Media;
using Windows.Media.Core;
using Windows.Media.Playback;
using YMhut.Box.Core.Logging;
using YMhut.Box.Core.Music;
namespace YMhut.Box.WinUI.Services;
public sealed class WindowsMusicPlaybackService : IMusicPlaybackService, IDisposable
{
private readonly IMusicProvider _provider;
private readonly IMusicProviderRegistry _providers;
private readonly IMusicHistoryStore _historyStore;
private readonly MusicSessionStore _sessionStore;
private readonly ILogService _logService;
private readonly MediaPlayer _player = new();
private readonly SemaphoreSlim _transition = new(1, 1);
private MusicPlaybackQuality _quality = MusicPlaybackQuality.HiRes;
@@ -16,11 +19,19 @@ public sealed class WindowsMusicPlaybackService : IMusicPlaybackService, IDispos
private TimeSpan _pendingPosition;
private bool _suppressSave;
private bool _disposed;
private bool _mediaRetryInProgress;
private int _mediaFailureRetries;
public WindowsMusicPlaybackService(IMusicProvider provider, MusicSessionStore sessionStore)
public WindowsMusicPlaybackService(
IMusicProviderRegistry providers,
MusicSessionStore sessionStore,
IMusicHistoryStore historyStore,
ILogService logService)
{
_provider = provider;
_providers = providers;
_sessionStore = sessionStore;
_historyStore = historyStore;
_logService = logService;
_player.AutoPlay = false;
_player.CommandManager.IsEnabled = false;
_player.MediaEnded += Player_MediaEnded;
@@ -39,12 +50,16 @@ public sealed class WindowsMusicPlaybackService : IMusicPlaybackService, IDispos
public event EventHandler? StateChanged;
public event EventHandler? CurrentSongChanged;
public MusicQueue Queue { get; } = new();
public MusicSong? CurrentSong { get; private set; }
public bool IsPlaying => _player.PlaybackSession.PlaybackState == MediaPlaybackState.Playing;
public MusicPlaybackFailure? LastFailure { get; private set; }
public TimeSpan Position => _player.PlaybackSession.Position;
public TimeSpan Duration => _player.PlaybackSession.NaturalDuration;
@@ -55,7 +70,7 @@ public sealed class WindowsMusicPlaybackService : IMusicPlaybackService, IDispos
set
{
_player.Volume = Math.Clamp(value, 0, 100) / 100;
_ = SaveAsync();
QueueSave();
StateChanged?.Invoke(this, EventArgs.Empty);
}
}
@@ -67,24 +82,31 @@ public sealed class WindowsMusicPlaybackService : IMusicPlaybackService, IDispos
{
if (_quality == value) return;
_quality = value;
if (!_suppressSave) _ = SaveAsync();
if (!_suppressSave) QueueSave();
StateChanged?.Invoke(this, EventArgs.Empty);
}
}
public async Task RestoreAsync(CancellationToken cancellationToken = default)
{
await _provider.InitializeAsync(cancellationToken).ConfigureAwait(false);
var session = await _sessionStore.LoadAsync(cancellationToken).ConfigureAwait(false);
if (session is null) return;
var providerId = session.CurrentSongProvider
?? session.Queue.ElementAtOrDefault(Math.Clamp(session.CurrentIndex, 0, Math.Max(0, session.Queue.Count - 1)))?.Provider
?? _providers.Current.Id;
if (_providers.TryGet(providerId, out var provider))
{
await provider.InitializeAsync(cancellationToken).ConfigureAwait(false);
}
_suppressSave = true;
try
{
Queue.Replace(session.Queue, session.CurrentSongId);
Queue.Replace(session.Queue, session.CurrentSongId, session.CurrentSongProvider);
Queue.Mode = session.Mode;
_quality = session.Quality;
_player.Volume = Math.Clamp(session.Volume, 0, 100) / 100;
_resumePosition = session.Position;
CurrentSong = Queue.Current;
SetCurrentSong(Queue.Current);
if (CurrentSong is not null) UpdateSystemMediaControls(CurrentSong);
}
finally
@@ -95,27 +117,99 @@ public sealed class WindowsMusicPlaybackService : IMusicPlaybackService, IDispos
}
public async Task PlayAsync(MusicSong song, IReadOnlyList<MusicSong>? queue = null, CancellationToken cancellationToken = default)
=> await PlayCoreAsync(song, queue, TimeSpan.Zero, cancellationToken).ConfigureAwait(false);
=> await PlayCoreAsync(song, queue, TimeSpan.Zero, cancellationToken, isRetry: false).ConfigureAwait(false);
private async Task PlayCoreAsync(MusicSong song, IReadOnlyList<MusicSong>? queue, TimeSpan startPosition, CancellationToken cancellationToken)
public async Task ChangeQualityAsync(MusicPlaybackQuality quality, CancellationToken cancellationToken = default)
{
if (CurrentSong is not { } song)
{
Quality = quality;
return;
}
var previousQuality = _quality;
var position = Position;
var wasPlaying = IsPlaying;
try
{
await PlayCoreAsync(
song,
null,
position,
cancellationToken,
isRetry: false,
requestedQuality: quality,
startPlayback: wasPlaying).ConfigureAwait(false);
}
catch
{
_quality = previousQuality;
StateChanged?.Invoke(this, EventArgs.Empty);
throw;
}
}
private async Task PlayCoreAsync(
MusicSong song,
IReadOnlyList<MusicSong>? queue,
TimeSpan startPosition,
CancellationToken cancellationToken,
bool isRetry,
MusicPlaybackQuality? requestedQuality = null,
bool startPlayback = true)
{
await _transition.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (queue is not null) Queue.Replace(queue, song.Id);
if (!isRetry)
{
_mediaFailureRetries = 0;
LastFailure = null;
}
if (queue is not null) Queue.Replace(queue, song.Id, song.Provider);
else Queue.Select(song);
var stream = await _provider.ResolveStreamAsync(song.Id, Quality, cancellationToken).ConfigureAwait(false);
MusicStreamResult stream;
try
{
var targetQuality = isRetry ? MusicPlaybackQuality.Standard : requestedQuality ?? Quality;
var provider = _providers.GetRequired(song.Provider);
await provider.InitializeAsync(cancellationToken).ConfigureAwait(false);
stream = await provider.ResolveStreamAsync(song.Id, targetQuality, cancellationToken).ConfigureAwait(false);
}
catch (Exception exception)
{
LastFailure = new MusicPlaybackFailure(MusicPlaybackFailureStage.Resolve, "STREAM_RESOLVE_FAILED", exception.Message, true);
throw;
}
if (!stream.Playable || stream.Uri is null)
{
throw new InvalidOperationException(stream.ProviderReason ?? "提供方未返回播放地址。");
var message = stream.ProviderReason ?? "提供方未返回播放地址。";
LastFailure = new MusicPlaybackFailure(MusicPlaybackFailureStage.Resolve, "STREAM_UNAVAILABLE", message, false);
throw new InvalidOperationException(message);
}
CurrentSong = song;
var probe = await _providers.GetRequired(song.Provider).ProbeStreamAsync(stream.Uri, cancellationToken).ConfigureAwait(false);
if (!probe.Reachable)
{
var message = probe.Reason ?? "播放地址当前不可访问。";
LastFailure = new MusicPlaybackFailure(MusicPlaybackFailureStage.Network, "STREAM_PROBE_FAILED", message, true);
throw new HttpRequestException(message);
}
_quality = stream.Quality;
SetCurrentSong(song);
_resumePosition = TimeSpan.Zero;
_pendingPosition = startPosition;
_player.Source = MediaSource.CreateFromUri(stream.Uri);
try
{
_player.Source = MediaSource.CreateFromUri(stream.Uri);
}
catch (Exception exception)
{
LastFailure = new MusicPlaybackFailure(MusicPlaybackFailureStage.MediaOpen, "MEDIA_SOURCE_FAILED", exception.Message, true);
throw;
}
UpdateSystemMediaControls(song);
_player.Play();
await SaveAsync(cancellationToken).ConfigureAwait(false);
if (startPlayback) _player.Play();
await SaveSafelyAsync(cancellationToken).ConfigureAwait(false);
StateChanged?.Invoke(this, EventArgs.Empty);
}
finally
@@ -133,7 +227,7 @@ public sealed class WindowsMusicPlaybackService : IMusicPlaybackService, IDispos
return;
}
if (IsPlaying) _player.Pause(); else _player.Play();
_ = SaveAsync();
QueueSave();
StateChanged?.Invoke(this, EventArgs.Empty);
}
@@ -158,7 +252,7 @@ public sealed class WindowsMusicPlaybackService : IMusicPlaybackService, IDispos
{
var duration = Duration;
_player.PlaybackSession.Position = duration > TimeSpan.Zero && position > duration ? duration : position < TimeSpan.Zero ? TimeSpan.Zero : position;
_ = SaveAsync();
QueueSave();
}
public void Dispose()
@@ -184,14 +278,50 @@ public sealed class WindowsMusicPlaybackService : IMusicPlaybackService, IDispos
private void Player_MediaOpened(MediaPlayer sender, object args)
{
LastFailure = null;
if (_pendingPosition <= TimeSpan.Zero) return;
var duration = sender.PlaybackSession.NaturalDuration;
sender.PlaybackSession.Position = duration > TimeSpan.Zero && _pendingPosition > duration ? duration : _pendingPosition;
_pendingPosition = TimeSpan.Zero;
}
private void Player_MediaFailed(MediaPlayer sender, MediaPlayerFailedEventArgs args)
=> StateChanged?.Invoke(this, EventArgs.Empty);
private async void Player_MediaFailed(MediaPlayer sender, MediaPlayerFailedEventArgs args)
{
var message = string.IsNullOrWhiteSpace(args.ErrorMessage) ? "Windows 媒体组件无法打开该音频。" : args.ErrorMessage;
var decoding = args.Error.ToString().Contains("Decod", StringComparison.OrdinalIgnoreCase);
if (!_mediaRetryInProgress && _mediaFailureRetries == 0 && CurrentSong is { } song)
{
_mediaFailureRetries = 1;
_mediaRetryInProgress = true;
var position = Position;
try
{
await PlayCoreAsync(song, null, position, CancellationToken.None, isRetry: true).ConfigureAwait(false);
return;
}
catch (Exception exception)
{
LastFailure ??= new MusicPlaybackFailure(
decoding ? MusicPlaybackFailureStage.Decode : MusicPlaybackFailureStage.MediaOpen,
decoding ? "MEDIA_DECODE_FAILED" : "MEDIA_OPEN_FAILED",
$"{message} 标准音质重试失败:{exception.Message}",
false);
}
finally
{
_mediaRetryInProgress = false;
}
}
else
{
LastFailure = new MusicPlaybackFailure(
decoding ? MusicPlaybackFailureStage.Decode : MusicPlaybackFailureStage.MediaOpen,
decoding ? "MEDIA_DECODE_FAILED" : "MEDIA_OPEN_FAILED",
message,
false);
}
StateChanged?.Invoke(this, EventArgs.Empty);
}
private async void Controls_ButtonPressed(SystemMediaTransportControls sender, SystemMediaTransportControlsButtonPressedEventArgs args)
{
@@ -229,9 +359,21 @@ public sealed class WindowsMusicPlaybackService : IMusicPlaybackService, IDispos
updater.Update();
}
private void SetCurrentSong(MusicSong? song)
{
var changed = !string.Equals(CurrentSong?.Provider, song?.Provider, StringComparison.OrdinalIgnoreCase) ||
!string.Equals(CurrentSong?.Id, song?.Id, StringComparison.Ordinal);
CurrentSong = song;
if (changed)
{
CurrentSongChanged?.Invoke(this, EventArgs.Empty);
if (song is not null) _ = RecordHistoryAsync(song);
}
}
private void OnStateChanged()
{
_ = SaveAsync();
QueueSave();
StateChanged?.Invoke(this, EventArgs.Empty);
}
@@ -241,7 +383,7 @@ public sealed class WindowsMusicPlaybackService : IMusicPlaybackService, IDispos
if (song is null) return;
try
{
await PlayCoreAsync(song, null, _resumePosition, CancellationToken.None).ConfigureAwait(false);
await PlayCoreAsync(song, null, _resumePosition, CancellationToken.None, isRetry: false).ConfigureAwait(false);
}
catch
{
@@ -249,6 +391,33 @@ public sealed class WindowsMusicPlaybackService : IMusicPlaybackService, IDispos
}
}
private void QueueSave() => _ = SaveSafelyAsync();
private async Task SaveSafelyAsync(CancellationToken cancellationToken = default)
{
try
{
await SaveAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
}
catch (Exception exception)
{
try
{
await _logService.WriteAsync(
"Warning",
"music",
"音乐会话状态保存失败",
exception.GetType().Name).ConfigureAwait(false);
}
catch
{
}
}
}
private Task SaveAsync(CancellationToken cancellationToken = default)
=> _sessionStore.SaveAsync(new MusicSession(
CurrentSong?.Id,
@@ -256,7 +425,28 @@ public sealed class WindowsMusicPlaybackService : IMusicPlaybackService, IDispos
Volume,
Queue.Mode,
Quality,
Queue.Items,
Queue.Items.ToArray(),
Queue.CurrentIndex,
DateTimeOffset.Now), cancellationToken);
DateTimeOffset.Now)
{
CurrentSongProvider = CurrentSong?.Provider
}, cancellationToken);
private async Task RecordHistoryAsync(MusicSong song)
{
try
{
await _historyStore.RecordPlayedAsync(song).ConfigureAwait(false);
}
catch (Exception exception)
{
try
{
await _logService.WriteAsync("Warning", "music", "最近播放保存失败", exception.GetType().Name).ConfigureAwait(false);
}
catch
{
}
}
}
}