diff --git a/LanMontainDesktop/.github/workflows/windows-ci.yml b/LanMontainDesktop/.github/workflows/windows-ci.yml
index be8490b..95098d7 100644
--- a/LanMontainDesktop/.github/workflows/windows-ci.yml
+++ b/LanMontainDesktop/.github/workflows/windows-ci.yml
@@ -14,10 +14,19 @@ on:
required: false
type: string
+concurrency:
+ group: desktop-ci-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/v') }}
+
+env:
+ DOTNET_VERSION: "10.0.x"
+ PROJECT_PATH: "LanMontainDesktop.csproj"
+
jobs:
validate:
name: Validate Build (Windows)
runs-on: windows-latest
+ timeout-minutes: 20
permissions:
contents: read
steps:
@@ -27,22 +36,98 @@ jobs:
- name: Setup .NET SDK
uses: actions/setup-dotnet@v4
with:
- dotnet-version: "10.0.x"
+ dotnet-version: ${{ env.DOTNET_VERSION }}
cache: true
+ cache-dependency-path: |
+ **/*.csproj
- name: Restore
- run: dotnet restore .\LanMontainDesktop.csproj
+ run: dotnet restore .\${{ env.PROJECT_PATH }}
- name: Build
- run: dotnet build .\LanMontainDesktop.csproj -c Release --no-restore
+ run: dotnet build .\${{ env.PROJECT_PATH }} -c Release --no-restore
- package_windows:
- name: Package Windows
- runs-on: windows-latest
- needs: validate
+ - name: Test (if test projects exist)
+ shell: pwsh
+ run: |
+ $testProjects = @(Get-ChildItem -Path . -Recurse -Filter *.csproj | Where-Object {
+ Select-String -Path $_.FullName -Pattern '\s*true\s*|Microsoft.NET.Test.Sdk' -Quiet
+ })
+
+ if ($testProjects.Count -eq 0) {
+ Write-Host "No test projects found. Skipping dotnet test."
+ exit 0
+ }
+
+ foreach ($project in $testProjects) {
+ Write-Host "Running tests in $($project.FullName)"
+ dotnet test $project.FullName -c Release --verbosity normal
+ }
+
+ resolve_version:
+ name: Resolve Package Version
+ runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v')
+ outputs:
+ value: ${{ steps.version.outputs.value }}
permissions:
- contents: write
+ contents: read
+ steps:
+ - name: Resolve version
+ id: version
+ shell: pwsh
+ run: |
+ $manualVersion = '${{ github.event.inputs.version }}'
+ if ($manualVersion) {
+ $version = $manualVersion.Trim()
+ } elseif ($env:GITHUB_REF -like "refs/tags/v*") {
+ $version = $env:GITHUB_REF_NAME.Substring(1)
+ } elseif ($env:GITHUB_REF -like "refs/tags/*") {
+ $version = $env:GITHUB_REF_NAME
+ } else {
+ $version = "0.0.$env:GITHUB_RUN_NUMBER"
+ }
+
+ if (-not $version) {
+ throw "Failed to resolve package version."
+ }
+
+ if ($version -notmatch '^\d+\.\d+\.\d+([\-+][0-9A-Za-z\.-]+)?$') {
+ throw "Invalid version format: $version"
+ }
+
+ "value=$version" >> $env:GITHUB_OUTPUT
+ Write-Host "Using package version: $version"
+
+ package:
+ name: Package (${{ matrix.name }})
+ needs:
+ - validate
+ - resolve_version
+ if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v')
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 60
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - name: Windows
+ runner: windows-latest
+ rid: win-x64
+ artifact_name: LanMontainDesktop-Setup
+ artifact_path: artifacts/installer/*.exe
+ - name: Linux
+ runner: ubuntu-latest
+ rid: linux-x64
+ artifact_name: LanMontainDesktop-linux-x64
+ artifact_path: artifacts/packages/*linux-x64*.zip
+ - name: macOS
+ runner: macos-latest
+ rid: osx-x64
+ artifact_name: LanMontainDesktop-osx-x64
+ artifact_path: artifacts/packages/*osx-x64*.zip
+ permissions:
+ contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -50,12 +135,20 @@ jobs:
- name: Setup .NET SDK
uses: actions/setup-dotnet@v4
with:
- dotnet-version: "10.0.x"
+ dotnet-version: ${{ env.DOTNET_VERSION }}
cache: true
+ cache-dependency-path: |
+ **/*.csproj
- name: Install Inno Setup
+ if: matrix.rid == 'win-x64'
shell: pwsh
run: |
+ if (Get-Command iscc.exe -ErrorAction SilentlyContinue) {
+ Write-Host "Inno Setup is already installed."
+ exit 0
+ }
+
if (Get-Command choco -ErrorAction SilentlyContinue) {
choco install innosetup --yes --no-progress
} elseif (Get-Command winget -ErrorAction SilentlyContinue) {
@@ -64,160 +157,61 @@ jobs:
throw "Neither choco nor winget is available to install Inno Setup."
}
- - name: Resolve Package Version
- id: version
+ - name: Build Package
shell: pwsh
run: |
- $manualVersion = '${{ github.event.inputs.version }}'
- if ($manualVersion) {
- $version = $manualVersion.Trim()
- } elseif ($env:GITHUB_REF -like "refs/tags/v*") {
- $version = $env:GITHUB_REF_NAME.Substring(1)
- } elseif ($env:GITHUB_REF -like "refs/tags/*") {
- $version = $env:GITHUB_REF_NAME
- } else {
- $version = "0.0.$env:GITHUB_RUN_NUMBER"
- }
-
- if (-not $version) {
- throw "Failed to resolve package version."
- }
-
- "value=$version" >> $env:GITHUB_OUTPUT
- Write-Host "Using package version: $version"
-
- - name: Build Windows Installer
- shell: pwsh
- run: |
- .\scripts\package.ps1 `
+ ./scripts/package.ps1 `
-Configuration Release `
- -RuntimeIdentifier win-x64 `
- -Version "${{ steps.version.outputs.value }}"
+ -RuntimeIdentifier ${{ matrix.rid }} `
+ -Version "${{ needs.resolve_version.outputs.value }}"
- - name: Upload Windows Installer Artifact
+ - name: Upload Package Artifact
uses: actions/upload-artifact@v4
with:
- name: LanMontainDesktop-Setup-${{ steps.version.outputs.value }}
- path: artifacts/installer/*.exe
+ name: ${{ matrix.artifact_name }}-${{ needs.resolve_version.outputs.value }}
+ path: ${{ matrix.artifact_path }}
if-no-files-found: error
- name: Upload Windows Publish Artifact
+ if: matrix.rid == 'win-x64'
uses: actions/upload-artifact@v4
with:
- name: LanMontainDesktop-Publish-win-x64-${{ steps.version.outputs.value }}
+ name: LanMontainDesktop-Publish-win-x64-${{ needs.resolve_version.outputs.value }}
path: artifacts/publish/win-x64/**
if-no-files-found: error
- - name: Attach Windows Installer to GitHub Release
- if: startsWith(github.ref, 'refs/tags/v')
+ publish_release_assets:
+ name: Attach Artifacts to GitHub Release
+ runs-on: ubuntu-latest
+ needs:
+ - package
+ - resolve_version
+ if: startsWith(github.ref, 'refs/tags/v')
+ permissions:
+ contents: write
+ steps:
+ - name: Download Windows Installer Artifact
+ uses: actions/download-artifact@v4
+ with:
+ name: LanMontainDesktop-Setup-${{ needs.resolve_version.outputs.value }}
+ path: release-assets/windows
+
+ - name: Download Linux Package Artifact
+ uses: actions/download-artifact@v4
+ with:
+ name: LanMontainDesktop-linux-x64-${{ needs.resolve_version.outputs.value }}
+ path: release-assets/linux
+
+ - name: Download macOS Package Artifact
+ uses: actions/download-artifact@v4
+ with:
+ name: LanMontainDesktop-osx-x64-${{ needs.resolve_version.outputs.value }}
+ path: release-assets/macos
+
+ - name: Attach Artifacts
uses: softprops/action-gh-release@v2
with:
- files: artifacts/installer/*.exe
-
- package_linux:
- name: Package Linux
- runs-on: ubuntu-latest
- needs: validate
- if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v')
- permissions:
- contents: read
- steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- - name: Setup .NET SDK
- uses: actions/setup-dotnet@v4
- with:
- dotnet-version: "10.0.x"
- cache: true
-
- - name: Resolve Package Version
- id: version
- shell: pwsh
- run: |
- $manualVersion = '${{ github.event.inputs.version }}'
- if ($manualVersion) {
- $version = $manualVersion.Trim()
- } elseif ($env:GITHUB_REF -like "refs/tags/v*") {
- $version = $env:GITHUB_REF_NAME.Substring(1)
- } elseif ($env:GITHUB_REF -like "refs/tags/*") {
- $version = $env:GITHUB_REF_NAME
- } else {
- $version = "0.0.$env:GITHUB_RUN_NUMBER"
- }
-
- if (-not $version) {
- throw "Failed to resolve package version."
- }
-
- "value=$version" >> $env:GITHUB_OUTPUT
- Write-Host "Using package version: $version"
-
- - name: Build Linux Package
- shell: pwsh
- run: |
- ./scripts/package.ps1 `
- -Configuration Release `
- -RuntimeIdentifier linux-x64 `
- -Version "${{ steps.version.outputs.value }}"
-
- - name: Upload Linux Package Artifact
- uses: actions/upload-artifact@v4
- with:
- name: LanMontainDesktop-linux-x64-${{ steps.version.outputs.value }}
- path: artifacts/packages/*linux-x64*.zip
- if-no-files-found: error
-
- package_macos:
- name: Package macOS
- runs-on: macos-latest
- needs: validate
- if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v')
- permissions:
- contents: read
- steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- - name: Setup .NET SDK
- uses: actions/setup-dotnet@v4
- with:
- dotnet-version: "10.0.x"
- cache: true
-
- - name: Resolve Package Version
- id: version
- shell: pwsh
- run: |
- $manualVersion = '${{ github.event.inputs.version }}'
- if ($manualVersion) {
- $version = $manualVersion.Trim()
- } elseif ($env:GITHUB_REF -like "refs/tags/v*") {
- $version = $env:GITHUB_REF_NAME.Substring(1)
- } elseif ($env:GITHUB_REF -like "refs/tags/*") {
- $version = $env:GITHUB_REF_NAME
- } else {
- $version = "0.0.$env:GITHUB_RUN_NUMBER"
- }
-
- if (-not $version) {
- throw "Failed to resolve package version."
- }
-
- "value=$version" >> $env:GITHUB_OUTPUT
- Write-Host "Using package version: $version"
-
- - name: Build macOS Package
- shell: pwsh
- run: |
- ./scripts/package.ps1 `
- -Configuration Release `
- -RuntimeIdentifier osx-x64 `
- -Version "${{ steps.version.outputs.value }}"
-
- - name: Upload macOS Package Artifact
- uses: actions/upload-artifact@v4
- with:
- name: LanMontainDesktop-osx-x64-${{ steps.version.outputs.value }}
- path: artifacts/packages/*osx-x64*.zip
- if-no-files-found: error
+ files: |
+ release-assets/windows/*.exe
+ release-assets/linux/*.zip
+ release-assets/macos/*.zip
diff --git a/LanMontainDesktop/Assets/Weather/HyperOS3/ATTRIBUTION.md b/LanMontainDesktop/Assets/Weather/HyperOS3/ATTRIBUTION.md
index 7ea96e7..9f9d09a 100644
--- a/LanMontainDesktop/Assets/Weather/HyperOS3/ATTRIBUTION.md
+++ b/LanMontainDesktop/Assets/Weather/HyperOS3/ATTRIBUTION.md
@@ -17,4 +17,18 @@ Extracted source paths inside APK:
- `assets/map_assets/VM3DRes/cross_sky_day.png` -> `hyper_cross_sky_day.png`
- `assets/map_assets/VM3DRes/cross_sky_night.png` -> `hyper_cross_sky_night.png`
+Extracted weather icon paths inside APK (`res/*.webp`):
+- `res/aO.webp` -> `Icons/icon_sunny_day.webp`
+- `res/k2.webp` -> `Icons/icon_moon_clear.webp`
+- `res/Ip.webp` -> `Icons/icon_partly_cloudy_day.webp`
+- `res/HI.webp` -> `Icons/icon_partly_cloudy_night.webp`
+- `res/E4.webp` -> `Icons/icon_cloudy.webp`
+- `res/5f.webp` -> `Icons/icon_rain_light.webp`
+- `res/fO.webp` -> `Icons/icon_rain_heavy.webp`
+- `res/lV1.webp` -> `Icons/icon_thunder.webp`
+- `res/mH1.webp` -> `Icons/icon_snow.webp`
+- `res/jB.webp` -> `Icons/icon_sleet.webp`
+- `res/Wl.webp` -> `Icons/icon_haze.webp`
+- `res/Mg.webp` -> `Icons/icon_windy.webp`
+
Use only according to Xiaomi's applicable license and usage terms.
diff --git a/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_cloudy.webp b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_cloudy.webp
new file mode 100644
index 0000000..228d2bd
Binary files /dev/null and b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_cloudy.webp differ
diff --git a/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_haze.webp b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_haze.webp
new file mode 100644
index 0000000..f34ef53
Binary files /dev/null and b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_haze.webp differ
diff --git a/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_moon_clear.webp b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_moon_clear.webp
new file mode 100644
index 0000000..5e1df1f
Binary files /dev/null and b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_moon_clear.webp differ
diff --git a/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_partly_cloudy_day.webp b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_partly_cloudy_day.webp
new file mode 100644
index 0000000..65e334f
Binary files /dev/null and b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_partly_cloudy_day.webp differ
diff --git a/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_partly_cloudy_night.webp b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_partly_cloudy_night.webp
new file mode 100644
index 0000000..38aa31e
Binary files /dev/null and b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_partly_cloudy_night.webp differ
diff --git a/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_rain_heavy.webp b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_rain_heavy.webp
new file mode 100644
index 0000000..6f233f6
Binary files /dev/null and b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_rain_heavy.webp differ
diff --git a/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_rain_light.webp b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_rain_light.webp
new file mode 100644
index 0000000..e373622
Binary files /dev/null and b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_rain_light.webp differ
diff --git a/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_sleet.webp b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_sleet.webp
new file mode 100644
index 0000000..50d69bd
Binary files /dev/null and b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_sleet.webp differ
diff --git a/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_snow.webp b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_snow.webp
new file mode 100644
index 0000000..b17c064
Binary files /dev/null and b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_snow.webp differ
diff --git a/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_sunny_day.webp b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_sunny_day.webp
new file mode 100644
index 0000000..96d98c8
Binary files /dev/null and b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_sunny_day.webp differ
diff --git a/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_thunder.webp b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_thunder.webp
new file mode 100644
index 0000000..4602164
Binary files /dev/null and b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_thunder.webp differ
diff --git a/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_windy.webp b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_windy.webp
new file mode 100644
index 0000000..2c55d40
Binary files /dev/null and b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_windy.webp differ
diff --git a/LanMontainDesktop/ComponentSystem/BuiltInComponentIds.cs b/LanMontainDesktop/ComponentSystem/BuiltInComponentIds.cs
index dd4c11f..ad51e50 100644
--- a/LanMontainDesktop/ComponentSystem/BuiltInComponentIds.cs
+++ b/LanMontainDesktop/ComponentSystem/BuiltInComponentIds.cs
@@ -11,6 +11,8 @@ public static class BuiltInComponentIds
public const string DesktopMultiDayWeather = "DesktopMultiDayWeather";
public const string DesktopExtendedWeather = "DesktopExtendedWeather";
public const string DesktopClassSchedule = "DesktopClassSchedule";
+ public const string DesktopMusicControl = "DesktopMusicControl";
+ public const string DesktopAudioRecorder = "DesktopAudioRecorder";
public const string Blank2x4 = "Blank2x4";
public const string Date = "Date";
public const string MonthCalendar = "MonthCalendar";
diff --git a/LanMontainDesktop/ComponentSystem/ComponentRegistry.cs b/LanMontainDesktop/ComponentSystem/ComponentRegistry.cs
index e052662..81276b3 100644
--- a/LanMontainDesktop/ComponentSystem/ComponentRegistry.cs
+++ b/LanMontainDesktop/ComponentSystem/ComponentRegistry.cs
@@ -103,6 +103,24 @@ public sealed class ComponentRegistry
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true,
ResizeMode: DesktopComponentResizeMode.Free),
+ new DesktopComponentDefinition(
+ BuiltInComponentIds.DesktopMusicControl,
+ "Music Control",
+ "Play",
+ "Media",
+ MinWidthCells: 4,
+ MinHeightCells: 2,
+ AllowStatusBarPlacement: false,
+ AllowDesktopPlacement: true),
+ new DesktopComponentDefinition(
+ BuiltInComponentIds.DesktopAudioRecorder,
+ "Recorder",
+ "MicOn",
+ "Media",
+ MinWidthCells: 2,
+ MinHeightCells: 2,
+ AllowStatusBarPlacement: false,
+ AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopWhiteboard,
"Blackboard Portrait",
diff --git a/LanMontainDesktop/LanMontainDesktop.csproj b/LanMontainDesktop/LanMontainDesktop.csproj
index 4760629..87fb919 100644
--- a/LanMontainDesktop/LanMontainDesktop.csproj
+++ b/LanMontainDesktop/LanMontainDesktop.csproj
@@ -31,14 +31,11 @@
+
+
-
-
+
+
diff --git a/LanMontainDesktop/Localization/en-US.json b/LanMontainDesktop/Localization/en-US.json
index 80a9f29..4c21bed 100644
--- a/LanMontainDesktop/Localization/en-US.json
+++ b/LanMontainDesktop/Localization/en-US.json
@@ -210,6 +210,7 @@
"component_category.date": "Calendar",
"component_category.weather": "Weather",
"component_category.board": "Board",
+ "component_category.media": "Media",
"component.date": "Calendar",
"component.month_calendar": "Month Calendar",
"component.lunar_calendar": "Lunar Calendar",
@@ -221,9 +222,30 @@
"component.multiday_weather": "Multi-day Weather",
"component.extended_weather": "Extended Weather",
"component.class_schedule": "Class Schedule",
+ "component.music_control": "Music Control",
+ "component.audio_recorder": "Recorder",
"component.whiteboard": "Blackboard (Portrait)",
"component.blackboard_landscape": "Blackboard (Landscape)",
"component.holiday_calendar": "Holiday Calendar",
+ "music.widget.unsupported": "Music control is not supported on this platform",
+ "music.widget.unsupported_hint": "This widget requires Windows SMTC",
+ "music.widget.no_session": "No active media session",
+ "music.widget.no_session_hint": "Open a player that supports SMTC",
+ "music.widget.open_player": "Open player",
+ "music.widget.unknown_title": "Unknown title",
+ "music.widget.unknown_artist": "Unknown artist",
+ "music.widget.status.opened": "Opened",
+ "music.widget.status.changing": "Changing",
+ "music.widget.status.stopped": "Stopped",
+ "music.widget.status.playing": "Playing",
+ "music.widget.status.paused": "Paused",
+ "recording.widget.title": "Recorder",
+ "recording.widget.hint.ready": "Tap red button to record",
+ "recording.widget.hint.recording": "Recording",
+ "recording.widget.hint.paused": "Paused",
+ "recording.widget.hint.unsupported": "Microphone is unavailable",
+ "recording.widget.hint.error": "Recording failed",
+ "recording.widget.hint.saved_format": "Saved {0}",
"desktop.add_page": "Add page",
"desktop.delete_page": "Delete page",
"placement.fill": "Fill",
diff --git a/LanMontainDesktop/Localization/zh-CN.json b/LanMontainDesktop/Localization/zh-CN.json
index ff0be7d..72f5049 100644
--- a/LanMontainDesktop/Localization/zh-CN.json
+++ b/LanMontainDesktop/Localization/zh-CN.json
@@ -210,6 +210,7 @@
"component_category.date": "日历",
"component_category.weather": "天气",
"component_category.board": "白板",
+ "component_category.media": "媒体",
"component.date": "日历",
"component.month_calendar": "月历",
"component.lunar_calendar": "农历",
@@ -221,9 +222,30 @@
"component.multiday_weather": "多日天气",
"component.extended_weather": "扩展天气",
"component.class_schedule": "课表",
+ "component.music_control": "音乐控制",
+ "component.audio_recorder": "录音",
"component.whiteboard": "竖向小黑板",
"component.blackboard_landscape": "横向小黑板",
"component.holiday_calendar": "节假日日历",
+ "music.widget.unsupported": "当前平台不支持音乐控制",
+ "music.widget.unsupported_hint": "该组件仅支持 Windows SMTC",
+ "music.widget.no_session": "未检测到正在播放的媒体",
+ "music.widget.no_session_hint": "请打开支持 SMTC 的播放器",
+ "music.widget.open_player": "打开播放器",
+ "music.widget.unknown_title": "未知歌曲",
+ "music.widget.unknown_artist": "未知艺术家",
+ "music.widget.status.opened": "已打开",
+ "music.widget.status.changing": "切换中",
+ "music.widget.status.stopped": "已停止",
+ "music.widget.status.playing": "播放中",
+ "music.widget.status.paused": "已暂停",
+ "recording.widget.title": "录音",
+ "recording.widget.hint.ready": "点击红色按钮开始",
+ "recording.widget.hint.recording": "录音中",
+ "recording.widget.hint.paused": "已暂停",
+ "recording.widget.hint.unsupported": "麦克风不可用",
+ "recording.widget.hint.error": "录音失败",
+ "recording.widget.hint.saved_format": "已保存 {0}",
"desktop.add_page": "新增页面",
"desktop.delete_page": "删除页面",
"placement.fill": "填充",
diff --git a/LanMontainDesktop/PACKAGING.md b/LanMontainDesktop/PACKAGING.md
index 9145379..acb7578 100644
--- a/LanMontainDesktop/PACKAGING.md
+++ b/LanMontainDesktop/PACKAGING.md
@@ -55,10 +55,12 @@ pwsh ./scripts/package.ps1 -RuntimeIdentifier linux-x64 -SkipArchive
Jobs:
- `Validate Build (Windows)` runs on every push and pull request.
-- Package jobs run on manual trigger or `v*` tag push:
- - `Package Windows` (`win-x64` installer)
- - `Package Linux` (`linux-x64` zip)
- - `Package macOS` (`osx-x64` zip)
+- Package flow runs on manual trigger or `v*` tag push:
+ - `Resolve Package Version` (single shared version source)
+ - `Package (Windows)` (`win-x64` installer)
+ - `Package (Linux)` (`linux-x64` zip)
+ - `Package (macOS)` (`osx-x64` zip)
+- On `v*` tags, `Attach Artifacts to GitHub Release` uploads Windows/Linux/macOS packages to the release.
### Trigger manual packaging
1. Open GitHub Actions.
diff --git a/LanMontainDesktop/Services/IAudioRecorderService.cs b/LanMontainDesktop/Services/IAudioRecorderService.cs
new file mode 100644
index 0000000..22e0a11
--- /dev/null
+++ b/LanMontainDesktop/Services/IAudioRecorderService.cs
@@ -0,0 +1,643 @@
+using System;
+using System.Buffers;
+using System.Collections.Generic;
+using System.IO;
+using System.Runtime.InteropServices;
+using PortAudioSharp;
+using PortAudioStream = PortAudioSharp.Stream;
+
+namespace LanMontainDesktop.Services;
+
+public enum AudioRecorderRuntimeState
+{
+ Unsupported = 0,
+ Ready = 1,
+ Recording = 2,
+ Paused = 3,
+ Error = 4
+}
+
+public sealed record AudioRecorderSnapshot(
+ AudioRecorderRuntimeState State,
+ TimeSpan Duration,
+ double InputLevel,
+ string LastSavedFilePath,
+ string LastError)
+{
+ public bool IsSupported => State != AudioRecorderRuntimeState.Unsupported;
+}
+
+public interface IAudioRecorderService : IDisposable
+{
+ AudioRecorderSnapshot GetSnapshot();
+
+ bool StartOrResume();
+
+ bool Pause();
+
+ string? StopAndSave();
+
+ void Discard();
+}
+
+public static class AudioRecorderServiceFactory
+{
+ private static readonly Lazy SharedService = new(
+ () =>
+ {
+ if (!OperatingSystem.IsWindows() && !OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS())
+ {
+ return new NoOpAudioRecorderService("Unsupported platform");
+ }
+
+ return new PortAudioRecorderService();
+ },
+ isThreadSafe: true);
+
+ public static IAudioRecorderService CreateDefault()
+ {
+ return SharedService.Value;
+ }
+}
+
+internal sealed class NoOpAudioRecorderService(string reason) : IAudioRecorderService
+{
+ private readonly AudioRecorderSnapshot _snapshot = new(
+ AudioRecorderRuntimeState.Unsupported,
+ TimeSpan.Zero,
+ 0,
+ string.Empty,
+ reason);
+
+ public AudioRecorderSnapshot GetSnapshot()
+ {
+ return _snapshot;
+ }
+
+ public bool StartOrResume()
+ {
+ return false;
+ }
+
+ public bool Pause()
+ {
+ return false;
+ }
+
+ public string? StopAndSave()
+ {
+ return null;
+ }
+
+ public void Discard()
+ {
+ }
+
+ public void Dispose()
+ {
+ }
+}
+
+public sealed class PortAudioRecorderService : IAudioRecorderService
+{
+ private const int ChannelCount = 1;
+ private const int BitsPerSample = 16;
+ private const int BytesPerSample = BitsPerSample / 8;
+ private const int PreferredSampleRate = 16000;
+ private const uint FramesPerBuffer = 320;
+
+ private readonly object _syncRoot = new();
+
+ private PortAudioStream? _stream;
+ private PortAudioStream.Callback? _streamCallback;
+ private MemoryStream? _pcmBuffer;
+
+ private AudioRecorderRuntimeState _state = AudioRecorderRuntimeState.Unsupported;
+ private string _lastSavedFilePath = string.Empty;
+ private string _lastError = string.Empty;
+ private int _inputDeviceIndex = -1;
+ private int _sampleRate = PreferredSampleRate;
+ private double _deviceDefaultSampleRate = PreferredSampleRate;
+ private long _capturedFrames;
+ private double _inputLevel;
+ private bool _isPortAudioInitialized;
+ private bool _isDisposed;
+
+ public PortAudioRecorderService()
+ {
+ InitializeRuntime();
+ }
+
+ public AudioRecorderSnapshot GetSnapshot()
+ {
+ lock (_syncRoot)
+ {
+ var level = _state == AudioRecorderRuntimeState.Recording
+ ? Math.Clamp(_inputLevel, 0, 1)
+ : 0;
+
+ var duration = _capturedFrames <= 0 || _sampleRate <= 0
+ ? TimeSpan.Zero
+ : TimeSpan.FromSeconds(_capturedFrames / (double)_sampleRate);
+
+ return new AudioRecorderSnapshot(
+ State: _state,
+ Duration: duration,
+ InputLevel: level,
+ LastSavedFilePath: _lastSavedFilePath,
+ LastError: _lastError);
+ }
+ }
+
+ public bool StartOrResume()
+ {
+ lock (_syncRoot)
+ {
+ if (_isDisposed)
+ {
+ return false;
+ }
+
+ if (_state == AudioRecorderRuntimeState.Unsupported)
+ {
+ return false;
+ }
+
+ if (_state == AudioRecorderRuntimeState.Error)
+ {
+ _state = AudioRecorderRuntimeState.Ready;
+ }
+
+ if (_state == AudioRecorderRuntimeState.Recording)
+ {
+ return true;
+ }
+
+ if (_state == AudioRecorderRuntimeState.Paused && _stream is not null)
+ {
+ try
+ {
+ _stream.Start();
+ _state = AudioRecorderRuntimeState.Recording;
+ _lastError = string.Empty;
+ return true;
+ }
+ catch (Exception ex)
+ {
+ SetErrorLocked(ex);
+ return false;
+ }
+ }
+
+ EnsureBufferLocked();
+ ResetCaptureStateLocked();
+ if (!TryOpenInputStreamLocked())
+ {
+ return false;
+ }
+
+ _state = AudioRecorderRuntimeState.Recording;
+ _lastError = string.Empty;
+ return true;
+ }
+ }
+
+ public bool Pause()
+ {
+ lock (_syncRoot)
+ {
+ if (_isDisposed || _state != AudioRecorderRuntimeState.Recording || _stream is null)
+ {
+ return false;
+ }
+
+ try
+ {
+ _stream.Stop();
+ _state = AudioRecorderRuntimeState.Paused;
+ _inputLevel = 0;
+ _lastError = string.Empty;
+ return true;
+ }
+ catch (Exception ex)
+ {
+ SetErrorLocked(ex);
+ return false;
+ }
+ }
+ }
+
+ public string? StopAndSave()
+ {
+ byte[] pcmData;
+ int sampleRate;
+
+ lock (_syncRoot)
+ {
+ if (_isDisposed ||
+ (_state != AudioRecorderRuntimeState.Recording && _state != AudioRecorderRuntimeState.Paused))
+ {
+ return null;
+ }
+
+ StopStreamLocked();
+
+ pcmData = _pcmBuffer?.ToArray() ?? Array.Empty();
+ sampleRate = _sampleRate;
+
+ ResetCaptureStateLocked();
+ _state = AudioRecorderRuntimeState.Ready;
+ _inputLevel = 0;
+ }
+
+ if (pcmData.Length == 0)
+ {
+ return null;
+ }
+
+ var outputPath = BuildOutputPath();
+ try
+ {
+ WriteWaveFile(outputPath, pcmData, sampleRate, ChannelCount, BitsPerSample);
+ }
+ catch (Exception ex)
+ {
+ lock (_syncRoot)
+ {
+ SetErrorLocked(ex);
+ }
+
+ return null;
+ }
+
+ lock (_syncRoot)
+ {
+ _lastSavedFilePath = outputPath;
+ _lastError = string.Empty;
+ }
+
+ return outputPath;
+ }
+
+ public void Discard()
+ {
+ lock (_syncRoot)
+ {
+ if (_isDisposed)
+ {
+ return;
+ }
+
+ StopStreamLocked();
+ ResetCaptureStateLocked();
+ _inputLevel = 0;
+ _lastError = string.Empty;
+
+ if (_state != AudioRecorderRuntimeState.Unsupported)
+ {
+ _state = AudioRecorderRuntimeState.Ready;
+ }
+ }
+ }
+
+ public void Dispose()
+ {
+ lock (_syncRoot)
+ {
+ if (_isDisposed)
+ {
+ return;
+ }
+
+ _isDisposed = true;
+
+ StopStreamLocked();
+ _pcmBuffer?.Dispose();
+ _pcmBuffer = null;
+
+ if (_isPortAudioInitialized)
+ {
+ try
+ {
+ PortAudio.Terminate();
+ }
+ catch
+ {
+ // Ignore shutdown failures.
+ }
+
+ _isPortAudioInitialized = false;
+ }
+ }
+ }
+
+ private void InitializeRuntime()
+ {
+ lock (_syncRoot)
+ {
+ if (_isDisposed || _isPortAudioInitialized)
+ {
+ return;
+ }
+
+ try
+ {
+ PortAudio.LoadNativeLibrary();
+ PortAudio.Initialize();
+ _isPortAudioInitialized = true;
+ }
+ catch (Exception ex)
+ {
+ _state = AudioRecorderRuntimeState.Unsupported;
+ _lastError = ResolveErrorMessage(ex);
+ return;
+ }
+
+ try
+ {
+ _inputDeviceIndex = PortAudio.DefaultInputDevice;
+ if (_inputDeviceIndex < 0)
+ {
+ _state = AudioRecorderRuntimeState.Unsupported;
+ _lastError = "No input device";
+ return;
+ }
+
+ var deviceInfo = PortAudio.GetDeviceInfo(_inputDeviceIndex);
+ if (deviceInfo.maxInputChannels < 1)
+ {
+ _state = AudioRecorderRuntimeState.Unsupported;
+ _lastError = "Input channels unavailable";
+ return;
+ }
+
+ _deviceDefaultSampleRate = deviceInfo.defaultSampleRate > 0
+ ? deviceInfo.defaultSampleRate
+ : PreferredSampleRate;
+ _state = AudioRecorderRuntimeState.Ready;
+ _lastError = string.Empty;
+ }
+ catch (Exception ex)
+ {
+ _state = AudioRecorderRuntimeState.Unsupported;
+ _lastError = ResolveErrorMessage(ex);
+ }
+ }
+ }
+
+ private bool TryOpenInputStreamLocked()
+ {
+ if (!_isPortAudioInitialized || _inputDeviceIndex < 0)
+ {
+ _state = AudioRecorderRuntimeState.Unsupported;
+ return false;
+ }
+
+ var inputParameters = new StreamParameters
+ {
+ device = _inputDeviceIndex,
+ channelCount = ChannelCount,
+ sampleFormat = SampleFormat.Int16,
+ suggestedLatency = ResolveSuggestedLatency(),
+ hostApiSpecificStreamInfo = IntPtr.Zero
+ };
+
+ _streamCallback ??= OnStreamCallback;
+ foreach (var candidateRate in BuildSampleRateCandidates())
+ {
+ try
+ {
+ _stream?.Dispose();
+ _stream = new PortAudioStream(
+ inputParameters,
+ null,
+ candidateRate,
+ FramesPerBuffer,
+ StreamFlags.ClipOff,
+ _streamCallback,
+ this);
+ _sampleRate = Math.Clamp((int)Math.Round(candidateRate), 8000, 96000);
+ _stream.Start();
+ return true;
+ }
+ catch (Exception ex)
+ {
+ _stream?.Dispose();
+ _stream = null;
+ _lastError = ResolveErrorMessage(ex);
+ }
+ }
+
+ _state = AudioRecorderRuntimeState.Error;
+ return false;
+ }
+
+ private StreamCallbackResult OnStreamCallback(
+ IntPtr input,
+ IntPtr output,
+ uint frameCount,
+ ref StreamCallbackTimeInfo timeInfo,
+ StreamCallbackFlags statusFlags,
+ IntPtr userData)
+ {
+ _ = output;
+ _ = timeInfo;
+ _ = statusFlags;
+ _ = userData;
+
+ if (frameCount == 0 || input == IntPtr.Zero)
+ {
+ return StreamCallbackResult.Continue;
+ }
+
+ var byteCount = checked((int)(frameCount * ChannelCount * BytesPerSample));
+ var buffer = ArrayPool.Shared.Rent(byteCount);
+
+ try
+ {
+ Marshal.Copy(input, buffer, 0, byteCount);
+ var peak = CalculatePeak(buffer, byteCount);
+
+ lock (_syncRoot)
+ {
+ if (_state != AudioRecorderRuntimeState.Recording)
+ {
+ return StreamCallbackResult.Continue;
+ }
+
+ _pcmBuffer?.Write(buffer, 0, byteCount);
+ _capturedFrames += frameCount;
+ _inputLevel = (_inputLevel * 0.72) + (peak * 0.28);
+ }
+ }
+ catch
+ {
+ // Keep callback resilient to transient IO/interop errors.
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(buffer);
+ }
+
+ return StreamCallbackResult.Continue;
+ }
+
+ private void StopStreamLocked()
+ {
+ if (_stream is null)
+ {
+ return;
+ }
+
+ try
+ {
+ if (_stream.IsActive)
+ {
+ _stream.Stop();
+ }
+ }
+ catch
+ {
+ // Ignore stop errors.
+ }
+
+ try
+ {
+ _stream.Close();
+ }
+ catch
+ {
+ // Ignore close errors.
+ }
+
+ _stream.Dispose();
+ _stream = null;
+ }
+
+ private void ResetCaptureStateLocked()
+ {
+ _capturedFrames = 0;
+ _sampleRate = Math.Clamp(_sampleRate, 8000, 96000);
+ _inputLevel = 0;
+ _pcmBuffer?.SetLength(0);
+ }
+
+ private void EnsureBufferLocked()
+ {
+ if (_pcmBuffer is not null)
+ {
+ return;
+ }
+
+ _pcmBuffer = new MemoryStream(capacity: 128 * 1024);
+ }
+
+ private double ResolveSuggestedLatency()
+ {
+ try
+ {
+ var info = PortAudio.GetDeviceInfo(_inputDeviceIndex);
+ if (info.defaultLowInputLatency > 0)
+ {
+ return info.defaultLowInputLatency;
+ }
+
+ if (info.defaultHighInputLatency > 0)
+ {
+ return info.defaultHighInputLatency;
+ }
+ }
+ catch
+ {
+ // Fall through to default latency.
+ }
+
+ return 0.04;
+ }
+
+ private double[] BuildSampleRateCandidates()
+ {
+ var ordered = new[] { PreferredSampleRate, _deviceDefaultSampleRate, 44100d, 48000d };
+ var unique = new HashSet();
+ var list = new List(ordered.Length);
+ foreach (var rate in ordered)
+ {
+ var rounded = (int)Math.Round(rate);
+ if (rounded < 8000 || rounded > 96000 || !unique.Add(rounded))
+ {
+ continue;
+ }
+
+ list.Add(rounded);
+ }
+
+ return list.ToArray();
+ }
+
+ private static double CalculatePeak(byte[] buffer, int byteCount)
+ {
+ double peak = 0;
+ for (var i = 0; i + 1 < byteCount; i += 2)
+ {
+ var sample = (short)(buffer[i] | (buffer[i + 1] << 8));
+ var normalized = Math.Abs(sample) / 32768d;
+ if (normalized > peak)
+ {
+ peak = normalized;
+ }
+ }
+
+ return Math.Clamp(peak, 0, 1);
+ }
+
+ private static string BuildOutputPath()
+ {
+ var root = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
+ if (string.IsNullOrWhiteSpace(root))
+ {
+ root = AppContext.BaseDirectory;
+ }
+
+ var folder = Path.Combine(root, "LanMontainDesktop", "Recordings");
+ Directory.CreateDirectory(folder);
+
+ var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss_fff");
+ return Path.Combine(folder, $"recording_{timestamp}.wav");
+ }
+
+ private static void WriteWaveFile(string path, byte[] pcmData, int sampleRate, int channels, int bitsPerSample)
+ {
+ var byteRate = sampleRate * channels * (bitsPerSample / 8);
+ var blockAlign = channels * (bitsPerSample / 8);
+ using var stream = File.Create(path);
+ using var writer = new BinaryWriter(stream);
+
+ writer.Write(new[] { (byte)'R', (byte)'I', (byte)'F', (byte)'F' });
+ writer.Write(36 + pcmData.Length);
+ writer.Write(new[] { (byte)'W', (byte)'A', (byte)'V', (byte)'E' });
+ writer.Write(new[] { (byte)'f', (byte)'m', (byte)'t', (byte)' ' });
+ writer.Write(16);
+ writer.Write((short)1);
+ writer.Write((short)channels);
+ writer.Write(sampleRate);
+ writer.Write(byteRate);
+ writer.Write((short)blockAlign);
+ writer.Write((short)bitsPerSample);
+ writer.Write(new[] { (byte)'d', (byte)'a', (byte)'t', (byte)'a' });
+ writer.Write(pcmData.Length);
+ writer.Write(pcmData);
+ }
+
+ private void SetErrorLocked(Exception ex)
+ {
+ _lastError = ResolveErrorMessage(ex);
+ _state = AudioRecorderRuntimeState.Error;
+ }
+
+ private static string ResolveErrorMessage(Exception ex)
+ {
+ return ex.Message.Trim().Length > 0
+ ? ex.Message.Trim()
+ : ex.GetType().Name;
+ }
+}
diff --git a/LanMontainDesktop/Services/IMusicControlService.cs b/LanMontainDesktop/Services/IMusicControlService.cs
new file mode 100644
index 0000000..af4cd14
--- /dev/null
+++ b/LanMontainDesktop/Services/IMusicControlService.cs
@@ -0,0 +1,121 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace LanMontainDesktop.Services;
+
+public enum MusicPlaybackStatus
+{
+ Unknown = 0,
+ Opened = 1,
+ Changing = 2,
+ Stopped = 3,
+ Playing = 4,
+ Paused = 5
+}
+
+public sealed record MusicPlaybackState(
+ bool IsSupported,
+ bool HasSession,
+ string SourceAppId,
+ string SourceAppName,
+ string Title,
+ string Artist,
+ string AlbumTitle,
+ byte[]? ThumbnailBytes,
+ TimeSpan Position,
+ TimeSpan Duration,
+ MusicPlaybackStatus PlaybackStatus,
+ bool CanPlayPause,
+ bool CanSkipPrevious,
+ bool CanSkipNext)
+{
+ public static MusicPlaybackState Unsupported()
+ {
+ return new MusicPlaybackState(
+ IsSupported: false,
+ HasSession: false,
+ SourceAppId: string.Empty,
+ SourceAppName: string.Empty,
+ Title: string.Empty,
+ Artist: string.Empty,
+ AlbumTitle: string.Empty,
+ ThumbnailBytes: null,
+ Position: TimeSpan.Zero,
+ Duration: TimeSpan.Zero,
+ PlaybackStatus: MusicPlaybackStatus.Unknown,
+ CanPlayPause: false,
+ CanSkipPrevious: false,
+ CanSkipNext: false);
+ }
+
+ public static MusicPlaybackState NoSession(bool isSupported = true)
+ {
+ return new MusicPlaybackState(
+ IsSupported: isSupported,
+ HasSession: false,
+ SourceAppId: string.Empty,
+ SourceAppName: string.Empty,
+ Title: string.Empty,
+ Artist: string.Empty,
+ AlbumTitle: string.Empty,
+ ThumbnailBytes: null,
+ Position: TimeSpan.Zero,
+ Duration: TimeSpan.Zero,
+ PlaybackStatus: MusicPlaybackStatus.Unknown,
+ CanPlayPause: false,
+ CanSkipPrevious: false,
+ CanSkipNext: false);
+ }
+}
+
+public interface IMusicControlService
+{
+ Task GetCurrentStateAsync(CancellationToken cancellationToken = default);
+
+ Task TogglePlayPauseAsync(CancellationToken cancellationToken = default);
+
+ Task SkipNextAsync(CancellationToken cancellationToken = default);
+
+ Task SkipPreviousAsync(CancellationToken cancellationToken = default);
+
+ Task LaunchSourceAppAsync(CancellationToken cancellationToken = default);
+}
+
+public static class MusicControlServiceFactory
+{
+ public static IMusicControlService CreateDefault()
+ {
+ return OperatingSystem.IsWindows()
+ ? new WindowsSmtcMusicControlService()
+ : new NoOpMusicControlService();
+ }
+}
+
+internal sealed class NoOpMusicControlService : IMusicControlService
+{
+ public Task GetCurrentStateAsync(CancellationToken cancellationToken = default)
+ {
+ return Task.FromResult(MusicPlaybackState.Unsupported());
+ }
+
+ public Task TogglePlayPauseAsync(CancellationToken cancellationToken = default)
+ {
+ return Task.FromResult(false);
+ }
+
+ public Task SkipNextAsync(CancellationToken cancellationToken = default)
+ {
+ return Task.FromResult(false);
+ }
+
+ public Task SkipPreviousAsync(CancellationToken cancellationToken = default)
+ {
+ return Task.FromResult(false);
+ }
+
+ public Task LaunchSourceAppAsync(CancellationToken cancellationToken = default)
+ {
+ return Task.FromResult(false);
+ }
+}
diff --git a/LanMontainDesktop/Services/WindowsSmtcMusicControlService.cs b/LanMontainDesktop/Services/WindowsSmtcMusicControlService.cs
new file mode 100644
index 0000000..c07ebf8
--- /dev/null
+++ b/LanMontainDesktop/Services/WindowsSmtcMusicControlService.cs
@@ -0,0 +1,579 @@
+using System;
+using System.Collections.Concurrent;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace LanMontainDesktop.Services;
+
+public sealed class WindowsSmtcMusicControlService : IMusicControlService
+{
+ private static readonly Type? SessionManagerType = ResolveWinRtType("Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager");
+ private static readonly Type? AppInfoType = ResolveWinRtType("Windows.ApplicationModel.AppInfo");
+ private static readonly MethodInfo? RequestSessionManagerAsyncMethod =
+ SessionManagerType?.GetMethod("RequestAsync", BindingFlags.Public | BindingFlags.Static);
+ private static readonly MethodInfo? AsTaskGenericMethodDefinition = ResolveAsTaskGenericMethod();
+ private static readonly MethodInfo? AsStreamForReadMethod = ResolveAsStreamForReadMethod();
+
+ private static readonly SemaphoreSlim ManagerLock = new(1, 1);
+ private static object? _sessionManager;
+
+ private readonly ConcurrentDictionary _sourceAppNameCache = new(StringComparer.OrdinalIgnoreCase);
+ private readonly SemaphoreSlim _stateGate = new(1, 1);
+
+ private string _thumbnailKey = string.Empty;
+ private byte[]? _thumbnailBytesCache;
+
+ public async Task GetCurrentStateAsync(CancellationToken cancellationToken = default)
+ {
+ if (!IsRuntimeSupported())
+ {
+ return MusicPlaybackState.Unsupported();
+ }
+
+ await _stateGate.WaitAsync(cancellationToken);
+ try
+ {
+ var session = await GetCurrentSessionAsync(cancellationToken);
+ if (session is null)
+ {
+ return MusicPlaybackState.NoSession(isSupported: true);
+ }
+
+ var mediaProperties = await TryGetMediaPropertiesAsync(session, cancellationToken);
+ var title = ReadStringProperty(mediaProperties, "Title");
+ var artist = ReadStringProperty(mediaProperties, "Artist");
+ var albumTitle = ReadStringProperty(mediaProperties, "AlbumTitle");
+
+ var playbackInfo = GetPropertyValue(session, "PlaybackInfo") ?? InvokeMethod(session, "GetPlaybackInfo");
+ var controls = GetPropertyValue(playbackInfo, "Controls");
+
+ var playbackStatusRaw = ReadIntProperty(playbackInfo, "PlaybackStatus");
+ var canPlayPause = ReadBoolProperty(controls, "IsPauseEnabled") || ReadBoolProperty(controls, "IsPlayEnabled");
+ var canSkipNext = ReadBoolProperty(controls, "IsNextEnabled");
+ var canSkipPrevious = ReadBoolProperty(controls, "IsPreviousEnabled");
+
+ var sourceAppId = ReadStringProperty(session, "SourceAppUserModelId");
+ var sourceAppName = await ResolveSourceAppDisplayNameAsync(sourceAppId, cancellationToken);
+
+ var timeline = InvokeMethod(session, "GetTimelineProperties");
+ var position = ReadTimeSpanProperty(timeline, "Position");
+ var start = ReadTimeSpanProperty(timeline, "StartTime");
+ var end = ReadTimeSpanProperty(timeline, "EndTime");
+
+ var duration = end - start;
+ if (duration < TimeSpan.Zero)
+ {
+ duration = TimeSpan.Zero;
+ }
+
+ var normalizedPosition = position - start;
+ if (normalizedPosition < TimeSpan.Zero)
+ {
+ normalizedPosition = TimeSpan.Zero;
+ }
+
+ if (duration > TimeSpan.Zero && normalizedPosition > duration)
+ {
+ normalizedPosition = duration;
+ }
+
+ var thumbnailBytes = await ResolveThumbnailBytesAsync(
+ mediaProperties,
+ sourceAppId,
+ title,
+ artist,
+ albumTitle,
+ cancellationToken);
+
+ return new MusicPlaybackState(
+ IsSupported: true,
+ HasSession: true,
+ SourceAppId: sourceAppId,
+ SourceAppName: sourceAppName,
+ Title: title,
+ Artist: artist,
+ AlbumTitle: albumTitle,
+ ThumbnailBytes: thumbnailBytes,
+ Position: normalizedPosition,
+ Duration: duration,
+ PlaybackStatus: MapPlaybackStatus(playbackStatusRaw),
+ CanPlayPause: canPlayPause,
+ CanSkipPrevious: canSkipPrevious,
+ CanSkipNext: canSkipNext);
+ }
+ catch
+ {
+ return MusicPlaybackState.NoSession(isSupported: true);
+ }
+ finally
+ {
+ _stateGate.Release();
+ }
+ }
+
+ public async Task TogglePlayPauseAsync(CancellationToken cancellationToken = default)
+ {
+ if (!IsRuntimeSupported())
+ {
+ return false;
+ }
+
+ var session = await GetCurrentSessionAsync(cancellationToken);
+ if (session is null)
+ {
+ return false;
+ }
+
+ var playbackInfo = GetPropertyValue(session, "PlaybackInfo") ?? InvokeMethod(session, "GetPlaybackInfo");
+ var controls = GetPropertyValue(playbackInfo, "Controls");
+ var playbackStatusRaw = ReadIntProperty(playbackInfo, "PlaybackStatus");
+
+ object? operation = null;
+ if (playbackStatusRaw == 4 && ReadBoolProperty(controls, "IsPauseEnabled"))
+ {
+ operation = InvokeMethod(session, "TryPauseAsync");
+ }
+ else if (ReadBoolProperty(controls, "IsPlayEnabled"))
+ {
+ operation = InvokeMethod(session, "TryPlayAsync");
+ }
+ else if (ReadBoolProperty(controls, "IsPauseEnabled"))
+ {
+ operation = InvokeMethod(session, "TryPauseAsync");
+ }
+ else
+ {
+ operation = InvokeMethod(session, "TryTogglePlayPauseAsync");
+ }
+
+ return await AwaitBooleanWinRtOperationAsync(operation, cancellationToken);
+ }
+
+ public async Task SkipNextAsync(CancellationToken cancellationToken = default)
+ {
+ if (!IsRuntimeSupported())
+ {
+ return false;
+ }
+
+ var session = await GetCurrentSessionAsync(cancellationToken);
+ if (session is null)
+ {
+ return false;
+ }
+
+ var playbackInfo = GetPropertyValue(session, "PlaybackInfo") ?? InvokeMethod(session, "GetPlaybackInfo");
+ var controls = GetPropertyValue(playbackInfo, "Controls");
+ if (!ReadBoolProperty(controls, "IsNextEnabled"))
+ {
+ return false;
+ }
+
+ return await AwaitBooleanWinRtOperationAsync(InvokeMethod(session, "TrySkipNextAsync"), cancellationToken);
+ }
+
+ public async Task SkipPreviousAsync(CancellationToken cancellationToken = default)
+ {
+ if (!IsRuntimeSupported())
+ {
+ return false;
+ }
+
+ var session = await GetCurrentSessionAsync(cancellationToken);
+ if (session is null)
+ {
+ return false;
+ }
+
+ var playbackInfo = GetPropertyValue(session, "PlaybackInfo") ?? InvokeMethod(session, "GetPlaybackInfo");
+ var controls = GetPropertyValue(playbackInfo, "Controls");
+ if (!ReadBoolProperty(controls, "IsPreviousEnabled"))
+ {
+ return false;
+ }
+
+ return await AwaitBooleanWinRtOperationAsync(InvokeMethod(session, "TrySkipPreviousAsync"), cancellationToken);
+ }
+
+ public async Task LaunchSourceAppAsync(CancellationToken cancellationToken = default)
+ {
+ if (!IsRuntimeSupported())
+ {
+ return false;
+ }
+
+ var session = await GetCurrentSessionAsync(cancellationToken);
+ if (session is null)
+ {
+ return false;
+ }
+
+ var sourceAppId = ReadStringProperty(session, "SourceAppUserModelId");
+ if (string.IsNullOrWhiteSpace(sourceAppId))
+ {
+ return false;
+ }
+
+ return TryOpenSourceApp(sourceAppId);
+ }
+
+ private async Task