mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
298defb829 | ||
|
|
bcf4be6d50 | ||
|
|
6c9f6be1b1 |
24
.trae/specs/class-schedule-enhancement/checklist.md
Normal file
24
.trae/specs/class-schedule-enhancement/checklist.md
Normal 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] 当前课程变化时自动复位到最新进行中课程
|
||||||
101
.trae/specs/class-schedule-enhancement/spec.md
Normal file
101
.trae/specs/class-schedule-enhancement/spec.md
Normal 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
|
||||||
|
|
||||||
|
(无)
|
||||||
61
.trae/specs/class-schedule-enhancement/tasks.md
Normal file
61
.trae/specs/class-schedule-enhancement/tasks.md
Normal 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 完成后进行
|
||||||
@@ -1,21 +1,33 @@
|
|||||||
# LanAirApp
|
# LanAirApp (Mirror)
|
||||||
|
|
||||||
## 中文
|
## 中文
|
||||||
|
|
||||||
`LanAirApp` 是阑山桌面插件生态的对外工作区。这个目录是宿主仓库中的镜像副本,权威版本以独立 `LanAirApp` 仓库为准。
|
这里的 `LanAirApp/` 是放在宿主仓库里的镜像副本,只用于本地联调和工作区构建,不是插件市场或插件开发资料的最终权威来源。
|
||||||
|
|
||||||
### 目录说明
|
### 这份镜像的角色
|
||||||
|
|
||||||
- `docs/`:插件开发与打包文档。
|
- 提供本地工作区里的 `airappmarket` 索引副本
|
||||||
- `samples/`:示例插件与参考项目。
|
- 提供插件文档、工具和样例镜像,便于和宿主一起联调
|
||||||
- `standards/`:插件清单和目录结构约定。
|
- 不承担宿主运行时职责
|
||||||
- `tools/`:插件打包与辅助工具。
|
|
||||||
|
|
||||||
### 与宿主的关系
|
### 权威来源
|
||||||
|
|
||||||
- 宿主程序只连接独立 `LanAirApp` 仓库中的官方市场索引。
|
- 插件市场与开发文档:独立 `LanAirApp` 仓库
|
||||||
- 每个插件项目应在仓库根目录提供 `.laapp` 和 `README.md`。
|
- 权威示例插件:独立 `LanMountainDesktop.SamplePlugin`
|
||||||
|
- 本目录中的 `samples/LanMountainDesktop.SamplePlugin` 只是镜像模板副本
|
||||||
|
|
||||||
## English
|
## English
|
||||||
|
|
||||||
`LanAirApp` is the external-facing workspace for the LanMountainDesktop plugin ecosystem. This copy is only a mirror inside the host repository; the standalone `LanAirApp` repository remains the source of truth.
|
This `LanAirApp/` directory is a mirror that lives inside the host repository. It exists for local workspace integration and build convenience only. It is not the final authority for the plugin market or developer-facing plugin materials.
|
||||||
|
|
||||||
|
### Role of this mirror
|
||||||
|
|
||||||
|
- keep a local copy of the `airappmarket` index for workspace integration
|
||||||
|
- keep mirrored docs, tools, and sample templates for local development
|
||||||
|
- avoid duplicating host runtime responsibilities
|
||||||
|
|
||||||
|
### Sources of truth
|
||||||
|
|
||||||
|
- Plugin market and developer docs: standalone `LanAirApp`
|
||||||
|
- Authoritative sample plugin: standalone `LanMountainDesktop.SamplePlugin`
|
||||||
|
- `samples/LanMountainDesktop.SamplePlugin` in this mirror is template/mirror content only
|
||||||
|
|||||||
@@ -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 控件残留
|
||||||
@@ -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** 壁纸设置功能正常工作(仅支持图片和纯色)
|
||||||
@@ -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
|
||||||
|
- 修改填充方式可见性绑定
|
||||||
|
|
||||||
|
### 验收标准
|
||||||
|
|
||||||
|
- [ ] 无视频预览 Border(IsVisible="{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
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
namespace LanMountainDesktop.PluginSdk;
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
[Obsolete("Plugin API 2.0.0 uses IPluginRuntimeContext and IServiceCollection-based initialization.")]
|
[Obsolete("Plugin API 3.0.0 uses IPluginRuntimeContext and IServiceCollection-based initialization.")]
|
||||||
public interface IPluginContext : IPluginRuntimeContext
|
public interface IPluginContext : IPluginRuntimeContext
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<Version>2.0.0</Version>
|
<Version>3.0.0</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ public partial class App : Application
|
|||||||
private bool _uiUnhandledExceptionHooked;
|
private bool _uiUnhandledExceptionHooked;
|
||||||
|
|
||||||
internal static SingleInstanceService? CurrentSingleInstanceService { get; set; }
|
internal static SingleInstanceService? CurrentSingleInstanceService { get; set; }
|
||||||
|
internal static (UserBehaviorAnalyticsService?, CrashReportService?) AnalyticsServices { get; set; }
|
||||||
internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle =>
|
internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle =>
|
||||||
(Current as App)?._hostApplicationLifecycle;
|
(Current as App)?._hostApplicationLifecycle;
|
||||||
|
|
||||||
@@ -569,6 +570,18 @@ public partial class App : Application
|
|||||||
_exitCleanupCompleted = true;
|
_exitCleanupCompleted = true;
|
||||||
_settingsFacade.Settings.Changed -= OnSettingsChanged;
|
_settingsFacade.Settings.Changed -= OnSettingsChanged;
|
||||||
_appearanceThemeService.Changed -= OnAppearanceThemeChanged;
|
_appearanceThemeService.Changed -= OnAppearanceThemeChanged;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (analytics, crashReport) = App.AnalyticsServices;
|
||||||
|
analytics?.SendShutdownEvent();
|
||||||
|
crashReport?.SendShutdownEvent();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("Analytics", "Failed to send shutdown events during exit cleanup.", ex);
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
HostUpdateWorkflowServiceProvider.GetOrCreate().TryApplyPendingUpdateOnExit();
|
HostUpdateWorkflowServiceProvider.GetOrCreate().TryApplyPendingUpdateOnExit();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace LanMountainDesktop.ComponentSystem;
|
namespace LanMountainDesktop.ComponentSystem;
|
||||||
|
|
||||||
public static class BuiltInComponentIds
|
public static class BuiltInComponentIds
|
||||||
{
|
{
|
||||||
@@ -40,4 +40,5 @@ public static class BuiltInComponentIds
|
|||||||
public const string DesktopWhiteboard = "DesktopWhiteboard";
|
public const string DesktopWhiteboard = "DesktopWhiteboard";
|
||||||
public const string DesktopBlackboardLandscape = "DesktopBlackboardLandscape";
|
public const string DesktopBlackboardLandscape = "DesktopBlackboardLandscape";
|
||||||
public const string DesktopBrowser = "DesktopBrowser";
|
public const string DesktopBrowser = "DesktopBrowser";
|
||||||
|
public const string DesktopOfficeRecentDocuments = "DesktopOfficeRecentDocuments";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using LanMountainDesktop.ComponentSystem.Extensions;
|
using LanMountainDesktop.ComponentSystem.Extensions;
|
||||||
@@ -327,6 +327,15 @@ public sealed class ComponentRegistry
|
|||||||
AllowStatusBarPlacement: false,
|
AllowStatusBarPlacement: false,
|
||||||
AllowDesktopPlacement: true,
|
AllowDesktopPlacement: true,
|
||||||
ResizeMode: DesktopComponentResizeMode.Free),
|
ResizeMode: DesktopComponentResizeMode.Free),
|
||||||
|
new DesktopComponentDefinition(
|
||||||
|
BuiltInComponentIds.DesktopOfficeRecentDocuments,
|
||||||
|
"Office Recent Documents",
|
||||||
|
"Folder",
|
||||||
|
"File",
|
||||||
|
MinWidthCells: 4,
|
||||||
|
MinHeightCells: 2,
|
||||||
|
AllowStatusBarPlacement: false,
|
||||||
|
AllowDesktopPlacement: true),
|
||||||
new DesktopComponentDefinition(
|
new DesktopComponentDefinition(
|
||||||
BuiltInComponentIds.Date,
|
BuiltInComponentIds.Date,
|
||||||
"Calendar",
|
"Calendar",
|
||||||
|
|||||||
@@ -27,8 +27,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||||
<ProjectReference Include="..\LanMountainDesktop.PluginsInstallHelper\LanMountainDesktop.PluginsInstallHelper.csproj"
|
<ProjectReference Include="..\LanMountainDesktop.PluginsInstallHelper\LanMountainDesktop.PluginsInstallHelper.csproj" ReferenceOutputAssembly="false" />
|
||||||
ReferenceOutputAssembly="false" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -53,13 +52,14 @@
|
|||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" 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="Microsoft.Data.Sqlite" Version="10.0.0" />
|
||||||
<PackageReference Include="LibVLCSharp.Avalonia" Version="3.9.5" />
|
|
||||||
<PackageReference Include="PortAudioSharp2" Version="1.0.6" />
|
<PackageReference Include="PortAudioSharp2" Version="1.0.6" />
|
||||||
<PackageReference Include="MaterialColorUtilities" Version="0.3.0" />
|
<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.Runtime.WindowsRuntime" Version="4.7.0" />
|
||||||
<PackageReference Include="System.Drawing.Common" Version="10.0.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')))
 or '$(RuntimeIdentifier)' == 'win-x64'
 or '$(RuntimeIdentifier)' == 'win-x86'" />
|
|
||||||
<PackageReference Include="VideoLAN.LibVLC.Mac" Version="3.1.3.1" Condition="('$(RuntimeIdentifier)' == '' and $([MSBuild]::IsOSPlatform('OSX')))
 or '$(RuntimeIdentifier)' == 'osx-x64'" />
|
|
||||||
<PackageReference Include="WebView.Avalonia" Version="11.0.0.1" />
|
<PackageReference Include="WebView.Avalonia" Version="11.0.0.1" />
|
||||||
<PackageReference Include="WebView.Avalonia.Desktop" Version="11.0.0.1" />
|
<PackageReference Include="WebView.Avalonia.Desktop" Version="11.0.0.1" />
|
||||||
<PackageReference Include="YamlDotNet" Version="16.3.0" />
|
<PackageReference Include="YamlDotNet" Version="16.3.0" />
|
||||||
@@ -69,17 +69,13 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PluginsInstallHelperFiles Include="..\LanMountainDesktop.PluginsInstallHelper\bin\$(Configuration)\net10.0\**\*.*" />
|
<PluginsInstallHelperFiles Include="..\LanMountainDesktop.PluginsInstallHelper\bin\$(Configuration)\net10.0\**\*.*" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<Copy SourceFiles="@(PluginsInstallHelperFiles)"
|
<Copy SourceFiles="@(PluginsInstallHelperFiles)" DestinationFiles="@(PluginsInstallHelperFiles->'$(OutDir)PluginsInstallHelper\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
|
||||||
DestinationFiles="@(PluginsInstallHelperFiles->'$(OutDir)PluginsInstallHelper\%(RecursiveDir)%(Filename)%(Extension)')"
|
|
||||||
SkipUnchangedFiles="true" />
|
|
||||||
</Target>
|
</Target>
|
||||||
|
|
||||||
<Target Name="CopyPluginsInstallHelperToPublish" AfterTargets="Publish" Condition="'$(PublishDir)' != ''">
|
<Target Name="CopyPluginsInstallHelperToPublish" AfterTargets="Publish" Condition="'$(PublishDir)' != ''">
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PluginsInstallHelperPublishFiles Include="..\LanMountainDesktop.PluginsInstallHelper\bin\$(Configuration)\net10.0\**\*.*" />
|
<PluginsInstallHelperPublishFiles Include="..\LanMountainDesktop.PluginsInstallHelper\bin\$(Configuration)\net10.0\**\*.*" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<Copy SourceFiles="@(PluginsInstallHelperPublishFiles)"
|
<Copy SourceFiles="@(PluginsInstallHelperPublishFiles)" DestinationFiles="@(PluginsInstallHelperPublishFiles->'$(PublishDir)PluginsInstallHelper\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
|
||||||
DestinationFiles="@(PluginsInstallHelperPublishFiles->'$(PublishDir)PluginsInstallHelper\%(RecursiveDir)%(Filename)%(Extension)')"
|
|
||||||
SkipUnchangedFiles="true" />
|
|
||||||
</Target>
|
</Target>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -255,7 +255,6 @@
|
|||||||
"settings.color.use_system_chrome_toggle": "Use system window chrome",
|
"settings.color.use_system_chrome_toggle": "Use system window chrome",
|
||||||
"settings.color.theme_color_label": "Theme accent color",
|
"settings.color.theme_color_label": "Theme accent color",
|
||||||
"settings.appearance.theme_color_mode_label": "Theme color source",
|
"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.neutral": "Default neutral",
|
||||||
"settings.appearance.theme_color_mode.user": "User theme color Monet",
|
"settings.appearance.theme_color_mode.user": "User theme color Monet",
|
||||||
"settings.appearance.theme_color_mode.wallpaper": "Wallpaper Monet",
|
"settings.appearance.theme_color_mode.wallpaper": "Wallpaper Monet",
|
||||||
@@ -265,6 +264,8 @@
|
|||||||
"settings.appearance.theme_color_preview.app": "Currently previewing colors extracted from the app wallpaper.",
|
"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.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.",
|
"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.none": "None",
|
||||||
"settings.appearance.system_material.mica": "Mica",
|
"settings.appearance.system_material.mica": "Mica",
|
||||||
"settings.appearance.system_material.acrylic": "Acrylic",
|
"settings.appearance.system_material.acrylic": "Acrylic",
|
||||||
@@ -580,6 +581,7 @@
|
|||||||
"component.whiteboard": "Blackboard (Portrait)",
|
"component.whiteboard": "Blackboard (Portrait)",
|
||||||
"component.blackboard_landscape": "Blackboard (Landscape)",
|
"component.blackboard_landscape": "Blackboard (Landscape)",
|
||||||
"component.browser": "Browser",
|
"component.browser": "Browser",
|
||||||
|
"component.office_recent_documents": "Recent Documents",
|
||||||
"component.holiday_calendar": "Holiday Calendar",
|
"component.holiday_calendar": "Holiday Calendar",
|
||||||
"component.study_environment": "Environment",
|
"component.study_environment": "Environment",
|
||||||
"component.study_session_control": "Study Session Control",
|
"component.study_session_control": "Study Session Control",
|
||||||
|
|||||||
@@ -31,13 +31,14 @@
|
|||||||
"settings.nav.plugins": "插件",
|
"settings.nav.plugins": "插件",
|
||||||
"settings.nav.about": "关于",
|
"settings.nav.about": "关于",
|
||||||
"settings.wallpaper.title": "壁纸",
|
"settings.wallpaper.title": "壁纸",
|
||||||
"settings.wallpaper.description": "选择图片或视频后可立即设为应用窗口壁纸。",
|
"settings.wallpaper.description": "选择图片后可立即设为应用窗口壁纸。",
|
||||||
"settings.wallpaper.current_label": "当前壁纸",
|
"settings.wallpaper.current_label": "当前壁纸",
|
||||||
"settings.wallpaper.type_label": "壁纸类型",
|
"settings.wallpaper.type_label": "壁纸类型",
|
||||||
"settings.wallpaper.type.image": "图片",
|
"settings.wallpaper.type.image": "图片",
|
||||||
"settings.wallpaper.type.video": "视频",
|
|
||||||
"settings.wallpaper.type.solid_color": "纯色",
|
"settings.wallpaper.type.solid_color": "纯色",
|
||||||
"settings.wallpaper.color_label": "壁纸颜色",
|
"settings.wallpaper.color_label": "壁纸颜色",
|
||||||
|
"settings.wallpaper.custom_color_tooltip": "自定义颜色",
|
||||||
|
"settings.wallpaper.custom_color_apply": "应用",
|
||||||
"settings.wallpaper.placement_label": "显示方式",
|
"settings.wallpaper.placement_label": "显示方式",
|
||||||
"settings.wallpaper.placement_desc": "调整图像在桌面上的填充方式。",
|
"settings.wallpaper.placement_desc": "调整图像在桌面上的填充方式。",
|
||||||
"settings.wallpaper.pick_button": "选择文件",
|
"settings.wallpaper.pick_button": "选择文件",
|
||||||
@@ -46,20 +47,14 @@
|
|||||||
"settings.wallpaper.storage_unavailable": "存储提供器不可用。",
|
"settings.wallpaper.storage_unavailable": "存储提供器不可用。",
|
||||||
"settings.wallpaper.import_failed": "导入壁纸文件失败。",
|
"settings.wallpaper.import_failed": "导入壁纸文件失败。",
|
||||||
"settings.wallpaper.image_applied": "图片壁纸已应用。",
|
"settings.wallpaper.image_applied": "图片壁纸已应用。",
|
||||||
"settings.wallpaper.video_applied": "视频壁纸已应用。",
|
|
||||||
"settings.wallpaper.unsupported_file": "所选文件类型不受支持。",
|
"settings.wallpaper.unsupported_file": "所选文件类型不受支持。",
|
||||||
"settings.wallpaper.apply_failed_format": "应用壁纸失败:{0}",
|
"settings.wallpaper.apply_failed_format": "应用壁纸失败:{0}",
|
||||||
"settings.wallpaper.mode_format": "壁纸模式:{0}。",
|
"settings.wallpaper.mode_format": "壁纸模式:{0}。",
|
||||||
"settings.wallpaper.video_mode": "视频壁纸使用自动填充模式。",
|
|
||||||
"settings.wallpaper.cleared": "背景已恢复为纯色。",
|
"settings.wallpaper.cleared": "背景已恢复为纯色。",
|
||||||
"settings.wallpaper.default_status": "当前使用纯色背景。",
|
"settings.wallpaper.default_status": "当前使用纯色背景。",
|
||||||
"settings.wallpaper.saved_not_found": "未找到已保存的壁纸文件,已使用纯色背景。",
|
"settings.wallpaper.saved_not_found": "未找到已保存的壁纸文件,已使用纯色背景。",
|
||||||
"settings.wallpaper.restored": "已恢复保存的壁纸。",
|
"settings.wallpaper.restored": "已恢复保存的壁纸。",
|
||||||
"settings.wallpaper.video_restored": "已恢复保存的视频壁纸。",
|
|
||||||
"settings.wallpaper.restore_failed": "恢复已保存壁纸失败,已使用纯色背景。",
|
"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.title": "网格布局",
|
||||||
"settings.grid.description": "每个组件至少占用一个格子(最小 1x1)。",
|
"settings.grid.description": "每个组件至少占用一个格子(最小 1x1)。",
|
||||||
"settings.grid.short_side_label": "短边格数",
|
"settings.grid.short_side_label": "短边格数",
|
||||||
@@ -85,7 +80,6 @@
|
|||||||
"settings.color.theme_ready_format": "主题色已就绪:{0}。",
|
"settings.color.theme_ready_format": "主题色已就绪:{0}。",
|
||||||
"settings.color.theme_applied_format": "{0}主题色已应用:{1}。",
|
"settings.color.theme_applied_format": "{0}主题色已应用:{1}。",
|
||||||
"settings.color.theme_updated_wallpaper": "壁纸已更新,莫奈色已刷新。",
|
"settings.color.theme_updated_wallpaper": "壁纸已更新,莫奈色已刷新。",
|
||||||
"settings.color.theme_updated_video": "视频壁纸已更新,主题色已刷新。",
|
|
||||||
"settings.color.theme_cleared_wallpaper": "壁纸已清除,莫奈色已刷新。",
|
"settings.color.theme_cleared_wallpaper": "壁纸已清除,莫奈色已刷新。",
|
||||||
"settings.status_bar.title": "状态栏",
|
"settings.status_bar.title": "状态栏",
|
||||||
"settings.status_bar.description": "选择顶部状态栏显示的组件。",
|
"settings.status_bar.description": "选择顶部状态栏显示的组件。",
|
||||||
@@ -260,7 +254,6 @@
|
|||||||
"settings.color.use_system_chrome_toggle": "使用系统窗口标题栏",
|
"settings.color.use_system_chrome_toggle": "使用系统窗口标题栏",
|
||||||
"settings.color.theme_color_label": "主题强调色",
|
"settings.color.theme_color_label": "主题强调色",
|
||||||
"settings.appearance.theme_color_mode_label": "主题色来源",
|
"settings.appearance.theme_color_mode_label": "主题色来源",
|
||||||
"settings.appearance.system_material_label": "系统材质",
|
|
||||||
"settings.appearance.theme_color_mode.neutral": "默认中性",
|
"settings.appearance.theme_color_mode.neutral": "默认中性",
|
||||||
"settings.appearance.theme_color_mode.user": "用户主题色 Monet",
|
"settings.appearance.theme_color_mode.user": "用户主题色 Monet",
|
||||||
"settings.appearance.theme_color_mode.wallpaper": "壁纸 Monet 取色",
|
"settings.appearance.theme_color_mode.wallpaper": "壁纸 Monet 取色",
|
||||||
@@ -270,6 +263,8 @@
|
|||||||
"settings.appearance.theme_color_preview.app": "当前正在预览从应用壁纸提取的颜色。",
|
"settings.appearance.theme_color_preview.app": "当前正在预览从应用壁纸提取的颜色。",
|
||||||
"settings.appearance.theme_color_preview.system": "当前正在预览从系统壁纸提取的颜色。",
|
"settings.appearance.theme_color_preview.system": "当前正在预览从系统壁纸提取的颜色。",
|
||||||
"settings.appearance.theme_color_preview.fallback": "没有可用壁纸,当前使用回退强调色。",
|
"settings.appearance.theme_color_preview.fallback": "没有可用壁纸,当前使用回退强调色。",
|
||||||
|
"component.color_scheme.follow_system": "跟随系统配色",
|
||||||
|
"component.color_scheme.native": "使用组件自定义配色",
|
||||||
"settings.appearance.system_material.none": "无",
|
"settings.appearance.system_material.none": "无",
|
||||||
"settings.appearance.system_material.mica": "Mica",
|
"settings.appearance.system_material.mica": "Mica",
|
||||||
"settings.appearance.system_material.acrylic": "Acrylic",
|
"settings.appearance.system_material.acrylic": "Acrylic",
|
||||||
@@ -392,7 +387,6 @@
|
|||||||
"settings.footer": "LanMountainDesktop 设置",
|
"settings.footer": "LanMountainDesktop 设置",
|
||||||
"filepicker.title": "选择壁纸",
|
"filepicker.title": "选择壁纸",
|
||||||
"filepicker.image_files": "图片文件",
|
"filepicker.image_files": "图片文件",
|
||||||
"filepicker.video_files": "视频文件",
|
|
||||||
"common.day": "日间",
|
"common.day": "日间",
|
||||||
"common.night": "夜间",
|
"common.night": "夜间",
|
||||||
"common.back": "返回",
|
"common.back": "返回",
|
||||||
@@ -585,6 +579,7 @@
|
|||||||
"component.whiteboard": "竖向小黑板",
|
"component.whiteboard": "竖向小黑板",
|
||||||
"component.blackboard_landscape": "横向小黑板",
|
"component.blackboard_landscape": "横向小黑板",
|
||||||
"component.browser": "浏览器",
|
"component.browser": "浏览器",
|
||||||
|
"component.office_recent_documents": "最近文档",
|
||||||
"component.holiday_calendar": "节假日日历",
|
"component.holiday_calendar": "节假日日历",
|
||||||
"component.study_environment": "环境",
|
"component.study_environment": "环境",
|
||||||
"component.study_session_control": "自习时段控制",
|
"component.study_session_control": "自习时段控制",
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ public sealed class AppSettingsSnapshot
|
|||||||
|
|
||||||
public bool UploadAnonymousUsageData { get; set; }
|
public bool UploadAnonymousUsageData { get; set; }
|
||||||
|
|
||||||
|
public string? DeviceId { get; set; }
|
||||||
|
|
||||||
public string UpdateChannel { get; set; } = "stable";
|
public string UpdateChannel { get; set; } = "stable";
|
||||||
|
|
||||||
public string UpdateMode { get; set; } = "download_then_confirm";
|
public string UpdateMode { get; set; } = "download_then_confirm";
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ public sealed class ComponentSettingsSnapshot
|
|||||||
{
|
{
|
||||||
public string DailyArtworkMirrorSource { get; set; } = DailyArtworkMirrorSources.Overseas;
|
public string DailyArtworkMirrorSource { get; set; } = DailyArtworkMirrorSources.Overseas;
|
||||||
|
|
||||||
|
public string? ColorSchemeSource { get; set; }
|
||||||
|
|
||||||
public List<ImportedClassScheduleSnapshot> ImportedClassSchedules { get; set; } = [];
|
public List<ImportedClassScheduleSnapshot> ImportedClassSchedules { get; set; } = [];
|
||||||
|
|
||||||
public string ActiveImportedClassScheduleId { get; set; } = string.Empty;
|
public string ActiveImportedClassScheduleId { get; set; } = string.Empty;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using Avalonia.WebView.Desktop;
|
|||||||
using LanMountainDesktop.Models;
|
using LanMountainDesktop.Models;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
using LanMountainDesktop.Services.Settings;
|
using LanMountainDesktop.Services.Settings;
|
||||||
|
using Sentry;
|
||||||
|
|
||||||
namespace LanMountainDesktop;
|
namespace LanMountainDesktop;
|
||||||
|
|
||||||
@@ -14,14 +15,14 @@ sealed class Program
|
|||||||
{
|
{
|
||||||
internal static string StartupRenderMode { get; private set; } = AppRenderingModeHelper.Default;
|
internal static string StartupRenderMode { get; private set; } = AppRenderingModeHelper.Default;
|
||||||
|
|
||||||
// Initialization code. Don't use any Avalonia, third-party APIs or any
|
|
||||||
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
|
|
||||||
// yet and stuff might break.
|
|
||||||
[STAThread]
|
[STAThread]
|
||||||
public static void Main(string[] args)
|
public static void Main(string[] args)
|
||||||
{
|
{
|
||||||
AppLogger.Initialize();
|
AppLogger.Initialize();
|
||||||
RegisterGlobalExceptionLogging();
|
RegisterGlobalExceptionLogging();
|
||||||
|
InitializeDeviceId();
|
||||||
|
InitializeCrashReporting();
|
||||||
|
InitializeUserBehaviorAnalytics();
|
||||||
var restartParentProcessId = AppRestartService.TryGetRestartParentProcessId(args);
|
var restartParentProcessId = AppRestartService.TryGetRestartParentProcessId(args);
|
||||||
|
|
||||||
using var singleInstance = AcquireSingleInstance(restartParentProcessId);
|
using var singleInstance = AcquireSingleInstance(restartParentProcessId);
|
||||||
@@ -49,6 +50,7 @@ sealed class Program
|
|||||||
StartupRenderMode = renderMode;
|
StartupRenderMode = renderMode;
|
||||||
AppLogger.Info("Startup", $"Resolved render mode '{renderMode}'.");
|
AppLogger.Info("Startup", $"Resolved render mode '{renderMode}'.");
|
||||||
App.CurrentSingleInstanceService = singleInstance;
|
App.CurrentSingleInstanceService = singleInstance;
|
||||||
|
App.AnalyticsServices = (_userBehaviorAnalyticsService, _crashReportService);
|
||||||
BuildAvaloniaApp(renderMode).StartWithClassicDesktopLifetime(args);
|
BuildAvaloniaApp(renderMode).StartWithClassicDesktopLifetime(args);
|
||||||
AppLogger.Info("Startup", "Application exited normally.");
|
AppLogger.Info("Startup", "Application exited normally.");
|
||||||
}
|
}
|
||||||
@@ -63,7 +65,6 @@ sealed class Program
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Avalonia configuration, don't remove; also used by visual designer.
|
|
||||||
public static AppBuilder BuildAvaloniaApp(string renderMode = AppRenderingModeHelper.Default)
|
public static AppBuilder BuildAvaloniaApp(string renderMode = AppRenderingModeHelper.Default)
|
||||||
{
|
{
|
||||||
var builder = AppBuilder.Configure<App>()
|
var builder = AppBuilder.Configure<App>()
|
||||||
@@ -151,7 +152,6 @@ sealed class Program
|
|||||||
}
|
}
|
||||||
catch (ArgumentException)
|
catch (ArgumentException)
|
||||||
{
|
{
|
||||||
// The previous process already exited before we started waiting.
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -167,6 +167,11 @@ sealed class Program
|
|||||||
"UnhandledException",
|
"UnhandledException",
|
||||||
$"Unhandled exception. IsTerminating={eventArgs.IsTerminating}",
|
$"Unhandled exception. IsTerminating={eventArgs.IsTerminating}",
|
||||||
eventArgs.ExceptionObject as Exception);
|
eventArgs.ExceptionObject as Exception);
|
||||||
|
|
||||||
|
if (eventArgs.IsTerminating)
|
||||||
|
{
|
||||||
|
SentrySdk.Flush(TimeSpan.FromSeconds(5));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
TaskScheduler.UnobservedTaskException += (_, eventArgs) =>
|
TaskScheduler.UnobservedTaskException += (_, eventArgs) =>
|
||||||
@@ -175,4 +180,187 @@ sealed class Program
|
|||||||
eventArgs.SetObserved();
|
eventArgs.SetObserved();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void InitializeDeviceId()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
DeviceIdService.Initialize(HostSettingsFacadeProvider.GetOrCreate());
|
||||||
|
AppLogger.Info("Startup", $"DeviceId initialized: {DeviceIdService.Instance.DeviceId}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("Startup", "Failed to initialize DeviceIdService.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void InitializeSentryForAnalytics()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var deviceId = DeviceIdService.Instance.DeviceId;
|
||||||
|
|
||||||
|
SentrySdk.Init(options =>
|
||||||
|
{
|
||||||
|
options.Dsn = "https://f2aad3a1c63b5f2213ad82683ce93c06@o4511049423257600.ingest.us.sentry.io/4511049425813504";
|
||||||
|
options.AutoSessionTracking = true;
|
||||||
|
options.Release = GetAppVersion();
|
||||||
|
options.Environment = GetEnvironment();
|
||||||
|
});
|
||||||
|
|
||||||
|
SentrySdk.ConfigureScope(scope =>
|
||||||
|
{
|
||||||
|
scope.User = new SentryUser
|
||||||
|
{
|
||||||
|
Id = deviceId
|
||||||
|
};
|
||||||
|
|
||||||
|
scope.SetTag("data_type", "analytics");
|
||||||
|
scope.SetTag("device_id", deviceId);
|
||||||
|
scope.SetTag("app_version", GetAppVersion());
|
||||||
|
scope.SetTag("os_name", GetOsName());
|
||||||
|
scope.SetTag("os_version", GetOsVersion());
|
||||||
|
scope.SetTag("os_build", GetOsBuild());
|
||||||
|
scope.SetTag("device_model", GetDeviceModel());
|
||||||
|
scope.SetTag("device_arch", GetDeviceArchitecture());
|
||||||
|
scope.SetTag("processor_count", GetProcessorCount().ToString());
|
||||||
|
scope.SetTag("total_memory_mb", GetTotalMemoryMB().ToString());
|
||||||
|
scope.SetTag("runtime_version", GetRuntimeVersion());
|
||||||
|
scope.SetTag("language", GetSystemLanguage());
|
||||||
|
scope.SetTag("clr_version", GetClrVersion());
|
||||||
|
scope.SetTag("is_64bit", Environment.Is64BitOperatingSystem.ToString());
|
||||||
|
});
|
||||||
|
|
||||||
|
SentrySdk.CaptureMessage("user_active");
|
||||||
|
|
||||||
|
AppLogger.Info("Startup", $"Analytics service initialized. DeviceId={deviceId}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("Startup", "Failed to initialize analytics service.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetAppVersion()
|
||||||
|
{
|
||||||
|
var version = typeof(Program).Assembly.GetName().Version;
|
||||||
|
return version is null ? "1.0.0" : $"{version.Major}.{version.Minor}.{version.Build}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetOsName()
|
||||||
|
{
|
||||||
|
if (OperatingSystem.IsWindows()) return "Windows";
|
||||||
|
if (OperatingSystem.IsLinux()) return "Linux";
|
||||||
|
if (OperatingSystem.IsMacOS()) return "macOS";
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetOsVersion()
|
||||||
|
{
|
||||||
|
try { return Environment.OSVersion.VersionString ?? "Unknown"; }
|
||||||
|
catch { return "Unknown"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetOsBuild()
|
||||||
|
{
|
||||||
|
try { return Environment.OSVersion.Version.Build.ToString() ?? "Unknown"; }
|
||||||
|
catch { return "Unknown"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetDeviceName()
|
||||||
|
{
|
||||||
|
try { return Environment.MachineName ?? "Unknown"; }
|
||||||
|
catch { return "Unknown"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetDeviceModel()
|
||||||
|
{
|
||||||
|
if (OperatingSystem.IsWindows()) return "Windows PC";
|
||||||
|
if (OperatingSystem.IsLinux()) return "Linux PC";
|
||||||
|
if (OperatingSystem.IsMacOS()) return "Mac";
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetDeviceArchitecture()
|
||||||
|
{
|
||||||
|
return Environment.Is64BitOperatingSystem ? "x64" : "x86";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int GetProcessorCount()
|
||||||
|
{
|
||||||
|
return Environment.ProcessorCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long GetTotalMemoryMB()
|
||||||
|
{
|
||||||
|
try { return GC.GetGCMemoryInfo().TotalAvailableMemoryBytes / (1024 * 1024); }
|
||||||
|
catch { return 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetRuntimeVersion()
|
||||||
|
{
|
||||||
|
return Environment.Version.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetSystemLanguage()
|
||||||
|
{
|
||||||
|
try { return System.Globalization.CultureInfo.CurrentUICulture.Name ?? "en-US"; }
|
||||||
|
catch { return "en-US"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetClrVersion()
|
||||||
|
{
|
||||||
|
return Environment.Version.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CrashReportService? _crashReportService;
|
||||||
|
private static UserBehaviorAnalyticsService? _userBehaviorAnalyticsService;
|
||||||
|
|
||||||
|
private static void InitializeCrashReporting()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
||||||
|
_crashReportService = new CrashReportService(settingsFacade, DeviceIdService.Instance);
|
||||||
|
_crashReportService.RefreshEnabledState();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("Startup", "Failed to initialize crash reporting service.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void InitializeUserBehaviorAnalytics()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
||||||
|
_userBehaviorAnalyticsService = new UserBehaviorAnalyticsService(settingsFacade, DeviceIdService.Instance);
|
||||||
|
_userBehaviorAnalyticsService.Initialize();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("Startup", "Failed to initialize user behavior analytics service.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetReleaseVersion()
|
||||||
|
{
|
||||||
|
var assembly = typeof(Program).Assembly;
|
||||||
|
var version = assembly.GetName().Version;
|
||||||
|
if (version is null)
|
||||||
|
{
|
||||||
|
return "1.0.0";
|
||||||
|
}
|
||||||
|
return version.Major >= 0 ? $"{version.Major}.{version.Minor}.{version.Build}" : "1.0.0";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetEnvironment()
|
||||||
|
{
|
||||||
|
#if DEBUG
|
||||||
|
return "development";
|
||||||
|
#else
|
||||||
|
return "production";
|
||||||
|
#endif
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ using LanMountainDesktop.Models;
|
|||||||
using LanMountainDesktop.PluginSdk;
|
using LanMountainDesktop.PluginSdk;
|
||||||
using LanMountainDesktop.Services.Settings;
|
using LanMountainDesktop.Services.Settings;
|
||||||
using LanMountainDesktop.Theme;
|
using LanMountainDesktop.Theme;
|
||||||
using LibVLCSharp.Shared;
|
|
||||||
using Microsoft.Win32;
|
using Microsoft.Win32;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Services;
|
namespace LanMountainDesktop.Services;
|
||||||
@@ -89,11 +88,6 @@ internal interface IMaterialSurfaceService
|
|||||||
AppearanceMaterialSurface GetSurface(ThemeColorContext context, MaterialSurfaceRole role);
|
AppearanceMaterialSurface GetSurface(ThemeColorContext context, MaterialSurfaceRole role);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal interface IVideoWallpaperSeedExtractor
|
|
||||||
{
|
|
||||||
IReadOnlyList<Color> ExtractSeedCandidates(string videoPath, MonetColorService monetColorService);
|
|
||||||
}
|
|
||||||
|
|
||||||
internal readonly record struct WallpaperSeedSourceDescriptor(
|
internal readonly record struct WallpaperSeedSourceDescriptor(
|
||||||
string SourceKind,
|
string SourceKind,
|
||||||
string SourceKey,
|
string SourceKey,
|
||||||
@@ -114,75 +108,6 @@ internal readonly record struct WallpaperPaletteResolution(
|
|||||||
Color EffectiveSeedColor,
|
Color EffectiveSeedColor,
|
||||||
string? ResolvedWallpaperPath);
|
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
|
internal sealed class SystemWallpaperService : ISystemWallpaperService
|
||||||
{
|
{
|
||||||
public bool IsSupported => OperatingSystem.IsWindows();
|
public bool IsSupported => OperatingSystem.IsWindows();
|
||||||
@@ -248,6 +173,15 @@ internal sealed class WindowMaterialService : IWindowMaterialService
|
|||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(window);
|
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;
|
window.Background = Brushes.Transparent;
|
||||||
|
|
||||||
if (!OperatingSystem.IsWindows() || !IsTransparencyEnabled())
|
if (!OperatingSystem.IsWindows() || !IsTransparencyEnabled())
|
||||||
@@ -259,7 +193,6 @@ internal sealed class WindowMaterialService : IWindowMaterialService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var normalizedMode = ThemeAppearanceValues.NormalizeSystemMaterialMode(materialMode);
|
|
||||||
window.TransparencyLevelHint = normalizedMode switch
|
window.TransparencyLevelHint = normalizedMode switch
|
||||||
{
|
{
|
||||||
ThemeAppearanceValues.MaterialMica =>
|
ThemeAppearanceValues.MaterialMica =>
|
||||||
@@ -469,7 +402,6 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
|
|||||||
private readonly ISystemWallpaperService _systemWallpaperService;
|
private readonly ISystemWallpaperService _systemWallpaperService;
|
||||||
private readonly IWindowMaterialService _windowMaterialService;
|
private readonly IWindowMaterialService _windowMaterialService;
|
||||||
private readonly IMaterialSurfaceService _materialSurfaceService;
|
private readonly IMaterialSurfaceService _materialSurfaceService;
|
||||||
private readonly IVideoWallpaperSeedExtractor _videoWallpaperSeedExtractor;
|
|
||||||
private readonly MonetColorService _monetColorService = new();
|
private readonly MonetColorService _monetColorService = new();
|
||||||
private readonly string _liveThemeColorMode;
|
private readonly string _liveThemeColorMode;
|
||||||
private readonly string _liveSystemMaterialMode;
|
private readonly string _liveSystemMaterialMode;
|
||||||
@@ -482,14 +414,12 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
|
|||||||
ISettingsFacadeService settingsFacade,
|
ISettingsFacadeService settingsFacade,
|
||||||
ISystemWallpaperService systemWallpaperService,
|
ISystemWallpaperService systemWallpaperService,
|
||||||
IWindowMaterialService windowMaterialService,
|
IWindowMaterialService windowMaterialService,
|
||||||
IMaterialSurfaceService materialSurfaceService,
|
IMaterialSurfaceService materialSurfaceService)
|
||||||
IVideoWallpaperSeedExtractor? videoWallpaperSeedExtractor = null)
|
|
||||||
{
|
{
|
||||||
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
|
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
|
||||||
_systemWallpaperService = systemWallpaperService ?? throw new ArgumentNullException(nameof(systemWallpaperService));
|
_systemWallpaperService = systemWallpaperService ?? throw new ArgumentNullException(nameof(systemWallpaperService));
|
||||||
_windowMaterialService = windowMaterialService ?? throw new ArgumentNullException(nameof(windowMaterialService));
|
_windowMaterialService = windowMaterialService ?? throw new ArgumentNullException(nameof(windowMaterialService));
|
||||||
_materialSurfaceService = materialSurfaceService ?? throw new ArgumentNullException(nameof(materialSurfaceService));
|
_materialSurfaceService = materialSurfaceService ?? throw new ArgumentNullException(nameof(materialSurfaceService));
|
||||||
_videoWallpaperSeedExtractor = videoWallpaperSeedExtractor ?? new LibVlcVideoWallpaperSeedExtractor();
|
|
||||||
var initialThemeState = _settingsFacade.Theme.Get();
|
var initialThemeState = _settingsFacade.Theme.Get();
|
||||||
_liveThemeColorMode = ThemeAppearanceValues.NormalizeThemeColorMode(
|
_liveThemeColorMode = ThemeAppearanceValues.NormalizeThemeColorMode(
|
||||||
initialThemeState.ThemeColorMode,
|
initialThemeState.ThemeColorMode,
|
||||||
@@ -878,7 +808,6 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
|
|||||||
IReadOnlyList<Color> seedCandidates = source.SourceKind switch
|
IReadOnlyList<Color> seedCandidates = source.SourceKind switch
|
||||||
{
|
{
|
||||||
"app_wallpaper" or "system_wallpaper" => ExtractImageSeedCandidates(source.FilePath),
|
"app_wallpaper" or "system_wallpaper" => ExtractImageSeedCandidates(source.FilePath),
|
||||||
"app_video" => ExtractVideoSeedCandidates(source.FilePath),
|
|
||||||
"app_solid" when source.SolidColor is { } solidColor => new[] { solidColor },
|
"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)
|
private WallpaperSeedSourceDescriptor ResolveWallpaperSeedSource(WallpaperSettingsState wallpaperState)
|
||||||
{
|
{
|
||||||
if (string.Equals(wallpaperState.Type, "SolidColor", StringComparison.OrdinalIgnoreCase) &&
|
if (string.Equals(wallpaperState.Type, "SolidColor", StringComparison.OrdinalIgnoreCase) &&
|
||||||
@@ -952,16 +871,6 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
|
|||||||
wallpaperPath,
|
wallpaperPath,
|
||||||
null);
|
null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (appWallpaperMediaType == WallpaperMediaType.Video)
|
|
||||||
{
|
|
||||||
return new WallpaperSeedSourceDescriptor(
|
|
||||||
"app_video",
|
|
||||||
CreateWallpaperSourceKey("app_video", wallpaperPath),
|
|
||||||
wallpaperPath,
|
|
||||||
wallpaperPath,
|
|
||||||
null);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var systemWallpaper = _systemWallpaperService.GetWallpaperPath();
|
var systemWallpaper = _systemWallpaperService.GetWallpaperPath();
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ public sealed class ClassIslandScheduleDataService : IClassIslandScheduleDataSer
|
|||||||
var totalElapsedWeeks = (int)Math.Floor(
|
var totalElapsedWeeks = (int)Math.Floor(
|
||||||
(referenceDate.ToDateTime(TimeOnly.MinValue) - cycleRule.SingleWeekStartDate.Value.ToDateTime(TimeOnly.MinValue)).TotalDays / 7d);
|
(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
|
var cycleOffset = cycleLength < cycleRule.MultiWeekRotationOffset.Count
|
||||||
? cycleRule.MultiWeekRotationOffset[cycleLength]
|
? cycleRule.MultiWeekRotationOffset[cycleLength]
|
||||||
@@ -668,7 +668,7 @@ public sealed class ClassIslandScheduleDataService : IClassIslandScheduleDataSer
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (weekCountDivTotal <= 1 || weekCountDivTotal >= cyclePositions.Count)
|
if (weekCountDivTotal <= 0 || weekCountDivTotal >= cyclePositions.Count)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
943
LanMountainDesktop/Services/CrashReportService.cs
Normal file
943
LanMountainDesktop/Services/CrashReportService.cs
Normal file
@@ -0,0 +1,943 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
using LanMountainDesktop.Services.Settings;
|
||||||
|
using Sentry;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
public sealed class DeviceIdService
|
||||||
|
{
|
||||||
|
private static DeviceIdService? _instance;
|
||||||
|
private string? _deviceId;
|
||||||
|
private readonly ISettingsFacadeService _settingsFacade;
|
||||||
|
private bool _isInitialized;
|
||||||
|
|
||||||
|
public static DeviceIdService Instance => _instance ?? throw new InvalidOperationException("DeviceIdService not initialized");
|
||||||
|
|
||||||
|
public DeviceIdService(ISettingsFacadeService settingsFacade)
|
||||||
|
{
|
||||||
|
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Initialize(ISettingsFacadeService settingsFacade)
|
||||||
|
{
|
||||||
|
_instance = new DeviceIdService(settingsFacade);
|
||||||
|
_instance.EnsureDeviceId();
|
||||||
|
}
|
||||||
|
|
||||||
|
public string DeviceId
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (_deviceId is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("DeviceId not initialized");
|
||||||
|
}
|
||||||
|
return _deviceId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureDeviceId()
|
||||||
|
{
|
||||||
|
if (_isInitialized)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isInitialized = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(snapshot.DeviceId))
|
||||||
|
{
|
||||||
|
snapshot.DeviceId = GenerateDeviceId();
|
||||||
|
_settingsFacade.Settings.SaveSnapshot(
|
||||||
|
SettingsScope.App,
|
||||||
|
snapshot,
|
||||||
|
changedKeys: [nameof(AppSettingsSnapshot.DeviceId)]);
|
||||||
|
_deviceId = snapshot.DeviceId;
|
||||||
|
AppLogger.Info("DeviceId", $"Generated new device ID: {_deviceId}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_deviceId = snapshot.DeviceId;
|
||||||
|
AppLogger.Info("DeviceId", $"Loaded existing device ID: {_deviceId}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_deviceId = GenerateDeviceId();
|
||||||
|
AppLogger.Warn("DeviceId", $"Failed to persist device ID, using generated ID: {_deviceId}", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GenerateDeviceId()
|
||||||
|
{
|
||||||
|
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
|
var deviceInfo = $"{Environment.MachineName}|{Environment.ProcessorCount}|{Environment.OSVersion}|{Environment.UserName}|{timestamp}";
|
||||||
|
using var sha = System.Security.Cryptography.SHA256.Create();
|
||||||
|
var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(deviceInfo));
|
||||||
|
return Convert.ToHexString(hash)[..32].ToLower();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UserBehaviorAnalyticsService : IDisposable
|
||||||
|
{
|
||||||
|
private const string PostHogApiKey = "phc_bhQZvKDDfsEdLT6kkRFvrWMT8Pc5aCGGsnxoc5ijSf9";
|
||||||
|
private const string PostHogHost = "https://us.i.posthog.com/capture/";
|
||||||
|
|
||||||
|
private bool _isEnabled;
|
||||||
|
private bool _isInitialized;
|
||||||
|
private readonly ISettingsFacadeService _settingsFacade;
|
||||||
|
private readonly DeviceIdService _deviceIdService;
|
||||||
|
private readonly Queue<UserBehaviorEvent> _eventQueue = new();
|
||||||
|
private readonly object _queueLock = new();
|
||||||
|
private System.Threading.Timer? _flushTimer;
|
||||||
|
private readonly PluginSdk.ISettingsService _settingsService;
|
||||||
|
|
||||||
|
public UserBehaviorAnalyticsService(ISettingsFacadeService settingsFacade, DeviceIdService deviceIdService)
|
||||||
|
{
|
||||||
|
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
|
||||||
|
_settingsService = settingsFacade.Settings;
|
||||||
|
_deviceIdService = deviceIdService ?? throw new ArgumentNullException(nameof(deviceIdService));
|
||||||
|
_settingsService.Changed += OnSettingsChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSettingsChanged(object? sender, PluginSdk.SettingsChangedEvent e)
|
||||||
|
{
|
||||||
|
if (e.Scope == PluginSdk.SettingsScope.App &&
|
||||||
|
e.ChangedKeys is not null &&
|
||||||
|
(e.ChangedKeys.Contains("UploadAnonymousCrashData") || e.ChangedKeys.Contains("UploadAnonymousUsageData")))
|
||||||
|
{
|
||||||
|
AppLogger.Info("UserBehaviorAnalytics", "Settings changed, refreshing enabled state.");
|
||||||
|
RefreshEnabledState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Initialize()
|
||||||
|
{
|
||||||
|
if (_isInitialized)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isInitialized = true;
|
||||||
|
RefreshEnabledState();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_flushTimer = new System.Threading.Timer(
|
||||||
|
_ => FlushEvents(),
|
||||||
|
null,
|
||||||
|
TimeSpan.FromSeconds(10),
|
||||||
|
TimeSpan.FromSeconds(30));
|
||||||
|
|
||||||
|
CaptureEvent("app_online", new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "event_type", "app_start" }
|
||||||
|
});
|
||||||
|
|
||||||
|
AppLogger.Info("UserBehaviorAnalytics", $"Analytics initialized. DeviceId={_deviceIdService.DeviceId}, Enabled={_isEnabled}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("UserBehaviorAnalytics", "Failed to initialize analytics.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void TrackClick(string componentName, string? action = null)
|
||||||
|
{
|
||||||
|
if (!_isEnabled || !_isInitialized)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CaptureEvent("ui_click", new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "component", componentName },
|
||||||
|
{ "action", action ?? "click" }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void TrackComponentDrag(string componentId, string action)
|
||||||
|
{
|
||||||
|
if (!_isEnabled || !_isInitialized)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CaptureEvent("component_drag", new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "component_id", componentId },
|
||||||
|
{ "action", action }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void TrackComponentDrop(string componentId, string targetPosition)
|
||||||
|
{
|
||||||
|
if (!_isEnabled || !_isInitialized)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CaptureEvent("component_drop", new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "component_id", componentId },
|
||||||
|
{ "target_position", targetPosition }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void TrackSettingsOpen(string settingsPage)
|
||||||
|
{
|
||||||
|
if (!_isEnabled || !_isInitialized)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CaptureEvent("settings_open", new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "page", settingsPage }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void TrackSettingsChange(string settingsPage, string settingKey, string? oldValue, string newValue)
|
||||||
|
{
|
||||||
|
if (!_isEnabled || !_isInitialized)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CaptureEvent("settings_change", new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "page", settingsPage },
|
||||||
|
{ "key", settingKey },
|
||||||
|
{ "old_value", oldValue ?? "" },
|
||||||
|
{ "new_value", newValue }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void TrackSettingsClose(string settingsPage)
|
||||||
|
{
|
||||||
|
if (!_isEnabled || !_isInitialized)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CaptureEvent("settings_close", new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "page", settingsPage }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void TrackUpdateAction(string action, string? version = null)
|
||||||
|
{
|
||||||
|
if (!_isEnabled || !_isInitialized)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var props = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "action", action }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (version is not null)
|
||||||
|
{
|
||||||
|
props["version"] = version;
|
||||||
|
}
|
||||||
|
|
||||||
|
CaptureEvent("update_action", props);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void TrackRestartAction(string action)
|
||||||
|
{
|
||||||
|
if (!_isEnabled || !_isInitialized)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CaptureEvent("restart_action", new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "action", action }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void TrackNavigation(string fromPage, string toPage)
|
||||||
|
{
|
||||||
|
if (!_isEnabled || !_isInitialized)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CaptureEvent("navigation", new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "from", fromPage },
|
||||||
|
{ "to", toPage }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SendCrashEvent()
|
||||||
|
{
|
||||||
|
if (!_isInitialized)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var properties = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "app_version", GetAppVersion() },
|
||||||
|
{ "event_time", DateTimeOffset.UtcNow.ToString("o") },
|
||||||
|
{ "event_type", "app_crash" }
|
||||||
|
};
|
||||||
|
|
||||||
|
CaptureEvent("app_crash", properties);
|
||||||
|
FlushEvents();
|
||||||
|
|
||||||
|
AppLogger.Info("UserBehaviorAnalytics", $"Crash event sent. DeviceId={_deviceIdService.DeviceId}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("UserBehaviorAnalytics", "Failed to send crash event.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SendShutdownEvent()
|
||||||
|
{
|
||||||
|
if (!_isInitialized)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var properties = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "app_version", GetAppVersion() },
|
||||||
|
{ "event_time", DateTimeOffset.UtcNow.ToString("o") },
|
||||||
|
{ "event_type", "app_shutdown" }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (_isEnabled)
|
||||||
|
{
|
||||||
|
properties["os_name"] = GetOsName();
|
||||||
|
properties["os_version"] = GetOsVersion();
|
||||||
|
properties["device_name"] = GetDeviceName();
|
||||||
|
properties["device_model"] = GetDeviceModel();
|
||||||
|
properties["device_arch"] = GetDeviceArchitecture();
|
||||||
|
properties["language"] = GetSystemLanguage();
|
||||||
|
}
|
||||||
|
|
||||||
|
CaptureEvent("app_shutdown", properties);
|
||||||
|
FlushEvents();
|
||||||
|
|
||||||
|
AppLogger.Info("UserBehaviorAnalytics", $"Shutdown event sent. DeviceId={_deviceIdService.DeviceId}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("UserBehaviorAnalytics", "Failed to send shutdown event.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RefreshEnabledState()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||||
|
var newEnabled = snapshot.UploadAnonymousUsageData;
|
||||||
|
|
||||||
|
if (_isEnabled != newEnabled)
|
||||||
|
{
|
||||||
|
_isEnabled = newEnabled;
|
||||||
|
AppLogger.Info("UserBehaviorAnalytics", $"User behavior analytics enabled state changed to '{_isEnabled}'.");
|
||||||
|
|
||||||
|
if (_isEnabled && _isInitialized)
|
||||||
|
{
|
||||||
|
CaptureEvent("analytics_enabled", new Dictionary<string, object>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("UserBehaviorAnalytics", "Failed to refresh analytics enabled state.", ex);
|
||||||
|
_isEnabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void CaptureEvent(string eventName, Dictionary<string, object>? properties = null)
|
||||||
|
{
|
||||||
|
if (!_isInitialized)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var eventData = new UserBehaviorEvent
|
||||||
|
{
|
||||||
|
Event = eventName,
|
||||||
|
DistinctId = _deviceIdService.DeviceId,
|
||||||
|
Timestamp = DateTimeOffset.UtcNow,
|
||||||
|
Properties = properties ?? new Dictionary<string, object>(),
|
||||||
|
IncludeDetailedData = _isEnabled
|
||||||
|
};
|
||||||
|
|
||||||
|
lock (_queueLock)
|
||||||
|
{
|
||||||
|
_eventQueue.Enqueue(eventData);
|
||||||
|
|
||||||
|
if (_eventQueue.Count >= 20)
|
||||||
|
{
|
||||||
|
FlushEvents();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("UserBehaviorAnalytics", $"Failed to capture event '{eventName}'.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void CapturePageView(string pageName, string? sourcePage = null)
|
||||||
|
{
|
||||||
|
var properties = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "page_name", pageName }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(sourcePage))
|
||||||
|
{
|
||||||
|
properties["source_page"] = sourcePage;
|
||||||
|
}
|
||||||
|
|
||||||
|
CaptureEvent("page_view", properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void CaptureFeatureUsage(string featureName, string action)
|
||||||
|
{
|
||||||
|
CaptureEvent("feature_usage", new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "feature_name", featureName },
|
||||||
|
{ "action", action }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void FlushEvents()
|
||||||
|
{
|
||||||
|
List<UserBehaviorEvent> eventsToSend;
|
||||||
|
|
||||||
|
lock (_queueLock)
|
||||||
|
{
|
||||||
|
if (_eventQueue.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
eventsToSend = new List<UserBehaviorEvent>();
|
||||||
|
while (_eventQueue.Count > 0 && eventsToSend.Count < 20)
|
||||||
|
{
|
||||||
|
eventsToSend.Add(_eventQueue.Dequeue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
SendEventsToPostHog(eventsToSend);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("UserBehaviorAnalytics", "Failed to send events to PostHog.", ex);
|
||||||
|
|
||||||
|
lock (_queueLock)
|
||||||
|
{
|
||||||
|
foreach (var evt in eventsToSend)
|
||||||
|
{
|
||||||
|
if (_eventQueue.Count < 100)
|
||||||
|
{
|
||||||
|
_eventQueue.Enqueue(evt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SendEventsToPostHog(List<UserBehaviorEvent> events)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var client = new System.Net.Http.HttpClient
|
||||||
|
{
|
||||||
|
Timeout = TimeSpan.FromSeconds(10)
|
||||||
|
};
|
||||||
|
|
||||||
|
var firstEvent = events.FirstOrDefault();
|
||||||
|
if (firstEvent is not null)
|
||||||
|
{
|
||||||
|
SendIdentifyToPostHog(client, firstEvent.DistinctId);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var e in events)
|
||||||
|
{
|
||||||
|
var properties = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "distinct_id", e.DistinctId }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (e.IncludeDetailedData)
|
||||||
|
{
|
||||||
|
properties["$os"] = GetOsName();
|
||||||
|
properties["$os_version"] = GetOsVersion();
|
||||||
|
properties["$app_version"] = GetAppVersion();
|
||||||
|
properties["$device_id"] = e.DistinctId;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var kvp in e.Properties)
|
||||||
|
{
|
||||||
|
properties[kvp.Key] = kvp.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var requestBody = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "api_key", PostHogApiKey },
|
||||||
|
{ "event", e.Event },
|
||||||
|
{ "timestamp", e.Timestamp.ToString("o") },
|
||||||
|
{ "properties", properties }
|
||||||
|
};
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(requestBody);
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(json);
|
||||||
|
|
||||||
|
var content = new System.Net.Http.ByteArrayContent(bytes);
|
||||||
|
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
|
||||||
|
|
||||||
|
var response = client.PostAsync(PostHogHost, content).GetAwaiter().GetResult();
|
||||||
|
var responseBody = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("UserBehaviorAnalytics", $"PostHog API error for event '{e.Event}': {response.StatusCode} - {responseBody}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AppLogger.Info("UserBehaviorAnalytics", $"Successfully sent {events.Count} events to PostHog.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("UserBehaviorAnalytics", "Failed to send events to PostHog API.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SendIdentifyToPostHog(System.Net.Http.HttpClient client, string distinctId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var userProperties = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "$device_id", distinctId },
|
||||||
|
{ "$app_version", GetAppVersion() },
|
||||||
|
{ "$os", GetOsName() },
|
||||||
|
{ "$os_version", GetOsVersion() }
|
||||||
|
};
|
||||||
|
|
||||||
|
var requestBody = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "api_key", PostHogApiKey },
|
||||||
|
{ "event", "$identify" },
|
||||||
|
{ "timestamp", DateTimeOffset.UtcNow.ToString("o") },
|
||||||
|
{ "properties", new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "distinct_id", distinctId },
|
||||||
|
{ "$set", userProperties }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(requestBody);
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(json);
|
||||||
|
|
||||||
|
var content = new System.Net.Http.ByteArrayContent(bytes);
|
||||||
|
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
|
||||||
|
|
||||||
|
var response = client.PostAsync(PostHogHost, content).GetAwaiter().GetResult();
|
||||||
|
var responseBody = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
AppLogger.Info("UserBehaviorAnalytics", $"PostHog identify response: {response.StatusCode}");
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("UserBehaviorAnalytics", $"PostHog identify failed: {response.StatusCode} - {responseBody}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("UserBehaviorAnalytics", "Failed to send identify to PostHog.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, object> GetEventProperties(UserBehaviorEvent e)
|
||||||
|
{
|
||||||
|
var props = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "$os", GetOsName() },
|
||||||
|
{ "$os_version", GetOsVersion() },
|
||||||
|
{ "$app_version", GetAppVersion() },
|
||||||
|
{ "$device_id", e.DistinctId }
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var kvp in e.Properties)
|
||||||
|
{
|
||||||
|
props[kvp.Key] = kvp.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return props;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsEnabled => _isEnabled;
|
||||||
|
|
||||||
|
public string DeviceId => _deviceIdService.DeviceId;
|
||||||
|
|
||||||
|
private static string GetAppVersion()
|
||||||
|
{
|
||||||
|
var assembly = typeof(UserBehaviorAnalyticsService).Assembly;
|
||||||
|
var version = assembly.GetName().Version;
|
||||||
|
return version is null ? "1.0.0" : $"{version.Major}.{version.Minor}.{version.Build}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetOsName()
|
||||||
|
{
|
||||||
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return "Windows";
|
||||||
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) return "Linux";
|
||||||
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) return "macOS";
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetOsVersion()
|
||||||
|
{
|
||||||
|
try { return Environment.OSVersion.VersionString ?? "Unknown"; }
|
||||||
|
catch { return "Unknown"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetDeviceName()
|
||||||
|
{
|
||||||
|
try { return Environment.MachineName ?? "Unknown"; }
|
||||||
|
catch { return "Unknown"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetDeviceModel()
|
||||||
|
{
|
||||||
|
var osDesc = RuntimeInformation.OSDescription;
|
||||||
|
if (osDesc.Contains("Windows")) return "Windows PC";
|
||||||
|
if (osDesc.Contains("Linux")) return "Linux PC";
|
||||||
|
if (osDesc.Contains("Darwin")) return "Mac";
|
||||||
|
return osDesc;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetDeviceArchitecture()
|
||||||
|
{
|
||||||
|
return RuntimeInformation.OSArchitecture.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetSystemLanguage()
|
||||||
|
{
|
||||||
|
try { return System.Globalization.CultureInfo.CurrentUICulture.Name ?? "en-US"; }
|
||||||
|
catch { return "en-US"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetOsBuild()
|
||||||
|
{
|
||||||
|
try { return Environment.OSVersion.Version.Build.ToString() ?? "Unknown"; }
|
||||||
|
catch { return "Unknown"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int GetProcessorCount()
|
||||||
|
{
|
||||||
|
return Environment.ProcessorCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long GetTotalMemoryMB()
|
||||||
|
{
|
||||||
|
try { return GC.GetGCMemoryInfo().TotalAvailableMemoryBytes / (1024 * 1024); }
|
||||||
|
catch { return 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetRuntimeVersion()
|
||||||
|
{
|
||||||
|
return Environment.Version.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetClrVersion()
|
||||||
|
{
|
||||||
|
return Environment.Version.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetDotNetVersion()
|
||||||
|
{
|
||||||
|
return Environment.Version.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_flushTimer?.Dispose();
|
||||||
|
FlushEvents();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("UserBehaviorAnalytics", "Error disposing analytics service.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class UserBehaviorEvent
|
||||||
|
{
|
||||||
|
public string Event { get; set; } = string.Empty;
|
||||||
|
public string DistinctId { get; set; } = string.Empty;
|
||||||
|
public DateTimeOffset Timestamp { get; set; }
|
||||||
|
public Dictionary<string, object> Properties { get; set; } = new();
|
||||||
|
public bool IncludeDetailedData { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class DictionaryExtensions
|
||||||
|
{
|
||||||
|
public static Dictionary<string, object> Merge(this Dictionary<string, object> first, Dictionary<string, object> second)
|
||||||
|
{
|
||||||
|
var result = new Dictionary<string, object>(first);
|
||||||
|
foreach (var kvp in second)
|
||||||
|
{
|
||||||
|
result[kvp.Key] = kvp.Value;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CrashReportService
|
||||||
|
{
|
||||||
|
private const string SentryDsn = "https://f2aad3a1c63b5f2213ad82683ce93c06@o4511049423257600.ingest.us.sentry.io/4511049425813504";
|
||||||
|
|
||||||
|
private bool _isInitialized;
|
||||||
|
private bool _isEnabled;
|
||||||
|
private readonly ISettingsFacadeService _settingsFacade;
|
||||||
|
private readonly DeviceIdService _deviceIdService;
|
||||||
|
private readonly PluginSdk.ISettingsService _settingsService;
|
||||||
|
|
||||||
|
public CrashReportService(ISettingsFacadeService settingsFacade, DeviceIdService deviceIdService)
|
||||||
|
{
|
||||||
|
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
|
||||||
|
_settingsService = settingsFacade.Settings;
|
||||||
|
_deviceIdService = deviceIdService ?? throw new ArgumentNullException(nameof(deviceIdService));
|
||||||
|
_settingsService.Changed += OnSettingsChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSettingsChanged(object? sender, PluginSdk.SettingsChangedEvent e)
|
||||||
|
{
|
||||||
|
if (e.Scope == PluginSdk.SettingsScope.App &&
|
||||||
|
e.ChangedKeys is not null &&
|
||||||
|
(e.ChangedKeys.Contains("UploadAnonymousCrashData") || e.ChangedKeys.Contains("UploadAnonymousUsageData")))
|
||||||
|
{
|
||||||
|
AppLogger.Info("CrashReport", "Settings changed, refreshing enabled state.");
|
||||||
|
RefreshEnabledState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RefreshEnabledState()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||||
|
var newEnabled = snapshot.UploadAnonymousCrashData;
|
||||||
|
|
||||||
|
if (_isEnabled != newEnabled)
|
||||||
|
{
|
||||||
|
_isEnabled = newEnabled;
|
||||||
|
AppLogger.Info("CrashReport", $"Crash reporting enabled state changed to '{_isEnabled}'.");
|
||||||
|
|
||||||
|
if (_isEnabled && !_isInitialized)
|
||||||
|
{
|
||||||
|
InitializeSentry();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("CrashReport", "Failed to refresh crash reporting enabled state.", ex);
|
||||||
|
_isEnabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InitializeSentry()
|
||||||
|
{
|
||||||
|
if (_isInitialized)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isInitialized = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
SentrySdk.Init(options =>
|
||||||
|
{
|
||||||
|
options.Dsn = SentryDsn;
|
||||||
|
options.AutoSessionTracking = true;
|
||||||
|
options.AttachStacktrace = true;
|
||||||
|
options.MaxBreadcrumbs = 100;
|
||||||
|
options.Release = GetAppVersion();
|
||||||
|
options.Environment = GetEnvironment();
|
||||||
|
});
|
||||||
|
|
||||||
|
ConfigureCrashReportingScope();
|
||||||
|
|
||||||
|
AppLogger.Info("CrashReport", $"Sentry crash reporting initialized. DeviceId={_deviceIdService.DeviceId}");
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
SentrySdk.CaptureMessage($"Crash reporting enabled - Debug mode test. DeviceId={_deviceIdService.DeviceId}");
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("CrashReport", "Failed to initialize Sentry crash reporting.", ex);
|
||||||
|
_isInitialized = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ConfigureCrashReportingScope()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
SentrySdk.ConfigureScope(scope =>
|
||||||
|
{
|
||||||
|
scope.User = new SentryUser
|
||||||
|
{
|
||||||
|
Id = _deviceIdService.DeviceId
|
||||||
|
};
|
||||||
|
|
||||||
|
scope.SetTag("data_type", "crash_report");
|
||||||
|
scope.SetTag("device_id", _deviceIdService.DeviceId);
|
||||||
|
scope.SetTag("device_name", GetDeviceName());
|
||||||
|
scope.SetTag("device_model", GetDeviceModel());
|
||||||
|
scope.SetTag("device_arch", GetDeviceArchitecture());
|
||||||
|
scope.SetTag("os_name", GetOsName());
|
||||||
|
scope.SetTag("os_version", GetOsVersion());
|
||||||
|
scope.SetTag("language", GetSystemLanguage());
|
||||||
|
});
|
||||||
|
|
||||||
|
AppLogger.Info("CrashReport", $"Crash reporting scope configured. DeviceId={_deviceIdService.DeviceId}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("CrashReport", "Failed to configure crash reporting scope.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsEnabled => _isEnabled;
|
||||||
|
|
||||||
|
public string DeviceId => _deviceIdService.DeviceId;
|
||||||
|
|
||||||
|
public void SendShutdownEvent()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_isEnabled && _isInitialized)
|
||||||
|
{
|
||||||
|
AppLogger.Info("CrashReport", $"Shutdown event will be sent via Sentry. DeviceId={_deviceIdService.DeviceId}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_isInitialized)
|
||||||
|
{
|
||||||
|
SentrySdk.Init(options =>
|
||||||
|
{
|
||||||
|
options.Dsn = SentryDsn;
|
||||||
|
options.AutoSessionTracking = false;
|
||||||
|
options.Release = GetAppVersion();
|
||||||
|
options.Environment = GetEnvironment();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
SentrySdk.ConfigureScope(scope =>
|
||||||
|
{
|
||||||
|
scope.User = new SentryUser
|
||||||
|
{
|
||||||
|
Id = _deviceIdService.DeviceId
|
||||||
|
};
|
||||||
|
scope.SetTag("data_type", "shutdown");
|
||||||
|
scope.SetTag("device_id", _deviceIdService.DeviceId);
|
||||||
|
scope.SetTag("app_version", GetAppVersion());
|
||||||
|
});
|
||||||
|
|
||||||
|
SentrySdk.CaptureMessage($"app_shutdown - DeviceId={_deviceIdService.DeviceId}");
|
||||||
|
SentrySdk.Flush(TimeSpan.FromSeconds(3));
|
||||||
|
|
||||||
|
AppLogger.Info("CrashReport", $"Shutdown event sent. DeviceId={_deviceIdService.DeviceId}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("CrashReport", "Failed to send shutdown event.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetDeviceName()
|
||||||
|
{
|
||||||
|
try { return Environment.MachineName ?? "Unknown"; }
|
||||||
|
catch { return "Unknown"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetDeviceModel()
|
||||||
|
{
|
||||||
|
var osDesc = RuntimeInformation.OSDescription;
|
||||||
|
if (osDesc.Contains("Windows")) return "Windows PC";
|
||||||
|
if (osDesc.Contains("Linux")) return "Linux PC";
|
||||||
|
if (osDesc.Contains("Darwin")) return "Mac";
|
||||||
|
return osDesc;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetDeviceArchitecture()
|
||||||
|
{
|
||||||
|
return RuntimeInformation.OSArchitecture.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetOsName()
|
||||||
|
{
|
||||||
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return "Windows";
|
||||||
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) return "Linux";
|
||||||
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) return "macOS";
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetOsVersion()
|
||||||
|
{
|
||||||
|
try { return Environment.OSVersion.VersionString ?? "Unknown"; }
|
||||||
|
catch { return "Unknown"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetSystemLanguage()
|
||||||
|
{
|
||||||
|
try { return System.Globalization.CultureInfo.CurrentUICulture.Name ?? "en-US"; }
|
||||||
|
catch { return "en-US"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetAppVersion()
|
||||||
|
{
|
||||||
|
var version = typeof(CrashReportService).Assembly.GetName().Version;
|
||||||
|
return version is null ? "1.0.0" : $"{version.Major}.{version.Minor}.{version.Build}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetEnvironment()
|
||||||
|
{
|
||||||
|
#if DEBUG
|
||||||
|
return "development";
|
||||||
|
#else
|
||||||
|
return "production";
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
153
LanMountainDesktop/Services/OfficeRecentDocumentsService.cs
Normal file
153
LanMountainDesktop/Services/OfficeRecentDocumentsService.cs
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using LanMountainDesktop.Services.Settings;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class OfficeRecentDocumentsService : IOfficeRecentDocumentsService
|
||||||
|
{
|
||||||
|
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" };
|
||||||
|
|
||||||
|
public List<OfficeRecentDocument> GetRecentDocuments(int maxCount = 20)
|
||||||
|
{
|
||||||
|
var documents = new List<OfficeRecentDocument>();
|
||||||
|
var recentPaths = GetRecentFolders();
|
||||||
|
|
||||||
|
foreach (var recentPath in recentPaths)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(recentPath))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var files = Directory.GetFiles(recentPath, "*.lnk");
|
||||||
|
foreach (var lnkPath in files)
|
||||||
|
{
|
||||||
|
var targetPath = GetShortcutTarget(lnkPath);
|
||||||
|
if (string.IsNullOrEmpty(targetPath))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var extension = Path.GetExtension(targetPath).ToLowerInvariant();
|
||||||
|
if (!IsOfficeFile(extension))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!System.IO.File.Exists(targetPath))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fileInfo = new FileInfo(targetPath);
|
||||||
|
var doc = new OfficeRecentDocument
|
||||||
|
{
|
||||||
|
FileName = Path.GetFileNameWithoutExtension(targetPath),
|
||||||
|
FilePath = targetPath,
|
||||||
|
Extension = extension,
|
||||||
|
LastModifiedTime = fileInfo.LastWriteTime,
|
||||||
|
FileSizeBytes = fileInfo.Length,
|
||||||
|
IconGlyph = GetIconGlyph(extension)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!documents.Any(d => d.FilePath == targetPath))
|
||||||
|
{
|
||||||
|
documents.Add(doc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return documents
|
||||||
|
.OrderByDescending(d => d.LastModifiedTime)
|
||||||
|
.Take(maxCount)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OpenDocument(string filePath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = filePath,
|
||||||
|
UseShellExecute = true
|
||||||
|
};
|
||||||
|
Process.Start(startInfo);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> GetRecentFolders()
|
||||||
|
{
|
||||||
|
var folders = new List<string>();
|
||||||
|
|
||||||
|
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
|
||||||
|
folders.Add(Path.Combine(appData, "Microsoft", "Word", "Recent"));
|
||||||
|
folders.Add(Path.Combine(appData, "Microsoft", "Excel", "Recent"));
|
||||||
|
folders.Add(Path.Combine(appData, "Microsoft", "PowerPoint", "Recent"));
|
||||||
|
|
||||||
|
return folders;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsOfficeFile(string extension)
|
||||||
|
{
|
||||||
|
return OfficeExtensions.Contains(extension) ||
|
||||||
|
ExcelExtensions.Contains(extension) ||
|
||||||
|
PowerPointExtensions.Contains(extension);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,12 +11,11 @@ namespace LanMountainDesktop.Services.Settings;
|
|||||||
public enum WallpaperMediaType
|
public enum WallpaperMediaType
|
||||||
{
|
{
|
||||||
None,
|
None,
|
||||||
Image,
|
Image
|
||||||
Video
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record GridSettingsState(int ShortSideCells, string SpacingPreset, int EdgeInsetPercent);
|
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(
|
public sealed record ThemeAppearanceSettingsState(
|
||||||
bool IsNightMode,
|
bool IsNightMode,
|
||||||
string? ThemeColor,
|
string? ThemeColor,
|
||||||
|
|||||||
@@ -147,11 +147,6 @@ internal sealed class WallpaperMediaService : IWallpaperMediaService
|
|||||||
".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp"
|
".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;
|
private readonly string _wallpapersDirectory;
|
||||||
|
|
||||||
public WallpaperMediaService()
|
public WallpaperMediaService()
|
||||||
@@ -180,11 +175,6 @@ internal sealed class WallpaperMediaService : IWallpaperMediaService
|
|||||||
return WallpaperMediaType.Image;
|
return WallpaperMediaType.Image;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (VideoExtensions.Contains(extension))
|
|
||||||
{
|
|
||||||
return WallpaperMediaType.Video;
|
|
||||||
}
|
|
||||||
|
|
||||||
return WallpaperMediaType.None;
|
return WallpaperMediaType.None;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -609,6 +599,7 @@ internal sealed class PrivacySettingsService : IPrivacySettingsService
|
|||||||
var snapshot = _settingsService.Load();
|
var snapshot = _settingsService.Load();
|
||||||
snapshot.UploadAnonymousCrashData = state.UploadAnonymousCrashData;
|
snapshot.UploadAnonymousCrashData = state.UploadAnonymousCrashData;
|
||||||
snapshot.UploadAnonymousUsageData = state.UploadAnonymousUsageData;
|
snapshot.UploadAnonymousUsageData = state.UploadAnonymousUsageData;
|
||||||
|
AppLogger.Info("PrivacySettings", $"Saving: UploadAnonymousCrashData={state.UploadAnonymousCrashData}, UploadAnonymousUsageData={state.UploadAnonymousUsageData}");
|
||||||
_settingsService.SaveSnapshot(
|
_settingsService.SaveSnapshot(
|
||||||
SettingsScope.App,
|
SettingsScope.App,
|
||||||
snapshot,
|
snapshot,
|
||||||
|
|||||||
32
LanMountainDesktop/Services/ShortcutHelper.cs
Normal file
32
LanMountainDesktop/Services/ShortcutHelper.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,9 @@ public static class ThemeAppearanceValues
|
|||||||
public const string ColorModeSeedMonet = "seed_monet";
|
public const string ColorModeSeedMonet = "seed_monet";
|
||||||
public const string ColorModeWallpaperMonet = "wallpaper_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 MaterialNone = "none";
|
||||||
public const string MaterialMica = "mica";
|
public const string MaterialMica = "mica";
|
||||||
public const string MaterialAcrylic = "acrylic";
|
public const string MaterialAcrylic = "acrylic";
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
using System;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
using LanMountainDesktop.Services.Settings;
|
using LanMountainDesktop.Services.Settings;
|
||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
namespace LanMountainDesktop.ViewModels;
|
namespace LanMountainDesktop.ViewModels;
|
||||||
|
|
||||||
@@ -28,6 +32,9 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool _uploadAnonymousUsageData;
|
private bool _uploadAnonymousUsageData;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _deviceId = string.Empty;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _privacyHeader = string.Empty;
|
private string _privacyHeader = string.Empty;
|
||||||
|
|
||||||
@@ -43,11 +50,47 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _usageUploadDescription = string.Empty;
|
private string _usageUploadDescription = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _deviceIdHeader = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _deviceIdDescription = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _refreshDeviceIdText = string.Empty;
|
||||||
|
|
||||||
public void Load()
|
public void Load()
|
||||||
{
|
{
|
||||||
var state = _settingsFacade.Privacy.Get();
|
var state = _settingsFacade.Privacy.Get();
|
||||||
UploadAnonymousCrashData = state.UploadAnonymousCrashData;
|
UploadAnonymousCrashData = state.UploadAnonymousCrashData;
|
||||||
UploadAnonymousUsageData = state.UploadAnonymousUsageData;
|
UploadAnonymousUsageData = state.UploadAnonymousUsageData;
|
||||||
|
DeviceId = DeviceIdService.Instance.DeviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void RefreshDeviceId()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var deviceInfo = $"{Environment.MachineName}|{Environment.ProcessorCount}|{Environment.OSVersion}|{Environment.UserName}|{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}";
|
||||||
|
using var sha = System.Security.Cryptography.SHA256.Create();
|
||||||
|
var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(deviceInfo));
|
||||||
|
var newDeviceId = Convert.ToHexString(hash)[..32].ToLower();
|
||||||
|
|
||||||
|
var snapshot = _settingsFacade.Settings.LoadSnapshot<Models.AppSettingsSnapshot>(SettingsScope.App);
|
||||||
|
snapshot.DeviceId = newDeviceId;
|
||||||
|
_settingsFacade.Settings.SaveSnapshot(
|
||||||
|
SettingsScope.App,
|
||||||
|
snapshot,
|
||||||
|
changedKeys: [nameof(Models.AppSettingsSnapshot.DeviceId)]);
|
||||||
|
|
||||||
|
DeviceId = newDeviceId;
|
||||||
|
AppLogger.Info("PrivacySettings", $"Device ID refreshed: {newDeviceId}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("PrivacySettings", "Failed to refresh device ID.", ex);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnUploadAnonymousCrashDataChanged(bool value)
|
partial void OnUploadAnonymousCrashDataChanged(bool value)
|
||||||
@@ -84,6 +127,9 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase
|
|||||||
CrashUploadDescription = L("settings.privacy.crash_upload_description", "Help us improve application stability.");
|
CrashUploadDescription = L("settings.privacy.crash_upload_description", "Help us improve application stability.");
|
||||||
UsageUploadHeader = L("settings.privacy.usage_upload_title", "Anonymous usage data uploads");
|
UsageUploadHeader = L("settings.privacy.usage_upload_title", "Anonymous usage data uploads");
|
||||||
UsageUploadDescription = L("settings.privacy.usage_upload_description", "Help us improve application features.");
|
UsageUploadDescription = L("settings.privacy.usage_upload_description", "Help us improve application features.");
|
||||||
|
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");
|
||||||
}
|
}
|
||||||
|
|
||||||
private string L(string key, string fallback)
|
private string L(string key, string fallback)
|
||||||
|
|||||||
@@ -82,23 +82,24 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool _isImage;
|
private bool _isImage;
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private bool _isVideo;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private Bitmap? _previewImage;
|
private Bitmap? _previewImage;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private IBrush? _previewBrush;
|
private IBrush? _previewBrush;
|
||||||
|
|
||||||
|
// 自定义颜色持久化
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _videoModeHintText = string.Empty;
|
private Color _customColor = Colors.White;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private IBrush _customColorBrush = new SolidColorBrush(Colors.White);
|
||||||
|
|
||||||
public void Load()
|
public void Load()
|
||||||
{
|
{
|
||||||
var wallpaper = _settingsFacade.Wallpaper.Get();
|
var wallpaper = _settingsFacade.Wallpaper.Get();
|
||||||
WallpaperPath = wallpaper.WallpaperPath ?? string.Empty;
|
WallpaperPath = wallpaper.WallpaperPath ?? string.Empty;
|
||||||
|
|
||||||
SelectedWallpaperType = WallpaperTypes.FirstOrDefault(t => t.Value == wallpaper.Type) ?? WallpaperTypes[0];
|
SelectedWallpaperType = WallpaperTypes.FirstOrDefault(t => t.Value == wallpaper.Type) ?? WallpaperTypes[0];
|
||||||
SelectedColor = wallpaper.Color ?? PresetColors[0];
|
SelectedColor = wallpaper.Color ?? PresetColors[0];
|
||||||
|
|
||||||
@@ -108,7 +109,14 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
|
|||||||
SelectedWallpaperPlacement = WallpaperPlacements.FirstOrDefault(option =>
|
SelectedWallpaperPlacement = WallpaperPlacements.FirstOrDefault(option =>
|
||||||
string.Equals(option.Value, wallpaperPlacement, StringComparison.OrdinalIgnoreCase))
|
string.Equals(option.Value, wallpaperPlacement, StringComparison.OrdinalIgnoreCase))
|
||||||
?? WallpaperPlacements[0];
|
?? WallpaperPlacements[0];
|
||||||
|
|
||||||
|
// 加载自定义颜色
|
||||||
|
if (!string.IsNullOrWhiteSpace(wallpaper.CustomColor) && Color.TryParse(wallpaper.CustomColor, out var customColor))
|
||||||
|
{
|
||||||
|
CustomColor = customColor;
|
||||||
|
CustomColorBrush = new SolidColorBrush(customColor);
|
||||||
|
}
|
||||||
|
|
||||||
UpdateVisibility();
|
UpdateVisibility();
|
||||||
UpdatePreviewFromCurrentSelection();
|
UpdatePreviewFromCurrentSelection();
|
||||||
}
|
}
|
||||||
@@ -124,8 +132,7 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
|
|||||||
private void UpdateVisibility()
|
private void UpdateVisibility()
|
||||||
{
|
{
|
||||||
IsImage = SelectedWallpaperType?.Value == "Image";
|
IsImage = SelectedWallpaperType?.Value == "Image";
|
||||||
IsVideo = SelectedWallpaperType?.Value == "Video";
|
IsImageOrVideo = IsImage;
|
||||||
IsImageOrVideo = SelectedWallpaperType?.Value is "Image" or "Video";
|
|
||||||
IsSolidColor = SelectedWallpaperType?.Value == "SolidColor";
|
IsSolidColor = SelectedWallpaperType?.Value == "SolidColor";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +142,16 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
|
|||||||
SaveWallpaper();
|
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)
|
public async Task ImportWallpaperAsync(string sourcePath)
|
||||||
{
|
{
|
||||||
var importedPath = await _settingsFacade.WallpaperMedia.ImportAssetAsync(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)
|
var normalizedPath = SelectedWallpaperType?.Value == "SolidColor" || string.IsNullOrWhiteSpace(WallpaperPath)
|
||||||
? null
|
? null
|
||||||
: WallpaperPath;
|
: WallpaperPath;
|
||||||
|
var customColorHex = $"#{CustomColor.A:X2}{CustomColor.R:X2}{CustomColor.G:X2}{CustomColor.B:X2}";
|
||||||
_settingsFacade.Wallpaper.Save(new WallpaperSettingsState(
|
_settingsFacade.Wallpaper.Save(new WallpaperSettingsState(
|
||||||
normalizedPath,
|
normalizedPath,
|
||||||
selectedType,
|
selectedType,
|
||||||
SelectedColor,
|
SelectedColor,
|
||||||
selectedPlacement));
|
selectedPlacement,
|
||||||
|
customColorHex));
|
||||||
}
|
}
|
||||||
|
|
||||||
private IReadOnlyList<SelectionOption> CreateWallpaperPlacements()
|
private IReadOnlyList<SelectionOption> CreateWallpaperPlacements()
|
||||||
@@ -246,7 +265,6 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
|
|||||||
return
|
return
|
||||||
[
|
[
|
||||||
new SelectionOption("Image", L("settings.wallpaper.type.image", "Image")),
|
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"))
|
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",
|
"#D8A7B1", "#B6C9BB", "#A2B5BB", "#E6E2D3",
|
||||||
"#B5A397", "#C5C1C0", "#D4BE8D", "#C08261",
|
"#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.");
|
WallpaperPlacementDescription = L("settings.wallpaper.placement_desc", "Adjust how the image fills the desktop.");
|
||||||
ImportWallpaperButtonText = L("settings.wallpaper.pick_button", "Import Wallpaper");
|
ImportWallpaperButtonText = L("settings.wallpaper.pick_button", "Import Wallpaper");
|
||||||
FilePickerTitle = L("filepicker.title", "Select 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)
|
private string L(string key, string fallback)
|
||||||
|
|||||||
@@ -17,6 +17,22 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<Border Classes="component-editor-card"
|
||||||
|
Padding="20">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<TextBlock x:Name="ColorSchemeHeaderTextBlock"
|
||||||
|
Classes="component-editor-section-title" />
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<RadioButton x:Name="FollowSystemRadioButton"
|
||||||
|
GroupName="ColorScheme"
|
||||||
|
IsCheckedChanged="OnColorSchemeChanged" />
|
||||||
|
<RadioButton x:Name="UseNativeRadioButton"
|
||||||
|
GroupName="ColorScheme"
|
||||||
|
IsCheckedChanged="OnColorSchemeChanged" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<Border Classes="component-editor-card"
|
<Border Classes="component-editor-card"
|
||||||
Padding="20">
|
Padding="20">
|
||||||
<StackPanel Spacing="12">
|
<StackPanel Spacing="12">
|
||||||
|
|||||||
@@ -11,13 +11,15 @@ using Avalonia.Media;
|
|||||||
using Avalonia.Platform.Storage;
|
using Avalonia.Platform.Storage;
|
||||||
using LanMountainDesktop.ComponentSystem;
|
using LanMountainDesktop.ComponentSystem;
|
||||||
using LanMountainDesktop.Models;
|
using LanMountainDesktop.Models;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Views.ComponentEditors;
|
namespace LanMountainDesktop.Views.ComponentEditors;
|
||||||
|
|
||||||
public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
|
public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
|
||||||
{
|
{
|
||||||
private readonly List<ImportedClassScheduleSnapshot> _importedSchedules = [];
|
private readonly List<ImportedClassScheduleSnapshot> _importedSchedules = [];
|
||||||
private string _activeScheduleId = string.Empty;
|
private string? _activeScheduleId;
|
||||||
|
private bool _suppressEvents;
|
||||||
|
|
||||||
public ClassScheduleComponentEditor()
|
public ClassScheduleComponentEditor()
|
||||||
: this(null)
|
: this(null)
|
||||||
@@ -62,10 +64,49 @@ public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
|
|||||||
|
|
||||||
private void ApplyState()
|
private void ApplyState()
|
||||||
{
|
{
|
||||||
|
var snapshot = LoadSnapshot();
|
||||||
|
var colorSchemeSource = snapshot.ColorSchemeSource;
|
||||||
|
|
||||||
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Class Schedule";
|
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Class Schedule";
|
||||||
DescriptionTextBlock.Text = L("schedule.settings.desc", "导入 ClassIsland 的 CSES 课表文件并选择启用项。");
|
DescriptionTextBlock.Text = L("schedule.settings.desc", "导入 ClassIsland 的 CSES 课表文件并选择启用项。");
|
||||||
|
|
||||||
|
ColorSchemeHeaderTextBlock.Text = L("component.settings.color_scheme", "配色方案");
|
||||||
|
FollowSystemRadioButton.Content = L("component.color_scheme.follow_system", "跟随系统配色");
|
||||||
|
UseNativeRadioButton.Content = L("component.color_scheme.native", "使用组件自定义配色");
|
||||||
|
|
||||||
AddScheduleButton.Content = L("schedule.settings.add", "添加课表");
|
AddScheduleButton.Content = L("schedule.settings.add", "添加课表");
|
||||||
EmptyStateTextBlock.Text = L("schedule.settings.empty", "暂无导入课表");
|
EmptyStateTextBlock.Text = L("schedule.settings.empty", "暂无导入课表");
|
||||||
|
|
||||||
|
_suppressEvents = true;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(colorSchemeSource) ||
|
||||||
|
colorSchemeSource == ThemeAppearanceValues.ColorSchemeFollowSystem)
|
||||||
|
{
|
||||||
|
FollowSystemRadioButton.IsChecked = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
UseNativeRadioButton.IsChecked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_suppressEvents = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnColorSchemeChanged(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_suppressEvents)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var useNative = UseNativeRadioButton.IsChecked == true;
|
||||||
|
var colorSchemeSource = useNative
|
||||||
|
? ThemeAppearanceValues.ColorSchemeNative
|
||||||
|
: ThemeAppearanceValues.ColorSchemeFollowSystem;
|
||||||
|
|
||||||
|
var snapshot = LoadSnapshot();
|
||||||
|
snapshot.ColorSchemeSource = colorSchemeSource;
|
||||||
|
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.ColorSchemeSource));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void OnAddScheduleClick(object? sender, RoutedEventArgs e)
|
private async void OnAddScheduleClick(object? sender, RoutedEventArgs e)
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||||
mc:Ignorable="d"
|
mc:Ignorable="d"
|
||||||
x:Class="LanMountainDesktop.Views.ComponentEditors.StudyEnvironmentComponentEditor">
|
x:Class="LanMountainDesktop.Views.ComponentEditors.StudyEnvironmentComponentEditor">
|
||||||
<StackPanel Spacing="16">
|
<StackPanel Spacing="16">
|
||||||
<Border Classes="component-editor-hero-card"
|
<Border Classes="component-editor-hero_card"
|
||||||
Padding="24">
|
Padding="24">
|
||||||
<StackPanel Spacing="8">
|
<StackPanel Spacing="8">
|
||||||
<TextBlock x:Name="HeadlineTextBlock"
|
<TextBlock x:Name="HeadlineTextBlock"
|
||||||
@@ -17,6 +18,22 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<Border Classes="component-editor-card"
|
||||||
|
Padding="20">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<TextBlock x:Name="ColorSchemeHeaderTextBlock"
|
||||||
|
Classes="component-editor-section-title" />
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<RadioButton x:Name="FollowSystemRadioButton"
|
||||||
|
GroupName="ColorScheme"
|
||||||
|
IsCheckedChanged="OnColorSchemeChanged" />
|
||||||
|
<RadioButton x:Name="UseNativeRadioButton"
|
||||||
|
GroupName="ColorScheme"
|
||||||
|
IsCheckedChanged="OnColorSchemeChanged" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<Border Classes="component-editor-card"
|
<Border Classes="component-editor-card"
|
||||||
Padding="20">
|
Padding="20">
|
||||||
<StackPanel Spacing="12">
|
<StackPanel Spacing="12">
|
||||||
@@ -27,7 +44,7 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<Border Classes="component-editor-card"
|
<Border Classes="component-editor_card"
|
||||||
Padding="20">
|
Padding="20">
|
||||||
<StackPanel Spacing="12">
|
<StackPanel Spacing="12">
|
||||||
<TextBlock x:Name="DbfsHeaderTextBlock"
|
<TextBlock x:Name="DbfsHeaderTextBlock"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using LanMountainDesktop.ComponentSystem;
|
using LanMountainDesktop.ComponentSystem;
|
||||||
using LanMountainDesktop.Models;
|
using LanMountainDesktop.Models;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Views.ComponentEditors;
|
namespace LanMountainDesktop.Views.ComponentEditors;
|
||||||
|
|
||||||
@@ -25,6 +26,8 @@ public partial class StudyEnvironmentComponentEditor : ComponentEditorViewBase
|
|||||||
var snapshot = LoadSnapshot();
|
var snapshot = LoadSnapshot();
|
||||||
var showDisplayDb = snapshot.StudyEnvironmentShowDisplayDb;
|
var showDisplayDb = snapshot.StudyEnvironmentShowDisplayDb;
|
||||||
var showDbfs = snapshot.StudyEnvironmentShowDbfs;
|
var showDbfs = snapshot.StudyEnvironmentShowDbfs;
|
||||||
|
var colorSchemeSource = snapshot.ColorSchemeSource;
|
||||||
|
|
||||||
if (!showDisplayDb && !showDbfs)
|
if (!showDisplayDb && !showDbfs)
|
||||||
{
|
{
|
||||||
showDisplayDb = true;
|
showDisplayDb = true;
|
||||||
@@ -32,16 +35,49 @@ public partial class StudyEnvironmentComponentEditor : ComponentEditorViewBase
|
|||||||
|
|
||||||
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Study Environment";
|
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Study Environment";
|
||||||
DescriptionTextBlock.Text = L("study.environment.settings.desc", "配置右侧实时噪音值显示内容。");
|
DescriptionTextBlock.Text = L("study.environment.settings.desc", "配置右侧实时噪音值显示内容。");
|
||||||
|
|
||||||
|
ColorSchemeHeaderTextBlock.Text = L("component.settings.color_scheme", "配色方案");
|
||||||
|
FollowSystemRadioButton.Content = L("component.color_scheme.follow_system", "跟随系统配色");
|
||||||
|
UseNativeRadioButton.Content = L("component.color_scheme.native", "使用组件自定义配色");
|
||||||
|
|
||||||
DisplayDbToggleSwitch.Content = L("study.environment.settings.show_display_db", "显示 display dB");
|
DisplayDbToggleSwitch.Content = L("study.environment.settings.show_display_db", "显示 display dB");
|
||||||
DbfsToggleSwitch.Content = L("study.environment.settings.show_dbfs", "显示 dBFS");
|
DbfsToggleSwitch.Content = L("study.environment.settings.show_dbfs", "显示 dBFS");
|
||||||
HintTextBlock.Text = L("study.environment.settings.hint", "至少启用一种显示方式。");
|
HintTextBlock.Text = L("study.environment.settings.hint", "至少启用一种显示方式。");
|
||||||
|
|
||||||
_suppressEvents = true;
|
_suppressEvents = true;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(colorSchemeSource) ||
|
||||||
|
colorSchemeSource == ThemeAppearanceValues.ColorSchemeFollowSystem)
|
||||||
|
{
|
||||||
|
FollowSystemRadioButton.IsChecked = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
UseNativeRadioButton.IsChecked = true;
|
||||||
|
}
|
||||||
|
|
||||||
DisplayDbToggleSwitch.IsChecked = showDisplayDb;
|
DisplayDbToggleSwitch.IsChecked = showDisplayDb;
|
||||||
DbfsToggleSwitch.IsChecked = showDbfs;
|
DbfsToggleSwitch.IsChecked = showDbfs;
|
||||||
_suppressEvents = false;
|
_suppressEvents = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnColorSchemeChanged(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_suppressEvents)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var useNative = UseNativeRadioButton.IsChecked == true;
|
||||||
|
var colorSchemeSource = useNative
|
||||||
|
? ThemeAppearanceValues.ColorSchemeNative
|
||||||
|
: ThemeAppearanceValues.ColorSchemeFollowSystem;
|
||||||
|
|
||||||
|
var snapshot = LoadSnapshot();
|
||||||
|
snapshot.ColorSchemeSource = colorSchemeSource;
|
||||||
|
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.ColorSchemeSource));
|
||||||
|
}
|
||||||
|
|
||||||
private void OnToggleChanged(object? sender, RoutedEventArgs e)
|
private void OnToggleChanged(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
_ = sender;
|
_ = sender;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ using Avalonia.Layout;
|
|||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
using Avalonia.Styling;
|
using Avalonia.Styling;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
|
using LanMountainDesktop.ComponentSystem;
|
||||||
using LanMountainDesktop.Models;
|
using LanMountainDesktop.Models;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
|
|
||||||
@@ -50,6 +51,7 @@ public partial class BaiduHotSearchWidget : UserControl, IDesktopComponentWidget
|
|||||||
private bool _autoRefreshEnabled = true;
|
private bool _autoRefreshEnabled = true;
|
||||||
private string _sourceType = BaiduHotSearchSourceTypes.Official;
|
private string _sourceType = BaiduHotSearchSourceTypes.Official;
|
||||||
private bool _isNightVisual = true;
|
private bool _isNightVisual = true;
|
||||||
|
private string? _componentColorScheme;
|
||||||
|
|
||||||
private sealed record HotItemVisual(
|
private sealed record HotItemVisual(
|
||||||
Border Host,
|
Border Host,
|
||||||
@@ -180,17 +182,25 @@ public partial class BaiduHotSearchWidget : UserControl, IDesktopComponentWidget
|
|||||||
|
|
||||||
private void ApplyNightModeVisual()
|
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"));
|
CardBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#1B2129") : Color.Parse("#FCFCFD"));
|
||||||
RootBorder.BorderBrush = new SolidColorBrush(_isNightVisual ? Color.Parse("#33FFFFFF") : Color.Parse("#00000000"));
|
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"));
|
RefreshButton.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#EFF1F5"));
|
||||||
RefreshGlyphIcon.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#5E6671"));
|
RefreshGlyphIcon.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#5E6671"));
|
||||||
|
|
||||||
foreach (var visual in _hotItemVisuals)
|
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"));
|
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;
|
enabled = snapshot.BaiduHotSearchAutoRefreshEnabled;
|
||||||
intervalMinutes = NormalizeAutoRefreshIntervalMinutes(snapshot.BaiduHotSearchAutoRefreshIntervalMinutes);
|
intervalMinutes = NormalizeAutoRefreshIntervalMinutes(snapshot.BaiduHotSearchAutoRefreshIntervalMinutes);
|
||||||
sourceType = BaiduHotSearchSourceTypes.Normalize(snapshot.BaiduHotSearchSourceType);
|
sourceType = BaiduHotSearchSourceTypes.Normalize(snapshot.BaiduHotSearchSourceType);
|
||||||
|
_componentColorScheme = snapshot.ColorSchemeSource;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// Keep fallback defaults.
|
_componentColorScheme = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
_autoRefreshEnabled = enabled;
|
_autoRefreshEnabled = enabled;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
using Avalonia.Styling;
|
using Avalonia.Styling;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
@@ -25,9 +26,17 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
|||||||
|
|
||||||
private readonly DispatcherTimer _refreshTimer = new()
|
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 ISettingsService _settingsService = HostSettingsFacadeProvider.GetOrCreate().Settings;
|
||||||
private readonly LocalizationService _localizationService = new();
|
private readonly LocalizationService _localizationService = new();
|
||||||
private readonly IClassIslandScheduleDataService _scheduleService = new ClassIslandScheduleDataService();
|
private readonly IClassIslandScheduleDataService _scheduleService = new ClassIslandScheduleDataService();
|
||||||
@@ -39,6 +48,7 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
|||||||
private string _languageCode = "zh-CN";
|
private string _languageCode = "zh-CN";
|
||||||
private string _componentId = BuiltInComponentIds.DesktopClassSchedule;
|
private string _componentId = BuiltInComponentIds.DesktopClassSchedule;
|
||||||
private string _placementId = string.Empty;
|
private string _placementId = string.Empty;
|
||||||
|
private string? _componentColorScheme;
|
||||||
|
|
||||||
public ClassScheduleWidget()
|
public ClassScheduleWidget()
|
||||||
{
|
{
|
||||||
@@ -50,6 +60,10 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
|||||||
SizeChanged += OnSizeChanged;
|
SizeChanged += OnSizeChanged;
|
||||||
ActualThemeVariantChanged += OnActualThemeVariantChanged;
|
ActualThemeVariantChanged += OnActualThemeVariantChanged;
|
||||||
|
|
||||||
|
ContentScrollViewer.PointerPressed += OnScrollViewerPointerPressed;
|
||||||
|
ContentScrollViewer.PointerMoved += OnScrollViewerPointerMoved;
|
||||||
|
ContentScrollViewer.PointerReleased += OnScrollViewerPointerReleased;
|
||||||
|
|
||||||
ApplyCellSize(_currentCellSize);
|
ApplyCellSize(_currentCellSize);
|
||||||
RefreshSchedule();
|
RefreshSchedule();
|
||||||
}
|
}
|
||||||
@@ -107,9 +121,89 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
|||||||
RefreshSchedule();
|
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)
|
private void OnRefreshTimerTick(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
|
var now = _timeZoneService?.GetCurrentTime() ?? DateTime.Now;
|
||||||
|
var currentDate = DateOnly.FromDateTime(now);
|
||||||
|
|
||||||
|
var previousCourseIndex = _lastCurrentCourseIndex;
|
||||||
|
|
||||||
RefreshSchedule();
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (courseIndex < CourseListPanel.Children.Count)
|
||||||
|
{
|
||||||
|
var targetChild = CourseListPanel.Children[courseIndex];
|
||||||
|
var bounds = targetChild.Bounds;
|
||||||
|
ContentScrollViewer.Offset = new Vector(0, bounds.Position.Y);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void RefreshFromSettings()
|
public void RefreshFromSettings()
|
||||||
@@ -134,44 +228,75 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
|||||||
_componentId,
|
_componentId,
|
||||||
_placementId);
|
_placementId);
|
||||||
_languageCode = _localizationService.NormalizeLanguageCode(appSettings.LanguageCode);
|
_languageCode = _localizationService.NormalizeLanguageCode(appSettings.LanguageCode);
|
||||||
|
_componentColorScheme = componentSettings.ColorSchemeSource;
|
||||||
var now = _timeZoneService?.GetCurrentTime() ?? DateTime.Now;
|
var now = _timeZoneService?.GetCurrentTime() ?? DateTime.Now;
|
||||||
UpdateHeader(now);
|
var today = DateOnly.FromDateTime(now);
|
||||||
|
|
||||||
var importedSchedulePath = ResolveImportedSchedulePath(componentSettings);
|
var importedSchedulePath = ResolveImportedSchedulePath(componentSettings);
|
||||||
var readResult = _scheduleService.Load(importedSchedulePath);
|
var readResult = _scheduleService.Load(importedSchedulePath);
|
||||||
if (!readResult.Success || readResult.Snapshot is null)
|
if (!readResult.Success || readResult.Snapshot is null)
|
||||||
{
|
{
|
||||||
_courseItems = Array.Empty<CourseItemViewModel>();
|
_courseItems = Array.Empty<CourseItemViewModel>();
|
||||||
|
UpdateHeader(now);
|
||||||
ShowStatus(L("schedule.widget.no_source", "未读取到 ClassIsland 课表"));
|
ShowStatus(L("schedule.widget.no_source", "未读取到 ClassIsland 课表"));
|
||||||
RenderScheduleItems();
|
RenderScheduleItems();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var snapshot = readResult.Snapshot;
|
var snapshot = readResult.Snapshot;
|
||||||
var today = DateOnly.FromDateTime(now);
|
|
||||||
if (!_scheduleService.TryResolveClassPlanForDate(snapshot, today, out var resolvedClassPlan))
|
if (!_scheduleService.TryResolveClassPlanForDate(snapshot, today, out var resolvedClassPlan))
|
||||||
{
|
{
|
||||||
_courseItems = Array.Empty<CourseItemViewModel>();
|
var nextDay = today.AddDays(1);
|
||||||
ShowStatus(L("schedule.widget.no_class_today", "今天没有课程"));
|
if (_scheduleService.TryResolveClassPlanForDate(snapshot, nextDay, out var nextDayClassPlan))
|
||||||
RenderScheduleItems();
|
{
|
||||||
return;
|
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))
|
if (!snapshot.TimeLayouts.TryGetValue(resolvedClassPlan.ClassPlan.TimeLayoutId, out var layout))
|
||||||
{
|
{
|
||||||
_courseItems = Array.Empty<CourseItemViewModel>();
|
_courseItems = Array.Empty<CourseItemViewModel>();
|
||||||
|
UpdateHeader(now);
|
||||||
ShowStatus(L("schedule.widget.layout_missing", "课表时间布局缺失"));
|
ShowStatus(L("schedule.widget.layout_missing", "课表时间布局缺失"));
|
||||||
RenderScheduleItems();
|
RenderScheduleItems();
|
||||||
return;
|
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)
|
if (_courseItems.Count == 0)
|
||||||
{
|
{
|
||||||
ShowStatus(L("schedule.widget.no_class_today", "今天没有课程"));
|
ShowStatus(L("schedule.widget.no_class_today", "今天没有课程"));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
var currentIndex = FindCurrentCourseIndex();
|
||||||
|
_lastCurrentCourseIndex = currentIndex;
|
||||||
HideStatus();
|
HideStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,6 +461,10 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var useMonetColor = ComponentColorSchemeHelper.ShouldUseMonetColor(
|
||||||
|
_componentColorScheme,
|
||||||
|
ComponentColorSchemeHelper.GetCurrentGlobalThemeColorMode());
|
||||||
|
|
||||||
var scale = ResolveScale();
|
var scale = ResolveScale();
|
||||||
var bulletSize = Math.Clamp(10 * scale, 5, 12);
|
var bulletSize = Math.Clamp(10 * scale, 5, 12);
|
||||||
var courseNameSize = Math.Clamp(42 * scale, 14, 42);
|
var courseNameSize = Math.Clamp(42 * scale, 14, 42);
|
||||||
@@ -350,7 +479,9 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
|||||||
|
|
||||||
var primaryBrush = CreateBrush(_isNightVisual ? "#F9FBFF" : "#151821");
|
var primaryBrush = CreateBrush(_isNightVisual ? "#F9FBFF" : "#151821");
|
||||||
var secondaryBrush = CreateBrush(_isNightVisual ? "#848B99" : "#667084");
|
var secondaryBrush = CreateBrush(_isNightVisual ? "#848B99" : "#667084");
|
||||||
var currentBrush = CreateBrush("#FF4D5A");
|
var currentBrush = useMonetColor
|
||||||
|
? CreateBrush("#FF4FC3F7")
|
||||||
|
: CreateBrush("#FF4D5A");
|
||||||
var normalBulletBrush = CreateBrush(_isNightVisual ? "#B8BEC9" : "#9AA3B2");
|
var normalBulletBrush = CreateBrush(_isNightVisual ? "#B8BEC9" : "#9AA3B2");
|
||||||
|
|
||||||
var visibleItems = _courseItems.Take(maxVisibleItems).ToList();
|
var visibleItems = _courseItems.Take(maxVisibleItems).ToList();
|
||||||
@@ -438,9 +569,22 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
|||||||
|
|
||||||
private void ApplyAdaptiveLayout()
|
private void ApplyAdaptiveLayout()
|
||||||
{
|
{
|
||||||
|
if (Bounds.Width <= 0 || Bounds.Height <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var scale = ResolveScale();
|
var scale = ResolveScale();
|
||||||
_isNightVisual = ResolveNightMode();
|
_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);
|
var cornerRadius = Math.Clamp(_currentCellSize * 0.45, 24, 44);
|
||||||
RootBorder.CornerRadius = new CornerRadius(cornerRadius);
|
RootBorder.CornerRadius = new CornerRadius(cornerRadius);
|
||||||
RootBorder.Background = _isNightVisual
|
RootBorder.Background = _isNightVisual
|
||||||
@@ -468,7 +612,7 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
|||||||
|
|
||||||
MonthTextBlock.Foreground = CreateBrush(_isNightVisual ? "#F8FAFF" : "#131722");
|
MonthTextBlock.Foreground = CreateBrush(_isNightVisual ? "#F8FAFF" : "#131722");
|
||||||
DayTextBlock.Foreground = CreateBrush(_isNightVisual ? "#F8FAFF" : "#131722");
|
DayTextBlock.Foreground = CreateBrush(_isNightVisual ? "#F8FAFF" : "#131722");
|
||||||
SlashTextBlock.Foreground = CreateBrush("#FF3250");
|
SlashTextBlock.Foreground = slashBrush;
|
||||||
WeekdayTextBlock.Foreground = CreateBrush(_isNightVisual ? "#C6CBD5" : "#4B5463");
|
WeekdayTextBlock.Foreground = CreateBrush(_isNightVisual ? "#C6CBD5" : "#4B5463");
|
||||||
ClassCountTextBlock.Foreground = CreateBrush(_isNightVisual ? "#8D95A4" : "#738095");
|
ClassCountTextBlock.Foreground = CreateBrush(_isNightVisual ? "#8D95A4" : "#738095");
|
||||||
StatusTextBlock.Foreground = CreateBrush(_isNightVisual ? "#9AA2B1" : "#4B5565");
|
StatusTextBlock.Foreground = CreateBrush(_isNightVisual ? "#9AA2B1" : "#4B5565");
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
@@ -439,6 +439,11 @@ public sealed class DesktopComponentRuntimeRegistry
|
|||||||
"component.browser",
|
"component.browser",
|
||||||
() => new BrowserWidget(),
|
() => new BrowserWidget(),
|
||||||
cellSize => Math.Clamp(cellSize * 0.24, 10, 24)),
|
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(
|
new DesktopComponentRuntimeRegistration(
|
||||||
BuiltInComponentIds.HolidayCalendar,
|
BuiltInComponentIds.HolidayCalendar,
|
||||||
"component.holiday_calendar",
|
"component.holiday_calendar",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||||
|
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="{DynamicResource SystemAccentColorLight2Brush}"
|
||||||
|
Opacity="0.2"
|
||||||
|
IsHitTestVisible="False" />
|
||||||
|
|
||||||
|
<Grid RowDefinitions="Auto,*" RowSpacing="8" Margin="16,14,16,14">
|
||||||
|
<Grid Grid.Row="0" ColumnDefinitions="*,Auto">
|
||||||
|
<TextBlock x:Name="HeaderTextBlock"
|
||||||
|
Text="最近文档"
|
||||||
|
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||||
|
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="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||||
|
</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="{DynamicResource AdaptiveGlassPanelBackgroundBrush}"
|
||||||
|
Padding="10"
|
||||||
|
Cursor="Hand"
|
||||||
|
PointerPressed="OnDocumentCardPointerPressed">
|
||||||
|
<Grid RowDefinitions="Auto,*,Auto">
|
||||||
|
<TextBlock Grid.Row="0"
|
||||||
|
Text="{Binding FileName}"
|
||||||
|
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||||
|
FontSize="12"
|
||||||
|
FontWeight="Medium"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
MaxLines="2"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
VerticalAlignment="Top" />
|
||||||
|
<TextBlock Grid.Row="2"
|
||||||
|
Text="{Binding TimeAgo}"
|
||||||
|
Foreground="{DynamicResource AdaptiveTextTertiaryBrush}"
|
||||||
|
FontSize="10"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
MaxLines="1" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock x:Name="StatusTextBlock"
|
||||||
|
IsVisible="False"
|
||||||
|
Text="暂无最近文档"
|
||||||
|
Foreground="{DynamicResource AdaptiveTextTertiaryBrush}"
|
||||||
|
FontSize="14"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
|
using LanMountainDesktop.Views.Components;
|
||||||
|
|
||||||
|
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 void LoadDocuments()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_isLoading = true;
|
||||||
|
StatusTextBlock.IsVisible = false;
|
||||||
|
|
||||||
|
_documents = _recentDocumentsService.GetRecentDocuments(20);
|
||||||
|
|
||||||
|
if (_documents.Count == 0)
|
||||||
|
{
|
||||||
|
StatusTextBlock.Text = "暂无最近文档";
|
||||||
|
StatusTextBlock.IsVisible = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateDisplay();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
StatusTextBlock.Text = "加载失败";
|
||||||
|
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 "刚刚";
|
||||||
|
if (span.TotalMinutes < 60)
|
||||||
|
return $"{(int)span.TotalMinutes} 分钟前";
|
||||||
|
if (span.TotalHours < 24)
|
||||||
|
return $"{(int)span.TotalHours} 小时前";
|
||||||
|
if (span.TotalDays < 7)
|
||||||
|
return $"{(int)span.TotalDays} 天前";
|
||||||
|
if (span.TotalDays < 30)
|
||||||
|
return $"{(int)(span.TotalDays / 7)} 周前";
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ using Avalonia;
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
|
using LanMountainDesktop.ComponentSystem;
|
||||||
using LanMountainDesktop.Models;
|
using LanMountainDesktop.Models;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg
|
|||||||
private double _currentCellSize = 48;
|
private double _currentCellSize = 48;
|
||||||
private bool _showDisplayDb = true;
|
private bool _showDisplayDb = true;
|
||||||
private bool _showDbfs;
|
private bool _showDbfs;
|
||||||
|
private string? _componentColorScheme;
|
||||||
private string _languageCode = "zh-CN";
|
private string _languageCode = "zh-CN";
|
||||||
private bool _isAttached;
|
private bool _isAttached;
|
||||||
private bool _isOnActivePage = true;
|
private bool _isOnActivePage = true;
|
||||||
@@ -147,6 +149,7 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg
|
|||||||
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
|
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
|
||||||
_showDisplayDb = componentSnapshot.StudyEnvironmentShowDisplayDb;
|
_showDisplayDb = componentSnapshot.StudyEnvironmentShowDisplayDb;
|
||||||
_showDbfs = componentSnapshot.StudyEnvironmentShowDbfs;
|
_showDbfs = componentSnapshot.StudyEnvironmentShowDbfs;
|
||||||
|
_componentColorScheme = componentSnapshot.ColorSchemeSource;
|
||||||
if (!_showDisplayDb && !_showDbfs)
|
if (!_showDisplayDb && !_showDbfs)
|
||||||
{
|
{
|
||||||
_showDisplayDb = true;
|
_showDisplayDb = true;
|
||||||
@@ -287,22 +290,26 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg
|
|||||||
|
|
||||||
private IBrush ResolveStatusBrush(StudyAnalyticsSnapshot snapshot)
|
private IBrush ResolveStatusBrush(StudyAnalyticsSnapshot snapshot)
|
||||||
{
|
{
|
||||||
|
var useMonetColor = ComponentColorSchemeHelper.ShouldUseMonetColor(
|
||||||
|
_componentColorScheme,
|
||||||
|
ComponentColorSchemeHelper.GetCurrentGlobalThemeColorMode());
|
||||||
|
|
||||||
if (snapshot.State == StudyAnalyticsRuntimeState.Unsupported ||
|
if (snapshot.State == StudyAnalyticsRuntimeState.Unsupported ||
|
||||||
snapshot.State == StudyAnalyticsRuntimeState.Error ||
|
snapshot.State == StudyAnalyticsRuntimeState.Error ||
|
||||||
snapshot.StreamStatus == NoiseStreamStatus.Error)
|
snapshot.StreamStatus == NoiseStreamStatus.Error)
|
||||||
{
|
{
|
||||||
return CreateBrush("#FFFF7B7B");
|
return useMonetColor ? CreateBrush("#FF6FD7A2") : CreateBrush("#FFFF7B7B");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (snapshot.StreamStatus == NoiseStreamStatus.Noisy)
|
if (snapshot.StreamStatus == NoiseStreamStatus.Noisy)
|
||||||
{
|
{
|
||||||
return CreateBrush("#FFFFB14A");
|
return useMonetColor ? CreateBrush("#FF4FC3F7") : CreateBrush("#FFFFB14A");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (snapshot.State == StudyAnalyticsRuntimeState.Running &&
|
if (snapshot.State == StudyAnalyticsRuntimeState.Running &&
|
||||||
snapshot.StreamStatus == NoiseStreamStatus.Quiet)
|
snapshot.StreamStatus == NoiseStreamStatus.Quiet)
|
||||||
{
|
{
|
||||||
return CreateBrush("#FF6FD7A2");
|
return useMonetColor ? CreateBrush("#FF81C784") : CreateBrush("#FF6FD7A2");
|
||||||
}
|
}
|
||||||
|
|
||||||
return TryResolveThemeBrush("AdaptiveTextPrimaryBrush", "#FFEFF3FF");
|
return TryResolveThemeBrush("AdaptiveTextPrimaryBrush", "#FFEFF3FF");
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ using LanMountainDesktop.PluginSdk;
|
|||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
using LanMountainDesktop.Theme;
|
using LanMountainDesktop.Theme;
|
||||||
using LanMountainDesktop.Views.Components;
|
using LanMountainDesktop.Views.Components;
|
||||||
using LibVLCSharp.Shared;
|
|
||||||
using LibVLCSharp.Avalonia;
|
|
||||||
|
|
||||||
namespace LanMountainDesktop.Views;
|
namespace LanMountainDesktop.Views;
|
||||||
|
|
||||||
@@ -213,7 +211,6 @@ public partial class MainWindow
|
|||||||
_wallpaperType = string.IsNullOrWhiteSpace(type) ? "Image" : type.Trim();
|
_wallpaperType = string.IsNullOrWhiteSpace(type) ? "Image" : type.Trim();
|
||||||
_wallpaperPlacement = WallpaperImageBrushFactory.NormalizePlacement(placement);
|
_wallpaperPlacement = WallpaperImageBrushFactory.NormalizePlacement(placement);
|
||||||
_wallpaperSolidColor = TryParseColor(color, out var parsedColor) ? parsedColor : null;
|
_wallpaperSolidColor = TryParseColor(color, out var parsedColor) ? parsedColor : null;
|
||||||
_wallpaperVideoPath = null;
|
|
||||||
_wallpaperDisplayState = WallpaperDisplayState.NoWallpaperConfigured;
|
_wallpaperDisplayState = WallpaperDisplayState.NoWallpaperConfigured;
|
||||||
|
|
||||||
_wallpaperBitmap?.Dispose();
|
_wallpaperBitmap?.Dispose();
|
||||||
@@ -235,17 +232,6 @@ public partial class MainWindow
|
|||||||
}
|
}
|
||||||
|
|
||||||
var extension = Path.GetExtension(_wallpaperPath);
|
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))
|
if (!SupportedImageExtensions.Contains(extension))
|
||||||
{
|
{
|
||||||
_wallpaperMediaType = WallpaperMediaType.Image;
|
_wallpaperMediaType = WallpaperMediaType.Image;
|
||||||
@@ -285,7 +271,6 @@ public partial class MainWindow
|
|||||||
if (_wallpaperMediaType == WallpaperMediaType.SolidColor && _wallpaperSolidColor.HasValue)
|
if (_wallpaperMediaType == WallpaperMediaType.SolidColor && _wallpaperSolidColor.HasValue)
|
||||||
{
|
{
|
||||||
DesktopWallpaperLayer.Background = new SolidColorBrush(_wallpaperSolidColor.Value);
|
DesktopWallpaperLayer.Background = new SolidColorBrush(_wallpaperSolidColor.Value);
|
||||||
ApplyVideoWallpaperPosterVisibility(showPoster: false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,7 +281,6 @@ public partial class MainWindow
|
|||||||
DesktopWallpaperLayer.Background = _defaultDesktopBackground ?? CreateNeutralWallpaperFallbackBrush();
|
DesktopWallpaperLayer.Background = _defaultDesktopBackground ?? CreateNeutralWallpaperFallbackBrush();
|
||||||
DesktopWallpaperImageLayer.Background = WallpaperImageBrushFactory.Create(_wallpaperBitmap, _wallpaperPlacement);
|
DesktopWallpaperImageLayer.Background = WallpaperImageBrushFactory.Create(_wallpaperBitmap, _wallpaperPlacement);
|
||||||
DesktopWallpaperImageLayer.IsVisible = true;
|
DesktopWallpaperImageLayer.IsVisible = true;
|
||||||
ApplyVideoWallpaperPosterVisibility(showPoster: false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,92 +292,17 @@ public partial class MainWindow
|
|||||||
DesktopWallpaperLayer.Background = _defaultDesktopBackground ?? CreateNeutralWallpaperFallbackBrush();
|
DesktopWallpaperLayer.Background = _defaultDesktopBackground ?? CreateNeutralWallpaperFallbackBrush();
|
||||||
DesktopWallpaperImageLayer.Background = WallpaperImageBrushFactory.Create(_lastValidWallpaperBitmap, _wallpaperPlacement);
|
DesktopWallpaperImageLayer.Background = WallpaperImageBrushFactory.Create(_lastValidWallpaperBitmap, _wallpaperPlacement);
|
||||||
DesktopWallpaperImageLayer.IsVisible = true;
|
DesktopWallpaperImageLayer.IsVisible = true;
|
||||||
ApplyVideoWallpaperPosterVisibility(showPoster: false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
DesktopWallpaperLayer.Background = _defaultDesktopBackground ?? CreateNeutralWallpaperFallbackBrush();
|
DesktopWallpaperLayer.Background = _defaultDesktopBackground ?? CreateNeutralWallpaperFallbackBrush();
|
||||||
ApplyVideoWallpaperPosterVisibility(
|
|
||||||
showPoster: _wallpaperMediaType == WallpaperMediaType.Video && _videoWallpaperPosterBitmap is not null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateWallpaperDisplay()
|
private void UpdateWallpaperDisplay()
|
||||||
{
|
{
|
||||||
if (_wallpaperMediaType == WallpaperMediaType.Video)
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrWhiteSpace(_wallpaperVideoPath))
|
|
||||||
{
|
|
||||||
StartVideoWallpaper(_wallpaperVideoPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
StopVideoWallpaper();
|
|
||||||
}
|
|
||||||
|
|
||||||
ApplyWallpaperBrush();
|
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()
|
private double CalculateCurrentBackgroundLuminance()
|
||||||
{
|
{
|
||||||
var brush = DesktopWallpaperLayer.Background;
|
var brush = DesktopWallpaperLayer.Background;
|
||||||
@@ -644,112 +553,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()
|
private DesktopLayoutSettingsSnapshot BuildDesktopLayoutSettingsSnapshot()
|
||||||
{
|
{
|
||||||
return new DesktopLayoutSettingsSnapshot
|
return new DesktopLayoutSettingsSnapshot
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
xmlns:ic="using:FluentIcons.Avalonia.Fluent"
|
xmlns:ic="using:FluentIcons.Avalonia.Fluent"
|
||||||
xmlns:mi="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
|
xmlns:mi="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
|
||||||
xmlns:comp="using:LanMountainDesktop.Views.Components"
|
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:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
mc:Ignorable="d"
|
mc:Ignorable="d"
|
||||||
@@ -123,18 +123,7 @@
|
|||||||
VerticalAlignment="Stretch"
|
VerticalAlignment="Stretch"
|
||||||
Background="Transparent" />
|
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"
|
<Grid x:Name="DesktopGrid"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ using LanMountainDesktop.Services;
|
|||||||
using LanMountainDesktop.Services.Settings;
|
using LanMountainDesktop.Services.Settings;
|
||||||
using LanMountainDesktop.Theme;
|
using LanMountainDesktop.Theme;
|
||||||
using LanMountainDesktop.Views.Components;
|
using LanMountainDesktop.Views.Components;
|
||||||
using LibVLCSharp.Shared;
|
|
||||||
|
|
||||||
namespace LanMountainDesktop.Views;
|
namespace LanMountainDesktop.Views;
|
||||||
|
|
||||||
@@ -35,7 +35,6 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
|||||||
{
|
{
|
||||||
None,
|
None,
|
||||||
Image,
|
Image,
|
||||||
Video,
|
|
||||||
SolidColor
|
SolidColor
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,10 +64,6 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
|||||||
{
|
{
|
||||||
".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp"
|
".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 =
|
private static readonly TaskbarActionId[] DefaultPinnedTaskbarActions =
|
||||||
[
|
[
|
||||||
TaskbarActionId.MinimizeToWindows
|
TaskbarActionId.MinimizeToWindows
|
||||||
@@ -120,30 +115,11 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
|||||||
private Bitmap? _wallpaperBitmap;
|
private Bitmap? _wallpaperBitmap;
|
||||||
private Bitmap? _lastValidWallpaperBitmap;
|
private Bitmap? _lastValidWallpaperBitmap;
|
||||||
private string? _lastValidWallpaperPath;
|
private string? _lastValidWallpaperPath;
|
||||||
private Bitmap? _videoWallpaperPosterBitmap;
|
|
||||||
private string? _videoWallpaperPosterPath;
|
|
||||||
private WallpaperMediaType _wallpaperMediaType;
|
private WallpaperMediaType _wallpaperMediaType;
|
||||||
private WallpaperDisplayState _wallpaperDisplayState = WallpaperDisplayState.NoWallpaperConfigured;
|
private WallpaperDisplayState _wallpaperDisplayState = WallpaperDisplayState.NoWallpaperConfigured;
|
||||||
private string _wallpaperPlacement = WallpaperImageBrushFactory.Fill;
|
private string _wallpaperPlacement = WallpaperImageBrushFactory.Fill;
|
||||||
private string? _wallpaperVideoPath;
|
|
||||||
private string _wallpaperType = "Image";
|
private string _wallpaperType = "Image";
|
||||||
private Color? _wallpaperSolidColor;
|
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? _wallpaperPath;
|
||||||
private string _wallpaperStatus = "Current background uses solid color.";
|
private string _wallpaperStatus = "Current background uses solid color.";
|
||||||
private IReadOnlyList<Color> _recommendedColors = Array.Empty<Color>();
|
private IReadOnlyList<Color> _recommendedColors = Array.Empty<Color>();
|
||||||
@@ -333,21 +309,9 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
|||||||
_detachedComponentLibraryWindow.Close();
|
_detachedComponentLibraryWindow.Close();
|
||||||
}
|
}
|
||||||
_detachedComponentLibraryWindow = null;
|
_detachedComponentLibraryWindow = null;
|
||||||
StopVideoWallpaper();
|
|
||||||
DisposeLauncherResources();
|
DisposeLauncherResources();
|
||||||
_videoWallpaperMedia?.Dispose();
|
|
||||||
_videoWallpaperMedia = null;
|
|
||||||
_videoWallpaperPlayer?.Dispose();
|
|
||||||
_videoWallpaperPlayer = null;
|
|
||||||
_desktopVideoFrameRefreshTimer?.Stop();
|
|
||||||
_desktopVideoFrameRefreshTimer = null;
|
|
||||||
_videoWallpaperPosterBitmap?.Dispose();
|
|
||||||
_videoWallpaperPosterBitmap = null;
|
|
||||||
_videoWallpaperPosterPath = null;
|
|
||||||
_lastValidWallpaperBitmap?.Dispose();
|
_lastValidWallpaperBitmap?.Dispose();
|
||||||
_lastValidWallpaperBitmap = null;
|
_lastValidWallpaperBitmap = null;
|
||||||
_libVlc?.Dispose();
|
|
||||||
_libVlc = null;
|
|
||||||
if (_recommendationInfoService is IDisposable recommendationServiceDisposable)
|
if (_recommendationInfoService is IDisposable recommendationServiceDisposable)
|
||||||
{
|
{
|
||||||
recommendationServiceDisposable.Dispose();
|
recommendationServiceDisposable.Dispose();
|
||||||
|
|||||||
@@ -31,6 +31,33 @@
|
|||||||
<ToggleSwitch IsChecked="{Binding UploadAnonymousUsageData}" />
|
<ToggleSwitch IsChecked="{Binding UploadAnonymousUsageData}" />
|
||||||
</ui:SettingsExpander.Footer>
|
</ui:SettingsExpander.Footer>
|
||||||
</ui:SettingsExpander>
|
</ui:SettingsExpander>
|
||||||
|
|
||||||
|
<Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||||
|
CornerRadius="8"
|
||||||
|
Padding="16"
|
||||||
|
Margin="0,16,0,0">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel Grid.Column="0">
|
||||||
|
<TextBlock Text="{Binding DeviceIdHeader}"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
FontSize="14" />
|
||||||
|
<TextBlock Text="{Binding DeviceIdDescription}"
|
||||||
|
FontSize="12"
|
||||||
|
Opacity="0.7"
|
||||||
|
Margin="0,4,0,8" />
|
||||||
|
<TextBox Text="{Binding DeviceId}"
|
||||||
|
IsReadOnly="True"
|
||||||
|
FontFamily="Consolas"
|
||||||
|
FontSize="12" />
|
||||||
|
</StackPanel>
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Content="{Binding RefreshDeviceIdText}"
|
||||||
|
Command="{Binding RefreshDeviceIdCommand}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="16,0,0,0"
|
||||||
|
Classes="accent-button" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|||||||
@@ -21,27 +21,11 @@
|
|||||||
CornerRadius="48"
|
CornerRadius="48"
|
||||||
BoxShadow="0 12 32 #50000000">
|
BoxShadow="0 12 32 #50000000">
|
||||||
<Panel Background="{DynamicResource AdaptiveSurfaceBaseBrush}" Margin="2">
|
<Panel Background="{DynamicResource AdaptiveSurfaceBaseBrush}" Margin="2">
|
||||||
<!-- 图片/视频预览 -->
|
<!-- 图片预览 -->
|
||||||
<Border Background="#FFF6F7F9"
|
<Border Background="#FFF6F7F9"
|
||||||
IsVisible="{Binding IsImage}">
|
IsVisible="{Binding IsImage}">
|
||||||
<Border Background="{Binding PreviewBrush}" />
|
<Border Background="{Binding PreviewBrush}" />
|
||||||
</Border>
|
</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}"
|
<Border Background="{Binding SelectedColor}"
|
||||||
IsVisible="{Binding IsSolidColor}" />
|
IsVisible="{Binding IsSolidColor}" />
|
||||||
@@ -52,31 +36,104 @@
|
|||||||
|
|
||||||
<!-- 右侧:颜色选择网格 -->
|
<!-- 右侧:颜色选择网格 -->
|
||||||
<StackPanel Grid.Column="1" VerticalAlignment="Center" Spacing="12" IsVisible="{Binding IsSolidColor}">
|
<StackPanel Grid.Column="1" VerticalAlignment="Center" Spacing="12" IsVisible="{Binding IsSolidColor}">
|
||||||
<TextBlock Text="{Binding WallpaperColorLabel}"
|
<TextBlock Text="{Binding WallpaperColorLabel}"
|
||||||
FontSize="14"
|
FontSize="14"
|
||||||
FontWeight="SemiBold"
|
FontWeight="SemiBold"
|
||||||
Opacity="0.8" />
|
Opacity="0.8" />
|
||||||
<ItemsControl ItemsSource="{Binding PresetColors}">
|
<UniformGrid Columns="4" Rows="3">
|
||||||
<ItemsControl.ItemsPanel>
|
<!-- 预设颜色 1-11 -->
|
||||||
<ItemsPanelTemplate>
|
<Button Width="48" Height="48" Margin="4" Padding="0"
|
||||||
<UniformGrid Columns="4" Rows="3" />
|
Background="#D8A7B1"
|
||||||
</ItemsPanelTemplate>
|
BorderThickness="0"
|
||||||
</ItemsControl.ItemsPanel>
|
CornerRadius="6"
|
||||||
<ItemsControl.ItemTemplate>
|
Command="{Binding SelectColorCommand}"
|
||||||
<DataTemplate>
|
CommandParameter="#D8A7B1"
|
||||||
<Button Width="48" Height="48" Margin="4" Padding="0"
|
ToolTip.Tip="#D8A7B1" />
|
||||||
Background="{Binding}"
|
<Button Width="48" Height="48" Margin="4" Padding="0"
|
||||||
BorderThickness="0"
|
Background="#B6C9BB"
|
||||||
CornerRadius="6"
|
BorderThickness="0"
|
||||||
Command="{Binding $parent[UserControl].((vm:WallpaperSettingsPageViewModel)DataContext).SelectColorCommand}"
|
CornerRadius="6"
|
||||||
CommandParameter="{Binding}"
|
Command="{Binding SelectColorCommand}"
|
||||||
ToolTip.Tip="{Binding}">
|
CommandParameter="#B6C9BB"
|
||||||
<!-- 简单的悬停与选中效果由 Button 默认样式提供,
|
ToolTip.Tip="#B6C9BB" />
|
||||||
由于使用了低饱和度颜色,背景色本身就很柔和 -->
|
<Button Width="48" Height="48" Margin="4" Padding="0"
|
||||||
</Button>
|
Background="#A2B5BB"
|
||||||
</DataTemplate>
|
BorderThickness="0"
|
||||||
</ItemsControl.ItemTemplate>
|
CornerRadius="6"
|
||||||
</ItemsControl>
|
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>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
@@ -105,7 +162,7 @@
|
|||||||
</ui:SettingsExpander.Footer>
|
</ui:SettingsExpander.Footer>
|
||||||
</ui:SettingsExpander>
|
</ui:SettingsExpander>
|
||||||
|
|
||||||
<!-- 图片/视频文件选择 -->
|
<!-- 图片文件选择 -->
|
||||||
<ui:SettingsExpander Header="{Binding WallpaperPathLabel}"
|
<ui:SettingsExpander Header="{Binding WallpaperPathLabel}"
|
||||||
IsVisible="{Binding IsImage}"
|
IsVisible="{Binding IsImage}"
|
||||||
Margin="0,4,0,0">
|
Margin="0,4,0,0">
|
||||||
@@ -129,7 +186,7 @@
|
|||||||
<!-- 填充方式 -->
|
<!-- 填充方式 -->
|
||||||
<ui:SettingsExpander Header="{Binding WallpaperPlacementLabel}"
|
<ui:SettingsExpander Header="{Binding WallpaperPlacementLabel}"
|
||||||
Description="{Binding WallpaperPlacementDescription}"
|
Description="{Binding WallpaperPlacementDescription}"
|
||||||
IsVisible="{Binding IsImageOrVideo}"
|
IsVisible="{Binding IsImage}"
|
||||||
Margin="0,4,0,0">
|
Margin="0,4,0,0">
|
||||||
<ui:SettingsExpander.IconSource>
|
<ui:SettingsExpander.IconSource>
|
||||||
<fi:SymbolIconSource Symbol="Maximize" />
|
<fi:SymbolIconSource Symbol="Maximize" />
|
||||||
@@ -147,12 +204,6 @@
|
|||||||
</ui:SettingsExpander.Footer>
|
</ui:SettingsExpander.Footer>
|
||||||
</ui:SettingsExpander>
|
</ui:SettingsExpander>
|
||||||
|
|
||||||
<TextBlock Margin="0,8,0,0"
|
|
||||||
IsVisible="{Binding IsVideo}"
|
|
||||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
|
||||||
Text="{Binding VideoModeHintText}"
|
|
||||||
TextWrapping="Wrap" />
|
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|||||||
@@ -1,38 +1,30 @@
|
|||||||
# 宿主侧插件运行时
|
# 宿主侧插件运行时 / Host Plugin Runtime
|
||||||
|
|
||||||
## 中文
|
## 中文
|
||||||
|
|
||||||
本目录保存阑山桌面宿主程序中的插件运行时实现。
|
本目录保存阑山桌面宿主侧插件运行时实现。
|
||||||
|
|
||||||
### 主要职责
|
### 主要职责
|
||||||
|
|
||||||
- 发现已安装插件
|
- 发现、安装和替换 `.laapp` 插件包
|
||||||
- 安装和替换 `.laapp` 插件包
|
- 加载插件程序集和共享契约
|
||||||
- 加载插件程序集
|
- 接入插件设置页、桌面组件与市场界面
|
||||||
- 接入插件贡献的设置页和桌面组件
|
- 为 `3.0.0` API 基线插件构建插件作用域的 `IServiceCollection` / `ServiceProvider`
|
||||||
- 在宿主设置界面中展示插件与市场信息
|
- 在激活前解析共享契约缓存,并暴露显式插件导出
|
||||||
|
|
||||||
### 市场安装优先级
|
### 与 LanAirApp 的分工
|
||||||
|
|
||||||
1. 宿主先连接 `LanAirApp/airappmarket/index.json`。
|
- `LanAirApp` 负责官方市场索引、开发文档、校验工具和镜像样例
|
||||||
2. 当条目同时提供 `releaseTag` 和 `releaseAssetName` 时,宿主优先按精确标签读取插件仓库的 GitHub Release 资产。
|
- 本目录负责宿主运行时发现、安装、加载和界面接入
|
||||||
3. 如果 Release 不存在、资产缺失、GitHub API 失败,或当前是本地工作区测试但找不到远程资产,宿主会退回 `downloadUrl` 指向的仓库根目录 `.laapp`。
|
- 权威示例插件是独立仓库 `LanMountainDesktop.SamplePlugin`,`LanAirApp` 中的样例目录只是镜像模板
|
||||||
4. 插件介绍始终读取仓库根目录 `README.md`。
|
|
||||||
5. 安装完成后只做暂存,重启后生效,不在运行时热重载市场安装插件。
|
|
||||||
|
|
||||||
### 核心文件
|
### 市场安装顺序
|
||||||
|
|
||||||
- `PluginLoader.cs`
|
1. 宿主读取官方 `LanAirApp/airappmarket/index.json`
|
||||||
- `PluginLoadContext.cs`
|
2. 若条目同时包含 `releaseTag` 与 `releaseAssetName`,优先解析 GitHub Release 资产
|
||||||
- `PluginRuntimeService.cs`
|
3. 若 Release 解析失败,则回退到仓库根目录 `.laapp`
|
||||||
- `PluginCatalogEntry.cs`
|
4. 插件详情始终读取插件仓库根目录 `README.md`
|
||||||
- `PluginMarketIndexService.cs`
|
5. 市场安装为暂存安装,重启后生效
|
||||||
- `PluginMarketInstallService.cs`
|
|
||||||
|
|
||||||
### 与 `LanAirApp` 的分工
|
|
||||||
|
|
||||||
- `LanAirApp` 负责插件开发文档、示例、市场索引和校验工具。
|
|
||||||
- 宿主目录负责运行时发现、安装、加载和界面接入。
|
|
||||||
|
|
||||||
## English
|
## English
|
||||||
|
|
||||||
@@ -40,25 +32,22 @@ This directory contains the host-side plugin runtime for LanMountainDesktop.
|
|||||||
|
|
||||||
### Responsibilities
|
### Responsibilities
|
||||||
|
|
||||||
- discover installed plugins
|
- discover, install, and replace `.laapp` packages
|
||||||
- install and replace `.laapp` packages
|
- load plugin assemblies and shared contracts
|
||||||
- load plugin assemblies
|
- integrate plugin settings pages, desktop components, and market UI
|
||||||
- integrate plugin settings pages and desktop components
|
- build a plugin-scoped `IServiceCollection` / `ServiceProvider` for API `3.0.0` plugins
|
||||||
- expose market and plugin management in the host UI
|
- resolve shared contract caches before activation and expose explicit plugin exports
|
||||||
- build a plugin-scoped `IServiceCollection`/`ServiceProvider` for API `2.0.0` plugins
|
|
||||||
- resolve shared contract assemblies into a version-isolated cache before plugin activation
|
### Relationship with LanAirApp
|
||||||
- expose explicit cross-plugin exports through `IPluginExportRegistry`
|
|
||||||
|
- `LanAirApp` owns the official market index, developer docs, validation tools, and mirrored sample templates
|
||||||
|
- this directory owns host-side discovery, installation, loading, and UI integration
|
||||||
|
- the authoritative sample plugin lives in the standalone `LanMountainDesktop.SamplePlugin` repository; the `LanAirApp` sample directory is only a mirror/template copy
|
||||||
|
|
||||||
### Market install order
|
### Market install order
|
||||||
|
|
||||||
1. The host reads `LanAirApp/airappmarket/index.json`.
|
1. The host reads the official `LanAirApp/airappmarket/index.json`
|
||||||
2. If an entry declares both `releaseTag` and `releaseAssetName`, the host first resolves the exact GitHub Release asset.
|
2. If an entry contains both `releaseTag` and `releaseAssetName`, the host first resolves the exact GitHub Release asset
|
||||||
3. If Release resolution fails, the host falls back to the repository root `.laapp` from `downloadUrl`.
|
3. If Release resolution fails, the host falls back to the repository-root `.laapp`
|
||||||
4. Plugin details always come from the repository root `README.md`.
|
4. Plugin details always come from the plugin repository root `README.md`
|
||||||
5. Market installs are staged and take effect after restart.
|
5. Market installs are staged and take effect after restart
|
||||||
|
|
||||||
### Dependency model
|
|
||||||
|
|
||||||
- Plugin-private managed and native NuGet dependencies remain plugin-local and are resolved through `AssemblyDependencyResolver`.
|
|
||||||
- Shared contract assemblies are downloaded from the official market index, cached under `LocalAppData/LanMountainDesktop/SharedContracts/<id>/<version>/`, and loaded into the default context so host and plugins share the same contract types.
|
|
||||||
- Different contract versions are isolated on disk. If two active plugins request incompatible versions of the same shared assembly name in one process, the host fails the later activation with a clear error instead of loading an ambiguous contract.
|
|
||||||
|
|||||||
68
README.md
68
README.md
@@ -1,49 +1,47 @@
|
|||||||
# 阑山桌面(LanMountainDesktop)
|
# 阑山桌面 / LanMountainDesktop
|
||||||
|
|
||||||
## 中文
|
## 中文
|
||||||
|
|
||||||
阑山桌面是一个基于 Avalonia 的桌面壳层项目。它不是单纯的启动器,而是一个可编排、可扩展、可长期演进的桌面信息空间。
|
`LanMontainDesktop` 是阑山桌面的宿主应用权威仓库,负责应用本体、宿主侧插件运行时,以及宿主侧 `PluginSdk` API 基线。
|
||||||
|
|
||||||
### 核心目标
|
### 本仓库负责什么
|
||||||
|
|
||||||
- 通过网格化布局管理桌面组件。
|
- `LanMountainDesktop/`:桌面宿主应用
|
||||||
- 提供状态栏、任务栏和多页桌面的统一外壳。
|
- `LanMountainDesktop.PluginSdk/`:宿主侧插件 API 真源
|
||||||
- 通过主题、玻璃效果和动效塑造统一体验。
|
- `LanMountainDesktop/plugins/`:插件发现、安装、加载、市场接入
|
||||||
- 通过组件系统和插件系统持续扩展能力。
|
- `LanMountainDesktop.Tests/`:宿主与插件运行时测试
|
||||||
|
- `LanAirApp/`:仅用于联调的镜像副本,权威版本仍以独立 `LanAirApp` 仓库为准
|
||||||
|
|
||||||
### 当前工程结构
|
### 生态边界
|
||||||
|
|
||||||
- `LanMountainDesktop/`:桌面主程序。
|
- 应用本体:`LanMontainDesktop`
|
||||||
- `LanMountainDesktop.RecommendationBackend/`:推荐内容后端。
|
- 插件市场与开发资料:独立 `LanAirApp`
|
||||||
- `LanMountainDesktop/ComponentSystem/`:组件定义与注册系统。
|
- 权威示例插件:独立 `LanMountainDesktop.SamplePlugin`
|
||||||
- `LanMountainDesktop/plugins/`:宿主侧插件加载、安装和设置集成。
|
|
||||||
- `docs/`:视觉与设计规范。
|
|
||||||
- `LanAirApp/`:插件开发资料镜像,权威版本以独立 `LanAirApp` 仓库为准。
|
|
||||||
|
|
||||||
### 生态关系
|
### 当前插件 API 基线
|
||||||
|
|
||||||
- 宿主程序只连接 `LanAirApp` 仓库中的官方市场索引。
|
- 宿主插件 API 基线:`3.0.0`
|
||||||
- 官方市场索引返回插件列表以及各插件项目根目录链接。
|
- `SampleClock` 共享契约:`2.0.0`
|
||||||
- 插件项目根目录提供 `.laapp` 安装包和 `README.md`。
|
|
||||||
|
|
||||||
### 当前状态
|
|
||||||
|
|
||||||
- Windows 是当前主要目标平台。
|
|
||||||
- 已提供组件系统、插件系统、主题系统和设置系统。
|
|
||||||
- 中文为主语言,英文为附加扩展语言。
|
|
||||||
- 仓库主入口解决方案文件已切换为 `LanMountainDesktop.slnx`,SDK 版本由根目录 `global.json` 锁定。
|
|
||||||
|
|
||||||
### 运行说明
|
|
||||||
|
|
||||||
运行方法见 [run.md](./run.md)。
|
|
||||||
|
|
||||||
## English
|
## English
|
||||||
|
|
||||||
LanMountainDesktop is an Avalonia-based desktop shell. It is designed as a composable and extensible desktop environment rather than a simple launcher.
|
`LanMontainDesktop` is the authoritative host repository for LanMountainDesktop. It owns the desktop application, the host-side plugin runtime, and the host-side `PluginSdk` API baseline.
|
||||||
|
|
||||||
### Main goals
|
### What this repository owns
|
||||||
|
|
||||||
- manage desktop widgets with a grid-based layout
|
- `LanMountainDesktop/`: the desktop host application
|
||||||
- provide a unified shell with status bar, taskbar, and multi-page desktop support
|
- `LanMountainDesktop.PluginSdk/`: the canonical host-side plugin API
|
||||||
- build a consistent experience through themes, glass effects, and motion
|
- `LanMountainDesktop/plugins/`: plugin discovery, installation, loading, and market integration
|
||||||
- extend capabilities through the component and plugin systems
|
- `LanMountainDesktop.Tests/`: host and plugin runtime tests
|
||||||
|
- `LanAirApp/`: a mirror kept for local workspace integration only; the standalone `LanAirApp` repository remains the source of truth
|
||||||
|
|
||||||
|
### Ecosystem boundaries
|
||||||
|
|
||||||
|
- Application host: `LanMontainDesktop`
|
||||||
|
- Plugin market and developer-facing materials: standalone `LanAirApp`
|
||||||
|
- Authoritative sample plugin: standalone `LanMountainDesktop.SamplePlugin`
|
||||||
|
|
||||||
|
### Current plugin API baseline
|
||||||
|
|
||||||
|
- Host plugin API baseline: `3.0.0`
|
||||||
|
- `SampleClock` shared contract: `2.0.0`
|
||||||
|
|||||||
Reference in New Issue
Block a user