Compare commits

..

8 Commits

Author SHA1 Message Date
lincube
081abeb688 0 6 7
可移动存储组件
2026-03-19 00:17:21 +08:00
lincube
594a62132f 0.6.6
滑动优化
2026-03-18 20:09:00 +08:00
lincube
15e589aedd 0.6.5
流畅性优化测试
2026-03-17 18:36:10 +08:00
lincube
ac4617f5cf 0.6.4 2026-03-17 14:57:41 +08:00
lincube
0645598753 0.6.3.1
最近文件查看优化,课程表组件优化,插件安装优化。
2026-03-17 12:30:30 +08:00
lincube
dadd132b4f 0.6.3
优化了文本框焦点,优化了更新体验,优化了遥测,披露了收集的数据。
2026-03-17 01:01:48 +08:00
lincube
298defb829 0.6.2
删除了视频壁纸功能,为纯色背景添加了自定义颜色选项。
2026-03-16 21:08:54 +08:00
lincube
bcf4be6d50 0.6.1
课表组件修复。加入最近文档组件。
2026-03-16 15:19:46 +08:00
71 changed files with 5526 additions and 568 deletions

View File

@@ -0,0 +1,16 @@
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
version = 1
name = "LanMountainDesktop"
[setup]
script = ""
[[actions]]
name = "运行"
icon = "run"
command = "dotnet run --project 'C:\\Users\\USER693091\\Documents\\GitHub\\LanMountainDesktop\\LanMountainDesktop\\LanMountainDesktop.csproj"
[[actions]]
name = "构建"
icon = "tool"
command = "dotnet build 'C:\\Users\\USER693091\\Documents\\GitHub\\LanMountainDesktop\\LanMountainDesktop.slnx"

View File

@@ -0,0 +1,24 @@
# Checklist
## 1. 课表单双周解析修复
- [x] 单周课程WeekCountDiv=1在单周正确显示
- [x] 双周课程WeekCountDiv=2在双周正确显示
- [x] 每周课程WeekCountDiv=0在所有周正确显示
- [x] 多周轮转2-32周正确计算当前周期位置
## 2. 课程动态移动功能
- [x] 课程结束自动从视图移除
- [x] 新课程自动移入视图可见区域
- [x] 当日课程全部结束后自动切换到次日课程表
## 3. 拖动交互功能
- [x] 课程表支持上下拖动滚动
- [x] 拖动操作流畅、响应及时
## 4. 自动复位功能
- [x] 用户手动拖动后,标记拖动状态
- [x] 当前课程变化时自动复位到最新进行中课程

View File

@@ -0,0 +1,101 @@
# 课程表组件功能优化规格说明书
## Why
当前课程表组件存在以下问题:
1. 单双周课程解析逻辑存在缺陷,无法正确识别单周/双周/每周模式
2. 课程无法动态移动,第一列始终显示进行中的课程,但存在无法正常移动的问题
3. 缺少用户拖动交互功能
4. 缺少拖动后的自动复位机制
## What Changes
- 修复 ClassIsland 课程单双周解析逻辑
- 实现课程动态移动机制(当前课程结束自动上移)
- 实现课程表上下拖动交互功能
- 实现自动复位功能(课程结束后视图复位到最新进行中课程)
## Impact
### Affected specs
- 课程表组件功能规范
### Affected code
- `Services/ClassIslandScheduleDataService.cs` - 课表解析服务
- `Views/Components/ClassScheduleWidget.axaml.cs` - 课表组件
---
## ADDED Requirements
### Requirement: 单双周课程解析
系统 SHALL 能够正确解析包含单双周信息的课程数据。
#### Scenario: 单周课程
- **WHEN** 课程设置为单周上课
- **THEN** 课程仅在单周显示
#### Scenario: 双周课程
- **WHEN** 课程设置为双周上课
- **THEN** 课程仅在双周显示
#### Scenario: 每周课程
- **WHEN** 课程设置为每周上课
- **THEN** 课程在所有周显示
---
### Requirement: 课程动态移动
系统 SHALL 实现课程的动态移动机制。
#### Scenario: 课程结束自动上移
- **WHEN** 当前进行中的课程结束
- **THEN** 课程列表自动向上移动
- **AND THEN** 下一个进行中或即将开始的课程移至视图可见区域
#### Scenario: 新课程移入视图
- **WHEN** 新的课程即将开始
- **THEN** 该课程自动移至视图可见区域
#### Scenario: 当日课程全部结束
- **WHEN** 当日所有课程已结束
- **THEN** 自动显示次日课程表
---
### Requirement: 拖动交互功能
系统 SHALL 提供课程表的上下拖动功能。
#### Scenario: 拖动查看课程
- **WHEN** 用户在课程表区域进行上下拖动
- **THEN** 课程列表随拖动方向滚动
- **AND THEN** 拖动操作流畅、响应及时
---
### Requirement: 自动复位功能
系统 SHALL 在用户手动拖动后自动复位到当前课程。
#### Scenario: 当前课程结束触发复位
- **WHEN** 用户手动拖动课程表后,当前课程结束
- **THEN** 视图自动复位到显示最新进行中课程的位置
---
## MODIFIED Requirements
### Requirement: 课程解析逻辑
**当前**: 单双周解析可能存在缺陷
**修改后**: 正确识别 WeekCountDiv 和 WeekCountDivTotal 参数,准确判断单周/双周/每周模式
---
## REMOVED Requirements
(无)

View File

@@ -0,0 +1,61 @@
# Tasks
## 1. 课表单双周解析修复
- [x] Task 1.1: 分析 ClassIsland 课表单双周数据结构
- [x] 分析 ClassIsland Schedule.json 和 Profile.json 中的周数规则字段
- [x] 确认 WeekCountDiv 和 WeekCountDivTotal 的含义和取值范围
- [x] Task 1.2: 修复 GetCyclePositionsByDate 方法
- [x] 检查单周开始日期的计算逻辑
- [x] 修复周期位置计算公式
- [x] Task 1.3: 修复 CheckRegularClassPlan 方法
- [x] 验证 weekCountDiv 和 weekCountDivTotal 的匹配逻辑
- [x] 确保单周=1、双周=2、每周=0 的正确处理
## 2. 课程动态移动功能
- [x] Task 2.1: 分析当前课程状态检测逻辑
- [x] 查看如何判断课程是否为"当前进行中"
- [x] Task 2.2: 实现定时刷新机制
- [x] 增加更频繁的刷新定时器(每分钟检查一次)
- [x] 实现课程状态变化检测
- [x] Task 2.3: 实现动态移动逻辑
- [x] 课程结束后自动上移
- [x] 新课程自动移入视图
- [x] Task 2.4: 实现次日课程切换
- [x] 当日所有课程结束后自动切换到次日
## 3. 拖动交互功能
- [x] Task 3.1: 实现 ScrollViewer 包裹
- [x] 修改 XAML 使用 ScrollViewer 包裹课程列表
- [x] Task 3.2: 实现拖动手势处理
- [x] 添加 PointerPressed/PointerMoved/PointerReleased 处理
- [x] 实现平滑滚动逻辑
## 4. 自动复位功能
- [x] Task 4.1: 记录用户拖动状态
- [x] 添加用户是否手动拖动的标志位
- [x] Task 4.2: 实现自动复位逻辑
- [x] 检测当前课程变化
- [x] 当用户手动拖动且当前课程变化时自动复位
# Task Dependencies
- Task 1.1 -> Task 1.2 -> Task 1.3
- Task 2.1 -> Task 2.2 -> Task 2.3 -> Task 2.4
- Task 3.1 -> Task 3.2
- Task 4.1 -> Task 4.2
# Parallelizable Tasks
- Task 1.x (解析修复) 与 Task 3.x (拖动) 可以并行开发
- Task 2.x (动态移动) 可以在 Task 1 完成后进行

View File

@@ -0,0 +1,578 @@
# 移除视频壁纸功能 - 技术设计文档
## 1. 概述
### 1.1 设计目标
本设计文档描述如何从 LanMountainDesktop 项目中完全移除视频壁纸功能,包括:
- 移除 LibVLC 相关依赖
- 清理主窗口中的视频壁纸代码
- 简化壁纸设置页面
- 清理本地化资源
### 1.2 技术约束
- 保持现有图片壁纸和纯色壁纸功能完整
- 确保应用构建和运行正常
- 不引入新的外部依赖
---
## 2. 架构变更
### 2.1 变更概览图
```
┌─────────────────────────────────────────────────────────────────┐
│ 变更前架构 │
├─────────────────────────────────────────────────────────────────┤
│ MainWindow │
│ ├── DesktopWallpaperLayer (背景层) │
│ │ ├── DesktopWallpaperImageLayer (图片层) │
│ │ ├── DesktopVideoWallpaperImage (视频海报层) │
│ │ └── DesktopVideoWallpaperView (VLC视频播放层) │
│ ├── _libVlc, _videoWallpaperPlayer, _videoWallpaperMedia │
│ └── StartVideoWallpaper(), StopVideoWallpaper() │
│ │
│ WallpaperSettingsPage │
│ ├── 类型选择: Image | Video | SolidColor │
│ └── 视频预览区域 │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 变更后架构 │
├─────────────────────────────────────────────────────────────────┤
│ MainWindow │
│ ├── DesktopWallpaperLayer (背景层) │
│ │ └── DesktopWallpaperImageLayer (图片层) │
│ └── (移除所有视频相关字段和方法) │
│ │
│ WallpaperSettingsPage │
│ ├── 类型选择: Image | SolidColor │
│ └── (移除视频预览区域) │
└─────────────────────────────────────────────────────────────────┘
```
### 2.2 组件变更清单
| 组件 | 变更类型 | 说明 |
|------|----------|------|
| LanMountainDesktop.csproj | 修改 | 移除 LibVLC 包引用 |
| MainWindow.axaml | 修改 | 移除视频控件和命名空间 |
| MainWindow.axaml.cs | 修改 | 移除视频相关字段和清理代码 |
| MainWindow.SettingsHardCut.Stubs.cs | 修改 | 移除视频壁纸方法 |
| AppearanceThemeService.cs | 修改 | 移除视频种子提取器 |
| WallpaperSettingsPage.axaml | 修改 | 移除视频类型UI |
| WallpaperSettingsPageViewModel.cs | 修改 | 移除视频相关属性 |
| SettingsContracts.cs | 修改 | 移除 Video 枚举值 |
| SettingsDomainServices.cs | 修改 | 移除视频扩展名检测 |
| zh-CN.json | 修改 | 移除视频相关本地化文本 |
---
## 3. 详细设计
### 3.1 项目依赖变更 (LanMountainDesktop.csproj)
#### 3.1.1 移除的包引用
```xml
<!-- 移除以下包引用 -->
<PackageReference Include="LibVLCSharp.Avalonia" Version="3.9.5" />
<PackageReference Include="VideoLAN.LibVLC.Windows" Version="3.0.23" Condition="..." />
<PackageReference Include="VideoLAN.LibVLC.Mac" Version="3.1.3.1" Condition="..." />
```
#### 3.1.2 变更影响
- 减少约 100MB+ 的依赖包大小
- 简化构建和发布流程
- 移除平台特定的原生库依赖
---
### 3.2 主窗口 XAML 变更 (MainWindow.axaml)
#### 3.2.1 移除命名空间声明
```xml
<!-- 移除此行 -->
xmlns:vlc="clr-namespace:LibVLCSharp.Avalonia;assembly=LibVLCSharp.Avalonia"
```
#### 3.2.2 移除视频壁纸控件
移除以下控件约第126-137行
```xml
<!-- 移除 DesktopVideoWallpaperImage -->
<Image x:Name="DesktopVideoWallpaperImage"
IsVisible="False"
IsHitTestVisible="False"
Stretch="UniformToFill"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" />
<!-- 移除 DesktopVideoWallpaperView -->
<vlc:VideoView x:Name="DesktopVideoWallpaperView"
IsVisible="False"
IsHitTestVisible="False"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" />
```
---
### 3.3 主窗口代码变更 (MainWindow.axaml.cs)
#### 3.3.1 移除 using 声明
```csharp
// 移除以下 using如果存在
using LibVLCSharp.Shared;
using LibVLCSharp.Avalonia;
```
#### 3.3.2 移除静态字段
```csharp
// 移除以下字段约第68-71行
private static readonly HashSet<string> SupportedVideoExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".mp4", ".mkv", ".webm", ".avi", ".mov", ".m4v"
};
```
#### 3.3.3 移除实例字段
```csharp
// 移除以下字段约第123-146行
private Bitmap? _videoWallpaperPosterBitmap;
private string? _videoWallpaperPosterPath;
private string? _wallpaperVideoPath;
private LibVLC? _libVlc;
private MediaPlayer? _videoWallpaperPlayer;
private Media? _videoWallpaperMedia;
private readonly object _desktopVideoFrameSync = new();
private MediaPlayer.LibVLCVideoLockCb? _desktopVideoLockCallback;
private MediaPlayer.LibVLCVideoUnlockCb? _desktopVideoUnlockCallback;
private MediaPlayer.LibVLCVideoDisplayCb? _desktopVideoDisplayCallback;
private DispatcherTimer? _desktopVideoFrameRefreshTimer;
private IntPtr _desktopVideoFrameBufferPtr;
private byte[]? _desktopVideoStagingBuffer;
private WriteableBitmap? _desktopVideoBitmap;
private int _desktopVideoFrameWidth;
private int _desktopVideoFrameHeight;
private int _desktopVideoFramePitch;
private int _desktopVideoFrameBufferSize;
private int _desktopVideoFrameDirtyFlag;
```
#### 3.3.4 修改 OnClosed 方法
移除视频相关清理代码约第336-350行
```csharp
// 移除以下代码行
StopVideoWallpaper();
_videoWallpaperMedia?.Dispose();
_videoWallpaperMedia = null;
_videoWallpaperPlayer?.Dispose();
_videoWallpaperPlayer = null;
_desktopVideoFrameRefreshTimer?.Stop();
_desktopVideoFrameRefreshTimer = null;
_videoWallpaperPosterBitmap?.Dispose();
_videoWallpaperPosterBitmap = null;
_videoWallpaperPosterPath = null;
_libVlc?.Dispose();
_libVlc = null;
```
---
### 3.4 主窗口 Stub 方法变更 (MainWindow.SettingsHardCut.Stubs.cs)
#### 3.4.1 移除 using 声明
```csharp
// 移除以下 using第19-20行
using LibVLCSharp.Shared;
using LibVLCSharp.Avalonia;
```
#### 3.4.2 移除方法
移除以下完整方法:
| 方法名 | 行号范围 | 说明 |
|--------|----------|------|
| `StartVideoWallpaper` | 337-383 | 启动视频壁纸播放 |
| `StopVideoWallpaper` | 385-395 | 停止视频壁纸播放 |
| `TryCaptureVideoWallpaperPosterFrame` | 666-751 | 捕获视频海报帧 |
| `ApplyVideoWallpaperPosterVisibility` | 647-664 | 控制视频海报可见性 |
#### 3.4.3 修改 UpdateWallpaperDisplay 方法
简化为仅处理图片壁纸:
```csharp
private void UpdateWallpaperDisplay()
{
// 移除视频分支,仅保留图片处理
StopVideoWallpaper(); // 移除此调用
ApplyWallpaperBrush();
}
```
修改后:
```csharp
private void UpdateWallpaperDisplay()
{
ApplyWallpaperBrush();
}
```
#### 3.4.4 修改 ApplyWallpaperBrush 方法
移除所有 `ApplyVideoWallpaperPosterVisibility` 调用:
```csharp
// 移除以下调用
ApplyVideoWallpaperPosterVisibility(showPoster: false);
ApplyVideoWallpaperPosterVisibility(showPoster: _videoWallpaperPosterBitmap is not null);
```
#### 3.4.5 修改 SetWallpaperState 方法
移除视频类型处理分支约第238-247行
```csharp
// 移除以下代码块
var requestedTypeIsVideo = string.Equals(_wallpaperType, "Video", StringComparison.OrdinalIgnoreCase);
if (SupportedVideoExtensions.Contains(extension) || requestedTypeIsVideo)
{
_wallpaperMediaType = WallpaperMediaType.Video;
_wallpaperVideoPath = _wallpaperPath;
_wallpaperDisplayState = File.Exists(_wallpaperPath)
? WallpaperDisplayState.CurrentValidWallpaper
: WallpaperDisplayState.TemporarilyUnavailable;
return;
}
```
---
### 3.5 外观主题服务变更 (AppearanceThemeService.cs)
#### 3.5.1 移除接口和类
移除以下代码约第92-184行
```csharp
// 移除接口
internal interface IVideoWallpaperSeedExtractor
{
IReadOnlyList<Color> ExtractSeedCandidates(string videoPath, MonetColorService monetColorService);
}
// 移除实现类
internal sealed class LibVlcVideoWallpaperSeedExtractor : IVideoWallpaperSeedExtractor
{
// ... 整个类实现
}
```
---
### 3.6 壁纸设置页面 XAML 变更 (WallpaperSettingsPage.axaml)
#### 3.6.1 移除视频预览区域
移除以下代码约第29-44行
```xml
<Border Background="#FFF6F7F9"
IsVisible="{Binding IsVideo}">
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="12">
<fi:FluentIcon Icon="Video"
Width="72"
Height="72"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
<TextBlock Text="{Binding VideoModeHintText}"
Width="300"
TextAlignment="Center"
TextWrapping="Wrap"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
</StackPanel>
</Border>
```
#### 3.6.2 移除视频模式提示文本
移除以下代码约第150-154行
```xml
<TextBlock Margin="0,8,0,0"
IsVisible="{Binding IsVideo}"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Text="{Binding VideoModeHintText}"
TextWrapping="Wrap" />
```
#### 3.6.3 修改填充方式设置可见性绑定
```xml
<!-- 修改前 -->
IsVisible="{Binding IsImageOrVideo}"
<!-- 修改后 -->
IsVisible="{Binding IsImage}"
```
---
### 3.7 壁纸设置 ViewModel 变更 (WallpaperSettingsPageViewModel.cs)
#### 3.7.1 移除属性
```csharp
// 移除以下属性
[ObservableProperty]
private bool _isImageOrVideo;
[ObservableProperty]
private bool _isVideo;
[ObservableProperty]
private string _videoModeHintText = string.Empty;
```
#### 3.7.2 修改 CreateWallpaperTypes 方法
```csharp
// 修改前
private IReadOnlyList<SelectionOption> CreateWallpaperTypes()
{
return
[
new SelectionOption("Image", L("settings.wallpaper.type.image", "Image")),
new SelectionOption("Video", L("settings.wallpaper.type.video", "Video")),
new SelectionOption("SolidColor", L("settings.wallpaper.type.solid_color", "Solid Color"))
];
}
// 修改后
private IReadOnlyList<SelectionOption> CreateWallpaperTypes()
{
return
[
new SelectionOption("Image", L("settings.wallpaper.type.image", "Image")),
new SelectionOption("SolidColor", L("settings.wallpaper.type.solid_color", "Solid Color"))
];
}
```
#### 3.7.3 修改 UpdateVisibility 方法
移除 IsVideo 和 IsImageOrVideo 的赋值:
```csharp
// 移除以下行
IsVideo = SelectedWallpaperType?.Value == "Video";
IsImageOrVideo = SelectedWallpaperType?.Value is "Image" or "Video";
```
#### 3.7.4 修改 RefreshLocalizedText 方法
```csharp
// 移除以下行
VideoModeHintText = L("settings.wallpaper.video_mode", "Video wallpaper uses automatic fill mode.");
```
---
### 3.8 设置契约变更 (SettingsContracts.cs)
#### 3.8.1 修改 WallpaperMediaType 枚举
```csharp
// 修改前
public enum WallpaperMediaType
{
None,
Image,
Video
}
// 修改后
public enum WallpaperMediaType
{
None,
Image
}
```
---
### 3.9 设置域服务变更 (SettingsDomainServices.cs)
#### 3.9.1 移除视频扩展名集合
```csharp
// 移除以下字段约第150-153行
private static readonly HashSet<string> VideoExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".mp4", ".mkv", ".webm", ".avi", ".mov", ".m4v"
};
```
#### 3.9.2 修改 DetectMediaType 方法
```csharp
// 修改前
public WallpaperMediaType DetectMediaType(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return WallpaperMediaType.None;
}
var extension = Path.GetExtension(path.Trim());
if (string.IsNullOrWhiteSpace(extension))
{
return WallpaperMediaType.None;
}
if (ImageExtensions.Contains(extension))
{
return WallpaperMediaType.Image;
}
if (VideoExtensions.Contains(extension))
{
return WallpaperMediaType.Video;
}
return WallpaperMediaType.None;
}
// 修改后
public WallpaperMediaType DetectMediaType(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return WallpaperMediaType.None;
}
var extension = Path.GetExtension(path.Trim());
if (string.IsNullOrWhiteSpace(extension))
{
return WallpaperMediaType.None;
}
if (ImageExtensions.Contains(extension))
{
return WallpaperMediaType.Image;
}
return WallpaperMediaType.None;
}
```
---
### 3.10 本地化文件变更 (zh-CN.json)
#### 3.10.1 移除的本地化键
```json
// 移除以下键值对
"settings.wallpaper.type.video": "视频",
"settings.wallpaper.video_applied": "视频壁纸已应用。",
"settings.wallpaper.video_mode": "视频壁纸使用自动填充模式。",
"settings.wallpaper.video_restored": "已恢复保存的视频壁纸。",
"settings.wallpaper.video_not_found": "未找到视频壁纸文件。",
"settings.wallpaper.video_player_unavailable": "视频播放器不可用。",
"settings.wallpaper.video_play_failed_format": "播放视频壁纸失败:{0}"
```
#### 3.10.2 修改描述文本
```json
// 修改前
"settings.wallpaper.description": "选择图片或视频后可立即设为应用窗口壁纸。",
// 修改后
"settings.wallpaper.description": "选择图片后可立即设为应用窗口壁纸。",
```
---
## 4. 数据模型变更
### 4.1 WallpaperMediaType 枚举简化
```
变更前: None | Image | Video
变更后: None | Image
```
### 4.2 设置存储兼容性
现有用户设置中如果包含 `Type: "Video"` 的壁纸配置:
- 应用将无法识别该类型
- 将回退到纯色背景
- 用户需要重新选择图片壁纸
---
## 5. 风险评估
### 5.1 潜在风险
| 风险 | 影响 | 缓解措施 |
|------|------|----------|
| 现有视频壁纸用户设置失效 | 中 | 应用会自动回退到纯色背景 |
| 遗漏的视频相关代码引用 | 低 | 编译器会报告未定义类型错误 |
| 本地化键遗漏 | 低 | 运行时会显示键名而非翻译文本 |
### 5.2 回滚策略
如需回滚,可通过 Git 恢复以下文件:
- LanMountainDesktop.csproj
- MainWindow.axaml / .axaml.cs
- MainWindow.SettingsHardCut.Stubs.cs
- AppearanceThemeService.cs
- WallpaperSettingsPage.axaml
- WallpaperSettingsPageViewModel.cs
- SettingsContracts.cs
- SettingsDomainServices.cs
- zh-CN.json
---
## 6. 验证清单
### 6.1 编译验证
- [ ] 项目编译无错误
- [ ] 无 LibVLC 相关类型引用警告
- [ ] 无未使用变量警告
### 6.2 功能验证
- [ ] 应用正常启动
- [ ] 图片壁纸正常显示
- [ ] 纯色壁纸正常显示
- [ ] 壁纸设置页面正常打开
- [ ] 类型选择器仅显示"图片"和"纯色"
- [ ] 壁纸导入功能正常工作
### 6.3 清理验证
- [ ] 无 LibVLC 相关 DLL 在输出目录
- [ ] 无视频相关本地化文本残留
- [ ] 无视频相关 UI 控件残留

View File

