mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
831 lines
31 KiB
C#
831 lines
31 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Avalonia;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Interactivity;
|
|
using Avalonia.Media;
|
|
using Avalonia.Media.Imaging;
|
|
using Avalonia.Styling;
|
|
using Avalonia.Threading;
|
|
using FluentIcons.Common;
|
|
using LanMountainDesktop.DesktopComponents.Runtime;
|
|
using LanMountainDesktop.Services;
|
|
using LanMountainDesktop.Theme;
|
|
|
|
namespace LanMountainDesktop.Views.Components;
|
|
|
|
public partial class MusicControlWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, IDisposable
|
|
{
|
|
private const Symbol PlaySymbol = Symbol.Play;
|
|
private const Symbol PauseSymbol = Symbol.Pause;
|
|
private const Symbol HeartSymbol = Symbol.Heart;
|
|
private const Symbol HeartFilledSymbol = Symbol.Heart;
|
|
|
|
private readonly IMusicControlService _musicControlService = MusicControlServiceFactory.CreateDefault();
|
|
private readonly MonetColorService _monetColorService = new();
|
|
private LanMountainDesktop.PluginSdk.ISettingsService _settingsService = LanMountainDesktop.Services.Settings.HostSettingsFacadeProvider.GetOrCreate().Settings;
|
|
private readonly LocalizationService _localizationService = new();
|
|
|
|
private CancellationTokenSource? _commandCts;
|
|
private Bitmap? _coverBitmap;
|
|
private MusicPlaybackState _currentState = MusicPlaybackState.NoSession(isSupported: true);
|
|
private MusicQueueState _currentQueue = MusicQueueState.Empty();
|
|
private string _languageCode = "zh-CN";
|
|
private double _currentCellSize = 48;
|
|
private bool _isAttached;
|
|
private bool _isOnActivePage = true;
|
|
private bool _isExecutingCommand;
|
|
private double _progressRatio;
|
|
private bool _isProgressIndeterminate;
|
|
private bool _isListening;
|
|
|
|
public MusicControlWidget()
|
|
{
|
|
InitializeComponent();
|
|
|
|
AttachedToVisualTree += OnAttachedToVisualTree;
|
|
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
|
SizeChanged += OnSizeChanged;
|
|
|
|
// Subscribe to service events
|
|
_musicControlService.PlaybackStateChanged += OnPlaybackStateChanged;
|
|
_musicControlService.QueueChanged += OnQueueChanged;
|
|
|
|
ApplyCellSize(_currentCellSize);
|
|
ApplyDynamicBackground(null);
|
|
ApplyState(MusicPlaybackState.NoSession(isSupported: OperatingSystem.IsWindows()));
|
|
}
|
|
|
|
public void ApplyCellSize(double cellSize)
|
|
{
|
|
_currentCellSize = Math.Max(1, cellSize);
|
|
var scale = ResolveScale();
|
|
|
|
var rootCornerRadius = ComponentChromeCornerRadiusHelper.Scale(30 * scale, 16, 44);
|
|
RootBorder.CornerRadius = rootCornerRadius;
|
|
ContentPaddingBorder.Padding = new Thickness(
|
|
ComponentChromeCornerRadiusHelper.SafeValue(14 * scale, 9, 22),
|
|
ComponentChromeCornerRadiusHelper.SafeValue(11 * scale, 7, 18),
|
|
ComponentChromeCornerRadiusHelper.SafeValue(14 * scale, 9, 22),
|
|
ComponentChromeCornerRadiusHelper.SafeValue(11 * scale, 7, 18));
|
|
LayoutGrid.RowSpacing = ComponentChromeCornerRadiusHelper.SafeValue(9 * scale, 6, 14);
|
|
HeaderRowGrid.ColumnSpacing = ComponentChromeCornerRadiusHelper.SafeValue(11 * scale, 8, 18);
|
|
MetaStackPanel.Spacing = ComponentChromeCornerRadiusHelper.SafeValue(3 * scale, 1, 6);
|
|
TimelineRowGrid.ColumnSpacing = ComponentChromeCornerRadiusHelper.SafeValue(9 * scale, 6, 14);
|
|
ActionRowGrid.ColumnSpacing = ComponentChromeCornerRadiusHelper.SafeValue(12 * scale, 8, 20);
|
|
ActionRowGrid.Margin = new Thickness(0, ComponentChromeCornerRadiusHelper.SafeValue(1 * scale, 0, 4), 0, 0);
|
|
DynamicBackgroundBase.CornerRadius = rootCornerRadius;
|
|
BackdropCoverHost.CornerRadius = rootCornerRadius;
|
|
DynamicGradientOverlay.CornerRadius = rootCornerRadius;
|
|
DynamicSoftLightOverlay.CornerRadius = rootCornerRadius;
|
|
|
|
CoverBorder.Width = Math.Clamp(56 * scale, 38, 86);
|
|
CoverBorder.Height = Math.Clamp(56 * scale, 38, 86);
|
|
CoverBorder.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(12 * scale, 8, 16);
|
|
|
|
TitleTextBlock.FontSize = Math.Clamp(20 * scale, 12, 28);
|
|
ArtistTextBlock.FontSize = Math.Clamp(14 * scale, 9, 18);
|
|
PlaybackActivityIcon.FontSize = Math.Clamp(13 * scale, 9, 16);
|
|
|
|
SourceAppButton.Padding = new Thickness(
|
|
Math.Clamp(9 * scale, 6, 14),
|
|
Math.Clamp(5 * scale, 3, 8));
|
|
SourceAppButton.Margin = new Thickness(0, Math.Clamp(1 * scale, 0, 3), 0, 0);
|
|
var sourceButtonHeight = Math.Clamp(32 * scale, 22, 44);
|
|
SourceAppButton.Height = sourceButtonHeight;
|
|
SourceAppButton.MinWidth = Math.Clamp(62 * scale, 46, 94);
|
|
SourceAppButton.CornerRadius = new CornerRadius(sourceButtonHeight / 2d);
|
|
SourceAppGlyphBadge.Width = Math.Clamp(22 * scale, 15, 30);
|
|
SourceAppGlyphBadge.Height = Math.Clamp(22 * scale, 15, 30);
|
|
SourceAppIcon.FontSize = Math.Clamp(13 * scale, 9, 18);
|
|
SourceChevronIcon.FontSize = Math.Clamp(12 * scale, 8, 16);
|
|
|
|
PositionTextBlock.FontSize = Math.Clamp(13 * scale, 8, 15);
|
|
DurationTextBlock.FontSize = Math.Clamp(13 * scale, 8, 15);
|
|
ProgressTrackHost.MinWidth = Math.Clamp(124 * scale, 88, 190);
|
|
var progressHeight = Math.Clamp(3.2 * scale, 2, 6);
|
|
ProgressTrackHost.Height = progressHeight;
|
|
ProgressTrackBorder.CornerRadius = new CornerRadius(progressHeight / 2d);
|
|
ProgressFillBorder.CornerRadius = new CornerRadius(progressHeight / 2d);
|
|
|
|
QueueButton.Width = QueueButton.Height = Math.Clamp(31 * scale, 23, 42);
|
|
FavoriteButton.Width = FavoriteButton.Height = Math.Clamp(31 * scale, 23, 42);
|
|
PreviousButton.Width = PreviousButton.Height = Math.Clamp(34 * scale, 25, 44);
|
|
NextButton.Width = NextButton.Height = Math.Clamp(34 * scale, 25, 44);
|
|
PlayPauseButton.Width = PlayPauseButton.Height = Math.Clamp(44 * scale, 31, 58);
|
|
|
|
QueueIcon.FontSize = Math.Clamp(16 * scale, 11, 21);
|
|
PreviousIcon.FontSize = Math.Clamp(18 * scale, 13, 24);
|
|
PlayPauseGlyphIcon.FontSize = Math.Clamp(23 * scale, 15, 32);
|
|
NextIcon.FontSize = Math.Clamp(18 * scale, 13, 24);
|
|
FavoriteIcon.FontSize = Math.Clamp(16 * scale, 11, 21);
|
|
|
|
UpdateTypography();
|
|
UpdateProgressVisual(_progressRatio, _isProgressIndeterminate);
|
|
}
|
|
|
|
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
|
|
{
|
|
_ = isEditMode;
|
|
var wasOnActivePage = _isOnActivePage;
|
|
_isOnActivePage = isOnActivePage;
|
|
UpdateListeningState();
|
|
|
|
if (!wasOnActivePage && _isOnActivePage && _isAttached)
|
|
{
|
|
// Refresh state when becoming visible again
|
|
_ = RefreshStateAsync();
|
|
}
|
|
}
|
|
|
|
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
|
{
|
|
_isAttached = true;
|
|
UpdateListeningState();
|
|
_ = RefreshStateAsync();
|
|
}
|
|
|
|
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
|
{
|
|
_isAttached = false;
|
|
UpdateListeningState();
|
|
CancelCommandRequest();
|
|
DisposeCoverBitmap();
|
|
}
|
|
|
|
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
|
{
|
|
ApplyCellSize(_currentCellSize);
|
|
}
|
|
|
|
private void OnPlaybackStateChanged(object? sender, MusicPlaybackState state)
|
|
{
|
|
Dispatcher.UIThread.Post(() =>
|
|
{
|
|
if (!_isAttached || !_isOnActivePage)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_currentState = state;
|
|
ApplyState(state);
|
|
});
|
|
}
|
|
|
|
private void OnQueueChanged(object? sender, MusicQueueState queue)
|
|
{
|
|
Dispatcher.UIThread.Post(() =>
|
|
{
|
|
if (!_isAttached || !_isOnActivePage)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_currentQueue = queue;
|
|
UpdateQueueButtonState();
|
|
});
|
|
}
|
|
|
|
private void UpdateListeningState()
|
|
{
|
|
var shouldListen = _isAttached && _isOnActivePage;
|
|
|
|
if (shouldListen && !_isListening)
|
|
{
|
|
_musicControlService.StartListening();
|
|
_isListening = true;
|
|
}
|
|
else if (!shouldListen && _isListening)
|
|
{
|
|
_musicControlService.StopListening();
|
|
_isListening = false;
|
|
}
|
|
}
|
|
|
|
private async Task RefreshStateAsync()
|
|
{
|
|
if (!_isAttached || !_isOnActivePage)
|
|
{
|
|
return;
|
|
}
|
|
|
|
UpdateLanguageCode();
|
|
|
|
try
|
|
{
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
var state = await _musicControlService.GetCurrentStateAsync(cts.Token);
|
|
|
|
if (cts.IsCancellationRequested || !_isAttached)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_currentState = state;
|
|
ApplyState(state);
|
|
|
|
// Also refresh queue
|
|
var queue = await _musicControlService.GetPlaybackQueueAsync(20, cts.Token);
|
|
_currentQueue = queue;
|
|
UpdateQueueButtonState();
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Ignore cancellation.
|
|
}
|
|
catch
|
|
{
|
|
var fallbackState = MusicPlaybackState.NoSession(isSupported: OperatingSystem.IsWindows());
|
|
_currentState = fallbackState;
|
|
ApplyState(fallbackState);
|
|
}
|
|
}
|
|
|
|
private async void OnPlayPauseButtonClick(object? sender, RoutedEventArgs e)
|
|
{
|
|
await ExecuteCommandAsync(token => _musicControlService.TogglePlayPauseAsync(token));
|
|
}
|
|
|
|
private async void OnPreviousButtonClick(object? sender, RoutedEventArgs e)
|
|
{
|
|
await ExecuteCommandAsync(token => _musicControlService.SkipPreviousAsync(token));
|
|
}
|
|
|
|
private async void OnNextButtonClick(object? sender, RoutedEventArgs e)
|
|
{
|
|
await ExecuteCommandAsync(token => _musicControlService.SkipNextAsync(token));
|
|
}
|
|
|
|
private async void OnFavoriteButtonClick(object? sender, RoutedEventArgs e)
|
|
{
|
|
await ExecuteCommandAsync(token => _musicControlService.ToggleFavoriteAsync(token));
|
|
}
|
|
|
|
private async void OnQueueButtonClick(object? sender, RoutedEventArgs e)
|
|
{
|
|
// Show queue flyout or panel
|
|
// For now, just refresh the queue
|
|
try
|
|
{
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
var queue = await _musicControlService.GetPlaybackQueueAsync(20, cts.Token);
|
|
_currentQueue = queue;
|
|
UpdateQueueButtonState();
|
|
}
|
|
catch { }
|
|
}
|
|
|
|
private async void OnSourceAppButtonClick(object? sender, RoutedEventArgs e)
|
|
{
|
|
await ExecuteCommandAsync(
|
|
token => _musicControlService.LaunchSourceAppAsync(token),
|
|
refreshAfterCommand: false,
|
|
requireActiveSession: false);
|
|
}
|
|
|
|
private async Task ExecuteCommandAsync(
|
|
Func<CancellationToken, Task<bool>> command,
|
|
bool refreshAfterCommand = true,
|
|
bool requireActiveSession = true)
|
|
{
|
|
if (_isExecutingCommand
|
|
|| !_currentState.IsSupported
|
|
|| (requireActiveSession && !_currentState.HasSession))
|
|
{
|
|
return;
|
|
}
|
|
|
|
_isExecutingCommand = true;
|
|
ApplyActionButtonState(_currentState);
|
|
|
|
try
|
|
{
|
|
CancelCommandRequest();
|
|
_commandCts = new CancellationTokenSource(TimeSpan.FromSeconds(4));
|
|
_ = await command(_commandCts.Token);
|
|
}
|
|
catch
|
|
{
|
|
// Ignore command transport errors and recover on next event.
|
|
}
|
|
finally
|
|
{
|
|
_isExecutingCommand = false;
|
|
CancelCommandRequest();
|
|
}
|
|
|
|
if (refreshAfterCommand)
|
|
{
|
|
// The event-driven system will update the UI automatically,
|
|
// but we also do a manual refresh to ensure consistency
|
|
await Task.Delay(100);
|
|
await RefreshStateAsync();
|
|
}
|
|
}
|
|
|
|
private void CancelCommandRequest()
|
|
{
|
|
var cts = Interlocked.Exchange(ref _commandCts, null);
|
|
if (cts is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
cts.Cancel();
|
|
cts.Dispose();
|
|
}
|
|
|
|
private void ApplyState(MusicPlaybackState state)
|
|
{
|
|
var hasMediaSession = state.IsSupported && state.HasSession;
|
|
|
|
if (!state.IsSupported)
|
|
{
|
|
TitleTextBlock.Text = L("music.widget.unsupported", "Music control is only available on Windows");
|
|
ArtistTextBlock.Text = L("music.widget.unsupported_hint", "SMTC backend is unavailable");
|
|
SourceAppTextBlock.Text = L("music.widget.open_player", "Open player");
|
|
StatusTextBlock.Text = "--";
|
|
PositionTextBlock.Text = "00:00";
|
|
DurationTextBlock.Text = "00:00";
|
|
PlaybackActivityIcon.IsVisible = false;
|
|
PlayPauseGlyphIcon.Symbol = PlaySymbol;
|
|
UpdateProgressVisual(0, false);
|
|
SetCoverImage(null);
|
|
ApplyNoMediaVisualTheme();
|
|
ApplyActionButtonState(state);
|
|
UpdateSourceAppButtonTooltip();
|
|
return;
|
|
}
|
|
|
|
if (!state.HasSession)
|
|
{
|
|
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;
|
|
|
|
// Update favorite button
|
|
FavoriteIcon.Symbol = state.IsFavorite ? HeartFilledSymbol : HeartSymbol;
|
|
FavoriteIcon.IconVariant = state.IsFavorite ? IconVariant.Filled : IconVariant.Regular;
|
|
|
|
SetCoverImage(state.ThumbnailBytes);
|
|
ApplyActionButtonState(state);
|
|
UpdateTypography();
|
|
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
|
|
? state.CanToggleFavorite
|
|
: showNoSessionStyle;
|
|
}
|
|
|
|
private void UpdateQueueButtonState()
|
|
{
|
|
// Update queue button visual state based on queue availability
|
|
var hasQueue = _currentQueue.IsSupported && _currentQueue.HasMoreItems;
|
|
QueueIcon.Opacity = hasQueue ? 1.0 : 0.5;
|
|
}
|
|
|
|
private void ApplyNoMediaVisualTheme()
|
|
{
|
|
ArtistTextBlock.MaxLines = 2;
|
|
|
|
DynamicBackgroundBase.Background = new SolidColorBrush(Color.Parse("#F0635D61"));
|
|
DynamicGradientOverlay.Background = new LinearGradientBrush
|
|
{
|
|
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
|
|
EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
|
|
GradientStops =
|
|
[
|
|
new GradientStop(Color.Parse("#44FFFFFF"), 0.0),
|
|
new GradientStop(Color.Parse("#15000000"), 0.60),
|
|
new GradientStop(Color.Parse("#30000000"), 1.0)
|
|
]
|
|
};
|
|
DynamicSoftLightOverlay.Background = new LinearGradientBrush
|
|
{
|
|
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
|
|
EndPoint = new RelativePoint(0, 1, RelativeUnit.Relative),
|
|
GradientStops =
|
|
[
|
|
new GradientStop(Color.Parse("#05000000"), 0.0),
|
|
new GradientStop(Color.Parse("#24000000"), 1.0)
|
|
]
|
|
};
|
|
|
|
RootBorder.BorderBrush = new SolidColorBrush(Color.Parse("#58FFFFFF"));
|
|
ProgressTrackBorder.Background = new SolidColorBrush(Color.Parse("#3DFFFFFF"));
|
|
ProgressFillBorder.Background = new SolidColorBrush(Color.Parse("#65FFFFFF"));
|
|
|
|
CoverBorder.Background = new LinearGradientBrush
|
|
{
|
|
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
|
|
EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
|
|
GradientStops =
|
|
[
|
|
new GradientStop(Color.Parse("#FFFF4767"), 0.0),
|
|
new GradientStop(Color.Parse("#FFFF1F56"), 0.58),
|
|
new GradientStop(Color.Parse("#FFD60045"), 1.0)
|
|
]
|
|
};
|
|
CoverBorder.BorderBrush = new SolidColorBrush(Color.Parse("#48FFFFFF"));
|
|
CoverFallbackGlyph.Symbol = Symbol.MusicNote1;
|
|
CoverFallbackGlyph.IconVariant = IconVariant.Filled;
|
|
CoverFallbackGlyph.Foreground = new SolidColorBrush(Color.Parse("#F5EFF3"));
|
|
|
|
SourceAppButton.Background = new SolidColorBrush(Color.Parse("#2FFFFFFF"));
|
|
SourceAppButton.BorderBrush = new SolidColorBrush(Color.Parse("#30FFFFFF"));
|
|
SourceAppGlyphBadge.Background = new SolidColorBrush(Color.Parse("#57FFFFFF"));
|
|
SourceAppGlyphBadge.BorderBrush = new SolidColorBrush(Color.Parse("#00FFFFFF"));
|
|
SourceAppIcon.IconVariant = IconVariant.Filled;
|
|
SourceAppIcon.Foreground = new SolidColorBrush(Color.Parse("#FBFFFFFF"));
|
|
|
|
// Reset favorite icon
|
|
FavoriteIcon.Symbol = HeartSymbol;
|
|
FavoriteIcon.IconVariant = IconVariant.Regular;
|
|
}
|
|
|
|
private void ApplyActiveVisualTheme()
|
|
{
|
|
ArtistTextBlock.MaxLines = 1;
|
|
|
|
CoverBorder.Background = new SolidColorBrush(Color.Parse("#3CFFFFFF"));
|
|
CoverBorder.BorderBrush = new SolidColorBrush(Color.Parse("#77FFFFFF"));
|
|
CoverFallbackGlyph.Symbol = Symbol.Album;
|
|
CoverFallbackGlyph.IconVariant = IconVariant.Regular;
|
|
CoverFallbackGlyph.Foreground = new SolidColorBrush(Color.Parse("#F3FFFFFF"));
|
|
|
|
SourceAppButton.Background = new SolidColorBrush(Color.Parse("#3AFFFFFF"));
|
|
SourceAppButton.BorderBrush = new SolidColorBrush(Color.Parse("#46FFFFFF"));
|
|
SourceAppGlyphBadge.Background = new SolidColorBrush(Color.Parse("#33FFFFFF"));
|
|
SourceAppGlyphBadge.BorderBrush = new SolidColorBrush(Color.Parse("#3CFFFFFF"));
|
|
SourceAppIcon.IconVariant = IconVariant.Filled;
|
|
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 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 void UpdateTypography()
|
|
{
|
|
var scale = ResolveScale();
|
|
var rootWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * 10.5;
|
|
var rootHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * 4.2;
|
|
var headerWidth = Math.Max(120, rootWidth - Math.Max(84, SourceAppButton.MinWidth) - 86);
|
|
var titleWidth = Math.Max(96, headerWidth);
|
|
var metaWidth = Math.Max(96, headerWidth);
|
|
var timelineWidth = Math.Max(52, rootWidth * 0.18);
|
|
var statusWidth = Math.Max(72, Math.Min(headerWidth, rootWidth * 0.26));
|
|
|
|
TitleTextBlock.FontSize = ComponentTypographyLayoutService.FitFontSize(
|
|
TitleTextBlock.Text,
|
|
titleWidth,
|
|
Math.Max(24, rootHeight * 0.12),
|
|
1,
|
|
12,
|
|
Math.Clamp(20 * scale, 12, 28),
|
|
FontWeight.SemiBold,
|
|
1.06d);
|
|
|
|
var artistMaxLines = ArtistTextBlock.MaxLines <= 0 ? 1 : ArtistTextBlock.MaxLines;
|
|
ArtistTextBlock.FontSize = ComponentTypographyLayoutService.FitFontSize(
|
|
ArtistTextBlock.Text,
|
|
metaWidth,
|
|
artistMaxLines > 1 ? Math.Max(32, rootHeight * 0.12) : Math.Max(20, rootHeight * 0.08),
|
|
artistMaxLines,
|
|
9,
|
|
Math.Clamp(14 * scale, 9, 18),
|
|
FontWeight.SemiBold,
|
|
1.06d);
|
|
|
|
PositionTextBlock.FontSize = ComponentTypographyLayoutService.FitFontSize(
|
|
PositionTextBlock.Text,
|
|
timelineWidth,
|
|
18,
|
|
1,
|
|
8,
|
|
Math.Clamp(13 * scale, 8, 15),
|
|
FontWeight.SemiBold,
|
|
1.05d);
|
|
DurationTextBlock.FontSize = ComponentTypographyLayoutService.FitFontSize(
|
|
DurationTextBlock.Text,
|
|
timelineWidth,
|
|
18,
|
|
1,
|
|
8,
|
|
Math.Clamp(13 * scale, 8, 15),
|
|
FontWeight.SemiBold,
|
|
1.05d);
|
|
StatusTextBlock.FontSize = ComponentTypographyLayoutService.FitFontSize(
|
|
StatusTextBlock.Text,
|
|
statusWidth,
|
|
18,
|
|
1,
|
|
8,
|
|
Math.Clamp(13 * scale, 8, 15),
|
|
FontWeight.Medium,
|
|
1.05d);
|
|
}
|
|
|
|
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);
|
|
var widthScale = Bounds.Width > 1
|
|
? Math.Clamp(Bounds.Width / Math.Max(1, _currentCellSize * 4), 0.58, 1.9)
|
|
: 1;
|
|
var heightScale = Bounds.Height > 1
|
|
? Math.Clamp(Bounds.Height / Math.Max(1, _currentCellSize * 2), 0.58, 1.9)
|
|
: 1;
|
|
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;
|
|
}
|
|
|
|
var trackWidth = ProgressTrackHost.Bounds.Width;
|
|
if (indeterminate)
|
|
{
|
|
ProgressFillBorder.Width = Math.Max(trackWidth * 0.24, 14);
|
|
ProgressFillBorder.Opacity = 0.56;
|
|
return;
|
|
}
|
|
|
|
ProgressFillBorder.Width = trackWidth * _progressRatio;
|
|
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 == "--"
|
|
? sourceName
|
|
: string.Create(CultureInfo.InvariantCulture, $"{sourceName} ({StatusTextBlock.Text})");
|
|
ToolTip.SetTip(SourceAppButton, statusText);
|
|
}
|
|
|
|
private void ApplyDynamicBackground(Bitmap? albumBitmap)
|
|
{
|
|
var nightMode = ResolveIsNightMode();
|
|
var palette = _monetColorService.BuildPalette(albumBitmap, nightMode);
|
|
var colors = palette.MonetColors.Count > 0 ? palette.MonetColors : palette.RecommendedColors;
|
|
|
|
var c0 = PickPaletteColor(colors, 0, Color.Parse("#C4A983"));
|
|
var c1 = PickPaletteColor(colors, 1, Color.Parse("#A88C6B"));
|
|
var c2 = PickPaletteColor(colors, 2, Color.Parse("#8B7459"));
|
|
var c3 = PickPaletteColor(colors, 4, Color.Parse("#6F5E4C"));
|
|
|
|
var topLeft = ColorMath.Blend(c0, Color.Parse("#FFFFFFFF"), nightMode ? 0.08 : 0.30);
|
|
var center = ColorMath.Blend(c1, c2, 0.34);
|
|
var bottomRight = ColorMath.Blend(c3, Color.Parse("#FF1F1A16"), nightMode ? 0.42 : 0.20);
|
|
var glow = ColorMath.Blend(c0, Color.Parse("#FFFFFFFF"), 0.38);
|
|
var borderColor = ColorMath.Blend(c0, Color.Parse("#FFFFFFFF"), 0.44);
|
|
|
|
DynamicBackgroundBase.Background = new SolidColorBrush(ColorMath.WithAlpha(center, 0xD6));
|
|
DynamicGradientOverlay.Background = new LinearGradientBrush
|
|
{
|
|
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
|
|
EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
|
|
GradientStops =
|
|
[
|
|
new GradientStop(ColorMath.WithAlpha(topLeft, 0xE6), 0.0),
|
|
new GradientStop(ColorMath.WithAlpha(center, 0xCF), 0.52),
|
|
new GradientStop(ColorMath.WithAlpha(bottomRight, 0xDA), 1.0)
|
|
]
|
|
};
|
|
|
|
DynamicSoftLightOverlay.Background = new LinearGradientBrush
|
|
{
|
|
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
|
|
EndPoint = new RelativePoint(1, 0, RelativeUnit.Relative),
|
|
GradientStops =
|
|
[
|
|
new GradientStop(ColorMath.WithAlpha(glow, 0x44), 0.0),
|
|
new GradientStop(ColorMath.WithAlpha(Color.Parse("#FFFFFFFF"), 0x10), 0.45),
|
|
new GradientStop(ColorMath.WithAlpha(Color.Parse("#FF000000"), nightMode ? (byte)0x44 : (byte)0x2B), 1.0)
|
|
]
|
|
};
|
|
|
|
RootBorder.BorderBrush = new SolidColorBrush(ColorMath.WithAlpha(borderColor, 0x7A));
|
|
ProgressTrackBorder.Background = new SolidColorBrush(
|
|
ColorMath.WithAlpha(ColorMath.Blend(center, Color.Parse("#FFFFFFFF"), 0.44), 0x88));
|
|
ProgressFillBorder.Background = new SolidColorBrush(
|
|
ColorMath.WithAlpha(ColorMath.Blend(c0, Color.Parse("#FFFFFFFF"), 0.76), 0xF2));
|
|
}
|
|
|
|
private bool ResolveIsNightMode()
|
|
{
|
|
if (ActualThemeVariant == ThemeVariant.Dark)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (ActualThemeVariant == ThemeVariant.Light)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return Application.Current?.ActualThemeVariant == ThemeVariant.Dark;
|
|
}
|
|
|
|
private static Color PickPaletteColor(IReadOnlyList<Color> colors, int index, Color fallback)
|
|
{
|
|
if (colors.Count == 0)
|
|
{
|
|
return fallback;
|
|
}
|
|
|
|
var safeIndex = Math.Clamp(index, 0, colors.Count - 1);
|
|
return colors[safeIndex];
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_musicControlService.PlaybackStateChanged -= OnPlaybackStateChanged;
|
|
_musicControlService.QueueChanged -= OnQueueChanged;
|
|
_musicControlService.StopListening();
|
|
if (_musicControlService is IDisposable disposableService)
|
|
{
|
|
disposableService.Dispose();
|
|
}
|
|
|
|
CancelCommandRequest();
|
|
DisposeCoverBitmap();
|
|
}
|
|
}
|