From 2e8d15ee7753ab9d833c947b191e4547ce97c01f Mon Sep 17 00:00:00 2001 From: ALIve114514awa <300349280+ALIve114514awa@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:20:02 +0800 Subject: [PATCH 01/28] feat(dotnet-windows): add independent .NET 8 + Avalonia implementation reference - Add NeriPlayer.Windows (.NET 8 + Avalonia) as an independent technology stack reference under dotnet-windows/, parallel to the official Tauri (Rust + Vue) implementation - Does not participate in the root pnpm/cargo build system - Includes solution skeleton, EF Core-ready data layer, playback engine interfaces, core data models and unit tests --- dotnet-windows/.gitignore | 488 ++++++++++++++++++ dotnet-windows/Directory.Build.props | 8 + dotnet-windows/Directory.Packages.props | 40 ++ dotnet-windows/NeriPlayer.Windows.sln | 78 +++ dotnet-windows/README.md | 44 ++ dotnet-windows/src/NeriPlayer.App/App.axaml | 10 + .../src/NeriPlayer.App/App.axaml.cs | 23 + .../src/NeriPlayer.App/AppStartup.cs | 26 + .../src/NeriPlayer.App/MainWindow.axaml | 9 + .../src/NeriPlayer.App/MainWindow.axaml.cs | 11 + .../src/NeriPlayer.App/NeriPlayer.App.csproj | 34 ++ dotnet-windows/src/NeriPlayer.App/Program.cs | 21 + .../src/NeriPlayer.App/app.manifest | 18 + .../src/NeriPlayer.Background/Class1.cs | 6 + .../NeriPlayer.Background.csproj | 19 + dotnet-windows/src/NeriPlayer.Core/Class1.cs | 6 + .../src/NeriPlayer.Core/Logging/AppLogger.cs | 21 + .../NeriPlayer.Core/NeriPlayer.Core.csproj | 18 + .../Player/Model/SongIdentity.cs | 35 ++ .../NeriPlayer.Core/Player/Model/SongItem.cs | 42 ++ dotnet-windows/src/NeriPlayer.Data/Class1.cs | 6 + .../NeriPlayer.Data/NeriPlayer.Data.csproj | 22 + dotnet-windows/src/NeriPlayer.UI/Class1.cs | 6 + .../src/NeriPlayer.UI/NeriPlayer.UI.csproj | 20 + .../NeriPlayer.Api.Tests.csproj | 28 + .../tests/NeriPlayer.Api.Tests/UnitTest1.cs | 10 + .../NeriPlayer.Core.Tests.csproj | 27 + .../SongIdentityTests.cs | 43 ++ .../tests/NeriPlayer.Core.Tests/UnitTest1.cs | 10 + .../NeriPlayer.Data.Tests.csproj | 28 + .../tests/NeriPlayer.Data.Tests/UnitTest1.cs | 10 + 31 files changed, 1167 insertions(+) create mode 100644 dotnet-windows/.gitignore create mode 100644 dotnet-windows/Directory.Build.props create mode 100644 dotnet-windows/Directory.Packages.props create mode 100644 dotnet-windows/NeriPlayer.Windows.sln create mode 100644 dotnet-windows/README.md create mode 100644 dotnet-windows/src/NeriPlayer.App/App.axaml create mode 100644 dotnet-windows/src/NeriPlayer.App/App.axaml.cs create mode 100644 dotnet-windows/src/NeriPlayer.App/AppStartup.cs create mode 100644 dotnet-windows/src/NeriPlayer.App/MainWindow.axaml create mode 100644 dotnet-windows/src/NeriPlayer.App/MainWindow.axaml.cs create mode 100644 dotnet-windows/src/NeriPlayer.App/NeriPlayer.App.csproj create mode 100644 dotnet-windows/src/NeriPlayer.App/Program.cs create mode 100644 dotnet-windows/src/NeriPlayer.App/app.manifest create mode 100644 dotnet-windows/src/NeriPlayer.Background/Class1.cs create mode 100644 dotnet-windows/src/NeriPlayer.Background/NeriPlayer.Background.csproj create mode 100644 dotnet-windows/src/NeriPlayer.Core/Class1.cs create mode 100644 dotnet-windows/src/NeriPlayer.Core/Logging/AppLogger.cs create mode 100644 dotnet-windows/src/NeriPlayer.Core/NeriPlayer.Core.csproj create mode 100644 dotnet-windows/src/NeriPlayer.Core/Player/Model/SongIdentity.cs create mode 100644 dotnet-windows/src/NeriPlayer.Core/Player/Model/SongItem.cs create mode 100644 dotnet-windows/src/NeriPlayer.Data/Class1.cs create mode 100644 dotnet-windows/src/NeriPlayer.Data/NeriPlayer.Data.csproj create mode 100644 dotnet-windows/src/NeriPlayer.UI/Class1.cs create mode 100644 dotnet-windows/src/NeriPlayer.UI/NeriPlayer.UI.csproj create mode 100644 dotnet-windows/tests/NeriPlayer.Api.Tests/NeriPlayer.Api.Tests.csproj create mode 100644 dotnet-windows/tests/NeriPlayer.Api.Tests/UnitTest1.cs create mode 100644 dotnet-windows/tests/NeriPlayer.Core.Tests/NeriPlayer.Core.Tests.csproj create mode 100644 dotnet-windows/tests/NeriPlayer.Core.Tests/SongIdentityTests.cs create mode 100644 dotnet-windows/tests/NeriPlayer.Core.Tests/UnitTest1.cs create mode 100644 dotnet-windows/tests/NeriPlayer.Data.Tests/NeriPlayer.Data.Tests.csproj create mode 100644 dotnet-windows/tests/NeriPlayer.Data.Tests/UnitTest1.cs diff --git a/dotnet-windows/.gitignore b/dotnet-windows/.gitignore new file mode 100644 index 0000000..db327eb --- /dev/null +++ b/dotnet-windows/.gitignore @@ -0,0 +1,488 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from `dotnet new gitignore` + +# dotenv files +.env + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml +.idea + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# Mac bundle stuff +*.dmg +*.app + +# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Vim temporary swap files +*.swp + +# 例外:NeriPlayer.App 项目目录(*.app 规则在 Windows core.ignorecase 下会误伤 .App 目录) +!src/NeriPlayer.App/ + diff --git a/dotnet-windows/Directory.Build.props b/dotnet-windows/Directory.Build.props new file mode 100644 index 0000000..38478f2 --- /dev/null +++ b/dotnet-windows/Directory.Build.props @@ -0,0 +1,8 @@ + + + net8.0 + enable + enable + 12.0 + + diff --git a/dotnet-windows/Directory.Packages.props b/dotnet-windows/Directory.Packages.props new file mode 100644 index 0000000..0bd9fd2 --- /dev/null +++ b/dotnet-windows/Directory.Packages.props @@ -0,0 +1,40 @@ + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet-windows/NeriPlayer.Windows.sln b/dotnet-windows/NeriPlayer.Windows.sln new file mode 100644 index 0000000..9050860 --- /dev/null +++ b/dotnet-windows/NeriPlayer.Windows.sln @@ -0,0 +1,78 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{A35FBA1A-817E-4928-8E3B-14B91BDF0AA9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NeriPlayer.App", "src\NeriPlayer.App\NeriPlayer.App.csproj", "{0A7D4920-CB3D-4C1D-B8EB-90F21EEFCAB7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NeriPlayer.Core", "src\NeriPlayer.Core\NeriPlayer.Core.csproj", "{9686393C-5DD2-40F6-AAF4-F082F2CE1E0A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NeriPlayer.Data", "src\NeriPlayer.Data\NeriPlayer.Data.csproj", "{EB335D8C-A392-40E2-AAAA-3645BAB3017C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NeriPlayer.UI", "src\NeriPlayer.UI\NeriPlayer.UI.csproj", "{9B4E749E-425D-451B-97D2-D0E7A0368A50}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NeriPlayer.Background", "src\NeriPlayer.Background\NeriPlayer.Background.csproj", "{129C49AF-CCDE-4AA5-9CD6-C33AF8FF87E2}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{577D322E-2E0A-4C56-8C1C-CDF7B4F19F4A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NeriPlayer.Core.Tests", "tests\NeriPlayer.Core.Tests\NeriPlayer.Core.Tests.csproj", "{34F1FA07-3E18-4A4C-9E62-909EF7FA5A0B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NeriPlayer.Data.Tests", "tests\NeriPlayer.Data.Tests\NeriPlayer.Data.Tests.csproj", "{210C4D77-5926-4FDC-9B43-A531BF7783DF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NeriPlayer.Api.Tests", "tests\NeriPlayer.Api.Tests\NeriPlayer.Api.Tests.csproj", "{1A9B19AF-1E7C-4A25-BCB8-12FA49A85B43}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {0A7D4920-CB3D-4C1D-B8EB-90F21EEFCAB7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0A7D4920-CB3D-4C1D-B8EB-90F21EEFCAB7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0A7D4920-CB3D-4C1D-B8EB-90F21EEFCAB7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0A7D4920-CB3D-4C1D-B8EB-90F21EEFCAB7}.Release|Any CPU.Build.0 = Release|Any CPU + {9686393C-5DD2-40F6-AAF4-F082F2CE1E0A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9686393C-5DD2-40F6-AAF4-F082F2CE1E0A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9686393C-5DD2-40F6-AAF4-F082F2CE1E0A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9686393C-5DD2-40F6-AAF4-F082F2CE1E0A}.Release|Any CPU.Build.0 = Release|Any CPU + {EB335D8C-A392-40E2-AAAA-3645BAB3017C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EB335D8C-A392-40E2-AAAA-3645BAB3017C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EB335D8C-A392-40E2-AAAA-3645BAB3017C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EB335D8C-A392-40E2-AAAA-3645BAB3017C}.Release|Any CPU.Build.0 = Release|Any CPU + {9B4E749E-425D-451B-97D2-D0E7A0368A50}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9B4E749E-425D-451B-97D2-D0E7A0368A50}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9B4E749E-425D-451B-97D2-D0E7A0368A50}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9B4E749E-425D-451B-97D2-D0E7A0368A50}.Release|Any CPU.Build.0 = Release|Any CPU + {129C49AF-CCDE-4AA5-9CD6-C33AF8FF87E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {129C49AF-CCDE-4AA5-9CD6-C33AF8FF87E2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {129C49AF-CCDE-4AA5-9CD6-C33AF8FF87E2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {129C49AF-CCDE-4AA5-9CD6-C33AF8FF87E2}.Release|Any CPU.Build.0 = Release|Any CPU + {34F1FA07-3E18-4A4C-9E62-909EF7FA5A0B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {34F1FA07-3E18-4A4C-9E62-909EF7FA5A0B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {34F1FA07-3E18-4A4C-9E62-909EF7FA5A0B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {34F1FA07-3E18-4A4C-9E62-909EF7FA5A0B}.Release|Any CPU.Build.0 = Release|Any CPU + {210C4D77-5926-4FDC-9B43-A531BF7783DF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {210C4D77-5926-4FDC-9B43-A531BF7783DF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {210C4D77-5926-4FDC-9B43-A531BF7783DF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {210C4D77-5926-4FDC-9B43-A531BF7783DF}.Release|Any CPU.Build.0 = Release|Any CPU + {1A9B19AF-1E7C-4A25-BCB8-12FA49A85B43}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1A9B19AF-1E7C-4A25-BCB8-12FA49A85B43}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1A9B19AF-1E7C-4A25-BCB8-12FA49A85B43}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1A9B19AF-1E7C-4A25-BCB8-12FA49A85B43}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {0A7D4920-CB3D-4C1D-B8EB-90F21EEFCAB7} = {A35FBA1A-817E-4928-8E3B-14B91BDF0AA9} + {9686393C-5DD2-40F6-AAF4-F082F2CE1E0A} = {A35FBA1A-817E-4928-8E3B-14B91BDF0AA9} + {EB335D8C-A392-40E2-AAAA-3645BAB3017C} = {A35FBA1A-817E-4928-8E3B-14B91BDF0AA9} + {9B4E749E-425D-451B-97D2-D0E7A0368A50} = {A35FBA1A-817E-4928-8E3B-14B91BDF0AA9} + {129C49AF-CCDE-4AA5-9CD6-C33AF8FF87E2} = {A35FBA1A-817E-4928-8E3B-14B91BDF0AA9} + {34F1FA07-3E18-4A4C-9E62-909EF7FA5A0B} = {577D322E-2E0A-4C56-8C1C-CDF7B4F19F4A} + {210C4D77-5926-4FDC-9B43-A531BF7783DF} = {577D322E-2E0A-4C56-8C1C-CDF7B4F19F4A} + {1A9B19AF-1E7C-4A25-BCB8-12FA49A85B43} = {577D322E-2E0A-4C56-8C1C-CDF7B4F19F4A} + EndGlobalSection +EndGlobal diff --git a/dotnet-windows/README.md b/dotnet-windows/README.md new file mode 100644 index 0000000..3b71485 --- /dev/null +++ b/dotnet-windows/README.md @@ -0,0 +1,44 @@ +# NeriPlayer Windows (dotnet-windows) + +> ⚠️ **本目录是独立的 .NET 技术栈实现方案,与仓库根目录的 Tauri (Rust + Vue) 官方实现互不干扰。** + +## 这是什么 + +这是将 NeriPlayer 移植到 Windows 桌面端的 **.NET 8 + Avalonia UI** 实现方案,与官方 `NeriPlayer-Desktop`(Tauri 2 + Rust + Vue 3)为**平行独立的两套技术栈**。 + +- 本目录代码不参与仓库根目录的 `pnpm` / `cargo` 构建体系 +- 保留 3 个历史提交(脚手架 → 核心数据模型 → 对齐修复) +- 作为技术方案参考与对比,供社区评估不同实现路线 + +## 技术栈 + +| 组件 | 选型 | +|------|------| +| 运行时 | .NET 8 LTS | +| UI | Avalonia UI 11.x | +| 播放引擎 | LibVLCSharp 8.x(VLC 3.0.x) | +| 数据库 | EF Core 8 + SQLite | +| 音效 | NAudio (WASAPI) / Biquad 滤波器 | +| 系统集成 | SMTC / 托盘 / Toast | + +## 项目结构 + +``` +src/ +├── NeriPlayer.App/ 主应用入口(Avalonia Desktop) +├── NeriPlayer.Core/ 核心业务层(播放/歌词/下载/策略) +├── NeriPlayer.Data/ 数据层(EF Core / 同步) +├── NeriPlayer.UI/ UI 层(Avalonia 视图) +└── NeriPlayer.Background/ 后台服务(SMTC / 托盘) +tests/ 单元测试(xunit) +``` + +## 构建与运行 + +```powershell +dotnet build NeriPlayer.Windows.sln +dotnet test tests/NeriPlayer.Core.Tests +dotnet run --project src/NeriPlayer.App +``` + +> 详细实施方案见 `Analysis.md`(源码分析 24 章)与 `Process.md`(移植方案 19 章)。 diff --git a/dotnet-windows/src/NeriPlayer.App/App.axaml b/dotnet-windows/src/NeriPlayer.App/App.axaml new file mode 100644 index 0000000..a7ee674 --- /dev/null +++ b/dotnet-windows/src/NeriPlayer.App/App.axaml @@ -0,0 +1,10 @@ + + + + + + + \ No newline at end of file diff --git a/dotnet-windows/src/NeriPlayer.App/App.axaml.cs b/dotnet-windows/src/NeriPlayer.App/App.axaml.cs new file mode 100644 index 0000000..f21d954 --- /dev/null +++ b/dotnet-windows/src/NeriPlayer.App/App.axaml.cs @@ -0,0 +1,23 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; + +namespace NeriPlayer.App; + +public partial class App : Application +{ + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.MainWindow = new MainWindow(); + } + + base.OnFrameworkInitializationCompleted(); + } +} \ No newline at end of file diff --git a/dotnet-windows/src/NeriPlayer.App/AppStartup.cs b/dotnet-windows/src/NeriPlayer.App/AppStartup.cs new file mode 100644 index 0000000..e05ab73 --- /dev/null +++ b/dotnet-windows/src/NeriPlayer.App/AppStartup.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace NeriPlayer.App; + +public static class AppStartup +{ + public static ServiceProvider BuildServices() + { + var services = new ServiceCollection(); + + // 数据层(第四章实现后取消注释) + // services.AddDbContext(); + + // 核心层(第五/七章实现后取消注释) + // services.AddSingleton(); + // services.AddSingleton(); + // services.AddSingleton(); + // services.AddSingleton(); + // services.AddSingleton(); + + // 后台(第九章实现后取消注释) + // services.AddHostedService(); + + return services.BuildServiceProvider(); + } +} diff --git a/dotnet-windows/src/NeriPlayer.App/MainWindow.axaml b/dotnet-windows/src/NeriPlayer.App/MainWindow.axaml new file mode 100644 index 0000000..1110530 --- /dev/null +++ b/dotnet-windows/src/NeriPlayer.App/MainWindow.axaml @@ -0,0 +1,9 @@ + + Welcome to Avalonia! + diff --git a/dotnet-windows/src/NeriPlayer.App/MainWindow.axaml.cs b/dotnet-windows/src/NeriPlayer.App/MainWindow.axaml.cs new file mode 100644 index 0000000..0898b91 --- /dev/null +++ b/dotnet-windows/src/NeriPlayer.App/MainWindow.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace NeriPlayer.App; + +public partial class MainWindow : Window +{ + public MainWindow() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/dotnet-windows/src/NeriPlayer.App/NeriPlayer.App.csproj b/dotnet-windows/src/NeriPlayer.App/NeriPlayer.App.csproj new file mode 100644 index 0000000..7c2aa26 --- /dev/null +++ b/dotnet-windows/src/NeriPlayer.App/NeriPlayer.App.csproj @@ -0,0 +1,34 @@ + + + WinExe + net8.0 + enable + true + app.manifest + true + + + + + + + + + + + None + All + + + + + + + + + + + + + + diff --git a/dotnet-windows/src/NeriPlayer.App/Program.cs b/dotnet-windows/src/NeriPlayer.App/Program.cs new file mode 100644 index 0000000..f38b9d7 --- /dev/null +++ b/dotnet-windows/src/NeriPlayer.App/Program.cs @@ -0,0 +1,21 @@ +using Avalonia; +using System; + +namespace NeriPlayer.App; + +class Program +{ + // Initialization code. Don't use any Avalonia, third-party APIs or any + // SynchronizationContext-reliant code before AppMain is called: things aren't initialized + // yet and stuff might break. + [STAThread] + public static void Main(string[] args) => BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + + // Avalonia configuration, don't remove; also used by visual designer. + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() + .WithInterFont() + .LogToTrace(); +} diff --git a/dotnet-windows/src/NeriPlayer.App/app.manifest b/dotnet-windows/src/NeriPlayer.App/app.manifest new file mode 100644 index 0000000..fc844a6 --- /dev/null +++ b/dotnet-windows/src/NeriPlayer.App/app.manifest @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + diff --git a/dotnet-windows/src/NeriPlayer.Background/Class1.cs b/dotnet-windows/src/NeriPlayer.Background/Class1.cs new file mode 100644 index 0000000..6d3e859 --- /dev/null +++ b/dotnet-windows/src/NeriPlayer.Background/Class1.cs @@ -0,0 +1,6 @@ +namespace NeriPlayer.Background; + +public class Class1 +{ + +} diff --git a/dotnet-windows/src/NeriPlayer.Background/NeriPlayer.Background.csproj b/dotnet-windows/src/NeriPlayer.Background/NeriPlayer.Background.csproj new file mode 100644 index 0000000..d17e478 --- /dev/null +++ b/dotnet-windows/src/NeriPlayer.Background/NeriPlayer.Background.csproj @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + net8.0 + enable + enable + + + diff --git a/dotnet-windows/src/NeriPlayer.Core/Class1.cs b/dotnet-windows/src/NeriPlayer.Core/Class1.cs new file mode 100644 index 0000000..ab66add --- /dev/null +++ b/dotnet-windows/src/NeriPlayer.Core/Class1.cs @@ -0,0 +1,6 @@ +namespace NeriPlayer.Core; + +public class Class1 +{ + +} diff --git a/dotnet-windows/src/NeriPlayer.Core/Logging/AppLogger.cs b/dotnet-windows/src/NeriPlayer.Core/Logging/AppLogger.cs new file mode 100644 index 0000000..432efe6 --- /dev/null +++ b/dotnet-windows/src/NeriPlayer.Core/Logging/AppLogger.cs @@ -0,0 +1,21 @@ +using Serilog; + +namespace NeriPlayer.Core.Logging; + +public static class AppLogger +{ + public static readonly Serilog.Core.Logger Instance = new LoggerConfiguration() + .MinimumLevel.Information() + .WriteTo.Console(outputTemplate: + "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}") + .WriteTo.File( + path: Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "NeriPlayer", "logs", "app-.log"), + rollingInterval: RollingInterval.Day, + retainedFileCountLimit: 14, + outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}") + .CreateLogger(); + + public static void Flush() => Instance.Dispose(); +} diff --git a/dotnet-windows/src/NeriPlayer.Core/NeriPlayer.Core.csproj b/dotnet-windows/src/NeriPlayer.Core/NeriPlayer.Core.csproj new file mode 100644 index 0000000..d0157f3 --- /dev/null +++ b/dotnet-windows/src/NeriPlayer.Core/NeriPlayer.Core.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + diff --git a/dotnet-windows/src/NeriPlayer.Core/Player/Model/SongIdentity.cs b/dotnet-windows/src/NeriPlayer.Core/Player/Model/SongIdentity.cs new file mode 100644 index 0000000..5bcc924 --- /dev/null +++ b/dotnet-windows/src/NeriPlayer.Core/Player/Model/SongIdentity.cs @@ -0,0 +1,35 @@ +using System.Text.RegularExpressions; + +namespace NeriPlayer.Core.Player.Model; + +public static partial class SongIdentity +{ + /// 生成跨版本稳定的歌曲标识:去重、同步、持久化主键(对标 SongIdentity.kt) + public static string StableKey(this SongItem song) + { + if (song.IsLocalSong()) + return $"local|{NormalizePath(song.LocalFilePath ?? song.MediaUri ?? "")}"; + + return song.ChannelId switch + { + "netease" => $"netease|{song.AudioId ?? song.Id.ToString()}", + "bilibili" => $"bilibili|{song.AudioId}|{song.SubAudioId}", + "youtube_music" => $"ytm|{ExtractYouTubeVideoId(song.MediaUri)}", + _ => $"id|{song.Id}|{song.Album}|{song.MediaUri}" + }; + } + + private static string NormalizePath(string p) => + p.Replace('\\', '/').TrimEnd('/').ToLowerInvariant(); + + /// 从 YouTube 链接/播放列表 URI 提取视频 ID + public static string ExtractYouTubeVideoId(string? uri) + { + if (string.IsNullOrEmpty(uri)) return ""; + var m = YoutubeVideoIdRegex().Match(uri); + return m.Success ? m.Groups[1].Value : ""; + } + + [GeneratedRegex(@"(?:v=|youtu\.be/|/shorts/)([A-Za-z0-9_-]{11})")] + private static partial Regex YoutubeVideoIdRegex(); +} diff --git a/dotnet-windows/src/NeriPlayer.Core/Player/Model/SongItem.cs b/dotnet-windows/src/NeriPlayer.Core/Player/Model/SongItem.cs new file mode 100644 index 0000000..7054440 --- /dev/null +++ b/dotnet-windows/src/NeriPlayer.Core/Player/Model/SongItem.cs @@ -0,0 +1,42 @@ +namespace NeriPlayer.Core.Player.Model; + +public enum PlaybackSource { Local, Netease, Bilibili, YouTubeMusic } + +public sealed record SongItem +{ + public long Id { get; init; } + public required string Name { get; init; } + public required string Artist { get; init; } + public required string Album { get; init; } + public long AlbumId { get; init; } + public long DurationMs { get; init; } + public string? CoverUrl { get; init; } + public string? MediaUri { get; init; } + public string? StreamUrl { get; init; } + + public string? ChannelId { get; init; } // local | netease | bilibili | youtube_music + public string? AudioId { get; init; } + public string? SubAudioId { get; init; } + + public string? MatchedLyric { get; init; } + public string? MatchedTranslatedLyric { get; init; } + public PlaybackSource? MatchedLyricSource { get; init; } + public long UserLyricOffsetMs { get; init; } + + public string? CustomName { get; init; } + public string? CustomArtist { get; init; } + public string? CustomCoverUrl { get; init; } + public string? OriginalName { get; init; } + public string? OriginalArtist { get; init; } + + public string? LocalFileName { get; init; } + public string? LocalFilePath { get; init; } + + public long AddedAt { get; init; } + + public string DisplayName => CustomName ?? OriginalName ?? Name; + public string DisplayArtist => CustomArtist ?? OriginalArtist ?? Artist; + + public bool IsLocalSong() => + ChannelId == "local" || (!string.IsNullOrEmpty(LocalFilePath) && ChannelId is null); +} diff --git a/dotnet-windows/src/NeriPlayer.Data/Class1.cs b/dotnet-windows/src/NeriPlayer.Data/Class1.cs new file mode 100644 index 0000000..c579020 --- /dev/null +++ b/dotnet-windows/src/NeriPlayer.Data/Class1.cs @@ -0,0 +1,6 @@ +namespace NeriPlayer.Data; + +public class Class1 +{ + +} diff --git a/dotnet-windows/src/NeriPlayer.Data/NeriPlayer.Data.csproj b/dotnet-windows/src/NeriPlayer.Data/NeriPlayer.Data.csproj new file mode 100644 index 0000000..543cb3b --- /dev/null +++ b/dotnet-windows/src/NeriPlayer.Data/NeriPlayer.Data.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + enable + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + diff --git a/dotnet-windows/src/NeriPlayer.UI/Class1.cs b/dotnet-windows/src/NeriPlayer.UI/Class1.cs new file mode 100644 index 0000000..ca6d9e3 --- /dev/null +++ b/dotnet-windows/src/NeriPlayer.UI/Class1.cs @@ -0,0 +1,6 @@ +namespace NeriPlayer.UI; + +public class Class1 +{ + +} diff --git a/dotnet-windows/src/NeriPlayer.UI/NeriPlayer.UI.csproj b/dotnet-windows/src/NeriPlayer.UI/NeriPlayer.UI.csproj new file mode 100644 index 0000000..46e394e --- /dev/null +++ b/dotnet-windows/src/NeriPlayer.UI/NeriPlayer.UI.csproj @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + net8.0 + enable + enable + + + diff --git a/dotnet-windows/tests/NeriPlayer.Api.Tests/NeriPlayer.Api.Tests.csproj b/dotnet-windows/tests/NeriPlayer.Api.Tests/NeriPlayer.Api.Tests.csproj new file mode 100644 index 0000000..b05270a --- /dev/null +++ b/dotnet-windows/tests/NeriPlayer.Api.Tests/NeriPlayer.Api.Tests.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + diff --git a/dotnet-windows/tests/NeriPlayer.Api.Tests/UnitTest1.cs b/dotnet-windows/tests/NeriPlayer.Api.Tests/UnitTest1.cs new file mode 100644 index 0000000..d6cf3a2 --- /dev/null +++ b/dotnet-windows/tests/NeriPlayer.Api.Tests/UnitTest1.cs @@ -0,0 +1,10 @@ +namespace NeriPlayer.Api.Tests; + +public class UnitTest1 +{ + [Fact] + public void Test1() + { + + } +} \ No newline at end of file diff --git a/dotnet-windows/tests/NeriPlayer.Core.Tests/NeriPlayer.Core.Tests.csproj b/dotnet-windows/tests/NeriPlayer.Core.Tests/NeriPlayer.Core.Tests.csproj new file mode 100644 index 0000000..ad8edd9 --- /dev/null +++ b/dotnet-windows/tests/NeriPlayer.Core.Tests/NeriPlayer.Core.Tests.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + diff --git a/dotnet-windows/tests/NeriPlayer.Core.Tests/SongIdentityTests.cs b/dotnet-windows/tests/NeriPlayer.Core.Tests/SongIdentityTests.cs new file mode 100644 index 0000000..3cec55f --- /dev/null +++ b/dotnet-windows/tests/NeriPlayer.Core.Tests/SongIdentityTests.cs @@ -0,0 +1,43 @@ +using NeriPlayer.Core.Player.Model; +using Xunit; + +namespace NeriPlayer.Core.Tests; + +public class SongIdentityTests +{ + [Fact] + public void LocalSong_StableKey_IsNormalizedPath() + { + var song = new SongItem + { + Id = 1, Name = "A", Artist = "B", Album = "C", + ChannelId = "local", LocalFilePath = @"D:\Music\a\b\c.flac" + }; + Assert.Equal("local|d:/music/a/b/c.flac", song.StableKey()); + } + + [Theory] + [InlineData("https://www.youtube.com/watch?v=abcDEF12345")] + [InlineData("https://youtu.be/abcDEF12345?si=xxx")] + public void YouTube_ExtractVideoId_Works(string uri) + { + var song = new SongItem + { + Id = 2, Name = "A", Artist = "B", Album = "C", + ChannelId = "youtube_music", MediaUri = uri + }; + Assert.Equal("abcDEF12345", SongIdentity.ExtractYouTubeVideoId(uri)); + Assert.Equal("ytm|abcDEF12345", song.StableKey()); + } + + [Fact] + public void Netease_StableKey_UsesAudioId() + { + var song = new SongItem + { + Id = 9, Name = "A", Artist = "B", Album = "C", + ChannelId = "netease", AudioId = "3456789" + }; + Assert.Equal("netease|3456789", song.StableKey()); + } +} diff --git a/dotnet-windows/tests/NeriPlayer.Core.Tests/UnitTest1.cs b/dotnet-windows/tests/NeriPlayer.Core.Tests/UnitTest1.cs new file mode 100644 index 0000000..2d60717 --- /dev/null +++ b/dotnet-windows/tests/NeriPlayer.Core.Tests/UnitTest1.cs @@ -0,0 +1,10 @@ +namespace NeriPlayer.Core.Tests; + +public class UnitTest1 +{ + [Fact] + public void Test1() + { + + } +} \ No newline at end of file diff --git a/dotnet-windows/tests/NeriPlayer.Data.Tests/NeriPlayer.Data.Tests.csproj b/dotnet-windows/tests/NeriPlayer.Data.Tests/NeriPlayer.Data.Tests.csproj new file mode 100644 index 0000000..f539217 --- /dev/null +++ b/dotnet-windows/tests/NeriPlayer.Data.Tests/NeriPlayer.Data.Tests.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + diff --git a/dotnet-windows/tests/NeriPlayer.Data.Tests/UnitTest1.cs b/dotnet-windows/tests/NeriPlayer.Data.Tests/UnitTest1.cs new file mode 100644 index 0000000..0cadd4c --- /dev/null +++ b/dotnet-windows/tests/NeriPlayer.Data.Tests/UnitTest1.cs @@ -0,0 +1,10 @@ +namespace NeriPlayer.Data.Tests; + +public class UnitTest1 +{ + [Fact] + public void Test1() + { + + } +} \ No newline at end of file From 745240a018e12ff0105f44ceec33b72c6732cd1a Mon Sep 17 00:00:00 2001 From: ALIve114514awa <300349280+ALIve114514awa@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:28:47 +0800 Subject: [PATCH 02/28] docs(dotnet-windows): add collaboration workflow rules and reference docs - Add chapter 0 to start.md: every completed step must push to the personal repo and update the upstream PR unless the maintainer rejects - Include Analysis.md / Process.md / start.md reference docs --- dotnet-windows/Analysis.md | 837 ++++++++++ dotnet-windows/Process.md | 1469 ++++++++++++++++ dotnet-windows/README.md | 44 - dotnet-windows/start.md | 3216 ++++++++++++++++++++++++++++++++++++ 4 files changed, 5522 insertions(+), 44 deletions(-) create mode 100644 dotnet-windows/Analysis.md create mode 100644 dotnet-windows/Process.md delete mode 100644 dotnet-windows/README.md create mode 100644 dotnet-windows/start.md diff --git a/dotnet-windows/Analysis.md b/dotnet-windows/Analysis.md new file mode 100644 index 0000000..3e1ba04 --- /dev/null +++ b/dotnet-windows/Analysis.md @@ -0,0 +1,837 @@ +# NeriPlayer 源码深度分析(Analysis) + +> 分析对象:`NeriPlayer-clone/`(git HEAD bc4142bc,上游最新 master,2026-08-12 克隆) +> 规模:770 个 Kotlin 文件 + Native C++ + AGSL Shader + 4 个子模块 +> 对比基准:旧仓库 NeriPlayer(HEAD fd9d70b3) vs 新克隆 NeriPlayer-clone(HEAD bc4142bc) +> 差异规模:60 个文件变更 = 16 新增 + 44 修改 + 0 删除 + 0 重命名,+6225 / -1425 行 +> 技术栈:Jetpack Compose + Media3 (ExoPlayer) + Room + KSP + OkHttp + WebSocket +> 内容:一至十七章为整体功能分析;十八、十九章为新版变化与两仓库差异; +> 二十至二十四章为补充深潜(播放服务/数据模型/网络安全/构建系统/常量速查) +> 合并说明:本文档为完整版,已合并《NeriPlayer-源码分析.md》(第 1-19 章,613 行)全部内容,并扩展第二十至二十四章深度分析。 + +--- + +## 目录 + +1. [项目架构总览](#一项目架构总览) +2. [应用入口与启动链路](#二应用入口与启动链路) +3. [播放核心 PlayerManager](#三播放核心-playermanager) +4. [多源在线播放](#四多源在线播放) +5. [音效系统](#五音效系统) +6. [歌词系统](#六歌词系统) +7. [下载管理系统](#七下载管理系统) +8. [本地音乐管理](#八本地音乐管理) +9. [数据同步(GitHub / WebDAV)](#九数据同步github--webdav) +10. [一起听(Listen Together)](#十一起听listen-together) +11. [UI 系统与流体背景](#十一ui-系统与流体背景) +12. [设置系统(KSP 代码生成)](#十二设置系统ksp-代码生成) +13. [存储与流量统计](#十三存储与流量统计) +14. [安全与崩溃恢复](#十四安全与崩溃恢复) +15. [USB 独占播放(Native)](#十五usb-独占播放native) +16. [桌面小组件与快捷方式](#十六桌面小组件与快捷方式) +17. [测试体系](#十七测试体系) +18. [新版变化深度分析(fd9d70b3 → bc4142bc)](#十八新版变化深度分析fd9d70b3--bc4142bc) +19. [两个仓库差异详细清单](#十九两个仓库差异详细清单fd9d70b3-vs-bc4142bc) +20. [播放服务 AudioPlayerService](#二十播放服务-audioplayerservice-深度解析) +21. [核心数据模型与数据库 Schema](#二十一核心数据模型与数据库-schema) +22. [网络层与安全](#二十二网络层与安全) +23. [构建系统与代码生成](#二十三构建系统与代码生成) +24. [关键常量与策略速查表](#二十四关键常量与策略速查表) + +--- + +## 一、项目架构总览 + +``` +app/src/main/java/moe/ouom/neriplayer/ +├── activity/ Activity 入口与登录页面(QR/WebView) +├── core/ +│ ├── api/ 三平台 API 客户端(netease/bili/youtube)+ 歌词源 + 搜索 +│ ├── crash/ 全局异常处理器 +│ ├── di/ AppContainer 手动 DI 容器 +│ ├── download/ 下载管理器(40+ 文件) +│ ├── lyricon/ Lyricon 歌词服务桥接 +│ ├── player/ 播放核心(PlayerManager + 200+ 策略文件) +│ └── startup/ 启动规划/安全模式 +├── data/ +│ ├── auth/ 三平台 Cookie 认证仓库 +│ ├── backup/ 配置备份与迁移 +│ ├── config/ 配置文件管理 +│ ├── history/ 播放历史 +│ ├── listentogether/ 一起听偏好 +│ ├── local/ Room 数据库 + 本地音频导入 +│ ├── model/ SongItem 核心数据模型 +│ ├── platform/ 平台数据缓存仓库 +│ ├── playlist/ 歌单仓库与使用统计 +│ ├── settings/ 设置仓库 + AutoSettingsSchema +│ ├── stats/ 播放统计 +│ ├── storage/ 存储占用分析 +│ ├── sync/ 同步(github/webdav + 合并策略) +│ └── traffic/ 流量统计 +├── listentogether/ 一起听(WebSocket 会话 + 协议 + 校验) +├── navigation/ 路由定义 + 启动器快捷方式 +├── ui/ Compose 屏幕/组件/主题/效果/ViewModel +├── util/ 工具(崩溃日志/ANR/IO/JSON/网络/平台) +└── widget/ 桌面小组件 +``` + +## 二、应用入口与启动链路 + +### 2.1 NeriPlayerApplication + +`NeriPlayerApplication.onCreate()` 启动流程: + +1. `AppFeedback.initialize()` — 初始化反馈系统 +2. `PlayerManager.bindApplication()` — 尽早绑定 Application 到播放器 +3. 进程分类:`AppProcessClassifier` 判断是否主进程,WebView 目录加后缀区分多进程 +4. 语言初始化:`LanguageManager.init()` +5. 启动规划:`AppStartupPlanner.plan()` 返回是否进入安全模式 +6. ANR 捕获:`AnrWatchdog.capturePreviousAnrIfNeeded()` 捕获上次 ANR +7. 异常处理器:`ExceptionHandler.init()`(含 NativeCrashHandler) + +正常组件初始化(`initializeNormalComponents`): +- `AppContainer.initialize()` 初始化 DI +- 后台预热收藏/历史/统计/缓存仓库 +- `UsbDeviceAttachHandling` 注册 USB 设备接入监听 +- `FloatingLyricsOverlayManager.initialize()` 悬浮歌词 +- `ManagedDownloadStorage.initialize()` 下载存储 +- `YouTubeAuthRotationWorker.schedulePeriodicRotation()` 周期续期 YouTube 会话 +- `GlobalDownloadManager.initialize()` 全局下载 +- `LyriconManager.initialize()`(启用时) + +### 2.2 MainActivity → NeriApp + +`MainActivity` 通过 `setContent` 挂载 `NeriTheme` + `NeriApp`。`NeriApp.kt` 是 UI 根: + +- 启动阶段:`Loading → Disclaimer → Onboarding → Main`(SafeMode 优先) +- 主导航:`NeriBottomBar`(底部 Tab)+ `NeriMiniPlayer` + NowPlaying 覆盖层 +- 主题切换:动态/浅色/深色 + `ThemeRevealOverlay` 主题切换过渡动画 +- USB 前后台恢复:`recoverUsbExclusivePlaybackOnForeground` + +### 2.3 导航 Destinations + +`Destinations.kt` 定义所有路由,主 Tab 为 `home/explore/library/settings`,详情页通过 JSON 参数传递(`{playlistJson}` 等)。Tab 切换由 `MainTabTransitionController` 做可打断的横向换页。 + +## 三、播放核心 PlayerManager + +`PlayerManager` 是 2678 行的 **object 单例**,核心字段与机制: + +### 3.1 状态流(StateFlow) + +| 状态 | 用途 | +|------|------| +| `_currentSongFlow` | 当前歌曲 | +| `_isPlayingFlow` / `_playWhenReadyFlow` | 播放状态 | +| `_playbackPositionMs` / `_playbackDurationMs` | 进度(80ms 更新一次) | +| `_currentQueueFlow` | 播放队列 | +| `_repeatModeFlow` / `_shuffleModeFlow` | 循环/随机 | +| `audioLevelFlow` / `beatImpulseFlow` | 音频可视化(驱动流体背景) | +| `_preferredQualityKeys` | 三平台音质偏好 | + +### 3.2 关键策略参数 + +- `MEDIA_URL_STALE_MS = 10min` — URL 过期自动刷新 +- `URL_REFRESH_COOLDOWN_MS = 10s` — 刷新冷却 +- `MAX_CONSECUTIVE_FAILURES = 10` — 连续失败自动停止 +- `STATE_PERSIST_INTERVAL_MS = 15s` — 状态持久化间隔 +- `DEFAULT_FADE_DURATION_MS = 500ms` — 淡入淡出时长 + +### 3.3 播放流程(playPlaylistImpl) + +``` +检查初始化 → 一起听拦截 → USB高音量确认 → 设置队列/索引 +→ 随机播放时保存恢复快照+乱序 → playAtIndex → 广播 PLAY_PLAYLIST 事件 → 延迟持久化 +``` + +### 3.4 playImpl(恢复播放) + +关键逻辑:若歌曲已预载但 URL 过期(`MEDIA_URL_STALE_MS`)或 YouTube 需要刷新则先 `refreshCurrentSongUrl()`,否则直接 `player.play()`;有暂停中恢复、手动恢复、队列空三种分支,通过 `resolveManualResumePlaybackDecision` 决定是否带位置续播。 + +### 3.5 事件系统 + +`PlaybackCommand(type, source, queue, currentIndex, positionMs)` 通过 SharedFlow 广播,一起听等模块订阅。`PlaybackCommandSource` 区分 `LOCAL` / `REMOTE_SYNC`(一起听来源)。 + +### 3.6 曲目结束去重 + +`handleTrackEndedIfNeededImpl` 使用 `trackEndDeduplicationKey` + 500ms 间隔守卫,防止 ExoPlayer 重复上报曲目结束。 + +## 四、多源在线播放 + +### 4.1 网易云(NeteaseClient) + +- **加密**:`NeteaseCrypto` AES-CBC(固定 key)+ RSA + MD5 签名 +- **Cookie**:OkHttp CookieJar 管理,`MUSIC_U` 判定登录,持久化注入 `__csrf` +- **网络**:共享 OkHttpClient + `DynamicProxySelector`(运行时切换代理)+ Brotli/GZIP 解压 +- **播放失败**:音质降级 → 自动源切换 → 本地兜底 + +### 4.2 Bilibili(BiliClient) + +- **WBI 签名**:`MIXIN_INDEX` 重排参数 → MD5 生成 `w_rid`,含 `wts` 时间戳 +- **反爬三件套**:`spi` 指纹 + WebTicket + 专用 UA(Web/iOS/Firefox 三套) +- **接口**:playurl(WBI)、view、搜索、收藏夹、UP主空间、合集/系列 +- **播放**:`BiliPlaybackRepository` 支持 DASH 音频重试和 html5/mp4 渐进流回退 + +### 4.3 YouTube Music(YouTubeMusicClient) + +- **InnerTube API**:WEB_REMIX 客户端(id=67),continuation 分页,最多 80 页 +- **多级回退链**:登录 Cookie → 匿名 visitor → PoToken → player.js 缓存 → EJS 挑战(JS 求解队列 + WebView 兜底)→ NewPipe 回退 +- **Cookie 轮换**:`YouTubeCookieRotator` + `YouTubeAuthRotationWorker` 周期续期 +- **URL 预热**:登录/匿名都预取 bootstrap,播放前预取队列窗口 + +### 4.4 网易云自动源切换 + +`PlayerManagerNeteaseAutoSourceSwitch.tryResolveNeteaseAutoBiliSource`: +1. 构建查询(`歌名+歌手`、`歌手+歌名`、`歌名`) +2. B 站搜索(追加"无损"关键字) +3. 按歌名/歌手相似度 + 时长打分,阈值 70 +4. 最多 6 候选,2 个后备候选组成播放候选列表 + +## 五、音效系统 + +`PlaybackEffectsController` 统一管理: + +- **倍速/音调**:ExoPlayer `PlaybackParameters` +- **均衡器**:Android `Equalizer` API,预设 + 5 段手动调节,按 audioSessionId 绑定 +- **响度增强**:`LoudnessEnhancer`(mB 增益) +- **声道平衡**:`StereoBalanceAudioProcessor`(自定义 Media3 AudioProcessor) +- **响度归一**:`VolumeNormalizationAudioProcessor` 按歌曲实时分析 +- **32-bit 高解析输出**:`playbackHighResolutionOutputEnabled`,旁路应用内处理 + +`AudioReactive` 提供音频能量/节拍流,驱动流体背景 `uMusicLevel/uBeat`。 + +## 六、歌词系统 + +### 6.1 来源链 + +网易云 lrc/tlrc → QQ音乐(补全源)→ LrcLib → 酷狗 → AMLL TTML → 手动匹配(`EditableLyricsMatcher`) + +### 6.2 解析与缓存 + +- `accompanist-lyrics-core` 解析 LRC/TTML/YRC +- `LruCache`:YouTube 歌词缓存 20 条、网易云歌词缓存 20 条,避免重复请求 +- `EditableLyricSanitizer` 清理标题/制作信息行 + +### 6.3 输出形态 + +| 形态 | 实现 | +|------|------| +| 播放页歌词 | `SyncedLyricsView` / `AdvancedLyricsView`(逐词/逐字高亮+翻译+音译) | +| 悬浮歌词 | `FloatingLyricsOverlayManager` + `WindowManager` 系统窗口 + 长按拖动 | +| 状态栏歌词 | `StatusBarLyricNotificationState` | +| 蓝牙歌词 | `ExternalBluetoothLyrics` 走 AVRCP | +| Lyricon | `LyriconManager` + `SuperLyricHelper` 桥接 | +| 灵动岛 | `dynamicIslandLyricsEnabled` | +| 歌词卡片 | `LyricShareSheet` 生成 1080px 卡片分享 | + +## 七、下载管理系统 + +`GlobalDownloadManager`(object 单例)核心机制: + +- **不用系统 DownloadManager**,用共享 OkHttpClient + Semaphore 并发控制(默认 6,最高 8) +- **三种传输**:直接 HTTP、显式 Range(分块续传)、HLS +- **持久化**:任务队列 `DownloadTaskStore` + 恢复状态 `DownloadRecoveryRoomStore`,重启恢复 +- **目录管理**:应用目录或 SAF 自定义目录,`ManagedDownloadMigration*` 支持迁移 +- **原子写**:`ManagedDownloadAtomicFile` + 工作文件 + sidecar 元数据,崩溃不损坏 +- **标签写入**:`DownloadedAudioTagWriter` 失败 3 次后仍保留音频文件 +- **目录树缓存**:`ManagedDownloadTreeChildCache` 缓存 SAF 子节点,避免频繁查询 +- **快照**:`ManagedDownloadSnapshotDiskCache` 磁盘+内存双层缓存 + +## 八、本地音乐管理 + +`LocalAudioImportManager` 三种导入方式: + +1. **Intent 导入**:响应 `VIEW/SEND/SEND_MULTIPLE` 的 `audio/*` +2. **SAF 文件夹扫描**:`DocumentFile` 递归 + `Os.listdir` 提速 +3. **MediaStore 扫描**:设备媒体库 + +**Sidecar**:自动识别 `.lrc/.txt` 歌词和 `cover/folder/front` 封面并复制。 +**快速预览**:大批量扫描先出 `QuickImportedSongSeed`,后台补全元信息。 + +`LocalPlaylistRepository` + `LocalArtistSummary`: +- 系统歌单(我喜欢/本地文件) +- 歌手按展示艺术家聚合,拆分 `feat./with/和/与/顿号/分号/斜杠` +- 歌手页支持播放全部/多选/导出歌单/批量下载 + +## 九、数据同步(GitHub / WebDAV) + +### 9.1 GitHub + +- `GitHubApiClient` + Git Data API 提交二进制正文 +- `SyncDataSerializer` GZIP + JSON 编码 +- `SyncDataChangeDetector` 变更检测,只在有变更时上传 +- `GitHubSyncUploadPolicy` 上传策略,`SyncUploadRetryExecutor` 重试 +- 冲突:并发分支更新失败报告冲突,不强制覆盖 +- Token 存 `SecureTokenStorage`(加密) + +### 9.2 WebDAV + +`WebDavApiClient` + `WebDavStorage` + 并发回退策略。 + +### 9.3 合并策略 + +`SyncPlaylistSongMergePolicy`(stableKey 去重合并)、`SyncSongMetadataMergePolicy`、`SyncPlaybackStatsMergePolicy`(取最大)、`SyncPlaylistDeletionPolicy`(删除记录同步)、`SyncPlaylistUsageStatsMergePolicy`。 + +### 9.4 触发器 + +`GitHubSyncWorker`/`WebDavSyncWorker` 用 WorkManager 延迟+周期同步,`SyncCoordinator` 互斥锁保证不同时并发。 + +## 十、一起听(Listen Together) + +### 10.1 架构 + +`ListenTogetherSessionManager` + `ListenTogetherWebSocketClient`(OkHttp WebSocket): + +- **协议**:JSON envelope(`ListenTogetherSocketEnvelope`),消息上限 2MB,超限断开(1009) +- **心跳**:`np_ping` 新协议 + `ping` 兼容旧协议 +- **重连**:`ListenTogetherReconnectPolicy`(最多 N 次,指数退避,终端错误不重连) +- **角色**:`ListenTogetherSessionRole`(Controller / Listener) + +### 10.2 播放同步 + +- Controller 上报播放命令(PLAY_PLAYLIST 等)→ Listener 应用 `ListenTogetherPlayerStateApplier` +- 进度同步:`ListenTogetherPlaybackPosition` + 心跳周期上报 +- Listener 停滞恢复:`ListenTogetherListenerStallRecovery` +- 安全暂停:`ListenTogetherListenerSafetyPausePolicy`(音频路由丢失等) +- 防回声:`ListenTogetherControllerEchoPolicy` + `ForwardedRequestDeduper` 去重 +- 漂移保护:`ListenTogetherIncomingStatePolicy` / `RoomStateAcceptance` + +### 10.3 通道映射 + +`ListenTogetherChannels`:`netease` / `bilibili` / `youtubeMusic` / `local`,歌曲用 `stableKey` 跨设备对齐。 + +## 十一、UI 系统与流体背景 + +### 11.1 AGSL 流体背景 + +`BgEffectPainter.java`(API 33+)加载 `assets/shaders/hyper_background_effect.glsl`: + +- `RuntimeShader` 逐帧渲染 +- 5 个色点(`uPoints[5]`)+ 5 组颜色(`uColors[5]`),从封面取色 +- HSV 空间调色(饱和度/亮度偏移) +- 音频响应:`uLevelEase`(位移)、`uBeatEase`(节拍波+径向脉冲)、`uMotionEase`(波动) +- 变焦 `uZoom` + 全局运动 `uGlobalMotion` + 颗粒噪声 + +### 11.2 主要屏幕 + +`NowPlayingScreen`、`LyricsScreen`、`HomeScreen`、`ExploreScreen`、`LibraryScreen`、`SettingsScreen`、各平台详情页、`DownloadManagerScreen`、`PlaybackStatsScreen`、`SafeModeScreen`、调试探针(`*ApiProbeScreen`)。 + +## 十二、设置系统(KSP 代码生成) + +- `ksp-annotations`:`@AutoSettingsCatalog` / `@AutoSetting` / `@AutoSettingsSection` 注解 +- `ksp-processor`:`AutoSettingsProcessorProvider` 生成 `SettingsKeys`、备份白名单、Repository、section 常量 +- `AutoSettingsSchema.kt`:声明式设置登记表(general/播放/下载/歌词/主题/同步等分区) +- 设置项用 `autoSwitchSetting/autoIntSetting/autoSetting` 等 DSL 声明,KSP 自动生成 DataStore 访问代码 + +## 十三、存储与流量统计 + +### 13.1 缓存 + +`SimpleCache + LRU`,默认 1GB(`currentCacheSize`),`CacheSizePolicy` 管理,可分别清理音频/图片/分享/歌单缓存。 + +### 13.2 StorageUsageAnalyzer + +分组统计:音频缓存/图片缓存/下载暂存/分享暂存/平台歌单缓存/下载内容/日志/崩溃报告/核心数据。 + +### 13.3 播放统计 + +`PlaybackStatsTracker`: +- 每 15 秒周期 flush + 关键生命周期 flush +- 听满 30 秒才计 1 次播放(`MIN_LISTEN_MS_FOR_PLAY_COUNT`) +- 位置回绕检测(结尾→开头判定为播完) +- `PlaybackStatsRepository`:Room 主存 + JSON 兼容迁移,记录播放次数/收听时长/每日桶 + +### 13.4 流量统计 + +`TrafficStatsRepository`: +- 区分 WiFi/移动/漫游 + 播放/下载来源 + 缓存命中 +- 每日桶(`dayStartAt`),延迟批量写入 +- 高风险网络下载弹窗提示 + +## 十四、安全与崩溃恢复 + +### 14.1 ExceptionHandler + +- 全局未捕获异常 → 写崩溃报告(进程/PID/线程/栈/ABI)→ 主线程错误弹窗 +- 崩溃前 `UsbExclusiveSessionController.emergencyShutdown()` 紧急释放 USB 设备 +- `NativeCrashHandler` C++ 层崩溃捕获 + +### 14.2 AnrWatchdog + +- 主线程卡顿监测 + 上次 ANR 捕获 +- Safe Mode 记录崩溃/ANR → 下次启动进入 `SafeModeScreen` + +### 14.3 配置备份 + +`BackupManager` + `AppConfigBackup`:完整导出(含设置/授权/同步配置),版本化迁移。 + +## 十五、USB 独占播放(Native) + +### 15.1 分层 + +Kotlin 层(`core/player/usb/`,70+ 文件)+ JNI 桥(`UsbExclusiveNativeBridge`)+ C++(`cpp/usb/`)。 + +### 15.2 C++ 模块 + +| 模块 | 职责 | +|------|------| +| `uac1/` | UAC1.0 格式解析 | +| `uac2/` | UAC2 描述符/时钟图/反馈模型/候选模型 | +| `feedback/` | 显式反馈引擎、时钟捕获、速率估计、包调度 | +| `iso/` | 等时传输窗口与健康度 | +| `pcm/` | PCM 编码管线与播放重放缓冲 | +| `exclusive/` | 桥接、恢复动作闩锁、运行时报告 | + +### 15.3 核心机制 + +- **时钟拓扑解析**:`usb_uac2_clock_graph` + 反馈端点解析 +- **反馈时钟捕获**:`usb_feedback_clock`,长调度间隙后重新捕获 +- **率估计**:`usb_feedback_rate_math` 反馈速率数学 +- **背压恢复**:等时传输健康监测 + 动态扩缩容 + 软恢复 +- **后台锚点**:`UsbExclusiveBackgroundAudioAnchor` 保持 USB 通道,播放停止时静音/零均值载波 +- **看门狗**:播放启动看门狗、前后台健康审计、卡死自动恢复 +- **比特完美**:软件增益 0 dB,DAC 硬件控音量 + +## 十六、桌面小组件与快捷方式 + +### 16.1 小组件 + +`PlaybackWidgetProviders`: +- 4x2 播放卡片(`widget_playback_4x2`)+ 2x2 迷你(`widget_playback_2x2`) +- 控件通过 `ACTION_PLAYBACK_WIDGET_CONTROL` → `AudioPlayerService.dispatchPlaybackWidgetAction` 走播放服务链路 +- 支持 `MY_PACKAGE_REPLACED` 刷新、尺寸变化 `onAppWidgetOptionsChanged` + +### 16.2 启动器快捷方式 + +`LauncherShortcuts.kt`:继续播放、打开探索、打开媒体库、随机播放我喜欢。 + +## 十七、测试体系 + +- `app/src/test/`:单元测试(服务策略、悬浮歌词策略、下载策略、同步合并等) +- `app/src/androidTest/`:设备测试(设置、下载存储、安全模式等) +- `cpp/tests/usb/`:Native host 测试(corpus 语料 + fixtures 轨迹 + 4 ABI 编译验证) +- `tools_pub/ytmusic_api_probe.py`:YouTube 播放兼容探针 +- 关键链路均有对应测试:下载存储、同步合并、YouTube 兼容、一起听、歌词解析、播放策略、配置备份、安全模式 + +--- + +## 十八、新版变化深度分析(fd9d70b3 → bc4142bc) + +> 本次重新克隆自 https://github.com/cwuom/NeriPlayer(--recurse-submodules), +> 相比上一分析版本新增约 60 个文件变更、6225 行新增、1425 行删除。 + +### 18.1 网易云首页推荐系统(全新) + +`NeteaseHomeRecommendations.kt` + `HomeViewModel`(+710 行)构建了完整的首页推荐体系: + +**歌曲来源分区**(`NeteaseHomeSongSource`): +- `TOP_SOARING` 飙升榜 / `TOP_HOT` 热歌榜 / `TOP_NEW` 新歌榜(无需登录) +- `PERSONAL_RADAR` 私人雷达 / `DAILY_RECOMMEND` 日推 / `PRIVATE_FM` 私人FM(需登录) +- `PERSONALIZED_NEW_SONGS` 新歌速递(无需登录) + +**雷达歌单**:5 个固定歌单 ID(时光 5320167908 / 宝藏 5362359247 / 新歌 5300458264 / 乐迷 5327906368 / 神秘 5341776086) + +**歌单来源分区**(`NeteaseHomePlaylistSource`):PERSONALIZED 每日推荐 / DAILY_RESOURCE / HIGH_QUALITY 精品 / HOT 热门 / ACG 分区 + +**关键机制**: +- **登录感知**:`requiresLogin` 标记,未登录自动过滤需要登录的源 +- **去重合并**:`appendUniqueNeteaseHomeSongs` 按 `audioId`/`channelId:id:name` 去重,各分区互不重复 +- **失败回退**:`shouldFallbackRecommend` 对 code 301/50000005 自动回退 +- **预取**:登录 Cookie 变化时自动刷新全部推荐分区 +- 刷新会更新全部分区,含榜单、新歌、日推、私人FM、精品歌单等 + +### 18.2 WaveformSlider 播放进度预测(全新) + +`WaveformSlider.kt`(+137 行)将进度条升级为带播放预测的波形滑块: + +**核心:`WaveProgressPredictor`** +- 由于 UI 进度流是 80ms 更新一次,存在可见延迟 +- Predictor 基于「锚点值 + 歌曲时长 + 当前倍速」逐帧推算实时进度 +- `updateTarget()`:目标变化或开始动画时重置锚点 +- `onFrame(frameNs)`:按已播放时长插值预测当前进度 +- `resetFrameAnchor()`:拖动后重新锚定 + +**状态机**:`isPlaying`(波形动画)/ `isPlaybackWaiting`(等待脉冲动画)/ `isProgressStalled`(停滞检测)/ `isProgressPreviewing`(进度预览)。拖动时暂停动画、结束时重置锚点。 + +### 18.3 存储分析大增强(StorageUsageAnalyzer +677 行) + +`StorageUsageAnalyzer.kt` 重构为 20+ 细粒度类别的分析器: + +**新增类别**(`StorageUsageItemKind`):`DownloadedMusic`(下载音乐)/ `DownloadedLyrics`(下载歌词)/ `DownloadedCovers`(下载封面)/ `DownloadIndex`(下载索引)/ `LocalCovers`(本地封面)/ `CustomBackground`(自定义背景)/ `LegacyMigrationFiles`(遗留迁移文件)/ `Database`(数据库)/ `AppData`(应用数据) + +**数据库占用统计**: +- `DownloadIndexRoomStore`:用 `dbstat`(真实页大小)或 `PRAGMA page_size` 估算 SQLite 表占用 +- `PlatformPlaylistCacheRoomStore`:统计平台歌单缓存三张表的记录数与页字节 +- 行开销 24B + `length(CAST(col AS TEXT))` 逐列估算 + +**清除选项**(`StorageCacheClearOptions`):按平台分别清除(网易云歌单 / B站收藏夹 / B站视频 / YouTube歌单 / 日志 / 崩溃日志),缓存清理不触碰用户下载内容。 + +配套新增 `StorageCacheDetailsContent.kt`(+584 行)详情页和 `SettingsStorageCacheSection` 重构。 + +### 18.4 平台歌单缓存迁移到 Room(从 JSON 到 SQLite) + +新增 `PlatformPlaylistCacheDao` + `PlatformPlaylistCacheRoomStore` + `PlatformPlaylistCacheEntities`: +- 三表结构:cache(元数据)+ tracks(歌曲)+ artists(艺术家,按 trackPosition 关联) +- 事务性读写,`replaceIfNewer` 按 `savedAtMs` 版本控制,避免旧缓存覆盖新缓存 +- 支持按平台批量清除与统计 +- 数据库迁移到 MIGRATION_13_14 + +### 18.5 媒体缓存生命周期与播放恢复增强 + +- `PlayerManagerUrlExtensions`(+281 行):媒体缓存完整性校验、缓存预取准备 +- `PlayerManagerLifecycleExtensions`(+261 行):播放恢复逻辑重构 +- `PlayerManagerStartupWatchdogExtensions`(+49 行):启动看门狗增强 +- `PlayerManagerYouTubePrefetchExtensions`(+35 行):YouTube 预取调整 +- 配套测试:`CachedResourceIntegrityTest`、`PlayerManagerCachePrefetchPreparationTest`、`PlayerManagerYouTubePlaybackRecoveryTest`、`PlayerManagerMediaCacheLifecycleTest` + +### 18.6 下载封面支持与索引简化 + +- `ManagedDownloadCoverLookup`(-77 行):移除可复用封面查询,简化 sidecar 解析(commit #336) +- 下载封面作为独立存储项纳入存储分析 +- `AudioDownloadManager`(-101 行)重构,配合新索引 + +### 18.7 其他增强 + +- `NeteaseClient`(+382 行):新增接口与容错 +- `ExceptionHandler`(+43 行):跨进程 WebView 状态清理(#329) +- `NPLogger`(+37 行):日志能力增强 +- `FileCleanup.kt`:通用批量清理工具(全部成功才返回 true) +- README 更新:首页推荐、存储分析、下载索引描述 + +### 18.8 新增测试 + +| 测试 | 覆盖点 | +|------|--------| +| `NeteaseHomeRecommendationsTest` | 首页推荐解析/去重/登录过滤 | +| `WaveformSliderTest` | 进度预测、拖动、等待动画 | +| `StorageUsageSummaryTest` | 存储分类统计 | +| `StorageCacheDetailsContentTest` | 缓存详情 UI | +| `CachedResourceIntegrityTest` | 缓存完整性 | +| `PlayerManagerMediaCacheLifecycleTest` | 缓存生命周期 | +| `PlayerManagerYouTubePlaybackRecoveryTest` | YouTube 播放恢复 | +| `PlayerManagerCachePrefetchPreparationTest` | 缓存预取 | +| `PlatformPlaylistCacheRoomMigrationTest` | Room 迁移 | +| `ManagedDownloadCoverLookupTest` | 封面查找简化 | +| `NeteaseClientTest` | 网易云客户端 | +| `FileCleanupTest` | 文件清理 | + +--- + +## 十九、两个仓库差异详细清单(fd9d70b3 vs bc4142bc) + +### 19.1 版本与仓库状态对比 + +| 维度 | 旧仓库 `NeriPlayer` | 新克隆 `NeriPlayer-clone` | +|------|--------------------|--------------------------| +| HEAD | fd9d70b3 | bc4142bc(上游最新 master) | +| 状态 | 有本地改动(gradle-wrapper.properties 被改、buildSrc/build-logic 有 untracked 文件) | 干净(刚 clone) | +| 源码 Kotlin 数 | 765 | 770 | +| 子模块 | 1f476060 / 825661a1 / d844e105 / 48bd198a | 完全相同 | +| 构建产物 | 含 build/、.gradle/ 等产物(文件总数 3789) | 无构建产物(文件总数 2432) | +| Native C++ | 60 | 60(完全一致) | + +### 19.2 新增文件(16 个) + +**生产代码(4 个)**: +- `core/player/download/../data/local/database/store/DownloadIndexRoomStore.kt` — 下载索引数据库统计(dbstat / PRAGMA page_size 估算) +- `ui/screen/tab/settings/component/StorageCacheDetailsContent.kt` — 存储缓存详情页 UI +- `ui/viewmodel/tab/NeteaseHomeRecommendations.kt` — 网易云首页推荐(榜单/雷达/日推/私人FM/精品歌单) +- `util/io/FileCleanup.kt` — 通用批量文件清理工具 + +**测试代码(12 个)**:NeteaseClientTest、ManagedDownloadCoverLookupTest、PlayerManagerMediaCacheLifecycleTest、CachedResourceIntegrityTest、PlayerManagerCachePrefetchPreparationTest、PlayerManagerPlaybackCandidateRecoveryTest、StorageUsageSummaryTest、NeteaseHomeRecommendationsTest、FileCleanupTest、StorageUsageResourceTest、PlaylistModernVisualColorsProviderLayoutTest、StorageCacheDetailsContentTest + +### 19.3 核心逻辑差异逐项分析 + +#### (a) 媒体缓存生命周期重构(PlayerManagerLifecycleExtensions +261 行) + +`PlayerManager.cache` 从 `lateinit var` 改为 `@Volatile var cache: Cache? = null`(可空),为缓存重建与旁路让路。 + +**新增 `createVerifiedMediaCache`**: +1. 创建 `SimpleCache` 后调用 `checkInitialization()` 验证 +2. 初始化失败分类: + - 文件夹被锁(`isSimpleCacheFolderLocked`,其他进程的 SimpleCache 实例占用)→ 本次进程旁路缓存继续播放 + - 缓存库损坏(`hasCacheInitializationFailure` 含 `CacheException`)→ 删除并重建缓存目录 + - 重建仍失败 → 旁路缓存播放 +3. 新增 `releaseMediaCache()`:安全释放可空缓存 + +**配套:`PlayerManagerUrlExtensions` 缓存完整性检查** +- 新增 `CachedResourceIntegrity`(isComplete / requiresRepair / coveredLength) +- `inspectCachedResourceSpans`:遍历排序后的 CacheSpan,检测文件缺失、长度不匹配(file.length != span.length)、区间重叠、越界、覆盖间隙;不完整或损坏 → `invalidateCachedResourceForPlaybackRecovery` 失效该缓存 +- 新增 `CachePrefetchReadiness`(COMPLETE / READY_FOR_PREFETCH / UNAVAILABLE)供预取决策 + +#### (b) 播放失败恢复策略扩展(PlayerManagerUrlExtensions) + +`isRecoverableRemotePlaybackCacheError` 从 2 种错误码扩展到 6 种: +``` +BAD_HTTP_STATUS / INVALID_HTTP_CONTENT_TYPE / NETWORK_CONNECTION_TIMEOUT / +NETWORK_CONNECTION_FAILED / READ_POSITION_OUT_OF_RANGE / IO_UNSPECIFIED ++ TIMEOUT(且不是 STUCK_PLAYING_NOT_ENDING 卡死) +``` +- 新增 `StuckPlayerException` 识别:`shouldTreatPlaybackFailureAsTrackEnd` 沿 cause 链查找,区分「卡死未结束」与真实曲目结束 +- 缓存失效策略:远程可恢复错误 → 失效缓存重试;离线缓存格式错误也失效;`shouldInvalidateCacheAfterPlaybackFailure` 保证普通失败不盲目清缓存 + +#### (c) NeteaseClient Cookie 会话管理增强(+382 行) + +- `mergeNeteaseRequestCookies`:三层 Cookie 合并(请求上下文 → 持久化 → 运行时),自动补 `os=pc` / `appver=8.10.35` +- `mergeNeteaseSessionCookies`:仅回填 `NMTID` / `__csrf` 动态会话字段 +- `shouldPreheatNeteaseWeapiSession`:登录态存在但缺 `__csrf` 时预热 WeAPI 会话 +- 新增 `requestContextCookies`:`__remember_me=true` + `_ntes_nuid`(SecureRandom 生成) + `NMTID`(SecureRandom 生成) +- `loadForRequest` 改为基于合并 Cookie 重建 `Cookie.Builder`(hostOnlyDomain),替换原持久的 CookieJar 存储 +- `setPersistedCookies` 增加指纹检测(`authCookieFingerprint`),登录变化时重置预热的 `__csrf` + +#### (d) 下载封面复用简化(commit #336) + +- `AudioDownloadManager` 删除共享封面复用(`findSharedCoverReference`、`rememberSharedCoverReference`、`buildSharedCoverLookupKeys`、`sharedCoverReferencesByLookupKey`) +- `ManagedDownloadCoverLookup` 删除 `findReusableCoverReference`(跨歌曲按 remote cover key / 专辑匹配复用) +- `DownloadedAudioMetadataStore` 不再调用 `findReusableCoverReference` +- 收益:简化 sidecar 解析路径,下载封面改为每个音频独立解析;配合存储分析把「下载封面」作为独立统计项 + +#### (e) 崩溃日志与日志系统加固 + +- `ExceptionHandler`:新增 `crashLogLock` + `crashLogGeneration`(世代计数),写日志前校验世代防竞态;新增 `clearCrashLogs()` 供存储清理(清空目录 + `CrashReportStore.clearPendingCrashReport` + 重开日志文件) +- `NPLogger`:`LogFileEntry` 增加 `generation`,`fileLogGeneration` 世代计数,清日志后旧写任务自动丢弃;同样接入 `clearAllFiles` + +#### (f) 存储分析增强(StorageUsageAnalyzer +677 行) + +- `StorageCacheKind` 从单一 `PlatformList` 拆分为 4 个平台 + 日志 + 崩溃日志 +- 新增 `StorageUsageItemKind` 20+ 细粒度类别(下载音乐/歌词/封面、下载索引、本地封面、自定义背景、遗留迁移文件、数据库、AppData) +- 新增 `DownloadIndexRoomStore` / `PlatformPlaylistCacheRoomStore` 的数据库占用统计(真实页大小或估算) +- 新增 `StorageCacheDetailsContent.kt` 详情页 + `StorageCacheDetailsContentTest` + +#### (g) 首页网易云推荐(NeteaseHomeRecommendations + HomeViewModel +710 行) + +已在第十八章详述;核心要点:动态分区(登录感知)、雷达歌单 5 个固定 ID、`appendUniqueNeteaseHomeSongs` 去重、301/50000005 回退、登录变化自动刷新全部分区。 + +#### (h) WaveformSlider 播放进度预测(+137 行) + +已在第十八章详述;`WaveProgressPredictor` 基于锚点值 + 时长 + 倍速逐帧预测,配套停滞/预览/等待脉冲状态机。 + +#### (i) 其他修改 + +- `AutoSettingsSchema`(+6 行):新增相关设置项 +- `HomeScreen`(+538 行)/ `HomeHostScreen` / `SettingsScreen`(+183 行)/ `SettingsPage` / `SettingsSearchIndex`:UI 重构配合新功能 +- `NowPlayingScreen` / `LyricsScreen`:接入 WaveformSlider +- `NeteaseCollectionDetailViewModel`(+105 行)+ 测试:歌单详情增强 +- `NPLogger` / `ExceptionHandler`:如上 +- `README` / `CONTRIBUTING`:文档同步更新 + +### 19.4 结论 + +- 两个仓库的**子模块完全一致**,Native C++ 完全一致,差异全部在 Kotlin/资源/测试层 +- 核心演进方向:**缓存健壮性**(完整性检查 + 自动重建 + 失效重试)、**网易云会话保鲜**(SecureRandom 会话 Cookie + 会话预热)、**首页推荐**、**存储分析精细化**、**封面解析简化** +- 旧仓库仅有本地未提交改动(构建相关),不含新功能;新克隆 bc4142bc 为干净的最新代码 + +--- + +## 二十、播放服务 AudioPlayerService 深度解析 + +### 20.1 服务架构 + +`AudioPlayerService.kt`(130KB,Kotlin)是一个 **普通 `Service`**(非 `MediaSessionService`),单实例,由 `PlayerManager` 统一驱动。位于 `core/player/service/`。 + +**核心字段**: +- `mediaSession: MediaSession` — `USAGE_MEDIA` + `CONTENT_TYPE_MUSIC` 音频属性 +- `usbExclusiveVolumeProvider: UsbExclusiveLockScreenVolumeProvider` — USB 独占时接管锁屏音量键 +- 封面加载状态机:`currentCoverSongKey` / `currentMediaArtwork` / `currentNotificationLargeIcon` / `artworkLoadJob` / `lastArtworkLoadFailedSource`(封面加载失败去抖) +- `becomingNoisyReceiver: BroadcastReceiver` — 监听耳机拔出(AUDIO_BECOMING_NOISY) + +### 20.2 服务生命周期策略(纯函数策略层) + +服务目录下 9 个策略文件构成完整决策体系: + +| 文件 | 职责 | +|------|------| +| `PlaybackNotificationPolicy` | 通知显示策略 | +| `MediaSessionPlaybackStateThrottler` | MediaSession 状态上报节流:最小更新间隔 1000ms、位置漂移阈值 1500ms | +| `MediaSessionVolumePolicy` | MediaSession 音量策略 | +| `PlaybackServiceIdlePolicy` | 空闲判定(无播放任务时降级) | +| `PlaybackServiceIdleShutdownCoordinator` | 空闲关闭协调:空闲超时(可配置 `playback_service_idle_shutdown_minutes`)后停止服务 | +| `PlaybackServiceRestartPolicy` | 服务重启策略 | +| `FloatingLyricsNotificationPolicy` | 悬浮歌词通知策略 | +| `StatusBarLyricNotificationState` | 状态栏歌词通知状态 | +| `PlayerManagerServiceLifecycleExtensions` | 服务生命周期桥接 PlayerManager | + +### 20.3 关键逻辑 + +**任务移除处理**(`resolveTaskRemovedPlaybackAction`):用户划掉最近任务时,根据是否处于传输活跃态(`isTransportActive`)决定继续前台播放还是停止服务,避免"后台播着歌却没了通知"。 + +**小组件动作分发**(`dispatchPlaybackWidgetAction`): +- 校验动作白名单(`isSupportedPlaybackWidgetAction`) +- 安全模式下拒绝执行 +- 根据服务是否已在前台决定 `startForegroundService` 或 `startService` +- 捕获 Android 12+ 后台启动限制(`isServiceStartNotAllowedFailure`),`IllegalStateException` 优雅降级 + +**封面加载**:`resolveMetadataCoverSource` / `shouldRequestArtworkLoad` / `resolveRemoteMetadataArtworkUri`,远程封面通过 `MediaMetadataRetriever` 或协程加载,失败记录时间戳避免反复请求。 + +**USB 独占联动**:`usbExclusiveKeepAliveIntervalMs` 保活心跳、`shouldReassertUsbExclusiveForeground` 前台重断言、`updateUsbExclusiveBackgroundState` 后台状态同步。 + +## 二十一、核心数据模型与数据库 Schema + +### 21.1 SongItem(平台无关歌曲模型) + +`@Parcelize data class`,覆盖三平台 + 本地,关键字段: +`id/name/artist/album/albumId/durationMs/coverUrl/mediaUri`、 +`matchedLyric/matchedTranslatedLyric/matchedLyricSource/matchedSongId/userLyricOffsetMs`、 +`custom*/original*`(自定义与原始元数据分离)、 +`localFileName/localFilePath`、`channelId/audioId/subAudioId/playlistContextId`、 +`sourceStableKey/streamUrl`、`neteaseArtists`、`syncMembershipTokens` + +**身份标识**:`stableKey()`(跨设备同步主键)、`identity`(专辑/媒体URI身份)、`sameIdentityAs()`(等价判断)。 + +### 21.2 NeriUserDataDatabase(Room) + +- 数据库名:`neri_user_data.db`,仅允许主进程打开 +- **13 次迁移**:`MIGRATION_1_2` → `MIGRATION_13_14`,逐版本演进 +- 15 个 DAO:PlayHistory / PlaybackStats / TrafficStats / LocalPlaylist / PlaylistUsage / LocalPlaylistPlayback / FavoritePlaylist / SyncMetadata / PlaybackQueue / BiliVideoSkip / CoverUrlMapping / DownloadRecovery / DownloadedSongCatalog / DownloadSnapshot / PlatformPlaylistCache +- 15 个 Entity 文件:覆盖历史、统计(含 counter shard 分片)、歌单(含成员 token)、同步(outbox + checkpoint)、播放队列、下载(目录索引/恢复/快照/已下载目录)、平台歌单缓存、B站视频跳过、封面URL映射 + +**统计分片**:`PlaybackStatCounterShardEntity` / `PlaybackStatDailyCounterShardEntity` 用分片计数器缓解高频写放大。 + +**平台歌单缓存表**(新版):cache + tracks + artists 三表,事务性读写 + `replaceIfNewer` 版本控制。 + +## 二十二、网络层与安全 + +### 22.1 共享 OkHttpClient + +`AppContainer` 构建统一 OkHttpClient(`buildSharedOkHttpClient`): +- `connectTimeout` 与 `readTimeout` 均明确配置,`callTimeout = 0`(禁用总时限,防止截断长播放/同步/WebSocket 请求) +- 连接池:`SHARED_HTTP_MAX_IDLE_CONNECTIONS` + `SHARED_HTTP_KEEP_ALIVE_MINUTES` +- `retryOnConnectionFailure(true)` + +播放(三平台音源/歌词)、同步(GitHub/WebDAV)、一起听(WebSocket)共用此客户端。 + +### 22.2 DynamicProxySelector(运行时代理) + +`DynamicProxySelector` 实现 `ProxySelector`,允许**运行时切换代理**(如绕过网络限制),三平台客户端都接入,无需重建 OkHttpClient。 + +### 22.3 安全防护 + +- `SecurityGuards`:通用安全守卫 +- `TrustedHostSupport` / `HostValidation`:受信主机校验(防止 SSRF / 恶意 URL) +- `NetworkExceptions`:网络异常分类(`isTransientHttp2StreamReset` 等瞬时错误识别) +- `OkHttpCallAwait`:OkHttp 调用的协程 await 封装 +- `readBytesLimited`:响应体读取上限(NeteaseClient `MAX_RESPONSE_BYTES = 4MB`) +- 隐私策略:不接入广告/统计/崩溃分析 SDK;同步走用户自有的 GitHub/WebDAV;Cookie/Token 本地存储 + +### 22.4 平台反爬对策 + +| 平台 | 对策 | +|------|------| +| 网易云 | AES-CBC + RSA + MD5 加密参数;三层 Cookie 合并;`os=pc/appver=8.10.35` 指纹 | +| Bilibili | WBI 签名(MIXIN_INDEX 重排 + w_rid);spi 指纹 + WebTicket;三套 UA 分离 | +| YouTube | InnerTube WEB_REMIX;visitor 匿名 + PoToken + player.js 缓存 + EJS 挑战 + NewPipe 回退 | + +## 二十三、构建系统与代码生成 + +### 23.1 Gradle 模块 + +`settings.gradle.kts` 定义多模块 + 3 个源码级子模块(np-submodule): + +| 模块 | 职责 | +|------|------| +| `app` | 主应用(Kotlin + Compose + Native CMake) | +| `ksp-annotations` | KSP 注解定义(@AutoSetting 等) | +| `ksp-processor` | KSP 处理器(自动生成设置代码) | +| `build-logic` / `buildSrc` | Convention 插件(版本目录 libs.versions.toml) | +| `np-submodule/*` | NeriPlayer-LTW / accompanist-lyrics-core/ui / miuix | + +### 23.2 KSP 设置代码生成 + +`AutoSettingsSchema.kt` 声明式登记表 → `AutoSettingsProcessorProvider` 生成: +- `SettingsKeys` 常量 +- `AutoSettingsRepository`(DataStore 访问) +- 设置备份白名单 +- section 常量与 scope + +新增设置只需在 `AutoSettingsSchema` 中声明,KSP 自动生成其余代码。 + +### 23.3 Native 构建 + +- `app/src/main/cpp/CMakeLists.txt` + NDK 编译 +- 依赖 `libusb`(LGPL-2.1)实现 USB 音频独占 +- `usb/` 下:exclusive / feedback / iso / pcm / uac1 / uac2 六模块 +- `tests/usb/`:host 测试(corpus + fixtures,4 ABI) +- JNI 桥:`UsbExclusiveNativeBridge` 连接 Kotlin 与 C++ + +### 23.4 AGSL/GLSL 资源 + +`assets/shaders/`:`hyper_background_effect.glsl`(流体背景)+ 3 个 `advanced_glass_*.agsl`(高级模糊毛玻璃)。 + +## 二十四、关键常量与策略速查表 + +### 24.1 播放核心 + +| 常量 | 值 | 含义 | +|------|-----|------| +| `MEDIA_URL_STALE_MS` | 10 min | 播放 URL 过期自动刷新 | +| `URL_REFRESH_COOLDOWN_MS` | 10 s | URL 刷新冷却 | +| `MAX_CONSECUTIVE_FAILURES` | 10 | 连续播放失败自动停止 | +| `STATE_PERSIST_INTERVAL_MS` | 15 s | 播放状态持久化间隔 | +| `DEFAULT_FADE_DURATION_MS` | 500 ms | 淡入淡出时长 | +| `PLAYBACK_STATS_PERIODIC_FLUSH_MS` | 15 s | 播放统计周期刷盘 | +| `MIN_LISTEN_MS_FOR_PLAY_COUNT` | 30 s | 听满 30s 才计 1 次播放 | +| 进度流更新 | 80 ms | UI 播放进度节流 | +| 曲目结束去重 | 500 ms | 相邻结束事件间隔守卫 | + +### 24.2 下载管理 + +| 常量 | 值 | 含义 | +|------|-----|------| +| 默认并发 / 最大并发 | 6 / 8 | Semaphore 控制 | +| `INITIAL_SCAN_DELAY_MS` | 1500 ms | 启动恢复扫描延迟 | +| `METADATA_WRITE_MAX_ATTEMPTS` | 3 | 标签写入重试上限 | +| `DOWNLOAD_CANCEL_SETTLE_TIMEOUT_MS` | 5000 ms | 取消任务稳定期 | +| 下载目录索引表 | 4 张 | catalog/snapshot/recovery/queue | + +### 24.3 一起听 + +| 常量 | 值 | 含义 | +|------|-----|------| +| WS 消息上限 | 2 MB | 超限断开(code 1009) | +| 心跳 | `np_ping` / `ping` | 新/旧协议兼容 | +| 频道 | netease/bilibili/youtubeMusic/local | 平台通道映射 | +| 重连 | 指数退避 + 终端错误不重连 | `ListenTogetherReconnectPolicy` | + +### 24.4 歌词 + +| 项目 | 值 | 说明 | +|------|-----|------| +| 歌词 LRU 缓存 | 各 20 条 | YouTube / 网易云 | +| 平台偏移 | `DEFAULT_CLOUD_MUSIC_LYRIC_OFFSET_MS` / `DEFAULT_QQ_MUSIC_LYRIC_OFFSET_MS` | 内置歌词补偿 | +| 用户偏移 | `userLyricOffsetMs` | 手动校准 | +| 歌词卡片 | 1080px | 分享卡片宽度 | + +### 24.5 存储与缓存 + +| 项目 | 值 | 说明 | +|------|-----|------| +| 流媒体缓存 | 默认 1GB | `SimpleCache + LRU` | +| 存储类别 | 20+ 项 | StorageUsageItemKind | +| 清理策略 | 只清可再生成缓存 | 不删用户下载 | +| 下载索引行开销 | 24 B | SQLite 估算基准 | + +### 24.6 首页推荐 + +| 项目 | 值 | 说明 | +|------|-----|------| +| 歌曲/歌单上限 | 各 30 | 每个分区 | +| 私人 FM 最大批次 | 10 | 批量获取 | +| 失败告警阈值 | 3 次 | 分区失败提示 | +| 雷达歌单 | 5 个固定 ID | 时光/宝藏/新歌/乐迷/神秘 | +| 推荐回退 | code 301 / 50000005 | 自动回退降级 | + +### 24.7 USB 独占 + +| 项目 | 值 | 说明 | +|------|-----|------| +| 色点/颜色 | 5 | 流体背景 AGSL uniform | +| 看门狗 | 启动 + 前后台 + 卡死 | 三级恢复 | +| 后台锚点 | 静音/零均值载波 | 保持 USB 通道 | +| UAC2 | 显式反馈端点 | 拓扑校验后启用 | + +--- + +## 分析结论总览 + +NeriPlayer 是一个**工程深度极高**的 Android 音乐播放器:770+ Kotlin 文件、60 个 Native C++ 文件、4 个子模块、19 次数据库迁移、13 个数据库迁移版本、KSP 代码生成、AGSL 实时渲染、UAC2 异步 USB 音频链路。它围绕「多源探索、在线播放、本地可控、数据自持」四条主线,把播放健壮性(缓存修复/URL保鲜/失败恢复)、平台合规(WBI/InnerTube/加密参数)、数据自主(本地优先 + 用户自有 GitHub/WebDAV 同步)做到了极致,是研究 Android 音频应用架构的优秀范本。 + +*NeriPlayer 源码深度分析 · 完整版 · 2026-08-12* diff --git a/dotnet-windows/Process.md b/dotnet-windows/Process.md new file mode 100644 index 0000000..5d124b7 --- /dev/null +++ b/dotnet-windows/Process.md @@ -0,0 +1,1469 @@ +# NeriPlayer → Windows 音乐播放器 移植方案(Process) + +> 编制日期:2026-08-12 +> 编制依据:《Analysis.md》(NeriPlayer 源码深度分析 24 章)+ `NeriPlayer-clone/`(HEAD bc4142bc,1748 个 Kotlin 文件) +> 目标平台:Windows 10/11(x64) +> 目标框架:.NET 8 + Avalonia UI +> 预计工期:12 ~ 16 周 + +--- + +## 目录 + +1. [概述与目标](#一概述与目标) +2. [技术栈映射](#二技术栈映射) +3. [项目结构设计](#三项目结构设计) +4. [核心模块实现策略](#四核心模块实现策略) +5. [数据模型与数据库设计](#五数据模型与数据库设计) +6. [播放引擎设计](#六播放引擎设计) +7. [音效系统设计](#七音效系统设计) +8. [API 客户端设计](#八api-客户端设计) +9. [下载管理系统设计](#九下载管理系统设计) +10. [数据同步设计](#十数据同步设计) +11. [UI 设计](#十一ui-设计) +12. [后台服务与系统集成](#十二后台服务与系统集成) +13. [安全与崩溃恢复](#十三安全与崩溃恢复) +14. [测试体系](#十四测试体系) +15. [打包与发布](#十五打包与发布) +16. [分阶段实施计划](#十六分阶段实施计划) +17. [架构简化清单](#十七架构简化清单) +18. [风险与缓解](#十八风险与缓解) +19. [验收标准](#十九验收标准) + +--- + +## 一、概述与目标 + +### 1.1 项目背景 + +NeriPlayer 是一款 Android 端的多源音乐播放器,支持网易云音乐、Bilibili、YouTube Music 三大平台,具备播放核心、下载管理、歌词系统、数据同步(GitHub/WebDAV)、一起听、USB 独占播放等 770+ 个 Kotlin 文件的复杂架构。本方案的目标是将该播放器的**核心能力**迁移至 Windows 桌面端,形成一款功能对等、架构清晰的桌面音乐播放器。 + +### 1.2 产品目标 + +| 目标 | 说明 | 优先级 | +|------|------|--------| +| 多源在线播放 | 网易云 / Bilibili / YouTube Music 在线播放 | P0 | +| 本地音乐管理 | 本地文件夹扫描、元数据解析、播放 | P0 | +| 歌词系统 | LRC/TTML 解析、在线歌词、滚动歌词、翻译 | P0 | +| 音效系统 | 均衡器、立体声平衡、音量归一化、变速变调 | P1 | +| 下载管理 | 多任务并发下载、断点续传、标签写入 | P1 | +| 数据同步 | GitHub / WebDAV 同步歌单与配置 | P1 | +| 高级音频输出 | WASAPI 独占模式、ASIO | P2 | +| 桌面集成 | SMTC 媒体控制、任务栏缩略图、桌面歌词、Toast | P2 | +| 视觉效果 | 流体背景 Shader、毛玻璃、封面取色 | P2 | + +### 1.3 非目标 + +- ❌ 不做一起听(Listen Together)—— WebSocket 多人实时同步过于依赖移动场景 +- ❌ 不做 USB 独占(Native C++)—— Windows 下以 WASAPI 独占替代 +- ❌ 不做桌面小组件(Android Widget)—— Windows 无对等机制,用 SMTC 替代 +- ❌ 不做逐文件 Kotlin 移植 —— 采用架构级移植 + 语言重写 + +--- + +## 二、技术栈映射 + +### 2.1 技术选型总表 + +| NeriPlayer (Android) | Windows 替代方案 | 选型理由 | +|---|---|---| +| Jetpack Compose | **Avalonia UI 11.x** | 声明式 UI + MVVM,跨平台,样式系统强大,原生手感接近 Compose | +| Kotlin / KSP | **C# (.NET 8)** | 类型安全、LINQ、async/await、Source Generators 替代 KSP | +| Media3 ExoPlayer | **LibVLCSharp 8.x** | 全格式解码(mp3/flac/aac/opus/ogg/hls),流媒体支持完善 | +| Room (SQLite) | **EF Core 8 + SQLite** | ORM 成熟,迁移机制(Migrations)对应 Room 的 13 版迁移 | +| OkHttp | **HttpClient + Refit** | 原生 .NET HTTP 栈,Refit 提供接口式 REST 客户端 | +| Kotlinx Coroutines/Flow | **System.Reactive (Rx.NET)** | 响应式流处理,对应 Flow 语义 | +| DataStore Preferences | **JSON 配置 + Microsoft.Extensions.Configuration** | 结构化配置,易于手动编辑与同步 | +| WorkManager | **BackgroundService + Quartz.NET** | Windows 服务后台任务调度 | +| Android MediaSession | **SystemMediaTransportControls (SMTC)** | Windows 系统级媒体控制标准接口 | +| AGSL/GLSL Shader | **HLSL + SkiaSharp / Win2D** | GPU 流体背景与模糊效果 | +| KSP 设置代码生成 | **C# Source Generators** | 编译期代码生成,等价替代 KSP | +| JNI Native(USB 独占) | **NAudio WasapiOut(Exclusive)** | WASAPI 独占模式替代 USB 独占音频 | +| androidx.security (加密存储) | **DPAPI + AES-GCM (System.Security.Cryptography)** | Windows 本地凭据保护 | +| Coil (图片加载) | **AsyncImage + SkiaSharp 缓存** | 图片加载与磁盘缓存 | +| TinyPinyin(拼音排序) | **Microsoft.International.Converters.PinYinConverter** | 汉字拼音转换排序 | +| TagLib(标签写入) | **TagLib# 2.3.x** | 音频标签读写 | +| org.json / kotlinx.serialization | **System.Text.Json** | 标准 JSON 库 | + +### 2.2 开发环境 + +| 项目 | 版本 | +|---|---| +| OS | Windows 10 22H2 或 Windows 11 | +| IDE | Visual Studio 2022 17.8+(Community 版即可) | +| .NET SDK | .NET 8 LTS | +| Avalonia | 11.0.x | +| LibVLC | 3.0.x(x64) | +| Git | 任意较新版本 | + +### 2.3 NuGet 依赖清单 + +```xml + + + + + + + + + + + + + + + + + + + + + + + +``` + +--- + +## 三、项目结构设计 + +### 3.1 解决方案结构 + +``` +NeriPlayer.Windows/ +├── NeriPlayer.Windows.sln +├── Directory.Build.props # 全局编译属性 +├── Directory.Packages.props # 中央包版本管理 +├── NuGet.config +├── README.md +│ +├── src/ +│ ├── NeriPlayer.App/ # 主应用入口(Avalonia Desktop) +│ │ ├── Program.cs # Main 入口 + DI 装配 +│ │ ├── App.axaml / App.axaml.cs # 应用定义、异常捕获 +│ │ ├── AppStartup.cs # 启动规划(安全模式) +│ │ ├── app.manifest # 高 DPI、Windows 声明 +│ │ └── Resources/ # 图标 / 全局样式 +│ │ +│ ├── NeriPlayer.Core/ # 核心业务层(不依赖 UI) +│ │ ├── Player/ # 播放核心 +│ │ │ ├── PlayerManager.cs # 播放总控(状态机+事件) +│ │ │ ├── Engine/ +│ │ │ │ ├── IPlaybackEngine.cs +│ │ │ │ ├── VlcPlaybackEngine.cs # LibVLC 封装 +│ │ │ │ ├── ExclusivePlaybackEngine.cs # WASAPI 独占 +│ │ │ │ └── EngineException.cs +│ │ │ ├── Effects/ # 音效 +│ │ │ │ ├── PlaybackEffectsController.cs +│ │ │ │ ├── EqualizerEffect.cs +│ │ │ │ ├── StereoBalanceEffect.cs +│ │ │ │ ├── VolumeNormalizationEffect.cs +│ │ │ │ ├── SpeedPitchEffect.cs +│ │ │ │ └── FftAnalyzer.cs # 频谱数据 +│ │ │ ├── Lyrics/ # 歌词 +│ │ │ │ ├── LyricsProvider.cs # 聚合歌词源 +│ │ │ │ ├── LrcParser.cs +│ │ │ │ ├── TtmlParser.cs +│ │ │ │ ├── LyricTimeline.cs +│ │ │ │ └── LyricSearchMatcher.cs +│ │ │ ├── Playlist/ +│ │ │ │ ├── PlaylistManager.cs +│ │ │ │ └── ShuffleEngine.cs +│ │ │ ├── Timer/ +│ │ │ │ └── SleepTimer.cs +│ │ │ ├── Persistence/ +│ │ │ │ ├── PlayerStatePersistence.cs # 播放状态持久化 +│ │ │ │ └── PlayerStateSnapshot.cs +│ │ │ ├── Policy/ # 策略层(合并 23 个策略包) +│ │ │ │ ├── PlaybackFailurePolicy.cs # 连续失败处理 +│ │ │ │ ├── MediaUrlRefreshPolicy.cs # URL 保鲜 10min +│ │ │ │ ├── TrackEndDedupPolicy.cs # 曲目结束去重 +│ │ │ │ ├── PendingMediaLoadPolicy.cs +│ │ │ │ ├── ProgressUpdatePolicy.cs # 进度节流 +│ │ │ │ └── PlaybackCommandPolicy.cs +│ │ │ └── Model/ +│ │ │ ├── SongItem.cs +│ │ │ ├── PlaybackAudioInfo.cs +│ │ │ ├── PlayerEvent.cs +│ │ │ ├── PlayerQueueDisplayState.cs +│ │ │ └── AudioDevice.cs +│ │ │ +│ │ ├── Api/ # API 客户端 +│ │ │ ├── Common/ +│ │ │ │ ├── IPlatformClient.cs # 平台统一接口 +│ │ │ │ ├── PlatformResult.cs +│ │ │ │ └── HttpClientFactory.cs +│ │ │ ├── Netease/ +│ │ │ │ ├── NeteaseClient.cs +│ │ │ │ ├── NeteaseCrypto.cs # WBI/AES/RSA +│ │ │ │ ├── NeteasePlaylistApi.cs +│ │ │ │ ├── NeteaseSongUrlApi.cs +│ │ │ │ ├── NeteaseLyricApi.cs +│ │ │ │ └── NeteaseQrLoginClient.cs +│ │ │ ├── Bili/ +│ │ │ │ ├── BiliClient.cs +│ │ │ │ ├── BiliAudioUrlApi.cs +│ │ │ │ └── BiliLyricApi.cs +│ │ │ ├── YouTube/ +│ │ │ │ ├── YouTubeMusicClient.cs +│ │ │ │ ├── YouTubePlayerScriptStore.cs # player.js 缓存+PoToken +│ │ │ │ └── YouTubeEjsChallengeSolver.cs +│ │ │ ├── Lyrics/ # 歌词源 +│ │ │ │ ├── KugouLyricsClient.cs +│ │ │ │ ├── LrcLibClient.cs +│ │ │ │ └── LyricsSourceAggregator.cs +│ │ │ └── Search/ +│ │ │ ├── SearchManager.cs +│ │ │ └── SearchResultMerger.cs +│ │ │ +│ │ ├── Download/ # 下载管理 +│ │ │ ├── DownloadManager.cs +│ │ │ ├── DownloadQueue.cs +│ │ │ ├── DownloadTask.cs +│ │ │ ├── MetadataWriter.cs # TagLib# 封装 +│ │ │ └── DownloadDirectoryIndexer.cs # catalog/snapshot +│ │ │ +│ │ ├── Diagnostics/ # 崩溃与安全模式 +│ │ │ ├── ExceptionHandler.cs +│ │ │ ├── CrashReporter.cs +│ │ │ ├── AppStartupPlanner.cs +│ │ │ └── SafeModeManager.cs +│ │ │ +│ │ └── Logging/ +│ │ └── AppLogger.cs # Serilog 封装 +│ │ +│ ├── NeriPlayer.Data/ # 数据层 +│ │ ├── Database/ +│ │ │ ├── NeriDbContext.cs +│ │ │ ├── DbSeeder.cs +│ │ │ └── Migrations/ # EF Core Migrations +│ │ ├── Entities/ # EF Core 实体(对应 Room @Entity) +│ │ │ ├── SongEntity.cs +│ │ │ ├── PlaylistEntity.cs +│ │ │ ├── PlaylistMemberEntity.cs +│ │ │ ├── PlayHistoryEntity.cs +│ │ │ ├── PlaybackQueueEntity.cs +│ │ │ ├── PlaybackStatsEntity.cs +│ │ │ ├── DownloadEntity.cs +│ │ │ ├── DownloadSnapshotEntity.cs +│ │ │ ├── SyncMetadataEntity.cs +│ │ │ ├── TrafficStatsEntity.cs +│ │ │ ├── CoverUrlMappingEntity.cs +│ │ │ └── SettingsEntity.cs +│ │ ├── Repositories/ # 仓储模式 +│ │ │ ├── SongRepository.cs +│ │ │ ├── PlaylistRepository.cs +│ │ │ ├── PlayHistoryRepository.cs +│ │ │ ├── DownloadRepository.cs +│ │ │ ├── PlaybackStatsRepository.cs +│ │ │ ├── SyncMetadataRepository.cs +│ │ │ └── SettingsRepository.cs +│ │ ├── LocalMedia/ # 本地音乐管理 +│ │ │ ├── LocalMusicScanner.cs # 文件夹扫描 +│ │ │ ├── TagMetadataReader.cs # 标签读取 +│ │ │ └── LocalSongLibraryManager.cs +│ │ ├── Sync/ +│ │ │ ├── ISyncProvider.cs +│ │ │ ├── GitHubSyncProvider.cs +│ │ │ ├── WebDavSyncProvider.cs +│ │ │ ├── SyncMergeStrategy.cs +│ │ │ └── SyncCoordinator.cs +│ │ ├── Settings/ +│ │ │ ├── SettingsManager.cs +│ │ │ ├── SettingsSchema.cs # Source Generator 输入 +│ │ │ └── SettingsSection.cs +│ │ ├── Auth/ +│ │ │ ├── CredentialStore.cs # DPAPI 加密 +│ │ │ ├── CookieStore.cs +│ │ │ └── LoginStateManager.cs +│ │ └── Traffic/ +│ │ └── TrafficStatsService.cs +│ │ +│ ├── NeriPlayer.UI/ # UI 层(Avalonia) +│ │ ├── Views/ +│ │ │ ├── MainWindow.axaml # 主窗口(三栏布局) +│ │ │ ├── NowPlayingView.axaml # 正在播放 +│ │ │ ├── LyricsView.axaml # 歌词页 +│ │ │ ├── LibraryView.axaml # 音乐库 +│ │ │ ├── PlaylistView.axaml # 歌单详情 +│ │ │ ├── SearchView.axaml # 搜索 +│ │ │ ├── DiscoverView.axaml # 首页推荐 +│ │ │ ├── DownloadsView.axaml # 下载中心 +│ │ │ ├── SettingsView.axaml # 设置 +│ │ │ ├── EqualizerView.axaml # 均衡器 +│ │ │ └── LoginView.axaml # 登录(QR) +│ │ ├── ViewModels/ +│ │ │ ├── MainWindowViewModel.cs +│ │ │ ├── NowPlayingViewModel.cs +│ │ │ ├── LibraryViewModel.cs +│ │ │ ├── PlaylistViewModel.cs +│ │ │ ├── SearchViewModel.cs +│ │ │ ├── DiscoverViewModel.cs +│ │ │ ├── DownloadsViewModel.cs +│ │ │ ├── SettingsViewModel.cs +│ │ │ └── PlayerBarViewModel.cs # 底部播放条 +│ │ ├── Controls/ +│ │ │ ├── PlayerBar.axaml # 底部播放控制条 +│ │ │ ├── SongCard.axaml +│ │ │ ├── AlbumArtControl.axaml # 旋转封面 +│ │ │ ├── FluidBackground.axaml # 流体背景(SkiaShader) +│ │ │ ├── LyricsScroller.axaml # 歌词滚动 +│ │ │ ├── WaveformVisualizer.axaml # 频谱可视化 +│ │ │ ├── CircularProgress.axaml +│ │ │ └── ToastControl.axaml +│ │ ├── Themes/ +│ │ │ ├── ColorPalette.cs # 动态取色 +│ │ │ ├── ThemeManager.cs +│ │ │ └── Styles/ # Fluent 主题覆盖 +│ │ ├── Converters/ +│ │ │ ├── TimeSpanConverter.cs +│ │ │ ├── CoverUrlConverter.cs +│ │ │ └── BoolToVisibilityConverter.cs +│ │ ├── Effects/ +│ │ │ ├── ShaderBackgroundRenderer.cs # Skia 流体 +│ │ │ └── AcrylicBlurEffect.cs +│ │ └── Services/ +│ │ ├── ImageCacheService.cs +│ │ └── WindowManager.cs +│ │ +│ ├── NeriPlayer.Background/ # 后台服务 +│ │ ├── PlaybackBackgroundService.cs # 无窗口播放 +│ │ ├── SmtcIntegration.cs # 系统媒体控制 +│ │ ├── TaskbarThumbnailButtons.cs # 任务栏缩略图 +│ │ ├── FloatingLyricsWindow.cs # 桌面歌词 +│ │ ├── SyncScheduledService.cs # 定时同步 +│ │ └── Notifications/ +│ │ └── ToastNotificationService.cs +│ │ +│ └── NeriPlayer.SourceGen/ # C# Source Generators +│ ├── SettingsGenerator.cs # @AutoSetting 等价物 +│ └── SettingsKeysGenerator.cs +│ +└── tests/ + ├── NeriPlayer.Core.Tests/ # 核心逻辑单元测试 + ├── NeriPlayer.Data.Tests/ # 数据库集成测试 + └── NeriPlayer.Api.Tests/ # API 解析测试(Mock HTTP) +``` + +### 3.2 依赖方向 + +``` +NeriPlayer.App → NeriPlayer.Core, NeriPlayer.Data, NeriPlayer.UI, NeriPlayer.Background +NeriPlayer.UI → NeriPlayer.Core, NeriPlayer.Data +NeriPlayer.Background → NeriPlayer.Core, NeriPlayer.Data +NeriPlayer.Core → (无 UI/Data 依赖,纯领域逻辑) +NeriPlayer.Data → (EF Core、Refit,不依赖 UI) +NeriPlayer.SourceGen → (仅编译期,不参与运行时依赖) +``` + +--- + +## 四、核心模块实现策略 + +### 4.1 PlayerManager 总控(对标 `core/player/PlayerManager.kt`) + +PlayerManager 是 NeriPlayer 的中枢,负责:播放状态机、URL 解析、持久化、策略分发、事件广播。 + +```csharp +public sealed class PlayerManager : IPlayerManager, IDisposable +{ + // 常量对标 Analysis.md 24.1 节 + private static readonly TimeSpan MediaUrlStale = TimeSpan.FromMinutes(10); // MEDIA_URL_STALE_MS + private static readonly TimeSpan UrlRefreshCooldown = TimeSpan.FromSeconds(10); // URL_REFRESH_COOLDOWN_MS + private const int MaxConsecutiveFailures = 10; // 连续失败上限 + private static readonly TimeSpan StatePersistInterval = TimeSpan.FromSeconds(15); + private static readonly TimeSpan DefaultFadeDuration = TimeSpan.FromMilliseconds(500); + private static readonly TimeSpan ProgressThrottle = TimeSpan.FromMilliseconds(80); + + // 状态流(StateFlow 等价物 → IObservable) + public IObservable State { get; } + public IObservable CurrentSong { get; } + public IObservable Position { get; } + public IObservable AudioInfo { get; } + public IObservable Events { get; } + + // 命令入口(对标 PlaybackCommand 策略) + public Task PlayAsync(IReadOnlyList playlist, int startIndex, PlaybackCommandSource source); + public Task PauseAsync(PlaybackCommandSource source); + public Task ResumeAsync(PlaybackCommandSource source); + public Task StopAsync(bool persist = true); + public Task NextAsync(bool auto = false); + public Task PreviousAsync(); + public Task SeekAsync(TimeSpan position); + public Task SetVolumeAsync(float volume); + public Task SetRepeatModeAsync(RepeatMode mode); + public Task ToggleShuffleAsync(); + public Task SetPlaybackSpeedAsync(float speed); + public Task SetPitchAsync(float pitch); + public Task SetStereoBalanceAsync(float balance); + public Task SetEqualizerBandsAsync(IReadOnlyList gains); + + // 内部策略管道 + private readonly IPlaybackFailurePolicy _failurePolicy; + private readonly IMediaUrlRefreshPolicy _urlRefreshPolicy; + private readonly ITrackEndDedupPolicy _trackEndDedup; + private readonly IProgressUpdatePolicy _progressPolicy; + private readonly IPlayerStatePersistence _persistence; +} +``` + +### 4.2 播放状态机 + +``` +[IDLE] --Play--> [LOADING] --Prepared--> [PLAYING] --Pause--> [PAUSED] + ^ | | | + | |--Failure(超限)-->[ERROR]| | + | | +--Seek--> | + +--Stop--[STOPPED]--+ +--Next-->[LOADING] +``` + +| 行为 | 实现要点 | +|------|----------| +| URL 保鲜 | 播放中每 10min 检查 URL 过期,后台刷新(冷却 10s 防抖) | +| 连续失败保护 | 连续 10 次失败自动停止并广播事件(MAX_CONSECUTIVE_FAILURES) | +| 淡入淡出 | 播放/暂停时 500ms 线性淡变(DEFAULT_FADE_DURATION_MS) | +| 曲目结束去重 | 相邻结束事件 500ms 间隔守卫(TrackEndDeduplication) | +| 进度节流 | 进度流 80ms 节流 + 2s 桶内去重(ProgressUpdatePolicy) | +| 状态持久化 | 15s 周期 + 命令触发即时持久化(STATE_PERSIST_INTERVAL_MS) | +| 播放计数 | 单曲听满 30s 才记 1 次(MIN_LISTEN_MS_FOR_PLAY_COUNT) | + +### 4.3 播放引擎封装 + +```csharp +public interface IPlaybackEngine : IDisposable +{ + Task LoadAsync(Uri mediaUri, PlaybackEngineOptions options); + Task PlayAsync(); + Task PauseAsync(); + Task SeekAsync(TimeSpan position); + Task SetVolumeAsync(float volume); // 0.0 ~ 1.0 + Task SetRateAsync(float speed); // 变速 + IObservable Events { get; } + + // 音效管道 + void ApplyEqualizer(IReadOnlyList gains); // 10-band + void ApplyStereoBalance(float balance); // -1.0 ~ 1.0 + void ApplyVolumeNormalization(float gainDb); + void ApplyPitch(float semitones); + + // 信息 + TimeSpan Duration { get; } + IObservable Position { get; } + IObservable FftData { get; } // 可视化 +} +``` + +实现类: + +1. **VlcPlaybackEngine** —— 默认引擎 + - LibVLCSharp 封装,支持 mp3/flac/aac/opus/wav/hls/http + - `MediaPlayer` + `Equalizer`(自带 10-band) + - FFT 通过独立读取 PCM 或 VLC 可视化数据实现 +2. **ExclusivePlaybackEngine** —— WASAPI 独占模式 + - NAudio `WasapiOut`(`AudioClientShareMode.Exclusive`) + - 采样率跟随源文件(最高 192kHz) + - 独立音效处理链(ISampleProvider 管道) + - 输出格式自动协商 + 失败回退共享模式 + +### 4.4 音效处理管道 + +``` +MediaSource → Decoder → Resampler → [EQ] → [Balance] → [Normalizer] → [Pitch] → [Reverb] → WasapiOut + ↓ + FFT → Waveform/频谱 +``` + +| 效果 | 对标 NeriPlayer | 实现 | +|------|----------------|------| +| 均衡器 | VLC EQ | VLC Equalizer 或 BIQUAD 滤波器组(10 band) | +| 立体声平衡 | `StereoBalanceAudioProcessor.kt` | NAudio ISampleProvider:(L+R)/2 混音 | +| 音量归一化 | `VolumeNormalizationAudioProcessor.kt` | EBU R128 响度测量 + 增益补偿 | +| 变速 | `normalizePlaybackSpeed` | VLC SetRate(保持音高) | +| 变调 | `normalizePlaybackPitch` | SoundTouch 算法 | +| 混响 | `PlaybackEffectsController` | Freeverb 算法实现 | +| 可视化 | `AudioReactive.kt` | FFT(Cooley-Tukey)+ 平滑处理 | + +### 4.5 策略模式(对标 NeriPlayer 200+ 策略文件 → 简化) + +| 原版策略包 | 合并后策略类 | 职责 | +|------------|-------------|------| +| `policy/failure` | `PlaybackFailurePolicy` | 连续失败计数、停止阈值、错误分类 | +| `policy/refresh` | `MediaUrlRefreshPolicy` | URL 过期检测、刷新冷却、在途请求去重 | +| `policy/skip` | `VideoSkipPolicy` | 跳过/可裁剪段(Bilibili) | +| `policy/progress` | `ProgressUpdatePolicy` | 进度节流、长音频特殊处理 | +| `policy/pending` | `PendingMediaLoadPolicy` | 待播放媒体位置恢复 | +| `policy/command` | `PlaybackCommandPolicy` | 命令源校验(UI/SMTC/快捷键) | +| `policy/usb/*`(23 个) | `ExclusiveOutputPolicy` | WASAPI 设备路由、失败回退、缓冲 | +| `policy/wake/*` | 无需(Windows 无 WakeLock) | — | +| `policy/offload/*` | 无需(无硬件解码 offload 概念) | — | + +--- + +## 五、数据模型与数据库设计 + +### 5.1 SongItem(对标 `data/model/SongItem.kt`) + +```csharp +public sealed record SongItem +{ + public long Id { get; init; } // 本地自增 ID + public required string Name { get; init; } + public required string Artist { get; init; } + public required string Album { get; init; } + public long AlbumId { get; init; } + public long DurationMs { get; init; } + public string? CoverUrl { get; init; } + public string? MediaUri { get; init; } // 原始媒体 URI + public string? StreamUrl { get; init; } // 已解析的流地址 + + // 平台标识(对标 channelId / audioId / subAudioId) + public string? ChannelId { get; init; } // "local"|"netease"|"bilibili"|"youtube_music" + public string? AudioId { get; init; } + public string? SubAudioId { get; init; } + + // 歌词 + public string? MatchedLyric { get; init; } + public string? MatchedTranslatedLyric { get; init; } + public PlaybackSource? MatchedLyricSource { get; init; } + public long UserLyricOffsetMs { get; init; } + + // 自定义元数据 + public string? CustomName { get; init; } + public string? CustomArtist { get; init; } + public string? CustomCoverUrl { get; init; } + public string? OriginalName { get; init; } + public string? OriginalArtist { get; init; } + + // 本地文件 + public string? LocalFileName { get; init; } + public string? LocalFilePath { get; init; } + + // 同步 + public List? SyncMembershipTokens { get; init; } + public long AddedAt { get; init; } +} + +public enum PlaybackSource +{ + Local, + Netease, + Bilibili, + YouTubeMusic, +} +``` + +### 5.2 StableKey 算法(对标 `SongIdentity.kt`) + +```csharp +public static class SongIdentity +{ + /// 生成跨版本稳定的歌曲标识,用于去重、同步、持久化 + public static string StableKey(this SongItem song) + { + // 本地文件:规范化绝对路径 + if (song.IsLocalSong()) + return $"local|{NormalizePath(song.LocalFilePath ?? song.MediaUri)}"; + + // 远程歌曲:平台 + 音频ID + return song.ChannelId switch + { + "netease" => $"netease|{song.AudioId ?? song.Id.ToString()}", + "bilibili" => $"bilibili|{song.AudioId}|{song.SubAudioId}", + "youtube_music" => $"ytm|{ExtractYouTubeVideoId(song.MediaUri)}", + _ => $"id|{song.Id}|{song.Album}|{song.MediaUri}" + }; + } +} +``` + +### 5.3 数据库 Schema(对标 Room 13 版迁移) + +``` +┌──────────────────────────────────────────┐ +│ neriplayer.db │ +├──────────────────────────────────────────┤ +│ songs id INTEGER PK, stable_key TEXT UNIQUE, +│ name, artist, album, album_id, duration_ms, +│ cover_url, media_uri, stream_url, channel_id, +│ audio_id, sub_audio_id, matched_lyric, +│ matched_translated_lyric, user_lyric_offset_ms, +│ custom_name, custom_artist, custom_cover_url, +│ local_file_name, local_file_path, added_at +│ INDEX idx_songs_stable_key +├──────────────────────────────────────────┤ +│ playlists id INTEGER PK, name, kind(本地/收藏/系统), +│ remote_platform, remote_id, created_at, updated_at +├──────────────────────────────────────────┤ +│ playlist_members playlist_id FK, song_id FK, position INTEGER, +│ PRIMARY KEY(playlist_id, position) +├──────────────────────────────────────────┤ +│ play_history id INTEGER PK, song_id FK, played_at, source +├──────────────────────────────────────────┤ +│ playback_queue song_id FK, position INTEGER +├──────────────────────────────────────────┤ +│ queue_state id INTEGER PK=1, index, position_ms, +│ repeat_mode, shuffle_enabled, +│ shuffle_restore_playlist_json TEXT +├──────────────────────────────────────────┤ +│ playback_stats song_id PK, play_count, total_play_ms, +│ last_played_at +│ stat_buckets song_id, day_key, play_count +├──────────────────────────────────────────┤ +│ downloads song_id PK, local_path, status, quality_key, +│ progress, error, created_at +│ download_snapshots root_key, bucket, entry_key, name, reference, +│ media_uri, local_file_path, size_bytes, +│ last_modified_ms, is_directory +│ PRIMARY KEY(root_key, bucket, entry_key) +├──────────────────────────────────────────┤ +│ sync_metadata key TEXT PK, etag, revision, updated_at +│ sync_outbox id, song_id, action(UPSERT/DELETE), payload_json +│ sync_checkpoints scope PK, token, updated_at +├──────────────────────────────────────────┤ +│ traffic_stats day_key PK, bytes_sent, bytes_received +├──────────────────────────────────────────┤ +│ cover_url_mapping local_url PK, network_url, updated_at +├──────────────────────────────────────────┤ +│ settings key TEXT PK, value_json, updated_at +├──────────────────────────────────────────┤ +│ cookie_credentials platform PK, encrypted_cookies BLOB, +│ encrypted_refresh_token BLOB, updated_at +│ (值使用 DPAPI 加密) +└──────────────────────────────────────────┘ +``` + +### 5.4 数据库迁移策略 + +- 使用 **EF Core Migrations**,对应 NeriPlayer Room 的 13 版迁移 +- 每个 Schema 版本一个 Migration 类,保留历史迁移(`Database/Migrations/`) +- 启动时自动 `Database.Migrate()`(对标 NeriPlayer 的迁移兼容) +- 破坏性变更走「新建表 + 数据复制 + 旧表删除」三段式(Room 同款策略) + +### 5.5 播放状态持久化(对标 `PersistedPlayerState.kt`) + +```csharp +public sealed record PersistedPlaybackState +{ + public IReadOnlyList Playlist { get; init; } = []; + public int Index { get; init; } + public string? MediaUrl { get; init; } + public long PositionMs { get; init; } + public bool ShouldResumePlayback { get; init; } + public RepeatMode? RepeatMode { get; init; } + public bool? ShuffleEnabled { get; init; } +} +``` + +- 存储位置:`%APPDATA%/NeriPlayer/playback_state.json` +- 写盘时机:每 15s + 播放命令(停止/切换)时 +- 恢复策略:启动后 1.5s 延迟恢复(对标 `INITIAL_SCAN_DELAY_MS`),失败静默跳过 +- 队列中的本地歌曲丢失时回退到「可恢复的本地媒体」检查(对标 `RestorableLocalMediaPolicy`) + +--- + +## 六、播放引擎设计 + +### 6.1 LibVLC 引擎详细设计 + +``` +VlcPlaybackEngine +├── LibVLC 初始化(--no-video、--audio-resampler、--network-caching=800ms) +├── MediaPlayer 实例 +├── Equalizer(10-band:31Hz~16kHz,-20dB ~ +20dB) +├── 事件桥接(Playing/Paused/EndReached/EncounteredError/Buffering/PositionChanged) +├── 位置同步(MediaPlayer.Time 轮询 + 事件) +└── URL 解析前置(HTTP/HLS 由 VLC 原生支持,无需额外解码器) +``` + +| 场景 | 处理 | +|------|------| +| 网络流 | `--network-caching=800` 缓冲;播放失败 → URL 重新解析 → 重试 1 次 | +| 高音质 | FLAC 24bit 原生支持;采样率自动协商 | +| 断网 | `EncounteredError` 事件 → 失败策略:连续失败计数 | +| 拖拽 | `MediaPlayer.Time = ms` 毫秒级精确 | +| 变速 | `MediaPlayer.Rate`(0.5~2.0) | +| 音量 | `MediaPlayer.Volume`(0~100)映射到 0~1 | +| 均衡器 | `Equalizer.Create(10 bands)` + `SetBands` | + +### 6.2 WASAPI 独占引擎详细设计(对标 USB 独占) + +``` +ExclusivePlaybackEngine (NAudio) +├── WasapiOut(device, AudioClientShareMode.Exclusive, 30ms buffer) +├── 源文件 → AudioFileReader → ISampleProvider 管道 +│ ├── VolumeNormalizationSampleProvider +│ ├── StereoBalanceSampleProvider +│ ├── EqualizerSampleProvider (BIQUAD) +│ └── PitchShifterSampleProvider (SoundTouch) +├── FftSampleProvider(实时 FFT 输出,用于可视化) +├── 设备事件(MMDeviceEnumerator 监听默认设备变化) +└── 失败回退(Exclusive 失败 → Shared 模式回退,对标 UsbExclusiveFallbackPolicy) +``` + +### 6.3 播放 URL 解析器 + +```csharp +public interface ISongUrlResolver +{ + Task ResolveAsync(SongItem song, QualityPreference quality); + bool Supports(SongItem song); +} + +public sealed class SongUrlResolverChain +{ + private readonly IReadOnlyList _resolvers = + [ + new LocalFileUrlResolver(), // 本地文件直接返回路径 + new NeteaseSongUrlResolver(), // 网易云 v1/v2 加密 API + new BiliAudioUrlResolver(), // Bilibili 音频流 + new YouTubeMusicUrlResolver(), // InnerTube + PoToken + EJS + ]; + + public async Task ResolveAsync(SongItem song, QualityPreference quality) + { + // 缓存命中检查(10min 有效) + // 逐个解析器尝试 + // URL 保鲜:后台任务提前刷新将过期 URL + } +} + +public record QualityPreference +{ + public string Netease { get; init; } = "exhigh"; // standard/higher/exhigh/loseless + public string YouTube { get; init; } = "high"; // low/medium/high/very_high + public string Bili { get; init; } = "high"; +} +``` + +### 6.4 曲目结束去重 + +对标 `TrackEndDeduplication.kt`(500ms 间隔守卫): + +```csharp +public sealed class TrackEndDeduplication +{ + private DateTimeOffset _lastEndAt; + private static readonly TimeSpan GuardWindow = TimeSpan.FromMilliseconds(500); + + public bool TryConsume() + { + var now = DateTimeOffset.UtcNow; + if (now - _lastEndAt < GuardWindow) return false; + _lastEndAt = now; + return true; + } +} +``` + +--- + +## 七、音效系统设计 + +### 7.1 效果链 + +``` +AudioPipeline +├── EqualizerEffect (10-band BIQUAD, 31Hz-16kHz, ±20dB, 支持预设) +│ 预设:流行/摇滚/爵士/古典/电子/人声/自定义 +├── StereoBalanceEffect (-1.0 全左 ~ 0 平衡 ~ +1.0 全右) +├── VolumeNormalization (EBU R128:测量 LUFS → 目标 -14 LUFS 增益) +├── SpeedEffect (0.5x ~ 2.0x, 保音高) +├── PitchEffect (-12 ~ +12 半音) +└── ReverbEffect (房间大小/衰减/干湿比) +``` + +### 7.2 均衡器实现 + +采用 **Direct Form I Biquad**(音频行业标准): + +```csharp +public sealed class BiquadFilter +{ + public enum FilterType { Peaking, LowShelf, HighShelf } + public double B0, B1, B2, A1, A2; // 系数由频率/增益/带宽计算 + + public float Process(float input) // 直通式差分方程 + { + // y[n] = b0*x[n] + b1*x[n-1] + b2*x[n-2] - a1*y[n-1] - a2*y[n-2] + } +} +``` + +10 段频率:`31.25, 62.5, 125, 250, 500, 1k, 2k, 4k, 8k, 16k`(Hz) + +### 7.3 音频可视化 + +- FFT 实现:Cooley-Tukey 基 2 算法(512~4096 点,50% overlap) +- 输出:对数频率刻度(20Hz~20kHz,64 频带)+ 峰值保持 + 衰减 +- 消费端:播放页频谱条、波形可视化 + +--- + +## 八、API 客户端设计 + +### 8.1 统一平台接口 + +```csharp +public interface IPlatformClient +{ + string PlatformId { get; } // "netease" / "bili" / "youtube_music" + bool IsLoggedIn { get; } + Task LoginAsync(LoginMethod method); // QR / Cookie / Token + + // 搜索 + Task SearchAsync(string keyword, SearchScope scope, int page); + + // 歌单 + Task> GetFeaturedPlaylistsAsync(int page); + Task GetPlaylistAsync(string playlistId); + + // 歌曲播放 + Task ResolveSongUrlAsync(SongItem song, string qualityKey); + + // 歌词 + Task GetLyricAsync(SongItem song); + + // 推荐 + Task GetRecommendationsAsync(); +} +``` + +### 8.2 网易云客户端(对标 `NeteaseClient.kt` + `NeteaseCrypto.kt`) + +| 能力 | 实现 | +|------|------| +| 搜索 | `weapi/search/get`(POST,JSON) | +| 歌曲 URL | `weapi/song/enhance/player/url/v1`(加密参数) | +| 歌单详情 | `weapi/v6/playlist/detail` | +| 歌词 | `weapi/song/lyric`(LRC + 翻译) | +| 首页推荐 | `weapi/v3/homepage/page` | +| 私人 FM | `weapi/radio/get` | +| 加密 | AES-CBC(`0CoJUm6Qyw8W8jud` key)+ RSA(公钥 `010001`)+ 随机 secretKey | +| WBI 签名 | 时间戳 + 随机数 + MD5/SHA1 摘要 | +| 登录 | 二维码轮询(create + check) | +| Cookie | DPAPI 加密文件,每次请求注入 | + +### 8.3 Bilibili 客户端(对标 `BiliClient.kt`) + +| 能力 | 实现 | +|------|------| +| 音频搜索 | `x/web-interface/search/type`(search_type=audio) | +| 音频 URL | `audio/music-service-c/songs/url`(v2 接口,WBI 签名) | +| 视频音频流 | `x/player/playurl`(fnval=16 提取 DASH audio) | +| 歌词 | 音频详情接口内嵌 | +| 登录 | 二维码(`x/passport-login/web/qrcode/generate`) | + +### 8.4 YouTube Music 客户端(对标 `YouTubeMusicClient.kt`) + +| 能力 | 实现 | +|------|------| +| 搜索 | InnerTube `youtubei/v1/search`(music 上下文) | +| 歌曲 URL | 播放列表加载 → 提取 streamingData(PoToken + signature) | +| player.js 解析 | 缓存到磁盘,48h 过期刷新(对标 `YouTubePlayerScriptStore`) | +| PoToken | 缓存 6h;EJS 挑战失败回退 NewPipeExtractor | +| 歌词 | InnerTube browse + watch 页面 | +| 登录 | Cookie(SAPISID / __Secure-3PAPISID) | + +### 8.5 歌词源聚合(对标 LyriconManager) + +``` +LyricsProvider +├── 内嵌歌词(文件内标签 / 远程匹配) +├── 网易云歌词 +├── QQ 音乐歌词(DEFAULT_QQ_MUSIC_LYRIC_OFFSET_MS 补偿) +├── Kugou 歌词(KC 解密 + LRC 转换) +├── LrcLib 歌词 +└── 合并排序:内嵌 > 匹配源 > 平台官方 > 第三方 +``` + +### 8.6 搜索聚合(对标 `SearchManager.kt`) + +- 多平台并发搜索(HttpClient 并行请求) +- 结果合并去重(按 stableKey) +- 排序:平台优先级(网易云 > YouTube > Bilibili)+ 相关度 +- 缓存:搜索结果 LRU(每平台 20 条) + +--- + +## 九、下载管理系统设计 + +### 9.1 架构(对标 `core/download/` 40+ 文件) + +``` +DownloadManager +├── DownloadQueue # 任务队列(Semaphore 并发控制) +│ ├── DefaultConcurrency = 6 +│ ├── MaxConcurrency = 8 +│ └── 取消后 5000ms 稳定期(DOWNLOAD_CANCEL_SETTLE_TIMEOUT_MS) +├── DownloadTask # 单个任务(HttpClient 流式下载 + 进度上报) +├── MetadataWriter # TagLib# 写标签(重试上限 3 次) +├── DownloadDirectoryIndexer # 三层索引 +│ ├── catalog # 主目录清单 +│ ├── snapshot # 快照 +│ └── recovery # 恢复(异常退出后扫描) +└── DownloadRepository # EF Core 持久化 +``` + +### 9.2 下载流程 + +``` +用户选择歌曲 + 质量 → 解析 URL → 创建 DownloadTask +→ 信号量获取(并发 ≤ 8) +→ HttpClient 流式下载到 .part 临时文件 +→ 完成后校验 → 重命名 → MetadataWriter 写入标签(封面/歌词/标题/艺人) +→ 更新 catalog + snapshot 索引 → 通知 UI +``` + +### 9.3 断点续传 + +- 下载中断 → 保留 `.part` + 已下载字节数(记录到 DB) +- 恢复时 `Range: bytes=<已下载>-` 续传 +- 服务端不支持 Range → 全量重下 + +### 9.4 下载目录结构 + +``` +NeriPlayer/Music/ +├── netease/ # 按平台分类 +│ └── {artist}/{album}/{title}.flac +├── bilibili/ +├── youtube/ +└── downloads.db # 索引数据库 +``` + +--- + +## 十、数据同步设计 + +### 10.1 同步架构(对标 `data/sync/` + Analysis.md 第九章) + +``` +SyncCoordinator +├── ISyncProvider (策略注入) +│ ├── GitHubSyncProvider # Octokit → repo 内 JSON 文件 +│ └── WebDavSyncProvider # WebDAV → 远程目录 +├── SyncMergeStrategy # 冲突合并 +├── SyncOutbox # 待同步操作队列(断网缓存) +├── SyncCheckpoint # 游标(增量同步) +└── 计划任务 # Quartz:每日 / 手动 / 事件触发 +``` + +### 10.2 同步内容 + +| 数据 | 格式 | 冲突策略 | +|------|------|----------| +| 歌单(含歌曲元数据) | JSON(不含本地文件路径,仅稳定键) | 按 updated_at + 因果 Token | +| 播放历史 | JSON(最近 1000 条) | 合并去重 | +| 收藏 | JSON(platform + songId) | 合并 | +| 设置 | `settings.json` | 最后写入胜出 | +| 播放统计 | JSON | 合并(取 max 计数) | + +### 10.3 因果一致性(对标 SyncCausalToken) + +- 每条同步记录携带 `SyncToken { songId, baseVersion, operationId }` +- 冲突时按「因果序 + 时间戳」裁决 +- 对应 `SyncOutboxEntity` / `SyncReplicaCheckpointEntity` 设计 + +--- + +## 十一、UI 设计 + +### 11.1 主窗口布局(对标 NeriPlayer 三栏布局) + +``` +┌──────────────────────────────────────────────────────────────┐ +│ 标题栏(自绘:Logo + 导航切换 + 搜索框 + 窗口控制) │ +├──────────┬──────────────────────────────────┬─────────────────┤ +│ 侧边栏 │ 内容区(页面容器) │ 播放队列 │ +│ │ │ (可折叠) │ +│ 🏠 首页 │ │ │ +│ 📻 发现 │ (NowPlaying / Library / │ ┌──────────┐ │ +│ 💿 我的 │ Search / Playlist / ...) │ │ 当前队列 │ │ +│ 📥 下载 │ │ └──────────┘ │ +│ ⚙ 设置 │ │ │ +├──────────┴──────────────────────────────────┴─────────────────┤ +│ 底部播放条:封面 │ 歌名-艺人 │ 进度条 │ ♥ │ ⏮ ▶/⏸ ⏭ │ EQ │ 歌词 │ 音量 │ +└────────────────────────────────────────────────────────────────┘ +``` + +### 11.2 正在播放页 + +| 区域 | 实现 | +|------|------| +| 背景 | 流体背景 Shader(SkiaShader 重写 AGSL `hyper_background_effect.glsl`) | +| 封面 | 旋转唱片动画(模糊 + 圆角遮罩 + 光影) | +| 主控区 | 进度条(可拖拽)+ 播放/暂停/上下曲/循环/随机 | +| 歌词区 | 可切换歌词视图(滚动高亮 + 翻译) | +| 音效区 | EQ 面板(10 滑块)+ 混响 + 立体声 | +| 频谱 | WaveformVisualizer 组件 | + +### 11.3 流体背景 Shader + +对标 `assets/shaders/hyper_background_effect.glsl`(5 色点 + 噪声流动): + +- **Skia 实现**:用 `SKRuntimeEffect`(Skia 的 Runtime Effect,GLSL 兼容) +- 直接迁移原 GLSL 到 Skia `sksl` 语法(改动极小) +- 参数:5 个色点(uniform vec4[5])+ 时间流 u_time + 鼠标交互偏移 +- 降级策略:GPU 不支持 → 静态渐变 + +### 11.4 毛玻璃效果 + +对标 `advanced_glass_*.agsl`(高级模糊毛玻璃): + +- 方案 A:`SKImageFilter.CreateBlur` + 背景快照(静态场景) +- 方案 B:Avalonia `ExperimentalAcrylicBorder`(实时模糊) +- 推荐:B(Avalonia 内置,性能好) + +### 11.5 歌词页面 + +- `LyricsScroller` 控件:ItemsControl + 虚拟化 + 居中高亮行 +- 滚动算法:positionMs → 行索引,当前行 1.25x 字号 + 主题色 +- 翻译歌词:双行(原文 + 翻译),可开关 +- 歌词偏移:长按 +/- 微调(1s 步进,对标 userLyricOffsetMs) +- 歌词搜索:标题+艺人 → 多源歌词匹配 +- 分享卡片:1080px 宽渲染 + +### 11.6 首页推荐 + +| 分区 | 数据源 | 上限 | +|------|--------|------| +| 每日推荐 | 网易云 `recommend/songs` | 30 首 | +| 推荐歌单 | 网易云 `personalized/playlist` | 30 个 | +| 私人 FM | 网易云 `radio/get` | 批量 10 | +| 雷达歌单 | 固定 5 个 ID(时光/宝藏/新歌/乐迷/神秘) | 5 个 | +| 失败降级 | code 301 / 50000005 → 回退热门歌单 | — | + +### 11.7 主题系统 + +- 暗色/亮色 + 跟随系统(ActualThemeVariant) +- 动态取色:封面 → 主色调(中位切分算法,对标 Palette ktx) +- Material.Avalonia 主题样式(Material 3 风格) +- 设置:主题、强调色、字体大小、模糊强度、流体背景开/关 + +### 11.8 图片加载 + +- `ImageCacheService`:HTTP 图片 → 磁盘缓存(%LOCALAPPDATA%/NeriPlayer/image-cache,LRU 1GB) +- 内存缓存:ConcurrentDictionary + 大小预算(32MB) +- 封面占位:渐变 + 首字母 +- 对标 Coil 的请求优先级 + 内存/磁盘双层缓存 + +--- + +## 十二、后台服务与系统集成 + +### 12.1 SMTC(SystemMediaTransportControls) + +对标 Android MediaSession: + +```csharp +public sealed class SmtcIntegration +{ + private readonly SystemMediaTransportControls _smtc; + + // 更新 + void UpdateMetadata(SongItem song, string? albumArtPath); + void UpdatePlaybackStatus(PlaybackStatus status); // Playing/Paused/Stopped + void UpdatePosition(TimeSpan position, TimeSpan duration); + + // 事件 → PlayerManager + ButtonPressed (Play/Pause/Next/Previous/Seek/Stop) +} +``` + +功能点: +- 任务栏媒体悬浮窗(封面 + 标题 + 进度 + 控制按钮) +- Win+L 锁屏媒体控制 +- 媒体键(键盘上的 ⏯⏭⏮) + +### 12.2 任务栏缩略图按钮 + +- 播放/暂停、上一曲、下一曲 3 个按钮 +- `ThumbButtonInfo` + `TaskbarItemInfo` + +### 12.3 桌面歌词(对标 FloatingLyricsOverlayManager) + +- `FloatingLyricsWindow`:Topmost + 透明 + 无边框 + 可拖动 +- 双行:原文 + 翻译 +- 设置:字号、颜色、位置锁定、点击穿透 + +### 12.4 后台播放(对标 AudioPlayerService) + +- 无 UI 场景:窗口关闭后播放不中断 +- 实现:托盘图标(TrayIcon)+ 后台线程持锁 + SMTC 常驻 +- 退出策略:托盘「退出」才真正释放引擎(对标 `PlaybackServiceIdlePolicy`) + +### 12.5 通知 + +- 播放状态变化 → Toast 通知(ToastNotificationManager) +- 下载完成 → Toast + 点击打开位置 +- 同步完成/失败 → Toast + +### 12.6 定时任务(对标 WorkManager) + +| 任务 | 调度 | 说明 | +|------|------|------| +| 同步 | 每日 02:00 + 手动 | Quartz cron | +| 播放统计刷盘 | 15s 周期 | PLAYBACK_STATS_PERIODIC_FLUSH_MS | +| URL 保鲜 | 事件触发 | 播放中每 10min | +| 清理过期缓存 | 每周 | 图片缓存 LRU 清理 | + +--- + +## 十三、安全与崩溃恢复 + +### 13.1 凭据安全(对标 androidx.security-crypto) + +| 数据 | 存储 | 加密 | +|------|------|------| +| Cookie(三平台) | `%APPDATA%/NeriPlayer/secure/cookies.bin` | **DPAPI**(CurrentUser) | +| 刷新 Token | 同上 | DPAPI | +| 网易云设备 ID | `%APPDATA%/NeriPlayer/secure/device_id` | 明文 + 权限限制 | + +### 13.2 崩溃处理(对标 ExceptionHandler + AnrWatchdog) + +```csharp +public static class ExceptionHandler +{ + public static void Install() + { + AppDomain.CurrentDomain.UnhandledException += OnUnhandled; + TaskScheduler.UnobservedTaskException += OnUnobservedTask; + Application.Current.DispatcherUnhandledException += OnDispatcher; // Avalonia + } + + // 崩溃报告:写入 %LOCALAPPDATA%/NeriPlayer/crash/ + // 上次崩溃检测:启动时扫描 crash/ 目录 → 提示是否发送报告 + // 安全模式:连续 2 次崩溃 → 下次启动禁用自动同步/自启 +} +``` + +### 13.3 安全模式(对标 SafeModeManager + AppStartupPlanner) + +- 触发条件:连续 2 次启动即崩溃、数据库迁移失败、设置文件损坏 +- 行为:禁用自动同步、禁用 YouTube PoToken 自研解析(走回退)、清空异常缓存 +- 用户可从设置页退出安全模式 + +### 13.4 数据备份 + +- 一键导出:settings.json + playlists.json + 播放历史 JSON(对标同步格式) +- 手动备份到任意目录 + +--- + +## 十四、测试体系 + +### 14.1 单元测试(NeriPlayer.Core.Tests) + +| 被测模块 | 用例 | +|----------|------| +| 歌词解析器 | LRC 偏移/多语言/空行/损坏输入 | +| StableKey | 本地/远程/YouTube 视频 ID 提取 | +| 曲目去重 | 500ms 窗口内去重 | +| 失败策略 | 连续失败计数与停止 | +| 播放状态机 | 状态转移合法/非法 | +| 音效处理 | EQ 增益正确性、立体声平衡端点值 | +| 下载队列 | 并发限制、取消稳定期 | +| 同步合并 | 因果 Token 冲突裁决 | +| 网易云加密 | 已知输入输出向量(固定密钥) | +| 搜索合并 | 多源去重排序 | + +### 14.2 集成测试(NeriPlayer.Data.Tests) + +- EF Core 迁移正确性(SQLite 内存库) +- Repository CRUD +- 播放状态持久化 round-trip + +### 14.3 UI 测试 + +- Avalonia Headless 测试框架(Avalonia.Headless.XUnit) +- 关键视图:播放条、歌词滚动、主窗口布局 + +### 14.4 性能基准 + +| 场景 | 目标 | +|------|------| +| 启动(冷启动到可播放) | ≤ 3s | +| 本地库 10,000 首扫描 | ≤ 30s(增量扫描 ≤ 2s) | +| 内存占用(5000 首歌单) | ≤ 400MB | +| 连续播放 48h | 无泄漏、无崩溃 | +| 封面缓存 | 启动后第二次加载 ≤ 100ms | + +--- + +## 十五、打包与发布 + +### 15.1 打包格式 + +| 格式 | 用途 | +|------|------| +| MSIX | Microsoft Store / 企业分发(自动更新) | +| 便携版 (self-contained) | 免安装单文件夹 | +| NSIS 安装包 | 传统安装器(可选) | + +### 15.2 发布配置 + +- `dotnet publish -c Release -r win-x64 --self-contained true` +- 包含 libvlc.dll + libvlccore.dll + plugins/ 目录 +- 代码签名(EV 证书,可选) + +### 15.3 CI/CD(GitHub Actions) + +``` +workflows/build.yml +├── job: windows-build +│ ├── checkout + setup .NET 8 +│ ├── restore + build(含 VLC 依赖) +│ ├── run tests(xunit) +│ ├── publish win-x64 +│ └── upload artifact(MSIX + portable) +``` + +--- + +## 十六、分阶段实施计划 + +### 阶段 1:项目脚手架与环境搭建(第 1 周) + +| # | 任务 | 产出 | 验收 | +|---|------|------|------| +| 1.1 | 创建解决方案 + 各项目骨架 | sln + 4 个 csproj | `dotnet build` 通过 | +| 1.2 | 配置 Directory.Build.props / Packages.props | 全局编译配置 | 统一 SDK/Nullable | +| 1.3 | 配置 DI 容器(AppStartup) | ServiceCollection 装配 | 应用可启动 | +| 1.4 | 配置 Serilog(文件 + 控制台) | 日志目录 | 启动日志可查 | +| 1.5 | 安装 Avalonia 模板 + 空窗口 | 空主窗口 | 窗口显示 | + +### 阶段 2:核心数据模型(第 1-2 周) + +| # | 任务 | 产出 | 验收 | +|---|------|------|------| +| 2.1 | SongItem / SongIdentity / PlaybackSource | model 类 | 单元测试通过 | +| 2.2 | PlaybackAudioInfo / 质量选项 | model 类 | 质量标签格式化正确 | +| 2.3 | PlayerEvent / 事件枚举 | model 类 | 事件订阅测试 | +| 2.4 | StableKey 算法 + 测试 | SongIdentity.cs | 去重测试通过 | +| 2.5 | JSON 序列化契约测试 | 序列化测试 | round-trip 稳定 | + +### 阶段 3:本地数据库(第 2-3 周) + +| # | 任务 | 产出 | 验收 | +|---|------|------|------| +| 3.1 | EF Core + SQLite 集成 | NeriDbContext | 迁移可生成 | +| 3.2 | 设计全部 Entity(15+ 表) | Entities/ | Schema 检查 | +| 3.3 | 初始迁移 + 种子数据 | Migrations/ | 迁移执行成功 | +| 3.4 | Repository 层 | Repositories/ | CRUD 集成测试 | +| 3.5 | 设置存储(SettingsRepository) | 配置读写 | 读写 round-trip | +| 3.6 | 播放状态持久化 | PlayerStatePersistence | 恢复测试 | + +### 阶段 4:播放引擎(第 3-5 周)← 最核心 + +| # | 任务 | 产出 | 验收 | +|---|------|------|------| +| 4.1 | IPlaybackEngine 接口定义 | 接口 | 编译通过 | +| 4.2 | LibVLC 集成 + 本地文件播放 | VlcPlaybackEngine | 播放本地 mp3/flac | +| 4.3 | 播放/暂停/Seek/音量/循环/随机 | PlayerManager 命令 | 状态机测试 | +| 4.4 | HTTP 流播放 + URL 解析链 | SongUrlResolverChain | 播放网络流 | +| 4.5 | 淡入淡出 + 失败保护 | 策略类 | 失败恢复测试 | +| 4.6 | 播放状态持久化恢复 | persistence | 重启恢复播放 | +| 4.7 | HLS 播放(VLC 原生) | 引擎测试 | HLS 流可播放 | + +### 阶段 5:音效系统(第 5-6 周) + +| # | 任务 | 产出 | 验收 | +|---|------|------|------| +| 5.1 | EQ 10-band(BIQUAD) | EqualizerEffect | 频响测试 | +| 5.2 | 立体声平衡 | StereoBalanceEffect | 端点值测试 | +| 5.3 | 音量归一化(R128) | VolumeNormalization | 响度测试 | +| 5.4 | 变速变调 | SpeedPitchEffect | 音质主观验收 | +| 5.5 | FFT 可视化数据 | FftAnalyzer | 频谱输出稳定 | +| 5.6 | EQ 预设 + 保存 | UI 集成 | 设置持久化 | + +### 阶段 6:API 客户端(第 6-8 周) + +| # | 任务 | 产出 | 验收 | +|---|------|------|------| +| 6.1 | 网易云搜索 + 歌单 | NeteaseClient | 真实搜索可返回 | +| 6.2 | 网易云 URL 解析(WBI 签名) | NeteaseCrypto | 播放验证 | +| 6.3 | 网易云歌词 | NeteaseLyricApi | LRC 正确 | +| 6.4 | 网易云登录(二维码) | NeteaseQrLoginClient | 扫码登录 | +| 6.5 | Bilibili 音频搜索 + URL | BiliClient | 播放验证 | +| 6.6 | YouTube Music 搜索 + URL(PoToken) | YouTubeMusicClient | 播放验证(P2) | +| 6.7 | 歌词源聚合(Kugou/LrcLib) | LyricsProvider | 多源回退 | +| 6.8 | 搜索聚合器 | SearchManager | 多平台结果合并 | + +### 阶段 7:下载管理(第 8-9 周) + +| # | 任务 | 产出 | 验收 | +|---|------|------|------| +| 7.1 | 下载队列 + 并发控制 | DownloadManager | 并发限制测试 | +| 7.2 | 流式下载 + 进度 | DownloadTask | 进度连续 | +| 7.3 | 断点续传 | 恢复逻辑 | 中断恢复 | +| 7.4 | 标签写入(TagLib#) | MetadataWriter | 封面嵌入成功 | +| 7.5 | 目录索引(catalog/snapshot) | Indexer | 扫描正确 | +| 7.6 | 下载中心 UI | DownloadsView | 管理界面可用 | + +### 阶段 8:数据同步(第 9-10 周) + +| # | 任务 | 产出 | 验收 | +|---|------|------|------| +| 8.1 | GitHub 同步 Provider | GitHubSyncProvider | 推送/拉取成功 | +| 8.2 | WebDAV 同步 Provider | WebDavSyncProvider | 推送/拉取成功 | +| 8.3 | 合并策略 + 因果 Token | SyncMergeStrategy | 冲突测试 | +| 8.4 | 增量同步 + 游标 | SyncCoordinator | 只传增量 | +| 8.5 | 设置备份/恢复 | 备份功能 | 备份还原 | + +### 阶段 9:UI 主框架(第 10-11 周) + +| # | 任务 | 产出 | 验收 | +|---|------|------|------| +| 9.1 | 主窗口三栏布局 + 导航 | MainWindow | 布局完成 | +| 9.2 | 底部播放条(绑定 PlayerManager) | PlayerBar | 播放联动 | +| 9.3 | 正在播放页 | NowPlayingView | 核心控制可用 | +| 9.4 | 歌词页(滚动高亮) | LyricsView | 歌词滚动正确 | +| 9.5 | 音乐库 + 本地扫描 | LibraryView | 扫描 + 展示 | +| 9.6 | 歌单详情 | PlaylistView | 歌曲列表操作 | +| 9.7 | 搜索页 | SearchView | 搜索展示 | +| 9.8 | 首页推荐 | DiscoverView | 推荐加载 | +| 9.9 | 设置页(SourceGen 生成) | SettingsView | 设置可用 | +| 9.10 | 主题系统(暗/亮 + 动态取色) | ThemeManager | 切换即时生效 | + +### 阶段 10:后台与系统集成(第 10-12 周) + +| # | 任务 | 产出 | 验收 | +|---|------|------|------| +| 10.1 | SMTC 集成 | SmtcIntegration | 任务栏媒体控制 | +| 10.2 | 托盘图标 + 后台播放 | TrayIntegration | 关窗继续播放 | +| 10.3 | 任务栏缩略图按钮 | ThumbnailButtons | 按钮可用 | +| 10.4 | 桌面歌词窗口 | FloatingLyricsWindow | 悬浮歌词 | +| 10.5 | Toast 通知 | NotificationService | 下载完成通知 | +| 10.6 | Quartz 定时任务 | SyncScheduledService | 定时同步 | + +### 阶段 11:高级特性(第 11-13 周) + +| # | 任务 | 产出 | 验收 | +|---|------|------|------| +| 11.1 | WASAPI 独占引擎 | ExclusivePlaybackEngine | 独占输出 | +| 11.2 | 流体背景 Shader | FluidBackground | GPU 流畅运行 | +| 11.3 | 毛玻璃效果 | AcrylicBlurEffect | 视觉效果 | +| 11.4 | 频谱可视化 | WaveformVisualizer | 随音乐跳动 | +| 11.5 | 迷你播放器 | MiniPlayerView | 简洁模式 | +| 11.6 | 快捷键 | HotkeyManager | 全局快捷键 | + +### 阶段 12:测试与打磨(第 13-14 周) + +| # | 任务 | 产出 | 验收 | +|---|------|------|------| +| 12.1 | 核心单元测试补全 | tests/ | 覆盖率 ≥ 60% | +| 12.2 | 数据层集成测试 | tests/ | 迁移/CRUD 通过 | +| 12.3 | UI Headless 测试 | tests/ | 关键视图渲染 | +| 12.4 | 性能基准 | benchmark 报告 | 达标(见 14.4) | +| 12.5 | 崩溃恢复验证 | 验证报告 | 安全模式可用 | +| 12.6 | 长时间播放稳定性(48h) | 稳定性报告 | 无泄漏无崩溃 | + +### 阶段 13:打包与发布(第 14-16 周) + +| # | 任务 | 产出 | 验收 | +|---|------|------|------| +| 13.1 | MSIX 打包 | .msix | 安装成功 | +| 13.2 | 便携版发布 | portable zip | 解压即用 | +| 13.3 | GitHub Actions CI/CD | workflows | 自动构建测试 | +| 13.4 | 版本号管理 + 更新检查 | AutoUpdater | 版本提示 | +| 13.5 | 用户文档 + README | docs | 使用说明 | + +--- + +## 十七、架构简化清单 + +NeriPlayer 共 1748 个 Kotlin 文件,Windows 重写必须做架构简化(保留能力、精简实现): + +| 原版模块 | 原版规模 | 简化后规模 | 说明 | +|----------|----------|-----------|------| +| `core/player/` | 124 文件(23 策略包) | ~25 文件 | 策略合并为 6 个内聚类 | +| `core/api/` | 34 文件 | ~25 文件 | 保留三平台,统一接口 | +| `data/local/` | ~55 文件 | ~25 文件 | EF Core 减少 DAO/Store 冗余 | +| `data/sync/` | ~20 文件 | ~10 文件 | 抽象 Provider | +| `data/settings/` | ~15 文件 | ~8 文件 | SourceGen 自动生成 | +| `core/download/` | ~45 文件 | ~8 文件 | 队列模型简化 | +| `listentogether/` | ~20 文件 | **0**(砍掉) | 非 Windows 场景 | +| `core/player/usb/` | ~35 文件 | ~3 文件 | WASAPI 独占替代 | +| `widget/` | ~15 文件 | **0**(砍掉) | SMTC 替代 | +| `core/lyricon/` | ~8 文件 | ~2 文件 | 内置歌词源替代 | +| **合计** | **~770** | **~130** | 代码量约减少 83% | + +### 必须保留的复杂设计 + +| 设计 | 保留原因 | +|------|----------| +| URL 保鲜 + 刷新冷却 | 在线播放核心体验 | +| 连续失败保护 | 网络波动兜底 | +| StableKey + 去重 | 同步/队列基础 | +| 因果 Token 同步 | 多端数据一致性 | +| 下载三层索引 | 异常退出后文件可恢复 | +| 播放状态 15s 持久化 | 崩溃恢复基础 | +| 听满 30s 计次 | 统计准确 | +| 进度节流 + 曲目去重 | 状态一致性 | + +--- + +## 十八、风险与缓解 + +| # | 风险 | 概率 | 影响 | 缓解措施 | +|---|------|------|------|----------| +| 1 | 网易云/B站 API 变更 | 高 | 高 | 版本化接口 + 失败降级 + URL 保鲜重试 | +| 2 | YouTube PoToken 反爬升级 | 高 | 中 | 分层:自研解析 → NewPipe 回退 → 用户 Cookie | +| 3 | LibVLC 集成问题(Avalonia 兼容) | 中 | 高 | 提前 PoC(第 4 阶段第 1 周先行验证) | +| 4 | EF Core 迁移与 Room 13 版 schema 差异 | 低 | 中 | 数据导出/导入 JSON 作为迁移桥 | +| 5 | 流体 Shader 性能(低端 GPU) | 中 | 低 | 降级为静态渐变 | +| 6 | WASAPI 独占设备独占失败 | 中 | 低 | 自动回退共享模式 + 设备变化监听 | +| 7 | 大曲库性能(>5 万首) | 低 | 中 | 分页 + 虚拟化 + 增量扫描 | +| 8 | 工期超预期 | 中 | 高 | 严格按 P0/P1 优先级裁剪;阶段 6.6/11.x 可后置 | + +**关键技术 PoC(先于主开发)**: +1. Avalonia 空窗口 + LibVLC 播放本地音频(半天) +2. LibVLC 播放网易云 HTTP 流(1 天) +3. EF Core SQLite 迁移执行(半天) +4. Skia RuntimeEffect 流体 Shader 跑通(1 天) + +--- + +## 十九、验收标准 + +### 19.1 功能验收(对标 NeriPlayer 核心能力) + +| 能力 | 验收标准 | +|------|----------| +| 本地播放 | mp3/flac/wav/aac/ogg/m4a 全部可播,标签正确 | +| 网易云播放 | 搜索→歌单→播放→歌词全链路可用,登录后可播高音质 | +| Bilibili 播放 | 音频搜索可播(P1),视频提取音频可播(P2) | +| YouTube Music | 搜索可播(P2,PoToken 可回退 NewPipe) | +| 歌词 | LRC 滚动 + 翻译 + 偏移校准 + 多源回退 | +| 音效 | EQ 10 段可调、立体声、变速不变调 | +| 下载 | 并发 8、断点续传、封面嵌入、目录索引恢复 | +| 同步 | GitHub/WebDAV 双向同步、冲突正确合并 | +| 播放恢复 | 重启后恢复队列/进度/模式 | +| 统计 | 播放计数(30s 规则)、流量统计 | + +### 19.2 非功能验收 + +| 维度 | 标准 | +|------|------| +| 启动时间 | 冷启动 ≤ 3s | +| 内存 | 峰值 ≤ 400MB(5000 首队列) | +| 稳定性 | 48h 连续播放无崩溃 | +| 兼容性 | Win10 21H2+ / Win11 | +| 可维护性 | 核心逻辑单元测试覆盖率 ≥ 60% | +| 部署 | 便携版免安装 + MSIX 可选 | + +### 19.3 里程碑 + +| 里程碑 | 时间点 | 交付物 | +|--------|--------|--------| +| M1 可播放本地音乐 | 第 5 周末 | 本地文件可播 + UI 播放条 | +| M2 可播在线音乐 | 第 8 周末 | 网易云/B站播放全链路 | +| M3 Beta 版 | 第 11 周末 | 核心功能 + UI + 后台集成 | +| M4 RC 版 | 第 14 周末 | 全功能 + 测试通过 | +| M5 v1.0 发布 | 第 16 周末 | 打包 + CI + 文档 | + +--- + +*NeriPlayer → Windows 移植方案 · Process.md · 2026-08-12* + diff --git a/dotnet-windows/README.md b/dotnet-windows/README.md deleted file mode 100644 index 3b71485..0000000 --- a/dotnet-windows/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# NeriPlayer Windows (dotnet-windows) - -> ⚠️ **本目录是独立的 .NET 技术栈实现方案,与仓库根目录的 Tauri (Rust + Vue) 官方实现互不干扰。** - -## 这是什么 - -这是将 NeriPlayer 移植到 Windows 桌面端的 **.NET 8 + Avalonia UI** 实现方案,与官方 `NeriPlayer-Desktop`(Tauri 2 + Rust + Vue 3)为**平行独立的两套技术栈**。 - -- 本目录代码不参与仓库根目录的 `pnpm` / `cargo` 构建体系 -- 保留 3 个历史提交(脚手架 → 核心数据模型 → 对齐修复) -- 作为技术方案参考与对比,供社区评估不同实现路线 - -## 技术栈 - -| 组件 | 选型 | -|------|------| -| 运行时 | .NET 8 LTS | -| UI | Avalonia UI 11.x | -| 播放引擎 | LibVLCSharp 8.x(VLC 3.0.x) | -| 数据库 | EF Core 8 + SQLite | -| 音效 | NAudio (WASAPI) / Biquad 滤波器 | -| 系统集成 | SMTC / 托盘 / Toast | - -## 项目结构 - -``` -src/ -├── NeriPlayer.App/ 主应用入口(Avalonia Desktop) -├── NeriPlayer.Core/ 核心业务层(播放/歌词/下载/策略) -├── NeriPlayer.Data/ 数据层(EF Core / 同步) -├── NeriPlayer.UI/ UI 层(Avalonia 视图) -└── NeriPlayer.Background/ 后台服务(SMTC / 托盘) -tests/ 单元测试(xunit) -``` - -## 构建与运行 - -```powershell -dotnet build NeriPlayer.Windows.sln -dotnet test tests/NeriPlayer.Core.Tests -dotnet run --project src/NeriPlayer.App -``` - -> 详细实施方案见 `Analysis.md`(源码分析 24 章)与 `Process.md`(移植方案 19 章)。 diff --git a/dotnet-windows/start.md b/dotnet-windows/start.md new file mode 100644 index 0000000..e58acb8 --- /dev/null +++ b/dotnet-windows/start.md @@ -0,0 +1,3216 @@ +# NeriPlayer → Windows 移植 · 详细实现过程(Start) + +> 本文档是《Process.md》的**逐步骤执行版**:每个阶段给出可复制的命令、完整代码、验证命令与验收标准。 +> 依据:《Analysis.md》(源码分析 24 章)+《Process.md》(移植方案 19 章) +> 目标:.NET 8 + Avalonia 11 + LibVLCSharp 3.8 的 Windows 音乐播放器 +> 建议按章节顺序执行,每章末尾的「✅ 验收」通过后才进入下一章。 + +--- + +## 目录 + +1. [环境搭建](#一环境搭建第01-03天) +2. [项目脚手架](#二项目脚手架第04-06天) +3. [核心数据模型](#三核心数据模型第07-09天) +4. [本地数据库](#四本地数据库第10-14天) +5. [播放引擎](#五播放引擎第15-22天) +6. [音效系统](#六音效系统第23-27天) +7. [API 客户端](#七api-客户端第28-37天) +8. [下载管理](#八下载管理第38-44天) +9. [数据同步](#九数据同步第45-50天) +10. [UI 主框架](#十ui-主框架第51-62天) +11. [后台与系统集成](#十一后台与系统集成第63-68天) +12. [视觉效果](#十二视觉效果第69-73天) +13. [安全与崩溃恢复](#十三安全与崩溃恢复第74-77天) +14. [测试体系](#十四测试体系贯穿全程) +15. [打包发布](#十五打包与发布第78-84天) + +--- + +## 〇、协作与提交流程(每个章节完成后必须执行) + +> 本文档的每个章节(每一步)完成后,**除完成本地验收外,还必须同步更新远端仓库并提交上游 PR**。 +> 该流程从「第三章 核心数据模型」完成时开始执行(第一次已执行,见下方记录)。 + +### 0.1 仓库布局 + +| 仓库 | 地址 | 用途 | +|------|------|------| +| 本地源码 | `D:\Project\Library\NeriPlayer.Windows` | 主开发目录(git 仓库) | +| 独立远端 | `https://github.com/ALIve114514awa/NeriPlayer-Windows-DotNet` | 个人备份 / 独立展示(origin) | +| 上游 fork | `D:\Project\Library\NeriPlayer-Desktop-fork` | 用于向官方仓库提交 PR | +| 上游仓库 | `https://github.com/cwuom/NeriPlayer-Desktop` | Tauri 官方实现(PR 目标,base=`main`) | +| PR 分支 | `feat/dotnet-windows-impl` | fork 内固定特性分支(反复更新) | +| 子目录 | `dotnet-windows/` | .NET 实现在 fork 内的存放位置(不动根目录) | + +### 0.2 每步提交流程(6 个动作) + +```powershell +# 1) 本地提交 +cd D:\Project\Library\NeriPlayer.Windows +git add -A +git commit -m "feat(第N章): <说明>" + +# 2) 推送到个人独立远端(保留完整历史) +git push origin master + +# 3) 同步 .NET 代码到 fork 的 dotnet-windows/ 子目录 +# 复制除 .git / bin / obj 之外的全部源码 +# 简单做法:整个目录覆盖复制后删除嵌套 .git(git add 时 bin/obj 会被 .gitignore 自动排除) +Remove-Item D:\Project\Library\NeriPlayer-Desktop-fork\dotnet-windows -Recurse -Force +Copy-Item D:\Project\Library\NeriPlayer.Windows D:\Project\Library\NeriPlayer-Desktop-fork\dotnet-windows -Recurse -Force +Remove-Item D:\Project\Library\NeriPlayer-Desktop-fork\dotnet-windows\.git -Recurse -Force + +# 4) fork 内提交并推送(自动更新既有 PR) +cd D:\Project\Library\NeriPlayer-Desktop-fork +git checkout feat/dotnet-windows-impl +git add dotnet-windows/ +git commit -m "feat(dotnet-windows): 第N章 <说明>" +git push origin feat/dotnet-windows-impl + +# 5) 确认 PR 仍为 open 且无冲突 +# PR: https://github.com/cwuom/NeriPlayer-Desktop/pull/20 +# (每次 push 后 PR 自动更新,无需重建) + +# 6) 记录本次提交信息到 0.3 提交记录表 +``` + +### 0.3 提交流程的触发规则 + +- ✅ **必须执行**:每个章节的「✅ 验收」通过后,按 0.2 流程执行。 +- 🚫 **例外(原作者拒绝时停止)**:如果上游维护者(cwuom)在 PR 中**明确表示拒绝/不接受**此类 .NET 独立实现,则停止向 `cwuom/NeriPlayer-Desktop` 提交新 PR;此时仅保留 0.2 第 1-2 步(本地 + 个人独立远端)。 +- 🚧 **未回复默认继续**:只要原作者未明确拒绝,就按每步完成继续提交/更新 PR。 + +### 0.4 提交记录表(每次执行后追加一行) + +| 日期 | 对应章节 | 本地提交 | 独立远端 | fork 分支 | 上游 PR | +|------|----------|----------|----------|-----------|---------| +| 2026-08-17 | 三、核心数据模型(初版) | `097e8b7` | master 已推送 | `feat/dotnet-windows-impl` @ `2e8d15e` | [PR #20](https://github.com/cwuom/NeriPlayer-Desktop/pull/20)(open) | + +--- + +## 一、环境搭建(第 01-03 天) + +### 1.1 安装清单 + +| 组件 | 版本 | 下载/命令 | +|------|------|-----------| +| Windows | 10 22H2 / 11 | 现有系统即可 | +| Visual Studio 2022 | 17.8+ Community | 勾选「.NET 桌面开发」工作负载 | +| .NET SDK | 8.0 LTS | `winget install Microsoft.DotNet.SDK.8` | +| Git | 任意新版本 | `winget install Git.Git` | +| LibVLC | 3.0.x x64 | https://get.videolan.org/vlc/ → 解压到 `D:\libs\vlc-3.0.20` | +| Windows Terminal | 最新 | 可选,便于多窗格操作 | + +### 1.2 验证安装 + +```powershell +'dotnet --version' # 期望 ≥ 8.0.x +dotnet --list-sdks +git --version +# VLC 验证:解压目录下应有 libvlc.dll / libvlccore.dll / plugins/ +Test-Path 'D:\libs\vlc-3.0.20\libvlc.dll' +``` + +### 1.3 安装 Avalonia 模板 + +```powershell +dotnet new install Avalonia.Templates +dotnet new list | findstr /i avalonia # 应看到 avalonia.app / avalonia.mvvm 等 +``` + +### 1.4 设置 NuGet 源(可选,国内加速) + +创建 `C:\Users\\AppData\Roaming\NuGet\NuGet.Config`: + +```xml + + + + + + + +``` + +### 1.5 初始化 Git 仓库 + +```powershell +cd D:\Project\Library +mkdir NeriPlayer.Windows +cd NeriPlayer.Windows +git init +# 创建 .gitignore(.NET 模板) +dotnet new gitignore +``` + +### 1.6 运行时配置(供播放引擎探测 VLC 路径) + +创建 `%APPDATA%\NeriPlayer\appsettings.json`(运行时读取,见第 5 章): + +```json +{ + "Vlc": { "LibDirectory": "D:\\libs\\vlc-3.0.20" }, + "Storage": { + "DataRoot": "%APPDATA%\\NeriPlayer", + "MusicRoot": "D:\\Music\\NeriPlayer" + } +} +``` + +**✅ 验收** +- [ ] `dotnet --version` ≥ 8.0 +- [ ] `dotnet new list` 中出现 avalonia 模板 +- [ ] `D:\libs\vlc-3.0.20\libvlc.dll` 存在 +- [ ] Git 仓库已初始化 + +--- +## 二、项目脚手架(第 04-06 天) + +### 2.1 创建解决方案与项目 + +```powershell +cd NeriPlayer.Windows +dotnet new sln -n NeriPlayer.Windows + +mkdir src +dotnet new avalonia.app -o src/NeriPlayer.App -n NeriPlayer.App +dotnet new classlib -o src/NeriPlayer.Core -n NeriPlayer.Core +dotnet new classlib -o src/NeriPlayer.Data -n NeriPlayer.Data +dotnet new classlib -o src/NeriPlayer.UI -n NeriPlayer.UI +dotnet new classlib -o src/NeriPlayer.Background -n NeriPlayer.Background + +mkdir tests +dotnet new xunit -o tests/NeriPlayer.Core.Tests -n NeriPlayer.Core.Tests +dotnet new xunit -o tests/NeriPlayer.Data.Tests -n NeriPlayer.Data.Tests +dotnet new xunit -o tests/NeriPlayer.Api.Tests -n NeriPlayer.Api.Tests + +# 加入解决方案 +dotnet sln add src/NeriPlayer.App src/NeriPlayer.Core src/NeriPlayer.Data ` + src/NeriPlayer.UI src/NeriPlayer.Background ` + tests/NeriPlayer.Core.Tests tests/NeriPlayer.Data.Tests tests/NeriPlayer.Api.Tests + +# 引用关系(对标 Process.md 3.2 依赖方向) +dotnet add src/NeriPlayer.App reference src/NeriPlayer.Core src/NeriPlayer.Data src/NeriPlayer.UI src/NeriPlayer.Background +dotnet add src/NeriPlayer.UI reference src/NeriPlayer.Core src/NeriPlayer.Data +dotnet add src/NeriPlayer.Background reference src/NeriPlayer.Core src/NeriPlayer.Data +dotnet add tests/NeriPlayer.Core.Tests reference src/NeriPlayer.Core +dotnet add tests/NeriPlayer.Data.Tests reference src/NeriPlayer.Data src/NeriPlayer.Core +dotnet add tests/NeriPlayer.Api.Tests reference src/NeriPlayer.Core +``` + +### 2.2 中央包管理(Directory.Packages.props) + +`Directory.Packages.props`(启用 Central Package Management): + +```xml + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +`Directory.Build.props`(全局编译属性): + +```xml + + + net8.0 + enable + enable + 12.0 + + +``` +> ⚠️ **模板与 CPM 冲突处理(重要修正,执行前必读)** +> +> 1. `dotnet new avalonia.app` / `dotnet new xunit` 生成的 `.csproj` 自带带 `Version` 的 `PackageReference`,启用 `Directory.Packages.props`(Central Package Management)后会触发 **NU1008 错误**。 +> 必须在 2.3 之前删除各 `.csproj` 中所有 `PackageReference` 的 `Version` 属性(或直接删除模板自带的引用),再由 2.3 的 `dotnet add package`(不带版本号)按中央版本统一拉取。 +> 2. **Avalonia 模板目标框架**:Avalonia.Templates 11.3.0 生成的 App 项目默认面向 `net9.0`,本机仅有 .NET 8 SDK 会报 NETSDK1045,需将 `src/NeriPlayer.App/NeriPlayer.App.csproj` 的 `` 改为 `net8.0`。 +> 3. **NuGet 源**:若 `mirrors.aliyun.com` 服务索引不可达(NU1301),从 `NuGet.Config` 移除 aliyun 源,仅保留 nuget.org。 +> 4. **CPM 下 `dotnet add package`(不带版本号)会解析源上最新版做兼容性校验**:若最新版不兼容 net8.0(如 EF Core 10.x 只支持 net10.0)会直接报 **NU1202** 而失败。稳妥做法是显式带版本:`dotnet add package Microsoft.EntityFrameworkCore.Sqlite --version 8.0.0`。 +> 5. **Avalonia.Fonts.Inter 必须保留**:模板 `Program.cs` 调用 `.WithInterFont()`,需在中央包清单补充 ``。 +> 6. **测试项目额外依赖**:xunit 模板自带 `Microsoft.NET.Test.Sdk` / `xunit` / `xunit.runner.visualstudio`(17.8.0 / 2.5.3 / 2.5.3),需在中央包清单补充对应 `PackageVersion`,否则 CPM 下报 NU1009。 +> 7. **WebDav.Client**:nuget.org 上无 2.8.1,实际可用 2.9.0(NU1603 会自动近似匹配),建议中央清单直接写 2.9.0。 + +### 2.3 安装 NuGet 包 + +```powershell +# Core +dotnet add src/NeriPlayer.Core package System.Reactive +dotnet add src/NeriPlayer.Core package Microsoft.Extensions.Http +dotnet add src/NeriPlayer.Core package Microsoft.Extensions.Configuration.Json +dotnet add src/NeriPlayer.Core package Serilog +dotnet add src/NeriPlayer.Core package Serilog.Sinks.Console +dotnet add src/NeriPlayer.Core package Serilog.Sinks.File + +# Data +dotnet add src/NeriPlayer.Data package Microsoft.EntityFrameworkCore.Sqlite +dotnet add src/NeriPlayer.Data package Microsoft.EntityFrameworkCore.Design +dotnet add src/NeriPlayer.Data package TagLibSharp +dotnet add src/NeriPlayer.Data package Octokit +dotnet add src/NeriPlayer.Data package WebDav.Client +dotnet add src/NeriPlayer.Data package Microsoft.International.Converters.PinYinConverter +dotnet add src/NeriPlayer.Data package Quartz + +# App +dotnet add src/NeriPlayer.App package Avalonia.Desktop +dotnet add src/NeriPlayer.App package Avalonia.Themes.Fluent +dotnet add src/NeriPlayer.App package Avalonia.ReactiveUI +dotnet add src/NeriPlayer.App package Serilog +dotnet add src/NeriPlayer.App package Serilog.Sinks.File +dotnet add src/NeriPlayer.App package Serilog.Sinks.Console +dotnet add src/NeriPlayer.App package Microsoft.Extensions.Hosting + +# UI +dotnet add src/NeriPlayer.UI package Avalonia +dotnet add src/NeriPlayer.UI package SkiaSharp +dotnet add src/NeriPlayer.UI package LibVLCSharp.Avalonia + +# Background +dotnet add src/NeriPlayer.Background package NAudio +dotnet add src/NeriPlayer.Background package Quartz + +# Tests +dotnet add tests/NeriPlayer.Api.Tests package Refit +dotnet add tests/NeriPlayer.Core.Tests package coverlet.collector +``` + +### 2.4 Serilog 日志(Core/Logging/AppLogger.cs) + +```csharp +using Serilog; + +namespace NeriPlayer.Core.Logging; + +public static class AppLogger +{ + public static readonly Serilog.Core.Logger Instance = new LoggerConfiguration() + .MinimumLevel.Information() + .WriteTo.Console(outputTemplate: + "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}") + .WriteTo.File( + path: Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "NeriPlayer", "logs", "app-.log"), + rollingInterval: RollingInterval.Day, + retainedFileCountLimit: 14, + outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}") + .CreateLogger(); + + public static void Flush() => Instance.Dispose(); +} +``` + +### 2.5 DI 容器装配(App/AppStartup.cs) + +对标 `AppContainer.initialize()` + `AppStartupPlanner.plan()`: + +```csharp +using Microsoft.Extensions.DependencyInjection; + +namespace NeriPlayer.App; + +public static class AppStartup +{ + public static ServiceProvider BuildServices() + { + var services = new ServiceCollection(); + + // 数据层 + services.AddDbContext(); + + // 核心层 + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // 后台 + services.AddHostedService(); + + return services.BuildServiceProvider(); + } +} +``` + +> 说明(修正):本文件引用的 `NeriDbContext` / `PlayerManager` / `HttpClientFactory` / `NeteaseClient` / `BiliClient` / `YouTubeMusicClient` / `SyncScheduledService` 在第三/五/七/九章才实现;骨架阶段必须**注释掉**这些注册行(保留 `ServiceCollection` 骨架),保证可编译。 + +### 2.6 首次编译验证 + +```powershell +dotnet build NeriPlayer.Windows.sln +``` + +**✅ 验收** +- [x] `dotnet build` 无错误(0 错误 / 10 警告:NU1701 PinYinConverter 兼容性提示、NU1603 WebDav 近似匹配) +- [x] 解决方案含 8 个项目,依赖方向符合 Process.md 3.2 +- [x] `dotnet run --project src/NeriPlayer.App` 弹出空白 Avalonia 窗口(实测:进程启动后存活 5s 无崩溃,PID=12532) + +--- +## 三、核心数据模型(第 07-09 天) + +### 3.1 SongItem(Core/Player/Model/SongItem.cs) + +对标 Analysis.md 21.1 节 + Process.md 5.1 节: + +```csharp +namespace NeriPlayer.Core.Player.Model; + +public enum PlaybackSource { Local, Netease, Bilibili, YouTubeMusic } + +public sealed record SongItem +{ + public long Id { get; init; } + public required string Name { get; init; } + public required string Artist { get; init; } + public required string Album { get; init; } + public long AlbumId { get; init; } + public long DurationMs { get; init; } + public string? CoverUrl { get; init; } + public string? MediaUri { get; init; } + public string? StreamUrl { get; init; } + + public string? ChannelId { get; init; } // local | netease | bilibili | youtube_music + public string? AudioId { get; init; } + public string? SubAudioId { get; init; } + + public string? MatchedLyric { get; init; } + public string? MatchedTranslatedLyric { get; init; } + public PlaybackSource? MatchedLyricSource { get; init; } + public long UserLyricOffsetMs { get; init; } + + public string? CustomName { get; init; } + public string? CustomArtist { get; init; } + public string? CustomCoverUrl { get; init; } + public string? OriginalName { get; init; } + public string? OriginalArtist { get; init; } + + public string? LocalFileName { get; init; } + public string? LocalFilePath { get; init; } + + public long AddedAt { get; init; } + + public string DisplayName => CustomName ?? OriginalName ?? Name; + public string DisplayArtist => CustomArtist ?? OriginalArtist ?? Artist; + + public bool IsLocalSong() => + ChannelId == "local" || (!string.IsNullOrEmpty(LocalFilePath) && ChannelId is null); +} +``` + +### 3.2 SongIdentity.StableKey(Core/Player/Model/SongIdentity.cs) + +```csharp +using System.Text.RegularExpressions; + +namespace NeriPlayer.Core.Player.Model; + +public static partial class SongIdentity +{ + /// 生成跨版本稳定的歌曲标识:去重、同步、持久化主键(对标 SongIdentity.kt) + public static string StableKey(this SongItem song) + { + if (song.IsLocalSong()) + return $"local|{NormalizePath(song.LocalFilePath ?? song.MediaUri ?? "")}"; + + return song.ChannelId switch + { + "netease" => $"netease|{song.AudioId ?? song.Id.ToString()}", + "bilibili" => $"bilibili|{song.AudioId}|{song.SubAudioId}", + "youtube_music" => $"ytm|{ExtractYouTubeVideoId(song.MediaUri)}", + _ => $"id|{song.Id}|{song.Album}|{song.MediaUri}" + }; + } + + private static string NormalizePath(string p) => + p.Replace('\\', '/').TrimEnd('/').ToLowerInvariant(); + + /// 从 YouTube 链接/播放列表 URI 提取视频 ID + public static string ExtractYouTubeVideoId(string? uri) + { + if (string.IsNullOrEmpty(uri)) return ""; + var m = YoutubeVideoIdRegex().Match(uri); + return m.Success ? m.Groups[1].Value : ""; + } + + [GeneratedRegex(@"(?:v=|youtu\.be/|/shorts/)([A-Za-z0-9_-]{11})")] + private static partial Regex YoutubeVideoIdRegex(); +} +``` +### 3.3 单元测试(tests/NeriPlayer.Core.Tests/SongIdentityTests.cs) + +```csharp +using NeriPlayer.Core.Player.Model; +using Xunit; + +namespace NeriPlayer.Core.Tests; + +public class SongIdentityTests +{ + [Fact] + public void LocalSong_StableKey_IsNormalizedPath() + { + var song = new SongItem + { + Id = 1, Name = "A", Artist = "B", Album = "C", + ChannelId = "local", LocalFilePath = @"D:\Music\a\b\c.flac" + }; + Assert.Equal("local|d:/music/a/b/c.flac", song.StableKey()); + } + + [Theory] + [InlineData("https://www.youtube.com/watch?v=abcDEF12345")] + [InlineData("https://youtu.be/abcDEF12345?si=xxx")] + public void YouTube_ExtractVideoId_Works(string uri) + { + var song = new SongItem + { + Id = 2, Name = "A", Artist = "B", Album = "C", + ChannelId = "youtube_music", MediaUri = uri + }; + Assert.Equal("abcDEF12345", SongIdentity.ExtractYouTubeVideoId(uri)); + Assert.Equal("ytm|abcDEF12345", song.StableKey()); + } + + [Fact] + public void Netease_StableKey_UsesAudioId() + { + var song = new SongItem + { + Id = 9, Name = "A", Artist = "B", Album = "C", + ChannelId = "netease", AudioId = "3456789" + }; + Assert.Equal("netease|3456789", song.StableKey()); + } +} +``` + +```powershell +dotnet test tests/NeriPlayer.Core.Tests +``` + +**✅ 验收** +- [ ] 4 个测试全部通过(本地路径规范化 / 2 个 YouTube 用例 / 网易云 StableKey) +- [ ] `SongItem` 可被其他项目引用编译 + +--- +## 四、本地数据库(第 10-14 天) + +### 4.1 实体定义(Data/Entities/) + +对标 Analysis.md 21.2 节 Room 15 张表 → EF Core 实体。核心实体: + +```csharp +// Data/Entities/SongEntity.cs +namespace NeriPlayer.Data.Entities; + +public sealed class SongEntity +{ + public long Id { get; set; } + public required string StableKey { get; set; } + public required string Name { get; set; } + public required string Artist { get; set; } + public required string Album { get; set; } + public long AlbumId { get; set; } + public long DurationMs { get; set; } + public string? CoverUrl { get; set; } + public string? MediaUri { get; set; } + public string? StreamUrl { get; set; } + public string? ChannelId { get; set; } + public string? AudioId { get; set; } + public string? SubAudioId { get; set; } + public string? MatchedLyric { get; set; } + public string? MatchedTranslatedLyric { get; set; } + public string? MatchedLyricSource { get; set; } + public long UserLyricOffsetMs { get; set; } + public string? CustomName { get; set; } + public string? CustomArtist { get; set; } + public string? CustomCoverUrl { get; set; } + public string? LocalFileName { get; set; } + public string? LocalFilePath { get; set; } + public long AddedAt { get; set; } +} +``` + +```csharp +// Data/Entities/PlaylistEntity.cs +namespace NeriPlayer.Data.Entities; + +public sealed class PlaylistEntity +{ + public long Id { get; set; } + public required string Name { get; set; } + public string Kind { get; set; } = "local"; // local | favorite | system + public string? RemotePlatform { get; set; } + public string? RemoteId { get; set; } + public long CreatedAt { get; set; } + public long UpdatedAt { get; set; } + public List Members { get; set; } = []; +} + +public sealed class PlaylistMemberEntity +{ + public long PlaylistId { get; set; } + public long SongId { get; set; } + public int Position { get; set; } + public PlaylistEntity? Playlist { get; set; } + public SongEntity? Song { get; set; } +} +``` + +```csharp +// Data/Entities/PlaybackStatsEntity.cs +namespace NeriPlayer.Data.Entities; + +public sealed class PlaybackStatsEntity +{ + public long SongId { get; set; } + public long PlayCount { get; set; } + public long TotalPlayMs { get; set; } + public long LastPlayedAt { get; set; } +} + +/// 每日统计分片桶(对标 PlaybackStatDailyCounterShardEntity,缓解写放大) +public sealed class StatBucketEntity +{ + public long SongId { get; set; } + public long DayKey { get; set; } // yyyyMMdd + public long PlayCount { get; set; } + public long ListenMs { get; set; } +} +``` +### 4.2 DbContext(Data/Database/NeriDbContext.cs) + +```csharp +using Microsoft.EntityFrameworkCore; +using NeriPlayer.Data.Entities; + +namespace NeriPlayer.Data.Database; + +public sealed class NeriDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Songs => Set(); + public DbSet Playlists => Set(); + public DbSet PlaylistMembers => Set(); + public DbSet PlaybackStats => Set(); + public DbSet StatBuckets => Set(); + + // 后续章节补充:PlayHistory / PlaybackQueue / QueueState / Downloads / + // DownloadSnapshots / SyncMetadata / SyncOutbox / SyncCheckpoints / + // TrafficStats / CoverUrlMapping / Settings / CookieCredentials + + protected override void OnModelCreating(ModelBuilder b) + { + b.Entity(e => + { + e.ToTable("songs"); + e.HasIndex(x => x.StableKey).IsUnique(); + }); + + b.Entity(e => e.ToTable("playlists")); + + b.Entity(e => + { + e.ToTable("playlist_members"); + e.HasKey(x => new { x.PlaylistId, x.Position }); + e.HasOne(x => x.Playlist).WithMany(p => p.Members) + .HasForeignKey(x => x.PlaylistId).OnDelete(DeleteBehavior.Cascade); + e.HasOne(x => x.Song).WithMany() + .HasForeignKey(x => x.SongId).OnDelete(DeleteBehavior.Cascade); + }); + + b.Entity(e => + { + e.ToTable("playback_stats"); + e.HasKey(x => x.SongId); + }); + + b.Entity(e => + { + e.ToTable("stat_buckets"); + e.HasKey(x => new { x.SongId, x.DayKey }); + }); + } +} +``` + +数据库连接工厂(Data/Database/DbContextFactory.cs): + +```csharp +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace NeriPlayer.Data.Database; + +/// 供 dotnet ef migrations 使用(设计时工厂) +public sealed class NeriDbContextFactory : IDesignTimeDbContextFactory +{ + public NeriDbContext CreateDbContext(string[] args) + { + var dbPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "NeriPlayer", "neriplayer.db"); + Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!); + + var options = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={dbPath}") + .Options; + return new NeriDbContext(options); + } +} +``` + +### 4.3 迁移 + +```powershell +# 安装 EF 工具(若未安装) +dotnet tool install --global dotnet-ef +cd src/NeriPlayer.Data +dotnet ef migrations add InitialCreate +dotnet ef database update +``` + +> 之后每个 Schema 变更:`dotnet ef migrations add ` 生成新版本; +> 破坏性变更采用「新建表 + 复制数据 + 删旧表」三段式,对标 Room 迁移策略(Process.md 5.4)。 + +### 4.4 仓储基类(Data/Repositories/RepositoryBase.cs) + +```csharp +using Microsoft.EntityFrameworkCore; + +namespace NeriPlayer.Data.Repositories; + +public abstract class RepositoryBase(TDbContext db) where TDbContext : DbContext +{ + protected TDbContext Db { get; } = db; + + protected async Task RunAsync(Func> action) + { + await using var t = await Db.Database.BeginTransactionAsync(); + var r = await action(Db); + await t.CommitAsync(); + return r; + } +} +``` + +### 4.5 SongRepository 示例 + +```csharp +using Microsoft.EntityFrameworkCore; +using NeriPlayer.Data.Database; +using NeriPlayer.Data.Entities; + +namespace NeriPlayer.Data.Repositories; + +public sealed class SongRepository(NeriDbContext db) +{ + public async Task GetByStableKeyAsync(string stableKey) => + await db.Songs.FirstOrDefaultAsync(s => s.StableKey == stableKey); + + public async Task UpsertAsync(SongEntity song) + { + var existing = await GetByStableKeyAsync(song.StableKey); + if (existing is not null) + { + db.Entry(existing).CurrentValues.SetValues(song); + await db.SaveChangesAsync(); + return existing.Id; + } + db.Songs.Add(song); + await db.SaveChangesAsync(); + return song.Id; + } +} +``` + +**✅ 验收** +- [ ] `dotnet ef migrations list` 输出 `InitialCreate` +- [ ] `neriplayer.db` 在 `%APPDATA%\NeriPlayer\` 生成,含 `songs` 等表 +- [ ] `SongRepository.UpsertAsync` 可重复调用且不产生重复行(stable_key 唯一) + +--- +## 五、播放引擎(第 15-22 天) + +### 5.1 引擎接口(Core/Player/Engine/IPlaybackEngine.cs) + +对标 Process.md 4.3 节: + +```csharp +using System.Reactive.Subjects; + +namespace NeriPlayer.Core.Player.Engine; + +public enum PlaybackEngineEventKind { Loaded, Playing, Paused, Stopped, Ended, Error, Buffering } + +public sealed record PlaybackEngineEvent(PlaybackEngineEventKind Kind, string? Message = null); + +public sealed record PlaybackEngineOptions +{ + public float Volume { get; init; } = 1.0f; + public float Rate { get; init; } = 1.0f; + public bool FadeOnPlay { get; init; } = true; +} + +public interface IPlaybackEngine : IDisposable +{ + Task LoadAsync(Uri mediaUri, PlaybackEngineOptions options); + Task PlayAsync(); + Task PauseAsync(); + Task SeekAsync(TimeSpan position); + Task SetVolumeAsync(float volume); + Task SetRateAsync(float speed); + IObservable Events { get; } + + void ApplyEqualizer(IReadOnlyList gains); // 10-band + void ApplyStereoBalance(float balance); // -1.0 ~ 1.0 + void ApplyVolumeNormalization(float gainDb); + void ApplyPitch(float semitones); + + TimeSpan Duration { get; } + IObservable Position { get; } + IObservable FftData { get; } +} +``` + +### 5.2 VLC 引擎(Core/Player/Engine/VlcPlaybackEngine.cs) + +```csharp +using System.Reactive.Subjects; +using LibVLCSharp.Shared; + +namespace NeriPlayer.Core.Player.Engine; + +public sealed class VlcPlaybackEngine : IPlaybackEngine +{ + private static readonly LibVLC? _libVlc; + private readonly MediaPlayer _player; + private readonly Subject _events = new(); + private readonly Subject _position = new(); + private readonly Subject _fft = new(); + + static VlcPlaybackEngine() + { + // 路径来自配置(见 1.6 appsettings.json)或环境变量 + var vlcDir = Environment.GetEnvironmentVariable("NERIPLAYER_VLC_DIR") + ?? @"D:\libs\vlc-3.0.20"; + Core.Initialize(vlcDir); + _libVlc = new LibVLC("--no-video", "--no-video-title-show"); + } + + public VlcPlaybackEngine() + { + _player = new MediaPlayer(_libVlc!); + _player.TimeChanged += (_, e) => _position.OnNext(TimeSpan.FromMilliseconds(e.Time)); + _player.EndReached += (_, _) => _events.OnNext(new(PlaybackEngineEventKind.Ended)); + _player.Playing += (_, _) => _events.OnNext(new(PlaybackEngineEventKind.Playing)); + _player.Paused += (_, _) => _events.OnNext(new(PlaybackEngineEventKind.Paused)); + _player.Stopped += (_, _) => _events.OnNext(new(PlaybackEngineEventKind.Stopped)); + _player.EncounteredError += (_, _) => + _events.OnNext(new(PlaybackEngineEventKind.Error, "VLC EncounteredError")); + _player.Buffering += (_, e) => + _events.OnNext(new(PlaybackEngineEventKind.Buffering, e.Cache.ToString())); + } + + public IObservable Events => _events; + public IObservable Position => _position.AsObservable(); + public IObservable FftData => _fft.AsObservable(); + public TimeSpan Duration => + _player.Length > 0 ? TimeSpan.FromMilliseconds(_player.Length) : TimeSpan.Zero; + + public Task LoadAsync(Uri mediaUri, PlaybackEngineOptions options) + { + using var media = new Media(_libVlc!, mediaUri); + if (!_player.Play(media)) return Task.FromException(new EngineException("VLC Play failed")); + _player.Volume = (int)(options.Volume * 100); + return Task.CompletedTask; + } + + public Task PlayAsync() { _player.Play(); return Task.CompletedTask; } + public Task PauseAsync() { _player.Pause(); return Task.CompletedTask; } + public Task SeekAsync(TimeSpan position) { _player.Time = (long)position.TotalMilliseconds; return Task.CompletedTask; } + public Task SetVolumeAsync(float volume) { _player.Volume = Math.Clamp((int)(volume * 100), 0, 200); return Task.CompletedTask; } + public Task SetRateAsync(float speed) { _player.SetRate(speed); return Task.CompletedTask; } + + public void ApplyEqualizer(IReadOnlyList gains) + { + var eq = new Equalizer(); + var bands = new[] { 31.25f, 62.5f, 125f, 250f, 500f, 1_000f, 2_000f, 4_000f, 8_000f, 16_000f }; + for (var i = 0; i < Math.Min(gains.Count, 10); i++) + eq.SetAmp(Math.Clamp(gains[i], -20, 20) * 100f, bands[i]); + _player.Equalizer = eq; + } + + public void ApplyStereoBalance(float balance) { /* 简化:VLC 通道控制 */ } + public void ApplyVolumeNormalization(float gainDb) { /* 见第 6 章 */ } + public void ApplyPitch(float semitones) { /* SoundTouch 见第 6 章 */ } + + public void Dispose() + { + _player.Stop(); + _player.Dispose(); + _events.Dispose(); + _position.Dispose(); + _fft.Dispose(); + } +} +``` + +> `EngineException` 定义:`public sealed class EngineException(string message) : Exception(message);` +### 5.3 PlayerManager 总控(Core/Player/PlayerManager.cs) + +对标 Analysis.md 第 3 章 + Process.md 4.1。常量全部对标 Analysis.md 24.1 节: + +```csharp +using System.Reactive.Subjects; +using NeriPlayer.Core.Player.Engine; +using NeriPlayer.Core.Player.Model; + +namespace NeriPlayer.Core.Player; + +public enum PlaybackState { Idle, Loading, Playing, Paused, Stopped, Error } +public enum RepeatMode { Off, All, One } +public enum PlaybackCommandSource { Local, Smtc, Shortcut, Auto } + +public sealed class PlayerManager : IDisposable +{ + // 常量对标 Analysis.md 24.1 播放核心 + private static readonly TimeSpan MediaUrlStale = TimeSpan.FromMinutes(10); // MEDIA_URL_STALE_MS + private static readonly TimeSpan UrlRefreshCooldown = TimeSpan.FromSeconds(10); + private const int MaxConsecutiveFailures = 10; + private static readonly TimeSpan StatePersistInterval = TimeSpan.FromSeconds(15); + private static readonly TimeSpan DefaultFadeDuration = TimeSpan.FromMilliseconds(500); + private static readonly TimeSpan ProgressThrottle = TimeSpan.FromMilliseconds(80); + private const long MinListenMsForPlayCount = 30_000; // 听满 30s 计 1 次 + + private readonly IPlaybackEngine _engine; + private readonly Subject _state = new(); + private readonly Subject _currentSong = new(); + private readonly Subject _position = new(); + + private List _queue = []; + private int _index; + private int _consecutiveFailures; + private RepeatMode _repeatMode; + private bool _shuffle; + private DateTimeOffset _lastUrlRefreshAt; + + public IObservable State => _state; + public IObservable CurrentSong => _currentSong; + public IObservable Position => _position; + public IReadOnlyList Queue => _queue; + public int CurrentIndex => _index; + + public PlayerManager(IPlaybackEngine engine) => _engine = engine; + public PlayerManager() : this(new VlcPlaybackEngine()) { } + + /// 播放歌单(对标 playPlaylistImpl:设置队列 → 播放 → 持久化) + public async Task PlayAsync(IReadOnlyList playlist, int startIndex, + PlaybackCommandSource source = PlaybackCommandSource.Local) + { + if (playlist.Count == 0) return; + _queue = playlist.ToList(); + _index = Math.Clamp(startIndex, 0, _queue.Count - 1); + _state.OnNext(PlaybackState.Loading); + await PlayAtIndexAsync(); + } + + private async Task PlayAtIndexAsync() + { + var song = _queue[_index]; + var url = await ResolveFreshUrlAsync(song); // URL 保鲜逻辑 + try + { + await _engine.LoadAsync(new Uri(url), new PlaybackEngineOptions { Volume = 1.0f }); + await _engine.PlayAsync(); + _consecutiveFailures = 0; + _currentSong.OnNext(song); + _state.OnNext(PlaybackState.Playing); + } + catch (Exception) + { + _consecutiveFailures++; + if (_consecutiveFailures >= MaxConsecutiveFailures) + { + _state.OnNext(PlaybackState.Error); + await _engine.PauseAsync(); + return; + } + await NextAsync(auto: true); // 自动切下一首 + } + } + + private async Task ResolveFreshUrlAsync(SongItem song) + { + if (string.IsNullOrEmpty(song.StreamUrl)) return song.StreamUrl!; + var age = DateTimeOffset.UtcNow - _lastUrlRefreshAt; + // URL 超过 10min 或标记为可刷新时重新解析(实际实现接入第 7 章 API 客户端) + if (age >= MediaUrlStale) _lastUrlRefreshAt = DateTimeOffset.UtcNow; + return song.StreamUrl; + } + + public async Task PauseAsync() { await _engine.PauseAsync(); _state.OnNext(PlaybackState.Paused); } + public async Task ResumeAsync() { await _engine.PlayAsync(); _state.OnNext(PlaybackState.Playing); } + + public async Task NextAsync(bool auto = false) + { + if (_index >= _queue.Count - 1) + { + if (_repeatMode == RepeatMode.All) _index = 0; + else { _state.OnNext(PlaybackState.Stopped); return; } + } + else _index++; + await PlayAtIndexAsync(); + } + + public async Task PreviousAsync() + { + _index = _index > 0 ? _index - 1 : _queue.Count - 1; + await PlayAtIndexAsync(); + } + + public async Task SeekAsync(TimeSpan position) => await _engine.SeekAsync(position); + public async Task SetVolumeAsync(float volume) => await _engine.SetVolumeAsync(volume); + public void SetRepeatMode(RepeatMode mode) => _repeatMode = mode; + public void ToggleShuffle() => _shuffle = !_shuffle; + + public void Dispose() { _engine.Dispose(); _state.Dispose(); _currentSong.Dispose(); _position.Dispose(); } +} +``` +### 5.4 策略类(Core/Player/Policy/) + +对标 Analysis.md 3.2 + Process.md 4.5(23 个策略包 → 6 个类)。示例: + +```csharp +// PlaybackFailurePolicy.cs —— 连续失败计数与停止阈值 +namespace NeriPlayer.Core.Player.Policy; + +public sealed class PlaybackFailurePolicy +{ + private const int MaxConsecutiveFailures = 10; + private int _count; + + public bool RecordFailure(out bool shouldStop) + { + _count++; + shouldStop = _count >= MaxConsecutiveFailures; + return shouldStop; + } + + public void RecordSuccess() => _count = 0; +} + +// TrackEndDedupPolicy.cs —— 500ms 相邻结束事件去重(Analysis.md 24.1) +public sealed class TrackEndDedupPolicy +{ + private DateTimeOffset _lastEndAt; + private static readonly TimeSpan GuardWindow = TimeSpan.FromMilliseconds(500); + + public bool TryConsume() + { + var now = DateTimeOffset.UtcNow; + if (now - _lastEndAt < GuardWindow) return false; + _lastEndAt = now; + return true; + } +} + +// MediaUrlRefreshPolicy.cs —— URL 10min 过期 + 10s 冷却防抖 +public sealed class MediaUrlRefreshPolicy +{ + private static readonly TimeSpan Stale = TimeSpan.FromMinutes(10); + private static readonly TimeSpan Cooldown = TimeSpan.FromSeconds(10); + private DateTimeOffset _lastRefreshAt = DateTimeOffset.MinValue; + + public bool ShouldRefresh(DateTimeOffset? urlCreatedAt) + { + if (urlCreatedAt is not null && DateTimeOffset.UtcNow - urlCreatedAt < Stale) return false; + if (DateTimeOffset.UtcNow - _lastRefreshAt < Cooldown) return false; + _lastRefreshAt = DateTimeOffset.UtcNow; + return true; + } +} +``` + +### 5.5 手动播放验证(临时控制台) + +在 `tests/NeriPlayer.Core.Tests` 中临时创建(仅手动执行,不进 CI): + +```csharp +public static class ManualPlay +{ + // 用法:ManualPlay.RunAsync(new Uri(@"D:\Music\demo.flac"), TimeSpan.FromSeconds(10)) + public static async Task RunAsync(Uri uri, TimeSpan seconds) + { + using var engine = new VlcPlaybackEngine(); + await engine.LoadAsync(uri, new PlaybackEngineOptions()); + await engine.PlayAsync(); + await Task.Delay(seconds); + await engine.PauseAsync(); + } +} +``` + +**✅ 验收** +- [ ] VLC 引擎可播放本地 `mp3/flac/ogg` 文件并出声 +- [ ] `PlayerManager.PlayAsync` → 状态流依次为 `Loading → Playing` +- [ ] 连续失败 10 次后进入 `Error` 并自动停止 +- [ ] 同一文件 500ms 内的 `Ended` 事件被 `TrackEndDedupPolicy` 过滤 + +--- +## 六、音效系统(第 23-27 天) + +### 6.1 Biquad 滤波器(Core/Player/Effects/BiquadFilter.cs) + +对标 Process.md 7.2(Direct Form I Biquad / RBJ 系数): + +```csharp +namespace NeriPlayer.Core.Player.Effects; + +public sealed class BiquadFilter +{ + public enum FilterType { Peaking, LowShelf, HighShelf } + + public double B0, B1, B2, A1, A2; // 系数 + private double _x1, _x2, _y1, _y2; + + public void Configure(FilterType type, double freqHz, double gainDb, + double sampleRate, double q = 0.707) + { + var a = Math.Pow(10, gainDb / 40.0); + var w0 = 2 * Math.PI * freqHz / sampleRate; + var cos = Math.Cos(w0); + var sin = Math.Sin(w0); + var alpha = sin / (2 * q); + + double b0, b1, b2, a1, a2; + switch (type) + { + case FilterType.Peaking: + b0 = 1 + alpha * a; b1 = -2 * cos; b2 = 1 - alpha * a; + a1 = -2 * cos; a2 = 1 + alpha / a; + break; + case FilterType.LowShelf: + var sq = 2 * Math.Sqrt(a) * alpha; + b0 = a * ((a + 1) - (a - 1) * cos + sq); + b1 = 2 * a * ((a - 1) - (a + 1) * cos); + b2 = a * ((a + 1) - (a - 1) * cos - sq); + a1 = -2 * ((a - 1) + (a + 1) * cos); + a2 = (a + 1) - (a - 1) * cos - sq; + break; + case FilterType.HighShelf: + var sqh = 2 * Math.Sqrt(a) * alpha; + b0 = a * ((a + 1) + (a - 1) * cos + sqh); + b1 = -2 * a * ((a - 1) + (a + 1) * cos); + b2 = a * ((a + 1) + (a - 1) * cos - sqh); + a1 = 2 * ((a - 1) - (a + 1) * cos); + a2 = (a + 1) - (a - 1) * cos - sqh; + break; + default: return; + } + + var a0 = 1.0 + a1 + a2; // 标准 RBJ:a0 归一化 + B0 = b0 / a0; B1 = b1 / a0; B2 = b2 / a0; + A1 = a1 / a0; A2 = a2 / a0; + } + + public float Process(float input) + { + var y = B0 * input + B1 * _x1 + B2 * _x2 - A1 * _y1 - A2 * _y2; + _x2 = _x1; _x1 = input; + _y2 = _y1; _y1 = y; + return (float)y; + } + + public void Reset() { _x1 = _x2 = _y1 = _y2 = 0; } +} +``` + +> 注:以上为 RBJ Audio EQ Cookbook 标准实现;直通验证(全 0 dB)见 6.5 测试。 + +### 6.2 均衡器(EqualizerEffect.cs) + +```csharp +namespace NeriPlayer.Core.Player.Effects; + +public sealed class EqualizerEffect +{ + public static readonly double[] BandsHz = + { 31.25, 62.5, 125, 250, 500, 1_000, 2_000, 4_000, 8_000, 16_000 }; + + public static readonly IReadOnlyDictionary Presets = + new Dictionary + { + ["默认"] = [0,0,0,0,0,0,0,0,0,0], + ["流行"] = [-1,0,1,2,3,2,0,1,2,1], + ["摇滚"] = [3,2,0,-1,1,2,3,2,1,0], + ["爵士"] = [2,1,0,1,2,2,0,0,1,2], + ["古典"] = [2,1,0,0,-1,-1,0,1,2,3], + ["电子"] = [2,2,1,0,-1,0,1,2,3,3], + ["人声"] = [-2,-1,0,1,2,3,3,2,1,-1], + }; + + private readonly BiquadFilter[] _filters; + + public EqualizerEffect(double sampleRate = 44100) + { + _filters = BandsHz.Select(_ => new BiquadFilter()).ToArray(); + SampleRate = sampleRate; + } + + public double SampleRate { get; } + + public void ApplyGains(IReadOnlyList gainsDb) + { + for (var i = 0; i < _filters.Length; i++) + { + var type = i == 0 ? BiquadFilter.FilterType.LowShelf + : i == _filters.Length - 1 ? BiquadFilter.FilterType.HighShelf + : BiquadFilter.FilterType.Peaking; + _filters[i].Configure(type, BandsHz[i], + Math.Clamp(gainsDb[i], -20, 20), SampleRate); + } + } + + public float Process(float sample) + { + foreach (var f in _filters) sample = f.Process(sample); + return sample; + } +} +``` +### 6.3 立体声平衡(StereoBalanceEffect.cs) + +```csharp +namespace NeriPlayer.Core.Player.Effects; + +/// 对标 StereoBalanceAudioProcessor.kt:(L+R) 混音权重 +public sealed class StereoBalanceEffect +{ + private float _balance; // -1.0 全左 ~ 0 平衡 ~ +1.0 全右 + + public void SetBalance(float balance) => _balance = Math.Clamp(balance, -1f, 1f); + + /// 输入交错立体声 buffer,原地处理 + public void Process(Span interleaved) + { + if (_balance == 0) return; + var lw = 1f - Math.Max(0, _balance); // 左权重 + var rw = 1f + Math.Min(0, _balance); // 右权重 + for (var i = 0; i < interleaved.Length; i += 2) + { + var l = interleaved[i]; + var r = interleaved[i + 1]; + interleaved[i] = l * lw + r * (1 - lw); + interleaved[i + 1] = r * rw + l * (1 - rw); + } + } +} +``` + +### 6.4 FFT 频谱(FftAnalyzer.cs) + +```csharp +namespace NeriPlayer.Core.Player.Effects; + +/// Cooley-Tukey 基 2 FFT + Hann 窗,输出 64 频带对数刻度 +public sealed class FftAnalyzer +{ + private readonly int _size; + private readonly float[] _window; + + public FftAnalyzer(int size = 1024) + { + _size = size; + _window = Enumerable.Range(0, size) + .Select(i => 0.5f * (1 - MathF.Cos(2 * MathF.PI * i / (size - 1)))) // Hann + .ToArray(); + } + + public float[] Compute(Span samples) + { + var re = new float[_size]; + var im = new float[_size]; + for (var i = 0; i < Math.Min(samples.Length, _size); i++) + { + re[i] = samples[i] * _window[i]; + im[i] = 0; + } + Fft(re, im); + + const int bands = 64; + var result = new float[bands]; + var nyquist = 20_000f; + var logMin = MathF.Log10(20); + var logMax = MathF.Log10(nyquist); + + for (var b = 0; b < bands; b++) + { + var f0 = MathF.Pow(10, logMin + (logMax - logMin) * b / bands); + var f1 = MathF.Pow(10, logMin + (logMax - logMin) * (b + 1) / bands); + var i0 = Math.Clamp((int)(f0 / nyquist * (_size / 2)), 0, _size / 2 - 1); + var i1 = Math.Clamp((int)(f1 / nyquist * (_size / 2)), i0 + 1, _size / 2); + var sum = 0f; + for (var i = i0; i < i1; i++) + sum += MathF.Sqrt(re[i] * re[i] + im[i] * im[i]); + result[b] = sum / MathF.Max(1, i1 - i0); + } + return result; + } + + private static void Fft(Span re, Span im) + { + var n = re.Length; + for (var i = 1, j = 0; i < n; i++) + { + var bit = n >> 1; + for (; (j & bit) != 0; bit >>= 1) j ^= bit; + j ^= bit; + if (i < j) (re[i], re[j]) = (re[j], re[i]); + } + for (var len = 2; len <= n; len <<= 1) + { + var ang = -2 * MathF.PI / len; + var wRe = MathF.Cos(ang); + var wIm = MathF.Sin(ang); + for (var i = 0; i < n; i += len) + { + var curRe = 1f; var curIm = 0f; + for (var k = 0; k < len / 2; k++) + { + var uRe = re[i + k]; var uIm = im[i + k]; + var vRe = re[i + k + len/2] * curRe - im[i + k + len/2] * curIm; + var vIm = re[i + k + len/2] * curIm + im[i + k + len/2] * curRe; + re[i + k] = uRe + vRe; im[i + k] = uIm + vIm; + re[i + k + len/2] = uRe - vRe; im[i + k + len/2] = uIm - vIm; + (curRe, curIm) = (curRe*wRe - curIm*wIm, curRe*wIm + curIm*wRe); + } + } + } + } +} +``` +### 6.5 音效单元测试 + +```csharp +using NeriPlayer.Core.Player.Effects; +using Xunit; + +namespace NeriPlayer.Core.Tests; + +public class EqualizerEffectTests +{ + [Fact] + public void Default_IsTransparent() + { + var eq = new EqualizerEffect(44100); + eq.ApplyGains(new double[10]); // 全 0 dB + Assert.Equal(1.0f, eq.Process(1.0f), 3); // 输出 ≈ 输入 + } + + [Fact] + public void StereoBalance_Endpoints_AreSane() + { + var sb = new StereoBalanceEffect(); + sb.SetBalance(1.0f); // 全左 + var data = new float[] { 1f, -1f, 1f, -1f }; + sb.Process(data); + Assert.True(data[0] > data[1]); // 左声道占优 + } + + [Fact] + public void Fft_OfSine_ReturnsNonZeroPeak() + { + var fft = new FftAnalyzer(1024); + var samples = new float[1024]; + for (var i = 0; i < 1024; i++) + samples[i] = MathF.Sin(2 * MathF.PI * 440f * i / 44100f); // 440Hz + var bands = fft.Compute(samples); + Assert.True(bands.Max() > 0f); + } +} +``` + +```powershell +dotnet test tests/NeriPlayer.Core.Tests +``` + +**✅ 验收** +- [ ] 均衡器全 0 dB 时输出≈输入(透明直通) +- [ ] 立体声平衡端点值行为正确 +- [ ] FFT 对 440Hz 正弦波能检测到非零能量 + +--- +## 七、API 客户端(第 28-37 天) + +### 7.1 统一平台接口(Core/Api/Common/IPlatformClient.cs) + +```csharp +namespace NeriPlayer.Core.Api.Common; + +public enum LoginMethod { QrCode, Cookie, Token } + +public sealed record LoginResult(bool Success, string? Message, string? QrUrl = null); +public sealed record SongUrlResult(bool Success, string? Url, string? QualityKey); +public sealed record LyricResult(string Lrc, string? TranslatedLrc, string Source); +public sealed record RemotePlaylist(string Id, string Name, string? CoverUrl); +public sealed record RemotePlaylistDetail(string Id, string Name, IReadOnlyList Songs); +public sealed record RecommendationFeed(IReadOnlyList Songs, IReadOnlyList Playlists); +public sealed record SearchResponse(IReadOnlyList Songs, bool HasMore); + +public interface IPlatformClient +{ + string PlatformId { get; } // "netease" | "bili" | "youtube_music" + bool IsLoggedIn { get; } + Task LoginAsync(LoginMethod method); + + Task SearchAsync(string keyword, int page = 1); + Task> GetFeaturedPlaylistsAsync(int page = 1); + Task GetPlaylistAsync(string playlistId); + Task ResolveSongUrlAsync(SongItem song, string? qualityKey = null); + Task GetLyricAsync(SongItem song); + Task GetRecommendationsAsync(); +} +``` + +### 7.2 网易云加密(Core/Api/Netease/NeteaseCrypto.cs) + +对标 Analysis.md 22.4 + Process.md 8.2: + +```csharp +using System.Security.Cryptography; +using System.Text; + +namespace NeriPlayer.Core.Api.Netease; + +/// weapi 加密:AES-CBC + RSA + 随机 secretKey(对标 NeteaseCrypto.kt) +public static class NeteaseCrypto +{ + private const string AesKey = "0CoJUm6Qyw8W8jud"; // 固定 key(weapi) + private const string AesIv = "0102030405060708"; + private const string RsaExponent = "010001"; + private const string RsaModulus = + "00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b7251" + + "52b3b17d8762718ed6396fddc39e9f8e93d1d3e3d9e9a4f8e8e8e8e8e8e8e8e8e8e"; + + private static readonly RandomNumberGenerator Rng = RandomNumberGenerator.Create(); + + public static Dictionary Weapi(Dictionary payload) + { + var text = System.Text.Json.JsonSerializer.Serialize(payload); + var secretKey = Random16Hex(); + var params1 = AesEncrypt(text, AesKey); + var params2 = AesEncrypt(params1, secretKey); + var encSecKey = RsaEncrypt(secretKey); + + return new Dictionary + { + ["params"] = params2, + ["encSecKey"] = encSecKey, + }; + } + + private static string Random16Hex() + { + var bytes = new byte[16]; + Rng.GetBytes(bytes); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } + + private static string AesEncrypt(string input, string key) + { + using var aes = Aes.Create(); + aes.Key = Encoding.UTF8.GetBytes(key); + aes.IV = Encoding.UTF8.GetBytes(AesIv); + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.PKCS7; + using var enc = aes.CreateEncryptor(); + var bytes = Encoding.UTF8.GetBytes(input); + var outBytes = enc.TransformFinalBlock(bytes, 0, bytes.Length); + return Convert.ToHexString(outBytes).ToLowerInvariant(); + } + + private static string RsaEncrypt(string input) + { + using var rsa = RSA.Create(); + var exponent = Convert.FromHexString(RsaExponent); + var modulus = Convert.FromHexString(RsaModulus); + rsa.ImportParameters(new RSAParameters { Exponent = exponent, Modulus = modulus }); + + var text = Encoding.UTF8.GetBytes(input).Reverse().ToArray(); // 逆序 + var encrypted = rsa.Encrypt(text, RSAEncryptionPadding.Pkcs1); + return Convert.ToHexString(encrypted).ToLowerInvariant(); + } +} +``` +### 7.3 网易云客户端(Core/Api/Netease/NeteaseClient.cs) + +```csharp +using System.Text; +using System.Text.Json; +using NeriPlayer.Core.Api.Common; + +namespace NeriPlayer.Core.Api.Netease; + +public sealed class NeteaseClient(HttpClient http) : IPlatformClient +{ + private const string BaseUrl = "https://music.163.com/weapi/"; + private const int MaxResponseBytes = 4 * 1024 * 1024; // MAX_RESPONSE_BYTES + + public string PlatformId => "netease"; + public bool IsLoggedIn { get; private set; } + + private async Task PostWeapiAsync(string path, Dictionary payload) + { + var form = NeteaseCrypto.Weapi(payload); + using var content = new FormUrlEncodedContent(form); + using var resp = await http.PostAsync(BaseUrl + path, content); + resp.EnsureSuccessStatusCode(); + var body = await resp.Content.ReadAsByteArrayAsync(); + if (body.Length > MaxResponseBytes) + throw new InvalidOperationException("Response too large"); + return Encoding.UTF8.GetString(body); + } + + public async Task SearchAsync(string keyword, int page = 1) + { + var json = await PostWeapiAsync("search/get", new Dictionary + { + ["s"] = keyword, + ["type"] = 1, + ["limit"] = 30, + ["offset"] = (page - 1) * 30, + }); + // 解析 result.songs[] → List(songs 的 ar/al 字段映射) + return new SearchResponse([], true); + } + + public async Task ResolveSongUrlAsync(SongItem song, string? qualityKey = null) + { + var level = qualityKey switch + { + "lossless" => "lossless", + "hires" => "hires", + "high" => "exhigh", + _ => "standard", + }; + var json = await PostWeapiAsync("song/enhance/player/url/v1", new Dictionary + { + ["ids"] = new[] { song.AudioId ?? song.Id.ToString() }, + ["level"] = level, + ["encodeType"] = "mp3", + }); + // 解析 data[0].url;为空 → 音质降级重试(standard → mp3 兜底) + return new SongUrlResult(false, null, level); + } + + public Task LoginAsync(LoginMethod method) => + method == LoginMethod.QrCode ? QrLoginAsync() : Task.FromResult(new LoginResult(false, "仅支持二维码")); + + private async Task QrLoginAsync() + { + // 1) weapi/login/qrcode/unikey 获取 key + // 2) 返回 QrUrl = https://music.163.com/login?code_key={key} + // 3) 轮询 weapi/login/qrcode/client/login(code 800 未扫 / 801 过期 / 803 成功) + // 4) 成功后持久化 Cookie(见第 13 章 CredentialStore) + return new LoginResult(false, "未实现"); + } + + public Task GetPlaylistAsync(string playlistId) => throw new NotImplementedException(); + public Task> GetFeaturedPlaylistsAsync(int page) => throw new NotImplementedException(); + public Task GetLyricAsync(SongItem song) => throw new NotImplementedException(); + public Task GetRecommendationsAsync() => throw new NotImplementedException(); +} +``` + +> **实现要点**(对标 Analysis.md 4.1 / 18.1): +> - Cookie 由 `CookieStore` 注入 HttpClient(CookieContainer 或手动 Header) +> - 播放失败回退链:音质降级 → 自动源切换(第 4.4 节)→ 本地兜底 +> - 首页推荐:登录感知(未登录过滤 requiresLogin 分区)+ 失败回退(code 301/50000005) + +### 7.4 Bilibili 客户端(Core/Api/Bili/BiliClient.cs) + +```csharp +using System.Security.Cryptography; +using System.Text; +using NeriPlayer.Core.Api.Common; + +namespace NeriPlayer.Core.Api.Bili; + +/// WBI 签名(对标 Analysis.md 4.2 + 22.4) +public static class WbiSignature +{ + private static readonly int[] MixinKeyEncTab = + [46,47,18,2,53,8,23,32,15,50,10,31,58,3,45,35,27,43,5,49,33,9,42,19,29,28,14,39,12,38,41,13,37,48,7,16,24,55,40,61,26,17,0,1,60,51,30,4,22,25,54,21,56,59,6,63,57,62,11,36,20,34,44,52]; + + public static string Sign(Dictionary query, string imgKey, string subKey) + { + var raw = imgKey + subKey; + var mixed = new string(MixinKeyEncTab.Select(i => raw[i]).ToArray()); + // w_rid = md5(mixedKey + wts + 排序后的 "k1=v1&k2=v2" 完整串) + var joined = string.Join("&", query.OrderBy(kv => kv.Key) + .Select(kv => $"{kv.Key}={kv.Value}")); + var m = MD5.HashData(Encoding.UTF8.GetBytes(mixed + joined)); + return Convert.ToHexString(m).ToLowerInvariant(); + } + + // 完整流程: + // 1) GET x/web-interface/nav 获取 img_url / sub_url 的 key 部分 + // 2) 拼装 query:wts = Unix 时间戳 + 业务参数 + // 3) 计算 w_rid = Sign(query, imgKey, subKey) 追加到 URL +} +``` + +> 实际 WBI 拼接格式需与 `tools_pub/ytmusic_api_probe.py` 与线上抓包对照校准(Analysis.md 17 章)。 + +**✅ 验收** +- [ ] `NeteaseCrypto.Weapi` 对固定输入产出固定格式(params + encSecKey) +- [ ] 网易云搜索接口可返回真实歌曲列表 +- [ ] Bilibili WBI 签名通过线上接口校验(不返回 -403) + +--- +### 7.5 YouTube Music 客户端(Core/Api/YouTube/YouTubeMusicClient.cs) + +```csharp +using System.Text.Json; +using NeriPlayer.Core.Api.Common; + +namespace NeriPlayer.Core.Api.YouTube; + +/// InnerTube WEB_REMIX 客户端(对标 Analysis.md 4.3 + 22.4) +public sealed class YouTubeMusicClient(HttpClient http, YouTubePlayerScriptStore scriptStore) : IPlatformClient +{ + private const string InnerTubeApi = "https://music.youtube.com/youtubei/v1/"; + private const string ClientName = "WEB_REMIX"; + private const int ClientVersion = 67; + + public string PlatformId => "youtube_music"; + public bool IsLoggedIn { get; private set; } + + private async Task PostInnerTubeAsync(string endpoint, Dictionary payload) + { + var body = new Dictionary + { + ["context"] = new Dictionary + { + ["client"] = new Dictionary + { + ["clientName"] = ClientName, + ["clientVersion"] = ClientVersion.ToString(), + ["hl"] = "zh-Hans", + ["gl"] = "CN", + } + }, + }; + foreach (var kv in payload) body[kv.Key] = kv.Value; + + using var resp = await http.PostAsJsonAsync(InnerTubeApi + endpoint, body); + resp.EnsureSuccessStatusCode(); + return await resp.Content.ReadAsStringAsync(); + } + + public async Task SearchAsync(string keyword, int page = 1) + { + var json = await PostInnerTubeAsync("search", new Dictionary + { + ["query"] = keyword, + ["params"] = "EgWKAQIIAWoKEAoQCRADEAA%3D", // music 歌曲过滤参数 + }); + // 解析 contents.tabbedSearchResultsRenderer...musicResponsiveListItemRenderer + return new SearchResponse([], true); + } + + public async Task ResolveSongUrlAsync(SongItem song, string? qualityKey = null) + { + // 1) 播放列表加载:browse/videoId → streamingData + // 2) 若需 PoToken:走 player.js 缓存 → EJS 挑战 → NewPipe 回退(多级回退链) + // 3) 解析 streamingData.adaptiveFormats 选最佳音频格式 + return new SongUrlResult(false, null, qualityKey); + } + + public Task LoginAsync(LoginMethod method) => + // Cookie 登录:SAPISID / __Secure-3PAPISID → CredentialStore 持久化 + Task.FromResult(new LoginResult(false, "Cookie 登录未实现")); + // 其余成员同 IPlatformClient 模式(略) +} +``` + +**YouTube 多级回退链**(对标 Analysis.md 4.3): + +``` +登录 Cookie → 匿名 visitor → PoToken(缓存 6h)→ player.js 缓存(48h 过期) +→ EJS 挑战(JS 求解队列)→ NewPipe 回退 +``` + +### 7.6 歌词聚合(Core/Api/Lyrics/LyricsSourceAggregator.cs) + +对标 Analysis.md 6.1 + Process.md 8.5: + +```csharp +using NeriPlayer.Core.Api.Common; + +namespace NeriPlayer.Core.Api.Lyrics; + +public sealed class LyricsSourceAggregator(IEnumerable sources, LyricsCache cache) +{ + public sealed class LyricsCache(int capacity = 20) + { + private readonly Dictionary _map = new(); + public LyricResult? Get(string key) => _map.TryGetValue(key, out var r) ? r : null; + public void Put(string key, LyricResult r) + { + if (_map.Count >= capacity) _map.Remove(_map.Keys.First()); + _map[key] = r; + } + } + + public async Task GetLyricAsync(SongItem song) + { + var key = $"{song.ChannelId}|{song.AudioId}"; + var cached = cache.Get(key); + if (cached is not null) return cached; + + // 合并排序:内嵌 > 匹配源 > 平台官方 > 第三方(QQ/Kugou/LrcLib) + foreach (var source in sources.OrderBy(s => s.Priority)) + { + var r = await source.TryGetAsync(song); + if (r is not null) + { + cache.Put(key, r); + return r; + } + } + return null; + } +} + +public abstract class LyricsSource(int priority) +{ + public int Priority { get; } = priority; + public abstract Task TryGetAsync(SongItem song); +} +``` + +### 7.7 搜索聚合(Core/Api/Search/SearchManager.cs) + +```csharp +using System.Collections.Concurrent; +using NeriPlayer.Core.Api.Common; + +namespace NeriPlayer.Core.Api.Search; + +public sealed class SearchManager(IEnumerable clients) +{ + private readonly ConcurrentDictionary _cache = new(); + + public async Task SearchAsync(string keyword, int page = 1) + { + var cacheKey = $"{keyword}|{page}"; + if (_cache.TryGetValue(cacheKey, out var hit)) return hit; + + // 三平台并发搜索 + var tasks = clients.Select(c => c.SearchAsync(keyword, page)).ToArray(); + var results = await Task.WhenAll(tasks); + + // 按 stableKey 去重合并 + var seen = new HashSet(); + var merged = new List(); + foreach (var r in results) + foreach (var song in r.Songs) + { + if (seen.Add(song.StableKey())) merged.Add(song); + } + var resp = new SearchResponse(merged, results.Any(r => r.HasMore)); + _cache[cacheKey] = resp; + return resp; + } +} +``` + +**✅ 验收** +- [ ] 网易云 + B 站 + YouTube 三平台搜索可并行返回、按 stableKey 去重 +- [ ] 歌词聚合按优先级回退,LRU 缓存(各 20 条)生效 +- [ ] YouTube 匿名访问可解析出音频流(离线可测:用本地 yt-dlp 校验 URL 有效性) + +--- +## 八、下载管理(第 38-44 天) + +### 8.1 下载任务(Core/Download/DownloadTask.cs) + +```csharp +using System.Reactive.Subjects; + +namespace NeriPlayer.Core.Download; + +public enum DownloadStatus { Queued, Downloading, Paused, Completed, Failed, Cancelled } + +public sealed record DownloadProgress(long TaskId, long BytesReceived, long? TotalBytes, + double Percent, DownloadStatus Status); + +public sealed class DownloadTask +{ + public long TaskId { get; init; } + public required string StableKey { get; init; } + public required string Url { get; init; } + public required string TargetPath { get; init; } + public DownloadStatus Status { get; private set; } = DownloadStatus.Queued; + public long BytesReceived { get; private set; } + + private readonly Subject _progress = new(); + public IObservable Progress => _progress; + private readonly CancellationTokenSource _cts = new(); + private readonly HttpClient _http; + + public DownloadTask(HttpClient http) => _http = http; + + public async Task RunAsync() + { + Status = DownloadStatus.Downloading; + try + { + // 断点续传:Range: bytes={BytesReceived}-(服务端需支持) + using var req = new HttpRequestMessage(HttpMethod.Get, Url); + if (BytesReceived > 0) req.Headers.Range = + new System.Net.Http.Headers.RangeHeaderValue(BytesReceived, null); + + using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, _cts.Token); + resp.EnsureSuccessStatusCode(); + + var total = resp.Content.Headers.ContentLength ?? 0; + await using var src = await resp.Content.ReadAsStreamAsync(_cts.Token); + Directory.CreateDirectory(Path.GetDirectoryName(TargetPath)!); + var tmpPath = TargetPath + ".part"; + await using var dst = File.Open(tmpPath, FileMode.Append); // .part 断点续写 + + var buffer = new byte[81920]; + while (true) + { + var read = await src.ReadAsync(buffer, _cts.Token); + if (read == 0) break; + await dst.WriteAsync(buffer.AsMemory(0, read), _cts.Token); + BytesReceived += read; + _progress.OnNext(new DownloadProgress(TaskId, BytesReceived, total, + total > 0 ? BytesReceived * 100.0 / total : 0, DownloadStatus.Downloading)); + } + + await dst.FlushAsync(_cts.Token); + if (File.Exists(TargetPath)) File.Delete(TargetPath); + File.Move(tmpPath, TargetPath); + Status = DownloadStatus.Completed; + _progress.OnNext(new DownloadProgress(TaskId, BytesReceived, total, 100, DownloadStatus.Completed)); + } + catch (OperationCanceledException) + { + Status = DownloadStatus.Cancelled; + } + catch (Exception) + { + Status = DownloadStatus.Failed; + } + } + + public void Cancel() => _cts.Cancel(); +} +``` + +### 8.2 下载队列(Core/Download/DownloadQueue.cs) + +```csharp +namespace NeriPlayer.Core.Download; + +/// Semaphore 并发控制:默认 6 / 最大 8(Analysis.md 24.2) +public sealed class DownloadQueue +{ + public const int DefaultConcurrency = 6; + public const int MaxConcurrency = 8; + public const int CancelSettleTimeoutMs = 5000; // DOWNLOAD_CANCEL_SETTLE_TIMEOUT_MS + + private readonly SemaphoreSlim _semaphore = new(DefaultConcurrency, MaxConcurrency); + private readonly Queue _pending = new(); + private readonly List _running = []; + + public event Action? Completed; + + public void Enqueue(DownloadTask task) + { + _pending.Enqueue(task); + _ = PumpAsync(); + } + + private async Task PumpAsync() + { + while (_pending.Count > 0) + { + await _semaphore.WaitAsync(); + var task = _pending.Dequeue(); + var run = Task.Run(task.RunAsync); + _running.Add(run); + _ = run.ContinueWith(_ => + { + _semaphore.Release(); + Completed?.Invoke(task); + }); + } + } +} +``` + +### 8.3 标签写入(Core/Download/MetadataWriter.cs) + +```csharp +using TagLib; + +namespace NeriPlayer.Core.Download; + +/// TagLib# 写标签,失败重试上限 3 次(Analysis.md 24.2) +public static class MetadataWriter +{ + public const int MaxAttempts = 3; + + public static async Task WriteAsync(string filePath, SongMetadata meta, CancellationToken ct = default) + { + for (var attempt = 1; attempt <= MaxAttempts; attempt++) + { + try + { + await Task.Run(() => WriteCore(filePath, meta), ct); + return; + } + catch when (attempt < MaxAttempts) { await Task.Delay(200 * attempt, ct); } + } + } + + private static void WriteCore(string filePath, SongMetadata meta) + { + using var file = TagLib.File.Create(filePath); + file.Tag.Title = meta.Title; + file.Tag.Performers = [meta.Artist]; + file.Tag.Album = meta.Album; + if (meta.CoverBytes is not null && file is IPictureTag pt) + pt.Pictures = [new Picture(meta.CoverBytes) { MimeType = "image/jpeg" }]; + file.Save(); + } +} + +public sealed record SongMetadata(string Title, string Artist, string Album, byte[]? CoverBytes); +``` + +### 8.4 下载索引(Data/Entities 扩展 + 三层索引) + +对标 Analysis.md 24.2「catalog/snapshot/recovery/queue 四张索引表」: + +| 表 | 作用 | 说明 | +|----|------|------| +| `downloads` | 主目录清单 | 歌曲 → 本地路径、状态、质量、进度 | +| `download_snapshots` | 快照 | 目录树捕获,主键 (root_key, bucket, entry_key) | +| `download_recovery` | 恢复 | 异常退出后扫描 `.part` 与孤儿文件 | +| `download_queue` | 队列 | 待下载任务持久化 | + +**✅ 验收** +- [ ] 8 路并发下载不超限(Semaphore 生效) +- [ ] 中断后 `.part` 保留,重试从 `Range: bytes=N-` 续传 +- [ ] 下载完成后 TagLib# 写入标题/艺人/专辑/封面并 `file.Save()` 无异常 + +--- +## 九、数据同步(第 45-50 天) + +### 9.1 同步接口(Data/Sync/ISyncProvider.cs) + +```csharp +namespace NeriPlayer.Data.Sync; + +public sealed record SyncFile(string Name, byte[] Content, string? Etag, DateTimeOffset ModifiedAt); +public sealed record SyncResult(bool Success, int ChangedCount, string? Message); + +/// 同步后端抽象:GitHub 仓库 或 WebDAV 目录 +public interface ISyncProvider +{ + string ProviderName { get; } + Task TestConnectionAsync(); + Task> ListAsync(string scope); + Task DownloadAsync(string scope, string name); + Task UploadAsync(string scope, string name, byte[] content, string? etag); + Task DeleteAsync(string scope, string name); +} +``` + +### 9.2 GitHub Provider(Data/Sync/GitHubSyncProvider.cs) + +```csharp +using Octokit; + +namespace NeriPlayer.Data.Sync; + +/// 对标 SyncGithubManager:仓库内 JSON 文件 +public sealed class GitHubSyncProvider : ISyncProvider +{ + private readonly GitHubClient _client; + private readonly string _owner; + private readonly string _repo; + private readonly string _pathPrefix; + + public GitHubSyncProvider(string token, string owner, string repo, string pathPrefix = "neriplayer") + { + _client = new GitHubClient(new ProductHeaderValue("NeriPlayer.Windows")) + { + Credentials = new Credentials(token) + }; + _owner = owner; _repo = repo; _pathPrefix = pathPrefix; + } + + public string ProviderName => "github"; + public async Task TestConnectionAsync() => + (await _client.User.Current()).Login.Length > 0; + + public async Task> ListAsync(string scope) + { + var items = await _client.Repository.Content.GetAllContents(_owner, _repo, + $"{_pathPrefix}/{scope}"); + return items.Select(i => new SyncFile(i.Name, Array.Empty(), + i.Sha, i.UpdatedAt ?? DateTimeOffset.MinValue)).ToList(); + } + + public async Task DownloadAsync(string scope, string name) + { + var item = await _client.Repository.Content.GetAllContents(_owner, _repo, + $"{_pathPrefix}/{scope}/{name}"); + var content = item[0].Content ?? ""; + return new SyncFile(name, System.Text.Encoding.UTF8.GetBytes(content), + item[0].Sha, item[0].UpdatedAt ?? DateTimeOffset.MinValue); + } + + public async Task UploadAsync(string scope, string name, byte[] content, string? etag) + { + var path = $"{_pathPrefix}/{scope}/{name}"; + var text = System.Text.Encoding.UTF8.GetString(content); + if (etag is not null) + await _client.Repository.Content.UpdateFile(_owner, _repo, path, + new UpdateFileRequest($"sync {name}", text, etag)); + else + await _client.Repository.Content.CreateFile(_owner, _repo, path, + new CreateFileRequest($"sync {name}", text)); + return true; + } + + public Task DeleteAsync(string scope, string name) => throw new NotImplementedException(); +} +``` + +### 9.3 WebDAV Provider(Data/Sync/WebDavSyncProvider.cs) + +```csharp +using WebDav; + +namespace NeriPlayer.Data.Sync; + +public sealed class WebDavSyncProvider : ISyncProvider +{ + private readonly WebDavClient _client; + private readonly string _root; + + public WebDavSyncProvider(Uri server, string user, string password, string root = "neriplayer") + { + _client = new WebDavClient(new WebDavClientParams + { + ServerUrl = server, + Credentials = new System.Net.NetworkCredential(user, password), + }); + _root = root; + } + + public string ProviderName => "webdav"; + + public async Task> ListAsync(string scope) + { + var result = await _client.Propfind($"{_root}/{scope}"); + return result.Resources + .Where(r => !r.IsCollection) + .Select(r => new SyncFile(r.Uri.Split('/').Last(), Array.Empty(), + r.ETag, r.LastModified ?? DateTimeOffset.MinValue)) + .ToList(); + } + + public async Task DownloadAsync(string scope, string name) + { + var resp = await _client.GetRawFile($"{_root}/{scope}/{name}"); + return resp.IsSuccessful + ? new SyncFile(name, await resp.Stream.ToArrayAsync(), + resp.Headers["ETag"], DateTimeOffset.UtcNow) + : null; + } + + public async Task UploadAsync(string scope, string name, byte[] content, string? etag) + { + var path = $"{_root}/{scope}/{name}"; + using var ms = new MemoryStream(content); + var resp = etag is null + ? await _client.PutFile(path, ms) + : await _client.PutFile(path, ms, + headers: new[] { new System.Net.WebHeaderCollection { { "If-Match", etag } } }); + return resp.IsSuccessful; + } + + public Task DeleteAsync(string scope, string name) => throw new NotImplementedException(); +} +``` +### 9.4 因果 Token 合并(Data/Sync/SyncMergeStrategy.cs) + +```csharp +namespace NeriPlayer.Data.Sync; + +/// 同步记录携带因果 Token(对标 SyncCausalToken / Analysis.md 9 章) +public sealed record SyncToken(string SongId, string BaseVersion, string OperationId); + +public static class SyncMergeStrategy +{ + /// 冲突裁决:因果序优先,其次时间戳 + public static int Compare(SyncToken a, SyncToken b, long aTimestamp, long bTimestamp) + { + if (a.BaseVersion == b.OperationId) return -1; // a 是 b 的祖先 + if (b.BaseVersion == a.OperationId) return 1; // b 是 a 的祖先 + return aTimestamp.CompareTo(bTimestamp); // 无因果关系 → 时间裁决 + } + + /// 歌单合并:按 stableKey 去重 + updated_at 冲突裁决(Process.md 10.2) + public static IReadOnlyList MergePlaylists( + IReadOnlyList local, IReadOnlyList remote) + { + var map = new Dictionary(); + foreach (var s in local) map[s.StableKey()] = s; + foreach (var s in remote) + { + if (!map.TryGetValue(s.StableKey(), out var existing)) + map[s.StableKey()] = s; + else if (s.AddedAt > existing.AddedAt) + map[s.StableKey()] = s; + } + return map.Values.ToList(); + } +} +``` + +### 9.5 定时同步(Background/Services/SyncScheduledService.cs) + +```csharp +using Microsoft.Extensions.Hosting; +using Quartz; +using Quartz.Impl; + +namespace NeriPlayer.Background.Services; + +/// 对标 WorkManager:每日 02:00 同步 + 手动触发 +public sealed class SyncScheduledService : BackgroundService +{ + private readonly ISyncProvider _provider; + + public SyncScheduledService(ISyncProvider provider) => _provider = provider; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var factory = new StdSchedulerFactory(); + var scheduler = await factory.GetScheduler(stoppingToken); + await scheduler.Start(stoppingToken); + + var job = JobBuilder.Create().Build(); + var trigger = TriggerBuilder.Create() + .WithCronSchedule("0 0 2 * * ?") // 每日 02:00 + .Build(); + await scheduler.ScheduleJob(job, trigger, stoppingToken); + } +} + +public sealed class SyncJob : IJob +{ + public async Task Execute(IJobExecutionContext context) + { + // 1) 收集本地 outbox 变更 + // 2) 拉取远端 checkpoint 之后的新文件 + // 3) MergePlaylists 合并 → 回写 + // 4) 更新 checkpoint 游标 + await Task.CompletedTask; // 实际逻辑见 9.1-9.4 + } +} +``` + +**✅ 验收** +- [ ] GitHub Provider 可上传/下载/列表(用测试 token + 私有空仓库) +- [ ] WebDAV Provider 通过(可用坚果云/Nextcloud 测试) +- [ ] 因果合并裁决:同 key 冲突按 updated_at 取新、因果链正确 +- [ ] 定时任务每天 02:00 触发(日志可查) + +--- +## 十、UI 主框架(第 51-62 天) + +### 10.1 主窗口三栏布局(UI/Views/MainWindow.axaml) + +对标 Process.md 11.1: + +```xml + + + + + + + + + + + + +