@@ -0,0 +1,206 @@
# 移除视频壁纸功能规格说明书
## Why
当前 LanMountainDesktop 项目包含视频壁纸功能,该功能引入了以下复杂性和依赖:
1. 引入了 LibVLCSharp.Avalonia、VideoLAN.LibVLC.Windows、VideoLAN.LibVLC.Mac 等重型依赖
2. 在主窗口中残留大量视频壁纸相关代码和字段
3. 在设置页面中保留了视频类型选择器和相关 UI 元素
4. 在本地化文件中保留了视频壁纸相关文本
5. 增加了应用复杂度和维护成本
用户决定移除该功能以简化代码库。
## What Changes
- 移除 LibVLCSharp.Avalonia 及 VideoLAN.LibVLC.* NuGet 依赖
- 移除 AppearanceThemeService.cs 中的 LibVlcVideoWallpaperSeedExtractor 类和 IVideoWallpaperSeedExtractor 接口
- 移除 MainWindow.axaml.cs 中的视频壁纸相关字段和清理代码
- 移除 MainWindow.SettingsHardCut.Stubs.cs 中的视频壁纸相关方法
- 移除 MainWindow.axaml 中的 DesktopVideoWallpaperImage 和 DesktopVideoWallpaperView 控件
- 移除 WallpaperSettingsPage.axaml 中的视频类型选择器和视频模式提示
- 移除 WallpaperSettingsPageViewModel.cs 中的 IsVideo、VideoModeHintText 等属性
- 移除 SettingsContracts.cs 中 WallpaperMediaType 枚举的 Video 值
- 移除 SettingsDomainServices.cs 中 WallpaperMediaService 类的视频扩展名检测逻辑
- 移除本地化文件中的视频壁纸相关文本
## Impact
### Affected specs
- 壁纸设置功能规格
- 主窗口桌面层规格
### Affected code
- `LanMountainDesktop.csproj` - NuGet 依赖配置
- `Services/AppearanceThemeService.cs` - 视频壁纸种子提取器
- `Views/MainWindow.axaml.cs` - 主窗口字段和清理逻辑
- `Views/MainWindow.SettingsHardCut.Stubs.cs` - 视频壁纸控制方法
- `Views/MainWindow.axaml` - 视频壁纸 UI 控件
- `Views/SettingsPages/WallpaperSettingsPage.axaml` - 壁纸设置页面 UI
- `ViewModels/WallpaperSettingsPageViewModel.cs` - 壁纸设置 ViewModel
- `Services/Settings/SettingsContracts.cs` - 壁纸媒体类型枚举
- `Services/Settings/SettingsDomainServices.cs` - 壁纸媒体服务
- `Localization/zh-CN.json` - 本地化文本
---
## REMOVED Requirements
### Requirement: 视频壁纸播放功能
**Reason**: 用户决定移除视频壁纸功能以简化代码库,减少重型依赖
**Migration**:
- 用户如需动态壁纸,可使用静态图片壁纸替代
- 现有视频壁纸设置将被重置为纯色背景
#### Scenario: 视频壁纸播放
- **GIVEN** 用户选择了视频文件作为壁纸
- **WHEN** 系统检测到视频格式
- **THEN** 系统不再支持视频壁纸播放
- **AND THEN** 系统提示用户该文件类型不受支持
### Requirement: LibVLC 依赖
**Reason**: 移除视频壁纸功能后不再需要 LibVLC 库
**Migration**: 从项目依赖中移除以下包:
- LibVLCSharp.Avalonia
- VideoLAN.LibVLC.Windows
- VideoLAN.LibVLC.Mac
### Requirement: 视频壁纸种子提取
**Reason**: 移除视频壁纸功能后不再需要从视频中提取颜色种子
**Migration**: 移除 `LibVlcVideoWallpaperSeedExtractor` 类和 `IVideoWallpaperSeedExtractor` 接口
### Requirement: 视频壁纸 UI 控件
**Reason**: 移除视频壁纸功能后不再需要视频显示控件
**Migration**: 移除 `DesktopVideoWallpaperImage``DesktopVideoWallpaperView` 控件
### Requirement: 视频类型选择器
**Reason**: 移除视频壁纸功能后不再需要视频类型选项
**Migration**: 从壁纸类型选择器中移除"视频"选项
---
## MODIFIED Requirements
### Requirement: 壁纸媒体类型检测
**当前**: 支持检测 None、Image、Video 三种类型
**修改后**: 仅支持检测 None、Image 两种类型
#### Scenario: 检测媒体类型
- **WHEN** 用户选择壁纸文件
- **THEN** 系统仅检测图片格式(.png, .jpg, .jpeg, .bmp, .gif, .webp
- **AND THEN** 视频格式文件将被识别为不受支持的类型
### Requirement: 壁纸类型选项
**当前**: 提供图片、视频、纯色三种类型选项
**修改后**: 仅提供图片、纯色两种类型选项
#### Scenario: 壁纸类型选择
- **WHEN** 用户打开壁纸设置页面
- **THEN** 类型选择器仅显示"图片"和"纯色"选项
- **AND THEN** "视频"选项不再显示
### Requirement: 壁纸设置页面预览
**当前**: 根据类型显示图片预览、视频预览或纯色预览
**修改后**: 根据类型显示图片预览或纯色预览
#### Scenario: 预览显示
- **WHEN** 用户选择壁纸类型
- **THEN** 系统仅显示图片预览或纯色预览
- **AND THEN** 视频预览区域不再显示
### Requirement: 主窗口壁纸显示
**当前**: 支持显示静态图片壁纸和视频壁纸
**修改后**: 仅支持显示静态图片壁纸
#### Scenario: 壁纸显示更新
- **WHEN** 用户应用新壁纸
- **THEN** 系统仅处理静态图片壁纸显示
- **AND THEN** 视频壁纸播放逻辑不再执行
---
## ADDED Requirements
### Requirement: 清理残留代码
系统 SHALL 完全移除视频壁纸功能相关的所有代码和资源。
#### Scenario: 主窗口字段清理
- **WHEN** 执行代码清理
- **THEN** 移除以下字段:
- `_videoWallpaperPosterBitmap`
- `_videoWallpaperPosterPath`
- `_libVlc`
- `_videoWallpaperPlayer`
- `_videoWallpaperMedia`
- `_wallpaperVideoPath`
#### Scenario: 主窗口方法清理
- **WHEN** 执行代码清理
- **THEN** 移除以下方法:
- `StartVideoWallpaper`
- `StopVideoWallpaper`
- `TryCaptureVideoWallpaperPosterFrame`
- `ApplyVideoWallpaperPosterVisibility`
- `UpdateWallpaperDisplay` 中的视频处理分支
#### Scenario: ViewModel 属性清理
- **WHEN** 执行代码清理
- **THEN** 移除以下属性:
- `IsVideo`
- `VideoModeHintText`
- `IsImageOrVideo`(改为 `IsImage`
#### Scenario: 本地化文本清理
- **WHEN** 执行代码清理
- **THEN** 移除以下本地化键:
- `settings.wallpaper.type.video`
- `settings.wallpaper.video_applied`
- `settings.wallpaper.video_mode`
- `settings.wallpaper.video_restored`
- `settings.wallpaper.video_not_found`
- `settings.wallpaper.video_player_unavailable`
- `settings.wallpaper.video_play_failed_format`
### Requirement: 依赖项清理
系统 SHALL 从项目文件中移除 LibVLC 相关 NuGet 包引用。
#### Scenario: NuGet 包移除
- **WHEN** 执行依赖清理
- **THEN** 移除以下包引用:
- `LibVLCSharp.Avalonia`
- `VideoLAN.LibVLC.Windows`
- `VideoLAN.LibVLC.Mac`
### Requirement: 构建验证
系统 SHALL 在移除视频壁纸功能后保持正常构建和运行。
#### Scenario: 构建成功
- **WHEN** 执行项目构建
- **THEN** 构建成功无错误
- **AND THEN** 所有现有测试通过
#### Scenario: 应用启动
- **WHEN** 启动应用程序
- **THEN** 应用正常启动
- **AND THEN** 壁纸设置功能正常工作(仅支持图片和纯色)

View File

@@ -0,0 +1,600 @@
# 移除视频壁纸功能 - 编码任务清单
## 任务概览
本文档将技术设计分解为可执行的编码任务,按依赖关系排序执行。
---
## 任务 1: 移除项目依赖
**优先级**: P0 (最高)
**依赖**: 无
**预估工作量**: 5 分钟
### 描述
从项目文件中移除 LibVLC 相关的 NuGet 包引用。
### 输入
- `LanMountainDesktop/LanMountainDesktop.csproj`
### 输出
- 修改后的 `LanMountainDesktop.csproj`,移除以下包引用:
- `LibVLCSharp.Avalonia`
- `VideoLAN.LibVLC.Windows`
- `VideoLAN.LibVLC.Mac`
### 验收标准
- [ ] 项目文件中不再包含 LibVLC 相关包引用
- [ ] 执行 `dotnet restore` 成功
### 执行提示
```
编辑 LanMountainDesktop.csproj移除以下 PackageReference 节点:
1. <PackageReference Include="LibVLCSharp.Avalonia" Version="3.9.5" />
2. <PackageReference Include="VideoLAN.LibVLC.Windows" ... />
3. <PackageReference Include="VideoLAN.LibVLC.Mac" ... />
```
---
## 任务 2: 移除主窗口 XAML 视频控件
**优先级**: P0
**依赖**: 任务 1
**预估工作量**: 10 分钟
### 描述
从 MainWindow.axaml 中移除视频壁纸相关的 XAML 控件和命名空间声明。
### 输入
- `LanMountainDesktop/Views/MainWindow.axaml`
### 输出
- 移除 LibVLC 命名空间声明
- 移除 `DesktopVideoWallpaperImage` 控件
- 移除 `DesktopVideoWallpaperView` 控件
### 验收标准
- [ ] XAML 中无 `xmlns:vlc` 命名空间
- [ ] XAML 中无 `DesktopVideoWallpaperImage` 元素
- [ ] XAML 中无 `DesktopVideoWallpaperView` 元素
### 执行提示
```
编辑 MainWindow.axaml
1. 移除第 9 行: xmlns:vlc="clr-namespace:LibVLCSharp.Avalonia;assembly=LibVLCSharp.Avalonia"
2. 移除第 126-131 行: <Image x:Name="DesktopVideoWallpaperImage" ... />
3. 移除第 133-137 行: <vlc:VideoView x:Name="DesktopVideoWallpaperView" ... />
```
---
## 任务 3: 移除主窗口代码视频字段
**优先级**: P0
**依赖**: 任务 1
**预估工作量**: 15 分钟
### 描述
从 MainWindow.axaml.cs 中移除视频壁纸相关的字段声明。
### 输入
- `LanMountainDesktop/Views/MainWindow.axaml.cs`
### 输出
- 移除 `SupportedVideoExtensions` 静态字段
- 移除所有视频相关实例字段
### 验收标准
- [ ]`SupportedVideoExtensions` 字段
- [ ]`_videoWallpaperPosterBitmap` 字段
- [ ]`_videoWallpaperPosterPath` 字段
- [ ]`_wallpaperVideoPath` 字段
- [ ]`_libVlc` 字段
- [ ]`_videoWallpaperPlayer` 字段
- [ ]`_videoWallpaperMedia` 字段
- [ ]`_desktopVideoFrameSync` 及相关视频帧处理字段
### 执行提示
```
编辑 MainWindow.axaml.cs
1. 移除第 68-71 行的 SupportedVideoExtensions 定义
2. 移除第 123-146 行的所有视频相关字段
```
---
## 任务 4: 移除主窗口 OnClosed 清理代码
**优先级**: P0
**依赖**: 任务 3
**预估工作量**: 5 分钟
### 描述
从 MainWindow.axaml.cs 的 OnClosed 方法中移除视频相关清理代码。
### 输入
- `LanMountainDesktop/Views/MainWindow.axaml.cs` (OnClosed 方法)
### 输出
- 简化的 OnClosed 方法,无视频清理逻辑
### 验收标准
- [ ] OnClosed 方法中无 `StopVideoWallpaper()` 调用
- [ ] OnClosed 方法中无 `_videoWallpaperMedia` 相关清理
- [ ] OnClosed 方法中无 `_videoWallpaperPlayer` 相关清理
- [ ] OnClosed 方法中无 `_libVlc` 相关清理
### 执行提示
```
编辑 MainWindow.axaml.cs 的 OnClosed 方法,移除以下代码行:
- StopVideoWallpaper();
- _videoWallpaperMedia?.Dispose(); _videoWallpaperMedia = null;
- _videoWallpaperPlayer?.Dispose(); _videoWallpaperPlayer = null;
- _desktopVideoFrameRefreshTimer?.Stop(); _desktopVideoFrameRefreshTimer = null;
- _videoWallpaperPosterBitmap?.Dispose(); _videoWallpaperPosterBitmap = null;
- _videoWallpaperPosterPath = null;
- _libVlc?.Dispose(); _libVlc = null;
```
---
## 任务 5: 移除主窗口 Stub 方法
**优先级**: P0
**依赖**: 任务 1
**预估工作量**: 20 分钟
### 描述
从 MainWindow.SettingsHardCut.Stubs.cs 中移除视频壁纸相关方法和 using 声明。
### 输入
- `LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs`
### 输出
- 移除 LibVLC using 声明
- 移除 `StartVideoWallpaper` 方法
- 移除 `StopVideoWallpaper` 方法
- 移除 `TryCaptureVideoWallpaperPosterFrame` 方法
- 移除 `ApplyVideoWallpaperPosterVisibility` 方法
### 验收标准
- [ ]`using LibVLCSharp.Shared;`
- [ ]`using LibVLCSharp.Avalonia;`
- [ ]`StartVideoWallpaper` 方法定义
- [ ]`StopVideoWallpaper` 方法定义
- [ ]`TryCaptureVideoWallpaperPosterFrame` 方法定义
- [ ]`ApplyVideoWallpaperPosterVisibility` 方法定义
### 执行提示
```
编辑 MainWindow.SettingsHardCut.Stubs.cs
1. 移除第 19-20 行的 using 声明
2. 移除 StartVideoWallpaper 方法(第 337-383 行)
3. 移除 StopVideoWallpaper 方法(第 385-395 行)
4. 移除 ApplyVideoWallpaperPosterVisibility 方法(第 647-664 行)
5. 移除 TryCaptureVideoWallpaperPosterFrame 方法(第 666-751 行)
```
---
## 任务 6: 简化壁纸状态处理逻辑
**优先级**: P0
**依赖**: 任务 5
**预估工作量**: 15 分钟
### 描述
修改 MainWindow.SettingsHardCut.Stubs.cs 中的壁纸状态处理方法,移除视频类型分支。
### 输入
- `LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs`
### 输出
- 简化的 `SetWallpaperState` 方法
- 简化的 `UpdateWallpaperDisplay` 方法
- 简化的 `ApplyWallpaperBrush` 方法
### 验收标准
- [ ] `SetWallpaperState` 中无视频类型检测分支
- [ ] `SetWallpaperState` 中无 `_wallpaperVideoPath` 赋值
- [ ] `UpdateWallpaperDisplay` 中无 `StopVideoWallpaper()` 调用
- [ ] `ApplyWallpaperBrush` 中无 `ApplyVideoWallpaperPosterVisibility` 调用
### 执行提示
```
编辑 MainWindow.SettingsHardCut.Stubs.cs
1. SetWallpaperState 方法:
- 移除 requestedTypeIsVideo 变量定义
- 移除视频类型检测 if 块SupportedVideoExtensions.Contains 检查)
2. UpdateWallpaperDisplay 方法:
- 移除视频类型分支,仅保留 ApplyWallpaperBrush() 调用
3. ApplyWallpaperBrush 方法:
- 移除所有 ApplyVideoWallpaperPosterVisibility 调用
```
---
## 任务 7: 移除外观主题服务视频提取器
**优先级**: P1
**依赖**: 任务 1
**预估工作量**: 10 分钟
### 描述
从 AppearanceThemeService.cs 中移除视频壁纸种子提取器接口和实现类。
### 输入
- `LanMountainDesktop/Services/AppearanceThemeService.cs`
### 输出
- 移除 `IVideoWallpaperSeedExtractor` 接口
- 移除 `LibVlcVideoWallpaperSeedExtractor`
### 验收标准
- [ ]`IVideoWallpaperSeedExtractor` 接口定义
- [ ]`LibVlcVideoWallpaperSeedExtractor` 类定义
### 执行提示
```
编辑 AppearanceThemeService.cs
移除第 92-184 行的接口和类定义:
- IVideoWallpaperSeedExtractor 接口
- LibVlcVideoWallpaperSeedExtractor 类
```
---
## 任务 8: 简化壁纸设置页面 XAML
**优先级**: P1
**依赖**: 无
**预估工作量**: 10 分钟
### 描述
从 WallpaperSettingsPage.axaml 中移除视频预览区域和相关 UI 元素。
### 输入
- `LanMountainDesktop/Views/SettingsPages/WallpaperSettingsPage.axaml`
### 输出
- 移除视频预览 Border 区域
- 移除视频模式提示 TextBlock
- 修改填充方式可见性绑定
### 验收标准
- [ ] 无视频预览 BorderIsVisible="{Binding IsVideo}"
- [ ] 无 VideoModeHintText 绑定的 TextBlock
- [ ] 填充方式设置绑定改为 `IsVisible="{Binding IsImage}"`
### 执行提示
```
编辑 WallpaperSettingsPage.axaml
1. 移除第 29-44 行的视频预览 Border
2. 移除第 150-154 行的视频模式提示 TextBlock
3. 修改第 132 行: IsVisible="{Binding IsImageOrVideo}" 改为 IsVisible="{Binding IsImage}"
```
---
## 任务 9: 简化壁纸设置 ViewModel
**优先级**: P1
**依赖**: 任务 8
**预估工作量**: 15 分钟
### 描述
从 WallpaperSettingsPageViewModel.cs 中移除视频相关属性和方法逻辑。
### 输入
- `LanMountainDesktop/ViewModels/WallpaperSettingsPageViewModel.cs`
### 输出
- 移除 `_isImageOrVideo``_isVideo``_videoModeHintText` 属性
- 修改 `CreateWallpaperTypes` 方法
- 修改 `UpdateVisibility` 方法
- 修改 `RefreshLocalizedText` 方法
### 验收标准
- [ ]`IsImageOrVideo` 属性
- [ ]`IsVideo` 属性
- [ ]`VideoModeHintText` 属性
- [ ] `CreateWallpaperTypes` 仅返回 Image 和 SolidColor 选项
- [ ] `UpdateVisibility` 中无 IsVideo、IsImageOrVideo 赋值
- [ ] `RefreshLocalizedText` 中无 VideoModeHintText 赋值
### 执行提示
```
编辑 WallpaperSettingsPageViewModel.cs
1. 移除第 76-77 行的 _isImageOrVideo 字段和属性
2. 移除第 85-86 行的 _isVideo 字段和属性
3. 移除第 94-95 行的 _videoModeHintText 字段和属性
4. 修改 CreateWallpaperTypes 方法,移除 Video 选项
5. 修改 UpdateVisibility 方法,移除 IsVideo 和 IsImageOrVideo 赋值
6. 修改 RefreshLocalizedText 方法,移除 VideoModeHintText 赋值
```
---
## 任务 10: 简化壁纸媒体类型枚举
**优先级**: P1
**依赖**: 无
**预估工作量**: 5 分钟
### 描述
从 SettingsContracts.cs 中移除 WallpaperMediaType 枚举的 Video 值。
### 输入
- `LanMountainDesktop/Services/Settings/SettingsContracts.cs`
### 输出
- 简化的 `WallpaperMediaType` 枚举
### 验收标准
- [ ] `WallpaperMediaType` 枚举仅包含 `None``Image`
### 执行提示
```
编辑 SettingsContracts.cs
修改第 11-16 行的枚举定义:
public enum WallpaperMediaType
{
None,
Image
}
```
---
## 任务 11: 简化壁纸媒体服务
**优先级**: P1
**依赖**: 任务 10
**预估工作量**: 10 分钟
### 描述
从 SettingsDomainServices.cs 中移除视频扩展名检测逻辑。
### 输入
- `LanMountainDesktop/Services/Settings/SettingsDomainServices.cs`
### 输出
- 移除 `VideoExtensions` 字段
- 简化 `DetectMediaType` 方法
### 验收标准
- [ ]`VideoExtensions` 字段定义
- [ ] `DetectMediaType` 方法中无视频扩展名检测逻辑
### 执行提示
```
编辑 SettingsDomainServices.cs
1. 移除第 150-153 行的 VideoExtensions 字段定义
2. 修改 DetectMediaType 方法,移除视频检测分支
```
---
## 任务 12: 清理本地化文件
**优先级**: P2
**依赖**: 无
**预估工作量**: 5 分钟
### 描述
从 zh-CN.json 中移除视频壁纸相关的本地化文本。
### 输入
- `LanMountainDesktop/Localization/zh-CN.json`
### 输出
- 移除视频相关本地化键
- 修改壁纸描述文本
### 验收标准
- [ ]`settings.wallpaper.type.video`
- [ ]`settings.wallpaper.video_applied`
- [ ]`settings.wallpaper.video_mode`
- [ ]`settings.wallpaper.video_restored`
- [ ]`settings.wallpaper.video_not_found`
- [ ]`settings.wallpaper.video_player_unavailable`
- [ ]`settings.wallpaper.video_play_failed_format`
- [ ] `settings.wallpaper.description` 文本已更新
### 执行提示
```
编辑 zh-CN.json
1. 移除以下键值对:
- "settings.wallpaper.type.video"
- "settings.wallpaper.video_applied"
- "settings.wallpaper.video_mode"
- "settings.wallpaper.video_restored"
- "settings.wallpaper.video_not_found"
- "settings.wallpaper.video_player_unavailable"
- "settings.wallpaper.video_play_failed_format"
2. 修改描述文本:
"settings.wallpaper.description": "选择图片后可立即设为应用窗口壁纸。"
```
---
## 任务 13: 构建验证
**优先级**: P0
**依赖**: 任务 1-12 全部完成
**预估工作量**: 10 分钟
### 描述
验证项目在移除视频壁纸功能后能够正常构建。
### 输入
- 整个项目
### 输出
- 构建成功确认
### 验收标准
- [ ] `dotnet build` 执行成功,无编译错误
- [ ] 无 LibVLC 相关类型未定义错误
- [ ] 无未使用变量警告(或已处理)
### 执行提示
```
在项目根目录执行:
dotnet build LanMountainDesktop/LanMountainDesktop.csproj
检查输出:
- 确认无编译错误
- 确认无 LibVLC 相关类型引用错误
```
---
## 任务 14: 功能验证
**优先级**: P0
**依赖**: 任务 13
**预估工作量**: 15 分钟
### 描述
验证应用在移除视频壁纸功能后核心功能正常工作。
### 输入
- 构建后的应用
### 输出
- 功能验证报告
### 验收标准
- [ ] 应用正常启动
- [ ] 图片壁纸正常显示
- [ ] 纯色壁纸正常显示
- [ ] 壁纸设置页面正常打开
- [ ] 类型选择器仅显示"图片"和"纯色"选项
- [ ] 壁纸导入功能正常工作
### 执行提示
```
运行应用:
dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj
手动验证:
1. 应用启动无崩溃
2. 打开设置 -> 壁纸页面
3. 确认类型选择器仅有"图片"和"纯色"
4. 测试选择图片壁纸
5. 测试选择纯色壁纸
```
---
## 任务依赖关系图
```
任务 1 (移除依赖)
├── 任务 2 (XAML控件)
├── 任务 3 (代码字段)
│ └── 任务 4 (OnClosed清理)
├── 任务 5 (Stub方法)
│ └── 任务 6 (状态处理逻辑)
└── 任务 7 (主题服务)
任务 8 (设置页面XAML)
└── 任务 9 (设置ViewModel)
任务 10 (枚举简化)
└── 任务 11 (媒体服务)
任务 12 (本地化) - 独立
任务 13 (构建验证) - 依赖所有任务
└── 任务 14 (功能验证)
```
---
## 执行顺序建议
按以下顺序执行可确保依赖关系正确:
1. **第一批** (可并行): 任务 1, 任务 8, 任务 10, 任务 12
2. **第二批** (可并行): 任务 2, 任务 3, 任务 5, 任务 7, 任务 9, 任务 11
3. **第三批** (可并行): 任务 4, 任务 6
4. **第四批**: 任务 13
5. **第五批**: 任务 14

View File

@@ -4,6 +4,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<Version>1.0.0</Version>
</PropertyGroup>
<ItemGroup>

View File

@@ -67,6 +67,15 @@ public partial class App : Application
internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle =>
(Current as App)?._hostApplicationLifecycle;
// 隐私政策查看事件
public static event Action? CurrentPrivacyPolicyViewRequested;
// 触发隐私政策查看事件的方法
public static void RaisePrivacyPolicyViewRequested()
{
CurrentPrivacyPolicyViewRequested?.Invoke();
}
public PluginRuntimeService? PluginRuntimeService => _pluginRuntimeService;
public ISettingsFacadeService SettingsFacade => _settingsFacade;
public IHostApplicationLifecycle HostApplicationLifecycle => _hostApplicationLifecycle;
@@ -501,6 +510,8 @@ public partial class App : Application
if (languageChanged)
{
// 清除本地化缓存,强制重新加载语言文件
_localizationService.ClearCache();
ApplyCurrentCultureFromSettings();
if (_trayIcons is not null)
{

View File

@@ -0,0 +1,326 @@
# LanMountainDesktop 隐私政策
**最后更新日期2026年3月17日**
---
## 引言
欢迎使用 LanMountainDesktop我们非常重视您的隐私保护。本隐私政策旨在向您说明我们如何收集、使用、存储和保护您的数据。
**请在使用本应用前仔细阅读本隐私政策。使用本应用即表示您同意本政策的条款。**
---
## 1. 数据收集范围
### 1.1 我们收集的数据
当您启用匿名数据收集功能时,我们会收集以下数据:
#### 匿名崩溃数据
- **崩溃报告**:应用崩溃时的错误日志和堆栈跟踪
- **设备信息**操作系统版本、设备型号、架构x64/x86
- **应用版本**:当前使用的应用版本号
- **设备标识符**匿名生成的唯一设备ID不包含个人信息
#### 匿名使用数据
- **应用启动和关闭事件**:记录应用何时启动和关闭
- **功能使用统计**:哪些功能被使用、使用频率
- **设置变更**:用户更改了哪些设置(不包含具体设置值)
- **界面交互**:点击了哪些按钮、访问了哪些页面
- **设备信息**:操作系统、应用版本、设备类型
### 1.2 始终收集的基础数据
**重要说明:** 为了统计应用的用户数量和日活跃用户,即使您关闭了匿名数据收集开关,我们仍会收集以下基础数据:
-**应用启动事件**:用于统计日活跃用户
-**设备标识符**:用于区分不同用户(不包含个人信息)
-**应用版本**:用于统计版本分布
**这些基础数据不包含任何个人身份信息,仅用于统计用户数量和应用使用情况。**
### 1.3 我们不收集的数据
我们**明确承诺不收集**以下数据:
- ❌ 个人身份信息(姓名、邮箱、电话等)
- ❌ 真实姓名或用户名
- ❌ 地理位置信息(精确位置)
- ❌ 文件内容或文档数据
- ❌ 密码或凭据信息
- ❌ 网络浏览历史
- ❌ 联系人信息
- ❌ 照片、视频或音频文件
---
## 2. 数据收集目的
我们收集数据的目的如下:
### 2.1 基础数据用途(始终收集)
- **统计用户数量**:了解应用的用户规模
- **统计日活跃用户**:了解应用的活跃程度
- **版本分布统计**:了解用户使用的版本情况
### 2.2 崩溃数据用途
- **提高应用稳定性**:识别和修复崩溃问题
- **优化性能**:分析性能瓶颈
- **改进用户体验**:了解应用在不同设备上的表现
### 2.3 使用数据用途
- **功能优化**:了解哪些功能最受欢迎,优先改进
- **用户体验改进**:优化界面设计和交互流程
- **统计分析**:了解用户规模和使用趋势
- **产品决策**:基于数据做出产品发展方向决策
---
## 3. 数据存储和处理
### 3.1 数据存储位置
我们使用以下第三方服务存储和处理数据:
#### Sentry崩溃报告
- **用途**:崩溃数据收集和分析
- **位置**:美国
- **官网**https://sentry.io
- **隐私政策**https://sentry.io/privacy/
#### PostHog使用分析
- **用途**:用户行为分析和统计
- **位置**:美国
- **官网**https://posthog.com
- **隐私政策**https://posthog.com/privacy
### 3.2 数据保留期限
- **崩溃数据**保留90天后自动删除
- **使用数据**保留12个月后自动删除
- **设备标识符**:永久保留(用于统计日活用户)
### 3.3 数据安全措施
我们采取以下安全措施保护您的数据:
- ✅ 数据传输使用HTTPS加密
- ✅ 数据存储使用加密技术
- ✅ 访问权限严格控制
- ✅ 定期安全审计
---
## 4. 数据共享
### 4.1 我们不会出售您的数据
我们**明确承诺**
- ❌ 不会出售您的个人数据
- ❌ 不会将您的数据用于广告目的
- ❌ 不会与第三方共享可识别个人的数据
### 4.2 必要的共享
我们仅在以下情况下共享数据:
- **服务提供商**与Sentry和PostHog共享数据以提供服务
- **法律要求**:在法律要求或政府机构合法要求时
---
## 5. 您的权利
### 5.1 选择权
您完全控制详细数据收集:
- **匿名崩溃数据**:可在设置中开启或关闭
- **匿名使用数据**:可在设置中开启或关闭
- **基础数据**:始终收集(用于统计用户数量)
**注意:** 即使关闭所有开关,我们仍会收集基础数据(应用启动事件和设备标识符)以统计用户数量。
### 5.2 数据访问权
您可以:
- 查看我们收集的数据类型
- 了解数据的使用目的
- 了解数据的存储位置
### 5.3 数据删除权
您可以:
- 随时关闭详细数据收集功能
- 删除本地存储的设备标识符
- 联系我们删除已收集的数据
---
## 6. 设备标识符
### 6.1 什么是设备标识符?
设备标识符是一个随机生成的唯一字符串,用于:
- 统计日活用户数量
- 区分不同设备
- 分析用户使用趋势
### 6.2 设备标识符的特点
- **匿名性**:不包含任何个人信息
- **随机性**通过SHA256算法生成
- **唯一性**:每个设备有唯一标识符
- **持久性**即使刷新设备ID仍能关联到同一用户
- **可重置**:您可以在设置中刷新设备标识符
### 6.3 设备标识符刷新
当您刷新设备标识符时:
- 生成新的设备ID
- **持久用户ID保持不变**,确保仍能关联到同一用户
- 统计数据不会丢失
### 6.4 设备标识符示例
```
a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
```
---
## 7. 儿童隐私保护
本应用不面向13岁以下儿童。我们不会故意收集儿童的个人信息。如果您发现我们无意中收集了儿童的数据请联系我们我们将立即删除相关数据。
---
## 8. 国际数据传输
由于我们的服务提供商位于美国,您的数据可能会被传输到美国。我们确保:
- 数据传输符合相关法律法规
- 服务提供商遵守GDPR等隐私法规
- 采取适当的安全措施保护数据
---
## 9. 隐私政策更新
我们可能会不时更新本隐私政策。更新时,我们将:
- 在本页面更新"最后更新日期"
- 在应用内通知您重大变更
- 继续使用应用即表示您同意更新后的政策
---
## 10. 联系我们
如果您对本隐私政策有任何疑问或建议,请通过以下方式联系我们:
- **GitHub Issues**https://github.com/wwiinnddyy/LanMountainDesktop/issues
- **电子邮件**[您的邮箱地址]
---
## 11. 法律依据
### 11.1 GDPR合规
如果您位于欧洲经济区EEA我们的数据处理基于
- **同意**:您明确同意数据收集
- **合法利益**:改进应用性能和用户体验
### 11.2 CCPA合规
如果您是加州居民,您有权:
- 知道我们收集了哪些数据
- 要求删除您的数据
- 选择退出数据销售(我们不销售数据)
---
## 12. 第三方链接
本应用可能包含第三方网站链接。我们不对这些网站的隐私政策负责。请阅读这些网站的隐私政策。
---
## 13. 数据收集示例
### 13.1 崩溃报告示例
```json
{
"event_id": "abc123",
"timestamp": "2024-01-01T12:00:00Z",
"device_id": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
"app_version": "1.0.0",
"os_name": "Windows",
"os_version": "10.0.19041",
"error_message": "NullReferenceException",
"stack_trace": "..."
}
```
### 13.2 使用数据示例
```json
{
"event": "app_online",
"timestamp": "2024-01-01T12:00:00Z",
"distinct_id": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
"properties": {
"app_version": "1.0.0",
"os_name": "Windows",
"event_type": "app_start",
"analytics_enabled": true
}
}
```
### 13.3 基础数据示例(始终收集)
```json
{
"event": "$pageview",
"timestamp": "2024-01-01T12:00:00Z",
"distinct_id": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
"properties": {
"$current_url": "app://main",
"$title": "LanMountainDesktop"
}
}
```
---
## 14. 您的同意
使用本应用即表示您:
- ✅ 已阅读并理解本隐私政策
- ✅ 同意我们按照本政策收集和使用数据
- ✅ 了解您可以随时撤回同意(详细数据收集)
- ✅ 了解基础数据将始终收集以统计用户数量
---
## 15. 免责声明
本隐私政策仅适用于 LanMountainDesktop 应用。我们不对以下情况负责:
- 第三方服务的隐私政策
- 您自行分享的数据
- 不可抗力导致的数据泄露
---
**感谢您信任阑山桌面LanMountainDesktop**
我们承诺保护您的隐私,并持续改进我们的隐私保护措施。

View File

@@ -1,4 +1,4 @@
namespace LanMountainDesktop.ComponentSystem;
namespace LanMountainDesktop.ComponentSystem;
public static class BuiltInComponentIds
{
@@ -40,4 +40,6 @@ public static class BuiltInComponentIds
public const string DesktopWhiteboard = "DesktopWhiteboard";
public const string DesktopBlackboardLandscape = "DesktopBlackboardLandscape";
public const string DesktopBrowser = "DesktopBrowser";
public const string DesktopOfficeRecentDocuments = "DesktopOfficeRecentDocuments";
public const string DesktopRemovableStorage = "DesktopRemovableStorage";
}

View File

@@ -0,0 +1,37 @@
using System;
using LanMountainDesktop.Services;
using LanMountainDesktop.Views;
namespace LanMountainDesktop.ComponentSystem;
public static class ComponentColorSchemeHelper
{
public static bool ShouldUseMonetColor(string? componentColorScheme, string globalThemeColorMode)
{
if (string.Equals(componentColorScheme, ThemeAppearanceValues.ColorSchemeNative, StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (string.Equals(componentColorScheme, ThemeAppearanceValues.ColorSchemeFollowSystem, StringComparison.OrdinalIgnoreCase))
{
return true;
}
return !string.Equals(globalThemeColorMode, ThemeAppearanceValues.ColorModeDefaultNeutral, StringComparison.OrdinalIgnoreCase);
}
public static string GetCurrentGlobalThemeColorMode()
{
try
{
var service = HostAppearanceThemeProvider.GetOrCreate();
var appearance = service.GetCurrent();
return appearance?.ThemeColorMode ?? ThemeAppearanceValues.ColorModeDefaultNeutral;
}
catch
{
return ThemeAppearanceValues.ColorModeDefaultNeutral;
}
}
}

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using LanMountainDesktop.ComponentSystem.Extensions;
@@ -327,6 +327,24 @@ public sealed class ComponentRegistry
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true,
ResizeMode: DesktopComponentResizeMode.Free),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopOfficeRecentDocuments,
"Office Recent Documents",
"Folder",
"File",
MinWidthCells: 4,
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopRemovableStorage,
"Removable Storage",
"Storage",
"File",
MinWidthCells: 2,
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.Date,
"Calendar",

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
@@ -21,6 +21,9 @@
<ItemGroup>
<Folder Include="Models\" />
<AvaloniaResource Include="Assets\**" />
<AvaloniaResource Include="Localization\**" />
<EmbeddedResource Include="Assets\Documents\Privacy.md" />
<EmbeddedResource Include="Localization\*.json" />
<None Include="Localization\*.json" CopyToOutputDirectory="PreserveNewest" />
<None Include="Extensions\Components\*.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
@@ -52,15 +55,18 @@
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.0" />
<PackageReference Include="LibVLCSharp.Avalonia" Version="3.9.5" />
<PackageReference Include="MudTools.OfficeInterop" Version="2.0.8" />
<PackageReference Include="MudTools.OfficeInterop.Word" Version="2.0.8" />
<PackageReference Include="MudTools.OfficeInterop.Excel" Version="2.0.8" />
<PackageReference Include="MudTools.OfficeInterop.PowerPoint" Version="2.0.8" />
<PackageReference Include="PortAudioSharp2" Version="1.0.6" />
<PackageReference Include="MaterialColorUtilities" Version="0.3.0" />
<PackageReference Include="PostHog" Version="2.4.0" />
<PackageReference Include="Sentry" Version="4.0.0" />
<PackageReference Include="System.Runtime.WindowsRuntime" Version="4.7.0" />
<PackageReference Include="System.Drawing.Common" Version="10.0.0" />
<PackageReference Include="VideoLAN.LibVLC.Windows" Version="3.0.23" Condition="('$(RuntimeIdentifier)' == '' and $([MSBuild]::IsOSPlatform('Windows')))&#xA; or '$(RuntimeIdentifier)' == 'win-x64'&#xA; or '$(RuntimeIdentifier)' == 'win-x86'" />
<PackageReference Include="VideoLAN.LibVLC.Mac" Version="3.1.3.1" Condition="('$(RuntimeIdentifier)' == '' and $([MSBuild]::IsOSPlatform('OSX')))&#xA; or '$(RuntimeIdentifier)' == 'osx-x64'" />
<PackageReference Include="WebView.Avalonia" Version="11.0.0.1" />
<PackageReference Include="WebView.Avalonia.Desktop" Version="11.0.0.1" />
<PackageReference Include="YamlDotNet" Version="16.3.0" />

View File

@@ -86,6 +86,8 @@
"settings.status_bar.description": "Choose which components appear on the top status bar.",
"settings.status_bar.clock_header": "Clock Component",
"settings.status_bar.clock_description": "Display a clock on the top status bar.",
"settings.status_bar.clock_transparent_background_label": "Transparent background",
"settings.status_bar.clock_transparent_background_desc": "Remove the capsule background and keep only the clock text.",
"settings.status_bar.spacing_header": "Component Spacing",
"settings.status_bar.spacing_desc": "Adjust spacing between status bar components.",
"settings.status_bar.spacing_mode_compact": "Compact",
@@ -99,6 +101,11 @@
"settings.privacy.crash_upload_description": "Help us improve application stability.",
"settings.privacy.usage_upload_title": "Anonymous usage data uploads",
"settings.privacy.usage_upload_description": "Help us improve application features.",
"settings.privacy.device_id_title": "Device ID",
"settings.privacy.device_id_description": "Unique identifier for this device. Click refresh to regenerate.",
"settings.privacy.refresh_device_id": "Refresh",
"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.location_source_header": "Location Source",
@@ -255,7 +262,6 @@
"settings.color.use_system_chrome_toggle": "Use system window chrome",
"settings.color.theme_color_label": "Theme accent color",
"settings.appearance.theme_color_mode_label": "Theme color source",
"settings.appearance.system_material_label": "System material",
"settings.appearance.theme_color_mode.neutral": "Default neutral",
"settings.appearance.theme_color_mode.user": "User theme color Monet",
"settings.appearance.theme_color_mode.wallpaper": "Wallpaper Monet",
@@ -265,6 +271,8 @@
"settings.appearance.theme_color_preview.app": "Currently previewing colors extracted from the app wallpaper.",
"settings.appearance.theme_color_preview.system": "Currently previewing colors extracted from the system wallpaper.",
"settings.appearance.theme_color_preview.fallback": "No usable wallpaper was found. The app is using a fallback accent.",
"component.color_scheme.follow_system": "Follow system color scheme",
"component.color_scheme.native": "Use component custom color scheme",
"settings.appearance.system_material.none": "None",
"settings.appearance.system_material.mica": "Mica",
"settings.appearance.system_material.acrylic": "Acrylic",
@@ -553,6 +561,7 @@
"component_category.info": "Info",
"component_category.calculator": "Calculator",
"component_category.study": "Study",
"component_category.file": "File",
"component.date": "Calendar",
"component.month_calendar": "Month Calendar",
"component.lunar_calendar": "Lunar Calendar",
@@ -580,6 +589,8 @@
"component.whiteboard": "Blackboard (Portrait)",
"component.blackboard_landscape": "Blackboard (Landscape)",
"component.browser": "Browser",
"component.office_recent_documents": "Recent Documents",
"component.removable_storage": "Removable Storage",
"component.holiday_calendar": "Holiday Calendar",
"component.study_environment": "Environment",
"component.study_session_control": "Study Session Control",
@@ -781,6 +792,20 @@
"study.environment.settings.show_display_db": "Show display dB",
"study.environment.settings.show_dbfs": "Show dBFS",
"study.environment.settings.hint": "At least one display mode must stay enabled.",
"removable_storage.settings.desc": "Show a connected USB drive with quick open and eject actions.",
"removable_storage.settings.behavior_title": "Behavior",
"removable_storage.settings.behavior_desc": "The widget automatically watches for removable drives and switches to the newest inserted USB drive.",
"removable_storage.action.open": "Open",
"removable_storage.action.eject": "Eject",
"removable_storage.widget.default_name": "Removable Drive",
"removable_storage.widget.empty_title": "No device inserted",
"removable_storage.widget.empty_subtitle": "Insert a USB drive to show it here.",
"removable_storage.widget.empty_hint": "Buttons stay disabled until a removable device is inserted.",
"removable_storage.widget.ready": "Ready to open or eject.",
"removable_storage.widget.ejecting": "Ejecting drive...",
"removable_storage.widget.eject_failed": "Could not eject this drive. Close any files on it and try again.",
"removable_storage.widget.open_failed": "Failed to open this drive.",
"removable_storage.widget.refresh_failed": "Drive list refresh failed.",
"study.session_control.action.start": "Start Study Session",
"study.session_control.action.stop": "Stop Study Session",
"study.session_control.idle_hint": "Tap the right button to start",
@@ -885,5 +910,7 @@
"placement.tile": "Tile",
"single_instance.notice.title": "App already running",
"single_instance.notice.description": "The app is already running. There is no need to click multiple times to open it.",
"single_instance.notice.button": "OK"
"single_instance.notice.button": "OK",
"market.status.install_success_restart_format": "✓ Plugin '{0}' installed successfully! Please restart the application to activate it.",
"market.dialog.restart_message_format": "Plugin '{0}' has been installed successfully.\n\nTo use this plugin, you need to restart the application now.\n\nWould you like to restart?"
}

View File

@@ -31,13 +31,14 @@
"settings.nav.plugins": "插件",
"settings.nav.about": "关于",
"settings.wallpaper.title": "壁纸",
"settings.wallpaper.description": "选择图片或视频后可立即设为应用窗口壁纸。",
"settings.wallpaper.description": "选择图片后可立即设为应用窗口壁纸。",
"settings.wallpaper.current_label": "当前壁纸",
"settings.wallpaper.type_label": "壁纸类型",
"settings.wallpaper.type.image": "图片",
"settings.wallpaper.type.video": "视频",
"settings.wallpaper.type.solid_color": "纯色",
"settings.wallpaper.color_label": "壁纸颜色",
"settings.wallpaper.custom_color_tooltip": "自定义颜色",
"settings.wallpaper.custom_color_apply": "应用",
"settings.wallpaper.placement_label": "显示方式",
"settings.wallpaper.placement_desc": "调整图像在桌面上的填充方式。",
"settings.wallpaper.pick_button": "选择文件",
@@ -46,20 +47,14 @@
"settings.wallpaper.storage_unavailable": "存储提供器不可用。",
"settings.wallpaper.import_failed": "导入壁纸文件失败。",
"settings.wallpaper.image_applied": "图片壁纸已应用。",
"settings.wallpaper.video_applied": "视频壁纸已应用。",
"settings.wallpaper.unsupported_file": "所选文件类型不受支持。",
"settings.wallpaper.apply_failed_format": "应用壁纸失败:{0}",
"settings.wallpaper.mode_format": "壁纸模式:{0}。",
"settings.wallpaper.video_mode": "视频壁纸使用自动填充模式。",
"settings.wallpaper.cleared": "背景已恢复为纯色。",
"settings.wallpaper.default_status": "当前使用纯色背景。",
"settings.wallpaper.saved_not_found": "未找到已保存的壁纸文件,已使用纯色背景。",
"settings.wallpaper.restored": "已恢复保存的壁纸。",
"settings.wallpaper.video_restored": "已恢复保存的视频壁纸。",
"settings.wallpaper.restore_failed": "恢复已保存壁纸失败,已使用纯色背景。",
"settings.wallpaper.video_not_found": "未找到视频壁纸文件。",
"settings.wallpaper.video_player_unavailable": "视频播放器不可用。",
"settings.wallpaper.video_play_failed_format": "播放视频壁纸失败:{0}",
"settings.grid.title": "网格布局",
"settings.grid.description": "每个组件至少占用一个格子(最小 1x1。",
"settings.grid.short_side_label": "短边格数",
@@ -85,12 +80,13 @@
"settings.color.theme_ready_format": "主题色已就绪:{0}。",
"settings.color.theme_applied_format": "{0}主题色已应用:{1}。",
"settings.color.theme_updated_wallpaper": "壁纸已更新,莫奈色已刷新。",
"settings.color.theme_updated_video": "视频壁纸已更新,主题色已刷新。",
"settings.color.theme_cleared_wallpaper": "壁纸已清除,莫奈色已刷新。",
"settings.status_bar.title": "状态栏",
"settings.status_bar.description": "选择顶部状态栏显示的组件。",
"settings.status_bar.clock_header": "时间组件",
"settings.status_bar.clock_description": "在顶部状态栏显示时钟。",
"settings.status_bar.clock_transparent_background_label": "透明背景",
"settings.status_bar.clock_transparent_background_desc": "移除胶囊背景,仅保留时钟文字。",
"settings.status_bar.spacing_header": "组件间距",
"settings.status_bar.spacing_desc": "调整状态栏组件之间的间距。",
"settings.status_bar.spacing_mode_compact": "紧凑",
@@ -104,6 +100,11 @@
"settings.privacy.crash_upload_description": "帮助我们提高应用稳定性。",
"settings.privacy.usage_upload_title": "匿名上传使用数据",
"settings.privacy.usage_upload_description": "帮助我们改善应用功能。",
"settings.privacy.device_id_title": "设备标识符",
"settings.privacy.device_id_description": "此设备的唯一标识符。点击刷新以重新生成。",
"settings.privacy.refresh_device_id": "刷新",
"settings.privacy.policy_hint_prefix": "了解更多详情,请",
"settings.privacy.view_policy": "查看我们的隐私政策",
"settings.weather.title": "天气",
"settings.weather.description": "配置天气位置、小米天气预览和启动时的位置刷新行为。",
"settings.weather.location_source_header": "位置来源",
@@ -260,7 +261,6 @@
"settings.color.use_system_chrome_toggle": "使用系统窗口标题栏",
"settings.color.theme_color_label": "主题强调色",
"settings.appearance.theme_color_mode_label": "主题色来源",
"settings.appearance.system_material_label": "系统材质",
"settings.appearance.theme_color_mode.neutral": "默认中性",
"settings.appearance.theme_color_mode.user": "用户主题色 Monet",
"settings.appearance.theme_color_mode.wallpaper": "壁纸 Monet 取色",
@@ -270,6 +270,8 @@
"settings.appearance.theme_color_preview.app": "当前正在预览从应用壁纸提取的颜色。",
"settings.appearance.theme_color_preview.system": "当前正在预览从系统壁纸提取的颜色。",
"settings.appearance.theme_color_preview.fallback": "没有可用壁纸,当前使用回退强调色。",
"component.color_scheme.follow_system": "跟随系统配色",
"component.color_scheme.native": "使用组件自定义配色",
"settings.appearance.system_material.none": "无",
"settings.appearance.system_material.mica": "Mica",
"settings.appearance.system_material.acrylic": "Acrylic",
@@ -392,7 +394,6 @@
"settings.footer": "LanMountainDesktop 设置",
"filepicker.title": "选择壁纸",
"filepicker.image_files": "图片文件",
"filepicker.video_files": "视频文件",
"common.day": "日间",
"common.night": "夜间",
"common.back": "返回",
@@ -558,6 +559,7 @@
"component_category.info": "信息推荐",
"component_category.calculator": "计算器",
"component_category.study": "自习",
"component_category.file": "文件",
"component.date": "日历",
"component.month_calendar": "月历",
"component.lunar_calendar": "农历",
@@ -585,6 +587,7 @@
"component.whiteboard": "竖向小黑板",
"component.blackboard_landscape": "横向小黑板",
"component.browser": "浏览器",
"component.office_recent_documents": "最近文档",
"component.holiday_calendar": "节假日日历",
"component.study_environment": "环境",
"component.study_session_control": "自习时段控制",
@@ -781,6 +784,21 @@
"study.environment.value.unavailable": "--",
"study.environment.value.display_format": "{0:F1} dB",
"study.environment.value.dbfs_format": "{0:F1} dBFS",
"component.removable_storage": "可移动存储",
"removable_storage.settings.desc": "在桌面上显示已连接的 U 盘,并提供打开与弹出操作。",
"removable_storage.settings.behavior_title": "行为",
"removable_storage.settings.behavior_desc": "组件会自动监听可移动存储设备,并优先显示最新插入的 U 盘。",
"removable_storage.action.open": "打开",
"removable_storage.action.eject": "弹出",
"removable_storage.widget.default_name": "可移动磁盘",
"removable_storage.widget.empty_title": "未插入设备",
"removable_storage.widget.empty_subtitle": "插入 U 盘后会自动显示在这里。",
"removable_storage.widget.empty_hint": "在插入可移动设备之前,底部按钮会保持置灰不可点击。",
"removable_storage.widget.ready": "已准备好,可直接打开或弹出。",
"removable_storage.widget.ejecting": "正在弹出设备...",
"removable_storage.widget.eject_failed": "无法弹出该设备,请先关闭正在占用它的文件后再试。",
"removable_storage.widget.open_failed": "打开该设备失败。",
"removable_storage.widget.refresh_failed": "刷新可移动存储列表失败。",
"study.environment.settings.title": "环境组件设置",
"study.environment.settings.desc": "配置右侧实时噪音值显示内容。",
"study.environment.settings.show_display_db": "显示 display dB",
@@ -890,5 +908,7 @@
"placement.tile": "平铺",
"single_instance.notice.title": "应用已经运行",
"single_instance.notice.description": "应用已经运行,无需多次点击打开。",
"single_instance.notice.button": "确定"
"single_instance.notice.button": "确定",
"market.status.install_success_restart_format": "✓ 插件'{0}'安装成功!请重启应用以激活它。",
"market.dialog.restart_message_format": "插件'{0}'已成功安装。\n\n要使用此插件您需要立即重启应用。\n\n是否立即重启"
}

View File

@@ -62,8 +62,6 @@ public sealed class AppSettingsSnapshot
public string AppRenderMode { get; set; } = "Default";
public bool AutoCheckUpdates { get; set; } = true;
public bool IncludePrereleaseUpdates { get; set; }
public bool UploadAnonymousCrashData { get; set; }
@@ -72,6 +70,8 @@ public sealed class AppSettingsSnapshot
public string? DeviceId { get; set; }
public string? PersistentUserId { get; set; }
public string UpdateChannel { get; set; } = "stable";
public string UpdateMode { get; set; } = "download_then_confirm";
@@ -101,6 +101,8 @@ public sealed class AppSettingsSnapshot
public string ClockDisplayFormat { get; set; } = "HourMinuteSecond";
public bool StatusBarClockTransparentBackground { get; set; }
public string StatusBarSpacingMode { get; set; } = "Relaxed";
public int StatusBarCustomSpacingPercent { get; set; } = 12;

View File

@@ -6,6 +6,8 @@ public sealed class ComponentSettingsSnapshot
{
public string DailyArtworkMirrorSource { get; set; } = DailyArtworkMirrorSources.Overseas;
public string? ColorSchemeSource { get; set; }
public List<ImportedClassScheduleSnapshot> ImportedClassSchedules { get; set; } = [];
public string ActiveImportedClassScheduleId { get; set; } = string.Empty;

View File

@@ -15,7 +15,6 @@ using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.Theme;
using LibVLCSharp.Shared;
using Microsoft.Win32;
namespace LanMountainDesktop.Services;
@@ -89,11 +88,6 @@ internal interface IMaterialSurfaceService
AppearanceMaterialSurface GetSurface(ThemeColorContext context, MaterialSurfaceRole role);
}
internal interface IVideoWallpaperSeedExtractor
{
IReadOnlyList<Color> ExtractSeedCandidates(string videoPath, MonetColorService monetColorService);
}
internal readonly record struct WallpaperSeedSourceDescriptor(
string SourceKind,
string SourceKey,
@@ -114,75 +108,6 @@ internal readonly record struct WallpaperPaletteResolution(
Color EffectiveSeedColor,
string? ResolvedWallpaperPath);
internal sealed class LibVlcVideoWallpaperSeedExtractor : IVideoWallpaperSeedExtractor
{
public IReadOnlyList<Color> ExtractSeedCandidates(string videoPath, MonetColorService monetColorService)
{
if (string.IsNullOrWhiteSpace(videoPath) || !File.Exists(videoPath))
{
return [];
}
var snapshotPath = Path.Combine(
Path.GetTempPath(),
$"lanmountaindesktop-video-seed-{Guid.NewGuid():N}.png");
try
{
using var libVlc = new LibVLC("--no-audio", "--intf=dummy", "--no-video-title-show");
using var media = new Media(libVlc, new Uri(videoPath));
using var mediaPlayer = new MediaPlayer(libVlc)
{
Media = media
};
mediaPlayer.Play();
var stopwatch = Stopwatch.StartNew();
while (stopwatch.Elapsed < TimeSpan.FromSeconds(5))
{
Thread.Sleep(180);
if (!mediaPlayer.TakeSnapshot(0, snapshotPath, 320, 180))
{
continue;
}
var fileInfo = new FileInfo(snapshotPath);
if (!fileInfo.Exists || fileInfo.Length <= 0)
{
continue;
}
using var bitmap = new Bitmap(snapshotPath);
return monetColorService.ExtractSeedCandidates(bitmap);
}
}
catch (Exception ex)
{
AppLogger.Warn(
"Appearance.VideoWallpaperPalette",
$"Failed to extract wallpaper seed candidates from video '{videoPath}'.",
ex);
}
finally
{
try
{
if (File.Exists(snapshotPath))
{
File.Delete(snapshotPath);
}
}
catch
{
// Best effort cleanup only.
}
}
return [];
}
}
internal sealed class SystemWallpaperService : ISystemWallpaperService
{
public bool IsSupported => OperatingSystem.IsWindows();
@@ -248,6 +173,15 @@ internal sealed class WindowMaterialService : IWindowMaterialService
{
ArgumentNullException.ThrowIfNull(window);
var normalizedMode = ThemeAppearanceValues.NormalizeSystemMaterialMode(materialMode);
if (normalizedMode == ThemeAppearanceValues.MaterialNone)
{
window.Background = Brushes.White;
window.TransparencyLevelHint = [WindowTransparencyLevel.None];
return;
}
window.Background = Brushes.Transparent;
if (!OperatingSystem.IsWindows() || !IsTransparencyEnabled())
@@ -259,7 +193,6 @@ internal sealed class WindowMaterialService : IWindowMaterialService
return;
}
var normalizedMode = ThemeAppearanceValues.NormalizeSystemMaterialMode(materialMode);
window.TransparencyLevelHint = normalizedMode switch
{
ThemeAppearanceValues.MaterialMica =>
@@ -469,7 +402,6 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
private readonly ISystemWallpaperService _systemWallpaperService;
private readonly IWindowMaterialService _windowMaterialService;
private readonly IMaterialSurfaceService _materialSurfaceService;
private readonly IVideoWallpaperSeedExtractor _videoWallpaperSeedExtractor;
private readonly MonetColorService _monetColorService = new();
private readonly string _liveThemeColorMode;
private readonly string _liveSystemMaterialMode;
@@ -482,14 +414,12 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
ISettingsFacadeService settingsFacade,
ISystemWallpaperService systemWallpaperService,
IWindowMaterialService windowMaterialService,
IMaterialSurfaceService materialSurfaceService,
IVideoWallpaperSeedExtractor? videoWallpaperSeedExtractor = null)
IMaterialSurfaceService materialSurfaceService)
{
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
_systemWallpaperService = systemWallpaperService ?? throw new ArgumentNullException(nameof(systemWallpaperService));
_windowMaterialService = windowMaterialService ?? throw new ArgumentNullException(nameof(windowMaterialService));
_materialSurfaceService = materialSurfaceService ?? throw new ArgumentNullException(nameof(materialSurfaceService));
_videoWallpaperSeedExtractor = videoWallpaperSeedExtractor ?? new LibVlcVideoWallpaperSeedExtractor();
var initialThemeState = _settingsFacade.Theme.Get();
_liveThemeColorMode = ThemeAppearanceValues.NormalizeThemeColorMode(
initialThemeState.ThemeColorMode,
@@ -878,7 +808,6 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
IReadOnlyList<Color> seedCandidates = source.SourceKind switch
{
"app_wallpaper" or "system_wallpaper" => ExtractImageSeedCandidates(source.FilePath),
"app_video" => ExtractVideoSeedCandidates(source.FilePath),
"app_solid" when source.SolidColor is { } solidColor => new[] { solidColor },
_ => []
};
@@ -912,16 +841,6 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
}
}
private IReadOnlyList<Color> ExtractVideoSeedCandidates(string? wallpaperPath)
{
if (string.IsNullOrWhiteSpace(wallpaperPath) || !File.Exists(wallpaperPath))
{
return [];
}
return _videoWallpaperSeedExtractor.ExtractSeedCandidates(wallpaperPath, _monetColorService);
}
private WallpaperSeedSourceDescriptor ResolveWallpaperSeedSource(WallpaperSettingsState wallpaperState)
{
if (string.Equals(wallpaperState.Type, "SolidColor", StringComparison.OrdinalIgnoreCase) &&
@@ -952,16 +871,6 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
wallpaperPath,
null);
}
if (appWallpaperMediaType == WallpaperMediaType.Video)
{
return new WallpaperSeedSourceDescriptor(
"app_video",
CreateWallpaperSourceKey("app_video", wallpaperPath),
wallpaperPath,
wallpaperPath,
null);
}
}
var systemWallpaper = _systemWallpaperService.GetWallpaperPath();

View File

@@ -163,7 +163,7 @@ public sealed class ClassIslandScheduleDataService : IClassIslandScheduleDataSer
var totalElapsedWeeks = (int)Math.Floor(
(referenceDate.ToDateTime(TimeOnly.MinValue) - cycleRule.SingleWeekStartDate.Value.ToDateTime(TimeOnly.MinValue)).TotalDays / 7d);
for (var cycleLength = 2; cycleLength <= maxCycle; cycleLength++)
for (var cycleLength = 1; cycleLength <= maxCycle; cycleLength++)
{
var cycleOffset = cycleLength < cycleRule.MultiWeekRotationOffset.Count
? cycleRule.MultiWeekRotationOffset[cycleLength]
@@ -668,7 +668,7 @@ public sealed class ClassIslandScheduleDataService : IClassIslandScheduleDataSer
return true;
}
if (weekCountDivTotal <= 1 || weekCountDivTotal >= cyclePositions.Count)
if (weekCountDivTotal <= 0 || weekCountDivTotal >= cyclePositions.Count)
{
return false;
}

View File

@@ -15,6 +15,7 @@ public sealed class DeviceIdService
{
private static DeviceIdService? _instance;
private string? _deviceId;
private string? _persistentUserId; // 持久化的用户ID用于关联设备
private readonly ISettingsFacadeService _settingsFacade;
private bool _isInitialized;
@@ -43,6 +44,19 @@ public sealed class DeviceIdService
}
}
// 持久化的用户ID用于跨设备关联用户
public string PersistentUserId
{
get
{
if (_persistentUserId is null)
{
throw new InvalidOperationException("PersistentUserId not initialized");
}
return _persistentUserId;
}
}
private void EnsureDeviceId()
{
if (_isInitialized)
@@ -56,13 +70,22 @@ public sealed class DeviceIdService
{
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
// 初始化或生成持久化用户ID只生成一次永不改变
if (string.IsNullOrEmpty(snapshot.PersistentUserId))
{
snapshot.PersistentUserId = GeneratePersistentUserId();
AppLogger.Info("DeviceId", $"Generated new persistent user ID: {snapshot.PersistentUserId}");
}
_persistentUserId = snapshot.PersistentUserId;
// 初始化或生成设备ID可以刷新
if (string.IsNullOrEmpty(snapshot.DeviceId))
{
snapshot.DeviceId = GenerateDeviceId();
_settingsFacade.Settings.SaveSnapshot(
SettingsScope.App,
snapshot,
changedKeys: [nameof(AppSettingsSnapshot.DeviceId)]);
changedKeys: [nameof(AppSettingsSnapshot.DeviceId), nameof(AppSettingsSnapshot.PersistentUserId)]);
_deviceId = snapshot.DeviceId;
AppLogger.Info("DeviceId", $"Generated new device ID: {_deviceId}");
}
@@ -75,6 +98,7 @@ public sealed class DeviceIdService
catch (Exception ex)
{
_deviceId = GenerateDeviceId();
_persistentUserId = GeneratePersistentUserId();
AppLogger.Warn("DeviceId", $"Failed to persist device ID, using generated ID: {_deviceId}", ex);
}
}
@@ -87,6 +111,15 @@ public sealed class DeviceIdService
var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(deviceInfo));
return Convert.ToHexString(hash)[..32].ToLower();
}
private static string GeneratePersistentUserId()
{
// 生成一个永久性的用户ID基于机器名和用户名的哈希
var userInfo = $"{Environment.MachineName}|{Environment.UserName}|LanMountainDesktop";
using var sha = System.Security.Cryptography.SHA256.Create();
var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(userInfo));
return Convert.ToHexString(hash)[..32].ToLower();
}
}
public sealed class UserBehaviorAnalyticsService : IDisposable
@@ -140,9 +173,18 @@ public sealed class UserBehaviorAnalyticsService : IDisposable
TimeSpan.FromSeconds(10),
TimeSpan.FromSeconds(30));
// 发送PostHog标准的$pageview事件用于统计日活始终发送不受开关影响
CaptureEvent("$pageview", new Dictionary<string, object>
{
{ "$current_url", "app://main" },
{ "$title", "LanMountainDesktop" }
});
// 发送应用启动事件(始终发送,用于统计用户数量)
CaptureEvent("app_online", new Dictionary<string, object>
{
{ "event_type", "app_start" }
{ "event_type", "app_start" },
{ "analytics_enabled", _isEnabled }
});
AppLogger.Info("UserBehaviorAnalytics", $"Analytics initialized. DeviceId={_deviceIdService.DeviceId}, Enabled={_isEnabled}");
@@ -382,10 +424,22 @@ public sealed class UserBehaviorAnalyticsService : IDisposable
try
{
// 基础事件($pageview, app_online, app_shutdown等始终发送用于统计用户数量
bool isBasicEvent = eventName.StartsWith("$") ||
eventName == "app_online" ||
eventName == "app_shutdown" ||
eventName == "$identify";
// 非基础事件只有在启用时才发送
if (!isBasicEvent && !_isEnabled)
{
return;
}
var eventData = new UserBehaviorEvent
{
Event = eventName,
DistinctId = _deviceIdService.DeviceId,
DistinctId = _deviceIdService.PersistentUserId, // 使用持久化用户ID
Timestamp = DateTimeOffset.UtcNow,
Properties = properties ?? new Dictionary<string, object>(),
IncludeDetailedData = _isEnabled
@@ -542,21 +596,29 @@ public sealed class UserBehaviorAnalyticsService : IDisposable
{
var userProperties = new Dictionary<string, object>
{
{ "$device_id", distinctId },
{ "$app_version", GetAppVersion() },
{ "$os", GetOsName() },
{ "$os_version", GetOsVersion() }
{ "$os_version", GetOsVersion() },
{ "$device_type", GetDeviceModel() },
{ "$device_id", _deviceIdService.DeviceId } // 当前设备ID
};
// PostHog正确的$identify格式
// 使用PersistentUserId作为distinct_id确保设备ID刷新后仍能关联到同一用户
var requestBody = new Dictionary<string, object>
{
{ "api_key", PostHogApiKey },
{ "event", "$identify" },
{ "distinct_id", _deviceIdService.PersistentUserId }, // 使用持久化用户ID
{ "timestamp", DateTimeOffset.UtcNow.ToString("o") },
{ "properties", new Dictionary<string, object>
{
{ "distinct_id", distinctId },
{ "$set", userProperties }
{ "$set", userProperties },
{ "$set_once", new Dictionary<string, object>
{
{ "first_app_open", DateTimeOffset.UtcNow.ToString("o") }
}
}
}
}
};
@@ -796,6 +858,9 @@ public sealed class CrashReportService
});
ConfigureCrashReportingScope();
// 显式开始会话跟踪
SentrySdk.StartSession();
AppLogger.Info("CrashReport", $"Sentry crash reporting initialized. DeviceId={_deviceIdService.DeviceId}");
@@ -849,7 +914,10 @@ public sealed class CrashReportService
{
if (_isEnabled && _isInitialized)
{
AppLogger.Info("CrashReport", $"Shutdown event will be sent via Sentry. DeviceId={_deviceIdService.DeviceId}");
// 结束Sentry会话
SentrySdk.EndSession();
SentrySdk.Flush(TimeSpan.FromSeconds(3));
AppLogger.Info("CrashReport", $"Shutdown event sent via Sentry. DeviceId={_deviceIdService.DeviceId}");
return;
}

View File

@@ -72,6 +72,9 @@ public static class DesktopComponentEditorRegistryFactory
[BuiltInComponentIds.DesktopStudyEnvironment] = new(
BuiltInComponentIds.DesktopStudyEnvironment,
context => new StudyEnvironmentComponentEditor(context)),
[BuiltInComponentIds.DesktopRemovableStorage] = new(
BuiltInComponentIds.DesktopRemovableStorage,
context => new RemovableStorageComponentEditor(context)),
[BuiltInComponentIds.DesktopWeather] = CreateWeatherRegistration(BuiltInComponentIds.DesktopWeather),
[BuiltInComponentIds.DesktopWeatherClock] = CreateWeatherRegistration(BuiltInComponentIds.DesktopWeatherClock),
[BuiltInComponentIds.DesktopHourlyWeather] = CreateWeatherRegistration(BuiltInComponentIds.DesktopHourlyWeather),

View File

@@ -1,6 +1,7 @@
using System;
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Text.Json;
namespace LanMountainDesktop.Services;
@@ -16,6 +17,23 @@ public sealed class LocalizationService
private readonly Dictionary<string, Dictionary<string, string>> _cache =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// 清除指定语言代码的缓存,强制下次重新加载。
/// 在语言切换时调用此方法以确保加载最新的语言文件。
/// </summary>
public void ClearCache(string? languageCode = null)
{
if (string.IsNullOrWhiteSpace(languageCode))
{
_cache.Clear();
}
else
{
var normalizedCode = NormalizeLanguageCode(languageCode);
_cache.Remove(normalizedCode);
}
}
public string NormalizeLanguageCode(string? languageCode)
{
return string.Equals(languageCode, "en-US", StringComparison.OrdinalIgnoreCase)
@@ -42,14 +60,17 @@ public sealed class LocalizationService
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
try
{
var filePath = Path.Combine(AppContext.BaseDirectory, "Localization", $"{languageCode}.json");
if (File.Exists(filePath))
var json = TryLoadFromFileSystem(languageCode);
if (string.IsNullOrEmpty(json))
{
json = TryLoadFromEmbeddedResource(languageCode);
}
if (!string.IsNullOrEmpty(json))
{
var json = File.ReadAllText(filePath);
// Defensive: tolerate accidentally duplicated UTF-8 BOM characters at file start.
json = json.TrimStart('\uFEFF');
var data = JsonSerializer.Deserialize<Dictionary<string, string>>(json, JsonOptions);
if (data is not null)
if (data is not null && data.Count > 0)
{
result = new Dictionary<string, string>(data, StringComparer.OrdinalIgnoreCase);
}
@@ -60,7 +81,48 @@ public sealed class LocalizationService
// Keep empty table for resilience.
}
_cache[languageCode] = result;
// 只有当语言表非空时才缓存,这样如果加载失败可以下次重试
if (result.Count > 0)
{
_cache[languageCode] = result;
}
return result;
}
private string? TryLoadFromFileSystem(string languageCode)
{
try
{
var filePath = Path.Combine(AppContext.BaseDirectory, "Localization", $"{languageCode}.json");
if (File.Exists(filePath))
{
return File.ReadAllText(filePath);
}
}
catch
{
// Continue to next method
}
return null;
}
private string? TryLoadFromEmbeddedResource(string languageCode)
{
try
{
var assembly = Assembly.GetExecutingAssembly();
var resourceName = $"LanMountainDesktop.Localization.{languageCode}.json";
using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream != null)
{
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
}
catch
{
// Continue to next method
}
return null;
}
}

View File

@@ -0,0 +1,723 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using Microsoft.Win32;
using MudTools.OfficeInterop;
using MudTools.OfficeInterop.Excel;
using MudTools.OfficeInterop.Word;
namespace LanMountainDesktop.Services;
public interface IOfficeRecentDocumentsService
{
List<OfficeRecentDocument> GetRecentDocuments(int maxCount = 20);
void OpenDocument(string filePath);
}
public sealed class OfficeRecentDocument
{
public string FileName { get; set; } = string.Empty;
public string FilePath { get; set; } = string.Empty;
public string Extension { get; set; } = string.Empty;
public DateTime LastModifiedTime { get; set; }
public long FileSizeBytes { get; set; }
public string IconGlyph { get; set; } = string.Empty;
internal DateTime? RecentAccessTime { get; set; }
internal int SourcePriority { get; set; }
internal int SourceOrder { get; set; }
}
public sealed class OfficeRecentDocumentsService : IOfficeRecentDocumentsService
{
private const string LogCategory = "OfficeRecentDocs";
private static readonly string[] OfficeExtensions = { ".doc", ".docx", ".dot", ".dotx", ".rtf" };
private static readonly string[] ExcelExtensions = { ".xls", ".xlsx", ".xlsm", ".xlsb", ".csv" };
private static readonly string[] PowerPointExtensions = { ".ppt", ".pptx", ".pptm", ".pps", ".ppsx" };
private static readonly Regex OfficeFilePathRegex = new(
@"(?:[A-Z]:\\|\\\\)[^\x00-\x1F""<>|]+?\.(?:docx?|dotx?|rtf|xlsx?|xlsm|xlsb|csv|pptx?|pptm|ppsx?)",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex OfficeMruTimestampRegex = new(
@"\[T(?<filetime>[0-9A-F]+)\]",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
public List<OfficeRecentDocument> GetRecentDocuments(int maxCount = 20)
{
var documents = new List<OfficeRecentDocument>();
if (!OperatingSystem.IsWindows())
{
return documents;
}
TryGetFromRegistry(documents);
TryGetFromRecentFolders(documents);
TryGetFromJumpLists(documents);
if (documents.Count < maxCount)
{
TryGetFromMudToolsInterop(documents);
}
return documents
.GroupBy(d => d.FilePath, StringComparer.OrdinalIgnoreCase)
.Select(MergeDocuments)
.OrderByDescending(d => d.RecentAccessTime ?? DateTime.MinValue)
.ThenBy(d => d.SourcePriority)
.ThenBy(d => d.SourceOrder)
.ThenByDescending(d => d.LastModifiedTime)
.Take(maxCount)
.ToList();
}
public void OpenDocument(string filePath)
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = filePath,
UseShellExecute = true
};
Process.Start(startInfo);
}
catch (Exception ex)
{
AppLogger.Warn(LogCategory, $"Failed to open Office document '{filePath}'.", ex);
}
}
private static OfficeRecentDocument MergeDocuments(IGrouping<string, OfficeRecentDocument> group)
{
var preferred = group
.OrderByDescending(d => d.RecentAccessTime ?? DateTime.MinValue)
.ThenBy(d => d.SourcePriority)
.ThenBy(d => d.SourceOrder)
.ThenByDescending(d => d.LastModifiedTime)
.First();
return new OfficeRecentDocument
{
FileName = preferred.FileName,
FilePath = preferred.FilePath,
Extension = preferred.Extension,
LastModifiedTime = group.Max(d => d.LastModifiedTime),
FileSizeBytes = preferred.FileSizeBytes,
IconGlyph = preferred.IconGlyph,
RecentAccessTime = group
.Where(d => d.RecentAccessTime.HasValue)
.Select(d => d.RecentAccessTime)
.Max(),
SourcePriority = preferred.SourcePriority,
SourceOrder = preferred.SourceOrder
};
}
[SupportedOSPlatform("windows")]
private void TryGetFromMudToolsInterop(List<OfficeRecentDocument> documents)
{
try
{
RunOnStaThread(() =>
{
var sourceOrder = 0;
TryGetFromWordInterop(documents, ref sourceOrder);
TryGetFromExcelInterop(documents, ref sourceOrder);
});
}
catch (Exception ex)
{
AppLogger.Warn(LogCategory, "MudTools.OfficeInterop recent-document read failed.", ex);
}
}
[SupportedOSPlatform("windows")]
private void TryGetFromWordInterop(List<OfficeRecentDocument> documents, ref int sourceOrder)
{
if (!TryGetOfficeApplication("Word.Application", out var comObject, out var createdNew))
{
return;
}
object? application = null;
try
{
application = WordFactory.Connection(comObject!);
if (createdNew)
{
TrySetProperty(comObject, "Visible", false);
TrySetProperty(application, "DisplayAlerts", WdAlertLevel.wdAlertsNone);
}
AddInteropRecentFiles(documents, GetPropertyValue(application, "RecentFiles"), 0, ref sourceOrder);
}
catch (Exception ex)
{
AppLogger.Warn(LogCategory, "Failed to read Word recent files via MudTools.OfficeInterop.", ex);
}
finally
{
CleanupOfficeApplication(application, comObject, createdNew);
}
}
[SupportedOSPlatform("windows")]
private void TryGetFromExcelInterop(List<OfficeRecentDocument> documents, ref int sourceOrder)
{
if (!TryGetOfficeApplication("Excel.Application", out var comObject, out var createdNew))
{
return;
}
object? application = null;
try
{
application = ExcelFactory.Connection(comObject!);
if (createdNew)
{
TrySetProperty(comObject, "Visible", false);
TrySetProperty(application, "DisplayAlerts", false);
}
AddInteropRecentFiles(documents, GetPropertyValue(application, "RecentFiles"), 0, ref sourceOrder);
}
catch (Exception ex)
{
AppLogger.Warn(LogCategory, "Failed to read Excel recent files via MudTools.OfficeInterop.", ex);
}
finally
{
CleanupOfficeApplication(application, comObject, createdNew);
}
}
private void AddInteropRecentFiles(
List<OfficeRecentDocument> documents,
object? recentFiles,
int sourcePriority,
ref int sourceOrder)
{
if (recentFiles == null)
{
return;
}
var count = GetIntProperty(recentFiles, "Count");
var itemProperty = recentFiles.GetType().GetProperty("Item");
if (count <= 0 || itemProperty == null)
{
return;
}
for (var index = 1; index <= count; index++)
{
try
{
var recentFile = itemProperty.GetValue(recentFiles, new object[] { index });
var filePath = GetStringProperty(recentFile, "Path");
AddDocumentIfExists(documents, filePath, sourcePriority, sourceOrder++, null);
}
catch
{
// Ignore a single malformed MRU entry and keep processing the rest.
}
}
}
[SupportedOSPlatform("windows")]
private static bool TryGetOfficeApplication(string progId, out object? comObject, out bool createdNew)
{
comObject = null;
createdNew = false;
var applicationType = Type.GetTypeFromProgID(progId, throwOnError: false);
if (applicationType == null)
{
return false;
}
try
{
comObject = Activator.CreateInstance(applicationType);
createdNew = comObject != null;
return comObject != null;
}
catch
{
return false;
}
}
[SupportedOSPlatform("windows")]
private static void CleanupOfficeApplication(object? application, object? comObject, bool createdNew)
{
try
{
if (createdNew && application != null)
{
InvokeParameterlessMethod(application, "Quit");
}
}
catch
{
}
try
{
if (application is IDisposable disposable)
{
disposable.Dispose();
}
}
catch
{
}
ReleaseComObject(application);
if (!ReferenceEquals(application, comObject))
{
ReleaseComObject(comObject);
}
}
[SupportedOSPlatform("windows")]
private static void ReleaseComObject(object? instance)
{
if (instance == null || !Marshal.IsComObject(instance))
{
return;
}
try
{
Marshal.FinalReleaseComObject(instance);
}
catch
{
}
}
[SupportedOSPlatform("windows")]
private static void RunOnStaThread(Action action)
{
Exception? exception = null;
using var finished = new ManualResetEventSlim();
var thread = new Thread(() =>
{
try
{
action();
}
catch (Exception ex)
{
exception = ex;
}
finally
{
finished.Set();
}
});
thread.IsBackground = true;
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
finished.Wait();
if (exception != null)
{
throw new InvalidOperationException("Failed to run Office interop on STA thread.", exception);
}
}
[SupportedOSPlatform("windows")]
private void TryGetFromRegistry(List<OfficeRecentDocument> documents)
{
try
{
using var officeRoot = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Office");
if (officeRoot == null)
{
return;
}
var versions = officeRoot
.GetSubKeyNames()
.Where(IsOfficeVersionKey)
.OrderByDescending(ParseVersionKey)
.ToList();
var sourceOrder = 0;
foreach (var version in versions)
{
TryGetFromRegistryApp(documents, version, "Word", ref sourceOrder);
TryGetFromRegistryApp(documents, version, "Excel", ref sourceOrder);
TryGetFromRegistryApp(documents, version, "PowerPoint", ref sourceOrder);
}
}
catch (Exception ex)
{
AppLogger.Warn(LogCategory, "Failed to read Office MRU entries from the registry.", ex);
}
}
[SupportedOSPlatform("windows")]
private void TryGetFromRegistryApp(List<OfficeRecentDocument> documents, string version, string appName, ref int sourceOrder)
{
TryGetFromRegistryMruKey(documents, $@"Software\Microsoft\Office\{version}\{appName}\File MRU", ref sourceOrder);
using var userMruRoot = Registry.CurrentUser.OpenSubKey($@"Software\Microsoft\Office\{version}\{appName}\User MRU");
if (userMruRoot == null)
{
return;
}
foreach (var identityKey in userMruRoot.GetSubKeyNames())
{
TryGetFromRegistryMruKey(
documents,
$@"Software\Microsoft\Office\{version}\{appName}\User MRU\{identityKey}\File MRU",
ref sourceOrder);
}
}
[SupportedOSPlatform("windows")]
private void TryGetFromRegistryMruKey(List<OfficeRecentDocument> documents, string registryPath, ref int sourceOrder)
{
using var key = Registry.CurrentUser.OpenSubKey(registryPath);
if (key == null)
{
return;
}
var entries = key
.GetValueNames()
.Where(name => name.StartsWith("Item ", StringComparison.OrdinalIgnoreCase))
.Select(name => new
{
Name = name,
Order = ParseMruItemOrder(name),
Value = key.GetValue(name) as string
})
.Where(entry => !string.IsNullOrWhiteSpace(entry.Value))
.OrderBy(entry => entry.Order);
foreach (var entry in entries)
{
var (filePath, recentAccessTime) = ParseOfficeMruValue(entry.Value!);
AddDocumentIfExists(documents, filePath, 1, sourceOrder++, recentAccessTime);
}
}
private void TryGetFromRecentFolders(List<OfficeRecentDocument> documents)
{
try
{
var linkFiles = GetRecentFolders()
.Where(Directory.Exists)
.SelectMany(path => Directory.EnumerateFiles(path, "*.lnk"))
.Select(path => new FileInfo(path))
.OrderByDescending(info => info.LastWriteTimeUtc)
.ToList();
var sourceOrder = 0;
foreach (var linkFile in linkFiles)
{
var targetPath = GetShortcutTarget(linkFile.FullName);
AddDocumentIfExists(documents, targetPath, 2, sourceOrder++, linkFile.LastWriteTime);
}
}
catch (Exception ex)
{
AppLogger.Warn(LogCategory, "Failed to read Windows Recent shortcut folders.", ex);
}
}
private void TryGetFromJumpLists(List<OfficeRecentDocument> documents)
{
try
{
var jumpListFiles = GetJumpListFolders()
.Where(Directory.Exists)
.SelectMany(path => Directory.EnumerateFiles(path, "*.automaticDestinations-ms")
.Concat(Directory.EnumerateFiles(path, "*.customDestinations-ms")))
.Select(path => new FileInfo(path))
.OrderByDescending(info => info.LastWriteTimeUtc)
.ToList();
var sourceOrder = 0;
foreach (var jumpListFile in jumpListFiles)
{
TryParseJumpListFile(jumpListFile, documents, ref sourceOrder);
}
}
catch (Exception ex)
{
AppLogger.Warn(LogCategory, "Failed to read Windows Jump Lists for Office documents.", ex);
}
}
private void TryParseJumpListFile(FileInfo jumpListFile, List<OfficeRecentDocument> documents, ref int sourceOrder)
{
try
{
var bytes = File.ReadAllBytes(jumpListFile.FullName);
foreach (var filePath in ExtractPossiblePaths(bytes))
{
AddDocumentIfExists(documents, filePath, 3, sourceOrder++, jumpListFile.LastWriteTime);
}
}
catch
{
// Ignore a single Jump List file and keep scanning the rest.
}
}
private static IEnumerable<string> ExtractPossiblePaths(byte[] bytes)
{
var paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var text in new[]
{
Encoding.Unicode.GetString(bytes),
Encoding.Latin1.GetString(bytes)
})
{
foreach (Match match in OfficeFilePathRegex.Matches(text))
{
var normalizedPath = NormalizeFilePath(match.Value);
if (!string.IsNullOrWhiteSpace(normalizedPath))
{
paths.Add(normalizedPath);
}
}
}
return paths;
}
private void AddDocumentIfExists(
List<OfficeRecentDocument> documents,
string? filePath,
int sourcePriority,
int sourceOrder,
DateTime? recentAccessTime)
{
try
{
var normalizedPath = NormalizeFilePath(filePath);
if (string.IsNullOrWhiteSpace(normalizedPath))
{
return;
}
var extension = Path.GetExtension(normalizedPath).ToLowerInvariant();
if (!IsOfficeFile(extension) || !File.Exists(normalizedPath))
{
return;
}
var fileInfo = new FileInfo(normalizedPath);
documents.Add(new OfficeRecentDocument
{
FileName = Path.GetFileNameWithoutExtension(normalizedPath),
FilePath = normalizedPath,
Extension = extension,
LastModifiedTime = fileInfo.LastWriteTime,
FileSizeBytes = fileInfo.Length,
IconGlyph = GetIconGlyph(extension),
RecentAccessTime = recentAccessTime,
SourcePriority = sourcePriority,
SourceOrder = sourceOrder
});
}
catch
{
// Ignore a single file and keep processing the rest of the MRU list.
}
}
private static IEnumerable<string> GetRecentFolders()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
return new[]
{
Path.Combine(appData, "Microsoft", "Windows", "Recent"),
Path.Combine(appData, "Microsoft", "Word", "Recent"),
Path.Combine(appData, "Microsoft", "Excel", "Recent"),
Path.Combine(appData, "Microsoft", "PowerPoint", "Recent"),
Path.Combine(localAppData, "Microsoft", "Office", "Word", "Recent"),
Path.Combine(localAppData, "Microsoft", "Office", "Excel", "Recent"),
Path.Combine(localAppData, "Microsoft", "Office", "PowerPoint", "Recent")
}.Distinct(StringComparer.OrdinalIgnoreCase);
}
private static IEnumerable<string> GetJumpListFolders()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
return new[]
{
Path.Combine(appData, "Microsoft", "Windows", "Recent", "AutomaticDestinations"),
Path.Combine(appData, "Microsoft", "Windows", "Recent", "CustomDestinations"),
Path.Combine(localAppData, "Microsoft", "Windows", "Recent", "AutomaticDestinations"),
Path.Combine(localAppData, "Microsoft", "Windows", "Recent", "CustomDestinations")
}.Distinct(StringComparer.OrdinalIgnoreCase);
}
private static bool IsOfficeVersionKey(string keyName)
{
return Version.TryParse(keyName, out _);
}
private static Version ParseVersionKey(string keyName)
{
return Version.TryParse(keyName, out var version) ? version : new Version(0, 0);
}
private static int ParseMruItemOrder(string valueName)
{
var numberText = valueName["Item ".Length..];
return int.TryParse(numberText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var number)
? number
: int.MaxValue;
}
private static (string? FilePath, DateTime? RecentAccessTime) ParseOfficeMruValue(string rawValue)
{
var filePath = ExtractOfficeFilePath(rawValue);
DateTime? recentAccessTime = null;
var timestampMatch = OfficeMruTimestampRegex.Match(rawValue);
if (timestampMatch.Success &&
long.TryParse(timestampMatch.Groups["filetime"].Value, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var fileTime) &&
fileTime > 0)
{
try
{
recentAccessTime = DateTime.FromFileTimeUtc(fileTime).ToLocalTime();
}
catch
{
recentAccessTime = null;
}
}
return (filePath, recentAccessTime);
}
private static string? ExtractOfficeFilePath(string rawValue)
{
if (string.IsNullOrWhiteSpace(rawValue))
{
return null;
}
var markerIndex = rawValue.LastIndexOf('*');
var candidate = markerIndex >= 0
? rawValue[(markerIndex + 1)..]
: rawValue;
var normalizedCandidate = NormalizeFilePath(candidate);
if (!string.IsNullOrWhiteSpace(normalizedCandidate) && IsOfficeFile(Path.GetExtension(normalizedCandidate)))
{
return normalizedCandidate;
}
var match = OfficeFilePathRegex.Match(rawValue);
return match.Success ? NormalizeFilePath(match.Value) : null;
}
private static string? NormalizeFilePath(string? rawPath)
{
if (string.IsNullOrWhiteSpace(rawPath))
{
return null;
}
var candidate = rawPath.Trim('\0', ' ', '"');
candidate = Environment.ExpandEnvironmentVariables(candidate);
if (Uri.TryCreate(candidate, UriKind.Absolute, out var uri) && uri.IsFile)
{
candidate = uri.LocalPath;
}
candidate = candidate.Replace('/', '\\');
return string.IsNullOrWhiteSpace(candidate) ? null : candidate;
}
private static bool IsOfficeFile(string extension)
{
return OfficeExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase) ||
ExcelExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase) ||
PowerPointExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase);
}
private static string GetIconGlyph(string extension)
{
return extension switch
{
".doc" or ".docx" or ".dot" or ".dotx" or ".rtf" => "\uE8A5",
".xls" or ".xlsx" or ".xlsm" or ".xlsb" or ".csv" => "\uE9F9",
".ppt" or ".pptx" or ".pptm" or ".pps" or ".ppsx" => "\uE8A1",
_ => "\uE8A5"
};
}
private static string? GetShortcutTarget(string lnkPath)
{
return ShortcutHelper.GetShortcutTarget(lnkPath);
}
private static object? GetPropertyValue(object? instance, string propertyName)
{
return instance?.GetType().GetProperty(propertyName)?.GetValue(instance);
}
private static string? GetStringProperty(object? instance, string propertyName)
{
return GetPropertyValue(instance, propertyName) as string;
}
private static int GetIntProperty(object instance, string propertyName)
{
var value = GetPropertyValue(instance, propertyName);
return value switch
{
int intValue => intValue,
short shortValue => shortValue,
long longValue => (int)longValue,
_ => 0
};
}
private static void TrySetProperty(object? instance, string propertyName, object value)
{
var property = instance?.GetType().GetProperty(propertyName);
if (property?.CanWrite == true)
{
property.SetValue(instance, value);
}
}
private static void InvokeParameterlessMethod(object instance, string methodName)
{
instance.GetType().GetMethod(methodName, Type.EmptyTypes)?.Invoke(instance, null);
}
}

