mirror of
https://github.com/atdunbg/Nekosonic-Music.git
synced 2026-08-06 03:44:18 +08:00
Compare commits
32
Commits
v0.2.0
...
f514d29f02
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f514d29f02 | ||
|
|
f7f0217047 | ||
|
|
83cfae3b10 | ||
|
|
1fddbeab77 | ||
|
|
2993495d05 | ||
|
|
263e9b31d9 | ||
|
|
dcfada6940 | ||
|
|
3535e2e8a0 | ||
|
|
e40f82cc51 | ||
|
|
68f29c8ea8 | ||
|
|
57aa9dae61 | ||
|
|
c275461015 | ||
|
|
6da544cffb | ||
|
|
65ed71503e | ||
|
|
970fb15f5a | ||
|
|
cf21c96eaf | ||
|
|
987d34f58b | ||
|
|
baa6235c56 | ||
|
|
38c079ed5c | ||
|
|
68e3b92a6a | ||
|
|
d718ee5b42 | ||
|
|
966825c885 | ||
|
|
e8efc7275a | ||
|
|
0740d9be29 | ||
|
|
d2546ca93c | ||
|
|
29df8ca491 | ||
|
|
3158338d0b | ||
|
|
79fb001ae7 | ||
|
|
fd4bbb4a0a | ||
|
|
3b800e451f | ||
|
|
718d3ed641 | ||
|
|
02f7df4201 |
@@ -0,0 +1,86 @@
|
||||
name: Release with Updater
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
platform: linux
|
||||
- os: windows-latest
|
||||
platform: windows
|
||||
- os: macos-latest
|
||||
platform: macos
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install Linux dependencies
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libasound2-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
build-essential \
|
||||
curl \
|
||||
wget \
|
||||
file \
|
||||
libssl-dev \
|
||||
libgtk-3-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Extract release notes for version
|
||||
id: release_notes
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION="${{ github.ref_name }}"
|
||||
# 从 CHANGELOG.md 中提取 "## v0.3.0" 到下一个 "## " 之间的内容
|
||||
NOTES=$(awk -v ver="## $VERSION" '$0 ~ ver {flag=1; next} /^## / && flag {exit} flag' CHANGELOG.md)
|
||||
|
||||
# 如果没有提取到内容,使用默认文本
|
||||
if [ -z "$NOTES" ]; then
|
||||
NOTES="See CHANGELOG.md for details."
|
||||
fi
|
||||
|
||||
# 将多行内容写入环境变量(GitHub Actions 支持多行输入)
|
||||
{
|
||||
echo 'NOTES<<EOF'
|
||||
echo "$NOTES"
|
||||
echo EOF
|
||||
} >> $GITHUB_ENV
|
||||
|
||||
- name: Build and publish with Tauri Action
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
with:
|
||||
tagName: ${{ github.ref_name }}
|
||||
releaseName: 'v__VERSION__'
|
||||
releaseBody: ${{ env.NOTES }}
|
||||
releaseDraft: false
|
||||
prerelease: false
|
||||
+275
@@ -0,0 +1,275 @@
|
||||
## v0.9.0
|
||||
|
||||
本次版本为音源系统与播放器核心重构版本,新增多音源融合系统、歌曲标签体系、搜索建议、播放器稳定性优化等重大功能。
|
||||
|
||||
### ✨ 新功能
|
||||
|
||||
#### 🎵 多音源系统
|
||||
- **可配置多音源**:支持配置多个音源(波点、酷狗、酷我),可独立启用/禁用/排序,支持自定义音源扩展
|
||||
- **音质优化**:酷我音源支持更高音质播放,提供更多音质选择
|
||||
- **多源自动切换**:歌曲播放时自动尝试多个音源,版本一致性匹配(原版/DJ/Live/混音)
|
||||
- **多源融合搜索**:搜索结果聚合多个音源,按歌名+歌手组合去重,提供更多选择
|
||||
|
||||
#### 🏷️ 歌曲标签体系
|
||||
- **智能标签显示**:自动识别并显示歌曲标签(音质/版本/音源),提供清晰的歌曲信息展示
|
||||
- **权限信息聚合**:统一管理歌手、歌单、专辑、每日推荐、收藏页的歌曲权限信息
|
||||
|
||||
#### 🔍 搜索体验优化
|
||||
- **实时搜索建议**:输入时自动显示搜索建议,支持中文拼音输入,120ms 防抖优化
|
||||
- **compositionend 支持**:正确处理中文输入法的组合输入过程,实时响应
|
||||
- **浮层定位优化**:Teleport + fixed 定位,避免被父容器裁剪,显示更完整
|
||||
|
||||
### 🎨 播放器改进
|
||||
|
||||
#### 🎧 核心播放优化
|
||||
- **网络流 seek 支持**:sharedBuffer 下载完成后支持拖动进度条跳转(byte_len 可用)
|
||||
- **切歌流程优化**:停止旧歌曲播放后再加载新歌曲,进度条正确归零,避免音频叠加
|
||||
- **加载状态可视化**:切歌时按钮显示加载状态并禁用,提供清晰的交互反馈
|
||||
- **tick generation 防竞态**:无 tick 时不覆盖新歌曲位置,避免播放位置跳变
|
||||
|
||||
#### 🖥️ 系统媒体控制
|
||||
- **跨平台媒体键支持**:系统媒体控制集成(Windows/Linux/macOS),支持媒体键、通知栏、锁屏控制
|
||||
- **Windows hwnd 就绪检查**:增加窗口句柄就绪检查,避免 panic,提升稳定性
|
||||
|
||||
## v0.8.1
|
||||
|
||||
### 🐛 修复
|
||||
- **关闭到托盘不生效**:点击关闭按钮选择「最小化到托盘」或「直接退出」时,窗口未隐藏/退出。原因是 `capabilities/default.json` 缺少 `core:window:allow-hide` 权限,前端调用 `currentWindow.hide()` 被 Tauri 2 权限系统静默拦截。补齐 `allow-hide` 与 `allow-show` 权限
|
||||
|
||||
## 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
|
||||
|
||||
### ✨ 新功能
|
||||
- **音乐云盘**:新增云盘页面,可浏览、播放云盘中的歌曲,查看文件详情(文件名、大小、比特率、上传时间),删除云盘歌曲,查看存储空间使用情况
|
||||
- **云盘上传**:支持上传本地音频文件到云盘,上传过程显示实时进度,支持 mp3/flac/wav/ogg/aac/m4a 格式
|
||||
- **下载音乐**:本地音乐拆分为「本地音乐」和「下载音乐」两个独立页面,下载音乐只显示通过应用下载的歌曲
|
||||
- **本地音乐多文件夹**:本地音乐支持添加多个扫描文件夹,通过三点按钮+弹窗管理文件夹路径
|
||||
- **歌手关注**:歌手详情页新增关注/取关按钮,关注状态在离开页面后不会丢失
|
||||
- **粘性导航栏**:页面滚动较深时顶部自动显示返回按钮和功能按钮,渐变模糊效果,不影响阅读
|
||||
- **骨架屏加载**:首页、歌单、歌手、专辑、云盘等多个页面加载时显示骨架占位动画,不再只有"加载中"文字
|
||||
|
||||
### 🐛 修复
|
||||
- 网络较差时播放启动超时,音乐实际已开始播放但界面仍显示暂停
|
||||
- 全屏漫游抽屉打开时点击评论按钮无法切换到评论页
|
||||
- 关闭漫游抽屉后再打开,始终显示评论而非歌词
|
||||
- 歌手详情页关注后离开再回来,关注状态丢失
|
||||
- 切歌时偶尔触发上一首歌的播放结束事件导致异常
|
||||
- 评论点赞无限叠加(改为服务端状态驱动)
|
||||
- 播放栏进度条上方多余分隔线
|
||||
|
||||
### 🎨 变更
|
||||
- 歌手详情页头像改为圆形,简介从独立标签页移至头部内嵌显示,溢出时可展开查看完整介绍
|
||||
- 歌单详情页描述溢出时显示"查看完整介绍"按钮
|
||||
- 首页推荐和排行榜加载失败时显示重试按钮,支持分别重试
|
||||
- 多个页面的返回按钮统一为粘性导航栏组件
|
||||
- 消息提示增加去重和数量限制,避免重复弹出
|
||||
|
||||
### ⚡ 优化
|
||||
- 页面切换更流畅,路由全部改为懒加载
|
||||
- 页面缓存管理优化,30 秒未访问自动释放,多级跳转时保留导航链上的页面,「我喜欢的音乐」常驻缓存
|
||||
- 本地音乐扫描不再阻塞界面导航
|
||||
- 应用启动不再等待网络请求完成
|
||||
|
||||
|
||||
## v0.6.0
|
||||
|
||||
### ✨ 新功能
|
||||
- **亮色主题**:新增浅色外观模式,支持深色/浅色切换,7 种主题色各有对应亮色变体
|
||||
- **封面主色背景**:全屏漫游抽屉背景自动提取封面图主色调,沉浸感更强;抽屉打开时底部播放栏也跟随封面主色,视觉融为一体
|
||||
- **发现页重做**:支持多类型搜索(歌曲/歌手/专辑),输入时自动显示搜索建议,搜索历史和热门搜索
|
||||
- **漫游增强**:全屏抽屉支持歌词/评论切换,播放栏新增减少推荐按钮
|
||||
- **减少推荐**:FM 模式下可标记"不推荐这首歌"或"不推荐这个歌手",后续不会再收到类似推荐
|
||||
- **列表风格统一**:正在播放的歌曲序号位置显示跳动动画,鼠标悬停显示播放图标;红心/下载等图标统一使用图标库
|
||||
|
||||
### 🐛 修复
|
||||
- 专辑页艺术家过多时窗口缩小会竖排显示,现在支持自动换行
|
||||
- FM 播放时退出登录后首页仍可点击下一首
|
||||
- 本地音乐播放时缓冲进度条未重置
|
||||
- 亮色主题下多处文字看不见
|
||||
- 退出 FM 模式时状态未正确清理
|
||||
- 暗色模式下关闭抽屉时播放栏短暂闪烁亮色
|
||||
|
||||
### 🎨 变更
|
||||
- 移除播放列表按钮上的数字角标
|
||||
- 主页每日推荐和 FM 卡片标题固定为白色,不随主题变化
|
||||
|
||||
### 🧹 清理
|
||||
- 内部代码优化和冗余清理
|
||||
|
||||
|
||||
## v0.5.1
|
||||
|
||||
### 🐛 修复
|
||||
- 修复页面缓存不刷新的问题:切换回已缓存的页面时数据永远不更新,现在超过 5 分钟会自动重新加载
|
||||
- 修复本地音乐页面空列表时刷新按钮不显示的问题
|
||||
- 修复修改下载路径后本地音乐列表不更新的问题,现在会自动刷新
|
||||
- 修复私人 FM 播放约二三十首后循环重复的问题:新增听歌打卡上报,服务端推荐不再重复
|
||||
- 修复歌词界面切换翻译开关时歌词未居中的问题
|
||||
- 修复 Linux 下从外部控制暂停时进度条跳回 0 的问题:MPRIS 现在正确报告播放进度位置
|
||||
|
||||
### ⚡ 优化
|
||||
- 私人 FM 预取队列优化,队列剩余不足时自动后台拉取下一批
|
||||
|
||||
|
||||
## v0.5.0
|
||||
|
||||
### ✨ 新功能
|
||||
- **蓝牙耳机/键盘媒体键控制**:支持通过蓝牙耳机按钮、键盘媒体键、系统通知栏/锁屏面板控制播放、暂停、切歌(Windows / Linux / macOS)
|
||||
- **网络状态检测**:断网和恢复时弹出提示,网络恢复后自动重新加载页面内容
|
||||
- **音量记忆**:关闭应用后音量设置不丢失,下次打开自动恢复
|
||||
- **歌词翻译**:支持显示歌词翻译,可在漫游页面切换开关
|
||||
- **登录页优化**:已登录用户访问登录页会自动跳转回首页
|
||||
|
||||
### 🎨 变更
|
||||
- 默认主题色改为天蓝色
|
||||
- 全局快捷键显示顺序调整为 Ctrl + Alt(之前是 Alt + Ctrl)
|
||||
- 快捷键显示优化:按键名更简洁,如 KeyP 显示为 P
|
||||
- 页面缓存优化:更多页面切换时保留状态,窗口隐藏时自动释放
|
||||
- 登录页等待确认时的文字颜色修正
|
||||
|
||||
### 🐛 修复
|
||||
- 手动检查更新时,之前跳过的版本现在会正常弹出更新提示
|
||||
- 点击正在播放的歌曲无法恢复播放的问题
|
||||
- 部分内部类型定义问题导致的潜在隐患
|
||||
|
||||
### ⚡ 底层优化
|
||||
- 音频播放引擎全面重构,播放更稳定
|
||||
- 后端 API 调用模式统一,代码更易维护
|
||||
- 歌曲数据模型统一,各页面显示更一致
|
||||
|
||||
|
||||
## v0.4.1
|
||||
|
||||
添加音频输出外设选择
|
||||
|
||||
|
||||
## v0.4.0
|
||||
|
||||
### ✨ 新功能
|
||||
- 添加歌曲的艺术家入口,歌曲的艺术家现可点击查看其他歌曲、专辑和介绍
|
||||
- 添加歌曲的评论查看功能
|
||||
|
||||
### 🐛 修复
|
||||
- 修复私人漫游自动播放下一首调用多次问题
|
||||
|
||||
### ⚡ 优化
|
||||
- 优化播放逻辑,歌曲列表在点击时候不再单首累加,而是直接获取当前列表所有的歌曲作为播放内容
|
||||
|
||||
|
||||
## v0.3.0
|
||||
|
||||
### ✨ 新功能
|
||||
- **本地音乐页面**:支持浏览、播放本地歌曲,横向菜单添加「从磁盘删除」功能
|
||||
- **下载系统**:支持下载歌曲到自定义路径,保存完整元数据(封面/专辑/时长)
|
||||
- **封面补全**:本地音乐缺少封面时尝试从网易云 API 获取
|
||||
- **更新信息**:添加查看最新版更新日志按钮
|
||||
- **下载路径**:支持自定义下载路径
|
||||
- **本地音乐**:支持本地音乐播放
|
||||
- **下载提示**:下载进度与完成提示
|
||||
- **快捷键绑定**:支持自定义全局和本地快捷键(播放/暂停、上一首/下一首、音量调节)
|
||||
|
||||
### 🐛 修复
|
||||
- 修复私人漫游播完一首歌后跳三首的问题
|
||||
- 修复全屏漫游抽屉和漫游页面无封面歌曲显示破损图片
|
||||
- 修复 PlayerBar 无封面歌曲显示破损图片
|
||||
- 修复播放网络歌曲时进度条先走但无声音
|
||||
|
||||
### ⚡ 优化
|
||||
- **流式播放**:边下载边播放,缓冲 64KB 后即刻开始,无需等待完整下载
|
||||
|
||||
|
||||
## v0.2.0
|
||||
|
||||
### 🎵 播放
|
||||
- 优化私人漫游(个人 FM)功能
|
||||
- 新增歌曲喜欢/取消喜欢红心
|
||||
- 新增播放历史本地记录(最近 200 首)
|
||||
|
||||
### 📋 歌单
|
||||
- 修改逻辑 我的歌单 不再显示收藏按钮
|
||||
- 收藏歌单支持取消收藏
|
||||
- 实现我的音乐功能
|
||||
- 实现历史播放记录功能
|
||||
|
||||
### 🎨 外观
|
||||
- 全局复选框与选择框优化
|
||||
- 部分UI优化,统一风格
|
||||
|
||||
### 🖥️ 窗口
|
||||
- 关闭窗口弹出确认弹窗:最小化到托盘 / 退出程序
|
||||
- 支持"不再询问"选项,可在设置中修改
|
||||
- 修复退出时 WebView2 报错(Error 1410)
|
||||
- 修复歌词抽屉全屏时候顶栏无法接收事件问题
|
||||
|
||||
### 💾 持久化
|
||||
- Cookie 存储迁移至 Tauri app_data_dir
|
||||
- 播放历史持久化到 localStorage
|
||||
|
||||
### ⚙️ 其他
|
||||
- 添加设置功能
|
||||
- 关于添加链接可直接访问仓库
|
||||
|
||||
|
||||
## v0.1.0
|
||||
|
||||
Nekosonic 是一款基于 Tauri 2 + Rust 的跨平台桌面音乐播放器,音源主要来自网易云音乐,开箱即用。
|
||||
@@ -1,81 +1,102 @@
|
||||
<div align="center">
|
||||
|
||||
# Nekosonic
|
||||
|
||||
一款轻量的跨平台的音乐播放器,支持Windows/Linux系统,音源主要源自的网易云音乐。
|
||||
轻量跨平台桌面音乐播放器 · 网易云音乐
|
||||
|
||||
## ✨ 特性
|
||||
[](https://github.com/atdunbg/Nekosonic-Music/releases)
|
||||
[](https://github.com/atdunbg/Nekosonic-Music/releases)
|
||||
[](https://github.com/atdunbg/Nekosonic-Music/releases)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
- 🔴 网易云账号登录(扫码)
|
||||
- 🎵 多音质播放(标准 / 较高 / 极高 / 无损 / Hi-Res)
|
||||
- 📻 私人漫游,沉浸式全屏歌词体验
|
||||
- ❤️ 一键喜欢 / 取消喜欢
|
||||
- 📋 歌单管理,收藏 / 取消收藏歌单
|
||||
- 📅 每日推荐歌曲
|
||||
- 🕐 本地播放历史记录
|
||||
- 🔍 关键词搜索歌曲
|
||||
- 🎤 实时滚动歌词
|
||||
- 🌚 Light / Dark Mode 主题切换
|
||||
- 🛠 更多特性添加中
|
||||
[下载安装](https://github.com/atdunbg/Nekosonic-Music/releases)
|
||||
|
||||
## 📦️ 安装
|
||||
---
|
||||
|
||||
访问本项目的 [Releases](https://gitea.atdunbg.xyz/atdunbg/Nekosonic-Music/releases) 页面下载安装包。
|
||||
</div>
|
||||
|
||||
## 🎵 播放
|
||||
|
||||
## 💻 配置开发环境
|
||||
- 多音质选择(标准 / 较高 / HQ / SQ / Hi-Res)
|
||||
- 私人漫游 FM(个性化推荐)
|
||||
- 系统媒体控制集成(MPRIS / SMTC / Now Playing)
|
||||
- 音频输出设备选择
|
||||
|
||||
## 🔍 发现
|
||||
|
||||
- 关键词搜索(歌曲 / 歌手 / 专辑)+ 搜索建议 + 热门搜索
|
||||
- 歌单浏览(推荐 / 排行榜 / 用户 / 收藏)
|
||||
- 歌手详情(热门歌曲 / 专辑 / 简介 + 关注)
|
||||
- 专辑详情
|
||||
- 每日推荐歌曲
|
||||
|
||||
## 🎤 歌词与评论
|
||||
|
||||
- 实时滚动歌词(ease-out 缓动 / 点击跳转 / 渐变透明度)
|
||||
- 歌词翻译
|
||||
- 全屏漫游模式(封面主色提取 + 歌词/评论双标签)
|
||||
- 歌曲评论(无限滚动 + 点赞)
|
||||
|
||||
## ❤️ 收藏与下载
|
||||
|
||||
- 一键喜欢 / 取消喜欢(同步到网易云账号)
|
||||
- 歌曲下载
|
||||
- 音乐云盘(上传 / 删除 / 详情 / 存储空间 / 上传进度)
|
||||
- 本地音乐(多文件夹扫描 / 封面补全)
|
||||
- 下载音乐(独立管理 / 删除)
|
||||
|
||||
## 🎨 个性化
|
||||
|
||||
- 多主题色(天蓝 / 翠绿 / 玫红 / 紫罗兰 / 橙色 / 青色 / 粉色)
|
||||
- 自定义快捷键(应用内 + 系统全局)
|
||||
- 关闭行为设置
|
||||
- 自动更新
|
||||
|
||||
---
|
||||
|
||||
## 安装
|
||||
|
||||
前往 [Releases](https://github.com/atdunbg/Nekosonic-Music/releases) 下载对应平台安装包。
|
||||
|
||||
## 配置开发环境
|
||||
|
||||
```bash
|
||||
# 安装前端依赖
|
||||
npm install
|
||||
|
||||
# 启动开发服务器
|
||||
npm run tauri dev
|
||||
|
||||
# 构建发布
|
||||
npm run tauri build
|
||||
npm run tauri dev # 开发
|
||||
npm run tauri build # 构建
|
||||
```
|
||||
|
||||
### 环境要求
|
||||
> 环境要求:Node.js ≥ 18 · Rust ≥ 1.70 · Tauri CLI 2
|
||||
|
||||
- Node.js >= 18
|
||||
- Rust >= 1.70
|
||||
- Tauri CLI 2
|
||||
|
||||
## 🛠 技术栈
|
||||
## 技术栈
|
||||
|
||||
| 层级 | 技术 |
|
||||
|------|------|
|
||||
|:------|:------|
|
||||
| 桌面框架 | Tauri 2 |
|
||||
| 前端 | Vue 3 + TypeScript |
|
||||
| 样式 | Tailwind CSS v4 + CSS 变量主题系统 |
|
||||
| 状态管理 | Pinia |
|
||||
| 路由 | Vue Router 4 |
|
||||
| 音频播放 | rodio (Rust) |
|
||||
| 前端 | Vue 3 + TypeScript + Pinia |
|
||||
| 样式 | Tailwind CSS v4 + CSS 变量主题 |
|
||||
| 音频解码 | symphonia + ringbuf |
|
||||
| 媒体控制 | souvlaki |
|
||||
| 网易云 API | ncm-api-rs |
|
||||
| 构建工具 | Vite 6 |
|
||||
| 构建 | Vite 6 |
|
||||
|
||||
## ☑️ Todo
|
||||
## Todo
|
||||
|
||||
- [x] 评论查看
|
||||
- [x] 歌曲下载
|
||||
- [x] 本地音乐
|
||||
- [x] 歌手详情页
|
||||
- [x] 专辑详情页
|
||||
- [x] 自定义全局快捷键
|
||||
- [x] 自动更新
|
||||
- [x] 歌词翻译
|
||||
- [x] 更多主题
|
||||
- [x] 音乐云盘
|
||||
- [ ] MV 播放
|
||||
- [ ] 音乐云盘
|
||||
- [ ] 评论系统
|
||||
- [ ] 下载功能
|
||||
- [ ] 自定义全局快捷键
|
||||
- [ ] 歌词翻译
|
||||
- [ ] 更多主题
|
||||
- [ ] 桌面歌词
|
||||
|
||||
欢迎提 Issue 和 Pull request。
|
||||
|
||||
## 📜 开源许可
|
||||
## 开源许可
|
||||
|
||||
本项目仅供个人学习研究使用,禁止用于商业及非法用途。
|
||||
|
||||
基于 [MIT license](https://opensource.org/licenses/MIT) 许可进行开源。
|
||||
|
||||
|
||||
## 致谢
|
||||
|
||||
- [ncm-api-rs](https://crates.io/crates/ncm-api-rs) — 网易云音乐 API 的 Rust 封装
|
||||
- [Tauri](https://tauri.app/) — 跨平台桌面应用框架
|
||||
- [Vue.js](https://vuejs.org/) — 渐进式 JavaScript 框架
|
||||
- [Tailwind CSS](https://tailwindcss.com/) — 实用优先的 CSS 框架
|
||||
- [rodio](https://crates.io/crates/rodio) — Rust 音频播放库
|
||||
本项目仅供个人学习研究使用,禁止用于商业及非法用途。基于 [MIT License](https://opensource.org/licenses/MIT) 开源。
|
||||
|
||||
Generated
+69
-704
File diff suppressed because it is too large
Load Diff
+7
-5
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "nekosonic",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.9.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -11,22 +11,24 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"axios": "^1.16.0",
|
||||
"howler": "^2.2.4",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"pinia": "^3.0.4",
|
||||
"qrcode": "^1.5.4",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/lucide": "^1.2.110",
|
||||
"@iconify/utils": "^3.1.3",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@vicons/ionicons5": "^0.13.0",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"@vueuse/motion": "^3.0.3",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"typescript": "~5.6.2",
|
||||
"unplugin-icons": "^23.0.1",
|
||||
|
||||
Generated
+2039
File diff suppressed because it is too large
Load Diff
Generated
+1607
-331
File diff suppressed because it is too large
Load Diff
+34
-5
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "Nekosonic"
|
||||
version = "0.1.0"
|
||||
version = "0.9.0"
|
||||
description = "A Simple music app"
|
||||
authors = ["atdunbg"]
|
||||
edition = "2021"
|
||||
@@ -12,7 +12,7 @@ edition = "2021"
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "demo_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
@@ -20,12 +20,41 @@ tauri-build = { version = "2", features = [] }
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-opener = "2"
|
||||
rodio = "0.20"
|
||||
tauri-plugin-single-instance = "2"
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
symphonia = { version = "0.5", features = ["mp3", "aac", "flac", "wav", "ogg", "vorbis", "isomp4", "mkv"] }
|
||||
ringbuf = "0.4"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
cpal = { version = "0.15" }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls", "stream"] }
|
||||
futures-util = "0.3"
|
||||
dirs = "5"
|
||||
lofty = "0.22"
|
||||
base64 = "0.22"
|
||||
md5 = "0.7"
|
||||
|
||||
ncm-api-rs = "0.1"
|
||||
tokio = { version = "1", features = ["rt", "sync"] }
|
||||
tokio = { version = "1", features = ["rt", "sync"] }
|
||||
async-trait = "0.1"
|
||||
urlencoding = "2"
|
||||
tauri-plugin-process = "2.3.1"
|
||||
tauri-plugin-updater = "2"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
souvlaki = "0.8"
|
||||
raw-window-handle = "0.6"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
souvlaki = "0.8"
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
opt-level = "s"
|
||||
|
||||
@@ -5,13 +5,22 @@
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default",
|
||||
"core:window:default",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-unminimize",
|
||||
"core:window:allow-maximize",
|
||||
"core:window:allow-unmaximize",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-toggle-maximize"
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-show",
|
||||
"core:path:default",
|
||||
"opener:default",
|
||||
"global-shortcut:allow-is-registered",
|
||||
"global-shortcut:allow-register",
|
||||
"global-shortcut:allow-unregister",
|
||||
"dialog:allow-open",
|
||||
"process:allow-restart",
|
||||
"updater:default"
|
||||
]
|
||||
}
|
||||
}
|
||||
+1397
-110
File diff suppressed because it is too large
Load Diff
@@ -1,382 +0,0 @@
|
||||
use rodio::{Decoder, OutputStream, Sink, Source};
|
||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||
use std::io::Cursor;
|
||||
use std::sync::mpsc::{channel, Receiver, Sender};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use tauri::AppHandle;
|
||||
use tauri::Emitter;
|
||||
|
||||
// ---------- 命令 ----------
|
||||
enum AudioCmd {
|
||||
Play(String),
|
||||
Pause,
|
||||
Resume,
|
||||
Stop,
|
||||
Seek(f64),
|
||||
SetVolume(f32),
|
||||
SetDevice(Option<String>),
|
||||
}
|
||||
|
||||
pub struct AudioController {
|
||||
tx: Sender<AudioCmd>,
|
||||
current_url: Arc<Mutex<Option<String>>>,
|
||||
}
|
||||
|
||||
impl AudioController {
|
||||
pub fn new(app_handle: AppHandle) -> Self {
|
||||
let (tx, rx) = channel();
|
||||
let current_url = Arc::new(Mutex::new(None));
|
||||
let url_clone = current_url.clone();
|
||||
let ah_clone = app_handle.clone(); // 克隆一个用于闭包
|
||||
thread::spawn(move || audio_thread(rx, url_clone, ah_clone));
|
||||
AudioController {
|
||||
tx,
|
||||
current_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 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); }
|
||||
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));
|
||||
}
|
||||
pub fn set_volume(&self, vol: f32) {
|
||||
let _ = self.tx.send(AudioCmd::SetVolume(vol));
|
||||
}
|
||||
}
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
fn download_audio_with_progress(
|
||||
url: &str,
|
||||
app_handle: &AppHandle,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let resp = reqwest::blocking::get(url)
|
||||
.map_err(|e| format!("下载失败: {}", e))?;
|
||||
|
||||
let total_size = resp.content_length().unwrap_or(0);
|
||||
let mut downloaded: u64 = 0;
|
||||
let mut buffer = Vec::new();
|
||||
let mut reader = resp;
|
||||
|
||||
loop {
|
||||
let mut chunk = [0u8; 8192];
|
||||
let read_size = reader.read(&mut chunk)
|
||||
.map_err(|e| format!("读取失败: {}", e))?;
|
||||
if read_size == 0 {
|
||||
break;
|
||||
}
|
||||
buffer.extend_from_slice(&chunk[..read_size]);
|
||||
downloaded += read_size as u64;
|
||||
|
||||
// 发送进度事件给前端(每 8192 字节发一次,不必太频繁)
|
||||
let progress = if total_size > 0 {
|
||||
(downloaded as f64 / total_size as f64) * 100.0
|
||||
} else {
|
||||
0.0 // 未知大小时为 0
|
||||
};
|
||||
let _ = app_handle.emit("cache-progress", progress);
|
||||
}
|
||||
|
||||
// 下载完成,确保进度为 100
|
||||
let _ = app_handle.emit("cache-progress", 100f64);
|
||||
Ok(buffer)
|
||||
}
|
||||
|
||||
// ---------- 音频线程 ----------
|
||||
fn audio_thread(rx: Receiver<AudioCmd>, current_url: Arc<Mutex<Option<String>>>, app_handle: AppHandle) {
|
||||
let mut selected_device: Option<String> = None;
|
||||
let mut output = create_output(&selected_device);
|
||||
let mut last_default_name = get_system_default_device_name();
|
||||
|
||||
let mut current_volume: f32 = 1.0;
|
||||
if let Some(ref sink) = output.sink {
|
||||
sink.set_volume(current_volume);
|
||||
}
|
||||
|
||||
let mut current_audio_data: Option<Vec<u8>> = None; // 缓存原始音频字节
|
||||
|
||||
loop {
|
||||
match rx.recv_timeout(Duration::from_millis(200)) {
|
||||
Ok(cmd) => {
|
||||
match cmd {
|
||||
AudioCmd::Play(url) => {
|
||||
// 停止旧播放并重建干净输出
|
||||
if let Some(ref sink) = output.sink {
|
||||
sink.stop();
|
||||
}
|
||||
output = create_output(&selected_device);
|
||||
if let Some(ref sink) = output.sink {
|
||||
sink.set_volume(current_volume);
|
||||
|
||||
match download_audio_with_progress(&url, &app_handle) {
|
||||
Ok(bytes) => {
|
||||
current_audio_data = Some(bytes.clone());
|
||||
let play_res = play_bytes(&bytes, sink);
|
||||
if let Err(e) = play_res {
|
||||
eprintln!("[audio] 播放失败: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("[audio] 下载失败: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AudioCmd::Pause => {
|
||||
if let Some(ref sink) = output.sink { sink.pause(); }
|
||||
}
|
||||
AudioCmd::Resume => {
|
||||
if let Some(ref sink) = output.sink { sink.play(); }
|
||||
}
|
||||
AudioCmd::Stop => {
|
||||
if let Some(ref sink) = output.sink { sink.stop(); }
|
||||
}
|
||||
|
||||
AudioCmd::Seek(time) => {
|
||||
if let Some(ref sink) = output.sink {
|
||||
// 优先尝试高效的 sink.try_seek(毫秒级)
|
||||
let seek_res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
sink.try_seek(Duration::from_secs_f64(time))
|
||||
}));
|
||||
|
||||
match seek_res {
|
||||
Ok(Ok(_)) => { /* 成功 */ }
|
||||
Ok(Err(e)) => {
|
||||
eprintln!("[audio] try_seek 失败: {:?}, 回退重建解码", e);
|
||||
// 回退方案:重新解码并从目标时间开始
|
||||
if let Some(ref bytes) = current_audio_data {
|
||||
sink.stop();
|
||||
sink.clear();
|
||||
let _ = play_bytes_with_seek(bytes, sink, time);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
eprintln!("[audio] try_seek 崩溃,回退重建解码");
|
||||
if let Some(ref bytes) = current_audio_data {
|
||||
sink.stop();
|
||||
sink.clear();
|
||||
let _ = play_bytes_with_seek(bytes, sink, time);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AudioCmd::SetVolume(vol) => {
|
||||
current_volume = vol;
|
||||
if let Some(ref sink) = output.sink {
|
||||
sink.set_volume(vol);
|
||||
}
|
||||
}
|
||||
|
||||
AudioCmd::SetDevice(dev) => {
|
||||
selected_device = dev;
|
||||
output = create_output(&selected_device);
|
||||
if let Some(ref sink) = output.sink {
|
||||
sink.set_volume(current_volume);
|
||||
// 如果正在播放,恢复播放
|
||||
if current_url.lock().unwrap().is_some() {
|
||||
if let Some(ref bytes) = current_audio_data {
|
||||
let _ = play_bytes(bytes, sink);
|
||||
}
|
||||
}
|
||||
}
|
||||
if selected_device.is_none() {
|
||||
last_default_name = get_system_default_device_name();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
|
||||
// 跟随系统默认设备变化
|
||||
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;
|
||||
output = create_output(&selected_device);
|
||||
if let Some(ref sink) = output.sink {
|
||||
sink.set_volume(current_volume);
|
||||
if let Some(ref bytes) = current_audio_data {
|
||||
let _ = play_bytes(bytes, sink);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 播放辅助函数 ----------
|
||||
|
||||
/// 直接播放字节数据
|
||||
fn play_bytes(bytes: &[u8], sink: &Sink) -> Result<(), String> {
|
||||
let cursor = Cursor::new(bytes.to_vec());
|
||||
let source = Decoder::new(cursor).map_err(|e| format!("解码失败: {}", e))?;
|
||||
sink.append(source);
|
||||
sink.play();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 播放字节数据并跳过指定秒数(用于 seek 回退)
|
||||
fn play_bytes_with_seek(bytes: &[u8], sink: &Sink, seek_secs: f64) -> Result<(), String> {
|
||||
let cursor = Cursor::new(bytes.to_vec());
|
||||
let source = Decoder::new(cursor).map_err(|e| format!("解码失败: {}", e))?;
|
||||
let source = source.skip_duration(Duration::from_secs_f64(seek_secs));
|
||||
sink.append(source);
|
||||
sink.play();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------- 其余函数保持不变(获取设备、创建输出等) ----------
|
||||
|
||||
fn get_system_default_device_name() -> Option<String> {
|
||||
rodio::cpal::default_host()
|
||||
.default_output_device()
|
||||
.and_then(|d| d.name().ok())
|
||||
}
|
||||
|
||||
pub fn list_output_devices() -> Vec<String> {
|
||||
let host = rodio::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![]
|
||||
}
|
||||
}
|
||||
|
||||
fn find_device_by_name(name: &str) -> Option<rodio::cpal::Device> {
|
||||
let host = rodio::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
|
||||
}
|
||||
|
||||
struct Output {
|
||||
_stream: OutputStream,
|
||||
sink: Option<Sink>,
|
||||
}
|
||||
|
||||
fn create_output(selected_device: &Option<String>) -> Output {
|
||||
match selected_device {
|
||||
Some(dev_name) => {
|
||||
if let Some(dev) = find_device_by_name(dev_name) {
|
||||
println!("[audio] 使用指定设备: {}", dev_name);
|
||||
match OutputStream::try_from_device(&dev) {
|
||||
Ok((stream, handle)) => {
|
||||
match Sink::try_new(&handle) {
|
||||
Ok(sink) => Output { _stream: stream, sink: Some(sink) },
|
||||
Err(e) => {
|
||||
eprintln!("[audio] Sink 创建失败: {}", e);
|
||||
Output { _stream: stream, sink: None }
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[audio] 指定设备无效,回退默认: {}", e);
|
||||
create_default_output()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
eprintln!("[audio] 未找到设备 `{}`,回退默认", dev_name);
|
||||
create_default_output()
|
||||
}
|
||||
}
|
||||
None => {
|
||||
println!("[audio] 跟随系统默认设备");
|
||||
create_default_output()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn create_default_output() -> Output {
|
||||
match OutputStream::try_default() {
|
||||
Ok((stream, handle)) => {
|
||||
match Sink::try_new(&handle) {
|
||||
Ok(sink) => Output { _stream: stream, sink: Some(sink) },
|
||||
Err(e) => {
|
||||
eprintln!("[audio] 默认 Sink 失败: {}", e);
|
||||
Output { _stream: stream, sink: None }
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => panic!("无法创建默认音频输出: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== Tauri 命令 =====================
|
||||
use tauri::State;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
|
||||
pub struct AppAudio(pub StdMutex<AudioController>);
|
||||
|
||||
#[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::command]
|
||||
pub fn pause_audio(state: State<'_, AppAudio>) {
|
||||
if let Ok(ctrl) = state.0.lock() { ctrl.pause(); }
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn resume_audio(state: State<'_, AppAudio>) {
|
||||
if let Ok(ctrl) = state.0.lock() { ctrl.resume(); }
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn stop_audio(state: State<'_, AppAudio>) {
|
||||
if let Ok(ctrl) = state.0.lock() { ctrl.stop(); }
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_output_devices() -> Vec<String> {
|
||||
list_output_devices()
|
||||
}
|
||||
|
||||
#[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::command]
|
||||
pub fn seek_audio(state: State<'_, AppAudio>, time: f64) {
|
||||
if let Ok(ctrl) = state.0.lock() {
|
||||
ctrl.seek(time);
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_volume(state: State<'_, AppAudio>, vol: f32) {
|
||||
if let Ok(ctrl) = state.0.lock() {
|
||||
ctrl.set_volume(vol);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
//! 流式音频缓冲区
|
||||
//!
|
||||
//! 提供线程安全的共享缓冲区与流式读取器,用于在下载线程与解码线程之间传递音频数据。
|
||||
|
||||
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> {
|
||||
// 只有下载完成后才能返回正确的文件总长度
|
||||
// symphonia 需要 byte_len() 来计算 seek 的字节位置
|
||||
let state = self.buffer.state_mutex().lock().unwrap();
|
||||
if state.done {
|
||||
Some(state.bytes.len() as u64)
|
||||
} else {
|
||||
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,413 @@
|
||||
//! 音频控制器
|
||||
//!
|
||||
//! 负责命令分发与播放生命周期管理。`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;
|
||||
}
|
||||
|
||||
/// 等待网络流 SharedBuffer 下载完成(用于 seek 前确保 byte_len 可用)。
|
||||
/// StreamingReader 的 byte_len() 只有在 done==true 时才返回文件长度,
|
||||
/// symphonia 才能计算 seek 的字节位置,否则 seek 会静默失败导致音频从头播放。
|
||||
fn wait_for_buffer_done(buffer: &SharedBuffer) {
|
||||
if buffer.is_done() || buffer.is_cancelled() {
|
||||
return;
|
||||
}
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(3);
|
||||
while !buffer.is_done() && !buffer.is_cancelled() {
|
||||
if std::time::Instant::now() > deadline {
|
||||
eprintln!("[audio] seek 等待网络流下载超时(3s)");
|
||||
break;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据当前播放源(本地文件或网络缓冲区)重建 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);
|
||||
|
||||
// 对网络流,等待 SharedBuffer 下载完成后再 seek
|
||||
if current_local_path.is_none() {
|
||||
if let Some(ref buffer) = current_audio_buffer {
|
||||
wait_for_buffer_done(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
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,316 @@
|
||||
//! 音频解码
|
||||
//!
|
||||
//! 基于 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),
|
||||
};
|
||||
if let Err(e) = format_reader.seek(SeekMode::Accurate, seek_to) {
|
||||
eprintln!("[audio] seek 失败(time={}s): {}", time, e);
|
||||
}
|
||||
}
|
||||
|
||||
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)),
|
||||
}
|
||||
}
|
||||
+111
-25
@@ -1,12 +1,14 @@
|
||||
use tauri::{
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
menu::{MenuBuilder, MenuItemBuilder},
|
||||
menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem},
|
||||
Manager, Emitter,
|
||||
};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
mod api;
|
||||
mod audio;
|
||||
mod media_controls;
|
||||
mod music_sources;
|
||||
use api::ApiController;
|
||||
use audio::AppAudio;
|
||||
|
||||
@@ -17,7 +19,6 @@ pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.setup(|app| {
|
||||
|
||||
// 注入控制器
|
||||
let app_data_dir = app.path().app_data_dir().expect("无法获取应用数据目录");
|
||||
let api_controller = ApiController::new(app_data_dir);
|
||||
app.manage(api_controller);
|
||||
@@ -26,36 +27,65 @@ pub fn run() {
|
||||
let app_audio = AppAudio(std::sync::Mutex::new(audio_controller));
|
||||
app.manage(app_audio);
|
||||
|
||||
// 托盘菜单
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use raw_window_handle::HasWindowHandle;
|
||||
use raw_window_handle::RawWindowHandle;
|
||||
let hwnd = if let Some(win) = app.get_webview_window("main") {
|
||||
win.window_handle().ok().and_then(|h| {
|
||||
if let RawWindowHandle::Win32(h) = h.as_raw() {
|
||||
Some(h.hwnd.get() as *mut std::ffi::c_void)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Windows 上 souvlaki 要求 hwnd 必须存在,否则 panic
|
||||
// 窗口尚未创建时跳过媒体控制初始化(不阻塞 app 启动)
|
||||
if hwnd.is_some() {
|
||||
media_controls::start_media_controls(app.handle().clone(), hwnd);
|
||||
} else {
|
||||
eprintln!("[media_controls] 窗口未就绪,跳过媒体控制初始化");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
media_controls::start_media_controls(app.handle().clone(), None);
|
||||
|
||||
let show = MenuItemBuilder::with_id("show", "显示窗口").build(app)?;
|
||||
let _sep1 = PredefinedMenuItem::separator(app)?;
|
||||
let prev = MenuItemBuilder::with_id("prev", "上一首").build(app)?;
|
||||
let play_pause = MenuItemBuilder::with_id("play_pause", "播放/暂停").build(app)?;
|
||||
let next = MenuItemBuilder::with_id("next", "下一首").build(app)?;
|
||||
let prev = MenuItemBuilder::with_id("prev", "上一首").build(app)?;
|
||||
let _sep2 = PredefinedMenuItem::separator(app)?;
|
||||
let quit = MenuItemBuilder::with_id("quit", "退出").build(app)?;
|
||||
|
||||
let menu = MenuBuilder::new(app)
|
||||
.item(&show)
|
||||
.separator()
|
||||
.item(&play_pause)
|
||||
.item(&next)
|
||||
.item(&prev)
|
||||
.items(&[&prev, &play_pause, &next])
|
||||
.separator()
|
||||
.item(&quit)
|
||||
.build()?;
|
||||
|
||||
// 托盘图标(使用应用默认图标)
|
||||
let icon = app.default_window_icon().cloned().unwrap();
|
||||
|
||||
let _tray = TrayIconBuilder::with_id("main-tray")
|
||||
.tooltip("Nekosonic")
|
||||
.tooltip("Nekosonic Music")
|
||||
.icon(icon)
|
||||
.show_menu_on_left_click(false)
|
||||
.menu(&menu)
|
||||
.on_menu_event(|app, event| {
|
||||
let window = app.get_webview_window("main").unwrap();
|
||||
match event.id().as_ref() {
|
||||
"show" => {
|
||||
window.show().unwrap();
|
||||
window.set_focus().unwrap();
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
let _ = app.emit("window-shown", ());
|
||||
}
|
||||
}
|
||||
"play_pause" => {
|
||||
let _ = app.emit("tray-play-pause", ());
|
||||
@@ -83,16 +113,19 @@ pub fn run() {
|
||||
} = event
|
||||
{
|
||||
let app = tray.app_handle();
|
||||
let window = app.get_webview_window("main").unwrap();
|
||||
window.show().unwrap();
|
||||
window.set_focus().unwrap();
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
let _ = app.emit("window-shown", ());
|
||||
}
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
// 点击关闭按钮时隐藏到托盘
|
||||
let window = app.get_webview_window("main").unwrap();
|
||||
let window_clone = window.clone();
|
||||
let app_handle = app.handle().clone();
|
||||
window.on_window_event(move |event| {
|
||||
if let tauri::WindowEvent::CloseRequested { api: close_api, .. } = event {
|
||||
if ALLOW_EXIT.load(Ordering::SeqCst) {
|
||||
@@ -100,6 +133,7 @@ pub fn run() {
|
||||
}
|
||||
close_api.prevent_close();
|
||||
let _ = window_clone.hide();
|
||||
let _ = app_handle.emit("window-hidden", ());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -110,14 +144,26 @@ pub fn run() {
|
||||
api::logout,
|
||||
|
||||
api::search_songs,
|
||||
api::cloudsearch,
|
||||
api::search_suggest,
|
||||
api::get_song_url,
|
||||
api::search_songs_multi,
|
||||
api::get_external_song_url,
|
||||
api::get_hot_search,
|
||||
api::get_playlist_detail,
|
||||
api::get_lyric,
|
||||
api::user_playlist,
|
||||
api::recommend_resource,
|
||||
api::recommend_songs,
|
||||
api::personalized,
|
||||
api::personalized_newsong,
|
||||
api::top_artists,
|
||||
api::top_song,
|
||||
api::album_newest,
|
||||
api::personal_fm,
|
||||
api::personal_fm_mode,
|
||||
api::fm_trash,
|
||||
api::scrobble,
|
||||
api::get_song_detail,
|
||||
api::get_qr_key,
|
||||
api::create_qr,
|
||||
@@ -131,16 +177,56 @@ pub fn run() {
|
||||
api::playlist_track_all,
|
||||
api::exit_app,
|
||||
|
||||
audio::play_audio,
|
||||
audio::pause_audio,
|
||||
audio::resume_audio,
|
||||
audio::stop_audio,
|
||||
audio::get_output_devices,
|
||||
audio::set_output_device,
|
||||
audio::seek_audio,
|
||||
audio::set_volume
|
||||
audio::commands::play_audio,
|
||||
audio::commands::play_local_audio,
|
||||
audio::commands::pause_audio,
|
||||
audio::commands::resume_audio,
|
||||
audio::commands::stop_audio,
|
||||
audio::commands::get_output_devices,
|
||||
audio::commands::set_output_device,
|
||||
audio::commands::seek_audio,
|
||||
audio::commands::get_audio_position,
|
||||
audio::commands::set_volume,
|
||||
audio::commands::is_audio_playing,
|
||||
|
||||
api::download_song,
|
||||
api::list_local_songs,
|
||||
api::scan_local_folders,
|
||||
api::delete_local_song,
|
||||
api::check_local_song,
|
||||
api::get_default_download_path,
|
||||
|
||||
api::artist_detail,
|
||||
api::artist_songs,
|
||||
api::artist_album,
|
||||
api::artist_desc,
|
||||
api::artist_sub,
|
||||
api::artist_sublist,
|
||||
api::album_detail,
|
||||
api::comment_new,
|
||||
api::comment_hot,
|
||||
api::comment_floor,
|
||||
api::comment_like,
|
||||
api::user_cloud,
|
||||
api::user_cloud_detail,
|
||||
api::user_cloud_del,
|
||||
api::cloud_upload,
|
||||
api::read_image_as_data_url,
|
||||
api::show_item_in_folder,
|
||||
])
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
let _ = window.unminimize();
|
||||
let _ = app.emit("window-shown", ());
|
||||
}
|
||||
}))
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running Nekosonic");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tauri::{AppHandle, Emitter, Listener};
|
||||
use souvlaki::{
|
||||
MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback,
|
||||
MediaPosition, PlatformConfig, SeekDirection,
|
||||
};
|
||||
|
||||
struct MediaState {
|
||||
controls: MediaControls,
|
||||
}
|
||||
|
||||
pub fn start_media_controls(app_handle: AppHandle, hwnd: Option<*mut std::ffi::c_void>) {
|
||||
let config = PlatformConfig {
|
||||
dbus_name: "nekosonic",
|
||||
display_name: "Nekosonic",
|
||||
hwnd,
|
||||
};
|
||||
|
||||
let mut controls = match MediaControls::new(config) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to create media controls: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let ah = app_handle.clone();
|
||||
if let Err(e) = controls.attach(move |event: MediaControlEvent| {
|
||||
let cmd = match &event {
|
||||
MediaControlEvent::Play => "Play",
|
||||
MediaControlEvent::Pause => "Pause",
|
||||
MediaControlEvent::Toggle => "PlayPause",
|
||||
MediaControlEvent::Next => "Next",
|
||||
MediaControlEvent::Previous => "Previous",
|
||||
MediaControlEvent::Stop => "Stop",
|
||||
MediaControlEvent::Raise => "Raise",
|
||||
MediaControlEvent::Quit => "Quit",
|
||||
MediaControlEvent::SetVolume(v) => {
|
||||
let _ = ah.emit("mpris-command", format!("SetVolume:{v}"));
|
||||
return;
|
||||
}
|
||||
MediaControlEvent::Seek(dir) => {
|
||||
let offset_us = match dir {
|
||||
SeekDirection::Forward => 5_000_000i64,
|
||||
SeekDirection::Backward => -5_000_000i64,
|
||||
};
|
||||
let _ = ah.emit("mpris-command", format!("Seek:{offset_us}"));
|
||||
return;
|
||||
}
|
||||
MediaControlEvent::SeekBy(dir, duration) => {
|
||||
let offset_us: i64 = match dir {
|
||||
SeekDirection::Forward => duration.as_micros() as i64,
|
||||
SeekDirection::Backward => -(duration.as_micros() as i64),
|
||||
};
|
||||
let _ = ah.emit("mpris-command", format!("Seek:{offset_us}"));
|
||||
return;
|
||||
}
|
||||
MediaControlEvent::SetPosition(pos) => {
|
||||
let pos_us = pos.0.as_micros() as i64;
|
||||
let _ = ah.emit("mpris-command", format!("SetPosition:{pos_us}"));
|
||||
return;
|
||||
}
|
||||
MediaControlEvent::OpenUri(_) => return,
|
||||
};
|
||||
let _ = ah.emit("mpris-command", cmd);
|
||||
}) {
|
||||
eprintln!("Failed to attach media control handler: {e}");
|
||||
return;
|
||||
}
|
||||
|
||||
let state = Arc::new(Mutex::new(MediaState { controls }));
|
||||
let state_for_listener = state.clone();
|
||||
|
||||
app_handle.listen("playback-state", move |event| {
|
||||
if let Ok(data) = serde_json::from_str::<serde_json::Value>(event.payload()) {
|
||||
let mut s = match state_for_listener.lock() {
|
||||
Ok(s) => s,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
if let Some(status) = data.get("status").and_then(|v| v.as_str()) {
|
||||
let position_us = data.get("positionUs").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let progress = if position_us > 0 {
|
||||
Some(MediaPosition(std::time::Duration::from_micros(position_us as u64)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let playback = match status {
|
||||
"playing" => MediaPlayback::Playing { progress },
|
||||
"paused" => MediaPlayback::Paused { progress },
|
||||
_ => MediaPlayback::Stopped,
|
||||
};
|
||||
let _ = s.controls.set_playback(playback);
|
||||
}
|
||||
|
||||
let title = data.get("title").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let album = data.get("album").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let artists = data
|
||||
.get("artists")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|a| a.as_str().map(|s| s.to_owned()))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let artist_str = artists.join(", ");
|
||||
let cover_url = data.get("coverUrl").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let duration_us = data.get("durationUs").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
|
||||
let metadata = MediaMetadata {
|
||||
title: if title.is_empty() { None } else { Some(title) },
|
||||
album: if album.is_empty() { None } else { Some(album) },
|
||||
artist: if artist_str.is_empty() { None } else { Some(&artist_str) },
|
||||
cover_url: if cover_url.is_empty() { None } else { Some(cover_url) },
|
||||
duration: if duration_us > 0 {
|
||||
Some(std::time::Duration::from_micros(duration_us as u64))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
};
|
||||
let _ = s.controls.set_metadata(metadata);
|
||||
}
|
||||
});
|
||||
|
||||
std::mem::forget(state);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
// 波点音乐(酷我旗下子品牌)
|
||||
// 参考 SPlayer bodian.ts / UnblockNeteaseMusic bodian.js
|
||||
//
|
||||
// 搜索:复用 kuwo 的 search.kuwo.cn/r.s 接口(无需签名)
|
||||
// URL:bd-api.kuwo.cn/api/play/music/v2/audioUrl,需 sign 签名 + 先调一次广告接口
|
||||
//
|
||||
// sign 算法(来自 bodian.js generateSign):
|
||||
// 1. url 末尾追加 ×tamp={ms}
|
||||
// 2. 提取 ? 后的 query 部分
|
||||
// 3. 去除非字母数字字符后按字符排序
|
||||
// 4. 拼接 "kuwotest" + sorted_chars + url.pathname
|
||||
// 5. md5
|
||||
|
||||
use async_trait::async_trait;
|
||||
use md5::compute as md5_compute;
|
||||
use serde_json::Value;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use super::{ExternalSong, MusicSource, build_client, parse_kuwo_abslist, parse_kuwo_json, truncate_chars};
|
||||
|
||||
const UA: &str = "Dart/2.19 (dart:io)";
|
||||
|
||||
pub struct BodianSource {
|
||||
label: String,
|
||||
/// 随机设备 ID(参考 SPlayer,每次实例化生成)
|
||||
device_id: String,
|
||||
}
|
||||
|
||||
impl BodianSource {
|
||||
pub fn new(label: String) -> Self {
|
||||
Self {
|
||||
label,
|
||||
device_id: gen_device_id(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成随机设备 ID(参考 SPlayer getRandomDeviceId)
|
||||
/// 用系统时间纳秒作为种子,每次实例化不同
|
||||
fn gen_device_id() -> String {
|
||||
let ns = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
// 取纳秒的低 11 位数字,范围 0~100000000000
|
||||
let n = (ns % 100_000_000_001) as u64;
|
||||
n.to_string()
|
||||
}
|
||||
|
||||
fn now_ms() -> u128 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// 生成 bodian 风格签名 URL
|
||||
/// 输入:原始 url(含 query),如 http://bd-api.kuwo.cn/api/play/music/v2/audioUrl?&br=320kmp3&musicId=123
|
||||
/// 输出:追加 ×tamp=...&sign=... 的完整 url
|
||||
///
|
||||
/// 算法(参考 UnblockNeteaseMusic bodian.js generateSign):
|
||||
/// 1. str += ×tamp={ms}
|
||||
/// 2. query = str 中 ? 之后的部分
|
||||
/// 3. filteredChars = query 去除非字母数字后按字符排序
|
||||
/// 4. dataToEncrypt = "kuwotest" + filteredChars + url.pathname
|
||||
/// 5. sign = md5(dataToEncrypt)
|
||||
fn generate_sign(url: &str) -> String {
|
||||
let ts = now_ms();
|
||||
let with_ts = format!("{}×tamp={}", url, ts);
|
||||
// 提取 pathname(? 之前的部分去掉 scheme://host)
|
||||
let (scheme_host, pathname, query_with_ts) = split_url(&with_ts);
|
||||
let _ = scheme_host; // 不需要
|
||||
// 提取 ? 之后的 query 部分(含 timestamp)
|
||||
let q = query_with_ts;
|
||||
// 去除非字母数字,按字符排序
|
||||
let mut chars: Vec<char> = q.chars().filter(|c| c.is_ascii_alphanumeric()).collect();
|
||||
chars.sort();
|
||||
let sorted: String = chars.iter().collect();
|
||||
let data = format!("kuwotest{}{}", sorted, pathname);
|
||||
let hash = md5_compute(data.as_bytes());
|
||||
let sign = format!("{:x}", hash);
|
||||
format!("{}&sign={}", with_ts, sign)
|
||||
}
|
||||
|
||||
/// 将 URL 拆分为 (scheme://host, pathname, query)
|
||||
/// 例:http://bd-api.kuwo.cn/api/play/music/v2/audioUrl?&br=320kmp3&musicId=123×tamp=...
|
||||
/// → ("http://bd-api.kuwo.cn", "/api/play/music/v2/audioUrl", "&br=320kmp3&musicId=123×tamp=...")
|
||||
fn split_url(url: &str) -> (String, String, String) {
|
||||
// 分离 query
|
||||
let (path_part, query) = url.split_once('?').map(|(p, q)| (p.to_string(), q.to_string()))
|
||||
.unwrap_or((url.to_string(), String::new()));
|
||||
// path_part 形如 http://bd-api.kuwo.cn/api/play/music/v2/audioUrl
|
||||
// 找到 scheme:// 后的第三个 /(即 host 之后的第一个 /)
|
||||
let scheme_end = path_part.find("://").map(|i| i + 3).unwrap_or(0);
|
||||
let after_scheme = &path_part[scheme_end..];
|
||||
let path_start = after_scheme.find('/').map(|i| scheme_end + i).unwrap_or(path_part.len());
|
||||
let scheme_host = path_part[..path_start].to_string();
|
||||
let pathname = path_part[path_start..].to_string();
|
||||
(scheme_host, pathname, query)
|
||||
}
|
||||
|
||||
fn client() -> Result<reqwest::Client, String> {
|
||||
build_client(UA, 10)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MusicSource for BodianSource {
|
||||
fn id(&self) -> &str {
|
||||
"bodian"
|
||||
}
|
||||
fn label(&self) -> &str {
|
||||
&self.label
|
||||
}
|
||||
|
||||
async fn search(&self, keyword: &str, _page: u32, limit: u32) -> Result<Vec<ExternalSong>, String> {
|
||||
let client = client()?;
|
||||
let kw = urlencoding::encode(keyword);
|
||||
let url = format!(
|
||||
"http://search.kuwo.cn/r.s?&correct=1&vipver=1&stype=comprehensive&encoding=utf8&rformat=json&mobi=1&show_copyright_off=1&searchapi=6&all={}",
|
||||
kw
|
||||
);
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("bodian 搜索请求失败: {}", e))?;
|
||||
let text = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("bodian 读取响应失败: {}", e))?;
|
||||
|
||||
// 复用 kuwo 的 JSON 解析(含单引号 JSON 兜底)和 abslist 解析逻辑
|
||||
let json: Value = parse_kuwo_json(&text, "bodian")?;
|
||||
|
||||
let abslist = json["content"][1]["musicpage"]["abslist"]
|
||||
.as_array()
|
||||
.ok_or_else(|| format!("bodian 搜索结果格式异常, body(前300): {}", truncate_chars(&text, 300)))?;
|
||||
|
||||
let list = parse_kuwo_abslist(&abslist[..abslist.len().min(limit as usize)], "bodian", &self.label);
|
||||
if list.is_empty() {
|
||||
return Err("bodian 搜索无结果".into());
|
||||
}
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
async fn get_url(&self, song_id: &str, quality: &str) -> Result<String, String> {
|
||||
let client = client()?;
|
||||
// 1. 先调一次广告接口(参考 SPlayer sendAdFreeRequest)
|
||||
// 模拟"观看广告"获取免费播放权限
|
||||
let _ = client
|
||||
.post("http://bd-api.kuwo.cn/api/service/advert/watch?uid=-1&token=×tamp=1724306124436&sign=15a676d66285117ad714e8c8371691da")
|
||||
.header("user-agent", "Dart/2.19 (dart:io)")
|
||||
.header("plat", "ar")
|
||||
.header("channel", "aliopen")
|
||||
.header("devid", &self.device_id)
|
||||
.header("ver", "3.9.0")
|
||||
.header("host", "bd-api.kuwo.cn")
|
||||
.header("qimei36", "1e9970cbcdc20a031dee9f37100017e1840e")
|
||||
.header("content-type", "application/json; charset=utf-8")
|
||||
.body(r#"{"type":5,"subType":5,"musicId":0,"adToken":""}"#)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
// 2. 构造带签名的 audioUrl 请求
|
||||
let br = if quality.contains("lossless") || quality.contains("hires") {
|
||||
"2000kflac"
|
||||
} else {
|
||||
"320kmp3"
|
||||
};
|
||||
let base = format!(
|
||||
"http://bd-api.kuwo.cn/api/play/music/v2/audioUrl?&br={}&musicId={}",
|
||||
br, song_id
|
||||
);
|
||||
let signed_url = generate_sign(&base);
|
||||
|
||||
let resp = client
|
||||
.get(&signed_url)
|
||||
.header("user-agent", "Dart/2.19 (dart:io)")
|
||||
.header("plat", "ar")
|
||||
.header("channel", "aliopen")
|
||||
.header("devid", &self.device_id)
|
||||
.header("ver", "3.9.0")
|
||||
.header("host", "bd-api.kuwo.cn")
|
||||
.header("qimei36", "1e9970cbcdc20a031dee9f37100017e1840e")
|
||||
.header("X-Forwarded-For", "1.0.1.114")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("bodian 获取URL失败: {}", e))?;
|
||||
|
||||
let text = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("bodian 读取URL失败: {}", e))?;
|
||||
|
||||
// 解析 bd-api 返回的 JSON
|
||||
let json: Value = serde_json::from_str(&text)
|
||||
.map_err(|e| format!("bodian 解析URL JSON失败: {} | body: {}", e, text))?;
|
||||
|
||||
if json["code"].as_i64() == Some(200) {
|
||||
if let Some(url) = json["data"]["audioUrl"].as_str().filter(|s| !s.is_empty()) {
|
||||
return Ok(url.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!("bodian 无法获取URL (code={}): {}",
|
||||
json["code"].as_i64().unwrap_or(-1),
|
||||
json["msg"].as_str().unwrap_or("未知错误")))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_split_url() {
|
||||
let url = "http://bd-api.kuwo.cn/api/play/music/v2/audioUrl?&br=320kmp3&musicId=123×tamp=1700000000000";
|
||||
let (host, path, query) = split_url(url);
|
||||
assert_eq!(host, "http://bd-api.kuwo.cn");
|
||||
assert_eq!(path, "/api/play/music/v2/audioUrl");
|
||||
assert_eq!(query, "&br=320kmp3&musicId=123×tamp=1700000000000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_sign_format() {
|
||||
let url = "http://bd-api.kuwo.cn/api/play/music/v2/audioUrl?&br=320kmp3&musicId=123";
|
||||
let signed = generate_sign(url);
|
||||
// 应包含 timestamp 和 sign
|
||||
assert!(signed.contains("×tamp="));
|
||||
assert!(signed.contains("&sign="));
|
||||
// 原始 URL 应保留
|
||||
assert!(signed.starts_with("http://bd-api.kuwo.cn/api/play/music/v2/audioUrl?&br=320kmp3&musicId=123"));
|
||||
}
|
||||
|
||||
/// 真实接口测试,需手动运行: cargo test -- --ignored --nocapture
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_bodian_get_url_real() {
|
||||
// 测试:搜索「唯一 邓紫棋」
|
||||
let src = BodianSource::new("波点音乐".into());
|
||||
let kw = "唯一 邓紫棋";
|
||||
let songs = src.search(kw, 1, 10).await.expect("搜索应成功");
|
||||
println!("bodian 搜索「{}」到 {} 首", kw, songs.len());
|
||||
for s in &songs {
|
||||
println!(" - {} - {} (id={})", s.name, s.artist, s.id);
|
||||
}
|
||||
// 尝试获取前 5 首的 URL
|
||||
for s in songs.iter().take(5) {
|
||||
match src.get_url(&s.id, "standard").await {
|
||||
Ok(url) => println!(" ✅ {} -> {}", s.name, url),
|
||||
Err(e) => println!(" ❌ {} -> {}", s.name, e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// 自定义 HTTP API 音源
|
||||
// 用户通过模板 URL + JSON 路径配置搜索/获取播放地址的接口
|
||||
//
|
||||
// 模板占位符:
|
||||
// 搜索 URL:{keyword} {page} {limit}
|
||||
// URL 接口:{id} {quality}
|
||||
//
|
||||
// JSON 路径用 "." 分隔,如 "data.songs"
|
||||
// 字段映射指定结果数组每项里各字段对应的 key 名
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{ExternalSong, MusicSource, SourceConfig, build_client};
|
||||
|
||||
const UA: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36";
|
||||
|
||||
pub struct CustomHttpSource {
|
||||
cfg: SourceConfig,
|
||||
}
|
||||
|
||||
impl CustomHttpSource {
|
||||
pub fn new(cfg: SourceConfig) -> Self {
|
||||
Self { cfg }
|
||||
}
|
||||
}
|
||||
|
||||
fn client() -> Result<reqwest::Client, String> {
|
||||
build_client(UA, 10)
|
||||
}
|
||||
|
||||
/// 按 "a.b.c" 路径访问嵌套 JSON
|
||||
fn json_path<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
|
||||
let mut cur = value;
|
||||
for k in path.split('.') {
|
||||
if k.is_empty() {
|
||||
continue;
|
||||
}
|
||||
cur = cur.get(k)?;
|
||||
}
|
||||
Some(cur)
|
||||
}
|
||||
|
||||
/// 取节点的字符串值(兼容字符串/数字)
|
||||
fn value_to_string(v: &Value) -> String {
|
||||
v.as_str()
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| v.to_string())
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MusicSource for CustomHttpSource {
|
||||
fn id(&self) -> &str {
|
||||
&self.cfg.id
|
||||
}
|
||||
fn label(&self) -> &str {
|
||||
&self.cfg.label
|
||||
}
|
||||
|
||||
async fn search(&self, keyword: &str, page: u32, limit: u32) -> Result<Vec<ExternalSong>, String> {
|
||||
let client = client()?;
|
||||
let tmpl = self
|
||||
.cfg
|
||||
.search_url
|
||||
.as_deref()
|
||||
.ok_or("自定义音源缺少 search_url")?;
|
||||
let url = tmpl
|
||||
.replace("{keyword}", &urlencoding::encode(keyword))
|
||||
.replace("{page}", &page.to_string())
|
||||
.replace("{limit}", &limit.to_string());
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("custom [{}] 请求失败: {}", self.cfg.id, e))?;
|
||||
let text = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("custom [{}] 读取失败: {}", self.cfg.id, e))?;
|
||||
let json: Value = serde_json::from_str(&text)
|
||||
.map_err(|e| format!("custom [{}] JSON 解析失败: {}", self.cfg.id, e))?;
|
||||
|
||||
let arr_path = self.cfg.search_path.as_deref().unwrap_or("data");
|
||||
let arr = json_path(&json, arr_path).and_then(|v| v.as_array()).ok_or_else(|| {
|
||||
format!(
|
||||
"custom [{}] 找不到数组路径: {}(响应: {})",
|
||||
self.cfg.id, arr_path, &text[..text.len().min(200)]
|
||||
)
|
||||
})?;
|
||||
|
||||
let id_f = self.cfg.id_field.as_deref().unwrap_or("id");
|
||||
let name_f = self.cfg.name_field.as_deref().unwrap_or("name");
|
||||
let artist_f = self.cfg.artist_field.as_deref().unwrap_or("artist");
|
||||
let album_f = self.cfg.album_field.as_deref().unwrap_or("album");
|
||||
let dur_f = self.cfg.duration_field.as_deref().unwrap_or("duration");
|
||||
let pic_f = self.cfg.pic_field.as_deref().unwrap_or("pic");
|
||||
|
||||
let list: Vec<ExternalSong> = arr
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let id = json_path(s, id_f).map(value_to_string).unwrap_or_default();
|
||||
let name = json_path(s, name_f).and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let artist = json_path(s, artist_f).and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let album = json_path(s, album_f).and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let duration = json_path(s, dur_f)
|
||||
.and_then(|v| {
|
||||
v.as_u64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let pic = json_path(s, pic_f).and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
ExternalSong {
|
||||
id,
|
||||
source: self.cfg.id.clone(),
|
||||
source_label: self.cfg.label.clone(),
|
||||
name,
|
||||
artist,
|
||||
album,
|
||||
duration,
|
||||
pic_url: pic,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
async fn get_url(&self, song_id: &str, quality: &str) -> Result<String, String> {
|
||||
let client = client()?;
|
||||
let tmpl = self.cfg.url_api.as_deref().ok_or("自定义音源缺少 url_api")?;
|
||||
let url = tmpl
|
||||
.replace("{id}", &urlencoding::encode(song_id))
|
||||
.replace("{quality}", quality);
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("custom [{}] URL 请求失败: {}", self.cfg.id, e))?;
|
||||
let text = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("custom [{}] URL 读取失败: {}", self.cfg.id, e))?;
|
||||
|
||||
// 先尝试 JSON 路径取 URL;不是 JSON 则视为纯 URL 文本
|
||||
if let Ok(json) = serde_json::from_str::<Value>(&text) {
|
||||
let url_path = self.cfg.url_path.as_deref().unwrap_or("data.url");
|
||||
if let Some(v) = json_path(&json, url_path) {
|
||||
if let Some(s) = v.as_str() {
|
||||
if !s.is_empty() {
|
||||
return Ok(s.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
return Err(format!(
|
||||
"custom [{}] URL 字段为空或路径错误: {}",
|
||||
self.cfg.id, text
|
||||
));
|
||||
}
|
||||
|
||||
let s = text.trim();
|
||||
if s.is_empty() {
|
||||
return Err("custom URL 为空".into());
|
||||
}
|
||||
Ok(s.to_string())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// 酷狗音乐(内置音源)
|
||||
// 搜索:mobilecdn.kugou.com/api/v3/search/song
|
||||
// URL:trackercdn.kugou.com/i/v2/ (key = md5(hash + "kgcloudv2"))
|
||||
|
||||
use async_trait::async_trait;
|
||||
use md5::compute as md5_compute;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{ExternalSong, MusicSource, build_client, truncate_chars};
|
||||
|
||||
const UA: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36";
|
||||
|
||||
pub struct KugouSource {
|
||||
label: String,
|
||||
}
|
||||
|
||||
impl KugouSource {
|
||||
pub fn new(label: String) -> Self {
|
||||
Self { label }
|
||||
}
|
||||
}
|
||||
|
||||
fn client() -> Result<reqwest::Client, String> {
|
||||
build_client(UA, 8)
|
||||
}
|
||||
|
||||
/// 酷狗 V2 接口 key 算法:md5(hash + "kgcloudv2")
|
||||
fn make_key(hash: &str) -> String {
|
||||
let data = format!("{}kgcloudv2", hash);
|
||||
let digest = md5_compute(data.as_bytes());
|
||||
format!("{:x}", digest)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MusicSource for KugouSource {
|
||||
fn id(&self) -> &str {
|
||||
"kugou"
|
||||
}
|
||||
fn label(&self) -> &str {
|
||||
&self.label
|
||||
}
|
||||
|
||||
async fn search(&self, keyword: &str, _page: u32, limit: u32) -> Result<Vec<ExternalSong>, String> {
|
||||
let client = client()?;
|
||||
let limit_str = limit.to_string();
|
||||
let resp = client
|
||||
.get("http://mobilecdn.kugou.com/api/v3/search/song")
|
||||
.query(&[
|
||||
("keyword", keyword),
|
||||
("page", "1"),
|
||||
("pagesize", &limit_str),
|
||||
("showtype", "20"),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("kugou 搜索请求失败: {}", e))?;
|
||||
let text = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("kugou 读取响应失败: {}", e))?;
|
||||
let json: Value =
|
||||
serde_json::from_str(&text).map_err(|e| format!("kugou 解析JSON失败: {}", e))?;
|
||||
|
||||
let info = json["data"]["info"]
|
||||
.as_array()
|
||||
.ok_or_else(|| format!("kugou 搜索无结果: {}", truncate_chars(&text, 200)))?;
|
||||
|
||||
let list: Vec<ExternalSong> = info
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let hash = s["hash"].as_str().unwrap_or("").to_string();
|
||||
// V3 接口字段是 songname/album_name/singername(带下划线),老接口是 songname/albumname/singername
|
||||
let name = s["songname"]
|
||||
.as_str()
|
||||
.or_else(|| s["filename"].as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let artist = s["singername"]
|
||||
.as_str()
|
||||
.or_else(|| s["singer_name"].as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let album = s["albumname"]
|
||||
.as_str()
|
||||
.or_else(|| s["album_name"].as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let duration = s["duration"].as_u64().map(|d| d * 1000).unwrap_or(0);
|
||||
// kugou V3 搜索接口不返回封面字段,需通过 album_id 单独调接口获取
|
||||
// 这里先留空,前端会 fallback 到默认封面图标
|
||||
let pic = s["albumpic"]
|
||||
.as_str()
|
||||
.or_else(|| s["album_img"].as_str())
|
||||
.or_else(|| s["img"].as_str())
|
||||
.or_else(|| s["pic"].as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
ExternalSong {
|
||||
id: hash,
|
||||
source: "kugou".into(),
|
||||
source_label: self.label.clone(),
|
||||
name,
|
||||
artist,
|
||||
album,
|
||||
duration,
|
||||
pic_url: pic,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if list.is_empty() {
|
||||
return Err("kugou 搜索无结果".into());
|
||||
}
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
async fn get_url(&self, song_id: &str, _quality: &str) -> Result<String, String> {
|
||||
let client = client()?;
|
||||
let hash = song_id;
|
||||
let key = make_key(hash);
|
||||
let filename = format!("{}.mp3", hash);
|
||||
let resp = client
|
||||
.get("http://trackercdn.kugou.com/i/v2/")
|
||||
.query(&[
|
||||
("key", key.as_str()),
|
||||
("hash", hash),
|
||||
("br", "hq"),
|
||||
("appid", "1005"),
|
||||
("pid", "2"),
|
||||
("behavior", "play"),
|
||||
("cmd", "25"),
|
||||
("filename", &filename),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("kugou 获取URL请求失败: {}", e))?;
|
||||
let text = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("kugou 读取URL响应失败: {}", e))?;
|
||||
let json: Value =
|
||||
serde_json::from_str(&text).map_err(|e| format!("kugou 解析URL JSON失败: {} | body: {}", e, truncate_chars(&text, 300)))?;
|
||||
// 响应中 url 是数组(新接口)或字符串(旧接口)
|
||||
let url = json["url"]
|
||||
.as_array()
|
||||
.and_then(|arr| arr.first())
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| json["url"].as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| format!("kugou URL 为空: status={} | body: {}", json["status"].as_u64().unwrap_or(0), truncate_chars(&text, 300)))?;
|
||||
Ok(url.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 真实接口测试,需手动运行: cargo test -- --ignored --nocapture
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_kugou_search_and_url() {
|
||||
// 测试:搜索「唯一 邓紫棋」
|
||||
let src = KugouSource::new("酷狗音乐".into());
|
||||
let kw = "唯一 邓紫棋";
|
||||
let songs = src.search(kw, 1, 10).await.expect("搜索应成功");
|
||||
println!("kugou 搜索「{}」到 {} 首", kw, songs.len());
|
||||
for s in &songs {
|
||||
println!(" - {} - {} (id={})", s.name, s.artist, s.id);
|
||||
}
|
||||
// 尝试获取前 5 首的 URL
|
||||
for s in songs.iter().take(5) {
|
||||
match src.get_url(&s.id, "standard").await {
|
||||
Ok(url) => println!(" ✅ {} -> {}", s.name, url),
|
||||
Err(e) => println!(" ❌ {} -> {}", s.name, e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! 酷我音乐音源
|
||||
//! 搜索: search.kuwo.cn/r.s(返回单引号 JSON,需预处理)
|
||||
//! URL: mobi.kuwo.cn/mobi.s + kwDES 加密(能绕过 VIP 限制)
|
||||
//! 移植自 UnblockNeteaseMusic/server 的 kuwo.js
|
||||
|
||||
use crate::music_sources::kwdes;
|
||||
use crate::music_sources::{ExternalSong, MusicSource, build_client, parse_kuwo_abslist, parse_kuwo_json, truncate_chars};
|
||||
use serde_json::Value;
|
||||
|
||||
const UA: &str = "okhttp/3.10.0";
|
||||
|
||||
pub struct KuwoSource {
|
||||
label: String,
|
||||
}
|
||||
|
||||
impl KuwoSource {
|
||||
pub fn new(label: String) -> Self {
|
||||
Self { label }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MusicSource for KuwoSource {
|
||||
fn id(&self) -> &str {
|
||||
"kuwo"
|
||||
}
|
||||
|
||||
fn label(&self) -> &str {
|
||||
&self.label
|
||||
}
|
||||
|
||||
async fn search(&self, keyword: &str, _page: u32, limit: u32) -> Result<Vec<ExternalSong>, String> {
|
||||
let url = format!(
|
||||
"http://search.kuwo.cn/r.s?correct=1&vipver=1&stype=comprehensive&encoding=utf8&rformat=json&mobi=1&show_copyright_off=1&searchapi=6&all={}",
|
||||
urlencoding::encode(keyword)
|
||||
);
|
||||
|
||||
let client = build_client(UA, 10)?;
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("X-Forwarded-For", "1.0.1.114")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("kuwo 搜索请求失败: {}", e))?;
|
||||
|
||||
let text = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("kuwo 搜索读取失败: {}", e))?;
|
||||
|
||||
let json: Value = parse_kuwo_json(&text, "kuwo")?;
|
||||
|
||||
let abs_list = json["content"][1]["musicpage"]["abslist"]
|
||||
.as_array()
|
||||
.ok_or_else(|| format!("kuwo 搜索结果格式异常, body(前300): {}", truncate_chars(&text, 300)))?;
|
||||
|
||||
Ok(parse_kuwo_abslist(&abs_list[..abs_list.len().min(limit as usize)], "kuwo", &self.label))
|
||||
}
|
||||
|
||||
async fn get_url(&self, song_id: &str, _quality: &str) -> Result<String, String> {
|
||||
// 用 kwDES 加密的 mobi.s 接口获取播放 URL(能绕过 VIP 限制)
|
||||
let query = format!(
|
||||
"corp=kuwo&source=kwplayer_ar_5.1.0.0_B_jiakong_vh.apk&p2p=1&type=convert_url2&sig=0&format=mp3&rid={}",
|
||||
song_id
|
||||
);
|
||||
let encrypted = kwdes::encrypt_query(&query);
|
||||
let url = format!("http://mobi.kuwo.cn/mobi.s?f=kuwo&q={}", encrypted);
|
||||
|
||||
let client = build_client(UA, 10)?;
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("kuwo mobi.s 请求失败: {}", e))?;
|
||||
|
||||
let text = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("kuwo mobi.s 读取失败: {}", e))?;
|
||||
|
||||
// 解析 key=value 格式响应(每行一个),提取 url 和 bitrate
|
||||
let mut url_val = String::new();
|
||||
let mut bitrate: u64 = 0;
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if let Some(pos) = line.find('=') {
|
||||
let key = line[..pos].trim();
|
||||
let val = line[pos + 1..].trim();
|
||||
match key {
|
||||
"url" => url_val = val.to_string(),
|
||||
"bitrate" => bitrate = val.parse::<u64>().unwrap_or(0),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 广告音频检测:bitrate < 32 通常是"酷我音乐以为你免费开放播放权限"提示音
|
||||
// 正常歌曲 bitrate >= 128
|
||||
if bitrate > 0 && bitrate < 32 {
|
||||
return Err(format!(
|
||||
"kuwo 返回广告音频 (bitrate={}), song_id={}",
|
||||
bitrate, song_id
|
||||
));
|
||||
}
|
||||
|
||||
if url_val.starts_with("http") {
|
||||
Ok(url_val)
|
||||
} else {
|
||||
Err(format!(
|
||||
"kuwo mobi.s 未返回有效 URL: {}",
|
||||
truncate_chars(&text, 200)
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 真实接口测试:搜索 + 获取 VIP 歌曲 URL
|
||||
/// 手动运行: cargo test -- --ignored --nocapture
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_kuwo_search_and_url() {
|
||||
let src = KuwoSource::new("酷我音乐".into());
|
||||
let kw = "唯一 邓紫棋";
|
||||
let songs = src.search(kw, 1, 10).await.expect("搜索应成功");
|
||||
println!("kuwo 搜索「{}」到 {} 首", kw, songs.len());
|
||||
for s in &songs {
|
||||
println!(" - {} - {} (id={})", s.name, s.artist, s.id);
|
||||
}
|
||||
// 尝试获取前 5 首的 URL
|
||||
for s in songs.iter().take(5) {
|
||||
match src.get_url(&s.id, "standard").await {
|
||||
Ok(url) => println!(" ✅ {} -> {}", s.name, url),
|
||||
Err(e) => println!(" ❌ {} -> {}", s.name, e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
//! kwDES 加密算法 —— 酷我音乐 mobi.s 接口的 query 加密
|
||||
//! 移植自 UnblockNeteaseMusic/server 的 kwDES.js
|
||||
//! 参考: https://github.com/UnblockNeteaseMusic/server/blob/enhanced/src/kwDES.js
|
||||
|
||||
// EXPANSION (E 扩展置换, 64个元素, 32->48位)
|
||||
const ARRAY_E: [i32; 64] = [
|
||||
31, 0, 1, 2, 3, 4, -1, -1, 3, 4, 5, 6, 7, 8, -1, -1,
|
||||
7, 8, 9, 10, 11, 12, -1, -1, 11, 12, 13, 14, 15, 16, -1, -1,
|
||||
15, 16, 17, 18, 19, 20, -1, -1, 19, 20, 21, 22, 23, 24, -1, -1,
|
||||
23, 24, 25, 26, 27, 28, -1, -1, 27, 28, 29, 30, 31, 30, -1, -1,
|
||||
];
|
||||
|
||||
// INITIAL_PERMUTATION (IP 初始置换, 64个元素)
|
||||
const ARRAY_IP: [i32; 64] = [
|
||||
57, 49, 41, 33, 25, 17, 9, 1,
|
||||
59, 51, 43, 35, 27, 19, 11, 3,
|
||||
61, 53, 45, 37, 29, 21, 13, 5,
|
||||
63, 55, 47, 39, 31, 23, 15, 7,
|
||||
56, 48, 40, 32, 24, 16, 8, 0,
|
||||
58, 50, 42, 34, 26, 18, 10, 2,
|
||||
60, 52, 44, 36, 28, 20, 12, 4,
|
||||
62, 54, 46, 38, 30, 22, 14, 6,
|
||||
];
|
||||
|
||||
// INVERSE_PERMUTATION (IP-1 逆置换, 64个元素)
|
||||
const ARRAY_IP_1: [i32; 64] = [
|
||||
39, 7, 47, 15, 55, 23, 63, 31,
|
||||
38, 6, 46, 14, 54, 22, 62, 30,
|
||||
37, 5, 45, 13, 53, 21, 61, 29,
|
||||
36, 4, 44, 12, 52, 20, 60, 28,
|
||||
35, 3, 43, 11, 51, 19, 59, 27,
|
||||
34, 2, 42, 10, 50, 18, 58, 26,
|
||||
33, 1, 41, 9, 49, 17, 57, 25,
|
||||
32, 0, 40, 8, 48, 16, 56, 24,
|
||||
];
|
||||
|
||||
// ROTATES (循环移位表, 16个元素)
|
||||
const ARRAY_LS: [u32; 16] = [
|
||||
1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1,
|
||||
];
|
||||
|
||||
// 移位掩码
|
||||
const ARRAY_LS_MASK: [u64; 3] = [0, 0x100001, 0x300003];
|
||||
|
||||
// PERMUTATION (P 置换, 32个元素)
|
||||
const ARRAY_P: [i32; 32] = [
|
||||
15, 6, 19, 20, 28, 11, 27, 16,
|
||||
0, 14, 22, 25, 4, 17, 30, 9,
|
||||
1, 7, 23, 13, 31, 26, 2, 8,
|
||||
18, 12, 29, 5, 21, 10, 3, 24,
|
||||
];
|
||||
|
||||
// PERMUTED_CHOICE1 (PC-1, 56个元素)
|
||||
const ARRAY_PC_1: [i32; 56] = [
|
||||
56, 48, 40, 32, 24, 16, 8,
|
||||
0, 57, 49, 41, 33, 25, 17,
|
||||
9, 1, 58, 50, 42, 34, 26,
|
||||
18, 10, 2, 59, 51, 43, 35,
|
||||
62, 54, 46, 38, 30, 22, 14,
|
||||
6, 61, 53, 45, 37, 29, 21,
|
||||
13, 5, 60, 52, 44, 36, 28,
|
||||
20, 12, 4, 27, 19, 11, 3,
|
||||
];
|
||||
|
||||
// PERMUTED_CHOICE2 (PC-2, 64个元素, -1表示跳过)
|
||||
const ARRAY_PC_2: [i32; 64] = [
|
||||
13, 16, 10, 23, 0, 4, -1, -1,
|
||||
2, 27, 14, 5, 20, 9, -1, -1,
|
||||
22, 18, 11, 3, 25, 7, -1, -1,
|
||||
15, 6, 26, 19, 12, 1, -1, -1,
|
||||
40, 51, 30, 36, 46, 54, -1, -1,
|
||||
29, 39, 50, 44, 32, 47, -1, -1,
|
||||
43, 48, 38, 55, 33, 52, -1, -1,
|
||||
45, 41, 49, 35, 28, 31, -1, -1,
|
||||
];
|
||||
|
||||
// S-Box (8个子表, 每个64个元素)
|
||||
const MATRIX_NS_BOX: [[u8; 64]; 8] = [
|
||||
[
|
||||
14, 4, 3, 15, 2, 13, 5, 3, 13, 14, 6, 9, 11, 2, 0, 5, 4, 1, 10, 12, 15,
|
||||
6, 9, 10, 1, 8, 12, 7, 8, 11, 7, 0, 0, 15, 10, 5, 14, 4, 9, 10, 7, 8,
|
||||
12, 3, 13, 1, 3, 6, 15, 12, 6, 11, 2, 9, 5, 0, 4, 2, 11, 14, 1, 7, 8,
|
||||
13,
|
||||
],
|
||||
[
|
||||
15, 0, 9, 5, 6, 10, 12, 9, 8, 7, 2, 12, 3, 13, 5, 2, 1, 14, 7, 8, 11, 4,
|
||||
0, 3, 14, 11, 13, 6, 4, 1, 10, 15, 3, 13, 12, 11, 15, 3, 6, 0, 4, 10, 1,
|
||||
7, 8, 4, 11, 14, 13, 8, 0, 6, 2, 15, 9, 5, 7, 1, 10, 12, 14, 2, 5, 9,
|
||||
],
|
||||
[
|
||||
10, 13, 1, 11, 6, 8, 11, 5, 9, 4, 12, 2, 15, 3, 2, 14, 0, 6, 13, 1, 3,
|
||||
15, 4, 10, 14, 9, 7, 12, 5, 0, 8, 7, 13, 1, 2, 4, 3, 6, 12, 11, 0, 13,
|
||||
5, 14, 6, 8, 15, 2, 7, 10, 8, 15, 4, 9, 11, 5, 9, 0, 14, 3, 10, 7, 1,
|
||||
12,
|
||||
],
|
||||
[
|
||||
7, 10, 1, 15, 0, 12, 11, 5, 14, 9, 8, 3, 9, 7, 4, 8, 13, 6, 2, 1, 6, 11,
|
||||
12, 2, 3, 0, 5, 14, 10, 13, 15, 4, 13, 3, 4, 9, 6, 10, 1, 12, 11, 0, 2,
|
||||
5, 0, 13, 14, 2, 8, 15, 7, 4, 15, 1, 10, 7, 5, 6, 12, 11, 3, 8, 9, 14,
|
||||
],
|
||||
[
|
||||
2, 4, 8, 15, 7, 10, 13, 6, 4, 1, 3, 12, 11, 7, 14, 0, 12, 2, 5, 9, 10,
|
||||
13, 0, 3, 1, 11, 15, 5, 6, 8, 9, 14, 14, 11, 5, 6, 4, 1, 3, 10, 2, 12,
|
||||
15, 0, 13, 2, 8, 5, 11, 8, 0, 15, 7, 14, 9, 4, 12, 7, 10, 9, 1, 13, 6,
|
||||
3,
|
||||
],
|
||||
[
|
||||
12, 9, 0, 7, 9, 2, 14, 1, 10, 15, 3, 4, 6, 12, 5, 11, 1, 14, 13, 0, 2,
|
||||
8, 7, 13, 15, 5, 4, 10, 8, 3, 11, 6, 10, 4, 6, 11, 7, 9, 0, 6, 4, 2, 13,
|
||||
1, 9, 15, 3, 8, 15, 3, 1, 14, 12, 5, 11, 0, 2, 12, 14, 7, 5, 10, 8, 13,
|
||||
],
|
||||
[
|
||||
4, 1, 3, 10, 15, 12, 5, 0, 2, 11, 9, 6, 8, 7, 6, 9, 11, 4, 12, 15, 0, 3,
|
||||
10, 5, 14, 13, 7, 8, 13, 14, 1, 2, 13, 6, 14, 9, 4, 1, 2, 14, 11, 13, 5,
|
||||
0, 1, 10, 8, 3, 0, 11, 3, 5, 9, 4, 15, 2, 7, 8, 12, 15, 10, 7, 6, 12,
|
||||
],
|
||||
[
|
||||
13, 7, 10, 0, 6, 9, 5, 15, 8, 4, 3, 10, 11, 14, 12, 5, 2, 11, 9, 6, 15,
|
||||
12, 0, 3, 4, 1, 14, 13, 1, 2, 7, 8, 1, 2, 12, 15, 10, 4, 0, 3, 13, 14,
|
||||
6, 9, 7, 8, 9, 6, 15, 1, 5, 12, 3, 10, 14, 5, 8, 7, 11, 0, 4, 13, 2, 11,
|
||||
],
|
||||
];
|
||||
|
||||
/// 按位置换:从 l 中按 arr 的索引取位,放到结果的对应位置
|
||||
fn bit_transform(arr: &[i32], l: u64) -> u64 {
|
||||
let mut result: u64 = 0;
|
||||
for (i, &idx) in arr.iter().enumerate() {
|
||||
if idx < 0 {
|
||||
continue;
|
||||
}
|
||||
if l & (1u64 << idx as u64) != 0 {
|
||||
result |= 1u64 << i as u64;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// 生成 16 个子密钥
|
||||
fn sub_keys(l: u64, mode: i32) -> [u64; 16] {
|
||||
let mut l2 = bit_transform(&ARRAY_PC_1, l);
|
||||
let mut longs = [0u64; 16];
|
||||
|
||||
for i in 0..16 {
|
||||
let ls = ARRAY_LS[i] as u32;
|
||||
let mask = ARRAY_LS_MASK[ARRAY_LS[i] as usize];
|
||||
|
||||
// (l2 & mask) << (28 - ls) | (l2 & !mask) >> ls
|
||||
let part1 = (l2 & mask).wrapping_shl(28 - ls);
|
||||
let part2 = (l2 & !mask) >> ls;
|
||||
l2 = part1 | part2;
|
||||
|
||||
longs[i] = bit_transform(&ARRAY_PC_2, l2);
|
||||
}
|
||||
|
||||
// 解密模式:反转子密钥顺序
|
||||
if mode == 1 {
|
||||
for j in 0..8 {
|
||||
longs.swap(j, 15 - j);
|
||||
}
|
||||
}
|
||||
|
||||
longs
|
||||
}
|
||||
|
||||
/// DES 加密一个 64 位块
|
||||
fn des64(subkeys: &[u64; 16], l: u64) -> u64 {
|
||||
let mut out = bit_transform(&ARRAY_IP, l);
|
||||
let mut p0 = out & 0xffffffff; // 低 32 位 (pSource[0])
|
||||
let mut p1 = out >> 32; // 高 32 位 (pSource[1])
|
||||
|
||||
for i in 0..16 {
|
||||
let mut r = p1;
|
||||
r = bit_transform(&ARRAY_E, r);
|
||||
r ^= subkeys[i];
|
||||
|
||||
// 取 8 个 8 位组
|
||||
let pr: [u64; 8] = [
|
||||
(r >> 0) & 255,
|
||||
(r >> 8) & 255,
|
||||
(r >> 16) & 255,
|
||||
(r >> 24) & 255,
|
||||
(r >> 32) & 255,
|
||||
(r >> 40) & 255,
|
||||
(r >> 48) & 255,
|
||||
(r >> 56) & 255,
|
||||
];
|
||||
|
||||
// S-Box 替换(反向遍历,每次左移 4 位)
|
||||
let mut s_out: u64 = 0;
|
||||
for sbi in (0..8).rev() {
|
||||
s_out = (s_out << 4) | MATRIX_NS_BOX[sbi][pr[sbi] as usize] as u64;
|
||||
}
|
||||
|
||||
r = bit_transform(&ARRAY_P, s_out);
|
||||
let temp = p0;
|
||||
p0 = p1;
|
||||
p1 = temp ^ r;
|
||||
}
|
||||
|
||||
// pSource.reverse() 后: pSource[0] = p1, pSource[1] = p0
|
||||
// out = (pSource[1] << 32) | pSource[0] = (p0 << 32) | p1
|
||||
out = (p0 << 32) | (p1 & 0xffffffff);
|
||||
out = bit_transform(&ARRAY_IP_1, out);
|
||||
out
|
||||
}
|
||||
|
||||
/// 加密/解密消息
|
||||
fn crypt(msg: &[u8], key: &[u8], mode: i32) -> Vec<u8> {
|
||||
// 处理密钥块:将 8 字节密钥转为 u64
|
||||
let mut l: u64 = 0;
|
||||
for i in 0..8 {
|
||||
l |= (key[i] as u64) << (i * 8);
|
||||
}
|
||||
|
||||
let subkeys = sub_keys(l, mode);
|
||||
|
||||
let j = msg.len() / 8;
|
||||
let mut result = Vec::new();
|
||||
|
||||
// 处理完整块
|
||||
for m in 0..j {
|
||||
let mut block: u64 = 0;
|
||||
for n in 0..8 {
|
||||
block |= (msg[n + m * 8] as u64) << (n * 8);
|
||||
}
|
||||
let encrypted = des64(&subkeys, block);
|
||||
for n in 0..8 {
|
||||
result.push((encrypted >> (n * 8)) as u8);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理剩余字节(加密模式追加完整块)
|
||||
let remainder = msg.len() % 8;
|
||||
if remainder > 0 || mode == 0 {
|
||||
let mut block: u64 = 0;
|
||||
let start = j * 8;
|
||||
for i in 0..remainder {
|
||||
block |= (msg[start + i] as u64) << (i * 8);
|
||||
}
|
||||
let encrypted = des64(&subkeys, block);
|
||||
for n in 0..8 {
|
||||
result.push((encrypted >> (n * 8)) as u8);
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
const SECRET_KEY: &[u8] = b"ylzsxkwm";
|
||||
|
||||
/// 加密 query 字符串并返回 base64 编码
|
||||
pub fn encrypt_query(query: &str) -> String {
|
||||
use base64::Engine;
|
||||
let encrypted = crypt(query.as_bytes(), SECRET_KEY, 0);
|
||||
base64::engine::general_purpose::STANDARD.encode(&encrypted)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 验证 kwDES 加密能正常执行且输出 base64
|
||||
#[test]
|
||||
fn test_encrypt_query_basic() {
|
||||
let query = "corp=kuwo&source=kwplayer_ar_5.1.0.0_B_jiakong_vh.apk&p2p=1&type=convert_url2&sig=0&format=mp3&rid=321260769";
|
||||
let result = encrypt_query(query);
|
||||
assert!(!result.is_empty());
|
||||
// base64 字符串只包含合法字符
|
||||
assert!(result.chars().all(|c| c.is_alphanumeric() || c == '+' || c == '/' || c == '='));
|
||||
println!("kwDES encrypt_query result: {}", result);
|
||||
}
|
||||
|
||||
/// 真实接口测试:用 kwDES 加密的 query 请求 kuwo mobi.s 获取 VIP 歌曲 URL
|
||||
/// 手动运行: cargo test -- --ignored --nocapture
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_kuwo_mobi_with_kwdes() {
|
||||
let rid = "321260769"; // 邓紫棋「唯一」VIP 歌曲
|
||||
let query = format!(
|
||||
"corp=kuwo&source=kwplayer_ar_5.1.0.0_B_jiakong_vh.apk&p2p=1&type=convert_url2&sig=0&format=mp3&rid={}",
|
||||
rid
|
||||
);
|
||||
let encrypted = encrypt_query(&query);
|
||||
let url = format!("http://mobi.kuwo.cn/mobi.s?f=kuwo&q={}", encrypted);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("user-agent", "okhttp/3.10.0")
|
||||
.send()
|
||||
.await
|
||||
.expect("请求应成功");
|
||||
|
||||
let text = resp.text().await.expect("读取响应应成功");
|
||||
println!("kuwo mobi.s 响应 (前 500 字符): {}", &text[..text.len().min(500)]);
|
||||
|
||||
// 响应中应包含 http URL
|
||||
assert!(text.contains("http"), "响应应包含 URL: {}", text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
// 音源模块:可扩展架构(内置 + 自定义 HTTP API)
|
||||
//
|
||||
// 设计要点:
|
||||
// - MusicSource trait 定义统一接口(search + get_url)
|
||||
// - 内置源:bodian(波点,首选)/ kugou(酷狗,次选)
|
||||
// - 自定义源:通过 SourceConfig 配置 HTTP API 模板,由 CustomHttpSource 实现
|
||||
// - 调用方传入 Vec<SourceConfig>,模块根据 kind 路由到对应实现
|
||||
// - 多源聚合搜索:并发请求所有启用源,按配置顺序合并结果
|
||||
|
||||
pub mod bodian;
|
||||
pub mod custom;
|
||||
pub mod kugou;
|
||||
pub mod kwdes;
|
||||
pub mod kuwo;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
/// 音源配置(前端持久化、传给后端都用这个结构)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SourceConfig {
|
||||
/// 唯一 ID
|
||||
/// - builtin: 'bodian' / 'kugou' / 'kuwo' / 'bilibili'
|
||||
/// - custom: 'custom-xxxx'(前端生成)
|
||||
pub id: String,
|
||||
/// 显示名称
|
||||
pub label: String,
|
||||
/// 是否启用
|
||||
pub enabled: bool,
|
||||
/// 类型: "builtin" | "custom"
|
||||
pub kind: String,
|
||||
|
||||
// ===== 自定义 HTTP API 源字段(kind == "custom" 时使用)=====
|
||||
/// 搜索 URL 模板,支持 {keyword} / {page} / {limit} 占位符
|
||||
#[serde(default)]
|
||||
pub search_url: Option<String>,
|
||||
/// 获取播放 URL 接口,支持 {id} / {quality} 占位符
|
||||
#[serde(default)]
|
||||
pub url_api: Option<String>,
|
||||
/// JSON 路径:搜索结果数组(如 "data.songs")
|
||||
#[serde(default)]
|
||||
pub search_path: Option<String>,
|
||||
/// JSON 路径:URL 字符串字段(如 "data.url")
|
||||
#[serde(default)]
|
||||
pub url_path: Option<String>,
|
||||
/// 字段映射(自定义源 JSON 字段名)
|
||||
#[serde(default)]
|
||||
pub id_field: Option<String>,
|
||||
#[serde(default)]
|
||||
pub name_field: Option<String>,
|
||||
#[serde(default)]
|
||||
pub artist_field: Option<String>,
|
||||
#[serde(default)]
|
||||
pub album_field: Option<String>,
|
||||
#[serde(default)]
|
||||
pub duration_field: Option<String>,
|
||||
#[serde(default)]
|
||||
pub pic_field: Option<String>,
|
||||
}
|
||||
|
||||
impl SourceConfig {
|
||||
/// 默认内置音源列表(顺序即默认优先级,波点为首选,参考 SPlayer)
|
||||
/// kuwo 使用 kwDES + mobi.s 接口,能绕过 VIP 限制播放完整歌曲
|
||||
#[allow(dead_code)]
|
||||
pub fn builtin_defaults() -> Vec<SourceConfig> {
|
||||
vec![
|
||||
SourceConfig::builtin("bodian", "波点音乐", true),
|
||||
SourceConfig::builtin("kugou", "酷狗音乐", true),
|
||||
SourceConfig::builtin("kuwo", "酷我音乐", true),
|
||||
]
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn builtin(id: &str, label: &str, enabled: bool) -> SourceConfig {
|
||||
SourceConfig {
|
||||
id: id.into(),
|
||||
label: label.into(),
|
||||
enabled,
|
||||
kind: "builtin".into(),
|
||||
search_url: None,
|
||||
url_api: None,
|
||||
search_path: None,
|
||||
url_path: None,
|
||||
id_field: None,
|
||||
name_field: None,
|
||||
artist_field: None,
|
||||
album_field: None,
|
||||
duration_field: None,
|
||||
pic_field: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 外部歌曲(搜索结果)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ExternalSong {
|
||||
/// 源内 ID(字符串,便于通用)
|
||||
pub id: String,
|
||||
/// 来源 source id("bodian" / "kugou" / 自定义源 id)
|
||||
pub source: String,
|
||||
/// 来源显示名("波点音乐" / ...)
|
||||
pub source_label: String,
|
||||
pub name: String,
|
||||
pub artist: String,
|
||||
pub album: String,
|
||||
/// 时长(毫秒)
|
||||
pub duration: u64,
|
||||
pub pic_url: String,
|
||||
}
|
||||
|
||||
/// 音源统一接口
|
||||
#[async_trait]
|
||||
pub trait MusicSource: Send + Sync {
|
||||
fn id(&self) -> &str;
|
||||
fn label(&self) -> &str;
|
||||
/// 搜索
|
||||
async fn search(&self, keyword: &str, page: u32, limit: u32) -> Result<Vec<ExternalSong>, String>;
|
||||
/// 获取播放 URL
|
||||
async fn get_url(&self, song_id: &str, quality: &str) -> Result<String, String>;
|
||||
}
|
||||
|
||||
/// 构建 HTTP 客户端(统一 UA / 超时,复用连接池)
|
||||
pub(crate) fn build_client(ua: &str, timeout_secs: u64) -> Result<Client, String> {
|
||||
Client::builder()
|
||||
.user_agent(ua)
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.build()
|
||||
.map_err(|e| format!("构建 HTTP 客户端失败: {}", e))
|
||||
}
|
||||
|
||||
/// 安全截断字符串到指定字符数(避免 UTF-8 多字节边界 panic)
|
||||
pub(crate) fn truncate_chars(s: &str, n: usize) -> String {
|
||||
s.chars().take(n).collect()
|
||||
}
|
||||
|
||||
/// 解析 kuwo r.s 接口返回的 JSON(bodian 与 kuwo 共用)
|
||||
///
|
||||
/// 该接口返回格式不稳定:
|
||||
/// - 有时返回标准双引号 JSON
|
||||
/// - 有时返回单引号 JSON(key/value 用 ' 包裹)
|
||||
///
|
||||
/// 先尝试直接解析标准 JSON;失败再用 `fix_single_quote_json` 修复单引号格式。
|
||||
/// 不能无脑 fix,因为 `fix_single_quote_json` 遇到标准 JSON 值内部的单引号
|
||||
/// (如歌名 "Don't Stop")会误判为定界符,破坏正确的 JSON。
|
||||
pub(crate) fn parse_kuwo_json(text: &str, source_tag: &str) -> Result<serde_json::Value, String> {
|
||||
match serde_json::from_str(text) {
|
||||
Ok(j) => Ok(j),
|
||||
Err(_) => {
|
||||
let fixed = fix_single_quote_json(text);
|
||||
serde_json::from_str(&fixed).map_err(|e| {
|
||||
format!(
|
||||
"{} 搜索JSON解析失败: {} | body(前200): {}",
|
||||
source_tag,
|
||||
e,
|
||||
truncate_chars(text, 200)
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 kuwo r.s 接口的 abslist 数组构造 ExternalSong 列表(bodian 与 kuwo 共用)
|
||||
pub(crate) fn parse_kuwo_abslist(
|
||||
abslist: &[serde_json::Value],
|
||||
source_id: &str,
|
||||
source_label: &str,
|
||||
) -> Vec<ExternalSong> {
|
||||
abslist
|
||||
.iter()
|
||||
.map(|item| {
|
||||
let musicrid = item["MUSICRID"].as_str().unwrap_or("");
|
||||
let id = musicrid
|
||||
.strip_prefix("MUSIC_")
|
||||
.or_else(|| musicrid.strip_prefix("music_"))
|
||||
.unwrap_or(musicrid)
|
||||
.to_string();
|
||||
let name = item["SONGNAME"].as_str().unwrap_or("").to_string();
|
||||
let artist = item["ARTIST"].as_str().unwrap_or("").to_string();
|
||||
let album = item["ALBUM"].as_str().unwrap_or("").to_string();
|
||||
// DURATION 兼容字符串/数字两种格式
|
||||
let duration = item["DURATION"]
|
||||
.as_str()
|
||||
.and_then(|d| d.parse::<u64>().ok())
|
||||
.or_else(|| item["DURATION"].as_u64())
|
||||
.map(|d| d * 1000)
|
||||
.unwrap_or(0);
|
||||
// web_albumpic_short 是相对路径(如 "120/s4s61/37/4021488843.jpg")
|
||||
// 需拼接 kuwo 图片 CDN 前缀;albumpic 字段有时是完整 URL
|
||||
let pic_raw = item["web_albumpic_short"]
|
||||
.as_str()
|
||||
.or_else(|| item["albumpic"].as_str())
|
||||
.or_else(|| item["pic"].as_str())
|
||||
.unwrap_or("");
|
||||
let pic_url = if pic_raw.is_empty() {
|
||||
String::new()
|
||||
} else if pic_raw.starts_with("http") {
|
||||
pic_raw.to_string()
|
||||
} else {
|
||||
format!("https://img1.kuwo.cn/star/albumcover/{}", pic_raw)
|
||||
};
|
||||
ExternalSong {
|
||||
id,
|
||||
source: source_id.to_string(),
|
||||
source_label: source_label.to_string(),
|
||||
name,
|
||||
artist,
|
||||
album,
|
||||
duration,
|
||||
pic_url,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 修复 kuwo r.s 接口返回的单引号 JSON
|
||||
/// kuwo 用 `'` 代替 `"` 作为字符串定界符,但值内部可能也含单引号
|
||||
/// 用状态机遍历:只在「作为定界符的位置」把 `'` 替换成 `"`
|
||||
///
|
||||
/// 规则:遇到 `'` 时判断它是否为定界符:
|
||||
/// - 如果当前在字符串内部(已遇到开定界符 `'`),则看下一个非空白字符
|
||||
/// 是 `:` `}` `,` `]` 之一 → 这是闭定界符;否则是值内部的字符,保留
|
||||
/// - 如果不在字符串内部,遇到 `'` 一定是开定界符
|
||||
fn fix_single_quote_json(text: &str) -> String {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let n = chars.len();
|
||||
let mut out = String::with_capacity(n);
|
||||
let mut in_str = false;
|
||||
let mut i = 0;
|
||||
|
||||
while i < n {
|
||||
let c = chars[i];
|
||||
if c == '\'' {
|
||||
if !in_str {
|
||||
out.push('"');
|
||||
in_str = true;
|
||||
} else {
|
||||
let mut j = i + 1;
|
||||
while j < n && (chars[j] == ' ' || chars[j] == '\t' || chars[j] == '\n' || chars[j] == '\r') {
|
||||
j += 1;
|
||||
}
|
||||
let next = if j < n { Some(chars[j]) } else { None };
|
||||
match next {
|
||||
Some(':') | Some('}') | Some(',') | Some(']') | None => {
|
||||
out.push('"');
|
||||
in_str = false;
|
||||
}
|
||||
_ => {
|
||||
out.push('\'');
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if c == '"' && in_str {
|
||||
out.push('\\');
|
||||
out.push('"');
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// 根据 SourceConfig 构造对应的音源实例
|
||||
pub fn get_source(cfg: &SourceConfig) -> Option<Box<dyn MusicSource>> {
|
||||
match cfg.kind.as_str() {
|
||||
"builtin" => match cfg.id.as_str() {
|
||||
"bodian" => Some(Box::new(bodian::BodianSource::new(cfg.label.clone()))),
|
||||
"kugou" => Some(Box::new(kugou::KugouSource::new(cfg.label.clone()))),
|
||||
"kuwo" => Some(Box::new(kuwo::KuwoSource::new(cfg.label.clone()))),
|
||||
_ => None,
|
||||
},
|
||||
"custom" => Some(Box::new(custom::CustomHttpSource::new(cfg.clone()))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 多源聚合搜索:并发请求所有启用的源,按配置顺序合并结果
|
||||
pub async fn search_multi(
|
||||
keyword: &str,
|
||||
sources: &[SourceConfig],
|
||||
limit: u32,
|
||||
) -> Vec<ExternalSong> {
|
||||
use futures_util::future::join_all;
|
||||
|
||||
let tasks: Vec<_> = sources
|
||||
.iter()
|
||||
.filter(|s| s.enabled)
|
||||
.filter_map(|cfg| {
|
||||
let src = get_source(cfg)?;
|
||||
let kw = keyword.to_string();
|
||||
Some(async move {
|
||||
let label = src.label().to_string();
|
||||
let sid = src.id().to_string();
|
||||
let _ = label;
|
||||
match src.search(&kw, 1, limit).await {
|
||||
Ok(list) => list,
|
||||
Err(e) => {
|
||||
eprintln!("[music_sources] {} 搜索失败: {}", sid, e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let results = join_all(tasks).await;
|
||||
let mut all = Vec::new();
|
||||
for r in results {
|
||||
all.extend(r);
|
||||
}
|
||||
all
|
||||
}
|
||||
|
||||
/// 网易云 fallback:按 sources 顺序尝试 (search + get_url)
|
||||
/// 用于 get_song_url 在网易云无 URL 时降级
|
||||
/// 返回 (url, source_label)
|
||||
///
|
||||
/// 匹配策略:
|
||||
/// 1. 用「歌名 歌手」在源里搜索
|
||||
/// 2. 只选原版(排除 Live/DJ/翻唱/伴奏/remix 等非原版)
|
||||
/// 3. 原版 get_url 失败(VIP/广告/付费)则换下一个源继续尝试
|
||||
/// 4. 所有源都失败就报错,不塞非原版替代品
|
||||
pub async fn fallback_get_url(
|
||||
song_name: &str,
|
||||
artist: &str,
|
||||
quality: &str,
|
||||
sources: &[SourceConfig],
|
||||
) -> Result<(String, String), String> {
|
||||
let target_lower = song_name.to_lowercase();
|
||||
// 歌手列表:按 "/" 分割(兼容前端 " / " 和 api.rs 的 "/" 格式)
|
||||
let artists: Vec<String> = if artist.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
artist
|
||||
.split('/')
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
};
|
||||
// 搜索关键词只用第一个歌手(多歌手全拼接会搜不到结果)
|
||||
let first_artist = artists.first().cloned().unwrap_or_default();
|
||||
|
||||
// 收集每个源的失败原因,便于排障
|
||||
let mut details: Vec<String> = Vec::new();
|
||||
|
||||
for cfg in sources.iter().filter(|s| s.enabled) {
|
||||
let src = match get_source(cfg) {
|
||||
Some(s) => s,
|
||||
None => continue,
|
||||
};
|
||||
let kw = if first_artist.is_empty() {
|
||||
song_name.to_string()
|
||||
} else {
|
||||
format!("{} {}", song_name, first_artist)
|
||||
};
|
||||
let results = match src.search(&kw, 1, 20).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
details.push(format!("{}搜索失败:{}", src.id(), e));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// 版本标签提取:从歌名中识别版本关键词
|
||||
// 用于「版本一致性匹配」——目标是原版就只匹配原版,目标是DJ版就只匹配DJ版
|
||||
// 避免 VIP 原版歌曲误匹配到同名 DJ 版,也避免 VIP DJ 版误匹配到同名原版
|
||||
let extract_version = |name: &str| -> Vec<&'static str> {
|
||||
let n = name.to_lowercase();
|
||||
let mut tags = Vec::new();
|
||||
// 括号通常表示版本标注(如 "Rise (Redux)" "RISE (Live)")
|
||||
// 目标是原版(无括号)时,带括号的搜索结果视为不同版本
|
||||
if n.contains('(') || n.contains('(') {
|
||||
tags.push("paren");
|
||||
}
|
||||
if n.contains("live") || n.contains("现场") || n.contains("演唱会") {
|
||||
tags.push("live");
|
||||
}
|
||||
if n.contains("remix") || n.contains("混音") {
|
||||
tags.push("remix");
|
||||
}
|
||||
if n.contains("dj") || n.contains("电音") {
|
||||
tags.push("dj");
|
||||
}
|
||||
if n.contains("伴奏") || n.contains("karaoke") || n.contains("inst") {
|
||||
tags.push("accompaniment");
|
||||
}
|
||||
if n.contains("翻唱") {
|
||||
tags.push("cover");
|
||||
}
|
||||
tags
|
||||
};
|
||||
// 版本一致性:目标歌名和搜索结果歌名的版本标签必须相同
|
||||
// - 目标是原版(无标签)→ 搜索结果也必须是原版(无标签)
|
||||
// - 目标是 DJ 版 → 搜索结果也必须是 DJ 版
|
||||
let version_match = |result_name: &str| -> bool {
|
||||
extract_version(&target_lower) == extract_version(result_name)
|
||||
};
|
||||
|
||||
// 歌手匹配:任一网易云歌手名与外部源歌手双向包含匹配
|
||||
// 双向是因为网易云可能用中文名"邓紫棋",外部源用英文名"G.E.M."
|
||||
// 去掉歌手字段中的标点(如 "G.E.M." → "gem"),避免点号/空格影响匹配
|
||||
let normalize_artist = |a: &str| -> String {
|
||||
a.to_lowercase()
|
||||
.chars()
|
||||
.filter(|c| c.is_alphanumeric() || *c == '&' || *c == '、')
|
||||
.collect()
|
||||
};
|
||||
let artist_match = |a: &str| -> bool {
|
||||
if artists.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let a_norm = normalize_artist(a);
|
||||
if a_norm.is_empty() {
|
||||
return false;
|
||||
}
|
||||
artists.iter().any(|art| {
|
||||
let art_norm = normalize_artist(art);
|
||||
!art_norm.is_empty() && (a_norm.contains(&art_norm) || art_norm.contains(&a_norm))
|
||||
})
|
||||
};
|
||||
|
||||
// 匹配优先级(逐级放宽,但每级都要求版本一致):
|
||||
// 1. 歌名完全匹配 + 版本一致 + 歌手匹配(最严格,优先选同名同版本同歌手)
|
||||
// 2. 歌名包含目标歌名 + 版本一致 + 歌手匹配(处理歌名带后缀,必须歌手匹配防误匹配)
|
||||
// 3. 歌名完全匹配 + 版本一致(最后兜底,不检查歌手——宁可播放同名同版本也不返回失败)
|
||||
let best = results.iter().find(|s| {
|
||||
s.name.to_lowercase() == target_lower && version_match(&s.name) && artist_match(&s.artist)
|
||||
})
|
||||
.or_else(|| results.iter().find(|s| {
|
||||
let n = s.name.to_lowercase();
|
||||
n.contains(&target_lower) && version_match(&s.name) && artist_match(&s.artist)
|
||||
}))
|
||||
.or_else(|| results.iter().find(|s| {
|
||||
s.name.to_lowercase() == target_lower && version_match(&s.name)
|
||||
}));
|
||||
|
||||
if let Some(song) = best {
|
||||
eprintln!("[music_sources] fallback 匹配: [{}] {} - {} (id={})", src.id(), song.name, song.artist, song.id);
|
||||
match src.get_url(&song.id, quality).await {
|
||||
Ok(url) if !url.is_empty() => {
|
||||
return Ok((url, src.label().to_string()));
|
||||
}
|
||||
Ok(_) => {
|
||||
details.push(format!("{}返回空URL", src.id()));
|
||||
}
|
||||
Err(e) => {
|
||||
details.push(format!("{}取URL失败:{}", src.id(), e));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
details.push(format!("{}未匹配到同版本", src.id()));
|
||||
}
|
||||
}
|
||||
Err(format!("所有音源均无可用 URL; 尝试明细: {}", details.join(", ")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 真实接口测试:验证「唯一 邓紫棋」的 fallback 能找到可播放 URL
|
||||
/// 手动运行: cargo test -- --ignored --nocapture
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_fallback_weiyi_dengziqi() {
|
||||
let sources = SourceConfig::builtin_defaults();
|
||||
match fallback_get_url("唯一", "邓紫棋", "standard", &sources).await {
|
||||
Ok((url, label)) => {
|
||||
println!("✅ fallback 成功: [{}] {}", label, url);
|
||||
assert!(url.starts_with("http"));
|
||||
}
|
||||
Err(e) => {
|
||||
panic!("❌ fallback 失败: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Nekosonic",
|
||||
"version": "0.2.0",
|
||||
"version": "0.9.0",
|
||||
"identifier": "com.atdunbg.Nekosonic",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -27,6 +27,7 @@
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"createUpdaterArtifacts": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
@@ -34,6 +35,21 @@
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
],
|
||||
"windows": {
|
||||
"webviewInstallMode": {
|
||||
"type": "downloadBootstrapper"
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"active": true,
|
||||
"endpoints": [
|
||||
"https://github.com/atdunbg/Nekosonic-Music/releases/latest/download/latest.json"
|
||||
],
|
||||
"dialog": false,
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDM1MDdCMTJCRTE3MUI4N0QKUldSOXVISGhLN0VITmM3ZkJlbjF3UGJrK3h6ellWZ2xSUG03b3d1RWlDeldSWk1nc0pic2J2MVkK"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+307
-368
@@ -1,408 +1,303 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-screen bg-base text-content overflow-hidden">
|
||||
<!-- 壁纸层:fixed 全屏最底层 -->
|
||||
<div
|
||||
v-if="settings.currentWallpaper.path"
|
||||
class="fixed inset-0 z-0 pointer-events-none overflow-hidden"
|
||||
>
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
class="h-10 flex items-center justify-between px-4 bg-surface/90 backdrop-blur select-none flex-shrink-0"
|
||||
>
|
||||
<span class="text-xs text-content-3 font-medium ml-2">Nekosonic Music</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<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="closeWindow" class="w-3 h-3 rounded-full bg-red-500 hover:bg-red-400 transition" title="关闭"></button>
|
||||
class="absolute inset-[-20px] bg-cover bg-center bg-no-repeat"
|
||||
:style="wallpaperStyle"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- 主题色遮罩层:半透明主题色覆盖壁纸,保证文字可读 -->
|
||||
<div
|
||||
v-if="settings.currentWallpaper.path"
|
||||
class="fixed inset-0 z-[1] pointer-events-none"
|
||||
:style="overlayStyle"
|
||||
></div>
|
||||
|
||||
<!-- 主容器 -->
|
||||
<div class="flex flex-col h-screen text-content overflow-hidden relative z-[2]" :style="rootBgStyle">
|
||||
<div class="flex flex-1 overflow-hidden" v-if="windowVisible">
|
||||
<Sidebar :mode="ui.sidebarMode" />
|
||||
|
||||
<div class="flex flex-col flex-1 overflow-hidden">
|
||||
<TopBar @close="closeWindow" />
|
||||
|
||||
<main class="flex-1 overflow-hidden flex flex-col" :style="{ paddingBottom: player.currentSong ? '70px' : '0px' }">
|
||||
<div ref="mainScrollRef" class="flex-1 overflow-y-auto min-h-0 relative">
|
||||
<router-view v-slot="{ Component }">
|
||||
<keep-alive :max="10" :include="keepAliveInclude">
|
||||
<component :is="Component" />
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<nav class="w-56 flex-shrink-0 flex flex-col bg-surface/80 backdrop-blur">
|
||||
<div class="flex-1 p-4 overflow-y-auto min-h-0">
|
||||
<div class="flex flex-col min-h-full">
|
||||
<div class="relative mb-4">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 text-content-3" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
|
||||
<input v-model="searchQuery" @keydown.enter="doSearch" type="text" placeholder="搜索音乐..."
|
||||
class="w-full rounded-lg bg-subtle pl-9 pr-3 py-2 text-sm text-content placeholder-content-3 outline-none focus:bg-muted transition" />
|
||||
</div>
|
||||
<div class="space-y-0.5">
|
||||
<router-link to="/"
|
||||
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">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12l9-9 9 9"/><path d="M5 10v10a1 1 0 001 1h3v-6h6v6h3a1 1 0 001-1V10"/></svg>
|
||||
推荐
|
||||
</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"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.4"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.4"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>
|
||||
漫游
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 mb-1 pt-2">
|
||||
<p class="text-xs text-content-3 px-3 mb-1">我的</p>
|
||||
<div class="space-y-0.5">
|
||||
<router-link to="/favorites"
|
||||
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">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
|
||||
我喜欢的音乐
|
||||
</router-link>
|
||||
<router-link to="/recent"
|
||||
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">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
|
||||
最近播放
|
||||
</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"
|
||||
@click="showCreatedPlaylists = !showCreatedPlaylists">
|
||||
<p class="text-xs text-content-3">我的歌单</p>
|
||||
<span class="text-xs text-content-3 transition-transform"
|
||||
:class="{ 'rotate-90': showCreatedPlaylists }">▶</span>
|
||||
</div>
|
||||
<div v-show="showCreatedPlaylists" class="space-y-0.5">
|
||||
<div v-for="pl in createdPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
|
||||
class="px-3 py-1.5 rounded-lg text-sm cursor-pointer truncate transition-all duration-200"
|
||||
:class="isPlaylistActive(pl.id) ? 'text-content bg-muted' : 'text-content-2 hover:text-content hover:bg-subtle'">
|
||||
{{ pl.name }}
|
||||
</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"
|
||||
@click="showSubPlaylists = !showSubPlaylists">
|
||||
<p class="text-xs text-content-3">收藏的歌单</p>
|
||||
<span class="text-xs text-content-3 transition-transform" :class="{ 'rotate-90': showSubPlaylists }">▶</span>
|
||||
</div>
|
||||
<div v-show="showSubPlaylists" class="space-y-0.5">
|
||||
<div v-for="pl in subPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
|
||||
class="px-3 py-1.5 rounded-lg text-sm cursor-pointer truncate transition-all duration-200"
|
||||
:class="isPlaylistActive(pl.id) ? 'text-content bg-muted' : 'text-content-2 hover:text-content hover:bg-subtle'">
|
||||
{{ pl.name }}
|
||||
</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">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
|
||||
设置
|
||||
</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">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h4a2 2 0 012 2v14a2 2 0 01-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>
|
||||
立即登录
|
||||
</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()"
|
||||
class="text-xs text-content-3 hover:text-danger transition">退出登录</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="flex-1 overflow-y-auto pb-24">
|
||||
<router-view v-slot="{ Component }">
|
||||
<keep-alive :max="3" include="HomeView,DiscoverView">
|
||||
<component :is="Component" />
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<Transition name="drawer">
|
||||
<div
|
||||
v-if="player.showRoamDrawer"
|
||||
class="fixed inset-0 z-50 flex flex-col backdrop-blur-xl bg-black/80"
|
||||
>
|
||||
<div class="h-10 flex items-center justify-between px-4 flex-shrink-0" data-tauri-drag-region>
|
||||
<button @click="player.closeRoamDrawer()" class="text-content-2 hover:text-content transition">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</button>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<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="closeWindow" class="w-3 h-3 rounded-full bg-red-500 hover:bg-red-400 transition" title="关闭"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-h-0 flex px-8 pb-8 gap-0">
|
||||
<div class="w-2/5 flex flex-col items-center justify-center flex-shrink-0">
|
||||
<img
|
||||
:src="roamSong?.al?.picUrl || roamSong?.album?.picUrl"
|
||||
class="w-72 h-72 rounded-3xl object-cover shadow-2xl mb-4"
|
||||
/>
|
||||
<h1 class="text-2xl font-bold text-white text-center">{{ roamSong?.name }}</h1>
|
||||
<p class="text-content-2 mt-2 text-center">{{ roamArtists }}</p>
|
||||
</div>
|
||||
<div class="w-3/5 relative min-h-0 overflow-hidden flex flex-col">
|
||||
<div ref="lyricScrollContainer" class="h-full overflow-y-auto custom-scroll px-4">
|
||||
<div v-if="lyrics.length > 0" class="w-full max-w-lg mx-auto text-center"
|
||||
:style="{ paddingTop: roamLyricPadPx + 'px', paddingBottom: roamLyricPadPx + 'px' }">
|
||||
<p
|
||||
v-for="(line, idx) in lyrics"
|
||||
:key="idx"
|
||||
:class="getRoamLyricClass(idx)"
|
||||
class="roam-lyric-line px-4 py-3 rounded-lg cursor-pointer transition-all duration-300"
|
||||
@click="seekToRoamLyric(line.time)"
|
||||
@mouseenter="roamLyricHovering = true"
|
||||
@mouseleave="roamLyricHovering = false"
|
||||
>
|
||||
{{ line.text }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="text-content-3 text-center mt-8">暂无歌词</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<RoamDrawer :visible="windowVisible && ui.showRoamDrawer" />
|
||||
|
||||
<PlayerBar v-if="player.currentSong" />
|
||||
<ToastContainer />
|
||||
|
||||
<Transition name="fade">
|
||||
<div v-if="showCloseModal" class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showCloseModal = false">
|
||||
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-80 p-6 select-auto">
|
||||
<h2 class="text-lg font-semibold text-content mb-1">关闭确认</h2>
|
||||
<p class="text-sm text-content-2 mb-5">你希望如何处理?</p>
|
||||
<div class="space-y-2.5 mb-4">
|
||||
<button @click="handleCloseAction('minimize')"
|
||||
class="w-full flex items-center gap-3 px-4 py-3 rounded-xl bg-subtle hover:bg-muted transition text-left">
|
||||
<div class="w-9 h-9 rounded-lg bg-accent-dim flex items-center justify-center flex-shrink-0">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-accent-text"><path d="M8 3v3a2 2 0 01-2 2H3m18 0h-3a2 2 0 01-2-2V3m0 18v-3a2 2 0 012-2h3M3 16h3a2 2 0 012 2v3"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-content">最小化到托盘</p>
|
||||
<p class="text-xs text-content-3">程序继续在后台运行</p>
|
||||
</div>
|
||||
</button>
|
||||
<button @click="handleCloseAction('exit')"
|
||||
class="w-full flex items-center gap-3 px-4 py-3 rounded-xl bg-subtle hover:bg-muted transition text-left">
|
||||
<div class="w-9 h-9 rounded-lg bg-danger-dim flex items-center justify-center flex-shrink-0">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-danger"><path d="M18 6L6 18M6 6l12 12"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-content">退出程序</p>
|
||||
<p class="text-xs text-content-3">完全关闭应用程序</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 cursor-pointer mb-4 select-none">
|
||||
<input type="checkbox" v-model="closeDontAskAgain" />
|
||||
<span class="text-xs text-content-2">不再询问,记住我的选择</span>
|
||||
</label>
|
||||
<button @click="showCloseModal = false"
|
||||
class="w-full py-2 rounded-lg bg-muted hover:bg-emphasis text-sm text-content-2 transition">
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<UpdateDialog
|
||||
:visible="updater.updateAvailable.value && !!updater.updateInfo.value"
|
||||
:info="{ version: updater.updateInfo.value?.version || '', date: updater.updateInfo.value?.date ?? null, body: updater.updateInfo.value?.body ?? null, currentVersion: updater.currentVersion.value }"
|
||||
:downloading="updater.downloading.value"
|
||||
:download-progress="updater.downloadProgress.value"
|
||||
@update="updater.downloadAndInstall()"
|
||||
@ignore="updater.ignoreVersion(updater.updateInfo.value?.version || '')"
|
||||
/>
|
||||
|
||||
<CloseModal
|
||||
:visible="showCloseModal"
|
||||
@confirm="handleCloseAction"
|
||||
@cancel="showCloseModal = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onBeforeUnmount, computed, nextTick } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { ref, watch, onMounted, onBeforeUnmount, computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useUserStore } from './stores/user';
|
||||
import { useSettingsStore, type CloseAction } from './stores/settings';
|
||||
import { usePlayerStore } from './stores/player';
|
||||
import { useUiStore } from './stores/ui';
|
||||
import TopBar from './components/TopBar.vue';
|
||||
import Sidebar from './components/Sidebar.vue';
|
||||
import RoamDrawer from './components/RoamDrawer.vue';
|
||||
import PlayerBar from './components/PlayerBar.vue';
|
||||
import ToastContainer from './components/ToastContainer.vue';
|
||||
import { usePlayerStore } from './stores/player';
|
||||
import { useLyric } from './composables/UserLyric';
|
||||
import CloseModal from './components/CloseModal.vue';
|
||||
import UpdateDialog from './components/UpdateDialog.vue';
|
||||
import { useOnlineStatus } from './composables/useOnlineStatus';
|
||||
import { showToast } from './composables/useToast';
|
||||
import { useUpdater } from './composables/useUpdater';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { register, unregister } from '@tauri-apps/plugin-global-shortcut';
|
||||
import { MusicApi, AudioApi, DeviceApi, AppApi } from './api';
|
||||
import { hexToRgba } from './utils/color';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const userStore = useUserStore();
|
||||
const player = usePlayerStore();
|
||||
const ui = useUiStore();
|
||||
const settings = useSettingsStore();
|
||||
const updater = useUpdater();
|
||||
const { isOnline } = useOnlineStatus();
|
||||
|
||||
watch(isOnline, (val, old) => {
|
||||
if (val && !old) showToast('网络已恢复', 'success');
|
||||
else if (!val && old) showToast('网络已断开,部分功能不可用', 'error');
|
||||
});
|
||||
|
||||
const createdPlaylists = ref<any[]>([]);
|
||||
const subPlaylists = ref<any[]>([]);
|
||||
const showCreatedPlaylists = ref(true);
|
||||
const showSubPlaylists = ref(true);
|
||||
const searchQuery = ref('');
|
||||
const showCloseModal = ref(false);
|
||||
const closeDontAskAgain = ref(false);
|
||||
const windowVisible = ref(true);
|
||||
|
||||
watch(() => settings.theme, (val) => {
|
||||
document.documentElement.setAttribute('data-theme', val);
|
||||
// --- Keep-alive 缓存管理 ---
|
||||
// 规则:30秒未访问的页面自动清除缓存;多级跳转时保留导航链上的页面;FavoriteSongs 常驻
|
||||
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> = {
|
||||
home: 'HomeView', discover: 'DiscoverView', search: 'DiscoverView',
|
||||
favorites: 'FavoriteSongsView', daily: 'DailySongsView',
|
||||
'local-music': 'LocalMusicView', 'downloaded-music': 'DownloadedMusicView',
|
||||
'cloud-music': 'CloudMusicView',
|
||||
playlist: 'PlaylistDetailView', artist: 'ArtistDetailView', album: 'AlbumDetailView',
|
||||
};
|
||||
const ALL_CACHEABLE = [...new Set(Object.values(ROUTE_COMPONENT))];
|
||||
const PERMANENT = new Set(['FavoriteSongsView']);
|
||||
const CACHE_TTL = 300_000;
|
||||
|
||||
const lastActivatedAt: Record<string, number> = {};
|
||||
const navStack = ref<string[]>([]);
|
||||
const currentComp = ref('');
|
||||
for (const name of ALL_CACHEABLE) lastActivatedAt[name] = Date.now();
|
||||
|
||||
watch(() => route.name, (newName, oldName) => {
|
||||
// 离开旧页面时刷新其计时(30s 从离开时算起)
|
||||
const oldComp = ROUTE_COMPONENT[oldName as string];
|
||||
if (oldComp) lastActivatedAt[oldComp] = Date.now();
|
||||
|
||||
const comp = ROUTE_COMPONENT[newName as string];
|
||||
if (!comp) return;
|
||||
currentComp.value = comp;
|
||||
lastActivatedAt[comp] = Date.now();
|
||||
const idx = navStack.value.indexOf(comp);
|
||||
if (idx !== -1) {
|
||||
// 返回:截断到该位置
|
||||
navStack.value = navStack.value.slice(0, idx + 1);
|
||||
} else {
|
||||
navStack.value.push(comp);
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
function doSearch() {
|
||||
const q = searchQuery.value.trim();
|
||||
if (q) router.push({ path: '/discover', query: { q } });
|
||||
}
|
||||
|
||||
const { lyrics, currentLyricIdx } = useLyric();
|
||||
const lyricScrollContainer = ref<HTMLElement | null>(null);
|
||||
const roamLyricHovering = ref(false);
|
||||
const roamLyricPadPx = ref(0);
|
||||
const roamSong = computed(() => player.currentSong);
|
||||
let roamResizeObserver: ResizeObserver | null = null;
|
||||
|
||||
function updateRoamLyricPad() {
|
||||
if (lyricScrollContainer.value) {
|
||||
roamLyricPadPx.value = Math.floor(lyricScrollContainer.value.clientHeight / 2);
|
||||
function computeInclude(): string[] {
|
||||
const now = Date.now();
|
||||
const include = new Set<string>(PERMANENT);
|
||||
if (currentComp.value) include.add(currentComp.value);
|
||||
for (const name of navStack.value) include.add(name);
|
||||
for (const name of ALL_CACHEABLE) {
|
||||
if (lastActivatedAt[name] && now - lastActivatedAt[name] < CACHE_TTL) include.add(name);
|
||||
}
|
||||
return [...include];
|
||||
}
|
||||
|
||||
watch(() => player.showRoamDrawer, (val) => {
|
||||
if (val) {
|
||||
nextTick(() => {
|
||||
updateRoamLyricPad();
|
||||
if (roamResizeObserver) roamResizeObserver.disconnect();
|
||||
if (lyricScrollContainer.value) {
|
||||
roamResizeObserver = new ResizeObserver(() => updateRoamLyricPad());
|
||||
roamResizeObserver.observe(lyricScrollContainer.value);
|
||||
}
|
||||
scrollToRoamActiveLyric();
|
||||
});
|
||||
const keepAliveInclude = ref<string[]>(computeInclude());
|
||||
let cleanupTimer: ReturnType<typeof setInterval>;
|
||||
function startCleanup() { cleanupTimer = setInterval(() => { keepAliveInclude.value = computeInclude(); }, 10_000); }
|
||||
function stopCleanup() { clearInterval(cleanupTimer); }
|
||||
|
||||
watch(() => settings.skin, () => {
|
||||
settings.applySkin();
|
||||
}, { immediate: true });
|
||||
|
||||
// 壁纸样式:通过 Rust 命令读取本地图片转 base64 data URL
|
||||
const wallpaperDataUrl = ref('');
|
||||
const wallpaperStyle = computed(() => {
|
||||
if (!wallpaperDataUrl.value) return {};
|
||||
const wp = settings.currentWallpaper;
|
||||
return {
|
||||
backgroundImage: `url(${wallpaperDataUrl.value})`,
|
||||
filter: `blur(${wp.blur}px)`,
|
||||
opacity: wp.opacity,
|
||||
};
|
||||
});
|
||||
|
||||
// 监听壁纸路径变化,异步加载图片
|
||||
watch(() => settings.currentWallpaper.path, async (path) => {
|
||||
if (!path) {
|
||||
wallpaperDataUrl.value = '';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
wallpaperDataUrl.value = await AppApi.readImageAsDataUrl(path);
|
||||
} catch (e) {
|
||||
console.error('加载壁纸失败:', e);
|
||||
wallpaperDataUrl.value = '';
|
||||
}
|
||||
}, { 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 (roamResizeObserver) {
|
||||
roamResizeObserver.disconnect();
|
||||
roamResizeObserver = null;
|
||||
// 宽屏:恢复用户选择
|
||||
if (ui.sidebarMode === 'drawer') {
|
||||
ui.setSidebarMode(savedSidebarMode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 根容器背景:有壁纸时透明(遮罩层已保证文字可读),无壁纸时不透明
|
||||
const rootBgStyle = computed(() => {
|
||||
const wp = settings.currentWallpaper;
|
||||
if (wp.path) {
|
||||
return {}; // 透明,遮罩层统一处理
|
||||
}
|
||||
return {
|
||||
backgroundColor: 'var(--c-bg)',
|
||||
};
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (roamResizeObserver) {
|
||||
roamResizeObserver.disconnect();
|
||||
roamResizeObserver = null;
|
||||
}
|
||||
// 主题色遮罩层:用 --c-bg 的半透明版本覆盖壁纸,保证文字对比度
|
||||
// 这是网易云式设计的核心:壁纸色调透出遮罩,文字始终清晰
|
||||
const overlayStyle = computed(() => {
|
||||
const bgColor = settings.currentColors.bg;
|
||||
const rgba = hexToRgba(bgColor, 0.82);
|
||||
return {
|
||||
backgroundColor: rgba,
|
||||
};
|
||||
});
|
||||
const roamArtists = computed(() => {
|
||||
if (!roamSong.value) return '';
|
||||
return roamSong.value.ar?.map((a: any) => a.name).join(' / ') || '';
|
||||
});
|
||||
|
||||
watch(currentLyricIdx, () => {
|
||||
if (player.showRoamDrawer && !roamLyricHovering.value) {
|
||||
nextTick(() => scrollToRoamActiveLyric());
|
||||
}
|
||||
});
|
||||
|
||||
function scrollToRoamActiveLyric() {
|
||||
if (!lyricScrollContainer.value || roamLyricHovering.value) return;
|
||||
const active = lyricScrollContainer.value.querySelector('.roam-lyric-active') as HTMLElement | null;
|
||||
if (active) {
|
||||
active.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
|
||||
function getRoamLyricClass(idx: number): string {
|
||||
const diff = Math.abs(idx - currentLyricIdx.value);
|
||||
if (idx === currentLyricIdx.value) {
|
||||
return 'roam-lyric-active text-accent-text font-semibold text-xl';
|
||||
}
|
||||
if (diff === 1) return 'text-content/70 text-lg';
|
||||
if (diff === 2) return 'text-content-2/50 text-base';
|
||||
return 'text-content-3/35 text-base';
|
||||
}
|
||||
|
||||
function seekToRoamLyric(time: number) {
|
||||
if (time != null && player.duration > 0) {
|
||||
player.seek(time);
|
||||
}
|
||||
}
|
||||
|
||||
async function openRoamFromSidebar() {
|
||||
if (player.isFmMode) {
|
||||
player.openRoamDrawer();
|
||||
} else {
|
||||
await player.loadFm();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPlaylists() {
|
||||
if (!userStore.isLoggedIn || !userStore.user) return;
|
||||
try {
|
||||
const jsonStr: string = await invoke('user_playlist', { uid: 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 (e) { /* 忽略 */ }
|
||||
}
|
||||
|
||||
function goPlaylist(id: number) {
|
||||
router.push({ name: 'playlist', params: { id } });
|
||||
}
|
||||
|
||||
function isPlaylistActive(id: number): boolean {
|
||||
return route.name === 'playlist' && Number(route.params.id) === id;
|
||||
}
|
||||
|
||||
watch(() => userStore.isLoggedIn, (val) => {
|
||||
if (val) {
|
||||
loadPlaylists();
|
||||
player.loadLikedIds();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
onMounted(() => {
|
||||
document.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||
startCleanup();
|
||||
|
||||
// 响应式侧边栏
|
||||
applyResponsiveSidebar();
|
||||
window.addEventListener('resize', applyResponsiveSidebar);
|
||||
|
||||
AudioApi.stopAudio().catch(() => {});
|
||||
|
||||
if (userStore.isLoggedIn) {
|
||||
loadPlaylists();
|
||||
player.loadLikedIds();
|
||||
MusicApi.getLoginStatus().then(jsonStr => {
|
||||
if (!jsonStr) return;
|
||||
const data = JSON.parse(jsonStr);
|
||||
if (data.account || data.profile) {
|
||||
const profile = data.profile || data.account;
|
||||
userStore.setUser({
|
||||
userId: profile.userId,
|
||||
nickname: profile.nickname,
|
||||
avatarUrl: profile.avatarUrl,
|
||||
});
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
updater.checkForUpdate(true);
|
||||
|
||||
if (settings.outputDevice) {
|
||||
DeviceApi.setOutputDevice(settings.outputDevice).catch(() => {});
|
||||
}
|
||||
try { await invoke('stop_audio'); } catch {}
|
||||
try {
|
||||
const jsonStr: string = await invoke('get_login_status');
|
||||
const data = JSON.parse(jsonStr);
|
||||
if (data.account || data.profile) {
|
||||
const profile = data.profile || data.account;
|
||||
userStore.setUser({
|
||||
userId: profile.userId,
|
||||
nickname: profile.nickname,
|
||||
avatarUrl: profile.avatarUrl,
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
|
||||
const currentWindow = getCurrentWindow();
|
||||
function minimizeWindow() { currentWindow.minimize(); }
|
||||
async function toggleMaximize() {
|
||||
const isMaximized = await currentWindow.isMaximized();
|
||||
if (isMaximized) { currentWindow.unmaximize(); } else { currentWindow.maximize(); }
|
||||
}
|
||||
|
||||
function closeWindow() {
|
||||
if (settings.closeAction === 'ask') {
|
||||
closeDontAskAgain.value = false;
|
||||
showCloseModal.value = true;
|
||||
} else if (settings.closeAction === 'minimize') {
|
||||
currentWindow.hide();
|
||||
} else {
|
||||
invoke('exit_app');
|
||||
AppApi.exitApp();
|
||||
}
|
||||
}
|
||||
function handleCloseAction(action: CloseAction) {
|
||||
if (closeDontAskAgain.value) {
|
||||
|
||||
function handleCloseAction(action: CloseAction, remember: boolean) {
|
||||
if (remember) {
|
||||
settings.setCloseAction(action);
|
||||
}
|
||||
showCloseModal.value = false;
|
||||
if (action === 'minimize') {
|
||||
currentWindow.hide();
|
||||
} else {
|
||||
invoke('exit_app');
|
||||
AppApi.exitApp();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,14 +311,65 @@ onMounted(() => {
|
||||
const unlisten3 = listen('tray-prev', () => {
|
||||
player.prev();
|
||||
});
|
||||
const unlisten4 = listen('window-hidden', () => {
|
||||
windowVisible.value = false;
|
||||
keepAliveInclude.value = [];
|
||||
stopCleanup();
|
||||
});
|
||||
const unlisten5 = listen('window-shown', () => {
|
||||
windowVisible.value = true;
|
||||
keepAliveInclude.value = computeInclude();
|
||||
startCleanup();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unlisten1.then(fn => fn());
|
||||
unlisten2.then(fn => fn());
|
||||
unlisten3.then(fn => fn());
|
||||
unlisten4.then(fn => fn());
|
||||
unlisten5.then(fn => fn());
|
||||
stopCleanup();
|
||||
});
|
||||
});
|
||||
|
||||
async function registerGlobalShortcuts() {
|
||||
const globalActions: Record<string, () => void> = {
|
||||
globalPlayPause: () => player.toggle(),
|
||||
globalPrev: () => player.prev(),
|
||||
globalNext: () => player.next(),
|
||||
globalVolUp: () => player.adjustVolume(5),
|
||||
globalVolDown: () => player.adjustVolume(-5),
|
||||
};
|
||||
for (const [id, action] of Object.entries(globalActions)) {
|
||||
const key = settings.shortcuts[id]?.key;
|
||||
if (!key) continue;
|
||||
try { await unregister(key); } catch { /* 忽略 */ }
|
||||
try {
|
||||
await register(key, (event) => {
|
||||
if (event.state === 'Pressed') action();
|
||||
});
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => settings.shortcuts, () => {
|
||||
registerGlobalShortcuts();
|
||||
}, { deep: true });
|
||||
|
||||
onMounted(() => {
|
||||
registerGlobalShortcuts();
|
||||
});
|
||||
|
||||
function parseShortcutKey(combo: string): { ctrl: boolean; alt: boolean; shift: boolean; code: string } {
|
||||
const parts = combo.split('+');
|
||||
return {
|
||||
ctrl: parts.includes('Control'),
|
||||
alt: parts.includes('Alt'),
|
||||
shift: parts.includes('Shift'),
|
||||
code: parts.find(p => !['Control', 'Alt', 'Shift'].includes(p)) || '',
|
||||
};
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
const el = e.target as HTMLElement;
|
||||
@@ -432,13 +378,26 @@ onMounted(() => {
|
||||
e.preventDefault();
|
||||
player.toggle();
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.code === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
player.next();
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.code === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
player.prev();
|
||||
|
||||
const localActions: Record<string, () => void> = {
|
||||
playPause: () => player.toggle(),
|
||||
prev: () => player.prev(),
|
||||
next: () => player.next(),
|
||||
volUp: () => player.adjustVolume(5),
|
||||
volDown: () => player.adjustVolume(-5),
|
||||
};
|
||||
for (const [id, action] of Object.entries(localActions)) {
|
||||
const key = settings.shortcuts[id]?.key;
|
||||
if (!key) continue;
|
||||
const parsed = parseShortcutKey(key);
|
||||
const ctrlMatch = parsed.ctrl ? (e.ctrlKey || e.metaKey) : !e.ctrlKey && !e.metaKey;
|
||||
const altMatch = parsed.alt ? e.altKey : !e.altKey;
|
||||
const shiftMatch = parsed.shift ? e.shiftKey : !e.shiftKey;
|
||||
if (ctrlMatch && altMatch && shiftMatch && e.code === parsed.code) {
|
||||
e.preventDefault();
|
||||
action();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeydown);
|
||||
@@ -447,23 +406,3 @@ onMounted(() => {
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.drawer-enter-active,
|
||||
.drawer-leave-active { transition: transform 0.3s ease; }
|
||||
.drawer-enter-from,
|
||||
.drawer-leave-to { transform: translateY(100%); }
|
||||
.fade-enter-active,
|
||||
.fade-leave-active { transition: opacity 0.2s ease; }
|
||||
.fade-enter-from,
|
||||
.fade-leave-to { opacity: 0; }
|
||||
.custom-scroll::-webkit-scrollbar { width: 0; display: none; }
|
||||
.roam-lyric-line:hover {
|
||||
background: var(--c-subtle);
|
||||
color: var(--c-content) !important;
|
||||
}
|
||||
.roam-lyric-active:hover {
|
||||
background: var(--c-subtle) !important;
|
||||
color: var(--c-content) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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,90 @@
|
||||
/**
|
||||
* 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,
|
||||
getExternalSongUrl: SongApi.getExternalSongUrl,
|
||||
|
||||
// 歌单
|
||||
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,
|
||||
searchSongsMulti: SearchApi.searchSongsMulti,
|
||||
|
||||
// 专辑
|
||||
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,34 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { MusicSourceConfig } from '../stores/settings';
|
||||
|
||||
/**
|
||||
* 搜索相关 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 });
|
||||
},
|
||||
|
||||
/** 多源聚合搜索:并发请求所有启用音源,合并结果返回
|
||||
* 返回 JSON: ExternalSong[](与后端 ExternalSong 结构对齐)
|
||||
* 用于「网易云搜不到的歌,从其他音源补充搜索结果」场景
|
||||
*/
|
||||
async searchSongsMulti(query: {
|
||||
keyword: string;
|
||||
sources: MusicSourceConfig[];
|
||||
limit?: number;
|
||||
}): Promise<string> {
|
||||
return invoke('search_songs_multi', { query });
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { MusicSourceConfig } from '../stores/settings';
|
||||
|
||||
/**
|
||||
* 歌曲相关 API
|
||||
*/
|
||||
export const SongApi = {
|
||||
/** 获取歌曲详情 */
|
||||
async getSongDetail(id: string): Promise<string> {
|
||||
return invoke('get_song_detail', { id });
|
||||
},
|
||||
|
||||
/** 获取歌曲播放 URL
|
||||
* sources: 启用的第三方音源配置列表(按优先级排序),网易云无 URL 时按顺序降级
|
||||
*/
|
||||
async getSongUrl(query: {
|
||||
id: number;
|
||||
level: string;
|
||||
fm_mode?: boolean;
|
||||
sources?: MusicSourceConfig[];
|
||||
}): 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 });
|
||||
},
|
||||
|
||||
/** 从指定外部音源获取播放 URL(用于搜索结果里来自外部源的歌曲)
|
||||
* 返回 JSON: { url, source, source_label }
|
||||
* 主源失败时会用 songName/artist/allSources 跨源 fallback
|
||||
*/
|
||||
async getExternalSongUrl(query: {
|
||||
source: MusicSourceConfig;
|
||||
songId: string;
|
||||
quality?: string;
|
||||
songName?: string;
|
||||
artist?: string;
|
||||
allSources?: MusicSourceConfig[];
|
||||
}): Promise<string> {
|
||||
return invoke('get_external_song_url', { 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>
|
||||
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<Transition name="fade">
|
||||
<div v-if="visible" class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="$emit('cancel')">
|
||||
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-80 p-6 select-auto">
|
||||
<h2 class="text-lg font-semibold text-content mb-1">关闭确认</h2>
|
||||
<p class="text-sm text-content-2 mb-5">你希望如何处理?</p>
|
||||
<div class="space-y-2.5 mb-4">
|
||||
<button @click="handleAction('minimize')"
|
||||
class="w-full flex items-center gap-3 px-4 py-3 rounded-xl bg-subtle hover:bg-muted transition text-left">
|
||||
<div class="w-9 h-9 rounded-lg bg-accent-dim flex items-center justify-center flex-shrink-0">
|
||||
<IconMaximize2 class="w-[18px] h-[18px] text-accent-text" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-content">最小化到托盘</p>
|
||||
<p class="text-xs text-content-3">程序继续在后台运行</p>
|
||||
</div>
|
||||
</button>
|
||||
<button @click="handleAction('exit')"
|
||||
class="w-full flex items-center gap-3 px-4 py-3 rounded-xl bg-subtle hover:bg-muted transition text-left">
|
||||
<div class="w-9 h-9 rounded-lg bg-danger-dim flex items-center justify-center flex-shrink-0">
|
||||
<IconX class="w-[18px] h-[18px] text-danger" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-content">退出程序</p>
|
||||
<p class="text-xs text-content-3">完全关闭应用程序</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 cursor-pointer mb-4 select-none">
|
||||
<input type="checkbox" v-model="dontAskAgain" />
|
||||
<span class="text-xs text-content-2">不再询问,记住我的选择</span>
|
||||
</label>
|
||||
<button @click="$emit('cancel')"
|
||||
class="w-full py-2 rounded-lg bg-muted hover:bg-emphasis text-sm text-content-2 transition">
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import type { CloseAction } from '../stores/settings';
|
||||
import IconMaximize2 from '~icons/lucide/maximize-2';
|
||||
import IconX from '~icons/lucide/x';
|
||||
|
||||
defineProps<{
|
||||
visible: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
confirm: [action: CloseAction, remember: boolean];
|
||||
cancel: [];
|
||||
}>();
|
||||
|
||||
const dontAskAgain = ref(false);
|
||||
|
||||
function handleAction(action: CloseAction) {
|
||||
emit('confirm', action, dontAskAgain.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active { transition: opacity 0.2s ease; }
|
||||
.fade-enter-from,
|
||||
.fade-leave-to { opacity: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<div class="bg-subtle rounded-xl p-3" ref="scrollContainer">
|
||||
<div v-if="loading" class="py-8 text-center text-sm" :class="darkMode ? 'text-white/60' : 'text-content-2'">加载中...</div>
|
||||
|
||||
<div v-else-if="comments.length === 0" class="py-8 text-center">
|
||||
<IconMessageSquare class="mx-auto mb-2 w-10 h-10" :class="darkMode ? 'text-white/40' : 'text-content-3'" />
|
||||
<p class="text-sm" :class="darkMode ? 'text-white/40' : 'text-content-3'">暂无评论</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="comment in comments"
|
||||
:key="comment.commentId"
|
||||
class="p-3 rounded-xl"
|
||||
:class="darkMode ? 'bg-white/8' : 'bg-surface/50'"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<img :src="comment.user.avatarUrl" class="w-8 h-8 rounded-full object-cover flex-shrink-0" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium truncate" :class="darkMode ? 'text-white/90' : 'text-content'">{{ comment.user.nickname }}</p>
|
||||
<p class="text-xs" :class="darkMode ? 'text-white/40' : 'text-content-3'">{{ new Date(comment.time).toLocaleDateString('zh-CN') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 text-sm leading-relaxed" :class="darkMode ? 'text-white/70' : 'text-content-2'">{{ comment.content }}</p>
|
||||
<div class="mt-2 flex justify-end">
|
||||
<button
|
||||
@click="likeComment(comment.commentId)"
|
||||
class="flex items-center gap-1 transition text-xs"
|
||||
:class="comment.liked ? 'text-danger' : (darkMode ? 'text-white/40 hover:text-danger' : 'text-content-3 hover:text-danger')"
|
||||
>
|
||||
<IconHeart style="font-size: 14px" :class="comment.liked ? '[&>path]:fill-current [&>path]:stroke-0' : ''" />
|
||||
<span>{{ comment.likedCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="sentinel" class="h-1"></div>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingMore" class="py-4 text-center text-sm" :class="darkMode ? 'text-white/40' : 'text-content-3'">加载中...</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { MusicApi } from '../api'
|
||||
import IconMessageSquare from '~icons/lucide/message-square'
|
||||
import IconHeart from '~icons/lucide/heart'
|
||||
|
||||
const props = defineProps<{
|
||||
type: number
|
||||
id: number
|
||||
darkMode?: boolean
|
||||
}>()
|
||||
|
||||
const comments = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
const hasMore = ref(true)
|
||||
const pageNo = ref(1)
|
||||
const pageSize = 20
|
||||
const sentinel = ref<HTMLElement | null>(null)
|
||||
let observer: IntersectionObserver | null = null
|
||||
|
||||
async function fetchComments(reset = false) {
|
||||
if (reset) {
|
||||
pageNo.value = 1
|
||||
comments.value = []
|
||||
hasMore.value = true
|
||||
}
|
||||
|
||||
if (!hasMore.value) return
|
||||
|
||||
if (reset) {
|
||||
loading.value = true
|
||||
} else {
|
||||
loadingMore.value = true
|
||||
}
|
||||
|
||||
try {
|
||||
const jsonStr: string = await MusicApi.commentHot({
|
||||
type: props.type,
|
||||
id: props.id,
|
||||
limit: pageSize,
|
||||
offset: (pageNo.value - 1) * pageSize
|
||||
})
|
||||
const data = JSON.parse(jsonStr)
|
||||
const list = data.hotComments || []
|
||||
if (reset) {
|
||||
comments.value = list
|
||||
} else {
|
||||
comments.value.push(...list)
|
||||
}
|
||||
hasMore.value = list.length >= pageSize
|
||||
} catch (e) {
|
||||
console.error('获取评论列表失败', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
if (loadingMore.value || !hasMore.value) return
|
||||
pageNo.value++
|
||||
fetchComments()
|
||||
}
|
||||
|
||||
const likingSet = ref(new Set<number>())
|
||||
|
||||
async function likeComment(cid: number) {
|
||||
if (likingSet.value.has(cid)) return
|
||||
const target = comments.value.find(c => c.commentId === cid)
|
||||
if (!target) return
|
||||
const liked = !!target.liked
|
||||
likingSet.value.add(cid)
|
||||
try {
|
||||
await MusicApi.commentLike({
|
||||
t: liked ? 0 : 1,
|
||||
type: props.type,
|
||||
id: props.id,
|
||||
cid
|
||||
})
|
||||
target.liked = !liked
|
||||
target.likedCount += liked ? -1 : 1
|
||||
} catch (e) {
|
||||
console.error('评论点赞失败', e)
|
||||
} finally {
|
||||
likingSet.value.delete(cid)
|
||||
}
|
||||
}
|
||||
|
||||
function setupObserver() {
|
||||
if (observer) observer.disconnect()
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
if (entries[0]?.isIntersecting) {
|
||||
loadMore()
|
||||
}
|
||||
}, { rootMargin: '200px' })
|
||||
nextTick(() => {
|
||||
if (sentinel.value) {
|
||||
observer!.observe(sentinel.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchComments(true).then(() => setupObserver())
|
||||
})
|
||||
|
||||
watch(() => props.id, () => {
|
||||
fetchComments(true).then(() => setupObserver())
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (observer) observer.disconnect()
|
||||
})
|
||||
</script>
|
||||
@@ -2,23 +2,23 @@
|
||||
<div class="relative" ref="container">
|
||||
<button
|
||||
@click="toggle"
|
||||
class="flex items-center justify-between bg-subtle border border-line rounded-lg px-3 py-1.5 text-sm text-content outline-none transition min-w-[140px] hover:border-content-3 focus:border-accent focus:shadow-[0_0_0_2px_var(--c-accent-dim)]"
|
||||
class="flex items-center justify-between bg-subtle border border-line rounded-lg px-3 py-1.5 text-sm text-content outline-none transition min-w-[140px] max-w-[320px] hover:border-content-3 focus:border-accent focus:shadow-[0_0_0_2px_var(--c-accent-dim)]"
|
||||
:class="{ 'border-accent shadow-[0_0_0_2px_var(--c-accent-dim)]': isOpen }"
|
||||
>
|
||||
<span>{{ currentLabel }}</span>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="transition-transform flex-shrink-0 ml-2" :class="{ 'rotate-180': isOpen }"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
<span class="truncate">{{ currentLabel }}</span>
|
||||
<IconChevronDown style="font-size: 12px" class="transition-transform flex-shrink-0 ml-2" :class="{ 'rotate-180': isOpen }" />
|
||||
</button>
|
||||
<Transition name="dropdown">
|
||||
<div v-if="isOpen" class="absolute right-0 top-full mt-1 bg-surface border border-line rounded-lg shadow-xl z-50 py-1 min-w-full overflow-hidden">
|
||||
<div v-if="isOpen" class="absolute right-0 top-full mt-1 bg-surface border border-line rounded-lg shadow-xl z-50 py-1 min-w-full max-w-[360px] overflow-hidden">
|
||||
<button
|
||||
v-for="(label, key) in options"
|
||||
:key="key"
|
||||
@click="select(key)"
|
||||
class="w-full text-left px-3 py-2 text-sm transition flex items-center justify-between"
|
||||
class="w-full text-left px-3 py-2 text-sm transition flex items-center justify-between gap-2"
|
||||
:class="modelValue === key ? 'bg-accent-dim text-accent-text' : 'text-content-2 hover:bg-subtle hover:text-content'"
|
||||
>
|
||||
<span>{{ label }}</span>
|
||||
<svg v-if="modelValue === key" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
<span class="truncate">{{ label }}</span>
|
||||
<IconCheck v-if="modelValue === key" style="font-size: 14px" />
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
@@ -27,6 +27,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import IconChevronDown from '~icons/lucide/chevron-down';
|
||||
import IconCheck from '~icons/lucide/check';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string;
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<!-- 普通头部:随内容滚动,标题和按钮一行 -->
|
||||
<div class="-mx-8 px-8 pt-3 pb-2">
|
||||
<div class="flex items-center gap-4">
|
||||
<slot />
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
+336
-95
@@ -1,58 +1,91 @@
|
||||
<template>
|
||||
<div
|
||||
class="fixed bottom-0 left-0 right-0 bg-surface/95 backdrop-blur border-t border-line z-50 select-none">
|
||||
class="fixed bottom-0 left-0 right-0 z-50 select-none backdrop-blur-xl"
|
||||
:style="playerBarBgStyle"
|
||||
>
|
||||
<div v-if="ui.dominantColor"
|
||||
class="absolute inset-0 pointer-events-none transition-opacity duration-300"
|
||||
:class="drawerActive ? 'opacity-100' : 'opacity-0'"
|
||||
:style="{ backgroundColor: ui.dominantColor }"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/60"></div>
|
||||
</div>
|
||||
|
||||
<div ref="progressBar" class="w-full h-1.5 bg-muted rounded-full relative group cursor-pointer overflow-visible"
|
||||
<div ref="progressBar" class="w-full h-1.5 relative group cursor-pointer overflow-visible"
|
||||
:class="drawerActive ? 'bg-white/10' : 'bg-muted'"
|
||||
@mousedown.prevent="startSeek">
|
||||
<div class="absolute left-0 top-0 h-full bg-emphasis rounded-full" :style="{ width: cacheProgress + '%' }"></div>
|
||||
<div class="absolute left-0 top-0 h-full bg-accent rounded-full"
|
||||
<div class="absolute left-0 top-0 h-full rounded-full" :class="drawerActive ? 'bg-white/20' : 'bg-emphasis'" :style="{ width: cacheProgress + '%' }"></div>
|
||||
<div class="absolute left-0 top-0 h-full rounded-full bg-accent"
|
||||
:style="{ width: displayProgress + '%' }"></div>
|
||||
<div
|
||||
class="absolute top-1/2 -translate-y-1/2 w-3.5 h-3.5 rounded-full bg-white shadow-lg opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
|
||||
class="absolute top-1/2 -translate-y-1/2 w-3.5 h-3.5 rounded-full bg-white shadow-lg border border-line opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
|
||||
:style="{ left: `calc(${displayProgress}% - 7px)` }"></div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center px-6 h-16">
|
||||
<div class="flex items-center px-6 h-16 relative z-10">
|
||||
<div class="flex items-center gap-3 w-56 min-w-0">
|
||||
<img :src="player.currentSong?.al?.picUrl"
|
||||
class="w-10 h-10 rounded-md object-cover 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" />
|
||||
</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="ui.toggleRoamDrawer()" title="全屏展示"
|
||||
:class="drawerActive ? 'bg-white/10' : 'bg-muted'">
|
||||
<IconMusic class="w-[18px] h-[18px]" :class="drawerActive ? 'text-white/50' : 'text-content-3'" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium truncate">{{ player.currentSong?.name }}</p>
|
||||
<p class="text-xs text-content-2 truncate">
|
||||
{{player.currentSong?.ar?.map((a: any) => a.name).join('/')}}
|
||||
<p class="text-sm font-medium truncate" :class="drawerActive ? 'text-white' : ''">{{ player.currentSong?.name }}</p>
|
||||
<p class="text-xs truncate" :class="drawerActive ? 'text-white/70' : 'text-content-2'">
|
||||
<template v-for="(a, i) in player.currentSong?.ar || []" :key="a.id || i">
|
||||
<span v-if="i > 0" :class="drawerActive ? 'text-white/40' : 'text-content-3'">/</span>
|
||||
<span class="hover:text-accent-text cursor-pointer transition" @click.stop="a.id && router.push({ name: 'artist', params: { id: a.id } })">{{ a.name }}</span>
|
||||
</template>
|
||||
<template v-if="player.currentSong?.al?.name">
|
||||
<span :class="drawerActive ? 'text-white/40' : 'text-content-3'" class="mx-1">·</span>
|
||||
<span class="hover:text-accent-text cursor-pointer transition" @click.stop="player.currentSong!.al.id && router.push({ name: 'album', params: { id: player.currentSong!.al.id } })">{{ player.currentSong.al.name }}</span>
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
<button @click="player.currentSong && player.toggleLike(player.currentSong.id)" class="flex-shrink-0 transition" :class="player.currentSong && player.isLiked(player.currentSong.id) ? 'text-danger' : 'text-content-3 hover:text-danger'">
|
||||
<svg v-if="player.currentSong && player.isLiked(player.currentSong.id)" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
|
||||
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
|
||||
<button @click="player.currentSong && player.toggleLike(player.currentSong.id)" class="flex-shrink-0 transition" :class="player.currentSong && player.isLiked(player.currentSong.id) ? 'text-danger' : (drawerActive ? 'text-white/50 hover:text-danger' : 'text-content-3 hover:text-danger')">
|
||||
<IconHeart v-if="player.currentSong && player.isLiked(player.currentSong.id)" class="w-4 h-4 text-danger [&>path]:fill-current [&>path]:stroke-0" />
|
||||
<IconHeart v-else class="w-4 h-4" />
|
||||
</button>
|
||||
<button v-if="player.currentSong" @click="shareSong(player.currentSong.id)" class="flex-shrink-0 transition" :class="drawerActive ? 'text-white/50 hover:text-white' : 'text-content-3 hover:text-accent-text'" title="分享">
|
||||
<IconShare2 class="w-4 h-4" />
|
||||
</button>
|
||||
<button v-if="player.currentSong && !download.isDownloaded(player.currentSong!.id) && !download.isDownloading(player.currentSong!.id)" @click="download.downloadSong(player.currentSong)" class="flex-shrink-0 transition" :class="drawerActive ? 'text-white/50 hover:text-white' : 'text-content-3 hover:text-accent-text'" title="下载">
|
||||
<IconDownload class="w-4 h-4" />
|
||||
</button>
|
||||
<IconLoader2 v-if="player.currentSong && download.isDownloading(player.currentSong!.id)" class="w-4 h-4 flex-shrink-0 animate-spin" :class="drawerActive ? 'text-white/50' : 'text-content-3'" />
|
||||
</div>
|
||||
|
||||
<div class="flex-1 flex flex-col items-center justify-center gap-1">
|
||||
<div class="flex items-center gap-5">
|
||||
<button @click="player.prev()" :disabled="player.isFmMode" :class="[
|
||||
<div class="flex items-center gap-5 relative">
|
||||
<button @click="player.prev()" :disabled="player.isFmMode || player.switching" :class="[
|
||||
'transition',
|
||||
player.isFmMode ? 'text-content-4 cursor-not-allowed' : 'text-content-2 hover:text-content',
|
||||
(player.isFmMode || player.switching) ? (drawerActive ? 'text-white/20 cursor-not-allowed' : 'text-content-4 cursor-not-allowed') : (drawerActive ? 'text-white/70 hover:text-white' : 'text-content-2 hover:text-content'),
|
||||
]">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="19 20 9 12 19 4 19 20"/><line x1="5" y1="19" x2="5" y2="5"/></svg>
|
||||
<IconSkipBack class="w-5 h-5" />
|
||||
</button>
|
||||
<button @click="player.toggle()"
|
||||
class="w-9 h-9 flex items-center justify-center rounded-full bg-muted hover:bg-emphasis transition border border-emphasis">
|
||||
<svg v-if="player.playing" width="16" height="16" viewBox="0 0 16 16" fill="currentColor"
|
||||
class="text-white">
|
||||
<rect x="3" y="2" width="3" height="12" rx="0.5" />
|
||||
<rect x="10" y="2" width="3" height="12" rx="0.5" />
|
||||
</svg>
|
||||
<svg v-else width="16" height="16" viewBox="0 0 16 16" fill="currentColor" class="text-white">
|
||||
<path d="M4 2.5v11l9-5.5z" />
|
||||
</svg>
|
||||
<button @click="player.toggle()" :disabled="player.switching"
|
||||
class="w-9 h-9 flex items-center justify-center rounded-full transition"
|
||||
:class="[
|
||||
drawerActive ? 'bg-white/15 hover:bg-white/25 border border-white/20' : 'bg-muted hover:bg-emphasis border border-emphasis',
|
||||
player.switching ? 'cursor-not-allowed' : '',
|
||||
]">
|
||||
<IconLoader2 v-if="player.switching" class="w-4 h-4 animate-spin" :class="drawerActive ? 'text-white' : 'text-content'" />
|
||||
<IconPause v-else-if="player.playing" class="w-4 h-4" :class="drawerActive ? 'text-white' : 'text-content'" />
|
||||
<IconPlay v-else class="w-4 h-4" :class="drawerActive ? 'text-white' : 'text-content'" />
|
||||
</button>
|
||||
<button @click="player.next()" class="text-content-2 hover:text-content transition">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 4 15 12 5 20 5 4"/><line x1="19" y1="5" x2="19" y2="19"/></svg>
|
||||
<button @click="player.next()" :disabled="player.switching" :class="[
|
||||
'transition',
|
||||
player.switching ? (drawerActive ? 'text-white/20 cursor-not-allowed' : 'text-content-4 cursor-not-allowed') : (drawerActive ? 'text-white/70 hover:text-white' : 'text-content-2 hover:text-content'),
|
||||
]">
|
||||
<IconSkipForward class="w-5 h-5" />
|
||||
</button>
|
||||
<button v-if="player.isFmMode && player.currentSong" @click="showDislikeModal = true" class="absolute left-full ml-5" :class="drawerActive ? 'text-white/50 hover:text-danger transition' : 'text-content-3 hover:text-danger transition'" title="减少推荐">
|
||||
<IconHeartOff class="w-[18px] h-[18px]" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs text-content-2">
|
||||
<div class="flex items-center gap-2 text-xs" :class="drawerActive ? 'text-white/70' : 'text-content-2'">
|
||||
<span>{{ formatTime(player.currentTime) }}</span>
|
||||
<span>/</span>
|
||||
<span>{{ formatTime(player.duration) }}</span>
|
||||
@@ -61,76 +94,191 @@
|
||||
|
||||
<div class="w-56 flex justify-end items-center gap-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="toggleMute" class="text-content-2 hover:text-content transition">
|
||||
<svg v-if="volume === 0" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg>
|
||||
<svg v-else width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 010 14.14M15.54 8.46a5 5 0 010 7.07"/></svg>
|
||||
<button @click="toggleMute" :class="drawerActive ? 'text-white/70 hover:text-white transition' : 'text-content-2 hover:text-content transition'">
|
||||
<IconVolumeX v-if="player.volume === 0" class="w-[18px] h-[18px]" />
|
||||
<IconVolume2 v-else class="w-[18px] h-[18px]" />
|
||||
</button>
|
||||
<div class="relative w-20 h-6 flex items-center">
|
||||
<input ref="volumeSlider" type="range" min="0" max="100" :value="volume"
|
||||
<input ref="volumeSlider" type="range" min="0" max="100" :value="player.volume"
|
||||
:style="{ background: volumeBarBg }" @input="handleVolumeChange"
|
||||
class="vol-slider w-full h-1.5 rounded-full appearance-none cursor-pointer bg-emphasis outline-none" />
|
||||
class="vol-slider w-full h-1.5 rounded-full appearance-none cursor-pointer outline-none"
|
||||
:class="drawerActive ? 'bg-white/20' : 'bg-emphasis'" />
|
||||
</div>
|
||||
</div>
|
||||
<button @click="togglePlayMode" class="text-content-2 hover:text-content transition" :title="modeTitle">
|
||||
<svg v-if="player.playMode === 'loop'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 014-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 01-4 4H3"/></svg>
|
||||
<svg v-else-if="player.playMode === 'shuffle'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg>
|
||||
<svg v-else width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 014-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 01-4 4H3"/><text x="11" y="15" font-size="8" fill="currentColor" stroke="none" font-weight="bold">1</text></svg>
|
||||
<button @click="togglePlayMode" :class="drawerActive ? 'text-white/70 hover:text-white transition' : 'text-content-2 hover:text-content transition'" :title="modeTitle">
|
||||
<IconRepeat v-if="player.playMode === 'loop'" class="w-[18px] h-[18px]" />
|
||||
<IconShuffle v-else-if="player.playMode === 'shuffle'" class="w-[18px] h-[18px]" />
|
||||
<IconRepeat1 v-else class="w-[18px] h-[18px]" />
|
||||
</button>
|
||||
<button @click="showQueuePanel = !showQueuePanel"
|
||||
class="text-content-2 hover:text-content transition relative" title="播放列表">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
|
||||
<span v-if="player.queue.length > 0"
|
||||
class="absolute -top-1 -right-1 bg-accent text-content text-xs rounded-full w-4 h-4 flex items-center justify-center">
|
||||
{{ player.queue.length }}
|
||||
</span>
|
||||
:class="drawerActive ? 'text-white/70 hover:text-white transition' : 'text-content-2 hover:text-content transition'" title="播放列表">
|
||||
<IconListMusic class="w-[18px] h-[18px]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition name="slide-up">
|
||||
<div v-if="showQueuePanel"
|
||||
class="border-t border-line bg-surface/95 backdrop-blur p-4 max-h-64 overflow-y-auto">
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<h3 class="text-sm font-semibold">播放列表 ({{ player.queue.length }})</h3>
|
||||
<button @click="player.clearQueue()" class="text-xs text-danger hover:text-danger transition">清空</button>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div v-for="(song, idx) in player.queue" :key="song.id + '-' + idx" @click="playFromQueue(idx)" :class="[
|
||||
'flex items-center gap-3 p-2 rounded-lg cursor-pointer transition',
|
||||
idx === player.currentIndex ? 'bg-accent-dim text-content' : 'hover:bg-subtle text-content-2',
|
||||
]">
|
||||
<span class="text-xs w-6 text-center">{{ idx + 1 }}</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs font-medium truncate">{{ song.name }}</p>
|
||||
<p class="text-xs text-content-3 truncate">
|
||||
{{song.ar?.map((a: any) => a.name).join('/')}}
|
||||
</p>
|
||||
<Teleport to="body">
|
||||
<Transition name="queue-fade">
|
||||
<div v-if="showDislikeModal && player.currentSong" class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showDislikeModal = false">
|
||||
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-80 p-5 select-auto">
|
||||
<h2 class="text-base font-semibold text-content mb-1">减少推荐</h2>
|
||||
<p class="text-xs text-content-3 mb-4">选择要减少的推荐类型</p>
|
||||
<div class="flex flex-col gap-2 mb-4">
|
||||
<button @click="dislikeSong"
|
||||
class="w-full flex items-center gap-3 px-4 py-3 rounded-xl bg-muted hover:bg-emphasis transition text-left">
|
||||
<IconMusic class="w-[18px] h-[18px] text-content-2 flex-shrink-0" />
|
||||
<div>
|
||||
<p class="text-sm font-medium">不推荐这首歌曲</p>
|
||||
<p class="text-xs text-content-3 truncate max-w-[200px]">{{ player.currentSong.name }}</p>
|
||||
</div>
|
||||
</button>
|
||||
<button v-if="dislikeArtistName" @click="dislikeArtist"
|
||||
class="w-full flex items-center gap-3 px-4 py-3 rounded-xl bg-muted hover:bg-emphasis transition text-left">
|
||||
<IconUserRound class="w-[18px] h-[18px] text-content-2 flex-shrink-0" />
|
||||
<div>
|
||||
<p class="text-sm font-medium">减少含此歌手的推荐</p>
|
||||
<p class="text-xs text-content-3 truncate max-w-[200px]">{{ dislikeArtistName }}</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<button @click.stop="player.removeFromQueue(idx)"
|
||||
class="text-content-3 hover:text-danger transition text-sm">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
<button @click="showDislikeModal = false"
|
||||
class="w-full py-2 rounded-lg bg-muted hover:bg-emphasis text-sm text-content-2 transition">
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Transition>
|
||||
<Transition name="queue-fade">
|
||||
<div v-if="showQueuePanel" class="fixed inset-0 z-[55] bg-black/40 backdrop-blur-[2px]" @click="showQueuePanel = false"></div>
|
||||
</Transition>
|
||||
<Transition name="queue-slide">
|
||||
<div v-if="showQueuePanel"
|
||||
class="fixed right-0 top-0 bottom-0 z-[56] w-[340px] bg-base/95 backdrop-blur border-l border-line flex flex-col shadow-2xl shadow-black/40">
|
||||
|
||||
<div class="px-5 pt-5 pb-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-[1rem] font-semibold text-content">播放列表</h3>
|
||||
<p class="text-xs text-content-3 mt-0.5">{{ player.queue.length }} 首歌曲</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="player.clearQueue()"
|
||||
class="px-2.5 py-1 text-xs text-content-3 hover:text-danger hover:bg-danger-dim rounded-lg transition">
|
||||
清空
|
||||
</button>
|
||||
<button @click="showQueuePanel = false"
|
||||
class="w-7 h-7 flex items-center justify-center rounded-lg text-content-3 hover:text-content hover:bg-subtle transition">
|
||||
<IconX class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-px mx-5 bg-line"></div>
|
||||
|
||||
<div ref="queueListEl" class="flex-1 overflow-y-auto px-3 py-2 relative">
|
||||
<div v-if="player.queue.length === 0" class="flex flex-col items-center justify-center h-full text-content-4 gap-3">
|
||||
<IconMusic class="w-10 h-10 opacity-40" />
|
||||
<p class="text-sm">播放列表为空</p>
|
||||
<p class="text-xs text-content-4">去发现好听的音乐吧</p>
|
||||
</div>
|
||||
|
||||
<SongListItem
|
||||
v-for="(song, idx) in player.queue" :key="song.id + '-' + idx"
|
||||
:id="'queue-item-' + idx"
|
||||
:song="song"
|
||||
:index="idx"
|
||||
:is-current="idx === player.currentIndex"
|
||||
show-playing-overlay
|
||||
cover-size="w-9 h-9"
|
||||
cover-size-param="?param=80y80"
|
||||
:container-class="idx === player.currentIndex ? 'bg-muted hover:bg-muted gap-3 px-3 py-2 rounded-lg' : 'hover:bg-subtle gap-3 px-3 py-2 rounded-lg'"
|
||||
@click="playFromQueue(idx)"
|
||||
>
|
||||
<template #actions>
|
||||
<button @click.stop="player.removeFromQueue(idx)"
|
||||
class="w-6 h-6 flex items-center justify-center rounded-md text-content-4 hover:text-danger hover:bg-danger-dim transition opacity-0 group-hover:opacity-100 flex-shrink-0">
|
||||
<IconX class="w-3 h-3" />
|
||||
</button>
|
||||
</template>
|
||||
</SongListItem>
|
||||
|
||||
<div class="h-2"></div>
|
||||
|
||||
<button v-if="player.currentIndex >= 0 && player.queue.length > 0" v-show="!currentSongVisible"
|
||||
@click="scrollToCurrent"
|
||||
class="sticky bottom-3 float-right mr-1 w-9 h-9 flex items-center justify-center rounded-full bg-surface/90 backdrop-blur shadow-lg shadow-black/30 text-content-3 hover:text-accent-text hover:bg-accent-dim/50 transition-all duration-300"
|
||||
title="定位到正在播放">
|
||||
<IconCrosshair class="w-[18px] h-[18px]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onBeforeUnmount, onMounted } from 'vue';
|
||||
import { usePlayerStore, PlayMode } from '../stores/player';
|
||||
import { ref, computed, watch, onBeforeUnmount, onMounted, nextTick } from 'vue';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import type { PlayMode } from '../types/song';
|
||||
import { useUiStore } from '../stores/ui';
|
||||
import { useSettingsStore } from '../stores/settings';
|
||||
import { useDownload } from '../composables/useDownload';
|
||||
import { formatTime } from '../utils/format';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { getCoverUrl } from '../utils/song';
|
||||
import { AudioApi } from '../api';
|
||||
import { showToast } from '../composables/useToast';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { useRouter } from 'vue-router';
|
||||
import SongListItem from './SongListItem.vue';
|
||||
import IconSkipBack from '~icons/lucide/skip-back';
|
||||
import IconPlay from '~icons/lucide/play';
|
||||
import IconPause from '~icons/lucide/pause';
|
||||
import IconSkipForward from '~icons/lucide/skip-forward';
|
||||
import IconHeartOff from '~icons/lucide/heart-off';
|
||||
import IconVolumeX from '~icons/lucide/volume-x';
|
||||
import IconVolume2 from '~icons/lucide/volume-2';
|
||||
import IconRepeat from '~icons/lucide/repeat';
|
||||
import IconShuffle from '~icons/lucide/shuffle';
|
||||
import IconRepeat1 from '~icons/lucide/repeat-1';
|
||||
import IconListMusic from '~icons/lucide/list-music';
|
||||
import IconDownload from '~icons/lucide/download';
|
||||
import IconLoader2 from '~icons/lucide/loader-2';
|
||||
import IconHeart from '~icons/lucide/heart';
|
||||
import IconX from '~icons/lucide/x';
|
||||
import IconMusic from '~icons/lucide/music';
|
||||
import IconCrosshair from '~icons/lucide/crosshair';
|
||||
import IconUserRound from '~icons/lucide/user-round';
|
||||
import IconShare2 from '~icons/lucide/share-2';
|
||||
import { hexToRgba } from '../utils/color';
|
||||
|
||||
const router = useRouter();
|
||||
const player = usePlayerStore();
|
||||
const ui = useUiStore();
|
||||
const settings = useSettingsStore();
|
||||
const download = useDownload();
|
||||
const drawerActive = computed(() => ui.showRoamDrawer && !!ui.dominantColor);
|
||||
|
||||
// PlayerBar 背景:有壁纸时用 --c-bg 高不透明度(与遮罩层同色系,视觉融合),
|
||||
// 无壁纸时用 surface 色
|
||||
const playerBarBgStyle = computed(() => {
|
||||
if (settings.currentWallpaper.path) {
|
||||
const bgColor = settings.currentColors.bg;
|
||||
const rgba = hexToRgba(bgColor, 0.82);
|
||||
return { backgroundColor: rgba };
|
||||
}
|
||||
return { backgroundColor: settings.currentColors.surface };
|
||||
});
|
||||
|
||||
const showQueuePanel = ref(false);
|
||||
const showDislikeModal = ref(false);
|
||||
const queueListEl = ref<HTMLElement | null>(null);
|
||||
const currentSongVisible = ref(true);
|
||||
const progressBar = ref<HTMLElement | null>(null);
|
||||
const isSeeking = ref(false);
|
||||
const previewTime = ref(0);
|
||||
const cacheProgress = ref(0);
|
||||
const volume = ref(100);
|
||||
const prevVolume = ref(100);
|
||||
|
||||
let unlistenCache: (() => void) | null = null;
|
||||
@@ -145,7 +293,17 @@ onBeforeUnmount(() => {
|
||||
if (unlistenCache) unlistenCache();
|
||||
});
|
||||
|
||||
const modeTexts = { loop: '列表循环', shuffle: '随机播放', 'repeat-one': '单曲循环' };
|
||||
watch(() => player.currentSong, (song) => {
|
||||
if (!song) {
|
||||
cacheProgress.value = 0;
|
||||
} else if (song.localPath) {
|
||||
cacheProgress.value = 100;
|
||||
} else {
|
||||
cacheProgress.value = 0;
|
||||
}
|
||||
});
|
||||
|
||||
const modeTexts: Record<PlayMode, string> = { loop: '列表循环', shuffle: '随机播放', 'repeat-one': '单曲循环' };
|
||||
const modeTitle = computed(() => modeTexts[player.playMode] || '列表循环');
|
||||
function togglePlayMode() {
|
||||
const modes: PlayMode[] = ['loop', 'shuffle', 'repeat-one'];
|
||||
@@ -154,13 +312,13 @@ function togglePlayMode() {
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
if (volume.value > 0) {
|
||||
prevVolume.value = volume.value;
|
||||
volume.value = 0;
|
||||
if (player.volume > 0) {
|
||||
prevVolume.value = player.volume;
|
||||
player.volume = 0;
|
||||
} else {
|
||||
volume.value = prevVolume.value || 100;
|
||||
player.volume = prevVolume.value || 100;
|
||||
}
|
||||
invoke('set_volume', { vol: volume.value / 100 });
|
||||
AudioApi.setVolume(player.volume / 100);
|
||||
}
|
||||
|
||||
let onDocMove: ((e: MouseEvent) => void) | null = null;
|
||||
@@ -218,31 +376,95 @@ function playFromQueue(index: number) {
|
||||
player.playCurrent();
|
||||
}
|
||||
|
||||
const dislikeArtistName = computed(() => {
|
||||
const song = player.currentSong;
|
||||
if (!song?.ar?.length) return '';
|
||||
return song.ar.map(a => a.name).join(' / ');
|
||||
});
|
||||
|
||||
async function dislikeSong() {
|
||||
if (!player.currentSong) return;
|
||||
showDislikeModal.value = false;
|
||||
await player.fmTrash(player.currentSong.id);
|
||||
showToast('已减少该歌曲推荐', 'success');
|
||||
}
|
||||
|
||||
async function dislikeArtist() {
|
||||
if (!player.currentSong) return;
|
||||
showDislikeModal.value = false;
|
||||
await player.fmTrash(player.currentSong.id);
|
||||
showToast('已减少含该歌手的推荐', 'success');
|
||||
}
|
||||
|
||||
async function shareSong(id: number) {
|
||||
const url = `https://music.163.com/song?id=${id}`;
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
showToast('链接已复制', 'success');
|
||||
} catch {
|
||||
showToast('复制失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToCurrent() {
|
||||
const el = document.getElementById('queue-item-' + player.currentIndex);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
|
||||
let currentSongObserver: IntersectionObserver | null = null;
|
||||
|
||||
function setupCurrentSongObserver() {
|
||||
if (currentSongObserver) {
|
||||
currentSongObserver.disconnect();
|
||||
currentSongObserver = null;
|
||||
}
|
||||
nextTick(() => {
|
||||
const el = document.getElementById('queue-item-' + player.currentIndex);
|
||||
if (!el || !queueListEl.value) return;
|
||||
currentSongObserver = new IntersectionObserver(
|
||||
([entry]) => { currentSongVisible.value = entry.isIntersecting; },
|
||||
{ root: queueListEl.value, threshold: 0.5 }
|
||||
);
|
||||
currentSongObserver.observe(el);
|
||||
});
|
||||
}
|
||||
|
||||
watch(() => [showQueuePanel.value, player.currentIndex, player.queue.length], () => {
|
||||
if (showQueuePanel.value) {
|
||||
setupCurrentSongObserver();
|
||||
} else {
|
||||
if (currentSongObserver) {
|
||||
currentSongObserver.disconnect();
|
||||
currentSongObserver = null;
|
||||
}
|
||||
currentSongVisible.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (currentSongObserver) {
|
||||
currentSongObserver.disconnect();
|
||||
currentSongObserver = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function handleVolumeChange(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const val = parseInt(target.value, 10);
|
||||
volume.value = val;
|
||||
await invoke('set_volume', { vol: val / 100 });
|
||||
player.volume = val;
|
||||
await AudioApi.setVolume(val / 100);
|
||||
}
|
||||
|
||||
const volumeBarBg = computed(() => {
|
||||
const pct = volume.value;
|
||||
return `linear-gradient(to right, var(--c-accent) 0%, var(--c-accent) ${pct}%, var(--c-muted) ${pct}%)`;
|
||||
const pct = player.volume;
|
||||
const track = drawerActive.value ? 'rgba(255,255,255,0.2)' : 'var(--c-muted)';
|
||||
return `linear-gradient(to right, var(--c-accent) 0%, var(--c-accent) ${pct}%, ${track} ${pct}%)`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.slide-up-enter-from,
|
||||
.slide-up-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
.vol-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
@@ -279,4 +501,23 @@ const volumeBarBg = computed(() => {
|
||||
.vol-slider::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.queue-fade-enter-active,
|
||||
.queue-fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.queue-fade-enter-from,
|
||||
.queue-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.queue-slide-enter-active,
|
||||
.queue-slide-leave-active {
|
||||
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.queue-slide-enter-from,
|
||||
.queue-slide-leave-to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
<template>
|
||||
<Transition name="drawer">
|
||||
<div v-if="visible" class="fixed inset-0 z-50 flex flex-col">
|
||||
<!-- 背景层:fade in 覆盖全屏 -->
|
||||
<div
|
||||
class="absolute inset-0 backdrop-blur-xl"
|
||||
:class="!ui.dominantColor && 'bg-surface/95'"
|
||||
:style="ui.dominantColor ? { backgroundColor: ui.dominantColor } : {}"
|
||||
></div>
|
||||
<div v-if="ui.dominantColor" class="absolute inset-0 bg-black/60 pointer-events-none"></div>
|
||||
|
||||
<!-- 内容层:slide up/down -->
|
||||
<div class="relative z-10 flex flex-col flex-1 min-h-0 drawer-content">
|
||||
<TitleBar :dark-mode="!!ui.dominantColor" transparent @close="ui.closeRoamDrawer()">
|
||||
<template #left>
|
||||
<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" />
|
||||
</button>
|
||||
</template>
|
||||
</TitleBar>
|
||||
|
||||
<div class="flex-1 min-h-0 flex px-8 pb-8 gap-0">
|
||||
<!-- 左侧:封面 + 歌曲信息 -->
|
||||
<div class="w-2/5 flex flex-col items-center justify-center flex-shrink-0">
|
||||
<img
|
||||
v-if="roamCoverUrl && !roamCoverError"
|
||||
:src="roamCoverUrl"
|
||||
class="w-72 h-72 rounded-3xl object-cover shadow-2xl mb-4"
|
||||
@error="roamCoverError = true"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="w-72 h-72 rounded-3xl flex items-center justify-center shadow-2xl mb-4"
|
||||
:class="dc ? 'bg-white/10' : 'bg-muted'"
|
||||
>
|
||||
<IconMusic class="w-16 h-16" :class="dc ? 'text-white/30' : 'text-content-4'" />
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-center" :class="dc ? 'text-white' : 'text-content'">{{ roamSong?.name }}</h1>
|
||||
<p class="mt-2 text-center" :class="dc ? 'text-white/70' : 'text-content-2'">
|
||||
<template v-for="(a, i) in roamSong?.ar || []" :key="a.id || i">
|
||||
<span v-if="i > 0" :class="dc ? 'text-white/40' : 'text-content-3'">/</span>
|
||||
<span class="hover:text-accent-text cursor-pointer transition" @click="a.id && navigateFromDrawer({ name: 'artist', params: { id: a.id } })">{{ a.name }}</span>
|
||||
</template>
|
||||
<template v-if="roamSong?.al?.name">
|
||||
<span :class="dc ? 'text-white/40' : 'text-content-3'" class="mx-1">·</span>
|
||||
<span class="hover:text-accent-text cursor-pointer transition" @click="roamSong!.al.id && navigateFromDrawer({ name: 'album', params: { id: roamSong!.al.id } })">{{ roamSong.al.name }}</span>
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:歌词/评论 -->
|
||||
<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">
|
||||
<button @click="ui.roamTab = 'lyric'"
|
||||
class="px-3 py-1 rounded-full text-sm transition"
|
||||
:class="tabClass(ui.roamTab === 'lyric')">
|
||||
歌词
|
||||
</button>
|
||||
<button @click="ui.roamTab = 'comment'"
|
||||
class="px-3 py-1 rounded-full text-sm transition"
|
||||
:class="tabClass(ui.roamTab === 'comment')">
|
||||
评论
|
||||
</button>
|
||||
<button v-if="hasTranslation" @click="toggleTranslation"
|
||||
class="ml-auto px-2.5 py-1 rounded-full text-xs transition flex items-center gap-1"
|
||||
:class="tabClass(showTranslation)">
|
||||
<IconLanguages class="w-3 h-3" />
|
||||
译
|
||||
</button>
|
||||
</div>
|
||||
<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"
|
||||
:style="{ paddingTop: roamLyricPadPx + 'px', paddingBottom: roamLyricPadPx + 'px' }">
|
||||
<p
|
||||
v-for="(line, idx) in lyrics"
|
||||
:key="idx"
|
||||
:class="getRoamLyricClass(idx)"
|
||||
class="roam-lyric-line px-4 py-3 rounded-lg cursor-pointer transition-[font-size,opacity] duration-300 ease-out"
|
||||
@click="seekToRoamLyric(line.time)"
|
||||
@mouseenter="roamLyricHovering = true"
|
||||
@mouseleave="roamLyricHovering = false"
|
||||
>
|
||||
{{ line.text }}
|
||||
<span v-if="showTranslation && line.translation" class="block text-sm mt-1" :class="getTranslationClass(idx)">{{ line.translation }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div v-else :class="dc ? 'text-white/40' : 'text-content-3'" class="text-center mt-8">暂无歌词</div>
|
||||
</div>
|
||||
<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="ui.commentSongId || roamSong.id" :key="ui.commentSongId || roamSong.id" :dark-mode="!!ui.dominantColor" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onBeforeUnmount, computed, nextTick } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { useUiStore } from '../stores/ui';
|
||||
import { getCoverUrl, extractDominantColor } from '../utils/song';
|
||||
import { useLyric } from '../composables/UserLyric';
|
||||
import TitleBar from './TitleBar.vue';
|
||||
import CommentSection from './CommentSection.vue';
|
||||
import IconChevronDown from '~icons/lucide/chevron-down';
|
||||
import IconMusic from '~icons/lucide/music';
|
||||
import IconLanguages from '~icons/lucide/languages';
|
||||
|
||||
defineProps<{
|
||||
visible: boolean;
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
const player = usePlayerStore();
|
||||
const ui = useUiStore();
|
||||
|
||||
// dominantColor 是否存在(模板中频繁使用)
|
||||
const dc = computed(() => !!ui.dominantColor);
|
||||
|
||||
const { lyrics, currentLyricIdx, hasTranslation, showTranslation, toggleTranslation } = useLyric();
|
||||
const lyricScrollContainer = ref<HTMLElement | null>(null);
|
||||
const roamLyricHovering = ref(false);
|
||||
const roamLyricPadPx = ref(0);
|
||||
const roamSong = computed(() => player.currentSong);
|
||||
const roamCoverError = ref(false);
|
||||
const roamCoverUrl = computed(() => {
|
||||
if (!roamSong.value) return '';
|
||||
return getCoverUrl(roamSong.value) || '';
|
||||
});
|
||||
|
||||
watch(roamCoverUrl, async (url) => {
|
||||
roamCoverError.value = false;
|
||||
if (url) {
|
||||
const color = await extractDominantColor(url);
|
||||
ui.dominantColor = color;
|
||||
} else {
|
||||
ui.dominantColor = '';
|
||||
}
|
||||
});
|
||||
|
||||
let roamResizeObserver: ResizeObserver | null = null;
|
||||
|
||||
function updateRoamLyricPad() {
|
||||
if (lyricScrollContainer.value) {
|
||||
roamLyricPadPx.value = Math.floor(lyricScrollContainer.value.clientHeight / 2);
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => ui.showRoamDrawer, (val) => {
|
||||
if (val) {
|
||||
nextTick(() => {
|
||||
updateRoamLyricPad();
|
||||
if (roamResizeObserver) roamResizeObserver.disconnect();
|
||||
if (lyricScrollContainer.value) {
|
||||
roamResizeObserver = new ResizeObserver(() => updateRoamLyricPad());
|
||||
roamResizeObserver.observe(lyricScrollContainer.value);
|
||||
}
|
||||
scrollToRoamActiveLyric();
|
||||
});
|
||||
} else {
|
||||
if (roamResizeObserver) {
|
||||
roamResizeObserver.disconnect();
|
||||
roamResizeObserver = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (roamResizeObserver) {
|
||||
roamResizeObserver.disconnect();
|
||||
roamResizeObserver = null;
|
||||
}
|
||||
});
|
||||
|
||||
watch(currentLyricIdx, () => {
|
||||
if (ui.showRoamDrawer && !roamLyricHovering.value) {
|
||||
nextTick(() => scrollToRoamActiveLyric());
|
||||
}
|
||||
});
|
||||
|
||||
watch(showTranslation, () => {
|
||||
if (ui.showRoamDrawer && !roamLyricHovering.value) {
|
||||
nextTick(() => scrollToRoamActiveLyric());
|
||||
}
|
||||
});
|
||||
|
||||
function scrollToRoamActiveLyric() {
|
||||
if (!lyricScrollContainer.value || roamLyricHovering.value) return;
|
||||
const container = lyricScrollContainer.value;
|
||||
const active = container.querySelector('.roam-lyric-active') as HTMLElement | null;
|
||||
if (!active) return;
|
||||
const target = active.offsetTop - container.clientHeight / 2 + active.clientHeight / 2;
|
||||
const start = container.scrollTop;
|
||||
const distance = target - start;
|
||||
if (Math.abs(distance) < 1) return;
|
||||
const duration = 400;
|
||||
const startTime = performance.now();
|
||||
function animate(now: number) {
|
||||
const elapsed = now - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const ease = 1 - Math.pow(1 - progress, 3);
|
||||
container.scrollTop = start + distance * ease;
|
||||
if (progress < 1) requestAnimationFrame(animate);
|
||||
}
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
// Tab 按钮统一样式
|
||||
function tabClass(active: boolean): string {
|
||||
if (dc.value) {
|
||||
return active ? 'bg-white/15 text-white font-medium' : 'text-white/50 hover:text-white/80';
|
||||
}
|
||||
return active ? 'bg-muted text-content font-medium' : 'text-content-3 hover:text-content';
|
||||
}
|
||||
|
||||
function getTranslationClass(idx: number): string {
|
||||
const diff = Math.abs(idx - currentLyricIdx.value);
|
||||
if (idx === currentLyricIdx.value) return dc.value ? 'text-[var(--c-accent)]' : 'text-accent-text';
|
||||
if (diff === 1) return dc.value ? 'text-white/70' : 'text-content/70';
|
||||
if (diff === 2) return dc.value ? 'text-white/50' : 'text-content-2/50';
|
||||
return dc.value ? 'text-white/35' : 'text-content-3/35';
|
||||
}
|
||||
|
||||
function getRoamLyricClass(idx: number): string {
|
||||
const diff = Math.abs(idx - currentLyricIdx.value);
|
||||
if (idx === currentLyricIdx.value) {
|
||||
return dc.value
|
||||
? 'roam-lyric-active font-bold text-xl text-[var(--c-accent)]'
|
||||
: 'roam-lyric-active text-accent-text font-semibold text-xl';
|
||||
}
|
||||
if (diff === 1) return dc.value ? 'text-white/70 text-lg' : 'text-content/70 text-lg';
|
||||
if (diff === 2) return dc.value ? 'text-white/50 text-[1rem]' : 'text-content-2/50 text-[1rem]';
|
||||
return dc.value ? 'text-white/35 text-[1rem]' : 'text-content-3/35 text-[1rem]';
|
||||
}
|
||||
|
||||
function seekToRoamLyric(time: number) {
|
||||
if (time != null && player.duration > 0) {
|
||||
player.seek(time);
|
||||
}
|
||||
}
|
||||
|
||||
function navigateFromDrawer(routeLocation: { name: string; params: any }) {
|
||||
ui.closeRoamDrawer();
|
||||
router.push(routeLocation);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 外层容器:fade in/out */
|
||||
.drawer-enter-active,
|
||||
.drawer-leave-active { transition: opacity 0.3s ease; }
|
||||
.drawer-enter-from,
|
||||
.drawer-leave-to { opacity: 0; }
|
||||
|
||||
/* 内容层:slide up/down */
|
||||
.drawer-enter-active .drawer-content,
|
||||
.drawer-leave-active .drawer-content { transition: transform 0.3s ease; }
|
||||
.drawer-enter-from .drawer-content,
|
||||
.drawer-leave-to .drawer-content { transform: translateY(100%); }
|
||||
|
||||
.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>
|
||||
@@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<!-- 抽屉模式:遮罩 + 滑出面板 -->
|
||||
<template v-if="mode === 'drawer'">
|
||||
<Transition name="fade">
|
||||
<div v-if="ui.drawerOpen" class="fixed inset-0 bg-black/50 z-40" @click="ui.closeDrawer()"></div>
|
||||
</Transition>
|
||||
<Transition name="slide">
|
||||
<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">
|
||||
<!-- Logo 区域 -->
|
||||
<div class="h-12 flex items-center flex-shrink-0 px-4 gap-2" data-tauri-drag-region>
|
||||
<IconMusic class="w-7 h-7 text-accent flex-shrink-0" />
|
||||
<span class="text-base font-bold text-content truncate">Nekosonic</span>
|
||||
</div>
|
||||
<SidebarContent :collapsed="false" @navigate="ui.closeDrawer()" />
|
||||
</nav>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<!-- 展开/折叠模式:常驻侧栏(透明背景,靠右边框分隔) -->
|
||||
<nav
|
||||
v-else
|
||||
class="relative flex-shrink-0 flex flex-col transition-all duration-300 border-r border-line-2/40"
|
||||
:class="mode === 'collapsed' ? 'w-16' : 'w-56'"
|
||||
>
|
||||
<!-- Logo 区域(高度与 TopBar 一致,用于对齐) -->
|
||||
<div
|
||||
class="h-12 flex items-center flex-shrink-0"
|
||||
:class="mode === 'collapsed' ? 'justify-center' : 'px-4 gap-2'"
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<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>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useUiStore, type SidebarMode } from '../stores/ui';
|
||||
import { useSettingsStore } from '../stores/settings';
|
||||
import SidebarContent from './SidebarContent.vue';
|
||||
import IconMusic from '~icons/lucide/music';
|
||||
|
||||
const ui = useUiStore();
|
||||
const settings = useSettingsStore();
|
||||
const router = useRouter();
|
||||
|
||||
defineProps<{
|
||||
mode: SidebarMode;
|
||||
}>();
|
||||
|
||||
// 侧边栏透明:无壁纸时继承主容器背景,有壁纸时透出壁纸
|
||||
// 仅抽屉模式需要半透明背景 + 模糊(浮在主内容之上)
|
||||
const sidebarBgStyle = computed(() => {
|
||||
if (settings.currentWallpaper.path) {
|
||||
return { backgroundColor: 'rgba(0, 0, 0, 0.2)' };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
</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>
|
||||
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div class="relative flex-shrink-0" ref="menuRef">
|
||||
<button @click.stop="toggle" class="text-content-3 hover:text-content transition p-1 rounded-md hover:bg-subtle">
|
||||
<IconEllipsis class="w-4 h-4 fill-current" />
|
||||
</button>
|
||||
<div v-if="open"
|
||||
class="absolute right-0 top-full mt-1 bg-surface border border-line rounded-xl shadow-xl z-50 py-1 min-w-[120px]">
|
||||
<button @click.stop="handleComment" class="w-full flex items-center gap-2 px-3 py-2 text-sm text-content-2 hover:bg-subtle hover:text-content transition">
|
||||
<IconMessageSquare style="font-size: 14px" />
|
||||
评论
|
||||
</button>
|
||||
<button @click.stop="handleShare" class="w-full flex items-center gap-2 px-3 py-2 text-sm text-content-2 hover:bg-subtle hover:text-content transition">
|
||||
<IconShare2 style="font-size: 14px" />
|
||||
分享
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onBeforeUnmount, onMounted } from 'vue';
|
||||
import { useUiStore } from '../stores/ui';
|
||||
import { showToast } from '../composables/useToast';
|
||||
import IconEllipsis from '~icons/lucide/ellipsis';
|
||||
import IconMessageSquare from '~icons/lucide/message-square';
|
||||
import IconShare2 from '~icons/lucide/share-2';
|
||||
|
||||
const ui = useUiStore();
|
||||
const props = defineProps<{ songId: number }>();
|
||||
const open = ref(false);
|
||||
const menuRef = ref<HTMLElement | null>(null);
|
||||
|
||||
function toggle() {
|
||||
open.value = !open.value;
|
||||
}
|
||||
|
||||
function handleComment() {
|
||||
open.value = false;
|
||||
ui.openCommentForSong(props.songId);
|
||||
}
|
||||
|
||||
async function handleShare() {
|
||||
open.value = false;
|
||||
const url = `https://music.163.com/song?id=${props.songId}`;
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
showToast('链接已复制', 'success');
|
||||
} catch {
|
||||
showToast('复制失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
if (menuRef.value && !menuRef.value.contains(e.target as Node)) {
|
||||
open.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('click', onClickOutside));
|
||||
onBeforeUnmount(() => document.removeEventListener('click', onClickOutside));
|
||||
</script>
|
||||
@@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<div :class="['flex items-center gap-4 p-3 rounded-xl cursor-pointer transition group', isCurrent ? 'bg-accent-dim hover:bg-accent-dim' : containerClass]">
|
||||
<slot name="index" :index="index" :is-current="isCurrent">
|
||||
<div v-if="showIndex" class="w-6 text-right flex-shrink-0 flex items-center justify-end h-5">
|
||||
<div v-if="isCurrent && showPlayingOverlay" class="flex items-center justify-end">
|
||||
<div class="flex items-center gap-[3px] h-4">
|
||||
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 50%; animation-delay: 0ms"></span>
|
||||
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 100%; animation-delay: 150ms"></span>
|
||||
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 35%; animation-delay: 300ms"></span>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<span class="text-xs text-content-3 group-hover:hidden">{{ index + 1 }}</span>
|
||||
<IconPlay class="hidden group-hover:block text-content" style="font-size: 14px" />
|
||||
</template>
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
<div :class="['rounded-md overflow-hidden flex-shrink-0 relative', coverClass]">
|
||||
<img v-if="coverSrc" :src="coverSrc" 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 style="font-size: 14px" class="text-content-4" />
|
||||
</div>
|
||||
<div v-if="isCurrent && showPlayingOverlay"
|
||||
class="absolute inset-0 bg-black/30 flex items-center justify-center">
|
||||
<div class="flex items-end gap-[2px] h-3">
|
||||
<span class="eq-bar-sm w-[2px] bg-white rounded-full" style="animation-delay: 0s"></span>
|
||||
<span class="eq-bar-sm w-[2px] bg-white rounded-full" style="animation-delay: 0.12s"></span>
|
||||
<span class="eq-bar-sm w-[2px] bg-white rounded-full" style="animation-delay: 0.24s"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium truncate flex items-center gap-1.5" :class="nameClass">
|
||||
<span class="truncate">{{ song.name }}</span>
|
||||
<span
|
||||
v-for="tag in songTags"
|
||||
:key="tag.kind"
|
||||
class="text-[10px] leading-none px-1 py-0.5 rounded flex-shrink-0 font-medium"
|
||||
:class="tag.class"
|
||||
>{{ tag.label }}</span>
|
||||
<span v-if="song.sourceLabel" class="text-[10px] px-1 py-0.5 rounded bg-muted text-content-2 flex-shrink-0">{{ song.sourceLabel }}</span>
|
||||
</p>
|
||||
<p class="text-xs text-content-2 truncate">
|
||||
<template v-if="song.ar?.length">
|
||||
<template v-for="(a, i) in song.ar" :key="a.id || i">
|
||||
<span v-if="i > 0" class="text-content-3">/</span>
|
||||
<span class="hover:text-accent-text cursor-pointer transition" @click.stop="a.id && router.push({ name: 'artist', params: { id: a.id } })">{{ a.name }}</span>
|
||||
</template>
|
||||
</template>
|
||||
<template v-if="song.al?.name">
|
||||
<span class="text-content-3 mx-1">·</span>
|
||||
<span class="hover:text-accent-text cursor-pointer transition" @click.stop="song.al.id && router.push({ name: 'album', params: { id: song.al.id } })">{{ song.al.name }}</span>
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<slot name="actions">
|
||||
<button v-if="showLike" @click.stop="player.toggleLike(song.id)" class="text-content-3 hover:text-danger transition flex-shrink-0">
|
||||
<IconHeart v-if="player.isLiked(song.id)" class="w-4 h-4 text-danger [&>path]:fill-current [&>path]:stroke-0" />
|
||||
<IconHeart v-else class="w-4 h-4" />
|
||||
</button>
|
||||
<button v-if="showDownload" @click.stop="download.downloadSong(song)" class="text-content-3 hover:text-accent-text transition flex-shrink-0" :title="download.isDownloaded(song.id) ? '已下载' : '下载'">
|
||||
<IconLoader2 v-if="download.isDownloading(song.id)" class="w-4 h-4 animate-spin" />
|
||||
<IconCheck v-else-if="download.isDownloaded(song.id)" class="w-4 h-4 text-accent-text" />
|
||||
<IconDownload v-else class="w-4 h-4" />
|
||||
</button>
|
||||
<SongItemMenu v-if="showMenu" :song-id="song.id" />
|
||||
</slot>
|
||||
|
||||
<span v-if="showDuration && song.dt" class="text-xs text-content-3 flex-shrink-0">{{ formatDuration(song.dt) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { useDownload } from '../composables/useDownload';
|
||||
import { getCoverUrl, getSongTags, type Song } from '../utils/song';
|
||||
import { formatDuration } from '../utils/format';
|
||||
import SongItemMenu from './SongItemMenu.vue';
|
||||
import IconPlay from '~icons/lucide/play';
|
||||
import IconMusic from '~icons/lucide/music';
|
||||
import IconHeart from '~icons/lucide/heart';
|
||||
import IconLoader2 from '~icons/lucide/loader-2';
|
||||
import IconCheck from '~icons/lucide/check';
|
||||
import IconDownload from '~icons/lucide/download';
|
||||
|
||||
const router = useRouter();
|
||||
const player = usePlayerStore();
|
||||
const download = useDownload();
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
song: Song;
|
||||
index: number;
|
||||
isCurrent?: boolean;
|
||||
showIndex?: boolean;
|
||||
showLike?: boolean;
|
||||
showDownload?: boolean;
|
||||
showMenu?: boolean;
|
||||
showDuration?: boolean;
|
||||
showPlayingOverlay?: boolean;
|
||||
coverSize?: string;
|
||||
coverSizeParam?: string;
|
||||
containerClass?: string;
|
||||
}>(), {
|
||||
isCurrent: false,
|
||||
showIndex: false,
|
||||
showLike: false,
|
||||
showDownload: false,
|
||||
showMenu: false,
|
||||
showDuration: false,
|
||||
showPlayingOverlay: false,
|
||||
coverSize: 'w-10 h-10',
|
||||
coverSizeParam: '',
|
||||
containerClass: 'hover:bg-subtle',
|
||||
});
|
||||
|
||||
const coverClass = computed(() => props.coverSize);
|
||||
const coverSrc = computed(() => getCoverUrl(props.song, props.coverSizeParam));
|
||||
const nameClass = computed(() => props.isCurrent ? 'text-accent-text' : '');
|
||||
const songTags = computed(() => getSongTags(props.song));
|
||||
</script>
|
||||
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
class="h-10 flex items-center justify-between px-4 flex-shrink-0 select-none relative z-10"
|
||||
:style="titleBarBgStyle"
|
||||
>
|
||||
<slot name="left">
|
||||
<span v-if="!darkMode" class="text-xs text-content-3 font-medium ml-2">Nekosonic Music</span>
|
||||
</slot>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<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 { computed } from 'vue';
|
||||
import { useSettingsStore } from '../stores/settings';
|
||||
import { minimizeWindow, toggleMaximize } from '../composables/useWindowControls';
|
||||
|
||||
const props = defineProps<{
|
||||
darkMode?: boolean;
|
||||
transparent?: boolean;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
const settings = useSettingsStore();
|
||||
|
||||
const titleBarBgStyle = computed(() => {
|
||||
if (props.transparent) return {};
|
||||
if (settings.currentWallpaper.path) return {}; // 有壁纸时透明,由遮罩层统一提供背景
|
||||
if (props.darkMode) return {};
|
||||
return { backgroundColor: settings.currentColors.surface };
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,338 @@
|
||||
<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 result = data.result || {};
|
||||
// 优先用 allMatch(type=mobile 格式)
|
||||
let list: string[] = (result.allMatch || []).map((m: any) => m.keyword);
|
||||
// 兜底:从 songs/artists/albums 提取名称
|
||||
if (list.length === 0) {
|
||||
list = [
|
||||
...(result.songs || []).map((s: any) => s.name),
|
||||
...(result.artists || []).map((a: any) => a.name),
|
||||
...(result.albums || []).map((a: any) => a.name),
|
||||
].filter(Boolean);
|
||||
}
|
||||
suggestions.value = list.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,88 @@
|
||||
<template>
|
||||
<Transition name="fade">
|
||||
<div v-if="visible" class="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="!downloading && handleIgnore()">
|
||||
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-[440px] max-h-[80vh] flex flex-col select-auto">
|
||||
<div class="p-6 pb-4">
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<div class="w-10 h-10 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0">
|
||||
<IconDownload class="w-5 h-5 text-accent-text" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-content">发现新版本</h2>
|
||||
<p class="text-xs text-content-3 mt-0.5">
|
||||
v{{ info.currentVersion }} → <span class="text-accent-text font-medium">v{{ info.version }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="info.date" class="text-xs text-content-3 mt-2 ml-[52px]">{{ formatDate(info.date) }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="info.body" class="px-6 pb-4 flex-1 overflow-y-auto max-h-60 ml-[52px]">
|
||||
<div class="text-sm text-content-2 leading-relaxed whitespace-pre-wrap">{{ info.body }}</div>
|
||||
</div>
|
||||
<div v-else class="px-6 pb-4 ml-[52px]">
|
||||
<p class="text-sm text-content-3">暂无更新日志</p>
|
||||
</div>
|
||||
|
||||
<div v-if="downloading" class="px-6 pb-2">
|
||||
<div class="w-full bg-subtle rounded-full h-2 overflow-hidden">
|
||||
<div class="h-full bg-accent rounded-full transition-all duration-300" :style="{ width: downloadProgress + '%' }"></div>
|
||||
</div>
|
||||
<p class="text-xs text-content-3 mt-1 text-center">正在下载更新 {{ downloadProgress }}%</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 border-t border-line flex gap-3">
|
||||
<button
|
||||
@click="handleIgnore"
|
||||
:disabled="downloading"
|
||||
class="flex-1 py-2 rounded-lg bg-muted hover:bg-emphasis text-sm text-content-2 transition disabled:opacity-50"
|
||||
>
|
||||
忽略此版本
|
||||
</button>
|
||||
<button
|
||||
@click="handleUpdate"
|
||||
:disabled="downloading"
|
||||
class="flex-1 py-2 rounded-lg bg-accent hover:bg-accent-hover text-white text-sm font-medium transition disabled:opacity-50"
|
||||
>
|
||||
{{ downloading ? `下载中 ${downloadProgress}%` : '立即更新' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { UpdateInfo } from '../composables/useUpdater'
|
||||
import IconDownload from '~icons/lucide/download'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
info: UpdateInfo & { currentVersion: string }
|
||||
downloading: boolean
|
||||
downloadProgress: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
update: []
|
||||
ignore: []
|
||||
}>()
|
||||
|
||||
function handleUpdate() {
|
||||
emit('update')
|
||||
}
|
||||
|
||||
function handleIgnore() {
|
||||
if (props.downloading) return
|
||||
emit('ignore')
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
try {
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })
|
||||
} catch {
|
||||
return dateStr
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -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,41 +1,37 @@
|
||||
import { ref, watch } from 'vue';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { parseLrc, getCurrentLyricIndex, LyricLine } from '../utils/lyric';
|
||||
import { watch } from 'vue';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { useLyricManager } from './useLyricManager';
|
||||
|
||||
/**
|
||||
* 歌词 composable(向后兼容入口)
|
||||
*
|
||||
* 委托给 useLyricManager,并自动绑定 player store 的 currentSong/currentTime。
|
||||
* 新代码建议直接使用 useLyricManager。
|
||||
*/
|
||||
export function useLyric() {
|
||||
const player = usePlayerStore();
|
||||
const manager = useLyricManager();
|
||||
|
||||
const lyrics = ref<LyricLine[]>([]);
|
||||
const currentLyricIdx = ref(-1);
|
||||
|
||||
watch(() => player.currentSong, async (song) => {
|
||||
if (!song) {
|
||||
lyrics.value = [];
|
||||
currentLyricIdx.value = -1;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const jsonStr: string = await invoke('get_lyric', { id: song.id });
|
||||
const data = JSON.parse(jsonStr);
|
||||
const lrc = data?.lrc?.lyric || '';
|
||||
lyrics.value = lrc ? parseLrc(lrc) : [];
|
||||
currentLyricIdx.value = -1;
|
||||
} catch {
|
||||
lyrics.value = [];
|
||||
// 跟随当前歌曲加载歌词
|
||||
watch(() => player.currentSong, (song) => {
|
||||
manager.loadLyric(song);
|
||||
// 预加载队列中下一首的歌词
|
||||
const next = player.queue[player.currentIndex + 1];
|
||||
if (next && next.id !== song?.id) {
|
||||
manager.preloadLyric(next);
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
// 跟随播放时间更新当前歌词行
|
||||
watch(() => player.currentTime, (t) => {
|
||||
if (lyrics.value.length === 0) return;
|
||||
const idx = getCurrentLyricIndex(lyrics.value, t);
|
||||
if (idx !== currentLyricIdx.value) {
|
||||
currentLyricIdx.value = idx;
|
||||
}
|
||||
manager.updateCurrentIndex(t);
|
||||
});
|
||||
|
||||
return {
|
||||
lyrics,
|
||||
currentLyricIdx,
|
||||
lyrics: manager.lyrics,
|
||||
currentLyricIdx: manager.currentLyricIdx,
|
||||
hasTranslation: manager.hasTranslation,
|
||||
showTranslation: manager.showTranslation,
|
||||
toggleTranslation: manager.toggleTranslation,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { reactive, watch } from 'vue';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { useSettingsStore } from '../stores/settings';
|
||||
import { showToast } from '../composables/useToast';
|
||||
import { getCoverUrl, type Song } from '../utils/song';
|
||||
import { DownloadApi } from '../api';
|
||||
|
||||
interface DownloadTask {
|
||||
id: number;
|
||||
name: string;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
const downloadingIds = reactive<Set<number>>(new Set());
|
||||
const tasks = reactive<DownloadTask[]>([]);
|
||||
const localSongIds = reactive<Set<number>>(new Set());
|
||||
|
||||
let listenerSetup = false;
|
||||
let storeSetup = false;
|
||||
|
||||
async function setupDownloadListener() {
|
||||
if (listenerSetup) return;
|
||||
listenerSetup = true;
|
||||
await listen<{ id: number; progress: number; name: string }>('download-progress', (event) => {
|
||||
const { id, progress, name } = event.payload;
|
||||
if (progress >= 100) {
|
||||
const idx = tasks.findIndex(t => t.id === id);
|
||||
if (idx >= 0) {
|
||||
tasks.splice(idx, 1);
|
||||
downloadingIds.delete(id);
|
||||
showToast(`${name} 下载完成`, 'success');
|
||||
}
|
||||
} else {
|
||||
const task = tasks.find(t => t.id === id);
|
||||
if (task) {
|
||||
task.progress = progress;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshLocalIds() {
|
||||
try {
|
||||
const settings = useSettingsStore();
|
||||
const list: { id: number }[] = await DownloadApi.listLocalSongs(settings.downloadPath || null);
|
||||
localSongIds.clear();
|
||||
for (const s of list) {
|
||||
localSongIds.add(s.id);
|
||||
}
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
|
||||
function ensureStoreSetup() {
|
||||
if (storeSetup) return;
|
||||
storeSetup = true;
|
||||
const settings = useSettingsStore();
|
||||
refreshLocalIds();
|
||||
watch(() => settings.downloadPath, () => {
|
||||
refreshLocalIds();
|
||||
});
|
||||
}
|
||||
|
||||
function isDownloaded(songId: number): boolean {
|
||||
return localSongIds.has(songId);
|
||||
}
|
||||
|
||||
function isDownloading(songId: number): boolean {
|
||||
return downloadingIds.has(songId);
|
||||
}
|
||||
|
||||
function getDownloadProgress(songId: number): number {
|
||||
const task = tasks.find(t => t.id === songId);
|
||||
return task?.progress ?? 0;
|
||||
}
|
||||
|
||||
async function downloadSong(song: Song) {
|
||||
if (downloadingIds.has(song.id)) return;
|
||||
if (localSongIds.has(song.id)) {
|
||||
showToast(`${song.name} 已下载`, 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = useSettingsStore();
|
||||
const artist = song.ar?.map(a => a.name).join(' / ') || '未知';
|
||||
const albumName = song.al?.name || null;
|
||||
const durationVal = song.dt || null;
|
||||
const coverUrl = getCoverUrl(song) || null;
|
||||
|
||||
downloadingIds.add(song.id);
|
||||
tasks.push({ id: song.id, name: song.name, progress: 0 });
|
||||
|
||||
try {
|
||||
await DownloadApi.downloadSong({
|
||||
id: song.id,
|
||||
name: song.name,
|
||||
artist,
|
||||
album: albumName,
|
||||
duration: durationVal,
|
||||
coverUrl,
|
||||
level: settings.audioQuality,
|
||||
downloadPath: settings.downloadPath || null,
|
||||
});
|
||||
localSongIds.add(song.id);
|
||||
} catch (e: any) {
|
||||
downloadingIds.delete(song.id);
|
||||
const idx = tasks.findIndex(t => t.id === song.id);
|
||||
if (idx >= 0) tasks.splice(idx, 1);
|
||||
if (e === '文件已存在') {
|
||||
localSongIds.add(song.id);
|
||||
showToast(`${song.name} 已下载`, 'info');
|
||||
} else if (e === 'VIP歌曲无法下载') {
|
||||
showToast(`${song.name} 为 VIP 歌曲,无法下载`, 'error');
|
||||
} else if (typeof e === 'string' && e.includes('VIP')) {
|
||||
showToast(`${song.name} 需要 VIP 权限才能下载`, 'error');
|
||||
} else {
|
||||
showToast(`下载失败: ${e}`, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function useDownload() {
|
||||
setupDownloadListener();
|
||||
ensureStoreSetup();
|
||||
return {
|
||||
downloadingIds,
|
||||
tasks,
|
||||
localSongIds,
|
||||
isDownloaded,
|
||||
isDownloading,
|
||||
getDownloadProgress,
|
||||
downloadSong,
|
||||
refreshLocalIds,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { MusicApi } from '../api';
|
||||
import type { Song } from '../utils/song';
|
||||
|
||||
export interface LocalSong {
|
||||
id: number;
|
||||
name: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
duration: number;
|
||||
cover: string | null;
|
||||
filename: string;
|
||||
fileSize: number;
|
||||
path: string;
|
||||
local: boolean;
|
||||
}
|
||||
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i];
|
||||
}
|
||||
|
||||
export function localSongToSong(local: LocalSong): Song {
|
||||
return {
|
||||
id: local.id,
|
||||
name: local.name,
|
||||
ar: local.artist ? [{ name: local.artist }] : [],
|
||||
al: { picUrl: local.cover || '', name: local.album || undefined },
|
||||
dt: local.duration || undefined,
|
||||
localPath: local.path,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchMissingCovers(songs: LocalSong[]): Promise<void> {
|
||||
const missing = songs.filter(s => !s.cover && s.id > 0 && s.id < 1e12);
|
||||
if (missing.length === 0) return;
|
||||
const ids = [...new Set(missing.map(s => s.id))];
|
||||
try {
|
||||
const jsonStr: string = await MusicApi.getSongDetail(JSON.stringify(ids));
|
||||
const data = JSON.parse(jsonStr);
|
||||
const detailMap = new Map<number, string>();
|
||||
for (const s of data.songs || []) {
|
||||
const url = s.al?.picUrl;
|
||||
if (url && s.id) detailMap.set(s.id, url + '?param=100y100');
|
||||
}
|
||||
for (const song of missing) {
|
||||
const url = detailMap.get(song.id);
|
||||
if (url) song.cover = url;
|
||||
}
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue';
|
||||
|
||||
const isOnline = ref(navigator.onLine);
|
||||
|
||||
function update() {
|
||||
isOnline.value = navigator.onLine;
|
||||
}
|
||||
|
||||
let refCount = 0;
|
||||
|
||||
export function useOnlineStatus() {
|
||||
onMounted(() => {
|
||||
refCount++;
|
||||
if (refCount === 1) {
|
||||
window.addEventListener('online', update);
|
||||
window.addEventListener('offline', update);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
refCount--;
|
||||
if (refCount <= 0) {
|
||||
refCount = 0;
|
||||
window.removeEventListener('online', update);
|
||||
window.removeEventListener('offline', update);
|
||||
}
|
||||
});
|
||||
|
||||
return { isOnline };
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* 页面数据缓存(L1 内存 + L2 localStorage 持久化)
|
||||
*
|
||||
* 两级缓存策略:
|
||||
* - L1:内存 Map,访问最快,重启即失
|
||||
* - L2:localStorage,持久化,重启后仍可命中
|
||||
*
|
||||
* 读取顺序:L1 → L2 → miss(L2 命中时回填 L1)
|
||||
* 写入策略:同时写 L1 + L2(L2 配额不足时静默淘汰最旧条目)
|
||||
*/
|
||||
const TTL = 30 * 60 * 1000;
|
||||
const MAX_ENTRIES = 30;
|
||||
const LS_PREFIX = 'pc:';
|
||||
|
||||
// L1 内存缓存
|
||||
const cache = new Map<string, { data: any; ts: number }>();
|
||||
|
||||
/** 从 localStorage 读取并校验 TTL */
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/** 写入 localStorage,配额不足时按 FIFO 淘汰旧条目后重试 */
|
||||
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;
|
||||
if (firstKey !== undefined) cache.delete(firstKey);
|
||||
}
|
||||
}
|
||||
|
||||
export function pageCacheGet(key: string): any | null {
|
||||
// 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) {
|
||||
cache.delete(key);
|
||||
lsDelete(key);
|
||||
}
|
||||
|
||||
export function pageCacheIsStale(key: string): boolean {
|
||||
const entry = cache.get(key) || lsGet(key);
|
||||
if (!entry) return true;
|
||||
return Date.now() - entry.ts > TTL;
|
||||
}
|
||||
@@ -9,7 +9,22 @@ export interface Toast {
|
||||
const toasts = ref<Toast[]>([]);
|
||||
let nextId = 0;
|
||||
|
||||
const MAX_TOASTS = 3;
|
||||
const DEDUP_WINDOW = 3000;
|
||||
const recentMessages = new Map<string, number>();
|
||||
|
||||
export function showToast(message: string, type: 'success' | 'error' | 'info' = 'info', duration = 3000) {
|
||||
const key = `${type}:${message}`;
|
||||
const now = Date.now();
|
||||
const lastShown = recentMessages.get(key);
|
||||
if (lastShown && now - lastShown < DEDUP_WINDOW) return;
|
||||
recentMessages.set(key, now);
|
||||
setTimeout(() => { recentMessages.delete(key); }, DEDUP_WINDOW);
|
||||
|
||||
if (toasts.value.length >= MAX_TOASTS) {
|
||||
toasts.value.shift();
|
||||
}
|
||||
|
||||
const id = nextId++;
|
||||
toasts.value.push({ id, message, type });
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { ref } from 'vue'
|
||||
import { check } from '@tauri-apps/plugin-updater'
|
||||
import { relaunch } from '@tauri-apps/plugin-process'
|
||||
import { getVersion } from '@tauri-apps/api/app'
|
||||
|
||||
export interface UpdateInfo {
|
||||
version: string
|
||||
date: string | null
|
||||
body: string | null
|
||||
}
|
||||
|
||||
const IGNORED_VERSION_KEY = 'updater_ignored_version'
|
||||
|
||||
// 模块级共享状态:所有 useUpdater() 调用返回同一份 ref
|
||||
// 否则 App.vue 的弹窗和 Settings.vue 的检查按钮会操作不同的实例,弹窗永远不显示
|
||||
const checking = ref(false)
|
||||
const downloading = ref(false)
|
||||
const downloadProgress = ref(0)
|
||||
const updateAvailable = ref(false)
|
||||
const updateInfo = ref<UpdateInfo | null>(null)
|
||||
const currentVersion = ref('')
|
||||
const error = ref('')
|
||||
|
||||
export function useUpdater() {
|
||||
|
||||
async function getCurrentVersion() {
|
||||
try {
|
||||
currentVersion.value = await getVersion()
|
||||
} catch {
|
||||
currentVersion.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function getIgnoredVersion(): string {
|
||||
try {
|
||||
return localStorage.getItem(IGNORED_VERSION_KEY) || ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function setIgnoredVersion(version: string) {
|
||||
try {
|
||||
localStorage.setItem(IGNORED_VERSION_KEY, version)
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
|
||||
async function checkForUpdate(silent = false): Promise<UpdateInfo | null> {
|
||||
if (checking.value) return null
|
||||
checking.value = true
|
||||
error.value = ''
|
||||
updateAvailable.value = false
|
||||
updateInfo.value = null
|
||||
|
||||
try {
|
||||
await getCurrentVersion()
|
||||
const result = await check()
|
||||
|
||||
if (!result) {
|
||||
if (!silent) error.value = '当前已是最新版本'
|
||||
return null
|
||||
}
|
||||
|
||||
const info: UpdateInfo = {
|
||||
version: result.version,
|
||||
date: result.date ?? null,
|
||||
body: result.body ?? null,
|
||||
}
|
||||
|
||||
const ignored = getIgnoredVersion()
|
||||
if (info.version === ignored && silent) {
|
||||
return null
|
||||
}
|
||||
|
||||
updateAvailable.value = true
|
||||
updateInfo.value = info
|
||||
return info
|
||||
} catch (e: any) {
|
||||
if (!silent) error.value = `检查更新失败: ${e}`
|
||||
return null
|
||||
} finally {
|
||||
checking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadAndInstall() {
|
||||
if (downloading.value) return
|
||||
downloading.value = true
|
||||
downloadProgress.value = 0
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const result = await check()
|
||||
if (!result) {
|
||||
error.value = '未找到可用更新'
|
||||
return
|
||||
}
|
||||
|
||||
let downloaded = 0
|
||||
let contentLength = 0
|
||||
await result.downloadAndInstall((event) => {
|
||||
switch (event.event) {
|
||||
case 'Started':
|
||||
contentLength = event.data.contentLength ?? 0
|
||||
break
|
||||
case 'Progress':
|
||||
downloaded += event.data.chunkLength
|
||||
if (contentLength > 0) {
|
||||
downloadProgress.value = Math.round((downloaded / contentLength) * 100)
|
||||
}
|
||||
break
|
||||
case 'Finished':
|
||||
downloadProgress.value = 100
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
await relaunch()
|
||||
} catch (e: any) {
|
||||
error.value = `更新失败: ${e}`
|
||||
} finally {
|
||||
downloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function ignoreVersion(version: string) {
|
||||
setIgnoredVersion(version)
|
||||
updateAvailable.value = false
|
||||
updateInfo.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
checking,
|
||||
downloading,
|
||||
downloadProgress,
|
||||
updateAvailable,
|
||||
updateInfo,
|
||||
currentVersion,
|
||||
error,
|
||||
checkForUpdate,
|
||||
downloadAndInstall,
|
||||
ignoreVersion,
|
||||
getCurrentVersion,
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -4,29 +4,22 @@ import './style.css';
|
||||
import router from './router';
|
||||
import { createPinia } from 'pinia';
|
||||
|
||||
// ---------- 彻底阻止双指拖动和手势 ----------
|
||||
const preventGesture = (e: Event) => e.preventDefault();
|
||||
|
||||
// 阻止 iOS / macOS 手势缩放和页面拖动
|
||||
document.addEventListener('gesturestart', preventGesture, { passive: false });
|
||||
document.addEventListener('gesturechange', preventGesture, { passive: false });
|
||||
document.addEventListener('gestureend', preventGesture, { passive: false });
|
||||
|
||||
// 阻止触控板双指水平滑动(若仍存在)
|
||||
window.addEventListener('wheel', (e: WheelEvent) => {
|
||||
// 只阻止水平方向,保留垂直滚动(内部容器会处理)
|
||||
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
// 阻止移动端双指触摸移动(不影响单指滚动)
|
||||
window.addEventListener('touchmove', (e: TouchEvent) => {
|
||||
if (e.touches.length >= 2) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}, { passive: false });
|
||||
// -------------------------------------------
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(router);
|
||||
|
||||
+30
-21
@@ -1,28 +1,37 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import Home from '@/views/Home.vue';
|
||||
import Discover from '@/views/Discover.vue';
|
||||
import PlaylistDetail from '@/views/PlaylistDetail.vue';
|
||||
import Login from '@/views/Login.vue';
|
||||
import FavoriteSongs from '@/views/FavoriteSongs.vue';
|
||||
import RecentPlays from '@/views/RecentPlays.vue';
|
||||
import DailySongs from '@/views/DailySongs.vue';
|
||||
import Settings from '@/views/Settings.vue';
|
||||
|
||||
|
||||
const routes = [
|
||||
{ path: '/', name: 'home', component: Home },
|
||||
{ path: '/discover', name: 'discover', component: Discover },
|
||||
{ path: '/search', name: 'search', component: Discover },
|
||||
{ path: '/roam', name: 'roam', component: () => import('@/views/Roam.vue') },
|
||||
{ path: '/favorites', name: 'favorites', component: FavoriteSongs },
|
||||
{ path: '/recent', name: 'recent', component: RecentPlays },
|
||||
{ path: '/daily', name: 'daily', component: DailySongs },
|
||||
{ path: '/login', name: 'login', component: Login },
|
||||
{ path: '/playlist/:id', name: 'playlist', component: PlaylistDetail },
|
||||
{ path: '/settings', name: 'settings', component: Settings },
|
||||
{ path: '/', name: 'home', component: () => import('@/views/Home.vue') },
|
||||
{ path: '/discover', name: 'discover', component: () => import('@/views/Discover.vue') },
|
||||
{ path: '/search', name: 'search', component: () => import('@/views/Discover.vue') },
|
||||
{ path: '/favorites', name: 'favorites', component: () => import('@/views/FavoriteSongs.vue') },
|
||||
{ path: '/recent', name: 'recent', component: () => import('@/views/RecentPlays.vue') },
|
||||
{ path: '/daily', name: 'daily', component: () => import('@/views/DailySongs.vue') },
|
||||
{ path: '/local-music', name: 'local-music', component: () => import('@/views/LocalMusic.vue') },
|
||||
{ path: '/downloaded-music', name: 'downloaded-music', component: () => import('@/views/DownloadedMusic.vue') },
|
||||
{ path: '/cloud-music', name: 'cloud-music', component: () => import('@/views/CloudMusic.vue') },
|
||||
{ path: '/login', name: 'login', component: () => import('@/views/Login.vue'), meta: { guest: true } },
|
||||
{ path: '/playlist/:id', name: 'playlist', component: () => import('@/views/PlaylistDetail.vue') },
|
||||
{ path: '/artist/:id', name: 'artist', component: () => import('@/views/ArtistDetail.vue') },
|
||||
{ path: '/album/:id', name: 'album', component: () => import('@/views/AlbumDetail.vue') },
|
||||
{ path: '/settings', name: 'settings', component: () => import('@/views/Settings.vue') },
|
||||
];
|
||||
|
||||
export default createRouter({
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
});
|
||||
});
|
||||
|
||||
router.beforeEach((to) => {
|
||||
if (to.meta.guest) {
|
||||
const raw = localStorage.getItem('user_profile');
|
||||
if (raw) {
|
||||
try {
|
||||
const data = JSON.parse(raw);
|
||||
if (data?.userId) return { name: 'home' };
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
declare module '~icons/lucide/*' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
export interface SkinColors {
|
||||
bg: string;
|
||||
surface: string;
|
||||
subtle: string;
|
||||
muted: string;
|
||||
emphasis: string;
|
||||
content: string;
|
||||
content2: string;
|
||||
content3: string;
|
||||
content4: string;
|
||||
line: string;
|
||||
line2: string;
|
||||
accent: string;
|
||||
accentHover: string;
|
||||
accentText: string;
|
||||
accentDim: string;
|
||||
danger: string;
|
||||
dangerDim: string;
|
||||
warning: string;
|
||||
info: string;
|
||||
}
|
||||
|
||||
interface PresetSkin {
|
||||
id: string;
|
||||
name: string;
|
||||
preview: string;
|
||||
colors: SkinColors;
|
||||
}
|
||||
|
||||
function darkSkin(accent: string, accentHover: string, accentText: string, name: string, id: string): PresetSkin {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
preview: accent,
|
||||
colors: {
|
||||
bg: '#02060c',
|
||||
surface: '#0a101a',
|
||||
subtle: `rgba(${hexToRgb(accent)}, 0.06)`,
|
||||
muted: `rgba(${hexToRgb(accent)}, 0.10)`,
|
||||
emphasis: `rgba(${hexToRgb(accent)}, 0.18)`,
|
||||
content: '#ffffff',
|
||||
content2: '#9ca3af',
|
||||
content3: '#6b7280',
|
||||
content4: '#4b5563',
|
||||
line: 'rgba(255, 255, 255, 0.08)',
|
||||
line2: 'rgba(255, 255, 255, 0.04)',
|
||||
accent,
|
||||
accentHover,
|
||||
accentText,
|
||||
accentDim: `rgba(${hexToRgb(accent)}, 0.20)`,
|
||||
danger: '#ef4444',
|
||||
dangerDim: 'rgba(239, 68, 68, 0.20)',
|
||||
warning: '#eab308',
|
||||
info: '#8b5cf6',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function lightSkin(accent: string, accentHover: string, accentText: string, name: string, id: string): PresetSkin {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
preview: accent,
|
||||
colors: {
|
||||
bg: '#f8fafc',
|
||||
surface: '#ffffff',
|
||||
subtle: `rgba(${hexToRgb(accent)}, 0.06)`,
|
||||
muted: `rgba(${hexToRgb(accent)}, 0.10)`,
|
||||
emphasis: `rgba(${hexToRgb(accent)}, 0.18)`,
|
||||
content: '#0f172a',
|
||||
content2: '#475569',
|
||||
content3: '#94a3b8',
|
||||
content4: '#cbd5e1',
|
||||
line: 'rgba(0, 0, 0, 0.08)',
|
||||
line2: 'rgba(0, 0, 0, 0.04)',
|
||||
accent,
|
||||
accentHover,
|
||||
accentText,
|
||||
accentDim: `rgba(${hexToRgb(accent)}, 0.15)`,
|
||||
danger: '#ef4444',
|
||||
dangerDim: 'rgba(239, 68, 68, 0.15)',
|
||||
warning: '#eab308',
|
||||
info: '#8b5cf6',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string): string {
|
||||
const h = hex.replace('#', '');
|
||||
const r = parseInt(h.substring(0, 2), 16);
|
||||
const g = parseInt(h.substring(2, 4), 16);
|
||||
const b = parseInt(h.substring(4, 6), 16);
|
||||
return `${r}, ${g}, ${b}`;
|
||||
}
|
||||
|
||||
export const presetSkins: PresetSkin[] = [
|
||||
// 深色
|
||||
darkSkin('#3b82f6', '#2563eb', '#60a5fa', '深蓝', 'dark-blue'),
|
||||
darkSkin('#22c55e', '#16a34a', '#4ade80', '深翠', 'dark-green'),
|
||||
darkSkin('#f43f5e', '#e11d48', '#fb7185', '深红', 'dark-rose'),
|
||||
darkSkin('#8b5cf6', '#7c3aed', '#a78bfa', '深紫', 'dark-violet'),
|
||||
darkSkin('#f97316', '#ea580c', '#fb923c', '深橙', 'dark-orange'),
|
||||
darkSkin('#06b6d4', '#0891b2', '#22d3ee', '深青', 'dark-cyan'),
|
||||
darkSkin('#ec4899', '#db2777', '#f472b6', '深粉', 'dark-pink'),
|
||||
// 浅色
|
||||
lightSkin('#3b82f6', '#2563eb', '#2563eb', '浅蓝', 'light-blue'),
|
||||
lightSkin('#22c55e', '#16a34a', '#16a34a', '浅翠', 'light-green'),
|
||||
lightSkin('#f43f5e', '#e11d48', '#e11d48', '浅红', 'light-rose'),
|
||||
lightSkin('#8b5cf6', '#7c3aed', '#7c3aed', '浅紫', 'light-violet'),
|
||||
lightSkin('#f97316', '#ea580c', '#ea580c', '浅橙', 'light-orange'),
|
||||
lightSkin('#06b6d4', '#0891b2', '#0891b2', '浅青', 'light-cyan'),
|
||||
lightSkin('#ec4899', '#db2777', '#db2777', '浅粉', 'light-pink'),
|
||||
];
|
||||
|
||||
const presetIdSet = new Set(presetSkins.map(s => s.id));
|
||||
|
||||
export function isPresetSkinId(id: string): boolean {
|
||||
return presetIdSet.has(id);
|
||||
}
|
||||
|
||||
export function getPresetSkin(id: string): PresetSkin | undefined {
|
||||
return presetSkins.find(s => s.id === id);
|
||||
}
|
||||
|
||||
/** 将皮肤颜色应用到 DOM CSS 变量 */
|
||||
export function applySkinColors(colors: SkinColors) {
|
||||
const root = document.documentElement;
|
||||
const map: Record<keyof SkinColors, string> = {
|
||||
bg: '--c-bg',
|
||||
surface: '--c-surface',
|
||||
subtle: '--c-subtle',
|
||||
muted: '--c-muted',
|
||||
emphasis: '--c-emphasis',
|
||||
content: '--c-content',
|
||||
content2: '--c-content-2',
|
||||
content3: '--c-content-3',
|
||||
content4: '--c-content-4',
|
||||
line: '--c-line',
|
||||
line2: '--c-line-2',
|
||||
accent: '--c-accent',
|
||||
accentHover: '--c-accent-hover',
|
||||
accentText: '--c-accent-text',
|
||||
accentDim: '--c-accent-dim',
|
||||
danger: '--c-danger',
|
||||
dangerDim: '--c-danger-dim',
|
||||
warning: '--c-warning',
|
||||
info: '--c-info',
|
||||
};
|
||||
for (const [key, cssVar] of Object.entries(map)) {
|
||||
root.style.setProperty(cssVar, colors[key as keyof SkinColors]);
|
||||
}
|
||||
}
|
||||
@@ -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,249 @@
|
||||
/**
|
||||
* 设置数据 Schema 迁移
|
||||
*
|
||||
* 每次新增字段或修改结构时,新增一个迁移函数并递增 CURRENT_SCHEMA_VERSION。
|
||||
* 迁移按版本号顺序执行:v0 → v1 → v2 → ... → CURRENT。
|
||||
*
|
||||
* 迁移函数签名:(data: any) => any
|
||||
* - 输入:旧版数据(可能是任意历史版本)
|
||||
* - 输出:升级后的数据
|
||||
* - 必须幂等:对已经是目标版本的数据执行应无副作用
|
||||
*/
|
||||
|
||||
/** 当前 Schema 版本号 */
|
||||
export const CURRENT_SCHEMA_VERSION = 7;
|
||||
|
||||
/** 迁移函数按目标版本号索引(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;
|
||||
},
|
||||
|
||||
/**
|
||||
* v4: 新增第三方音源 fallback 设置
|
||||
*
|
||||
* 老用户没有 musicSourceEnabled / musicSources 字段,
|
||||
* 默认开启总开关,启用 kugou + kuwo(与默认值一致)。
|
||||
*/
|
||||
4: (data) => {
|
||||
if (typeof data.musicSourceEnabled !== 'boolean') {
|
||||
data.musicSourceEnabled = true;
|
||||
}
|
||||
if (!Array.isArray(data.musicSources)) {
|
||||
data.musicSources = ['kugou', 'kuwo'];
|
||||
}
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* v5: 音源模型重构
|
||||
*
|
||||
* 旧版 musicSources 是 string[](仅存音源 id),
|
||||
* 新版改为 MusicSourceConfig[](含 id/label/enabled/kind 及自定义 HTTP API 字段)。
|
||||
*
|
||||
* 迁移规则:
|
||||
* - 已是对象数组:保留,补齐缺失字段
|
||||
* - 是字符串数组:转换为对象,旧列表里的视为 enabled,bodain(新增波点)默认启用并置于首位
|
||||
* - 不存在或格式不对:使用默认内置音源列表
|
||||
*/
|
||||
5: (data) => {
|
||||
// 默认内置音源列表(与 settings.ts 的 builtinMusicSourceDefaults 一致)
|
||||
const BUILTIN_DEFAULTS: Array<{ id: string; label: string; enabled: boolean; kind: 'builtin' }> = [
|
||||
{ id: 'bodian', label: '波点音乐', enabled: true, kind: 'builtin' },
|
||||
{ id: 'kugou', label: '酷狗音乐', enabled: true, kind: 'builtin' },
|
||||
{ id: 'kuwo', label: '酷我音乐', enabled: true, kind: 'builtin' },
|
||||
{ id: 'bilibili', label: 'Bilibili 音频', enabled: false, kind: 'builtin' },
|
||||
];
|
||||
const BUILTIN_LABELS: Record<string, string> = {
|
||||
bodian: '波点音乐',
|
||||
kugou: '酷狗音乐',
|
||||
kuwo: '酷我音乐',
|
||||
bilibili: 'Bilibili 音频',
|
||||
};
|
||||
|
||||
const oldSources = data.musicSources;
|
||||
// 字符串数组 → 对象数组
|
||||
if (Array.isArray(oldSources) && oldSources.length > 0 && typeof oldSources[0] === 'string') {
|
||||
const oldIds = new Set(oldSources.filter((s: any) => typeof s === 'string'));
|
||||
// 波点(bodian)作为新默认首选,老用户没有则补上启用
|
||||
const result: any[] = [];
|
||||
for (const def of BUILTIN_DEFAULTS) {
|
||||
// 老用户已启用的源保持启用;新增的 bodian 默认启用
|
||||
const enabled = oldIds.has(def.id) || def.id === 'bodian' ? true : def.enabled;
|
||||
result.push({ ...def, enabled });
|
||||
}
|
||||
// 老用户列表里可能有自定义源 id(理论上 v4 没有自定义源),保留为 builtin 兜底
|
||||
for (const sid of oldIds) {
|
||||
if (typeof sid !== 'string') continue;
|
||||
if (result.some(r => r.id === sid)) continue;
|
||||
result.push({
|
||||
id: sid,
|
||||
label: BUILTIN_LABELS[sid] || sid,
|
||||
enabled: true,
|
||||
kind: 'builtin',
|
||||
});
|
||||
}
|
||||
data.musicSources = result;
|
||||
} else if (Array.isArray(oldSources) && oldSources.length > 0 && typeof oldSources[0] === 'object') {
|
||||
// 已是对象数组:补齐缺失字段(kind / 默认 label 等)
|
||||
data.musicSources = oldSources.map((s: any) => {
|
||||
if (!s || typeof s !== 'object') return null;
|
||||
const id: string = String(s.id || '');
|
||||
const kind: string = s.kind === 'custom' ? 'custom' : 'builtin';
|
||||
return {
|
||||
id,
|
||||
label: s.label || BUILTIN_LABELS[id] || id,
|
||||
enabled: typeof s.enabled === 'boolean' ? s.enabled : true,
|
||||
kind,
|
||||
...(kind === 'custom' ? {
|
||||
searchUrl: s.searchUrl,
|
||||
urlApi: s.urlApi,
|
||||
searchPath: s.searchPath,
|
||||
urlPath: s.urlPath,
|
||||
idField: s.idField,
|
||||
nameField: s.nameField,
|
||||
artistField: s.artistField,
|
||||
albumField: s.albumField,
|
||||
durationField: s.durationField,
|
||||
picField: s.picField,
|
||||
} : {}),
|
||||
};
|
||||
}).filter(Boolean);
|
||||
// 老用户对象数组里可能没有 bodian,补上
|
||||
if (!data.musicSources.some((s: any) => s.id === 'bodian')) {
|
||||
data.musicSources.unshift({ id: 'bodian', label: '波点音乐', enabled: true, kind: 'builtin' });
|
||||
}
|
||||
} else {
|
||||
// 不存在或空:使用默认
|
||||
data.musicSources = BUILTIN_DEFAULTS.map(s => ({ ...s }));
|
||||
}
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* v6: 移除已废弃的 bilibili 内置源
|
||||
*
|
||||
* bilibili 接口需 cookie,无 cookie 返回 412,已从内置源列表中删除。
|
||||
* 注意:kuwo 之前因 r.s 接口返回单引号 JSON 解析失败也被移除,
|
||||
* 但现已用 kwDES + mobi.s 接口重写(能绕过 VIP 限制),不再废弃。
|
||||
* 用户如果列表里有 bilibili id,直接过滤掉;自定义源保留。
|
||||
*/
|
||||
6: (data) => {
|
||||
if (Array.isArray(data.musicSources)) {
|
||||
data.musicSources = data.musicSources.filter((s: any) =>
|
||||
s && typeof s === 'object' && !(s.kind === 'builtin' && s.id === 'bilibili')
|
||||
);
|
||||
}
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* v7: 恢复 kuwo 内置源
|
||||
*
|
||||
* 之前 v6 错误地把 kuwo 也一并移除了。kuwo 现已用 kwDES 加密 + mobi.s 接口重写,
|
||||
* 能绕过 VIP 限制播放完整歌曲(参考 SPlayer 实现),是播放 VIP 歌曲的关键音源。
|
||||
* 对已升级到 v6(kuwo 被删除)的用户,把 kuwo 加回列表末尾(默认启用)。
|
||||
* 已有 kuwo 的用户不受影响(幂等)。
|
||||
*/
|
||||
7: (data) => {
|
||||
if (Array.isArray(data.musicSources)) {
|
||||
const hasKuwo = data.musicSources.some((s: any) =>
|
||||
s && typeof s === 'object' && s.id === 'kuwo' && s.kind === 'builtin'
|
||||
);
|
||||
if (!hasKuwo) {
|
||||
data.musicSources.push({
|
||||
id: 'kuwo',
|
||||
label: '酷我音乐',
|
||||
enabled: true,
|
||||
kind: 'builtin',
|
||||
});
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
+623
-198
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
};
|
||||
});
|
||||
+372
-16
@@ -1,8 +1,9 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, watch } from 'vue';
|
||||
import { ref, watch, computed } from 'vue';
|
||||
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 ThemeMode = 'dark' | 'light';
|
||||
export type CloseAction = 'ask' | 'minimize' | 'exit';
|
||||
|
||||
export const qualityLabels: Record<AudioQuality, string> = {
|
||||
@@ -19,33 +20,246 @@ export const closeActionLabels: Record<CloseAction, string> = {
|
||||
exit: '直接退出',
|
||||
};
|
||||
|
||||
/**
|
||||
* 音源类型
|
||||
* - builtin: 内置源(bodian/kugou/kuwo/bilibili),由后端实现
|
||||
* - custom: 自定义 HTTP API 源,由用户配置模板 URL + JSON 路径
|
||||
*/
|
||||
export type MusicSourceKind = 'builtin' | 'custom';
|
||||
|
||||
/**
|
||||
* 音源配置(前端持久化、调用后端命令都用这个结构,与后端 SourceConfig 对齐)
|
||||
*/
|
||||
export interface MusicSourceConfig {
|
||||
/** 唯一 ID(builtin: 'bodian'/'kugou'/...;custom: 'custom-xxxx') */
|
||||
id: string;
|
||||
/** 显示名称 */
|
||||
label: string;
|
||||
/** 是否启用 */
|
||||
enabled: boolean;
|
||||
/** 类型 */
|
||||
kind: MusicSourceKind;
|
||||
|
||||
// ===== 自定义 HTTP API 源字段(kind === 'custom' 时使用)=====
|
||||
/** 搜索 URL 模板,支持 {keyword} {page} {limit} 占位符 */
|
||||
searchUrl?: string;
|
||||
/** 获取播放 URL 接口,支持 {id} {quality} 占位符 */
|
||||
urlApi?: string;
|
||||
/** JSON 路径:搜索结果数组(如 "data.songs") */
|
||||
searchPath?: string;
|
||||
/** JSON 路径:URL 字符串字段(如 "data.url") */
|
||||
urlPath?: string;
|
||||
/** 字段映射(自定义源 JSON 字段名) */
|
||||
idField?: string;
|
||||
nameField?: string;
|
||||
artistField?: string;
|
||||
albumField?: string;
|
||||
durationField?: string;
|
||||
picField?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 内置音源默认配置(顺序即默认优先级,波点为首选,参考 SPlayer)
|
||||
* kuwo 使用 kwDES + mobi.s 接口,能绕过 VIP 限制播放完整歌曲
|
||||
*/
|
||||
export const builtinMusicSourceDefaults: MusicSourceConfig[] = [
|
||||
{ id: 'bodian', label: '波点音乐', enabled: true, kind: 'builtin' },
|
||||
{ id: 'kugou', label: '酷狗音乐', enabled: true, kind: 'builtin' },
|
||||
{ id: 'kuwo', label: '酷我音乐', enabled: true, kind: 'builtin' },
|
||||
];
|
||||
|
||||
/** 内置音源 id 集合,用于判断是否可删除 */
|
||||
export const builtinSourceIds = new Set(builtinMusicSourceDefaults.map(s => s.id));
|
||||
|
||||
export interface ShortcutBinding {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const defaultShortcuts: Record<string, ShortcutBinding> = {
|
||||
playPause: { key: 'Control+KeyP', label: '播放/暂停' },
|
||||
prev: { key: 'Control+ArrowLeft', label: '上一首' },
|
||||
next: { key: 'Control+ArrowRight', label: '下一首' },
|
||||
volUp: { key: 'Control+ArrowUp', label: '音量增加' },
|
||||
volDown: { key: 'Control+ArrowDown', label: '音量减小' },
|
||||
globalPlayPause: { key: 'Control+Alt+KeyP', label: '播放/暂停(全局)' },
|
||||
globalPrev: { key: 'Control+Alt+ArrowLeft', label: '上一首(全局)' },
|
||||
globalNext: { key: 'Control+Alt+ArrowRight', label: '下一首(全局)' },
|
||||
globalVolUp: { key: 'Control+Alt+ArrowUp', label: '音量增加(全局)' },
|
||||
globalVolDown: { key: 'Control+Alt+ArrowDown', label: '音量减小(全局)' },
|
||||
};
|
||||
|
||||
export interface CustomSkin {
|
||||
id: string;
|
||||
name: string;
|
||||
preview: string;
|
||||
colors: SkinColors;
|
||||
/** 壁纸图片路径,为空则使用纯色背景 */
|
||||
wallpaper: string;
|
||||
/** 壁纸模糊度 0-30 */
|
||||
wallpaperBlur: number;
|
||||
/** 壁纸透明度 0-1 */
|
||||
wallpaperOpacity: number;
|
||||
}
|
||||
|
||||
export interface MusicFolder {
|
||||
path: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface SettingsData {
|
||||
audioQuality: AudioQuality;
|
||||
downloadPath: string;
|
||||
theme: ThemeMode;
|
||||
localMusicFolders: MusicFolder[];
|
||||
skin: string; // 预设皮肤 id 或 custom-xxx
|
||||
customSkins: CustomSkin[];
|
||||
closeAction: CloseAction;
|
||||
shortcuts: Record<string, ShortcutBinding>;
|
||||
outputDevice: string | null;
|
||||
volume: number;
|
||||
/** 第三方音源总开关 */
|
||||
musicSourceEnabled: boolean;
|
||||
/** 音源列表(有序:搜索聚合顺序 / fallback 优先级;含 builtin + custom) */
|
||||
musicSources: MusicSourceConfig[];
|
||||
schemaVersion: number;
|
||||
}
|
||||
|
||||
function loadSettings(): SettingsData {
|
||||
try {
|
||||
const raw = localStorage.getItem('app_settings');
|
||||
if (raw) return JSON.parse(raw);
|
||||
} catch {}
|
||||
function defaultSettings(): SettingsData {
|
||||
return {
|
||||
audioQuality: 'standard',
|
||||
downloadPath: '',
|
||||
theme: 'dark',
|
||||
localMusicFolders: [],
|
||||
skin: 'dark-blue',
|
||||
customSkins: [],
|
||||
closeAction: 'ask',
|
||||
shortcuts: { ...defaultShortcuts },
|
||||
outputDevice: null,
|
||||
volume: 100,
|
||||
musicSourceEnabled: true,
|
||||
musicSources: builtinMusicSourceDefaults.map(s => ({ ...s })),
|
||||
schemaVersion: CURRENT_SCHEMA_VERSION,
|
||||
};
|
||||
}
|
||||
|
||||
function loadSettings(): SettingsData {
|
||||
const defaults = defaultSettings();
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem('app_settings');
|
||||
if (!raw) return defaults;
|
||||
|
||||
const parsed = JSON.parse(raw);
|
||||
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,
|
||||
musicSourceEnabled: typeof migrated.musicSourceEnabled === 'boolean' ? migrated.musicSourceEnabled : defaults.musicSourceEnabled,
|
||||
musicSources: Array.isArray(migrated.musicSources) ? migrated.musicSources : defaults.musicSources,
|
||||
schemaVersion: CURRENT_SCHEMA_VERSION,
|
||||
};
|
||||
} catch { /* 忽略 */ }
|
||||
return defaults;
|
||||
}
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
const saved = loadSettings();
|
||||
|
||||
const audioQuality = ref<AudioQuality>(saved.audioQuality);
|
||||
const downloadPath = ref<string>(saved.downloadPath);
|
||||
const theme = ref<ThemeMode>(saved.theme);
|
||||
const localMusicFolders = ref<MusicFolder[]>(saved.localMusicFolders);
|
||||
const skin = ref<string>(saved.skin);
|
||||
const customSkins = ref<CustomSkin[]>(saved.customSkins);
|
||||
const closeAction = ref<CloseAction>(saved.closeAction || 'ask');
|
||||
const shortcuts = ref<Record<string, ShortcutBinding>>(saved.shortcuts);
|
||||
const outputDevice = ref<string | null>(saved.outputDevice);
|
||||
const volume = ref<number>(saved.volume);
|
||||
const musicSourceEnabled = ref<boolean>(saved.musicSourceEnabled);
|
||||
const musicSources = ref<MusicSourceConfig[]>(saved.musicSources);
|
||||
|
||||
const isPreset = computed(() => isPresetSkinId(skin.value));
|
||||
|
||||
const currentCustomSkin = computed(() => {
|
||||
if (isPreset.value) return null;
|
||||
return customSkins.value.find(s => s.id === skin.value) || null;
|
||||
});
|
||||
|
||||
const skinPreview = computed(() => {
|
||||
if (isPreset.value) {
|
||||
return getPresetSkin(skin.value)?.preview || '#3b82f6';
|
||||
}
|
||||
return currentCustomSkin.value?.preview || '#3b82f6';
|
||||
});
|
||||
|
||||
const currentColors = computed<SkinColors>(() => {
|
||||
if (isPreset.value) {
|
||||
return getPresetSkin(skin.value)!.colors;
|
||||
}
|
||||
const custom = currentCustomSkin.value;
|
||||
if (!custom) {
|
||||
return getPresetSkin('dark-blue')!.colors;
|
||||
}
|
||||
return custom.colors;
|
||||
});
|
||||
|
||||
const currentWallpaper = computed(() => {
|
||||
if (isPreset.value) return { path: '', blur: 10, opacity: 0.3 };
|
||||
const custom = currentCustomSkin.value;
|
||||
return {
|
||||
path: custom?.wallpaper || '',
|
||||
blur: custom?.wallpaperBlur ?? 10,
|
||||
opacity: custom?.wallpaperOpacity ?? 0.3,
|
||||
};
|
||||
});
|
||||
|
||||
function setSkin(id: string) {
|
||||
skin.value = id;
|
||||
}
|
||||
|
||||
function addCustomSkin(s: CustomSkin) {
|
||||
customSkins.value = [...customSkins.value, s];
|
||||
skin.value = s.id;
|
||||
}
|
||||
|
||||
function updateCustomSkin(id: string, updates: Partial<CustomSkin>) {
|
||||
customSkins.value = customSkins.value.map(s =>
|
||||
s.id === id ? { ...s, ...updates } : s
|
||||
);
|
||||
if (skin.value === id) {
|
||||
applySkin();
|
||||
}
|
||||
}
|
||||
|
||||
function removeCustomSkin(id: string) {
|
||||
customSkins.value = customSkins.value.filter(s => s.id !== id);
|
||||
if (skin.value === id) {
|
||||
skin.value = 'dark-blue';
|
||||
}
|
||||
}
|
||||
|
||||
function applySkin() {
|
||||
let colors: SkinColors;
|
||||
if (isPreset.value) {
|
||||
const preset = getPresetSkin(skin.value);
|
||||
colors = preset!.colors;
|
||||
} else {
|
||||
const custom = currentCustomSkin.value;
|
||||
if (!custom) {
|
||||
skin.value = 'dark-blue';
|
||||
colors = getPresetSkin('dark-blue')!.colors;
|
||||
} else {
|
||||
colors = custom.colors;
|
||||
}
|
||||
}
|
||||
applySkinColors(colors);
|
||||
}
|
||||
|
||||
function setAudioQuality(q: AudioQuality) {
|
||||
audioQuality.value = q;
|
||||
@@ -55,20 +269,129 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
downloadPath.value = p;
|
||||
}
|
||||
|
||||
function setTheme(t: ThemeMode) {
|
||||
theme.value = t;
|
||||
function addLocalMusicPath(p: string) {
|
||||
if (!localMusicFolders.value.some(f => f.path === p)) {
|
||||
localMusicFolders.value = [...localMusicFolders.value, { path: p, enabled: true }];
|
||||
}
|
||||
}
|
||||
|
||||
function removeLocalMusicPath(p: string) {
|
||||
localMusicFolders.value = localMusicFolders.value.filter(f => f.path !== p);
|
||||
}
|
||||
|
||||
function toggleLocalMusicFolder(p: string) {
|
||||
localMusicFolders.value = localMusicFolders.value.map(f =>
|
||||
f.path === p ? { ...f, enabled: !f.enabled } : f
|
||||
);
|
||||
}
|
||||
|
||||
const enabledMusicPaths = computed(() =>
|
||||
localMusicFolders.value.filter(f => f.enabled).map(f => f.path)
|
||||
);
|
||||
|
||||
function setCloseAction(a: CloseAction) {
|
||||
closeAction.value = a;
|
||||
}
|
||||
|
||||
watch([audioQuality, downloadPath, theme, closeAction], () => {
|
||||
function setShortcut(id: string, key: string) {
|
||||
shortcuts.value = { ...shortcuts.value, [id]: { ...shortcuts.value[id], key } };
|
||||
}
|
||||
|
||||
function resetShortcuts() {
|
||||
shortcuts.value = { ...defaultShortcuts };
|
||||
}
|
||||
|
||||
function setOutputDevice(device: string | null) {
|
||||
outputDevice.value = device;
|
||||
}
|
||||
|
||||
// ===== 音源管理 =====
|
||||
|
||||
function setMusicSourceEnabled(v: boolean) {
|
||||
musicSourceEnabled.value = v;
|
||||
}
|
||||
|
||||
/** 切换某个音源的启用状态(保持原顺序) */
|
||||
function toggleMusicSource(id: string) {
|
||||
musicSources.value = musicSources.value.map(s =>
|
||||
s.id === id ? { ...s, enabled: !s.enabled } : s
|
||||
);
|
||||
}
|
||||
|
||||
/** 设置某个音源的 enabled 为指定值 */
|
||||
function setMusicSourceEnabledById(id: string, enabled: boolean) {
|
||||
musicSources.value = musicSources.value.map(s =>
|
||||
s.id === id ? { ...s, enabled } : s
|
||||
);
|
||||
}
|
||||
|
||||
/** 调整音源顺序(拖拽排序用):把 from 移到 to 位置 */
|
||||
function moveMusicSource(from: number, to: number) {
|
||||
if (from < 0 || to < 0 || from >= musicSources.value.length) return;
|
||||
const arr = [...musicSources.value];
|
||||
const [item] = arr.splice(from, 1);
|
||||
arr.splice(Math.min(to, arr.length), 0, item);
|
||||
musicSources.value = arr;
|
||||
}
|
||||
|
||||
/** 添加自定义音源 */
|
||||
function addCustomMusicSource(cfg: MusicSourceConfig) {
|
||||
if (musicSources.value.some(s => s.id === cfg.id)) return;
|
||||
musicSources.value = [...musicSources.value, cfg];
|
||||
}
|
||||
|
||||
/** 更新自定义音源配置 */
|
||||
function updateMusicSource(id: string, updates: Partial<MusicSourceConfig>) {
|
||||
musicSources.value = musicSources.value.map(s =>
|
||||
s.id === id ? { ...s, ...updates, id: s.id, kind: s.kind } : s
|
||||
);
|
||||
}
|
||||
|
||||
/** 删除音源(内置源不可删除,只能禁用) */
|
||||
function removeMusicSource(id: string) {
|
||||
if (builtinSourceIds.has(id)) return;
|
||||
musicSources.value = musicSources.value.filter(s => s.id !== id);
|
||||
}
|
||||
|
||||
/** 恢复默认音源列表(保留用户已有的自定义源) */
|
||||
function resetMusicSourcesToDefault() {
|
||||
const customs = musicSources.value.filter(s => s.kind === 'custom');
|
||||
musicSources.value = [...builtinMusicSourceDefaults.map(s => ({ ...s })), ...customs];
|
||||
}
|
||||
|
||||
/** 当前已启用的音源列表(含总开关检查,传给后端搜索/降级用) */
|
||||
const enabledMusicSources = computed(() =>
|
||||
musicSourceEnabled.value ? musicSources.value.filter(s => s.enabled) : []
|
||||
);
|
||||
|
||||
function resetAll() {
|
||||
audioQuality.value = 'standard';
|
||||
downloadPath.value = '';
|
||||
localMusicFolders.value = [];
|
||||
skin.value = 'dark-blue';
|
||||
customSkins.value = [];
|
||||
closeAction.value = 'ask';
|
||||
shortcuts.value = { ...defaultShortcuts };
|
||||
outputDevice.value = null;
|
||||
volume.value = 100;
|
||||
musicSourceEnabled.value = true;
|
||||
musicSources.value = builtinMusicSourceDefaults.map(s => ({ ...s }));
|
||||
}
|
||||
|
||||
watch([audioQuality, downloadPath, localMusicFolders, skin, customSkins, closeAction, shortcuts, outputDevice, volume, musicSourceEnabled, musicSources], () => {
|
||||
const data: SettingsData = {
|
||||
audioQuality: audioQuality.value,
|
||||
downloadPath: downloadPath.value,
|
||||
theme: theme.value,
|
||||
localMusicFolders: localMusicFolders.value,
|
||||
skin: skin.value,
|
||||
customSkins: customSkins.value,
|
||||
closeAction: closeAction.value,
|
||||
shortcuts: shortcuts.value,
|
||||
outputDevice: outputDevice.value,
|
||||
volume: volume.value,
|
||||
musicSourceEnabled: musicSourceEnabled.value,
|
||||
musicSources: musicSources.value,
|
||||
schemaVersion: CURRENT_SCHEMA_VERSION,
|
||||
};
|
||||
localStorage.setItem('app_settings', JSON.stringify(data));
|
||||
}, { deep: true });
|
||||
@@ -76,11 +399,44 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
return {
|
||||
audioQuality,
|
||||
downloadPath,
|
||||
theme,
|
||||
localMusicFolders,
|
||||
enabledMusicPaths,
|
||||
skin,
|
||||
customSkins,
|
||||
isPreset,
|
||||
currentCustomSkin,
|
||||
currentColors,
|
||||
skinPreview,
|
||||
currentWallpaper,
|
||||
closeAction,
|
||||
shortcuts,
|
||||
outputDevice,
|
||||
volume,
|
||||
musicSourceEnabled,
|
||||
musicSources,
|
||||
enabledMusicSources,
|
||||
setSkin,
|
||||
addCustomSkin,
|
||||
updateCustomSkin,
|
||||
removeCustomSkin,
|
||||
applySkin,
|
||||
setAudioQuality,
|
||||
setDownloadPath,
|
||||
setTheme,
|
||||
addLocalMusicPath,
|
||||
removeLocalMusicPath,
|
||||
toggleLocalMusicFolder,
|
||||
setCloseAction,
|
||||
setOutputDevice,
|
||||
setMusicSourceEnabled,
|
||||
toggleMusicSource,
|
||||
setMusicSourceEnabledById,
|
||||
moveMusicSource,
|
||||
addCustomMusicSource,
|
||||
updateMusicSource,
|
||||
removeMusicSource,
|
||||
resetMusicSourcesToDefault,
|
||||
setShortcut,
|
||||
resetShortcuts,
|
||||
resetAll,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { MusicApi } from '../api';
|
||||
|
||||
export interface UserProfile {
|
||||
userId: number;
|
||||
@@ -21,7 +21,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try { await invoke('logout'); } catch {}
|
||||
try { await MusicApi.logout(); } catch { /* 忽略 */ }
|
||||
user.value = null;
|
||||
isLoggedIn.value = false;
|
||||
localStorage.removeItem('user_profile');
|
||||
|
||||
+52
-37
@@ -23,52 +23,30 @@
|
||||
}
|
||||
|
||||
@layer base {
|
||||
/* 默认值(首次加载 fallback,JS 会立即覆盖) */
|
||||
:root {
|
||||
--c-bg: #030712;
|
||||
--c-surface: #111827;
|
||||
--c-subtle: rgba(255, 255, 255, 0.05);
|
||||
--c-muted: rgba(255, 255, 255, 0.10);
|
||||
--c-emphasis: rgba(255, 255, 255, 0.18);
|
||||
--c-bg: #02060c;
|
||||
--c-surface: #0a101a;
|
||||
--c-subtle: rgba(59, 130, 246, 0.06);
|
||||
--c-muted: rgba(59, 130, 246, 0.10);
|
||||
--c-emphasis: rgba(59, 130, 246, 0.18);
|
||||
--c-content: #ffffff;
|
||||
--c-content-2: #9ca3af;
|
||||
--c-content-3: #6b7280;
|
||||
--c-content-4: #4b5563;
|
||||
--c-line: rgba(255, 255, 255, 0.10);
|
||||
--c-line-2: rgba(255, 255, 255, 0.05);
|
||||
--c-accent: #22c55e;
|
||||
--c-accent-hover: #16a34a;
|
||||
--c-accent-text: #4ade80;
|
||||
--c-accent-dim: rgba(34, 197, 94, 0.20);
|
||||
--c-line: rgba(255, 255, 255, 0.08);
|
||||
--c-line-2: rgba(255, 255, 255, 0.04);
|
||||
--c-accent: #3b82f6;
|
||||
--c-accent-hover: #2563eb;
|
||||
--c-accent-text: #60a5fa;
|
||||
--c-accent-dim: rgba(59, 130, 246, 0.20);
|
||||
--c-danger: #ef4444;
|
||||
--c-danger-dim: rgba(239, 68, 68, 0.20);
|
||||
--c-warning: #eab308;
|
||||
--c-info: #3b82f6;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--c-bg: #f3f4f6;
|
||||
--c-surface: #ffffff;
|
||||
--c-subtle: rgba(0, 0, 0, 0.04);
|
||||
--c-muted: rgba(0, 0, 0, 0.08);
|
||||
--c-emphasis: rgba(0, 0, 0, 0.12);
|
||||
--c-content: #111827;
|
||||
--c-content-2: #4b5563;
|
||||
--c-content-3: #6b7280;
|
||||
--c-content-4: #9ca3af;
|
||||
--c-line: rgba(0, 0, 0, 0.10);
|
||||
--c-line-2: rgba(0, 0, 0, 0.05);
|
||||
--c-accent: #16a34a;
|
||||
--c-accent-hover: #15803d;
|
||||
--c-accent-text: #16a34a;
|
||||
--c-accent-dim: rgba(22, 163, 74, 0.15);
|
||||
--c-danger: #dc2626;
|
||||
--c-danger-dim: rgba(220, 38, 38, 0.15);
|
||||
--c-warning: #ca8a04;
|
||||
--c-info: #2563eb;
|
||||
--c-info: #8b5cf6;
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--c-bg);
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
overscroll-behavior: none;
|
||||
@@ -77,14 +55,25 @@
|
||||
body {
|
||||
@apply antialiased;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
background: var(--c-bg);
|
||||
color: var(--c-content);
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
input, textarea, [contenteditable="true"] {
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
|
||||
/* 滚动条:hover 容器显示主题色 thumb(--c-muted / --c-emphasis),默认透明。
|
||||
- 颜色跟随主题变量(皮肤切换自动适配)
|
||||
- 宽度 5px,不显眼;hover 容器才显示,hover thumb 变深
|
||||
- 彻底无滚动条的容器(歌词界面)加 .custom-scroll 或 [data-no-scrollbar] */
|
||||
::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
@@ -93,12 +82,32 @@
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: var(--c-muted);
|
||||
background-color: transparent;
|
||||
border-radius: 3px;
|
||||
}
|
||||
*:hover > ::-webkit-scrollbar-thumb,
|
||||
*:hover::-webkit-scrollbar-thumb {
|
||||
background-color: var(--c-muted);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
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 {
|
||||
appearance: none;
|
||||
@@ -154,3 +163,9 @@
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 骨架屏脉冲动画:三个详情页(Playlist/Album/Artist)共用 */
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 歌曲相关类型定义
|
||||
*/
|
||||
|
||||
/**
|
||||
* 歌曲信息
|
||||
*/
|
||||
export interface Song {
|
||||
/** 歌曲 ID(网易云歌曲 ID;外部音源歌曲用占位负数) */
|
||||
id: number;
|
||||
/** 歌曲名称 */
|
||||
name: string;
|
||||
/** 歌手列表 */
|
||||
ar: { id?: number; name: string }[];
|
||||
/** 专辑信息 */
|
||||
al: { id?: number; picUrl: string; name?: string };
|
||||
/** 时长(毫秒) */
|
||||
dt?: number;
|
||||
/** 本地文件路径(本地歌曲专用) */
|
||||
localPath?: string;
|
||||
/** 推荐算法标识(私人 FM 推荐) */
|
||||
alg?: string;
|
||||
/** 音频码率(bps,如 320000 表示 320kbps) */
|
||||
br?: number;
|
||||
/** 网易云付费标志:0=免费 1=VIP 4=数字专辑 8=低音质免费试听 */
|
||||
fee?: number;
|
||||
/** 当前可播放的最高码率(bps),来自 privilege.maxbr */
|
||||
maxbr?: number;
|
||||
/** 实际播放音源显示名(如 "netease"、"波点音乐"),由后端 fallback 返回 */
|
||||
source?: string;
|
||||
/** 外部音源歌曲专用:源内 ID(字符串,如 kuwo rid / kugou hash) */
|
||||
externalId?: string;
|
||||
/** 外部音源歌曲专用:源 ID("bodian" / "kugou" / "kuwo" / "bilibili" / "custom-xxx") */
|
||||
externalSourceId?: string;
|
||||
/** 外部音源歌曲专用:来源显示名("波点音乐" / ...) */
|
||||
sourceLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放模式
|
||||
* - loop: 列表循环
|
||||
* - shuffle: 随机播放
|
||||
* - repeat-one: 单曲循环
|
||||
*/
|
||||
export type PlayMode = 'loop' | 'shuffle' | 'repeat-one';
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 将 hex 颜色值转换为 rgba 字符串
|
||||
*/
|
||||
export function hexToRgba(hex: string, alpha: number): string {
|
||||
const rgbMatch = hex.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
|
||||
if (rgbMatch) {
|
||||
return `rgba(${rgbMatch[1]}, ${rgbMatch[2]}, ${rgbMatch[3]}, ${alpha})`;
|
||||
}
|
||||
let h = hex.replace('#', '');
|
||||
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
|
||||
const r = parseInt(h.substring(0, 2), 16);
|
||||
const g = parseInt(h.substring(2, 4), 16);
|
||||
const b = parseInt(h.substring(4, 6), 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将颜色值(hex 或 rgba)转换为 hex 格式(供 input[type=color] 使用)
|
||||
*/
|
||||
export function toHex(color: string): string {
|
||||
if (color.startsWith('#')) {
|
||||
return color.length === 4
|
||||
? '#' + color[1] + color[1] + color[2] + color[2] + color[3] + color[3]
|
||||
: color;
|
||||
}
|
||||
const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
|
||||
if (!m) return '#000000';
|
||||
const r = parseInt(m[1]).toString(16).padStart(2, '0');
|
||||
const g = parseInt(m[2]).toString(16).padStart(2, '0');
|
||||
const b = parseInt(m[3]).toString(16).padStart(2, '0');
|
||||
return `#${r}${g}${b}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
+11
-2
@@ -12,9 +12,18 @@ export function formatTime(sec: number): string {
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const YI = 100_000_000;
|
||||
const WAN = 10_000;
|
||||
|
||||
export function formatPlayCount(count: number): string {
|
||||
if (!count) return '0';
|
||||
if (count >= 100000000) return (count / 100000000).toFixed(1) + '亿';
|
||||
if (count >= 10000) return (count / 10000).toFixed(1) + '万';
|
||||
if (count >= YI) return (count / YI).toFixed(1) + '亿';
|
||||
if (count >= WAN) return (count / WAN).toFixed(1) + '万';
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
export function formatDate(ts: number): string {
|
||||
if (!ts) return '';
|
||||
const d = new Date(ts);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
+30
-3
@@ -1,6 +1,7 @@
|
||||
export interface LyricLine {
|
||||
time: number; // 秒
|
||||
time: number;
|
||||
text: string;
|
||||
translation?: string;
|
||||
}
|
||||
|
||||
export function parseLrc(lrcStr: string): LyricLine[] {
|
||||
@@ -20,11 +21,37 @@ export function parseLrc(lrcStr: string): LyricLine[] {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 按时长排序
|
||||
result.sort((a, b) => a.time - b.time);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function mergeTranslation(lyrics: LyricLine[], tLrcStr: string): LyricLine[] {
|
||||
if (!tLrcStr) return lyrics;
|
||||
const tLines = parseLrc(tLrcStr);
|
||||
if (tLines.length === 0) return lyrics;
|
||||
|
||||
const tMap = new Map<number, string>();
|
||||
for (const t of tLines) {
|
||||
const key = Math.round(t.time * 100);
|
||||
tMap.set(key, t.text);
|
||||
}
|
||||
|
||||
return lyrics.map(line => {
|
||||
const key = Math.round(line.time * 100);
|
||||
const translation = tMap.get(key);
|
||||
if (translation) {
|
||||
return { ...line, translation };
|
||||
}
|
||||
for (let offset = -3; offset <= 3; offset++) {
|
||||
const t = tMap.get(key + offset);
|
||||
if (t) {
|
||||
return { ...line, translation: t };
|
||||
}
|
||||
}
|
||||
return line;
|
||||
});
|
||||
}
|
||||
|
||||
export function getCurrentLyricIndex(lyrics: LyricLine[], currentTime: number): number {
|
||||
let index = -1;
|
||||
for (let i = 0; i < lyrics.length; i++) {
|
||||
@@ -35,4 +62,4 @@ export function getCurrentLyricIndex(lyrics: LyricLine[], currentTime: number):
|
||||
}
|
||||
}
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
+186
-13
@@ -1,18 +1,191 @@
|
||||
export type { Song } from '../types/song';
|
||||
import type { Song } from '../types/song';
|
||||
|
||||
export function normalizeSong(song: any): Song {
|
||||
const al = {
|
||||
id: song.al?.id || song.album?.id,
|
||||
picUrl: song.al?.picUrl || song.album?.picUrl || '',
|
||||
name: song.al?.name || song.album?.name,
|
||||
};
|
||||
const rawAr = (song.ar && song.ar.length > 0) ? song.ar : (song.artists || []);
|
||||
// 过滤掉 id 和 name 同时不存在的歌手(下线艺人等)
|
||||
const ar = rawAr.filter((a: any) => a.name);
|
||||
let dt = song.dt || song.duration || 0;
|
||||
if (dt < 100 || dt > 7200000) dt = 0;
|
||||
// fee / maxbr 可能直接在 song 上,也可能在 song.privilege 里
|
||||
// (/cloudsearch 附在 song 上,/artist/songs / /playlist/detail 等可能在单独 privileges 数组)
|
||||
const priv = song.privilege || {};
|
||||
return {
|
||||
id: song.id,
|
||||
name: song.name,
|
||||
ar,
|
||||
al,
|
||||
dt,
|
||||
localPath: song.localPath,
|
||||
alg: song.alg || undefined,
|
||||
br: song.br || undefined,
|
||||
fee: typeof song.fee === 'number' ? song.fee : (typeof priv.fee === 'number' ? priv.fee : undefined),
|
||||
maxbr: priv.maxbr || song.maxbr || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一规范化歌曲对象,确保 al.picUrl、ar、dt 字段存在且合理
|
||||
* 将网易云单独的 privileges 数组合并到 songs 中(给每个 song 附上对应 privilege)
|
||||
* 用于 /artist/songs / /playlist/detail 等接口,privilege 不在 song 上而是单独数组
|
||||
*/
|
||||
export function normalizeSong(song: any) {
|
||||
const normalized = { ...song };
|
||||
// 封面 / 艺术家兼容
|
||||
if (!normalized.al?.picUrl && normalized.album?.picUrl) {
|
||||
normalized.al = { ...normalized.al, picUrl: normalized.album.picUrl };
|
||||
export function attachPrivileges(songs: any[], privileges?: any[]): any[] {
|
||||
if (!Array.isArray(privileges) || !privileges.length) return songs;
|
||||
const map = new Map<number, any>();
|
||||
for (const p of privileges) {
|
||||
if (p?.id != null) map.set(p.id, p);
|
||||
}
|
||||
if (!normalized.ar || normalized.ar.length === 0) {
|
||||
normalized.ar = normalized.artists || [];
|
||||
return songs.map(s => (s && !s.privilege && s.id != null && map.has(s.id))
|
||||
? { ...s, privilege: map.get(s.id) }
|
||||
: s);
|
||||
}
|
||||
|
||||
/** 将网易云单独的 privileges 数组合并到 songs 并 normalize(5 个详情页共用) */
|
||||
export function normalizeSongsWithPrivileges(songs: any[], privileges?: any[]): Song[] {
|
||||
return attachPrivileges(songs, privileges).map(normalizeSong);
|
||||
}
|
||||
|
||||
/** 歌曲标签类型 */
|
||||
export type SongTagKind = 'vip' | 'album' | 'hires' | 'sq' | 'hq' | 'original' | 'cover' | 'live' | 'accompaniment' | 'dj';
|
||||
|
||||
export interface SongTag {
|
||||
/** 标签文案 */
|
||||
label: string;
|
||||
/** 标签种类,便于按需样式覆盖 */
|
||||
kind: SongTagKind;
|
||||
/** Tailwind class 字符串(背景 + 文字色) */
|
||||
class: string;
|
||||
}
|
||||
|
||||
// getSongTags 版本标签正则(模块级常量,避免每次调用重建)
|
||||
// 注意:JS 正则的 \b 只对 ASCII 字符生效,所以中文字符周围不能用 \b
|
||||
const RE_COVER = /翻唱|\bcover\b/i;
|
||||
const RE_ORIGINAL = /原唱/;
|
||||
const RE_LIVE = /\blive\b|现场|演唱会|巡演|concert/i;
|
||||
const ACCOMPANIMENT_RE = /伴奏|karaoke|\binst\b/i;
|
||||
const REMIX_RE = /\b(?:dj|remix)\b|混音|电音|dj版|混音版/i;
|
||||
|
||||
/**
|
||||
* 计算歌曲应显示的标签
|
||||
* 顺序:付费 → 音质 → 版本(原唱/翻唱/Live/伴奏/DJ)
|
||||
* 参考 SPlayer 的标签展示
|
||||
*/
|
||||
export function getSongTags(song: Song): SongTag[] {
|
||||
const tags: SongTag[] = [];
|
||||
const name = song.name || '';
|
||||
const lower = name.toLowerCase();
|
||||
|
||||
// 1) 付费标签(网易云 fee 字段)
|
||||
// 注意:fee=8 实际表示「免费低音质版」,大量免费歌曲都是 8,不能当试听
|
||||
// - fee=1: VIP 歌曲
|
||||
// - fee=4: 数字专辑
|
||||
// - fee=0/8: 免费歌曲,不显示付费标签
|
||||
if (song.fee === 1) {
|
||||
tags.push({ label: 'VIP', kind: 'vip', class: 'bg-danger/15 text-danger' });
|
||||
} else if (song.fee === 4) {
|
||||
tags.push({ label: '专辑', kind: 'album', class: 'bg-warning/15 text-warning' });
|
||||
}
|
||||
// 时长:只保留合理的 dt(100ms ~ 2小时),否则置 0
|
||||
if (!normalized.dt || normalized.dt < 100 || normalized.dt > 7200000) {
|
||||
normalized.dt = 0;
|
||||
|
||||
// 2) 音质标签:优先用 maxbr(最大可达码率),fallback 到 br(实际码率)
|
||||
const qualityBr = song.maxbr || song.br || 0;
|
||||
if (qualityBr >= 1999000) {
|
||||
tags.push({ label: 'Hi-Res', kind: 'hires', class: 'bg-info/15 text-info' });
|
||||
} else if (qualityBr >= 999000) {
|
||||
tags.push({ label: 'SQ', kind: 'sq', class: 'bg-warning/15 text-warning' });
|
||||
} else if (qualityBr >= 320000) {
|
||||
tags.push({ label: 'HQ', kind: 'hq', class: 'bg-accent/15 text-accent-text' });
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// 3) 版本标签(从歌名推断,互斥:原唱/翻唱 二选一)
|
||||
const isCover = RE_COVER.test(lower);
|
||||
const isOriginal = RE_ORIGINAL.test(name);
|
||||
const isLive = RE_LIVE.test(lower);
|
||||
const isAccompaniment = ACCOMPANIMENT_RE.test(lower);
|
||||
const isDj = REMIX_RE.test(lower);
|
||||
|
||||
if (isCover) {
|
||||
tags.push({ label: '翻唱', kind: 'cover', class: 'bg-info/15 text-info' });
|
||||
} else if (isOriginal) {
|
||||
tags.push({ label: '原唱', kind: 'original', class: 'bg-accent/15 text-accent-text' });
|
||||
}
|
||||
if (isLive) {
|
||||
tags.push({ label: 'Live', kind: 'live', class: 'bg-accent/15 text-accent-text' });
|
||||
}
|
||||
if (isAccompaniment) {
|
||||
tags.push({ label: '伴奏', kind: 'accompaniment', class: 'bg-muted text-content-2' });
|
||||
}
|
||||
if (isDj) {
|
||||
tags.push({ label: 'DJ', kind: 'dj', class: 'bg-warning/15 text-warning' });
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
export function getCoverUrl(song: Song | null, sizeParam = ''): string {
|
||||
if (!song) return '';
|
||||
const raw = song.al?.picUrl || '';
|
||||
if (!raw) return '';
|
||||
if (!sizeParam || raw.startsWith('data:')) return raw;
|
||||
return raw + sizeParam;
|
||||
}
|
||||
|
||||
export function getArtistDisplay(song: Song): string {
|
||||
if (!song.ar || song.ar.length === 0) return '未知歌手';
|
||||
const names = song.ar
|
||||
.filter(a => a.id != null && a.name)
|
||||
.map(a => a.name);
|
||||
return names.length > 0 ? names.join(' / ') : '未知歌手';
|
||||
}
|
||||
|
||||
const colorCache = new Map<string, string>();
|
||||
const MAX_COLOR_CACHE = 200;
|
||||
|
||||
export function extractDominantColor(imageUrl: string): Promise<string> {
|
||||
if (colorCache.has(imageUrl)) {
|
||||
return Promise.resolve(colorCache.get(imageUrl)!);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
const size = 8;
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) { resolve(''); return; }
|
||||
ctx.drawImage(img, 0, 0, size, size);
|
||||
const data = ctx.getImageData(0, 0, size, size).data;
|
||||
|
||||
let r = 0, g = 0, b = 0, count = 0;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
r += data[i];
|
||||
g += data[i + 1];
|
||||
b += data[i + 2];
|
||||
count++;
|
||||
}
|
||||
r = Math.round(r / count);
|
||||
g = Math.round(g / count);
|
||||
b = Math.round(b / count);
|
||||
|
||||
const color = `rgb(${r}, ${g}, ${b})`;
|
||||
if (colorCache.size >= MAX_COLOR_CACHE) {
|
||||
const firstKey = colorCache.keys().next().value;
|
||||
if (firstKey !== undefined) colorCache.delete(firstKey);
|
||||
}
|
||||
colorCache.set(imageUrl, color);
|
||||
resolve(color);
|
||||
} catch {
|
||||
resolve('');
|
||||
}
|
||||
};
|
||||
img.onerror = () => resolve('');
|
||||
img.src = imageUrl;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,664 @@
|
||||
<template>
|
||||
<DetailLayout ref="layoutRef">
|
||||
<!-- 头部(常驻):滚动时收缩为紧凑态
|
||||
参考 SPlayer ListDetail.vue .small 触发 height/font-size/opacity 过渡 -->
|
||||
<template #header="{ compact }">
|
||||
<div class="album-header" :class="{ 'is-compact': compact }">
|
||||
<!-- 头部骨架 -->
|
||||
<div v-if="!album && albumLoading" class="header-row">
|
||||
<div class="cover-wrap">
|
||||
<div class="cover-img cover-skeleton"></div>
|
||||
</div>
|
||||
<div class="info-col">
|
||||
<div class="h-8 bg-muted rounded w-1/2 animate-pulse"></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>
|
||||
<div class="actions-row">
|
||||
<div class="h-10 w-28 bg-muted rounded-full animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 头部信息 -->
|
||||
<div v-else-if="album" class="header-row">
|
||||
<!-- 封面 + 阴影背板 + 顶部渐变遮罩(参考 SPlayer .cover + .cover-shadow + .cover-mask)
|
||||
封面图加载完成后淡入(opacity 0→1),避免空白闪烁 -->
|
||||
<div class="cover-wrap">
|
||||
<img :src="album.picUrl" class="cover-shadow" alt="" aria-hidden="true" />
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<!-- 内容区(独立滚动):歌曲 / 评论 按 tab 切换渲染
|
||||
songs 用 v-if 仅激活时渲染;comments 用「首次激活后保活」模式(v-if + v-show)保留滚动位置 -->
|
||||
<template #content>
|
||||
<div class="album-list-inner">
|
||||
<!-- 加载失败 -->
|
||||
<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="fetchAlbum(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 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>
|
||||
|
||||
<!-- 歌曲 tab:v-if 仅在激活时渲染 -->
|
||||
<template v-else-if="currentTab === 'songs'">
|
||||
<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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch, onActivated } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { MusicApi } from '../api';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { normalizeSongsWithPrivileges, type Song } from '../utils/song';
|
||||
import { formatDate } from '../utils/format';
|
||||
import { pageCacheGet, pageCacheSet } from '../composables/usePageCache';
|
||||
import VirtualSongList from '../components/VirtualSongList.vue';
|
||||
import CommentSection from '../components/CommentSection.vue';
|
||||
import DetailLayout from '../components/DetailLayout.vue';
|
||||
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' });
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const player = usePlayerStore();
|
||||
|
||||
const album = ref<any>(null);
|
||||
const songs = ref<Song[]>([]);
|
||||
const albumLoading = ref(true);
|
||||
const songsLoading = 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) {
|
||||
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) {
|
||||
const cached = pageCacheGet(cacheKey);
|
||||
if (cached) {
|
||||
album.value = cached.album;
|
||||
songs.value = cached.songs;
|
||||
albumLoading.value = false;
|
||||
songsLoading.value = false;
|
||||
loadError.value = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
albumLoading.value = true;
|
||||
songsLoading.value = true;
|
||||
loadError.value = false;
|
||||
album.value = null;
|
||||
songs.value = [];
|
||||
try {
|
||||
const jsonStr: string = await MusicApi.albumDetail(id);
|
||||
const data = JSON.parse(jsonStr);
|
||||
album.value = data.album;
|
||||
albumLoading.value = false;
|
||||
songs.value = normalizeSongsWithPrivileges(data.songs || [], data.privileges);
|
||||
songsLoading.value = false;
|
||||
pageCacheSet(cacheKey, { album: album.value, songs: songs.value });
|
||||
} catch (e) {
|
||||
console.error('获取专辑详情失败', e);
|
||||
loadError.value = true;
|
||||
albumLoading.value = false;
|
||||
songsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchAlbum(Number(route.params.id));
|
||||
});
|
||||
|
||||
// 合并两个 route.params.id watcher:仅监听一次,同时处理状态重置和数据获取
|
||||
watch(() => route.params.id, (newId) => {
|
||||
if (newId && route.name === 'album') fetchAlbum(Number(newId));
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
if (loadError.value) fetchAlbum(Number(route.params.id), true);
|
||||
});
|
||||
|
||||
function playAll() {
|
||||
// 播放当前过滤后的列表(搜索时只播放匹配项)
|
||||
const list = filteredSongs.value.length ? filteredSongs.value : songs.value;
|
||||
if (list.length === 0) return;
|
||||
player.playAll(list);
|
||||
}
|
||||
</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>
|
||||
@@ -0,0 +1,569 @@
|
||||
<template>
|
||||
<DetailLayout ref="layoutRef">
|
||||
<!-- 头部(常驻):滚动时收缩为紧凑态(头像缩小、标题缩小、简介隐藏)
|
||||
参考 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="ar-head-row">
|
||||
<div class="ar-cover cover-skeleton"></div>
|
||||
<div class="flex-1 space-y-3">
|
||||
<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-2/3 animate-pulse"></div>
|
||||
<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-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 && (artist || songs.length || albums.length)">
|
||||
<!-- 头部行:头像 + 信息(标题/简介/操作按钮 + tab) -->
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<!-- 专辑 tab:v-if 仅在激活时渲染 -->
|
||||
<div v-if="activeTab === 'albums'">
|
||||
<div v-if="albumsLoading" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<div v-for="i in 8" :key="i" class="bg-muted rounded-xl animate-pulse">
|
||||
<div class="w-full aspect-square"></div>
|
||||
<div class="p-3 space-y-2">
|
||||
<div class="h-4 bg-subtle rounded w-3/4"></div>
|
||||
<div class="h-3 bg-subtle rounded w-1/2"></div>
|
||||
</div>
|
||||
</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>
|
||||
</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">{{ 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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch, onActivated } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { MusicApi } from '../api';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { formatPlayCount, formatDate } from '../utils/format';
|
||||
import { checkOverflow } from '../utils/dom';
|
||||
import { normalizeSongsWithPrivileges, type Song } from '../utils/song';
|
||||
import { pageCacheGet, pageCacheSet } from '../composables/usePageCache';
|
||||
import VirtualSongList from '../components/VirtualSongList.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import DetailLayout from '../components/DetailLayout.vue';
|
||||
import IconPlay from '~icons/lucide/play';
|
||||
import IconMusic from '~icons/lucide/music';
|
||||
import IconX from '~icons/lucide/x';
|
||||
import IconChevronDown from '~icons/lucide/chevron-down';
|
||||
|
||||
defineOptions({ name: 'ArtistDetailView' });
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const player = usePlayerStore();
|
||||
|
||||
const artist = ref<any>(null);
|
||||
const songs = ref<Song[]>([]);
|
||||
const albums = ref<any[]>([]);
|
||||
const briefDesc = ref('');
|
||||
const loadError = ref(false);
|
||||
const songsLoading = ref(false);
|
||||
const albumsLoading = ref(false);
|
||||
const activeTab = ref<'songs' | 'albums'>('songs');
|
||||
const showDescModal = ref(false);
|
||||
const descOverflow = ref(false);
|
||||
const descEl = ref<HTMLElement | null>(null);
|
||||
const isFollowed = ref(false);
|
||||
const followLoading = ref(false);
|
||||
// DetailLayout 实例引用:用于切换歌手时重置滚动 & 紧凑态
|
||||
const layoutRef = ref<InstanceType<typeof DetailLayout> | null>(null);
|
||||
|
||||
const tabs = [
|
||||
{ key: 'songs' as const, label: '热门歌曲' },
|
||||
{ key: 'albums' as const, label: '专辑' },
|
||||
];
|
||||
|
||||
const artistName = computed(() => {
|
||||
if (artist.value?.name) return artist.value.name;
|
||||
if (songs.value.length > 0 && songs.value[0].ar?.length > 0) return songs.value[0].ar[0].name;
|
||||
if (albums.value.length > 0) return albums.value[0].artist?.name || albums.value[0].artists?.[0]?.name || '';
|
||||
return '未知歌手';
|
||||
});
|
||||
|
||||
const artistCover = computed(() => {
|
||||
if (artist.value?.cover) return artist.value.cover;
|
||||
if (artist.value?.picUrl) return artist.value.picUrl;
|
||||
if (artist.value?.img1v1Url) return artist.value.img1v1Url;
|
||||
return '';
|
||||
});
|
||||
|
||||
const artistFollowers = computed(() => {
|
||||
if (!artist.value) return 0;
|
||||
return artist.value.followeds || artist.value.followCount || artist.value.fans || 0;
|
||||
});
|
||||
|
||||
function checkDescOverflow() {
|
||||
checkOverflow(descEl, descOverflow);
|
||||
}
|
||||
|
||||
async function fetchArtist(id: number, force = false) {
|
||||
const cacheKey = `artist_${id}`;
|
||||
// 切换歌手时重置 tab 与滚动状态(参考 SPlayer watch detailData.id)
|
||||
activeTab.value = 'songs';
|
||||
layoutRef.value?.resetScroll();
|
||||
if (!force) {
|
||||
const cached = pageCacheGet(cacheKey);
|
||||
if (cached) {
|
||||
artist.value = cached.artist;
|
||||
songs.value = cached.songs;
|
||||
albums.value = cached.albums;
|
||||
briefDesc.value = cached.briefDesc;
|
||||
loadError.value = false;
|
||||
checkDescOverflow();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
loadError.value = false;
|
||||
artist.value = null;
|
||||
songs.value = [];
|
||||
albums.value = [];
|
||||
briefDesc.value = '';
|
||||
|
||||
const loadDetail = async () => {
|
||||
try {
|
||||
const jsonStr = await MusicApi.artistDetail(id);
|
||||
const data = JSON.parse(jsonStr);
|
||||
const a = data.artist || data.data?.artist || data;
|
||||
artist.value = a;
|
||||
} catch { /* 忽略 */ }
|
||||
};
|
||||
|
||||
const loadFollowStatus = async () => {
|
||||
try {
|
||||
const jsonStr = await MusicApi.artistSublist(100, 0);
|
||||
const data = JSON.parse(jsonStr);
|
||||
const list = data.data || [];
|
||||
isFollowed.value = list.some((item: any) => item.id === id);
|
||||
} catch { /* 忽略 */ }
|
||||
};
|
||||
|
||||
const loadSongs = async () => {
|
||||
songsLoading.value = true;
|
||||
try {
|
||||
const jsonStr = await MusicApi.artistSongs({ id, order: 'hot', limit: 50, offset: 0 });
|
||||
const data = JSON.parse(jsonStr);
|
||||
// /artist/songs 返回的 privilege 在单独的 privileges 数组里,需合并到 song 上
|
||||
songs.value = normalizeSongsWithPrivileges(data.songs || [], data.privileges);
|
||||
} catch { /* 忽略 */ }
|
||||
finally { songsLoading.value = false; }
|
||||
};
|
||||
|
||||
const loadAlbums = async () => {
|
||||
albumsLoading.value = true;
|
||||
try {
|
||||
const jsonStr = await MusicApi.artistAlbum(id, 30, 0);
|
||||
const data = JSON.parse(jsonStr);
|
||||
albums.value = data.hotAlbums || [];
|
||||
} catch { /* 忽略 */ }
|
||||
finally { albumsLoading.value = false; }
|
||||
};
|
||||
|
||||
const loadDesc = async () => {
|
||||
try {
|
||||
const jsonStr = await MusicApi.artistDesc(id);
|
||||
const data = JSON.parse(jsonStr);
|
||||
if (data.briefDesc) {
|
||||
briefDesc.value = data.briefDesc;
|
||||
} else if (Array.isArray(data.introduction) && data.introduction.length > 0) {
|
||||
briefDesc.value = data.introduction.map((item: any) => item.txt || '').filter(Boolean).join('\n');
|
||||
}
|
||||
checkDescOverflow();
|
||||
} catch { /* 忽略 */ }
|
||||
};
|
||||
|
||||
await Promise.allSettled([loadDetail(), loadSongs(), loadAlbums(), loadDesc(), loadFollowStatus()]);
|
||||
|
||||
if (!artist.value && !songs.value.length && !albums.value.length && !briefDesc.value) {
|
||||
loadError.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
pageCacheSet(cacheKey, { artist: artist.value, songs: songs.value, albums: albums.value, briefDesc: briefDesc.value });
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchArtist(Number(route.params.id));
|
||||
});
|
||||
|
||||
watch(() => route.params.id, (newId) => {
|
||||
if (newId && route.name === 'artist') fetchArtist(Number(newId));
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
if (loadError.value) fetchArtist(Number(route.params.id), true);
|
||||
});
|
||||
|
||||
function playAll() {
|
||||
if (songs.value.length === 0) return;
|
||||
player.playAll(songs.value);
|
||||
}
|
||||
|
||||
async function toggleFollow() {
|
||||
const id = Number(route.params.id);
|
||||
if (!id || followLoading.value) return;
|
||||
followLoading.value = true;
|
||||
try {
|
||||
await MusicApi.artistSub(id, !isFollowed.value);
|
||||
isFollowed.value = !isFollowed.value;
|
||||
} catch (e) {
|
||||
console.error('关注操作失败', e);
|
||||
} finally {
|
||||
followLoading.value = false;
|
||||
}
|
||||
}
|
||||
</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>
|
||||
@@ -0,0 +1,436 @@
|
||||
<template>
|
||||
<div class="p-8 text-content">
|
||||
<PageHeader>
|
||||
<h1 class="text-2xl font-bold">音乐云盘</h1>
|
||||
<span v-if="totalCount" class="text-xs text-content-3">{{ totalCount }} 首</span>
|
||||
<template #actions>
|
||||
<button
|
||||
@click="refresh"
|
||||
class="px-3 py-1 bg-muted hover:bg-emphasis rounded-full text-xs transition"
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
<button
|
||||
@click="pickAndUpload"
|
||||
:disabled="uploading"
|
||||
class="px-3 py-1 bg-accent/15 text-accent-text hover:bg-accent/25 rounded-full text-xs transition disabled:opacity-50"
|
||||
>
|
||||
{{ uploading ? '上传中...' : '上传歌曲' }}
|
||||
</button>
|
||||
<!-- 上传进度 -->
|
||||
<div v-if="uploading && uploadProgress < 100" class="flex items-center gap-2 text-xs text-content-3">
|
||||
<div class="w-24 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div class="h-full bg-accent rounded-full transition-all duration-300" :style="{ width: uploadProgress + '%' }"></div>
|
||||
</div>
|
||||
<span>{{ uploadProgress.toFixed(0) }}%</span>
|
||||
</div>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- 存储空间 -->
|
||||
<div v-if="cloudSize > 0" class="mb-6 p-4 bg-subtle rounded-xl">
|
||||
<div class="flex items-center justify-between text-xs mb-2">
|
||||
<span class="text-content-2">已使用 {{ formatFileSize(cloudSize) }} / {{ formatFileSize(cloudMaxSize) }}</span>
|
||||
<span class="text-content-3">{{ cloudUsagePercent }}%</span>
|
||||
</div>
|
||||
<div class="h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div class="h-full bg-accent rounded-full transition-all duration-500" :style="{ width: cloudUsagePercent + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!userStore.isLoggedIn" class="text-content-3 py-8">
|
||||
请先登录后查看云盘音乐
|
||||
</div>
|
||||
|
||||
<div v-else-if="loading && !songs.length" 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>
|
||||
|
||||
<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="refresh" class="px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition">重试</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!songs.length" class="text-content-3 py-8">云盘中暂无音乐</div>
|
||||
|
||||
<div v-else class="space-y-1">
|
||||
<SongListItem
|
||||
v-for="(song, index) in songs"
|
||||
:key="song.id"
|
||||
:song="song"
|
||||
:index="index"
|
||||
:is-current="player.currentSong?.id === song.id"
|
||||
show-index
|
||||
show-like
|
||||
show-download
|
||||
show-duration
|
||||
show-playing-overlay
|
||||
:container-class="player.currentSong?.id === song.id ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
|
||||
@click="player.playFromList(songs, index)"
|
||||
>
|
||||
<template #actions>
|
||||
<span class="text-xs text-content-3 flex-shrink-0">{{ formatFileSize(cloudData[index]?.fileSize || 0) }}</span>
|
||||
<div class="relative flex-shrink-0">
|
||||
<button
|
||||
@click.stop="toggleMenu(song.id)"
|
||||
class="text-content-3 hover:text-content transition p-1 rounded hover:bg-muted"
|
||||
title="更多"
|
||||
>
|
||||
<IconEllipsis class="w-4 h-4 fill-current" />
|
||||
</button>
|
||||
<Transition name="fade">
|
||||
<div v-if="openMenuId === song.id" class="absolute right-0 top-full mt-1 w-40 bg-surface border border-line rounded-xl shadow-2xl overflow-hidden z-50" @click.stop>
|
||||
<button @click="showDetail(index)" class="w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-content-2 hover:bg-subtle transition">
|
||||
<IconInfo style="font-size: 14px" />
|
||||
查看详情
|
||||
</button>
|
||||
<button @click="confirmDelete(song)" class="w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-danger/80 hover:bg-danger/10 transition">
|
||||
<IconTrash2 style="font-size: 14px" />
|
||||
从云盘删除
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
</SongListItem>
|
||||
</div>
|
||||
|
||||
<!-- 加载更多 -->
|
||||
<div v-if="hasMore && songs.length" class="flex justify-center py-6">
|
||||
<button
|
||||
@click="loadMore"
|
||||
:disabled="loadingMore"
|
||||
class="px-6 py-2 bg-subtle hover:bg-muted rounded-full text-sm transition disabled:opacity-50"
|
||||
>
|
||||
{{ loadingMore ? '加载中...' : '加载更多' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<Transition name="fade">
|
||||
<div v-if="showDetailModal && detailData" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showDetailModal = false">
|
||||
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-[380px] p-6 select-auto">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold truncate pr-4">{{ detailData.songName }}</h2>
|
||||
<button @click="showDetailModal = false" class="text-content-3 hover:text-content transition flex-shrink-0">
|
||||
<IconX class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-content-3">文件名</span>
|
||||
<span class="text-content-2 text-right max-w-[220px] truncate" :title="detailData.fileName">{{ detailData.fileName }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-content-3">歌手</span>
|
||||
<span class="text-content-2">{{ detailData.artist }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-content-3">专辑</span>
|
||||
<span class="text-content-2">{{ detailData.album }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-content-3">文件大小</span>
|
||||
<span class="text-content-2">{{ formatFileSize(detailData.fileSize) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-content-3">比特率</span>
|
||||
<span class="text-content-2">{{ detailData.bitrate ? (detailData.bitrate / 1000) + ' kbps' : '未知' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-content-3">上传时间</span>
|
||||
<span class="text-content-2">{{ detailData.addTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- 删除确认 -->
|
||||
<Transition name="fade">
|
||||
<div v-if="showDeleteConfirm" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showDeleteConfirm = false">
|
||||
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-80 p-6 select-auto">
|
||||
<h2 class="text-lg font-semibold text-content mb-1">确认删除</h2>
|
||||
<p class="text-sm text-content-2 mb-5">确定要从云盘删除「{{ deleteTarget?.name }}」吗?</p>
|
||||
<div class="flex gap-3">
|
||||
<button @click="showDeleteConfirm = false"
|
||||
class="flex-1 py-2 rounded-lg bg-muted hover:bg-emphasis text-sm text-content-2 transition">
|
||||
取消
|
||||
</button>
|
||||
<button @click="doDelete"
|
||||
class="flex-1 py-2 rounded-lg bg-danger/20 hover:bg-danger/30 text-danger text-sm font-medium transition">
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onActivated, onBeforeUnmount } from 'vue';
|
||||
import { MusicApi } from '../api';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { useUserStore } from '../stores/user';
|
||||
import { showToast } from '../composables/useToast';
|
||||
import { normalizeSong, type Song } from '../utils/song';
|
||||
import { pageCacheGet, pageCacheSet, pageCacheInvalidate } from '../composables/usePageCache';
|
||||
import { formatFileSize } from '../composables/useLocalMusic';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import IconEllipsis from '~icons/lucide/ellipsis';
|
||||
import IconInfo from '~icons/lucide/info';
|
||||
import IconTrash2 from '~icons/lucide/trash-2';
|
||||
import IconX from '~icons/lucide/x';
|
||||
|
||||
defineOptions({ name: 'CloudMusicView' });
|
||||
|
||||
const player = usePlayerStore();
|
||||
const userStore = useUserStore();
|
||||
|
||||
interface CloudItem {
|
||||
songId: number;
|
||||
fileSize: number;
|
||||
fileName: string;
|
||||
bitrate: number;
|
||||
addTime: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
songName: string;
|
||||
}
|
||||
|
||||
const songs = ref<Song[]>([]);
|
||||
const cloudData = ref<CloudItem[]>([]);
|
||||
const loading = ref(true);
|
||||
const loadingMore = ref(false);
|
||||
const loadError = ref(false);
|
||||
const hasMore = ref(false);
|
||||
const totalCount = ref(0);
|
||||
const openMenuId = ref<number | null>(null);
|
||||
const showDeleteConfirm = ref(false);
|
||||
const showDetailModal = ref(false);
|
||||
const detailData = ref<CloudItem | null>(null);
|
||||
const deleteTarget = ref<Song | null>(null);
|
||||
const cloudSize = ref(0);
|
||||
const cloudMaxSize = ref(0);
|
||||
const uploading = ref(false);
|
||||
const uploadProgress = ref(0);
|
||||
let unlistenProgress: UnlistenFn | null = null;
|
||||
|
||||
const cloudUsagePercent = computed(() => {
|
||||
if (cloudMaxSize.value === 0) return 0;
|
||||
return Math.min(100, Math.round(cloudSize.value / cloudMaxSize.value * 100));
|
||||
});
|
||||
|
||||
const LIMIT = 30;
|
||||
let currentOffset = 0;
|
||||
|
||||
function formatTimestamp(ts: number): string {
|
||||
if (!ts) return '未知';
|
||||
return new Date(ts).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function toggleMenu(id: number) {
|
||||
openMenuId.value = openMenuId.value === id ? null : id;
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
openMenuId.value = null;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', closeMenu);
|
||||
// 监听上传进度事件
|
||||
listen<{ filename: string; progress: number; uploaded: number; total: number }>('cloud-upload-progress', (e) => {
|
||||
uploadProgress.value = e.payload.progress;
|
||||
}).then(fn => { unlistenProgress = fn; });
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', closeMenu);
|
||||
unlistenProgress?.();
|
||||
});
|
||||
|
||||
async function fetchCloud(offset = 0, append = false) {
|
||||
if (!userStore.isLoggedIn) {
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!append) {
|
||||
loading.value = true;
|
||||
loadError.value = false;
|
||||
} else {
|
||||
loadingMore.value = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const jsonStr = await MusicApi.userCloud(LIMIT, offset);
|
||||
const data = JSON.parse(jsonStr);
|
||||
const items = data.data || [];
|
||||
|
||||
const newSongs = items.map((item: any) => {
|
||||
const s = item.simpleSong || {};
|
||||
return normalizeSong({
|
||||
...s,
|
||||
id: s.id || item.songId,
|
||||
name: s.name || item.fileName,
|
||||
ar: s.ar || (item.artist ? [{ name: item.artist }] : []),
|
||||
al: s.al || { name: item.album || '未知专辑' },
|
||||
dt: s.dt || item.duration,
|
||||
});
|
||||
});
|
||||
|
||||
const newCloudData: CloudItem[] = items.map((item: any) => ({
|
||||
songId: item.songId,
|
||||
fileSize: item.fileSize || 0,
|
||||
fileName: item.fileName || '',
|
||||
bitrate: item.bitrate || 0,
|
||||
addTime: formatTimestamp(item.addTime),
|
||||
artist: item.artist || (item.simpleSong?.ar || []).map((a: any) => a.name).join(' / ') || '未知歌手',
|
||||
album: item.album || item.simpleSong?.al?.name || '未知专辑',
|
||||
songName: item.simpleSong?.name || item.fileName?.replace(/\.\w+$/, '') || '未知歌曲',
|
||||
}));
|
||||
|
||||
if (append) {
|
||||
songs.value = [...songs.value, ...newSongs];
|
||||
cloudData.value = [...cloudData.value, ...newCloudData];
|
||||
} else {
|
||||
songs.value = newSongs;
|
||||
cloudData.value = newCloudData;
|
||||
}
|
||||
|
||||
totalCount.value = data.count || songs.value.length;
|
||||
currentOffset = offset + items.length;
|
||||
hasMore.value = songs.value.length < totalCount.value;
|
||||
cloudSize.value = data.size || 0;
|
||||
cloudMaxSize.value = data.maxSize || 0;
|
||||
|
||||
if (!append) {
|
||||
pageCacheSet('cloudMusic', {
|
||||
songs: songs.value, cloudData: cloudData.value, totalCount: totalCount.value,
|
||||
hasMore: hasMore.value, offset: currentOffset,
|
||||
cloudSize: cloudSize.value, cloudMaxSize: cloudMaxSize.value,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取云盘音乐失败', e);
|
||||
if (!append) loadError.value = true;
|
||||
else showToast('加载更多失败', 'error');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
loadingMore.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
pageCacheInvalidate('cloudMusic');
|
||||
currentOffset = 0;
|
||||
fetchCloud(0, false);
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
fetchCloud(currentOffset, true);
|
||||
}
|
||||
|
||||
async function pickAndUpload() {
|
||||
const selected = await open({
|
||||
multiple: true,
|
||||
filters: [{ name: '音频文件', extensions: ['mp3', 'flac', 'wav', 'ogg', 'aac', 'm4a'] }],
|
||||
title: '选择要上传的歌曲',
|
||||
});
|
||||
if (!selected) return;
|
||||
|
||||
const paths = Array.isArray(selected) ? selected : [selected];
|
||||
uploading.value = true;
|
||||
uploadProgress.value = 0;
|
||||
|
||||
for (const filePath of paths) {
|
||||
uploadProgress.value = 0;
|
||||
try {
|
||||
await MusicApi.cloudUpload(filePath);
|
||||
showToast('上传成功', 'success');
|
||||
} catch (e: any) {
|
||||
showToast(`上传失败: ${e || '未知错误'}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
uploading.value = false;
|
||||
uploadProgress.value = 0;
|
||||
// 等待服务端完全提交后再刷新列表
|
||||
setTimeout(() => refresh(), 1000);
|
||||
}
|
||||
|
||||
function showDetail(index: number) {
|
||||
openMenuId.value = null;
|
||||
detailData.value = cloudData.value[index] || null;
|
||||
showDetailModal.value = true;
|
||||
}
|
||||
|
||||
function confirmDelete(song: Song) {
|
||||
openMenuId.value = null;
|
||||
deleteTarget.value = song;
|
||||
showDeleteConfirm.value = true;
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
if (!deleteTarget.value) return;
|
||||
try {
|
||||
await MusicApi.userCloudDel(deleteTarget.value.id);
|
||||
const targetId = deleteTarget.value.id;
|
||||
const idx = songs.value.findIndex(s => s.id === targetId);
|
||||
songs.value = songs.value.filter(s => s.id !== targetId);
|
||||
if (idx !== -1) cloudData.value.splice(idx, 1);
|
||||
totalCount.value = Math.max(0, totalCount.value - 1);
|
||||
pageCacheInvalidate('cloudMusic');
|
||||
showToast('已从云盘删除', 'success');
|
||||
} catch {
|
||||
showToast('删除失败', 'error');
|
||||
}
|
||||
showDeleteConfirm.value = false;
|
||||
deleteTarget.value = null;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!userStore.isLoggedIn) {
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
const cached = pageCacheGet('cloudMusic');
|
||||
if (cached) {
|
||||
songs.value = cached.songs;
|
||||
cloudData.value = cached.cloudData;
|
||||
totalCount.value = cached.totalCount;
|
||||
hasMore.value = cached.hasMore;
|
||||
currentOffset = cached.offset;
|
||||
cloudSize.value = cached.cloudSize || 0;
|
||||
cloudMaxSize.value = cached.cloudMaxSize || 0;
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
fetchCloud();
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
if (loadError.value) refresh();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
+70
-50
@@ -1,9 +1,6 @@
|
||||
<template>
|
||||
<div class="p-8 text-content">
|
||||
<button @click="$router.back()" class="mb-4 text-content-2 hover:text-content transition">
|
||||
← 返回
|
||||
</button>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<PageHeader>
|
||||
<h1 class="text-2xl font-bold">每日推荐</h1>
|
||||
<button
|
||||
v-if="songs.length > 0"
|
||||
@@ -12,69 +9,92 @@
|
||||
>
|
||||
播放全部
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="loading" class="text-content-2">加载中...</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="(song, index) in songs"
|
||||
:key="song.id"
|
||||
@click="player.play(song)"
|
||||
class="flex items-center gap-4 p-3 rounded-xl hover:bg-subtle transition cursor-pointer group"
|
||||
:class="{ 'bg-accent-dim': isCurrentSong(song.id) }"
|
||||
>
|
||||
<div class="w-6 text-right flex-shrink-0 flex items-center justify-end h-5">
|
||||
<div v-if="isCurrentSong(song.id)" class="flex items-center justify-end">
|
||||
<div class="flex items-center gap-[3px] h-4">
|
||||
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 50%; animation-delay: 0ms"></span>
|
||||
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 100%; animation-delay: 150ms"></span>
|
||||
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 35%; animation-delay: 300ms"></span>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<span class="text-xs text-content-3 group-hover:hidden">{{ index + 1 }}</span>
|
||||
<svg class="hidden group-hover:block text-content" width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4 2.5v11l9-5.5z"/></svg>
|
||||
</template>
|
||||
</PageHeader>
|
||||
<div v-if="loading" 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>
|
||||
<img :src="song.al?.picUrl" class="w-10 h-10 rounded object-cover flex-shrink-0" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium truncate" :class="isCurrentSong(song.id) ? 'text-accent-text' : ''">{{ song.name }}</p>
|
||||
<p class="text-xs text-content-2 truncate">
|
||||
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
|
||||
</p>
|
||||
</div>
|
||||
<button @click.stop="player.toggleLike(song.id)" class="text-content-3 hover:text-danger transition flex-shrink-0">
|
||||
<svg v-if="player.isLiked(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" class="text-danger"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
|
||||
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
|
||||
</button>
|
||||
<span class="text-xs text-content-3">{{ formatDuration(song.dt) }}</span>
|
||||
</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="loadData(true)" class="px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition">重试</button>
|
||||
</div>
|
||||
<VirtualSongList
|
||||
v-else
|
||||
: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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { ref, onMounted, onActivated, watch } from 'vue';
|
||||
import { MusicApi } from '../api';
|
||||
import VirtualSongList from '../components/VirtualSongList.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { formatDuration } from '../utils/format';
|
||||
import { pageCacheGet, pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
|
||||
import { useOnlineStatus } from '../composables/useOnlineStatus';
|
||||
import { normalizeSongsWithPrivileges, type Song } from '../utils/song';
|
||||
|
||||
defineOptions({ name: 'DailySongsView' });
|
||||
|
||||
const player = usePlayerStore();
|
||||
const songs = ref<any[]>([]);
|
||||
const { isOnline } = useOnlineStatus();
|
||||
const songs = ref<Song[]>([]);
|
||||
const loading = ref(true);
|
||||
const loadError = ref(false);
|
||||
|
||||
function isCurrentSong(songId: number): boolean {
|
||||
return player.currentSong?.id === songId;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
async function loadData(force = false) {
|
||||
if (!force) {
|
||||
const cached = pageCacheGet('dailySongs');
|
||||
if (cached) {
|
||||
songs.value = cached;
|
||||
loading.value = false;
|
||||
loadError.value = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const jsonStr: string = await invoke('recommend_songs');
|
||||
loadError.value = false;
|
||||
const jsonStr: string = await MusicApi.recommendSongs();
|
||||
const data = JSON.parse(jsonStr);
|
||||
songs.value = data.data?.dailySongs || [];
|
||||
songs.value = normalizeSongsWithPrivileges(data.data?.dailySongs || [], data.data?.privileges);
|
||||
pageCacheSet('dailySongs', songs.value);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
console.error('获取每日推荐失败', e);
|
||||
loadError.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData);
|
||||
|
||||
onActivated(() => {
|
||||
if (loadError.value) {
|
||||
loadData(true);
|
||||
} else if (pageCacheIsStale('dailySongs')) {
|
||||
loadData();
|
||||
}
|
||||
});
|
||||
|
||||
watch(isOnline, (val, old) => {
|
||||
if (val && !old && songs.value.length === 0) {
|
||||
pageCacheInvalidate('dailySongs');
|
||||
loadData();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
+236
-67
@@ -1,16 +1,5 @@
|
||||
<template>
|
||||
<div class="p-8 text-content">
|
||||
<h1 class="text-2xl font-bold mb-4">发现音乐</h1>
|
||||
|
||||
<!-- 搜索框 -->
|
||||
<input
|
||||
v-model="keyword"
|
||||
@keyup.enter="handleSearch"
|
||||
placeholder="搜索歌曲、歌手、专辑..."
|
||||
class="mb-4 w-full rounded-xl bg-muted p-3 text-content placeholder-content-2 outline-none backdrop-blur"
|
||||
/>
|
||||
|
||||
<!-- 热门搜索标签(仅在没有搜索且未显示结果时出现) -->
|
||||
<div v-if="!hasSearched && !loading && hotTags.length" class="mb-6">
|
||||
<h2 class="text-sm font-semibold mb-3">🔥 热门搜索</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@@ -18,40 +7,84 @@
|
||||
v-for="tag in hotTags"
|
||||
:key="tag.searchWord"
|
||||
@click="searchTag(tag.searchWord)"
|
||||
class="px-3 py-1 rounded-full bg-muted hover:bg-emphasis cursor-pointer transition text-sm"
|
||||
class="px-3 py-1.5 rounded-full bg-muted hover:bg-emphasis cursor-pointer transition text-sm"
|
||||
>
|
||||
{{ tag.searchWord }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 输出设备选择 -->
|
||||
<!-- <div class="mb-4">
|
||||
<label class="mr-2 text-sm text-content-2">输出设备:</label>
|
||||
<select v-model="selectedDevice" @change="changeDevice" class="bg-muted text-white rounded p-1 text-sm">
|
||||
<option :value="null">跟随系统默认</option>
|
||||
<option v-for="dev in devices" :key="dev" :value="dev">{{ dev }}</option>
|
||||
</select>
|
||||
</div> -->
|
||||
<div v-if="hasSearched">
|
||||
<div class="flex items-center gap-1 mb-4 bg-muted rounded-lg p-1 w-fit">
|
||||
<button v-for="tab in tabs" :key="tab.type" @click="switchTab(tab.type)"
|
||||
:class="['px-4 py-1.5 rounded-md text-sm font-medium transition', activeTab === tab.type ? 'bg-surface text-content shadow-sm' : 'text-content-2 hover:text-content']">
|
||||
{{ tab.label }}
|
||||
<span v-if="resultCache.has(tab.type) && resultCache.get(tab.type)!.count > 0" class="text-xs text-content-3 ml-1">{{ resultCache.get(tab.type)!.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 搜索结果 -->
|
||||
<div v-if="loading" class="text-content-2">搜索中...</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="song in results"
|
||||
:key="song.id"
|
||||
@click="playSong(song)"
|
||||
class="flex items-center gap-4 p-3 rounded-xl backdrop-blur-md bg-subtle hover:bg-muted border border-line-2 cursor-pointer transition"
|
||||
>
|
||||
<img :src="song.al?.picUrl" class="w-12 h-12 rounded-lg object-cover" />
|
||||
<div>
|
||||
<p class="font-medium">{{ song.name }}</p>
|
||||
<p class="text-sm text-content-2">
|
||||
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
|
||||
</p>
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<div class="flex items-end gap-1 h-6">
|
||||
<span class="eq-bar w-[3px] bg-accent rounded-full" style="animation-delay: 0s"></span>
|
||||
<span class="eq-bar w-[3px] bg-accent rounded-full" style="animation-delay: 0.12s"></span>
|
||||
<span class="eq-bar w-[3px] bg-accent rounded-full" style="animation-delay: 0.24s"></span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!loading && hasSearched && results.length === 0" class="text-content-2">无结果</p>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="activeTab === 1">
|
||||
<div v-if="currentResults.length" class="space-y-2">
|
||||
<SongListItem
|
||||
v-for="(song, index) in currentResults"
|
||||
:key="song.id"
|
||||
:song="song"
|
||||
:index="index"
|
||||
show-download
|
||||
show-menu
|
||||
cover-size="w-12 h-12"
|
||||
container-class="bg-subtle hover:bg-muted border border-line-2"
|
||||
@click="player.playFromList(currentResults, index)"
|
||||
/>
|
||||
</div>
|
||||
<p v-else class="text-content-2 text-center py-8">{{ cacheError ? '搜索失败,点击其他标签页刷新重试' : '未找到相关歌曲' }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeTab === 100">
|
||||
<div v-if="currentResults.length" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<div v-for="artist in currentResults" :key="artist.id" @click="router.push({ name: 'artist', params: { id: artist.id } })"
|
||||
class="bg-subtle hover:bg-muted border border-line-2 rounded-xl p-4 cursor-pointer transition flex items-center gap-3">
|
||||
<img v-if="artist.picUrl" :src="artist.picUrl + '?param=100y100'" class="w-14 h-14 rounded-full object-cover flex-shrink-0" />
|
||||
<div v-else class="w-14 h-14 rounded-full bg-muted flex items-center justify-center flex-shrink-0">
|
||||
<IconUserRound class="w-5 h-5 text-content-3" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium truncate">{{ artist.name }}</p>
|
||||
<p v-if="artist.alias?.length" class="text-xs text-content-3 truncate">{{ artist.alias[0] }}</p>
|
||||
<p v-if="artist.musicSize" class="text-xs text-content-3">{{ artist.musicSize }} 首歌曲</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-content-2 text-center py-8">{{ cacheError ? '搜索失败,点击其他标签页刷新重试' : '未找到相关歌手' }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeTab === 10">
|
||||
<div v-if="currentResults.length" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
<div v-for="album in currentResults" :key="album.id" @click="router.push({ name: 'album', params: { id: album.id } })"
|
||||
class="bg-subtle hover:bg-muted border border-line-2 rounded-xl overflow-hidden cursor-pointer transition">
|
||||
<img v-if="album.picUrl" :src="album.picUrl + '?param=200y200'" class="w-full aspect-square object-cover" />
|
||||
<div v-else class="w-full aspect-square bg-muted flex items-center justify-center">
|
||||
<IconDisc class="w-8 h-8 text-content-3" />
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<p class="text-sm font-medium truncate">{{ album.name }}</p>
|
||||
<p class="text-xs text-content-2 truncate mt-0.5">{{ album.artist?.name || '' }}</p>
|
||||
<p v-if="album.publishTime" class="text-xs text-content-3 mt-0.5">{{ formatDate(album.publishTime) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-content-2 text-center py-8">{{ cacheError ? '搜索失败,点击其他标签页刷新重试' : '未找到相关专辑' }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -59,65 +92,201 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'DiscoverView' });
|
||||
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ref, computed, onMounted, onActivated, watch } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { MusicApi } from '../api';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { useSettingsStore } from '../stores/settings';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
import { normalizeSong, type Song } from '../utils/song';
|
||||
import { formatDate } from '../utils/format';
|
||||
import { pageCacheGet, pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
|
||||
import { useOnlineStatus } from '../composables/useOnlineStatus';
|
||||
import IconUserRound from '~icons/lucide/user-round';
|
||||
import IconDisc from '~icons/lucide/disc';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const player = usePlayerStore();
|
||||
const settings = useSettingsStore();
|
||||
const { isOnline } = useOnlineStatus();
|
||||
|
||||
/** 外部音源歌曲 ID 计数器(生成唯一负数占位 ID) */
|
||||
let externalIdCounter = -1;
|
||||
/** 将后端 ExternalSong 转为前端 Song 结构 */
|
||||
function externalSongToSong(ext: any): Song {
|
||||
const artistStr: string = ext.artist || '';
|
||||
const ar = artistStr
|
||||
? artistStr.split(/[\/,;&|]/).map((name: string) => ({ name: name.trim() })).filter((a: any) => a.name)
|
||||
: [];
|
||||
return {
|
||||
id: externalIdCounter--,
|
||||
name: ext.name || '未知歌曲',
|
||||
ar,
|
||||
al: { picUrl: ext.pic_url || '', name: ext.album || undefined },
|
||||
dt: ext.duration || 0,
|
||||
externalId: String(ext.id || ''),
|
||||
externalSourceId: ext.source || '',
|
||||
sourceLabel: ext.source_label || '',
|
||||
};
|
||||
}
|
||||
|
||||
const keyword = ref('');
|
||||
const results = ref<any[]>([]);
|
||||
const loading = ref(false);
|
||||
const hasSearched = ref(false);
|
||||
const hotTags = ref<any[]>([]);
|
||||
const activeTab = ref(1);
|
||||
const cacheError = ref(false);
|
||||
|
||||
const devices = ref<string[]>([]);
|
||||
interface CacheEntry {
|
||||
data: Song[] | any[];
|
||||
count: number;
|
||||
dirty: boolean;
|
||||
}
|
||||
|
||||
const resultCache = ref<Map<number, CacheEntry>>(new Map());
|
||||
const lastSearchKeyword = ref('');
|
||||
|
||||
const currentResults = computed(() => {
|
||||
const entry = resultCache.value.get(activeTab.value);
|
||||
return entry ? entry.data : [];
|
||||
});
|
||||
|
||||
const tabs = [
|
||||
{ type: 1, label: '歌曲' },
|
||||
{ type: 100, label: '歌手' },
|
||||
{ type: 10, label: '专辑' },
|
||||
];
|
||||
|
||||
async function loadHotTags() {
|
||||
const cached = pageCacheGet('discover_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, 12);
|
||||
pageCacheSet('discover_hotTags', hotTags.value);
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 获取输出设备列表
|
||||
try { devices.value = await invoke('get_output_devices'); } catch {}
|
||||
await loadHotTags();
|
||||
});
|
||||
|
||||
// 获取热门搜索
|
||||
try {
|
||||
const json = await invoke('get_hot_search');
|
||||
const data = JSON.parse(json as string);
|
||||
hotTags.value = (data.data || []).slice(0, 12);
|
||||
} catch {}
|
||||
onActivated(async () => {
|
||||
if (pageCacheIsStale('discover_hotTags')) loadHotTags();
|
||||
});
|
||||
|
||||
// 检查路由是否有查询关键词,自动搜索
|
||||
const q = route.query.q as string;
|
||||
if (q) {
|
||||
keyword.value = q;
|
||||
await handleSearch();
|
||||
router.replace({ query: {} });
|
||||
// 监听路由搜索参数变化(immediate 处理首次加载,_t 时间戳确保相同关键词也能重复搜索)
|
||||
watch(() => [route.query.q, route.query._t], async ([q]) => {
|
||||
if (q && typeof q === 'string') {
|
||||
await handleSearch(q);
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
watch(isOnline, (val, old) => {
|
||||
if (val && !old && hotTags.value.length === 0) {
|
||||
pageCacheInvalidate('discover_hotTags');
|
||||
loadHotTags();
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSearch() {
|
||||
if (!keyword.value.trim()) return;
|
||||
loading.value = true;
|
||||
async function handleSearch(q: string) {
|
||||
const query = q.trim();
|
||||
if (!query) return;
|
||||
hasSearched.value = true;
|
||||
cacheError.value = false;
|
||||
lastSearchKeyword.value = query;
|
||||
resultCache.value.clear();
|
||||
|
||||
await Promise.all([
|
||||
fetchTabResults(1),
|
||||
fetchTabResults(100),
|
||||
fetchTabResults(10),
|
||||
]);
|
||||
}
|
||||
|
||||
async function fetchTabResults(type: number) {
|
||||
const dedupeKey = (s: Song) => `${s.name.toLowerCase()}|${(s.ar?.[0]?.name || '').toLowerCase()}`;
|
||||
const entry = resultCache.value.get(type);
|
||||
if (entry && !entry.dirty) return;
|
||||
|
||||
loading.value = true;
|
||||
cacheError.value = false;
|
||||
try {
|
||||
const jsonStr: string = await invoke('search_songs', { query: { keyword: keyword.value } });
|
||||
// 网易云搜索
|
||||
const cloudsearchPromise = MusicApi.cloudsearch({
|
||||
keyword: lastSearchKeyword.value, searchType: type, limit: 30
|
||||
});
|
||||
|
||||
// 多源聚合搜索(仅歌曲 tab,且音源补充开启且有启用源时)
|
||||
const canMultiSearch = type === 1
|
||||
&& settings.musicSourceEnabled
|
||||
&& settings.enabledMusicSources.length > 0;
|
||||
const multiSearchPromise = canMultiSearch
|
||||
? MusicApi.searchSongsMulti({
|
||||
keyword: lastSearchKeyword.value,
|
||||
sources: settings.enabledMusicSources,
|
||||
limit: 20,
|
||||
}).catch(() => '[]')
|
||||
: Promise.resolve('[]');
|
||||
|
||||
const [jsonStr, externalJsonStr] = await Promise.all([cloudsearchPromise, multiSearchPromise]);
|
||||
|
||||
const data = JSON.parse(jsonStr);
|
||||
results.value = data.result?.songs || [];
|
||||
const result = data.result || {};
|
||||
|
||||
let items: any[] = [];
|
||||
if (type === 1) {
|
||||
const neteaseSongs: Song[] = (result.songs || []).map(normalizeSong);
|
||||
// 外部音源结果(如 QQ 音乐独占歌曲,通过 bodian/kugou/kuwo 等源搜到)
|
||||
const externalSongs: Song[] = (JSON.parse(externalJsonStr) || []).map(externalSongToSong);
|
||||
// 去重1:外部源中与网易云「歌名+歌手」都匹配的歌曲跳过,保留网易云版本
|
||||
// 只按歌名匹配会误杀——例如网易云有别的「定玄」,会把酷我的原版也过滤掉
|
||||
const neteaseKeys = new Set(neteaseSongs.map(dedupeKey));
|
||||
const externalFiltered = externalSongs.filter(s => {
|
||||
const key = dedupeKey(s);
|
||||
return !neteaseKeys.has(key);
|
||||
});
|
||||
// 去重2:外部源之间也可能搜到同一首(如 kuwo 和 bodian 都有),保留第一个
|
||||
const seenExternal = new Set<string>();
|
||||
const dedupedExternal = externalFiltered.filter(s => {
|
||||
const key = dedupeKey(s);
|
||||
if (seenExternal.has(key)) return false;
|
||||
seenExternal.add(key);
|
||||
return true;
|
||||
});
|
||||
items = [...neteaseSongs, ...dedupedExternal];
|
||||
} else if (type === 100) {
|
||||
items = result.artists || [];
|
||||
} else if (type === 10) {
|
||||
items = result.albums || [];
|
||||
}
|
||||
|
||||
resultCache.value.set(type, { data: items, count: items.length, dirty: false });
|
||||
} catch (e) {
|
||||
console.error('搜索出错:', e);
|
||||
resultCache.value.set(type, { data: [], count: 0, dirty: true });
|
||||
cacheError.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function switchTab(type: number) {
|
||||
if (type === activeTab.value) return;
|
||||
activeTab.value = type;
|
||||
|
||||
const entry = resultCache.value.get(type);
|
||||
if (!entry || entry.dirty) {
|
||||
await fetchTabResults(type);
|
||||
}
|
||||
}
|
||||
|
||||
function searchTag(tag: string) {
|
||||
keyword.value = tag;
|
||||
handleSearch();
|
||||
// 通过路由跳转触发搜索(带时间戳确保相同关键词也能重复搜索)
|
||||
router.push({ name: 'discover', query: { q: tag, _t: String(Date.now()) } });
|
||||
}
|
||||
|
||||
async function playSong(song: any) {
|
||||
player.play(song);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<div class="p-8 text-content">
|
||||
<PageHeader>
|
||||
<h1 class="text-2xl font-bold">下载音乐</h1>
|
||||
<span v-if="songs.length" class="text-xs text-content-3">{{ songs.length }} 首</span>
|
||||
<template #actions>
|
||||
<button
|
||||
@click="refresh"
|
||||
class="px-3 py-1 bg-muted hover:bg-emphasis rounded-full text-xs transition"
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div v-if="loading" 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 v-else-if="songs.length === 0" class="text-content-3">
|
||||
暂无下载音乐
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<SongListItem
|
||||
v-for="(song, index) in normalizedSongs"
|
||||
:key="song.id + '-' + index"
|
||||
:song="song"
|
||||
:index="index"
|
||||
:is-current="player.currentSong?.id === song.id"
|
||||
show-index
|
||||
show-duration
|
||||
show-playing-overlay
|
||||
:container-class="player.currentSong?.id === song.id ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
|
||||
@click="player.playFromList(normalizedSongs, index)"
|
||||
>
|
||||
<template #actions>
|
||||
<span class="text-xs text-content-3 flex-shrink-0">{{ formatFileSize(songs[index].fileSize) }}</span>
|
||||
<div class="relative flex-shrink-0">
|
||||
<button
|
||||
@click.stop="toggleMenu(songs[index].id)"
|
||||
class="text-content-3 hover:text-content transition p-1 rounded hover:bg-muted"
|
||||
title="更多"
|
||||
>
|
||||
<IconEllipsis class="w-4 h-4 fill-current" />
|
||||
</button>
|
||||
<Transition name="fade">
|
||||
<div v-if="openMenuId === songs[index].id" class="absolute right-0 top-full mt-1 w-44 bg-surface border border-line rounded-xl shadow-2xl overflow-hidden z-50" @click.stop>
|
||||
<button @click="confirmDelete(songs[index])" class="w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-danger/80 hover:bg-danger/10 transition">
|
||||
<IconTrash2 style="font-size: 14px" />
|
||||
从磁盘中删除
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
</SongListItem>
|
||||
</div>
|
||||
|
||||
<Transition name="fade">
|
||||
<div v-if="showDeleteConfirm" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showDeleteConfirm = false">
|
||||
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-80 p-6 select-auto">
|
||||
<h2 class="text-lg font-semibold text-content mb-1">确认删除</h2>
|
||||
<p class="text-sm text-content-2 mb-5">确定要删除「{{ deleteTarget?.name }}」吗?此操作不可撤销。</p>
|
||||
<div class="flex gap-3">
|
||||
<button @click="showDeleteConfirm = false"
|
||||
class="flex-1 py-2 rounded-lg bg-muted hover:bg-emphasis text-sm text-content-2 transition">
|
||||
取消
|
||||
</button>
|
||||
<button @click="doDelete"
|
||||
class="flex-1 py-2 rounded-lg bg-danger/20 hover:bg-danger/30 text-danger text-sm font-medium transition">
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onActivated, onBeforeUnmount, watch } from 'vue';
|
||||
import { DownloadApi } from '../api';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { useDownload } from '../composables/useDownload';
|
||||
import { useSettingsStore } from '../stores/settings';
|
||||
import { showToast } from '../composables/useToast';
|
||||
import { pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
|
||||
import { formatFileSize, localSongToSong, fetchMissingCovers, type LocalSong } from '../composables/useLocalMusic';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import IconEllipsis from '~icons/lucide/ellipsis';
|
||||
import IconTrash2 from '~icons/lucide/trash-2';
|
||||
|
||||
defineOptions({ name: 'DownloadedMusicView' });
|
||||
|
||||
const player = usePlayerStore();
|
||||
const download = useDownload();
|
||||
const settings = useSettingsStore();
|
||||
|
||||
const songs = ref<LocalSong[]>([]);
|
||||
const loading = ref(true);
|
||||
const showDeleteConfirm = ref(false);
|
||||
const deleteTarget = ref<LocalSong | null>(null);
|
||||
const openMenuId = ref<number | null>(null);
|
||||
|
||||
const normalizedSongs = computed(() => songs.value.map(localSongToSong));
|
||||
|
||||
function toggleMenu(id: number) {
|
||||
openMenuId.value = openMenuId.value === id ? null : id;
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
openMenuId.value = null;
|
||||
}
|
||||
|
||||
onMounted(() => { document.addEventListener('click', closeMenu); });
|
||||
onBeforeUnmount(() => { document.removeEventListener('click', closeMenu); });
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const list = await DownloadApi.listLocalSongs(settings.downloadPath || null);
|
||||
songs.value = list;
|
||||
pageCacheSet('downloadedMusic', list);
|
||||
fetchMissingCovers(songs.value);
|
||||
} catch (e) {
|
||||
console.error('加载下载音乐列表失败', e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refresh);
|
||||
|
||||
onActivated(() => {
|
||||
if (pageCacheIsStale('downloadedMusic')) refresh();
|
||||
});
|
||||
|
||||
watch(() => settings.downloadPath, () => { refresh(); });
|
||||
|
||||
function confirmDelete(song: LocalSong) {
|
||||
openMenuId.value = null;
|
||||
deleteTarget.value = song;
|
||||
showDeleteConfirm.value = true;
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
if (!deleteTarget.value) return;
|
||||
try {
|
||||
await DownloadApi.deleteLocalSong({ id: deleteTarget.value.id, filename: deleteTarget.value.filename, downloadPath: settings.downloadPath || null });
|
||||
songs.value = songs.value.filter(s => s.id !== deleteTarget.value!.id);
|
||||
download.localSongIds.delete(deleteTarget.value.id);
|
||||
pageCacheInvalidate('downloadedMusic');
|
||||
showToast('已删除', 'success');
|
||||
} catch (e) {
|
||||
showToast('删除失败', 'error');
|
||||
}
|
||||
showDeleteConfirm.value = false;
|
||||
deleteTarget.value = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
+75
-37
@@ -1,68 +1,87 @@
|
||||
<template>
|
||||
<div class="p-8 text-content">
|
||||
<button @click="$router.back()" class="mb-4 text-content-2 hover:text-content transition">
|
||||
← 返回
|
||||
</button>
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<PageHeader>
|
||||
<h1 class="text-2xl font-bold">我喜欢的音乐</h1>
|
||||
<button
|
||||
v-if="songs.length"
|
||||
@click="player.playAll(songs)"
|
||||
class="px-4 py-1.5 bg-muted hover:bg-emphasis rounded-full text-sm transition"
|
||||
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>
|
||||
</div>
|
||||
</PageHeader>
|
||||
<div v-if="!userStore.isLoggedIn" class="text-content-2">
|
||||
请先登录后查看喜欢的音乐
|
||||
</div>
|
||||
<div v-else-if="loading" class="text-content-2">加载中...</div>
|
||||
<div v-else-if="songs.length === 0" class="text-content-2">暂无喜欢的音乐</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="(song, index) in songs"
|
||||
:key="song.id"
|
||||
@click="player.play(song)"
|
||||
class="flex items-center gap-4 p-3 rounded-xl hover:bg-subtle transition cursor-pointer"
|
||||
>
|
||||
<span class="text-xs text-content-3 w-6 text-right">{{ index + 1 }}</span>
|
||||
<img :src="song.al?.picUrl" class="w-10 h-10 rounded object-cover" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium truncate">{{ song.name }}</p>
|
||||
<p class="text-xs text-content-2 truncate">
|
||||
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
|
||||
</p>
|
||||
<div v-else-if="loading" 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>
|
||||
<button @click.stop="player.toggleLike(song.id)" class="text-content-3 hover:text-danger transition flex-shrink-0">
|
||||
<svg v-if="player.isLiked(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" class="text-danger"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
|
||||
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
|
||||
</button>
|
||||
<span class="text-xs text-content-3">{{ formatDuration(song.dt) }}</span>
|
||||
</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="loadData(true)" class="px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition">重试</button>
|
||||
</div>
|
||||
<div v-else-if="songs.length === 0" class="text-content-2">暂无喜欢的音乐</div>
|
||||
<VirtualSongList
|
||||
v-else
|
||||
: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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { ref, onMounted, onActivated, watch } from 'vue';
|
||||
import { MusicApi } from '../api';
|
||||
import VirtualSongList from '../components/VirtualSongList.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { useUserStore } from '../stores/user';
|
||||
import { normalizeSong } from '../utils/song';
|
||||
import { formatDuration } from '../utils/format';
|
||||
import { normalizeSongsWithPrivileges, type Song } from '../utils/song';
|
||||
import { pageCacheGet, pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
|
||||
import { useOnlineStatus } from '../composables/useOnlineStatus';
|
||||
import IconPlay from '~icons/lucide/play';
|
||||
|
||||
defineOptions({ name: 'FavoriteSongsView' });
|
||||
|
||||
const player = usePlayerStore();
|
||||
const userStore = useUserStore();
|
||||
const songs = ref<any[]>([]);
|
||||
const { isOnline } = useOnlineStatus();
|
||||
const songs = ref<Song[]>([]);
|
||||
const loading = ref(true);
|
||||
const loadError = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
async function loadData(force = false) {
|
||||
if (!userStore.isLoggedIn) {
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
if (!force) {
|
||||
const cached = pageCacheGet('favoriteSongs');
|
||||
if (cached) {
|
||||
songs.value = cached;
|
||||
loading.value = false;
|
||||
loadError.value = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const playlistJson: string = await invoke('user_playlist', { uid: userStore.user!.userId });
|
||||
loadError.value = false;
|
||||
const playlistJson: string = await MusicApi.userPlaylist(userStore.user!.userId);
|
||||
const playlistData = JSON.parse(playlistJson);
|
||||
const created = (playlistData.playlist || []).filter((p: any) => !p.subscribed);
|
||||
if (created.length === 0) {
|
||||
@@ -70,13 +89,32 @@ onMounted(async () => {
|
||||
return;
|
||||
}
|
||||
const likePlaylistId = created[0].id;
|
||||
const trackJson: string = await invoke('playlist_track_all', { query: { id: likePlaylistId } });
|
||||
const trackJson: string = await MusicApi.playlistTrackAll(likePlaylistId);
|
||||
const trackData = JSON.parse(trackJson);
|
||||
songs.value = (trackData.songs || []).map(normalizeSong);
|
||||
songs.value = normalizeSongsWithPrivileges(trackData.songs || [], trackData.privileges);
|
||||
pageCacheSet('favoriteSongs', songs.value);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
console.error('加载我喜欢的音乐失败', e);
|
||||
loadError.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData);
|
||||
|
||||
onActivated(() => {
|
||||
if (loadError.value) {
|
||||
loadData(true);
|
||||
} else if (pageCacheIsStale('favoriteSongs')) {
|
||||
loadData();
|
||||
}
|
||||
});
|
||||
|
||||
watch(isOnline, (val, old) => {
|
||||
if (val && !old && userStore.isLoggedIn && songs.value.length === 0) {
|
||||
pageCacheInvalidate('favoriteSongs');
|
||||
loadData();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
+428
-77
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<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
|
||||
class="h-48 bg-gradient-to-br from-pink-600 to-purple-700 rounded-3xl overflow-hidden relative cursor-pointer group"
|
||||
@@ -11,9 +11,9 @@
|
||||
<div class="relative z-10 p-6 flex flex-col justify-between h-full">
|
||||
<div>
|
||||
<p class="text-xs text-white/60 mb-1">📅 {{ todayStr }}</p>
|
||||
<h2 class="text-2xl font-bold">每日推荐</h2>
|
||||
<h2 class="text-2xl font-bold text-white">每日推荐</h2>
|
||||
</div>
|
||||
<p class="text-xs text-white/60">根据你的口味生成,每天 6:00 更新</p>
|
||||
<p class="text-xs text-white/60">根据你的口味生成,每天凌晨更新</p>
|
||||
</div>
|
||||
<div class="absolute right-4 top-1/2 -translate-y-1/2 text-6xl opacity-20">🎧</div>
|
||||
</div>
|
||||
@@ -33,15 +33,15 @@
|
||||
|
||||
<div class="relative z-10 h-full flex flex-col justify-between p-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-white/50"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.4"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.4"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>
|
||||
<IconRadio class="w-4 h-4 text-white/50" />
|
||||
<span class="text-xs text-white/50 font-medium">私人漫游</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="text-xl font-bold" v-if="!player.fmSong && userStore.isLoggedIn">发现新音乐</h2>
|
||||
<h2 class="text-xl font-bold" v-else-if="!userStore.isLoggedIn">私人漫游</h2>
|
||||
<h2 class="text-lg font-bold truncate" v-else>{{ fmDisplayName }}</h2>
|
||||
<h2 class="text-xl font-bold text-white" v-if="!player.fmSong && userStore.isLoggedIn">发现新音乐</h2>
|
||||
<h2 class="text-xl font-bold text-white" v-else-if="!userStore.isLoggedIn">私人漫游</h2>
|
||||
<h2 class="text-lg font-bold truncate text-white" v-else>{{ fmDisplayName }}</h2>
|
||||
<p v-if="!userStore.isLoggedIn" class="text-xs text-white/50 mt-1">登录后开启沉浸式音乐探索</p>
|
||||
<p v-else-if="!player.fmSong" class="text-xs text-white/50 mt-1">根据你的喜好,为你推荐意想不到的好歌</p>
|
||||
<p v-else class="text-xs text-white/60 truncate mt-1">{{ fmDisplayArtists }}</p>
|
||||
@@ -50,95 +50,239 @@
|
||||
<button v-if="userStore.isLoggedIn && !player.fmSong"
|
||||
@click.stop="startFmPlay"
|
||||
class="w-10 h-10 flex items-center justify-center rounded-full bg-white/15 hover:bg-white/25 backdrop-blur-sm transition">
|
||||
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" class="text-white">
|
||||
<path d="M4 2.5v11l9-5.5z" />
|
||||
</svg>
|
||||
<IconPlay class="w-4 h-4 fill-current text-white" />
|
||||
</button>
|
||||
<template v-if="player.fmSong">
|
||||
<button @click.stop="player.toggleFm"
|
||||
class="w-10 h-10 flex items-center justify-center rounded-full bg-white/15 hover:bg-white/25 backdrop-blur-sm transition">
|
||||
<svg v-if="player.fmPlaying" width="18" height="18" viewBox="0 0 16 16" fill="currentColor" class="text-white">
|
||||
<rect x="3" y="2" width="3" height="12" rx="0.5" />
|
||||
<rect x="10" y="2" width="3" height="12" rx="0.5" />
|
||||
</svg>
|
||||
<svg v-else width="18" height="18" viewBox="0 0 16 16" fill="currentColor" class="text-white">
|
||||
<path d="M4 2.5v11l9-5.5z" />
|
||||
</svg>
|
||||
<IconPause v-if="player.fmPlaying" class="w-[18px] h-[18px] fill-current text-white" />
|
||||
<IconPlay v-else class="w-[18px] h-[18px] fill-current text-white" />
|
||||
</button>
|
||||
<button @click.stop="player.nextFm"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 backdrop-blur-sm transition">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="text-white"><polygon points="5 4 15 12 5 20 5 4"/><line x1="19" y1="5" x2="19" y2="19"/></svg>
|
||||
<IconSkipForward class="w-[14px] h-[14px] text-white" />
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 第二行:为你推荐(需登录) -->
|
||||
<div v-if="userStore.isLoggedIn && recPlaylists.length" class="mb-10">
|
||||
<h2 class="text-xl font-semibold mb-4">🎯 为你推荐</h2>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 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">
|
||||
<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">{{ pl.copywriter || '' }}</p>
|
||||
<!-- 推荐歌单(个性化,无需登录也可获取) -->
|
||||
<section class="mb-10">
|
||||
<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>
|
||||
|
||||
<!-- 新歌速递 -->
|
||||
<section class="mb-10">
|
||||
<SectionHeader
|
||||
:title="userStore.isLoggedIn ? '🆕 推荐新歌' : '🆕 新歌速递'"
|
||||
:subtitle="userStore.isLoggedIn ? '根据你的口味推荐' : '发现最新音乐'"
|
||||
:icon="IconTrendingUp"
|
||||
/>
|
||||
<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>
|
||||
|
||||
<!-- 第三行:热门歌单(排行榜) -->
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold mb-4">📈 热门歌单</h2>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<div v-for="pl in rankPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
|
||||
class="bg-subtle rounded-xl overflow-hidden hover:bg-muted transition cursor-pointer backdrop-blur-sm">
|
||||
<img :src="pl.coverImgUrl" class="w-full aspect-square object-cover" />
|
||||
<div class="p-3">
|
||||
<p class="text-sm font-medium truncate">{{ pl.name }}</p>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium truncate text-content flex items-center gap-1">
|
||||
<span class="truncate">{{ song.name }}</span>
|
||||
<span
|
||||
v-for="tag in getSongTags(song)"
|
||||
:key="tag.kind"
|
||||
class="text-[10px] leading-none px-1 py-0.5 rounded flex-shrink-0 font-medium"
|
||||
:class="tag.class"
|
||||
>{{ tag.label }}</span>
|
||||
</p>
|
||||
<p class="text-xs text-content-2 truncate">{{ getArtistDisplay(song) }}</p>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
<!-- 热门歌手 -->
|
||||
<section class="mb-10">
|
||||
<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>
|
||||
|
||||
<!-- 新碟上架 -->
|
||||
<section class="mb-10">
|
||||
<SectionHeader title="💿 新碟上架" subtitle="最新专辑" :icon="IconDisc" />
|
||||
<CardGrid
|
||||
:items="newAlbums"
|
||||
:loading="albumsLoading"
|
||||
:error="albumsError"
|
||||
error-text="新碟上架加载失败"
|
||||
@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>
|
||||
|
||||
<!-- 热门歌单(排行榜) -->
|
||||
<section>
|
||||
<SectionHeader title="📈 热门歌单" subtitle="排行榜精选" :icon="IconTrendingUp" />
|
||||
<CardGrid
|
||||
:items="rankPlaylists"
|
||||
:loading="rankLoading"
|
||||
:error="rankError"
|
||||
error-text="热门歌单加载失败"
|
||||
@retry="fetchRankPlaylists"
|
||||
>
|
||||
<PlaylistCard
|
||||
v-for="pl in rankPlaylists"
|
||||
: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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ref, onMounted, onActivated, watch, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { MusicApi, RecApi } from '../api';
|
||||
import { useUserStore } from '../stores/user';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { useUiStore } from '../stores/ui';
|
||||
import { pageCacheGet, pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
|
||||
import { useOnlineStatus } from '../composables/useOnlineStatus';
|
||||
import { getCoverUrl, getArtistDisplay, getSongTags, normalizeSong, type Song } from '../utils/song';
|
||||
import PlaylistCard, { type PlaylistCardData } from '../components/Card/PlaylistCard.vue';
|
||||
import ArtistCard, { type ArtistCardData } from '../components/Card/ArtistCard.vue';
|
||||
import AlbumCard, { type AlbumCardData } from '../components/Card/AlbumCard.vue';
|
||||
import SectionHeader from '../components/Card/SectionHeader.vue';
|
||||
import CardGrid from '../components/Card/CardGrid.vue';
|
||||
import IconRadio from '~icons/lucide/radio';
|
||||
import IconPlay from '~icons/lucide/play';
|
||||
import IconPause from '~icons/lucide/pause';
|
||||
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 player = usePlayerStore();
|
||||
const ui = useUiStore();
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const { isOnline } = useOnlineStatus();
|
||||
|
||||
const rankPlaylists = ref<any[]>([]);
|
||||
const recPlaylists = ref<any[]>([]);
|
||||
const todayStr = ref('');
|
||||
// --- 推荐歌单 ---
|
||||
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];
|
||||
|
||||
import { computed } from 'vue';
|
||||
// --- 新歌速递 ---
|
||||
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 fmCoverUrl = computed(() => {
|
||||
return player.fmSong?.al?.picUrl || player.fmSong?.album?.picUrl || '';
|
||||
});
|
||||
// --- 新碟上架 ---
|
||||
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 fmDisplayArtists = computed(() => {
|
||||
if (!player.fmSong) return '';
|
||||
return player.fmSong.ar?.map((a: any) => a.name).join(' / ') ||
|
||||
player.fmSong.artists?.map((a: any) => a.name).join(' / ') || '';
|
||||
return player.fmSong.ar?.map((a: { name: string }) => a.name).join(' / ') || '';
|
||||
});
|
||||
|
||||
|
||||
// 首次点击播放按钮:开始 FM 并播放
|
||||
async function startFmPlay() {
|
||||
if (!player.fmSong) {
|
||||
await player.loadFm();
|
||||
@@ -156,35 +300,188 @@ function onFmCardClick() {
|
||||
startFmPlay();
|
||||
return;
|
||||
}
|
||||
player.openRoamDrawer();
|
||||
ui.openRoamDrawer();
|
||||
}
|
||||
|
||||
// --- 数据获取 ---
|
||||
async function fetchRankPlaylists() {
|
||||
const cacheKey = 'home_rank';
|
||||
const cached = pageCacheGet(cacheKey);
|
||||
if (cached) {
|
||||
rankPlaylists.value = cached;
|
||||
return;
|
||||
}
|
||||
rankLoading.value = true;
|
||||
rankError.value = false;
|
||||
try {
|
||||
const results = await Promise.allSettled(
|
||||
RANK_IDS.map(id => MusicApi.getPlaylistDetail(id))
|
||||
);
|
||||
rankPlaylists.value = results
|
||||
.filter(r => r.status === 'fulfilled')
|
||||
.map((r: any) => {
|
||||
const data = JSON.parse(r.value);
|
||||
return data.playlist;
|
||||
})
|
||||
.filter(Boolean);
|
||||
pageCacheSet(cacheKey, rankPlaylists.value);
|
||||
} catch {
|
||||
rankError.value = true;
|
||||
} finally {
|
||||
rankLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRecPlaylists() {
|
||||
const cacheKey = 'home_rec';
|
||||
const cached = pageCacheGet(cacheKey);
|
||||
if (cached) {
|
||||
recPlaylists.value = cached;
|
||||
return;
|
||||
}
|
||||
recLoading.value = true;
|
||||
recError.value = false;
|
||||
try {
|
||||
// 已登录用 recommend_resource(更精准),未登录用 personalized
|
||||
if (userStore.isLoggedIn) {
|
||||
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);
|
||||
} catch {
|
||||
recError.value = true;
|
||||
} finally {
|
||||
recLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchNewSongs() {
|
||||
const cacheKey = 'home_new_songs';
|
||||
const cached = pageCacheGet(cacheKey);
|
||||
if (cached) {
|
||||
newSongs.value = cached;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTopArtists() {
|
||||
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 () => {
|
||||
const d = new Date();
|
||||
todayStr.value = `${d.getMonth() + 1}月${d.getDate()}日`;
|
||||
await loadData();
|
||||
});
|
||||
|
||||
// 排行榜
|
||||
const results = await Promise.allSettled(
|
||||
RANK_IDS.map(id => invoke('get_playlist_detail', { id }))
|
||||
);
|
||||
rankPlaylists.value = results
|
||||
.filter(r => r.status === 'fulfilled')
|
||||
.map((r: any) => {
|
||||
const data = JSON.parse(r.value);
|
||||
return data.playlist;
|
||||
})
|
||||
.filter(Boolean);
|
||||
onActivated(() => {
|
||||
if (pageCacheIsStale('home_rec') || pageCacheIsStale('home_new_songs')) loadData();
|
||||
});
|
||||
|
||||
// 推荐歌单(需登录)
|
||||
if (userStore.isLoggedIn) {
|
||||
try {
|
||||
const json = await invoke('recommend_resource');
|
||||
const data = JSON.parse(json as string);
|
||||
recPlaylists.value = data.recommend || [];
|
||||
} catch { }
|
||||
// 登录状态变化时清除首页缓存并重新加载(推荐内容依赖登录态)
|
||||
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) => {
|
||||
if (val && !old) {
|
||||
const hasAny = rankPlaylists.value.length || recPlaylists.value.length
|
||||
|| newSongs.value.length || topArtists.value.length || newAlbums.value.length;
|
||||
if (!hasAny) {
|
||||
['home_rank', 'home_rec', 'home_new_songs', 'home_top_artists', 'home_new_albums']
|
||||
.forEach(k => pageCacheInvalidate(k));
|
||||
loadData();
|
||||
} else {
|
||||
if (rankError.value) { pageCacheInvalidate('home_rank'); fetchRankPlaylists(); }
|
||||
if (recError.value) { pageCacheInvalidate('home_rec'); fetchRecPlaylists(); }
|
||||
if (newSongsError.value) { pageCacheInvalidate('home_new_songs'); fetchNewSongs(); }
|
||||
if (artistsError.value) { pageCacheInvalidate('home_top_artists'); fetchTopArtists(); }
|
||||
if (albumsError.value) { pageCacheInvalidate('home_new_albums'); fetchNewAlbums(); }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- 跳转 ---
|
||||
function goDaily() {
|
||||
router.push('/daily');
|
||||
}
|
||||
@@ -193,7 +490,61 @@ function goPlaylist(id: number) {
|
||||
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() {
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
<template>
|
||||
<div class="p-8 text-content">
|
||||
<PageHeader>
|
||||
<h1 class="text-2xl font-bold">本地音乐</h1>
|
||||
<span v-if="songs.length" class="text-xs text-content-3">{{ songs.length }} 首</span>
|
||||
<template #actions>
|
||||
<button @click="cycleSort" class="px-3 py-1 bg-muted hover:bg-emphasis rounded-full text-xs transition-all flex items-center justify-center gap-1 whitespace-nowrap">
|
||||
<IconArrowUpDown class="w-3 h-3" />
|
||||
{{ sortLabel }}
|
||||
</button>
|
||||
<button @click="refresh" class="px-3 py-1 bg-muted hover:bg-emphasis rounded-full text-xs transition">刷新</button>
|
||||
<button @click="showFolderModal = true" class="px-3 py-1 bg-muted hover:bg-emphasis rounded-full text-xs transition">扫描目录</button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div v-if="loading" 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 v-else-if="settings.localMusicFolders.length === 0" class="text-content-3 py-4">
|
||||
请先添加要扫描的文件夹
|
||||
</div>
|
||||
<div v-else-if="settings.enabledMusicPaths.length === 0" class="text-content-3 py-4">
|
||||
请至少启用一个扫描文件夹
|
||||
</div>
|
||||
<div v-else-if="songs.length === 0" class="text-content-3">
|
||||
当前文件夹下没有音乐文件,支持 mp3、flac、wav、ogg、aac、m4a 格式
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<SongListItem
|
||||
v-for="(song, index) in sortedSongs"
|
||||
:key="song.id + '-' + index"
|
||||
:song="sortedNormalized[index]"
|
||||
:index="index"
|
||||
:is-current="player.currentSong?.id === song.id"
|
||||
show-index
|
||||
show-duration
|
||||
show-playing-overlay
|
||||
:container-class="player.currentSong?.id === song.id ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
|
||||
@click="player.playFromList(sortedNormalized, index)"
|
||||
>
|
||||
<template #actions>
|
||||
<span class="text-xs text-content-3 flex-shrink-0">{{ formatFileSize(song.fileSize) }}</span>
|
||||
<div class="relative flex-shrink-0" :ref="(el: any) => menuRefs[song.id] = el">
|
||||
<button @click.stop="toggleMenu(song.id)" class="text-content-3 hover:text-content transition p-1 rounded-md hover:bg-subtle">
|
||||
<IconEllipsis class="w-4 h-4 fill-current" />
|
||||
</button>
|
||||
<div v-if="openMenuId === song.id"
|
||||
class="absolute right-0 top-full mt-1 bg-surface border border-line rounded-xl shadow-xl z-50 py-1 min-w-[140px]">
|
||||
<button @click.stop="openFolder(song.path)" class="w-full flex items-center gap-2 px-3 py-2 text-sm text-content-2 hover:bg-subtle hover:text-content transition whitespace-nowrap">
|
||||
<IconFolderOpen class="w-3.5 h-3.5" />
|
||||
打开所在文件夹
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SongListItem>
|
||||
</div>
|
||||
|
||||
<!-- 文件夹管理弹窗 -->
|
||||
<Transition name="fade">
|
||||
<div v-if="showFolderModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showFolderModal = false">
|
||||
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-[420px] p-6 select-auto">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold">扫描目录</h2>
|
||||
<button @click="showFolderModal = false" class="text-content-3 hover:text-content transition">
|
||||
<IconX class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="settings.localMusicFolders.length === 0" class="text-sm text-content-3 py-4 text-center">
|
||||
未添加任何文件夹
|
||||
</div>
|
||||
<div v-else class="space-y-1.5 max-h-60 overflow-y-auto mb-4">
|
||||
<div v-for="folder in settings.localMusicFolders" :key="folder.path" class="flex items-center gap-2 px-3 py-2 bg-subtle rounded-lg group">
|
||||
<button @click="settings.toggleLocalMusicFolder(folder.path)" class="flex-shrink-0" :title="folder.enabled ? '点击禁用' : '点击启用'">
|
||||
<IconCheckSquare v-if="folder.enabled" class="w-4 h-4 text-accent-text" />
|
||||
<IconSquare v-else class="w-4 h-4 text-content-4" />
|
||||
</button>
|
||||
<IconFolder class="w-4 h-4 text-content-3 flex-shrink-0" />
|
||||
<span class="text-sm truncate flex-1" :class="folder.enabled ? 'text-content-2' : 'text-content-4 line-through'" :title="folder.path">{{ folder.path }}</span>
|
||||
<button @click="settings.removeLocalMusicPath(folder.path)" class="text-content-4 hover:text-danger transition opacity-0 group-hover:opacity-100" title="移除">
|
||||
<IconX class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="addFolder" class="w-full py-2.5 rounded-lg bg-accent/15 text-accent-text hover:bg-accent/25 text-sm font-medium transition">
|
||||
添加文件夹
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onActivated, watch, onBeforeUnmount } from 'vue';
|
||||
import { AppApi, DownloadApi } from '../api';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { useSettingsStore } from '../stores/settings';
|
||||
import { pageCacheSet, pageCacheIsStale } from '../composables/usePageCache';
|
||||
import { formatFileSize, localSongToSong, fetchMissingCovers, type LocalSong } from '../composables/useLocalMusic';
|
||||
import { showToast } from '../composables/useToast';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import IconFolder from '~icons/lucide/folder';
|
||||
import IconFolderOpen from '~icons/lucide/folder-open';
|
||||
import IconX from '~icons/lucide/x';
|
||||
import IconArrowUpDown from '~icons/lucide/arrow-up-down';
|
||||
import IconCheckSquare from '~icons/lucide/check-square';
|
||||
import IconSquare from '~icons/lucide/square';
|
||||
import IconEllipsis from '~icons/lucide/ellipsis';
|
||||
|
||||
defineOptions({ name: 'LocalMusicView' });
|
||||
|
||||
const player = usePlayerStore();
|
||||
const settings = useSettingsStore();
|
||||
|
||||
const songs = ref<LocalSong[]>([]);
|
||||
const loading = ref(true);
|
||||
const showFolderModal = ref(false);
|
||||
|
||||
// 排序:点击循环切换
|
||||
type SortKey = 'default' | 'name' | 'size';
|
||||
const SORT_CYCLE: SortKey[] = ['default', 'name', 'size'];
|
||||
const SORT_LABELS: Record<SortKey, string> = { default: '默认', name: '名称', size: '大小' };
|
||||
const sortBy = ref<SortKey>('default');
|
||||
|
||||
const sortLabel = computed(() => SORT_LABELS[sortBy.value]);
|
||||
|
||||
function cycleSort() {
|
||||
const idx = SORT_CYCLE.indexOf(sortBy.value);
|
||||
sortBy.value = SORT_CYCLE[(idx + 1) % SORT_CYCLE.length];
|
||||
}
|
||||
|
||||
const sortedSongs = computed(() => {
|
||||
const list = [...songs.value];
|
||||
if (sortBy.value === 'name') {
|
||||
list.sort((a, b) => a.name.localeCompare(b.name, 'zh-Hans-CN'));
|
||||
} else if (sortBy.value === 'size') {
|
||||
list.sort((a, b) => b.fileSize - a.fileSize);
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
const sortedNormalized = computed(() => sortedSongs.value.map(localSongToSong));
|
||||
|
||||
// 三点菜单
|
||||
const openMenuId = ref<number | null>(null);
|
||||
const menuRefs: Record<number, HTMLElement | null> = {};
|
||||
|
||||
function toggleMenu(id: number) {
|
||||
openMenuId.value = openMenuId.value === id ? null : id;
|
||||
}
|
||||
|
||||
async function openFolder(path: string) {
|
||||
openMenuId.value = null;
|
||||
try {
|
||||
await AppApi.showItemInFolder(path);
|
||||
} catch (e: any) {
|
||||
showToast(e.toString(), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
if (openMenuId.value !== null) {
|
||||
const el = menuRefs[openMenuId.value];
|
||||
if (el && !el.contains(e.target as Node)) {
|
||||
openMenuId.value = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('click', onClickOutside));
|
||||
onBeforeUnmount(() => document.removeEventListener('click', onClickOutside));
|
||||
|
||||
async function addFolder() {
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: '选择音乐文件夹',
|
||||
});
|
||||
if (selected) {
|
||||
settings.addLocalMusicPath(selected);
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const paths = settings.enabledMusicPaths;
|
||||
if (paths.length === 0) {
|
||||
songs.value = [];
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const list = await DownloadApi.scanLocalFolders(paths);
|
||||
songs.value = list;
|
||||
pageCacheSet('localMusic', list);
|
||||
fetchMissingCovers(songs.value);
|
||||
} catch (e) {
|
||||
console.error('扫描本地音乐文件夹失败', e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refresh);
|
||||
|
||||
onActivated(() => {
|
||||
if (pageCacheIsStale('localMusic')) refresh();
|
||||
});
|
||||
|
||||
watch(() => settings.enabledMusicPaths, () => { refresh(); }, { deep: true });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user