mirror of
https://github.com/atdunbg/Nekosonic-Music.git
synced 2026-08-06 03:44:18 +08:00
feat: v0.8.0 详情页架构重构、皮肤系统、侧边栏交互升级、底层模块拆分
- 详情页统一 DetailLayout 组件(常驻头部 + 独立滚动 + 紧凑态动画) - 歌曲/评论 tab 切换(懒加载 + 保活),专辑模糊搜索 - 皮肤系统 skins.ts(19 语义色 + 14 预设 + 自定义皮肤 + 壁纸) - 设置页皮肤模块(外观/主题色/皮肤编辑器/壁纸选择) - 可折叠侧边栏 + TopBar + 折叠态歌单浮层(高度不越 PlayerBar) - 首页扩展至 6 区块 + 通用卡片组件 + 虚拟歌曲列表 - audio.rs 拆为 8 模块,api.ts 拆为 api/ 目录,player store 拆为 4 store - 修复播放竞态、seek 暂停、评论提前加载、紧凑态卡死等
This commit is contained in:
@@ -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
|
## v0.7.0
|
||||||
|
|
||||||
### ✨ 新功能
|
### ✨ 新功能
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "nekosonic",
|
"name": "nekosonic",
|
||||||
"version": "0.6.0",
|
"version": "0.8.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "nekosonic",
|
"name": "nekosonic",
|
||||||
"version": "0.6.0",
|
"version": "0.8.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "nekosonic",
|
"name": "nekosonic",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.7.0",
|
"version": "0.8.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
Generated
+8
-8
@@ -4,7 +4,7 @@ version = 4
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "Nekosonic"
|
name = "Nekosonic"
|
||||||
version = "0.7.0"
|
version = "0.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"cpal",
|
"cpal",
|
||||||
@@ -65,9 +65,9 @@ checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "alloc-stdlib"
|
name = "alloc-stdlib"
|
||||||
version = "0.2.2"
|
version = "0.2.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece"
|
checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alloc-no-stdlib",
|
"alloc-no-stdlib",
|
||||||
]
|
]
|
||||||
@@ -472,9 +472,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "brotli"
|
name = "brotli"
|
||||||
version = "8.0.3"
|
version = "8.0.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610"
|
checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alloc-no-stdlib",
|
"alloc-no-stdlib",
|
||||||
"alloc-stdlib",
|
"alloc-stdlib",
|
||||||
@@ -483,9 +483,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "brotli-decompressor"
|
name = "brotli-decompressor"
|
||||||
version = "5.0.1"
|
version = "5.0.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924"
|
checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alloc-no-stdlib",
|
"alloc-no-stdlib",
|
||||||
"alloc-stdlib",
|
"alloc-stdlib",
|
||||||
@@ -5854,7 +5854,7 @@ dependencies = [
|
|||||||
"png 0.18.1",
|
"png 0.18.1",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "Nekosonic"
|
name = "Nekosonic"
|
||||||
version = "0.7.0"
|
version = "0.8.0"
|
||||||
description = "A Simple music app"
|
description = "A Simple music app"
|
||||||
authors = ["atdunbg"]
|
authors = ["atdunbg"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -5,17 +5,15 @@
|
|||||||
"windows": ["main"],
|
"windows": ["main"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"opener:default",
|
"core:window:default",
|
||||||
"core:window:allow-minimize",
|
"core:window:allow-minimize",
|
||||||
|
"core:window:allow-unminimize",
|
||||||
"core:window:allow-maximize",
|
"core:window:allow-maximize",
|
||||||
"core:window:allow-unmaximize",
|
"core:window:allow-unmaximize",
|
||||||
"core:window:allow-close",
|
"core:window:allow-is-maximized",
|
||||||
"core:window:allow-hide",
|
|
||||||
"core:window:allow-start-dragging",
|
"core:window:allow-start-dragging",
|
||||||
"core:window:allow-toggle-maximize",
|
"core:path:default",
|
||||||
"core:window:allow-unminimize",
|
"opener:default",
|
||||||
"core:window:allow-show",
|
|
||||||
"core:window:allow-set-focus",
|
|
||||||
"global-shortcut:allow-is-registered",
|
"global-shortcut:allow-is-registered",
|
||||||
"global-shortcut:allow-register",
|
"global-shortcut:allow-register",
|
||||||
"global-shortcut:allow-unregister",
|
"global-shortcut:allow-unregister",
|
||||||
@@ -23,4 +21,4 @@
|
|||||||
"process:allow-restart",
|
"process:allow-restart",
|
||||||
"updater:default"
|
"updater:default"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -343,6 +343,50 @@ pub async fn recommend_resource(state: State<'_, ApiController>) -> Result<Strin
|
|||||||
api_call!(state, recommend_resource)
|
api_call!(state, recommend_resource)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 个性化推荐歌单(无需登录)
|
||||||
|
/// 对应 /personalized
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn personalized(limit: Option<u32>, state: State<'_, ApiController>) -> Result<String, String> {
|
||||||
|
api_call!(state, personalized, params: [
|
||||||
|
("limit", &limit.unwrap_or(30).to_string())
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 推荐新歌
|
||||||
|
/// 对应 /personalized/newsong
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn personalized_newsong(limit: Option<u32>, state: State<'_, ApiController>) -> Result<String, String> {
|
||||||
|
api_call!(state, personalized_newsong, params: [
|
||||||
|
("limit", &limit.unwrap_or(10).to_string())
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 热门歌手
|
||||||
|
/// 对应 /top/artists
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn top_artists(limit: Option<u32>, offset: Option<u32>, state: State<'_, ApiController>) -> Result<String, String> {
|
||||||
|
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<u32>, state: State<'_, ApiController>) -> Result<String, String> {
|
||||||
|
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<String, String> {
|
||||||
|
api_call!(state, album_newest)
|
||||||
|
}
|
||||||
|
|
||||||
/// 私人漫游模式查询参数
|
/// 私人漫游模式查询参数
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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<u8>,
|
||||||
|
pub(crate) done: bool,
|
||||||
|
pub(crate) cancelled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 线程安全的共享缓冲区,支持生产者写入和消费者读取的同步等待
|
||||||
|
pub struct SharedBuffer {
|
||||||
|
state: Mutex<BufferState>,
|
||||||
|
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<BufferState> {
|
||||||
|
&self.state
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取内部条件变量(供 StreamingReader 使用)
|
||||||
|
pub(crate) fn condvar(&self) -> &Condvar {
|
||||||
|
&self.available
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 流式读取器,从共享缓冲区中按需读取数据,实现 `Read` 和 `Seek` trait
|
||||||
|
pub struct StreamingReader {
|
||||||
|
buffer: Arc<SharedBuffer>,
|
||||||
|
pos: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StreamingReader {
|
||||||
|
/// 创建新的流式读取器,绑定到指定的共享缓冲区
|
||||||
|
pub fn new(buffer: Arc<SharedBuffer>) -> Self {
|
||||||
|
StreamingReader { buffer, pos: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Read for StreamingReader {
|
||||||
|
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||||
|
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<u64> {
|
||||||
|
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<f32>;
|
||||||
|
pub type RingConsumer = HeapCons<f32>;
|
||||||
|
|
||||||
|
/// 创建指定容量的环形缓冲区,返回 (生产者, 消费者)
|
||||||
|
pub fn create_ring_buffer(capacity: usize) -> (RingProducer, RingConsumer) {
|
||||||
|
let rb = HeapRb::<f32>::new(capacity);
|
||||||
|
rb.split()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 为 StreamingReader 实现 symphonia 的 MediaSource trait
|
||||||
|
impl MediaSource for StreamingReader {
|
||||||
|
fn is_seekable(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn byte_len(&self) -> Option<u64> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AudioController>);
|
||||||
|
|
||||||
|
/// 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<String> {
|
||||||
|
list_output_devices()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tauri 命令:设置音频输出设备,传入 None 使用系统默认设备
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn set_output_device(state: State<'_, AppAudio>, device: Option<String>) {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
@@ -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<String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 音频控制器,通过通道向音频线程发送控制命令
|
||||||
|
pub struct AudioController {
|
||||||
|
tx: Sender<AudioCmd>,
|
||||||
|
current_url: Arc<Mutex<Option<String>>>,
|
||||||
|
position: Arc<Mutex<f64>>,
|
||||||
|
is_playing: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String>) {
|
||||||
|
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<OutputContext>, shared_position: &Arc<Mutex<f64>>) {
|
||||||
|
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<String>,
|
||||||
|
audio_buffer: &Option<Arc<SharedBuffer>>,
|
||||||
|
) -> Option<MediaSourceStream> {
|
||||||
|
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<OutputContext>,
|
||||||
|
shared_position: &Arc<Mutex<f64>>,
|
||||||
|
local_path: &Option<String>,
|
||||||
|
audio_buffer: &Option<Arc<SharedBuffer>>,
|
||||||
|
selected_device: &Option<String>,
|
||||||
|
current_volume: f32,
|
||||||
|
audio_paused: bool,
|
||||||
|
) -> Result<OutputContext, String> {
|
||||||
|
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<AudioCmd>,
|
||||||
|
_current_url: Arc<Mutex<Option<String>>>,
|
||||||
|
shared_position: Arc<Mutex<f64>>,
|
||||||
|
is_playing: Arc<AtomicBool>,
|
||||||
|
app_handle: AppHandle,
|
||||||
|
) {
|
||||||
|
let mut selected_device: Option<String> = None;
|
||||||
|
let mut current_volume: f32 = 1.0;
|
||||||
|
let mut output_ctx: Option<OutputContext> = None;
|
||||||
|
let mut current_audio_buffer: Option<Arc<SharedBuffer>> = None;
|
||||||
|
let mut current_local_path: Option<String> = 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<f32> {
|
||||||
|
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<f32> {
|
||||||
|
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<f32> {
|
||||||
|
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<AtomicBool>,
|
||||||
|
cancelled: Arc<AtomicBool>,
|
||||||
|
decode_done: Arc<AtomicBool>,
|
||||||
|
seek_time: Option<f64>,
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
//! 音频输出设备管理
|
||||||
|
//!
|
||||||
|
//! 列举、查找、选择音频输出设备。当用户指定的设备不存在时回退到系统默认设备。
|
||||||
|
|
||||||
|
use cpal::traits::{DeviceTrait, HostTrait};
|
||||||
|
|
||||||
|
/// 获取系统默认输出设备的名称
|
||||||
|
pub fn get_system_default_device_name() -> Option<String> {
|
||||||
|
cpal::default_host()
|
||||||
|
.default_output_device()
|
||||||
|
.and_then(|d| d.name().ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 列出系统中所有可用的音频输出设备名称(去重排序后)
|
||||||
|
pub fn list_output_devices() -> Vec<String> {
|
||||||
|
let host = cpal::default_host();
|
||||||
|
if let Ok(devices) = host.output_devices() {
|
||||||
|
let mut names: Vec<String> = 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<cpal::Device> {
|
||||||
|
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<String>) -> 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("无可用音频设备"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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<AtomicBool>,
|
||||||
|
pub cancelled: Arc<AtomicBool>,
|
||||||
|
pub decode_done: Arc<AtomicBool>,
|
||||||
|
pub buffer_exhausted: Arc<AtomicBool>,
|
||||||
|
pub volume: Arc<Mutex<f32>>,
|
||||||
|
pub sample_rate: u32,
|
||||||
|
pub channels: u16,
|
||||||
|
pub samples_played: Arc<AtomicU64>,
|
||||||
|
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<f64>,
|
||||||
|
) -> Result<OutputContext, String> {
|
||||||
|
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<Mutex<f32>>,
|
||||||
|
playing: Arc<AtomicBool>,
|
||||||
|
samples_played: Arc<AtomicU64>,
|
||||||
|
decode_done: Arc<AtomicBool>,
|
||||||
|
buffer_exhausted: Arc<AtomicBool>,
|
||||||
|
) -> Result<Stream, String> {
|
||||||
|
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<f32> = 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<f32> = 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)),
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
-11
@@ -146,6 +146,11 @@ pub fn run() {
|
|||||||
api::user_playlist,
|
api::user_playlist,
|
||||||
api::recommend_resource,
|
api::recommend_resource,
|
||||||
api::recommend_songs,
|
api::recommend_songs,
|
||||||
|
api::personalized,
|
||||||
|
api::personalized_newsong,
|
||||||
|
api::top_artists,
|
||||||
|
api::top_song,
|
||||||
|
api::album_newest,
|
||||||
api::personal_fm,
|
api::personal_fm,
|
||||||
api::personal_fm_mode,
|
api::personal_fm_mode,
|
||||||
api::fm_trash,
|
api::fm_trash,
|
||||||
@@ -163,17 +168,17 @@ pub fn run() {
|
|||||||
api::playlist_track_all,
|
api::playlist_track_all,
|
||||||
api::exit_app,
|
api::exit_app,
|
||||||
|
|
||||||
audio::play_audio,
|
audio::commands::play_audio,
|
||||||
audio::play_local_audio,
|
audio::commands::play_local_audio,
|
||||||
audio::pause_audio,
|
audio::commands::pause_audio,
|
||||||
audio::resume_audio,
|
audio::commands::resume_audio,
|
||||||
audio::stop_audio,
|
audio::commands::stop_audio,
|
||||||
audio::get_output_devices,
|
audio::commands::get_output_devices,
|
||||||
audio::set_output_device,
|
audio::commands::set_output_device,
|
||||||
audio::seek_audio,
|
audio::commands::seek_audio,
|
||||||
audio::get_audio_position,
|
audio::commands::get_audio_position,
|
||||||
audio::set_volume,
|
audio::commands::set_volume,
|
||||||
audio::is_audio_playing,
|
audio::commands::is_audio_playing,
|
||||||
|
|
||||||
api::download_song,
|
api::download_song,
|
||||||
api::list_local_songs,
|
api::list_local_songs,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Nekosonic",
|
"productName": "Nekosonic",
|
||||||
"version": "0.7.0",
|
"version": "0.8.0",
|
||||||
"identifier": "com.atdunbg.Nekosonic",
|
"identifier": "com.atdunbg.Nekosonic",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
+58
-12
@@ -19,21 +19,25 @@
|
|||||||
|
|
||||||
<!-- 主容器 -->
|
<!-- 主容器 -->
|
||||||
<div class="flex flex-col h-screen text-content overflow-hidden relative z-[2]" :style="rootBgStyle">
|
<div class="flex flex-col h-screen text-content overflow-hidden relative z-[2]" :style="rootBgStyle">
|
||||||
<TitleBar @close="closeWindow" />
|
|
||||||
|
|
||||||
<div class="flex flex-1 overflow-hidden" v-if="windowVisible">
|
<div class="flex flex-1 overflow-hidden" v-if="windowVisible">
|
||||||
<Sidebar />
|
<Sidebar :mode="ui.sidebarMode" />
|
||||||
|
|
||||||
<main class="flex-1 overflow-y-auto pb-24">
|
<div class="flex flex-col flex-1 overflow-hidden">
|
||||||
<router-view v-slot="{ Component }">
|
<TopBar @close="closeWindow" />
|
||||||
<keep-alive :max="10" :include="keepAliveInclude">
|
|
||||||
<component :is="Component" />
|
<main class="flex-1 overflow-hidden flex flex-col" :style="{ paddingBottom: player.currentSong ? '70px' : '0px' }">
|
||||||
</keep-alive>
|
<div ref="mainScrollRef" class="flex-1 overflow-y-auto min-h-0 relative">
|
||||||
</router-view>
|
<router-view v-slot="{ Component }">
|
||||||
</main>
|
<keep-alive :max="10" :include="keepAliveInclude">
|
||||||
|
<component :is="Component" />
|
||||||
|
</keep-alive>
|
||||||
|
</router-view>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<RoamDrawer :visible="windowVisible && player.showRoamDrawer" />
|
<RoamDrawer :visible="windowVisible && ui.showRoamDrawer" />
|
||||||
|
|
||||||
<PlayerBar v-if="player.currentSong" />
|
<PlayerBar v-if="player.currentSong" />
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
@@ -61,7 +65,8 @@ import { useRoute } from 'vue-router';
|
|||||||
import { useUserStore } from './stores/user';
|
import { useUserStore } from './stores/user';
|
||||||
import { useSettingsStore, type CloseAction } from './stores/settings';
|
import { useSettingsStore, type CloseAction } from './stores/settings';
|
||||||
import { usePlayerStore } from './stores/player';
|
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 Sidebar from './components/Sidebar.vue';
|
||||||
import RoamDrawer from './components/RoamDrawer.vue';
|
import RoamDrawer from './components/RoamDrawer.vue';
|
||||||
import PlayerBar from './components/PlayerBar.vue';
|
import PlayerBar from './components/PlayerBar.vue';
|
||||||
@@ -79,6 +84,7 @@ import { hexToRgba } from './utils/color';
|
|||||||
|
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const player = usePlayerStore();
|
const player = usePlayerStore();
|
||||||
|
const ui = useUiStore();
|
||||||
const settings = useSettingsStore();
|
const settings = useSettingsStore();
|
||||||
const updater = useUpdater();
|
const updater = useUpdater();
|
||||||
const { isOnline } = useOnlineStatus();
|
const { isOnline } = useOnlineStatus();
|
||||||
@@ -95,6 +101,22 @@ const windowVisible = ref(true);
|
|||||||
// 规则:30秒未访问的页面自动清除缓存;多级跳转时保留导航链上的页面;FavoriteSongs 常驻
|
// 规则:30秒未访问的页面自动清除缓存;多级跳转时保留导航链上的页面;FavoriteSongs 常驻
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
|
// 主滚动容器 ref(路由切换时重置滚动位置)
|
||||||
|
const mainScrollRef = ref<HTMLElement | null>(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<string, string> = {
|
const ROUTE_COMPONENT: Record<string, string> = {
|
||||||
home: 'HomeView', discover: 'DiscoverView', search: 'DiscoverView',
|
home: 'HomeView', discover: 'DiscoverView', search: 'DiscoverView',
|
||||||
favorites: 'FavoriteSongsView', daily: 'DailySongsView',
|
favorites: 'FavoriteSongsView', daily: 'DailySongsView',
|
||||||
@@ -175,6 +197,26 @@ watch(() => settings.currentWallpaper.path, async (path) => {
|
|||||||
}
|
}
|
||||||
}, { immediate: true });
|
}, { 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 rootBgStyle = computed(() => {
|
||||||
const wp = settings.currentWallpaper;
|
const wp = settings.currentWallpaper;
|
||||||
@@ -206,6 +248,10 @@ onMounted(() => {
|
|||||||
document.addEventListener('contextmenu', (e) => e.preventDefault());
|
document.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||||
startCleanup();
|
startCleanup();
|
||||||
|
|
||||||
|
// 响应式侧边栏
|
||||||
|
applyResponsiveSidebar();
|
||||||
|
window.addEventListener('resize', applyResponsiveSidebar);
|
||||||
|
|
||||||
AudioApi.stopAudio().catch(() => {});
|
AudioApi.stopAudio().catch(() => {});
|
||||||
|
|
||||||
if (userStore.isLoggedIn) {
|
if (userStore.isLoggedIn) {
|
||||||
|
|||||||
-233
@@ -1,233 +0,0 @@
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
|
||||||
|
|
||||||
export namespace MusicApi {
|
|
||||||
export async function getLoginStatus(): Promise<string> {
|
|
||||||
return invoke('get_login_status');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function logout(): Promise<void> {
|
|
||||||
return invoke('logout');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getQrKey(): Promise<string> {
|
|
||||||
return invoke('get_qr_key');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function checkQrStatus(key: string): Promise<string> {
|
|
||||||
return invoke('check_qr_status', { query: { key } });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function likelist(uid: number): Promise<string> {
|
|
||||||
return invoke('likelist', { uid });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function likeSong(id: number, like: boolean): Promise<void> {
|
|
||||||
return invoke('like_song', { query: { id, like: like ? 'true' : 'false' } });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function userPlaylist(uid: number): Promise<string> {
|
|
||||||
return invoke('user_playlist', { uid });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getPlaylistDetail(id: number): Promise<string> {
|
|
||||||
return invoke('get_playlist_detail', { id });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function playlistTrackAll(id: number): Promise<string> {
|
|
||||||
return invoke('playlist_track_all', { query: { id } });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function playlistSubscribe(id: number, subscribe: boolean): Promise<void> {
|
|
||||||
return invoke('playlist_subscribe', { query: { id, subscribe } });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function recommendResource(): Promise<string> {
|
|
||||||
return invoke('recommend_resource');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function recommendSongs(): Promise<string> {
|
|
||||||
return invoke('recommend_songs');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getSongDetail(id: string): Promise<string> {
|
|
||||||
return invoke('get_song_detail', { id });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getSongUrl(query: { id: number; level: string; fm_mode?: boolean }): Promise<string> {
|
|
||||||
return invoke('get_song_url', { query });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getLyric(id: number): Promise<string> {
|
|
||||||
return invoke('get_lyric', { id });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function searchSuggest(keyword: string): Promise<string> {
|
|
||||||
return invoke('search_suggest', { query: { keyword } });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getHotSearch(): Promise<string> {
|
|
||||||
return invoke('get_hot_search');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function cloudsearch(query: { keyword: string; searchType: number; limit: number }): Promise<string> {
|
|
||||||
return invoke('cloudsearch', { query });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function albumDetail(id: number): Promise<string> {
|
|
||||||
return invoke('album_detail', { id });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function artistDetail(id: number): Promise<string> {
|
|
||||||
return invoke('artist_detail', { id });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function artistSongs(query: { id: number; order: string; limit: number; offset: number }): Promise<string> {
|
|
||||||
return invoke('artist_songs', { query });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function artistAlbum(id: number, limit: number, offset: number): Promise<string> {
|
|
||||||
return invoke('artist_album', { id, limit, offset });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function artistDesc(id: number): Promise<string> {
|
|
||||||
return invoke('artist_desc', { id });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function artistSub(id: number, sub: boolean): Promise<string> {
|
|
||||||
return invoke('artist_sub', { query: { id, sub } });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function artistSublist(limit = 100, offset = 0): Promise<string> {
|
|
||||||
return invoke('artist_sublist', { query: { limit, offset } });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function commentHot(query: { type: number; id: number; limit: number; offset: number }): Promise<string> {
|
|
||||||
return invoke('comment_hot', { query });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function commentLike(query: { t: number; type: number; id: number; cid: number }): Promise<void> {
|
|
||||||
return invoke('comment_like', { query });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function personalFm(): Promise<string> {
|
|
||||||
return invoke('personal_fm');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function personalFmMode(query: { mode: string; subMode: string; limit: number }): Promise<string> {
|
|
||||||
return invoke('personal_fm_mode', { query });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fmTrash(id: number, time: number): Promise<void> {
|
|
||||||
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<void> {
|
|
||||||
return invoke('scrobble', { query });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 云盘
|
|
||||||
export async function userCloud(limit = 30, offset = 0): Promise<string> {
|
|
||||||
return invoke('user_cloud', { limit, offset });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function userCloudDel(id: number): Promise<string> {
|
|
||||||
return invoke('user_cloud_del', { id });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function cloudUpload(filePath: string): Promise<string> {
|
|
||||||
return invoke('cloud_upload', { filePath });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export namespace AudioApi {
|
|
||||||
export async function playAudio(url: string): Promise<void> {
|
|
||||||
return invoke('play_audio', { url });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function playLocalAudio(path: string): Promise<void> {
|
|
||||||
return invoke('play_local_audio', { path });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function pauseAudio(): Promise<void> {
|
|
||||||
return invoke('pause_audio');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resumeAudio(): Promise<void> {
|
|
||||||
return invoke('resume_audio');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function stopAudio(): Promise<void> {
|
|
||||||
return invoke('stop_audio');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function seekAudio(time: number): Promise<void> {
|
|
||||||
return invoke('seek_audio', { time });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setVolume(vol: number): Promise<void> {
|
|
||||||
return invoke('set_volume', { vol });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAudioPosition(): Promise<number> {
|
|
||||||
return invoke('get_audio_position');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function isAudioPlaying(): Promise<boolean> {
|
|
||||||
return invoke('is_audio_playing');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export namespace DeviceApi {
|
|
||||||
export async function getOutputDevices(): Promise<string[]> {
|
|
||||||
return invoke('get_output_devices');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setOutputDevice(device: string | null): Promise<void> {
|
|
||||||
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<void> {
|
|
||||||
return invoke('download_song', { query });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listLocalSongs(downloadPath: string | null): Promise<any[]> {
|
|
||||||
return invoke('list_local_songs', { downloadPath });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function scanLocalFolders(paths: string[]): Promise<any[]> {
|
|
||||||
return invoke('scan_local_folders', { paths });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteLocalSong(query: { id: number; filename: string; downloadPath: string | null }): Promise<void> {
|
|
||||||
return invoke('delete_local_song', { query });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getDefaultDownloadPath(): Promise<string> {
|
|
||||||
return invoke('get_default_download_path');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export namespace AppApi {
|
|
||||||
export function exitApp(): Promise<void> {
|
|
||||||
return invoke('exit_app');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function readImageAsDataUrl(path: string): Promise<string> {
|
|
||||||
return invoke('read_image_as_data_url', { path });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function showItemInFolder(path: string): Promise<void> {
|
|
||||||
return invoke('show_item_in_folder', { path });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 专辑相关 API
|
||||||
|
*/
|
||||||
|
export const AlbumApi = {
|
||||||
|
/** 获取专辑详情 */
|
||||||
|
async albumDetail(id: number): Promise<string> {
|
||||||
|
return invoke('album_detail', { id });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用级 API
|
||||||
|
*/
|
||||||
|
export const AppApi = {
|
||||||
|
/** 退出应用 */
|
||||||
|
exitApp(): Promise<void> {
|
||||||
|
return invoke('exit_app');
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 读取图片为 data URL */
|
||||||
|
async readImageAsDataUrl(path: string): Promise<string> {
|
||||||
|
return invoke('read_image_as_data_url', { path });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 在文件管理器中显示文件 */
|
||||||
|
async showItemInFolder(path: string): Promise<void> {
|
||||||
|
return invoke('show_item_in_folder', { path });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 歌手相关 API
|
||||||
|
*/
|
||||||
|
export const ArtistApi = {
|
||||||
|
/** 获取歌手详情 */
|
||||||
|
async artistDetail(id: number): Promise<string> {
|
||||||
|
return invoke('artist_detail', { id });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 获取歌手歌曲 */
|
||||||
|
async artistSongs(query: { id: number; order: string; limit: number; offset: number }): Promise<string> {
|
||||||
|
return invoke('artist_songs', { query });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 获取歌手专辑 */
|
||||||
|
async artistAlbum(id: number, limit: number, offset: number): Promise<string> {
|
||||||
|
return invoke('artist_album', { id, limit, offset });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 获取歌手描述 */
|
||||||
|
async artistDesc(id: number): Promise<string> {
|
||||||
|
return invoke('artist_desc', { id });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 关注/取消关注歌手 */
|
||||||
|
async artistSub(id: number, sub: boolean): Promise<string> {
|
||||||
|
return invoke('artist_sub', { query: { id, sub } });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 已关注的歌手列表 */
|
||||||
|
async artistSublist(limit = 100, offset = 0): Promise<string> {
|
||||||
|
return invoke('artist_sublist', { query: { limit, offset } });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 音频播放控制 API
|
||||||
|
*/
|
||||||
|
export const AudioApi = {
|
||||||
|
/** 播放网络音频 */
|
||||||
|
async playAudio(url: string): Promise<void> {
|
||||||
|
return invoke('play_audio', { url });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 播放本地音频文件 */
|
||||||
|
async playLocalAudio(path: string): Promise<void> {
|
||||||
|
return invoke('play_local_audio', { path });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 暂停播放 */
|
||||||
|
async pauseAudio(): Promise<void> {
|
||||||
|
return invoke('pause_audio');
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 恢复播放 */
|
||||||
|
async resumeAudio(): Promise<void> {
|
||||||
|
return invoke('resume_audio');
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 停止播放 */
|
||||||
|
async stopAudio(): Promise<void> {
|
||||||
|
return invoke('stop_audio');
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 跳转到指定位置(秒) */
|
||||||
|
async seekAudio(time: number): Promise<void> {
|
||||||
|
return invoke('seek_audio', { time });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 设置音量(0.0 - 1.0) */
|
||||||
|
async setVolume(vol: number): Promise<void> {
|
||||||
|
return invoke('set_volume', { vol });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 获取当前播放位置(秒) */
|
||||||
|
async getAudioPosition(): Promise<number> {
|
||||||
|
return invoke('get_audio_position');
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 是否正在播放 */
|
||||||
|
async isAudioPlaying(): Promise<boolean> {
|
||||||
|
return invoke('is_audio_playing');
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 云盘相关 API
|
||||||
|
*/
|
||||||
|
export const CloudApi = {
|
||||||
|
/** 获取云盘歌曲列表 */
|
||||||
|
async userCloud(limit = 30, offset = 0): Promise<string> {
|
||||||
|
return invoke('user_cloud', { limit, offset });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 删除云盘歌曲 */
|
||||||
|
async userCloudDel(id: number): Promise<string> {
|
||||||
|
return invoke('user_cloud_del', { id });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 上传歌曲到云盘 */
|
||||||
|
async cloudUpload(filePath: string): Promise<string> {
|
||||||
|
return invoke('cloud_upload', { filePath });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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<string> {
|
||||||
|
return invoke('comment_hot', { query });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 点赞/取消点赞评论 */
|
||||||
|
async commentLike(query: { t: number; type: number; id: number; cid: number }): Promise<void> {
|
||||||
|
return invoke('comment_like', { query });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 音频输出设备 API
|
||||||
|
*/
|
||||||
|
export const DeviceApi = {
|
||||||
|
/** 获取所有输出设备 */
|
||||||
|
async getOutputDevices(): Promise<string[]> {
|
||||||
|
return invoke('get_output_devices');
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 设置输出设备(null 表示默认) */
|
||||||
|
async setOutputDevice(device: string | null): Promise<void> {
|
||||||
|
return invoke('set_output_device', { device });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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<void> {
|
||||||
|
return invoke('download_song', { query });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 列出已下载的歌曲 */
|
||||||
|
async listLocalSongs(downloadPath: string | null): Promise<any[]> {
|
||||||
|
return invoke('list_local_songs', { downloadPath });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 扫描本地文件夹 */
|
||||||
|
async scanLocalFolders(paths: string[]): Promise<any[]> {
|
||||||
|
return invoke('scan_local_folders', { paths });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 删除本地歌曲文件 */
|
||||||
|
async deleteLocalSong(query: { id: number; filename: string; downloadPath: string | null }): Promise<void> {
|
||||||
|
return invoke('delete_local_song', { query });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 获取默认下载路径 */
|
||||||
|
async getDefaultDownloadPath(): Promise<string> {
|
||||||
|
return invoke('get_default_download_path');
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 私人 FM 相关 API
|
||||||
|
*/
|
||||||
|
export const FmApi = {
|
||||||
|
/** 获取私人 FM 歌曲 */
|
||||||
|
async personalFm(): Promise<string> {
|
||||||
|
return invoke('personal_fm');
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 切换私人 FM 模式 */
|
||||||
|
async personalFmMode(query: { mode: string; subMode: string; limit: number }): Promise<string> {
|
||||||
|
return invoke('personal_fm_mode', { query });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 私人 FM 不喜欢(扔进垃圾桶) */
|
||||||
|
async fmTrash(id: number, time: number): Promise<void> {
|
||||||
|
return invoke('fm_trash', { query: { id, time } });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录相关 API
|
||||||
|
*/
|
||||||
|
export const LoginApi = {
|
||||||
|
/** 获取登录状态 */
|
||||||
|
async getLoginStatus(): Promise<string> {
|
||||||
|
return invoke('get_login_status');
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 退出登录 */
|
||||||
|
async logout(): Promise<void> {
|
||||||
|
return invoke('logout');
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 获取二维码登录 key */
|
||||||
|
async getQrKey(): Promise<string> {
|
||||||
|
return invoke('get_qr_key');
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 检查二维码扫描状态 */
|
||||||
|
async checkQrStatus(key: string): Promise<string> {
|
||||||
|
return invoke('check_qr_status', { query: { key } });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 歌单相关 API
|
||||||
|
*/
|
||||||
|
export const PlaylistApi = {
|
||||||
|
/** 获取用户歌单列表 */
|
||||||
|
async userPlaylist(uid: number): Promise<string> {
|
||||||
|
return invoke('user_playlist', { uid });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 获取歌单详情 */
|
||||||
|
async getPlaylistDetail(id: number): Promise<string> {
|
||||||
|
return invoke('get_playlist_detail', { id });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 获取歌单全部曲目 */
|
||||||
|
async playlistTrackAll(id: number): Promise<string> {
|
||||||
|
return invoke('playlist_track_all', { query: { id } });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 订阅/取消订阅歌单 */
|
||||||
|
async playlistSubscribe(id: number, subscribe: boolean): Promise<void> {
|
||||||
|
return invoke('playlist_subscribe', { query: { id, subscribe } });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 每日推荐歌单 */
|
||||||
|
async recommendResource(): Promise<string> {
|
||||||
|
return invoke('recommend_resource');
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 每日推荐歌曲 */
|
||||||
|
async recommendSongs(): Promise<string> {
|
||||||
|
return invoke('recommend_songs');
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 推荐相关 API
|
||||||
|
*/
|
||||||
|
export const RecApi = {
|
||||||
|
/** 个性化推荐歌单(无需登录) */
|
||||||
|
async personalized(limit: number = 30): Promise<string> {
|
||||||
|
return invoke('personalized', { limit });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 推荐新歌 */
|
||||||
|
async personalizedNewsong(limit: number = 10): Promise<string> {
|
||||||
|
return invoke('personalized_newsong', { limit });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 热门歌手 */
|
||||||
|
async topArtists(limit: number = 30, offset: number = 0): Promise<string> {
|
||||||
|
return invoke('top_artists', { limit, offset });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 新歌速递,type: 全部:0 / 华语:7 / 欧美:96 / 韩国:16 / 日本:8 */
|
||||||
|
async topSong(areaType: number = 0): Promise<string> {
|
||||||
|
return invoke('top_song', { areaType });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 最新专辑(新碟上架) */
|
||||||
|
async albumNewest(): Promise<string> {
|
||||||
|
return invoke('album_newest');
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 搜索相关 API
|
||||||
|
*/
|
||||||
|
export const SearchApi = {
|
||||||
|
/** 搜索建议 */
|
||||||
|
async searchSuggest(keyword: string): Promise<string> {
|
||||||
|
return invoke('search_suggest', { query: { keyword } });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 热门搜索 */
|
||||||
|
async getHotSearch(): Promise<string> {
|
||||||
|
return invoke('get_hot_search');
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 云搜索 */
|
||||||
|
async cloudsearch(query: { keyword: string; searchType: number; limit: number }): Promise<string> {
|
||||||
|
return invoke('cloudsearch', { query });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 歌曲相关 API
|
||||||
|
*/
|
||||||
|
export const SongApi = {
|
||||||
|
/** 获取歌曲详情 */
|
||||||
|
async getSongDetail(id: string): Promise<string> {
|
||||||
|
return invoke('get_song_detail', { id });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 获取歌曲播放 URL */
|
||||||
|
async getSongUrl(query: { id: number; level: string; fm_mode?: boolean }): Promise<string> {
|
||||||
|
return invoke('get_song_url', { query });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 获取歌词 */
|
||||||
|
async getLyric(id: number): Promise<string> {
|
||||||
|
return invoke('get_lyric', { id });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 获取喜欢列表 */
|
||||||
|
async likelist(uid: number): Promise<string> {
|
||||||
|
return invoke('likelist', { uid });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 喜欢/取消喜欢歌曲 */
|
||||||
|
async likeSong(id: number, like: boolean): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
return invoke('scrobble', { query });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="group relative rounded-xl overflow-hidden bg-subtle hover:bg-muted transition cursor-pointer"
|
||||||
|
@click="$emit('click', album)"
|
||||||
|
>
|
||||||
|
<!-- 封面 -->
|
||||||
|
<div class="relative aspect-square overflow-hidden">
|
||||||
|
<img
|
||||||
|
v-if="coverUrl"
|
||||||
|
:src="coverUrl"
|
||||||
|
:alt="album.name"
|
||||||
|
class="w-full h-full object-cover transition duration-300 group-hover:scale-105 group-hover:brightness-90"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div v-else class="w-full h-full bg-muted flex items-center justify-center">
|
||||||
|
<IconDisc class="w-8 h-8 text-content-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 悬浮播放按钮 -->
|
||||||
|
<button
|
||||||
|
v-if="showPlayBtn"
|
||||||
|
@click.stop="$emit('play', album)"
|
||||||
|
class="absolute bottom-2 right-2 w-9 h-9 flex items-center justify-center rounded-full bg-white/20 backdrop-blur-md text-white opacity-0 translate-y-2 group-hover:opacity-100 group-hover:translate-y-0 hover:bg-white/30 transition"
|
||||||
|
:title="isCurrentPlaying ? '暂停' : '播放'"
|
||||||
|
>
|
||||||
|
<IconPause v-if="isCurrentPlaying" class="w-4 h-4 fill-current" />
|
||||||
|
<IconPlay v-else class="w-4 h-4 fill-current ml-0.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 信息 -->
|
||||||
|
<div class="p-3">
|
||||||
|
<p class="text-sm font-medium truncate text-content">{{ album.name }}</p>
|
||||||
|
<p v-if="subtitle" class="text-xs text-content-2 mt-1 truncate">{{ subtitle }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import IconDisc from '~icons/lucide/disc-3';
|
||||||
|
import IconPlay from '~icons/lucide/play';
|
||||||
|
import IconPause from '~icons/lucide/pause';
|
||||||
|
import { formatDate } from '../../utils/format';
|
||||||
|
|
||||||
|
export interface AlbumCardData {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
picUrl?: string;
|
||||||
|
blurPicUrl?: string;
|
||||||
|
artist?: { name?: string } | string;
|
||||||
|
artists?: { name?: string }[];
|
||||||
|
publishTime?: number;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
album: AlbumCardData;
|
||||||
|
showPlayBtn?: boolean;
|
||||||
|
isCurrentPlaying?: boolean;
|
||||||
|
}>(), {
|
||||||
|
showPlayBtn: true,
|
||||||
|
isCurrentPlaying: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: 'click', album: AlbumCardData): void;
|
||||||
|
(e: 'play', album: AlbumCardData): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const coverUrl = computed(() => props.album.picUrl || props.album.blurPicUrl || '');
|
||||||
|
const subtitle = computed(() => {
|
||||||
|
const a = props.album;
|
||||||
|
const artistName = typeof a.artist === 'string'
|
||||||
|
? a.artist
|
||||||
|
: a.artist?.name || a.artists?.map(ar => ar.name).filter(Boolean).join(' / ') || '';
|
||||||
|
const date = a.publishTime ? formatDate(a.publishTime) : '';
|
||||||
|
const count = a.size ? `${a.size}首` : '';
|
||||||
|
const parts = [artistName, date, count].filter(Boolean);
|
||||||
|
return parts.join(' · ');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="group flex flex-col items-center cursor-pointer transition"
|
||||||
|
@click="$emit('click', artist)"
|
||||||
|
>
|
||||||
|
<!-- 圆形封面 -->
|
||||||
|
<div class="relative w-full aspect-square rounded-full overflow-hidden mb-3">
|
||||||
|
<img
|
||||||
|
v-if="coverUrl"
|
||||||
|
:src="coverUrl"
|
||||||
|
:alt="artist.name"
|
||||||
|
class="w-full h-full object-cover transition duration-300 group-hover:scale-110 group-hover:brightness-90"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div v-else class="w-full h-full bg-muted flex items-center justify-center">
|
||||||
|
<IconUser class="w-8 h-8 text-content-4" />
|
||||||
|
</div>
|
||||||
|
<!-- 悬浮遮罩 + 图标 -->
|
||||||
|
<div class="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition flex items-center justify-center">
|
||||||
|
<IconArtist class="w-7 h-7 text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 信息 -->
|
||||||
|
<p class="text-sm font-medium truncate text-content text-center w-full">{{ artist.name }}</p>
|
||||||
|
<p v-if="subtitle" class="text-xs text-content-3 mt-0.5 text-center truncate w-full">{{ subtitle }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import IconUser from '~icons/lucide/user';
|
||||||
|
import IconArtist from '~icons/lucide/mic';
|
||||||
|
|
||||||
|
export interface ArtistCardData {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
picUrl?: string;
|
||||||
|
img1v1Url?: string;
|
||||||
|
musicSize?: number;
|
||||||
|
albumSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
artist: ArtistCardData;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: 'click', artist: ArtistCardData): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const coverUrl = computed(() => props.artist.picUrl || props.artist.img1v1Url || '');
|
||||||
|
const subtitle = computed(() => {
|
||||||
|
const a = props.artist;
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (a.musicSize) parts.push(`${a.musicSize}首`);
|
||||||
|
if (a.albumSize) parts.push(`${a.albumSize}张专辑`);
|
||||||
|
return parts.join(' · ');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<template>
|
||||||
|
<div class="grid gap-4" :class="gridClass">
|
||||||
|
<template v-if="loading && !items.length">
|
||||||
|
<div
|
||||||
|
v-for="i in skeletonCount"
|
||||||
|
:key="'skel-' + i"
|
||||||
|
class="rounded-xl overflow-hidden bg-subtle animate-pulse"
|
||||||
|
>
|
||||||
|
<div class="w-full aspect-square bg-muted"></div>
|
||||||
|
<div class="p-3 space-y-2">
|
||||||
|
<div class="h-4 bg-muted rounded w-3/4"></div>
|
||||||
|
<div class="h-3 bg-muted rounded w-1/2"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="error && !items.length">
|
||||||
|
<div class="col-span-full flex flex-col items-center justify-center py-12 gap-3">
|
||||||
|
<p class="text-content-2 text-sm">{{ errorText }}</p>
|
||||||
|
<button
|
||||||
|
@click="$emit('retry')"
|
||||||
|
class="px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition"
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<slot />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
items: unknown[];
|
||||||
|
loading?: boolean;
|
||||||
|
error?: boolean;
|
||||||
|
errorText?: string;
|
||||||
|
skeletonCount?: number;
|
||||||
|
/** 自定义栅格列数类名,默认响应式 */
|
||||||
|
gridClass?: string;
|
||||||
|
}>(), {
|
||||||
|
loading: false,
|
||||||
|
error: false,
|
||||||
|
errorText: '加载失败',
|
||||||
|
skeletonCount: 6,
|
||||||
|
gridClass: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const gridClass = computed(() => props.gridClass || 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6');
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: 'retry'): void;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="group relative rounded-xl overflow-hidden bg-subtle hover:bg-muted transition cursor-pointer"
|
||||||
|
@click="$emit('click', playlist)"
|
||||||
|
>
|
||||||
|
<!-- 封面 -->
|
||||||
|
<div class="relative aspect-square overflow-hidden">
|
||||||
|
<img
|
||||||
|
v-if="coverUrl"
|
||||||
|
:src="coverUrl"
|
||||||
|
:alt="playlist.name"
|
||||||
|
class="w-full h-full object-cover transition duration-300 group-hover:scale-105 group-hover:brightness-90"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div v-else class="w-full h-full bg-muted flex items-center justify-center">
|
||||||
|
<IconMusic class="w-8 h-8 text-content-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 顶部渐变 + 播放量 -->
|
||||||
|
<div
|
||||||
|
v-if="playCount"
|
||||||
|
class="absolute top-0 left-0 right-0 h-1/3 bg-gradient-to-b from-black/40 to-transparent flex items-start justify-end p-2"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-1 text-white text-xs">
|
||||||
|
<IconPlay class="w-3 h-3 fill-current" />
|
||||||
|
<span>{{ formattedPlayCount }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 悬浮播放按钮 -->
|
||||||
|
<button
|
||||||
|
v-if="showPlayBtn"
|
||||||
|
@click.stop="$emit('play', playlist)"
|
||||||
|
class="absolute bottom-2 right-2 w-9 h-9 flex items-center justify-center rounded-full bg-white/20 backdrop-blur-md text-white opacity-0 translate-y-2 group-hover:opacity-100 group-hover:translate-y-0 hover:bg-white/30 transition"
|
||||||
|
:title="isCurrentPlaying ? '暂停' : '播放'"
|
||||||
|
>
|
||||||
|
<IconPause v-if="isCurrentPlaying" class="w-4 h-4 fill-current" />
|
||||||
|
<IconPlay v-else class="w-4 h-4 fill-current ml-0.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 信息 -->
|
||||||
|
<div class="p-3">
|
||||||
|
<p class="text-sm font-medium truncate text-content">{{ playlist.name }}</p>
|
||||||
|
<p v-if="subtitle" class="text-xs text-content-2 mt-1 truncate">{{ subtitle }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import IconMusic from '~icons/lucide/music';
|
||||||
|
import IconPlay from '~icons/lucide/play';
|
||||||
|
import IconPause from '~icons/lucide/pause';
|
||||||
|
import { formatPlayCount } from '../../utils/format';
|
||||||
|
|
||||||
|
export interface PlaylistCardData {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
picUrl?: string;
|
||||||
|
coverImgUrl?: string;
|
||||||
|
playCount?: number;
|
||||||
|
copywriter?: string;
|
||||||
|
description?: string;
|
||||||
|
creator?: { name?: string } | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
playlist: PlaylistCardData;
|
||||||
|
showPlayBtn?: boolean;
|
||||||
|
coverSize?: string;
|
||||||
|
isCurrentPlaying?: boolean;
|
||||||
|
}>(), {
|
||||||
|
showPlayBtn: true,
|
||||||
|
isCurrentPlaying: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: 'click', playlist: PlaylistCardData): void;
|
||||||
|
(e: 'play', playlist: PlaylistCardData): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const coverUrl = computed(() => props.playlist.picUrl || props.playlist.coverImgUrl || '');
|
||||||
|
const playCount = computed(() => props.playlist.playCount || 0);
|
||||||
|
const formattedPlayCount = computed(() => formatPlayCount(playCount.value));
|
||||||
|
const subtitle = computed(() => {
|
||||||
|
const c = props.playlist;
|
||||||
|
if (c.copywriter) return c.copywriter;
|
||||||
|
if (c.description) return c.description;
|
||||||
|
const creator = c.creator;
|
||||||
|
if (typeof creator === 'string') return creator;
|
||||||
|
if (creator?.name) return `by ${creator.name}`;
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex items-center justify-between mb-4 mt-2">
|
||||||
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
|
<component :is="icon" v-if="icon" class="w-5 h-5 text-accent-text flex-shrink-0" />
|
||||||
|
<h2 class="text-xl font-semibold text-content truncate">{{ title }}</h2>
|
||||||
|
<span v-if="subtitle" class="text-xs text-content-3 truncate hidden sm:inline">· {{ subtitle }}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="showMore"
|
||||||
|
@click="$emit('more')"
|
||||||
|
class="flex items-center gap-1 text-sm text-content-2 hover:text-content transition flex-shrink-0"
|
||||||
|
>
|
||||||
|
<span>更多</span>
|
||||||
|
<IconChevronRight class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Component } from 'vue';
|
||||||
|
import IconChevronRight from '~icons/lucide/chevron-right';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
icon?: Component;
|
||||||
|
showMore?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: 'more'): void;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
@@ -92,7 +92,7 @@ async function fetchComments(reset = false) {
|
|||||||
}
|
}
|
||||||
hasMore.value = list.length >= pageSize
|
hasMore.value = list.length >= pageSize
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error('获取评论列表失败', e)
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
loadingMore.value = false
|
loadingMore.value = false
|
||||||
@@ -123,7 +123,7 @@ async function likeComment(cid: number) {
|
|||||||
target.liked = !liked
|
target.liked = !liked
|
||||||
target.likedCount += liked ? -1 : 1
|
target.likedCount += liked ? -1 : 1
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error('评论点赞失败', e)
|
||||||
} finally {
|
} finally {
|
||||||
likingSet.value.delete(cid)
|
likingSet.value.delete(cid)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<template>
|
||||||
|
<!-- 详情页统一布局(参考 SPlayer list-detail 架构)
|
||||||
|
absolute; inset:0 脱离 mainScrollRef 滚动流,钉死覆盖视口;
|
||||||
|
内部 flex 列布局:header flex-shrink:0 常驻顶部,content flex-1 min-h:0 独立滚动。
|
||||||
|
父级 mainScrollRef 已加 position:relative 作定位上下文。
|
||||||
|
滚动 content 超过阈值时切换 is-compact(参考 SPlayer useListScroll),通过插槽 prop 透出。 -->
|
||||||
|
<div class="detail-layout" :class="{ 'is-compact': isCompact }">
|
||||||
|
<header class="detail-header">
|
||||||
|
<slot name="header" :compact="isCompact" />
|
||||||
|
</header>
|
||||||
|
<div ref="contentRef" class="detail-content" data-scroll @scroll="onScroll">
|
||||||
|
<slot name="content" :compact="isCompact" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onBeforeUnmount, nextTick } from 'vue';
|
||||||
|
|
||||||
|
defineOptions({ name: 'DetailLayout' });
|
||||||
|
|
||||||
|
const contentRef = ref<HTMLElement | null>(null);
|
||||||
|
// 紧凑态:参考 SPlayer useListScroll,scrollTop > 10 触发
|
||||||
|
const isCompact = ref(false);
|
||||||
|
const COMPACT_THRESHOLD = 10;
|
||||||
|
// 内容不足时不触发紧凑(避免短列表也收缩头部)
|
||||||
|
const MIN_SCROLL_RANGE = 150;
|
||||||
|
|
||||||
|
let ticking = false;
|
||||||
|
function onScroll() {
|
||||||
|
if (ticking) return;
|
||||||
|
ticking = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
ticking = false;
|
||||||
|
const el = contentRef.value;
|
||||||
|
if (!el) return;
|
||||||
|
// 内容不足以滚动时保持展开
|
||||||
|
if (el.scrollHeight - el.clientHeight < MIN_SCROLL_RANGE) {
|
||||||
|
if (isCompact.value) isCompact.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isCompact.value = el.scrollTop > COMPACT_THRESHOLD;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 供父组件重置滚动 & 紧凑态(切换页面/数据时)
|
||||||
|
function resetScroll() {
|
||||||
|
isCompact.value = false;
|
||||||
|
nextTick(() => {
|
||||||
|
if (contentRef.value) contentRef.value.scrollTop = 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ contentRef, isCompact, resetScroll });
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
isCompact.value = false;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 根容器:absolute 钉死填满 mainScrollRef 视口 + flex 列布局
|
||||||
|
absolute 元素不撑高父级 → mainScrollRef 不滚动 → header 不被带走 */
|
||||||
|
.detail-layout {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 头部:flex-shrink:0 常驻顶部,不随内容滚动 */
|
||||||
|
.detail-header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
/* 头部内部所有过渡元素统一动画曲线(参考 SPlayer transition: height/font-size 0.3s) */
|
||||||
|
transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 内容区:flex-1 min-h:0 占满剩余空间,独立滚动 */
|
||||||
|
.detail-content {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- 普通头部:随内容滚动,返回独占一行,标题和按钮在第二行 -->
|
<!-- 普通头部:随内容滚动,标题和按钮一行 -->
|
||||||
<div class="-mx-8 px-8 pt-3 pb-2">
|
<div class="-mx-8 px-8 pt-3 pb-2">
|
||||||
<button @click="$router.back()" class="mb-1 text-content-2 hover:text-content transition text-sm">
|
|
||||||
← 返回
|
|
||||||
</button>
|
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<slot />
|
<slot />
|
||||||
<slot name="actions" />
|
<slot name="actions" />
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
class="fixed bottom-0 left-0 right-0 z-50 select-none backdrop-blur-xl"
|
class="fixed bottom-0 left-0 right-0 z-50 select-none backdrop-blur-xl"
|
||||||
:style="playerBarBgStyle"
|
:style="playerBarBgStyle"
|
||||||
>
|
>
|
||||||
<div v-if="player.dominantColor"
|
<div v-if="ui.dominantColor"
|
||||||
class="absolute inset-0 pointer-events-none transition-opacity duration-300"
|
class="absolute inset-0 pointer-events-none transition-opacity duration-300"
|
||||||
:class="drawerActive ? 'opacity-100' : 'opacity-0'"
|
:class="drawerActive ? 'opacity-100' : 'opacity-0'"
|
||||||
:style="{ backgroundColor: player.dominantColor }"
|
:style="{ backgroundColor: ui.dominantColor }"
|
||||||
>
|
>
|
||||||
<div class="absolute inset-0 bg-black/60"></div>
|
<div class="absolute inset-0 bg-black/60"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -24,10 +24,10 @@
|
|||||||
|
|
||||||
<div class="flex items-center px-6 h-16 relative z-10">
|
<div class="flex items-center px-6 h-16 relative z-10">
|
||||||
<div class="flex items-center gap-3 w-56 min-w-0">
|
<div class="flex items-center gap-3 w-56 min-w-0">
|
||||||
<div v-if="getCoverUrl(player.currentSong)" class="w-10 h-10 rounded-md overflow-hidden flex-shrink-0 cursor-pointer hover:scale-105 transition-transform" @click="player.toggleRoamDrawer()" title="全屏展示">
|
<div v-if="getCoverUrl(player.currentSong)" class="w-10 h-10 rounded-md overflow-hidden flex-shrink-0 cursor-pointer hover:scale-105 transition-transform" @click="ui.toggleRoamDrawer()" title="全屏展示">
|
||||||
<img :src="getCoverUrl(player.currentSong)" class="w-full h-full object-cover" />
|
<img :src="getCoverUrl(player.currentSong)" class="w-full h-full object-cover" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="w-10 h-10 rounded-md flex-shrink-0 flex items-center justify-center cursor-pointer hover:scale-105 transition-transform" @click="player.toggleRoamDrawer()" title="全屏展示"
|
<div v-else class="w-10 h-10 rounded-md flex-shrink-0 flex items-center justify-center cursor-pointer hover:scale-105 transition-transform" @click="ui.toggleRoamDrawer()" title="全屏展示"
|
||||||
:class="drawerActive ? 'bg-white/10' : 'bg-muted'">
|
:class="drawerActive ? 'bg-white/10' : 'bg-muted'">
|
||||||
<IconMusic class="w-[18px] h-[18px]" :class="drawerActive ? 'text-white/50' : 'text-content-3'" />
|
<IconMusic class="w-[18px] h-[18px]" :class="drawerActive ? 'text-white/50' : 'text-content-3'" />
|
||||||
</div>
|
</div>
|
||||||
@@ -213,7 +213,9 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onBeforeUnmount, onMounted, nextTick } from 'vue';
|
import { ref, computed, watch, onBeforeUnmount, onMounted, nextTick } from 'vue';
|
||||||
import { usePlayerStore, PlayMode } from '../stores/player';
|
import { usePlayerStore } from '../stores/player';
|
||||||
|
import type { PlayMode } from '../types/song';
|
||||||
|
import { useUiStore } from '../stores/ui';
|
||||||
import { useSettingsStore } from '../stores/settings';
|
import { useSettingsStore } from '../stores/settings';
|
||||||
import { useDownload } from '../composables/useDownload';
|
import { useDownload } from '../composables/useDownload';
|
||||||
import { formatTime } from '../utils/format';
|
import { formatTime } from '../utils/format';
|
||||||
@@ -246,9 +248,10 @@ import { hexToRgba } from '../utils/color';
|
|||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const player = usePlayerStore();
|
const player = usePlayerStore();
|
||||||
|
const ui = useUiStore();
|
||||||
const settings = useSettingsStore();
|
const settings = useSettingsStore();
|
||||||
const download = useDownload();
|
const download = useDownload();
|
||||||
const drawerActive = computed(() => player.showRoamDrawer && !!player.dominantColor);
|
const drawerActive = computed(() => ui.showRoamDrawer && !!ui.dominantColor);
|
||||||
|
|
||||||
// PlayerBar 背景:有壁纸时用 --c-bg 高不透明度(与遮罩层同色系,视觉融合),
|
// PlayerBar 背景:有壁纸时用 --c-bg 高不透明度(与遮罩层同色系,视觉融合),
|
||||||
// 无壁纸时用 surface 色
|
// 无壁纸时用 surface 色
|
||||||
|
|||||||
@@ -4,16 +4,16 @@
|
|||||||
<!-- 背景层:fade in 覆盖全屏 -->
|
<!-- 背景层:fade in 覆盖全屏 -->
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0 backdrop-blur-xl"
|
class="absolute inset-0 backdrop-blur-xl"
|
||||||
:class="!player.dominantColor && 'bg-surface/95'"
|
:class="!ui.dominantColor && 'bg-surface/95'"
|
||||||
:style="player.dominantColor ? { backgroundColor: player.dominantColor } : {}"
|
:style="ui.dominantColor ? { backgroundColor: ui.dominantColor } : {}"
|
||||||
></div>
|
></div>
|
||||||
<div v-if="player.dominantColor" class="absolute inset-0 bg-black/60 pointer-events-none"></div>
|
<div v-if="ui.dominantColor" class="absolute inset-0 bg-black/60 pointer-events-none"></div>
|
||||||
|
|
||||||
<!-- 内容层:slide up/down -->
|
<!-- 内容层:slide up/down -->
|
||||||
<div class="relative z-10 flex flex-col flex-1 min-h-0 drawer-content">
|
<div class="relative z-10 flex flex-col flex-1 min-h-0 drawer-content">
|
||||||
<TitleBar :dark-mode="!!player.dominantColor" transparent @close="player.closeRoamDrawer()">
|
<TitleBar :dark-mode="!!ui.dominantColor" transparent @close="ui.closeRoamDrawer()">
|
||||||
<template #left>
|
<template #left>
|
||||||
<button @click="player.closeRoamDrawer()" :class="dc ? 'text-white/60 hover:text-white' : 'text-content-2 hover:text-content'" class="transition">
|
<button @click="ui.closeRoamDrawer()" :class="dc ? 'text-white/60 hover:text-white' : 'text-content-2 hover:text-content'" class="transition">
|
||||||
<IconChevronDown class="w-5 h-5" />
|
<IconChevronDown class="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
@@ -51,14 +51,14 @@
|
|||||||
<!-- 右侧:歌词/评论 -->
|
<!-- 右侧:歌词/评论 -->
|
||||||
<div class="w-3/5 relative min-h-0 overflow-hidden flex flex-col">
|
<div class="w-3/5 relative min-h-0 overflow-hidden flex flex-col">
|
||||||
<div class="flex items-center gap-1 mb-3 px-4">
|
<div class="flex items-center gap-1 mb-3 px-4">
|
||||||
<button @click="player.roamTab = 'lyric'"
|
<button @click="ui.roamTab = 'lyric'"
|
||||||
class="px-3 py-1 rounded-full text-sm transition"
|
class="px-3 py-1 rounded-full text-sm transition"
|
||||||
:class="tabClass(player.roamTab === 'lyric')">
|
:class="tabClass(ui.roamTab === 'lyric')">
|
||||||
歌词
|
歌词
|
||||||
</button>
|
</button>
|
||||||
<button @click="player.roamTab = 'comment'"
|
<button @click="ui.roamTab = 'comment'"
|
||||||
class="px-3 py-1 rounded-full text-sm transition"
|
class="px-3 py-1 rounded-full text-sm transition"
|
||||||
:class="tabClass(player.roamTab === 'comment')">
|
:class="tabClass(ui.roamTab === 'comment')">
|
||||||
评论
|
评论
|
||||||
</button>
|
</button>
|
||||||
<button v-if="hasTranslation" @click="toggleTranslation"
|
<button v-if="hasTranslation" @click="toggleTranslation"
|
||||||
@@ -68,14 +68,14 @@
|
|||||||
译
|
译
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="player.roamTab === 'lyric'" ref="lyricScrollContainer" class="flex-1 min-h-0 overflow-y-auto custom-scroll px-4">
|
<div v-show="ui.roamTab === 'lyric'" ref="lyricScrollContainer" class="flex-1 min-h-0 overflow-y-auto custom-scroll px-4">
|
||||||
<div v-if="lyrics.length > 0" class="w-full max-w-lg mx-auto text-center"
|
<div v-if="lyrics.length > 0" class="w-full max-w-lg mx-auto text-center"
|
||||||
:style="{ paddingTop: roamLyricPadPx + 'px', paddingBottom: roamLyricPadPx + 'px' }">
|
:style="{ paddingTop: roamLyricPadPx + 'px', paddingBottom: roamLyricPadPx + 'px' }">
|
||||||
<p
|
<p
|
||||||
v-for="(line, idx) in lyrics"
|
v-for="(line, idx) in lyrics"
|
||||||
:key="idx"
|
:key="idx"
|
||||||
:class="getRoamLyricClass(idx)"
|
:class="getRoamLyricClass(idx)"
|
||||||
class="roam-lyric-line px-4 py-3 rounded-lg cursor-pointer whitespace-nowrap transition-[font-size] duration-300 ease-out"
|
class="roam-lyric-line px-4 py-3 rounded-lg cursor-pointer transition-[font-size,opacity] duration-300 ease-out"
|
||||||
@click="seekToRoamLyric(line.time)"
|
@click="seekToRoamLyric(line.time)"
|
||||||
@mouseenter="roamLyricHovering = true"
|
@mouseenter="roamLyricHovering = true"
|
||||||
@mouseleave="roamLyricHovering = false"
|
@mouseleave="roamLyricHovering = false"
|
||||||
@@ -86,8 +86,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-else :class="dc ? 'text-white/40' : 'text-content-3'" class="text-center mt-8">暂无歌词</div>
|
<div v-else :class="dc ? 'text-white/40' : 'text-content-3'" class="text-center mt-8">暂无歌词</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="player.roamTab === 'comment'" class="flex-1 min-h-0 overflow-y-auto px-4 pb-4">
|
<div v-show="ui.roamTab === 'comment'" class="flex-1 min-h-0 overflow-y-auto px-4 pb-4">
|
||||||
<CommentSection v-if="roamSong" :type="0" :id="player.commentSongId || roamSong.id" :key="player.commentSongId || roamSong.id" :dark-mode="!!player.dominantColor" />
|
<CommentSection v-if="roamSong" :type="0" :id="ui.commentSongId || roamSong.id" :key="ui.commentSongId || roamSong.id" :dark-mode="!!ui.dominantColor" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -100,6 +100,7 @@
|
|||||||
import { ref, watch, onBeforeUnmount, computed, nextTick } from 'vue';
|
import { ref, watch, onBeforeUnmount, computed, nextTick } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { usePlayerStore } from '../stores/player';
|
import { usePlayerStore } from '../stores/player';
|
||||||
|
import { useUiStore } from '../stores/ui';
|
||||||
import { getCoverUrl, extractDominantColor } from '../utils/song';
|
import { getCoverUrl, extractDominantColor } from '../utils/song';
|
||||||
import { useLyric } from '../composables/UserLyric';
|
import { useLyric } from '../composables/UserLyric';
|
||||||
import TitleBar from './TitleBar.vue';
|
import TitleBar from './TitleBar.vue';
|
||||||
@@ -114,9 +115,10 @@ defineProps<{
|
|||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const player = usePlayerStore();
|
const player = usePlayerStore();
|
||||||
|
const ui = useUiStore();
|
||||||
|
|
||||||
// dominantColor 是否存在(模板中频繁使用)
|
// dominantColor 是否存在(模板中频繁使用)
|
||||||
const dc = computed(() => !!player.dominantColor);
|
const dc = computed(() => !!ui.dominantColor);
|
||||||
|
|
||||||
const { lyrics, currentLyricIdx, hasTranslation, showTranslation, toggleTranslation } = useLyric();
|
const { lyrics, currentLyricIdx, hasTranslation, showTranslation, toggleTranslation } = useLyric();
|
||||||
const lyricScrollContainer = ref<HTMLElement | null>(null);
|
const lyricScrollContainer = ref<HTMLElement | null>(null);
|
||||||
@@ -133,9 +135,9 @@ watch(roamCoverUrl, async (url) => {
|
|||||||
roamCoverError.value = false;
|
roamCoverError.value = false;
|
||||||
if (url) {
|
if (url) {
|
||||||
const color = await extractDominantColor(url);
|
const color = await extractDominantColor(url);
|
||||||
player.dominantColor = color;
|
ui.dominantColor = color;
|
||||||
} else {
|
} else {
|
||||||
player.dominantColor = '';
|
ui.dominantColor = '';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -147,7 +149,7 @@ function updateRoamLyricPad() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => player.showRoamDrawer, (val) => {
|
watch(() => ui.showRoamDrawer, (val) => {
|
||||||
if (val) {
|
if (val) {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
updateRoamLyricPad();
|
updateRoamLyricPad();
|
||||||
@@ -174,13 +176,13 @@ onBeforeUnmount(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
watch(currentLyricIdx, () => {
|
watch(currentLyricIdx, () => {
|
||||||
if (player.showRoamDrawer && !roamLyricHovering.value) {
|
if (ui.showRoamDrawer && !roamLyricHovering.value) {
|
||||||
nextTick(() => scrollToRoamActiveLyric());
|
nextTick(() => scrollToRoamActiveLyric());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(showTranslation, () => {
|
watch(showTranslation, () => {
|
||||||
if (player.showRoamDrawer && !roamLyricHovering.value) {
|
if (ui.showRoamDrawer && !roamLyricHovering.value) {
|
||||||
nextTick(() => scrollToRoamActiveLyric());
|
nextTick(() => scrollToRoamActiveLyric());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -241,7 +243,7 @@ function seekToRoamLyric(time: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function navigateFromDrawer(routeLocation: { name: string; params: any }) {
|
function navigateFromDrawer(routeLocation: { name: string; params: any }) {
|
||||||
player.closeRoamDrawer();
|
ui.closeRoamDrawer();
|
||||||
router.push(routeLocation);
|
router.push(routeLocation);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -260,4 +262,14 @@ function navigateFromDrawer(routeLocation: { name: string; params: any }) {
|
|||||||
.drawer-leave-to .drawer-content { transform: translateY(100%); }
|
.drawer-leave-to .drawer-content { transform: translateY(100%); }
|
||||||
|
|
||||||
.custom-scroll::-webkit-scrollbar { width: 0; display: none; }
|
.custom-scroll::-webkit-scrollbar { width: 0; display: none; }
|
||||||
|
|
||||||
|
/* 歌词行:长歌词允许换行(不再被截断);
|
||||||
|
锁定行高让字号在 active/diff 切换时盒模型高度不变,避免整列抖动乱跳。
|
||||||
|
行高取 active 字号(text-xl=1.25rem≈20px)对应舒适值 1.5×≈30px,
|
||||||
|
其余小字号在同一行高内垂直居中显示。 */
|
||||||
|
.roam-lyric-line {
|
||||||
|
line-height: 1.5;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+166
-189
@@ -1,206 +1,183 @@
|
|||||||
<template>
|
<template>
|
||||||
<nav class="w-56 flex-shrink-0 flex flex-col" :style="sidebarBgStyle">
|
<!-- 抽屉模式:遮罩 + 滑出面板 -->
|
||||||
<div class="flex-1 p-4 overflow-y-auto min-h-0">
|
<template v-if="mode === 'drawer'">
|
||||||
<div class="flex flex-col min-h-full">
|
<Transition name="fade">
|
||||||
<div class="space-y-0.5">
|
<div v-if="ui.drawerOpen" class="fixed inset-0 bg-black/50 z-40" @click="ui.closeDrawer()"></div>
|
||||||
<router-link to="/"
|
</Transition>
|
||||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
|
<Transition name="slide">
|
||||||
active-class="!text-content !bg-muted">
|
<nav v-if="ui.drawerOpen" class="fixed left-0 top-0 bottom-0 w-64 z-50 flex flex-col shadow-2xl backdrop-blur-md" :style="sidebarBgStyle">
|
||||||
<IconHome class="w-[18px] h-[18px]" />
|
<!-- Logo 区域 -->
|
||||||
推荐
|
<div class="h-12 flex items-center flex-shrink-0 px-4 gap-2" data-tauri-drag-region>
|
||||||
</router-link>
|
<IconMusic class="w-7 h-7 text-accent flex-shrink-0" />
|
||||||
<router-link to="/discover"
|
<span class="text-base font-bold text-content truncate">Nekosonic</span>
|
||||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
|
|
||||||
active-class="!text-content !bg-muted">
|
|
||||||
<IconSearch class="w-[18px] h-[18px]" />
|
|
||||||
发现
|
|
||||||
</router-link>
|
|
||||||
<button
|
|
||||||
@click="openRoamFromSidebar"
|
|
||||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle w-full text-left"
|
|
||||||
>
|
|
||||||
<IconRadio class="w-[18px] h-[18px]" />
|
|
||||||
漫游
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<SidebarContent :collapsed="false" @navigate="ui.closeDrawer()" />
|
||||||
|
</nav>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div class="mt-4 mb-1 pt-2">
|
<!-- 展开/折叠模式:常驻侧栏(透明背景,靠右边框分隔) -->
|
||||||
<p class="text-xs text-content-3 px-3 mb-1">我的</p>
|
<nav
|
||||||
<div class="space-y-0.5">
|
v-else
|
||||||
<router-link to="/favorites"
|
class="relative flex-shrink-0 flex flex-col transition-all duration-300 border-r border-line-2/40"
|
||||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
|
:class="mode === 'collapsed' ? 'w-16' : 'w-56'"
|
||||||
active-class="!text-content !bg-muted">
|
>
|
||||||
<IconHeart class="w-[18px] h-[18px]" />
|
<!-- Logo 区域(高度与 TopBar 一致,用于对齐) -->
|
||||||
我喜欢的音乐
|
<div
|
||||||
</router-link>
|
class="h-12 flex items-center flex-shrink-0"
|
||||||
<router-link to="/recent"
|
:class="mode === 'collapsed' ? 'justify-center' : 'px-4 gap-2'"
|
||||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
|
data-tauri-drag-region
|
||||||
active-class="!text-content !bg-muted">
|
>
|
||||||
<IconClock class="w-[18px] h-[18px]" />
|
<IconMusic class="w-7 h-7 text-accent flex-shrink-0 cursor-pointer" @click="router.push('/')" />
|
||||||
最近播放
|
<span v-if="mode === 'expanded'" class="text-base font-bold text-content truncate cursor-pointer" @click="router.push('/')">Nekosonic</span>
|
||||||
</router-link>
|
|
||||||
<router-link to="/local-music"
|
|
||||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
|
|
||||||
active-class="!text-content !bg-muted">
|
|
||||||
<IconMusic class="w-[18px] h-[18px]" />
|
|
||||||
本地音乐
|
|
||||||
</router-link>
|
|
||||||
<router-link to="/downloaded-music"
|
|
||||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
|
|
||||||
active-class="!text-content !bg-muted">
|
|
||||||
<IconDownload class="w-[18px] h-[18px]" />
|
|
||||||
下载音乐
|
|
||||||
</router-link>
|
|
||||||
<router-link v-if="userStore.isLoggedIn" to="/cloud-music"
|
|
||||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
|
|
||||||
active-class="!text-content !bg-muted">
|
|
||||||
<IconCloud class="w-[18px] h-[18px]" />
|
|
||||||
音乐云盘
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 mb-1 pt-2" v-if="userStore.isLoggedIn">
|
|
||||||
<div class="flex items-center justify-between px-3 mb-1 cursor-pointer group"
|
|
||||||
@click="showCreatedPlaylists = !showCreatedPlaylists">
|
|
||||||
<p class="text-xs text-content-3">我的歌单</p>
|
|
||||||
<IconChevronRight class="w-3 h-3 text-content-3 transition-transform" :class="{ 'rotate-90': showCreatedPlaylists }" />
|
|
||||||
</div>
|
|
||||||
<div v-show="showCreatedPlaylists" class="space-y-0.5">
|
|
||||||
<div v-for="pl in createdPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
|
|
||||||
class="flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all duration-200"
|
|
||||||
:class="isPlaylistActive(pl.id) ? 'bg-muted' : 'hover:bg-subtle'">
|
|
||||||
<img :src="pl.coverImgUrl + '?param=80y80'" class="w-8 h-8 rounded object-cover flex-shrink-0" />
|
|
||||||
<span class="text-sm truncate"
|
|
||||||
:class="isPlaylistActive(pl.id) ? 'text-content font-medium' : 'text-content-2'">{{ pl.name }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 mb-1 pt-2" v-if="userStore.isLoggedIn">
|
|
||||||
<div class="flex items-center justify-between px-3 mb-1 cursor-pointer group"
|
|
||||||
@click="showSubPlaylists = !showSubPlaylists">
|
|
||||||
<p class="text-xs text-content-3">收藏的歌单</p>
|
|
||||||
<IconChevronRight class="w-3 h-3 text-content-3 transition-transform" :class="{ 'rotate-90': showSubPlaylists }" />
|
|
||||||
</div>
|
|
||||||
<div v-show="showSubPlaylists" class="space-y-0.5">
|
|
||||||
<div v-for="pl in subPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
|
|
||||||
class="flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all duration-200"
|
|
||||||
:class="isPlaylistActive(pl.id) ? 'bg-muted' : 'hover:bg-subtle'">
|
|
||||||
<img :src="pl.coverImgUrl + '?param=80y80'" class="w-8 h-8 rounded object-cover flex-shrink-0" />
|
|
||||||
<span class="text-sm truncate"
|
|
||||||
:class="isPlaylistActive(pl.id) ? 'text-content font-medium' : 'text-content-2'">{{ pl.name }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-auto pt-4" :class="player.currentSong ? 'pb-20' : 'pb-2'">
|
|
||||||
<div class="px-1">
|
|
||||||
<router-link to="/settings"
|
|
||||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
|
|
||||||
active-class="!text-content !bg-muted">
|
|
||||||
<IconSettings class="w-[18px] h-[18px]" />
|
|
||||||
设置
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
<div v-if="!userStore.isLoggedIn" class="mt-3 p-3 rounded-xl bg-subtle/60">
|
|
||||||
<p class="text-xs text-content-3 mb-2">强烈建议登录以提升体验</p>
|
|
||||||
<router-link to="/login"
|
|
||||||
class="flex items-center justify-center gap-2 w-full px-4 py-2 rounded-lg bg-accent hover:bg-accent-hover transition text-sm font-medium text-white">
|
|
||||||
<IconLogIn class="w-4 h-4" />
|
|
||||||
立即登录
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
<div v-else class="flex items-center gap-3 px-2 mt-3">
|
|
||||||
<img :src="userStore.user?.avatarUrl" class="w-8 h-8 rounded-full ring-2 ring-accent/50" />
|
|
||||||
<div class="min-w-0">
|
|
||||||
<p class="text-sm font-medium truncate">{{ userStore.user?.nickname }}</p>
|
|
||||||
<button @click="userStore.logout(); player.stop()"
|
|
||||||
class="text-xs text-content-3 hover:text-danger transition">退出登录</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 导航内容 -->
|
||||||
|
<SidebarContent :collapsed="mode === 'collapsed'" />
|
||||||
|
|
||||||
|
<!-- 折叠/展开触发器:复刻 naive-ui n-layout-toggle-bar(SPlayer 同款)
|
||||||
|
两根竖条,中间 4px 重叠(无可见间隙),默认含蓄低饱和不抢聚焦;
|
||||||
|
hover 时两根竖条各自旋转,在中间分开形成 < 或 > 箭头:
|
||||||
|
展开态 → <(指向左,折叠方向);折叠态 → >(指向右,展开方向)。
|
||||||
|
命中区 44×88,加大 hover 容错;无背景色,仅靠竖条亮度变化反馈。 -->
|
||||||
|
<button
|
||||||
|
@click="ui.toggleSidebar()"
|
||||||
|
class="sider-trigger"
|
||||||
|
:class="mode === 'expanded' ? 'is-expanded' : 'is-collapsed'"
|
||||||
|
:title="mode === 'expanded' ? '折叠侧边栏' : '展开侧边栏'"
|
||||||
|
aria-label="切换侧边栏"
|
||||||
|
>
|
||||||
|
<span class="toggle-bar">
|
||||||
|
<span class="bar bar-top"></span>
|
||||||
|
<span class="bar bar-bottom"></span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, onMounted, computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useRouter, useRoute } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useUserStore } from '../stores/user';
|
import { useUiStore, type SidebarMode } from '../stores/ui';
|
||||||
import { usePlayerStore } from '../stores/player';
|
|
||||||
import { useSettingsStore } from '../stores/settings';
|
import { useSettingsStore } from '../stores/settings';
|
||||||
import { MusicApi } from '../api';
|
import SidebarContent from './SidebarContent.vue';
|
||||||
import IconHome from '~icons/lucide/home';
|
|
||||||
import IconSearch from '~icons/lucide/search';
|
|
||||||
import IconRadio from '~icons/lucide/radio';
|
|
||||||
import IconHeart from '~icons/lucide/heart';
|
|
||||||
import IconSettings from '~icons/lucide/settings';
|
|
||||||
import IconLogIn from '~icons/lucide/log-in';
|
|
||||||
import IconChevronRight from '~icons/lucide/chevron-right';
|
|
||||||
import IconClock from '~icons/lucide/clock';
|
|
||||||
import IconMusic from '~icons/lucide/music';
|
import IconMusic from '~icons/lucide/music';
|
||||||
import IconCloud from '~icons/lucide/cloud';
|
|
||||||
import IconDownload from '~icons/lucide/download';
|
|
||||||
|
|
||||||
const router = useRouter();
|
const ui = useUiStore();
|
||||||
const route = useRoute();
|
|
||||||
const userStore = useUserStore();
|
|
||||||
const player = usePlayerStore();
|
|
||||||
const settings = useSettingsStore();
|
const settings = useSettingsStore();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
// 有壁纸时侧栏轻微半透明区分区域,无壁纸时保持原样
|
defineProps<{
|
||||||
|
mode: SidebarMode;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// 侧边栏透明:无壁纸时继承主容器背景,有壁纸时透出壁纸
|
||||||
|
// 仅抽屉模式需要半透明背景 + 模糊(浮在主内容之上)
|
||||||
const sidebarBgStyle = computed(() => {
|
const sidebarBgStyle = computed(() => {
|
||||||
if (settings.currentWallpaper.path) return {}; // 有壁纸时透明,由遮罩层统一提供背景
|
if (settings.currentWallpaper.path) {
|
||||||
return { backgroundColor: settings.currentColors.surface };
|
return { backgroundColor: 'rgba(0, 0, 0, 0.2)' };
|
||||||
});
|
|
||||||
|
|
||||||
const createdPlaylists = ref<any[]>([]);
|
|
||||||
const subPlaylists = ref<any[]>([]);
|
|
||||||
const showCreatedPlaylists = ref(true);
|
|
||||||
const showSubPlaylists = ref(true);
|
|
||||||
|
|
||||||
async function loadPlaylists() {
|
|
||||||
if (!userStore.isLoggedIn || !userStore.user) return;
|
|
||||||
try {
|
|
||||||
const jsonStr: string = await MusicApi.userPlaylist(userStore.user.userId);
|
|
||||||
const data = JSON.parse(jsonStr);
|
|
||||||
createdPlaylists.value = (data.playlist || []).filter((p: any) => !p.subscribed).slice(1);
|
|
||||||
subPlaylists.value = (data.playlist || []).filter((p: any) => p.subscribed);
|
|
||||||
} catch { /* 忽略 */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
function goPlaylist(id: number) {
|
|
||||||
router.push({ name: 'playlist', params: { id } });
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPlaylistActive(id: number): boolean {
|
|
||||||
return route.name === 'playlist' && Number(route.params.id) === id;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openRoamFromSidebar() {
|
|
||||||
if (!userStore.isLoggedIn) {
|
|
||||||
router.push('/login');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (player.isFmMode) {
|
|
||||||
player.openRoamDrawer();
|
|
||||||
} else {
|
|
||||||
await player.loadFm();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(() => userStore.isLoggedIn, (val) => {
|
|
||||||
if (val) {
|
|
||||||
loadPlaylists();
|
|
||||||
player.loadLikedIds();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (userStore.isLoggedIn) {
|
|
||||||
loadPlaylists();
|
|
||||||
}
|
}
|
||||||
|
return {};
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.slide-enter-active,
|
||||||
|
.slide-leave-active {
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
}
|
||||||
|
.slide-enter-from,
|
||||||
|
.slide-leave-to {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 复刻 naive-ui n-layout-toggle-bar(SPlayer 同款)实现细节
|
||||||
|
参考源码:naive-ui es/layout/src/styles/layout-sider.cssr.mjs
|
||||||
|
关键点:
|
||||||
|
1. 两根竖条 width:4 height:38 left:14,top=0 / top=34 → 中间 4px 重叠(无可见间隙)
|
||||||
|
2. hover 时各自旋转 ±12deg + scale(1.15) + translateY(±2px),
|
||||||
|
两根在中间分开、两端反向旋转,拼出 < 或 > 箭头
|
||||||
|
3. 展开态(左 sider 未折叠)→ <(指向左,折叠方向)
|
||||||
|
折叠态 → >(指向右,展开方向)
|
||||||
|
4. 默认低饱和(--c-content-3 + opacity),hover 变亮;无背景色
|
||||||
|
5. 命中区 44×88,加大 hover 容错 */
|
||||||
|
.sider-trigger {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: -22px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 30;
|
||||||
|
width: 44px;
|
||||||
|
height: 88px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 双竖条容器:居中于命中区(尺寸沿用 naive-ui 32×72 比例,整体放大到 44×88)
|
||||||
|
透明度加在容器上而非每根竖条上:两根实色竖条重叠时颜色不会叠加变深,
|
||||||
|
整体随容器统一淡入淡出(参考 SPlayer .n-layout-toggle-bar { opacity:.3; &:hover{opacity:1} }) */
|
||||||
|
.toggle-bar {
|
||||||
|
position: relative;
|
||||||
|
width: 44px;
|
||||||
|
height: 88px;
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
.sider-trigger:hover .toggle-bar {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 单根竖条:4×38,圆角,水平居中于 44 宽容器(left:20)
|
||||||
|
实色背景(无自身 opacity),两根垂直排列,中间 4px 重叠(无可见间隙):
|
||||||
|
总高度 = 38 + 38 - 4 = 72,在 88 容器中垂直居中 → 起始 top = (88-72)/2 = 8
|
||||||
|
bar-top: top 8(占 8..46);bar-bottom: top 42(占 42..80),重叠区 42..46 */
|
||||||
|
.bar {
|
||||||
|
position: absolute;
|
||||||
|
width: 4px;
|
||||||
|
height: 38px;
|
||||||
|
left: 20px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background-color: var(--c-content-3);
|
||||||
|
transition:
|
||||||
|
transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
background-color 0.3s ease;
|
||||||
|
}
|
||||||
|
.bar-top { top: 8px; }
|
||||||
|
.bar-bottom { top: 42px; }
|
||||||
|
|
||||||
|
/* hover 时两根竖条变亮(透明度由容器统一处理) */
|
||||||
|
.sider-trigger:hover .bar {
|
||||||
|
background-color: var(--c-content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 展开态 hover → < 箭头(指向左,折叠方向)
|
||||||
|
naive-ui 公式:top = rotate(12deg) scale(1.15) translateY(-2px)
|
||||||
|
bottom = rotate(-12deg) scale(1.15) translateY(2px)
|
||||||
|
两根中间端反向旋转分开、远端继续向外,形成 < */
|
||||||
|
.is-expanded:hover .bar-top {
|
||||||
|
transform: rotate(12deg) scale(1.15) translateY(-2px);
|
||||||
|
}
|
||||||
|
.is-expanded:hover .bar-bottom {
|
||||||
|
transform: rotate(-12deg) scale(1.15) translateY(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 折叠态 hover → > 箭头(指向右,展开方向)
|
||||||
|
naive-ui 公式:top = rotate(-12deg) scale(1.15) translateY(-2px)
|
||||||
|
bottom = rotate(12deg) scale(1.15) translateY(2px) */
|
||||||
|
.is-collapsed:hover .bar-top {
|
||||||
|
transform: rotate(-12deg) scale(1.15) translateY(-2px);
|
||||||
|
}
|
||||||
|
.is-collapsed:hover .bar-bottom {
|
||||||
|
transform: rotate(12deg) scale(1.15) translateY(2px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,390 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex-1 overflow-y-auto min-h-0" :class="collapsed ? 'p-2' : 'p-4'">
|
||||||
|
<div class="flex flex-col min-h-full">
|
||||||
|
<!-- 主导航 -->
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
<router-link to="/"
|
||||||
|
:class="navItemClass"
|
||||||
|
active-class="!text-content !bg-muted"
|
||||||
|
:title="collapsed ? '推荐' : undefined">
|
||||||
|
<IconHome class="w-[18px] h-[18px] flex-shrink-0" />
|
||||||
|
<span class="nav-label" :class="labelClass">推荐</span>
|
||||||
|
</router-link>
|
||||||
|
<router-link to="/discover"
|
||||||
|
:class="navItemClass"
|
||||||
|
active-class="!text-content !bg-muted"
|
||||||
|
:title="collapsed ? '发现' : undefined">
|
||||||
|
<IconSearch class="w-[18px] h-[18px] flex-shrink-0" />
|
||||||
|
<span class="nav-label" :class="labelClass">发现</span>
|
||||||
|
</router-link>
|
||||||
|
<button
|
||||||
|
@click="openRoamFromSidebar"
|
||||||
|
:class="navItemClass"
|
||||||
|
:title="collapsed ? '漫游' : undefined">
|
||||||
|
<IconRadio class="w-[18px] h-[18px] flex-shrink-0" />
|
||||||
|
<span class="nav-label" :class="labelClass">漫游</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 我的 -->
|
||||||
|
<div class="mt-4 mb-1 pt-2">
|
||||||
|
<p v-if="!collapsed" class="text-xs text-content-3 px-3 mb-1">我的</p>
|
||||||
|
<div v-else class="border-t border-line-2/50 my-2"></div>
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
<router-link to="/favorites"
|
||||||
|
:class="navItemClass"
|
||||||
|
active-class="!text-content !bg-muted"
|
||||||
|
:title="collapsed ? '我喜欢的音乐' : undefined">
|
||||||
|
<IconHeart class="w-[18px] h-[18px] flex-shrink-0" />
|
||||||
|
<span class="nav-label" :class="labelClass">我喜欢的音乐</span>
|
||||||
|
</router-link>
|
||||||
|
<router-link to="/recent"
|
||||||
|
:class="navItemClass"
|
||||||
|
active-class="!text-content !bg-muted"
|
||||||
|
:title="collapsed ? '最近播放' : undefined">
|
||||||
|
<IconClock class="w-[18px] h-[18px] flex-shrink-0" />
|
||||||
|
<span class="nav-label" :class="labelClass">最近播放</span>
|
||||||
|
</router-link>
|
||||||
|
<router-link to="/local-music"
|
||||||
|
:class="navItemClass"
|
||||||
|
active-class="!text-content !bg-muted"
|
||||||
|
:title="collapsed ? '本地音乐' : undefined">
|
||||||
|
<IconMusic class="w-[18px] h-[18px] flex-shrink-0" />
|
||||||
|
<span class="nav-label" :class="labelClass">本地音乐</span>
|
||||||
|
</router-link>
|
||||||
|
<router-link to="/downloaded-music"
|
||||||
|
:class="navItemClass"
|
||||||
|
active-class="!text-content !bg-muted"
|
||||||
|
:title="collapsed ? '下载音乐' : undefined">
|
||||||
|
<IconDownload class="w-[18px] h-[18px] flex-shrink-0" />
|
||||||
|
<span class="nav-label" :class="labelClass">下载音乐</span>
|
||||||
|
</router-link>
|
||||||
|
<router-link v-if="userStore.isLoggedIn" to="/cloud-music"
|
||||||
|
:class="navItemClass"
|
||||||
|
active-class="!text-content !bg-muted"
|
||||||
|
:title="collapsed ? '音乐云盘' : undefined">
|
||||||
|
<IconCloud class="w-[18px] h-[18px] flex-shrink-0" />
|
||||||
|
<span class="nav-label" :class="labelClass">音乐云盘</span>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 我的歌单 -->
|
||||||
|
<div v-if="userStore.isLoggedIn" class="mt-4 mb-1 pt-2">
|
||||||
|
<!-- 展开模式:内联歌单列表 -->
|
||||||
|
<template v-if="!collapsed">
|
||||||
|
<div class="flex items-center justify-between px-3 mb-1 cursor-pointer group"
|
||||||
|
@click="showCreatedPlaylists = !showCreatedPlaylists">
|
||||||
|
<p class="text-xs text-content-3">我的歌单</p>
|
||||||
|
<IconChevronRight class="w-3 h-3 text-content-3 transition-transform" :class="{ 'rotate-90': showCreatedPlaylists }" />
|
||||||
|
</div>
|
||||||
|
<div v-show="showCreatedPlaylists" class="space-y-0.5">
|
||||||
|
<div v-for="pl in createdPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
|
||||||
|
class="flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all duration-200"
|
||||||
|
:class="isPlaylistActive(pl.id) ? 'bg-muted' : 'hover:bg-subtle'">
|
||||||
|
<img :src="pl.coverImgUrl + '?param=80y80'" class="w-8 h-8 rounded object-cover flex-shrink-0" />
|
||||||
|
<span class="text-sm truncate"
|
||||||
|
:class="isPlaylistActive(pl.id) ? 'text-content font-medium' : 'text-content-2'">{{ pl.name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 折叠模式:图标 + Teleport popover 浮层 -->
|
||||||
|
<template v-else>
|
||||||
|
<div class="border-t border-line-2/50 my-2"></div>
|
||||||
|
<div
|
||||||
|
ref="createdTriggerRef"
|
||||||
|
class="relative"
|
||||||
|
@mouseenter="openPopover('created')"
|
||||||
|
@mouseleave="scheduleClose('created')"
|
||||||
|
>
|
||||||
|
<div :class="navItemClass" class="cursor-pointer" title="我的歌单">
|
||||||
|
<IconListMusic class="w-[18px] h-[18px] flex-shrink-0" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 收藏的歌单 -->
|
||||||
|
<div v-if="userStore.isLoggedIn" class="mt-4 mb-1 pt-2">
|
||||||
|
<template v-if="!collapsed">
|
||||||
|
<div class="flex items-center justify-between px-3 mb-1 cursor-pointer group"
|
||||||
|
@click="showSubPlaylists = !showSubPlaylists">
|
||||||
|
<p class="text-xs text-content-3">收藏的歌单</p>
|
||||||
|
<IconChevronRight class="w-3 h-3 text-content-3 transition-transform" :class="{ 'rotate-90': showSubPlaylists }" />
|
||||||
|
</div>
|
||||||
|
<div v-show="showSubPlaylists" class="space-y-0.5">
|
||||||
|
<div v-for="pl in subPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
|
||||||
|
class="flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all duration-200"
|
||||||
|
:class="isPlaylistActive(pl.id) ? 'bg-muted' : 'hover:bg-subtle'">
|
||||||
|
<img :src="pl.coverImgUrl + '?param=80y80'" class="w-8 h-8 rounded object-cover flex-shrink-0" />
|
||||||
|
<span class="text-sm truncate"
|
||||||
|
:class="isPlaylistActive(pl.id) ? 'text-content font-medium' : 'text-content-2'">{{ pl.name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div
|
||||||
|
ref="subTriggerRef"
|
||||||
|
class="relative"
|
||||||
|
@mouseenter="openPopover('sub')"
|
||||||
|
@mouseleave="scheduleClose('sub')"
|
||||||
|
>
|
||||||
|
<div :class="navItemClass" class="cursor-pointer" title="收藏的歌单">
|
||||||
|
<IconStar class="w-[18px] h-[18px] flex-shrink-0" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 折叠模式歌单浮层(Teleport 到 body,避免被 overflow 裁剪) -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="popover.open && popover.type === 'created'"
|
||||||
|
class="playlist-popover-portal flex flex-col"
|
||||||
|
:style="popoverStyle"
|
||||||
|
@mouseenter="cancelClose()"
|
||||||
|
@mouseleave="scheduleClose('created')"
|
||||||
|
>
|
||||||
|
<p class="text-xs text-content-3 px-3 py-2 border-b border-line-2/40 flex-shrink-0">我的歌单</p>
|
||||||
|
<div class="flex-1 min-h-0 overflow-y-auto py-1">
|
||||||
|
<div v-for="pl in createdPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
|
||||||
|
class="flex items-center gap-2.5 px-3 py-1.5 hover:bg-muted cursor-pointer transition">
|
||||||
|
<img :src="pl.coverImgUrl + '?param=80y80'" class="w-8 h-8 rounded object-cover flex-shrink-0" />
|
||||||
|
<span class="text-sm truncate text-content-2">{{ pl.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="!createdPlaylists.length" class="px-3 py-2 text-xs text-content-3">暂无歌单</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="popover.open && popover.type === 'sub'"
|
||||||
|
class="playlist-popover-portal flex flex-col"
|
||||||
|
:style="popoverStyle"
|
||||||
|
@mouseenter="cancelClose()"
|
||||||
|
@mouseleave="scheduleClose('sub')"
|
||||||
|
>
|
||||||
|
<p class="text-xs text-content-3 px-3 py-2 border-b border-line-2/40 flex-shrink-0">收藏的歌单</p>
|
||||||
|
<div class="flex-1 min-h-0 overflow-y-auto py-1">
|
||||||
|
<div v-for="pl in subPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
|
||||||
|
class="flex items-center gap-2.5 px-3 py-1.5 hover:bg-muted cursor-pointer transition">
|
||||||
|
<img :src="pl.coverImgUrl + '?param=80y80'" class="w-8 h-8 rounded object-cover flex-shrink-0" />
|
||||||
|
<span class="text-sm truncate text-content-2">{{ pl.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="!subPlaylists.length" class="px-3 py-2 text-xs text-content-3">暂无歌单</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<!-- 底部:设置 + 用户 -->
|
||||||
|
<div class="mt-auto pt-4" :class="player.currentSong ? 'pb-20' : 'pb-2'">
|
||||||
|
<div :class="collapsed ? 'px-0' : 'px-1'">
|
||||||
|
<router-link to="/settings"
|
||||||
|
:class="navItemClass"
|
||||||
|
active-class="!text-content !bg-muted"
|
||||||
|
:title="collapsed ? '设置' : undefined">
|
||||||
|
<IconSettings class="w-[18px] h-[18px] flex-shrink-0" />
|
||||||
|
<span class="nav-label" :class="labelClass">设置</span>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 未登录 -->
|
||||||
|
<div v-if="!userStore.isLoggedIn && !collapsed" class="mt-3 p-3 rounded-xl bg-subtle/60">
|
||||||
|
<p class="text-xs text-content-3 mb-2">强烈建议登录以提升体验</p>
|
||||||
|
<router-link to="/login"
|
||||||
|
class="flex items-center justify-center gap-2 w-full px-4 py-2 rounded-lg bg-accent hover:bg-accent-hover transition text-sm font-medium text-white">
|
||||||
|
<IconLogIn class="w-4 h-4" />
|
||||||
|
立即登录
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="!userStore.isLoggedIn && collapsed" class="mt-3 flex justify-center">
|
||||||
|
<router-link to="/login"
|
||||||
|
class="w-9 h-9 flex items-center justify-center rounded-lg bg-accent hover:bg-accent-hover transition text-white"
|
||||||
|
title="登录">
|
||||||
|
<IconLogIn class="w-4 h-4" />
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 已登录 -->
|
||||||
|
<div v-else-if="!collapsed" class="flex items-center gap-3 px-2 mt-3">
|
||||||
|
<img :src="userStore.user?.avatarUrl" class="w-8 h-8 rounded-full ring-2 ring-accent/50" />
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm font-medium truncate">{{ userStore.user?.nickname }}</p>
|
||||||
|
<button @click="userStore.logout(); player.stop()"
|
||||||
|
class="text-xs text-content-3 hover:text-danger transition">退出登录</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="mt-3 flex justify-center">
|
||||||
|
<img :src="userStore.user?.avatarUrl"
|
||||||
|
class="w-8 h-8 rounded-full ring-2 ring-accent/50 cursor-pointer"
|
||||||
|
:title="userStore.user?.nickname"
|
||||||
|
@click="router.push('/settings')" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onMounted, computed, reactive } from 'vue';
|
||||||
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
|
import { useUserStore } from '../stores/user';
|
||||||
|
import { usePlayerStore } from '../stores/player';
|
||||||
|
import { useUiStore } from '../stores/ui';
|
||||||
|
import { MusicApi } from '../api';
|
||||||
|
import IconHome from '~icons/lucide/home';
|
||||||
|
import IconSearch from '~icons/lucide/search';
|
||||||
|
import IconRadio from '~icons/lucide/radio';
|
||||||
|
import IconHeart from '~icons/lucide/heart';
|
||||||
|
import IconSettings from '~icons/lucide/settings';
|
||||||
|
import IconLogIn from '~icons/lucide/log-in';
|
||||||
|
import IconChevronRight from '~icons/lucide/chevron-right';
|
||||||
|
import IconClock from '~icons/lucide/clock';
|
||||||
|
import IconMusic from '~icons/lucide/music';
|
||||||
|
import IconCloud from '~icons/lucide/cloud';
|
||||||
|
import IconDownload from '~icons/lucide/download';
|
||||||
|
import IconListMusic from '~icons/lucide/list-music';
|
||||||
|
import IconStar from '~icons/lucide/star';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
collapsed: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
navigate: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const player = usePlayerStore();
|
||||||
|
const ui = useUiStore();
|
||||||
|
|
||||||
|
// 导航项样式:始终 flex 布局,折叠时居中无文字宽度
|
||||||
|
const navItemClass = computed(() => [
|
||||||
|
'flex items-center rounded-lg transition-colors duration-200 text-content-2 hover:text-content hover:bg-subtle',
|
||||||
|
props.collapsed
|
||||||
|
? 'w-9 h-9 mx-auto justify-center'
|
||||||
|
: 'gap-3 px-3 py-2 w-full',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 文字标签样式:用 max-width + overflow-hidden 平滑过渡,防止换行乱跳
|
||||||
|
const labelClass = computed(() => [
|
||||||
|
'nav-label whitespace-nowrap overflow-hidden transition-all duration-300',
|
||||||
|
props.collapsed ? 'max-w-0 opacity-0' : 'max-w-[160px] opacity-100',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const createdPlaylists = ref<any[]>([]);
|
||||||
|
const subPlaylists = ref<any[]>([]);
|
||||||
|
const showCreatedPlaylists = ref(true);
|
||||||
|
const showSubPlaylists = ref(true);
|
||||||
|
|
||||||
|
// 折叠模式 popover 状态(Teleport 到 body,避免被 overflow 裁剪)
|
||||||
|
const createdTriggerRef = ref<HTMLElement | null>(null);
|
||||||
|
const subTriggerRef = ref<HTMLElement | null>(null);
|
||||||
|
const popover = reactive({ open: false, type: 'created' as 'created' | 'sub' });
|
||||||
|
let closeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const popoverStyle = computed(() => {
|
||||||
|
const el = popover.type === 'created' ? createdTriggerRef.value : subTriggerRef.value;
|
||||||
|
if (!el) return { display: 'none' };
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
// 浮层可用最大高度:从触发点顶部到 playerBar 顶部
|
||||||
|
// playerBar 高度 64px (h-16) + 8px 间距;无播放时仅留 8px 底部边距
|
||||||
|
// 避免浮层底部被 playerBar 遮挡,看不到下方歌单
|
||||||
|
const playerBarReserved = player.currentSong ? 72 : 8;
|
||||||
|
const maxH = Math.max(120, window.innerHeight - rect.top - playerBarReserved);
|
||||||
|
return {
|
||||||
|
position: 'fixed' as const,
|
||||||
|
left: `${rect.right + 8}px`,
|
||||||
|
top: `${rect.top}px`,
|
||||||
|
maxHeight: `${maxH}px`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function openPopover(type: 'created' | 'sub') {
|
||||||
|
if (closeTimer) { clearTimeout(closeTimer); closeTimer = null; }
|
||||||
|
popover.type = type;
|
||||||
|
popover.open = true;
|
||||||
|
}
|
||||||
|
function scheduleClose(_type: 'created' | 'sub') {
|
||||||
|
if (closeTimer) clearTimeout(closeTimer);
|
||||||
|
closeTimer = setTimeout(() => { popover.open = false; }, 200);
|
||||||
|
}
|
||||||
|
function cancelClose() {
|
||||||
|
if (closeTimer) { clearTimeout(closeTimer); closeTimer = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPlaylists() {
|
||||||
|
if (!userStore.isLoggedIn || !userStore.user) return;
|
||||||
|
try {
|
||||||
|
const jsonStr: string = await MusicApi.userPlaylist(userStore.user.userId);
|
||||||
|
const data = JSON.parse(jsonStr);
|
||||||
|
createdPlaylists.value = (data.playlist || []).filter((p: any) => !p.subscribed).slice(1);
|
||||||
|
subPlaylists.value = (data.playlist || []).filter((p: any) => p.subscribed);
|
||||||
|
} catch { /* 忽略 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function goPlaylist(id: number) {
|
||||||
|
popover.open = false;
|
||||||
|
router.push({ name: 'playlist', params: { id } });
|
||||||
|
emit('navigate');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlaylistActive(id: number): boolean {
|
||||||
|
return route.name === 'playlist' && Number(route.params.id) === id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openRoamFromSidebar() {
|
||||||
|
if (!userStore.isLoggedIn) {
|
||||||
|
router.push('/login');
|
||||||
|
emit('navigate');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (player.isFmMode) {
|
||||||
|
ui.openRoamDrawer();
|
||||||
|
} else {
|
||||||
|
await player.loadFm();
|
||||||
|
}
|
||||||
|
emit('navigate');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 路由变化时通知父组件(抽屉模式下关闭抽屉)
|
||||||
|
watch(() => route.fullPath, () => {
|
||||||
|
emit('navigate');
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => userStore.isLoggedIn, (val) => {
|
||||||
|
if (val) {
|
||||||
|
loadPlaylists();
|
||||||
|
player.loadLikedIds();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (userStore.isLoggedIn) {
|
||||||
|
loadPlaylists();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 折叠模式歌单浮层样式(Teleport 到 body,需用全局类名) */
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.playlist-popover-portal {
|
||||||
|
min-width: 200px;
|
||||||
|
background-color: var(--color-surface, #fff);
|
||||||
|
border: 1px solid var(--color-line-2, rgba(0,0,0,0.1));
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||||
|
z-index: 9999;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: popoverFadeIn 0.2s ease;
|
||||||
|
}
|
||||||
|
@keyframes popoverFadeIn {
|
||||||
|
from { opacity: 0; transform: translateX(-8px); }
|
||||||
|
to { opacity: 1; transform: translateX(0); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -19,13 +19,13 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onBeforeUnmount, onMounted } from 'vue';
|
import { ref, onBeforeUnmount, onMounted } from 'vue';
|
||||||
import { usePlayerStore } from '../stores/player';
|
import { useUiStore } from '../stores/ui';
|
||||||
import { showToast } from '../composables/useToast';
|
import { showToast } from '../composables/useToast';
|
||||||
import IconEllipsis from '~icons/lucide/ellipsis';
|
import IconEllipsis from '~icons/lucide/ellipsis';
|
||||||
import IconMessageSquare from '~icons/lucide/message-square';
|
import IconMessageSquare from '~icons/lucide/message-square';
|
||||||
import IconShare2 from '~icons/lucide/share-2';
|
import IconShare2 from '~icons/lucide/share-2';
|
||||||
|
|
||||||
const player = usePlayerStore();
|
const ui = useUiStore();
|
||||||
const props = defineProps<{ songId: number }>();
|
const props = defineProps<{ songId: number }>();
|
||||||
const open = ref(false);
|
const open = ref(false);
|
||||||
const menuRef = ref<HTMLElement | null>(null);
|
const menuRef = ref<HTMLElement | null>(null);
|
||||||
@@ -36,7 +36,7 @@ function toggle() {
|
|||||||
|
|
||||||
function handleComment() {
|
function handleComment() {
|
||||||
open.value = false;
|
open.value = false;
|
||||||
player.openCommentForSong(props.songId);
|
ui.openCommentForSong(props.songId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleShare() {
|
async function handleShare() {
|
||||||
|
|||||||
@@ -17,8 +17,8 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
|
||||||
import { useSettingsStore } from '../stores/settings';
|
import { useSettingsStore } from '../stores/settings';
|
||||||
|
import { minimizeWindow, toggleMaximize } from '../composables/useWindowControls';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
darkMode?: boolean;
|
darkMode?: boolean;
|
||||||
@@ -37,19 +37,4 @@ const titleBarBgStyle = computed(() => {
|
|||||||
if (props.darkMode) return {};
|
if (props.darkMode) return {};
|
||||||
return { backgroundColor: settings.currentColors.surface };
|
return { backgroundColor: settings.currentColors.surface };
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentWindow = getCurrentWindow();
|
|
||||||
|
|
||||||
function minimizeWindow() {
|
|
||||||
currentWindow.minimize();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleMaximize() {
|
|
||||||
const isMaximized = await currentWindow.isMaximized();
|
|
||||||
if (isMaximized) {
|
|
||||||
currentWindow.unmaximize();
|
|
||||||
} else {
|
|
||||||
currentWindow.maximize();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="h-12 flex items-center px-4 gap-3 flex-shrink-0 select-none relative z-20 border-b border-line-2/40"
|
||||||
|
:style="barBgStyle"
|
||||||
|
>
|
||||||
|
<!-- 抽屉模式:菜单触发按钮 -->
|
||||||
|
<button
|
||||||
|
v-if="ui.sidebarMode === 'drawer'"
|
||||||
|
@click="ui.toggleDrawer()"
|
||||||
|
class="w-8 h-8 flex items-center justify-center rounded-lg text-content-2 hover:text-content hover:bg-muted transition flex-shrink-0"
|
||||||
|
title="打开菜单"
|
||||||
|
>
|
||||||
|
<IconMenu class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 前进/后退 -->
|
||||||
|
<div class="flex items-center gap-1 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
@click="goBack"
|
||||||
|
:disabled="!canGoBack"
|
||||||
|
:class="[
|
||||||
|
'w-7 h-7 flex items-center justify-center rounded-full transition',
|
||||||
|
canGoBack ? 'text-content-2 hover:text-content hover:bg-muted' : 'text-content-3/40 cursor-not-allowed'
|
||||||
|
]"
|
||||||
|
title="后退"
|
||||||
|
>
|
||||||
|
<IconArrowLeft class="w-[18px] h-[18px]" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="goForward"
|
||||||
|
:disabled="!canGoForward"
|
||||||
|
:class="[
|
||||||
|
'w-7 h-7 flex items-center justify-center rounded-full transition',
|
||||||
|
canGoForward ? 'text-content-2 hover:text-content hover:bg-muted' : 'text-content-3/40 cursor-not-allowed'
|
||||||
|
]"
|
||||||
|
title="前进"
|
||||||
|
>
|
||||||
|
<IconArrowRight class="w-[18px] h-[18px]" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 可拖拽空白区(窗口拖动) -->
|
||||||
|
<div class="flex-1 h-full" data-tauri-drag-region></div>
|
||||||
|
|
||||||
|
<!-- 搜索框 -->
|
||||||
|
<div class="relative w-80 flex-shrink-0" @click.stop>
|
||||||
|
<IconSearch class="absolute left-3 top-1/2 -translate-y-1/2 text-content-3 w-4 h-4 pointer-events-none" />
|
||||||
|
<input
|
||||||
|
ref="searchInputRef"
|
||||||
|
v-model="keyword"
|
||||||
|
@input="onInputChange"
|
||||||
|
@keydown.enter="handleSearch"
|
||||||
|
@focus="onInputFocus"
|
||||||
|
placeholder="搜索歌曲、歌手、专辑..."
|
||||||
|
class="w-full h-8 rounded-full bg-muted/70 pl-9 pr-8 text-sm text-content placeholder-content-3 outline-none focus:bg-muted focus:ring-1 focus:ring-accent/40 transition"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="keyword"
|
||||||
|
@click="clearKeyword"
|
||||||
|
class="absolute right-2.5 top-1/2 -translate-y-1/2 text-content-3 hover:text-content transition"
|
||||||
|
>
|
||||||
|
<IconX class="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 下拉面板 -->
|
||||||
|
<Transition name="fade">
|
||||||
|
<div
|
||||||
|
v-if="showPanel"
|
||||||
|
class="absolute z-30 left-0 right-0 top-full mt-1.5 bg-surface border border-line-2 rounded-xl shadow-xl overflow-hidden max-h-[60vh] overflow-y-auto"
|
||||||
|
>
|
||||||
|
<!-- 搜索建议 -->
|
||||||
|
<div v-if="suggestions.length" class="p-1.5">
|
||||||
|
<p class="text-xs text-content-3 px-3 py-1.5">搜索建议</p>
|
||||||
|
<button
|
||||||
|
v-for="s in suggestions"
|
||||||
|
:key="s"
|
||||||
|
@click="searchTag(s)"
|
||||||
|
class="w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-muted transition flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<IconSearch class="w-3.5 h-3.5 text-content-3 flex-shrink-0" />
|
||||||
|
<span>{{ s }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 搜索历史 -->
|
||||||
|
<div v-if="!suggestions.length && searchHistory.length" class="p-1.5">
|
||||||
|
<div class="flex items-center justify-between px-3 py-1.5">
|
||||||
|
<p class="text-xs text-content-3">搜索历史</p>
|
||||||
|
<button @click.stop="clearHistory" class="text-xs text-content-3 hover:text-danger transition">清空</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-for="h in searchHistory"
|
||||||
|
:key="h"
|
||||||
|
@click="searchTag(h)"
|
||||||
|
class="w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-muted transition flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<IconHistory class="w-3.5 h-3.5 text-content-3 flex-shrink-0" />
|
||||||
|
<span>{{ h }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 热门搜索 -->
|
||||||
|
<div v-if="!suggestions.length && !searchHistory.length && hotTags.length" class="p-1.5">
|
||||||
|
<p class="text-xs text-content-3 px-3 py-1.5">热门搜索</p>
|
||||||
|
<button
|
||||||
|
v-for="tag in hotTags"
|
||||||
|
:key="tag.searchWord"
|
||||||
|
@click="searchTag(tag.searchWord)"
|
||||||
|
class="w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-muted transition flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<IconFlame class="w-3.5 h-3.5 text-content-3 flex-shrink-0" />
|
||||||
|
<span>{{ tag.searchWord }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 可拖拽空白区(窗口拖动) -->
|
||||||
|
<div class="flex-1 h-full" data-tauri-drag-region></div>
|
||||||
|
|
||||||
|
<!-- 右侧:窗口控制 -->
|
||||||
|
<div class="flex items-center gap-1.5 flex-shrink-0">
|
||||||
|
<button @click="minimizeWindow" class="w-3 h-3 rounded-full bg-yellow-500 hover:bg-yellow-400 transition" title="最小化"></button>
|
||||||
|
<button @click="toggleMaximize" class="w-3 h-3 rounded-full bg-green-500 hover:bg-green-400 transition" title="最大化/还原"></button>
|
||||||
|
<button @click="$emit('close')" class="w-3 h-3 rounded-full bg-red-500 hover:bg-red-400 transition" title="关闭"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||||
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
|
import { MusicApi } from '../api';
|
||||||
|
import { useUiStore } from '../stores/ui';
|
||||||
|
import { pageCacheGet, pageCacheSet, pageCacheIsStale, pageCacheInvalidate } from '../composables/usePageCache';
|
||||||
|
import { minimizeWindow, toggleMaximize } from '../composables/useWindowControls';
|
||||||
|
import IconArrowLeft from '~icons/lucide/arrow-left';
|
||||||
|
import IconArrowRight from '~icons/lucide/arrow-right';
|
||||||
|
import IconSearch from '~icons/lucide/search';
|
||||||
|
import IconX from '~icons/lucide/x';
|
||||||
|
import IconHistory from '~icons/lucide/history';
|
||||||
|
import IconFlame from '~icons/lucide/flame';
|
||||||
|
import IconMenu from '~icons/lucide/menu';
|
||||||
|
|
||||||
|
defineEmits<{ close: [] }>();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
const ui = useUiStore();
|
||||||
|
|
||||||
|
// --- 导航历史 ---
|
||||||
|
const canGoBack = ref(false);
|
||||||
|
const canGoForward = ref(false);
|
||||||
|
|
||||||
|
function updateNavState() {
|
||||||
|
// window.history.length 在 SPA 中不可靠,用自定义栈判断
|
||||||
|
canGoBack.value = navStack.value.length > 1;
|
||||||
|
canGoForward.value = navStack.value.length > 0 && navStackIdx.value < navStack.value.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const navStack = ref<string[]>([]);
|
||||||
|
const navStackIdx = ref(-1);
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
if (!canGoBack.value) return;
|
||||||
|
router.go(-1);
|
||||||
|
}
|
||||||
|
function goForward() {
|
||||||
|
if (!canGoForward.value) return;
|
||||||
|
router.go(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 搜索 ---
|
||||||
|
const searchInputRef = ref<HTMLInputElement | null>(null);
|
||||||
|
const keyword = ref('');
|
||||||
|
const suggestions = ref<string[]>([]);
|
||||||
|
const showPanel = ref(false);
|
||||||
|
const hotTags = ref<any[]>([]);
|
||||||
|
|
||||||
|
const HISTORY_KEY = 'search_history';
|
||||||
|
const MAX_HISTORY = 15;
|
||||||
|
|
||||||
|
function loadSearchHistory(): string[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(HISTORY_KEY);
|
||||||
|
if (raw) return JSON.parse(raw);
|
||||||
|
} catch { /* 忽略 */ }
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSearchHistory(q: string) {
|
||||||
|
let history = loadSearchHistory();
|
||||||
|
history = history.filter(h => h !== q);
|
||||||
|
history.unshift(q);
|
||||||
|
if (history.length > MAX_HISTORY) history = history.slice(0, MAX_HISTORY);
|
||||||
|
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchHistory = ref<string[]>(loadSearchHistory());
|
||||||
|
|
||||||
|
function clearHistory() {
|
||||||
|
searchHistory.value = [];
|
||||||
|
localStorage.removeItem(HISTORY_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
let suggestTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function onInputChange() {
|
||||||
|
if (suggestTimer) clearTimeout(suggestTimer);
|
||||||
|
if (!keyword.value.trim()) {
|
||||||
|
suggestions.value = [];
|
||||||
|
showPanel.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
suggestTimer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const jsonStr: string = await MusicApi.searchSuggest(keyword.value.trim());
|
||||||
|
const data = JSON.parse(jsonStr);
|
||||||
|
const all = data.result?.allMatch || [];
|
||||||
|
suggestions.value = all.map((m: any) => m.keyword).slice(0, 8);
|
||||||
|
showPanel.value = true;
|
||||||
|
} catch {
|
||||||
|
suggestions.value = [];
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInputFocus() {
|
||||||
|
showPanel.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearKeyword() {
|
||||||
|
keyword.value = '';
|
||||||
|
suggestions.value = [];
|
||||||
|
showPanel.value = true;
|
||||||
|
searchInputRef.value?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchTag(q: string) {
|
||||||
|
keyword.value = q;
|
||||||
|
handleSearch();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSearch() {
|
||||||
|
const q = keyword.value.trim();
|
||||||
|
if (!q) return;
|
||||||
|
showPanel.value = false;
|
||||||
|
saveSearchHistory(q);
|
||||||
|
searchHistory.value = loadSearchHistory();
|
||||||
|
// 跳转到 Discover 页面并带 query
|
||||||
|
router.push({ name: 'discover', query: { q } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHotTags() {
|
||||||
|
const cached = pageCacheGet('topbar_hotTags');
|
||||||
|
if (cached) {
|
||||||
|
hotTags.value = cached;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const json = await MusicApi.getHotSearch();
|
||||||
|
const data = JSON.parse(json as string);
|
||||||
|
hotTags.value = (data.data || []).slice(0, 8);
|
||||||
|
pageCacheSet('topbar_hotTags', hotTags.value);
|
||||||
|
} catch { /* 忽略 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 窗口控制(来自公共 composable) ---
|
||||||
|
|
||||||
|
// --- 背景样式 ---
|
||||||
|
// 透明背景:无壁纸时继承主容器,有壁纸时透出壁纸
|
||||||
|
const barBgStyle = computed(() => {
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- 路由监听:维护导航栈 + 点击外部关闭面板 ---
|
||||||
|
function onRouteChange() {
|
||||||
|
const path = route.fullPath;
|
||||||
|
if (navStackIdx.value >= 0 && navStack.value[navStackIdx.value] === path) return;
|
||||||
|
|
||||||
|
// 如果是前进/后退,栈已存在该路径
|
||||||
|
const existingIdx = navStack.value.indexOf(path);
|
||||||
|
if (existingIdx !== -1) {
|
||||||
|
navStackIdx.value = existingIdx;
|
||||||
|
} else {
|
||||||
|
// 新导航:截断前进历史
|
||||||
|
navStack.value = navStack.value.slice(0, navStackIdx.value + 1);
|
||||||
|
navStack.value.push(path);
|
||||||
|
navStackIdx.value = navStack.value.length - 1;
|
||||||
|
}
|
||||||
|
updateNavState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDocClick() {
|
||||||
|
showPanel.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadHotTags();
|
||||||
|
onRouteChange();
|
||||||
|
router.afterEach(onRouteChange);
|
||||||
|
document.addEventListener('click', onDocClick);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('click', onDocClick);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 热门标签缓存过期刷新
|
||||||
|
onMounted(() => {
|
||||||
|
if (pageCacheIsStale('topbar_hotTags')) {
|
||||||
|
pageCacheInvalidate('topbar_hotTags');
|
||||||
|
loadHotTags();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<SongListItem
|
||||||
|
v-for="(song, index) in songs"
|
||||||
|
:key="song.id"
|
||||||
|
:song="song"
|
||||||
|
:index="index"
|
||||||
|
:is-current="song.id === currentSongId"
|
||||||
|
:show-index="showIndex"
|
||||||
|
:show-like="showLike"
|
||||||
|
:show-download="showDownload"
|
||||||
|
:show-menu="showMenu"
|
||||||
|
:show-duration="showDuration"
|
||||||
|
:show-playing-overlay="showPlayingOverlay"
|
||||||
|
:cover-size="coverSize"
|
||||||
|
:cover-size-param="coverSizeParam"
|
||||||
|
@click="$emit('song-click', song, index)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import SongListItem from './SongListItem.vue';
|
||||||
|
import type { Song } from '../types/song';
|
||||||
|
|
||||||
|
withDefaults(defineProps<{
|
||||||
|
songs: Song[];
|
||||||
|
currentSongId?: number;
|
||||||
|
showIndex?: boolean;
|
||||||
|
showLike?: boolean;
|
||||||
|
showDownload?: boolean;
|
||||||
|
showMenu?: boolean;
|
||||||
|
showDuration?: boolean;
|
||||||
|
showPlayingOverlay?: boolean;
|
||||||
|
coverSize?: string;
|
||||||
|
coverSizeParam?: string;
|
||||||
|
}>(), {
|
||||||
|
showIndex: false,
|
||||||
|
showLike: false,
|
||||||
|
showDownload: false,
|
||||||
|
showMenu: false,
|
||||||
|
showDuration: false,
|
||||||
|
showPlayingOverlay: false,
|
||||||
|
coverSize: 'w-10 h-10',
|
||||||
|
coverSizeParam: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: 'song-click', song: Song, index: number): void;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
@@ -1,60 +1,37 @@
|
|||||||
import { ref, watch } from 'vue';
|
import { watch } from 'vue';
|
||||||
import { parseLrc, mergeTranslation, getCurrentLyricIndex, LyricLine } from '../utils/lyric';
|
|
||||||
import { usePlayerStore } from '../stores/player';
|
import { usePlayerStore } from '../stores/player';
|
||||||
import { MusicApi } from '../api';
|
import { useLyricManager } from './useLyricManager';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 歌词 composable(向后兼容入口)
|
||||||
|
*
|
||||||
|
* 委托给 useLyricManager,并自动绑定 player store 的 currentSong/currentTime。
|
||||||
|
* 新代码建议直接使用 useLyricManager。
|
||||||
|
*/
|
||||||
export function useLyric() {
|
export function useLyric() {
|
||||||
const player = usePlayerStore();
|
const player = usePlayerStore();
|
||||||
|
const manager = useLyricManager();
|
||||||
|
|
||||||
const lyrics = ref<LyricLine[]>([]);
|
// 跟随当前歌曲加载歌词
|
||||||
const currentLyricIdx = ref(-1);
|
watch(() => player.currentSong, (song) => {
|
||||||
const showTranslation = ref(true);
|
manager.loadLyric(song);
|
||||||
const hasTranslation = ref(false);
|
// 预加载队列中下一首的歌词
|
||||||
|
const next = player.queue[player.currentIndex + 1];
|
||||||
watch(() => player.currentSong, async (song) => {
|
if (next && next.id !== song?.id) {
|
||||||
if (!song) {
|
manager.preloadLyric(next);
|
||||||
lyrics.value = [];
|
|
||||||
currentLyricIdx.value = -1;
|
|
||||||
hasTranslation.value = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const jsonStr: string = await MusicApi.getLyric(song.id);
|
|
||||||
const data = JSON.parse(jsonStr);
|
|
||||||
const lrc = data?.lrc?.lyric || '';
|
|
||||||
const tLrc = data?.tlyric?.lyric || '';
|
|
||||||
let parsed = lrc ? parseLrc(lrc) : [];
|
|
||||||
if (tLrc && parsed.length > 0) {
|
|
||||||
parsed = mergeTranslation(parsed, tLrc);
|
|
||||||
hasTranslation.value = parsed.some(l => l.translation);
|
|
||||||
} else {
|
|
||||||
hasTranslation.value = false;
|
|
||||||
}
|
|
||||||
lyrics.value = parsed;
|
|
||||||
currentLyricIdx.value = -1;
|
|
||||||
} catch {
|
|
||||||
lyrics.value = [];
|
|
||||||
hasTranslation.value = false;
|
|
||||||
}
|
}
|
||||||
}, { immediate: true });
|
}, { immediate: true });
|
||||||
|
|
||||||
|
// 跟随播放时间更新当前歌词行
|
||||||
watch(() => player.currentTime, (t) => {
|
watch(() => player.currentTime, (t) => {
|
||||||
if (lyrics.value.length === 0) return;
|
manager.updateCurrentIndex(t);
|
||||||
const idx = getCurrentLyricIndex(lyrics.value, t);
|
|
||||||
if (idx !== currentLyricIdx.value) {
|
|
||||||
currentLyricIdx.value = idx;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function toggleTranslation() {
|
|
||||||
showTranslation.value = !showTranslation.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
lyrics,
|
lyrics: manager.lyrics,
|
||||||
currentLyricIdx,
|
currentLyricIdx: manager.currentLyricIdx,
|
||||||
hasTranslation,
|
hasTranslation: manager.hasTranslation,
|
||||||
showTranslation,
|
showTranslation: manager.showTranslation,
|
||||||
toggleTranslation,
|
toggleTranslation: manager.toggleTranslation,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
import { SongApi } from '../api/song';
|
||||||
|
import { parseLrc, mergeTranslation, getCurrentLyricIndex, type LyricLine } from '../utils/lyric';
|
||||||
|
import type { Song } from '../types/song';
|
||||||
|
|
||||||
|
/** 已解析的歌词数据 */
|
||||||
|
export interface LyricData {
|
||||||
|
lines: LyricLine[];
|
||||||
|
hasTranslation: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_CACHE = 100;
|
||||||
|
/** 歌词持久化 TTL:7 天(歌词很少变动) */
|
||||||
|
const LYRIC_TTL = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
const LYRIC_LS_PREFIX = 'lyric:';
|
||||||
|
|
||||||
|
/** L1 内存缓存(按 songId 索引) */
|
||||||
|
const lyricCache = new Map<number, LyricData>();
|
||||||
|
|
||||||
|
/** 从 L2(localStorage)读取歌词并校验 TTL */
|
||||||
|
function lyricLsGet(songId: number): LyricData | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(LYRIC_LS_PREFIX + songId);
|
||||||
|
if (!raw) return null;
|
||||||
|
const entry = JSON.parse(raw);
|
||||||
|
if (Date.now() - entry.ts > LYRIC_TTL) {
|
||||||
|
localStorage.removeItem(LYRIC_LS_PREFIX + songId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entry.data as LyricData;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 写入 L2(localStorage),配额不足时静默失败 */
|
||||||
|
function lyricLsSet(songId: number, data: LyricData) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LYRIC_LS_PREFIX + songId, JSON.stringify({ data, ts: Date.now() }));
|
||||||
|
} catch { /* 配额不足:仅保留内存缓存 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前歌词请求序列号(防竞态) */
|
||||||
|
let lyricReqSeq = 0;
|
||||||
|
|
||||||
|
/** 正在加载中的请求(去重) */
|
||||||
|
const pendingRequests = new Map<number, Promise<LyricData>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析原始歌词 JSON 为 LyricData
|
||||||
|
*/
|
||||||
|
function parseLyricJson(jsonStr: string): LyricData {
|
||||||
|
const data = JSON.parse(jsonStr);
|
||||||
|
const lrc = data?.lrc?.lyric || '';
|
||||||
|
const tLrc = data?.tlyric?.lyric || '';
|
||||||
|
let parsed = lrc ? parseLrc(lrc) : [];
|
||||||
|
let hasTranslation = false;
|
||||||
|
if (tLrc && parsed.length > 0) {
|
||||||
|
parsed = mergeTranslation(parsed, tLrc);
|
||||||
|
hasTranslation = parsed.some(l => l.translation);
|
||||||
|
}
|
||||||
|
return { lines: parsed, hasTranslation };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取歌词数据(带缓存 + 请求去重)
|
||||||
|
*
|
||||||
|
* @param songId 歌曲 ID
|
||||||
|
* @param force 是否强制刷新缓存
|
||||||
|
*/
|
||||||
|
async function fetchLyric(songId: number, force = false): Promise<LyricData> {
|
||||||
|
// L1 内存
|
||||||
|
if (!force && lyricCache.has(songId)) {
|
||||||
|
return lyricCache.get(songId)!;
|
||||||
|
}
|
||||||
|
// L2 localStorage(回填 L1)
|
||||||
|
if (!force) {
|
||||||
|
const l2 = lyricLsGet(songId);
|
||||||
|
if (l2) {
|
||||||
|
if (lyricCache.size >= MAX_CACHE) {
|
||||||
|
const firstKey = lyricCache.keys().next().value;
|
||||||
|
if (firstKey !== undefined) lyricCache.delete(firstKey);
|
||||||
|
}
|
||||||
|
lyricCache.set(songId, l2);
|
||||||
|
return l2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pendingRequests.has(songId)) {
|
||||||
|
return pendingRequests.get(songId)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = (async () => {
|
||||||
|
const jsonStr = await SongApi.getLyric(songId);
|
||||||
|
const data = parseLyricJson(jsonStr);
|
||||||
|
// 写入 L1 + L2(LRU 策略:超限时删除最早条目)
|
||||||
|
if (lyricCache.size >= MAX_CACHE) {
|
||||||
|
const firstKey = lyricCache.keys().next().value;
|
||||||
|
if (firstKey !== undefined) lyricCache.delete(firstKey);
|
||||||
|
}
|
||||||
|
lyricCache.set(songId, data);
|
||||||
|
lyricLsSet(songId, data);
|
||||||
|
return data;
|
||||||
|
})().finally(() => {
|
||||||
|
pendingRequests.delete(songId);
|
||||||
|
});
|
||||||
|
|
||||||
|
pendingRequests.set(songId, promise);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 歌词管理器
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* - 跟随 currentSong 自动加载歌词(带防竞态)
|
||||||
|
* - 跟随 currentTime 自动计算当前行
|
||||||
|
* - 内存缓存(避免重复请求)
|
||||||
|
* - 预加载下一首歌词
|
||||||
|
*/
|
||||||
|
export function useLyricManager() {
|
||||||
|
const lyrics = ref<LyricLine[]>([]);
|
||||||
|
const currentLyricIdx = ref(-1);
|
||||||
|
const showTranslation = ref(true);
|
||||||
|
const hasTranslation = ref(false);
|
||||||
|
|
||||||
|
/** 当前加载的 songId(用于防竞态) */
|
||||||
|
let currentSongId: number | null = null;
|
||||||
|
|
||||||
|
/** 加载指定歌曲的歌词 */
|
||||||
|
async function loadLyric(song: Song | null) {
|
||||||
|
if (!song) {
|
||||||
|
lyrics.value = [];
|
||||||
|
currentLyricIdx.value = -1;
|
||||||
|
hasTranslation.value = false;
|
||||||
|
currentSongId = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seq = ++lyricReqSeq;
|
||||||
|
currentSongId = song.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fetchLyric(song.id);
|
||||||
|
// 防竞态:如果期间又切了歌,丢弃本次结果
|
||||||
|
if (seq !== lyricReqSeq || currentSongId !== song.id) return;
|
||||||
|
lyrics.value = data.lines;
|
||||||
|
hasTranslation.value = data.hasTranslation;
|
||||||
|
currentLyricIdx.value = -1;
|
||||||
|
} catch {
|
||||||
|
if (seq !== lyricReqSeq) return;
|
||||||
|
lyrics.value = [];
|
||||||
|
hasTranslation.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 预加载指定歌曲的歌词(不更新当前显示) */
|
||||||
|
function preloadLyric(song: Song | null) {
|
||||||
|
if (!song) return;
|
||||||
|
fetchLyric(song.id).catch(() => { /* 预加载失败静默 */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据当前播放时间更新当前歌词行 */
|
||||||
|
function updateCurrentIndex(time: number) {
|
||||||
|
if (lyrics.value.length === 0) return;
|
||||||
|
const idx = getCurrentLyricIndex(lyrics.value, time);
|
||||||
|
if (idx !== currentLyricIdx.value) {
|
||||||
|
currentLyricIdx.value = idx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTranslation() {
|
||||||
|
showTranslation.value = !showTranslation.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
lyrics,
|
||||||
|
currentLyricIdx,
|
||||||
|
showTranslation,
|
||||||
|
hasTranslation,
|
||||||
|
loadLyric,
|
||||||
|
preloadLyric,
|
||||||
|
updateCurrentIndex,
|
||||||
|
toggleTranslation,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,36 +1,112 @@
|
|||||||
const cache = new Map<string, { data: any; ts: number }>();
|
/**
|
||||||
|
* 页面数据缓存(L1 内存 + L2 localStorage 持久化)
|
||||||
|
*
|
||||||
|
* 两级缓存策略:
|
||||||
|
* - L1:内存 Map,访问最快,重启即失
|
||||||
|
* - L2:localStorage,持久化,重启后仍可命中
|
||||||
|
*
|
||||||
|
* 读取顺序:L1 → L2 → miss(L2 命中时回填 L1)
|
||||||
|
* 写入策略:同时写 L1 + L2(L2 配额不足时静默淘汰最旧条目)
|
||||||
|
*/
|
||||||
const TTL = 30 * 60 * 1000;
|
const TTL = 30 * 60 * 1000;
|
||||||
const MAX_ENTRIES = 30;
|
const MAX_ENTRIES = 30;
|
||||||
|
const LS_PREFIX = 'pc:';
|
||||||
|
|
||||||
export function pageCacheGet(key: string): any | null {
|
// L1 内存缓存
|
||||||
const entry = cache.get(key);
|
const cache = new Map<string, { data: any; ts: number }>();
|
||||||
if (!entry) return null;
|
|
||||||
if (Date.now() - entry.ts > TTL) {
|
/** 从 localStorage 读取并校验 TTL */
|
||||||
cache.delete(key);
|
function lsGet(key: string): { data: any; ts: number } | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(LS_PREFIX + key);
|
||||||
|
if (!raw) return null;
|
||||||
|
const entry = JSON.parse(raw);
|
||||||
|
if (Date.now() - entry.ts > TTL) {
|
||||||
|
localStorage.removeItem(LS_PREFIX + key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return entry.data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pageCacheSet(key: string, data: any) {
|
/** 写入 localStorage,配额不足时按 FIFO 淘汰旧条目后重试 */
|
||||||
if (cache.size >= MAX_ENTRIES && !cache.has(key)) {
|
function lsSet(key: string, entry: { data: any; ts: number }) {
|
||||||
// 淘汰最旧的条目
|
try {
|
||||||
|
localStorage.setItem(LS_PREFIX + key, JSON.stringify(entry));
|
||||||
|
} catch {
|
||||||
|
// 配额不足:扫描淘汰最旧的条目后重试一次
|
||||||
|
const keys: { k: string; ts: number }[] = [];
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const k = localStorage.key(i);
|
||||||
|
if (k && k.startsWith(LS_PREFIX)) {
|
||||||
|
try {
|
||||||
|
const e = JSON.parse(localStorage.getItem(k) || '{}');
|
||||||
|
keys.push({ k, ts: e.ts || 0 });
|
||||||
|
} catch { /* 忽略损坏条目 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keys.sort((a, b) => a.ts - b.ts);
|
||||||
|
// 淘汰最旧的 1/3
|
||||||
|
const removeCount = Math.max(1, Math.floor(keys.length / 3));
|
||||||
|
for (let i = 0; i < removeCount && i < keys.length; i++) {
|
||||||
|
localStorage.removeItem(keys[i].k);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LS_PREFIX + key, JSON.stringify(entry));
|
||||||
|
} catch { /* 仍然失败则放弃持久化,仅保留内存缓存 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function lsDelete(key: string) {
|
||||||
|
localStorage.removeItem(LS_PREFIX + key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** L1 淘汰最旧条目 */
|
||||||
|
function evictL1IfNeeded() {
|
||||||
|
if (cache.size >= MAX_ENTRIES) {
|
||||||
const firstKey = cache.keys().next().value;
|
const firstKey = cache.keys().next().value;
|
||||||
if (firstKey !== undefined) cache.delete(firstKey);
|
if (firstKey !== undefined) cache.delete(firstKey);
|
||||||
}
|
}
|
||||||
cache.set(key, { data, ts: Date.now() });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pageCacheDelete(key: string) {
|
export function pageCacheGet(key: string): any | null {
|
||||||
cache.delete(key);
|
// L1
|
||||||
|
const l1 = cache.get(key);
|
||||||
|
if (l1) {
|
||||||
|
if (Date.now() - l1.ts > TTL) {
|
||||||
|
cache.delete(key);
|
||||||
|
lsDelete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return l1.data;
|
||||||
|
}
|
||||||
|
// L2
|
||||||
|
const l2 = lsGet(key);
|
||||||
|
if (l2) {
|
||||||
|
// 回填 L1
|
||||||
|
evictL1IfNeeded();
|
||||||
|
cache.set(key, l2);
|
||||||
|
return l2.data;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pageCacheSet(key: string, data: any) {
|
||||||
|
const entry = { data, ts: Date.now() };
|
||||||
|
evictL1IfNeeded();
|
||||||
|
cache.set(key, entry);
|
||||||
|
lsSet(key, entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pageCacheInvalidate(key: string) {
|
export function pageCacheInvalidate(key: string) {
|
||||||
cache.delete(key);
|
cache.delete(key);
|
||||||
|
lsDelete(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pageCacheIsStale(key: string): boolean {
|
export function pageCacheIsStale(key: string): boolean {
|
||||||
const entry = cache.get(key);
|
const entry = cache.get(key) || lsGet(key);
|
||||||
if (!entry) return true;
|
if (!entry) return true;
|
||||||
return Date.now() - entry.ts > TTL;
|
return Date.now() - entry.ts > TTL;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||||
|
|
||||||
|
// 窗口控制:最小化 / 最大化切换
|
||||||
|
// TitleBar 与 TopBar 共用,避免重复实现
|
||||||
|
const currentWindow = getCurrentWindow();
|
||||||
|
|
||||||
|
export function minimizeWindow() {
|
||||||
|
currentWindow.minimize();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleMaximize() {
|
||||||
|
const isMaximized = await currentWindow.isMaximized();
|
||||||
|
if (isMaximized) {
|
||||||
|
currentWindow.unmaximize();
|
||||||
|
} else {
|
||||||
|
currentWindow.maximize();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useUserStore } from './user';
|
||||||
|
import { MusicApi } from '../api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 喜欢列表 store
|
||||||
|
*
|
||||||
|
* 管理用户喜欢的歌曲 ID 集合,支持加载、查询、切换喜欢状态。
|
||||||
|
*/
|
||||||
|
export const useLikedStore = defineStore('liked', () => {
|
||||||
|
/** 已喜欢的歌曲 ID 集合 */
|
||||||
|
const likedIds = ref<Set<number>>(new Set());
|
||||||
|
|
||||||
|
/** 判断指定歌曲是否已喜欢 */
|
||||||
|
function isLiked(songId: number): boolean {
|
||||||
|
return likedIds.value.has(songId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 加载当前用户的喜欢列表 */
|
||||||
|
async function loadLikedIds() {
|
||||||
|
const userStore = useUserStore();
|
||||||
|
if (!userStore.isLoggedIn) return;
|
||||||
|
try {
|
||||||
|
const json = await MusicApi.likelist(userStore.user!.userId);
|
||||||
|
const data = JSON.parse(json);
|
||||||
|
const ids: number[] = data.ids || data.data?.ids || [];
|
||||||
|
likedIds.value = new Set(ids);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载喜欢列表失败', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 切换指定歌曲的喜欢状态 */
|
||||||
|
async function toggleLike(songId: number) {
|
||||||
|
const wasLiked = likedIds.value.has(songId);
|
||||||
|
const newLike = !wasLiked;
|
||||||
|
try {
|
||||||
|
await MusicApi.likeSong(songId, newLike);
|
||||||
|
if (newLike) {
|
||||||
|
likedIds.value.add(songId);
|
||||||
|
} else {
|
||||||
|
likedIds.value.delete(songId);
|
||||||
|
}
|
||||||
|
likedIds.value = new Set(likedIds.value);
|
||||||
|
} catch { /* 忽略 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
likedIds,
|
||||||
|
isLiked,
|
||||||
|
loadLikedIds,
|
||||||
|
toggleLike,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* 设置数据 Schema 迁移
|
||||||
|
*
|
||||||
|
* 每次新增字段或修改结构时,新增一个迁移函数并递增 CURRENT_SCHEMA_VERSION。
|
||||||
|
* 迁移按版本号顺序执行:v0 → v1 → v2 → ... → CURRENT。
|
||||||
|
*
|
||||||
|
* 迁移函数签名:(data: any) => any
|
||||||
|
* - 输入:旧版数据(可能是任意历史版本)
|
||||||
|
* - 输出:升级后的数据
|
||||||
|
* - 必须幂等:对已经是目标版本的数据执行应无副作用
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 当前 Schema 版本号 */
|
||||||
|
export const CURRENT_SCHEMA_VERSION = 3;
|
||||||
|
|
||||||
|
/** 迁移函数按目标版本号索引(data 升级到此版本后即为 vN) */
|
||||||
|
const migrations: Record<number, (data: any) => any> = {
|
||||||
|
/**
|
||||||
|
* v1: theme + appearance → skin
|
||||||
|
*
|
||||||
|
* 旧版用独立的 theme(blue/green/...)和 appearance(dark/light)字段,
|
||||||
|
* 合并为统一的 skin 字段(如 "dark-blue")。
|
||||||
|
*/
|
||||||
|
1: (data) => {
|
||||||
|
if (!data.skin && (data.theme || data.appearance)) {
|
||||||
|
const appearance = data.appearance || 'dark';
|
||||||
|
const theme = data.theme || 'blue';
|
||||||
|
const validThemes = ['blue', 'green', 'rose', 'violet', 'orange', 'cyan', 'pink'];
|
||||||
|
const t = validThemes.includes(theme) ? theme : 'blue';
|
||||||
|
data.skin = appearance === 'light' ? `light-${t}` : `dark-${t}`;
|
||||||
|
}
|
||||||
|
if (!data.skin) data.skin = 'dark-blue';
|
||||||
|
delete data.theme;
|
||||||
|
delete data.appearance;
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v2: 全局 wallpaper → 移入自定义皮肤
|
||||||
|
*
|
||||||
|
* 旧版有全局 wallpaper/wallpaperBlur/wallpaperOpacity 字段,
|
||||||
|
* 迁移到每个自定义皮肤的对应字段(仅填充缺失项)。
|
||||||
|
*/
|
||||||
|
2: (data) => {
|
||||||
|
if (data.wallpaper && Array.isArray(data.customSkins) && data.customSkins.length > 0) {
|
||||||
|
data.customSkins = data.customSkins.map((s: any) => {
|
||||||
|
if (!s.wallpaper) {
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
wallpaper: data.wallpaper,
|
||||||
|
wallpaperBlur: s.wallpaperBlur ?? data.wallpaperBlur ?? 10,
|
||||||
|
wallpaperOpacity: s.wallpaperOpacity ?? data.wallpaperOpacity ?? 0.3,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
delete data.wallpaper;
|
||||||
|
delete data.wallpaperBlur;
|
||||||
|
delete data.wallpaperOpacity;
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v3: localMusicPaths (string[]) → localMusicFolders ({path, enabled}[])
|
||||||
|
*
|
||||||
|
* 旧版本地音乐路径是字符串数组,新版改为带 enabled 开关的对象数组。
|
||||||
|
*/
|
||||||
|
3: (data) => {
|
||||||
|
if (!data.localMusicFolders && Array.isArray(data.localMusicPaths) && data.localMusicPaths.length) {
|
||||||
|
data.localMusicFolders = data.localMusicPaths.map((p: string) => ({ path: p, enabled: true }));
|
||||||
|
}
|
||||||
|
if (!Array.isArray(data.localMusicFolders)) {
|
||||||
|
data.localMusicFolders = [];
|
||||||
|
}
|
||||||
|
// 兼容 localMusicFolders 内仍是字符串的情况
|
||||||
|
data.localMusicFolders = data.localMusicFolders.map((f: any) =>
|
||||||
|
typeof f === 'string' ? { path: f, enabled: true } : f
|
||||||
|
);
|
||||||
|
delete data.localMusicPaths;
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行迁移:将旧版数据升级到 CURRENT_SCHEMA_VERSION
|
||||||
|
*
|
||||||
|
* @param raw 从 localStorage 读取的原始数据(可能无 schemaVersion)
|
||||||
|
* @returns 迁移后的数据,含 schemaVersion = CURRENT_SCHEMA_VERSION
|
||||||
|
*/
|
||||||
|
export function migrateSettings(raw: any): any {
|
||||||
|
let data = { ...raw };
|
||||||
|
let version = typeof data.schemaVersion === 'number' ? data.schemaVersion : 0;
|
||||||
|
|
||||||
|
while (version < CURRENT_SCHEMA_VERSION) {
|
||||||
|
const next = version + 1;
|
||||||
|
const migrator = migrations[next];
|
||||||
|
if (migrator) {
|
||||||
|
data = migrator(data);
|
||||||
|
}
|
||||||
|
version = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
data.schemaVersion = CURRENT_SCHEMA_VERSION;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
+38
-106
@@ -1,25 +1,16 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { ref, watch } from 'vue';
|
import { ref, watch } from 'vue';
|
||||||
import { normalizeSong, type Song } from '../utils/song';
|
import { normalizeSong } from '../utils/song';
|
||||||
|
import type { Song, PlayMode } from '../types/song';
|
||||||
import { useSettingsStore } from './settings';
|
import { useSettingsStore } from './settings';
|
||||||
import { useUserStore } from './user';
|
import { useLikedStore } from './liked';
|
||||||
|
import { useRecentStore } from './recent';
|
||||||
import { showToast } from '../composables/useToast';
|
import { showToast } from '../composables/useToast';
|
||||||
import { MusicApi, AudioApi } from '../api';
|
import { MusicApi, AudioApi } from '../api';
|
||||||
|
|
||||||
export type PlayMode = 'loop' | 'shuffle' | 'repeat-one';
|
|
||||||
export type { Song };
|
|
||||||
|
|
||||||
import { listen, emit } from '@tauri-apps/api/event';
|
import { listen, emit } from '@tauri-apps/api/event';
|
||||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||||
|
|
||||||
function loadRecentLocal(): Song[] {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem('recent_local');
|
|
||||||
if (raw) return JSON.parse(raw);
|
|
||||||
} catch { /* 忽略 */ }
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const usePlayerStore = defineStore('player', () => {
|
export const usePlayerStore = defineStore('player', () => {
|
||||||
const currentSong = ref<Song | null>(null);
|
const currentSong = ref<Song | null>(null);
|
||||||
const playing = ref(false);
|
const playing = ref(false);
|
||||||
@@ -29,11 +20,21 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
const queue = ref<Song[]>([]);
|
const queue = ref<Song[]>([]);
|
||||||
const currentIndex = ref(-1);
|
const currentIndex = ref(-1);
|
||||||
|
|
||||||
|
/** 当前播放来源(用于卡片显示播放/暂停状态) */
|
||||||
|
const currentSource = ref<{ type: 'playlist' | 'album' | 'songs' | 'fm' | null; id: number | null }>({
|
||||||
|
type: null,
|
||||||
|
id: null,
|
||||||
|
});
|
||||||
|
|
||||||
const settings = useSettingsStore();
|
const settings = useSettingsStore();
|
||||||
const volume = ref(settings.volume);
|
const volume = ref(settings.volume);
|
||||||
|
|
||||||
watch(volume, (val) => { settings.volume = val; });
|
watch(volume, (val) => { settings.volume = val; });
|
||||||
|
|
||||||
|
const recentStore = useRecentStore();
|
||||||
|
const likedStore = useLikedStore();
|
||||||
|
const { addRecent } = recentStore;
|
||||||
|
|
||||||
let tickInterval: ReturnType<typeof setInterval> | null = null;
|
let tickInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
function clearTick() {
|
function clearTick() {
|
||||||
if (tickInterval) { clearInterval(tickInterval); tickInterval = null; }
|
if (tickInterval) { clearInterval(tickInterval); tickInterval = null; }
|
||||||
@@ -42,12 +43,6 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
tickInterval = v;
|
tickInterval = v;
|
||||||
}
|
}
|
||||||
|
|
||||||
const recentLocal = ref<Song[]>(loadRecentLocal());
|
|
||||||
const MAX_RECENT = 200;
|
|
||||||
let recentLocalTimer: ReturnType<typeof setTimeout> | undefined;
|
|
||||||
|
|
||||||
const likedIds = ref<Set<number>>(new Set());
|
|
||||||
|
|
||||||
function emitPlaybackState() {
|
function emitPlaybackState() {
|
||||||
const song = currentSong.value;
|
const song = currentSong.value;
|
||||||
const status = playing.value ? 'playing' : (song ? 'paused' : 'stopped');
|
const status = playing.value ? 'playing' : (song ? 'paused' : 'stopped');
|
||||||
@@ -63,52 +58,6 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function isLiked(songId: number): boolean {
|
|
||||||
return likedIds.value.has(songId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadLikedIds() {
|
|
||||||
const userStore = useUserStore();
|
|
||||||
if (!userStore.isLoggedIn) return;
|
|
||||||
try {
|
|
||||||
const json = await MusicApi.likelist(userStore.user!.userId);
|
|
||||||
const data = JSON.parse(json);
|
|
||||||
const ids: number[] = data.ids || data.data?.ids || [];
|
|
||||||
likedIds.value = new Set(ids);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('加载喜欢列表失败', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleLike(songId: number) {
|
|
||||||
const wasLiked = likedIds.value.has(songId);
|
|
||||||
const newLike = !wasLiked;
|
|
||||||
try {
|
|
||||||
await MusicApi.likeSong(songId, newLike);
|
|
||||||
if (newLike) {
|
|
||||||
likedIds.value.add(songId);
|
|
||||||
} else {
|
|
||||||
likedIds.value.delete(songId);
|
|
||||||
}
|
|
||||||
likedIds.value = new Set(likedIds.value);
|
|
||||||
} catch { /* 忽略 */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
function addRecent(song: Song) {
|
|
||||||
recentLocal.value = recentLocal.value.filter(s => s.id !== song.id);
|
|
||||||
recentLocal.value.unshift(song);
|
|
||||||
if (recentLocal.value.length > MAX_RECENT) {
|
|
||||||
recentLocal.value = recentLocal.value.slice(0, MAX_RECENT);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(recentLocal, (val) => {
|
|
||||||
clearTimeout(recentLocalTimer);
|
|
||||||
recentLocalTimer = setTimeout(() => {
|
|
||||||
localStorage.setItem('recent_local', JSON.stringify(val));
|
|
||||||
}, 2000);
|
|
||||||
}, { deep: true });
|
|
||||||
|
|
||||||
const isFmMode = ref(false);
|
const isFmMode = ref(false);
|
||||||
const fmQueue: Song[] = [];
|
const fmQueue: Song[] = [];
|
||||||
let fmNextCallback: (() => void) | null = null;
|
let fmNextCallback: (() => void) | null = null;
|
||||||
@@ -331,7 +280,7 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
await playCurrent();
|
await playCurrent();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function playFromList(songs: Song[], startIndex: number) {
|
async function playFromList(songs: Song[], startIndex: number, source?: { type: 'playlist' | 'album' | 'songs'; id?: number }) {
|
||||||
disableFmMode();
|
disableFmMode();
|
||||||
if (songs.length === 0) return;
|
if (songs.length === 0) return;
|
||||||
|
|
||||||
@@ -340,7 +289,12 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
const sameQueue = queue.value.length === songs.length
|
const sameQueue = queue.value.length === songs.length
|
||||||
&& queue.value.every((s, i) => s.id === songs[i].id);
|
&& queue.value.every((s, i) => s.id === songs[i].id);
|
||||||
if (sameQueue) {
|
if (sameQueue) {
|
||||||
if (!playing.value) {
|
// 同一首歌且同队列:切换播放/暂停
|
||||||
|
if (playing.value) {
|
||||||
|
await AudioApi.pauseAudio();
|
||||||
|
playing.value = false;
|
||||||
|
clearTick();
|
||||||
|
} else {
|
||||||
await AudioApi.resumeAudio();
|
await AudioApi.resumeAudio();
|
||||||
playing.value = true;
|
playing.value = true;
|
||||||
startTick();
|
startTick();
|
||||||
@@ -351,6 +305,7 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
|
|
||||||
queue.value = [...songs];
|
queue.value = [...songs];
|
||||||
currentIndex.value = Math.max(0, Math.min(startIndex, songs.length - 1));
|
currentIndex.value = Math.max(0, Math.min(startIndex, songs.length - 1));
|
||||||
|
currentSource.value = { type: source?.type ?? null, id: source?.id ?? null };
|
||||||
await playCurrent();
|
await playCurrent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -560,6 +515,7 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function toggle() {
|
async function toggle() {
|
||||||
|
if (_switchingSong) return;
|
||||||
try {
|
try {
|
||||||
const backendPlaying = await AudioApi.isAudioPlaying();
|
const backendPlaying = await AudioApi.isAudioPlaying();
|
||||||
if (backendPlaying !== playing.value) {
|
if (backendPlaying !== playing.value) {
|
||||||
@@ -590,15 +546,17 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
|
|
||||||
function prev() {
|
function prev() {
|
||||||
if (isFmMode.value) return;
|
if (isFmMode.value) return;
|
||||||
|
if (_switchingSong) return;
|
||||||
if (queue.value.length === 0) return;
|
if (queue.value.length === 0) return;
|
||||||
currentIndex.value = (currentIndex.value - 1 + queue.value.length) % queue.value.length;
|
currentIndex.value = (currentIndex.value - 1 + queue.value.length) % queue.value.length;
|
||||||
playCurrent();
|
playCurrent();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function playAll(songs: Song[]) {
|
async function playAll(songs: Song[], source?: { type: 'playlist' | 'album' | 'songs'; id?: number }) {
|
||||||
if (songs.length === 0) return;
|
if (songs.length === 0) return;
|
||||||
queue.value = [...songs];
|
queue.value = [...songs];
|
||||||
currentIndex.value = 0;
|
currentIndex.value = 0;
|
||||||
|
currentSource.value = { type: source?.type ?? null, id: source?.id ?? null };
|
||||||
await playCurrent();
|
await playCurrent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -630,6 +588,7 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function seek(time: number) {
|
async function seek(time: number) {
|
||||||
|
if (_switchingSong) return;
|
||||||
try {
|
try {
|
||||||
currentTime.value = time;
|
currentTime.value = time;
|
||||||
if (onSeekStart) onSeekStart();
|
if (onSeekStart) onSeekStart();
|
||||||
@@ -661,6 +620,7 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_switchingSong) return;
|
||||||
if (queue.value.length === 0) return;
|
if (queue.value.length === 0) return;
|
||||||
let nextIndex: number;
|
let nextIndex: number;
|
||||||
switch (playMode.value) {
|
switch (playMode.value) {
|
||||||
@@ -685,30 +645,6 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
playCurrent();
|
playCurrent();
|
||||||
}
|
}
|
||||||
|
|
||||||
const showRoamDrawer = ref(false);
|
|
||||||
const roamTab = ref<'lyric' | 'comment'>('lyric');
|
|
||||||
const commentSongId = ref<number | null>(null);
|
|
||||||
const dominantColor = ref('');
|
|
||||||
|
|
||||||
function openRoamDrawer(tab: 'lyric' | 'comment' = 'lyric') {
|
|
||||||
roamTab.value = tab;
|
|
||||||
showRoamDrawer.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCommentForSong(songId: number) {
|
|
||||||
commentSongId.value = songId;
|
|
||||||
openRoamDrawer('comment');
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeRoamDrawer() {
|
|
||||||
showRoamDrawer.value = false;
|
|
||||||
roamTab.value = 'lyric';
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleRoamDrawer() {
|
|
||||||
showRoamDrawer.value = !showRoamDrawer.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadFirstFmSong() {
|
async function loadFirstFmSong() {
|
||||||
try {
|
try {
|
||||||
const batch = await fetchFmBatch();
|
const batch = await fetchFmBatch();
|
||||||
@@ -720,7 +656,7 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error('FM 加载下一首失败', e);
|
||||||
showToast('FM 加载失败', 'error');
|
showToast('FM 加载失败', 'error');
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -843,6 +779,7 @@ watch(playing, (val) => {
|
|||||||
duration,
|
duration,
|
||||||
queue,
|
queue,
|
||||||
currentIndex,
|
currentIndex,
|
||||||
|
currentSource,
|
||||||
playMode,
|
playMode,
|
||||||
isFmMode,
|
isFmMode,
|
||||||
enableFmMode,
|
enableFmMode,
|
||||||
@@ -864,21 +801,16 @@ watch(playing, (val) => {
|
|||||||
removeFromQueue,
|
removeFromQueue,
|
||||||
clearQueue,
|
clearQueue,
|
||||||
|
|
||||||
recentLocal,
|
// 喜欢列表(委托给 liked store)
|
||||||
|
get likedIds() { return likedStore.likedIds; },
|
||||||
|
isLiked: likedStore.isLiked,
|
||||||
|
loadLikedIds: likedStore.loadLikedIds,
|
||||||
|
toggleLike: likedStore.toggleLike,
|
||||||
|
|
||||||
likedIds,
|
// 最近播放(委托给 recent store)
|
||||||
isLiked,
|
get recentLocal() { return recentStore.recentLocal; },
|
||||||
loadLikedIds,
|
addRecent,
|
||||||
toggleLike,
|
|
||||||
|
|
||||||
showRoamDrawer,
|
|
||||||
roamTab,
|
|
||||||
commentSongId,
|
|
||||||
dominantColor,
|
|
||||||
openCommentForSong,
|
|
||||||
openRoamDrawer,
|
|
||||||
closeRoamDrawer,
|
|
||||||
toggleRoamDrawer,
|
|
||||||
loadFirstFmSong,
|
loadFirstFmSong,
|
||||||
|
|
||||||
fetchFmBatch,
|
fetchFmBatch,
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
import type { Song } from '../types/song';
|
||||||
|
|
||||||
|
const RECENT_KEY = 'recent_local';
|
||||||
|
const MAX_RECENT = 200;
|
||||||
|
|
||||||
|
function loadRecentLocal(): Song[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(RECENT_KEY);
|
||||||
|
if (raw) return JSON.parse(raw);
|
||||||
|
} catch { /* 忽略 */ }
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最近播放 store
|
||||||
|
*
|
||||||
|
* 管理最近播放过的歌曲列表,持久化到 localStorage。
|
||||||
|
*/
|
||||||
|
export const useRecentStore = defineStore('recent', () => {
|
||||||
|
/** 最近播放的歌曲列表(按时间倒序) */
|
||||||
|
const recentLocal = ref<Song[]>(loadRecentLocal());
|
||||||
|
|
||||||
|
let persistTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
/** 添加一首歌到最近播放列表(去重并放到最前) */
|
||||||
|
function addRecent(song: Song) {
|
||||||
|
recentLocal.value = recentLocal.value.filter(s => s.id !== song.id);
|
||||||
|
recentLocal.value.unshift(song);
|
||||||
|
if (recentLocal.value.length > MAX_RECENT) {
|
||||||
|
recentLocal.value = recentLocal.value.slice(0, MAX_RECENT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(recentLocal, (val) => {
|
||||||
|
clearTimeout(persistTimer);
|
||||||
|
persistTimer = setTimeout(() => {
|
||||||
|
localStorage.setItem(RECENT_KEY, JSON.stringify(val));
|
||||||
|
}, 2000);
|
||||||
|
}, { deep: true });
|
||||||
|
|
||||||
|
return {
|
||||||
|
recentLocal,
|
||||||
|
addRecent,
|
||||||
|
};
|
||||||
|
});
|
||||||
+28
-52
@@ -1,6 +1,7 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { ref, watch, computed } from 'vue';
|
import { ref, watch, computed } from 'vue';
|
||||||
import { getPresetSkin, isPresetSkinId, applySkinColors, type SkinColors } from '../skins';
|
import { getPresetSkin, isPresetSkinId, applySkinColors, type SkinColors } from '../skins';
|
||||||
|
import { migrateSettings, CURRENT_SCHEMA_VERSION } from './migrations/settingsMigrations';
|
||||||
|
|
||||||
export type AudioQuality = 'standard' | 'higher' | 'exhigh' | 'lossless' | 'hires';
|
export type AudioQuality = 'standard' | 'higher' | 'exhigh' | 'lossless' | 'hires';
|
||||||
export type CloseAction = 'ask' | 'minimize' | 'exit';
|
export type CloseAction = 'ask' | 'minimize' | 'exit';
|
||||||
@@ -58,7 +59,6 @@ export interface MusicFolder {
|
|||||||
interface SettingsData {
|
interface SettingsData {
|
||||||
audioQuality: AudioQuality;
|
audioQuality: AudioQuality;
|
||||||
downloadPath: string;
|
downloadPath: string;
|
||||||
localMusicPaths: string[]; // 旧格式,迁移用
|
|
||||||
localMusicFolders: MusicFolder[];
|
localMusicFolders: MusicFolder[];
|
||||||
skin: string; // 预设皮肤 id 或 custom-xxx
|
skin: string; // 预设皮肤 id 或 custom-xxx
|
||||||
customSkins: CustomSkin[];
|
customSkins: CustomSkin[];
|
||||||
@@ -66,61 +66,13 @@ interface SettingsData {
|
|||||||
shortcuts: Record<string, ShortcutBinding>;
|
shortcuts: Record<string, ShortcutBinding>;
|
||||||
outputDevice: string | null;
|
outputDevice: string | null;
|
||||||
volume: number;
|
volume: number;
|
||||||
|
schemaVersion: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadSettings(): SettingsData {
|
function loadSettings(): SettingsData {
|
||||||
try {
|
const defaults: SettingsData = {
|
||||||
const raw = localStorage.getItem('app_settings');
|
|
||||||
if (raw) {
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
|
|
||||||
// 迁移旧版 theme + appearance → skin
|
|
||||||
let skin = parsed.skin || 'dark-blue';
|
|
||||||
if (!parsed.skin && (parsed.theme || parsed.appearance)) {
|
|
||||||
const appearance = parsed.appearance || 'dark';
|
|
||||||
const theme = parsed.theme || 'blue';
|
|
||||||
const validThemes = ['blue', 'green', 'rose', 'violet', 'orange', 'cyan', 'pink'];
|
|
||||||
const t = validThemes.includes(theme) ? theme : 'blue';
|
|
||||||
skin = appearance === 'light' ? `light-${t}` : `dark-${t}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 迁移旧版全局壁纸 → 移入自定义皮肤(如果有自定义皮肤且没有壁纸)
|
|
||||||
let customSkins = parsed.customSkins || [];
|
|
||||||
if (parsed.wallpaper && customSkins.length > 0) {
|
|
||||||
customSkins = customSkins.map((s: any) => {
|
|
||||||
if (!s.wallpaper) {
|
|
||||||
return { ...s, wallpaper: parsed.wallpaper, wallpaperBlur: parsed.wallpaperBlur ?? 10, wallpaperOpacity: parsed.wallpaperOpacity ?? 0.3 };
|
|
||||||
}
|
|
||||||
return s;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 迁移旧格式 localMusicPaths → localMusicFolders
|
|
||||||
let folders: MusicFolder[] = (parsed.localMusicFolders || []).map((f: any) =>
|
|
||||||
typeof f === 'string' ? { path: f, enabled: true } : f
|
|
||||||
);
|
|
||||||
if (!parsed.localMusicFolders && parsed.localMusicPaths?.length) {
|
|
||||||
folders = parsed.localMusicPaths.map((p: string) => ({ path: p, enabled: true }));
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
audioQuality: parsed.audioQuality || 'standard',
|
|
||||||
downloadPath: parsed.downloadPath || '',
|
|
||||||
localMusicPaths: [],
|
|
||||||
localMusicFolders: folders,
|
|
||||||
skin,
|
|
||||||
customSkins,
|
|
||||||
closeAction: parsed.closeAction || 'ask',
|
|
||||||
shortcuts: { ...defaultShortcuts, ...(parsed.shortcuts || {}) },
|
|
||||||
outputDevice: parsed.outputDevice || null,
|
|
||||||
volume: typeof parsed.volume === 'number' ? parsed.volume : 100,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch { /* 忽略 */ }
|
|
||||||
return {
|
|
||||||
audioQuality: 'standard',
|
audioQuality: 'standard',
|
||||||
downloadPath: '',
|
downloadPath: '',
|
||||||
localMusicPaths: [],
|
|
||||||
localMusicFolders: [],
|
localMusicFolders: [],
|
||||||
skin: 'dark-blue',
|
skin: 'dark-blue',
|
||||||
customSkins: [],
|
customSkins: [],
|
||||||
@@ -128,7 +80,31 @@ function loadSettings(): SettingsData {
|
|||||||
shortcuts: { ...defaultShortcuts },
|
shortcuts: { ...defaultShortcuts },
|
||||||
outputDevice: null,
|
outputDevice: null,
|
||||||
volume: 100,
|
volume: 100,
|
||||||
|
schemaVersion: CURRENT_SCHEMA_VERSION,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('app_settings');
|
||||||
|
if (!raw) return defaults;
|
||||||
|
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
// 执行迁移(theme→skin、wallpaper→customSkin、localMusicPaths→localMusicFolders)
|
||||||
|
const migrated = migrateSettings(parsed);
|
||||||
|
|
||||||
|
return {
|
||||||
|
audioQuality: migrated.audioQuality || defaults.audioQuality,
|
||||||
|
downloadPath: migrated.downloadPath || defaults.downloadPath,
|
||||||
|
localMusicFolders: Array.isArray(migrated.localMusicFolders) ? migrated.localMusicFolders : defaults.localMusicFolders,
|
||||||
|
skin: migrated.skin || defaults.skin,
|
||||||
|
customSkins: Array.isArray(migrated.customSkins) ? migrated.customSkins : defaults.customSkins,
|
||||||
|
closeAction: migrated.closeAction || defaults.closeAction,
|
||||||
|
shortcuts: { ...defaultShortcuts, ...(migrated.shortcuts || {}) },
|
||||||
|
outputDevice: migrated.outputDevice ?? null,
|
||||||
|
volume: typeof migrated.volume === 'number' ? migrated.volume : defaults.volume,
|
||||||
|
schemaVersion: CURRENT_SCHEMA_VERSION,
|
||||||
|
};
|
||||||
|
} catch { /* 忽略 */ }
|
||||||
|
return defaults;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useSettingsStore = defineStore('settings', () => {
|
export const useSettingsStore = defineStore('settings', () => {
|
||||||
@@ -290,7 +266,6 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
const data: SettingsData = {
|
const data: SettingsData = {
|
||||||
audioQuality: audioQuality.value,
|
audioQuality: audioQuality.value,
|
||||||
downloadPath: downloadPath.value,
|
downloadPath: downloadPath.value,
|
||||||
localMusicPaths: [],
|
|
||||||
localMusicFolders: localMusicFolders.value,
|
localMusicFolders: localMusicFolders.value,
|
||||||
skin: skin.value,
|
skin: skin.value,
|
||||||
customSkins: customSkins.value,
|
customSkins: customSkins.value,
|
||||||
@@ -298,6 +273,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
shortcuts: shortcuts.value,
|
shortcuts: shortcuts.value,
|
||||||
outputDevice: outputDevice.value,
|
outputDevice: outputDevice.value,
|
||||||
volume: volume.value,
|
volume: volume.value,
|
||||||
|
schemaVersion: CURRENT_SCHEMA_VERSION,
|
||||||
};
|
};
|
||||||
localStorage.setItem('app_settings', JSON.stringify(data));
|
localStorage.setItem('app_settings', JSON.stringify(data));
|
||||||
}, { deep: true });
|
}, { deep: true });
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 侧边栏显示模式
|
||||||
|
* - expanded:完整展开(224px,显示图标+文字)
|
||||||
|
* - collapsed:折叠为图标栏(64px,仅图标)
|
||||||
|
* - drawer:抽屉模式(窄屏时,覆盖式滑出)
|
||||||
|
*/
|
||||||
|
export type SidebarMode = 'expanded' | 'collapsed' | 'drawer';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI 状态 store
|
||||||
|
*
|
||||||
|
* 管理 Roam 抽屉(全屏播放/歌词/评论)和侧边栏相关的 UI 状态。
|
||||||
|
*/
|
||||||
|
export const useUiStore = defineStore('ui', () => {
|
||||||
|
/** Roam 抽屉是否展开 */
|
||||||
|
const showRoamDrawer = ref(false);
|
||||||
|
/** 当前 Roam 抽屉显示的标签页 */
|
||||||
|
const roamTab = ref<'lyric' | 'comment'>('lyric');
|
||||||
|
/** Roam 抽屉评论区对应的歌曲 ID(null 表示使用 currentSong.id) */
|
||||||
|
const commentSongId = ref<number | null>(null);
|
||||||
|
/** 当前歌曲封面提取的主色调(用于沉浸式背景) */
|
||||||
|
const dominantColor = ref('');
|
||||||
|
|
||||||
|
/** 侧边栏模式(持久化到 localStorage) */
|
||||||
|
const sidebarMode = ref<SidebarMode>(loadSidebarMode());
|
||||||
|
/** 抽屉模式下侧边栏是否展开 */
|
||||||
|
const drawerOpen = ref(false);
|
||||||
|
|
||||||
|
function loadSidebarMode(): SidebarMode {
|
||||||
|
const saved = localStorage.getItem('sidebar_mode');
|
||||||
|
if (saved === 'expanded' || saved === 'collapsed') return saved;
|
||||||
|
return 'expanded';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSidebarMode(mode: SidebarMode) {
|
||||||
|
sidebarMode.value = mode;
|
||||||
|
if (mode !== 'drawer') {
|
||||||
|
localStorage.setItem('sidebar_mode', mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 切换展开/折叠(不影响 drawer 模式) */
|
||||||
|
function toggleSidebar() {
|
||||||
|
if (sidebarMode.value === 'expanded') {
|
||||||
|
setSidebarMode('collapsed');
|
||||||
|
} else if (sidebarMode.value === 'collapsed') {
|
||||||
|
setSidebarMode('expanded');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 打开/关闭抽屉(仅 drawer 模式生效) */
|
||||||
|
function toggleDrawer() {
|
||||||
|
drawerOpen.value = !drawerOpen.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 关闭抽屉 */
|
||||||
|
function closeDrawer() {
|
||||||
|
drawerOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 打开 Roam 抽屉,可指定初始标签页 */
|
||||||
|
function openRoamDrawer(tab: 'lyric' | 'comment' = 'lyric') {
|
||||||
|
roamTab.value = tab;
|
||||||
|
showRoamDrawer.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 打开 Roam 抽屉并定位到指定歌曲的评论区 */
|
||||||
|
function openCommentForSong(songId: number) {
|
||||||
|
commentSongId.value = songId;
|
||||||
|
openRoamDrawer('comment');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 关闭 Roam 抽屉并重置标签页 */
|
||||||
|
function closeRoamDrawer() {
|
||||||
|
showRoamDrawer.value = false;
|
||||||
|
roamTab.value = 'lyric';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 切换 Roam 抽屉展开状态 */
|
||||||
|
function toggleRoamDrawer() {
|
||||||
|
showRoamDrawer.value = !showRoamDrawer.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
showRoamDrawer,
|
||||||
|
roamTab,
|
||||||
|
commentSongId,
|
||||||
|
dominantColor,
|
||||||
|
sidebarMode,
|
||||||
|
drawerOpen,
|
||||||
|
setSidebarMode,
|
||||||
|
toggleSidebar,
|
||||||
|
toggleDrawer,
|
||||||
|
closeDrawer,
|
||||||
|
openRoamDrawer,
|
||||||
|
openCommentForSong,
|
||||||
|
closeRoamDrawer,
|
||||||
|
toggleRoamDrawer,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -70,6 +70,10 @@
|
|||||||
-webkit-user-select: text;
|
-webkit-user-select: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 滚动条:hover 容器显示主题色 thumb(--c-muted / --c-emphasis),默认透明。
|
||||||
|
- 颜色跟随主题变量(皮肤切换自动适配)
|
||||||
|
- 宽度 5px,不显眼;hover 容器才显示,hover thumb 变深
|
||||||
|
- 彻底无滚动条的容器(歌词界面)加 .custom-scroll 或 [data-no-scrollbar] */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 5px;
|
width: 5px;
|
||||||
height: 5px;
|
height: 5px;
|
||||||
@@ -88,6 +92,22 @@
|
|||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background-color: var(--c-emphasis);
|
background-color: var(--c-emphasis);
|
||||||
}
|
}
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--c-muted) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 彻底隐藏滚动条的容器(歌词界面等) */
|
||||||
|
.custom-scroll,
|
||||||
|
[data-no-scrollbar] {
|
||||||
|
scrollbar-width: none !important;
|
||||||
|
}
|
||||||
|
.custom-scroll::-webkit-scrollbar,
|
||||||
|
[data-no-scrollbar]::-webkit-scrollbar {
|
||||||
|
width: 0 !important;
|
||||||
|
height: 0 !important;
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
@@ -143,3 +163,9 @@
|
|||||||
transform: rotate(45deg);
|
transform: rotate(45deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 骨架屏脉冲动画:三个详情页(Playlist/Album/Artist)共用 */
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* 歌曲相关类型定义
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 歌曲信息
|
||||||
|
*/
|
||||||
|
export interface Song {
|
||||||
|
/** 歌曲 ID */
|
||||||
|
id: number;
|
||||||
|
/** 歌曲名称 */
|
||||||
|
name: string;
|
||||||
|
/** 歌手列表 */
|
||||||
|
ar: { id?: number; name: string }[];
|
||||||
|
/** 专辑信息 */
|
||||||
|
al: { id?: number; picUrl: string; name?: string };
|
||||||
|
/** 时长(毫秒) */
|
||||||
|
dt?: number;
|
||||||
|
/** 本地文件路径(本地歌曲专用) */
|
||||||
|
localPath?: string;
|
||||||
|
/** 推荐算法标识(私人 FM 推荐) */
|
||||||
|
alg?: string;
|
||||||
|
/** 音频码率 */
|
||||||
|
br?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 播放模式
|
||||||
|
* - loop: 列表循环
|
||||||
|
* - shuffle: 随机播放
|
||||||
|
* - repeat-one: 单曲循环
|
||||||
|
*/
|
||||||
|
export type PlayMode = 'loop' | 'shuffle' | 'repeat-one';
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { nextTick, type Ref } from 'vue';
|
||||||
|
|
||||||
|
// 检查描述元素是否溢出(用于显示"查看完整介绍"按钮)
|
||||||
|
// 三个详情页(PlaylistDetail / AlbumDetail / ArtistDetail)共用
|
||||||
|
export function checkOverflow(
|
||||||
|
elRef: Ref<HTMLElement | null>,
|
||||||
|
overflowRef: Ref<boolean>,
|
||||||
|
tolerance = 2,
|
||||||
|
) {
|
||||||
|
nextTick(() => {
|
||||||
|
if (elRef.value) {
|
||||||
|
overflowRef.value = elRef.value.scrollHeight > elRef.value.clientHeight + tolerance;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
+2
-15
@@ -1,13 +1,5 @@
|
|||||||
export interface Song {
|
export type { Song } from '../types/song';
|
||||||
id: number;
|
import type { Song } from '../types/song';
|
||||||
name: string;
|
|
||||||
ar: { id?: number; name: string }[];
|
|
||||||
al: { id?: number; picUrl: string; name?: string };
|
|
||||||
dt?: number;
|
|
||||||
localPath?: string;
|
|
||||||
alg?: string;
|
|
||||||
br?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeSong(song: any): Song {
|
export function normalizeSong(song: any): Song {
|
||||||
const al = {
|
const al = {
|
||||||
@@ -48,11 +40,6 @@ export function getArtistDisplay(song: Song): string {
|
|||||||
return names.length > 0 ? names.join(' / ') : '未知歌手';
|
return names.length > 0 ? names.join(' / ') : '未知歌手';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAlbumDisplay(song: Song): string {
|
|
||||||
if (!song.al?.id || !song.al?.name) return '未知专辑';
|
|
||||||
return song.al.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
const colorCache = new Map<string, string>();
|
const colorCache = new Map<string, string>();
|
||||||
const MAX_COLOR_CACHE = 200;
|
const MAX_COLOR_CACHE = 200;
|
||||||
|
|
||||||
|
|||||||
+583
-74
@@ -1,84 +1,188 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="p-8 text-content">
|
<DetailLayout ref="layoutRef">
|
||||||
<PageHeader />
|
<!-- 头部(常驻):滚动时收缩为紧凑态
|
||||||
|
参考 SPlayer ListDetail.vue .small 触发 height/font-size/opacity 过渡 -->
|
||||||
<!-- 头部骨架 -->
|
<template #header="{ compact }">
|
||||||
<div v-if="!album && albumLoading" class="flex gap-6 mb-8">
|
<div class="album-header" :class="{ 'is-compact': compact }">
|
||||||
<div class="w-44 h-44 rounded-xl bg-muted animate-pulse flex-shrink-0"></div>
|
<!-- 头部骨架 -->
|
||||||
<div class="flex-1 space-y-3">
|
<div v-if="!album && albumLoading" class="header-row">
|
||||||
<div class="h-7 bg-muted rounded w-1/2 animate-pulse"></div>
|
<div class="cover-wrap">
|
||||||
<div class="h-4 bg-muted rounded w-1/3 animate-pulse"></div>
|
<div class="cover-img cover-skeleton"></div>
|
||||||
<div class="h-4 bg-muted rounded w-1/4 animate-pulse"></div>
|
</div>
|
||||||
<div class="h-10 w-28 bg-muted rounded-full animate-pulse mt-4"></div>
|
<div class="info-col">
|
||||||
</div>
|
<div class="h-8 bg-muted rounded w-1/2 animate-pulse"></div>
|
||||||
</div>
|
<div class="info-collapse">
|
||||||
|
<div class="h-4 bg-muted rounded w-1/3 animate-pulse mt-3"></div>
|
||||||
<!-- 头部信息 -->
|
<div class="h-3 bg-muted rounded w-1/4 animate-pulse mt-2"></div>
|
||||||
<div v-else-if="album" class="flex gap-6 mb-8">
|
</div>
|
||||||
<img :src="album.picUrl" class="w-44 h-44 rounded-xl object-cover shadow-lg flex-shrink-0" />
|
<div class="actions-row">
|
||||||
<div class="flex flex-col justify-between min-w-0">
|
<div class="h-10 w-28 bg-muted rounded-full animate-pulse"></div>
|
||||||
<div>
|
</div>
|
||||||
<h1 class="text-2xl font-bold leading-tight">{{ album.name }}</h1>
|
|
||||||
<div v-if="album.artists?.length" class="flex flex-wrap items-center gap-x-1 gap-y-0.5 mt-2 text-sm text-content-2">
|
|
||||||
<template v-for="(ar, idx) in album.artists" :key="ar.id">
|
|
||||||
<span v-if="(idx as number) > 0" class="text-content-3">/</span>
|
|
||||||
<span
|
|
||||||
class="hover:text-accent-text cursor-pointer transition whitespace-nowrap"
|
|
||||||
@click="ar.id && router.push({ name: 'artist', params: { id: ar.id } })"
|
|
||||||
>{{ ar.name }}</span>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-content-3 mt-2">
|
|
||||||
{{ formatDate(album.publishTime) }} · {{ songs.length }} 首歌曲
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3 mt-4">
|
|
||||||
<button
|
<!-- 头部信息 -->
|
||||||
@click="playAll"
|
<div v-else-if="album" class="header-row">
|
||||||
class="px-5 py-2 bg-accent hover:bg-accent-hover rounded-full text-white font-medium transition flex items-center gap-2"
|
<!-- 封面 + 阴影背板 + 顶部渐变遮罩(参考 SPlayer .cover + .cover-shadow + .cover-mask)
|
||||||
>
|
封面图加载完成后淡入(opacity 0→1),避免空白闪烁 -->
|
||||||
<IconPlay class="w-4 h-4 fill-current" />
|
<div class="cover-wrap">
|
||||||
播放全部
|
<img :src="album.picUrl" class="cover-shadow" alt="" aria-hidden="true" />
|
||||||
</button>
|
<img
|
||||||
|
:src="album.picUrl"
|
||||||
|
class="cover-img"
|
||||||
|
:class="{ 'is-loaded': coverLoaded }"
|
||||||
|
:alt="album.name"
|
||||||
|
@load="coverLoaded = true"
|
||||||
|
/>
|
||||||
|
<!-- 顶部渐变遮罩:紧凑时淡出(参考 SPlayer .cover-mask) -->
|
||||||
|
<div class="cover-mask"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 信息列:标题(常驻)+ 元信息(紧凑时折叠)+ 操作行(常驻底部)
|
||||||
|
操作行内左侧播放按钮,右侧模糊搜索 + 歌曲/评论 tab(参考 SPlayer .menu .left/.right) -->
|
||||||
|
<div class="info-col">
|
||||||
|
<div class="info-top min-w-0">
|
||||||
|
<h1 class="album-title font-bold leading-tight">{{ album.name }}</h1>
|
||||||
|
|
||||||
|
<!-- 元信息:紧凑时通过 max-height + opacity 折叠(参考 SPlayer n-collapse-transition) -->
|
||||||
|
<div class="info-collapse">
|
||||||
|
<!-- 艺术家行 -->
|
||||||
|
<div v-if="album.artists?.length" class="meta-row">
|
||||||
|
<IconUserRound class="meta-icon" />
|
||||||
|
<div class="flex flex-wrap items-center gap-x-1 gap-y-0.5">
|
||||||
|
<template v-for="(ar, idx) in album.artists" :key="ar.id">
|
||||||
|
<span v-if="(idx as number) > 0" class="text-content-3">/</span>
|
||||||
|
<span
|
||||||
|
class="meta-text hover:text-accent-text cursor-pointer transition whitespace-nowrap"
|
||||||
|
@click="ar.id && router.push({ name: 'artist', params: { id: ar.id } })"
|
||||||
|
>{{ ar.name }}</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 歌曲数 + 发行日期 -->
|
||||||
|
<div class="meta-row">
|
||||||
|
<IconMusic class="meta-icon" />
|
||||||
|
<span class="meta-text">{{ songs.length }} 首歌曲</span>
|
||||||
|
<IconCalendar class="meta-icon ml-3" v-if="album.publishTime" />
|
||||||
|
<span class="meta-text" v-if="album.publishTime">{{ formatDate(album.publishTime) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 简介(如有) -->
|
||||||
|
<p v-if="album.description" class="album-desc meta-text mt-2">
|
||||||
|
{{ album.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作行:始终在信息列底部(参考 SPlayer .menu position:absolute bottom:0)
|
||||||
|
左侧播放按钮常驻;右侧模糊搜索 + 歌曲/评论 tab(窄屏隐藏) -->
|
||||||
|
<div class="actions-row">
|
||||||
|
<div class="actions-left">
|
||||||
|
<button
|
||||||
|
@click="playAll"
|
||||||
|
class="play-btn bg-accent hover:bg-accent-hover text-white font-medium rounded-full transition flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<IconPlay class="w-4 h-4 fill-current" />
|
||||||
|
<span>播放全部</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="actions-right">
|
||||||
|
<!-- 模糊搜索:过滤当前歌曲列表 -->
|
||||||
|
<div class="search-wrap" @click.stop>
|
||||||
|
<IconSearch class="search-icon" />
|
||||||
|
<input
|
||||||
|
v-model="searchKeyword"
|
||||||
|
placeholder="模糊搜索"
|
||||||
|
class="search-input"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="searchKeyword"
|
||||||
|
@click="searchKeyword = ''"
|
||||||
|
class="search-clear"
|
||||||
|
>
|
||||||
|
<IconX class="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- 歌曲 / 评论 tab -->
|
||||||
|
<div class="tabs-wrap">
|
||||||
|
<button
|
||||||
|
class="tab-btn"
|
||||||
|
:class="{ active: currentTab === 'songs' }"
|
||||||
|
@click="currentTab = 'songs'"
|
||||||
|
>歌曲<span class="tab-count">{{ songs.length }}</span></button>
|
||||||
|
<button
|
||||||
|
class="tab-btn"
|
||||||
|
:class="{ active: currentTab === 'comments' }"
|
||||||
|
@click="currentTab = 'comments'"
|
||||||
|
>评论</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
<!-- 加载失败 -->
|
<!-- 内容区(独立滚动):歌曲 / 评论 按 tab 切换渲染
|
||||||
<div v-if="loadError" class="flex flex-col items-center justify-center py-16 gap-3">
|
songs 用 v-if 仅激活时渲染;comments 用「首次激活后保活」模式(v-if + v-show)保留滚动位置 -->
|
||||||
<p class="text-content-2 text-sm">加载失败</p>
|
<template #content>
|
||||||
<button @click="fetchAlbum(Number(route.params.id), true)" class="px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition">重试</button>
|
<div class="album-list-inner">
|
||||||
</div>
|
<!-- 加载失败 -->
|
||||||
|
<div v-if="loadError" class="flex flex-col items-center justify-center py-16 gap-3">
|
||||||
<!-- 歌曲列表骨架 -->
|
<p class="text-content-2 text-sm">加载失败</p>
|
||||||
<div v-else-if="songsLoading" class="space-y-1">
|
<button @click="fetchAlbum(Number(route.params.id), true)" class="px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition">重试</button>
|
||||||
<div v-for="i in 6" :key="i" class="flex items-center gap-3 px-3 py-2">
|
|
||||||
<div class="w-12 h-12 bg-muted rounded animate-pulse flex-shrink-0"></div>
|
|
||||||
<div class="flex-1 space-y-2">
|
|
||||||
<div class="h-4 bg-muted rounded w-2/3 animate-pulse"></div>
|
|
||||||
<div class="h-3 bg-muted rounded w-1/3 animate-pulse"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 歌曲列表 -->
|
<!-- 歌曲列表骨架 -->
|
||||||
<VirtualSongList
|
<div v-else-if="songsLoading" class="space-y-1">
|
||||||
v-else-if="songs.length"
|
<div v-for="i in 6" :key="i" class="flex items-center gap-3 px-3 py-2">
|
||||||
:songs="songs"
|
<div class="w-12 h-12 bg-muted rounded animate-pulse flex-shrink-0"></div>
|
||||||
:current-song-id="player.currentSong?.id"
|
<div class="flex-1 space-y-2">
|
||||||
show-index
|
<div class="h-4 bg-muted rounded w-2/3 animate-pulse"></div>
|
||||||
show-like
|
<div class="h-3 bg-muted rounded w-1/3 animate-pulse"></div>
|
||||||
show-download
|
</div>
|
||||||
show-menu
|
</div>
|
||||||
show-duration
|
</div>
|
||||||
show-playing-overlay
|
|
||||||
@song-click="(_s, i) => player.playFromList(songs, i)"
|
<!-- 歌曲 tab:v-if 仅在激活时渲染 -->
|
||||||
/>
|
<template v-else-if="currentTab === 'songs'">
|
||||||
</div>
|
<VirtualSongList
|
||||||
|
v-if="filteredSongs.length"
|
||||||
|
:songs="filteredSongs"
|
||||||
|
:current-song-id="player.currentSong?.id"
|
||||||
|
show-index
|
||||||
|
show-like
|
||||||
|
show-download
|
||||||
|
show-menu
|
||||||
|
show-duration
|
||||||
|
show-playing-overlay
|
||||||
|
@song-click="(_s, i) => player.playFromList(filteredSongs, i)"
|
||||||
|
/>
|
||||||
|
<!-- 搜索无结果 -->
|
||||||
|
<div v-else-if="searchKeyword" class="flex flex-col items-center justify-center py-16 gap-2 text-content-3 text-sm">
|
||||||
|
<p>搜不到关于 {{ searchKeyword }} 的任何歌曲</p>
|
||||||
|
</div>
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<div v-else class="flex flex-col items-center justify-center py-16 gap-2 text-content-3 text-sm">
|
||||||
|
<p>暂无歌曲</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 评论 tab:首次切到时挂载(commentsActivated=true),之后保活以保留滚动位置 -->
|
||||||
|
<CommentSection
|
||||||
|
v-if="commentsActivated"
|
||||||
|
v-show="currentTab === 'comments'"
|
||||||
|
:type="3"
|
||||||
|
:id="Number(route.params.id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</DetailLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, watch, onActivated } from 'vue';
|
import { ref, computed, onMounted, watch, onActivated } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { MusicApi } from '../api';
|
import { MusicApi } from '../api';
|
||||||
import { usePlayerStore } from '../stores/player';
|
import { usePlayerStore } from '../stores/player';
|
||||||
@@ -86,8 +190,14 @@ import { normalizeSong, type Song } from '../utils/song';
|
|||||||
import { formatDate } from '../utils/format';
|
import { formatDate } from '../utils/format';
|
||||||
import { pageCacheGet, pageCacheSet } from '../composables/usePageCache';
|
import { pageCacheGet, pageCacheSet } from '../composables/usePageCache';
|
||||||
import VirtualSongList from '../components/VirtualSongList.vue';
|
import VirtualSongList from '../components/VirtualSongList.vue';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import CommentSection from '../components/CommentSection.vue';
|
||||||
|
import DetailLayout from '../components/DetailLayout.vue';
|
||||||
import IconPlay from '~icons/lucide/play';
|
import IconPlay from '~icons/lucide/play';
|
||||||
|
import IconUserRound from '~icons/lucide/user-round';
|
||||||
|
import IconMusic from '~icons/lucide/music';
|
||||||
|
import IconCalendar from '~icons/lucide/calendar';
|
||||||
|
import IconSearch from '~icons/lucide/search';
|
||||||
|
import IconX from '~icons/lucide/x';
|
||||||
|
|
||||||
defineOptions({ name: 'AlbumDetailView' });
|
defineOptions({ name: 'AlbumDetailView' });
|
||||||
|
|
||||||
@@ -101,8 +211,47 @@ const albumLoading = ref(true);
|
|||||||
const songsLoading = ref(false);
|
const songsLoading = ref(false);
|
||||||
const loadError = ref(false);
|
const loadError = ref(false);
|
||||||
|
|
||||||
|
// 封面加载完成标记(控制淡入)
|
||||||
|
const coverLoaded = ref(false);
|
||||||
|
|
||||||
|
// 当前 tab:songs / comments(参考 SPlayer currentTab)
|
||||||
|
const currentTab = ref<'songs' | 'comments'>('songs');
|
||||||
|
// 评论 tab 首次激活标记:懒加载 + 保活(v-if 控制挂载,v-show 控制显示)
|
||||||
|
const commentsActivated = ref(false);
|
||||||
|
|
||||||
|
// 模糊搜索关键词(参考 SPlayer searchValue)
|
||||||
|
const searchKeyword = ref('');
|
||||||
|
|
||||||
|
// DetailLayout 实例引用:用于切换专辑/tab 时重置滚动 & 紧凑态
|
||||||
|
const layoutRef = ref<InstanceType<typeof DetailLayout> | null>(null);
|
||||||
|
|
||||||
|
// 过滤后的歌曲列表:按关键词模糊匹配歌名/歌手/专辑名
|
||||||
|
const filteredSongs = computed<Song[]>(() => {
|
||||||
|
const kw = searchKeyword.value.trim().toLowerCase();
|
||||||
|
if (!kw) return songs.value;
|
||||||
|
return songs.value.filter((s) => {
|
||||||
|
const name = (s.name || '').toLowerCase();
|
||||||
|
const artist = (s.ar?.map((a: any) => a.name).join('') || '').toLowerCase();
|
||||||
|
const albumName = (s.al?.name || '').toLowerCase();
|
||||||
|
return name.includes(kw) || artist.includes(kw) || albumName.includes(kw);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 切换 tab 时重置紧凑态:评论/歌曲高度差异大,避免紧凑态卡死
|
||||||
|
// 首次切到评论 tab 时激活保活标记
|
||||||
|
watch(currentTab, (tab) => {
|
||||||
|
layoutRef.value?.resetScroll();
|
||||||
|
if (tab === 'comments') commentsActivated.value = true;
|
||||||
|
});
|
||||||
|
|
||||||
async function fetchAlbum(id: number, force = false) {
|
async function fetchAlbum(id: number, force = false) {
|
||||||
const cacheKey = `album_${id}`;
|
const cacheKey = `album_${id}`;
|
||||||
|
// 切换专辑时重置 tab、搜索、封面与滚动状态(参考 SPlayer watch detailData.id)
|
||||||
|
currentTab.value = 'songs';
|
||||||
|
commentsActivated.value = false;
|
||||||
|
searchKeyword.value = '';
|
||||||
|
coverLoaded.value = false;
|
||||||
|
layoutRef.value?.resetScroll();
|
||||||
if (!force) {
|
if (!force) {
|
||||||
const cached = pageCacheGet(cacheKey);
|
const cached = pageCacheGet(cacheKey);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
@@ -129,7 +278,7 @@ async function fetchAlbum(id: number, force = false) {
|
|||||||
songsLoading.value = false;
|
songsLoading.value = false;
|
||||||
pageCacheSet(cacheKey, { album: album.value, songs: songs.value });
|
pageCacheSet(cacheKey, { album: album.value, songs: songs.value });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error('获取专辑详情失败', e);
|
||||||
loadError.value = true;
|
loadError.value = true;
|
||||||
albumLoading.value = false;
|
albumLoading.value = false;
|
||||||
songsLoading.value = false;
|
songsLoading.value = false;
|
||||||
@@ -140,6 +289,7 @@ onMounted(() => {
|
|||||||
fetchAlbum(Number(route.params.id));
|
fetchAlbum(Number(route.params.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 合并两个 route.params.id watcher:仅监听一次,同时处理状态重置和数据获取
|
||||||
watch(() => route.params.id, (newId) => {
|
watch(() => route.params.id, (newId) => {
|
||||||
if (newId && route.name === 'album') fetchAlbum(Number(newId));
|
if (newId && route.name === 'album') fetchAlbum(Number(newId));
|
||||||
});
|
});
|
||||||
@@ -149,7 +299,366 @@ onActivated(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function playAll() {
|
function playAll() {
|
||||||
if (songs.value.length === 0) return;
|
// 播放当前过滤后的列表(搜索时只播放匹配项)
|
||||||
player.playAll(songs.value);
|
const list = filteredSongs.value.length ? filteredSongs.value : songs.value;
|
||||||
|
if (list.length === 0) return;
|
||||||
|
player.playAll(list);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* === 头部容器:固定高度 + padding,紧凑态收窄 ===
|
||||||
|
参考 SPlayer .detail height 240→120 过渡。
|
||||||
|
根容器布局由 DetailLayout 提供(absolute + flex 列 + header flex-shrink:0)。 */
|
||||||
|
.album-header {
|
||||||
|
height: 240px;
|
||||||
|
z-index: 1;
|
||||||
|
padding: 12px 32px 24px 32px;
|
||||||
|
border-bottom: 1px solid transparent;
|
||||||
|
transition:
|
||||||
|
height 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
padding 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
border-color 0.3s ease;
|
||||||
|
}
|
||||||
|
.album-header.is-compact {
|
||||||
|
height: 120px;
|
||||||
|
padding: 12px 32px 12px 32px;
|
||||||
|
border-bottom-color: var(--c-line-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 头部内行:封面 + 信息列横向排列 */
|
||||||
|
.header-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
height: 100%;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.album-header.is-compact .header-row {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === 封面区(参考 SPlayer .cover + .cover-shadow) ===
|
||||||
|
正方形,高度跟随 header 高度(240/120),宽度等于高度。
|
||||||
|
position:relative 承载阴影背板。 */
|
||||||
|
.cover-wrap {
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主封面图:加载完成后淡入(参考 SPlayer img opacity 0→1 + .is-loaded)
|
||||||
|
未加载时 opacity:0 避免空白闪烁;加载后 .is-loaded 触发淡入 */
|
||||||
|
.cover-img {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 8px;
|
||||||
|
z-index: 1;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.35s ease-in-out;
|
||||||
|
}
|
||||||
|
.cover-img.is-loaded {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
/* 骨架占位 */
|
||||||
|
.cover-skeleton {
|
||||||
|
background-color: var(--c-muted);
|
||||||
|
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 阴影背板:模糊 + 半透明 + 略小,营造封面浮起的氛围 */
|
||||||
|
.cover-shadow {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 8px;
|
||||||
|
filter: blur(12px) opacity(0.6);
|
||||||
|
transform: scale(0.92, 0.96);
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶部渐变遮罩(参考 SPlayer .cover-mask):紧凑时淡出 */
|
||||||
|
.cover-mask {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 30%;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 2;
|
||||||
|
background: linear-gradient(rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0));
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.album-header.is-compact .cover-mask {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === 信息列(参考 SPlayer .data) ===
|
||||||
|
flex:1 占满剩余宽度,纵向 flex:顶部信息 + 底部操作行。
|
||||||
|
justify-content:space-between 让操作行始终贴底。 */
|
||||||
|
.info-col {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-right: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === 标题(参考 SPlayer .name) ===
|
||||||
|
展开 30px,紧凑 22px。固定 line-height + min-height 防字号变化抖动。 */
|
||||||
|
.album-title {
|
||||||
|
font-size: 30px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1.3;
|
||||||
|
min-height: 39px;
|
||||||
|
transition: font-size 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
.album-header.is-compact .album-title {
|
||||||
|
font-size: 22px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === 元信息折叠区(参考 SPlayer n-collapse-transition) ===
|
||||||
|
展开 max-height 大、opacity 1;紧凑 max-height 0、opacity 0、收起间距。 */
|
||||||
|
.info-collapse {
|
||||||
|
max-height: 160px;
|
||||||
|
opacity: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
transition:
|
||||||
|
max-height 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
opacity 0.2s ease,
|
||||||
|
margin 0.3s ease;
|
||||||
|
}
|
||||||
|
.album-header.is-compact .info-collapse {
|
||||||
|
max-height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
margin-top: -4px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 元信息行:图标 + 文字,多行堆叠 */
|
||||||
|
.meta-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.meta-row:first-of-type {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.meta-icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
color: var(--c-content-3);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.meta-text {
|
||||||
|
color: var(--c-content-2);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 简介:单行省略 */
|
||||||
|
.album-desc {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === 操作行(参考 SPlayer .menu position:absolute bottom:0) ===
|
||||||
|
始终在信息列底部;左侧播放按钮 + 右侧搜索/Tab,两端对齐。 */
|
||||||
|
.actions-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.actions-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.actions-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.play-btn {
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
transition:
|
||||||
|
height 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
padding 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
.album-header.is-compact .play-btn {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 模糊搜索框(参考 SPlayer .search):胶囊形,聚焦时变宽 */
|
||||||
|
.search-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 40px;
|
||||||
|
width: 130px;
|
||||||
|
border-radius: 25px;
|
||||||
|
background-color: var(--c-subtle);
|
||||||
|
border: 1px solid var(--c-line-2);
|
||||||
|
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1), height 0.3s ease;
|
||||||
|
}
|
||||||
|
.search-wrap:focus-within {
|
||||||
|
width: 200px;
|
||||||
|
border-color: var(--c-accent);
|
||||||
|
}
|
||||||
|
.search-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-left: 12px;
|
||||||
|
color: var(--c-content-3);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--c-content);
|
||||||
|
}
|
||||||
|
.search-input::placeholder { color: var(--c-content-3); }
|
||||||
|
.search-clear {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-right: 8px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--c-content-3);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background-color 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
.search-clear:hover {
|
||||||
|
background-color: var(--c-muted);
|
||||||
|
color: var(--c-content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 歌曲/评论 tab(参考 SPlayer .tabs type=segment) */
|
||||||
|
.tabs-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 40px;
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 25px;
|
||||||
|
background-color: var(--c-subtle);
|
||||||
|
border: 1px solid var(--c-line-2);
|
||||||
|
transition: height 0.3s ease;
|
||||||
|
}
|
||||||
|
.tab-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 22px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--c-content-2);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.3s, color 0.3s;
|
||||||
|
}
|
||||||
|
.tab-btn.active {
|
||||||
|
background-color: var(--c-accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.tab-btn:not(.active):hover {
|
||||||
|
color: var(--c-content);
|
||||||
|
}
|
||||||
|
.tab-count {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.7;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 紧凑态:右侧搜索/tab 高度缩小(参考 SPlayer .small .menu) */
|
||||||
|
.album-header.is-compact .search-wrap,
|
||||||
|
.album-header.is-compact .tabs-wrap {
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
.album-header.is-compact .search-wrap {
|
||||||
|
width: 110px;
|
||||||
|
}
|
||||||
|
.album-header.is-compact .search-wrap:focus-within {
|
||||||
|
width: 160px;
|
||||||
|
}
|
||||||
|
.album-header.is-compact .tab-btn {
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 窄屏隐藏右侧搜索/tab(参考 SPlayer @media max-width:1200px .right) */
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.actions-right {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 窄屏适配(参考 SPlayer @media) */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.album-header {
|
||||||
|
height: 180px;
|
||||||
|
padding: 12px 16px 20px 16px;
|
||||||
|
}
|
||||||
|
.album-header.is-compact {
|
||||||
|
height: 100px;
|
||||||
|
}
|
||||||
|
.header-row {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.info-col {
|
||||||
|
padding-right: 12px;
|
||||||
|
}
|
||||||
|
.album-title {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
.album-header.is-compact .album-title {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 列表内层 padding(外层滚动容器由 DetailLayout 提供) */
|
||||||
|
.album-list-inner {
|
||||||
|
padding: 0 32px 32px 32px;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.album-list-inner {
|
||||||
|
padding: 0 16px 24px 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
+381
-153
@@ -1,173 +1,187 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="p-8 text-content">
|
<DetailLayout ref="layoutRef">
|
||||||
<PageHeader />
|
<!-- 头部(常驻):滚动时收缩为紧凑态(头像缩小、标题缩小、简介隐藏)
|
||||||
|
参考 SPlayer ListDetail.vue .small 触发 height/font-size/opacity 过渡 -->
|
||||||
|
<template #header="{ compact }">
|
||||||
|
<div class="ar-header" :class="{ 'is-compact': compact }">
|
||||||
|
<PageHeader />
|
||||||
|
|
||||||
<!-- 头部骨架 -->
|
<!-- 头部骨架 -->
|
||||||
<div v-if="!artist && !songs.length && !albums.length" class="flex gap-6 mb-4">
|
<div v-if="!artist && !songs.length && !albums.length" class="ar-head-row">
|
||||||
<div class="w-44 h-44 rounded-full bg-muted animate-pulse flex-shrink-0"></div>
|
<div class="ar-cover cover-skeleton"></div>
|
||||||
<div class="flex-1 space-y-3">
|
<div class="flex-1 space-y-3">
|
||||||
<div class="h-7 bg-muted rounded w-1/3 animate-pulse"></div>
|
<div class="h-7 bg-muted rounded w-1/3 animate-pulse"></div>
|
||||||
<div class="h-4 bg-muted rounded w-1/4 animate-pulse"></div>
|
<div class="h-4 bg-muted rounded w-1/4 animate-pulse"></div>
|
||||||
<div class="h-4 bg-muted rounded w-2/3 animate-pulse"></div>
|
<div class="h-4 bg-muted rounded w-2/3 animate-pulse"></div>
|
||||||
<div class="flex gap-3 mt-4">
|
<div class="flex gap-3 mt-4">
|
||||||
<div class="h-10 w-28 bg-muted rounded-full animate-pulse"></div>
|
<div class="h-10 w-28 bg-muted rounded-full animate-pulse"></div>
|
||||||
<div class="h-10 w-20 bg-muted rounded-full animate-pulse"></div>
|
<div class="h-10 w-20 bg-muted rounded-full animate-pulse"></div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="loadError" class="flex flex-col items-center justify-center py-16 gap-3">
|
|
||||||
<p class="text-content-2 text-sm">加载失败</p>
|
|
||||||
<button @click="fetchArtist(Number(route.params.id), true)" class="px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition">重试</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-if="!loadError">
|
|
||||||
<!-- 头部:头像 + 简介 -->
|
|
||||||
<div v-if="artist || songs.length || albums.length" class="flex gap-6 mb-4">
|
|
||||||
<img v-if="artistCover" :src="artistCover" class="w-44 h-44 rounded-full object-cover shadow-lg flex-shrink-0" />
|
|
||||||
<div v-else class="w-44 h-44 rounded-full bg-muted flex items-center justify-center flex-shrink-0">
|
|
||||||
<IconMusic class="w-12 h-12 text-content-4" />
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col min-w-0 flex-1">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-2xl font-bold leading-tight">{{ artistName }}</h1>
|
|
||||||
<p v-if="artistFollowers || artist?.musicSize" class="text-xs text-content-3 mt-1">
|
|
||||||
<span v-if="artistFollowers">{{ formatPlayCount(artistFollowers) }} 粉丝</span>
|
|
||||||
<span v-if="artistFollowers && artist?.musicSize"> · </span>
|
|
||||||
<span v-if="artist?.musicSize">{{ artist.musicSize }} 首歌曲</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div v-if="briefDesc" class="mt-3">
|
|
||||||
<p
|
|
||||||
ref="descEl"
|
|
||||||
class="text-sm text-content-2 leading-relaxed whitespace-pre-wrap overflow-hidden"
|
|
||||||
style="max-height: 4.5em"
|
|
||||||
>{{ briefDesc }}</p>
|
|
||||||
<button
|
|
||||||
v-if="descOverflow"
|
|
||||||
@click="showDescModal = true"
|
|
||||||
class="inline-flex items-center gap-1 text-xs text-accent-text hover:text-accent-text/80 mt-1.5 px-2 py-0.5 rounded-full bg-accent-text/10 transition"
|
|
||||||
>
|
|
||||||
<IconChevronDown class="w-3 h-3" />
|
|
||||||
查看完整介绍
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3 mt-auto pt-4">
|
|
||||||
<button
|
|
||||||
@click="playAll"
|
|
||||||
class="px-5 py-2 bg-accent hover:bg-accent-hover rounded-full text-white font-medium transition flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<IconPlay class="w-4 h-4 fill-current" />
|
|
||||||
播放全部
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="toggleFollow"
|
|
||||||
:disabled="followLoading"
|
|
||||||
class="px-5 py-2 rounded-full font-medium transition flex items-center gap-2"
|
|
||||||
:class="isFollowed
|
|
||||||
? 'bg-subtle text-content-2 hover:bg-muted'
|
|
||||||
: 'bg-accent/15 text-accent-text hover:bg-accent/25'"
|
|
||||||
>
|
|
||||||
{{ isFollowed ? '已关注' : '关注' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 简介弹窗 -->
|
|
||||||
<Teleport to="body">
|
|
||||||
<div v-if="showDescModal" class="fixed inset-0 z-50 flex items-center justify-center" @click.self="showDescModal = false">
|
|
||||||
<div class="absolute inset-0 bg-black/50" @click="showDescModal = false"></div>
|
|
||||||
<div class="relative bg-surface rounded-2xl shadow-2xl max-w-lg w-full mx-4 max-h-[70vh] flex flex-col">
|
|
||||||
<div class="flex items-center justify-between p-5 border-b border-line-2">
|
|
||||||
<h2 class="text-lg font-semibold">{{ artistName }} 的介绍</h2>
|
|
||||||
<button @click="showDescModal = false" class="text-content-3 hover:text-content transition">
|
|
||||||
<IconX class="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="p-5 overflow-y-auto text-sm text-content-2 leading-relaxed whitespace-pre-wrap">{{ briefDesc }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Teleport>
|
|
||||||
|
|
||||||
<!-- 内容区:热门歌曲 / 专辑 -->
|
|
||||||
<div class="flex gap-2 mb-6">
|
|
||||||
<button
|
|
||||||
v-for="tab in tabs"
|
|
||||||
:key="tab.key"
|
|
||||||
@click="activeTab = tab.key"
|
|
||||||
class="px-4 py-1.5 rounded-full text-sm transition"
|
|
||||||
:class="activeTab === tab.key ? 'bg-accent text-white' : 'bg-subtle text-content-2 hover:bg-muted'"
|
|
||||||
>
|
|
||||||
{{ tab.label }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 歌曲列表 -->
|
|
||||||
<div v-if="activeTab === 'songs'">
|
|
||||||
<div v-if="songsLoading" class="space-y-1">
|
|
||||||
<div v-for="i in 6" :key="i" class="flex items-center gap-3 px-3 py-2">
|
|
||||||
<div class="w-12 h-12 bg-muted rounded animate-pulse flex-shrink-0"></div>
|
|
||||||
<div class="flex-1 space-y-2">
|
|
||||||
<div class="h-4 bg-muted rounded w-2/3 animate-pulse"></div>
|
|
||||||
<div class="h-3 bg-muted rounded w-1/3 animate-pulse"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<VirtualSongList
|
|
||||||
v-else-if="songs.length"
|
|
||||||
:songs="songs"
|
|
||||||
:current-song-id="player.currentSong?.id"
|
|
||||||
show-index
|
|
||||||
show-like
|
|
||||||
show-download
|
|
||||||
show-menu
|
|
||||||
show-duration
|
|
||||||
show-playing-overlay
|
|
||||||
@song-click="(_s, i) => player.playFromList(songs, i)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 专辑列表 -->
|
<div v-else-if="loadError" class="flex flex-col items-center justify-center py-16 gap-3">
|
||||||
<div v-if="activeTab === 'albums'">
|
<p class="text-content-2 text-sm">加载失败</p>
|
||||||
<div v-if="albumsLoading" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
<button @click="fetchArtist(Number(route.params.id), true)" class="px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition">重试</button>
|
||||||
<div v-for="i in 8" :key="i" class="bg-muted rounded-xl animate-pulse">
|
</div>
|
||||||
<div class="w-full aspect-square"></div>
|
|
||||||
<div class="p-3 space-y-2">
|
<template v-if="!loadError && (artist || songs.length || albums.length)">
|
||||||
<div class="h-4 bg-subtle rounded w-3/4"></div>
|
<!-- 头部行:头像 + 信息(标题/简介/操作按钮 + tab) -->
|
||||||
<div class="h-3 bg-subtle rounded w-1/2"></div>
|
<div class="ar-head-row">
|
||||||
|
<img v-if="artistCover" :src="artistCover" class="ar-cover" />
|
||||||
|
<div v-else class="ar-cover ar-cover-placeholder">
|
||||||
|
<IconMusic class="w-12 h-12 text-content-4" />
|
||||||
|
</div>
|
||||||
|
<div class="ar-info">
|
||||||
|
<div class="ar-info-top">
|
||||||
|
<h1 class="ar-title">{{ artistName }}</h1>
|
||||||
|
<!-- 元信息(紧凑时折叠隐藏,参考 SPlayer n-collapse-transition) -->
|
||||||
|
<div class="ar-collapse">
|
||||||
|
<p v-if="artistFollowers || artist?.musicSize" class="text-xs text-content-3 mt-1">
|
||||||
|
<span v-if="artistFollowers">{{ formatPlayCount(artistFollowers) }} 粉丝</span>
|
||||||
|
<span v-if="artistFollowers && artist?.musicSize"> · </span>
|
||||||
|
<span v-if="artist?.musicSize">{{ artist.musicSize }} 首歌曲</span>
|
||||||
|
</p>
|
||||||
|
<div v-if="briefDesc" class="mt-2">
|
||||||
|
<p
|
||||||
|
ref="descEl"
|
||||||
|
class="text-sm text-content-2 leading-relaxed whitespace-pre-wrap overflow-hidden"
|
||||||
|
style="max-height: 4.5em"
|
||||||
|
>{{ briefDesc }}</p>
|
||||||
|
<button
|
||||||
|
v-if="descOverflow"
|
||||||
|
@click="showDescModal = true"
|
||||||
|
class="inline-flex items-center gap-1 text-xs text-accent-text hover:text-accent-text/80 mt-1.5 px-2 py-0.5 rounded-full bg-accent-text/10 transition"
|
||||||
|
>
|
||||||
|
<IconChevronDown class="w-3 h-3" />
|
||||||
|
查看完整介绍
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作行:播放按钮 + 关注按钮 + tab(始终常驻底部) -->
|
||||||
|
<div class="ar-actions">
|
||||||
|
<button
|
||||||
|
@click="playAll"
|
||||||
|
class="ar-play-btn"
|
||||||
|
>
|
||||||
|
<IconPlay class="w-4 h-4 fill-current" />
|
||||||
|
<span>播放全部</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="toggleFollow"
|
||||||
|
:disabled="followLoading"
|
||||||
|
class="ar-follow-btn"
|
||||||
|
:class="{ 'is-followed': isFollowed }"
|
||||||
|
>
|
||||||
|
{{ isFollowed ? '已关注' : '关注' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 热门歌曲 / 专辑 tab(紧凑态缩小) -->
|
||||||
|
<div class="ar-tabs">
|
||||||
|
<button
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.key"
|
||||||
|
class="ar-tab"
|
||||||
|
:class="{ active: activeTab === tab.key }"
|
||||||
|
@click="activeTab = tab.key"
|
||||||
|
>{{ tab.label }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 内容区(独立滚动):歌曲列表 / 专辑列表 按 tab 切换渲染 -->
|
||||||
|
<template #content>
|
||||||
|
<div class="px-8 pb-8 text-content" v-if="!loadError">
|
||||||
|
<!-- 歌曲 tab:v-if 仅在激活时渲染(无评论需保活,两 tab 均 v-if) -->
|
||||||
|
<div v-if="activeTab === 'songs'">
|
||||||
|
<div v-if="songsLoading" class="space-y-1">
|
||||||
|
<div v-for="i in 6" :key="i" class="flex items-center gap-3 px-3 py-2">
|
||||||
|
<div class="w-12 h-12 bg-muted rounded animate-pulse flex-shrink-0"></div>
|
||||||
|
<div class="flex-1 space-y-2">
|
||||||
|
<div class="h-4 bg-muted rounded w-2/3 animate-pulse"></div>
|
||||||
|
<div class="h-3 bg-muted rounded w-1/3 animate-pulse"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<VirtualSongList
|
||||||
|
v-else-if="songs.length"
|
||||||
|
:songs="songs"
|
||||||
|
:current-song-id="player.currentSong?.id"
|
||||||
|
show-index
|
||||||
|
show-like
|
||||||
|
show-download
|
||||||
|
show-menu
|
||||||
|
show-duration
|
||||||
|
show-playing-overlay
|
||||||
|
@song-click="(_s, i) => player.playFromList(songs, i)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="albums.length" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
|
||||||
<div
|
<!-- 专辑 tab:v-if 仅在激活时渲染 -->
|
||||||
v-for="album in albums"
|
<div v-if="activeTab === 'albums'">
|
||||||
:key="album.id"
|
<div v-if="albumsLoading" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
@click="router.push({ name: 'album', params: { id: album.id } })"
|
<div v-for="i in 8" :key="i" class="bg-muted rounded-xl animate-pulse">
|
||||||
class="bg-subtle rounded-xl overflow-hidden hover:bg-muted transition cursor-pointer"
|
<div class="w-full aspect-square"></div>
|
||||||
>
|
<div class="p-3 space-y-2">
|
||||||
<img :src="album.picUrl" class="w-full aspect-square object-cover" />
|
<div class="h-4 bg-subtle rounded w-3/4"></div>
|
||||||
<div class="p-3">
|
<div class="h-3 bg-subtle rounded w-1/2"></div>
|
||||||
<p class="text-sm font-medium truncate">{{ album.name }}</p>
|
</div>
|
||||||
<p class="text-xs text-content-2 mt-1">{{ formatDate(album.publishTime) }}</p>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="albums.length" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
<div
|
||||||
|
v-for="album in albums"
|
||||||
|
:key="album.id"
|
||||||
|
@click="router.push({ name: 'album', params: { id: album.id } })"
|
||||||
|
class="bg-subtle rounded-xl overflow-hidden hover:bg-muted transition cursor-pointer"
|
||||||
|
>
|
||||||
|
<img :src="album.picUrl" class="w-full aspect-square object-cover" />
|
||||||
|
<div class="p-3">
|
||||||
|
<p class="text-sm font-medium truncate">{{ album.name }}</p>
|
||||||
|
<p class="text-xs text-content-2 mt-1">{{ formatDate(album.publishTime) }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</DetailLayout>
|
||||||
|
|
||||||
|
<!-- 简介弹窗 -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="showDescModal" class="fixed inset-0 z-50 flex items-center justify-center" @click.self="showDescModal = false">
|
||||||
|
<div class="absolute inset-0 bg-black/50" @click="showDescModal = false"></div>
|
||||||
|
<div class="relative bg-surface rounded-2xl shadow-2xl max-w-lg w-full mx-4 max-h-[70vh] flex flex-col">
|
||||||
|
<div class="flex items-center justify-between p-5 border-b border-line-2">
|
||||||
|
<h2 class="text-lg font-semibold">{{ artistName }} 的介绍</h2>
|
||||||
|
<button @click="showDescModal = false" class="text-content-3 hover:text-content transition">
|
||||||
|
<IconX class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="p-5 overflow-y-auto text-sm text-content-2 leading-relaxed whitespace-pre-wrap">{{ briefDesc }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, watch, onActivated, nextTick } from 'vue';
|
import { ref, computed, onMounted, watch, onActivated } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { MusicApi } from '../api';
|
import { MusicApi } from '../api';
|
||||||
import { usePlayerStore } from '../stores/player';
|
import { usePlayerStore } from '../stores/player';
|
||||||
import { formatPlayCount, formatDate } from '../utils/format';
|
import { formatPlayCount, formatDate } from '../utils/format';
|
||||||
|
import { checkOverflow } from '../utils/dom';
|
||||||
import { normalizeSong, type Song } from '../utils/song';
|
import { normalizeSong, type Song } from '../utils/song';
|
||||||
import { pageCacheGet, pageCacheSet } from '../composables/usePageCache';
|
import { pageCacheGet, pageCacheSet } from '../composables/usePageCache';
|
||||||
import VirtualSongList from '../components/VirtualSongList.vue';
|
import VirtualSongList from '../components/VirtualSongList.vue';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
|
import DetailLayout from '../components/DetailLayout.vue';
|
||||||
import IconPlay from '~icons/lucide/play';
|
import IconPlay from '~icons/lucide/play';
|
||||||
import IconMusic from '~icons/lucide/music';
|
import IconMusic from '~icons/lucide/music';
|
||||||
import IconX from '~icons/lucide/x';
|
import IconX from '~icons/lucide/x';
|
||||||
@@ -186,16 +200,18 @@ const briefDesc = ref('');
|
|||||||
const loadError = ref(false);
|
const loadError = ref(false);
|
||||||
const songsLoading = ref(false);
|
const songsLoading = ref(false);
|
||||||
const albumsLoading = ref(false);
|
const albumsLoading = ref(false);
|
||||||
const activeTab = ref('songs');
|
const activeTab = ref<'songs' | 'albums'>('songs');
|
||||||
const showDescModal = ref(false);
|
const showDescModal = ref(false);
|
||||||
const descOverflow = ref(false);
|
const descOverflow = ref(false);
|
||||||
const descEl = ref<HTMLElement | null>(null);
|
const descEl = ref<HTMLElement | null>(null);
|
||||||
const isFollowed = ref(false);
|
const isFollowed = ref(false);
|
||||||
const followLoading = ref(false);
|
const followLoading = ref(false);
|
||||||
|
// DetailLayout 实例引用:用于切换歌手时重置滚动 & 紧凑态
|
||||||
|
const layoutRef = ref<InstanceType<typeof DetailLayout> | null>(null);
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ key: 'songs', label: '热门歌曲' },
|
{ key: 'songs' as const, label: '热门歌曲' },
|
||||||
{ key: 'albums', label: '专辑' },
|
{ key: 'albums' as const, label: '专辑' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const artistName = computed(() => {
|
const artistName = computed(() => {
|
||||||
@@ -218,15 +234,14 @@ const artistFollowers = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function checkDescOverflow() {
|
function checkDescOverflow() {
|
||||||
nextTick(() => {
|
checkOverflow(descEl, descOverflow);
|
||||||
if (descEl.value) {
|
|
||||||
descOverflow.value = descEl.value.scrollHeight > descEl.value.clientHeight + 2;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchArtist(id: number, force = false) {
|
async function fetchArtist(id: number, force = false) {
|
||||||
const cacheKey = `artist_${id}`;
|
const cacheKey = `artist_${id}`;
|
||||||
|
// 切换歌手时重置 tab 与滚动状态(参考 SPlayer watch detailData.id)
|
||||||
|
activeTab.value = 'songs';
|
||||||
|
layoutRef.value?.resetScroll();
|
||||||
if (!force) {
|
if (!force) {
|
||||||
const cached = pageCacheGet(cacheKey);
|
const cached = pageCacheGet(cacheKey);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
@@ -338,3 +353,216 @@ async function toggleFollow() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 头部容器:固定 padding,紧凑态收窄 padding 让出空间给列表
|
||||||
|
参考 SPlayer .detail height 240→120 过渡 */
|
||||||
|
.ar-header {
|
||||||
|
padding: 12px 32px 24px 32px;
|
||||||
|
transition: padding 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
.ar-header.is-compact {
|
||||||
|
padding: 12px 32px 12px 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 头部行:头像 + 信息横向排列 */
|
||||||
|
.ar-head-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 头像:圆形,176→96 紧凑过渡(参考 SPlayer .cover height:100% of 240/120) */
|
||||||
|
.ar-cover {
|
||||||
|
width: 176px;
|
||||||
|
height: 176px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition:
|
||||||
|
width 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
height 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
.ar-cover.cover-skeleton {
|
||||||
|
background-color: var(--c-muted);
|
||||||
|
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
|
}
|
||||||
|
.ar-cover-placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: var(--c-muted);
|
||||||
|
}
|
||||||
|
.ar-header.is-compact .ar-cover {
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 信息列:纵向占满,顶部标题+折叠区,底部操作行 */
|
||||||
|
.ar-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.ar-info-top {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标题:24→18 紧凑过渡(参考 SPlayer .name 30→22)
|
||||||
|
固定 line-height + min-height 防止字号变化时抖动 */
|
||||||
|
.ar-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1.3;
|
||||||
|
min-height: 32px;
|
||||||
|
transition: font-size 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
.ar-header.is-compact .ar-title {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 元信息折叠区:紧凑时 max-height:0 + opacity:0 隐藏(参考 SPlayer n-collapse-transition) */
|
||||||
|
.ar-collapse {
|
||||||
|
max-height: 200px;
|
||||||
|
opacity: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
transition:
|
||||||
|
max-height 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
.ar-header.is-compact .ar-collapse {
|
||||||
|
max-height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 操作行:播放按钮 + 关注 + tab,紧凑态缩小 */
|
||||||
|
.ar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.ar-play-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 20px;
|
||||||
|
background-color: var(--c-accent);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 9999px;
|
||||||
|
transition:
|
||||||
|
background-color 0.2s ease,
|
||||||
|
padding 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
font-size 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
.ar-play-btn:hover {
|
||||||
|
background-color: var(--c-accent-hover);
|
||||||
|
}
|
||||||
|
.ar-follow-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background-color: var(--c-accent-dim);
|
||||||
|
color: var(--c-accent-text);
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 14px;
|
||||||
|
transition:
|
||||||
|
background-color 0.2s ease,
|
||||||
|
padding 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
font-size 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
.ar-follow-btn.is-followed {
|
||||||
|
background-color: var(--c-subtle);
|
||||||
|
color: var(--c-content-2);
|
||||||
|
}
|
||||||
|
.ar-follow-btn:not(.is-followed):hover {
|
||||||
|
background-color: var(--c-emphasis);
|
||||||
|
}
|
||||||
|
.ar-follow-btn.is-followed:hover {
|
||||||
|
background-color: var(--c-muted);
|
||||||
|
}
|
||||||
|
.ar-header.is-compact .ar-play-btn,
|
||||||
|
.ar-header.is-compact .ar-follow-btn {
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 热门歌曲/专辑 tab:胶囊形,紧凑态缩小 */
|
||||||
|
.ar-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: auto;
|
||||||
|
background-color: var(--c-subtle);
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
transition: padding 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
.ar-tab {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--c-content-2);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 9999px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color 0.2s ease,
|
||||||
|
color 0.2s ease,
|
||||||
|
padding 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
font-size 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
.ar-tab.active {
|
||||||
|
background-color: var(--c-accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.ar-tab:not(.active):hover {
|
||||||
|
color: var(--c-content);
|
||||||
|
}
|
||||||
|
.ar-header.is-compact .ar-tab {
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 窄屏适配(参考 SPlayer @media) */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.ar-header {
|
||||||
|
padding: 12px 16px 20px 16px;
|
||||||
|
}
|
||||||
|
.ar-header.is-compact {
|
||||||
|
padding: 12px 16px 12px 16px;
|
||||||
|
}
|
||||||
|
.ar-head-row {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.ar-cover {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
}
|
||||||
|
.ar-header.is-compact .ar-cover {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
}
|
||||||
|
.ar-title {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.ar-header.is-compact .ar-title {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
/* 窄屏隐藏 tab 文字以节省空间(仅活跃 tab 显示) */
|
||||||
|
.ar-tabs {
|
||||||
|
gap: 2px;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
.ar-tab {
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -322,7 +322,7 @@ async function fetchCloud(offset = 0, append = false) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error('获取云盘音乐失败', e);
|
||||||
if (!append) loadError.value = true;
|
if (!append) loadError.value = true;
|
||||||
else showToast('加载更多失败', 'error');
|
else showToast('加载更多失败', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ async function loadData(force = false) {
|
|||||||
songs.value = (data.data?.dailySongs || []).map(normalizeSong);
|
songs.value = (data.data?.dailySongs || []).map(normalizeSong);
|
||||||
pageCacheSet('dailySongs', songs.value);
|
pageCacheSet('dailySongs', songs.value);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error('获取每日推荐失败', e);
|
||||||
loadError.value = true;
|
loadError.value = true;
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ async function refresh() {
|
|||||||
pageCacheSet('downloadedMusic', list);
|
pageCacheSet('downloadedMusic', list);
|
||||||
fetchMissingCovers(songs.value);
|
fetchMissingCovers(songs.value);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error('加载下载音乐列表失败', e);
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ async function loadData(force = false) {
|
|||||||
songs.value = (trackData.songs || []).map(normalizeSong);
|
songs.value = (trackData.songs || []).map(normalizeSong);
|
||||||
pageCacheSet('favoriteSongs', songs.value);
|
pageCacheSet('favoriteSongs', songs.value);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error('加载我喜欢的音乐失败', e);
|
||||||
loadError.value = true;
|
loadError.value = true;
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
|
|||||||
+345
-117
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="p-8 text-content">
|
<div class="p-8 text-content">
|
||||||
<!-- 第一行:每日推荐 & 私人漫游 卡片 -->
|
<!-- 第一行:每日推荐 & 私人漫游 卡片 -->
|
||||||
<div class="grid grid-cols-2 gap-6 mb-10">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-10">
|
||||||
<!-- 每日推荐 -->
|
<!-- 每日推荐 -->
|
||||||
<div
|
<div
|
||||||
class="h-48 bg-gradient-to-br from-pink-600 to-purple-700 rounded-3xl overflow-hidden relative cursor-pointer group"
|
class="h-48 bg-gradient-to-br from-pink-600 to-purple-700 rounded-3xl overflow-hidden relative cursor-pointer group"
|
||||||
@@ -67,127 +67,214 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 第二行:为你推荐(需登录) -->
|
<!-- 推荐歌单(个性化,无需登录也可获取) -->
|
||||||
<div v-if="userStore.isLoggedIn" class="mb-10">
|
<section class="mb-10">
|
||||||
<h2 class="text-xl font-semibold mb-4">🎯 为你推荐</h2>
|
<SectionHeader
|
||||||
|
:title="userStore.isLoggedIn ? '🎯 为你推荐' : '🎵 推荐歌单'"
|
||||||
|
:subtitle="userStore.isLoggedIn ? '根据你的口味生成' : '精选好歌单'"
|
||||||
|
:icon="IconSparkles"
|
||||||
|
/>
|
||||||
|
<CardGrid
|
||||||
|
:items="recPlaylists"
|
||||||
|
:loading="recLoading"
|
||||||
|
:error="recError"
|
||||||
|
error-text="推荐歌单加载失败"
|
||||||
|
@retry="fetchRecPlaylists"
|
||||||
|
>
|
||||||
|
<PlaylistCard
|
||||||
|
v-for="pl in recPlaylists"
|
||||||
|
:key="pl.id"
|
||||||
|
:playlist="pl"
|
||||||
|
:is-current-playing="player.currentSource.type === 'playlist' && player.currentSource.id === pl.id && player.playing"
|
||||||
|
@click="goPlaylist(pl.id)"
|
||||||
|
@play="playPlaylist(pl)"
|
||||||
|
/>
|
||||||
|
</CardGrid>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- 加载中:骨架屏 -->
|
<!-- 新歌速递 -->
|
||||||
<div v-if="recLoading && !recPlaylists.length" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
|
<section class="mb-10">
|
||||||
<div v-for="i in 6" :key="'skel-'+i" class="bg-subtle rounded-xl overflow-hidden max-w-[220px] justify-self-center w-full animate-pulse">
|
<SectionHeader
|
||||||
<div class="w-full aspect-square bg-muted"></div>
|
:title="userStore.isLoggedIn ? '🆕 推荐新歌' : '🆕 新歌速递'"
|
||||||
<div class="p-3 space-y-2">
|
:subtitle="userStore.isLoggedIn ? '根据你的口味推荐' : '发现最新音乐'"
|
||||||
<div class="h-4 bg-muted rounded w-3/4"></div>
|
:icon="IconTrendingUp"
|
||||||
<div class="h-3 bg-muted rounded w-1/2"></div>
|
/>
|
||||||
|
<CardGrid
|
||||||
|
:items="newSongs"
|
||||||
|
:loading="newSongsLoading"
|
||||||
|
:error="newSongsError"
|
||||||
|
error-text="新歌加载失败"
|
||||||
|
:skeleton-count="4"
|
||||||
|
grid-class="grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||||
|
@retry="fetchNewSongs"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(song, index) in newSongs"
|
||||||
|
:key="song.id"
|
||||||
|
@click="playNewSong(index)"
|
||||||
|
class="flex items-center gap-3 p-3 rounded-xl bg-subtle hover:bg-muted transition cursor-pointer group"
|
||||||
|
>
|
||||||
|
<div class="w-12 h-12 rounded-md overflow-hidden flex-shrink-0 relative">
|
||||||
|
<img
|
||||||
|
v-if="song.al?.picUrl"
|
||||||
|
:src="song.al.picUrl"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div v-else class="w-full h-full bg-muted flex items-center justify-center">
|
||||||
|
<IconMusic class="w-4 h-4 text-content-4" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="flex-1 min-w-0">
|
||||||
</div>
|
<p class="text-sm font-medium truncate text-content">{{ song.name }}</p>
|
||||||
|
<p class="text-xs text-content-2 truncate">{{ getArtistDisplay(song) }}</p>
|
||||||
<!-- 加载失败 -->
|
|
||||||
<div v-else-if="recError && !recPlaylists.length" class="flex flex-col items-center justify-center py-12 gap-3">
|
|
||||||
<p class="text-content-2 text-sm">推荐加载失败</p>
|
|
||||||
<button @click="fetchRecPlaylists"
|
|
||||||
class="px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition">
|
|
||||||
重试
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 正常内容 -->
|
|
||||||
<div v-else-if="recPlaylists.length" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
|
|
||||||
<div v-for="pl in recPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
|
|
||||||
class="bg-subtle rounded-xl overflow-hidden hover:bg-muted transition cursor-pointer max-w-[220px] justify-self-center w-full">
|
|
||||||
<img :src="pl.picUrl" class="w-full aspect-square object-cover" />
|
|
||||||
<div class="p-3">
|
|
||||||
<p class="text-sm font-medium truncate">{{ pl.name }}</p>
|
|
||||||
<p class="text-xs text-content-2 mt-1 truncate">{{ pl.copywriter || pl.description || '' }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
class="w-8 h-8 flex items-center justify-center rounded-full bg-muted/50 text-content-2 group-hover:bg-accent group-hover:text-white transition flex-shrink-0"
|
||||||
|
:title="isCurrentNewSong(song) ? (player.playing ? '暂停' : '播放') : '播放'"
|
||||||
|
>
|
||||||
|
<IconPause v-if="isCurrentNewSong(song) && player.playing" class="w-3.5 h-3.5 fill-current" />
|
||||||
|
<IconPlay v-else class="w-3.5 h-3.5 fill-current ml-0.5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardGrid>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<!-- 第三行:热门歌单(排行榜) -->
|
<!-- 热门歌手 -->
|
||||||
<div>
|
<section class="mb-10">
|
||||||
<h2 class="text-xl font-semibold mb-4">📈 热门歌单</h2>
|
<SectionHeader title="🎤 热门歌手" subtitle="人气音乐人" :icon="IconFlame" />
|
||||||
|
<CardGrid
|
||||||
|
:items="topArtists"
|
||||||
|
:loading="artistsLoading"
|
||||||
|
:error="artistsError"
|
||||||
|
error-text="热门歌手加载失败"
|
||||||
|
:skeleton-count="6"
|
||||||
|
grid-class="grid-cols-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8"
|
||||||
|
@retry="fetchTopArtists"
|
||||||
|
>
|
||||||
|
<ArtistCard
|
||||||
|
v-for="ar in topArtists"
|
||||||
|
:key="ar.id"
|
||||||
|
:artist="ar"
|
||||||
|
@click="goArtist(ar.id)"
|
||||||
|
/>
|
||||||
|
</CardGrid>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- 加载中:骨架屏 -->
|
<!-- 新碟上架 -->
|
||||||
<div v-if="rankLoading && !rankPlaylists.length" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
|
<section class="mb-10">
|
||||||
<div v-for="i in 4" :key="'rskel-'+i" class="bg-subtle rounded-xl overflow-hidden max-w-[220px] justify-self-center w-full animate-pulse">
|
<SectionHeader title="💿 新碟上架" subtitle="最新专辑" :icon="IconDisc" />
|
||||||
<div class="w-full aspect-square bg-muted"></div>
|
<CardGrid
|
||||||
<div class="p-3 space-y-2">
|
:items="newAlbums"
|
||||||
<div class="h-4 bg-muted rounded w-3/4"></div>
|
:loading="albumsLoading"
|
||||||
</div>
|
:error="albumsError"
|
||||||
</div>
|
error-text="新碟上架加载失败"
|
||||||
</div>
|
@retry="fetchNewAlbums"
|
||||||
|
>
|
||||||
|
<AlbumCard
|
||||||
|
v-for="al in newAlbums"
|
||||||
|
:key="al.id"
|
||||||
|
:album="al"
|
||||||
|
:is-current-playing="player.currentSource.type === 'album' && player.currentSource.id === al.id && player.playing"
|
||||||
|
@click="goAlbum(al.id)"
|
||||||
|
@play="playAlbum(al)"
|
||||||
|
/>
|
||||||
|
</CardGrid>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- 加载失败 -->
|
<!-- 热门歌单(排行榜) -->
|
||||||
<div v-else-if="rankError && !rankPlaylists.length" class="flex flex-col items-center justify-center py-12 gap-3">
|
<section>
|
||||||
<p class="text-content-2 text-sm">热门歌单加载失败</p>
|
<SectionHeader title="📈 热门歌单" subtitle="排行榜精选" :icon="IconTrendingUp" />
|
||||||
<button @click="fetchRankPlaylists"
|
<CardGrid
|
||||||
class="px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition">
|
:items="rankPlaylists"
|
||||||
重试
|
:loading="rankLoading"
|
||||||
</button>
|
:error="rankError"
|
||||||
</div>
|
error-text="热门歌单加载失败"
|
||||||
|
@retry="fetchRankPlaylists"
|
||||||
<!-- 正常内容 -->
|
>
|
||||||
<div v-else-if="rankPlaylists.length" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
|
<PlaylistCard
|
||||||
<div v-for="pl in rankPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
|
v-for="pl in rankPlaylists"
|
||||||
class="bg-subtle rounded-xl overflow-hidden hover:bg-muted transition cursor-pointer backdrop-blur-sm max-w-[220px] justify-self-center w-full">
|
:key="pl.id"
|
||||||
<img :src="pl.coverImgUrl" class="w-full aspect-square object-cover" />
|
:playlist="pl"
|
||||||
<div class="p-3">
|
:is-current-playing="player.currentSource.type === 'playlist' && player.currentSource.id === pl.id && player.playing"
|
||||||
<p class="text-sm font-medium truncate">{{ pl.name }}</p>
|
@click="goPlaylist(pl.id)"
|
||||||
<p v-if="pl.description || pl.copywriter" class="text-xs text-content-2 mt-1 truncate">{{ pl.description || pl.copywriter }}</p>
|
@play="playPlaylist(pl)"
|
||||||
</div>
|
/>
|
||||||
</div>
|
</CardGrid>
|
||||||
</div>
|
</section>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onActivated, watch } from 'vue';
|
import { ref, onMounted, onActivated, watch, computed } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { MusicApi } from '../api';
|
import { MusicApi, RecApi } from '../api';
|
||||||
import { useUserStore } from '../stores/user';
|
import { useUserStore } from '../stores/user';
|
||||||
import { usePlayerStore } from '../stores/player';
|
import { usePlayerStore } from '../stores/player';
|
||||||
|
import { useUiStore } from '../stores/ui';
|
||||||
import { pageCacheGet, pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
|
import { pageCacheGet, pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
|
||||||
import { useOnlineStatus } from '../composables/useOnlineStatus';
|
import { useOnlineStatus } from '../composables/useOnlineStatus';
|
||||||
import { getCoverUrl } from '../utils/song';
|
import { getCoverUrl, getArtistDisplay, normalizeSong, type Song } from '../utils/song';
|
||||||
|
import PlaylistCard, { type PlaylistCardData } from '../components/Card/PlaylistCard.vue';
|
||||||
defineOptions({ name: 'HomeView' });
|
import ArtistCard, { type ArtistCardData } from '../components/Card/ArtistCard.vue';
|
||||||
|
import AlbumCard, { type AlbumCardData } from '../components/Card/AlbumCard.vue';
|
||||||
const player = usePlayerStore();
|
import SectionHeader from '../components/Card/SectionHeader.vue';
|
||||||
const router = useRouter();
|
import CardGrid from '../components/Card/CardGrid.vue';
|
||||||
const userStore = useUserStore();
|
|
||||||
const { isOnline } = useOnlineStatus();
|
|
||||||
|
|
||||||
const rankPlaylists = ref<any[]>([]);
|
|
||||||
const rankLoading = ref(false);
|
|
||||||
const rankError = ref(false);
|
|
||||||
const recPlaylists = ref<any[]>([]);
|
|
||||||
const recLoading = ref(false);
|
|
||||||
const recError = ref(false);
|
|
||||||
const todayStr = ref('');
|
|
||||||
const RANK_IDS = [3778678, 3779629, 19723756, 2884035];
|
|
||||||
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import IconRadio from '~icons/lucide/radio';
|
import IconRadio from '~icons/lucide/radio';
|
||||||
import IconPlay from '~icons/lucide/play';
|
import IconPlay from '~icons/lucide/play';
|
||||||
import IconPause from '~icons/lucide/pause';
|
import IconPause from '~icons/lucide/pause';
|
||||||
import IconSkipForward from '~icons/lucide/skip-forward';
|
import IconSkipForward from '~icons/lucide/skip-forward';
|
||||||
|
import IconMusic from '~icons/lucide/music';
|
||||||
|
import IconSparkles from '~icons/lucide/sparkles';
|
||||||
|
import IconTrendingUp from '~icons/lucide/trending-up';
|
||||||
|
import IconFlame from '~icons/lucide/flame';
|
||||||
|
import IconDisc from '~icons/lucide/disc-3';
|
||||||
|
|
||||||
|
defineOptions({ name: 'HomeView' });
|
||||||
|
|
||||||
const fmCoverUrl = computed(() => {
|
const player = usePlayerStore();
|
||||||
return getCoverUrl(player.fmSong) || '';
|
const ui = useUiStore();
|
||||||
});
|
const router = useRouter();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const { isOnline } = useOnlineStatus();
|
||||||
|
|
||||||
|
// --- 推荐歌单 ---
|
||||||
|
const recPlaylists = ref<PlaylistCardData[]>([]);
|
||||||
|
const recLoading = ref(false);
|
||||||
|
const recError = ref(false);
|
||||||
|
|
||||||
|
// --- 热门歌单(排行榜) ---
|
||||||
|
const rankPlaylists = ref<PlaylistCardData[]>([]);
|
||||||
|
const rankLoading = ref(false);
|
||||||
|
const rankError = ref(false);
|
||||||
|
const RANK_IDS = [3778678, 3779629, 19723756, 2884035];
|
||||||
|
|
||||||
|
// --- 新歌速递 ---
|
||||||
|
const newSongs = ref<Song[]>([]);
|
||||||
|
const newSongsLoading = ref(false);
|
||||||
|
const newSongsError = ref(false);
|
||||||
|
|
||||||
|
// --- 热门歌手 ---
|
||||||
|
const topArtists = ref<ArtistCardData[]>([]);
|
||||||
|
const artistsLoading = ref(false);
|
||||||
|
const artistsError = ref(false);
|
||||||
|
|
||||||
|
// --- 新碟上架 ---
|
||||||
|
const newAlbums = ref<AlbumCardData[]>([]);
|
||||||
|
const albumsLoading = ref(false);
|
||||||
|
const albumsError = ref(false);
|
||||||
|
|
||||||
|
const todayStr = ref('');
|
||||||
|
|
||||||
|
const fmCoverUrl = computed(() => getCoverUrl(player.fmSong) || '');
|
||||||
const fmDisplayName = computed(() => player.fmSong?.name || '私人漫游');
|
const fmDisplayName = computed(() => player.fmSong?.name || '私人漫游');
|
||||||
const fmDisplayArtists = computed(() => {
|
const fmDisplayArtists = computed(() => {
|
||||||
if (!player.fmSong) return '';
|
if (!player.fmSong) return '';
|
||||||
return player.fmSong.ar?.map((a: { name: string }) => a.name).join(' / ') || '';
|
return player.fmSong.ar?.map((a: { name: string }) => a.name).join(' / ') || '';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
async function startFmPlay() {
|
async function startFmPlay() {
|
||||||
if (!player.fmSong) {
|
if (!player.fmSong) {
|
||||||
await player.loadFm();
|
await player.loadFm();
|
||||||
@@ -205,9 +292,10 @@ function onFmCardClick() {
|
|||||||
startFmPlay();
|
startFmPlay();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
player.openRoamDrawer();
|
ui.openRoamDrawer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 数据获取 ---
|
||||||
async function fetchRankPlaylists() {
|
async function fetchRankPlaylists() {
|
||||||
const cacheKey = 'home_rank';
|
const cacheKey = 'home_rank';
|
||||||
const cached = pageCacheGet(cacheKey);
|
const cached = pageCacheGet(cacheKey);
|
||||||
@@ -237,7 +325,6 @@ async function fetchRankPlaylists() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchRecPlaylists() {
|
async function fetchRecPlaylists() {
|
||||||
if (!userStore.isLoggedIn) return;
|
|
||||||
const cacheKey = 'home_rec';
|
const cacheKey = 'home_rec';
|
||||||
const cached = pageCacheGet(cacheKey);
|
const cached = pageCacheGet(cacheKey);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
@@ -247,9 +334,16 @@ async function fetchRecPlaylists() {
|
|||||||
recLoading.value = true;
|
recLoading.value = true;
|
||||||
recError.value = false;
|
recError.value = false;
|
||||||
try {
|
try {
|
||||||
const json = await MusicApi.recommendResource();
|
// 已登录用 recommend_resource(更精准),未登录用 personalized
|
||||||
const data = JSON.parse(json as string);
|
if (userStore.isLoggedIn) {
|
||||||
recPlaylists.value = data.recommend || [];
|
const json = await MusicApi.recommendResource();
|
||||||
|
const data = JSON.parse(json as string);
|
||||||
|
recPlaylists.value = (data.recommend || []).slice(0, 12);
|
||||||
|
} else {
|
||||||
|
const json = await RecApi.personalized(12);
|
||||||
|
const data = JSON.parse(json as string);
|
||||||
|
recPlaylists.value = (data.result || []).slice(0, 12);
|
||||||
|
}
|
||||||
pageCacheSet(cacheKey, recPlaylists.value);
|
pageCacheSet(cacheKey, recPlaylists.value);
|
||||||
} catch {
|
} catch {
|
||||||
recError.value = true;
|
recError.value = true;
|
||||||
@@ -258,16 +352,88 @@ async function fetchRecPlaylists() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadData() {
|
async function fetchNewSongs() {
|
||||||
const cached = pageCacheGet('home');
|
const cacheKey = 'home_new_songs';
|
||||||
|
const cached = pageCacheGet(cacheKey);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
rankPlaylists.value = cached.rankPlaylists || [];
|
newSongs.value = cached;
|
||||||
recPlaylists.value = cached.recPlaylists || [];
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
newSongsLoading.value = true;
|
||||||
|
newSongsError.value = false;
|
||||||
|
try {
|
||||||
|
let list: Song[];
|
||||||
|
if (userStore.isLoggedIn) {
|
||||||
|
// 已登录:用 personalized_newsong(基于账号口味推荐)
|
||||||
|
const json = await RecApi.personalizedNewsong(8);
|
||||||
|
const data = JSON.parse(json as string);
|
||||||
|
list = (data.result || []).slice(0, 8).map((item: any) => normalizeSong(item.song || item));
|
||||||
|
} else {
|
||||||
|
// 未登录:用 top_song(全站新歌速递)
|
||||||
|
const json = await RecApi.topSong(0);
|
||||||
|
const data = JSON.parse(json as string);
|
||||||
|
list = (data.data || []).slice(0, 8).map((s: any) => normalizeSong(s));
|
||||||
|
}
|
||||||
|
newSongs.value = list;
|
||||||
|
pageCacheSet(cacheKey, list);
|
||||||
|
} catch {
|
||||||
|
newSongsError.value = true;
|
||||||
|
} finally {
|
||||||
|
newSongsLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fetchRankPlaylists();
|
async function fetchTopArtists() {
|
||||||
fetchRecPlaylists();
|
const cacheKey = 'home_top_artists';
|
||||||
|
const cached = pageCacheGet(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
topArtists.value = cached;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
artistsLoading.value = true;
|
||||||
|
artistsError.value = false;
|
||||||
|
try {
|
||||||
|
const json = await RecApi.topArtists(8, 0);
|
||||||
|
const data = JSON.parse(json as string);
|
||||||
|
topArtists.value = (data.artists || []).slice(0, 8);
|
||||||
|
pageCacheSet(cacheKey, topArtists.value);
|
||||||
|
} catch {
|
||||||
|
artistsError.value = true;
|
||||||
|
} finally {
|
||||||
|
artistsLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchNewAlbums() {
|
||||||
|
const cacheKey = 'home_new_albums';
|
||||||
|
const cached = pageCacheGet(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
newAlbums.value = cached;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
albumsLoading.value = true;
|
||||||
|
albumsError.value = false;
|
||||||
|
try {
|
||||||
|
const json = await RecApi.albumNewest();
|
||||||
|
const data = JSON.parse(json as string);
|
||||||
|
newAlbums.value = (data.albums || []).slice(0, 12);
|
||||||
|
pageCacheSet(cacheKey, newAlbums.value);
|
||||||
|
} catch {
|
||||||
|
albumsError.value = true;
|
||||||
|
} finally {
|
||||||
|
albumsLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
// 并行拉取所有区块(各自带独立缓存)
|
||||||
|
await Promise.allSettled([
|
||||||
|
fetchRankPlaylists(),
|
||||||
|
fetchRecPlaylists(),
|
||||||
|
fetchNewSongs(),
|
||||||
|
fetchTopArtists(),
|
||||||
|
fetchNewAlbums(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -277,29 +443,37 @@ onMounted(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
onActivated(() => {
|
onActivated(() => {
|
||||||
if (pageCacheIsStale('home')) loadData();
|
if (pageCacheIsStale('home_rec') || pageCacheIsStale('home_new_songs')) loadData();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 登录状态变化时清除首页缓存并重新加载(推荐内容依赖登录态)
|
||||||
|
watch(() => userStore.isLoggedIn, (val, old) => {
|
||||||
|
if (val !== old) {
|
||||||
|
['home_rec', 'home_new_songs', 'home_rank', 'home_top_artists', 'home_new_albums']
|
||||||
|
.forEach(k => pageCacheInvalidate(k));
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(isOnline, (val, old) => {
|
watch(isOnline, (val, old) => {
|
||||||
if (val && !old) {
|
if (val && !old) {
|
||||||
if (rankPlaylists.value.length === 0 && recPlaylists.value.length === 0) {
|
const hasAny = rankPlaylists.value.length || recPlaylists.value.length
|
||||||
pageCacheInvalidate('home');
|
|| newSongs.value.length || topArtists.value.length || newAlbums.value.length;
|
||||||
pageCacheInvalidate('home_rank');
|
if (!hasAny) {
|
||||||
pageCacheInvalidate('home_rec');
|
['home_rank', 'home_rec', 'home_new_songs', 'home_top_artists', 'home_new_albums']
|
||||||
|
.forEach(k => pageCacheInvalidate(k));
|
||||||
loadData();
|
loadData();
|
||||||
} else {
|
} else {
|
||||||
if (rankError.value) {
|
if (rankError.value) { pageCacheInvalidate('home_rank'); fetchRankPlaylists(); }
|
||||||
pageCacheInvalidate('home_rank');
|
if (recError.value) { pageCacheInvalidate('home_rec'); fetchRecPlaylists(); }
|
||||||
fetchRankPlaylists();
|
if (newSongsError.value) { pageCacheInvalidate('home_new_songs'); fetchNewSongs(); }
|
||||||
}
|
if (artistsError.value) { pageCacheInvalidate('home_top_artists'); fetchTopArtists(); }
|
||||||
if (recError.value) {
|
if (albumsError.value) { pageCacheInvalidate('home_new_albums'); fetchNewAlbums(); }
|
||||||
pageCacheInvalidate('home_rec');
|
|
||||||
fetchRecPlaylists();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- 跳转 ---
|
||||||
function goDaily() {
|
function goDaily() {
|
||||||
router.push('/daily');
|
router.push('/daily');
|
||||||
}
|
}
|
||||||
@@ -308,7 +482,61 @@ function goPlaylist(id: number) {
|
|||||||
router.push({ name: 'playlist', params: { id } });
|
router.push({ name: 'playlist', params: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goArtist(id: number) {
|
||||||
|
router.push({ name: 'artist', params: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function goAlbum(id: number) {
|
||||||
|
router.push({ name: 'album', params: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
function goLogin() {
|
function goLogin() {
|
||||||
router.push('/login');
|
router.push('/login');
|
||||||
}
|
}
|
||||||
</script>
|
|
||||||
|
// --- 播放操作 ---
|
||||||
|
async function playPlaylist(pl: PlaylistCardData) {
|
||||||
|
try {
|
||||||
|
// 如果正在播放这个歌单:切换播放/暂停
|
||||||
|
if (player.currentSource.type === 'playlist' && player.currentSource.id === pl.id) {
|
||||||
|
player.toggle();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const json = await MusicApi.playlistTrackAll(pl.id);
|
||||||
|
const data = JSON.parse(json as string);
|
||||||
|
const songs: Song[] = (data.songs || []).map((s: any) => normalizeSong(s));
|
||||||
|
if (songs.length > 0) {
|
||||||
|
player.playAll(songs, { type: 'playlist', id: pl.id });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('播放歌单失败:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playAlbum(al: AlbumCardData) {
|
||||||
|
try {
|
||||||
|
// 如果正在播放这个专辑:切换播放/暂停
|
||||||
|
if (player.currentSource.type === 'album' && player.currentSource.id === al.id) {
|
||||||
|
player.toggle();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const json = await MusicApi.albumDetail(al.id);
|
||||||
|
const data = JSON.parse(json as string);
|
||||||
|
const songs: Song[] = (data.songs || []).map((s: any) => normalizeSong(s));
|
||||||
|
if (songs.length > 0) {
|
||||||
|
player.playAll(songs, { type: 'album', id: al.id });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('播放专辑失败:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function playNewSong(index: number) {
|
||||||
|
if (newSongs.value.length === 0) return;
|
||||||
|
player.playFromList(newSongs.value, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCurrentNewSong(song: Song): boolean {
|
||||||
|
return player.currentSong?.id === song.id;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ async function refresh() {
|
|||||||
pageCacheSet('localMusic', list);
|
pageCacheSet('localMusic', list);
|
||||||
fetchMissingCovers(songs.value);
|
fetchMissingCovers(songs.value);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error('扫描本地音乐文件夹失败', e);
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
+327
-118
@@ -1,139 +1,177 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="p-8 text-content">
|
<DetailLayout ref="layoutRef">
|
||||||
<PageHeader />
|
<!-- 头部(常驻):滚动时收缩为紧凑态(封面缩小、标题缩小、介绍隐藏) -->
|
||||||
|
<template #header="{ compact }">
|
||||||
|
<div class="pl-header" :class="{ 'is-compact': compact }">
|
||||||
|
<PageHeader />
|
||||||
|
|
||||||
<!-- 头部骨架 -->
|
<!-- 头部骨架 -->
|
||||||
<div v-if="!playlist && playlistLoading" class="flex gap-6 mb-8">
|
<div v-if="!playlist && playlistLoading" class="pl-head-row">
|
||||||
<div class="w-44 h-44 rounded-xl bg-muted animate-pulse flex-shrink-0"></div>
|
<div class="pl-cover cover-skeleton"></div>
|
||||||
<div class="flex-1 space-y-3">
|
<div class="flex-1 space-y-3">
|
||||||
<div class="h-7 bg-muted rounded w-1/2 animate-pulse"></div>
|
<div class="h-7 bg-muted rounded w-1/2 animate-pulse"></div>
|
||||||
<div class="h-4 bg-muted rounded w-1/3 animate-pulse"></div>
|
<div class="h-4 bg-muted rounded w-1/3 animate-pulse"></div>
|
||||||
<div class="h-4 bg-muted rounded w-2/3 animate-pulse"></div>
|
<div class="h-4 bg-muted rounded w-2/3 animate-pulse"></div>
|
||||||
<div class="flex gap-3 mt-4">
|
<div class="flex gap-3 mt-4">
|
||||||
<div class="h-10 w-28 bg-muted rounded-full animate-pulse"></div>
|
<div class="h-10 w-28 bg-muted rounded-full animate-pulse"></div>
|
||||||
<div class="h-10 w-20 bg-muted rounded-full animate-pulse"></div>
|
<div class="h-10 w-20 bg-muted rounded-full animate-pulse"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 头部信息 -->
|
||||||
|
<div v-else-if="playlist" class="pl-head-row">
|
||||||
|
<img :src="playlist.coverImgUrl" class="pl-cover" />
|
||||||
|
<div class="pl-info">
|
||||||
|
<div class="pl-info-top">
|
||||||
|
<h1 class="pl-title">{{ playlist.name }}</h1>
|
||||||
|
<!-- 元信息(紧凑时折叠隐藏,参考 SPlayer n-collapse-transition) -->
|
||||||
|
<div class="pl-collapse">
|
||||||
|
<div v-if="playlist.creator" class="flex items-center gap-2 mt-2">
|
||||||
|
<img :src="playlist.creator.avatarUrl" class="w-5 h-5 rounded-full" />
|
||||||
|
<span class="text-sm text-content-2">{{ playlist.creator.nickname }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="playlist.description" class="mt-2">
|
||||||
|
<p
|
||||||
|
ref="descEl"
|
||||||
|
class="text-sm text-content-2 leading-relaxed overflow-hidden"
|
||||||
|
style="max-height: 3em"
|
||||||
|
>{{ playlist.description }}</p>
|
||||||
|
<button
|
||||||
|
v-if="descOverflow"
|
||||||
|
@click="showDescModal = true"
|
||||||
|
class="inline-flex items-center gap-1 text-xs text-accent-text hover:text-accent-text/80 mt-1 px-2 py-0.5 rounded-full bg-accent-text/10 transition"
|
||||||
|
>
|
||||||
|
<IconChevronDown class="w-3 h-3" />
|
||||||
|
查看完整介绍
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-content-3 mt-2">
|
||||||
|
{{ playlist.trackCount }} 首歌曲 · 播放 {{ formatPlayCount(playlist.playCount) }} 次
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作行:播放按钮 + tab(始终常驻底部) -->
|
||||||
|
<div class="pl-actions">
|
||||||
|
<button
|
||||||
|
@click="playAll"
|
||||||
|
class="pl-play-btn"
|
||||||
|
>
|
||||||
|
<IconPlay class="w-4 h-4 fill-current" />
|
||||||
|
<span>播放全部</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="!isOwnPlaylist"
|
||||||
|
@click="toggleSubscribe"
|
||||||
|
class="pl-sub-btn"
|
||||||
|
:class="subscribed ? 'text-accent-text' : 'text-content/70'"
|
||||||
|
>
|
||||||
|
<IconBookmark class="w-4 h-4" :class="subscribed ? 'fill-current' : ''" />
|
||||||
|
{{ subscribed ? '已收藏' : '收藏歌单' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 歌曲 / 评论 tab(紧凑态缩小) -->
|
||||||
|
<div class="pl-tabs">
|
||||||
|
<button
|
||||||
|
class="pl-tab"
|
||||||
|
:class="{ active: currentTab === 'songs' }"
|
||||||
|
@click="currentTab = 'songs'"
|
||||||
|
>歌曲<span class="pl-tab-count">{{ songs.length }}</span></button>
|
||||||
|
<button
|
||||||
|
class="pl-tab"
|
||||||
|
:class="{ active: currentTab === 'comments' }"
|
||||||
|
@click="currentTab = 'comments'"
|
||||||
|
>评论</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
<!-- 头部信息 -->
|
<!-- 内容区(独立滚动):歌曲 / 评论 按 tab 切换渲染 -->
|
||||||
<div v-else-if="playlist" class="flex gap-6 mb-8">
|
<template #content>
|
||||||
<img :src="playlist.coverImgUrl" class="w-44 h-44 rounded-xl object-cover shadow-lg flex-shrink-0" />
|
<div class="px-8 pb-8 text-content">
|
||||||
<div class="flex flex-col justify-between min-w-0">
|
<!-- 加载失败 -->
|
||||||
<div>
|
<div v-if="loadError" class="flex flex-col items-center justify-center py-16 gap-3">
|
||||||
<h1 class="text-2xl font-bold leading-tight">{{ playlist.name }}</h1>
|
<p class="text-content-2 text-sm">加载失败</p>
|
||||||
<div v-if="playlist.creator" class="flex items-center gap-2 mt-2">
|
<button @click="fetchPlaylist(Number(route.params.id), true)" class="px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition">重试</button>
|
||||||
<img :src="playlist.creator.avatarUrl" class="w-5 h-5 rounded-full" />
|
|
||||||
<span class="text-sm text-content-2">{{ playlist.creator.nickname }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="playlist.description" class="mt-2">
|
|
||||||
<p
|
|
||||||
ref="descEl"
|
|
||||||
class="text-sm text-content-2 leading-relaxed overflow-hidden"
|
|
||||||
style="max-height: 3em"
|
|
||||||
>{{ playlist.description }}</p>
|
|
||||||
<button
|
|
||||||
v-if="descOverflow"
|
|
||||||
@click="showDescModal = true"
|
|
||||||
class="inline-flex items-center gap-1 text-xs text-accent-text hover:text-accent-text/80 mt-1 px-2 py-0.5 rounded-full bg-accent-text/10 transition"
|
|
||||||
>
|
|
||||||
<IconChevronDown class="w-3 h-3" />
|
|
||||||
查看完整介绍
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-content-3 mt-2">
|
|
||||||
{{ playlist.trackCount }} 首歌曲 · 播放 {{ formatPlayCount(playlist.playCount) }} 次
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3 mt-4">
|
|
||||||
<button
|
<template v-else>
|
||||||
@click="playAll"
|
<!-- 歌曲 tab:v-if 仅在激活时渲染(参考 SPlayer) -->
|
||||||
class="px-5 py-2 bg-accent hover:bg-accent-hover rounded-full text-white font-medium transition flex items-center gap-2"
|
<div v-if="currentTab === 'songs'">
|
||||||
>
|
<!-- 歌曲列表骨架 -->
|
||||||
<IconPlay class="w-4 h-4 fill-current" />
|
<div v-if="songsLoading" class="space-y-1">
|
||||||
播放全部
|
<div v-for="i in 8" :key="i" class="flex items-center gap-3 px-3 py-2">
|
||||||
</button>
|
<div class="w-12 h-12 bg-muted rounded animate-pulse flex-shrink-0"></div>
|
||||||
<button
|
<div class="flex-1 space-y-2">
|
||||||
v-if="!isOwnPlaylist"
|
<div class="h-4 bg-muted rounded w-2/3 animate-pulse"></div>
|
||||||
@click="toggleSubscribe"
|
<div class="h-3 bg-muted rounded w-1/3 animate-pulse"></div>
|
||||||
class="px-4 py-2 bg-muted hover:bg-emphasis rounded-full text-sm transition flex items-center gap-2"
|
</div>
|
||||||
:class="subscribed ? 'text-accent-text' : 'text-content/70'"
|
</div>
|
||||||
>
|
</div>
|
||||||
<IconBookmark class="w-4 h-4" :class="subscribed ? 'fill-current' : ''" />
|
<!-- 歌曲列表 -->
|
||||||
{{ subscribed ? '已收藏' : '收藏歌单' }}
|
<VirtualSongList
|
||||||
|
v-else-if="songs.length"
|
||||||
|
:songs="songs"
|
||||||
|
:current-song-id="player.currentSong?.id"
|
||||||
|
show-index
|
||||||
|
show-like
|
||||||
|
show-download
|
||||||
|
show-menu
|
||||||
|
show-duration
|
||||||
|
show-playing-overlay
|
||||||
|
@song-click="(_s, i) => player.playFromList(songs, i)"
|
||||||
|
/>
|
||||||
|
<div v-else-if="!songsLoading" class="text-content-2">暂无歌曲</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 评论 tab:首次切到时挂载(commentsActivated=true),之后保活以保留滚动位置 -->
|
||||||
|
<CommentSection
|
||||||
|
v-if="commentsActivated && playlist"
|
||||||
|
v-show="currentTab === 'comments'"
|
||||||
|
:type="2"
|
||||||
|
:id="Number(route.params.id)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</DetailLayout>
|
||||||
|
|
||||||
|
<!-- 简介弹窗 -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="showDescModal" class="fixed inset-0 z-50 flex items-center justify-center" @click.self="showDescModal = false">
|
||||||
|
<div class="absolute inset-0 bg-black/50" @click="showDescModal = false"></div>
|
||||||
|
<div class="relative bg-surface rounded-2xl shadow-2xl max-w-lg w-full mx-4 max-h-[70vh] flex flex-col">
|
||||||
|
<div class="flex items-center justify-between p-5 border-b border-line-2">
|
||||||
|
<h2 class="text-lg font-semibold">{{ playlist?.name }} 的介绍</h2>
|
||||||
|
<button @click="showDescModal = false" class="text-content-3 hover:text-content transition">
|
||||||
|
<IconX class="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="p-5 overflow-y-auto text-sm text-content-2 leading-relaxed whitespace-pre-wrap">{{ playlist?.description }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Teleport>
|
||||||
<!-- 简介弹窗 -->
|
|
||||||
<Teleport to="body">
|
|
||||||
<div v-if="showDescModal" class="fixed inset-0 z-50 flex items-center justify-center" @click.self="showDescModal = false">
|
|
||||||
<div class="absolute inset-0 bg-black/50" @click="showDescModal = false"></div>
|
|
||||||
<div class="relative bg-surface rounded-2xl shadow-2xl max-w-lg w-full mx-4 max-h-[70vh] flex flex-col">
|
|
||||||
<div class="flex items-center justify-between p-5 border-b border-line-2">
|
|
||||||
<h2 class="text-lg font-semibold">{{ playlist?.name }} 的介绍</h2>
|
|
||||||
<button @click="showDescModal = false" class="text-content-3 hover:text-content transition">
|
|
||||||
<IconX class="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="p-5 overflow-y-auto text-sm text-content-2 leading-relaxed whitespace-pre-wrap">{{ playlist?.description }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Teleport>
|
|
||||||
|
|
||||||
<!-- 加载失败 -->
|
|
||||||
<div v-if="loadError" class="flex flex-col items-center justify-center py-16 gap-3">
|
|
||||||
<p class="text-content-2 text-sm">加载失败</p>
|
|
||||||
<button @click="fetchPlaylist(Number(route.params.id), true)" class="px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition">重试</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 歌曲列表骨架 -->
|
|
||||||
<div v-else-if="songsLoading" class="space-y-1">
|
|
||||||
<div v-for="i in 8" :key="i" class="flex items-center gap-3 px-3 py-2">
|
|
||||||
<div class="w-12 h-12 bg-muted rounded animate-pulse flex-shrink-0"></div>
|
|
||||||
<div class="flex-1 space-y-2">
|
|
||||||
<div class="h-4 bg-muted rounded w-2/3 animate-pulse"></div>
|
|
||||||
<div class="h-3 bg-muted rounded w-1/3 animate-pulse"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 歌曲列表 -->
|
|
||||||
<VirtualSongList
|
|
||||||
v-else-if="songs.length"
|
|
||||||
:songs="songs"
|
|
||||||
:current-song-id="player.currentSong?.id"
|
|
||||||
show-index
|
|
||||||
show-like
|
|
||||||
show-download
|
|
||||||
show-menu
|
|
||||||
show-duration
|
|
||||||
show-playing-overlay
|
|
||||||
@song-click="(_s, i) => player.playFromList(songs, i)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div v-else-if="!songsLoading && !loadError" class="text-content-2">暂无歌曲</div>
|
|
||||||
|
|
||||||
<div v-if="playlist" class="mt-8">
|
|
||||||
<CommentSection :type="2" :id="Number(route.params.id)" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, watch, onActivated, nextTick } from 'vue';
|
import { ref, computed, onMounted, watch, onActivated } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { MusicApi } from '../api';
|
import { MusicApi } from '../api';
|
||||||
import { usePlayerStore } from '../stores/player';
|
import { usePlayerStore } from '../stores/player';
|
||||||
import { useUserStore } from '../stores/user';
|
import { useUserStore } from '../stores/user';
|
||||||
import { showToast } from '../composables/useToast';
|
import { showToast } from '../composables/useToast';
|
||||||
import { formatPlayCount } from '../utils/format';
|
import { formatPlayCount } from '../utils/format';
|
||||||
|
import { checkOverflow } from '../utils/dom';
|
||||||
import { normalizeSong, type Song } from '../utils/song';
|
import { normalizeSong, type Song } from '../utils/song';
|
||||||
import { pageCacheGet, pageCacheSet } from '../composables/usePageCache';
|
import { pageCacheGet, pageCacheSet } from '../composables/usePageCache';
|
||||||
import VirtualSongList from '../components/VirtualSongList.vue';
|
import VirtualSongList from '../components/VirtualSongList.vue';
|
||||||
import CommentSection from '../components/CommentSection.vue';
|
import CommentSection from '../components/CommentSection.vue';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
|
import DetailLayout from '../components/DetailLayout.vue';
|
||||||
import IconPlay from '~icons/lucide/play';
|
import IconPlay from '~icons/lucide/play';
|
||||||
import IconBookmark from '~icons/lucide/bookmark';
|
import IconBookmark from '~icons/lucide/bookmark';
|
||||||
import IconX from '~icons/lucide/x';
|
import IconX from '~icons/lucide/x';
|
||||||
@@ -154,6 +192,10 @@ const subscribed = ref(false);
|
|||||||
const showDescModal = ref(false);
|
const showDescModal = ref(false);
|
||||||
const descOverflow = ref(false);
|
const descOverflow = ref(false);
|
||||||
const descEl = ref<HTMLElement | null>(null);
|
const descEl = ref<HTMLElement | null>(null);
|
||||||
|
const layoutRef = ref<InstanceType<typeof DetailLayout> | null>(null);
|
||||||
|
const currentTab = ref<'songs' | 'comments'>('songs');
|
||||||
|
// 评论 tab 首次激活标记:懒加载 + 保活(v-if 控制挂载,v-show 控制显示)
|
||||||
|
const commentsActivated = ref(false);
|
||||||
|
|
||||||
const isOwnPlaylist = computed(() => {
|
const isOwnPlaylist = computed(() => {
|
||||||
if (!playlist.value || !userStore.user) return false;
|
if (!playlist.value || !userStore.user) return false;
|
||||||
@@ -161,15 +203,15 @@ const isOwnPlaylist = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function checkDescOverflow() {
|
function checkDescOverflow() {
|
||||||
nextTick(() => {
|
checkOverflow(descEl, descOverflow);
|
||||||
if (descEl.value) {
|
|
||||||
descOverflow.value = descEl.value.scrollHeight > descEl.value.clientHeight + 2;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchPlaylist(id: number, force = false) {
|
async function fetchPlaylist(id: number, force = false) {
|
||||||
const cacheKey = `playlist_${id}`;
|
const cacheKey = `playlist_${id}`;
|
||||||
|
// 切换歌单时重置 tab、评论激活与滚动状态
|
||||||
|
currentTab.value = 'songs';
|
||||||
|
commentsActivated.value = false;
|
||||||
|
layoutRef.value?.resetScroll();
|
||||||
if (!force) {
|
if (!force) {
|
||||||
const cached = pageCacheGet(cacheKey);
|
const cached = pageCacheGet(cacheKey);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
@@ -200,7 +242,7 @@ async function fetchPlaylist(id: number, force = false) {
|
|||||||
pageCacheSet(cacheKey, { playlist: playlist.value, songs: songs.value, subscribed: subscribed.value });
|
pageCacheSet(cacheKey, { playlist: playlist.value, songs: songs.value, subscribed: subscribed.value });
|
||||||
checkDescOverflow();
|
checkDescOverflow();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error('获取歌单详情失败', e);
|
||||||
loadError.value = true;
|
loadError.value = true;
|
||||||
playlistLoading.value = false;
|
playlistLoading.value = false;
|
||||||
songsLoading.value = false;
|
songsLoading.value = false;
|
||||||
@@ -208,6 +250,13 @@ async function fetchPlaylist(id: number, force = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 切换 tab 时重置紧凑态(评论/歌曲高度差异大,避免紧凑态卡死)
|
||||||
|
// 首次切到评论 tab 时激活保活标记
|
||||||
|
watch(currentTab, (tab) => {
|
||||||
|
layoutRef.value?.resetScroll();
|
||||||
|
if (tab === 'comments') commentsActivated.value = true;
|
||||||
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchPlaylist(Number(route.params.id));
|
fetchPlaylist(Number(route.params.id));
|
||||||
});
|
});
|
||||||
@@ -237,3 +286,163 @@ async function toggleSubscribe() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 头部容器:固定 padding,紧凑态收窄 padding 让出空间给列表
|
||||||
|
参考 SPlayer .detail height 240→120 过渡 */
|
||||||
|
.pl-header {
|
||||||
|
padding: 12px 32px 24px 32px;
|
||||||
|
transition: padding 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
.pl-header.is-compact {
|
||||||
|
padding: 12px 32px 12px 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 头部行:封面 + 信息横向排列 */
|
||||||
|
.pl-head-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 封面:176→120 紧凑过渡(参考 SPlayer .cover height:100% of 240/120) */
|
||||||
|
.pl-cover {
|
||||||
|
width: 176px;
|
||||||
|
height: 176px;
|
||||||
|
border-radius: 12px;
|
||||||
|
object-fit: cover;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition:
|
||||||
|
width 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
height 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
.pl-cover.cover-skeleton {
|
||||||
|
background-color: var(--c-muted);
|
||||||
|
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
|
}
|
||||||
|
.pl-header.is-compact .pl-cover {
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 信息列:纵向占满,顶部标题+折叠区,底部操作行 */
|
||||||
|
.pl-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.pl-info-top {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标题:24→18 紧凑过渡(参考 SPlayer .name 30→22) */
|
||||||
|
.pl-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1.3;
|
||||||
|
transition: font-size 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
.pl-header.is-compact .pl-title {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 元信息折叠区:紧凑时 max-height:0 + opacity:0 隐藏(参考 SPlayer n-collapse-transition) */
|
||||||
|
.pl-collapse {
|
||||||
|
max-height: 200px;
|
||||||
|
opacity: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
transition:
|
||||||
|
max-height 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
.pl-header.is-compact .pl-collapse {
|
||||||
|
max-height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 操作行:播放按钮 + 收藏 + tab,紧凑态缩小 */
|
||||||
|
.pl-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.pl-play-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 20px;
|
||||||
|
background-color: var(--c-accent);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 9999px;
|
||||||
|
transition:
|
||||||
|
background-color 0.2s ease,
|
||||||
|
padding 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
font-size 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
.pl-play-btn:hover {
|
||||||
|
background-color: var(--c-accent-hover);
|
||||||
|
}
|
||||||
|
.pl-sub-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background-color: var(--c-muted);
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 14px;
|
||||||
|
transition:
|
||||||
|
background-color 0.2s ease,
|
||||||
|
padding 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
font-size 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
.pl-sub-btn:hover {
|
||||||
|
background-color: var(--c-emphasis);
|
||||||
|
}
|
||||||
|
.pl-header.is-compact .pl-play-btn,
|
||||||
|
.pl-header.is-compact .pl-sub-btn {
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 歌曲/评论 tab:胶囊形,紧凑态缩小 */
|
||||||
|
.pl-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: auto;
|
||||||
|
background-color: var(--c-subtle);
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
transition: padding 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
.pl-tab {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 5px 14px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--c-content-2);
|
||||||
|
transition:
|
||||||
|
background-color 0.2s ease,
|
||||||
|
color 0.2s ease,
|
||||||
|
padding 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
font-size 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
.pl-tab.active {
|
||||||
|
background-color: var(--c-accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.pl-tab-count {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.pl-header.is-compact .pl-tab {
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user