View File

@@ -0,0 +1,310 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.InteropServices;
namespace LanMountainDesktop.Services;
public sealed record RemovableStorageDrive(
string RootPath,
string DriveLetter,
string? VolumeLabel);
public interface IRemovableStorageService
{
IReadOnlyList<RemovableStorageDrive> GetConnectedDrives();
bool OpenDrive(string rootPath);
bool EjectDrive(string rootPath);
}
public sealed class RemovableStorageService : IRemovableStorageService
{
public IReadOnlyList<RemovableStorageDrive> GetConnectedDrives()
{
var drives = new List<RemovableStorageDrive>();
foreach (var drive in DriveInfo.GetDrives())
{
try
{
if (drive.DriveType != DriveType.Removable || !drive.IsReady)
{
continue;
}
var rootPath = NormalizeRootPath(drive.Name);
if (string.IsNullOrWhiteSpace(rootPath))
{
continue;
}
var driveLetter = rootPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var volumeLabel = string.IsNullOrWhiteSpace(drive.VolumeLabel)
? null
: drive.VolumeLabel.Trim();
drives.Add(new RemovableStorageDrive(rootPath, driveLetter, volumeLabel));
}
catch (Exception ex)
{
AppLogger.Warn("RemovableStorage", $"Failed to inspect drive '{drive.Name}'.", ex);
}
}
return drives
.OrderBy(drive => drive.DriveLetter, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
public bool OpenDrive(string rootPath)
{
var normalizedRootPath = NormalizeRootPath(rootPath);
if (string.IsNullOrWhiteSpace(normalizedRootPath))
{
return false;
}
try
{
Process.Start(new ProcessStartInfo
{
FileName = normalizedRootPath,
UseShellExecute = true
});
return true;
}
catch (Exception ex)
{
AppLogger.Warn("RemovableStorage", $"Failed to open drive '{normalizedRootPath}'.", ex);
return false;
}
}
public bool EjectDrive(string rootPath)
{
if (!OperatingSystem.IsWindows())
{
return false;
}
var normalizedRootPath = NormalizeRootPath(rootPath);
if (string.IsNullOrWhiteSpace(normalizedRootPath))
{
return false;
}
object? shellApplication = null;
object? computerFolder = null;
object? driveItem = null;
try
{
var shellType = Type.GetTypeFromProgID("Shell.Application");
if (shellType is null)
{
return false;
}
shellApplication = Activator.CreateInstance(shellType);
if (shellApplication is null)
{
return false;
}
computerFolder = shellType.InvokeMember(
"NameSpace",
BindingFlags.InvokeMethod,
binder: null,
target: shellApplication,
args: [17]);
if (computerFolder is null)
{
return false;
}
var driveToken = normalizedRootPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
driveItem = computerFolder.GetType().InvokeMember(
"ParseName",
BindingFlags.InvokeMethod,
binder: null,
target: computerFolder,
args: [driveToken]);
if (driveItem is null)
{
return false;
}
if (TryInvokeVerb(driveItem, "Eject"))
{
return true;
}
return TryInvokeLocalizedEjectVerb(driveItem);
}
catch (Exception ex)
{
AppLogger.Warn("RemovableStorage", $"Failed to eject drive '{normalizedRootPath}'.", ex);
return false;
}
finally
{
ReleaseComObject(driveItem);
ReleaseComObject(computerFolder);
ReleaseComObject(shellApplication);
}
}
private static bool TryInvokeLocalizedEjectVerb(object driveItem)
{
object? verbs = null;
try
{
verbs = driveItem.GetType().InvokeMember(
"Verbs",
BindingFlags.InvokeMethod,
binder: null,
target: driveItem,
args: null);
if (verbs is null)
{
return false;
}
var verbsType = verbs.GetType();
var countObject = verbsType.InvokeMember(
"Count",
BindingFlags.GetProperty,
binder: null,
target: verbs,
args: null);
var count = countObject is null
? 0
: Convert.ToInt32(countObject, CultureInfo.InvariantCulture);
for (var index = 0; index < count; index++)
{
object? verb = null;
try
{
verb = verbsType.InvokeMember(
"Item",
BindingFlags.InvokeMethod,
binder: null,
target: verbs,
args: [index]);
if (verb is null)
{
continue;
}
var verbNameObject = verb.GetType().InvokeMember(
"Name",
BindingFlags.GetProperty,
binder: null,
target: verb,
args: null);
var verbName = Convert.ToString(verbNameObject, CultureInfo.InvariantCulture);
if (!IsEjectVerbName(verbName))
{
continue;
}
verb.GetType().InvokeMember(
"DoIt",
BindingFlags.InvokeMethod,
binder: null,
target: verb,
args: null);
return true;
}
finally
{
ReleaseComObject(verb);
}
}
return false;
}
finally
{
ReleaseComObject(verbs);
}
}
private static bool TryInvokeVerb(object driveItem, string verbName)
{
try
{
driveItem.GetType().InvokeMember(
"InvokeVerb",
BindingFlags.InvokeMethod,
binder: null,
target: driveItem,
args: [verbName]);
return true;
}
catch
{
return false;
}
}
private static bool IsEjectVerbName(string? verbName)
{
if (string.IsNullOrWhiteSpace(verbName))
{
return false;
}
var normalized = string.Concat(
verbName
.Where(character => !char.IsWhiteSpace(character) && character != '&'))
.Trim();
return normalized.Contains("Eject", StringComparison.OrdinalIgnoreCase) ||
normalized.Contains("弹出", StringComparison.Ordinal) ||
normalized.Contains("安全删除", StringComparison.Ordinal) ||
normalized.Contains("卸载", StringComparison.Ordinal);
}
private static string NormalizeRootPath(string? rootPath)
{
if (string.IsNullOrWhiteSpace(rootPath))
{
return string.Empty;
}
var trimmed = rootPath.Trim();
if (trimmed.Length == 1 && char.IsLetter(trimmed[0]))
{
return string.Create(CultureInfo.InvariantCulture, $"{trimmed}:{Path.DirectorySeparatorChar}");
}
if (trimmed.Length == 2 && char.IsLetter(trimmed[0]) && trimmed[1] == ':')
{
return trimmed + Path.DirectorySeparatorChar;
}
var normalized = trimmed.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
var resolvedRoot = Path.GetPathRoot(normalized);
return string.IsNullOrWhiteSpace(resolvedRoot)
? normalized
: resolvedRoot;
}
private static void ReleaseComObject(object? value)
{
if (value is not null && Marshal.IsComObject(value))
{
Marshal.FinalReleaseComObject(value);
}
}
}

View File

@@ -11,12 +11,11 @@ namespace LanMountainDesktop.Services.Settings;
public enum WallpaperMediaType
{
None,
Image,
Video
Image
}
public sealed record GridSettingsState(int ShortSideCells, string SpacingPreset, int EdgeInsetPercent);
public sealed record WallpaperSettingsState(string? WallpaperPath, string Type, string? Color, string Placement);
public sealed record WallpaperSettingsState(string? WallpaperPath, string Type, string? Color, string Placement, string? CustomColor = null);
public sealed record ThemeAppearanceSettingsState(
bool IsNightMode,
string? ThemeColor,
@@ -30,6 +29,7 @@ public sealed record StatusBarSettingsState(
bool EnableDynamicTaskbarActions,
string TaskbarLayoutMode,
string ClockDisplayFormat,
bool ClockTransparentBackground,
string SpacingMode,
int CustomSpacingPercent);
public sealed record WeatherSettingsState(
@@ -48,7 +48,6 @@ public sealed record PrivacySettingsState(
bool UploadAnonymousCrashData,
bool UploadAnonymousUsageData);
public sealed record UpdateSettingsState(
bool AutoCheckUpdates,
bool IncludePrereleaseUpdates,
string UpdateChannel,
string UpdateMode,

View File

@@ -147,11 +147,6 @@ internal sealed class WallpaperMediaService : IWallpaperMediaService
".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp"
};
private static readonly HashSet<string> VideoExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".mp4", ".mkv", ".webm", ".avi", ".mov", ".m4v"
};
private readonly string _wallpapersDirectory;
public WallpaperMediaService()
@@ -180,11 +175,6 @@ internal sealed class WallpaperMediaService : IWallpaperMediaService
return WallpaperMediaType.Image;
}
if (VideoExtensions.Contains(extension))
{
return WallpaperMediaType.Video;
}
return WallpaperMediaType.None;
}
@@ -371,6 +361,7 @@ internal sealed class StatusBarSettingsService : IStatusBarSettingsService
snapshot.EnableDynamicTaskbarActions,
snapshot.TaskbarLayoutMode,
snapshot.ClockDisplayFormat,
snapshot.StatusBarClockTransparentBackground,
snapshot.StatusBarSpacingMode,
snapshot.StatusBarCustomSpacingPercent);
}
@@ -383,6 +374,7 @@ internal sealed class StatusBarSettingsService : IStatusBarSettingsService
snapshot.EnableDynamicTaskbarActions = state.EnableDynamicTaskbarActions;
snapshot.TaskbarLayoutMode = state.TaskbarLayoutMode;
snapshot.ClockDisplayFormat = state.ClockDisplayFormat;
snapshot.StatusBarClockTransparentBackground = state.ClockTransparentBackground;
snapshot.StatusBarSpacingMode = state.SpacingMode;
snapshot.StatusBarCustomSpacingPercent = state.CustomSpacingPercent;
_settingsService.SaveSnapshot(
@@ -395,6 +387,7 @@ internal sealed class StatusBarSettingsService : IStatusBarSettingsService
nameof(AppSettingsSnapshot.EnableDynamicTaskbarActions),
nameof(AppSettingsSnapshot.TaskbarLayoutMode),
nameof(AppSettingsSnapshot.ClockDisplayFormat),
nameof(AppSettingsSnapshot.StatusBarClockTransparentBackground),
nameof(AppSettingsSnapshot.StatusBarSpacingMode),
nameof(AppSettingsSnapshot.StatusBarCustomSpacingPercent)
]);
@@ -638,7 +631,6 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
snapshot.UpdateChannel,
snapshot.IncludePrereleaseUpdates);
return new UpdateSettingsState(
snapshot.AutoCheckUpdates,
string.Equals(normalizedChannel, UpdateSettingsValues.ChannelPreview, StringComparison.OrdinalIgnoreCase),
normalizedChannel,
UpdateSettingsValues.NormalizeMode(snapshot.UpdateMode),
@@ -656,7 +648,6 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
var normalizedChannel = UpdateSettingsValues.NormalizeChannel(
state.UpdateChannel,
state.IncludePrereleaseUpdates);
snapshot.AutoCheckUpdates = state.AutoCheckUpdates;
snapshot.IncludePrereleaseUpdates = string.Equals(
normalizedChannel,
UpdateSettingsValues.ChannelPreview,
@@ -682,7 +673,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
snapshot,
changedKeys:
[
nameof(AppSettingsSnapshot.AutoCheckUpdates),
nameof(AppSettingsSnapshot.IncludePrereleaseUpdates),
nameof(AppSettingsSnapshot.IncludePrereleaseUpdates),
nameof(AppSettingsSnapshot.UpdateChannel),
nameof(AppSettingsSnapshot.UpdateMode),

View File

@@ -294,6 +294,8 @@ internal sealed class SettingsWindowService : ISettingsWindowService
if (languageChanged)
{
var regionState = _settingsFacade.Region.Get();
// 清除本地化缓存,强制重新加载语言文件
_localizationService.ClearCache();
_viewModel.RefreshLanguage(regionState.LanguageCode);
_pageRegistry.Rebuild();
_window.ReloadPages(_viewModel.CurrentPageId);

View File

@@ -0,0 +1,32 @@
using System.Runtime.InteropServices;
namespace LanMountainDesktop.Services;
internal static class ShortcutHelper
{
[ComImport]
[Guid("72C24DD5-D70A-438B-8A42-98424B88AFB8")]
internal class WshShell { }
[ComImport]
[Guid("F935DC21-1CF0-11D0-ADB9-00C04FD58A0B")]
[InterfaceType(ComInterfaceType.InterfaceIsIDispatch)]
internal interface IWshShortcut
{
string TargetPath { get; set; }
}
public static string? GetShortcutTarget(string lnkPath)
{
try
{
dynamic shell = new WshShell();
dynamic shortcut = shell.CreateShortcut(lnkPath);
return shortcut.TargetPath;
}
catch
{
return null;
}
}
}

View File

@@ -10,6 +10,9 @@ public static class ThemeAppearanceValues
public const string ColorModeSeedMonet = "seed_monet";
public const string ColorModeWallpaperMonet = "wallpaper_monet";
public const string ColorSchemeFollowSystem = "follow_system";
public const string ColorSchemeNative = "native";
public const string MaterialNone = "none";
public const string MaterialMica = "mica";
public const string MaterialAcrylic = "acrylic";

View File

@@ -131,13 +131,10 @@ public sealed class UpdateWorkflowService
CancellationToken cancellationToken = default)
{
var state = _settingsFacade.Update.Get();
if (!state.AutoCheckUpdates)
{
return;
}
try
{
// Always check for updates on startup (removed AutoCheckUpdates check)
var result = await CheckForUpdatesAsync(currentVersion, cancellationToken);
if (!result.Success || !result.IsUpdateAvailable || result.PreferredAsset is null)
{
@@ -145,12 +142,14 @@ public sealed class UpdateWorkflowService
}
var normalizedMode = UpdateSettingsValues.NormalizeMode(state.UpdateMode);
if (string.Equals(normalizedMode, UpdateSettingsValues.ModeManual, StringComparison.OrdinalIgnoreCase))
// For "Silent Download" and "Silent Install" modes, automatically download the update
if (string.Equals(normalizedMode, UpdateSettingsValues.ModeDownloadThenConfirm, StringComparison.OrdinalIgnoreCase) ||
string.Equals(normalizedMode, UpdateSettingsValues.ModeSilentOnExit, StringComparison.OrdinalIgnoreCase))
{
return;
await DownloadReleaseAsync(result, cancellationToken: cancellationToken);
}
await DownloadReleaseAsync(result, cancellationToken: cancellationToken);
// For "Manual" mode, just check but don't download
}
catch (OperationCanceledException)
{

View File

@@ -1,5 +1,6 @@
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:assists="clr-namespace:Material.Styles.Assists;assembly=Material.Styles">
<Style Selector="Window.component-editor-window">
<Setter Property="Background" Value="{DynamicResource EditorWindowBackgroundBrush}" />
</Style>
@@ -74,7 +75,21 @@
</Style>
<Style Selector="Window.component-editor-window RadioButton">
<Setter Property="Theme" Value="{StaticResource MaterialRadioButton}" />
<Setter Property="Foreground" Value="{DynamicResource ComponentEditorPrimaryTextBrush}" />
<Setter Property="Margin" Value="0,2" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="assists:SelectionControlAssist.Size" Value="20" />
<Setter Property="assists:SelectionControlAssist.Foreground" Value="{DynamicResource ComponentEditorSecondaryTextBrush}" />
<Setter Property="assists:SelectionControlAssist.InnerForeground" Value="{DynamicResource EditorPrimaryBrush}" />
</Style>
<Style Selector="Window.component-editor-window RadioButton:pointerover">
<Setter Property="assists:SelectionControlAssist.Foreground" Value="{DynamicResource EditorSelectOutlineStrongBrush}" />
</Style>
<Style Selector="Window.component-editor-window RadioButton:checked">
<Setter Property="assists:SelectionControlAssist.Foreground" Value="{DynamicResource EditorPrimaryBrush}" />
</Style>
<Style Selector="Window.component-editor-window ToggleSwitch">

View File

@@ -564,11 +564,19 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
{
RefreshInstalledSnapshot();
RefreshItemStates();
// 设置更明显的状态消息
var pluginName = result.PluginName ?? item.Name;
StatusMessage = string.Format(
CultureInfo.CurrentCulture,
L("market.status.install_success_format", "Plugin '{0}' has been staged. Restart the app to apply it."),
result.PluginName ?? item.Name);
RestartRequested?.Invoke(RestartRequiredMessage);
L("market.status.install_success_restart_format", "Plugin '{0}' installed successfully! Please restart the application to activate it."),
pluginName);
// 触发重启提醒
RestartRequested?.Invoke(string.Format(
CultureInfo.CurrentCulture,
L("market.dialog.restart_message_format", "Plugin '{0}' has been installed successfully.\n\nTo use this plugin, you need to restart the application now.\n\nWould you like to restart?"),
pluginName));
return;
}

View File

@@ -0,0 +1,92 @@
using System;
using System.IO;
using System.Reflection;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.ViewModels;
public sealed partial class PrivacyPolicyViewModel : ViewModelBase
{
private readonly LocalizationService _localizationService = new();
private readonly string _languageCode;
[ObservableProperty]
private string _title = string.Empty;
[ObservableProperty]
private string _description = string.Empty;
[ObservableProperty]
private string _loadingText = string.Empty;
[ObservableProperty]
private string _errorText = string.Empty;
[ObservableProperty]
private string _markdownContent = string.Empty;
[ObservableProperty]
private bool _isLoading = true;
[ObservableProperty]
private bool _hasError;
[ObservableProperty]
private bool _hasContent;
public PrivacyPolicyViewModel()
{
_languageCode = "zh-CN";
RefreshLocalizedText();
LoadPrivacyPolicy();
}
private void RefreshLocalizedText()
{
Title = L("settings.privacy.policy_title", "Privacy Policy");
Description = L("settings.privacy.policy_description", "Learn how we collect, use, and protect your data.");
LoadingText = L("settings.privacy.policy_loading", "Loading privacy policy...");
}
private async void LoadPrivacyPolicy()
{
try
{
IsLoading = true;
HasError = false;
HasContent = false;
// 从嵌入资源加载隐私政策Markdown文件
var assembly = Assembly.GetExecutingAssembly();
var resourceName = "LanMountainDesktop.Assets.Documents.Privacy.md";
using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream is null)
{
throw new FileNotFoundException($"Privacy policy resource not found: {resourceName}");
}
using var reader = new StreamReader(stream);
var markdown = await reader.ReadToEndAsync();
MarkdownContent = markdown;
IsLoading = false;
HasContent = true;
AppLogger.Info("PrivacyPolicy", "Privacy policy loaded successfully.");
}
catch (Exception ex)
{
AppLogger.Warn("PrivacyPolicy", "Failed to load privacy policy.", ex);
IsLoading = false;
HasError = true;
ErrorText = L("settings.privacy.policy_error", "Failed to load privacy policy. Please try again later.");
}
}
private string L(string key, string fallback)
=> _localizationService.GetString(_languageCode, key, fallback);
}

View File

@@ -15,6 +15,8 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase
private readonly string _languageCode;
private bool _isInitializing;
public event Action? ViewPrivacyPolicyRequested;
public PrivacySettingsPageViewModel(ISettingsFacadeService settingsFacade)
{
_settingsFacade = settingsFacade;
@@ -59,6 +61,12 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase
[ObservableProperty]
private string _refreshDeviceIdText = string.Empty;
[ObservableProperty]
private string _viewPrivacyPolicyText = string.Empty;
[ObservableProperty]
private string _privacyPolicyHintPrefix = string.Empty;
public void Load()
{
var state = _settingsFacade.Privacy.Get();
@@ -130,6 +138,25 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase
DeviceIdHeader = L("settings.privacy.device_id_title", "Device ID");
DeviceIdDescription = L("settings.privacy.device_id_description", "Unique identifier for this device. Click refresh to regenerate.");
RefreshDeviceIdText = L("settings.privacy.refresh_device_id", "Refresh");
PrivacyPolicyHintPrefix = L("settings.privacy.policy_hint_prefix", "For more details, please ");
ViewPrivacyPolicyText = L("settings.privacy.view_policy", "view our privacy policy");
}
[RelayCommand]
private void ViewPrivacyPolicy()
{
try
{
// 触发隐私政策查看事件
AppLogger.Info("PrivacySettings", "User requested to view privacy policy.");
// 发送事件通知显示隐私政策
ViewPrivacyPolicyRequested?.Invoke();
}
catch (Exception ex)
{
AppLogger.Warn("PrivacySettings", "Failed to view privacy policy.", ex);
}
}
private string L(string key, string fallback)

View File

@@ -268,12 +268,17 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase
partial void OnSelectedLanguageChanged(SelectionOption value)
{
RefreshPreview();
if (_isInitializing || value is null)
{
return;
}
// 更新语言代码并刷新UI文本
_languageCode = _localizationService.NormalizeLanguageCode(value.Value);
RefreshLocalizedText();
RefreshPreview();
// 保存设置
_settingsFacade.Region.Save(new RegionSettingsState(
value.Value,
NormalizeTimeZoneId(SelectedTimeZone?.Id)));
@@ -1329,9 +1334,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
LoadStateFromSettings();
}
[ObservableProperty]
private bool _autoCheckUpdates;
[ObservableProperty]
private string _selectedUpdateChannelValue = UpdateSettingsValues.ChannelStable;
@@ -1380,9 +1382,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
[ObservableProperty]
private string _preferencesDescription = string.Empty;
[ObservableProperty]
private string _autoCheckUpdatesLabel = string.Empty;
[ObservableProperty]
private string _updateChannelLabel = string.Empty;
@@ -1520,16 +1519,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
private bool IsBusy => IsCheckingForUpdates || IsDownloading;
partial void OnAutoCheckUpdatesChanged(bool value)
{
if (_isInitializing)
{
return;
}
SaveUpdateSettings();
}
partial void OnSelectedUpdateChannelOptionChanged(SelectionOption? value)
{
if (value is not null &&
@@ -1729,7 +1718,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
var current = _settingsFacade.Update.Get();
_settingsFacade.Update.Save(current with
{
AutoCheckUpdates = AutoCheckUpdates,
IncludePrereleaseUpdates = string.Equals(
SelectedUpdateChannelValue,
UpdateSettingsValues.ChannelPreview,
@@ -1841,7 +1829,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
StatusCardDescription = L("settings.update.status_card_description", "Check for updates and review the latest release information.");
PreferencesHeader = L("settings.update.preferences_header", "Update Preferences");
PreferencesDescription = L("settings.update.preferences_description", "Choose your release channel, download source, behavior, and download speed.");
AutoCheckUpdatesLabel = L("settings.update.auto_check_toggle", "Automatically check for updates on startup");
UpdateChannelLabel = L("settings.update.channel_label", "Update Channel");
UpdateSourceLabel = L("settings.update.source_label", "Download Source");
UpdateModeLabel = L("settings.update.mode_label", "Update Mode");
@@ -1870,7 +1857,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
{
var update = _settingsFacade.Update.Get();
_isInitializing = true;
AutoCheckUpdates = update.AutoCheckUpdates;
SelectedUpdateChannelValue = UpdateSettingsValues.NormalizeChannel(update.UpdateChannel, update.IncludePrereleaseUpdates);
SelectedUpdateSourceValue = UpdateSettingsValues.NormalizeDownloadSource(update.UpdateDownloadSource);
SelectedUpdateModeValue = UpdateSettingsValues.NormalizeMode(update.UpdateMode);

View File

@@ -39,6 +39,9 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
[ObservableProperty]
private SelectionOption _selectedClockFormat = new("HourMinuteSecond", "Hour:Minute:Second");
[ObservableProperty]
private bool _clockTransparentBackground;
[ObservableProperty]
private SelectionOption _selectedSpacingMode = new("Relaxed", "Relaxed");
@@ -66,6 +69,12 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
[ObservableProperty]
private string _clockFormatLabel = string.Empty;
[ObservableProperty]
private string _clockTransparentBackgroundLabel = string.Empty;
[ObservableProperty]
private string _clockTransparentBackgroundDescription = string.Empty;
[ObservableProperty]
private string _spacingHeader = string.Empty;
@@ -88,6 +97,7 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
SelectedClockFormat = ClockFormats.FirstOrDefault(option =>
string.Equals(option.Value, clockFormat, StringComparison.OrdinalIgnoreCase))
?? ClockFormats[1];
ClockTransparentBackground = state.ClockTransparentBackground;
var spacingMode = NormalizeSpacingMode(state.SpacingMode);
SelectedSpacingMode = SpacingModes.FirstOrDefault(option =>
@@ -117,6 +127,16 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
Save();
}
partial void OnClockTransparentBackgroundChanged(bool value)
{
if (_isInitializing)
{
return;
}
Save();
}
partial void OnSelectedSpacingModeChanged(SelectionOption value)
{
IsCustomSpacingVisible = string.Equals(value?.Value, "Custom", StringComparison.OrdinalIgnoreCase);
@@ -163,6 +183,7 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
state.EnableDynamicTaskbarActions,
state.TaskbarLayoutMode,
SelectedClockFormat.Value,
ClockTransparentBackground,
NormalizeSpacingMode(SelectedSpacingMode.Value),
Math.Clamp(CustomSpacingPercent, 0, 30)));
}
@@ -194,6 +215,8 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
ClockHeader = L("settings.status_bar.clock_header", "Clock Component");
ClockDescription = L("settings.status_bar.clock_description", "Display a clock on the top status bar.");
ClockFormatLabel = L("settings.status_bar.clock_format_label", "Clock format");
ClockTransparentBackgroundLabel = L("settings.status_bar.clock_transparent_background_label", "Transparent background");
ClockTransparentBackgroundDescription = L("settings.status_bar.clock_transparent_background_desc", "Remove the capsule background and keep only the clock text.");
SpacingHeader = L("settings.status_bar.spacing_header", "Component Spacing");
SpacingDescription = L("settings.status_bar.spacing_desc", "Adjust spacing between status bar components.");
CustomSpacingLabel = L("settings.status_bar.spacing_custom_label", "Custom spacing (%)");

View File

@@ -82,23 +82,24 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
[ObservableProperty]
private bool _isImage;
[ObservableProperty]
private bool _isVideo;
[ObservableProperty]
private Bitmap? _previewImage;
[ObservableProperty]
private IBrush? _previewBrush;
// 自定义颜色持久化
[ObservableProperty]
private string _videoModeHintText = string.Empty;
private Color _customColor = Colors.White;
[ObservableProperty]
private IBrush _customColorBrush = new SolidColorBrush(Colors.White);
public void Load()
{
var wallpaper = _settingsFacade.Wallpaper.Get();
WallpaperPath = wallpaper.WallpaperPath ?? string.Empty;
SelectedWallpaperType = WallpaperTypes.FirstOrDefault(t => t.Value == wallpaper.Type) ?? WallpaperTypes[0];
SelectedColor = wallpaper.Color ?? PresetColors[0];
@@ -108,7 +109,14 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
SelectedWallpaperPlacement = WallpaperPlacements.FirstOrDefault(option =>
string.Equals(option.Value, wallpaperPlacement, StringComparison.OrdinalIgnoreCase))
?? WallpaperPlacements[0];
// 加载自定义颜色
if (!string.IsNullOrWhiteSpace(wallpaper.CustomColor) && Color.TryParse(wallpaper.CustomColor, out var customColor))
{
CustomColor = customColor;
CustomColorBrush = new SolidColorBrush(customColor);
}
UpdateVisibility();
UpdatePreviewFromCurrentSelection();
}
@@ -124,8 +132,7 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
private void UpdateVisibility()
{
IsImage = SelectedWallpaperType?.Value == "Image";
IsVideo = SelectedWallpaperType?.Value == "Video";
IsImageOrVideo = SelectedWallpaperType?.Value is "Image" or "Video";
IsImageOrVideo = IsImage;
IsSolidColor = SelectedWallpaperType?.Value == "SolidColor";
}
@@ -135,6 +142,16 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
SaveWallpaper();
}
partial void OnCustomColorChanged(Color value)
{
CustomColorBrush = new SolidColorBrush(value);
// 将自定义颜色应用到壁纸
var colorHex = $"#{value.A:X2}{value.R:X2}{value.G:X2}{value.B:X2}";
SelectedColor = colorHex;
if (_isInitializing) return;
SaveWallpaper();
}
public async Task ImportWallpaperAsync(string sourcePath)
{
var importedPath = await _settingsFacade.WallpaperMedia.ImportAssetAsync(sourcePath);
@@ -222,11 +239,13 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
var normalizedPath = SelectedWallpaperType?.Value == "SolidColor" || string.IsNullOrWhiteSpace(WallpaperPath)
? null
: WallpaperPath;
var customColorHex = $"#{CustomColor.A:X2}{CustomColor.R:X2}{CustomColor.G:X2}{CustomColor.B:X2}";
_settingsFacade.Wallpaper.Save(new WallpaperSettingsState(
normalizedPath,
selectedType,
SelectedColor,
selectedPlacement));
selectedPlacement,
customColorHex));
}
private IReadOnlyList<SelectionOption> CreateWallpaperPlacements()
@@ -246,7 +265,6 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
return
[
new SelectionOption("Image", L("settings.wallpaper.type.image", "Image")),
new SelectionOption("Video", L("settings.wallpaper.type.video", "Video")),
new SelectionOption("SolidColor", L("settings.wallpaper.type.solid_color", "Solid Color"))
];
}
@@ -257,7 +275,7 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
[
"#D8A7B1", "#B6C9BB", "#A2B5BB", "#E6E2D3",
"#B5A397", "#C5C1C0", "#D4BE8D", "#C08261",
"#8E9775", "#9FBAD3", "#E5BAA2", "#4E596F"
"#8E9775", "#9FBAD3", "#E5BAA2"
];
}
@@ -271,7 +289,6 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
WallpaperPlacementDescription = L("settings.wallpaper.placement_desc", "Adjust how the image fills the desktop.");
ImportWallpaperButtonText = L("settings.wallpaper.pick_button", "Import Wallpaper");
FilePickerTitle = L("filepicker.title", "Select wallpaper");
VideoModeHintText = L("settings.wallpaper.video_mode", "Video wallpaper uses automatic fill mode.");
}
private string L(string key, string fallback)

View File

@@ -241,6 +241,7 @@ public partial class ComponentEditorWindow : Window
"DataLine" => MaterialIconKind.ChartLine,
"Edit" => MaterialIconKind.Pencil,
"Calculator" => MaterialIconKind.Calculator,
"Storage" => MaterialIconKind.UsbFlashDrive,
"Globe" => MaterialIconKind.Web,
"Play" => MaterialIconKind.Play,
_ => MaterialIconKind.Settings

View File

@@ -17,6 +17,25 @@
</StackPanel>
</Border>
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="12">
<TextBlock x:Name="ColorSchemeHeaderTextBlock"
Classes="component-editor-section-title" />
<ComboBox x:Name="ColorSchemeComboBox"
Classes="component-editor-select"
HorizontalAlignment="Stretch"
SelectionChanged="OnColorSchemeSelectionChanged">
<ComboBoxItem x:Name="FollowSystemColorSchemeItem"
Classes="component-editor-select-item"
Tag="follow_system" />
<ComboBoxItem x:Name="UseNativeColorSchemeItem"
Classes="component-editor-select-item"
Tag="native" />
</ComboBox>
</StackPanel>
</Border>
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="12">

View File

@@ -11,13 +11,15 @@ using Avalonia.Media;
using Avalonia.Platform.Storage;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.ComponentEditors;
public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
{
private readonly List<ImportedClassScheduleSnapshot> _importedSchedules = [];
private string _activeScheduleId = string.Empty;
private string? _activeScheduleId;
private bool _suppressEvents;
public ClassScheduleComponentEditor()
: this(null)
@@ -62,10 +64,46 @@ public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
private void ApplyState()
{
var snapshot = LoadSnapshot();
var colorSchemeSource = snapshot.ColorSchemeSource;
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Class Schedule";
DescriptionTextBlock.Text = L("schedule.settings.desc", "导入 ClassIsland 的 CSES 课表文件并选择启用项。");
AddScheduleButton.Content = L("schedule.settings.add", "添加课表");
EmptyStateTextBlock.Text = L("schedule.settings.empty", "暂无导入课表");
DescriptionTextBlock.Text = L(
"schedule.settings.desc",
"Import a ClassIsland CSES schedule file and choose which one to use.");
ColorSchemeHeaderTextBlock.Text = L("component.settings.color_scheme", "Color Scheme");
FollowSystemColorSchemeItem.Content = L("component.color_scheme.follow_system", "Follow system color scheme");
UseNativeColorSchemeItem.Content = L("component.color_scheme.native", "Use component custom color scheme");
AddScheduleButton.Content = L("schedule.settings.add", "Add Schedule");
EmptyStateTextBlock.Text = L("schedule.settings.empty", "No imported schedules yet.");
_suppressEvents = true;
ColorSchemeComboBox.SelectedItem =
string.IsNullOrEmpty(colorSchemeSource) ||
string.Equals(colorSchemeSource, ThemeAppearanceValues.ColorSchemeFollowSystem, StringComparison.OrdinalIgnoreCase)
? FollowSystemColorSchemeItem
: UseNativeColorSchemeItem;
_suppressEvents = false;
}
private void OnColorSchemeSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
var colorSchemeSource = ColorSchemeComboBox.SelectedItem is ComboBoxItem item && item.Tag is string tag
? tag
: ThemeAppearanceValues.ColorSchemeFollowSystem;
var snapshot = LoadSnapshot();
snapshot.ColorSchemeSource = colorSchemeSource;
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.ColorSchemeSource));
}
private async void OnAddScheduleClick(object? sender, RoutedEventArgs e)
@@ -80,11 +118,11 @@ public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
var files = await storageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = L("schedule.settings.picker_title", "选择 ClassIsland 课表文件"),
Title = L("schedule.settings.picker_title", "Choose ClassIsland schedule file"),
AllowMultiple = false,
FileTypeFilter =
[
new FilePickerFileType(L("schedule.settings.picker_file_type", "ClassIsland CSES 课表"))
new FilePickerFileType(L("schedule.settings.picker_file_type", "ClassIsland CSES Schedule"))
{
Patterns = ["*.cses", "*.yaml", "*.yml"]
}
@@ -114,7 +152,7 @@ public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
{
Id = Guid.NewGuid().ToString("N"),
DisplayName = Path.GetFileNameWithoutExtension(importedPath)?.Trim()
?? L("schedule.settings.unnamed", "未命名课表"),
?? L("schedule.settings.unnamed", "Untitled Schedule"),
FilePath = importedPath
});
_activeScheduleId = _importedSchedules[^1].Id;
@@ -178,7 +216,7 @@ public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
var title = new TextBlock
{
Text = string.IsNullOrWhiteSpace(item.DisplayName)
? L("schedule.settings.unnamed", "未命名课表")
? L("schedule.settings.unnamed", "Untitled Schedule")
: item.DisplayName,
FontWeight = FontWeight.SemiBold
};
@@ -193,7 +231,7 @@ public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
var deleteButton = new Button
{
Content = L("schedule.settings.delete", "删除"),
Content = L("schedule.settings.delete", "Delete"),
Tag = item.Id,
Padding = new Thickness(12, 8),
HorizontalAlignment = HorizontalAlignment.Right

View File

@@ -0,0 +1,50 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
x:Class="LanMountainDesktop.Views.ComponentEditors.RemovableStorageComponentEditor">
<StackPanel Spacing="16">
<Border Classes="component-editor-hero-card"
Padding="24">
<StackPanel Spacing="8">
<TextBlock x:Name="HeadlineTextBlock"
Classes="component-editor-headline"
TextWrapping="Wrap" />
<TextBlock x:Name="DescriptionTextBlock"
Classes="component-editor-secondary-text"
TextWrapping="Wrap" />
</StackPanel>
</Border>
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="12">
<TextBlock x:Name="ColorSchemeHeaderTextBlock"
Classes="component-editor-section-title" />
<ComboBox x:Name="ColorSchemeComboBox"
Classes="component-editor-select"
HorizontalAlignment="Stretch"
SelectionChanged="OnColorSchemeSelectionChanged">
<ComboBoxItem x:Name="FollowSystemColorSchemeItem"
Classes="component-editor-select-item"
Tag="follow_system" />
<ComboBoxItem x:Name="UseNativeColorSchemeItem"
Classes="component-editor-select-item"
Tag="native" />
</ComboBox>
</StackPanel>
</Border>
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="10">
<TextBlock x:Name="BehaviorHeaderTextBlock"
Classes="component-editor-section-title" />
<TextBlock x:Name="BehaviorTextBlock"
Classes="component-editor-secondary-text"
TextWrapping="Wrap" />
</StackPanel>
</Border>
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,67 @@
using System;
using Avalonia.Controls;
using Avalonia.Interactivity;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.ComponentEditors;
public partial class RemovableStorageComponentEditor : ComponentEditorViewBase
{
private bool _suppressEvents;
public RemovableStorageComponentEditor()
: this(null)
{
}
public RemovableStorageComponentEditor(DesktopComponentEditorContext? context)
: base(context)
{
InitializeComponent();
ApplyState();
}
private void ApplyState()
{
var snapshot = LoadSnapshot();
var colorSchemeSource = snapshot.ColorSchemeSource;
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Removable Storage";
DescriptionTextBlock.Text = L(
"removable_storage.settings.desc",
"Show a connected USB drive with quick open and eject actions.");
ColorSchemeHeaderTextBlock.Text = L("component.settings.color_scheme", "Color Scheme");
FollowSystemColorSchemeItem.Content = L("component.color_scheme.follow_system", "Follow system color scheme");
UseNativeColorSchemeItem.Content = L("component.color_scheme.native", "Use component custom color scheme");
BehaviorHeaderTextBlock.Text = L("removable_storage.settings.behavior_title", "Behavior");
BehaviorTextBlock.Text = L(
"removable_storage.settings.behavior_desc",
"The widget automatically watches for removable drives and switches to the newest inserted USB drive.");
_suppressEvents = true;
ColorSchemeComboBox.SelectedItem =
string.IsNullOrWhiteSpace(colorSchemeSource) ||
string.Equals(colorSchemeSource, ThemeAppearanceValues.ColorSchemeFollowSystem, StringComparison.OrdinalIgnoreCase)
? FollowSystemColorSchemeItem
: UseNativeColorSchemeItem;
_suppressEvents = false;
}
private void OnColorSchemeSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
var snapshot = LoadSnapshot();
snapshot.ColorSchemeSource = ColorSchemeComboBox.SelectedItem is ComboBoxItem item && item.Tag is string tag
? tag
: ThemeAppearanceValues.ColorSchemeFollowSystem;
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.ColorSchemeSource));
}
}

