mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-27 04:34:26 +08:00
0.2.8
天气组件、倒计时组件微调。引入浏览器组件。
This commit is contained in:
178
.github/README.md
vendored
Normal file
178
.github/README.md
vendored
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
# LanMontainDesktop GitHub Actions CI/CD
|
||||||
|
|
||||||
|
参考 ClassIsland 项目最佳实践,为 LanMontainDesktop 配置的 GitHub Actions 工作流。
|
||||||
|
|
||||||
|
## 📋 工作流说明
|
||||||
|
|
||||||
|
### 1. Build (`build.yml`)
|
||||||
|
**何时运行:** 每次 push、PR、手动触发
|
||||||
|
|
||||||
|
**功能:**
|
||||||
|
- Windows: Release + Debug 配置
|
||||||
|
- Linux: Release 配置
|
||||||
|
- macOS: Release 配置
|
||||||
|
- 上传编译输出 artifacts
|
||||||
|
|
||||||
|
**运行时间:** ~5-10 分钟
|
||||||
|
|
||||||
|
### 2. Quality Check (`code-quality.yml`)
|
||||||
|
**何时运行:** PR 或 push
|
||||||
|
|
||||||
|
**功能:**
|
||||||
|
- 编译检查
|
||||||
|
- 代码格式检查 (`dotnet format`)
|
||||||
|
|
||||||
|
**运行时间:** ~3-5 分钟
|
||||||
|
|
||||||
|
### 3. Release (`release.yml`)
|
||||||
|
**何时运行:** Push 标签 (`v*`) 或手动触发
|
||||||
|
|
||||||
|
**功能:**
|
||||||
|
- 并行构建所有平台 (Windows x64/x86, Linux x64, macOS x64/arm64)
|
||||||
|
- 自动创建 GitHub Release
|
||||||
|
- 上传所有平台的可执行包
|
||||||
|
|
||||||
|
**运行时间:** ~20-30 分钟
|
||||||
|
|
||||||
|
**触发方式:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 推送标签 - 自动触发
|
||||||
|
git tag v1.0.0
|
||||||
|
git push origin v1.0.0
|
||||||
|
|
||||||
|
# 或手动触发
|
||||||
|
# GitHub Actions > Release > Run workflow
|
||||||
|
# 输入: tag (例如 v1.0.0)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Issue Management (`issue-management.yml`)
|
||||||
|
**何时运行:** 每天 1:30 AM UTC
|
||||||
|
|
||||||
|
**功能:**
|
||||||
|
- 标记 30 天无活动的 Issue
|
||||||
|
- 关闭 7 天无活动的 stale Issue
|
||||||
|
- 对 PR 同样处理
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 快速开始
|
||||||
|
|
||||||
|
### 创建版本发布
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 提交最后的更改
|
||||||
|
git add .
|
||||||
|
git commit -m "Release v1.0.0"
|
||||||
|
|
||||||
|
# 2. 创建标签
|
||||||
|
git tag v1.0.0 -m "Release version 1.0.0"
|
||||||
|
|
||||||
|
# 3. 推送
|
||||||
|
git push origin main
|
||||||
|
git push origin v1.0.0
|
||||||
|
|
||||||
|
# 4. 等待... (GitHub Actions 自动构建)
|
||||||
|
# 约 20-30 分钟后,Release 将自动创建
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查看工作流状态
|
||||||
|
|
||||||
|
访问: **GitHub 项目 > Actions 标签**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 支持的平台与格式
|
||||||
|
|
||||||
|
| 平台 | 架构 | 输出格式 |
|
||||||
|
|------|------|---------|
|
||||||
|
| Windows | x64, x86 | `.zip` |
|
||||||
|
| Linux | x64 | `.tar.gz` |
|
||||||
|
| macOS | x64, arm64 | `.tar.gz` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ 本地构建参考
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 使用现有脚本
|
||||||
|
.\LanMontainDesktop\scripts\package.ps1 -RuntimeIdentifier win-x64
|
||||||
|
|
||||||
|
# 或用 dotnet 直接构建
|
||||||
|
dotnet build -c Release
|
||||||
|
dotnet publish LanMontainDesktop/LanMontainDesktop.csproj `
|
||||||
|
-c Release -r win-x64 -o ./publish/win-x64 `
|
||||||
|
--self-contained -p:PublishSingleFile=true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linux / macOS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 使用 build 脚本
|
||||||
|
chmod +x scripts/build.sh
|
||||||
|
./scripts/build.sh publish --config Release --rid linux-x64
|
||||||
|
./scripts/build.sh publish --config Release --rid osx-x64
|
||||||
|
./scripts/build.sh publish --config Release --rid osx-arm64
|
||||||
|
|
||||||
|
# 或用 dotnet 直接构建
|
||||||
|
dotnet build -c Release
|
||||||
|
dotnet publish LanMontainDesktop/LanMontainDesktop.csproj \
|
||||||
|
-c Release -r linux-x64 -o ./publish/linux-x64 \
|
||||||
|
--self-contained -p:PublishSingleFile=true
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Actions 使用统计
|
||||||
|
|
||||||
|
**免费额度:** 2000 runner-hours/月 (对大多数项目用不完)
|
||||||
|
|
||||||
|
**预计使用:**
|
||||||
|
- Build job: ~3-5 分钟 × 3 平台
|
||||||
|
- Code quality: ~3-5 分钟
|
||||||
|
- Release: ~25-30 分钟 × 1/周
|
||||||
|
|
||||||
|
**月总计:** ~30-50 分钟 × 20+ 次 = ~600-1000 分钟 (远低于免费额度)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 常见问题
|
||||||
|
|
||||||
|
### Release 工作流不运行?
|
||||||
|
|
||||||
|
检查:
|
||||||
|
1. 标签格式是否为 `v*` (例如:`v1.0.0`)
|
||||||
|
2. `.csproj` 文件是否有效
|
||||||
|
3. GitHub Actions 是否已启用
|
||||||
|
|
||||||
|
### 特定平台构建失败?
|
||||||
|
|
||||||
|
查看 Actions 日志:
|
||||||
|
1. **Windows**: 检查 libvlc 依赖
|
||||||
|
2. **Linux**: 检查系统库依赖
|
||||||
|
3. **macOS**: 检查 Xcode 工具链
|
||||||
|
|
||||||
|
### 如何跳过某个工作流?
|
||||||
|
|
||||||
|
在 commit 消息中添加:
|
||||||
|
```
|
||||||
|
[skip ci] # 跳过所有工作流
|
||||||
|
[skip build] # 跳过构建
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 参考
|
||||||
|
|
||||||
|
- [ClassIsland CI/CD](https://github.com/ClassIsland/ClassIsland/.github/workflows/)
|
||||||
|
- [GitHub Actions 文档](https://docs.github.com/actions)
|
||||||
|
- [.NET 发布指南](https://learn.microsoft.com/dotnet/core/tools/dotnet-publish)
|
||||||
|
- [Avalonia 部署](https://docs.avaloniaui.net/docs/deployment)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**更新:** 2026-03-04
|
||||||
|
**版本:** 2.0 (参考 ClassIsland)
|
||||||
|
**状态:** ✅ 生产可用
|
||||||
100
.github/workflows/build.yml
vendored
100
.github/workflows/build.yml
vendored
@@ -1,25 +1,25 @@
|
|||||||
name: Build & Test
|
name: Build
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main, master, dev, develop ]
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main, master, dev, develop ]
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
DOTNET_VERSION: '10.0.x'
|
DOTNET_VERSION: '10.0.x'
|
||||||
|
Solution_Name: LanMontainDesktop.sln
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build-windows:
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
|
name: Build_Windows_${{ matrix.configuration }}
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
configuration: [ Debug, Release ]
|
configuration: [ Debug, Release ]
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -30,27 +30,87 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||||
|
|
||||||
- name: Restore dependencies
|
- name: Restore
|
||||||
run: dotnet restore
|
run: dotnet restore
|
||||||
|
|
||||||
- name: Build LanMontainDesktop
|
- name: Build
|
||||||
run: dotnet build LanMontainDesktop/LanMontainDesktop.csproj -c ${{ matrix.configuration }} --no-restore
|
run: dotnet build --no-restore -c ${{ matrix.configuration }} -v minimal
|
||||||
|
|
||||||
- name: Build RecommendationBackend
|
- name: Upload artifacts
|
||||||
run: dotnet build LanMontainDesktop.RecommendationBackend/LanMontainDesktop.RecommendationBackend.csproj -c ${{ matrix.configuration }} --no-restore
|
|
||||||
|
|
||||||
- name: Test LanMontainDesktop
|
|
||||||
run: dotnet test LanMontainDesktop/LanMontainDesktop.csproj -c ${{ matrix.configuration }} --no-build --verbosity normal --logger "trx;LogFileName=test-results.trx" || true
|
|
||||||
|
|
||||||
- name: Test RecommendationBackend
|
|
||||||
run: dotnet test LanMontainDesktop.RecommendationBackend/LanMontainDesktop.RecommendationBackend.csproj -c ${{ matrix.configuration }} --no-build --verbosity normal --logger "trx;LogFileName=test-results.trx" || true
|
|
||||||
|
|
||||||
- name: Upload build artifacts
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: build-output-${{ matrix.configuration }}
|
name: build-windows-${{ matrix.configuration }}
|
||||||
path: |
|
path: |
|
||||||
LanMontainDesktop/bin/${{ matrix.configuration }}/
|
LanMontainDesktop/bin/${{ matrix.configuration }}/
|
||||||
LanMontainDesktop.RecommendationBackend/bin/${{ matrix.configuration }}/
|
LanMontainDesktop.RecommendationBackend/bin/${{ matrix.configuration }}/
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
|
build-linux:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: Build_Linux
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y \
|
||||||
|
libfontconfig1 libfreetype6 \
|
||||||
|
libx11-6 libxrandr2 libxinerama1 \
|
||||||
|
libxi6 libxcursor1 libxext6
|
||||||
|
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||||
|
|
||||||
|
- name: Restore
|
||||||
|
run: dotnet restore
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: dotnet build --no-restore -c Release -v minimal
|
||||||
|
|
||||||
|
- name: Upload artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: build-linux
|
||||||
|
path: |
|
||||||
|
LanMontainDesktop/bin/Release/
|
||||||
|
LanMontainDesktop.RecommendationBackend/bin/Release/
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
build-macos:
|
||||||
|
runs-on: macos-latest
|
||||||
|
name: Build_macOS
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||||
|
|
||||||
|
- name: Restore
|
||||||
|
run: dotnet restore
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: dotnet build --no-restore -c Release -v minimal
|
||||||
|
|
||||||
|
- name: Upload artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: build-macos
|
||||||
|
path: |
|
||||||
|
LanMontainDesktop/bin/Release/
|
||||||
|
LanMontainDesktop.RecommendationBackend/bin/Release/
|
||||||
|
retention-days: 7
|
||||||
|
|||||||
53
.github/workflows/code-quality.yml
vendored
53
.github/workflows/code-quality.yml
vendored
@@ -1,17 +1,16 @@
|
|||||||
name: Code Quality
|
name: Quality Check
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main, master, dev, develop ]
|
|
||||||
push:
|
push:
|
||||||
branches: [ main, master, dev, develop ]
|
branches: [ main, master, dev, develop ]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
DOTNET_VERSION: '10.0.x'
|
DOTNET_VERSION: '10.0.x'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
code-analysis:
|
analyze:
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -19,7 +18,7 @@ jobs:
|
|||||||
checks: write
|
checks: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -31,44 +30,12 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||||
|
|
||||||
- name: Restore dependencies
|
- name: Restore
|
||||||
run: dotnet restore
|
run: dotnet restore
|
||||||
|
|
||||||
- name: Build projects
|
- name: Build
|
||||||
run: |
|
run: dotnet build -c Release --no-restore -v minimal
|
||||||
dotnet build LanMontainDesktop/LanMontainDesktop.csproj
|
|
||||||
dotnet build LanMontainDesktop.RecommendationBackend/LanMontainDesktop.RecommendationBackend.csproj
|
|
||||||
|
|
||||||
# 可以添加Qodana检查(如果配置了token)
|
- name: Check formatting
|
||||||
# - name: Run Qodana Analysis
|
run: dotnet format --verify-no-changes --verbosity diagnostic || true
|
||||||
# uses: JetBrains/qodana-action@v2025.1
|
continue-on-error: true
|
||||||
# with:
|
|
||||||
# pr-mode: true
|
|
||||||
# env:
|
|
||||||
# QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
|
|
||||||
# QODANA_ENDPOINT: 'https://qodana.cloud'
|
|
||||||
|
|
||||||
dotnet-format:
|
|
||||||
runs-on: windows-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup .NET
|
|
||||||
uses: actions/setup-dotnet@v4
|
|
||||||
with:
|
|
||||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
|
||||||
|
|
||||||
- name: Restore dependencies
|
|
||||||
run: dotnet restore
|
|
||||||
|
|
||||||
- name: Check code formatting
|
|
||||||
run: |
|
|
||||||
dotnet format --verify-no-changes --verbosity diagnostic || (
|
|
||||||
echo "::warning::Code formatting issues detected. Please run 'dotnet format' locally."
|
|
||||||
exit 0
|
|
||||||
)
|
|
||||||
|
|||||||
36
.github/workflows/issue-management.yml
vendored
36
.github/workflows/issue-management.yml
vendored
@@ -1,13 +1,12 @@
|
|||||||
name: Issue Management
|
name: Issue Management
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
|
||||||
schedule:
|
schedule:
|
||||||
# Every day at 1:30 AM UTC
|
- cron: "30 1 * * *" # Daily at 1:30 AM UTC
|
||||||
- cron: "30 1 * * *"
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
close-stale-issues:
|
stale:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
issues: write
|
issues: write
|
||||||
@@ -18,20 +17,17 @@ jobs:
|
|||||||
uses: actions/stale@v9
|
uses: actions/stale@v9
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
any-of-labels: 'need-more-info,waiting-for-response'
|
days-before-issue-stale: 30
|
||||||
days-before-issue-stale: 14
|
|
||||||
days-before-issue-close: 7
|
days-before-issue-close: 7
|
||||||
days-before-pr-stale: 21
|
days-before-pr-stale: 30
|
||||||
days-before-pr-close: 14
|
days-before-pr-close: 7
|
||||||
stale-issue-label: 'stale'
|
stale-issue-label: stale
|
||||||
stale-pr-label: 'stale'
|
stale-pr-label: stale
|
||||||
stale-issue-message: |
|
close-issue-label: closed
|
||||||
This issue has been inactive for 14 days.
|
close-pr-label: closed
|
||||||
It will be closed in 7 days if there's no activity.
|
stale-issue-message: This issue is stale and will be closed in 7 days
|
||||||
Please comment to keep it open.
|
stale-pr-message: This PR is stale and will be closed in 7 days
|
||||||
stale-pr-message: |
|
close-issue-message: Closed as stale
|
||||||
This PR has been inactive for 21 days.
|
close-pr-message: Closed as stale
|
||||||
It will be closed in 14 days if there's no activity.
|
exempt-issue-labels: pinned,security
|
||||||
Please comment or update the PR to keep it open.
|
exempt-pr-labels: pinned,security
|
||||||
close-issue-message: 'Closed due to inactivity. Feel free to reopen if needed.'
|
|
||||||
close-pr-message: 'Closed due to inactivity. Feel free to reopen if needed.'
|
|
||||||
|
|||||||
299
.github/workflows/release.yml
vendored
299
.github/workflows/release.yml
vendored
@@ -1,138 +1,98 @@
|
|||||||
name: Release & Publish (Multi-Platform)
|
name: Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
- 'release-*'
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
version:
|
tag:
|
||||||
description: 'Release version (e.g., 1.0.0)'
|
description: 'Release tag'
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
is_prerelease:
|
is_prerelease:
|
||||||
description: 'Mark as pre-release'
|
description: 'Pre-release'
|
||||||
required: false
|
required: false
|
||||||
type: boolean
|
type: boolean
|
||||||
default: false
|
default: false
|
||||||
build_windows:
|
|
||||||
description: 'Build Windows (x64/x86)'
|
|
||||||
required: false
|
|
||||||
type: boolean
|
|
||||||
default: true
|
|
||||||
build_linux:
|
|
||||||
description: 'Build Linux (x64)'
|
|
||||||
required: false
|
|
||||||
type: boolean
|
|
||||||
default: true
|
|
||||||
build_macos:
|
|
||||||
description: 'Build macOS (x64/arm64)'
|
|
||||||
required: false
|
|
||||||
type: boolean
|
|
||||||
default: true
|
|
||||||
|
|
||||||
env:
|
env:
|
||||||
DOTNET_VERSION: '10.0.x'
|
DOTNET_VERSION: '10.0.x'
|
||||||
|
Solution_Name: LanMontainDesktop.sln
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
prepare:
|
prepare:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
version: ${{ steps.version.outputs.version }}
|
version: ${{ steps.version.outputs.version }}
|
||||||
is_prerelease: ${{ steps.version.outputs.is_prerelease }}
|
tag: ${{ steps.version.outputs.tag }}
|
||||||
build_windows: ${{ steps.versions.outputs.build_windows }}
|
|
||||||
build_linux: ${{ steps.versions.outputs.build_linux }}
|
|
||||||
build_macos: ${{ steps.versions.outputs.build_macos }}
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Get version from tag or input
|
- name: Get release info
|
||||||
id: version
|
id: version
|
||||||
shell: bash
|
|
||||||
run: |
|
run: |
|
||||||
if [[ "${{ github.event_name }}" == "push" ]]; then
|
if [[ "${{ github.event_name }}" == "push" ]]; then
|
||||||
VERSION=${GITHUB_REF#refs/tags/}
|
TAG=${GITHUB_REF#refs/tags/}
|
||||||
else
|
else
|
||||||
VERSION=${{ github.event.inputs.version }}
|
TAG=${{ github.event.inputs.tag }}
|
||||||
fi
|
fi
|
||||||
VERSION=${VERSION#v}
|
VERSION=${TAG#v}
|
||||||
IS_PRERELEASE=${{ github.event.inputs.is_prerelease }}
|
echo "tag=${TAG}" >> $GITHUB_OUTPUT
|
||||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||||
echo "is_prerelease=${IS_PRERELEASE}" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Determine build targets
|
|
||||||
id: versions
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
if [[ "${{ github.event_name }}" == "push" ]]; then
|
|
||||||
echo "build_windows=true" >> $GITHUB_OUTPUT
|
|
||||||
echo "build_linux=true" >> $GITHUB_OUTPUT
|
|
||||||
echo "build_macos=true" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "build_windows=${{ github.event.inputs.build_windows }}" >> $GITHUB_OUTPUT
|
|
||||||
echo "build_linux=${{ github.event.inputs.build_linux }}" >> $GITHUB_OUTPUT
|
|
||||||
echo "build_macos=${{ github.event.inputs.build_macos }}" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
|
|
||||||
build-windows:
|
build-windows:
|
||||||
if: needs.prepare.outputs.build_windows == 'true'
|
|
||||||
needs: prepare
|
needs: prepare
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
arch: [ x64, x86 ]
|
arch: [x64, x86]
|
||||||
include:
|
name: Build_Windows_${{ matrix.arch }}
|
||||||
- arch: x64
|
|
||||||
rid: win-x64
|
|
||||||
- arch: x86
|
|
||||||
rid: win-x86
|
|
||||||
|
|
||||||
name: Build Windows (${{ matrix.arch }})
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||||
|
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@v4
|
uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||||
|
|
||||||
- name: Update version in csproj
|
- name: Restore
|
||||||
run: |
|
|
||||||
$version = "${{ needs.prepare.outputs.version }}"
|
|
||||||
(Get-Content LanMontainDesktop/LanMontainDesktop.csproj) -replace '(<Version>)[^<]*(</Version>)', "`$1$version`$2" | Set-Content LanMontainDesktop/LanMontainDesktop.csproj
|
|
||||||
(Get-Content LanMontainDesktop.RecommendationBackend/LanMontainDesktop.RecommendationBackend.csproj) -replace '(<Version>)[^<]*(</Version>)', "`$1$version`$2" | Set-Content LanMontainDesktop.RecommendationBackend/LanMontainDesktop.RecommendationBackend.csproj
|
|
||||||
shell: pwsh
|
|
||||||
|
|
||||||
- name: Restore dependencies
|
|
||||||
run: dotnet restore
|
run: dotnet restore
|
||||||
|
|
||||||
- name: Publish LanMontainDesktop
|
- name: Build
|
||||||
|
run: dotnet build -c Release --no-restore -v minimal
|
||||||
|
|
||||||
|
- name: Publish
|
||||||
run: |
|
run: |
|
||||||
dotnet publish LanMontainDesktop/LanMontainDesktop.csproj `
|
dotnet publish LanMontainDesktop/LanMontainDesktop.csproj `
|
||||||
-c Release `
|
-c Release `
|
||||||
-o ./publish/${{ matrix.rid }} `
|
-o ./publish/windows-${{ matrix.arch }} `
|
||||||
-r ${{ matrix.rid }} `
|
|
||||||
--self-contained `
|
--self-contained `
|
||||||
|
-r win-${{ matrix.arch }} `
|
||||||
-p:PublishSingleFile=true `
|
-p:PublishSingleFile=true `
|
||||||
-p:PublishTrimmed=false `
|
-p:DebugType=none
|
||||||
-p:IncludeNativeLibrariesForSelfExtract=true
|
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|
||||||
- name: Create Windows package
|
- name: Package
|
||||||
run: |
|
run: |
|
||||||
$packageDir = "LanMontainDesktop-${{ needs.prepare.outputs.version }}-${{ matrix.rid }}"
|
$version = "${{ needs.prepare.outputs.version }}"
|
||||||
New-Item -ItemType Directory -Path $packageDir -Force | Out-Null
|
$arch = "${{ matrix.arch }}"
|
||||||
Copy-Item "./publish/${{ matrix.rid }}/*" -Destination $packageDir -Recurse -Force
|
$source = "publish/windows-$arch"
|
||||||
Compress-Archive -Path $packageDir -DestinationPath "$packageDir.zip" -Force
|
$package = "LanMontainDesktop-$version-win-$arch"
|
||||||
Write-Host "✅ Package created: $packageDir.zip"
|
|
||||||
|
New-Item -ItemType Directory -Path "$package" -Force | Out-Null
|
||||||
|
Copy-Item -Path "$source/*" -Destination "$package" -Recurse -Force
|
||||||
|
Compress-Archive -Path "$package" -DestinationPath "$package.zip" -Force
|
||||||
|
|
||||||
|
Write-Host "Created: $package.zip"
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|
||||||
- name: Upload artifacts
|
- name: Upload
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: release-windows-${{ matrix.arch }}
|
name: release-windows-${{ matrix.arch }}
|
||||||
@@ -140,209 +100,160 @@ jobs:
|
|||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
build-linux:
|
build-linux:
|
||||||
if: needs.prepare.outputs.build_linux == 'true'
|
|
||||||
needs: prepare
|
needs: prepare
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
name: Build_Linux
|
||||||
matrix:
|
|
||||||
arch: [ x64 ]
|
|
||||||
include:
|
|
||||||
- arch: x64
|
|
||||||
rid: linux-x64
|
|
||||||
|
|
||||||
name: Build Linux (${{ matrix.arch }})
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y \
|
||||||
|
libfontconfig1 libfreetype6 \
|
||||||
|
libx11-6 libxrandr2 libxinerama1 \
|
||||||
|
libxi6 libxcursor1 libxext6 \
|
||||||
|
libxrender1 libxkbcommon-x11-0
|
||||||
|
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@v4
|
uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||||
|
|
||||||
- name: Install Linux dependencies
|
- name: Restore
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y \
|
|
||||||
libfontconfig1 \
|
|
||||||
libfreetype6 \
|
|
||||||
libx11-6 \
|
|
||||||
libxrandr2 \
|
|
||||||
libxinerama1 \
|
|
||||||
libxi6 \
|
|
||||||
libxcursor1 \
|
|
||||||
libxext6 \
|
|
||||||
libxrender1 \
|
|
||||||
libxkbcommon-x11-0
|
|
||||||
|
|
||||||
- name: Update version in csproj
|
|
||||||
run: |
|
|
||||||
sed -i 's/<Version>[^<]*<\/Version>/<Version>${{ needs.prepare.outputs.version }}<\/Version>/g' LanMontainDesktop/LanMontainDesktop.csproj
|
|
||||||
sed -i 's/<Version>[^<]*<\/Version>/<Version>${{ needs.prepare.outputs.version }}<\/Version>/g' LanMontainDesktop.RecommendationBackend/LanMontainDesktop.RecommendationBackend.csproj
|
|
||||||
|
|
||||||
- name: Restore dependencies
|
|
||||||
run: dotnet restore
|
run: dotnet restore
|
||||||
|
|
||||||
- name: Publish LanMontainDesktop
|
- name: Build
|
||||||
|
run: dotnet build -c Release --no-restore -v minimal
|
||||||
|
|
||||||
|
- name: Publish
|
||||||
run: |
|
run: |
|
||||||
dotnet publish LanMontainDesktop/LanMontainDesktop.csproj \
|
dotnet publish LanMontainDesktop/LanMontainDesktop.csproj \
|
||||||
-c Release \
|
-c Release \
|
||||||
-o ./publish/${{ matrix.rid }} \
|
-o ./publish/linux-x64 \
|
||||||
-r ${{ matrix.rid }} \
|
|
||||||
--self-contained \
|
--self-contained \
|
||||||
|
-r linux-x64 \
|
||||||
-p:PublishSingleFile=true \
|
-p:PublishSingleFile=true \
|
||||||
-p:PublishTrimmed=false
|
-p:DebugType=none
|
||||||
|
|
||||||
- name: Create Linux packages
|
- name: Package
|
||||||
run: |
|
run: |
|
||||||
PACKAGE_DIR="LanMontainDesktop-${{ needs.prepare.outputs.version }}-${{ matrix.rid }}"
|
version="${{ needs.prepare.outputs.version }}"
|
||||||
mkdir -p "$PACKAGE_DIR"
|
source="publish/linux-x64"
|
||||||
cp -r "./publish/${{ matrix.rid }}"/* "$PACKAGE_DIR/"
|
package="LanMontainDesktop-$version-linux-x64"
|
||||||
|
|
||||||
# Create tar.gz
|
mkdir -p "$package"
|
||||||
tar -czf "$PACKAGE_DIR.tar.gz" "$PACKAGE_DIR"
|
cp -r "$source"/* "$package/"
|
||||||
echo "✅ Created: $PACKAGE_DIR.tar.gz"
|
tar -czf "$package.tar.gz" "$package"
|
||||||
|
|
||||||
# Optional: Create AppImage (requires specific tools)
|
echo "Created: $package.tar.gz"
|
||||||
# This is commented out as it requires additional dependencies
|
|
||||||
# appimage-builder or similar tools would go here
|
|
||||||
|
|
||||||
- name: Upload artifacts
|
- name: Upload
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: release-linux-${{ matrix.arch }}
|
name: release-linux
|
||||||
path: LanMontainDesktop-*.tar.gz
|
path: LanMontainDesktop-*.tar.gz
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
build-macos:
|
build-macos:
|
||||||
if: needs.prepare.outputs.build_macos == 'true'
|
|
||||||
needs: prepare
|
needs: prepare
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
arch: [ x64, arm64 ]
|
arch: [x64, arm64]
|
||||||
include:
|
name: Build_macOS_${{ matrix.arch }}
|
||||||
- arch: x64
|
|
||||||
rid: osx-x64
|
|
||||||
- arch: arm64
|
|
||||||
rid: osx-arm64
|
|
||||||
|
|
||||||
name: Build macOS (${{ matrix.arch }})
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||||
|
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@v4
|
uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||||
|
|
||||||
- name: Update version in csproj
|
- name: Restore
|
||||||
run: |
|
|
||||||
sed -i '' 's/<Version>[^<]*<\/Version>/<Version>${{ needs.prepare.outputs.version }}<\/Version>/g' LanMontainDesktop/LanMontainDesktop.csproj
|
|
||||||
sed -i '' 's/<Version>[^<]*<\/Version>/<Version>${{ needs.prepare.outputs.version }}<\/Version>/g' LanMontainDesktop.RecommendationBackend/LanMontainDesktop.RecommendationBackend.csproj
|
|
||||||
|
|
||||||
- name: Restore dependencies
|
|
||||||
run: dotnet restore
|
run: dotnet restore
|
||||||
|
|
||||||
- name: Publish LanMontainDesktop
|
- name: Build
|
||||||
|
run: dotnet build -c Release --no-restore -v minimal
|
||||||
|
|
||||||
|
- name: Publish
|
||||||
run: |
|
run: |
|
||||||
dotnet publish LanMontainDesktop/LanMontainDesktop.csproj \
|
dotnet publish LanMontainDesktop/LanMontainDesktop.csproj \
|
||||||
-c Release \
|
-c Release \
|
||||||
-o ./publish/${{ matrix.rid }} \
|
-o ./publish/macos-${{ matrix.arch }} \
|
||||||
-r ${{ matrix.rid }} \
|
|
||||||
--self-contained \
|
--self-contained \
|
||||||
|
-r osx-${{ matrix.arch }} \
|
||||||
-p:PublishSingleFile=true \
|
-p:PublishSingleFile=true \
|
||||||
-p:PublishTrimmed=false
|
-p:DebugType=none
|
||||||
|
|
||||||
- name: Create macOS packages
|
- name: Package
|
||||||
run: |
|
run: |
|
||||||
PACKAGE_DIR="LanMontainDesktop-${{ needs.prepare.outputs.version }}-${{ matrix.rid }}"
|
version="${{ needs.prepare.outputs.version }}"
|
||||||
mkdir -p "$PACKAGE_DIR"
|
arch="${{ matrix.arch }}"
|
||||||
cp -r "./publish/${{ matrix.rid }}"/* "$PACKAGE_DIR/"
|
source="publish/macos-$arch"
|
||||||
|
package="LanMontainDesktop-$version-macos-$arch"
|
||||||
|
|
||||||
# Create tar.gz
|
mkdir -p "$package"
|
||||||
tar -czf "$PACKAGE_DIR.tar.gz" "$PACKAGE_DIR"
|
cp -r "$source"/* "$package/"
|
||||||
echo "✅ Created: $PACKAGE_DIR.tar.gz"
|
tar -czf "$package.tar.gz" "$package"
|
||||||
|
|
||||||
# Optional: Create DMG (requires additional tools)
|
echo "Created: $package.tar.gz"
|
||||||
# DMG creation would go here if needed
|
|
||||||
|
|
||||||
- name: Upload artifacts
|
- name: Upload
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: release-macos-${{ matrix.arch }}
|
name: release-macos-${{ matrix.arch }}
|
||||||
path: LanMontainDesktop-*.tar.gz
|
path: LanMontainDesktop-*.tar.gz
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
create-release:
|
github-release:
|
||||||
needs: prepare
|
needs: [ prepare, build-windows, build-linux, build-macos ]
|
||||||
if: always()
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Download all artifacts
|
- name: Download artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
path: ./release-artifacts
|
path: artifacts
|
||||||
pattern: release-*
|
pattern: release-*
|
||||||
|
|
||||||
- name: List downloaded artifacts
|
- name: Create Release
|
||||||
run: |
|
uses: ncipollo/release-action@v1
|
||||||
echo "📦 Downloaded artifacts:"
|
|
||||||
find ./release-artifacts -type f -name "*.zip" -o -name "*.tar.gz" | sort
|
|
||||||
|
|
||||||
- name: Create GitHub Release
|
|
||||||
uses: softprops/action-gh-release@v1
|
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
|
||||||
with:
|
with:
|
||||||
files: |
|
tag: ${{ github.ref_name }}
|
||||||
release-artifacts/**/*.zip
|
|
||||||
release-artifacts/**/*.tar.gz
|
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: ${{ needs.prepare.outputs.is_prerelease }}
|
prerelease: ${{ github.event.inputs.is_prerelease == 'true' }}
|
||||||
|
artifacts: artifacts/**/*
|
||||||
body: |
|
body: |
|
||||||
## Release ${{ needs.prepare.outputs.version }}
|
## Release ${{ needs.prepare.outputs.version }}
|
||||||
|
|
||||||
### 📥 Downloads
|
### Downloads
|
||||||
|
|
||||||
**Windows:**
|
**Windows:**
|
||||||
- `LanMontainDesktop-${{ needs.prepare.outputs.version }}-win-x64.zip` - Windows 64-bit
|
- win-x64 (64-bit)
|
||||||
- `LanMontainDesktop-${{ needs.prepare.outputs.version }}-win-x86.zip` - Windows 32-bit
|
- win-x86 (32-bit)
|
||||||
|
|
||||||
**Linux:**
|
**Linux:**
|
||||||
- `LanMontainDesktop-${{ needs.prepare.outputs.version }}-linux-x64.tar.gz` - Linux 64-bit
|
- linux-x64
|
||||||
|
|
||||||
**macOS:**
|
**macOS:**
|
||||||
- `LanMontainDesktop-${{ needs.prepare.outputs.version }}-osx-x64.tar.gz` - macOS Intel
|
- macos-x64 (Intel)
|
||||||
- `LanMontainDesktop-${{ needs.prepare.outputs.version }}-osx-arm64.tar.gz` - macOS Apple Silicon
|
- macos-arm64 (Apple Silicon)
|
||||||
|
|
||||||
### 📝 Changes
|
See commits for changes.
|
||||||
See commits for detailed changes: ${{ github.event.compare || 'https://github.com/${{ github.repository }}/commits/${{ github.sha }}' }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
### 💾 Installation
|
|
||||||
|
|
||||||
**Windows:** Extract zip and run `LanMontainDesktop.exe`
|
|
||||||
|
|
||||||
**Linux:** Extract tar.gz and run `./LanMontainDesktop`
|
|
||||||
|
|
||||||
**macOS:** Extract tar.gz and run `./LanMontainDesktop`
|
|
||||||
|
|
||||||
### ℹ️ System Requirements
|
|
||||||
- .NET Runtime 10.0+ (included in self-contained builds)
|
|
||||||
- Windows 10+, macOS 10.15+, or modern Linux distribution
|
|
||||||
|
|
||||||
---
|
|
||||||
*Built by ${{ github.event.actor }} on ${{ github.event.head_commit.timestamp }}*
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|||||||
@@ -28,6 +28,11 @@
|
|||||||
<Setter Property="FontFamily" Value="{DynamicResource AppFontFamily}" />
|
<Setter Property="FontFamily" Value="{DynamicResource AppFontFamily}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<!-- Mitigation for Avalonia ScrollContentPresenter offset recursion during gesture inertia -->
|
||||||
|
<Style Selector="ScrollViewer">
|
||||||
|
<Setter Property="ScrollViewer.IsScrollInertiaEnabled" Value="False" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
<Style Selector="fi|SymbolIcon">
|
<Style Selector="fi|SymbolIcon">
|
||||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||||
<Setter Property="FontSize" Value="16" />
|
<Setter Property="FontSize" Value="16" />
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using System.Linq;
|
|||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
using LanMontainDesktop.ViewModels;
|
using LanMontainDesktop.ViewModels;
|
||||||
using LanMontainDesktop.Views;
|
using LanMontainDesktop.Views;
|
||||||
|
using AvaloniaWebView;
|
||||||
|
|
||||||
namespace LanMontainDesktop;
|
namespace LanMontainDesktop;
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ public partial class App : Application
|
|||||||
{
|
{
|
||||||
public override void Initialize()
|
public override void Initialize()
|
||||||
{
|
{
|
||||||
|
AvaloniaWebViewBuilder.Initialize(default);
|
||||||
AvaloniaXamlLoader.Load(this);
|
AvaloniaXamlLoader.Load(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,4 +22,5 @@ public static class BuiltInComponentIds
|
|||||||
public const string DesktopDailyArtwork = "DesktopDailyArtwork";
|
public const string DesktopDailyArtwork = "DesktopDailyArtwork";
|
||||||
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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,6 +159,16 @@ public sealed class ComponentRegistry
|
|||||||
AllowStatusBarPlacement: false,
|
AllowStatusBarPlacement: false,
|
||||||
AllowDesktopPlacement: true,
|
AllowDesktopPlacement: true,
|
||||||
ResizeMode: DesktopComponentResizeMode.Free),
|
ResizeMode: DesktopComponentResizeMode.Free),
|
||||||
|
new DesktopComponentDefinition(
|
||||||
|
BuiltInComponentIds.DesktopBrowser,
|
||||||
|
"Browser",
|
||||||
|
"Globe",
|
||||||
|
"Board",
|
||||||
|
MinWidthCells: 4,
|
||||||
|
MinHeightCells: 4,
|
||||||
|
AllowStatusBarPlacement: false,
|
||||||
|
AllowDesktopPlacement: true,
|
||||||
|
ResizeMode: DesktopComponentResizeMode.Free),
|
||||||
new DesktopComponentDefinition(
|
new DesktopComponentDefinition(
|
||||||
BuiltInComponentIds.Date,
|
BuiltInComponentIds.Date,
|
||||||
"Calendar",
|
"Calendar",
|
||||||
|
|||||||
@@ -36,6 +36,8 @@
|
|||||||
<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.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="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.Desktop" Version="11.0.0.1" />
|
||||||
<PackageReference Include="YamlDotNet" Version="16.3.0" />
|
<PackageReference Include="YamlDotNet" Version="16.3.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -230,6 +230,7 @@
|
|||||||
"component.daily_artwork": "Daily Artwork",
|
"component.daily_artwork": "Daily Artwork",
|
||||||
"component.whiteboard": "Blackboard (Portrait)",
|
"component.whiteboard": "Blackboard (Portrait)",
|
||||||
"component.blackboard_landscape": "Blackboard (Landscape)",
|
"component.blackboard_landscape": "Blackboard (Landscape)",
|
||||||
|
"component.browser": "Browser",
|
||||||
"component.holiday_calendar": "Holiday Calendar",
|
"component.holiday_calendar": "Holiday Calendar",
|
||||||
"poetry.widget.loading_content": "Loading poetry...",
|
"poetry.widget.loading_content": "Loading poetry...",
|
||||||
"poetry.widget.loading_author": "Loading...",
|
"poetry.widget.loading_author": "Loading...",
|
||||||
|
|||||||
@@ -230,6 +230,7 @@
|
|||||||
"component.daily_artwork": "每日名画",
|
"component.daily_artwork": "每日名画",
|
||||||
"component.whiteboard": "竖向小黑板",
|
"component.whiteboard": "竖向小黑板",
|
||||||
"component.blackboard_landscape": "横向小黑板",
|
"component.blackboard_landscape": "横向小黑板",
|
||||||
|
"component.browser": "浏览器",
|
||||||
"component.holiday_calendar": "节假日日历",
|
"component.holiday_calendar": "节假日日历",
|
||||||
"poetry.widget.loading_content": "正在加载诗词",
|
"poetry.widget.loading_content": "正在加载诗词",
|
||||||
"poetry.widget.loading_author": "加载中",
|
"poetry.widget.loading_author": "加载中",
|
||||||
|
|||||||
138
LanMontainDesktop/Models/StudyAnalyticsModels.cs
Normal file
138
LanMontainDesktop/Models/StudyAnalyticsModels.cs
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace LanMontainDesktop.Models;
|
||||||
|
|
||||||
|
public enum StudyAnalyticsRuntimeState
|
||||||
|
{
|
||||||
|
Unsupported = 0,
|
||||||
|
Ready = 1,
|
||||||
|
Running = 2,
|
||||||
|
Paused = 3,
|
||||||
|
Error = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum NoiseStreamStatus
|
||||||
|
{
|
||||||
|
Initializing = 0,
|
||||||
|
Quiet = 1,
|
||||||
|
Noisy = 2,
|
||||||
|
Error = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum StudySessionRuntimeState
|
||||||
|
{
|
||||||
|
Idle = 0,
|
||||||
|
Running = 1,
|
||||||
|
Completed = 2,
|
||||||
|
Error = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum StudyDataMode
|
||||||
|
{
|
||||||
|
Realtime = 0,
|
||||||
|
SessionRunning = 1,
|
||||||
|
SessionReport = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record StudyAnalyticsConfig(
|
||||||
|
int FrameMs = 50,
|
||||||
|
int SliceSec = 30,
|
||||||
|
double ScoreThresholdDbfs = -50,
|
||||||
|
int SegmentMergeGapMs = 500,
|
||||||
|
int MaxSegmentsPerMin = 6,
|
||||||
|
double SilenceFloorDbfs = -90,
|
||||||
|
double BaselineDb = 45,
|
||||||
|
bool ShowRelativeDb = true,
|
||||||
|
bool AlertSoundEnabled = false,
|
||||||
|
int AvgWindowSec = 1,
|
||||||
|
int RealtimeBufferCapacity = 240);
|
||||||
|
|
||||||
|
public sealed record NoiseRealtimePoint(
|
||||||
|
DateTimeOffset Timestamp,
|
||||||
|
double Rms,
|
||||||
|
double Dbfs,
|
||||||
|
double DisplayDb,
|
||||||
|
double Peak,
|
||||||
|
bool IsOverThreshold);
|
||||||
|
|
||||||
|
public sealed record NoiseSliceRawStats(
|
||||||
|
double AvgDbfs,
|
||||||
|
double MaxDbfs,
|
||||||
|
double P50Dbfs,
|
||||||
|
double P95Dbfs,
|
||||||
|
double OverRatioDbfs,
|
||||||
|
int SegmentCount,
|
||||||
|
double SampledDurationMs,
|
||||||
|
int GapCount,
|
||||||
|
double MaxGapMs);
|
||||||
|
|
||||||
|
public sealed record NoiseSliceDisplayStats(
|
||||||
|
double AvgDb,
|
||||||
|
double P95Db);
|
||||||
|
|
||||||
|
public sealed record NoiseScoreBreakdown(
|
||||||
|
double SustainedPenalty,
|
||||||
|
double TimePenalty,
|
||||||
|
double SegmentPenalty,
|
||||||
|
double TotalPenalty,
|
||||||
|
double Score,
|
||||||
|
double SustainedLevelDbfs,
|
||||||
|
double OverRatioDbfs,
|
||||||
|
int SegmentCount,
|
||||||
|
double Minutes,
|
||||||
|
double DurationMs);
|
||||||
|
|
||||||
|
public sealed record NoiseSliceSummary(
|
||||||
|
DateTimeOffset StartAt,
|
||||||
|
DateTimeOffset EndAt,
|
||||||
|
int FrameCount,
|
||||||
|
NoiseSliceRawStats Raw,
|
||||||
|
NoiseSliceDisplayStats Display,
|
||||||
|
double Score,
|
||||||
|
NoiseScoreBreakdown ScoreDetail);
|
||||||
|
|
||||||
|
public sealed record StudySessionOptions(
|
||||||
|
string? Label = null,
|
||||||
|
DateTimeOffset? PlannedEndAt = null);
|
||||||
|
|
||||||
|
public sealed record StudySessionMetrics(
|
||||||
|
double CurrentScore,
|
||||||
|
double AvgScore,
|
||||||
|
double MinScore,
|
||||||
|
double MaxScore,
|
||||||
|
double WeightedOverRatioDbfs,
|
||||||
|
int TotalSegmentCount,
|
||||||
|
TimeSpan EffectiveDuration,
|
||||||
|
int SliceCount);
|
||||||
|
|
||||||
|
public sealed record StudySessionSnapshot(
|
||||||
|
StudySessionRuntimeState State,
|
||||||
|
string? SessionId,
|
||||||
|
string Label,
|
||||||
|
DateTimeOffset? StartedAt,
|
||||||
|
DateTimeOffset? EndedAt,
|
||||||
|
TimeSpan Elapsed,
|
||||||
|
StudySessionMetrics Metrics,
|
||||||
|
string LastError);
|
||||||
|
|
||||||
|
public sealed record StudySessionReport(
|
||||||
|
string SessionId,
|
||||||
|
string Label,
|
||||||
|
DateTimeOffset StartedAt,
|
||||||
|
DateTimeOffset EndedAt,
|
||||||
|
TimeSpan Duration,
|
||||||
|
StudySessionMetrics Metrics,
|
||||||
|
IReadOnlyList<NoiseSliceSummary> Slices);
|
||||||
|
|
||||||
|
public sealed record StudyAnalyticsSnapshot(
|
||||||
|
StudyAnalyticsRuntimeState State,
|
||||||
|
NoiseStreamStatus StreamStatus,
|
||||||
|
StudyDataMode DataMode,
|
||||||
|
StudyAnalyticsConfig Config,
|
||||||
|
NoiseRealtimePoint? LatestRealtimePoint,
|
||||||
|
NoiseSliceSummary? LatestSlice,
|
||||||
|
IReadOnlyList<NoiseRealtimePoint> RealtimeBuffer,
|
||||||
|
StudySessionSnapshot Session,
|
||||||
|
StudySessionReport? LastSessionReport,
|
||||||
|
string LastError);
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
|
using Avalonia.WebView.Desktop;
|
||||||
using System;
|
using System;
|
||||||
|
|
||||||
namespace LanMontainDesktop;
|
namespace LanMontainDesktop;
|
||||||
@@ -16,6 +17,7 @@ sealed class Program
|
|||||||
public static AppBuilder BuildAvaloniaApp()
|
public static AppBuilder BuildAvaloniaApp()
|
||||||
=> AppBuilder.Configure<App>()
|
=> AppBuilder.Configure<App>()
|
||||||
.UsePlatformDetect()
|
.UsePlatformDetect()
|
||||||
|
.UseDesktopWebView()
|
||||||
.WithInterFont()
|
.WithInterFont()
|
||||||
.LogToTrace();
|
.LogToTrace();
|
||||||
}
|
}
|
||||||
|
|||||||
48
LanMontainDesktop/Services/IStudyAnalyticsService.cs
Normal file
48
LanMontainDesktop/Services/IStudyAnalyticsService.cs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
using System;
|
||||||
|
using LanMontainDesktop.Models;
|
||||||
|
|
||||||
|
namespace LanMontainDesktop.Services;
|
||||||
|
|
||||||
|
public sealed class StudyAnalyticsSnapshotChangedEventArgs(StudyAnalyticsSnapshot snapshot) : EventArgs
|
||||||
|
{
|
||||||
|
public StudyAnalyticsSnapshot Snapshot { get; } = snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class NoiseSliceClosedEventArgs(NoiseSliceSummary slice) : EventArgs
|
||||||
|
{
|
||||||
|
public NoiseSliceSummary Slice { get; } = slice;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class StudySessionCompletedEventArgs(StudySessionReport report) : EventArgs
|
||||||
|
{
|
||||||
|
public StudySessionReport Report { get; } = report;
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IStudyAnalyticsService : IDisposable
|
||||||
|
{
|
||||||
|
StudyAnalyticsSnapshot GetSnapshot();
|
||||||
|
|
||||||
|
StudyAnalyticsConfig GetConfig();
|
||||||
|
|
||||||
|
void UpdateConfig(StudyAnalyticsConfig config);
|
||||||
|
|
||||||
|
bool StartOrResumeMonitoring();
|
||||||
|
|
||||||
|
bool PauseMonitoring();
|
||||||
|
|
||||||
|
bool StopMonitoring();
|
||||||
|
|
||||||
|
bool StartStudySession(StudySessionOptions? options = null);
|
||||||
|
|
||||||
|
bool StopStudySession();
|
||||||
|
|
||||||
|
bool CancelStudySession();
|
||||||
|
|
||||||
|
void ClearLastSessionReport();
|
||||||
|
|
||||||
|
event EventHandler<StudyAnalyticsSnapshotChangedEventArgs>? SnapshotUpdated;
|
||||||
|
|
||||||
|
event EventHandler<NoiseSliceClosedEventArgs>? SliceClosed;
|
||||||
|
|
||||||
|
event EventHandler<StudySessionCompletedEventArgs>? SessionCompleted;
|
||||||
|
}
|
||||||
492
LanMontainDesktop/Services/StudyAnalyticsInternals.cs
Normal file
492
LanMontainDesktop/Services/StudyAnalyticsInternals.cs
Normal file
@@ -0,0 +1,492 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using LanMontainDesktop.Models;
|
||||||
|
|
||||||
|
namespace LanMontainDesktop.Services;
|
||||||
|
|
||||||
|
internal readonly record struct NoisePipelineTickResult(
|
||||||
|
NoiseRealtimePoint RealtimePoint,
|
||||||
|
NoiseSliceSummary? ClosedSlice);
|
||||||
|
|
||||||
|
internal sealed class NoiseFramePipeline
|
||||||
|
{
|
||||||
|
private StudyAnalyticsConfig _config;
|
||||||
|
private readonly Queue<NoiseRealtimePoint> _realtimeBuffer = new();
|
||||||
|
private readonly List<NoiseRealtimePoint> _slicePoints = [];
|
||||||
|
|
||||||
|
private DateTimeOffset _sliceStartAt;
|
||||||
|
private DateTimeOffset _lastFrameAt;
|
||||||
|
private DateTimeOffset _lastOverThresholdAt;
|
||||||
|
|
||||||
|
private int _overThresholdFrameCount;
|
||||||
|
private int _segmentCount;
|
||||||
|
private bool _segmentOpen;
|
||||||
|
private int _gapCount;
|
||||||
|
private double _maxGapMs;
|
||||||
|
|
||||||
|
public NoiseFramePipeline(StudyAnalyticsConfig config)
|
||||||
|
{
|
||||||
|
_config = NormalizeConfig(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UpdateConfig(StudyAnalyticsConfig config)
|
||||||
|
{
|
||||||
|
_config = NormalizeConfig(config);
|
||||||
|
Reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Reset()
|
||||||
|
{
|
||||||
|
_realtimeBuffer.Clear();
|
||||||
|
_slicePoints.Clear();
|
||||||
|
_sliceStartAt = default;
|
||||||
|
_lastFrameAt = default;
|
||||||
|
_lastOverThresholdAt = default;
|
||||||
|
_overThresholdFrameCount = 0;
|
||||||
|
_segmentCount = 0;
|
||||||
|
_segmentOpen = false;
|
||||||
|
_gapCount = 0;
|
||||||
|
_maxGapMs = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<NoiseRealtimePoint> GetRealtimeBufferSnapshot()
|
||||||
|
{
|
||||||
|
return _realtimeBuffer.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public NoisePipelineTickResult AddFrame(DateTimeOffset timestamp, double rms, double dbfs, double displayDb, double peak)
|
||||||
|
{
|
||||||
|
if (_sliceStartAt == default)
|
||||||
|
{
|
||||||
|
_sliceStartAt = timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_lastFrameAt != default)
|
||||||
|
{
|
||||||
|
var actualGapMs = (timestamp - _lastFrameAt).TotalMilliseconds;
|
||||||
|
var expectedGapMs = _config.FrameMs;
|
||||||
|
var jitterMs = Math.Max(0, actualGapMs - expectedGapMs);
|
||||||
|
if (jitterMs > Math.Max(12, expectedGapMs * 0.8))
|
||||||
|
{
|
||||||
|
_gapCount++;
|
||||||
|
_maxGapMs = Math.Max(_maxGapMs, jitterMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastFrameAt = timestamp;
|
||||||
|
|
||||||
|
var isOverThreshold = dbfs > _config.ScoreThresholdDbfs;
|
||||||
|
if (isOverThreshold)
|
||||||
|
{
|
||||||
|
_overThresholdFrameCount++;
|
||||||
|
if (_segmentOpen)
|
||||||
|
{
|
||||||
|
_lastOverThresholdAt = timestamp;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var canMergeToPrevious = _lastOverThresholdAt != default &&
|
||||||
|
(timestamp - _lastOverThresholdAt).TotalMilliseconds <= _config.SegmentMergeGapMs;
|
||||||
|
if (!canMergeToPrevious)
|
||||||
|
{
|
||||||
|
_segmentCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
_segmentOpen = true;
|
||||||
|
_lastOverThresholdAt = timestamp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (_segmentOpen && _lastOverThresholdAt != default)
|
||||||
|
{
|
||||||
|
var silentGapMs = (timestamp - _lastOverThresholdAt).TotalMilliseconds;
|
||||||
|
if (silentGapMs > _config.SegmentMergeGapMs)
|
||||||
|
{
|
||||||
|
_segmentOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var point = new NoiseRealtimePoint(
|
||||||
|
timestamp,
|
||||||
|
rms,
|
||||||
|
dbfs,
|
||||||
|
displayDb,
|
||||||
|
peak,
|
||||||
|
isOverThreshold);
|
||||||
|
_slicePoints.Add(point);
|
||||||
|
_realtimeBuffer.Enqueue(point);
|
||||||
|
|
||||||
|
while (_realtimeBuffer.Count > _config.RealtimeBufferCapacity)
|
||||||
|
{
|
||||||
|
_realtimeBuffer.Dequeue();
|
||||||
|
}
|
||||||
|
|
||||||
|
var elapsedSeconds = (timestamp - _sliceStartAt).TotalSeconds;
|
||||||
|
if (elapsedSeconds + 1e-6 < _config.SliceSec)
|
||||||
|
{
|
||||||
|
return new NoisePipelineTickResult(point, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var slice = BuildClosedSlice(timestamp);
|
||||||
|
ResetSliceState(timestamp);
|
||||||
|
return new NoisePipelineTickResult(point, slice);
|
||||||
|
}
|
||||||
|
|
||||||
|
private NoiseSliceSummary BuildClosedSlice(DateTimeOffset endAt)
|
||||||
|
{
|
||||||
|
var sampledDurationMs = _slicePoints.Count * _config.FrameMs;
|
||||||
|
if (_slicePoints.Count == 0 || sampledDurationMs <= 0)
|
||||||
|
{
|
||||||
|
var emptyRaw = new NoiseSliceRawStats(
|
||||||
|
AvgDbfs: _config.SilenceFloorDbfs,
|
||||||
|
MaxDbfs: _config.SilenceFloorDbfs,
|
||||||
|
P50Dbfs: _config.SilenceFloorDbfs,
|
||||||
|
P95Dbfs: _config.SilenceFloorDbfs,
|
||||||
|
OverRatioDbfs: 0,
|
||||||
|
SegmentCount: 0,
|
||||||
|
SampledDurationMs: 0,
|
||||||
|
GapCount: _gapCount,
|
||||||
|
MaxGapMs: _maxGapMs);
|
||||||
|
var emptyDisplay = new NoiseSliceDisplayStats(_config.BaselineDb, _config.BaselineDb);
|
||||||
|
var emptyScore = ScoreCalculator.Calculate(
|
||||||
|
p50Dbfs: emptyRaw.P50Dbfs,
|
||||||
|
overRatioDbfs: emptyRaw.OverRatioDbfs,
|
||||||
|
segmentCount: emptyRaw.SegmentCount,
|
||||||
|
sampledDurationMs: 0,
|
||||||
|
scoreThresholdDbfs: _config.ScoreThresholdDbfs,
|
||||||
|
maxSegmentsPerMin: _config.MaxSegmentsPerMin);
|
||||||
|
return new NoiseSliceSummary(
|
||||||
|
_sliceStartAt,
|
||||||
|
endAt,
|
||||||
|
0,
|
||||||
|
emptyRaw,
|
||||||
|
emptyDisplay,
|
||||||
|
emptyScore.Score,
|
||||||
|
emptyScore);
|
||||||
|
}
|
||||||
|
|
||||||
|
var dbfsList = _slicePoints.Select(p => p.Dbfs).OrderBy(v => v).ToArray();
|
||||||
|
var displayList = _slicePoints.Select(p => p.DisplayDb).OrderBy(v => v).ToArray();
|
||||||
|
|
||||||
|
var avgDbfs = ScoreCalculator.ComputeAverageDbfs(dbfsList);
|
||||||
|
var maxDbfs = dbfsList[^1];
|
||||||
|
var p50Dbfs = Percentile(sortedValues: dbfsList, percentile: 0.50);
|
||||||
|
var p95Dbfs = Percentile(sortedValues: dbfsList, percentile: 0.95);
|
||||||
|
var overRatio = _overThresholdFrameCount / (double)_slicePoints.Count;
|
||||||
|
|
||||||
|
var raw = new NoiseSliceRawStats(
|
||||||
|
AvgDbfs: avgDbfs,
|
||||||
|
MaxDbfs: maxDbfs,
|
||||||
|
P50Dbfs: p50Dbfs,
|
||||||
|
P95Dbfs: p95Dbfs,
|
||||||
|
OverRatioDbfs: Math.Clamp(overRatio, 0, 1),
|
||||||
|
SegmentCount: _segmentCount,
|
||||||
|
SampledDurationMs: sampledDurationMs,
|
||||||
|
GapCount: _gapCount,
|
||||||
|
MaxGapMs: _maxGapMs);
|
||||||
|
|
||||||
|
var display = new NoiseSliceDisplayStats(
|
||||||
|
AvgDb: Math.Round(displayList.Average(), 2),
|
||||||
|
P95Db: Math.Round(Percentile(displayList, 0.95), 2));
|
||||||
|
|
||||||
|
var score = ScoreCalculator.Calculate(
|
||||||
|
p50Dbfs: raw.P50Dbfs,
|
||||||
|
overRatioDbfs: raw.OverRatioDbfs,
|
||||||
|
segmentCount: raw.SegmentCount,
|
||||||
|
sampledDurationMs: raw.SampledDurationMs,
|
||||||
|
scoreThresholdDbfs: _config.ScoreThresholdDbfs,
|
||||||
|
maxSegmentsPerMin: _config.MaxSegmentsPerMin);
|
||||||
|
|
||||||
|
return new NoiseSliceSummary(
|
||||||
|
_sliceStartAt,
|
||||||
|
endAt,
|
||||||
|
_slicePoints.Count,
|
||||||
|
raw,
|
||||||
|
display,
|
||||||
|
score.Score,
|
||||||
|
score);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResetSliceState(DateTimeOffset nextSliceStartAt)
|
||||||
|
{
|
||||||
|
_slicePoints.Clear();
|
||||||
|
_sliceStartAt = nextSliceStartAt;
|
||||||
|
_lastOverThresholdAt = default;
|
||||||
|
_overThresholdFrameCount = 0;
|
||||||
|
_segmentCount = 0;
|
||||||
|
_segmentOpen = false;
|
||||||
|
_gapCount = 0;
|
||||||
|
_maxGapMs = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double Percentile(double[] sortedValues, double percentile)
|
||||||
|
{
|
||||||
|
if (sortedValues.Length == 0)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortedValues.Length == 1)
|
||||||
|
{
|
||||||
|
return sortedValues[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
var clamped = Math.Clamp(percentile, 0, 1);
|
||||||
|
var position = (sortedValues.Length - 1) * clamped;
|
||||||
|
var lower = (int)Math.Floor(position);
|
||||||
|
var upper = (int)Math.Ceiling(position);
|
||||||
|
if (lower == upper)
|
||||||
|
{
|
||||||
|
return sortedValues[lower];
|
||||||
|
}
|
||||||
|
|
||||||
|
var factor = position - lower;
|
||||||
|
return sortedValues[lower] + ((sortedValues[upper] - sortedValues[lower]) * factor);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StudyAnalyticsConfig NormalizeConfig(StudyAnalyticsConfig config)
|
||||||
|
{
|
||||||
|
var frameMs = Math.Clamp(config.FrameMs, 20, 250);
|
||||||
|
var sliceSec = Math.Clamp(config.SliceSec, 5, 600);
|
||||||
|
var threshold = Math.Clamp(config.ScoreThresholdDbfs, -100, -5);
|
||||||
|
var mergeGapMs = Math.Clamp(config.SegmentMergeGapMs, 100, 4000);
|
||||||
|
var maxSegments = Math.Clamp(config.MaxSegmentsPerMin, 1, 40);
|
||||||
|
var silenceFloor = Math.Clamp(config.SilenceFloorDbfs, -100, -20);
|
||||||
|
var baselineDb = Math.Clamp(config.BaselineDb, 20, 90);
|
||||||
|
var avgWindowSec = Math.Clamp(config.AvgWindowSec, 1, 8);
|
||||||
|
var ringCapacity = Math.Clamp(config.RealtimeBufferCapacity, 60, 1200);
|
||||||
|
|
||||||
|
return config with
|
||||||
|
{
|
||||||
|
FrameMs = frameMs,
|
||||||
|
SliceSec = sliceSec,
|
||||||
|
ScoreThresholdDbfs = threshold,
|
||||||
|
SegmentMergeGapMs = mergeGapMs,
|
||||||
|
MaxSegmentsPerMin = maxSegments,
|
||||||
|
SilenceFloorDbfs = silenceFloor,
|
||||||
|
BaselineDb = baselineDb,
|
||||||
|
AvgWindowSec = avgWindowSec,
|
||||||
|
RealtimeBufferCapacity = ringCapacity
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class ScoreCalculator
|
||||||
|
{
|
||||||
|
public static NoiseScoreBreakdown Calculate(
|
||||||
|
double p50Dbfs,
|
||||||
|
double overRatioDbfs,
|
||||||
|
int segmentCount,
|
||||||
|
double sampledDurationMs,
|
||||||
|
double scoreThresholdDbfs,
|
||||||
|
int maxSegmentsPerMin)
|
||||||
|
{
|
||||||
|
var minutes = Math.Max(1d / 60d, sampledDurationMs / 60000d);
|
||||||
|
var sustainedPenalty = Clamp01((p50Dbfs - scoreThresholdDbfs) / 6d);
|
||||||
|
var timePenalty = Clamp01(overRatioDbfs / 0.30d);
|
||||||
|
var segmentRatePerMin = segmentCount / minutes;
|
||||||
|
var segmentPenalty = Clamp01(segmentRatePerMin / Math.Max(1, maxSegmentsPerMin));
|
||||||
|
var totalPenalty = (0.40d * sustainedPenalty) + (0.30d * timePenalty) + (0.30d * segmentPenalty);
|
||||||
|
var score = Math.Clamp(100d * (1d - totalPenalty), 0, 100);
|
||||||
|
|
||||||
|
return new NoiseScoreBreakdown(
|
||||||
|
SustainedPenalty: Math.Round(sustainedPenalty, 4),
|
||||||
|
TimePenalty: Math.Round(timePenalty, 4),
|
||||||
|
SegmentPenalty: Math.Round(segmentPenalty, 4),
|
||||||
|
TotalPenalty: Math.Round(totalPenalty, 4),
|
||||||
|
Score: Math.Round(score, 2),
|
||||||
|
SustainedLevelDbfs: Math.Round(p50Dbfs, 3),
|
||||||
|
OverRatioDbfs: Math.Round(Math.Clamp(overRatioDbfs, 0, 1), 4),
|
||||||
|
SegmentCount: Math.Max(0, segmentCount),
|
||||||
|
Minutes: Math.Round(minutes, 4),
|
||||||
|
DurationMs: Math.Max(0, sampledDurationMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static double ComputeAverageDbfs(double[] dbfsValues)
|
||||||
|
{
|
||||||
|
if (dbfsValues.Length == 0)
|
||||||
|
{
|
||||||
|
return -100;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Average in energy domain then convert back to dBFS.
|
||||||
|
var avgPower = dbfsValues
|
||||||
|
.Select(DbfsToPower)
|
||||||
|
.Average();
|
||||||
|
return Math.Round(PowerToDbfs(avgPower), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static double DbfsToPower(double dbfs)
|
||||||
|
{
|
||||||
|
return Math.Pow(10d, dbfs / 10d);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static double PowerToDbfs(double power)
|
||||||
|
{
|
||||||
|
if (power <= 1e-12)
|
||||||
|
{
|
||||||
|
return -100;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.Clamp(10d * Math.Log10(power), -100, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double Clamp01(double value)
|
||||||
|
{
|
||||||
|
return Math.Clamp(value, 0, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class SessionAccumulator
|
||||||
|
{
|
||||||
|
private readonly List<NoiseSliceSummary> _slices = [];
|
||||||
|
|
||||||
|
private StudySessionRuntimeState _state = StudySessionRuntimeState.Idle;
|
||||||
|
private string? _sessionId;
|
||||||
|
private string _label = string.Empty;
|
||||||
|
private DateTimeOffset? _startedAt;
|
||||||
|
private DateTimeOffset? _endedAt;
|
||||||
|
private string _lastError = string.Empty;
|
||||||
|
|
||||||
|
private double _sumEffectiveMs;
|
||||||
|
private double _sumWeightedScore;
|
||||||
|
private double _sumWeightedOverRatio;
|
||||||
|
private int _totalSegments;
|
||||||
|
private double _minScore = 100;
|
||||||
|
private double _maxScore;
|
||||||
|
private double _currentScore;
|
||||||
|
|
||||||
|
public bool IsRunning => _state == StudySessionRuntimeState.Running;
|
||||||
|
|
||||||
|
public bool Start(DateTimeOffset now, StudySessionOptions options)
|
||||||
|
{
|
||||||
|
if (IsRunning)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_state = StudySessionRuntimeState.Running;
|
||||||
|
_sessionId = Guid.NewGuid().ToString("N");
|
||||||
|
_label = string.IsNullOrWhiteSpace(options.Label) ? "Study Session" : options.Label.Trim();
|
||||||
|
_startedAt = now;
|
||||||
|
_endedAt = null;
|
||||||
|
_lastError = string.Empty;
|
||||||
|
_slices.Clear();
|
||||||
|
_sumEffectiveMs = 0;
|
||||||
|
_sumWeightedScore = 0;
|
||||||
|
_sumWeightedOverRatio = 0;
|
||||||
|
_totalSegments = 0;
|
||||||
|
_minScore = 100;
|
||||||
|
_maxScore = 0;
|
||||||
|
_currentScore = 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddSlice(NoiseSliceSummary slice)
|
||||||
|
{
|
||||||
|
if (!IsRunning)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_slices.Add(slice);
|
||||||
|
var effectiveMs = Math.Max(0, slice.Raw.SampledDurationMs);
|
||||||
|
_sumEffectiveMs += effectiveMs;
|
||||||
|
_sumWeightedScore += slice.Score * effectiveMs;
|
||||||
|
_sumWeightedOverRatio += slice.Raw.OverRatioDbfs * effectiveMs;
|
||||||
|
_totalSegments += Math.Max(0, slice.Raw.SegmentCount);
|
||||||
|
_currentScore = slice.Score;
|
||||||
|
_minScore = Math.Min(_minScore, slice.Score);
|
||||||
|
_maxScore = Math.Max(_maxScore, slice.Score);
|
||||||
|
}
|
||||||
|
|
||||||
|
public StudySessionReport? Stop(DateTimeOffset now)
|
||||||
|
{
|
||||||
|
if (!IsRunning || _startedAt is null || string.IsNullOrWhiteSpace(_sessionId))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_state = StudySessionRuntimeState.Completed;
|
||||||
|
_endedAt = now;
|
||||||
|
|
||||||
|
var metrics = BuildMetrics();
|
||||||
|
return new StudySessionReport(
|
||||||
|
SessionId: _sessionId,
|
||||||
|
Label: _label,
|
||||||
|
StartedAt: _startedAt.Value,
|
||||||
|
EndedAt: _endedAt.Value,
|
||||||
|
Duration: _endedAt.Value - _startedAt.Value,
|
||||||
|
Metrics: metrics,
|
||||||
|
Slices: _slices.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Cancel()
|
||||||
|
{
|
||||||
|
if (!IsRunning)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ResetToIdle();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public StudySessionSnapshot GetSnapshot(DateTimeOffset now)
|
||||||
|
{
|
||||||
|
var startedAt = _startedAt;
|
||||||
|
var endedAt = _endedAt;
|
||||||
|
var elapsed = startedAt is null
|
||||||
|
? TimeSpan.Zero
|
||||||
|
: (endedAt ?? now) - startedAt.Value;
|
||||||
|
if (elapsed < TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
elapsed = TimeSpan.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new StudySessionSnapshot(
|
||||||
|
State: _state,
|
||||||
|
SessionId: _sessionId,
|
||||||
|
Label: _label,
|
||||||
|
StartedAt: startedAt,
|
||||||
|
EndedAt: endedAt,
|
||||||
|
Elapsed: elapsed,
|
||||||
|
Metrics: BuildMetrics(),
|
||||||
|
LastError: _lastError);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ResetToIdle()
|
||||||
|
{
|
||||||
|
_state = StudySessionRuntimeState.Idle;
|
||||||
|
_sessionId = null;
|
||||||
|
_label = string.Empty;
|
||||||
|
_startedAt = null;
|
||||||
|
_endedAt = null;
|
||||||
|
_lastError = string.Empty;
|
||||||
|
_slices.Clear();
|
||||||
|
_sumEffectiveMs = 0;
|
||||||
|
_sumWeightedScore = 0;
|
||||||
|
_sumWeightedOverRatio = 0;
|
||||||
|
_totalSegments = 0;
|
||||||
|
_minScore = 100;
|
||||||
|
_maxScore = 0;
|
||||||
|
_currentScore = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private StudySessionMetrics BuildMetrics()
|
||||||
|
{
|
||||||
|
var avgScore = _sumEffectiveMs <= 0 ? 0 : _sumWeightedScore / _sumEffectiveMs;
|
||||||
|
var avgOverRatio = _sumEffectiveMs <= 0 ? 0 : _sumWeightedOverRatio / _sumEffectiveMs;
|
||||||
|
var minScore = _slices.Count == 0 ? 0 : _minScore;
|
||||||
|
var maxScore = _slices.Count == 0 ? 0 : _maxScore;
|
||||||
|
return new StudySessionMetrics(
|
||||||
|
CurrentScore: Math.Round(_currentScore, 2),
|
||||||
|
AvgScore: Math.Round(avgScore, 2),
|
||||||
|
MinScore: Math.Round(minScore, 2),
|
||||||
|
MaxScore: Math.Round(maxScore, 2),
|
||||||
|
WeightedOverRatioDbfs: Math.Round(avgOverRatio, 4),
|
||||||
|
TotalSegmentCount: _totalSegments,
|
||||||
|
EffectiveDuration: TimeSpan.FromMilliseconds(_sumEffectiveMs),
|
||||||
|
SliceCount: _slices.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
493
LanMontainDesktop/Services/StudyAnalyticsService.cs
Normal file
493
LanMontainDesktop/Services/StudyAnalyticsService.cs
Normal file
@@ -0,0 +1,493 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using LanMontainDesktop.Models;
|
||||||
|
|
||||||
|
namespace LanMontainDesktop.Services;
|
||||||
|
|
||||||
|
public static class StudyAnalyticsServiceFactory
|
||||||
|
{
|
||||||
|
private static readonly Lazy<IStudyAnalyticsService> SharedService = new(
|
||||||
|
() => new StudyAnalyticsService(),
|
||||||
|
isThreadSafe: true);
|
||||||
|
|
||||||
|
public static IStudyAnalyticsService CreateDefault()
|
||||||
|
{
|
||||||
|
return SharedService.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
||||||
|
{
|
||||||
|
private readonly object _syncRoot = new();
|
||||||
|
private readonly IAudioRecorderService _audioRecorderService;
|
||||||
|
private readonly Timer _samplingTimer;
|
||||||
|
private readonly NoiseFramePipeline _pipeline;
|
||||||
|
private readonly SessionAccumulator _sessionAccumulator = new();
|
||||||
|
|
||||||
|
private StudyAnalyticsConfig _config = new();
|
||||||
|
private StudyAnalyticsRuntimeState _state;
|
||||||
|
private NoiseStreamStatus _streamStatus = NoiseStreamStatus.Initializing;
|
||||||
|
private StudyDataMode _dataMode = StudyDataMode.Realtime;
|
||||||
|
private NoiseRealtimePoint? _latestRealtime;
|
||||||
|
private NoiseSliceSummary? _latestSlice;
|
||||||
|
private StudySessionReport? _lastSessionReport;
|
||||||
|
private string _lastError = string.Empty;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public StudyAnalyticsService(IAudioRecorderService? audioRecorderService = null)
|
||||||
|
{
|
||||||
|
_audioRecorderService = audioRecorderService ?? AudioRecorderServiceFactory.CreateDefault();
|
||||||
|
_pipeline = new NoiseFramePipeline(_config);
|
||||||
|
_samplingTimer = new Timer(OnSamplingTick, null, Timeout.Infinite, Timeout.Infinite);
|
||||||
|
|
||||||
|
var audioSnapshot = _audioRecorderService.GetSnapshot();
|
||||||
|
if (audioSnapshot.IsSupported)
|
||||||
|
{
|
||||||
|
_state = StudyAnalyticsRuntimeState.Ready;
|
||||||
|
_streamStatus = NoiseStreamStatus.Quiet;
|
||||||
|
_lastError = string.Empty;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_state = StudyAnalyticsRuntimeState.Unsupported;
|
||||||
|
_streamStatus = NoiseStreamStatus.Error;
|
||||||
|
_lastError = audioSnapshot.LastError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public event EventHandler<StudyAnalyticsSnapshotChangedEventArgs>? SnapshotUpdated;
|
||||||
|
|
||||||
|
public event EventHandler<NoiseSliceClosedEventArgs>? SliceClosed;
|
||||||
|
|
||||||
|
public event EventHandler<StudySessionCompletedEventArgs>? SessionCompleted;
|
||||||
|
|
||||||
|
public StudyAnalyticsSnapshot GetSnapshot()
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
return BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public StudyAnalyticsConfig GetConfig()
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
return _config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UpdateConfig(StudyAnalyticsConfig config)
|
||||||
|
{
|
||||||
|
StudyAnalyticsSnapshot snapshot;
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
ThrowIfDisposedLocked();
|
||||||
|
_config = NormalizeConfig(config);
|
||||||
|
_pipeline.UpdateConfig(_config);
|
||||||
|
if (_state == StudyAnalyticsRuntimeState.Running)
|
||||||
|
{
|
||||||
|
StartTimerLocked();
|
||||||
|
}
|
||||||
|
|
||||||
|
_latestSlice = null;
|
||||||
|
UpdateDataModeLocked();
|
||||||
|
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot));
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool StartOrResumeMonitoring()
|
||||||
|
{
|
||||||
|
StudyAnalyticsSnapshot snapshot;
|
||||||
|
bool started;
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
ThrowIfDisposedLocked();
|
||||||
|
started = TryStartMonitoringLocked();
|
||||||
|
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot));
|
||||||
|
return started;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool PauseMonitoring()
|
||||||
|
{
|
||||||
|
StudyAnalyticsSnapshot snapshot;
|
||||||
|
bool paused;
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
ThrowIfDisposedLocked();
|
||||||
|
if (_state != StudyAnalyticsRuntimeState.Running)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_audioRecorderService.Pause())
|
||||||
|
{
|
||||||
|
_state = StudyAnalyticsRuntimeState.Error;
|
||||||
|
_streamStatus = NoiseStreamStatus.Error;
|
||||||
|
_lastError = _audioRecorderService.GetSnapshot().LastError;
|
||||||
|
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||||
|
paused = false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
StopTimerLocked();
|
||||||
|
_state = StudyAnalyticsRuntimeState.Paused;
|
||||||
|
_lastError = string.Empty;
|
||||||
|
UpdateDataModeLocked();
|
||||||
|
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||||
|
paused = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot));
|
||||||
|
return paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool StopMonitoring()
|
||||||
|
{
|
||||||
|
StudyAnalyticsSnapshot snapshot;
|
||||||
|
StudySessionReport? finishedReport = null;
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
ThrowIfDisposedLocked();
|
||||||
|
if (_state is StudyAnalyticsRuntimeState.Unsupported)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_audioRecorderService.Discard();
|
||||||
|
StopTimerLocked();
|
||||||
|
_pipeline.Reset();
|
||||||
|
_latestRealtime = null;
|
||||||
|
_latestSlice = null;
|
||||||
|
_state = StudyAnalyticsRuntimeState.Ready;
|
||||||
|
_streamStatus = NoiseStreamStatus.Quiet;
|
||||||
|
_lastError = string.Empty;
|
||||||
|
|
||||||
|
if (_sessionAccumulator.IsRunning)
|
||||||
|
{
|
||||||
|
finishedReport = _sessionAccumulator.Stop(DateTimeOffset.UtcNow);
|
||||||
|
_lastSessionReport = finishedReport;
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateDataModeLocked();
|
||||||
|
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finishedReport is not null)
|
||||||
|
{
|
||||||
|
SessionCompleted?.Invoke(this, new StudySessionCompletedEventArgs(finishedReport));
|
||||||
|
}
|
||||||
|
|
||||||
|
SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool StartStudySession(StudySessionOptions? options = null)
|
||||||
|
{
|
||||||
|
StudyAnalyticsSnapshot snapshot;
|
||||||
|
bool started;
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
ThrowIfDisposedLocked();
|
||||||
|
if (_sessionAccumulator.IsRunning)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryStartMonitoringLocked())
|
||||||
|
{
|
||||||
|
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||||
|
started = false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var normalizedOptions = options ?? new StudySessionOptions();
|
||||||
|
if (!_sessionAccumulator.Start(DateTimeOffset.UtcNow, normalizedOptions))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastSessionReport = null;
|
||||||
|
UpdateDataModeLocked();
|
||||||
|
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||||
|
started = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot));
|
||||||
|
return started;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool StopStudySession()
|
||||||
|
{
|
||||||
|
StudySessionReport? report;
|
||||||
|
StudyAnalyticsSnapshot snapshot;
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
ThrowIfDisposedLocked();
|
||||||
|
report = _sessionAccumulator.Stop(DateTimeOffset.UtcNow);
|
||||||
|
if (report is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastSessionReport = report;
|
||||||
|
UpdateDataModeLocked();
|
||||||
|
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
SessionCompleted?.Invoke(this, new StudySessionCompletedEventArgs(report));
|
||||||
|
SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool CancelStudySession()
|
||||||
|
{
|
||||||
|
StudyAnalyticsSnapshot snapshot;
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
ThrowIfDisposedLocked();
|
||||||
|
if (!_sessionAccumulator.Cancel())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateDataModeLocked();
|
||||||
|
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClearLastSessionReport()
|
||||||
|
{
|
||||||
|
StudyAnalyticsSnapshot snapshot;
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
ThrowIfDisposedLocked();
|
||||||
|
_lastSessionReport = null;
|
||||||
|
UpdateDataModeLocked();
|
||||||
|
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
StopTimerLocked();
|
||||||
|
_samplingTimer.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSamplingTick(object? state)
|
||||||
|
{
|
||||||
|
StudyAnalyticsSnapshot? snapshot = null;
|
||||||
|
NoiseSliceSummary? closedSlice = null;
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
if (_disposed || _state != StudyAnalyticsRuntimeState.Running)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var audioSnapshot = _audioRecorderService.GetSnapshot();
|
||||||
|
if (!audioSnapshot.IsSupported)
|
||||||
|
{
|
||||||
|
_state = StudyAnalyticsRuntimeState.Unsupported;
|
||||||
|
_streamStatus = NoiseStreamStatus.Error;
|
||||||
|
_lastError = audioSnapshot.LastError;
|
||||||
|
StopTimerLocked();
|
||||||
|
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||||
|
}
|
||||||
|
else if (audioSnapshot.State == AudioRecorderRuntimeState.Error)
|
||||||
|
{
|
||||||
|
_state = StudyAnalyticsRuntimeState.Error;
|
||||||
|
_streamStatus = NoiseStreamStatus.Error;
|
||||||
|
_lastError = string.IsNullOrWhiteSpace(audioSnapshot.LastError)
|
||||||
|
? "Audio recorder returned an error state."
|
||||||
|
: audioSnapshot.LastError;
|
||||||
|
StopTimerLocked();
|
||||||
|
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var rms = Math.Clamp(audioSnapshot.InputLevel, 0, 1);
|
||||||
|
var dbfs = ConvertInputLevelToDbfs(rms, _config.SilenceFloorDbfs);
|
||||||
|
var displayDb = ComputeDisplayDb(dbfs, _config);
|
||||||
|
var tickResult = _pipeline.AddFrame(
|
||||||
|
now,
|
||||||
|
rms,
|
||||||
|
dbfs,
|
||||||
|
displayDb,
|
||||||
|
peak: rms);
|
||||||
|
|
||||||
|
_latestRealtime = tickResult.RealtimePoint;
|
||||||
|
_streamStatus = tickResult.RealtimePoint.IsOverThreshold
|
||||||
|
? NoiseStreamStatus.Noisy
|
||||||
|
: NoiseStreamStatus.Quiet;
|
||||||
|
|
||||||
|
if (tickResult.ClosedSlice is not null)
|
||||||
|
{
|
||||||
|
closedSlice = tickResult.ClosedSlice;
|
||||||
|
_latestSlice = closedSlice;
|
||||||
|
if (_sessionAccumulator.IsRunning)
|
||||||
|
{
|
||||||
|
_sessionAccumulator.AddSlice(closedSlice);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastError = string.Empty;
|
||||||
|
UpdateDataModeLocked();
|
||||||
|
snapshot = BuildSnapshotLocked(now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot is not null)
|
||||||
|
{
|
||||||
|
SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (closedSlice is not null)
|
||||||
|
{
|
||||||
|
SliceClosed?.Invoke(this, new NoiseSliceClosedEventArgs(closedSlice));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryStartMonitoringLocked()
|
||||||
|
{
|
||||||
|
if (_state == StudyAnalyticsRuntimeState.Unsupported)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_state == StudyAnalyticsRuntimeState.Running)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_audioRecorderService.StartOrResume())
|
||||||
|
{
|
||||||
|
_state = StudyAnalyticsRuntimeState.Error;
|
||||||
|
_streamStatus = NoiseStreamStatus.Error;
|
||||||
|
_lastError = _audioRecorderService.GetSnapshot().LastError;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_state = StudyAnalyticsRuntimeState.Running;
|
||||||
|
_streamStatus = NoiseStreamStatus.Quiet;
|
||||||
|
_lastError = string.Empty;
|
||||||
|
StartTimerLocked();
|
||||||
|
UpdateDataModeLocked();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartTimerLocked()
|
||||||
|
{
|
||||||
|
_samplingTimer.Change(
|
||||||
|
dueTime: TimeSpan.Zero,
|
||||||
|
period: TimeSpan.FromMilliseconds(_config.FrameMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StopTimerLocked()
|
||||||
|
{
|
||||||
|
_samplingTimer.Change(Timeout.Infinite, Timeout.Infinite);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateDataModeLocked()
|
||||||
|
{
|
||||||
|
if (_sessionAccumulator.IsRunning)
|
||||||
|
{
|
||||||
|
_dataMode = StudyDataMode.SessionRunning;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_dataMode = _lastSessionReport is null
|
||||||
|
? StudyDataMode.Realtime
|
||||||
|
: StudyDataMode.SessionReport;
|
||||||
|
}
|
||||||
|
|
||||||
|
private StudyAnalyticsSnapshot BuildSnapshotLocked(DateTimeOffset now)
|
||||||
|
{
|
||||||
|
return new StudyAnalyticsSnapshot(
|
||||||
|
State: _state,
|
||||||
|
StreamStatus: _streamStatus,
|
||||||
|
DataMode: _dataMode,
|
||||||
|
Config: _config,
|
||||||
|
LatestRealtimePoint: _latestRealtime,
|
||||||
|
LatestSlice: _latestSlice,
|
||||||
|
RealtimeBuffer: _pipeline.GetRealtimeBufferSnapshot(),
|
||||||
|
Session: _sessionAccumulator.GetSnapshot(now),
|
||||||
|
LastSessionReport: _lastSessionReport,
|
||||||
|
LastError: _lastError);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double ConvertInputLevelToDbfs(double level, double silenceFloorDbfs)
|
||||||
|
{
|
||||||
|
var clampedLevel = Math.Clamp(level, 0, 1);
|
||||||
|
if (clampedLevel <= 1e-5)
|
||||||
|
{
|
||||||
|
return silenceFloorDbfs;
|
||||||
|
}
|
||||||
|
|
||||||
|
var dbfs = 20d * Math.Log10(clampedLevel);
|
||||||
|
return Math.Clamp(dbfs, silenceFloorDbfs, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double ComputeDisplayDb(double dbfs, StudyAnalyticsConfig config)
|
||||||
|
{
|
||||||
|
// Keep score and calibration decoupled: scoring uses dBFS, display maps it to user-facing dB.
|
||||||
|
var referenceDelta = dbfs - config.ScoreThresholdDbfs;
|
||||||
|
return Math.Round(config.BaselineDb + referenceDelta, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StudyAnalyticsConfig NormalizeConfig(StudyAnalyticsConfig config)
|
||||||
|
{
|
||||||
|
var frameMs = Math.Clamp(config.FrameMs, 20, 250);
|
||||||
|
var sliceSec = Math.Clamp(config.SliceSec, 5, 600);
|
||||||
|
var threshold = Math.Clamp(config.ScoreThresholdDbfs, -100, -5);
|
||||||
|
var mergeGapMs = Math.Clamp(config.SegmentMergeGapMs, 100, 4000);
|
||||||
|
var maxSegments = Math.Clamp(config.MaxSegmentsPerMin, 1, 40);
|
||||||
|
var silenceFloor = Math.Clamp(config.SilenceFloorDbfs, -100, -20);
|
||||||
|
var baselineDb = Math.Clamp(config.BaselineDb, 20, 90);
|
||||||
|
var avgWindowSec = Math.Clamp(config.AvgWindowSec, 1, 8);
|
||||||
|
var ringCapacity = Math.Clamp(config.RealtimeBufferCapacity, 60, 1200);
|
||||||
|
|
||||||
|
return config with
|
||||||
|
{
|
||||||
|
FrameMs = frameMs,
|
||||||
|
SliceSec = sliceSec,
|
||||||
|
ScoreThresholdDbfs = threshold,
|
||||||
|
SegmentMergeGapMs = mergeGapMs,
|
||||||
|
MaxSegmentsPerMin = maxSegments,
|
||||||
|
SilenceFloorDbfs = silenceFloor,
|
||||||
|
BaselineDb = baselineDb,
|
||||||
|
AvgWindowSec = avgWindowSec,
|
||||||
|
RealtimeBufferCapacity = ringCapacity
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ThrowIfDisposedLocked()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
throw new ObjectDisposedException(nameof(StudyAnalyticsService));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
73
LanMontainDesktop/Views/Components/BrowserWidget.axaml
Normal file
73
LanMontainDesktop/Views/Components/BrowserWidget.axaml
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<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:webview="clr-namespace:AvaloniaWebView;assembly=Avalonia.WebView"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
d:DesignWidth="480"
|
||||||
|
d:DesignHeight="480"
|
||||||
|
x:Class="LanMontainDesktop.Views.Components.BrowserWidget">
|
||||||
|
|
||||||
|
<Border x:Name="RootBorder"
|
||||||
|
Background="#F4F7FC"
|
||||||
|
CornerRadius="24"
|
||||||
|
ClipToBounds="True"
|
||||||
|
Padding="10">
|
||||||
|
<Grid RowDefinitions="*,Auto"
|
||||||
|
RowSpacing="8">
|
||||||
|
<Border x:Name="WebViewHostBorder"
|
||||||
|
Grid.Row="0"
|
||||||
|
CornerRadius="16"
|
||||||
|
ClipToBounds="True"
|
||||||
|
Background="#FFFFFFFF"
|
||||||
|
BorderBrush="#22000000"
|
||||||
|
BorderThickness="1">
|
||||||
|
<webview:WebView x:Name="BrowserWebView" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border x:Name="AddressBarBorder"
|
||||||
|
Grid.Row="1"
|
||||||
|
CornerRadius="14"
|
||||||
|
Background="#ECF2FA"
|
||||||
|
BorderBrush="#22000000"
|
||||||
|
BorderThickness="1"
|
||||||
|
Padding="8,6">
|
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto"
|
||||||
|
ColumnSpacing="8">
|
||||||
|
<Button x:Name="RefreshButton"
|
||||||
|
Grid.Column="0"
|
||||||
|
Width="34"
|
||||||
|
Height="34"
|
||||||
|
Padding="0"
|
||||||
|
CornerRadius="17"
|
||||||
|
ToolTip.Tip="Refresh"
|
||||||
|
Click="OnRefreshButtonClick">
|
||||||
|
<fi:SymbolIcon Symbol="ArrowClockwise"
|
||||||
|
FontSize="15" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<TextBox x:Name="AddressTextBox"
|
||||||
|
Grid.Column="1"
|
||||||
|
VerticalContentAlignment="Center"
|
||||||
|
HorizontalContentAlignment="Left"
|
||||||
|
Watermark="https://example.com"
|
||||||
|
Text="https://www.bing.com"
|
||||||
|
KeyDown="OnAddressTextBoxKeyDown" />
|
||||||
|
|
||||||
|
<Button x:Name="GoButton"
|
||||||
|
Grid.Column="2"
|
||||||
|
Width="34"
|
||||||
|
Height="34"
|
||||||
|
Padding="0"
|
||||||
|
CornerRadius="17"
|
||||||
|
ToolTip.Tip="Go"
|
||||||
|
Click="OnGoButtonClick">
|
||||||
|
<fi:SymbolIcon Symbol="ArrowRight"
|
||||||
|
FontSize="15" />
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</UserControl>
|
||||||
291
LanMontainDesktop/Views/Components/BrowserWidget.axaml.cs
Normal file
291
LanMontainDesktop/Views/Components/BrowserWidget.axaml.cs
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
using System;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Styling;
|
||||||
|
using AvaloniaWebView;
|
||||||
|
using WebViewCore.Events;
|
||||||
|
|
||||||
|
namespace LanMontainDesktop.Views.Components;
|
||||||
|
|
||||||
|
public partial class BrowserWidget : UserControl, IDesktopComponentWidget
|
||||||
|
, IDesktopPageVisibilityAwareComponentWidget
|
||||||
|
{
|
||||||
|
private static readonly Uri DefaultHomeUri = new("https://www.bing.com");
|
||||||
|
private double _currentCellSize = 48;
|
||||||
|
private bool? _isNightModeApplied;
|
||||||
|
private Uri _lastKnownUri = DefaultHomeUri;
|
||||||
|
private bool _isOnActiveDesktopPage;
|
||||||
|
private bool _isEditMode;
|
||||||
|
private bool _isWebViewActive = true;
|
||||||
|
|
||||||
|
public BrowserWidget()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
SizeChanged += OnSizeChanged;
|
||||||
|
ActualThemeVariantChanged += OnActualThemeVariantChanged;
|
||||||
|
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||||
|
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||||
|
|
||||||
|
ApplyCellSize(_currentCellSize);
|
||||||
|
ApplyTheme(force: true);
|
||||||
|
BrowserWebView.NavigationStarting += OnBrowserWebViewNavigationStarting;
|
||||||
|
UpdateWebViewActiveState();
|
||||||
|
NavigateTo(DefaultHomeUri);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ApplyCellSize(double cellSize)
|
||||||
|
{
|
||||||
|
_currentCellSize = Math.Max(1, cellSize);
|
||||||
|
|
||||||
|
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.34, 12, 28));
|
||||||
|
RootBorder.Padding = new Thickness(
|
||||||
|
Math.Clamp(_currentCellSize * 0.20, 8, 18));
|
||||||
|
|
||||||
|
WebViewHostBorder.CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.24, 10, 22));
|
||||||
|
AddressBarBorder.CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.22, 10, 20));
|
||||||
|
AddressBarBorder.Padding = new Thickness(8, 6);
|
||||||
|
|
||||||
|
var rowSpacing = 8d;
|
||||||
|
if (RootBorder.Child is Grid rootGrid)
|
||||||
|
{
|
||||||
|
rootGrid.RowSpacing = rowSpacing;
|
||||||
|
}
|
||||||
|
|
||||||
|
var buttonSize = Math.Clamp(_currentCellSize * 0.72, 30, 36);
|
||||||
|
var buttonCorner = buttonSize * 0.5;
|
||||||
|
var iconSize = Math.Clamp(buttonSize * 0.44, 14, 16);
|
||||||
|
foreach (var button in new[] { RefreshButton, GoButton })
|
||||||
|
{
|
||||||
|
button.Width = buttonSize;
|
||||||
|
button.Height = buttonSize;
|
||||||
|
button.CornerRadius = new CornerRadius(buttonCorner);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RefreshButton.Content is FluentIcons.Avalonia.SymbolIcon refreshIcon)
|
||||||
|
{
|
||||||
|
refreshIcon.FontSize = iconSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (GoButton.Content is FluentIcons.Avalonia.SymbolIcon goIcon)
|
||||||
|
{
|
||||||
|
goIcon.FontSize = iconSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
AddressTextBox.FontSize = Math.Clamp(_currentCellSize * 0.30, 12, 15);
|
||||||
|
AddressTextBox.Height = buttonSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||||
|
{
|
||||||
|
ApplyTheme(force: true);
|
||||||
|
UpdateWebViewActiveState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||||
|
{
|
||||||
|
_isOnActiveDesktopPage = false;
|
||||||
|
UpdateWebViewActiveState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||||
|
{
|
||||||
|
ApplyCellSize(_currentCellSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnActualThemeVariantChanged(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
ApplyTheme(force: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyTheme(bool force)
|
||||||
|
{
|
||||||
|
var isNightMode = ResolveIsNightMode();
|
||||||
|
if (!force && _isNightModeApplied.HasValue && _isNightModeApplied.Value == isNightMode)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isNightModeApplied = isNightMode;
|
||||||
|
RootBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#FF141A24") : Color.Parse("#FFF4F7FC"));
|
||||||
|
WebViewHostBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#FF0A0E15") : Color.Parse("#FFFFFFFF"));
|
||||||
|
WebViewHostBorder.BorderBrush = new SolidColorBrush(isNightMode ? Color.Parse("#33FFFFFF") : Color.Parse("#22000000"));
|
||||||
|
AddressBarBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#1BFFFFFF") : Color.Parse("#ECF2FA"));
|
||||||
|
AddressBarBorder.BorderBrush = new SolidColorBrush(isNightMode ? Color.Parse("#26FFFFFF") : Color.Parse("#22000000"));
|
||||||
|
|
||||||
|
var idleBackground = new SolidColorBrush(isNightMode ? Color.Parse("#24FFFFFF") : Color.Parse("#DCE6F5"));
|
||||||
|
var idleForeground = new SolidColorBrush(isNightMode ? Color.Parse("#FFE5E7EB") : Color.Parse("#FF1E293B"));
|
||||||
|
|
||||||
|
foreach (var button in new[] { RefreshButton, GoButton })
|
||||||
|
{
|
||||||
|
button.Background = idleBackground;
|
||||||
|
button.Foreground = idleForeground;
|
||||||
|
button.BorderThickness = new Thickness(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
AddressTextBox.Background = new SolidColorBrush(isNightMode ? Color.Parse("#1F000000") : Color.Parse("#FFFFFFFF"));
|
||||||
|
AddressTextBox.BorderBrush = new SolidColorBrush(isNightMode ? Color.Parse("#2FFFFFFF") : Color.Parse("#22000000"));
|
||||||
|
AddressTextBox.Foreground = idleForeground;
|
||||||
|
AddressTextBox.CaretBrush = idleForeground;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool ResolveIsNightMode()
|
||||||
|
{
|
||||||
|
if (ActualThemeVariant == ThemeVariant.Dark)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ActualThemeVariant == ThemeVariant.Light)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.TryFindResource("AdaptiveSurfaceBaseBrush", out var value) &&
|
||||||
|
value is ISolidColorBrush brush)
|
||||||
|
{
|
||||||
|
return CalculateRelativeLuminance(brush.Color) < 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double CalculateRelativeLuminance(Color color)
|
||||||
|
{
|
||||||
|
static double ToLinear(double channel)
|
||||||
|
{
|
||||||
|
return channel <= 0.03928
|
||||||
|
? channel / 12.92
|
||||||
|
: Math.Pow((channel + 0.055) / 1.055, 2.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
var red = ToLinear(color.R / 255d);
|
||||||
|
var green = ToLinear(color.G / 255d);
|
||||||
|
var blue = ToLinear(color.B / 255d);
|
||||||
|
return (0.2126 * red) + (0.7152 * green) + (0.0722 * blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnRefreshButtonClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (!_isWebViewActive)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (BrowserWebView.Url is not null)
|
||||||
|
{
|
||||||
|
BrowserWebView.Reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NavigateTo(DefaultHomeUri);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnGoButtonClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
NavigateFromAddressBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnAddressTextBoxKeyDown(object? sender, KeyEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key != Key.Enter)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NavigateFromAddressBar();
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void NavigateFromAddressBar()
|
||||||
|
{
|
||||||
|
var target = TryNormalizeUri(AddressTextBox.Text);
|
||||||
|
if (target is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NavigateTo(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void NavigateTo(Uri uri)
|
||||||
|
{
|
||||||
|
_lastKnownUri = uri;
|
||||||
|
AddressTextBox.Text = uri.ToString();
|
||||||
|
if (_isWebViewActive)
|
||||||
|
{
|
||||||
|
BrowserWebView.Url = uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnBrowserWebViewNavigationStarting(object? sender, WebViewUrlLoadingEventArg e)
|
||||||
|
{
|
||||||
|
if (e.Url is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastKnownUri = e.Url;
|
||||||
|
AddressTextBox.Text = e.Url.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
|
||||||
|
{
|
||||||
|
_isOnActiveDesktopPage = isOnActivePage;
|
||||||
|
_isEditMode = isEditMode;
|
||||||
|
UpdateWebViewActiveState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateWebViewActiveState()
|
||||||
|
{
|
||||||
|
var shouldBeActive = _isOnActiveDesktopPage && !_isEditMode && IsVisible;
|
||||||
|
if (_isWebViewActive == shouldBeActive)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isWebViewActive = shouldBeActive;
|
||||||
|
if (!_isWebViewActive)
|
||||||
|
{
|
||||||
|
if (BrowserWebView.Url is Uri currentUri)
|
||||||
|
{
|
||||||
|
_lastKnownUri = currentUri;
|
||||||
|
}
|
||||||
|
|
||||||
|
BrowserWebView.IsHitTestVisible = false;
|
||||||
|
BrowserWebView.IsVisible = false;
|
||||||
|
BrowserWebView.Url = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
BrowserWebView.IsVisible = true;
|
||||||
|
BrowserWebView.IsHitTestVisible = true;
|
||||||
|
BrowserWebView.Url = _lastKnownUri;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Uri? TryNormalizeUri(string? rawText)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(rawText))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidate = rawText.Trim();
|
||||||
|
if (!candidate.Contains("://", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
candidate = $"https://{candidate}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Uri.TryCreate(candidate, UriKind.Absolute, out var uri))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return uri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)
|
||||||
|
? uri
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -185,6 +185,11 @@ public sealed class DesktopComponentRuntimeRegistry
|
|||||||
"component.blackboard_landscape",
|
"component.blackboard_landscape",
|
||||||
() => new WhiteboardWidget(baseWidthCells: 4),
|
() => new WhiteboardWidget(baseWidthCells: 4),
|
||||||
cellSize => Math.Clamp(cellSize * 0.24, 10, 24)),
|
cellSize => Math.Clamp(cellSize * 0.24, 10, 24)),
|
||||||
|
new DesktopComponentRuntimeRegistration(
|
||||||
|
BuiltInComponentIds.DesktopBrowser,
|
||||||
|
"component.browser",
|
||||||
|
() => new BrowserWidget(),
|
||||||
|
cellSize => Math.Clamp(cellSize * 0.24, 10, 24)),
|
||||||
new DesktopComponentRuntimeRegistration(
|
new DesktopComponentRuntimeRegistration(
|
||||||
BuiltInComponentIds.HolidayCalendar,
|
BuiltInComponentIds.HolidayCalendar,
|
||||||
"component.holiday_calendar",
|
"component.holiday_calendar",
|
||||||
|
|||||||
@@ -96,7 +96,7 @@
|
|||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Margin="0,2,0,0"
|
Margin="0,2,0,0"
|
||||||
RowDefinitions="Auto,Auto"
|
RowDefinitions="Auto,Auto"
|
||||||
ColumnDefinitions="Auto,*"
|
ColumnDefinitions="*,Auto"
|
||||||
RowSpacing="2"
|
RowSpacing="2"
|
||||||
ColumnSpacing="8">
|
ColumnSpacing="8">
|
||||||
<Border x:Name="CityInfoBadge"
|
<Border x:Name="CityInfoBadge"
|
||||||
@@ -115,12 +115,27 @@
|
|||||||
MaxLines="1" />
|
MaxLines="1" />
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<Border x:Name="RangeInfoBadge"
|
<Border x:Name="ConditionInfoBadge"
|
||||||
Grid.Row="1"
|
Grid.Row="1"
|
||||||
Grid.Column="0"
|
Grid.Column="0"
|
||||||
Background="Transparent"
|
Background="Transparent"
|
||||||
CornerRadius="0"
|
CornerRadius="0"
|
||||||
Padding="0">
|
Padding="0">
|
||||||
|
<TextBlock x:Name="ConditionTextBlock"
|
||||||
|
Text="雾"
|
||||||
|
FontSize="20"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
TextAlignment="Left"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
MaxLines="1" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border x:Name="RangeInfoBadge"
|
||||||
|
Grid.Row="1"
|
||||||
|
Grid.Column="1"
|
||||||
|
Background="Transparent"
|
||||||
|
CornerRadius="0"
|
||||||
|
Padding="0">
|
||||||
<TextBlock x:Name="RangeTextBlock"
|
<TextBlock x:Name="RangeTextBlock"
|
||||||
Text="11°/4°"
|
Text="11°/4°"
|
||||||
FontSize="20"
|
FontSize="20"
|
||||||
@@ -131,21 +146,6 @@
|
|||||||
MaxLines="1"
|
MaxLines="1"
|
||||||
Opacity="0.92" />
|
Opacity="0.92" />
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<Border x:Name="ConditionInfoBadge"
|
|
||||||
Grid.Row="1"
|
|
||||||
Grid.Column="1"
|
|
||||||
Background="Transparent"
|
|
||||||
CornerRadius="0"
|
|
||||||
Padding="0">
|
|
||||||
<TextBlock x:Name="ConditionTextBlock"
|
|
||||||
Text="雾"
|
|
||||||
FontSize="20"
|
|
||||||
FontWeight="SemiBold"
|
|
||||||
TextAlignment="Left"
|
|
||||||
TextTrimming="CharacterEllipsis"
|
|
||||||
MaxLines="1" />
|
|
||||||
</Border>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Image x:Name="WeatherIconImage"
|
<Image x:Name="WeatherIconImage"
|
||||||
|
|||||||
@@ -426,22 +426,30 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
|
|||||||
var compactness = Math.Clamp((0.90 - scale) / 0.55, 0, 1);
|
var compactness = Math.Clamp((0.90 - scale) / 0.55, 0, 1);
|
||||||
LayoutRoot.RowSpacing = Math.Clamp(height * 0.012, 5, 13);
|
LayoutRoot.RowSpacing = Math.Clamp(height * 0.012, 5, 13);
|
||||||
SummaryGrid.ColumnSpacing = Math.Clamp(width * 0.016, 8, 22);
|
SummaryGrid.ColumnSpacing = Math.Clamp(width * 0.016, 8, 22);
|
||||||
|
SummaryInfoGrid.ColumnSpacing = Math.Clamp(width * 0.010, 6, 14);
|
||||||
|
SummaryInfoGrid.RowSpacing = Math.Clamp(height * 0.003, 1, 4);
|
||||||
HourlyGrid.ColumnSpacing = Math.Clamp(width * 0.007, 3, 10);
|
HourlyGrid.ColumnSpacing = Math.Clamp(width * 0.007, 3, 10);
|
||||||
DailyGrid.RowSpacing = Math.Clamp(height * 0.009, 4, 10);
|
DailyGrid.RowSpacing = Math.Clamp(height * 0.009, 4, 10);
|
||||||
TemperatureTextBlock.FontSize = Math.Clamp(height * 0.18, 52, 154);
|
TemperatureTextBlock.FontSize = Math.Clamp(height * 0.18, 52, 154);
|
||||||
TemperatureTextBlock.FontWeight = ToVariableWeight(Lerp(300, 370, Math.Clamp((scale - 0.50) / 1.2, 0, 1)));
|
TemperatureTextBlock.FontWeight = ToVariableWeight(Lerp(300, 370, Math.Clamp((scale - 0.50) / 1.2, 0, 1)));
|
||||||
CityTextBlock.FontSize = Math.Clamp(height * 0.040, 12, 30);
|
var cityFontSize = Math.Clamp(height * 0.040, 12, 30);
|
||||||
ConditionTextBlock.FontSize = Math.Clamp(height * 0.046, 13, 34);
|
var topInfoFontSize = Math.Clamp(height * 0.044, 12, 32);
|
||||||
RangeTextBlock.FontSize = Math.Clamp(height * 0.043, 12, 32);
|
CityTextBlock.FontSize = cityFontSize;
|
||||||
|
ConditionTextBlock.FontSize = topInfoFontSize;
|
||||||
|
RangeTextBlock.FontSize = topInfoFontSize;
|
||||||
CityTextBlock.FontWeight = ToVariableWeight(Lerp(520, 590, Math.Clamp((scale - 0.50) / 1.2, 0, 1)));
|
CityTextBlock.FontWeight = ToVariableWeight(Lerp(520, 590, Math.Clamp((scale - 0.50) / 1.2, 0, 1)));
|
||||||
ConditionTextBlock.FontWeight = ToVariableWeight(Lerp(560, 630, Math.Clamp((scale - 0.50) / 1.2, 0, 1)));
|
var topInfoWeight = ToVariableWeight(Lerp(570, 630, Math.Clamp((scale - 0.50) / 1.2, 0, 1)));
|
||||||
RangeTextBlock.FontWeight = ToVariableWeight(Lerp(560, 620, Math.Clamp((scale - 0.50) / 1.2, 0, 1)));
|
ConditionTextBlock.FontWeight = topInfoWeight;
|
||||||
|
RangeTextBlock.FontWeight = topInfoWeight;
|
||||||
|
CityTextBlock.LineHeight = cityFontSize * 1.10;
|
||||||
|
ConditionTextBlock.LineHeight = topInfoFontSize * 1.08;
|
||||||
|
RangeTextBlock.LineHeight = topInfoFontSize * 1.08;
|
||||||
var iconSize = Math.Clamp(height * 0.116, 36, 102);
|
var iconSize = Math.Clamp(height * 0.116, 36, 102);
|
||||||
WeatherIconImage.Width = iconSize;
|
WeatherIconImage.Width = iconSize;
|
||||||
WeatherIconImage.Height = iconSize;
|
WeatherIconImage.Height = iconSize;
|
||||||
ConditionTextBlock.MaxWidth = Math.Clamp(width * 0.20, 80, 240);
|
ConditionTextBlock.MaxWidth = Math.Clamp(width * 0.24, 88, 280);
|
||||||
RangeTextBlock.MaxWidth = Math.Clamp(width * 0.20, 80, 240);
|
RangeTextBlock.MaxWidth = Math.Clamp(width * 0.17, 72, 210);
|
||||||
CityTextBlock.MaxWidth = Math.Clamp(width * 0.28, 90, 290);
|
CityTextBlock.MaxWidth = Math.Clamp(width * 0.30, 96, 320);
|
||||||
|
|
||||||
HourlyPanelBorder.Padding = new Thickness(0);
|
HourlyPanelBorder.Padding = new Thickness(0);
|
||||||
HourlyPanelBorder.CornerRadius = new CornerRadius(0);
|
HourlyPanelBorder.CornerRadius = new CornerRadius(0);
|
||||||
|
|||||||
@@ -12,11 +12,8 @@
|
|||||||
CornerRadius="34"
|
CornerRadius="34"
|
||||||
ClipToBounds="True"
|
ClipToBounds="True"
|
||||||
Padding="14">
|
Padding="14">
|
||||||
<Viewbox Stretch="Uniform">
|
|
||||||
<Grid x:Name="LayoutRoot"
|
<Grid x:Name="LayoutRoot"
|
||||||
Width="300"
|
RowDefinitions="1.1*,2.3*,0.62*,0.78*,0.95*"
|
||||||
Height="300"
|
|
||||||
RowDefinitions="Auto,Auto,Auto,Auto,Auto"
|
|
||||||
RowSpacing="8">
|
RowSpacing="8">
|
||||||
<TextBlock x:Name="TitleTextBlock"
|
<TextBlock x:Name="TitleTextBlock"
|
||||||
Grid.Row="0"
|
Grid.Row="0"
|
||||||
@@ -25,21 +22,36 @@
|
|||||||
FontWeight="SemiBold"
|
FontWeight="SemiBold"
|
||||||
Foreground="#61697C"
|
Foreground="#61697C"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
Margin="0,10,0,0" />
|
VerticalAlignment="Bottom"
|
||||||
|
TextAlignment="Center"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
MaxLines="1"
|
||||||
|
Margin="8,0,8,0" />
|
||||||
|
|
||||||
|
<Viewbox Grid.Row="1"
|
||||||
|
Stretch="Uniform"
|
||||||
|
StretchDirection="DownOnly"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="8,0,8,0">
|
||||||
<TextBlock x:Name="CountTextBlock"
|
<TextBlock x:Name="CountTextBlock"
|
||||||
Grid.Row="1"
|
|
||||||
Text="0"
|
Text="0"
|
||||||
FontFeatures="tnum"
|
FontFeatures="tnum"
|
||||||
FontSize="120"
|
FontSize="132"
|
||||||
FontWeight="Bold"
|
FontWeight="Bold"
|
||||||
Foreground="#0A0A0A"
|
Foreground="#0A0A0A"
|
||||||
HorizontalAlignment="Center" />
|
HorizontalAlignment="Center"
|
||||||
|
TextAlignment="Center" />
|
||||||
|
</Viewbox>
|
||||||
|
|
||||||
<Canvas Grid.Row="2"
|
<Viewbox Grid.Row="2"
|
||||||
Width="260"
|
Stretch="Uniform"
|
||||||
Height="40"
|
StretchDirection="DownOnly"
|
||||||
HorizontalAlignment="Center">
|
HorizontalAlignment="Stretch"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="14,0,14,0">
|
||||||
|
<Canvas Width="260"
|
||||||
|
Height="40">
|
||||||
<Path Data="M 10,16 C 68,11 192,11 250,16"
|
<Path Data="M 10,16 C 68,11 192,11 250,16"
|
||||||
Stroke="#1A73F0"
|
Stroke="#1A73F0"
|
||||||
StrokeThickness="12"
|
StrokeThickness="12"
|
||||||
@@ -60,6 +72,7 @@
|
|||||||
Canvas.Top="27.5"
|
Canvas.Top="27.5"
|
||||||
Opacity="0.35" />
|
Opacity="0.35" />
|
||||||
</Canvas>
|
</Canvas>
|
||||||
|
</Viewbox>
|
||||||
|
|
||||||
<Grid Grid.Row="3"
|
<Grid Grid.Row="3"
|
||||||
ColumnDefinitions="*,Auto,*"
|
ColumnDefinitions="*,Auto,*"
|
||||||
@@ -73,9 +86,12 @@
|
|||||||
<TextBlock x:Name="DayUnitTextBlock"
|
<TextBlock x:Name="DayUnitTextBlock"
|
||||||
Grid.Column="1"
|
Grid.Column="1"
|
||||||
Text="Days"
|
Text="Days"
|
||||||
FontSize="56"
|
FontSize="52"
|
||||||
Foreground="#7D869A"
|
Foreground="#7D869A"
|
||||||
FontWeight="Medium"
|
FontWeight="Medium"
|
||||||
|
TextAlignment="Center"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
MaxLines="1"
|
||||||
VerticalAlignment="Center" />
|
VerticalAlignment="Center" />
|
||||||
<Border Grid.Column="2"
|
<Border Grid.Column="2"
|
||||||
Height="2"
|
Height="2"
|
||||||
@@ -87,11 +103,15 @@
|
|||||||
<TextBlock x:Name="DateTextBlock"
|
<TextBlock x:Name="DateTextBlock"
|
||||||
Grid.Row="4"
|
Grid.Row="4"
|
||||||
Text="2024-10-01"
|
Text="2024-10-01"
|
||||||
FontSize="34"
|
FontSize="32"
|
||||||
Foreground="#596177"
|
Foreground="#596177"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Stretch"
|
||||||
Margin="0,2,0,8" />
|
VerticalAlignment="Center"
|
||||||
|
TextAlignment="Center"
|
||||||
|
TextWrapping="NoWrap"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
MaxLines="1"
|
||||||
|
Margin="8,0,8,0" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Viewbox>
|
|
||||||
</Border>
|
</Border>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using System.Threading;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Media;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using LanMontainDesktop.Services;
|
using LanMontainDesktop.Services;
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ public partial class HolidayCalendarWidget : UserControl, IDesktopComponentWidge
|
|||||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||||
SizeChanged += OnSizeChanged;
|
SizeChanged += OnSizeChanged;
|
||||||
|
|
||||||
|
ApplyCellSize(_currentCellSize);
|
||||||
TriggerContentRefresh();
|
TriggerContentRefresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +144,7 @@ public partial class HolidayCalendarWidget : UserControl, IDesktopComponentWidge
|
|||||||
CountTextBlock.Text = "--";
|
CountTextBlock.Text = "--";
|
||||||
DayUnitTextBlock.Text = isZh ? "\u5929" : "Days";
|
DayUnitTextBlock.Text = isZh ? "\u5929" : "Days";
|
||||||
DateTextBlock.Text = "--";
|
DateTextBlock.Text = "--";
|
||||||
|
ApplyCellSize(_currentCellSize);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,28 +198,216 @@ public partial class HolidayCalendarWidget : UserControl, IDesktopComponentWidge
|
|||||||
? $"{holidayDateText} - make-up workday"
|
? $"{holidayDateText} - make-up workday"
|
||||||
: holidayDateText;
|
: holidayDateText;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ApplyCellSize(_currentCellSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ApplyCellSize(double cellSize)
|
public void ApplyCellSize(double cellSize)
|
||||||
{
|
{
|
||||||
_currentCellSize = Math.Max(1, cellSize);
|
_currentCellSize = Math.Max(1, cellSize);
|
||||||
var scale = ResolveScale();
|
var width = Bounds.Width > 1 ? Bounds.Width : 220;
|
||||||
|
var height = Bounds.Height > 1 ? Bounds.Height : 220;
|
||||||
|
var shortSide = Math.Min(width, height);
|
||||||
|
var scale = ResolveScale(width, height);
|
||||||
|
var isCompact = width < 170 || height < 170;
|
||||||
|
var isUltraCompact = width < 130 || height < 130;
|
||||||
|
var titleUnits = GetDisplayUnits(TitleTextBlock.Text);
|
||||||
|
var dateUnits = GetDisplayUnits(DateTextBlock.Text);
|
||||||
|
var titleNeedsTwoLines = isUltraCompact || titleUnits >= (isCompact ? 13 : 17);
|
||||||
|
var dateNeedsTwoLines = isUltraCompact || dateUnits >= (isCompact ? 15 : 20);
|
||||||
|
|
||||||
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 15, 50));
|
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(shortSide * 0.13, 10, 46));
|
||||||
RootBorder.Padding = new Thickness(Math.Clamp(14 * scale, 7, 22));
|
var padding = Math.Clamp(shortSide * 0.05, 4.5, 21);
|
||||||
LayoutRoot.RowSpacing = Math.Clamp(8 * scale, 4, 14);
|
RootBorder.Padding = new Thickness(padding);
|
||||||
|
LayoutRoot.RowSpacing = Math.Clamp(shortSide * 0.028, 2.2, 12);
|
||||||
|
var rowWeights = ApplyAdaptiveRowHeights(isCompact, isUltraCompact, titleNeedsTwoLines, dateNeedsTwoLines);
|
||||||
|
|
||||||
TitleTextBlock.FontSize = Math.Clamp(24 * scale, 11, 36);
|
var innerWidth = Math.Max(1, width - padding * 2);
|
||||||
CountTextBlock.FontSize = Math.Clamp(120 * scale, 36, 160);
|
var innerHeight = Math.Max(1, height - padding * 2);
|
||||||
DayUnitTextBlock.FontSize = Math.Clamp(56 * scale, 16, 78);
|
var totalWeight = Math.Max(0.001, rowWeights[0] + rowWeights[1] + rowWeights[2] + rowWeights[3] + rowWeights[4]);
|
||||||
DateTextBlock.FontSize = Math.Clamp(34 * scale, 12, 50);
|
var row0Height = innerHeight * (rowWeights[0] / totalWeight);
|
||||||
|
var row1Height = innerHeight * (rowWeights[1] / totalWeight);
|
||||||
|
var row3Height = innerHeight * (rowWeights[3] / totalWeight);
|
||||||
|
var row4Height = innerHeight * (rowWeights[4] / totalWeight);
|
||||||
|
var horizontalMargin = Math.Clamp(8 * scale, 4, 14);
|
||||||
|
var titleMaxWidth = Math.Max(24, innerWidth - horizontalMargin * 2);
|
||||||
|
var dateMaxWidth = titleMaxWidth;
|
||||||
|
|
||||||
|
var titlePreferred = Math.Clamp(24 * scale, 8.8, 34);
|
||||||
|
var titleHeightCap = Math.Max(10, row0Height * 0.94);
|
||||||
|
var titleLineCount = titleNeedsTwoLines ? 2 : 1;
|
||||||
|
TitleTextBlock.MaxLines = titleLineCount;
|
||||||
|
TitleTextBlock.TextWrapping = titleLineCount > 1 ? TextWrapping.Wrap : TextWrapping.NoWrap;
|
||||||
|
TitleTextBlock.Margin = new Thickness(horizontalMargin, 0, horizontalMargin, 0);
|
||||||
|
TitleTextBlock.FontSize = FitTextSize(
|
||||||
|
TitleTextBlock.Text,
|
||||||
|
TitleTextBlock.FontWeight,
|
||||||
|
Math.Min(titlePreferred, Math.Max(8.8, row0Height * 0.62)),
|
||||||
|
8.6,
|
||||||
|
titleMaxWidth,
|
||||||
|
titleHeightCap,
|
||||||
|
titleLineCount,
|
||||||
|
lineHeightFactor: 1.10);
|
||||||
|
TitleTextBlock.LineHeight = TitleTextBlock.FontSize * 1.10;
|
||||||
|
|
||||||
|
var digitCount = Math.Max(1, CountTextBlock.Text?.Trim().Length ?? 1);
|
||||||
|
var digitCompression = digitCount switch
|
||||||
|
{
|
||||||
|
>= 5 => 0.68,
|
||||||
|
4 => 0.8,
|
||||||
|
3 => 0.9,
|
||||||
|
_ => 1.0
|
||||||
|
};
|
||||||
|
var countCompactFactor = isUltraCompact ? 0.86 : isCompact ? 0.93 : 1.0;
|
||||||
|
var countPreferred = Math.Clamp(132 * scale * digitCompression * countCompactFactor, 28, 170);
|
||||||
|
var countHeightCap = Math.Max(30, row1Height * 0.96);
|
||||||
|
CountTextBlock.FontSize = FitTextSize(
|
||||||
|
CountTextBlock.Text,
|
||||||
|
CountTextBlock.FontWeight,
|
||||||
|
Math.Min(countPreferred, Math.Max(28, row1Height * 0.9)),
|
||||||
|
24,
|
||||||
|
titleMaxWidth,
|
||||||
|
countHeightCap,
|
||||||
|
maxLines: 1,
|
||||||
|
lineHeightFactor: 1.08);
|
||||||
|
CountTextBlock.LineHeight = CountTextBlock.FontSize * 1.08;
|
||||||
|
|
||||||
|
var unitCompactFactor = isUltraCompact ? 0.8 : isCompact ? 0.9 : 1.0;
|
||||||
|
DayUnitTextBlock.FontSize = Math.Clamp(52 * scale * unitCompactFactor, 10, 72);
|
||||||
|
DayUnitTextBlock.FontSize = Math.Min(DayUnitTextBlock.FontSize, Math.Max(10, row3Height * 0.64));
|
||||||
|
DayUnitTextBlock.LineHeight = DayUnitTextBlock.FontSize * 1.02;
|
||||||
|
|
||||||
|
var dateCompactFactor = isUltraCompact ? 0.84 : isCompact ? 0.92 : 1.0;
|
||||||
|
var datePreferred = Math.Clamp(32 * scale * dateCompactFactor, 9, 46);
|
||||||
|
var dateHeightCap = Math.Max(10, row4Height * 0.96);
|
||||||
|
var dateLineCount = dateNeedsTwoLines ? 2 : 1;
|
||||||
|
DateTextBlock.MaxLines = dateLineCount;
|
||||||
|
DateTextBlock.TextWrapping = dateLineCount > 1 ? TextWrapping.Wrap : TextWrapping.NoWrap;
|
||||||
|
DateTextBlock.Margin = new Thickness(horizontalMargin, 0, horizontalMargin, 0);
|
||||||
|
DateTextBlock.FontSize = FitTextSize(
|
||||||
|
DateTextBlock.Text,
|
||||||
|
DateTextBlock.FontWeight,
|
||||||
|
Math.Min(datePreferred, Math.Max(9, row4Height * 0.58)),
|
||||||
|
8.5,
|
||||||
|
dateMaxWidth,
|
||||||
|
dateHeightCap,
|
||||||
|
dateLineCount,
|
||||||
|
lineHeightFactor: 1.12);
|
||||||
|
DateTextBlock.LineHeight = DateTextBlock.FontSize * 1.12;
|
||||||
}
|
}
|
||||||
|
|
||||||
private double ResolveScale()
|
private double[] ApplyAdaptiveRowHeights(
|
||||||
|
bool isCompact,
|
||||||
|
bool isUltraCompact,
|
||||||
|
bool titleNeedsTwoLines,
|
||||||
|
bool dateNeedsTwoLines)
|
||||||
{
|
{
|
||||||
var cellScale = Math.Clamp(_currentCellSize / 44d, 0.60, 1.95);
|
var weights = isUltraCompact
|
||||||
var heightScale = Bounds.Height > 1 ? Math.Clamp(Bounds.Height / 300d, 0.58, 2.0) : 1;
|
? new[] { 1.35, 2.55, 0.48, 0.6, 0.82 }
|
||||||
var widthScale = Bounds.Width > 1 ? Math.Clamp(Bounds.Width / 300d, 0.58, 2.0) : 1;
|
: isCompact
|
||||||
return Math.Clamp(Math.Min(cellScale, Math.Min(heightScale, widthScale) * 1.05), 0.58, 1.95);
|
? new[] { 1.2, 2.45, 0.56, 0.7, 0.9 }
|
||||||
|
: new[] { 1.1, 2.3, 0.62, 0.78, 0.95 };
|
||||||
|
|
||||||
|
if (titleNeedsTwoLines)
|
||||||
|
{
|
||||||
|
weights[0] += 0.36;
|
||||||
|
weights[1] -= 0.21;
|
||||||
|
weights[2] -= 0.08;
|
||||||
|
weights[3] -= 0.07;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dateNeedsTwoLines)
|
||||||
|
{
|
||||||
|
weights[4] += 0.42;
|
||||||
|
weights[1] -= 0.23;
|
||||||
|
weights[2] -= 0.10;
|
||||||
|
weights[3] -= 0.09;
|
||||||
|
}
|
||||||
|
|
||||||
|
weights[0] = Math.Max(0.92, weights[0]);
|
||||||
|
weights[1] = Math.Max(1.45, weights[1]);
|
||||||
|
weights[2] = Math.Max(0.34, weights[2]);
|
||||||
|
weights[3] = Math.Max(0.44, weights[3]);
|
||||||
|
weights[4] = Math.Max(0.72, weights[4]);
|
||||||
|
|
||||||
|
if (LayoutRoot.RowDefinitions.Count < 5)
|
||||||
|
{
|
||||||
|
return weights;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < 5; i++)
|
||||||
|
{
|
||||||
|
LayoutRoot.RowDefinitions[i].Height = new GridLength(weights[i], GridUnitType.Star);
|
||||||
|
}
|
||||||
|
|
||||||
|
return weights;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int GetDisplayUnits(string? text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var units = 0;
|
||||||
|
foreach (var ch in text.Trim())
|
||||||
|
{
|
||||||
|
if (char.IsWhiteSpace(ch))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
units += ch > 0x7F ? 2 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return units;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double FitTextSize(
|
||||||
|
string? text,
|
||||||
|
FontWeight fontWeight,
|
||||||
|
double preferredSize,
|
||||||
|
double minSize,
|
||||||
|
double maxWidth,
|
||||||
|
double maxHeight,
|
||||||
|
int maxLines,
|
||||||
|
double lineHeightFactor)
|
||||||
|
{
|
||||||
|
var safeText = string.IsNullOrWhiteSpace(text) ? " " : text.Trim();
|
||||||
|
var safeMaxWidth = Math.Max(1, maxWidth);
|
||||||
|
var safeMaxHeight = Math.Max(1, maxHeight);
|
||||||
|
var safeMaxLines = Math.Max(1, maxLines);
|
||||||
|
|
||||||
|
var probe = new TextBlock
|
||||||
|
{
|
||||||
|
Text = safeText,
|
||||||
|
FontWeight = fontWeight,
|
||||||
|
MaxLines = safeMaxLines,
|
||||||
|
TextWrapping = safeMaxLines > 1 ? TextWrapping.Wrap : TextWrapping.NoWrap
|
||||||
|
};
|
||||||
|
|
||||||
|
for (var size = preferredSize; size >= minSize; size -= 0.5)
|
||||||
|
{
|
||||||
|
probe.FontSize = size;
|
||||||
|
probe.LineHeight = size * lineHeightFactor;
|
||||||
|
probe.Measure(new Size(safeMaxWidth, double.PositiveInfinity));
|
||||||
|
var desired = probe.DesiredSize;
|
||||||
|
if (desired.Width <= safeMaxWidth + 0.6 &&
|
||||||
|
desired.Height <= safeMaxHeight + 0.6)
|
||||||
|
{
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return minSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
private double ResolveScale(double width, double height)
|
||||||
|
{
|
||||||
|
var cellScale = Math.Clamp(_currentCellSize / 44d, 0.56, 2.0);
|
||||||
|
var widthScale = Math.Clamp(width / 220d, 0.5, 2.0);
|
||||||
|
var heightScale = Math.Clamp(height / 220d, 0.5, 2.0);
|
||||||
|
return Math.Clamp(Math.Min(cellScale, Math.Min(widthScale, heightScale) * 1.02), 0.5, 2.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,3 +17,8 @@ public interface IWeatherInfoAwareComponentWidget
|
|||||||
{
|
{
|
||||||
void SetWeatherInfoService(IWeatherInfoService weatherInfoService);
|
void SetWeatherInfoService(IWeatherInfoService weatherInfoService);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public interface IDesktopPageVisibilityAwareComponentWidget
|
||||||
|
{
|
||||||
|
void SetDesktopPageContext(bool isOnActivePage, bool isEditMode);
|
||||||
|
}
|
||||||
|
|||||||
@@ -825,10 +825,10 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime
|
|||||||
TopRowGrid.ColumnSpacing = Math.Clamp(7.5 * scaleX, 4, 13);
|
TopRowGrid.ColumnSpacing = Math.Clamp(7.5 * scaleX, 4, 13);
|
||||||
|
|
||||||
var availableHeight = Math.Max(80, innerHeight - (ContentGrid.RowSpacing * 2));
|
var availableHeight = Math.Max(80, innerHeight - (ContentGrid.RowSpacing * 2));
|
||||||
var topZoneRatio = Math.Clamp(0.55 + ((1 - compactness) * 0.04), 0.52, 0.60);
|
var topZoneRatio = Math.Clamp(0.52 + ((1 - compactness) * 0.03), 0.48, 0.56);
|
||||||
var bottomZoneRatio = Math.Clamp(0.29 - (compactness * 0.03), 0.24, 0.32);
|
var bottomZoneRatio = Math.Clamp(0.36 - (compactness * 0.02), 0.32, 0.40);
|
||||||
var topZoneHeight = Math.Clamp(availableHeight * topZoneRatio, 48, availableHeight - 28);
|
var topZoneHeight = Math.Clamp(availableHeight * topZoneRatio, 44, availableHeight - 30);
|
||||||
var bottomZoneHeight = Math.Clamp(availableHeight * bottomZoneRatio, 26, availableHeight - topZoneHeight - 6);
|
var bottomZoneHeight = Math.Clamp(availableHeight * bottomZoneRatio, 34, availableHeight - topZoneHeight - 6);
|
||||||
if (topZoneHeight + bottomZoneHeight > availableHeight - 6)
|
if (topZoneHeight + bottomZoneHeight > availableHeight - 6)
|
||||||
{
|
{
|
||||||
bottomZoneHeight = Math.Max(24, availableHeight - topZoneHeight - 6);
|
bottomZoneHeight = Math.Max(24, availableHeight - topZoneHeight - 6);
|
||||||
@@ -842,11 +842,11 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime
|
|||||||
ContentGrid.RowDefinitions[2].Height = new GridLength(bottomZoneHeight, GridUnitType.Pixel);
|
ContentGrid.RowDefinitions[2].Height = new GridLength(bottomZoneHeight, GridUnitType.Pixel);
|
||||||
}
|
}
|
||||||
|
|
||||||
var topScaleH = Math.Clamp(topZoneHeight / 112d, 0.60, 2.2);
|
var topScaleH = Math.Clamp(topZoneHeight / 112d, 0.58, 2.2);
|
||||||
var topScaleW = Math.Clamp(innerWidth / 288d, 0.60, 2.2);
|
var topScaleW = Math.Clamp(innerWidth / 288d, 0.60, 2.2);
|
||||||
var topScale = Math.Clamp((topScaleH * 0.72) + (topScaleW * 0.28), 0.60, 2.2);
|
var topScale = Math.Clamp((topScaleH * 0.70) + (topScaleW * 0.30), 0.58, 2.2);
|
||||||
var bottomScaleH = Math.Clamp(bottomZoneHeight / 94d, 0.52, 2.1);
|
var bottomScaleH = Math.Clamp(bottomZoneHeight / 80d, 0.62, 2.2);
|
||||||
var bottomScale = Math.Clamp((bottomScaleH * 0.76) + (scaleX * 0.24), 0.52, 2.1);
|
var bottomScale = Math.Clamp((bottomScaleH * 0.80) + (scaleX * 0.20), 0.62, 2.2);
|
||||||
|
|
||||||
var iconSize = Math.Clamp(
|
var iconSize = Math.Clamp(
|
||||||
Math.Max(52, topZoneHeight * 0.50) * (0.76 + (topScale * 0.24)),
|
Math.Max(52, topZoneHeight * 0.50) * (0.76 + (topScale * 0.24)),
|
||||||
@@ -857,9 +857,9 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime
|
|||||||
WeatherIconImage.Margin = new Thickness(0, Math.Clamp(-5 * topScale, -12, 0), 0, 0);
|
WeatherIconImage.Margin = new Thickness(0, Math.Clamp(-5 * topScale, -12, 0), 0, 0);
|
||||||
|
|
||||||
TemperatureTextBlock.FontSize = Math.Clamp(
|
TemperatureTextBlock.FontSize = Math.Clamp(
|
||||||
Math.Max(56, topZoneHeight * 0.74) * (0.72 + (topScale * 0.28)),
|
Math.Max(52, topZoneHeight * 0.69) * (0.74 + (topScale * 0.24)),
|
||||||
52,
|
50,
|
||||||
156);
|
146);
|
||||||
TemperatureTextBlock.FontWeight = ToVariableWeight(310);
|
TemperatureTextBlock.FontWeight = ToVariableWeight(310);
|
||||||
TemperatureTextBlock.Margin = new Thickness(Math.Clamp(-2 * topScale, -5, 0), Math.Clamp(-8 * topScale, -14, -3), 0, 0);
|
TemperatureTextBlock.Margin = new Thickness(Math.Clamp(-2 * topScale, -5, 0), Math.Clamp(-8 * topScale, -14, -3), 0, 0);
|
||||||
var temperatureMaxWidthLimit = Math.Max(90, innerWidth * 0.70);
|
var temperatureMaxWidthLimit = Math.Max(90, innerWidth * 0.70);
|
||||||
@@ -868,33 +868,57 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime
|
|||||||
90,
|
90,
|
||||||
temperatureMaxWidthLimit);
|
temperatureMaxWidthLimit);
|
||||||
|
|
||||||
BottomInfoStack.Spacing = Math.Clamp(1.0 * bottomScale, 0, 3);
|
var bottomStackSpacing = Math.Clamp(1.2 * bottomScale, 1, 4);
|
||||||
|
BottomInfoStack.Spacing = bottomStackSpacing;
|
||||||
BottomInfoStack.Margin = new Thickness(0, 0, 0, Math.Clamp(1.8 * scaleY, 0, 4));
|
BottomInfoStack.Margin = new Thickness(0, 0, 0, Math.Clamp(1.8 * scaleY, 0, 4));
|
||||||
BottomInfoStack.MaxHeight = Math.Max(24, bottomZoneHeight);
|
BottomInfoStack.MaxHeight = Math.Max(32, bottomZoneHeight);
|
||||||
|
|
||||||
var bottomTextMaxWidth = Math.Min(innerWidth, Math.Max(48, innerWidth * 0.78));
|
var bottomTextMaxWidth = Math.Min(innerWidth, Math.Max(56, innerWidth * 0.84));
|
||||||
ConditionStack.Spacing = Math.Clamp(1.0 + (1.6 * bottomScale), 1, 5);
|
var conditionStackSpacing = Math.Clamp(1.4 + (2.1 * bottomScale), 1.2, 7);
|
||||||
|
ConditionStack.Spacing = conditionStackSpacing;
|
||||||
ConditionStack.Margin = new Thickness(0);
|
ConditionStack.Margin = new Thickness(0);
|
||||||
var infoFontSize = Math.Clamp(
|
var infoFontSizeRaw = Math.Clamp(
|
||||||
Math.Max(12, bottomZoneHeight * 0.30) * (0.78 + (bottomScale * 0.22)),
|
Math.Max(14, bottomZoneHeight * 0.38) * (0.82 + (bottomScale * 0.24)),
|
||||||
12,
|
15,
|
||||||
34);
|
42);
|
||||||
var infoFontWeight = ToVariableWeight(560);
|
var infoFontSize = infoFontSizeRaw;
|
||||||
|
const double infoLineHeightFactor = 1.10;
|
||||||
|
var estimatedBottomUsedHeight =
|
||||||
|
(infoFontSize * infoLineHeightFactor * 3) +
|
||||||
|
conditionStackSpacing +
|
||||||
|
bottomStackSpacing +
|
||||||
|
2;
|
||||||
|
if (estimatedBottomUsedHeight > bottomZoneHeight)
|
||||||
|
{
|
||||||
|
var shrink = Math.Clamp(bottomZoneHeight / estimatedBottomUsedHeight, 0.58, 1.0);
|
||||||
|
infoFontSize = Math.Max(11, infoFontSize * shrink);
|
||||||
|
conditionStackSpacing = Math.Max(0.8, conditionStackSpacing * shrink);
|
||||||
|
bottomStackSpacing = Math.Max(0.8, bottomStackSpacing * shrink);
|
||||||
|
ConditionStack.Spacing = conditionStackSpacing;
|
||||||
|
BottomInfoStack.Spacing = bottomStackSpacing;
|
||||||
|
}
|
||||||
|
|
||||||
|
var infoFontWeight = ToVariableWeight(590);
|
||||||
ConditionTextBlock.FontSize = infoFontSize;
|
ConditionTextBlock.FontSize = infoFontSize;
|
||||||
ConditionTextBlock.FontWeight = infoFontWeight;
|
ConditionTextBlock.FontWeight = infoFontWeight;
|
||||||
|
ConditionTextBlock.LineHeight = infoFontSize * infoLineHeightFactor;
|
||||||
ConditionTextBlock.MaxWidth = bottomTextMaxWidth;
|
ConditionTextBlock.MaxWidth = bottomTextMaxWidth;
|
||||||
RangeTextBlock.FontSize = infoFontSize;
|
RangeTextBlock.FontSize = infoFontSize;
|
||||||
RangeTextBlock.FontWeight = infoFontWeight;
|
RangeTextBlock.FontWeight = infoFontWeight;
|
||||||
|
RangeTextBlock.LineHeight = infoFontSize * infoLineHeightFactor;
|
||||||
RangeTextBlock.MaxWidth = bottomTextMaxWidth;
|
RangeTextBlock.MaxWidth = bottomTextMaxWidth;
|
||||||
|
|
||||||
CityInfoBadge.Padding = new Thickness(0);
|
CityInfoBadge.Padding = new Thickness(0);
|
||||||
CityInfoBadge.CornerRadius = new CornerRadius(0);
|
CityInfoBadge.CornerRadius = new CornerRadius(0);
|
||||||
|
CityInfoBadge.MaxWidth = bottomTextMaxWidth;
|
||||||
LocationIcon.FontSize = Math.Clamp(
|
LocationIcon.FontSize = Math.Clamp(
|
||||||
Math.Max(8, bottomZoneHeight * 0.13) * (0.74 + (bottomScale * 0.20)),
|
Math.Max(9, bottomZoneHeight * 0.16) * (0.76 + (bottomScale * 0.22)),
|
||||||
8,
|
9,
|
||||||
14);
|
18);
|
||||||
|
LocationIcon.FontSize = Math.Min(LocationIcon.FontSize, infoFontSize * 0.72);
|
||||||
CityTextBlock.FontSize = infoFontSize;
|
CityTextBlock.FontSize = infoFontSize;
|
||||||
CityTextBlock.FontWeight = infoFontWeight;
|
CityTextBlock.FontWeight = infoFontWeight;
|
||||||
|
CityTextBlock.LineHeight = infoFontSize * infoLineHeightFactor;
|
||||||
CityTextBlock.MaxWidth = bottomTextMaxWidth;
|
CityTextBlock.MaxWidth = bottomTextMaxWidth;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -934,6 +934,8 @@ public partial class MainWindow
|
|||||||
Grid.SetRowSpan(host, heightCells);
|
Grid.SetRowSpan(host, heightCells);
|
||||||
pageGrid.Children.Add(host);
|
pageGrid.Children.Add(host);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
UpdateDesktopPageAwareComponentContext();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void PlaceDesktopComponentOnPage(string componentId, int pageIndex, int row, int column)
|
private void PlaceDesktopComponentOnPage(string componentId, int pageIndex, int row, int column)
|
||||||
@@ -991,6 +993,7 @@ public partial class MainWindow
|
|||||||
pageGrid.Children.Add(host);
|
pageGrid.Children.Add(host);
|
||||||
|
|
||||||
_desktopComponentPlacements.Add(placement);
|
_desktopComponentPlacements.Add(placement);
|
||||||
|
UpdateDesktopPageAwareComponentContext();
|
||||||
PersistSettings();
|
PersistSettings();
|
||||||
|
|
||||||
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
||||||
@@ -1291,6 +1294,45 @@ public partial class MainWindow
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void UpdateDesktopPageAwareComponentContext()
|
||||||
|
{
|
||||||
|
var activeDesktopPageIndex = _isSettingsOpen ? -1 : _currentDesktopSurfaceIndex;
|
||||||
|
var isEditMode = _isComponentLibraryOpen || _isSettingsOpen;
|
||||||
|
|
||||||
|
foreach (var pair in _desktopPageComponentGrids)
|
||||||
|
{
|
||||||
|
var isOnActivePage = pair.Key == activeDesktopPageIndex;
|
||||||
|
foreach (var host in pair.Value.Children.OfType<Border>())
|
||||||
|
{
|
||||||
|
if (!host.Classes.Contains(DesktopComponentHostClass))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TryGetContentHost(host)?.Child is Control componentRoot)
|
||||||
|
{
|
||||||
|
ApplyDesktopPageContext(componentRoot, isOnActivePage, isEditMode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplyDesktopPageContext(Control root, bool isOnActivePage, bool isEditMode)
|
||||||
|
{
|
||||||
|
if (root is IDesktopPageVisibilityAwareComponentWidget awareRoot)
|
||||||
|
{
|
||||||
|
awareRoot.SetDesktopPageContext(isOnActivePage, isEditMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var descendant in root.GetVisualDescendants())
|
||||||
|
{
|
||||||
|
if (descendant is IDesktopPageVisibilityAwareComponentWidget awareChild)
|
||||||
|
{
|
||||||
|
awareChild.SetDesktopPageContext(isOnActivePage, isEditMode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static Border? TryGetResizeHandle(Border host)
|
private static Border? TryGetResizeHandle(Border host)
|
||||||
{
|
{
|
||||||
if (host.Child is Grid hostChrome)
|
if (host.Child is Grid hostChrome)
|
||||||
@@ -1368,6 +1410,8 @@ public partial class MainWindow
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
UpdateDesktopPageAwareComponentContext();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ApplyDesktopEditStateToHost(Border host, bool isEditMode)
|
private void ApplyDesktopEditStateToHost(Border host, bool isEditMode)
|
||||||
|
|||||||
@@ -281,6 +281,8 @@ public partial class MainWindow
|
|||||||
{
|
{
|
||||||
CloseLauncherFolderOverlay();
|
CloseLauncherFolderOverlay();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
UpdateDesktopPageAwareComponentContext();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void MoveSurfaceBy(int delta)
|
private void MoveSurfaceBy(int delta)
|
||||||
|
|||||||
@@ -1769,6 +1769,7 @@ public partial class MainWindow
|
|||||||
}
|
}
|
||||||
|
|
||||||
_isSettingsOpen = true;
|
_isSettingsOpen = true;
|
||||||
|
UpdateDesktopPageAwareComponentContext();
|
||||||
UpdateAdaptiveTextSystem();
|
UpdateAdaptiveTextSystem();
|
||||||
ApplyWallpaperBrush();
|
ApplyWallpaperBrush();
|
||||||
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
||||||
@@ -1805,6 +1806,7 @@ public partial class MainWindow
|
|||||||
}
|
}
|
||||||
|
|
||||||
_isSettingsOpen = false;
|
_isSettingsOpen = false;
|
||||||
|
UpdateDesktopPageAwareComponentContext();
|
||||||
UpdateAdaptiveTextSystem();
|
UpdateAdaptiveTextSystem();
|
||||||
ApplyWallpaperBrush();
|
ApplyWallpaperBrush();
|
||||||
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
||||||
|
|||||||
771
noise.md
Normal file
771
noise.md
Normal file
@@ -0,0 +1,771 @@
|
|||||||
|
# 沉浸式时钟噪音计算与评分技术文档
|
||||||
|
|
||||||
|
Immersive Clock 的噪音监测系统不仅仅是一个简单的分贝计,它内置了一个基于心理声学与专注力理论的评分引擎。该引擎旨在客观、多维度地量化环境噪音对学习心流的干扰程度。
|
||||||
|
|
||||||
|
本文档详细解析了该系统的计算原理、核心指标定义、评分算法及完整的技术架构。
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
1. [核心理念](#1-核心理念)
|
||||||
|
2. [系统架构](#2-系统架构)
|
||||||
|
3. [数据采集层](#3-数据采集层)
|
||||||
|
4. [数据聚合层](#4-数据聚合层)
|
||||||
|
5. [评分算法核心](#5-评分算法核心)
|
||||||
|
6. [数据存储层](#6-数据存储层)
|
||||||
|
7. [历史报告生成](#7-历史报告生成)
|
||||||
|
8. [流服务整合](#8-流服务整合)
|
||||||
|
9. [配置参数体系](#9-配置参数体系)
|
||||||
|
10. [类型定义](#10-类型定义)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 核心理念
|
||||||
|
|
||||||
|
### 1.1 设计原则
|
||||||
|
|
||||||
|
系统认为,并非所有"响声"都是一样的。对于专注力而言:
|
||||||
|
|
||||||
|
- **持续的嗡嗡声**(如嘈杂的人群)比**偶尔的掉笔声**更具破坏性
|
||||||
|
- **频繁的打断**(如每分钟都有人说话)比**单次的大声喧哗**更让人烦躁
|
||||||
|
- **评分与校准分离**:评分使用原始 DBFS 数据,校准仅影响显示分贝
|
||||||
|
|
||||||
|
因此,评分系统采用了 **多维度加权扣分制**,满分 100 分,根据环境表现进行扣分。
|
||||||
|
|
||||||
|
### 1.2 评分与校准分离
|
||||||
|
|
||||||
|
项目通过"原始数据(用于评分)"与"显示数据(用于展示)"的**严格分层**,杜绝了校准值导致的评分偏差:
|
||||||
|
|
||||||
|
1. **评分只依赖原始 DBFS(设备输出的相对电平)**
|
||||||
|
- 评分的三项核心指标(`p50Dbfs`、`overRatioDbfs`、`segmentCount`)都来自原始 `dbfs` 统计
|
||||||
|
- "超阈时长占比"判定条件固定为:`dbfs > scoreThresholdDbfs`(阈值默认 `-50 dBFS`),与校准无关
|
||||||
|
- 这意味着即使用户把"显示分贝基准"调高/调低,评分侧的 `dbfs` 不会变化,因此得分与超阈时长也不会被"调参刷分"
|
||||||
|
|
||||||
|
2. **校准仅影响 Display dB(UI 展示口径),不进入评分链路**
|
||||||
|
- 校准(`baselineRms` / `baselineDb`)只用于将 `rms` 映射为 `displayDb`,用于实时显示与报告中的"噪音等级分布"等图表展示
|
||||||
|
- 这些展示口径变化不会反向影响评分输入,也不会改变切片摘要中的 `raw.*` 字段
|
||||||
|
|
||||||
|
3. **统计报告中"超阈时长"取自 raw.overRatioDbfs**
|
||||||
|
- 报告里展示的"超阈时长"是对每个切片 `raw.overRatioDbfs` 按有效采样时长加权汇总得到,仍然完全基于 DBFS
|
||||||
|
- 相比之下,"噪音等级分布"使用的是 `display.avgDb`(校准后的显示分贝),因此它会随校准变化——这是为了更贴近用户直觉的 dB 区间划分
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 系统架构
|
||||||
|
|
||||||
|
### 2.1 整体架构图
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 用户界面层 │
|
||||||
|
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||||
|
│ │ 实时监控组件 │ │ 噪音报告弹窗 │ │ 噪音历史列表 │ │
|
||||||
|
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
↑
|
||||||
|
│ 订阅/发布
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 流服务层 │
|
||||||
|
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 噪音流服务 - 订阅管理、生命周期控制、设置热更新 │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
↑
|
||||||
|
│ 帧数据流
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 数据聚合层 │
|
||||||
|
│ ┌──────────────────┐ ┌────────────────────────────────────┐ │
|
||||||
|
│ │ 噪音帧处理器 │ │ 噪音切片聚合器 │ │
|
||||||
|
│ │ - RMS/dBFS 计算 │ │ - 切片聚合、统计指标、评分计算 │ │
|
||||||
|
│ │ - 50ms/帧 │ │ - 30秒/切片 │ │
|
||||||
|
│ └──────────────────┘ └────────────────────────────────────┘ │
|
||||||
|
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 实时环形缓冲区 - 保留固定时长的实时数据 │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
↑
|
||||||
|
│ 音频流
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 数据采集层 │
|
||||||
|
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 麦克风采集 - Web Audio API、滤波器、AnalyserNode │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
↑
|
||||||
|
│ 物理音频
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 数据存储层 │
|
||||||
|
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 切片存储 - localStorage、时间清理、容量限制 │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
↑
|
||||||
|
│ 历史数据
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 历史报告层 │
|
||||||
|
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 历史构建 - 课表关联、加权平均评分、覆盖率计算 │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 模块说明
|
||||||
|
|
||||||
|
| 模块 | 功能 |
|
||||||
|
|------|------|
|
||||||
|
| 类型定义 | 核心类型定义 |
|
||||||
|
| 常量定义 | 分析参数常量、报告参数常量 |
|
||||||
|
| 麦克风采集 | 音频采集 |
|
||||||
|
| 帧处理器 | 帧处理 |
|
||||||
|
| 切片聚合器 | 切片聚合 |
|
||||||
|
| 环形缓冲区 | 实时数据 |
|
||||||
|
| 流服务 | 流管理 |
|
||||||
|
| 评分引擎 | 评分算法 |
|
||||||
|
| 切片服务 | 存储服务 |
|
||||||
|
| 历史构建 | 历史报告 |
|
||||||
|
| 设置管理 | 设置管理 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 数据采集层
|
||||||
|
|
||||||
|
### 3.1 麦克风采集
|
||||||
|
|
||||||
|
#### 3.1.1 Web Audio API 使用
|
||||||
|
|
||||||
|
系统使用 Web Audio API 获取麦克风输入,构建完整的音频处理链路:
|
||||||
|
|
||||||
|
```
|
||||||
|
麦克风 → MediaStream → MediaStreamAudioSourceNode
|
||||||
|
→ 高通滤波器 (80Hz) → 低通滤波器 (8000Hz)
|
||||||
|
→ AnalyserNode (FFT Size 2048)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.1.2 音频滤波器配置
|
||||||
|
|
||||||
|
| 滤波器类型 | 截止频率 | 作用 |
|
||||||
|
|-----------|---------|------|
|
||||||
|
| 高通滤波器 | 80 Hz | 过滤低频噪音(如空调嗡嗡声) |
|
||||||
|
| 低通滤波器 | 8000 Hz | 过滤高频噪音(如电子设备啸叫) |
|
||||||
|
|
||||||
|
#### 3.1.3 AnalyserNode 配置
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
analyser.fftSize = 2048; // FFT 窗口大小
|
||||||
|
analyser.smoothingTimeConstant = 0; // 无平滑,实时响应
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.1.4 权限处理与错误处理
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 麦克风权限请求配置
|
||||||
|
{
|
||||||
|
audio: {
|
||||||
|
echoCancellation: false, // 禁用回声消除
|
||||||
|
noiseSuppression: false, // 禁用降噪
|
||||||
|
autoGainControl: false, // 禁用自动增益
|
||||||
|
},
|
||||||
|
video: false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**浏览器兼容性说明:**
|
||||||
|
- 部分浏览器/设备可能忽略上述约束设置
|
||||||
|
- 建议在 UI 中提示用户实际生效的约束
|
||||||
|
- 需要测试矩阵验证:Chrome/Firefox/Safari/Edge/iOS Safari/Android WebView
|
||||||
|
|
||||||
|
**错误处理:**
|
||||||
|
- `NotAllowedError` / `SecurityError` → 权限拒绝
|
||||||
|
- `AudioContext not supported` → 浏览器不支持
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 帧处理器
|
||||||
|
|
||||||
|
#### 3.2.1 采样频率
|
||||||
|
|
||||||
|
- **帧间隔**:50ms(约 20 fps)
|
||||||
|
- **数据来源**:AnalyserNode.getFloatTimeDomainData()
|
||||||
|
|
||||||
|
#### 3.2.2 RMS(均方根)计算
|
||||||
|
|
||||||
|
RMS 是衡量音频信号强度的标准方法:
|
||||||
|
|
||||||
|
**公式:**
|
||||||
|
$$ \text{RMS} = \sqrt{\frac{1}{N} \sum_{i=1}^{N} x_i^2} $$
|
||||||
|
|
||||||
|
#### 3.2.3 dBFS(分贝满刻度)转换
|
||||||
|
|
||||||
|
dBFS 是数字音频的标准分贝单位,范围 -100 到 0 dB:
|
||||||
|
|
||||||
|
**公式:**
|
||||||
|
$$ \text{dBFS} = 20 \times \log_{10}(\text{RMS}) $$
|
||||||
|
|
||||||
|
**范围限制:**
|
||||||
|
- 最小值:-100 dBFS(静音)
|
||||||
|
- 最大值:0 dBFS(满刻度)
|
||||||
|
|
||||||
|
#### 3.2.4 峰值检测
|
||||||
|
|
||||||
|
峰值用于检测突发噪音,在 RMS 计算过程中同时记录峰值。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 数据聚合层
|
||||||
|
|
||||||
|
### 4.1 切片聚合器
|
||||||
|
|
||||||
|
#### 4.1.1 切片时长
|
||||||
|
|
||||||
|
- **默认切片时长**:30 秒
|
||||||
|
- **可配置范围**:≥ 1 秒
|
||||||
|
|
||||||
|
#### 4.1.2 统计指标计算
|
||||||
|
|
||||||
|
切片聚合器为每个切片计算以下统计指标:
|
||||||
|
|
||||||
|
| 指标 | 说明 | 计算方法 |
|
||||||
|
|------|------|---------|
|
||||||
|
| avgDbfs | 平均分贝 | 能量平均(线性域 RMS 平均后转回 dBFS) |
|
||||||
|
| maxDbfs | 最大分贝 | 所有帧 dBFS 的最大值 |
|
||||||
|
| p50Dbfs | 中位数分贝 | 线性域分位数(RMS 域计算后转回 dBFS) |
|
||||||
|
| p95Dbfs | 95分位数分贝 | 线性域分位数(RMS 域计算后转回 dBFS) |
|
||||||
|
| overRatioDbfs | 超阈值比例 | 超阈值时长 / 采样时长 |
|
||||||
|
| segmentCount | 事件段数量 | 独立噪音事件次数 |
|
||||||
|
| sampledDurationMs | 采样时长 | 有效采样时间(排除缺口) |
|
||||||
|
| gapCount | 缺口数量 | 数据缺口次数 |
|
||||||
|
| maxGapMs | 最大缺口时长 | 最长数据缺口时长 |
|
||||||
|
|
||||||
|
#### 4.1.3 能量平均计算(avgDbfs)
|
||||||
|
|
||||||
|
**公式:**
|
||||||
|
$$ \text{avgDbfs} = 20 \times \log_{10}\left(\sqrt{\frac{1}{N} \sum_{i=1}^{N} 10^{\text{dBFS}_i / 10}}\right) $$
|
||||||
|
|
||||||
|
**物理意义:** 在线性域(RMS)上做平均,符合能量守恒定律
|
||||||
|
|
||||||
|
#### 4.1.4 线性域分位数计算
|
||||||
|
|
||||||
|
**公式:**
|
||||||
|
$$ \text{quantileDbfs} = 20 \times \log_{10}(Q_{\text{RMS}}(p)) $$
|
||||||
|
|
||||||
|
其中 $Q_{\text{RMS}}(p)$ 是 RMS 域的分位数,使用线性插值计算:
|
||||||
|
$$ Q_{\text{RMS}}(p) = x_{\lfloor i \rfloor} \times (1 - w) + x_{\lceil i \rceil} \times w $$
|
||||||
|
|
||||||
|
- $i = (n-1) \times p$
|
||||||
|
- $w = i - \lfloor i \rfloor$
|
||||||
|
|
||||||
|
**物理意义:** 在线性域(RMS)上计算分位数,符合能量统计的严谨性
|
||||||
|
|
||||||
|
#### 4.1.5 超阈值比例计算(时间加权)
|
||||||
|
|
||||||
|
**公式:**
|
||||||
|
$$ \text{overRatioDbfs} = \frac{\text{超阈值时长}}{\text{采样时长}} $$
|
||||||
|
|
||||||
|
**物理意义:** 使用实际时长而非帧数计算比例,更精确
|
||||||
|
|
||||||
|
#### 4.1.6 事件段检测与合并算法
|
||||||
|
|
||||||
|
事件段检测用于识别独立的噪音事件:
|
||||||
|
|
||||||
|
**合并规则:**
|
||||||
|
- **合并窗口**:500ms(默认)
|
||||||
|
- 如果两次超阈值事件间隔 ≤ 500ms,合并为同一事件段
|
||||||
|
- 否则计为新的独立事件段
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```
|
||||||
|
时间轴: 0ms 200ms 400ms 600ms 800ms 1000ms
|
||||||
|
状态: [噪音] [噪音] [安静] [噪音] [噪音] [安静]
|
||||||
|
合并后: └─────── 事件段1 ───────┘ └── 事件段2 ──┘
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4.1.7 显示分贝映射(校准机制)
|
||||||
|
|
||||||
|
显示分贝用于用户界面展示,支持校准:
|
||||||
|
|
||||||
|
**公式(有校准):**
|
||||||
|
$$ \text{displayDb} = \text{baselineDb} + 20 \times \log_{10}\left(\frac{\text{rms}}{\text{baselineRms}}\right) $$
|
||||||
|
|
||||||
|
**公式(无校准):**
|
||||||
|
$$ \text{displayDb} = 20 \times \log_{10}\left(\frac{\text{rms}}{10^{-3}}\right) + 60 $$
|
||||||
|
|
||||||
|
**范围限制:** 20 dB ~ 100 dB
|
||||||
|
|
||||||
|
**校准流程说明:**
|
||||||
|
1. 使用标准声源(如 60 dB 的白噪音)
|
||||||
|
2. 测量对应的 RMS 值
|
||||||
|
3. 设置为 baselineRms
|
||||||
|
4. 设置对应的显示分贝为 baselineDb
|
||||||
|
|
||||||
|
#### 4.1.8 缺口检测与采样时长统计
|
||||||
|
|
||||||
|
**缺口阈值:** `max(1000ms, frameMs × 5)` = **1000ms**(默认)
|
||||||
|
|
||||||
|
当检测到数据缺口时,会触发切片完成并记录缺口信息。
|
||||||
|
|
||||||
|
#### 4.1.9 无效帧过滤
|
||||||
|
|
||||||
|
低于 -90 dBFS 的帧被视为静音/无效信号,不参与统计。
|
||||||
|
|
||||||
|
**常量说明:**
|
||||||
|
- `INVALID_DBFS_THRESHOLD = -90`:统计意义上的"静音"阈值
|
||||||
|
- `DBFS_MIN_POSSIBLE = -100`:物理最小可表示值(用于 clamp)
|
||||||
|
- `DBFS_MAX_POSSIBLE = 0`:物理最大可表示值(用于 clamp)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.2 实时环形缓冲区
|
||||||
|
|
||||||
|
#### 4.2.1 数据结构设计
|
||||||
|
|
||||||
|
环形缓冲区使用固定容量数组实现,通过起始索引和当前长度管理数据。
|
||||||
|
|
||||||
|
#### 4.2.2 时间窗口裁剪策略
|
||||||
|
|
||||||
|
**裁剪规则:** 移除时间戳早于 `当前时间 - retentionMs` 的数据点
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 评分算法核心
|
||||||
|
|
||||||
|
### 5.1 三大核心指标
|
||||||
|
|
||||||
|
评分引擎从以下三个维度对噪音数据进行分析:
|
||||||
|
|
||||||
|
#### A. 持续噪音水平 (Sustained Level)
|
||||||
|
|
||||||
|
- **定义**:剔除突发噪音后的环境"底噪"水平
|
||||||
|
- **算法**:使用时段内所有帧的中位数电平 (`p50Dbfs`)
|
||||||
|
- **意义**:反映环境本身是否安静。如果环境中有持续的风扇声或交谈声,该指标会升高
|
||||||
|
|
||||||
|
#### B. 超阈值时长占比 (Over Threshold Ratio)
|
||||||
|
|
||||||
|
- **定义**:原始 `DBFS` 超过评分阈值(`scoreThresholdDbfs`)的时间比例
|
||||||
|
- **算法**:`超阈值时长 / 采样时长`(超标判定:`dbfs > scoreThresholdDbfs`)
|
||||||
|
- **意义**:反映环境的"纯净度"。即使是 0.1 秒的尖叫也会被精确计入,无法被平均值掩盖
|
||||||
|
|
||||||
|
> **提示**:评分阈值(`scoreThresholdDbfs`,单位 dBFS)与"界面报警/提示音"使用的显示分贝阈值(`maxLevelDb`,单位 dB)不是同一个概念;前者只用于评分,后者用于判定 noisy/quiet 与提示音触发。
|
||||||
|
|
||||||
|
#### C. 打断次数密度 (Interruption Density)
|
||||||
|
|
||||||
|
- **定义**:单位时间内(每分钟)发生的独立噪音事件次数
|
||||||
|
- **智能合并算法**:
|
||||||
|
- 系统设有 **500ms** (默认) 的合并窗口
|
||||||
|
- 如果两次响声间隔小于该窗口(如拉椅子的一连串声音),会被合并为 **1 次打断**
|
||||||
|
- 只有间隔较长的响声才会被计为新的打断
|
||||||
|
- **意义**:反映环境的干扰频率。频繁的打断(如断断续续的说话声)比连续的噪音更易打断心流
|
||||||
|
|
||||||
|
### 5.2 评分引擎
|
||||||
|
|
||||||
|
#### 5.2.1 三维度评分模型
|
||||||
|
|
||||||
|
评分系统从三个维度对噪音进行评估:
|
||||||
|
|
||||||
|
| 维度 | 权重 | 指标 | 满扣分条件 |
|
||||||
|
|------|------|------|-----------|
|
||||||
|
| **持续噪音** | 40% | p50Dbfs | 中位数超过阈值 6 dBFS |
|
||||||
|
| **超阈时长** | 30% | overRatioDbfs | 超阈时间占比 30% |
|
||||||
|
| **打断频次** | 30% | segmentCount | 6 次/分钟 |
|
||||||
|
|
||||||
|
#### 5.2.2 评分公式
|
||||||
|
|
||||||
|
**总惩罚系数:**
|
||||||
|
$$ \text{TotalPenalty} = 0.40 \times P_{\text{sustained}} + 0.30 \times P_{\text{time}} + 0.30 \times P_{\text{segment}} $$
|
||||||
|
|
||||||
|
**最终得分:**
|
||||||
|
$$ \text{Score} = 100 \times (1 - \text{TotalPenalty}) $$
|
||||||
|
|
||||||
|
#### 5.2.3 惩罚系数计算
|
||||||
|
|
||||||
|
##### A. 持续噪音惩罚
|
||||||
|
|
||||||
|
**公式:**
|
||||||
|
$$ P_{\text{sustained}} = \text{clamp}_{[0,1]}\left(\frac{\text{p50Dbfs} - \text{threshold}}{6}\right) $$
|
||||||
|
|
||||||
|
**满扣分条件:** `p50Dbfs - threshold ≥ 6 dBFS`
|
||||||
|
|
||||||
|
##### B. 超阈时长惩罚
|
||||||
|
|
||||||
|
**公式:**
|
||||||
|
$$ P_{\text{time}} = \text{clamp}_{[0,1]}\left(\frac{\text{overRatioDbfs}}{0.3}\right) $$
|
||||||
|
|
||||||
|
**满扣分条件:** `overRatioDbfs ≥ 30%`
|
||||||
|
|
||||||
|
##### C. 打断频次惩罚
|
||||||
|
|
||||||
|
**公式:**
|
||||||
|
$$ P_{\text{segment}} = \text{clamp}_{[0,1]}\left(\frac{\text{segmentCount} / \text{minutes}}{\text{maxSegmentsPerMin}}\right) $$
|
||||||
|
|
||||||
|
**满扣分条件:** `segmentsPerMin ≥ 6 次/分钟`
|
||||||
|
|
||||||
|
#### 5.2.4 权重解读
|
||||||
|
|
||||||
|
- **持续噪音 (40%)**:持续底噪仍会明显拉低分数
|
||||||
|
- **超阈时长 (30%)**:只要大部分时间安静,偶尔的噪音仍可被容忍
|
||||||
|
- **打断频次 (30%)**:强调"被频繁打断"对心流的破坏,提升对碎片化干扰的惩罚力度
|
||||||
|
|
||||||
|
#### 5.2.5 边界条件处理
|
||||||
|
|
||||||
|
- DBFS 范围限制:-100 到 0 dB
|
||||||
|
- 惩罚系数范围限制:0 到 1
|
||||||
|
- 评分范围限制:0 到 100 分
|
||||||
|
|
||||||
|
#### 5.2.6 有效时长处理
|
||||||
|
|
||||||
|
优先使用采样有效时长,不存在时回退到物理时长。
|
||||||
|
|
||||||
|
#### 5.2.7 评分示例
|
||||||
|
|
||||||
|
**场景 1:安静环境**
|
||||||
|
- p50Dbfs = -60 dBFS, threshold = -50 dBFS
|
||||||
|
- overRatioDbfs = 0.05 (5%)
|
||||||
|
- segmentCount = 1, duration = 30s
|
||||||
|
|
||||||
|
```
|
||||||
|
sustainedPenalty = clamp01((-60 - (-50)) / 6) = clamp01(-10/6) = 0
|
||||||
|
timePenalty = clamp01(0.05 / 0.3) = 0.167
|
||||||
|
segmentPenalty = clamp01((1/0.5) / 6) = clamp01(2/6) = 0.333
|
||||||
|
|
||||||
|
TotalPenalty = 0.4×0 + 0.3×0.167 + 0.3×0.333 = 0.15
|
||||||
|
Score = 100 × (1 - 0.15) = 85 分
|
||||||
|
```
|
||||||
|
|
||||||
|
**场景 2:嘈杂环境**
|
||||||
|
- p50Dbfs = -45 dBFS, threshold = -50 dBFS
|
||||||
|
- overRatioDbfs = 0.40 (40%)
|
||||||
|
- segmentCount = 8, duration = 30s
|
||||||
|
|
||||||
|
```
|
||||||
|
sustainedPenalty = clamp01((-45 - (-50)) / 6) = clamp01(5/6) = 0.833
|
||||||
|
timePenalty = clamp01(0.40 / 0.3) = 1.0
|
||||||
|
segmentPenalty = clamp01((8/0.5) / 6) = clamp01(16/6) = 1.0
|
||||||
|
|
||||||
|
TotalPenalty = 0.4×0.833 + 0.3×1.0 + 0.3×1.0 = 0.933
|
||||||
|
Score = 100 × (1 - 0.933) = 6.7 分
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 数据存储层
|
||||||
|
|
||||||
|
### 6.1 切片服务
|
||||||
|
|
||||||
|
#### 6.1.1 localStorage 存储策略
|
||||||
|
|
||||||
|
存储键:`noise-slices`
|
||||||
|
|
||||||
|
**隐私说明:**
|
||||||
|
- 存储内容:时间戳、噪音统计(不包含音频数据)
|
||||||
|
- 风险:可能泄露位置/日程信息
|
||||||
|
- 建议:在 UI 中提供"清除历史"功能
|
||||||
|
|
||||||
|
#### 6.1.2 时间窗口清理
|
||||||
|
|
||||||
|
**默认保留时长:** 14 天
|
||||||
|
**可配置范围:** 1 ~ 365 天
|
||||||
|
|
||||||
|
使用新切片的结束时间作为基准计算 cutoff,确保新切片不会被清理。
|
||||||
|
|
||||||
|
#### 6.1.3 容量限制
|
||||||
|
|
||||||
|
**容量上限:** 本地存储配额的 90%
|
||||||
|
|
||||||
|
#### 6.1.4 数据规范化与校验
|
||||||
|
|
||||||
|
**精度控制:**
|
||||||
|
- dBFS:3 位小数
|
||||||
|
- overRatioDbfs:4 位小数
|
||||||
|
- 显示分贝:2 位小数
|
||||||
|
- 评分:1 位小数
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 历史报告生成
|
||||||
|
|
||||||
|
### 7.1 历史构建器
|
||||||
|
|
||||||
|
#### 7.1.1 与课表关联逻辑
|
||||||
|
|
||||||
|
**关联规则:**
|
||||||
|
1. 按日期分组切片
|
||||||
|
2. 对每个日期的每个课时,查找重叠的切片
|
||||||
|
3. 计算该课时的平均评分
|
||||||
|
|
||||||
|
#### 7.1.2 时段平均评分计算(加权平均)
|
||||||
|
|
||||||
|
**公式:**
|
||||||
|
$$ \text{avgScore} = \frac{\sum_{i} \text{score}_i \times \text{effectiveMs}_i}{\sum_{i} \text{effectiveMs}_i} $$
|
||||||
|
|
||||||
|
其中:
|
||||||
|
$$ \text{effectiveMs}_i = \text{sampledDurationMs}_i \times \frac{\text{overlapMs}_i}{\text{sliceMs}_i} $$
|
||||||
|
|
||||||
|
#### 7.1.3 覆盖率计算
|
||||||
|
|
||||||
|
**公式:**
|
||||||
|
$$ \text{coverageRatio} = \frac{\text{totalMs}}{\text{periodMs}} $$
|
||||||
|
|
||||||
|
**含义:** 课时内有效采样时长占课时总时长的比例
|
||||||
|
|
||||||
|
#### 7.1.4 日期时间处理
|
||||||
|
|
||||||
|
**时区说明:**
|
||||||
|
- 使用本地时区
|
||||||
|
- 内部存储使用 UTC 时间戳
|
||||||
|
- 对外展示使用本地时间
|
||||||
|
|
||||||
|
**日期格式:** `YYYY-MM-DD`
|
||||||
|
**时间格式:** `HH:MM`
|
||||||
|
|
||||||
|
#### 7.1.5 跨天课时处理
|
||||||
|
|
||||||
|
如果结束时间 ≤ 开始时间,则课时跨越到次日。
|
||||||
|
|
||||||
|
#### 7.1.6 报告中的图表
|
||||||
|
|
||||||
|
在噪音统计报告中,您可以直观地看到这些数据:
|
||||||
|
|
||||||
|
- **评分走势图**:展示了 `Score` 随时间的变化,帮助您回顾专注状态
|
||||||
|
- **噪音等级分布**:将每一帧归类为安静/正常/吵闹/极吵,直观展示时间占比
|
||||||
|
- **扣分归因**:直接显示上述三个维度的扣分比例,告诉您为什么分低(是因为一直吵,还是因为总被打断)
|
||||||
|
- **打断次数密度**:展示每分钟被干扰的次数
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 流服务整合
|
||||||
|
|
||||||
|
### 8.1 噪音流服务
|
||||||
|
|
||||||
|
#### 8.1.1 订阅/发布模式
|
||||||
|
|
||||||
|
**模式:** 观察者模式
|
||||||
|
- 多个组件可同时订阅
|
||||||
|
- 最后一个订阅者取消时自动停止采集
|
||||||
|
|
||||||
|
#### 8.1.2 生命周期管理
|
||||||
|
|
||||||
|
流服务支持启动、停止和重启操作,自动管理采集资源的生命周期。
|
||||||
|
|
||||||
|
#### 8.1.3 预热帧处理
|
||||||
|
|
||||||
|
**目的:** 丢弃麦克风启动后的不稳定数据(约 500ms)
|
||||||
|
|
||||||
|
#### 8.1.4 设置热更新响应
|
||||||
|
|
||||||
|
**需要重启的参数:**
|
||||||
|
- frameMs
|
||||||
|
- sliceSec
|
||||||
|
- scoreThresholdDbfs
|
||||||
|
- segmentMergeGapMs
|
||||||
|
- maxSegmentsPerMin
|
||||||
|
|
||||||
|
**无需重启的参数:**
|
||||||
|
- maxLevelDb
|
||||||
|
- showRealtimeDb
|
||||||
|
- alertSoundEnabled
|
||||||
|
- avgWindowSec
|
||||||
|
- baselineDb
|
||||||
|
|
||||||
|
#### 8.1.5 时间加权平均
|
||||||
|
|
||||||
|
**公式:**
|
||||||
|
$$ \text{avg} = \frac{\sum_{i} v_i \times (t_{i+1} - t_i)}{\sum_{i} (t_{i+1} - t_i)} $$
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 配置参数体系
|
||||||
|
|
||||||
|
### 9.1 常量定义
|
||||||
|
|
||||||
|
#### 9.1.1 分析参数
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
NOISE_ANALYSIS_SLICE_SEC = 30; // 切片时长 30 秒
|
||||||
|
NOISE_ANALYSIS_FRAME_MS = 50; // 帧间隔 50ms
|
||||||
|
NOISE_SCORE_THRESHOLD_DBFS = -50; // 评分阈值 -50dBFS
|
||||||
|
NOISE_SCORE_SEGMENT_MERGE_GAP_MS = 500; // 事件段合并间隔 500ms
|
||||||
|
NOISE_SCORE_MAX_SEGMENTS_PER_MIN = 6; // 每分钟最大事件段数 6
|
||||||
|
NOISE_REALTIME_CHART_SLICE_COUNT = 1; // 实时图表切片数 1
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 9.1.2 报告参数
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
DEFAULT_NOISE_REPORT_RETENTION_DAYS = 14; // 默认保留 14 天
|
||||||
|
MIN_NOISE_REPORT_RETENTION_DAYS = 1; // 最小保留 1 天
|
||||||
|
MAX_NOISE_REPORT_RETENTION_DAYS_FALLBACK = 365; // 最大保留 365 天
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 设置管理
|
||||||
|
|
||||||
|
#### 9.2.1 固定参数
|
||||||
|
|
||||||
|
为保证评分口径稳定,避免用户通过调整参数"刷分",以下参数固定为程序内常量:
|
||||||
|
- sliceSec
|
||||||
|
- frameMs
|
||||||
|
- scoreThresholdDbfs
|
||||||
|
- segmentMergeGapMs
|
||||||
|
- maxSegmentsPerMin
|
||||||
|
|
||||||
|
#### 9.2.2 可配置参数
|
||||||
|
|
||||||
|
| 参数 | 类型 | 默认值 | 说明 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| maxLevelDb | number | 55 | 最大允许噪音级别(显示分贝) |
|
||||||
|
| baselineDb | number | 40 | 手动基准显示分贝 |
|
||||||
|
| showRealtimeDb | boolean | true | 是否显示实时分贝 |
|
||||||
|
| avgWindowSec | number | 1 | 噪音平均时间窗(秒) |
|
||||||
|
| alertSoundEnabled | boolean | false | 超阈值提示音开关 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 类型定义
|
||||||
|
|
||||||
|
### 10.1 核心类型
|
||||||
|
|
||||||
|
#### 10.1.1 噪音帧采样
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface NoiseFrameSample {
|
||||||
|
t: number; // 时间戳
|
||||||
|
rms: number; // 均方根值
|
||||||
|
dbfs: number; // 分贝值 (dBFS)
|
||||||
|
peak?: number; // 峰值
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 10.1.2 噪音切片原始统计
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface NoiseSliceRawStats {
|
||||||
|
avgDbfs: number; // 平均分贝
|
||||||
|
maxDbfs: number; // 最大分贝
|
||||||
|
p50Dbfs: number; // 中位数分贝
|
||||||
|
p95Dbfs: number; // 95分位数分贝
|
||||||
|
overRatioDbfs: number; // 超阈值比例
|
||||||
|
segmentCount: number; // 事件段数量
|
||||||
|
sampledDurationMs?: number; // 采样时长
|
||||||
|
gapCount?: number; // 缺口数量
|
||||||
|
maxGapMs?: number; // 最大缺口时长
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 10.1.3 噪音切片显示统计
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface NoiseSliceDisplayStats {
|
||||||
|
avgDb: number; // 平均显示分贝
|
||||||
|
p95Db: number; // 95分位数显示分贝
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 10.1.4 噪音评分明细
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface NoiseScoreBreakdown {
|
||||||
|
sustainedPenalty: number; // 持续噪音惩罚
|
||||||
|
timePenalty: number; // 时间惩罚
|
||||||
|
segmentPenalty: number; // 事件段惩罚
|
||||||
|
thresholdsUsed: {
|
||||||
|
scoreThresholdDbfs: number; // 使用的评分阈值
|
||||||
|
segmentMergeGapMs: number; // 使用的合并间隔
|
||||||
|
maxSegmentsPerMin: number; // 使用的最大事件段数
|
||||||
|
};
|
||||||
|
sustainedLevelDbfs: number; // 持续电平
|
||||||
|
overRatioDbfs: number; // 超阈值比例
|
||||||
|
segmentCount: number; // 事件段数量
|
||||||
|
minutes: number; // 时长(分钟)
|
||||||
|
durationMs?: number; // 物理时长
|
||||||
|
sampledDurationMs?: number; // 采样时长
|
||||||
|
coverageRatio?: number; // 覆盖率
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 10.1.5 噪音切片摘要
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface NoiseSliceSummary {
|
||||||
|
start: number; // 开始时间戳
|
||||||
|
end: number; // 结束时间戳
|
||||||
|
frames: number; // 帧数
|
||||||
|
raw: NoiseSliceRawStats; // 原始统计
|
||||||
|
display: NoiseSliceDisplayStats; // 显示统计
|
||||||
|
score: number; // 评分
|
||||||
|
scoreDetail: NoiseScoreBreakdown; // 评分明细
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 10.1.6 实时数据点
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface NoiseRealtimePoint {
|
||||||
|
t: number; // 时间戳
|
||||||
|
dbfs: number; // 分贝值 (dBFS)
|
||||||
|
displayDb: number; // 显示分贝
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 10.1.7 噪音流快照
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface NoiseStreamSnapshot {
|
||||||
|
status: NoiseStreamStatus; // 流状态
|
||||||
|
realtimeDisplayDb: number; // 实时显示分贝
|
||||||
|
realtimeDbfs: number; // 实时分贝 (dBFS)
|
||||||
|
maxLevelDb: number; // 最大允许级别
|
||||||
|
showRealtimeDb: boolean; // 是否显示实时分贝
|
||||||
|
alertSoundEnabled: boolean; // 提示音开关
|
||||||
|
ringBuffer: NoiseRealtimePoint[]; // 环形缓冲区快照
|
||||||
|
latestSlice: NoiseSliceSummary | null; // 最新切片
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 10.1.8 噪音流状态
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type NoiseStreamStatus =
|
||||||
|
| "initializing" // 初始化中
|
||||||
|
| "quiet" // 安静
|
||||||
|
| "noisy" // 嘈杂
|
||||||
|
| "permission-denied" // 权限拒绝
|
||||||
|
| "error"; // 错误
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录
|
||||||
|
|
||||||
|
### A. 术语表
|
||||||
|
|
||||||
|
| 术语 | 英文 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 均方根 | RMS (Root Mean Square) | 衡量音频信号强度的标准方法 |
|
||||||
|
| 分贝满刻度 | dBFS (Decibels relative to Full Scale) | 数字音频的标准分贝单位,范围 -100 到 0 dB |
|
||||||
|
| 显示分贝 | Display dB | 用于用户界面展示的分贝值,范围 20 到 100 dB |
|
||||||
|
| 切片 | Slice | 固定时间窗口(默认 30 秒)内的噪音数据聚合 |
|
||||||
|
| 帧 | Frame | 单次音频采样(默认 50ms) |
|
||||||
|
| 事件段 | Segment | 独立的噪音事件,通过合并窗口(500ms)合并 |
|
||||||
|
|
||||||
|
### B. 参数固定策略
|
||||||
|
|
||||||
|
为保证统计口径稳定,当前版本将"分析与评分"的高级参数固定为程序内常量:
|
||||||
|
|
||||||
|
| 参数 | 值 | 说明 |
|
||||||
|
|------|-----|------|
|
||||||
|
| frameMs | 50ms | 约 20fps |
|
||||||
|
| sliceSec | 30s | 切片时长 |
|
||||||
|
| scoreThresholdDbfs | -50 dBFS | 评分阈值 |
|
||||||
|
| segmentMergeGapMs | 500ms | 事件段合并间隔 |
|
||||||
|
| maxSegmentsPerMin | 6 | 每分钟最大事件段数 |
|
||||||
|
|
||||||
|
### C. 技术栈
|
||||||
|
|
||||||
|
- **音频处理**:Web Audio API
|
||||||
|
- **数据存储**:localStorage
|
||||||
|
- **前端框架**:React 18
|
||||||
|
- **构建工具**:Vite 5
|
||||||
|
- **类型系统**:TypeScript 5.4
|
||||||
|
|
||||||
305
scripts/build.sh
305
scripts/build.sh
@@ -1,220 +1,159 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# LanMontainDesktop Build Script for Linux/macOS
|
# Build script for LanMontainDesktop
|
||||||
# Usage: ./build.sh [options]
|
# Cross-platform build support: Linux, macOS
|
||||||
# Example: ./build.sh --project LanMontainDesktop.csproj --rid linux-x64 --version 1.0.0
|
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Default values
|
Script_Dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
PROJECT="LanMontainDesktop/LanMontainDesktop.csproj"
|
Root_Dir="$(dirname "$Script_Dir")"
|
||||||
CONFIGURATION="Release"
|
|
||||||
|
# Detect OS
|
||||||
|
OS=""
|
||||||
|
ARCH=""
|
||||||
RID=""
|
RID=""
|
||||||
VERSION=""
|
|
||||||
PUBLISH_DIR=""
|
|
||||||
SKIP_RESTORE=false
|
|
||||||
VERBOSE=false
|
|
||||||
|
|
||||||
# Colors for output
|
detect_os() {
|
||||||
RED='\033[0;31m'
|
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
|
||||||
GREEN='\033[0;32m'
|
OS="linux"
|
||||||
YELLOW='\033[1;33m'
|
RID="linux-x64"
|
||||||
BLUE='\033[0;34m'
|
elif [[ "$OSTYPE" == "darwin"* ]]; then
|
||||||
NC='\033[0m' # No Color
|
OS="macos"
|
||||||
|
ARCH=$(uname -m)
|
||||||
# Functions
|
if [[ "$ARCH" == "arm64" ]]; then
|
||||||
print_error() {
|
RID="osx-arm64"
|
||||||
echo -e "${RED}❌ Error: $1${NC}" >&2
|
else
|
||||||
|
RID="osx-x64"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Unsupported OS: $OSTYPE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
print_success() {
|
print_usage() {
|
||||||
echo -e "${GREEN}✅ $1${NC}"
|
|
||||||
}
|
|
||||||
|
|
||||||
print_info() {
|
|
||||||
echo -e "${BLUE}ℹ️ $1${NC}"
|
|
||||||
}
|
|
||||||
|
|
||||||
print_warning() {
|
|
||||||
echo -e "${YELLOW}⚠️ $1${NC}"
|
|
||||||
}
|
|
||||||
|
|
||||||
show_help() {
|
|
||||||
cat << EOF
|
cat << EOF
|
||||||
LanMontainDesktop Build Script for Linux/macOS
|
LanMontainDesktop Build Script
|
||||||
|
|
||||||
Usage: $0 [options]
|
Usage: $0 [command] [options]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
build Build the project
|
||||||
|
publish Publish as self-contained
|
||||||
|
clean Clean build output
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-p, --project PATH Project file path (default: LanMontainDesktop/LanMontainDesktop.csproj)
|
--config CONFIG Build configuration (Debug/Release, default: Release)
|
||||||
-c, --config CONFIG Configuration: Release/Debug (default: Release)
|
--rid RID Runtime identifier (e.g., linux-x64, osx-arm64)
|
||||||
-r, --rid RID Runtime Identifier: linux-x64, osx-x64, osx-arm64 (required)
|
--os OS Operating system (linux, macos)
|
||||||
-v, --version VERSION Version number (default: read from csproj)
|
|
||||||
-o, --output DIR Output directory for publish
|
|
||||||
--skip-restore Skip dotnet restore
|
|
||||||
--verbose Verbose output
|
|
||||||
-h, --help Show this help message
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
./build.sh --rid linux-x64 --version 1.0.0
|
$0 build
|
||||||
./build.sh --rid osx-x64 --output ./publish
|
$0 publish --config Release --rid linux-x64
|
||||||
./build.sh --project LanMontainDesktop/LanMontainDesktop.csproj --rid osx-arm64
|
$0 clean
|
||||||
|
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
# Parse arguments
|
build_project() {
|
||||||
|
local config="$1"
|
||||||
|
echo "Building $OS ($RID) - $config configuration..."
|
||||||
|
|
||||||
|
cd "$Root_Dir"
|
||||||
|
|
||||||
|
dotnet restore
|
||||||
|
dotnet build -c "$config" --no-restore -v minimal
|
||||||
|
|
||||||
|
echo "✅ Build completed"
|
||||||
|
}
|
||||||
|
|
||||||
|
publish_project() {
|
||||||
|
local config="$1"
|
||||||
|
local output_dir="$Root_Dir/publish/$RID"
|
||||||
|
|
||||||
|
echo "Publishing $OS ($RID)..."
|
||||||
|
|
||||||
|
cd "$Root_Dir"
|
||||||
|
|
||||||
|
dotnet restore
|
||||||
|
dotnet publish LanMontainDesktop/LanMontainDesktop.csproj \
|
||||||
|
-c "$config" \
|
||||||
|
-o "$output_dir" \
|
||||||
|
--self-contained \
|
||||||
|
-r "$RID" \
|
||||||
|
-p:PublishSingleFile=true \
|
||||||
|
-p:DebugType=none \
|
||||||
|
-v minimal
|
||||||
|
|
||||||
|
echo "✅ Published to: $output_dir"
|
||||||
|
echo " Size: $(du -sh "$output_dir" | cut -f1)"
|
||||||
|
}
|
||||||
|
|
||||||
|
clean_project() {
|
||||||
|
echo "Cleaning build output..."
|
||||||
|
cd "$Root_Dir"
|
||||||
|
|
||||||
|
rm -rf ./publish
|
||||||
|
rm -rf ./bin
|
||||||
|
rm -rf ./obj
|
||||||
|
find . -name "bin" -type d -exec rm -rf {} + 2>/dev/null || true
|
||||||
|
find . -name "obj" -type d -exec rm -rf {} + 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "✅ Clean completed"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main
|
||||||
|
detect_os
|
||||||
|
|
||||||
|
config="Release"
|
||||||
|
command="build"
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
-p|--project)
|
build|publish|clean)
|
||||||
PROJECT="$2"
|
command="$1"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--config)
|
||||||
|
config="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-c|--config)
|
--rid)
|
||||||
CONFIGURATION="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
-r|--rid)
|
|
||||||
RID="$2"
|
RID="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-v|--version)
|
--os)
|
||||||
VERSION="$2"
|
OS="$2"
|
||||||
|
if [[ "$OS" == "linux" ]]; then
|
||||||
|
RID="linux-x64"
|
||||||
|
elif [[ "$OS" == "macos" ]]; then
|
||||||
|
RID="osx-x64"
|
||||||
|
fi
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-o|--output)
|
|
||||||
PUBLISH_DIR="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--skip-restore)
|
|
||||||
SKIP_RESTORE=true
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
--verbose)
|
|
||||||
VERBOSE=true
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
-h|--help)
|
-h|--help)
|
||||||
show_help
|
print_usage
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
print_error "Unknown option: $1"
|
echo "Unknown option: $1"
|
||||||
show_help
|
print_usage
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
# Validation
|
case $command in
|
||||||
if [ -z "$RID" ]; then
|
build)
|
||||||
print_error "Runtime Identifier (--rid) is required"
|
build_project "$config"
|
||||||
show_help
|
;;
|
||||||
|
publish)
|
||||||
|
publish_project "$config"
|
||||||
|
;;
|
||||||
|
clean)
|
||||||
|
clean_project
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
print_usage
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
;;
|
||||||
|
esac
|
||||||
if [ ! -f "$PROJECT" ]; then
|
|
||||||
print_error "Project file not found: $PROJECT"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Detect OS
|
|
||||||
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
|
|
||||||
OS="linux"
|
|
||||||
DETECTED_RID="linux-x64"
|
|
||||||
elif [[ "$OSTYPE" == "darwin"* ]]; then
|
|
||||||
OS="macos"
|
|
||||||
# Try to detect architecture
|
|
||||||
if [ "$(uname -m)" == "arm64" ]; then
|
|
||||||
DETECTED_RID="osx-arm64"
|
|
||||||
else
|
|
||||||
DETECTED_RID="osx-x64"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
print_error "Unsupported OS: $OSTYPE"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
print_info "Detected OS: $OS ($DETECTED_RID)"
|
|
||||||
print_info "Target RID: $RID"
|
|
||||||
|
|
||||||
# Read version from csproj if not provided
|
|
||||||
if [ -z "$VERSION" ]; then
|
|
||||||
VERSION=$(grep -oP '<Version>\K[^<]*' "$PROJECT" | head -1)
|
|
||||||
if [ -z "$VERSION" ]; then
|
|
||||||
VERSION="1.0.0"
|
|
||||||
print_warning "No version found in csproj, using default: $VERSION"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
print_info "Version: $VERSION"
|
|
||||||
print_info "Configuration: $CONFIGURATION"
|
|
||||||
|
|
||||||
# Set output directory
|
|
||||||
if [ -z "$PUBLISH_DIR" ]; then
|
|
||||||
PUBLISH_DIR="./publish/$RID"
|
|
||||||
fi
|
|
||||||
|
|
||||||
print_info "Output directory: $PUBLISH_DIR"
|
|
||||||
|
|
||||||
# Restore dependencies
|
|
||||||
if [ "$SKIP_RESTORE" = false ]; then
|
|
||||||
print_info "Restoring dependencies..."
|
|
||||||
if [ "$VERBOSE" = true ]; then
|
|
||||||
dotnet restore --verbosity detailed
|
|
||||||
else
|
|
||||||
dotnet restore
|
|
||||||
fi
|
|
||||||
print_success "Dependencies restored"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Build
|
|
||||||
print_info "Building..."
|
|
||||||
if [ "$VERBOSE" = true ]; then
|
|
||||||
dotnet build "$PROJECT" \
|
|
||||||
-c "$CONFIGURATION" \
|
|
||||||
--no-restore \
|
|
||||||
--verbosity detailed
|
|
||||||
else
|
|
||||||
dotnet build "$PROJECT" -c "$CONFIGURATION" --no-restore
|
|
||||||
fi
|
|
||||||
print_success "Build completed"
|
|
||||||
|
|
||||||
# Publish
|
|
||||||
print_info "Publishing..."
|
|
||||||
PUBLISH_ARGS=(
|
|
||||||
"$PROJECT"
|
|
||||||
"-c" "$CONFIGURATION"
|
|
||||||
"-o" "$PUBLISH_DIR"
|
|
||||||
"-r" "$RID"
|
|
||||||
"--self-contained"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add platform-specific publish options
|
|
||||||
if [ "$VERBOSE" = true ]; then
|
|
||||||
PUBLISH_ARGS+=("--verbosity" "detailed")
|
|
||||||
fi
|
|
||||||
|
|
||||||
dotnet publish "${PUBLISH_ARGS[@]}" \
|
|
||||||
-p:PublishSingleFile=true \
|
|
||||||
-p:PublishTrimmed=false \
|
|
||||||
-p:DebugType=embedded \
|
|
||||||
-p:DebugSymbols=false
|
|
||||||
|
|
||||||
print_success "Published to: $PUBLISH_DIR"
|
|
||||||
|
|
||||||
# Show result
|
|
||||||
if [ -d "$PUBLISH_DIR" ]; then
|
|
||||||
SIZE=$(du -sh "$PUBLISH_DIR" | cut -f1)
|
|
||||||
FILE_COUNT=$(find "$PUBLISH_DIR" -type f | wc -l)
|
|
||||||
print_success "Build complete! Output size: $SIZE ($FILE_COUNT files)"
|
|
||||||
|
|
||||||
if [ "$VERBOSE" = true ]; then
|
|
||||||
print_info "Output contents:"
|
|
||||||
ls -lh "$PUBLISH_DIR"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
print_error "Publish directory not found"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
print_success "Done!"
|
|
||||||
|
|||||||
Reference in New Issue
Block a user