Files
LanMountainDesktop/LanMountainDesktop/Views/Components/RemovableStorageWidget.axaml.cs
lincube 33baaa579d 0.7.1
2026-03-20 22:37:37 +08:00

596 lines
21 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.Threading;
using FluentIcons.Avalonia;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.Theme;
namespace LanMountainDesktop.Views.Components;
public partial class RemovableStorageWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, IComponentPlacementContextAware, IDisposable
{
private readonly record struct RemovableStoragePalette(
Color BackgroundFrom,
Color BackgroundTo,
Color Border,
Color AccentOrb,
Color AccentGlow,
Color IconBadgeBackground,
Color IconForeground,
Color PrimaryText,
Color SecondaryText,
Color StatusText,
Color Accent,
Color OnAccent,
Color SecondaryButtonBackground,
Color SecondaryButtonBorder,
Color SecondaryButtonForeground,
Color DisabledButtonBackground,
Color DisabledButtonBorder,
Color DisabledButtonForeground);
private readonly DispatcherTimer _pollTimer = new()
{
Interval = TimeSpan.FromSeconds(2)
};
private readonly IRemovableStorageService _removableStorageService = new RemovableStorageService();
private readonly LocalizationService _localizationService = new();
private ISettingsService _settingsService = HostSettingsFacadeProvider.GetOrCreate().Settings;
private IComponentInstanceSettingsStore _componentSettingsStore = HostComponentSettingsStoreProvider.GetOrCreate();
private IReadOnlyList<RemovableStorageDrive> _connectedDrives = Array.Empty<RemovableStorageDrive>();
private string _componentId = BuiltInComponentIds.DesktopRemovableStorage;
private string _placementId = string.Empty;
private string _languageCode = "zh-CN";
private string? _componentColorScheme;
private string _selectedDriveRootPath = string.Empty;
private string? _statusOverrideText;
private double _currentCellSize = 48;
private bool _isAttached;
private bool _isOnActivePage = true;
private bool _isRefreshing;
private bool _isDisposed;
public RemovableStorageWidget()
{
InitializeComponent();
_pollTimer.Tick += OnPollTimerTick;
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
ActualThemeVariantChanged += OnActualThemeVariantChanged;
ApplyCellSize(_currentCellSize);
ReloadSettings();
ApplyVisualState();
}
public void ApplyCellSize(double cellSize)
{
_currentCellSize = Math.Max(1, cellSize);
ApplyLayoutMetrics();
}
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
{
_ = isEditMode;
var shouldRefresh = !_isOnActivePage && isOnActivePage;
_isOnActivePage = isOnActivePage;
UpdatePollingState();
if (shouldRefresh)
{
_ = RefreshDriveListAsync();
}
}
public void SetComponentPlacementContext(string componentId, string? placementId)
{
_componentId = string.IsNullOrWhiteSpace(componentId)
? BuiltInComponentIds.DesktopRemovableStorage
: componentId.Trim();
_placementId = placementId?.Trim() ?? string.Empty;
RefreshFromSettings();
}
public void RefreshFromSettings()
{
ReloadSettings();
ApplyVisualState();
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_ = sender;
_ = e;
_isAttached = true;
UpdatePollingState();
_ = RefreshDriveListAsync();
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_ = sender;
_ = e;
_isAttached = false;
UpdatePollingState();
}
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
{
_ = sender;
_ = e;
ApplyLayoutMetrics();
}
private void OnActualThemeVariantChanged(object? sender, EventArgs e)
{
_ = sender;
_ = e;
ApplyVisualState();
}
private async void OnPollTimerTick(object? sender, EventArgs e)
{
_ = sender;
_ = e;
await RefreshDriveListAsync();
}
private async void OnOpenClick(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
var drive = GetSelectedDrive();
if (drive is null)
{
return;
}
if (_removableStorageService.OpenDrive(drive.RootPath))
{
_statusOverrideText = L("removable_storage.widget.ready", "Ready to open or eject.");
ApplyVisualState();
return;
}
_statusOverrideText = L("removable_storage.widget.open_failed", "Failed to open this drive.");
ApplyVisualState();
await RefreshDriveListAsync();
}
private async void OnEjectClick(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
var drive = GetSelectedDrive();
if (drive is null)
{
return;
}
_statusOverrideText = L("removable_storage.widget.ejecting", "Ejecting drive...");
ApplyVisualState();
var ejected = _removableStorageService.EjectDrive(drive.RootPath);
_statusOverrideText = ejected
? L("removable_storage.widget.ejecting", "Ejecting drive...")
: L("removable_storage.widget.eject_failed", "Could not eject this drive. Close any files on it and try again.");
ApplyVisualState();
await RefreshDriveListAsync();
}
private async Task RefreshDriveListAsync()
{
if (_isDisposed || _isRefreshing)
{
return;
}
_isRefreshing = true;
try
{
var previousDriveRoots = new HashSet<string>(
_connectedDrives.Select(drive => drive.RootPath),
StringComparer.OrdinalIgnoreCase);
var latestDrives = await Task.Run(() => _removableStorageService.GetConnectedDrives());
if (_isDisposed)
{
return;
}
var newlyInsertedDrive = latestDrives.FirstOrDefault(drive => !previousDriveRoots.Contains(drive.RootPath));
_connectedDrives = latestDrives;
if (newlyInsertedDrive is not null)
{
_selectedDriveRootPath = newlyInsertedDrive.RootPath;
}
else if (string.IsNullOrWhiteSpace(_selectedDriveRootPath) ||
!_connectedDrives.Any(drive => string.Equals(drive.RootPath, _selectedDriveRootPath, StringComparison.OrdinalIgnoreCase)))
{
_selectedDriveRootPath = _connectedDrives.FirstOrDefault().RootPath ?? string.Empty;
}
if (_connectedDrives.Count == 0)
{
_selectedDriveRootPath = string.Empty;
_statusOverrideText = null;
}
else if (newlyInsertedDrive is not null)
{
_statusOverrideText = null;
}
ReloadSettings();
ApplyVisualState();
}
catch (Exception ex)
{
AppLogger.Warn("RemovableStorageWidget", "Failed to refresh removable storage widget.", ex);
_statusOverrideText = L("removable_storage.widget.refresh_failed", "Drive list refresh failed.");
ApplyVisualState();
}
finally
{
_isRefreshing = false;
}
}
private void ReloadSettings()
{
try
{
var appSettings = _settingsService.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
var componentSettings = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
_languageCode = _localizationService.NormalizeLanguageCode(appSettings.LanguageCode);
_componentColorScheme = componentSettings.ColorSchemeSource;
}
catch
{
_languageCode = _localizationService.NormalizeLanguageCode(_languageCode);
}
}
private void ApplyVisualState()
{
var drive = GetSelectedDrive();
var hasDrive = drive is not null;
var palette = ResolvePalette();
RootBorder.Background = CreateGradientBrush(palette.BackgroundFrom, palette.BackgroundTo);
RootBorder.BorderBrush = CreateBrush(palette.Border);
AccentOrb.Background = CreateBrush(palette.AccentOrb);
AccentGlow.Background = CreateBrush(palette.AccentGlow);
IconBadge.Background = CreateBrush(palette.IconBadgeBackground);
DriveIcon.Foreground = CreateBrush(palette.IconForeground);
DriveNameTextBlock.Foreground = CreateBrush(palette.PrimaryText);
DriveDetailTextBlock.Foreground = CreateBrush(palette.SecondaryText);
StatusTextBlock.Foreground = CreateBrush(palette.StatusText);
if (hasDrive)
{
ApplyButtonPalette(
OpenButton,
OpenButtonIcon,
OpenButtonTextBlock,
palette.Accent,
palette.OnAccent,
palette.Accent);
ApplyButtonPalette(
EjectButton,
EjectButtonIcon,
EjectButtonTextBlock,
palette.SecondaryButtonBackground,
palette.SecondaryButtonForeground,
palette.SecondaryButtonBorder);
}
else
{
ApplyButtonPalette(
OpenButton,
OpenButtonIcon,
OpenButtonTextBlock,
palette.DisabledButtonBackground,
palette.DisabledButtonForeground,
palette.DisabledButtonBorder);
ApplyButtonPalette(
EjectButton,
EjectButtonIcon,
EjectButtonTextBlock,
palette.DisabledButtonBackground,
palette.DisabledButtonForeground,
palette.DisabledButtonBorder);
}
OpenButton.IsEnabled = hasDrive;
EjectButton.IsEnabled = hasDrive;
OpenButtonTextBlock.Text = L("removable_storage.action.open", "Open");
EjectButtonTextBlock.Text = L("removable_storage.action.eject", "Eject");
if (hasDrive)
{
var selectedDrive = drive!;
DriveNameTextBlock.Text = ResolveDriveName(selectedDrive);
DriveDetailTextBlock.Text = selectedDrive.DriveLetter;
StatusTextBlock.Text = _statusOverrideText ??
L("removable_storage.widget.ready", "Ready to open or eject.");
}
else
{
DriveNameTextBlock.Text = L("removable_storage.widget.empty_title", "No device inserted");
DriveDetailTextBlock.Text = L("removable_storage.widget.empty_subtitle", "Insert a USB drive to show it here.");
StatusTextBlock.Text = L("removable_storage.widget.empty_hint", "Buttons stay disabled until a removable device is inserted.");
}
ApplyLayoutMetrics();
}
private void ApplyLayoutMetrics()
{
var scale = ResolveScale();
var width = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * 2;
RootBorder.CornerRadius = ComponentChromeCornerRadiusHelper.ResolveMainRectangleRadius();
RootBorder.Padding = new Thickness(
ComponentChromeCornerRadiusHelper.SafeValue(16 * scale, 10, 24),
ComponentChromeCornerRadiusHelper.SafeValue(15 * scale, 10, 22),
ComponentChromeCornerRadiusHelper.SafeValue(16 * scale, 10, 24),
ComponentChromeCornerRadiusHelper.SafeValue(15 * scale, 10, 22));
LayoutGrid.RowSpacing = Math.Clamp(10 * scale, 8, 16);
HeaderGrid.ColumnSpacing = Math.Clamp(12 * scale, 8, 16);
HeaderTextStack.Spacing = Math.Clamp(2 * scale, 1, 4);
var badgeSize = Math.Clamp(44 * scale, 38, 60);
IconBadge.Width = badgeSize;
IconBadge.Height = badgeSize;
IconBadge.CornerRadius = new CornerRadius(badgeSize * 0.5);
DriveIcon.FontSize = Math.Clamp(24 * scale, 20, 32);
DriveNameTextBlock.FontSize = Math.Clamp(16 * scale, 13, 24);
DriveDetailTextBlock.FontSize = Math.Clamp(11.5 * scale, 10, 16);
StatusTextBlock.FontSize = Math.Clamp(12 * scale, 10, 17);
StatusTextBlock.MaxWidth = Math.Max(96, width - (RootBorder.Padding.Left + RootBorder.Padding.Right));
var buttonHeight = Math.Clamp(42 * scale, 38, 54);
var buttonPadding = Math.Clamp(14 * scale, 10, 20);
var buttonCornerRadius = Math.Clamp(buttonHeight * 0.5, 18, 999);
OpenButton.Height = buttonHeight;
OpenButton.Padding = new Thickness(buttonPadding, 0);
OpenButton.CornerRadius = new CornerRadius(buttonCornerRadius);
EjectButton.Height = buttonHeight;
EjectButton.Padding = new Thickness(buttonPadding, 0);
EjectButton.CornerRadius = new CornerRadius(buttonCornerRadius);
OpenButtonIcon.FontSize = Math.Clamp(16 * scale, 14, 20);
EjectButtonIcon.FontSize = Math.Clamp(16 * scale, 14, 20);
OpenButtonTextBlock.FontSize = Math.Clamp(13 * scale, 11.5, 18);
EjectButtonTextBlock.FontSize = Math.Clamp(13 * scale, 11.5, 18);
AccentOrb.Width = Math.Clamp(width * 0.44, 96, 176);
AccentOrb.Height = AccentOrb.Width;
AccentOrb.CornerRadius = new CornerRadius(AccentOrb.Width * 0.5);
AccentGlow.Height = Math.Clamp(76 * scale, 52, 110);
AccentGlow.CornerRadius = new CornerRadius(AccentGlow.Height * 0.5);
}
private RemovableStorageDrive? GetSelectedDrive()
{
if (_connectedDrives.Count == 0)
{
return null;
}
if (!string.IsNullOrWhiteSpace(_selectedDriveRootPath))
{
var selected = _connectedDrives.FirstOrDefault(drive =>
string.Equals(drive.RootPath, _selectedDriveRootPath, StringComparison.OrdinalIgnoreCase));
if (selected is not null)
{
return selected;
}
}
return _connectedDrives[0];
}
private string ResolveDriveName(RemovableStorageDrive drive)
{
return string.IsNullOrWhiteSpace(drive.VolumeLabel)
? L("removable_storage.widget.default_name", "Removable Drive")
: drive.VolumeLabel.Trim();
}
private RemovableStoragePalette ResolvePalette()
{
var useMonetColor = ComponentColorSchemeHelper.ShouldUseMonetColor(
_componentColorScheme,
ComponentColorSchemeHelper.GetCurrentGlobalThemeColorMode());
if (!useMonetColor)
{
var nativeAccent = Color.Parse("#FF65A8FF");
var nativeBackgroundFrom = Color.Parse("#FF10345F");
var nativeBackgroundTo = Color.Parse("#FF0D213E");
var nativePrimaryText = Color.Parse("#FFF4F8FF");
var nativeSecondaryText = Color.Parse("#C8D9F5FF");
var nativeDisabled = Color.Parse("#30465D7A");
return new RemovableStoragePalette(
nativeBackgroundFrom,
nativeBackgroundTo,
Color.Parse("#6A97D6FF"),
Color.Parse("#2F8BC5FF"),
Color.Parse("#4C79BFFF"),
Color.Parse("#335BAAFF"),
Color.Parse("#FFF5FAFF"),
nativePrimaryText,
nativeSecondaryText,
Color.Parse("#D8E7FFFF"),
nativeAccent,
ColorMath.EnsureContrast(Color.Parse("#FF071420"), nativeAccent, 4.5),
Color.Parse("#24FFFFFF"),
Color.Parse("#5A9ACDFF"),
nativePrimaryText,
nativeDisabled,
Color.Parse("#4D6782A0"),
Color.Parse("#8FA8BDD1"));
}
var surfaceRaised = ResolveThemeColor("AdaptiveSurfaceRaisedBrush", "#FF1A2332");
var surfaceOverlay = ResolveThemeColor("AdaptiveSurfaceOverlayBrush", "#FF111827");
var accent = ResolveThemeColor("AdaptiveAccentBrush", "#FF61A8FF");
var onAccent = ResolveThemeColor("AdaptiveOnAccentBrush", "#FFFFFFFF");
var primaryText = ResolveThemeColor("AdaptiveTextPrimaryBrush", "#FFF8FAFC");
var secondaryText = ResolveThemeColor("AdaptiveTextSecondaryBrush", "#FFD0D7E3");
var mutedText = ResolveThemeColor("AdaptiveTextMutedBrush", "#FFAFB8C7");
var disabledButtonBackground = ColorMath.WithAlpha(ColorMath.Blend(surfaceRaised, surfaceOverlay, 0.35), 0xD8);
var disabledButtonBorder = ColorMath.WithAlpha(ColorMath.Blend(surfaceRaised, accent, 0.18), 0x88);
var disabledButtonForeground = ColorMath.WithAlpha(primaryText, 0x88);
var backgroundFrom = ColorMath.Blend(surfaceRaised, accent, 0.18);
var backgroundTo = ColorMath.Blend(surfaceOverlay, surfaceRaised, 0.46);
var border = ColorMath.WithAlpha(ColorMath.Blend(accent, surfaceRaised, 0.38), 0xB8);
var iconBadgeBackground = ColorMath.Blend(surfaceRaised, accent, 0.28);
var iconForeground = ColorMath.EnsureContrast(accent, iconBadgeBackground, 3.0);
var secondaryButtonBackground = ColorMath.WithAlpha(ColorMath.Blend(surfaceRaised, accent, 0.10), 0xE6);
var secondaryButtonBorder = ColorMath.WithAlpha(ColorMath.Blend(accent, surfaceRaised, 0.46), 0xC6);
return new RemovableStoragePalette(
backgroundFrom,
backgroundTo,
border,
ColorMath.WithAlpha(accent, 0x28),
ColorMath.WithAlpha(ColorMath.Blend(accent, backgroundFrom, 0.26), 0x74),
iconBadgeBackground,
iconForeground,
primaryText,
secondaryText,
mutedText,
accent,
onAccent,
secondaryButtonBackground,
secondaryButtonBorder,
primaryText,
disabledButtonBackground,
disabledButtonBorder,
disabledButtonForeground);
}
private Color ResolveThemeColor(string resourceKey, string fallbackHex)
{
if (this.TryFindResource(resourceKey, out var resource))
{
if (resource is ISolidColorBrush solidBrush)
{
return solidBrush.Color;
}
if (resource is SolidColorBrush directSolidBrush)
{
return directSolidBrush.Color;
}
}
return Color.Parse(fallbackHex);
}
private double ResolveScale()
{
var cellScale = Math.Clamp(_currentCellSize / 48d, 0.72, 2.2);
var widthScale = Bounds.Width > 1 ? Math.Clamp(Bounds.Width / 220d, 0.72, 2.4) : 1;
var heightScale = Bounds.Height > 1 ? Math.Clamp(Bounds.Height / 220d, 0.72, 2.4) : 1;
return Math.Clamp(Math.Min(cellScale, Math.Min(widthScale, heightScale)), 0.72, 2.2);
}
private string L(string key, string fallback)
{
return _localizationService.GetString(_languageCode, key, fallback);
}
private static void ApplyButtonPalette(
Button button,
FluentIcon icon,
TextBlock textBlock,
Color background,
Color foreground,
Color border)
{
button.Background = CreateBrush(background);
button.BorderBrush = CreateBrush(border);
button.BorderThickness = new Thickness(1);
button.Foreground = CreateBrush(foreground);
icon.Foreground = CreateBrush(foreground);
textBlock.Foreground = CreateBrush(foreground);
}
private static IBrush CreateGradientBrush(Color from, Color to)
{
return new LinearGradientBrush
{
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
GradientStops = new GradientStops
{
new GradientStop(from, 0),
new GradientStop(to, 1)
}
};
}
private static SolidColorBrush CreateBrush(Color color)
{
return new(color);
}
private void UpdatePollingState()
{
if (_isAttached && _isOnActivePage)
{
if (!_pollTimer.IsEnabled)
{
_pollTimer.Start();
}
return;
}
_pollTimer.Stop();
}
public void Dispose()
{
if (_isDisposed)
{
return;
}
_isDisposed = true;
_pollTimer.Stop();
_pollTimer.Tick -= OnPollTimerTick;
AttachedToVisualTree -= OnAttachedToVisualTree;
DetachedFromVisualTree -= OnDetachedFromVisualTree;
SizeChanged -= OnSizeChanged;
ActualThemeVariantChanged -= OnActualThemeVariantChanged;
}
}