View File

@@ -2,6 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:ui="using:FluentAvalonia.UI.Controls"
mc:Ignorable="d"
x:Class="LanMountainDesktop.Views.ComponentEditors.StudyEnvironmentComponentEditor">
<StackPanel Spacing="16">
@@ -17,6 +18,25 @@
</StackPanel>
</Border>
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="12">
<TextBlock x:Name="ColorSchemeHeaderTextBlock"
Classes="component-editor-section-title" />
<ComboBox x:Name="ColorSchemeComboBox"
Classes="component-editor-select"
HorizontalAlignment="Stretch"
SelectionChanged="OnColorSchemeSelectionChanged">
<ComboBoxItem x:Name="FollowSystemColorSchemeItem"
Classes="component-editor-select-item"
Tag="follow_system" />
<ComboBoxItem x:Name="UseNativeColorSchemeItem"
Classes="component-editor-select-item"
Tag="native" />
</ComboBox>
</StackPanel>
</Border>
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="12">

View File

@@ -1,6 +1,9 @@
using System;
using Avalonia.Controls;
using Avalonia.Interactivity;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.ComponentEditors;
@@ -25,23 +28,55 @@ public partial class StudyEnvironmentComponentEditor : ComponentEditorViewBase
var snapshot = LoadSnapshot();
var showDisplayDb = snapshot.StudyEnvironmentShowDisplayDb;
var showDbfs = snapshot.StudyEnvironmentShowDbfs;
var colorSchemeSource = snapshot.ColorSchemeSource;
if (!showDisplayDb && !showDbfs)
{
showDisplayDb = true;
}
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Study Environment";
DescriptionTextBlock.Text = L("study.environment.settings.desc", "配置右侧实时噪音值显示内容。");
DisplayDbToggleSwitch.Content = L("study.environment.settings.show_display_db", "显示 display dB");
DbfsToggleSwitch.Content = L("study.environment.settings.show_dbfs", "显示 dBFS");
HintTextBlock.Text = L("study.environment.settings.hint", "至少启用一种显示方式。");
DescriptionTextBlock.Text = L(
"study.environment.settings.desc",
"Configure the realtime audio level information shown on the right side.");
ColorSchemeHeaderTextBlock.Text = L("component.settings.color_scheme", "Color Scheme");
FollowSystemColorSchemeItem.Content = L("component.color_scheme.follow_system", "Follow system color scheme");
UseNativeColorSchemeItem.Content = L("component.color_scheme.native", "Use component custom color scheme");
DisplayDbToggleSwitch.Content = L("study.environment.settings.show_display_db", "Show display dB");
DbfsToggleSwitch.Content = L("study.environment.settings.show_dbfs", "Show dBFS");
HintTextBlock.Text = L("study.environment.settings.hint", "At least one display mode must stay enabled.");
_suppressEvents = true;
ColorSchemeComboBox.SelectedItem =
string.IsNullOrEmpty(colorSchemeSource) ||
string.Equals(colorSchemeSource, ThemeAppearanceValues.ColorSchemeFollowSystem, StringComparison.OrdinalIgnoreCase)
? FollowSystemColorSchemeItem
: UseNativeColorSchemeItem;
DisplayDbToggleSwitch.IsChecked = showDisplayDb;
DbfsToggleSwitch.IsChecked = showDbfs;
_suppressEvents = false;
}
private void OnColorSchemeSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
var colorSchemeSource = ColorSchemeComboBox.SelectedItem is ComboBoxItem item && item.Tag is string tag
? tag
: ThemeAppearanceValues.ColorSchemeFollowSystem;
var snapshot = LoadSnapshot();
snapshot.ColorSchemeSource = colorSchemeSource;
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.ColorSchemeSource));
}
private void OnToggleChanged(object? sender, RoutedEventArgs e)
{
_ = sender;

View File

@@ -13,6 +13,7 @@ using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.Threading;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
@@ -50,6 +51,7 @@ public partial class BaiduHotSearchWidget : UserControl, IDesktopComponentWidget
private bool _autoRefreshEnabled = true;
private string _sourceType = BaiduHotSearchSourceTypes.Official;
private bool _isNightVisual = true;
private string? _componentColorScheme;
private sealed record HotItemVisual(
Border Host,
@@ -180,17 +182,25 @@ public partial class BaiduHotSearchWidget : UserControl, IDesktopComponentWidget
private void ApplyNightModeVisual()
{
var useMonetColor = ComponentColorSchemeHelper.ShouldUseMonetColor(
_componentColorScheme,
ComponentColorSchemeHelper.GetCurrentGlobalThemeColorMode());
var brandColor = useMonetColor
? (_isNightVisual ? Color.Parse("#9FABFF") : Color.Parse("#4F6BEB"))
: (_isNightVisual ? Color.Parse("#5D93FF") : Color.Parse("#2932E1"));
CardBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#1B2129") : Color.Parse("#FCFCFD"));
RootBorder.BorderBrush = new SolidColorBrush(_isNightVisual ? Color.Parse("#33FFFFFF") : Color.Parse("#00000000"));
BrandTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#5D93FF") : Color.Parse("#2932E1"));
BrandTextBlock.Foreground = new SolidColorBrush(brandColor);
RefreshButton.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#EFF1F5"));
RefreshGlyphIcon.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#5E6671"));
foreach (var visual in _hotItemVisuals)
{
visual.IndexTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#5D93FF") : Color.Parse("#2932E1"));
visual.IndexTextBlock.Foreground = new SolidColorBrush(brandColor);
visual.TitleTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"));
}
@@ -488,10 +498,11 @@ public partial class BaiduHotSearchWidget : UserControl, IDesktopComponentWidget
enabled = snapshot.BaiduHotSearchAutoRefreshEnabled;
intervalMinutes = NormalizeAutoRefreshIntervalMinutes(snapshot.BaiduHotSearchAutoRefreshIntervalMinutes);
sourceType = BaiduHotSearchSourceTypes.Normalize(snapshot.BaiduHotSearchSourceType);
_componentColorScheme = snapshot.ColorSchemeSource;
}
catch
{
// Keep fallback defaults.
_componentColorScheme = null;
}
_autoRefreshEnabled = enabled;

View File

@@ -43,7 +43,7 @@
<Grid Grid.Row="1">
<ScrollViewer x:Name="ContentScrollViewer"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Disabled">
VerticalScrollBarVisibility="Auto">
<StackPanel x:Name="CourseListPanel" />
</ScrollViewer>

View File

@@ -1,9 +1,10 @@
using System;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.Threading;
@@ -25,9 +26,17 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
private readonly DispatcherTimer _refreshTimer = new()
{
Interval = TimeSpan.FromMinutes(4)
Interval = TimeSpan.FromMinutes(1)
};
private int _lastCurrentCourseIndex = -1;
private DateOnly _lastRefreshDate = DateOnly.MinValue;
private bool _isUserScrolling;
private Vector _lastScrollOffset;
private Point _dragStartPoint;
private Point _lastDragPoint;
private ISettingsService _settingsService = HostSettingsFacadeProvider.GetOrCreate().Settings;
private readonly LocalizationService _localizationService = new();
private readonly IClassIslandScheduleDataService _scheduleService = new ClassIslandScheduleDataService();
@@ -39,6 +48,7 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
private string _languageCode = "zh-CN";
private string _componentId = BuiltInComponentIds.DesktopClassSchedule;
private string _placementId = string.Empty;
private string? _componentColorScheme;
public ClassScheduleWidget()
{
@@ -50,6 +60,10 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
SizeChanged += OnSizeChanged;
ActualThemeVariantChanged += OnActualThemeVariantChanged;
ContentScrollViewer.PointerPressed += OnScrollViewerPointerPressed;
ContentScrollViewer.PointerMoved += OnScrollViewerPointerMoved;
ContentScrollViewer.PointerReleased += OnScrollViewerPointerReleased;
ApplyCellSize(_currentCellSize);
RefreshSchedule();
}
@@ -107,9 +121,109 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
RefreshSchedule();
}
private void OnScrollViewerPointerPressed(object? sender, PointerPressedEventArgs e)
{
_isUserScrolling = true;
_dragStartPoint = e.GetCurrentPoint(ContentScrollViewer).Position;
_lastDragPoint = _dragStartPoint;
_lastScrollOffset = ContentScrollViewer.Offset;
}
private void OnScrollViewerPointerMoved(object? sender, PointerEventArgs e)
{
if (!_isUserScrolling)
{
return;
}
var currentPoint = e.GetCurrentPoint(ContentScrollViewer);
var currentPosition = currentPoint.Position;
var deltaY = currentPosition.Y - _lastDragPoint.Y;
var newOffset = _lastScrollOffset;
newOffset = newOffset.WithY(newOffset.Y - deltaY);
ContentScrollViewer.Offset = newOffset;
_lastDragPoint = currentPosition;
}
private void OnScrollViewerPointerReleased(object? sender, PointerReleasedEventArgs e)
{
_lastScrollOffset = ContentScrollViewer.Offset;
}
private void OnRefreshTimerTick(object? sender, EventArgs e)
{
var now = _timeZoneService?.GetCurrentTime() ?? DateTime.Now;
var currentDate = DateOnly.FromDateTime(now);
var previousCourseIndex = _lastCurrentCourseIndex;
RefreshSchedule();
var newCurrentCourseIndex = FindCurrentCourseIndex();
_lastCurrentCourseIndex = newCurrentCourseIndex;
if (previousCourseIndex != newCurrentCourseIndex && newCurrentCourseIndex >= 0)
{
if (_isUserScrolling)
{
_isUserScrolling = false;
}
ScrollToCurrentCourse(newCurrentCourseIndex);
}
if (_lastRefreshDate != currentDate && currentDate > _lastRefreshDate)
{
_lastRefreshDate = currentDate;
}
}
private int FindCurrentCourseIndex()
{
for (var i = 0; i < _courseItems.Count; i++)
{
if (_courseItems[i].IsCurrent)
{
return i;
}
}
return -1;
}
private void ScrollToCurrentCourse(int courseIndex)
{
if (courseIndex < 0 || courseIndex >= _courseItems.Count)
{
return;
}
// 确保在UI线程执行
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
if (courseIndex >= CourseListPanel.Children.Count)
{
return;
}
var targetChild = CourseListPanel.Children[courseIndex];
if (targetChild == null || !targetChild.IsArrangeValid)
{
return;
}
var bounds = targetChild.Bounds;
var scrollViewerHeight = ContentScrollViewer.Bounds.Height;
var contentHeight = CourseListPanel.Bounds.Height;
// 计算滚动位置,使当前课程居中显示
var targetOffset = bounds.Position.Y - (scrollViewerHeight / 2) + (bounds.Height / 2);
// 确保不超出边界
targetOffset = Math.Max(0, Math.Min(targetOffset, contentHeight - scrollViewerHeight));
ContentScrollViewer.Offset = new Vector(0, targetOffset);
}, Avalonia.Threading.DispatcherPriority.Loaded);
}
public void RefreshFromSettings()
@@ -134,45 +248,85 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
_componentId,
_placementId);
_languageCode = _localizationService.NormalizeLanguageCode(appSettings.LanguageCode);
_componentColorScheme = componentSettings.ColorSchemeSource;
var now = _timeZoneService?.GetCurrentTime() ?? DateTime.Now;
UpdateHeader(now);
var today = DateOnly.FromDateTime(now);
var importedSchedulePath = ResolveImportedSchedulePath(componentSettings);
var readResult = _scheduleService.Load(importedSchedulePath);
if (!readResult.Success || readResult.Snapshot is null)
{
_courseItems = Array.Empty<CourseItemViewModel>();
UpdateHeader(now);
ShowStatus(L("schedule.widget.no_source", "未读取到 ClassIsland 课表"));
RenderScheduleItems();
return;
}
var snapshot = readResult.Snapshot;
var today = DateOnly.FromDateTime(now);
if (!_scheduleService.TryResolveClassPlanForDate(snapshot, today, out var resolvedClassPlan))
{
_courseItems = Array.Empty<CourseItemViewModel>();
ShowStatus(L("schedule.widget.no_class_today", "今天没有课程"));
RenderScheduleItems();
return;
var nextDay = today.AddDays(1);
if (_scheduleService.TryResolveClassPlanForDate(snapshot, nextDay, out var nextDayClassPlan))
{
resolvedClassPlan = nextDayClassPlan;
today = nextDay;
}
else
{
_courseItems = Array.Empty<CourseItemViewModel>();
UpdateHeader(now);
ShowStatus(L("schedule.widget.no_class_today", "今天没有课程"));
RenderScheduleItems();
return;
}
}
if (!snapshot.TimeLayouts.TryGetValue(resolvedClassPlan.ClassPlan.TimeLayoutId, out var layout))
{
_courseItems = Array.Empty<CourseItemViewModel>();
UpdateHeader(now);
ShowStatus(L("schedule.widget.layout_missing", "课表时间布局缺失"));
RenderScheduleItems();
return;
}
_courseItems = BuildCourseItemViewModels(snapshot, resolvedClassPlan.ClassPlan, layout, now);
var adjustedNow = today == DateOnly.FromDateTime(now) ? now : DateTime.Today.AddHours(8);
_courseItems = BuildCourseItemViewModels(snapshot, resolvedClassPlan.ClassPlan, layout, adjustedNow);
if (_courseItems.Count == 0)
{
var nextDay = today.AddDays(1);
if (_scheduleService.TryResolveClassPlanForDate(snapshot, nextDay, out var nextDayClassPlan) &&
snapshot.TimeLayouts.TryGetValue(nextDayClassPlan.ClassPlan.TimeLayoutId, out var nextLayout))
{
today = nextDay;
adjustedNow = DateTime.Today.AddHours(8);
_courseItems = BuildCourseItemViewModels(snapshot, nextDayClassPlan.ClassPlan, nextLayout, adjustedNow);
}
}
UpdateHeader(today.ToDateTime(TimeOnly.MinValue));
if (_courseItems.Count == 0)
{
ShowStatus(L("schedule.widget.no_class_today", "今天没有课程"));
}
else
{
var currentIndex = FindCurrentCourseIndex();
_lastCurrentCourseIndex = currentIndex;
HideStatus();
// 初始化时自动跳转到当前课程
if (currentIndex >= 0)
{
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
ScrollToCurrentCourse(currentIndex);
}, Avalonia.Threading.DispatcherPriority.Loaded);
}
}
RenderScheduleItems();
@@ -336,6 +490,10 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
return;
}
var useMonetColor = ComponentColorSchemeHelper.ShouldUseMonetColor(
_componentColorScheme,
ComponentColorSchemeHelper.GetCurrentGlobalThemeColorMode());
var scale = ResolveScale();
var bulletSize = Math.Clamp(10 * scale, 5, 12);
var courseNameSize = Math.Clamp(42 * scale, 14, 42);
@@ -350,13 +508,14 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
var primaryBrush = CreateBrush(_isNightVisual ? "#F9FBFF" : "#151821");
var secondaryBrush = CreateBrush(_isNightVisual ? "#848B99" : "#667084");
var currentBrush = CreateBrush("#FF4D5A");
var currentBrush = useMonetColor
? CreateBrush("#FF4FC3F7")
: CreateBrush("#FF4D5A");
var normalBulletBrush = CreateBrush(_isNightVisual ? "#B8BEC9" : "#9AA3B2");
var visibleItems = _courseItems.Take(maxVisibleItems).ToList();
for (var i = 0; i < visibleItems.Count; i++)
for (var i = 0; i < _courseItems.Count; i++)
{
var item = visibleItems[i];
var item = _courseItems[i];
var bulletBrush = item.IsCurrent ? currentBrush : normalBulletBrush;
var bullet = new Border
@@ -438,9 +597,22 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
private void ApplyAdaptiveLayout()
{
if (Bounds.Width <= 0 || Bounds.Height <= 0)
{
return;
}
var scale = ResolveScale();
_isNightVisual = ResolveNightMode();
var useMonetColor = ComponentColorSchemeHelper.ShouldUseMonetColor(
_componentColorScheme,
ComponentColorSchemeHelper.GetCurrentGlobalThemeColorMode());
var slashBrush = useMonetColor
? CreateBrush("#FF4FC3F7")
: CreateBrush("#FF3250");
var cornerRadius = Math.Clamp(_currentCellSize * 0.45, 24, 44);
RootBorder.CornerRadius = new CornerRadius(cornerRadius);
RootBorder.Background = _isNightVisual
@@ -468,7 +640,7 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
MonthTextBlock.Foreground = CreateBrush(_isNightVisual ? "#F8FAFF" : "#131722");
DayTextBlock.Foreground = CreateBrush(_isNightVisual ? "#F8FAFF" : "#131722");
SlashTextBlock.Foreground = CreateBrush("#FF3250");
SlashTextBlock.Foreground = slashBrush;
WeekdayTextBlock.Foreground = CreateBrush(_isNightVisual ? "#C6CBD5" : "#4B5463");
ClassCountTextBlock.Foreground = CreateBrush(_isNightVisual ? "#8D95A4" : "#738095");
StatusTextBlock.Foreground = CreateBrush(_isNightVisual ? "#9AA2B1" : "#4B5565");

View File

@@ -23,6 +23,8 @@ public partial class ClockWidget : UserControl, IDesktopComponentWidget, ITimeZo
private TimeZoneService? _timeZoneService;
private ClockDisplayFormat _displayFormat = ClockDisplayFormat.HourMinuteSecond;
private bool _transparentBackground;
private double _lastAppliedCellSize = 100;
public ClockWidget()
{
@@ -44,11 +46,32 @@ public partial class ClockWidget : UserControl, IDesktopComponentWidget, ITimeZo
}
}
public bool TransparentBackground
{
get => _transparentBackground;
set
{
if (_transparentBackground == value)
{
return;
}
_transparentBackground = value;
ApplyChrome();
ApplyCellSize(_lastAppliedCellSize);
}
}
public void SetDisplayFormat(ClockDisplayFormat format)
{
DisplayFormat = format;
}
public void SetTransparentBackground(bool transparentBackground)
{
TransparentBackground = transparentBackground;
}
public void SetTimeZoneService(TimeZoneService timeZoneService)
{
ClearTimeZoneService();
@@ -101,6 +124,8 @@ public partial class ClockWidget : UserControl, IDesktopComponentWidget, ITimeZo
public void ApplyCellSize(double cellSize)
{
_lastAppliedCellSize = cellSize;
// --- Class Island “满盈”风格算法 ---
// 1. 计算组件高度:保持与任务栏核心比例一致 (0.74x)
@@ -129,8 +154,39 @@ public partial class ClockWidget : UserControl, IDesktopComponentWidget, ITimeZo
{
panel.Spacing = Math.Clamp(cellSize * 0.06, 2, 8);
}
if (_transparentBackground)
{
RootBorder.MinWidth = 0;
RootBorder.Padding = new Thickness(Math.Clamp(cellSize * 0.06, 4, 10), 0);
return;
}
// 确保清除可能存在的固定 Padding由代码控制“紧密感”
RootBorder.MinWidth = cellSize * 2.2;
RootBorder.Padding = new Thickness(Math.Clamp(cellSize * 0.15, 12, 24), 0);
}
private void ApplyChrome()
{
if (_transparentBackground)
{
RootBorder.Classes.Remove("glass-panel");
RootBorder.Background = Brushes.Transparent;
RootBorder.BorderBrush = Brushes.Transparent;
RootBorder.BorderThickness = new Thickness(0);
RootBorder.BoxShadow = default;
return;
}
if (!RootBorder.Classes.Contains("glass-panel"))
{
RootBorder.Classes.Add("glass-panel");
}
RootBorder.ClearValue(Border.BackgroundProperty);
RootBorder.ClearValue(Border.BorderBrushProperty);
RootBorder.ClearValue(Border.BorderThicknessProperty);
RootBorder.ClearValue(Border.BoxShadowProperty);
}
}

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
@@ -439,6 +439,16 @@ public sealed class DesktopComponentRuntimeRegistry
"component.browser",
() => new BrowserWidget(),
cellSize => Math.Clamp(cellSize * 0.24, 10, 24)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopOfficeRecentDocuments,
"component.office_recent_documents",
() => new OfficeRecentDocumentsWidget(),
cellSize => Math.Clamp(cellSize * 0.50, 10, 24)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopRemovableStorage,
"component.removable_storage",
() => new RemovableStorageWidget(),
cellSize => Math.Clamp(cellSize * 0.46, 12, 26)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.HolidayCalendar,
"component.holiday_calendar",

View File

@@ -0,0 +1,10 @@
using System;
namespace LanMountainDesktop.Views.Components;
public sealed class OfficeRecentDocumentViewModel
{
public string FileName { get; set; } = string.Empty;
public string FilePath { get; set; } = string.Empty;
public string TimeAgo { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,108 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:fi="using:FluentIcons.Avalonia"
xmlns:vm="using:LanMountainDesktop.Views.Components"
mc:Ignorable="d"
d:DesignWidth="640"
d:DesignHeight="320"
x:Class="LanMountainDesktop.Views.Components.OfficeRecentDocumentsWidget">
<Border x:Name="RootBorder"
CornerRadius="34"
Background="#2D5A8E"
ClipToBounds="True"
BorderThickness="0"
Padding="0">
<Grid>
<Border x:Name="AccentCorner"
Width="140"
Height="140"
HorizontalAlignment="Right"
VerticalAlignment="Top"
Margin="0,-40,-40,0"
CornerRadius="70"
Background="#4A90D9"
Opacity="0.3"
IsHitTestVisible="False" />
<Grid RowDefinitions="Auto,*" RowSpacing="8" Margin="16,14,16,14">
<Grid Grid.Row="0" ColumnDefinitions="*,Auto">
<TextBlock x:Name="HeaderTextBlock"
Text="最近文档"
Foreground="#D8FFFFFF"
FontSize="18"
FontWeight="SemiBold"
VerticalAlignment="Center" />
<Button x:Name="RefreshButton"
Grid.Column="1"
Width="28"
Height="28"
CornerRadius="14"
Background="Transparent"
BorderBrush="Transparent"
BorderThickness="0"
Padding="0"
Focusable="False"
PointerPressed="OnRefreshPointerPressed">
<fi:SymbolIcon Symbol="ArrowSync"
FontSize="14"
Foreground="#B8FFFFFF" />
</Button>
</Grid>
<ScrollViewer Grid.Row="1"
HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Disabled"
Margin="0,4,0,0">
<ItemsControl x:Name="DocumentsItemsControl">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" Spacing="8" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:OfficeRecentDocumentViewModel">
<Border x:Name="DocumentCard"
Width="130"
Height="90"
CornerRadius="10"
Background="#3AFFFFFF"
Padding="10"
Cursor="Hand"
PointerPressed="OnDocumentCardPointerPressed">
<Grid RowDefinitions="Auto,*,Auto">
<TextBlock Grid.Row="0"
Text="{Binding FileName}"
Foreground="#D8FFFFFF"
FontSize="12"
FontWeight="Medium"
TextTrimming="CharacterEllipsis"
MaxLines="2"
TextWrapping="Wrap"
VerticalAlignment="Top" />
<TextBlock Grid.Row="2"
Text="{Binding TimeAgo}"
Foreground="#9AFFFFFF"
FontSize="10"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
<TextBlock x:Name="StatusTextBlock"
IsVisible="False"
Text="暂无最近文档"
Foreground="#9AFFFFFF"
FontSize="14"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Grid>
</Border>
</UserControl>

View File

@@ -0,0 +1,145 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Input;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.Components;
public partial class OfficeRecentDocumentsWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget
{
private readonly IOfficeRecentDocumentsService _recentDocumentsService;
private List<OfficeRecentDocument> _documents = new();
private bool _isOnActivePage;
private bool _isEditMode;
private bool _isLoading;
public OfficeRecentDocumentsWidget()
{
InitializeComponent();
_recentDocumentsService = new OfficeRecentDocumentsService();
}
public void ApplyCellSize(double cellSize)
{
if (RootBorder is null)
{
return;
}
var scale = cellSize / 100.0;
RootBorder.CornerRadius = new Avalonia.CornerRadius(Math.Max(8, 34 * scale));
}
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
{
_isOnActivePage = isOnActivePage;
_isEditMode = isEditMode;
if (_isOnActivePage && !_isLoading)
{
LoadDocuments();
}
}
private async void LoadDocuments()
{
if (_isLoading)
{
return;
}
try
{
_isLoading = true;
StatusTextBlock.IsVisible = false;
DocumentsItemsControl.ItemsSource = null;
_documents = await Task.Run(() => _recentDocumentsService.GetRecentDocuments(20));
if (_documents.Count == 0)
{
StatusTextBlock.Text = "\u6682\u65e0\u6700\u8fd1\u6587\u6863";
StatusTextBlock.IsVisible = true;
return;
}
UpdateDisplay();
}
catch (Exception ex)
{
AppLogger.Warn("OfficeRecentDocsWidget", "Failed to load recent Office documents.", ex);
StatusTextBlock.Text = "\u52a0\u8f7d\u5931\u8d25";
StatusTextBlock.IsVisible = true;
}
finally
{
_isLoading = false;
}
}
private void UpdateDisplay()
{
var displayItems = _documents.Select(d => new OfficeRecentDocumentViewModel
{
FileName = d.FileName,
FilePath = d.FilePath,
TimeAgo = GetTimeAgo(d.LastModifiedTime)
}).ToList();
DocumentsItemsControl.ItemsSource = displayItems;
}
private static string GetTimeAgo(DateTime dateTime)
{
var span = DateTime.Now - dateTime;
if (span.TotalMinutes < 1)
{
return "\u521a\u521a";
}
if (span.TotalMinutes < 60)
{
return $"{(int)span.TotalMinutes} \u5206\u949f\u524d";
}
if (span.TotalHours < 24)
{
return $"{(int)span.TotalHours} \u5c0f\u65f6\u524d";
}
if (span.TotalDays < 7)
{
return $"{(int)span.TotalDays} \u5929\u524d";
}
if (span.TotalDays < 30)
{
return $"{(int)(span.TotalDays / 7)} \u5468\u524d";
}
return dateTime.ToString("MM/dd");
}
private void OnRefreshPointerPressed(object? sender, PointerPressedEventArgs e)
{
LoadDocuments();
}
private void OnDocumentCardPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (sender is Border border && border.DataContext is { } data)
{
var filePathProperty = data.GetType().GetProperty("FilePath");
var filePath = filePathProperty?.GetValue(data) as string;
if (!string.IsNullOrEmpty(filePath))
{
_recentDocumentsService.OpenDocument(filePath);
}
}
}
}

View File

@@ -0,0 +1,119 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:fi="using:FluentIcons.Avalonia"
mc:Ignorable="d"
d:DesignWidth="280"
d:DesignHeight="280"
x:Class="LanMountainDesktop.Views.Components.RemovableStorageWidget">
<Border x:Name="RootBorder"
CornerRadius="28"
BorderThickness="1"
Padding="16"
ClipToBounds="True">
<Grid>
<Border x:Name="AccentOrb"
Width="132"
Height="132"
HorizontalAlignment="Right"
VerticalAlignment="Top"
Margin="0,-48,-48,0"
CornerRadius="66"
IsHitTestVisible="False" />
<Border x:Name="AccentGlow"
Height="76"
Margin="-18,0,-18,-34"
HorizontalAlignment="Stretch"
VerticalAlignment="Bottom"
CornerRadius="38"
Opacity="0.42"
IsHitTestVisible="False" />
<Grid x:Name="LayoutGrid"
RowDefinitions="Auto,*,Auto,Auto"
RowSpacing="10">
<Grid x:Name="HeaderGrid"
ColumnDefinitions="Auto,*"
ColumnSpacing="12">
<Border x:Name="IconBadge"
Width="44"
Height="44"
CornerRadius="22"
VerticalAlignment="Top">
<fi:FluentIcon x:Name="DriveIcon"
Icon="UsbStick"
IconVariant="Regular"
FontSize="24"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<StackPanel x:Name="HeaderTextStack"
Grid.Column="1"
Spacing="2"
VerticalAlignment="Center">
<TextBlock x:Name="DriveNameTextBlock"
FontWeight="SemiBold"
TextWrapping="NoWrap"
TextTrimming="CharacterEllipsis" />
<TextBlock x:Name="DriveDetailTextBlock"
TextWrapping="NoWrap"
TextTrimming="CharacterEllipsis" />
</StackPanel>
</Grid>
<TextBlock x:Name="StatusTextBlock"
Grid.Row="1"
VerticalAlignment="Center"
TextWrapping="Wrap" />
<Button x:Name="OpenButton"
Grid.Row="2"
Height="42"
Padding="14,0"
CornerRadius="999"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Click="OnOpenClick">
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="8">
<fi:FluentIcon x:Name="OpenButtonIcon"
Icon="OpenFolder"
IconVariant="Regular"
FontSize="16"
VerticalAlignment="Center" />
<TextBlock x:Name="OpenButtonTextBlock"
Grid.Column="1"
FontWeight="SemiBold"
VerticalAlignment="Center" />
</Grid>
</Button>
<Button x:Name="EjectButton"
Grid.Row="3"
Height="42"
Padding="14,0"
CornerRadius="999"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Click="OnEjectClick">
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="8">
<fi:FluentIcon x:Name="EjectButtonIcon"
Icon="ArrowEject"
IconVariant="Regular"
FontSize="16"
VerticalAlignment="Center" />
<TextBlock x:Name="EjectButtonTextBlock"
Grid.Column="1"
FontWeight="SemiBold"
VerticalAlignment="Center" />
</Grid>
</Button>
</Grid>
</Grid>
</Border>
</UserControl>

View File

@@ -0,0 +1,596 @@
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;
var cornerRadius = Math.Clamp(_currentCellSize * 0.44, 18, 34);
RootBorder.CornerRadius = new CornerRadius(cornerRadius);
RootBorder.Padding = new Thickness(
Math.Clamp(16 * scale, 10, 24),
Math.Clamp(15 * scale, 10, 22),
Math.Clamp(16 * scale, 10, 24),
Math.Clamp(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;
}
}

View File

@@ -4,6 +4,7 @@ using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Threading;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
@@ -24,6 +25,7 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg
private double _currentCellSize = 48;
private bool _showDisplayDb = true;
private bool _showDbfs;
private string? _componentColorScheme;
private string _languageCode = "zh-CN";
private bool _isAttached;
private bool _isOnActivePage = true;
@@ -147,6 +149,7 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
_showDisplayDb = componentSnapshot.StudyEnvironmentShowDisplayDb;
_showDbfs = componentSnapshot.StudyEnvironmentShowDbfs;
_componentColorScheme = componentSnapshot.ColorSchemeSource;
if (!_showDisplayDb && !_showDbfs)
{
_showDisplayDb = true;
@@ -287,22 +290,26 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg
private IBrush ResolveStatusBrush(StudyAnalyticsSnapshot snapshot)
{
var useMonetColor = ComponentColorSchemeHelper.ShouldUseMonetColor(
_componentColorScheme,
ComponentColorSchemeHelper.GetCurrentGlobalThemeColorMode());
if (snapshot.State == StudyAnalyticsRuntimeState.Unsupported ||
snapshot.State == StudyAnalyticsRuntimeState.Error ||
snapshot.StreamStatus == NoiseStreamStatus.Error)
{
return CreateBrush("#FFFF7B7B");
return useMonetColor ? CreateBrush("#FF6FD7A2") : CreateBrush("#FFFF7B7B");
}
if (snapshot.StreamStatus == NoiseStreamStatus.Noisy)
{
return CreateBrush("#FFFFB14A");
return useMonetColor ? CreateBrush("#FF4FC3F7") : CreateBrush("#FFFFB14A");
}
if (snapshot.State == StudyAnalyticsRuntimeState.Running &&
snapshot.StreamStatus == NoiseStreamStatus.Quiet)
{
return CreateBrush("#FF6FD7A2");
return useMonetColor ? CreateBrush("#FF81C784") : CreateBrush("#FF6FD7A2");
}
return TryResolveThemeBrush("AdaptiveTextPrimaryBrush", "#FFEFF3FF");

View File

@@ -398,10 +398,12 @@ public partial class MainWindow
_clockDisplayFormat = snapshot.ClockDisplayFormat == "HourMinute"
? ClockDisplayFormat.HourMinute
: ClockDisplayFormat.HourMinuteSecond;
_statusBarClockTransparentBackground = snapshot.StatusBarClockTransparentBackground;
if (ClockWidget is not null)
{
ClockWidget.SetDisplayFormat(_clockDisplayFormat);
ClockWidget.SetTransparentBackground(_statusBarClockTransparentBackground);
}
}
@@ -413,6 +415,7 @@ public partial class MainWindow
if (ClockWidget is not null)
{
ClockWidget.IsVisible = showClock;
ClockWidget.SetTransparentBackground(_statusBarClockTransparentBackground);
if (showClock)
{
ClockWidget.SetDisplayFormat(_clockDisplayFormat);
@@ -964,6 +967,7 @@ public partial class MainWindow
DisposeComponentIfNeeded(host);
contentHost.Child = component;
ApplyDesktopEditStateToHost(host, _isComponentLibraryOpen);
InvalidateDesktopPageAwareComponentContextCache();
UpdateDesktopPageAwareComponentContext();
if (_selectedDesktopComponentHost == host)
{
@@ -1102,6 +1106,7 @@ public partial class MainWindow
ClearTimeZoneServiceBindings(pageGrid.Children.OfType<Control>().ToList());
pageGrid.Children.Clear();
InvalidateDesktopPageAwareComponentContextCache();
var maxColumns = pageGrid.ColumnDefinitions.Count;
var maxRows = pageGrid.RowDefinitions.Count;
@@ -1204,6 +1209,7 @@ public partial class MainWindow
pageGrid.Children.Add(host);
_desktopComponentPlacements.Add(placement);
InvalidateDesktopPageAwareComponentContextCache();
UpdateDesktopPageAwareComponentContext();
PersistSettings();
@@ -1577,14 +1583,86 @@ public partial class MainWindow
}
}
private void InvalidateDesktopPageAwareComponentContextCache()
{
_desktopPageContextInitialized = false;
_desktopPageContextActiveMask = 0;
}
private int BuildDesktopPageAwareComponentActiveMask()
{
if (_isSettingsOpen)
{
return 0;
}
var activeMask = 0;
if (_desktopSurfacePageWidth > 1 &&
_desktopPagesHostTransform is not null &&
(_isDesktopSwipeActive ||
_desktopPageContextSettlingSourceIndex is not null ||
_desktopPageContextSettlingTargetIndex is not null))
{
var viewportLeft = -_desktopPagesHostTransform.X;
var viewportRight = viewportLeft + _desktopSurfacePageWidth;
for (var pageIndex = 0; pageIndex < _desktopPageCount; pageIndex++)
{
var pageLeft = pageIndex * _desktopSurfacePageWidth;
var pageRight = pageLeft + _desktopSurfacePageWidth;
if (pageRight > viewportLeft + 0.5d && pageLeft < viewportRight - 0.5d)
{
activeMask |= 1 << pageIndex;
}
}
}
if (_currentDesktopSurfaceIndex >= 0 && _currentDesktopSurfaceIndex < _desktopPageCount)
{
activeMask |= 1 << _currentDesktopSurfaceIndex;
}
if (_desktopPageContextSettlingSourceIndex is int sourceIndex &&
sourceIndex >= 0 &&
sourceIndex < _desktopPageCount)
{
activeMask |= 1 << sourceIndex;
}
if (_desktopPageContextSettlingTargetIndex is int targetIndex &&
targetIndex >= 0 &&
targetIndex < _desktopPageCount)
{
activeMask |= 1 << targetIndex;
}
return activeMask;
}
private void UpdateDesktopPageAwareComponentContext()
{
var activeDesktopPageIndex = _isSettingsOpen ? -1 : _currentDesktopSurfaceIndex;
var isEditMode = _isComponentLibraryOpen || _isSettingsOpen;
var activeMask = BuildDesktopPageAwareComponentActiveMask();
var pageUpdateMask = !_desktopPageContextInitialized || isEditMode != _desktopPageContextEditMode
? _desktopPageComponentGrids.Keys.Aggregate(0, (mask, pageIndex) => mask | (1 << pageIndex))
: activeMask ^ _desktopPageContextActiveMask;
if (_desktopPageContextInitialized &&
pageUpdateMask == 0 &&
isEditMode == _desktopPageContextEditMode &&
activeMask == _desktopPageContextActiveMask)
{
return;
}
foreach (var pair in _desktopPageComponentGrids)
{
var isOnActivePage = pair.Key == activeDesktopPageIndex;
var pageBit = 1 << pair.Key;
if ((pageUpdateMask & pageBit) == 0)
{
continue;
}
var isOnActivePage = (activeMask & pageBit) != 0;
foreach (var host in pair.Value.Children.OfType<Border>())
{
if (!host.Classes.Contains(DesktopComponentHostClass))
@@ -1598,6 +1676,10 @@ public partial class MainWindow
}
}
}
_desktopPageContextInitialized = true;
_desktopPageContextEditMode = isEditMode;
_desktopPageContextActiveMask = activeMask;
}
private static void ApplyDesktopPageContext(Control root, bool isOnActivePage, bool isEditMode)
@@ -2702,6 +2784,11 @@ public partial class MainWindow
return Symbol.Apps;
}
if (string.Equals(categoryId, "File", StringComparison.OrdinalIgnoreCase))
{
return Symbol.Folder;
}
return Symbol.Apps;
}
@@ -2747,6 +2834,11 @@ public partial class MainWindow
return L("component_category.study", "Study");
}
if (string.Equals(categoryId, "File", StringComparison.OrdinalIgnoreCase))
{
return L("component_category.file", "File");
}
return categoryId;
}

