From 263e9b31d9cc7996142aebefb7d5823a4528bcfc Mon Sep 17 00:00:00 2001 From: Atdunbg Date: Fri, 26 Jun 2026 21:05:51 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v0.8.0=20=E8=AF=A6=E6=83=85=E9=A1=B5?= =?UTF-8?q?=E6=9E=B6=E6=9E=84=E9=87=8D=E6=9E=84=E3=80=81=E7=9A=AE=E8=82=A4?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E3=80=81=E4=BE=A7=E8=BE=B9=E6=A0=8F=E4=BA=A4?= =?UTF-8?q?=E4=BA=92=E5=8D=87=E7=BA=A7=E3=80=81=E5=BA=95=E5=B1=82=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E6=8B=86=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 详情页统一 DetailLayout 组件(常驻头部 + 独立滚动 + 紧凑态动画) - 歌曲/评论 tab 切换(懒加载 + 保活),专辑模糊搜索 - 皮肤系统 skins.ts(19 语义色 + 14 预设 + 自定义皮肤 + 壁纸) - 设置页皮肤模块(外观/主题色/皮肤编辑器/壁纸选择) - 可折叠侧边栏 + TopBar + 折叠态歌单浮层(高度不越 PlayerBar) - 首页扩展至 6 区块 + 通用卡片组件 + 虚拟歌曲列表 - audio.rs 拆为 8 模块,api.ts 拆为 api/ 目录,player store 拆为 4 store - 修复播放竞态、seek 暂停、评论提前加载、紧凑态卡死等 --- CHANGELOG.md | 61 + package-lock.json | 4 +- package.json | 2 +- src-tauri/Cargo.lock | 16 +- src-tauri/Cargo.toml | 2 +- src-tauri/capabilities/default.json | 14 +- src-tauri/src/api.rs | 44 + src-tauri/src/audio.rs | 1232 ------------------- src-tauri/src/audio/buffer.rs | 203 +++ src-tauri/src/audio/commands.rs | 82 ++ src-tauri/src/audio/controller.rs | 389 ++++++ src-tauri/src/audio/decoder.rs | 314 +++++ src-tauri/src/audio/device.rs | 55 + src-tauri/src/audio/download.rs | 44 + src-tauri/src/audio/mod.rs | 24 + src-tauri/src/audio/output.rs | 253 ++++ src-tauri/src/lib.rs | 27 +- src-tauri/tauri.conf.json | 2 +- src/App.vue | 70 +- src/api.ts | 233 ---- src/api/album.ts | 11 + src/api/app.ts | 21 + src/api/artist.ts | 36 + src/api/audio.ts | 51 + src/api/cloud.ts | 21 + src/api/comment.ts | 16 + src/api/device.ts | 16 + src/api/download.ts | 40 + src/api/fm.ts | 21 + src/api/index.ts | 88 ++ src/api/login.ts | 26 + src/api/playlist.ts | 36 + src/api/rec.ts | 31 + src/api/search.ts | 21 + src/api/song.ts | 36 + src/components/Card/AlbumCard.vue | 82 ++ src/components/Card/ArtistCard.vue | 60 + src/components/Card/CardGrid.vue | 59 + src/components/Card/PlaylistCard.vue | 95 ++ src/components/Card/SectionHeader.vue | 33 + src/components/CommentSection.vue | 4 +- src/components/DetailLayout.vue | 88 ++ src/components/PageHeader.vue | 5 +- src/components/PlayerBar.vue | 15 +- src/components/RoamDrawer.vue | 52 +- src/components/Sidebar.vue | 355 +++--- src/components/SidebarContent.vue | 390 ++++++ src/components/SongItemMenu.vue | 6 +- src/components/TitleBar.vue | 17 +- src/components/TopBar.vue | 328 +++++ src/components/VirtualSongList.vue | 51 + src/composables/UserLyric.ts | 69 +- src/composables/useLyricManager.ts | 185 +++ src/composables/usePageCache.ts | 104 +- src/composables/useWindowControls.ts | 18 + src/stores/liked.ts | 55 + src/stores/migrations/settingsMigrations.ts | 106 ++ src/stores/player.ts | 144 +-- src/stores/recent.ts | 47 + src/stores/settings.ts | 80 +- src/stores/ui.ts | 103 ++ src/style.css | 26 + src/types/song.ts | 33 + src/utils/dom.ts | 15 + src/utils/song.ts | 17 +- src/views/AlbumDetail.vue | 657 ++++++++-- src/views/ArtistDetail.vue | 534 +++++--- src/views/CloudMusic.vue | 2 +- src/views/DailySongs.vue | 2 +- src/views/DownloadedMusic.vue | 2 +- src/views/FavoriteSongs.vue | 2 +- src/views/Home.vue | 462 +++++-- src/views/LocalMusic.vue | 2 +- src/views/PlaylistDetail.vue | 445 +++++-- 74 files changed, 5843 insertions(+), 2449 deletions(-) delete mode 100644 src-tauri/src/audio.rs create mode 100644 src-tauri/src/audio/buffer.rs create mode 100644 src-tauri/src/audio/commands.rs create mode 100644 src-tauri/src/audio/controller.rs create mode 100644 src-tauri/src/audio/decoder.rs create mode 100644 src-tauri/src/audio/device.rs create mode 100644 src-tauri/src/audio/download.rs create mode 100644 src-tauri/src/audio/mod.rs create mode 100644 src-tauri/src/audio/output.rs delete mode 100644 src/api.ts create mode 100644 src/api/album.ts create mode 100644 src/api/app.ts create mode 100644 src/api/artist.ts create mode 100644 src/api/audio.ts create mode 100644 src/api/cloud.ts create mode 100644 src/api/comment.ts create mode 100644 src/api/device.ts create mode 100644 src/api/download.ts create mode 100644 src/api/fm.ts create mode 100644 src/api/index.ts create mode 100644 src/api/login.ts create mode 100644 src/api/playlist.ts create mode 100644 src/api/rec.ts create mode 100644 src/api/search.ts create mode 100644 src/api/song.ts create mode 100644 src/components/Card/AlbumCard.vue create mode 100644 src/components/Card/ArtistCard.vue create mode 100644 src/components/Card/CardGrid.vue create mode 100644 src/components/Card/PlaylistCard.vue create mode 100644 src/components/Card/SectionHeader.vue create mode 100644 src/components/DetailLayout.vue create mode 100644 src/components/SidebarContent.vue create mode 100644 src/components/TopBar.vue create mode 100644 src/components/VirtualSongList.vue create mode 100644 src/composables/useLyricManager.ts create mode 100644 src/composables/useWindowControls.ts create mode 100644 src/stores/liked.ts create mode 100644 src/stores/migrations/settingsMigrations.ts create mode 100644 src/stores/recent.ts create mode 100644 src/stores/ui.ts create mode 100644 src/types/song.ts create mode 100644 src/utils/dom.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b7cc986..89cb915 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,64 @@ +## v0.8.0 + +本次版本为架构重构与交互升级版本,涵盖皮肤系统、详情页架构、侧边栏交互、播放器稳定性、底层模块拆分等方面的系统性优化。 + +### ✨ 新功能 +- **皮肤系统重构**:新增 `skins.ts` 模块,定义 `SkinColors` 色板(bg/surface/subtle/muted/emphasis/content×4/line×2/accent×4/danger×2/warning/info 共 19 个语义色),通过 `applySkinColors()` 写入 CSS 变量到 `document.documentElement`。内置 14 套预设皮肤(7 色 × 深浅),支持自定义皮肤(颜色 + 壁纸 + 模糊度 + 透明度) +- **设置页皮肤模块**:Settings.vue 新增「皮肤」分区——深浅外观切换、7 色主题色选择、自定义皮肤网格(带壁纸预览)、皮肤编辑器(19 个颜色色板 + 壁纸选择 + 模糊/透明度滑杆)。壁纸通过 Rust 命令读取本地图片转 data URL 渲染 +- **顶栏导航**:新增 TopBar 组件,集成前进/后退按钮和搜索框,前进后退按钮仅在与主内容区对齐的位置显示,搜索可直接在顶栏输入,无需再点击搜索选项 +- **可折叠侧边栏**:侧边栏支持展开/折叠/抽屉三种模式,折叠时仅显示图标,窗口过窄自动切换为抽屉模式。折叠/展开触发条采用 SPlayer 风格的双竖条设计,hover 时通过 ±12deg 旋转形成方向箭头(参考 naive-ui n-layout-toggle-bar) +- **折叠态歌单浮层**:侧边栏折叠后,hover「我的歌单」「收藏的歌单」图标弹出浮层显示歌单列表,通过 Teleport 渲染到 body 避免被裁剪。浮层高度动态计算,底部永不越过 PlayerBar +- **首页内容扩展**:首页新增「新歌速递」「热门歌手」「新碟上架」三个区块,配合原有的「推荐歌单」「每日推荐」「热门歌单」,共 6 个内容区块 +- **通用卡片组件**:新增 PlaylistCard、ArtistCard、AlbumCard、SectionHeader、CardGrid 五个可复用卡片组件,统一封面、标题、副标题、悬浮播放按钮的展示风格 +- **虚拟歌曲列表**:新增 VirtualSongList 组件,复用 SongListItem 并支持自定义配置 +- **推荐 API**:后端新增 personalized、personalized_newsong、top_artists、top_song、album_newest 五个 Tauri 命令,前端新增 rec.ts API 模块 +- **详情页统一架构**:新增 DetailLayout 组件,统一歌单/专辑/歌手三个详情页布局——头部常驻 + 内容独立滚动 + 滚动触发紧凑态(参考 SPlayer ListDetail/useListScroll)。`absolute;inset:0` 脱离主滚动流,flex 列布局让 header `flex-shrink:0` 不被带走,content `flex-1 min-h:0` 独立滚动 +- **紧凑态动画**:滚动列表超过 10px 时触发头部收缩,封面/标题缩小、元信息折叠隐藏、按钮缩小,所有过渡统一 `cubic-bezier(0.4, 0, 0.2, 1)` 0.3s,参考 SPlayer `.small` 类行为 +- **歌曲/评论 tab 切换**:三个详情页头部均加入歌曲/评论(或热门歌曲/专辑)tab 切换。songs 用 `v-if` 仅激活时渲染,comments 用「首次激活后保活」模式(`v-if` 控制挂载 + `v-show` 控制显示),保留滚动位置且不提前发起评论 API 请求 +- **专辑模糊搜索**:专辑详情页头部加入模糊搜索框,按歌名/歌手/专辑名实时过滤歌曲列表,播放全部时只播过滤结果 +- **本地音乐多文件夹管理**:本地音乐支持添加多个扫描文件夹,每条可独立启用/禁用/移除,扫描时跳过禁用文件夹 + +### 🐛 修复 +- **个性化推荐错乱**:首页聚合缓存在未登录时生成,登录后未清除导致「依据口味推荐」显示的全站数据。改为登录/登出时清除首页缓存并重新加载,区分登录态调用 `recommend_resource`(个性化)和 `personalized`(全站) +- **播放竞态条件**:切歌、播放/暂停、拖动进度条等异步操作可能产生竞态冲突。引入序列号(`_playSeq`)和状态锁(`_switchingSong`)防护 +- **拖动进度条暂停修复**:seek 过程中状态不一致导致播放/暂停混乱 +- **折叠态歌单不可用**:折叠时歌单图标用 popover 浮层但被 `overflow-y-auto` 容器裁剪,导致「空有两个按钮没有实际作用」。改用 Teleport + fixed 定位渲染浮层 +- **折叠态浮层被 PlayerBar 遮挡**:浮层原用固定 `max-h-60`,触发图标靠下时底部歌单被 PlayerBar 盖住。改为按视口高度动态计算 maxHeight,浮层底部永不越过 PlayerBar 顶部 +- **详情页头部被滚走**:原实现头部和列表在同一滚动容器中,向下滚动时封面/标题/按钮全部滚走。重构为 DetailLayout 后头部 `flex-shrink:0` 常驻顶部,列表独立滚动 +- **评论 tab 提前加载**:原 PlaylistDetail 用 `v-show` 包裹 CommentSection 导致组件挂载即触发评论 API。改为懒加载 + 保活模式,首次切到评论 tab 才发起请求 +- **切换 tab 紧凑态卡死**:评论/歌曲高度差异大,切换 tab 后紧凑态可能卡在错误状态。新增 `watch(currentTab)` 调用 `resetScroll()` 重置滚动和紧凑态 +- **侧边栏折叠按钮非箭头**:原实现用单根竖条倾斜,hover 时呈斜线非箭头。改为 naive-ui 双竖条方案:两根 4×38px 竖条 4px 重叠,旋转 ±12deg 形成 `<` 或 `>` 箭头 +- **箭头重叠区颜色加深**:opacity 应用到单个竖条导致重叠区颜色叠加变深。改为 opacity 应用到容器(默认 0.5,hover 1.0),竖条使用纯色 +- **歌词截断与抖动**:长歌词被 `whitespace-nowrap` 截断。移除该属性并加 `word-break:break-word` + `overflow-wrap:anywhere`;活跃行字号变化导致抖动,锁定 `line-height:1.5` +- **滚动条主题色丢失**:之前误将 `--c-content-3` 用作 thumb 颜色。从 git 历史恢复原实现:hover 容器显示 `--c-muted`,hover thumb 变 `--c-emphasis`,主题切换自动跟随 +- **专辑标题字号变化抖动**:从 30px 切到 22px 时容器高度变化导致布局抖动。固定 `line-height:1.3` + `min-height:39px`,过渡期间容器高度恒定 +- **AlbumDetail 重复绑定滚动**:keep-alive 首次挂载时 onMounted 和 onActivated 都触发 `bindScroll()`。重构后 DetailLayout 自管滚动监听,AlbumDetail 移除全部 bindScroll/unbindScroll 逻辑 + +### 🎨 变更 +- **侧边栏布局重构**:侧边栏改为全高布局,TopBar 仅在主内容区上方,前进/后退按钮与主内容对齐。侧边栏顶部新增 Logo 占位区(与 TopBar 等高) +- **侧边栏透明化**:移除侧边栏和顶栏的不透明背景色,仅靠一条 40% 透明度的边框线分隔,壁纸/主题色从底层透出,实现「半隐藏」视觉 +- **文字不乱跳**:侧边栏展开/折叠过渡时,菜单文字改为 `max-width` + `overflow-hidden` + `whitespace-nowrap` 平滑过渡,不再因宽度变化导致换行乱跳 +- **顶栏透明化**:TopBar 移除背景色,加底部细线与侧边栏右边框呼应 +- **专辑封面立体感**:专辑详情页封面加入模糊阴影背板(`blur(12px) opacity(0.6) scale(0.92,0.96)`)和顶部渐变遮罩,紧凑时遮罩淡出,参考 SPlayer `.cover-shadow`/`.cover-mask` +- **漫游抽屉外观一体化**:RoamDrawer 改用 `backdrop-blur-xl` 配合皮肤系统色板,外观跟随当前皮肤 +- **全局样式精简**:`style.css` 精简约 298 行,原硬编码色值迁移至皮肤 CSS 变量系统 + +### ⚡ 优化 +- **音频模块拆分**:将单文件 audio.rs(1228 行)拆分为 audio/ 目录下 8 个模块(mod/buffer/commands/controller/decoder/device/download/output),提升可维护性 +- **状态管理职责分离**:将单一 player store 拆分为 player、liked、recent、ui 四个职责单一的 store +- **类型定义抽取**:Song 等类型定义抽取到 types/song.ts +- **API 层领域分离**:将 api.ts(225 行)拆分为 api/ 目录下 song/playlist/artist/album/search/rec/comment/device 等领域模块,index.ts 作向后兼容层 +- **两级缓存**:页面数据和歌词实现 L1 内存 + L2 localStorage 两级缓存,带 TTL 和配额管理 +- **设置迁移**:设置数据引入版本号和迁移机制(migrations/settingsMigrations.ts),支持未来 schema 变更 +- **AlbumDetail 重构接入 DetailLayout**:原本自行实现 absolute 布局 + isCompact + 滚动监听,与 DetailLayout 逻辑重复。重构后改用 DetailLayout 组件 + slot prop,删除约 100 行重复代码(onScroll/bindScroll/unbindScroll/resetScrollState/listScrollRef/rafId 等) +- **窗口控制逻辑抽取**:TitleBar 与 TopBar 重复实现 minimize/toggleMaximize。抽取为 `composables/useWindowControls.ts` 共用 +- **溢出检测抽取**:PlaylistDetail 与 ArtistDetail 重复实现 `checkDescOverflow`。抽取为 `utils/dom.ts` 的 `checkOverflow` 函数共用 +- **骨架动画全局化**:`@keyframes pulse` 原在三个详情页分别定义,移至全局 `style.css` 共用 +- **死代码清理**:删除未使用的 `pageCacheDelete`(与 `pageCacheInvalidate` 重复)、`getAlbumDisplay`、`stores/player.ts` 中冗余的 `Song/PlayMode` re-export +- **错误日志可读性**:9 处裸 `console.error(e)` 添加描述性前缀(如 `console.error('获取歌单详情失败', e)`),方便定位 +- **AlbumDetail watcher 合并**:原本监听 `route.params.id` 的两个 watcher(一个重置状态、一个 fetchAlbum)合并为一个 +- **新增后端命令**:`read_image_as_data_url`(读取本地图片转 data URL,供壁纸预览使用)、`show_item_in_folder`(在文件管理器中打开文件位置) + ## v0.7.0 ### ✨ 新功能 diff --git a/package-lock.json b/package-lock.json index 11a0039..8295c86 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nekosonic", - "version": "0.6.0", + "version": "0.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nekosonic", - "version": "0.6.0", + "version": "0.8.0", "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.7.1", diff --git a/package.json b/package.json index 266903b..e0f46f3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "nekosonic", "private": true, - "version": "0.7.0", + "version": "0.8.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9629829..b73452f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "Nekosonic" -version = "0.7.0" +version = "0.8.0" dependencies = [ "base64 0.22.1", "cpal", @@ -65,9 +65,9 @@ checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" [[package]] name = "alloc-stdlib" -version = "0.2.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" dependencies = [ "alloc-no-stdlib", ] @@ -472,9 +472,9 @@ dependencies = [ [[package]] name = "brotli" -version = "8.0.3" +version = "8.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -483,9 +483,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.1" +version = "5.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -5854,7 +5854,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c021572..1bf08b9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "Nekosonic" -version = "0.7.0" +version = "0.8.0" description = "A Simple music app" authors = ["atdunbg"] edition = "2021" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index d2fe68e..f216ddd 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -5,17 +5,15 @@ "windows": ["main"], "permissions": [ "core:default", - "opener:default", + "core:window:default", "core:window:allow-minimize", + "core:window:allow-unminimize", "core:window:allow-maximize", "core:window:allow-unmaximize", - "core:window:allow-close", - "core:window:allow-hide", + "core:window:allow-is-maximized", "core:window:allow-start-dragging", - "core:window:allow-toggle-maximize", - "core:window:allow-unminimize", - "core:window:allow-show", - "core:window:allow-set-focus", + "core:path:default", + "opener:default", "global-shortcut:allow-is-registered", "global-shortcut:allow-register", "global-shortcut:allow-unregister", @@ -23,4 +21,4 @@ "process:allow-restart", "updater:default" ] -} +} \ No newline at end of file diff --git a/src-tauri/src/api.rs b/src-tauri/src/api.rs index 20ca024..9a15255 100644 --- a/src-tauri/src/api.rs +++ b/src-tauri/src/api.rs @@ -343,6 +343,50 @@ pub async fn recommend_resource(state: State<'_, ApiController>) -> Result, state: State<'_, ApiController>) -> Result { + api_call!(state, personalized, params: [ + ("limit", &limit.unwrap_or(30).to_string()) + ]) +} + +/// 推荐新歌 +/// 对应 /personalized/newsong +#[tauri::command] +pub async fn personalized_newsong(limit: Option, state: State<'_, ApiController>) -> Result { + api_call!(state, personalized_newsong, params: [ + ("limit", &limit.unwrap_or(10).to_string()) + ]) +} + +/// 热门歌手 +/// 对应 /top/artists +#[tauri::command] +pub async fn top_artists(limit: Option, offset: Option, state: State<'_, ApiController>) -> Result { + api_call!(state, top_artists, params: [ + ("limit", &limit.unwrap_or(30).to_string()), + ("offset", &offset.unwrap_or(0).to_string()) + ]) +} + +/// 新歌速递 +/// 对应 /top/song,type: 全部:0 / 华语:7 / 欧美:96 / 韩国:16 / 日本:8 +#[tauri::command] +pub async fn top_song(area_type: Option, state: State<'_, ApiController>) -> Result { + api_call!(state, top_song, params: [ + ("type", &area_type.unwrap_or(0).to_string()) + ]) +} + +/// 最新专辑(新碟上架) +/// 对应 /album/newest +#[tauri::command] +pub async fn album_newest(state: State<'_, ApiController>) -> Result { + api_call!(state, album_newest) +} + /// 私人漫游模式查询参数 #[derive(Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs deleted file mode 100644 index 2585223..0000000 --- a/src-tauri/src/audio.rs +++ /dev/null @@ -1,1232 +0,0 @@ -use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; -use cpal::{SampleRate, Stream, StreamConfig}; -use ringbuf::{HeapCons, HeapProd, HeapRb, traits::{Split, Producer, Consumer}}; -use std::io::Read; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::mpsc::{channel, Receiver, Sender}; -use std::sync::{Arc, Condvar, Mutex}; -use std::thread; -use std::time::Duration; -use symphonia::core::audio::{AudioBufferRef, Signal}; -use symphonia::core::codecs::{DecoderOptions, CODEC_TYPE_NULL}; -use symphonia::core::errors::Error as SymphoniaError; -use symphonia::core::formats::{FormatOptions, SeekMode, SeekTo}; -use symphonia::core::io::{MediaSource, MediaSourceStream}; -use symphonia::core::meta::MetadataOptions; -use symphonia::core::probe::Hint; -use symphonia::core::units::Time; -use tauri::AppHandle; -use tauri::Emitter; - -/// 音频控制命令枚举,用于音频线程之间的消息传递 -enum AudioCmd { - Play(String), - PlayLocal(String), - Pause, - Resume, - Stop, - Seek(f64), - SetVolume(f32), - SetDevice(Option), -} - -/// 音频控制器,通过通道向音频线程发送控制命令 -pub struct AudioController { - tx: Sender, - current_url: Arc>>, - position: Arc>, - is_playing: Arc, -} - -impl AudioController { - /// 创建新的音频控制器,并启动后台音频线程 - pub fn new(app_handle: AppHandle) -> Self { - let (tx, rx) = channel(); - let current_url = Arc::new(Mutex::new(None)); - let position = Arc::new(Mutex::new(0.0)); - let is_playing = Arc::new(AtomicBool::new(false)); - let url_clone = current_url.clone(); - let pos_clone = position.clone(); - let playing_clone = is_playing.clone(); - let ah_clone = app_handle.clone(); - thread::spawn(move || audio_thread(rx, url_clone, pos_clone, playing_clone, ah_clone)); - AudioController { tx, current_url, position, is_playing } - } - - /// 播放指定URL的网络音频 - pub fn play_url(&self, url: &str) { - *self.current_url.lock().unwrap() = Some(url.to_string()); - let _ = self.tx.send(AudioCmd::Play(url.to_string())); - } - /// 播放指定路径的本地音频文件 - pub fn play_local(&self, path: &str) { - *self.current_url.lock().unwrap() = Some(path.to_string()); - let _ = self.tx.send(AudioCmd::PlayLocal(path.to_string())); - } - /// 暂停当前播放 - pub fn pause(&self) { let _ = self.tx.send(AudioCmd::Pause); } - /// 恢复播放 - pub fn resume(&self) { let _ = self.tx.send(AudioCmd::Resume); } - /// 停止当前播放 - pub fn stop(&self) { let _ = self.tx.send(AudioCmd::Stop); } - /// 设置音频输出设备,传入 None 则使用系统默认设备 - pub fn set_device(&self, device: Option) { - let _ = self.tx.send(AudioCmd::SetDevice(device)); - } - /// 跳转到指定时间位置(秒) - pub fn seek(&self, time: f64) { let _ = self.tx.send(AudioCmd::Seek(time)); } - /// 设置播放音量,范围 0.0 ~ 1.0 - pub fn set_volume(&self, vol: f32) { let _ = self.tx.send(AudioCmd::SetVolume(vol)); } - /// 获取当前播放位置(秒) - pub fn get_position(&self) -> f64 { - *self.position.lock().unwrap() - } - pub fn get_is_playing(&self) -> bool { - self.is_playing.load(Ordering::Relaxed) - } -} - -/// 缓冲区内部状态,存储已下载的字节数据及完成/取消标志 -struct BufferState { - bytes: Vec, - done: bool, - cancelled: bool, -} - -/// 线程安全的共享缓冲区,支持生产者写入和消费者读取的同步等待 -struct SharedBuffer { - state: Mutex, - available: Condvar, -} - -impl SharedBuffer { - /// 创建新的空共享缓冲区 - fn new() -> Self { - SharedBuffer { - state: Mutex::new(BufferState { - bytes: Vec::new(), - done: false, - cancelled: false, - }), - available: Condvar::new(), - } - } - - /// 向缓冲区追加写入一块数据,并通知等待的读取者 - fn write_chunk(&self, chunk: &[u8]) { - let mut state = self.state.lock().unwrap(); - state.bytes.extend_from_slice(chunk); - self.available.notify_all(); - } - - /// 标记缓冲区写入已完成,通知读取者不再有新数据 - fn mark_done(&self) { - let mut state = self.state.lock().unwrap(); - state.done = true; - self.available.notify_all(); - } - - /// 取消缓冲区,中断正在进行的读写操作 - fn cancel(&self) { - let mut state = self.state.lock().unwrap(); - state.cancelled = true; - self.available.notify_all(); - } - - /// 返回已缓冲的数据字节数 - fn len(&self) -> usize { - self.state.lock().unwrap().bytes.len() - } - - /// 检查缓冲区是否已标记为写入完成 - fn is_done(&self) -> bool { - self.state.lock().unwrap().done - } - - /// 检查缓冲区是否已被取消 - fn is_cancelled(&self) -> bool { - self.state.lock().unwrap().cancelled - } -} - -/// 流式读取器,从共享缓冲区中按需读取数据,实现 `Read` 和 `Seek` trait -struct StreamingReader { - buffer: Arc, - pos: usize, -} - -impl StreamingReader { - /// 创建新的流式读取器,绑定到指定的共享缓冲区 - fn new(buffer: Arc) -> Self { - StreamingReader { buffer, pos: 0 } - } -} - -impl Read for StreamingReader { - fn read(&mut self, buf: &mut [u8]) -> std::io::Result { - let mut state = self.buffer.state.lock().unwrap(); - loop { - let available = state.bytes.len().saturating_sub(self.pos); - if available > 0 { - let to_read = std::cmp::min(buf.len(), available); - buf[..to_read].copy_from_slice(&state.bytes[self.pos..self.pos + to_read]); - self.pos += to_read; - return Ok(to_read); - } - if state.done { - return Ok(0); - } - if state.cancelled { - return Err(std::io::Error::new(std::io::ErrorKind::Interrupted, "cancelled")); - } - let result = self - .buffer - .available - .wait_timeout(state, Duration::from_millis(500)) - .unwrap(); - state = result.0; - } - } -} - -impl std::io::Seek for StreamingReader { - fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result { - let new_pos = match pos { - std::io::SeekFrom::Start(offset) => offset as i64, - std::io::SeekFrom::Current(offset) => self.pos as i64 + offset, - std::io::SeekFrom::End(offset) => { - let mut state = self.buffer.state.lock().unwrap(); - loop { - if state.done { - break state.bytes.len() as i64 + offset; - } - if state.cancelled { - return Err(std::io::Error::new( - std::io::ErrorKind::Interrupted, - "cancelled", - )); - } - let result = self - .buffer - .available - .wait_timeout(state, Duration::from_millis(500)) - .unwrap(); - state = result.0; - } - } - }; - if new_pos < 0 { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "seek before start", - )); - } - let mut state = self.buffer.state.lock().unwrap(); - loop { - if new_pos as usize <= state.bytes.len() { - self.pos = new_pos as usize; - return Ok(self.pos as u64); - } - if state.done { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "seek past end", - )); - } - if state.cancelled { - return Err(std::io::Error::new( - std::io::ErrorKind::Interrupted, - "cancelled", - )); - } - let result = self - .buffer - .available - .wait_timeout(state, Duration::from_millis(500)) - .unwrap(); - state = result.0; - } - } -} - -impl MediaSource for StreamingReader { - fn is_seekable(&self) -> bool { true } - fn byte_len(&self) -> Option { None } -} - -/// 流式下载音频数据到共享缓冲区,支持下载进度事件通知 -fn download_audio_streaming( - url: &str, - buffer: &SharedBuffer, - app_handle: &AppHandle, -) -> Result<(), String> { - let resp = reqwest::blocking::get(url).map_err(|e| format!("下载失败: {}", e))?; - if !resp.status().is_success() { - return Err(format!("HTTP 错误: {}", resp.status())); - } - let total_size = resp.content_length().unwrap_or(0); - let mut downloaded: u64 = 0; - let mut reader = resp; - loop { - if buffer.is_cancelled() { - return Err("下载已取消".to_string()); - } - let mut chunk = [0u8; 8192]; - let read_size = reader - .read(&mut chunk) - .map_err(|e| format!("读取失败: {}", e))?; - if read_size == 0 { - break; - } - buffer.write_chunk(&chunk[..read_size]); - downloaded += read_size as u64; - let progress = if total_size > 0 { - (downloaded as f64 / total_size as f64) * 100.0 - } else { - 0.0 - }; - let _ = app_handle.emit("cache-progress", progress); - } - Ok(()) -} - -/// 初始缓冲区大小,达到此字节数后才开始播放 -const INITIAL_BUFFER_SIZE: usize = 65536; -/// 环形缓冲区容量(采样数),约 4 秒的 48kHz 立体声数据 -const RING_BUFFER_SAMPLES: usize = 48000 * 4; - -/// 播放状态,记录当前播放的运行时信息 -struct PlaybackState { - playing: Arc, - cancelled: Arc, - decode_done: Arc, - buffer_exhausted: Arc, - volume: Arc>, - sample_rate: u32, - channels: u16, - samples_played: Arc, - start_time: f64, -} - -impl PlaybackState { - /// 根据已播放采样数计算当前播放位置(秒) - fn position(&self) -> f64 { - let samples = self.samples_played.load(Ordering::Relaxed) as f64; - self.start_time + samples / (self.sample_rate as f64 * self.channels as f64) - } -} - -/// 输出上下文,持有音频输出流和解码线程的句柄 -struct OutputContext { - _stream: Stream, - _decode_thread: thread::JoinHandle<()>, - playback: PlaybackState, -} - -/// 将 Symphonia 解码后的音频缓冲区转换为交错排列的 f32 采样数据 -fn convert_to_interleaved_f32(decoded: &AudioBufferRef) -> Vec { - let channels = decoded.spec().channels.count(); - let frames = decoded.frames(); - let mut out = Vec::with_capacity(frames * channels); - - match decoded { - AudioBufferRef::U8(buf) => { - for frame in 0..frames { - for ch in 0..channels { - out.push(buf.chan(ch)[frame] as f32 / u8::MAX as f32 * 2.0 - 1.0); - } - } - } - AudioBufferRef::U16(buf) => { - for frame in 0..frames { - for ch in 0..channels { - out.push(buf.chan(ch)[frame] as f32 / u16::MAX as f32 * 2.0 - 1.0); - } - } - } - AudioBufferRef::U24(buf) => { - for frame in 0..frames { - for ch in 0..channels { - out.push(buf.chan(ch)[frame].0 as f32 / 8388607.0); - } - } - } - AudioBufferRef::U32(buf) => { - for frame in 0..frames { - for ch in 0..channels { - out.push(buf.chan(ch)[frame] as f32 / u32::MAX as f32 * 2.0 - 1.0); - } - } - } - AudioBufferRef::S8(buf) => { - for frame in 0..frames { - for ch in 0..channels { - out.push(buf.chan(ch)[frame] as f32 / i8::MAX as f32); - } - } - } - AudioBufferRef::S16(buf) => { - for frame in 0..frames { - for ch in 0..channels { - out.push(buf.chan(ch)[frame] as f32 / i16::MAX as f32); - } - } - } - AudioBufferRef::S24(buf) => { - for frame in 0..frames { - for ch in 0..channels { - out.push(buf.chan(ch)[frame].0 as f32 / 8388607.0); - } - } - } - AudioBufferRef::S32(buf) => { - for frame in 0..frames { - for ch in 0..channels { - out.push(buf.chan(ch)[frame] as f32 / i32::MAX as f32); - } - } - } - AudioBufferRef::F32(buf) => { - for frame in 0..frames { - for ch in 0..channels { - out.push(buf.chan(ch)[frame]); - } - } - } - AudioBufferRef::F64(buf) => { - for frame in 0..frames { - for ch in 0..channels { - out.push(buf.chan(ch)[frame] as f32); - } - } - } - } - - out -} - -/// 重混声道数,将交错采样数据从源声道数转换为目标声道数 -fn remix_channels( - interleaved: &[f32], - src_channels: u16, - target_channels: u16, - src_frames: usize, -) -> Vec { - if src_channels == target_channels { - return interleaved.to_vec(); - } - - let src_ch = src_channels as usize; - let tgt_ch = target_channels as usize; - let mut out = Vec::with_capacity(src_frames * tgt_ch); - - if src_ch == 1 && tgt_ch == 2 { - for &s in interleaved { - out.push(s); - out.push(s); - } - } else if src_ch == 2 && tgt_ch == 1 { - for i in 0..src_frames { - let l = interleaved[i * 2]; - let r = interleaved[i * 2 + 1]; - out.push((l + r) * 0.5); - } - } else { - for i in 0..src_frames { - for ch in 0..tgt_ch { - let src_ch_idx = ch.min(src_ch.saturating_sub(1)); - out.push(interleaved[i * src_ch + src_ch_idx]); - } - } - } - - out -} - -/// 对解码音频进行重采样和声道重混,输出目标采样率和声道数的交错 f32 数据 -fn resample_and_remix( - decoded: &AudioBufferRef, - target_sample_rate: u32, - target_channels: u16, - src_rate: f64, - src_channels: u16, -) -> Vec { - let interleaved = convert_to_interleaved_f32(decoded); - let src_frames = if src_channels > 0 { - interleaved.len() / src_channels as usize - } else { - 0 - }; - - if src_frames == 0 { - return Vec::new(); - } - - let remixed = remix_channels(&interleaved, src_channels, target_channels, src_frames); - let remixed_ch = target_channels as usize; - - let ratio = target_sample_rate as f64 / src_rate; - let need_resample = (ratio - 1.0).abs() > 0.001; - - if !need_resample { - return remixed; - } - - let target_frames = (src_frames as f64 * ratio).round() as usize; - if target_frames == 0 { - return Vec::new(); - } - - let mut out = Vec::with_capacity(target_frames * remixed_ch); - for i in 0..target_frames { - let src_pos = i as f64 / ratio; - let src_idx = src_pos as usize; - let frac = src_pos - src_idx as f64; - let next_idx = (src_idx + 1).min(src_frames - 1); - - for ch in 0..remixed_ch { - let s0 = remixed[src_idx * remixed_ch + ch]; - let s1 = remixed[next_idx * remixed_ch + ch]; - out.push(s0 + (s1 - s0) * frac as f32); - } - } - - out -} - -/// 将音频数据解码并写入环形缓冲区,供播放回调消费 -fn decode_to_ring( - mss: MediaSourceStream, - mut producer: HeapProd, - playing: Arc, - cancelled: Arc, - decode_done: Arc, - seek_time: Option, - target_sample_rate: u32, - target_channels: u16, -) { - let hint = Hint::new(); - let format_opts = FormatOptions { - enable_gapless: true, - ..Default::default() - }; - let metadata_opts = MetadataOptions::default(); - let decoder_opts = DecoderOptions::default(); - - let probed = match symphonia::default::get_probe().format(&hint, mss, &format_opts, &metadata_opts) { - Ok(p) => p, - Err(e) => { - eprintln!("[audio] 探测格式失败: {}", e); - decode_done.store(true, Ordering::Relaxed); - return; - } - }; - - let mut format_reader = probed.format; - let track = match format_reader - .tracks() - .iter() - .find(|t| t.codec_params.codec != CODEC_TYPE_NULL) - { - Some(t) => t, - None => { - eprintln!("[audio] 未找到有效音频轨道"); - decode_done.store(true, Ordering::Relaxed); - return; - } - }; - - let track_id = track.id; - let codec_params = &track.codec_params; - let src_rate = codec_params.sample_rate.unwrap_or(44100) as f64; - let src_channels = codec_params.channels.unwrap_or_else(|| { - symphonia::core::audio::Channels::FRONT_LEFT | symphonia::core::audio::Channels::FRONT_RIGHT - }).count() as u16; - - let mut decoder = match symphonia::default::get_codecs().make(codec_params, &decoder_opts) { - Ok(d) => d, - Err(e) => { - eprintln!("[audio] 创建解码器失败: {}", e); - decode_done.store(true, Ordering::Relaxed); - return; - } - }; - - if let Some(time) = seek_time { - let seek_to = SeekTo::Time { - time: Time::from(time), - track_id: Some(track_id), - }; - let _ = format_reader.seek(SeekMode::Accurate, seek_to); - } - - let ratio = target_sample_rate as f64 / src_rate; - let need_resample = (ratio - 1.0).abs() > 0.001; - let need_remix = src_channels != target_channels; - - while !cancelled.load(Ordering::Relaxed) { - let packet = match format_reader.next_packet() { - Ok(p) => p, - Err(SymphoniaError::IoError(ref e)) - if e.kind() == std::io::ErrorKind::UnexpectedEof => - { - break; - } - Err(SymphoniaError::ResetRequired) => continue, - Err(e) => { - eprintln!("[audio] 读取包失败: {}", e); - break; - } - }; - - if packet.track_id() != track_id { - continue; - } - - let decoded = match decoder.decode(&packet) { - Ok(d) => d, - Err(e) => { - eprintln!("[audio] 解码失败: {}", e); - continue; - } - }; - - let samples = if need_resample || need_remix { - resample_and_remix(&decoded, target_sample_rate, target_channels, src_rate, src_channels) - } else { - convert_to_interleaved_f32(&decoded) - }; - - let mut write_pos = 0; - while write_pos < samples.len() && !cancelled.load(Ordering::Relaxed) { - let remaining = &samples[write_pos..]; - let n = producer.push_slice(remaining); - if n == 0 { - if !playing.load(Ordering::Relaxed) { - while !playing.load(Ordering::Relaxed) && !cancelled.load(Ordering::Relaxed) { - thread::sleep(Duration::from_millis(10)); - } - } - thread::sleep(Duration::from_millis(1)); - continue; - } - write_pos += n; - } - } - - decode_done.store(true, Ordering::Relaxed); -} - -/// 启动音频播放,创建解码线程和 cpal 输出流 -fn start_playback( - mss: MediaSourceStream, - device: &cpal::Device, - current_volume: f32, - seek_time: Option, -) -> Result { - let default_config = device - .default_output_config() - .map_err(|e| format!("获取设备配置失败: {}", e))?; - - let sr = default_config.sample_rate().0; - let ch = default_config.channels(); - let sample_format = default_config.sample_format(); - - let rb = HeapRb::::new(RING_BUFFER_SAMPLES); - let (producer, consumer) = rb.split(); - - let playing = Arc::new(AtomicBool::new(true)); - let cancelled = Arc::new(AtomicBool::new(false)); - let decode_done = Arc::new(AtomicBool::new(false)); - let buffer_exhausted = Arc::new(AtomicBool::new(false)); - let volume = Arc::new(Mutex::new(current_volume)); - let samples_played = Arc::new(AtomicU64::new(0)); - let start_time = seek_time.unwrap_or(0.0); - - let playing_clone = playing.clone(); - let cancelled_clone = cancelled.clone(); - let decode_done_clone = decode_done.clone(); - let decode_handle = thread::spawn(move || { - decode_to_ring( - mss, - producer, - playing_clone, - cancelled_clone, - decode_done_clone, - seek_time, - sr, - ch, - ); - }); - - let stream = build_cpal_stream(device, sr, ch, sample_format, consumer, volume.clone(), playing.clone(), samples_played.clone(), decode_done.clone(), buffer_exhausted.clone())?; - stream.play().map_err(|e| format!("播放流失败: {}", e))?; - - Ok(OutputContext { - _stream: stream, - _decode_thread: decode_handle, - playback: PlaybackState { - playing, - cancelled, - decode_done, - buffer_exhausted, - volume, - sample_rate: sr, - channels: ch, - samples_played, - start_time, - }, - }) -} - -/// 构建 cpal 音频输出流,支持 f32、i16、u16 三种采样格式 -fn build_cpal_stream( - device: &cpal::Device, - sample_rate: u32, - channels: u16, - sample_format: cpal::SampleFormat, - mut consumer: HeapCons, - volume: Arc>, - playing: Arc, - samples_played: Arc, - decode_done: Arc, - buffer_exhausted: Arc, -) -> Result { - let config = StreamConfig { - channels, - sample_rate: SampleRate(sample_rate), - buffer_size: cpal::BufferSize::Default, - }; - - let err_fn = |err: cpal::StreamError| eprintln!("[audio] 输出错误: {}", err); - - match sample_format { - cpal::SampleFormat::F32 => { - let sp = samples_played; - let dd = decode_done; - let be = buffer_exhausted; - device - .build_output_stream( - &config, - move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { - if !playing.load(Ordering::Relaxed) { - data.fill(0.0); - return; - } - let vol = *volume.lock().unwrap(); - let read = consumer.pop_slice(data); - for (i, s) in data.iter_mut().enumerate() { - if i < read { - *s *= vol; - } else { - *s = 0.0; - } - } - sp.fetch_add(read as u64, Ordering::Relaxed); - if read == 0 && dd.load(Ordering::Relaxed) { - be.store(true, Ordering::Relaxed); - } - }, - err_fn, - None, - ) - .map_err(|e| format!("创建输出流失败: {}", e)) - } - - cpal::SampleFormat::I16 => { - let mut f32_buf: Vec = Vec::new(); - let sp = samples_played; - let dd = decode_done; - let be = buffer_exhausted; - device - .build_output_stream( - &config, - move |data: &mut [i16], _: &cpal::OutputCallbackInfo| { - if !playing.load(Ordering::Relaxed) { - data.fill(0); - return; - } - let vol = *volume.lock().unwrap(); - if f32_buf.len() != data.len() { - f32_buf.resize(data.len(), 0.0); - } - let read = consumer.pop_slice(&mut f32_buf); - for (i, s) in data.iter_mut().enumerate() { - if i < read { - *s = (f32_buf[i] * vol * 32767.0) - .clamp(-32768.0, 32767.0) as i16; - } else { - *s = 0; - } - } - sp.fetch_add(read as u64, Ordering::Relaxed); - if read == 0 && dd.load(Ordering::Relaxed) { - be.store(true, Ordering::Relaxed); - } - }, - err_fn, - None, - ) - .map_err(|e| format!("创建输出流失败: {}", e)) - } - - cpal::SampleFormat::U16 => { - let mut f32_buf: Vec = Vec::new(); - let sp = samples_played; - let dd = decode_done; - let be = buffer_exhausted; - device - .build_output_stream( - &config, - move |data: &mut [u16], _: &cpal::OutputCallbackInfo| { - if !playing.load(Ordering::Relaxed) { - data.fill(32768); - return; - } - let vol = *volume.lock().unwrap(); - if f32_buf.len() != data.len() { - f32_buf.resize(data.len(), 0.0); - } - let read = consumer.pop_slice(&mut f32_buf); - for (i, s) in data.iter_mut().enumerate() { - if i < read { - *s = ((f32_buf[i] * vol + 1.0) * 32767.5) - .clamp(0.0, 65535.0) as u16; - } else { - *s = 32768; - } - } - sp.fetch_add(read as u64, Ordering::Relaxed); - if read == 0 && dd.load(Ordering::Relaxed) { - be.store(true, Ordering::Relaxed); - } - }, - err_fn, - None, - ) - .map_err(|e| format!("创建输出流失败: {}", e)) - } - - _ => Err(format!("不支持的采样格式: {:?}", sample_format)), - } -} - -/// 获取系统默认输出设备的名称 -fn get_system_default_device_name() -> Option { - cpal::default_host() - .default_output_device() - .and_then(|d| d.name().ok()) -} - -/// 列出系统中所有可用的音频输出设备名称(去重排序后) -pub fn list_output_devices() -> Vec { - let host = cpal::default_host(); - if let Ok(devices) = host.output_devices() { - let mut names: Vec = devices.filter_map(|d| d.name().ok()).collect(); - names.sort(); - names.dedup(); - names - } else { - vec![] - } -} - -/// 按名称查找音频输出设备,未找到则返回 None -fn find_device_by_name(name: &str) -> Option { - let host = cpal::default_host(); - if let Ok(devices) = host.output_devices() { - for d in devices { - if let Ok(n) = d.name() { - if n == name { - return Some(d); - } - } - } - } - None -} - -/// 获取音频输出设备,优先使用指定名称的设备,否则回退到系统默认设备 -fn get_output_device(selected: &Option) -> cpal::Device { - match selected { - Some(name) => find_device_by_name(name).unwrap_or_else(|| { - eprintln!("[audio] 未找到设备 `{}`,回退默认", name); - cpal::default_host() - .default_output_device() - .expect("无可用音频设备") - }), - None => cpal::default_host() - .default_output_device() - .expect("无可用音频设备"), - } -} - -/// 停止播放,取消解码并重置共享播放位置 -fn stop_playback(output_ctx: &mut Option, shared_position: &Arc>) { - if let Some(ref mut ctx) = output_ctx { - ctx.playback.cancelled.store(true, Ordering::Relaxed); - ctx.playback.playing.store(false, Ordering::Relaxed); - } - *output_ctx = None; - *shared_position.lock().unwrap() = 0.0; -} - -fn rebuild_mss( - local_path: &Option, - audio_buffer: &Option>, -) -> Option { - if let Some(ref path) = local_path { - let file = std::fs::File::open(path).ok()?; - Some(MediaSourceStream::new(Box::new(file), Default::default())) - } else if let Some(ref buffer) = audio_buffer { - let reader = StreamingReader::new(buffer.clone()); - Some(MediaSourceStream::new(Box::new(reader), Default::default())) - } else { - None - } -} - -fn restart_playback_on_device_change( - output_ctx: &mut Option, - shared_position: &Arc>, - local_path: &Option, - audio_buffer: &Option>, - selected_device: &Option, - current_volume: f32, - audio_paused: bool, -) -> Result { - let current_pos = output_ctx - .as_ref() - .map(|ctx| ctx.playback.position()) - .unwrap_or(0.0); - let was_paused = audio_paused; - stop_playback(output_ctx, shared_position); - - let mss = rebuild_mss(local_path, audio_buffer) - .ok_or_else(|| "无法重建音频源".to_string())?; - let device = get_output_device(selected_device); - - let ctx = start_playback(mss, &device, current_volume, Some(current_pos))?; - if was_paused { - ctx.playback.playing.store(false, Ordering::Relaxed); - } - Ok(ctx) -} - -/// 音频线程主循环,接收命令并管理播放生命周期,包括设备热切换和播放结束检测 -fn audio_thread(rx: Receiver, _current_url: Arc>>, shared_position: Arc>, is_playing: Arc, app_handle: AppHandle) { - let mut selected_device: Option = None; - let mut current_volume: f32 = 1.0; - let mut output_ctx: Option = None; - let mut current_audio_buffer: Option> = None; - let mut current_local_path: Option = None; - let mut audio_active = false; - let mut audio_paused = false; - let mut manual_stop = false; - let mut last_default_name = get_system_default_device_name(); - - loop { - match rx.recv_timeout(Duration::from_millis(200)) { - Ok(cmd) => match cmd { - AudioCmd::Play(url) => { - audio_active = false; - audio_paused = false; - is_playing.store(false, Ordering::Relaxed); - manual_stop = false; - current_local_path = None; - - stop_playback(&mut output_ctx, &shared_position); - if let Some(ref buf) = current_audio_buffer { - buf.cancel(); - } - - let buffer = Arc::new(SharedBuffer::new()); - current_audio_buffer = Some(buffer.clone()); - - let buffer_clone = buffer.clone(); - let ah_clone = app_handle.clone(); - let url_clone = url.clone(); - thread::spawn(move || { - if let Err(e) = download_audio_streaming(&url_clone, &buffer_clone, &ah_clone) { - if !buffer_clone.is_cancelled() { - eprintln!("[audio] 流式下载失败: {}", e); - } - } - buffer_clone.mark_done(); - }); - - loop { - let len = buffer.len(); - if len >= INITIAL_BUFFER_SIZE || buffer.is_done() || buffer.is_cancelled() { - break; - } - thread::sleep(Duration::from_millis(50)); - } - - if buffer.is_cancelled() || buffer.len() == 0 { - current_audio_buffer = None; - continue; - } - - let mss = MediaSourceStream::new( - Box::new(StreamingReader::new(buffer.clone())), - Default::default(), - ); - - let device = get_output_device(&selected_device); - match start_playback(mss, &device, current_volume, None) { - Ok(ctx) => { - output_ctx = Some(ctx); - audio_active = true; - is_playing.store(true, Ordering::Relaxed); - let _ = app_handle.emit("audio-started", ()); - } - Err(e) => { - eprintln!("[audio] 播放启动失败: {}", e); - } - } - } - - AudioCmd::PlayLocal(path) => { - audio_active = false; - audio_paused = false; - is_playing.store(false, Ordering::Relaxed); - manual_stop = false; - current_local_path = Some(path.clone()); - - stop_playback(&mut output_ctx, &shared_position); - if let Some(ref buf) = current_audio_buffer { - buf.cancel(); - } - - let file = match std::fs::File::open(&path) { - Ok(f) => f, - Err(e) => { - eprintln!("[audio] 打开本地文件失败: {}", e); - continue; - } - }; - - let buffer = Arc::new(SharedBuffer::new()); - current_audio_buffer = Some(buffer.clone()); - - let mss = MediaSourceStream::new(Box::new(file), Default::default()); - - let device = get_output_device(&selected_device); - match start_playback(mss, &device, current_volume, None) { - Ok(ctx) => { - output_ctx = Some(ctx); - audio_active = true; - is_playing.store(true, Ordering::Relaxed); - let _ = app_handle.emit("audio-started", ()); - } - Err(e) => { - eprintln!("[audio] 本地播放失败: {}", e); - } - } - } - - AudioCmd::Pause => { - audio_paused = true; - is_playing.store(false, Ordering::Relaxed); - if let Some(ref ctx) = output_ctx { - ctx.playback.playing.store(false, Ordering::Relaxed); - } - } - - AudioCmd::Resume => { - audio_paused = false; - if audio_active { - is_playing.store(true, Ordering::Relaxed); - } - if let Some(ref ctx) = output_ctx { - ctx.playback.playing.store(true, Ordering::Relaxed); - } - } - - AudioCmd::Stop => { - audio_active = false; - audio_paused = false; - is_playing.store(false, Ordering::Relaxed); - manual_stop = true; - stop_playback(&mut output_ctx, &shared_position); - if let Some(ref buf) = current_audio_buffer { - buf.cancel(); - } - } - - AudioCmd::Seek(time) => { - stop_playback(&mut output_ctx, &shared_position); - - let mss = match rebuild_mss(¤t_local_path, ¤t_audio_buffer) { - Some(mss) => mss, - None => continue, - }; - - let device = get_output_device(&selected_device); - match start_playback(mss, &device, current_volume, Some(time)) { - Ok(ctx) => { - if audio_paused { - is_playing.store(false, Ordering::Relaxed); - ctx.playback.playing.store(false, Ordering::Relaxed); - } else { - is_playing.store(true, Ordering::Relaxed); - } - output_ctx = Some(ctx); - audio_active = true; - } - Err(e) => { - eprintln!("[audio] seek 播放失败: {}", e); - } - } - } - - AudioCmd::SetVolume(vol) => { - current_volume = vol; - if let Some(ref ctx) = output_ctx { - *ctx.playback.volume.lock().unwrap() = vol; - } - } - - AudioCmd::SetDevice(dev) => { - selected_device = dev; - if audio_active { - match restart_playback_on_device_change( - &mut output_ctx, - &shared_position, - ¤t_local_path, - ¤t_audio_buffer, - &selected_device, - current_volume, - audio_paused, - ) { - Ok(ctx) => { output_ctx = Some(ctx); } - Err(e) => { eprintln!("[audio] 设备切换失败: {}", e); } - } - } - if selected_device.is_none() { - last_default_name = get_system_default_device_name(); - } - } - }, - - Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { - if audio_active { - if let Some(ref ctx) = output_ctx { - if ctx.playback.decode_done.load(Ordering::Relaxed) - && ctx.playback.buffer_exhausted.load(Ordering::Relaxed) - && !manual_stop && !audio_paused { - audio_active = false; - is_playing.store(false, Ordering::Relaxed); - let _ = app_handle.emit("audio-ended", ()); - } - let pos = ctx.playback.position(); - *shared_position.lock().unwrap() = pos; - } - } - - if selected_device.is_none() { - let current_default = get_system_default_device_name(); - if current_default != last_default_name { - println!( - "[audio] 系统默认设备变化: {:?} -> {:?}", - last_default_name, current_default - ); - last_default_name = current_default; - - if audio_active { - if let Ok(ctx) = restart_playback_on_device_change( - &mut output_ctx, - &shared_position, - ¤t_local_path, - ¤t_audio_buffer, - &selected_device, - current_volume, - audio_paused, - ) { - output_ctx = Some(ctx); - } - } - } - } - } - - Err(_) => break, - } - } -} - -use tauri::State; -use std::sync::Mutex as StdMutex; - -/// Tauri 管理的音频状态,内部包装 `AudioController` 的互斥锁 -pub struct AppAudio(pub StdMutex); - -/// Tauri 命令:播放网络音频 -#[tauri::command] -pub fn play_audio(state: State<'_, AppAudio>, url: String) -> Result<(), String> { - let ctrl = state.0.lock().map_err(|e| e.to_string())?; - ctrl.play_url(&url); - Ok(()) -} - -/// Tauri 命令:播放本地音频文件 -#[tauri::command] -pub fn play_local_audio(state: State<'_, AppAudio>, path: String) -> Result<(), String> { - let ctrl = state.0.lock().map_err(|e| e.to_string())?; - ctrl.play_local(&path); - Ok(()) -} - -/// Tauri 命令:暂停当前播放 -#[tauri::command] -pub fn pause_audio(state: State<'_, AppAudio>) { - if let Ok(ctrl) = state.0.lock() { ctrl.pause(); } -} - -/// Tauri 命令:恢复播放 -#[tauri::command] -pub fn resume_audio(state: State<'_, AppAudio>) { - if let Ok(ctrl) = state.0.lock() { ctrl.resume(); } -} - -/// Tauri 命令:停止当前播放 -#[tauri::command] -pub fn stop_audio(state: State<'_, AppAudio>) { - if let Ok(ctrl) = state.0.lock() { ctrl.stop(); } -} - -/// Tauri 命令:获取所有可用的音频输出设备列表 -#[tauri::command] -pub fn get_output_devices() -> Vec { - list_output_devices() -} - -/// Tauri 命令:设置音频输出设备,传入 None 使用系统默认设备 -#[tauri::command] -pub fn set_output_device(state: State<'_, AppAudio>, device: Option) { - if let Ok(ctrl) = state.0.lock() { ctrl.set_device(device); } -} - -/// Tauri 命令:跳转到指定播放位置(秒) -#[tauri::command] -pub fn seek_audio(state: State<'_, AppAudio>, time: f64) { - if let Ok(ctrl) = state.0.lock() { ctrl.seek(time); } -} - -/// Tauri 命令:获取当前播放位置(秒) -#[tauri::command] -pub fn get_audio_position(state: State<'_, AppAudio>) -> f64 { - if let Ok(ctrl) = state.0.lock() { ctrl.get_position() } else { 0.0 } -} - -/// Tauri 命令:设置播放音量 -#[tauri::command] -pub fn set_volume(state: State<'_, AppAudio>, vol: f32) { - if let Ok(ctrl) = state.0.lock() { ctrl.set_volume(vol); } -} - -#[tauri::command] -pub fn is_audio_playing(state: State<'_, AppAudio>) -> bool { - if let Ok(ctrl) = state.0.lock() { ctrl.get_is_playing() } else { false } -} diff --git a/src-tauri/src/audio/buffer.rs b/src-tauri/src/audio/buffer.rs new file mode 100644 index 0000000..4d825ec --- /dev/null +++ b/src-tauri/src/audio/buffer.rs @@ -0,0 +1,203 @@ +//! 流式音频缓冲区 +//! +//! 提供线程安全的共享缓冲区与流式读取器,用于在下载线程与解码线程之间传递音频数据。 + +use ringbuf::{HeapCons, HeapProd, HeapRb, traits::Split}; +use std::io::Read; +use std::sync::{Arc, Condvar, Mutex}; +use std::time::Duration; +use symphonia::core::io::MediaSource; + +/// 缓冲区内部状态,存储已下载的字节数据及完成/取消标志 +pub(crate) struct BufferState { + pub(crate) bytes: Vec, + pub(crate) done: bool, + pub(crate) cancelled: bool, +} + +/// 线程安全的共享缓冲区,支持生产者写入和消费者读取的同步等待 +pub struct SharedBuffer { + state: Mutex, + available: Condvar, +} + +impl SharedBuffer { + /// 创建新的空共享缓冲区 + pub fn new() -> Self { + SharedBuffer { + state: Mutex::new(BufferState { + bytes: Vec::new(), + done: false, + cancelled: false, + }), + available: Condvar::new(), + } + } + + /// 向缓冲区追加写入一块数据,并通知等待的读取者 + pub fn write_chunk(&self, chunk: &[u8]) { + let mut state = self.state.lock().unwrap(); + state.bytes.extend_from_slice(chunk); + self.available.notify_all(); + } + + /// 标记缓冲区写入已完成,通知读取者不再有新数据 + pub fn mark_done(&self) { + let mut state = self.state.lock().unwrap(); + state.done = true; + self.available.notify_all(); + } + + /// 取消缓冲区,中断正在进行的读写操作 + pub fn cancel(&self) { + let mut state = self.state.lock().unwrap(); + state.cancelled = true; + self.available.notify_all(); + } + + /// 返回已缓冲的数据字节数 + pub fn len(&self) -> usize { + self.state.lock().unwrap().bytes.len() + } + + /// 检查缓冲区是否已标记为写入完成 + pub fn is_done(&self) -> bool { + self.state.lock().unwrap().done + } + + /// 检查缓冲区是否已被取消 + pub fn is_cancelled(&self) -> bool { + self.state.lock().unwrap().cancelled + } + + /// 获取内部状态锁(供 StreamingReader 使用) + pub(crate) fn state_mutex(&self) -> &Mutex { + &self.state + } + + /// 获取内部条件变量(供 StreamingReader 使用) + pub(crate) fn condvar(&self) -> &Condvar { + &self.available + } +} + +/// 流式读取器,从共享缓冲区中按需读取数据,实现 `Read` 和 `Seek` trait +pub struct StreamingReader { + buffer: Arc, + pos: usize, +} + +impl StreamingReader { + /// 创建新的流式读取器,绑定到指定的共享缓冲区 + pub fn new(buffer: Arc) -> Self { + StreamingReader { buffer, pos: 0 } + } +} + +impl Read for StreamingReader { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let mut state = self.buffer.state_mutex().lock().unwrap(); + loop { + let available = state.bytes.len().saturating_sub(self.pos); + if available > 0 { + let to_read = std::cmp::min(buf.len(), available); + buf[..to_read].copy_from_slice(&state.bytes[self.pos..self.pos + to_read]); + self.pos += to_read; + return Ok(to_read); + } + if state.done { + return Ok(0); + } + if state.cancelled { + return Err(std::io::Error::new(std::io::ErrorKind::Interrupted, "cancelled")); + } + let result = self + .buffer + .condvar() + .wait_timeout(state, Duration::from_millis(500)) + .unwrap(); + state = result.0; + } + } +} + +impl std::io::Seek for StreamingReader { + fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result { + let new_pos = match pos { + std::io::SeekFrom::Start(offset) => offset as i64, + std::io::SeekFrom::Current(offset) => self.pos as i64 + offset, + std::io::SeekFrom::End(offset) => { + let mut state = self.buffer.state_mutex().lock().unwrap(); + loop { + if state.done { + break state.bytes.len() as i64 + offset; + } + if state.cancelled { + return Err(std::io::Error::new( + std::io::ErrorKind::Interrupted, + "cancelled", + )); + } + let result = self + .buffer + .condvar() + .wait_timeout(state, Duration::from_millis(500)) + .unwrap(); + state = result.0; + } + } + }; + if new_pos < 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "seek before start", + )); + } + let mut state = self.buffer.state_mutex().lock().unwrap(); + loop { + if new_pos as usize <= state.bytes.len() { + self.pos = new_pos as usize; + return Ok(self.pos as u64); + } + if state.done { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "seek past end", + )); + } + if state.cancelled { + return Err(std::io::Error::new( + std::io::ErrorKind::Interrupted, + "cancelled", + )); + } + let result = self + .buffer + .condvar() + .wait_timeout(state, Duration::from_millis(500)) + .unwrap(); + state = result.0; + } + } +} + +/// 环形缓冲区类型别名,便于在模块间共享 +pub type RingProducer = HeapProd; +pub type RingConsumer = HeapCons; + +/// 创建指定容量的环形缓冲区,返回 (生产者, 消费者) +pub fn create_ring_buffer(capacity: usize) -> (RingProducer, RingConsumer) { + let rb = HeapRb::::new(capacity); + rb.split() +} + +/// 为 StreamingReader 实现 symphonia 的 MediaSource trait +impl MediaSource for StreamingReader { + fn is_seekable(&self) -> bool { + true + } + + fn byte_len(&self) -> Option { + None + } +} diff --git a/src-tauri/src/audio/commands.rs b/src-tauri/src/audio/commands.rs new file mode 100644 index 0000000..02c492c --- /dev/null +++ b/src-tauri/src/audio/commands.rs @@ -0,0 +1,82 @@ +//! Tauri 命令 +//! +//! 暴露给前端调用的所有 `#[tauri::command]` 函数。 +//! `AppAudio` 是 Tauri 管理的状态,内部包装 `AudioController` 的互斥锁。 + +use std::sync::Mutex as StdMutex; +use tauri::State; + +use crate::audio::controller::AudioController; +use crate::audio::device::list_output_devices; + +/// Tauri 管理的音频状态,内部包装 `AudioController` 的互斥锁 +pub struct AppAudio(pub StdMutex); + +/// Tauri 命令:播放网络音频 +#[tauri::command] +pub fn play_audio(state: State<'_, AppAudio>, url: String) -> Result<(), String> { + let ctrl = state.0.lock().map_err(|e| e.to_string())?; + ctrl.play_url(&url); + Ok(()) +} + +/// Tauri 命令:播放本地音频文件 +#[tauri::command] +pub fn play_local_audio(state: State<'_, AppAudio>, path: String) -> Result<(), String> { + let ctrl = state.0.lock().map_err(|e| e.to_string())?; + ctrl.play_local(&path); + Ok(()) +} + +/// Tauri 命令:暂停当前播放 +#[tauri::command] +pub fn pause_audio(state: State<'_, AppAudio>) { + if let Ok(ctrl) = state.0.lock() { ctrl.pause(); } +} + +/// Tauri 命令:恢复播放 +#[tauri::command] +pub fn resume_audio(state: State<'_, AppAudio>) { + if let Ok(ctrl) = state.0.lock() { ctrl.resume(); } +} + +/// Tauri 命令:停止当前播放 +#[tauri::command] +pub fn stop_audio(state: State<'_, AppAudio>) { + if let Ok(ctrl) = state.0.lock() { ctrl.stop(); } +} + +/// Tauri 命令:获取所有可用的音频输出设备列表 +#[tauri::command] +pub fn get_output_devices() -> Vec { + list_output_devices() +} + +/// Tauri 命令:设置音频输出设备,传入 None 使用系统默认设备 +#[tauri::command] +pub fn set_output_device(state: State<'_, AppAudio>, device: Option) { + if let Ok(ctrl) = state.0.lock() { ctrl.set_device(device); } +} + +/// Tauri 命令:跳转到指定播放位置(秒) +#[tauri::command] +pub fn seek_audio(state: State<'_, AppAudio>, time: f64) { + if let Ok(ctrl) = state.0.lock() { ctrl.seek(time); } +} + +/// Tauri 命令:获取当前播放位置(秒) +#[tauri::command] +pub fn get_audio_position(state: State<'_, AppAudio>) -> f64 { + if let Ok(ctrl) = state.0.lock() { ctrl.get_position() } else { 0.0 } +} + +/// Tauri 命令:设置播放音量 +#[tauri::command] +pub fn set_volume(state: State<'_, AppAudio>, vol: f32) { + if let Ok(ctrl) = state.0.lock() { ctrl.set_volume(vol); } +} + +#[tauri::command] +pub fn is_audio_playing(state: State<'_, AppAudio>) -> bool { + if let Ok(ctrl) = state.0.lock() { ctrl.get_is_playing() } else { false } +} diff --git a/src-tauri/src/audio/controller.rs b/src-tauri/src/audio/controller.rs new file mode 100644 index 0000000..1cafa4e --- /dev/null +++ b/src-tauri/src/audio/controller.rs @@ -0,0 +1,389 @@ +//! 音频控制器 +//! +//! 负责命令分发与播放生命周期管理。`AudioController` 是对外接口, +//! 通过通道向后台 `audio_thread` 发送命令;`audio_thread` 是核心事件循环, +//! 管理播放状态、设备热切换、播放结束检测等。 + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{channel, Receiver, Sender}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Duration; +use symphonia::core::io::MediaSourceStream; +use tauri::{AppHandle, Emitter}; + +use crate::audio::buffer::{SharedBuffer, StreamingReader}; +use crate::audio::device::{get_output_device, get_system_default_device_name}; +use crate::audio::download::download_audio_streaming; +use crate::audio::output::{OutputContext, INITIAL_BUFFER_SIZE, start_playback}; + +/// 音频控制命令枚举,用于音频线程之间的消息传递 +enum AudioCmd { + Play(String), + PlayLocal(String), + Pause, + Resume, + Stop, + Seek(f64), + SetVolume(f32), + SetDevice(Option), +} + +/// 音频控制器,通过通道向音频线程发送控制命令 +pub struct AudioController { + tx: Sender, + current_url: Arc>>, + position: Arc>, + is_playing: Arc, +} + +impl AudioController { + /// 创建新的音频控制器,并启动后台音频线程 + pub fn new(app_handle: AppHandle) -> Self { + let (tx, rx) = channel(); + let current_url = Arc::new(Mutex::new(None)); + let position = Arc::new(Mutex::new(0.0)); + let is_playing = Arc::new(AtomicBool::new(false)); + let url_clone = current_url.clone(); + let pos_clone = position.clone(); + let playing_clone = is_playing.clone(); + let ah_clone = app_handle.clone(); + thread::spawn(move || audio_thread(rx, url_clone, pos_clone, playing_clone, ah_clone)); + AudioController { tx, current_url, position, is_playing } + } + + /// 播放指定URL的网络音频 + pub fn play_url(&self, url: &str) { + *self.current_url.lock().unwrap() = Some(url.to_string()); + let _ = self.tx.send(AudioCmd::Play(url.to_string())); + } + /// 播放指定路径的本地音频文件 + pub fn play_local(&self, path: &str) { + *self.current_url.lock().unwrap() = Some(path.to_string()); + let _ = self.tx.send(AudioCmd::PlayLocal(path.to_string())); + } + /// 暂停当前播放 + pub fn pause(&self) { let _ = self.tx.send(AudioCmd::Pause); } + /// 恢复播放 + pub fn resume(&self) { let _ = self.tx.send(AudioCmd::Resume); } + /// 停止当前播放 + pub fn stop(&self) { let _ = self.tx.send(AudioCmd::Stop); } + /// 设置音频输出设备,传入 None 则使用系统默认设备 + pub fn set_device(&self, device: Option) { + let _ = self.tx.send(AudioCmd::SetDevice(device)); + } + /// 跳转到指定时间位置(秒) + pub fn seek(&self, time: f64) { let _ = self.tx.send(AudioCmd::Seek(time)); } + /// 设置播放音量,范围 0.0 ~ 1.0 + pub fn set_volume(&self, vol: f32) { let _ = self.tx.send(AudioCmd::SetVolume(vol)); } + /// 获取当前播放位置(秒) + pub fn get_position(&self) -> f64 { + *self.position.lock().unwrap() + } + pub fn get_is_playing(&self) -> bool { + self.is_playing.load(Ordering::Relaxed) + } +} + +/// 停止播放,取消解码并重置共享播放位置 +fn stop_playback(output_ctx: &mut Option, shared_position: &Arc>) { + if let Some(ref mut ctx) = output_ctx { + ctx.playback.cancelled.store(true, Ordering::Relaxed); + ctx.playback.playing.store(false, Ordering::Relaxed); + } + *output_ctx = None; + *shared_position.lock().unwrap() = 0.0; +} + +/// 根据当前播放源(本地文件或网络缓冲区)重建 MediaSourceStream +fn rebuild_mss( + local_path: &Option, + audio_buffer: &Option>, +) -> Option { + if let Some(ref path) = local_path { + let file = std::fs::File::open(path).ok()?; + Some(MediaSourceStream::new(Box::new(file), Default::default())) + } else if let Some(ref buffer) = audio_buffer { + let reader = StreamingReader::new(buffer.clone()); + Some(MediaSourceStream::new(Box::new(reader), Default::default())) + } else { + None + } +} + +/// 设备切换时重启播放,保留当前播放位置与暂停状态 +fn restart_playback_on_device_change( + output_ctx: &mut Option, + shared_position: &Arc>, + local_path: &Option, + audio_buffer: &Option>, + selected_device: &Option, + current_volume: f32, + audio_paused: bool, +) -> Result { + let current_pos = output_ctx + .as_ref() + .map(|ctx| ctx.playback.position()) + .unwrap_or(0.0); + let was_paused = audio_paused; + stop_playback(output_ctx, shared_position); + + let mss = rebuild_mss(local_path, audio_buffer) + .ok_or_else(|| "无法重建音频源".to_string())?; + let device = get_output_device(selected_device); + + let ctx = start_playback(mss, &device, current_volume, Some(current_pos))?; + if was_paused { + ctx.playback.playing.store(false, Ordering::Relaxed); + } + Ok(ctx) +} + +/// 音频线程主循环,接收命令并管理播放生命周期,包括设备热切换和播放结束检测 +fn audio_thread( + rx: Receiver, + _current_url: Arc>>, + shared_position: Arc>, + is_playing: Arc, + app_handle: AppHandle, +) { + let mut selected_device: Option = None; + let mut current_volume: f32 = 1.0; + let mut output_ctx: Option = None; + let mut current_audio_buffer: Option> = None; + let mut current_local_path: Option = None; + let mut audio_active = false; + let mut audio_paused = false; + let mut manual_stop = false; + let mut last_default_name = get_system_default_device_name(); + + loop { + match rx.recv_timeout(Duration::from_millis(200)) { + Ok(cmd) => match cmd { + AudioCmd::Play(url) => { + audio_active = false; + audio_paused = false; + is_playing.store(false, Ordering::Relaxed); + manual_stop = false; + current_local_path = None; + + stop_playback(&mut output_ctx, &shared_position); + if let Some(ref buf) = current_audio_buffer { + buf.cancel(); + } + + let buffer = Arc::new(SharedBuffer::new()); + current_audio_buffer = Some(buffer.clone()); + + let buffer_clone = buffer.clone(); + let ah_clone = app_handle.clone(); + let url_clone = url.clone(); + thread::spawn(move || { + if let Err(e) = download_audio_streaming(&url_clone, &buffer_clone, &ah_clone) { + if !buffer_clone.is_cancelled() { + eprintln!("[audio] 流式下载失败: {}", e); + } + } + buffer_clone.mark_done(); + }); + + loop { + let len = buffer.len(); + if len >= INITIAL_BUFFER_SIZE || buffer.is_done() || buffer.is_cancelled() { + break; + } + thread::sleep(Duration::from_millis(50)); + } + + if buffer.is_cancelled() || buffer.len() == 0 { + current_audio_buffer = None; + continue; + } + + let mss = MediaSourceStream::new( + Box::new(StreamingReader::new(buffer.clone())), + Default::default(), + ); + + let device = get_output_device(&selected_device); + match start_playback(mss, &device, current_volume, None) { + Ok(ctx) => { + output_ctx = Some(ctx); + audio_active = true; + is_playing.store(true, Ordering::Relaxed); + let _ = app_handle.emit("audio-started", ()); + } + Err(e) => { + eprintln!("[audio] 播放启动失败: {}", e); + } + } + } + + AudioCmd::PlayLocal(path) => { + audio_active = false; + audio_paused = false; + is_playing.store(false, Ordering::Relaxed); + manual_stop = false; + current_local_path = Some(path.clone()); + + stop_playback(&mut output_ctx, &shared_position); + if let Some(ref buf) = current_audio_buffer { + buf.cancel(); + } + + let file = match std::fs::File::open(&path) { + Ok(f) => f, + Err(e) => { + eprintln!("[audio] 打开本地文件失败: {}", e); + continue; + } + }; + + let buffer = Arc::new(SharedBuffer::new()); + current_audio_buffer = Some(buffer.clone()); + + let mss = MediaSourceStream::new(Box::new(file), Default::default()); + + let device = get_output_device(&selected_device); + match start_playback(mss, &device, current_volume, None) { + Ok(ctx) => { + output_ctx = Some(ctx); + audio_active = true; + is_playing.store(true, Ordering::Relaxed); + let _ = app_handle.emit("audio-started", ()); + } + Err(e) => { + eprintln!("[audio] 本地播放失败: {}", e); + } + } + } + + AudioCmd::Pause => { + audio_paused = true; + is_playing.store(false, Ordering::Relaxed); + if let Some(ref ctx) = output_ctx { + ctx.playback.playing.store(false, Ordering::Relaxed); + } + } + + AudioCmd::Resume => { + audio_paused = false; + if audio_active { + is_playing.store(true, Ordering::Relaxed); + } + if let Some(ref ctx) = output_ctx { + ctx.playback.playing.store(true, Ordering::Relaxed); + } + } + + AudioCmd::Stop => { + audio_active = false; + audio_paused = false; + is_playing.store(false, Ordering::Relaxed); + manual_stop = true; + stop_playback(&mut output_ctx, &shared_position); + if let Some(ref buf) = current_audio_buffer { + buf.cancel(); + } + } + + AudioCmd::Seek(time) => { + stop_playback(&mut output_ctx, &shared_position); + + let mss = match rebuild_mss(¤t_local_path, ¤t_audio_buffer) { + Some(mss) => mss, + None => continue, + }; + + let device = get_output_device(&selected_device); + match start_playback(mss, &device, current_volume, Some(time)) { + Ok(ctx) => { + if audio_paused { + is_playing.store(false, Ordering::Relaxed); + ctx.playback.playing.store(false, Ordering::Relaxed); + } else { + is_playing.store(true, Ordering::Relaxed); + } + output_ctx = Some(ctx); + audio_active = true; + } + Err(e) => { + eprintln!("[audio] seek 播放失败: {}", e); + } + } + } + + AudioCmd::SetVolume(vol) => { + current_volume = vol; + if let Some(ref ctx) = output_ctx { + *ctx.playback.volume.lock().unwrap() = vol; + } + } + + AudioCmd::SetDevice(dev) => { + selected_device = dev; + if audio_active { + match restart_playback_on_device_change( + &mut output_ctx, + &shared_position, + ¤t_local_path, + ¤t_audio_buffer, + &selected_device, + current_volume, + audio_paused, + ) { + Ok(ctx) => { output_ctx = Some(ctx); } + Err(e) => { eprintln!("[audio] 设备切换失败: {}", e); } + } + } + if selected_device.is_none() { + last_default_name = get_system_default_device_name(); + } + } + }, + + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + if audio_active { + if let Some(ref ctx) = output_ctx { + if ctx.playback.decode_done.load(Ordering::Relaxed) + && ctx.playback.buffer_exhausted.load(Ordering::Relaxed) + && !manual_stop && !audio_paused { + audio_active = false; + is_playing.store(false, Ordering::Relaxed); + let _ = app_handle.emit("audio-ended", ()); + } + let pos = ctx.playback.position(); + *shared_position.lock().unwrap() = pos; + } + } + + if selected_device.is_none() { + let current_default = get_system_default_device_name(); + if current_default != last_default_name { + println!( + "[audio] 系统默认设备变化: {:?} -> {:?}", + last_default_name, current_default + ); + last_default_name = current_default; + + if audio_active { + if let Ok(ctx) = restart_playback_on_device_change( + &mut output_ctx, + &shared_position, + ¤t_local_path, + ¤t_audio_buffer, + &selected_device, + current_volume, + audio_paused, + ) { + output_ctx = Some(ctx); + } + } + } + } + } + + Err(_) => break, + } + } +} diff --git a/src-tauri/src/audio/decoder.rs b/src-tauri/src/audio/decoder.rs new file mode 100644 index 0000000..26e9b67 --- /dev/null +++ b/src-tauri/src/audio/decoder.rs @@ -0,0 +1,314 @@ +//! 音频解码 +//! +//! 基于 symphonia 解码音频,并提供采样格式转换、声道重混、重采样等工具函数。 +//! `decode_to_ring` 是核心入口,将解码后的样本写入环形缓冲区供播放回调消费。 + +use ringbuf::traits::Producer; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::thread; +use std::time::Duration; +use symphonia::core::audio::{AudioBufferRef, Signal}; +use symphonia::core::codecs::{DecoderOptions, CODEC_TYPE_NULL}; +use symphonia::core::errors::Error as SymphoniaError; +use symphonia::core::formats::{FormatOptions, SeekMode, SeekTo}; +use symphonia::core::io::MediaSourceStream; +use symphonia::core::meta::MetadataOptions; +use symphonia::core::probe::Hint; +use symphonia::core::units::Time; + +use crate::audio::buffer::RingProducer; + +/// 将 Symphonia 解码后的音频缓冲区转换为交错排列的 f32 采样数据 +fn convert_to_interleaved_f32(decoded: &AudioBufferRef) -> Vec { + let channels = decoded.spec().channels.count(); + let frames = decoded.frames(); + let mut out = Vec::with_capacity(frames * channels); + + match decoded { + AudioBufferRef::U8(buf) => { + for frame in 0..frames { + for ch in 0..channels { + out.push(buf.chan(ch)[frame] as f32 / u8::MAX as f32 * 2.0 - 1.0); + } + } + } + AudioBufferRef::U16(buf) => { + for frame in 0..frames { + for ch in 0..channels { + out.push(buf.chan(ch)[frame] as f32 / u16::MAX as f32 * 2.0 - 1.0); + } + } + } + AudioBufferRef::U24(buf) => { + for frame in 0..frames { + for ch in 0..channels { + out.push(buf.chan(ch)[frame].0 as f32 / 8388607.0); + } + } + } + AudioBufferRef::U32(buf) => { + for frame in 0..frames { + for ch in 0..channels { + out.push(buf.chan(ch)[frame] as f32 / u32::MAX as f32 * 2.0 - 1.0); + } + } + } + AudioBufferRef::S8(buf) => { + for frame in 0..frames { + for ch in 0..channels { + out.push(buf.chan(ch)[frame] as f32 / i8::MAX as f32); + } + } + } + AudioBufferRef::S16(buf) => { + for frame in 0..frames { + for ch in 0..channels { + out.push(buf.chan(ch)[frame] as f32 / i16::MAX as f32); + } + } + } + AudioBufferRef::S24(buf) => { + for frame in 0..frames { + for ch in 0..channels { + out.push(buf.chan(ch)[frame].0 as f32 / 8388607.0); + } + } + } + AudioBufferRef::S32(buf) => { + for frame in 0..frames { + for ch in 0..channels { + out.push(buf.chan(ch)[frame] as f32 / i32::MAX as f32); + } + } + } + AudioBufferRef::F32(buf) => { + for frame in 0..frames { + for ch in 0..channels { + out.push(buf.chan(ch)[frame]); + } + } + } + AudioBufferRef::F64(buf) => { + for frame in 0..frames { + for ch in 0..channels { + out.push(buf.chan(ch)[frame] as f32); + } + } + } + } + + out +} + +/// 重混声道数,将交错采样数据从源声道数转换为目标声道数 +fn remix_channels( + interleaved: &[f32], + src_channels: u16, + target_channels: u16, + src_frames: usize, +) -> Vec { + if src_channels == target_channels { + return interleaved.to_vec(); + } + + let src_ch = src_channels as usize; + let tgt_ch = target_channels as usize; + let mut out = Vec::with_capacity(src_frames * tgt_ch); + + if src_ch == 1 && tgt_ch == 2 { + for &s in interleaved { + out.push(s); + out.push(s); + } + } else if src_ch == 2 && tgt_ch == 1 { + for i in 0..src_frames { + let l = interleaved[i * 2]; + let r = interleaved[i * 2 + 1]; + out.push((l + r) * 0.5); + } + } else { + for i in 0..src_frames { + for ch in 0..tgt_ch { + let src_ch_idx = ch.min(src_ch.saturating_sub(1)); + out.push(interleaved[i * src_ch + src_ch_idx]); + } + } + } + + out +} + +/// 对解码音频进行重采样和声道重混,输出目标采样率和声道数的交错 f32 数据 +fn resample_and_remix( + decoded: &AudioBufferRef, + target_sample_rate: u32, + target_channels: u16, + src_rate: f64, + src_channels: u16, +) -> Vec { + let interleaved = convert_to_interleaved_f32(decoded); + let src_frames = if src_channels > 0 { + interleaved.len() / src_channels as usize + } else { + 0 + }; + + if src_frames == 0 { + return Vec::new(); + } + + let remixed = remix_channels(&interleaved, src_channels, target_channels, src_frames); + let remixed_ch = target_channels as usize; + + let ratio = target_sample_rate as f64 / src_rate; + let need_resample = (ratio - 1.0).abs() > 0.001; + + if !need_resample { + return remixed; + } + + let target_frames = (src_frames as f64 * ratio).round() as usize; + if target_frames == 0 { + return Vec::new(); + } + + let mut out = Vec::with_capacity(target_frames * remixed_ch); + for i in 0..target_frames { + let src_pos = i as f64 / ratio; + let src_idx = src_pos as usize; + let frac = src_pos - src_idx as f64; + let next_idx = (src_idx + 1).min(src_frames - 1); + + for ch in 0..remixed_ch { + let s0 = remixed[src_idx * remixed_ch + ch]; + let s1 = remixed[next_idx * remixed_ch + ch]; + out.push(s0 + (s1 - s0) * frac as f32); + } + } + + out +} + +/// 将音频数据解码并写入环形缓冲区,供播放回调消费 +pub fn decode_to_ring( + mss: MediaSourceStream, + mut producer: RingProducer, + playing: Arc, + cancelled: Arc, + decode_done: Arc, + seek_time: Option, + target_sample_rate: u32, + target_channels: u16, +) { + let hint = Hint::new(); + let format_opts = FormatOptions { + enable_gapless: true, + ..Default::default() + }; + let metadata_opts = MetadataOptions::default(); + let decoder_opts = DecoderOptions::default(); + + let probed = match symphonia::default::get_probe().format(&hint, mss, &format_opts, &metadata_opts) { + Ok(p) => p, + Err(e) => { + eprintln!("[audio] 探测格式失败: {}", e); + decode_done.store(true, Ordering::Relaxed); + return; + } + }; + + let mut format_reader = probed.format; + let track = match format_reader + .tracks() + .iter() + .find(|t| t.codec_params.codec != CODEC_TYPE_NULL) + { + Some(t) => t, + None => { + eprintln!("[audio] 未找到有效音频轨道"); + decode_done.store(true, Ordering::Relaxed); + return; + } + }; + + let track_id = track.id; + let codec_params = &track.codec_params; + let src_rate = codec_params.sample_rate.unwrap_or(44100) as f64; + let src_channels = codec_params.channels.unwrap_or_else(|| { + symphonia::core::audio::Channels::FRONT_LEFT | symphonia::core::audio::Channels::FRONT_RIGHT + }).count() as u16; + + let mut decoder = match symphonia::default::get_codecs().make(codec_params, &decoder_opts) { + Ok(d) => d, + Err(e) => { + eprintln!("[audio] 创建解码器失败: {}", e); + decode_done.store(true, Ordering::Relaxed); + return; + } + }; + + if let Some(time) = seek_time { + let seek_to = SeekTo::Time { + time: Time::from(time), + track_id: Some(track_id), + }; + let _ = format_reader.seek(SeekMode::Accurate, seek_to); + } + + let ratio = target_sample_rate as f64 / src_rate; + let need_resample = (ratio - 1.0).abs() > 0.001; + let need_remix = src_channels != target_channels; + + while !cancelled.load(Ordering::Relaxed) { + let packet = match format_reader.next_packet() { + Ok(p) => p, + Err(SymphoniaError::IoError(ref e)) + if e.kind() == std::io::ErrorKind::UnexpectedEof => + { + break; + } + Err(SymphoniaError::ResetRequired) => continue, + Err(e) => { + eprintln!("[audio] 读取包失败: {}", e); + break; + } + }; + + if packet.track_id() != track_id { + continue; + } + + let decoded = match decoder.decode(&packet) { + Ok(d) => d, + Err(e) => { + eprintln!("[audio] 解码失败: {}", e); + continue; + } + }; + + let samples = if need_resample || need_remix { + resample_and_remix(&decoded, target_sample_rate, target_channels, src_rate, src_channels) + } else { + convert_to_interleaved_f32(&decoded) + }; + + let mut write_pos = 0; + while write_pos < samples.len() && !cancelled.load(Ordering::Relaxed) { + let remaining = &samples[write_pos..]; + let n = producer.push_slice(remaining); + if n == 0 { + if !playing.load(Ordering::Relaxed) { + while !playing.load(Ordering::Relaxed) && !cancelled.load(Ordering::Relaxed) { + thread::sleep(Duration::from_millis(10)); + } + } + thread::sleep(Duration::from_millis(1)); + continue; + } + write_pos += n; + } + } + + decode_done.store(true, Ordering::Relaxed); +} diff --git a/src-tauri/src/audio/device.rs b/src-tauri/src/audio/device.rs new file mode 100644 index 0000000..7d86708 --- /dev/null +++ b/src-tauri/src/audio/device.rs @@ -0,0 +1,55 @@ +//! 音频输出设备管理 +//! +//! 列举、查找、选择音频输出设备。当用户指定的设备不存在时回退到系统默认设备。 + +use cpal::traits::{DeviceTrait, HostTrait}; + +/// 获取系统默认输出设备的名称 +pub fn get_system_default_device_name() -> Option { + cpal::default_host() + .default_output_device() + .and_then(|d| d.name().ok()) +} + +/// 列出系统中所有可用的音频输出设备名称(去重排序后) +pub fn list_output_devices() -> Vec { + let host = cpal::default_host(); + if let Ok(devices) = host.output_devices() { + let mut names: Vec = devices.filter_map(|d| d.name().ok()).collect(); + names.sort(); + names.dedup(); + names + } else { + vec![] + } +} + +/// 按名称查找音频输出设备,未找到则返回 None +pub fn find_device_by_name(name: &str) -> Option { + let host = cpal::default_host(); + if let Ok(devices) = host.output_devices() { + for d in devices { + if let Ok(n) = d.name() { + if n == name { + return Some(d); + } + } + } + } + None +} + +/// 获取音频输出设备,优先使用指定名称的设备,否则回退到系统默认设备 +pub fn get_output_device(selected: &Option) -> cpal::Device { + match selected { + Some(name) => find_device_by_name(name).unwrap_or_else(|| { + eprintln!("[audio] 未找到设备 `{}`,回退默认", name); + cpal::default_host() + .default_output_device() + .expect("无可用音频设备") + }), + None => cpal::default_host() + .default_output_device() + .expect("无可用音频设备"), + } +} diff --git a/src-tauri/src/audio/download.rs b/src-tauri/src/audio/download.rs new file mode 100644 index 0000000..321e1bf --- /dev/null +++ b/src-tauri/src/audio/download.rs @@ -0,0 +1,44 @@ +//! 音频流式下载 +//! +//! 负责从网络 URL 流式下载音频数据到共享缓冲区,并通过事件通知下载进度。 + +use std::io::Read; +use tauri::{AppHandle, Emitter}; + +use crate::audio::buffer::SharedBuffer; + +/// 流式下载音频数据到共享缓冲区,支持下载进度事件通知 +pub fn download_audio_streaming( + url: &str, + buffer: &SharedBuffer, + app_handle: &AppHandle, +) -> Result<(), String> { + let resp = reqwest::blocking::get(url).map_err(|e| format!("下载失败: {}", e))?; + if !resp.status().is_success() { + return Err(format!("HTTP 错误: {}", resp.status())); + } + let total_size = resp.content_length().unwrap_or(0); + let mut downloaded: u64 = 0; + let mut reader = resp; + loop { + if buffer.is_cancelled() { + return Err("下载已取消".to_string()); + } + let mut chunk = [0u8; 8192]; + let read_size = reader + .read(&mut chunk) + .map_err(|e| format!("读取失败: {}", e))?; + if read_size == 0 { + break; + } + buffer.write_chunk(&chunk[..read_size]); + downloaded += read_size as u64; + let progress = if total_size > 0 { + (downloaded as f64 / total_size as f64) * 100.0 + } else { + 0.0 + }; + let _ = app_handle.emit("cache-progress", progress); + } + Ok(()) +} diff --git a/src-tauri/src/audio/mod.rs b/src-tauri/src/audio/mod.rs new file mode 100644 index 0000000..7bfd50b --- /dev/null +++ b/src-tauri/src/audio/mod.rs @@ -0,0 +1,24 @@ +//! 音频模块 +//! +//! 负责音频的解码、播放、设备管理与对外 Tauri 命令。 +//! +//! 模块结构: +//! - [`buffer`]: 流式缓冲区与读取器 +//! - [`download`]: 网络音频流式下载 +//! - [`decoder`]: symphonia 解码 + 重采样 + 声道重混 +//! - [`output`]: cpal 输出流与播放状态 +//! - [`device`]: 输出设备列举与选择 +//! - [`controller`]: 命令分发与播放生命周期 +//! - [`commands`]: 对外 Tauri 命令 + +pub mod buffer; +pub mod commands; +pub mod controller; +pub mod decoder; +pub mod device; +pub mod download; +pub mod output; + +// 对外重导出,保持与旧 `audio.rs` 相同的公开 API +pub use commands::AppAudio; +pub use controller::AudioController; diff --git a/src-tauri/src/audio/output.rs b/src-tauri/src/audio/output.rs new file mode 100644 index 0000000..79c536e --- /dev/null +++ b/src-tauri/src/audio/output.rs @@ -0,0 +1,253 @@ +//! 音频输出 +//! +//! 基于 cpal 构建音频输出流,将环形缓冲区中的样本推送到设备。 +//! 同时定义播放状态 `PlaybackState` 与输出上下文 `OutputContext`。 + +use cpal::traits::{DeviceTrait, StreamTrait}; +use cpal::{SampleRate, Stream, StreamConfig}; +use ringbuf::traits::Consumer; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread; +use symphonia::core::io::MediaSourceStream; + +use crate::audio::buffer::{RingConsumer, create_ring_buffer}; +use crate::audio::decoder::decode_to_ring; + +/// 初始缓冲区大小,达到此字节数后才开始播放 +pub const INITIAL_BUFFER_SIZE: usize = 65536; +/// 环形缓冲区容量(采样数),约 4 秒的 48kHz 立体声数据 +pub const RING_BUFFER_SAMPLES: usize = 48000 * 4; + +/// 播放状态,记录当前播放的运行时信息 +pub struct PlaybackState { + pub playing: Arc, + pub cancelled: Arc, + pub decode_done: Arc, + pub buffer_exhausted: Arc, + pub volume: Arc>, + pub sample_rate: u32, + pub channels: u16, + pub samples_played: Arc, + pub start_time: f64, +} + +impl PlaybackState { + /// 根据已播放采样数计算当前播放位置(秒) + pub fn position(&self) -> f64 { + let samples = self.samples_played.load(Ordering::Relaxed) as f64; + self.start_time + samples / (self.sample_rate as f64 * self.channels as f64) + } +} + +/// 输出上下文,持有音频输出流和解码线程的句柄 +pub struct OutputContext { + pub _stream: Stream, + pub _decode_thread: thread::JoinHandle<()>, + pub playback: PlaybackState, +} + +/// 启动音频播放,创建解码线程和 cpal 输出流 +pub fn start_playback( + mss: MediaSourceStream, + device: &cpal::Device, + current_volume: f32, + seek_time: Option, +) -> Result { + let default_config = device + .default_output_config() + .map_err(|e| format!("获取设备配置失败: {}", e))?; + + let sr = default_config.sample_rate().0; + let ch = default_config.channels(); + let sample_format = default_config.sample_format(); + + let (producer, consumer) = create_ring_buffer(RING_BUFFER_SAMPLES); + + let playing = Arc::new(AtomicBool::new(true)); + let cancelled = Arc::new(AtomicBool::new(false)); + let decode_done = Arc::new(AtomicBool::new(false)); + let buffer_exhausted = Arc::new(AtomicBool::new(false)); + let volume = Arc::new(Mutex::new(current_volume)); + let samples_played = Arc::new(AtomicU64::new(0)); + let start_time = seek_time.unwrap_or(0.0); + + let playing_clone = playing.clone(); + let cancelled_clone = cancelled.clone(); + let decode_done_clone = decode_done.clone(); + let decode_handle = thread::spawn(move || { + decode_to_ring( + mss, + producer, + playing_clone, + cancelled_clone, + decode_done_clone, + seek_time, + sr, + ch, + ); + }); + + let stream = build_cpal_stream( + device, + sr, + ch, + sample_format, + consumer, + volume.clone(), + playing.clone(), + samples_played.clone(), + decode_done.clone(), + buffer_exhausted.clone(), + )?; + stream.play().map_err(|e| format!("播放流失败: {}", e))?; + + Ok(OutputContext { + _stream: stream, + _decode_thread: decode_handle, + playback: PlaybackState { + playing, + cancelled, + decode_done, + buffer_exhausted, + volume, + sample_rate: sr, + channels: ch, + samples_played, + start_time, + }, + }) +} + +/// 构建 cpal 音频输出流,支持 f32、i16、u16 三种采样格式 +#[allow(clippy::too_many_arguments)] +fn build_cpal_stream( + device: &cpal::Device, + sample_rate: u32, + channels: u16, + sample_format: cpal::SampleFormat, + mut consumer: RingConsumer, + volume: Arc>, + playing: Arc, + samples_played: Arc, + decode_done: Arc, + buffer_exhausted: Arc, +) -> Result { + let config = StreamConfig { + channels, + sample_rate: SampleRate(sample_rate), + buffer_size: cpal::BufferSize::Default, + }; + + let err_fn = |err: cpal::StreamError| eprintln!("[audio] 输出错误: {}", err); + + match sample_format { + cpal::SampleFormat::F32 => { + let sp = samples_played; + let dd = decode_done; + let be = buffer_exhausted; + device + .build_output_stream( + &config, + move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { + if !playing.load(Ordering::Relaxed) { + data.fill(0.0); + return; + } + let vol = *volume.lock().unwrap(); + let read = consumer.pop_slice(data); + for (i, s) in data.iter_mut().enumerate() { + if i < read { + *s *= vol; + } else { + *s = 0.0; + } + } + sp.fetch_add(read as u64, Ordering::Relaxed); + if read == 0 && dd.load(Ordering::Relaxed) { + be.store(true, Ordering::Relaxed); + } + }, + err_fn, + None, + ) + .map_err(|e| format!("创建输出流失败: {}", e)) + } + + cpal::SampleFormat::I16 => { + let mut f32_buf: Vec = Vec::new(); + let sp = samples_played; + let dd = decode_done; + let be = buffer_exhausted; + device + .build_output_stream( + &config, + move |data: &mut [i16], _: &cpal::OutputCallbackInfo| { + if !playing.load(Ordering::Relaxed) { + data.fill(0); + return; + } + let vol = *volume.lock().unwrap(); + if f32_buf.len() != data.len() { + f32_buf.resize(data.len(), 0.0); + } + let read = consumer.pop_slice(&mut f32_buf); + for (i, s) in data.iter_mut().enumerate() { + if i < read { + *s = (f32_buf[i] * vol * 32767.0) + .clamp(-32768.0, 32767.0) as i16; + } else { + *s = 0; + } + } + sp.fetch_add(read as u64, Ordering::Relaxed); + if read == 0 && dd.load(Ordering::Relaxed) { + be.store(true, Ordering::Relaxed); + } + }, + err_fn, + None, + ) + .map_err(|e| format!("创建输出流失败: {}", e)) + } + + cpal::SampleFormat::U16 => { + let mut f32_buf: Vec = Vec::new(); + let sp = samples_played; + let dd = decode_done; + let be = buffer_exhausted; + device + .build_output_stream( + &config, + move |data: &mut [u16], _: &cpal::OutputCallbackInfo| { + if !playing.load(Ordering::Relaxed) { + data.fill(32768); + return; + } + let vol = *volume.lock().unwrap(); + if f32_buf.len() != data.len() { + f32_buf.resize(data.len(), 0.0); + } + let read = consumer.pop_slice(&mut f32_buf); + for (i, s) in data.iter_mut().enumerate() { + if i < read { + *s = ((f32_buf[i] * vol + 1.0) * 32767.5) + .clamp(0.0, 65535.0) as u16; + } else { + *s = 32768; + } + } + sp.fetch_add(read as u64, Ordering::Relaxed); + if read == 0 && dd.load(Ordering::Relaxed) { + be.store(true, Ordering::Relaxed); + } + }, + err_fn, + None, + ) + .map_err(|e| format!("创建输出流失败: {}", e)) + } + + _ => Err(format!("不支持的采样格式: {:?}", sample_format)), + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dc5ca39..4c5a675 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -146,6 +146,11 @@ pub fn run() { api::user_playlist, api::recommend_resource, api::recommend_songs, + api::personalized, + api::personalized_newsong, + api::top_artists, + api::top_song, + api::album_newest, api::personal_fm, api::personal_fm_mode, api::fm_trash, @@ -163,17 +168,17 @@ pub fn run() { api::playlist_track_all, api::exit_app, - audio::play_audio, - audio::play_local_audio, - audio::pause_audio, - audio::resume_audio, - audio::stop_audio, - audio::get_output_devices, - audio::set_output_device, - audio::seek_audio, - audio::get_audio_position, - audio::set_volume, - audio::is_audio_playing, + audio::commands::play_audio, + audio::commands::play_local_audio, + audio::commands::pause_audio, + audio::commands::resume_audio, + audio::commands::stop_audio, + audio::commands::get_output_devices, + audio::commands::set_output_device, + audio::commands::seek_audio, + audio::commands::get_audio_position, + audio::commands::set_volume, + audio::commands::is_audio_playing, api::download_song, api::list_local_songs, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index acd781a..f98f7ad 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Nekosonic", - "version": "0.7.0", + "version": "0.8.0", "identifier": "com.atdunbg.Nekosonic", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.vue b/src/App.vue index b669b1a..b5acfab 100644 --- a/src/App.vue +++ b/src/App.vue @@ -19,21 +19,25 @@
- -
- + -
- - - - - -
+
+ + +
+
+ + + + + +
+
+
- + @@ -61,7 +65,8 @@ import { useRoute } from 'vue-router'; import { useUserStore } from './stores/user'; import { useSettingsStore, type CloseAction } from './stores/settings'; import { usePlayerStore } from './stores/player'; -import TitleBar from './components/TitleBar.vue'; +import { useUiStore } from './stores/ui'; +import TopBar from './components/TopBar.vue'; import Sidebar from './components/Sidebar.vue'; import RoamDrawer from './components/RoamDrawer.vue'; import PlayerBar from './components/PlayerBar.vue'; @@ -79,6 +84,7 @@ import { hexToRgba } from './utils/color'; const userStore = useUserStore(); const player = usePlayerStore(); +const ui = useUiStore(); const settings = useSettingsStore(); const updater = useUpdater(); const { isOnline } = useOnlineStatus(); @@ -95,6 +101,22 @@ const windowVisible = ref(true); // 规则:30秒未访问的页面自动清除缓存;多级跳转时保留导航链上的页面;FavoriteSongs 常驻 const route = useRoute(); +// 主滚动容器 ref(路由切换时重置滚动位置) +const mainScrollRef = ref(null); + +// 路由切换时重置主滚动容器到顶部 +// keep-alive 缓存会保留滚动位置,详情页等需要主动重置 +watch(() => route.path, () => { + // 双重保险:nextTick + 延迟,确保 DOM 更新和缓存恢复后再滚动 + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (mainScrollRef.value) { + mainScrollRef.value.scrollTo({ top: 0, left: 0, behavior: 'auto' }); + } + }); + }); +}); + const ROUTE_COMPONENT: Record = { home: 'HomeView', discover: 'DiscoverView', search: 'DiscoverView', favorites: 'FavoriteSongsView', daily: 'DailySongsView', @@ -175,6 +197,26 @@ watch(() => settings.currentWallpaper.path, async (path) => { } }, { immediate: true }); +// --- 响应式侧边栏:窄屏自动切抽屉模式 --- +const DRAWER_BREAKPOINT = 768; // px +let savedSidebarMode: 'expanded' | 'collapsed' = 'expanded'; + +function applyResponsiveSidebar() { + const width = window.innerWidth; + if (width < DRAWER_BREAKPOINT) { + // 窄屏:切抽屉模式(不持久化,保留用户原选择) + if (ui.sidebarMode !== 'drawer') { + savedSidebarMode = ui.sidebarMode as 'expanded' | 'collapsed'; + ui.setSidebarMode('drawer'); + } + } else { + // 宽屏:恢复用户选择 + if (ui.sidebarMode === 'drawer') { + ui.setSidebarMode(savedSidebarMode); + } + } +} + // 根容器背景:有壁纸时透明(遮罩层已保证文字可读),无壁纸时不透明 const rootBgStyle = computed(() => { const wp = settings.currentWallpaper; @@ -206,6 +248,10 @@ onMounted(() => { document.addEventListener('contextmenu', (e) => e.preventDefault()); startCleanup(); + // 响应式侧边栏 + applyResponsiveSidebar(); + window.addEventListener('resize', applyResponsiveSidebar); + AudioApi.stopAudio().catch(() => {}); if (userStore.isLoggedIn) { diff --git a/src/api.ts b/src/api.ts deleted file mode 100644 index 7c3903d..0000000 --- a/src/api.ts +++ /dev/null @@ -1,233 +0,0 @@ -import { invoke } from '@tauri-apps/api/core'; - -export namespace MusicApi { - export async function getLoginStatus(): Promise { - return invoke('get_login_status'); - } - - export async function logout(): Promise { - return invoke('logout'); - } - - export async function getQrKey(): Promise { - return invoke('get_qr_key'); - } - - export async function checkQrStatus(key: string): Promise { - return invoke('check_qr_status', { query: { key } }); - } - - export async function likelist(uid: number): Promise { - return invoke('likelist', { uid }); - } - - export async function likeSong(id: number, like: boolean): Promise { - return invoke('like_song', { query: { id, like: like ? 'true' : 'false' } }); - } - - export async function userPlaylist(uid: number): Promise { - return invoke('user_playlist', { uid }); - } - - export async function getPlaylistDetail(id: number): Promise { - return invoke('get_playlist_detail', { id }); - } - - export async function playlistTrackAll(id: number): Promise { - return invoke('playlist_track_all', { query: { id } }); - } - - export async function playlistSubscribe(id: number, subscribe: boolean): Promise { - return invoke('playlist_subscribe', { query: { id, subscribe } }); - } - - export async function recommendResource(): Promise { - return invoke('recommend_resource'); - } - - export async function recommendSongs(): Promise { - return invoke('recommend_songs'); - } - - export async function getSongDetail(id: string): Promise { - return invoke('get_song_detail', { id }); - } - - export async function getSongUrl(query: { id: number; level: string; fm_mode?: boolean }): Promise { - return invoke('get_song_url', { query }); - } - - export async function getLyric(id: number): Promise { - return invoke('get_lyric', { id }); - } - - export async function searchSuggest(keyword: string): Promise { - return invoke('search_suggest', { query: { keyword } }); - } - - export async function getHotSearch(): Promise { - return invoke('get_hot_search'); - } - - export async function cloudsearch(query: { keyword: string; searchType: number; limit: number }): Promise { - return invoke('cloudsearch', { query }); - } - - export async function albumDetail(id: number): Promise { - return invoke('album_detail', { id }); - } - - export async function artistDetail(id: number): Promise { - return invoke('artist_detail', { id }); - } - - export async function artistSongs(query: { id: number; order: string; limit: number; offset: number }): Promise { - return invoke('artist_songs', { query }); - } - - export async function artistAlbum(id: number, limit: number, offset: number): Promise { - return invoke('artist_album', { id, limit, offset }); - } - - export async function artistDesc(id: number): Promise { - return invoke('artist_desc', { id }); - } - - export async function artistSub(id: number, sub: boolean): Promise { - return invoke('artist_sub', { query: { id, sub } }); - } - - export async function artistSublist(limit = 100, offset = 0): Promise { - return invoke('artist_sublist', { query: { limit, offset } }); - } - - export async function commentHot(query: { type: number; id: number; limit: number; offset: number }): Promise { - return invoke('comment_hot', { query }); - } - - export async function commentLike(query: { t: number; type: number; id: number; cid: number }): Promise { - return invoke('comment_like', { query }); - } - - export async function personalFm(): Promise { - return invoke('personal_fm'); - } - - export async function personalFmMode(query: { mode: string; subMode: string; limit: number }): Promise { - return invoke('personal_fm_mode', { query }); - } - - export async function fmTrash(id: number, time: number): Promise { - return invoke('fm_trash', { query: { id, time } }); - } - - export async function scrobble(query: { id: number; sourceid: string; time: number; alg?: string; source?: string; bitrate?: number }): Promise { - return invoke('scrobble', { query }); - } - - // 云盘 - export async function userCloud(limit = 30, offset = 0): Promise { - return invoke('user_cloud', { limit, offset }); - } - - export async function userCloudDel(id: number): Promise { - return invoke('user_cloud_del', { id }); - } - - export async function cloudUpload(filePath: string): Promise { - return invoke('cloud_upload', { filePath }); - } -} - -export namespace AudioApi { - export async function playAudio(url: string): Promise { - return invoke('play_audio', { url }); - } - - export async function playLocalAudio(path: string): Promise { - return invoke('play_local_audio', { path }); - } - - export async function pauseAudio(): Promise { - return invoke('pause_audio'); - } - - export async function resumeAudio(): Promise { - return invoke('resume_audio'); - } - - export async function stopAudio(): Promise { - return invoke('stop_audio'); - } - - export async function seekAudio(time: number): Promise { - return invoke('seek_audio', { time }); - } - - export async function setVolume(vol: number): Promise { - return invoke('set_volume', { vol }); - } - - export async function getAudioPosition(): Promise { - return invoke('get_audio_position'); - } - - export async function isAudioPlaying(): Promise { - return invoke('is_audio_playing'); - } -} - -export namespace DeviceApi { - export async function getOutputDevices(): Promise { - return invoke('get_output_devices'); - } - - export async function setOutputDevice(device: string | null): Promise { - return invoke('set_output_device', { device }); - } -} - -export namespace DownloadApi { - export async function downloadSong(query: { - id: number; - name: string; - artist: string; - album: string | null; - duration: number | null; - coverUrl: string | null; - level: string; - downloadPath: string | null; - }): Promise { - return invoke('download_song', { query }); - } - - export async function listLocalSongs(downloadPath: string | null): Promise { - return invoke('list_local_songs', { downloadPath }); - } - - export async function scanLocalFolders(paths: string[]): Promise { - return invoke('scan_local_folders', { paths }); - } - - export async function deleteLocalSong(query: { id: number; filename: string; downloadPath: string | null }): Promise { - return invoke('delete_local_song', { query }); - } - - export async function getDefaultDownloadPath(): Promise { - return invoke('get_default_download_path'); - } -} - -export namespace AppApi { - export function exitApp(): Promise { - return invoke('exit_app'); - } - - export async function readImageAsDataUrl(path: string): Promise { - return invoke('read_image_as_data_url', { path }); - } - - export async function showItemInFolder(path: string): Promise { - return invoke('show_item_in_folder', { path }); - } -} diff --git a/src/api/album.ts b/src/api/album.ts new file mode 100644 index 0000000..e39d110 --- /dev/null +++ b/src/api/album.ts @@ -0,0 +1,11 @@ +import { invoke } from '@tauri-apps/api/core'; + +/** + * 专辑相关 API + */ +export const AlbumApi = { + /** 获取专辑详情 */ + async albumDetail(id: number): Promise { + return invoke('album_detail', { id }); + }, +}; diff --git a/src/api/app.ts b/src/api/app.ts new file mode 100644 index 0000000..d3130e5 --- /dev/null +++ b/src/api/app.ts @@ -0,0 +1,21 @@ +import { invoke } from '@tauri-apps/api/core'; + +/** + * 应用级 API + */ +export const AppApi = { + /** 退出应用 */ + exitApp(): Promise { + return invoke('exit_app'); + }, + + /** 读取图片为 data URL */ + async readImageAsDataUrl(path: string): Promise { + return invoke('read_image_as_data_url', { path }); + }, + + /** 在文件管理器中显示文件 */ + async showItemInFolder(path: string): Promise { + return invoke('show_item_in_folder', { path }); + }, +}; diff --git a/src/api/artist.ts b/src/api/artist.ts new file mode 100644 index 0000000..6a08b5a --- /dev/null +++ b/src/api/artist.ts @@ -0,0 +1,36 @@ +import { invoke } from '@tauri-apps/api/core'; + +/** + * 歌手相关 API + */ +export const ArtistApi = { + /** 获取歌手详情 */ + async artistDetail(id: number): Promise { + return invoke('artist_detail', { id }); + }, + + /** 获取歌手歌曲 */ + async artistSongs(query: { id: number; order: string; limit: number; offset: number }): Promise { + return invoke('artist_songs', { query }); + }, + + /** 获取歌手专辑 */ + async artistAlbum(id: number, limit: number, offset: number): Promise { + return invoke('artist_album', { id, limit, offset }); + }, + + /** 获取歌手描述 */ + async artistDesc(id: number): Promise { + return invoke('artist_desc', { id }); + }, + + /** 关注/取消关注歌手 */ + async artistSub(id: number, sub: boolean): Promise { + return invoke('artist_sub', { query: { id, sub } }); + }, + + /** 已关注的歌手列表 */ + async artistSublist(limit = 100, offset = 0): Promise { + return invoke('artist_sublist', { query: { limit, offset } }); + }, +}; diff --git a/src/api/audio.ts b/src/api/audio.ts new file mode 100644 index 0000000..ad1fdfe --- /dev/null +++ b/src/api/audio.ts @@ -0,0 +1,51 @@ +import { invoke } from '@tauri-apps/api/core'; + +/** + * 音频播放控制 API + */ +export const AudioApi = { + /** 播放网络音频 */ + async playAudio(url: string): Promise { + return invoke('play_audio', { url }); + }, + + /** 播放本地音频文件 */ + async playLocalAudio(path: string): Promise { + return invoke('play_local_audio', { path }); + }, + + /** 暂停播放 */ + async pauseAudio(): Promise { + return invoke('pause_audio'); + }, + + /** 恢复播放 */ + async resumeAudio(): Promise { + return invoke('resume_audio'); + }, + + /** 停止播放 */ + async stopAudio(): Promise { + return invoke('stop_audio'); + }, + + /** 跳转到指定位置(秒) */ + async seekAudio(time: number): Promise { + return invoke('seek_audio', { time }); + }, + + /** 设置音量(0.0 - 1.0) */ + async setVolume(vol: number): Promise { + return invoke('set_volume', { vol }); + }, + + /** 获取当前播放位置(秒) */ + async getAudioPosition(): Promise { + return invoke('get_audio_position'); + }, + + /** 是否正在播放 */ + async isAudioPlaying(): Promise { + return invoke('is_audio_playing'); + }, +}; diff --git a/src/api/cloud.ts b/src/api/cloud.ts new file mode 100644 index 0000000..4a8f692 --- /dev/null +++ b/src/api/cloud.ts @@ -0,0 +1,21 @@ +import { invoke } from '@tauri-apps/api/core'; + +/** + * 云盘相关 API + */ +export const CloudApi = { + /** 获取云盘歌曲列表 */ + async userCloud(limit = 30, offset = 0): Promise { + return invoke('user_cloud', { limit, offset }); + }, + + /** 删除云盘歌曲 */ + async userCloudDel(id: number): Promise { + return invoke('user_cloud_del', { id }); + }, + + /** 上传歌曲到云盘 */ + async cloudUpload(filePath: string): Promise { + return invoke('cloud_upload', { filePath }); + }, +}; diff --git a/src/api/comment.ts b/src/api/comment.ts new file mode 100644 index 0000000..7c4e142 --- /dev/null +++ b/src/api/comment.ts @@ -0,0 +1,16 @@ +import { invoke } from '@tauri-apps/api/core'; + +/** + * 评论相关 API + */ +export const CommentApi = { + /** 获取热门评论 */ + async commentHot(query: { type: number; id: number; limit: number; offset: number }): Promise { + return invoke('comment_hot', { query }); + }, + + /** 点赞/取消点赞评论 */ + async commentLike(query: { t: number; type: number; id: number; cid: number }): Promise { + return invoke('comment_like', { query }); + }, +}; diff --git a/src/api/device.ts b/src/api/device.ts new file mode 100644 index 0000000..47132f7 --- /dev/null +++ b/src/api/device.ts @@ -0,0 +1,16 @@ +import { invoke } from '@tauri-apps/api/core'; + +/** + * 音频输出设备 API + */ +export const DeviceApi = { + /** 获取所有输出设备 */ + async getOutputDevices(): Promise { + return invoke('get_output_devices'); + }, + + /** 设置输出设备(null 表示默认) */ + async setOutputDevice(device: string | null): Promise { + return invoke('set_output_device', { device }); + }, +}; diff --git a/src/api/download.ts b/src/api/download.ts new file mode 100644 index 0000000..113f949 --- /dev/null +++ b/src/api/download.ts @@ -0,0 +1,40 @@ +import { invoke } from '@tauri-apps/api/core'; + +/** + * 下载与本地音乐 API + */ +export const DownloadApi = { + /** 下载歌曲 */ + async downloadSong(query: { + id: number; + name: string; + artist: string; + album: string | null; + duration: number | null; + coverUrl: string | null; + level: string; + downloadPath: string | null; + }): Promise { + return invoke('download_song', { query }); + }, + + /** 列出已下载的歌曲 */ + async listLocalSongs(downloadPath: string | null): Promise { + return invoke('list_local_songs', { downloadPath }); + }, + + /** 扫描本地文件夹 */ + async scanLocalFolders(paths: string[]): Promise { + return invoke('scan_local_folders', { paths }); + }, + + /** 删除本地歌曲文件 */ + async deleteLocalSong(query: { id: number; filename: string; downloadPath: string | null }): Promise { + return invoke('delete_local_song', { query }); + }, + + /** 获取默认下载路径 */ + async getDefaultDownloadPath(): Promise { + return invoke('get_default_download_path'); + }, +}; diff --git a/src/api/fm.ts b/src/api/fm.ts new file mode 100644 index 0000000..fb9e16a --- /dev/null +++ b/src/api/fm.ts @@ -0,0 +1,21 @@ +import { invoke } from '@tauri-apps/api/core'; + +/** + * 私人 FM 相关 API + */ +export const FmApi = { + /** 获取私人 FM 歌曲 */ + async personalFm(): Promise { + return invoke('personal_fm'); + }, + + /** 切换私人 FM 模式 */ + async personalFmMode(query: { mode: string; subMode: string; limit: number }): Promise { + return invoke('personal_fm_mode', { query }); + }, + + /** 私人 FM 不喜欢(扔进垃圾桶) */ + async fmTrash(id: number, time: number): Promise { + return invoke('fm_trash', { query: { id, time } }); + }, +}; diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 0000000..874529b --- /dev/null +++ b/src/api/index.ts @@ -0,0 +1,88 @@ +/** + * API 统一入口 + * + * 按业务域拆分到独立文件,此处重新组装为命名空间以保持向后兼容。 + * 新代码建议直接从具体域文件导入,例如: + * import { SongApi } from '../api/song'; + */ +import { LoginApi } from './login'; +import { SongApi } from './song'; +import { PlaylistApi } from './playlist'; +import { SearchApi } from './search'; +import { AlbumApi } from './album'; +import { ArtistApi } from './artist'; +import { CommentApi } from './comment'; +import { FmApi } from './fm'; +import { CloudApi } from './cloud'; +import { AudioApi } from './audio'; +import { DeviceApi } from './device'; +import { DownloadApi } from './download'; +import { AppApi } from './app'; +import { RecApi } from './rec'; + +export { LoginApi, SongApi, PlaylistApi, SearchApi, AlbumApi, ArtistApi, CommentApi, FmApi, CloudApi, AudioApi, DeviceApi, DownloadApi, AppApi, RecApi }; + +/** + * 音乐业务 API(向后兼容命名空间) + * @deprecated 建议直接使用具体域的 Api 对象,如 `SongApi`、`PlaylistApi` 等 + */ +export const MusicApi = { + // 登录 + getLoginStatus: LoginApi.getLoginStatus, + logout: LoginApi.logout, + getQrKey: LoginApi.getQrKey, + checkQrStatus: LoginApi.checkQrStatus, + + // 歌曲 + getSongDetail: SongApi.getSongDetail, + getSongUrl: SongApi.getSongUrl, + getLyric: SongApi.getLyric, + likelist: SongApi.likelist, + likeSong: SongApi.likeSong, + scrobble: SongApi.scrobble, + + // 歌单 + userPlaylist: PlaylistApi.userPlaylist, + getPlaylistDetail: PlaylistApi.getPlaylistDetail, + playlistTrackAll: PlaylistApi.playlistTrackAll, + playlistSubscribe: PlaylistApi.playlistSubscribe, + recommendResource: PlaylistApi.recommendResource, + recommendSongs: PlaylistApi.recommendSongs, + + // 搜索 + searchSuggest: SearchApi.searchSuggest, + getHotSearch: SearchApi.getHotSearch, + cloudsearch: SearchApi.cloudsearch, + + // 专辑 + albumDetail: AlbumApi.albumDetail, + + // 歌手 + artistDetail: ArtistApi.artistDetail, + artistSongs: ArtistApi.artistSongs, + artistAlbum: ArtistApi.artistAlbum, + artistDesc: ArtistApi.artistDesc, + artistSub: ArtistApi.artistSub, + artistSublist: ArtistApi.artistSublist, + + // 评论 + commentHot: CommentApi.commentHot, + commentLike: CommentApi.commentLike, + + // 私人 FM + personalFm: FmApi.personalFm, + personalFmMode: FmApi.personalFmMode, + fmTrash: FmApi.fmTrash, + + // 云盘 + userCloud: CloudApi.userCloud, + userCloudDel: CloudApi.userCloudDel, + cloudUpload: CloudApi.cloudUpload, + + // 推荐 + personalized: RecApi.personalized, + personalizedNewsong: RecApi.personalizedNewsong, + topArtists: RecApi.topArtists, + topSong: RecApi.topSong, + albumNewest: RecApi.albumNewest, +}; diff --git a/src/api/login.ts b/src/api/login.ts new file mode 100644 index 0000000..f5527dd --- /dev/null +++ b/src/api/login.ts @@ -0,0 +1,26 @@ +import { invoke } from '@tauri-apps/api/core'; + +/** + * 登录相关 API + */ +export const LoginApi = { + /** 获取登录状态 */ + async getLoginStatus(): Promise { + return invoke('get_login_status'); + }, + + /** 退出登录 */ + async logout(): Promise { + return invoke('logout'); + }, + + /** 获取二维码登录 key */ + async getQrKey(): Promise { + return invoke('get_qr_key'); + }, + + /** 检查二维码扫描状态 */ + async checkQrStatus(key: string): Promise { + return invoke('check_qr_status', { query: { key } }); + }, +}; diff --git a/src/api/playlist.ts b/src/api/playlist.ts new file mode 100644 index 0000000..9828d8c --- /dev/null +++ b/src/api/playlist.ts @@ -0,0 +1,36 @@ +import { invoke } from '@tauri-apps/api/core'; + +/** + * 歌单相关 API + */ +export const PlaylistApi = { + /** 获取用户歌单列表 */ + async userPlaylist(uid: number): Promise { + return invoke('user_playlist', { uid }); + }, + + /** 获取歌单详情 */ + async getPlaylistDetail(id: number): Promise { + return invoke('get_playlist_detail', { id }); + }, + + /** 获取歌单全部曲目 */ + async playlistTrackAll(id: number): Promise { + return invoke('playlist_track_all', { query: { id } }); + }, + + /** 订阅/取消订阅歌单 */ + async playlistSubscribe(id: number, subscribe: boolean): Promise { + return invoke('playlist_subscribe', { query: { id, subscribe } }); + }, + + /** 每日推荐歌单 */ + async recommendResource(): Promise { + return invoke('recommend_resource'); + }, + + /** 每日推荐歌曲 */ + async recommendSongs(): Promise { + return invoke('recommend_songs'); + }, +}; diff --git a/src/api/rec.ts b/src/api/rec.ts new file mode 100644 index 0000000..af4125a --- /dev/null +++ b/src/api/rec.ts @@ -0,0 +1,31 @@ +import { invoke } from '@tauri-apps/api/core'; + +/** + * 推荐相关 API + */ +export const RecApi = { + /** 个性化推荐歌单(无需登录) */ + async personalized(limit: number = 30): Promise { + return invoke('personalized', { limit }); + }, + + /** 推荐新歌 */ + async personalizedNewsong(limit: number = 10): Promise { + return invoke('personalized_newsong', { limit }); + }, + + /** 热门歌手 */ + async topArtists(limit: number = 30, offset: number = 0): Promise { + return invoke('top_artists', { limit, offset }); + }, + + /** 新歌速递,type: 全部:0 / 华语:7 / 欧美:96 / 韩国:16 / 日本:8 */ + async topSong(areaType: number = 0): Promise { + return invoke('top_song', { areaType }); + }, + + /** 最新专辑(新碟上架) */ + async albumNewest(): Promise { + return invoke('album_newest'); + }, +}; diff --git a/src/api/search.ts b/src/api/search.ts new file mode 100644 index 0000000..de1e788 --- /dev/null +++ b/src/api/search.ts @@ -0,0 +1,21 @@ +import { invoke } from '@tauri-apps/api/core'; + +/** + * 搜索相关 API + */ +export const SearchApi = { + /** 搜索建议 */ + async searchSuggest(keyword: string): Promise { + return invoke('search_suggest', { query: { keyword } }); + }, + + /** 热门搜索 */ + async getHotSearch(): Promise { + return invoke('get_hot_search'); + }, + + /** 云搜索 */ + async cloudsearch(query: { keyword: string; searchType: number; limit: number }): Promise { + return invoke('cloudsearch', { query }); + }, +}; diff --git a/src/api/song.ts b/src/api/song.ts new file mode 100644 index 0000000..7e19ed6 --- /dev/null +++ b/src/api/song.ts @@ -0,0 +1,36 @@ +import { invoke } from '@tauri-apps/api/core'; + +/** + * 歌曲相关 API + */ +export const SongApi = { + /** 获取歌曲详情 */ + async getSongDetail(id: string): Promise { + return invoke('get_song_detail', { id }); + }, + + /** 获取歌曲播放 URL */ + async getSongUrl(query: { id: number; level: string; fm_mode?: boolean }): Promise { + return invoke('get_song_url', { query }); + }, + + /** 获取歌词 */ + async getLyric(id: number): Promise { + return invoke('get_lyric', { id }); + }, + + /** 获取喜欢列表 */ + async likelist(uid: number): Promise { + return invoke('likelist', { uid }); + }, + + /** 喜欢/取消喜欢歌曲 */ + async likeSong(id: number, like: boolean): Promise { + return invoke('like_song', { query: { id, like: like ? 'true' : 'false' } }); + }, + + /** 上报听歌记录(scrobble) */ + async scrobble(query: { id: number; sourceid: string; time: number; alg?: string; source?: string; bitrate?: number }): Promise { + return invoke('scrobble', { query }); + }, +}; diff --git a/src/components/Card/AlbumCard.vue b/src/components/Card/AlbumCard.vue new file mode 100644 index 0000000..5c024a3 --- /dev/null +++ b/src/components/Card/AlbumCard.vue @@ -0,0 +1,82 @@ + + + diff --git a/src/components/Card/ArtistCard.vue b/src/components/Card/ArtistCard.vue new file mode 100644 index 0000000..91454ed --- /dev/null +++ b/src/components/Card/ArtistCard.vue @@ -0,0 +1,60 @@ + + + diff --git a/src/components/Card/CardGrid.vue b/src/components/Card/CardGrid.vue new file mode 100644 index 0000000..cc94fb0 --- /dev/null +++ b/src/components/Card/CardGrid.vue @@ -0,0 +1,59 @@ + + + diff --git a/src/components/Card/PlaylistCard.vue b/src/components/Card/PlaylistCard.vue new file mode 100644 index 0000000..0a22161 --- /dev/null +++ b/src/components/Card/PlaylistCard.vue @@ -0,0 +1,95 @@ + + + diff --git a/src/components/Card/SectionHeader.vue b/src/components/Card/SectionHeader.vue new file mode 100644 index 0000000..a279a9f --- /dev/null +++ b/src/components/Card/SectionHeader.vue @@ -0,0 +1,33 @@ + + + diff --git a/src/components/CommentSection.vue b/src/components/CommentSection.vue index 3902431..92cb21a 100644 --- a/src/components/CommentSection.vue +++ b/src/components/CommentSection.vue @@ -92,7 +92,7 @@ async function fetchComments(reset = false) { } hasMore.value = list.length >= pageSize } catch (e) { - console.error(e) + console.error('获取评论列表失败', e) } finally { loading.value = false loadingMore.value = false @@ -123,7 +123,7 @@ async function likeComment(cid: number) { target.liked = !liked target.likedCount += liked ? -1 : 1 } catch (e) { - console.error(e) + console.error('评论点赞失败', e) } finally { likingSet.value.delete(cid) } diff --git a/src/components/DetailLayout.vue b/src/components/DetailLayout.vue new file mode 100644 index 0000000..5cbc02f --- /dev/null +++ b/src/components/DetailLayout.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/src/components/PageHeader.vue b/src/components/PageHeader.vue index a289da1..ade1513 100644 --- a/src/components/PageHeader.vue +++ b/src/components/PageHeader.vue @@ -1,9 +1,6 @@