View File

@@ -5,6 +5,7 @@ using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Animation;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
@@ -16,6 +17,7 @@ using Avalonia.VisualTree;
using FluentAvalonia.UI.Controls;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
using LanMountainDesktop.Theme;
namespace LanMountainDesktop.Views;
@@ -54,6 +56,8 @@ public partial class MainWindow
private int _currentDesktopSurfaceIndex;
private double _desktopSurfacePageWidth;
private TranslateTransform? _desktopPagesHostTransform;
private Transitions? _desktopPagesHostSnapTransitions;
private bool _desktopPagesHostTransitionsSuspended;
private bool _isDesktopSwipeActive;
private bool _isDesktopSwipeDirectionLocked;
private Point _desktopSwipeStartPoint;
@@ -62,6 +66,12 @@ public partial class MainWindow
private long _desktopSwipeLastTimestamp;
private double _desktopSwipeVelocityX;
private double _desktopSwipeBaseOffset;
private bool _desktopPageContextInitialized;
private bool _desktopPageContextEditMode;
private int _desktopPageContextActiveMask;
private int? _desktopPageContextSettlingSourceIndex;
private int? _desktopPageContextSettlingTargetIndex;
private int _desktopPageContextSettleRevision;
private int LauncherSurfaceIndex => Math.Max(MinDesktopPageCount, _desktopPageCount);
@@ -164,6 +174,15 @@ public partial class MainWindow
DesktopPagesHost.RenderTransform = _desktopPagesHostTransform;
}
if (_desktopPagesHostTransitionsSuspended)
{
_desktopPagesHostTransform.Transitions = null;
}
else
{
_desktopPagesHostSnapTransitions ??= _desktopPagesHostTransform.Transitions;
}
var viewportRow = gridMetrics.RowCount > 2 ? 1 : 0;
var viewportRowSpan = gridMetrics.RowCount > 2 ? gridMetrics.RowCount - 2 : 1;
var pageWidth = Math.Max(1, gridMetrics.GridWidthPx);
@@ -200,6 +219,7 @@ public partial class MainWindow
DesktopPagesContainer.Width = pageWidth * _desktopPageCount;
DesktopPagesContainer.Height = pageHeight;
_desktopPageComponentGrids.Clear();
InvalidateDesktopPageAwareComponentContextCache();
for (var index = 0; index < _desktopPageCount; index++)
{
DesktopPagesContainer.ColumnDefinitions.Add(new ColumnDefinition(new GridLength(pageWidth, GridUnitType.Pixel)));
@@ -354,6 +374,88 @@ public partial class MainWindow
UpdateDesktopPageAwareComponentContext();
}
private void SetDesktopPagesHostSnapAnimationEnabled(bool enabled)
{
if (_desktopPagesHostTransform is null)
{
return;
}
if (enabled)
{
if (!_desktopPagesHostTransitionsSuspended)
{
return;
}
_desktopPagesHostTransform.Transitions = _desktopPagesHostSnapTransitions;
_desktopPagesHostTransitionsSuspended = false;
return;
}
if (_desktopPagesHostTransitionsSuspended)
{
return;
}
_desktopPagesHostSnapTransitions ??= _desktopPagesHostTransform.Transitions;
_desktopPagesHostTransform.Transitions = null;
_desktopPagesHostTransitionsSuspended = true;
}
private void ClearDesktopPageContextSettle(bool refreshContext)
{
_desktopPageContextSettleRevision++;
_desktopPageContextSettlingSourceIndex = null;
_desktopPageContextSettlingTargetIndex = null;
if (refreshContext)
{
UpdateDesktopPageAwareComponentContext();
}
}
private void BeginDesktopPageContextSettle(int previousIndex, int targetIndex)
{
var sourceIndex = previousIndex >= 0 && previousIndex < _desktopPageCount
? previousIndex
: (int?)null;
var destinationIndex = targetIndex >= 0 && targetIndex < _desktopPageCount
? targetIndex
: (int?)null;
if (sourceIndex == destinationIndex && destinationIndex is not null)
{
ClearDesktopPageContextSettle(refreshContext: false);
return;
}
if (sourceIndex is null && destinationIndex is null)
{
ClearDesktopPageContextSettle(refreshContext: false);
return;
}
_desktopPageContextSettleRevision++;
var settleRevision = _desktopPageContextSettleRevision;
_desktopPageContextSettlingSourceIndex = sourceIndex;
_desktopPageContextSettlingTargetIndex = destinationIndex;
DispatcherTimer.RunOnce(
() =>
{
if (settleRevision != _desktopPageContextSettleRevision)
{
return;
}
_desktopPageContextSettlingSourceIndex = null;
_desktopPageContextSettlingTargetIndex = null;
UpdateDesktopPageAwareComponentContext();
},
FluttermotionToken.Page + TimeSpan.FromMilliseconds(36));
}
private void MoveSurfaceBy(int delta)
{
if (delta == 0)
@@ -373,9 +475,11 @@ public partial class MainWindow
return;
}
var previousIndex = _currentDesktopSurfaceIndex;
_currentDesktopSurfaceIndex = target;
BeginDesktopPageContextSettle(previousIndex, target);
ApplyDesktopSurfaceOffset();
PersistSettings();
SchedulePersistSettings(delayMs: Math.Max(280, (int)FluttermotionToken.Page.TotalMilliseconds + 80));
}
private bool CanSwipeDesktopSurface()
@@ -426,6 +530,7 @@ public partial class MainWindow
return;
}
ClearDesktopPageContextSettle(refreshContext: false);
_isDesktopSwipeActive = true;
_isDesktopSwipeDirectionLocked = false;
_desktopSwipeStartPoint = pointerInViewport;
@@ -603,6 +708,7 @@ public partial class MainWindow
}
_isDesktopSwipeDirectionLocked = true;
SetDesktopPagesHostSnapAnimationEnabled(enabled: false);
if (e.Pointer.Captured != DesktopPagesViewport)
{
e.Pointer.Capture(DesktopPagesViewport);
@@ -621,6 +727,7 @@ public partial class MainWindow
}
_desktopPagesHostTransform.X = tentative;
UpdateDesktopPageAwareComponentContext();
e.Handled = true;
}
@@ -656,6 +763,7 @@ public partial class MainWindow
_desktopSwipeLastTimestamp = 0;
if (wasDirectionLocked)
{
SetDesktopPagesHostSnapAnimationEnabled(enabled: true);
ApplyDesktopSurfaceOffset();
}
}
@@ -682,6 +790,8 @@ public partial class MainWindow
return false;
}
SetDesktopPagesHostSnapAnimationEnabled(enabled: true);
var deltaX = _desktopSwipeCurrentPoint.X - _desktopSwipeStartPoint.X;
var deltaY = _desktopSwipeCurrentPoint.Y - _desktopSwipeStartPoint.Y;
var absDeltaX = Math.Abs(deltaX);

View File

@@ -16,8 +16,6 @@ using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
using LanMountainDesktop.Theme;
using LanMountainDesktop.Views.Components;
using LibVLCSharp.Shared;
using LibVLCSharp.Avalonia;
namespace LanMountainDesktop.Views;
@@ -34,6 +32,11 @@ public partial class MainWindow
{
_ = sender;
if (_suppressOwnSettingsReloadCount > 0)
{
return;
}
if (e.Scope == SettingsScope.App && e.ChangedKeys is { Count: > 0 })
{
var changedKeys = e.ChangedKeys.ToArray();
@@ -213,7 +216,6 @@ public partial class MainWindow
_wallpaperType = string.IsNullOrWhiteSpace(type) ? "Image" : type.Trim();
_wallpaperPlacement = WallpaperImageBrushFactory.NormalizePlacement(placement);
_wallpaperSolidColor = TryParseColor(color, out var parsedColor) ? parsedColor : null;
_wallpaperVideoPath = null;
_wallpaperDisplayState = WallpaperDisplayState.NoWallpaperConfigured;
_wallpaperBitmap?.Dispose();
@@ -235,17 +237,6 @@ public partial class MainWindow
}
var extension = Path.GetExtension(_wallpaperPath);
var requestedTypeIsVideo = string.Equals(_wallpaperType, "Video", StringComparison.OrdinalIgnoreCase);
if (SupportedVideoExtensions.Contains(extension) || requestedTypeIsVideo)
{
_wallpaperMediaType = WallpaperMediaType.Video;
_wallpaperVideoPath = _wallpaperPath;
_wallpaperDisplayState = File.Exists(_wallpaperPath)
? WallpaperDisplayState.CurrentValidWallpaper
: WallpaperDisplayState.TemporarilyUnavailable;
return;
}
if (!SupportedImageExtensions.Contains(extension))
{
_wallpaperMediaType = WallpaperMediaType.Image;
@@ -285,7 +276,6 @@ public partial class MainWindow
if (_wallpaperMediaType == WallpaperMediaType.SolidColor && _wallpaperSolidColor.HasValue)
{
DesktopWallpaperLayer.Background = new SolidColorBrush(_wallpaperSolidColor.Value);
ApplyVideoWallpaperPosterVisibility(showPoster: false);
return;
}
@@ -296,7 +286,6 @@ public partial class MainWindow
DesktopWallpaperLayer.Background = _defaultDesktopBackground ?? CreateNeutralWallpaperFallbackBrush();
DesktopWallpaperImageLayer.Background = WallpaperImageBrushFactory.Create(_wallpaperBitmap, _wallpaperPlacement);
DesktopWallpaperImageLayer.IsVisible = true;
ApplyVideoWallpaperPosterVisibility(showPoster: false);
return;
}
@@ -308,92 +297,17 @@ public partial class MainWindow
DesktopWallpaperLayer.Background = _defaultDesktopBackground ?? CreateNeutralWallpaperFallbackBrush();
DesktopWallpaperImageLayer.Background = WallpaperImageBrushFactory.Create(_lastValidWallpaperBitmap, _wallpaperPlacement);
DesktopWallpaperImageLayer.IsVisible = true;
ApplyVideoWallpaperPosterVisibility(showPoster: false);
return;
}
DesktopWallpaperLayer.Background = _defaultDesktopBackground ?? CreateNeutralWallpaperFallbackBrush();
ApplyVideoWallpaperPosterVisibility(
showPoster: _wallpaperMediaType == WallpaperMediaType.Video && _videoWallpaperPosterBitmap is not null);
}
private void UpdateWallpaperDisplay()
{
if (_wallpaperMediaType == WallpaperMediaType.Video)
{
if (!string.IsNullOrWhiteSpace(_wallpaperVideoPath))
{
StartVideoWallpaper(_wallpaperVideoPath);
}
}
else
{
StopVideoWallpaper();
}
ApplyWallpaperBrush();
}
private void StartVideoWallpaper(string videoPath)
{
if (string.IsNullOrWhiteSpace(videoPath) || !File.Exists(videoPath))
{
ApplyVideoWallpaperPosterVisibility(showPoster: _videoWallpaperPosterBitmap is not null);
return;
}
try
{
_libVlc ??= new LibVLC();
_videoWallpaperPlayer ??= new MediaPlayer(_libVlc);
if (_videoWallpaperMedia?.Mrl != videoPath)
{
_videoWallpaperMedia?.Dispose();
_videoWallpaperMedia = new Media(_libVlc, new Uri(videoPath));
_videoWallpaperPlayer.Media = _videoWallpaperMedia;
}
if (DesktopVideoWallpaperView is { } videoView)
{
videoView.MediaPlayer = _videoWallpaperPlayer;
videoView.IsVisible = true;
}
if (!string.Equals(_videoWallpaperPosterPath, videoPath, StringComparison.OrdinalIgnoreCase))
{
ApplyVideoWallpaperPosterVisibility(showPoster: false);
}
else
{
ApplyVideoWallpaperPosterVisibility(showPoster: _videoWallpaperPosterBitmap is not null);
}
if (!_videoWallpaperPlayer.IsPlaying)
{
_videoWallpaperPlayer.Play();
}
TryCaptureVideoWallpaperPosterFrame(videoPath);
}
catch
{
ApplyVideoWallpaperPosterVisibility(showPoster: _videoWallpaperPosterBitmap is not null);
}
}
private void StopVideoWallpaper()
{
if (DesktopVideoWallpaperView is { } videoView)
{
videoView.IsVisible = false;
}
_videoWallpaperPlayer?.Stop();
_wallpaperVideoPath = null;
ApplyVideoWallpaperPosterVisibility(showPoster: false);
}
private double CalculateCurrentBackgroundLuminance()
{
var brush = DesktopWallpaperLayer.Background;
@@ -473,6 +387,7 @@ public partial class MainWindow
private void PersistSettings()
{
_persistSettingsRevision++;
if (_suppressSettingsPersistence)
{
return;
@@ -480,6 +395,8 @@ public partial class MainWindow
try
{
// Saving our own state should not trigger a full external reload cycle.
_suppressOwnSettingsReloadCount++;
_settingsService.SaveSnapshot(SettingsScope.App, BuildAppSettingsSnapshot());
_componentLayoutStore.SaveLayout(BuildDesktopLayoutSettingsSnapshot());
_settingsService.SaveSnapshot(SettingsScope.Launcher, BuildLauncherSettingsSnapshot());
@@ -488,11 +405,29 @@ public partial class MainWindow
{
AppLogger.Warn("SettingsRuntime", "Failed to persist settings.", ex);
}
finally
{
if (_suppressOwnSettingsReloadCount > 0)
{
_suppressOwnSettingsReloadCount--;
}
}
}
private void SchedulePersistSettings(int delayMs = 200)
{
DispatcherTimer.RunOnce(PersistSettings, TimeSpan.FromMilliseconds(Math.Max(0, delayMs)));
var revision = ++_persistSettingsRevision;
DispatcherTimer.RunOnce(
() =>
{
if (revision != _persistSettingsRevision)
{
return;
}
PersistSettings();
},
TimeSpan.FromMilliseconds(Math.Max(0, delayMs)));
}
internal void ReloadFromPersistedSettings()
@@ -564,6 +499,7 @@ public partial class MainWindow
var latestWeatherState = _weatherSettingsService.Get();
var latestUpdateState = _updateSettingsService.Get();
var latestThemeState = _themeSettingsService.Get();
var latestPrivacyState = _settingsFacade.Privacy.Get();
return new AppSettingsSnapshot
{
GridShortSideCells = _targetShortSideCells,
@@ -595,7 +531,8 @@ public partial class MainWindow
WeatherNoTlsRequests = latestWeatherState.NoTlsRequests,
AutoStartWithWindows = _autoStartWithWindows,
AppRenderMode = _selectedAppRenderMode,
AutoCheckUpdates = latestUpdateState.AutoCheckUpdates,
UploadAnonymousCrashData = latestPrivacyState.UploadAnonymousCrashData,
UploadAnonymousUsageData = latestPrivacyState.UploadAnonymousUsageData,
IncludePrereleaseUpdates = latestUpdateState.IncludePrereleaseUpdates,
UpdateChannel = latestUpdateState.UpdateChannel,
UpdateMode = latestUpdateState.UpdateMode,
@@ -610,6 +547,7 @@ public partial class MainWindow
EnableDynamicTaskbarActions = _enableDynamicTaskbarActions,
TaskbarLayoutMode = _taskbarLayoutMode,
ClockDisplayFormat = _clockDisplayFormat == ClockDisplayFormat.HourMinute ? "HourMinute" : "HourMinuteSecond",
StatusBarClockTransparentBackground = _statusBarClockTransparentBackground,
StatusBarSpacingMode = _statusBarSpacingMode,
StatusBarCustomSpacingPercent = _statusBarCustomSpacingPercent
};
@@ -644,112 +582,6 @@ public partial class MainWindow
}
}
private void ApplyVideoWallpaperPosterVisibility(bool showPoster)
{
if (DesktopVideoWallpaperImage is not { } posterImage)
{
return;
}
if (!showPoster ||
_videoWallpaperPosterBitmap is null ||
!string.Equals(_videoWallpaperPosterPath, _wallpaperVideoPath, StringComparison.OrdinalIgnoreCase))
{
posterImage.IsVisible = false;
return;
}
posterImage.Source = _videoWallpaperPosterBitmap;
posterImage.IsVisible = true;
}
private void TryCaptureVideoWallpaperPosterFrame(string videoPath)
{
if (_videoWallpaperPlayer is null || string.IsNullOrWhiteSpace(videoPath))
{
return;
}
_ = Task.Run(async () =>
{
var snapshotPath = Path.Combine(
Path.GetTempPath(),
$"lanmountaindesktop-wallpaper-poster-{Guid.NewGuid():N}.png");
try
{
for (var attempt = 0; attempt < 12; attempt++)
{
await Task.Delay(250).ConfigureAwait(false);
if (_wallpaperMediaType != WallpaperMediaType.Video ||
!string.Equals(_wallpaperVideoPath, videoPath, StringComparison.OrdinalIgnoreCase) ||
_videoWallpaperPlayer is null)
{
return;
}
if (!_videoWallpaperPlayer.TakeSnapshot(0, snapshotPath, 640, 360))
{
continue;
}
if (!File.Exists(snapshotPath))
{
continue;
}
var fileInfo = new FileInfo(snapshotPath);
if (fileInfo.Length <= 0)
{
continue;
}
Bitmap posterBitmap;
await using (var stream = File.OpenRead(snapshotPath))
{
posterBitmap = new Bitmap(stream);
}
await Dispatcher.UIThread.InvokeAsync(() =>
{
if (_wallpaperMediaType != WallpaperMediaType.Video ||
!string.Equals(_wallpaperVideoPath, videoPath, StringComparison.OrdinalIgnoreCase))
{
posterBitmap.Dispose();
return;
}
_videoWallpaperPosterBitmap?.Dispose();
_videoWallpaperPosterBitmap = posterBitmap;
_videoWallpaperPosterPath = videoPath;
ApplyVideoWallpaperPosterVisibility(showPoster: true);
});
return;
}
}
catch
{
// Best effort poster capture only.
}
finally
{
try
{
if (File.Exists(snapshotPath))
{
File.Delete(snapshotPath);
}
}
catch
{
// Best effort cleanup only.
}
}
});
}
private DesktopLayoutSettingsSnapshot BuildDesktopLayoutSettingsSnapshot()
{
return new DesktopLayoutSettingsSnapshot

View File

@@ -6,7 +6,7 @@
xmlns:ic="using:FluentIcons.Avalonia.Fluent"
xmlns:mi="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
xmlns:comp="using:LanMountainDesktop.Views.Components"
xmlns:vlc="clr-namespace:LibVLCSharp.Avalonia;assembly=LibVLCSharp.Avalonia"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
@@ -123,18 +123,7 @@
VerticalAlignment="Stretch"
Background="Transparent" />
<Image x:Name="DesktopVideoWallpaperImage"
IsVisible="False"
IsHitTestVisible="False"
Stretch="UniformToFill"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" />
<vlc:VideoView x:Name="DesktopVideoWallpaperView"
IsVisible="False"
IsHitTestVisible="False"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" />
<Grid x:Name="DesktopGrid"
HorizontalAlignment="Center"
@@ -156,7 +145,9 @@
<TranslateTransform>
<TranslateTransform.Transitions>
<Transitions>
<DoubleTransition Property="X" Duration="{StaticResource FluttermotionToken.Duration.Page}" />
<DoubleTransition Property="X"
Duration="{StaticResource FluttermotionToken.Duration.Page}"
Easing="0.22,1,0.36,1" />
</Transitions>
</TranslateTransform.Transitions>
</TranslateTransform>

View File

@@ -25,7 +25,7 @@ using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.Theme;
using LanMountainDesktop.Views.Components;
using LibVLCSharp.Shared;
namespace LanMountainDesktop.Views;
@@ -35,7 +35,6 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
{
None,
Image,
Video,
SolidColor
}
@@ -65,10 +64,6 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
{
".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp"
};
private static readonly HashSet<string> SupportedVideoExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".mp4", ".mkv", ".webm", ".avi", ".mov", ".m4v"
};
private static readonly TaskbarActionId[] DefaultPinnedTaskbarActions =
[
TaskbarActionId.MinimizeToWindows
@@ -120,30 +115,11 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
private Bitmap? _wallpaperBitmap;
private Bitmap? _lastValidWallpaperBitmap;
private string? _lastValidWallpaperPath;
private Bitmap? _videoWallpaperPosterBitmap;
private string? _videoWallpaperPosterPath;
private WallpaperMediaType _wallpaperMediaType;
private WallpaperDisplayState _wallpaperDisplayState = WallpaperDisplayState.NoWallpaperConfigured;
private string _wallpaperPlacement = WallpaperImageBrushFactory.Fill;
private string? _wallpaperVideoPath;
private string _wallpaperType = "Image";
private Color? _wallpaperSolidColor;
private LibVLC? _libVlc;
private MediaPlayer? _videoWallpaperPlayer;
private Media? _videoWallpaperMedia;
private readonly object _desktopVideoFrameSync = new();
private MediaPlayer.LibVLCVideoLockCb? _desktopVideoLockCallback;
private MediaPlayer.LibVLCVideoUnlockCb? _desktopVideoUnlockCallback;
private MediaPlayer.LibVLCVideoDisplayCb? _desktopVideoDisplayCallback;
private DispatcherTimer? _desktopVideoFrameRefreshTimer;
private IntPtr _desktopVideoFrameBufferPtr;
private byte[]? _desktopVideoStagingBuffer;
private WriteableBitmap? _desktopVideoBitmap;
private int _desktopVideoFrameWidth;
private int _desktopVideoFrameHeight;
private int _desktopVideoFramePitch;
private int _desktopVideoFrameBufferSize;
private int _desktopVideoFrameDirtyFlag;
private string? _wallpaperPath;
private string _wallpaperStatus = "Current background uses solid color.";
private IReadOnlyList<Color> _recommendedColors = Array.Empty<Color>();
@@ -155,6 +131,7 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
private string _gridSpacingPreset = "Relaxed";
private string _statusBarSpacingMode = "Relaxed";
private int _statusBarCustomSpacingPercent = 12;
private bool _statusBarClockTransparentBackground;
private int _desktopEdgeInsetPercent = DefaultEdgeInsetPercent;
private string _taskbarLayoutMode = TaskbarLayoutBottomFullRowMacStyle;
private string _languageCode = "zh-CN";
@@ -177,6 +154,8 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
private bool _isWeatherPreviewInProgress;
private ClockDisplayFormat _clockDisplayFormat = ClockDisplayFormat.HourMinuteSecond;
private bool _externalSettingsReloadPending;
private int _persistSettingsRevision;
private int _suppressOwnSettingsReloadCount;
private double CurrentDesktopPitch => _currentDesktopCellSize + _currentDesktopCellGap;
public MainWindow()
@@ -333,21 +312,9 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
_detachedComponentLibraryWindow.Close();
}
_detachedComponentLibraryWindow = null;
StopVideoWallpaper();
DisposeLauncherResources();
_videoWallpaperMedia?.Dispose();
_videoWallpaperMedia = null;
_videoWallpaperPlayer?.Dispose();
_videoWallpaperPlayer = null;
_desktopVideoFrameRefreshTimer?.Stop();
_desktopVideoFrameRefreshTimer = null;
_videoWallpaperPosterBitmap?.Dispose();
_videoWallpaperPosterBitmap = null;
_videoWallpaperPosterPath = null;
_lastValidWallpaperBitmap?.Dispose();
_lastValidWallpaperBitmap = null;
_libVlc?.Dispose();
_libVlc = null;
if (_recommendationInfoService is IDisposable recommendationServiceDisposable)
{
recommendationServiceDisposable.Dispose();

View File

@@ -16,8 +16,11 @@
<ui:SettingsExpander.Footer>
<Grid ColumnDefinitions="*,Auto"
ColumnSpacing="12">
<TextBox Text="{Binding SearchText}"
Watermark="{Binding SearchPlaceholder}" />
<TextBox x:Name="SearchTextBox"
Text="{Binding SearchText}"
Watermark="{Binding SearchPlaceholder}"
Focusable="True"
IsTabStop="True" />
<Button Grid.Column="1"
Command="{Binding RefreshCommand}"
Content="{Binding RefreshButtonText}" />

View File

@@ -0,0 +1,55 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:LanMountainDesktop.ViewModels"
xmlns:mdxaml="https://github.com/whistyun/Markdown.Avalonia"
xmlns:helpers="using:LanMountainDesktop.Helpers"
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
x:Class="LanMountainDesktop.Views.SettingsPages.PrivacyPolicyDrawer"
x:DataType="vm:PrivacyPolicyViewModel">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Classes="settings-page-container"
Margin="0,0,0,8">
<Border Classes="settings-section-card">
<StackPanel Spacing="12">
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="12">
<Border Classes="settings-section-card-icon-host"
Width="48"
Height="48">
<Viewbox Stretch="Uniform">
<fi:SymbolIcon Symbol="Document" />
</Viewbox>
</Border>
<StackPanel Grid.Column="1"
Spacing="4"
VerticalAlignment="Center">
<TextBlock Classes="settings-card-header"
Margin="0"
Text="{Binding Title}" />
<TextBlock Classes="settings-item-description"
Text="{Binding Description}" />
</StackPanel>
</Grid>
</StackPanel>
</Border>
<Border Classes="settings-section-card">
<StackPanel Spacing="12">
<TextBlock Classes="settings-item-description"
IsVisible="{Binding IsLoading}"
Text="{Binding LoadingText}" />
<TextBlock Classes="settings-item-description"
IsVisible="{Binding HasError}"
Text="{Binding ErrorText}"
TextWrapping="Wrap" />
<mdxaml:MarkdownScrollViewer IsVisible="{Binding HasContent}"
Markdown="{Binding MarkdownContent}"
Engine="{x:Static helpers:PluginMarketMarkdownHelper.Engine}" />
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</UserControl>

View File

@@ -0,0 +1,17 @@
using Avalonia.Controls;
using LanMountainDesktop.ViewModels;
namespace LanMountainDesktop.Views.SettingsPages;
public partial class PrivacyPolicyDrawer : UserControl
{
public PrivacyPolicyDrawer()
{
InitializeComponent();
}
public PrivacyPolicyDrawer(PrivacyPolicyViewModel viewModel) : this()
{
DataContext = viewModel;
}
}

View File

@@ -45,10 +45,13 @@
FontSize="12"
Opacity="0.7"
Margin="0,4,0,8" />
<TextBox Text="{Binding DeviceId}"
<TextBox x:Name="DeviceIdTextBox"
Text="{Binding DeviceId}"
IsReadOnly="True"
FontFamily="Consolas"
FontSize="12" />
FontSize="12"
Focusable="False"
IsTabStop="False" />
</StackPanel>
<Button Grid.Column="1"
Content="{Binding RefreshDeviceIdText}"
@@ -58,6 +61,28 @@
Classes="accent-button" />
</Grid>
</Border>
<StackPanel Orientation="Horizontal"
Margin="0,16,0,0"
Spacing="4">
<TextBlock Text="{Binding PrivacyPolicyHintPrefix}"
FontSize="13"
VerticalAlignment="Center" />
<Button Content="{Binding ViewPrivacyPolicyText}"
Command="{Binding ViewPrivacyPolicyCommand}"
Background="Transparent"
BorderThickness="0"
Padding="0"
FontSize="13"
Foreground="{DynamicResource SystemAccentColor}"
Cursor="Hand">
<Button.Styles>
<Style Selector="Button:pointerover /template/ ContentPresenter">
<Setter Property="TextBlock.Foreground" Value="{DynamicResource SystemAccentColorDark1}" />
</Style>
</Button.Styles>
</Button>
</StackPanel>
</StackPanel>
</ScrollViewer>
</UserControl>

View File

@@ -22,9 +22,17 @@ public partial class PrivacySettingsPage : SettingsPageBase
public PrivacySettingsPage(PrivacySettingsPageViewModel viewModel)
{
ViewModel = viewModel;
ViewModel.ViewPrivacyPolicyRequested += OnViewPrivacyPolicyRequested;
DataContext = ViewModel;
InitializeComponent();
}
public PrivacySettingsPageViewModel ViewModel { get; }
private void OnViewPrivacyPolicyRequested()
{
var privacyPolicyViewModel = new PrivacyPolicyViewModel();
var drawer = new PrivacyPolicyDrawer(privacyPolicyViewModel);
OpenDrawer(drawer, privacyPolicyViewModel.Title);
}
}

View File

@@ -38,6 +38,20 @@
</ComboBox>
</Grid>
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem>
<Grid ColumnDefinitions="*,Auto"
ColumnSpacing="16">
<StackPanel Spacing="2">
<TextBlock Text="{Binding ClockTransparentBackgroundLabel}" />
<TextBlock Text="{Binding ClockTransparentBackgroundDescription}"
Opacity="0.75"
TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch Grid.Column="1"
IsChecked="{Binding ClockTransparentBackground}"
VerticalAlignment="Center" />
</Grid>
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
<Separator Classes="settings-separator" />

View File

@@ -21,12 +21,17 @@
<Setter Property="FontSize" Value="12" />
<Setter Property="Opacity" Value="0.68" />
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
<Setter Property="TextWrapping" Value="Wrap" />
<Setter Property="MaxWidth" Value="200" />
</Style>
<Style Selector="TextBlock.update-kv-value">
<Setter Property="FontSize" Value="14" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
<Setter Property="TextWrapping" Value="Wrap" />
<Setter Property="MaxWidth" Value="200" />
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
</Style>
</UserControl.Styles>
@@ -64,10 +69,12 @@
Content="{Binding CheckForUpdatesButtonText}" />
</Grid>
<Grid ColumnDefinitions="*,*"
ColumnSpacing="14"
RowSpacing="12">
<StackPanel Grid.Column="0"
<Grid ColumnDefinitions="Auto,*"
RowDefinitions="Auto,Auto"
ColumnSpacing="20"
RowSpacing="16">
<StackPanel Grid.Row="0"
Grid.Column="0"
Spacing="4">
<TextBlock Classes="update-kv-label"
Text="{Binding CurrentVersionLabel}" />
@@ -75,7 +82,8 @@
Text="{Binding CurrentVersionText}" />
</StackPanel>
<StackPanel Grid.Column="1"
<StackPanel Grid.Row="0"
Grid.Column="1"
Spacing="4"
IsVisible="{Binding IsLatestVersionVisible}">
<TextBlock Classes="update-kv-label"
@@ -105,18 +113,27 @@
</StackPanel>
</Grid>
<StackPanel Spacing="8">
<StackPanel Spacing="12"
HorizontalAlignment="Left">
<TextBlock Classes="settings-item-description"
Text="{Binding UpdateStatus}" />
Text="{Binding UpdateStatus}"
TextWrapping="Wrap"
HorizontalAlignment="Left"
MaxWidth="500" />
<ProgressBar Minimum="0"
Maximum="100"
Value="{Binding DownloadProgressValue}"
IsVisible="{Binding IsDownloadProgressVisible}" />
IsVisible="{Binding IsDownloadProgressVisible}"
HorizontalAlignment="Stretch"
Margin="0,4,0,4" />
<TextBlock Classes="settings-item-description"
IsVisible="{Binding IsDownloadProgressVisible}"
Text="{Binding DownloadProgressText}" />
Text="{Binding DownloadProgressText}"
TextWrapping="Wrap"
HorizontalAlignment="Left"
Margin="0,4,0,0" />
</StackPanel>
<StackPanel Orientation="Horizontal"
@@ -210,15 +227,7 @@
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander Classes="settings-expander-card"
Header="{Binding AutoCheckUpdatesLabel}">
<ui:SettingsExpander.IconSource>
<fi:SymbolIconSource Symbol="ClockAlarm" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding AutoCheckUpdates}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</StackPanel>
</ScrollViewer>
</UserControl>

View File

@@ -21,27 +21,11 @@
CornerRadius="48"
BoxShadow="0 12 32 #50000000">
<Panel Background="{DynamicResource AdaptiveSurfaceBaseBrush}" Margin="2">
<!-- 图片/视频预览 -->
<!-- 图片预览 -->
<Border Background="#FFF6F7F9"
IsVisible="{Binding IsImage}">
<Border Background="{Binding PreviewBrush}" />
</Border>
<Border Background="#FFF6F7F9"
IsVisible="{Binding IsVideo}">
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="12">
<fi:FluentIcon Icon="Video"
Width="72"
Height="72"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
<TextBlock Text="{Binding VideoModeHintText}"
Width="300"
TextAlignment="Center"
TextWrapping="Wrap"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
</StackPanel>
</Border>
<!-- 纯色预览 -->
<Border Background="{Binding SelectedColor}"
IsVisible="{Binding IsSolidColor}" />
@@ -52,31 +36,104 @@
<!-- 右侧:颜色选择网格 -->
<StackPanel Grid.Column="1" VerticalAlignment="Center" Spacing="12" IsVisible="{Binding IsSolidColor}">
<TextBlock Text="{Binding WallpaperColorLabel}"
FontSize="14"
FontWeight="SemiBold"
<TextBlock Text="{Binding WallpaperColorLabel}"
FontSize="14"
FontWeight="SemiBold"
Opacity="0.8" />
<ItemsControl ItemsSource="{Binding PresetColors}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<UniformGrid Columns="4" Rows="3" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Button Width="48" Height="48" Margin="4" Padding="0"
Background="{Binding}"
BorderThickness="0"
CornerRadius="6"
Command="{Binding $parent[UserControl].((vm:WallpaperSettingsPageViewModel)DataContext).SelectColorCommand}"
CommandParameter="{Binding}"
ToolTip.Tip="{Binding}">
<!-- 简单的悬停与选中效果由 Button 默认样式提供,
由于使用了低饱和度颜色,背景色本身就很柔和 -->
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<UniformGrid Columns="4" Rows="3">
<!-- 预设颜色 1-11 -->
<Button Width="48" Height="48" Margin="4" Padding="0"
Background="#D8A7B1"
BorderThickness="0"
CornerRadius="6"
Command="{Binding SelectColorCommand}"
CommandParameter="#D8A7B1"
ToolTip.Tip="#D8A7B1" />
<Button Width="48" Height="48" Margin="4" Padding="0"
Background="#B6C9BB"
BorderThickness="0"
CornerRadius="6"
Command="{Binding SelectColorCommand}"
CommandParameter="#B6C9BB"
ToolTip.Tip="#B6C9BB" />
<Button Width="48" Height="48" Margin="4" Padding="0"
Background="#A2B5BB"
BorderThickness="0"
CornerRadius="6"
Command="{Binding SelectColorCommand}"
CommandParameter="#A2B5BB"
ToolTip.Tip="#A2B5BB" />
<Button Width="48" Height="48" Margin="4" Padding="0"
Background="#E6E2D3"
BorderThickness="0"
CornerRadius="6"
Command="{Binding SelectColorCommand}"
CommandParameter="#E6E2D3"
ToolTip.Tip="#E6E2D3" />
<Button Width="48" Height="48" Margin="4" Padding="0"
Background="#B5A397"
BorderThickness="0"
CornerRadius="6"
Command="{Binding SelectColorCommand}"
CommandParameter="#B5A397"
ToolTip.Tip="#B5A397" />
<Button Width="48" Height="48" Margin="4" Padding="0"
Background="#C5C1C0"
BorderThickness="0"
CornerRadius="6"
Command="{Binding SelectColorCommand}"
CommandParameter="#C5C1C0"
ToolTip.Tip="#C5C1C0" />
<Button Width="48" Height="48" Margin="4" Padding="0"
Background="#D4BE8D"
BorderThickness="0"
CornerRadius="6"
Command="{Binding SelectColorCommand}"
CommandParameter="#D4BE8D"
ToolTip.Tip="#D4BE8D" />
<Button Width="48" Height="48" Margin="4" Padding="0"
Background="#C08261"
BorderThickness="0"
CornerRadius="6"
Command="{Binding SelectColorCommand}"
CommandParameter="#C08261"
ToolTip.Tip="#C08261" />
<Button Width="48" Height="48" Margin="4" Padding="0"
Background="#8E9775"
BorderThickness="0"
CornerRadius="6"
Command="{Binding SelectColorCommand}"
CommandParameter="#8E9775"
ToolTip.Tip="#8E9775" />
<Button Width="48" Height="48" Margin="4" Padding="0"
Background="#9FBAD3"
BorderThickness="0"
CornerRadius="6"
Command="{Binding SelectColorCommand}"
CommandParameter="#9FBAD3"
ToolTip.Tip="#9FBAD3" />
<Button Width="48" Height="48" Margin="4" Padding="0"
Background="#E5BAA2"
BorderThickness="0"
CornerRadius="6"
Command="{Binding SelectColorCommand}"
CommandParameter="#E5BAA2"
ToolTip.Tip="#E5BAA2" />
<!-- 第12个位置自定义颜色选择器 -->
<Button Width="48" Height="48" Margin="4" Padding="0"
Background="{Binding CustomColorBrush}"
BorderThickness="0"
CornerRadius="6"
ToolTip.Tip="自定义颜色">
<Button.Flyout>
<Flyout Placement="BottomEdgeAlignedLeft">
<StackPanel Width="260" Spacing="12">
<ColorPicker Color="{Binding CustomColor}" />
</StackPanel>
</Flyout>
</Button.Flyout>
</Button>
</UniformGrid>
</StackPanel>
</Grid>
@@ -105,7 +162,7 @@
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<!-- 图片/视频文件选择 -->
<!-- 图片文件选择 -->
<ui:SettingsExpander Header="{Binding WallpaperPathLabel}"
IsVisible="{Binding IsImage}"
Margin="0,4,0,0">
@@ -129,7 +186,7 @@
<!-- 填充方式 -->
<ui:SettingsExpander Header="{Binding WallpaperPlacementLabel}"
Description="{Binding WallpaperPlacementDescription}"
IsVisible="{Binding IsImageOrVideo}"
IsVisible="{Binding IsImage}"
Margin="0,4,0,0">
<ui:SettingsExpander.IconSource>
<fi:SymbolIconSource Symbol="Maximize" />
@@ -147,12 +204,6 @@
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<TextBlock Margin="0,8,0,0"
IsVisible="{Binding IsVideo}"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Text="{Binding VideoModeHintText}"
TextWrapping="Wrap" />
</StackPanel>
</ScrollViewer>
</UserControl>

View File

@@ -96,8 +96,11 @@
<StackPanel Spacing="14">
<Grid ColumnDefinitions="*,Auto"
ColumnSpacing="12">
<TextBox Text="{Binding SearchKeyword}"
Watermark="{Binding SearchPlaceholder}" />
<TextBox x:Name="SearchKeywordTextBox"
Text="{Binding SearchKeyword}"
Watermark="{Binding SearchPlaceholder}"
Focusable="True"
IsTabStop="True" />
<Button Grid.Column="1"
Command="{Binding SearchCommand}"
Content="{Binding SearchButtonText}" />
@@ -178,12 +181,18 @@
</Grid>
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem>
<TextBox Text="{Binding LocationKey}"
Watermark="{Binding LocationKeyPlaceholder}" />
<TextBox x:Name="LocationKeyTextBox"
Text="{Binding LocationKey}"
Watermark="{Binding LocationKeyPlaceholder}"
Focusable="True"
IsTabStop="True" />
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem>
<TextBox Text="{Binding LocationName}"
Watermark="{Binding LocationNamePlaceholder}" />
<TextBox x:Name="LocationNameTextBox"
Text="{Binding LocationName}"
Watermark="{Binding LocationNamePlaceholder}"
Focusable="True"
IsTabStop="True" />
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
@@ -230,11 +239,14 @@
<fi:SymbolIconSource Symbol="Warning" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<TextBox Width="360"
<TextBox x:Name="ExcludedAlertsTextBox"
Width="360"
MinHeight="120"
AcceptsReturn="True"
TextWrapping="Wrap"
Text="{Binding ExcludedAlerts}" />
Text="{Binding ExcludedAlerts}"
Focusable="True"
IsTabStop="True" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>

View File

@@ -5,6 +5,7 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>1.0.0</Version>
</PropertyGroup>
</Project>