mirror of
https://github.com/atdunbg/Nekosonic-Music.git
synced 2026-06-22 00:58:51 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dcfada6940 | |||
| 3535e2e8a0 | |||
| e40f82cc51 | |||
| 68f29c8ea8 | |||
| 57aa9dae61 | |||
| c275461015 | |||
| 6da544cffb |
74
CHANGELOG.md
74
CHANGELOG.md
@ -1,3 +1,77 @@
|
||||
## 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
|
||||
|
||||
### ✨ 新功能
|
||||
|
||||
142
README.md
142
README.md
@ -1,126 +1,102 @@
|
||||
<div align="center">
|
||||
|
||||
# Nekosonic
|
||||
|
||||
一款轻量的跨平台音乐播放器,支持 Windows / Linux / macOS,音源源自网易云音乐。
|
||||
轻量跨平台桌面音乐播放器 · 网易云音乐
|
||||
|
||||
## ✨ 特性
|
||||
[](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)
|
||||
|
||||
### 播放
|
||||
[下载安装](https://github.com/atdunbg/Nekosonic-Music/releases)
|
||||
|
||||
- 🎵 在线音乐播放,流式缓冲边下边播
|
||||
- 🎵 多音质选择(标准 / 较高 / 极高 HQ / 无损 SQ / Hi-Res)
|
||||
- 🔄 播放模式切换(列表循环 / 随机播放 / 单曲循环)
|
||||
- ⏯ 播放控制(播放 / 暂停 / 上一首 / 下一首 / 进度跳转 / 音量调节)
|
||||
- 📋 播放队列管理(查看队列 / 移除歌曲 / 清空队列)
|
||||
- 📻 私人漫游 FM(个性化推荐,VIP 试听自动跳过)
|
||||
- 🎵 本地音乐播放(支持 mp3 / flac / wav / ogg / aac / m4a / wma / opus)
|
||||
- 🔊 音频输出设备选择
|
||||
- 🎧 系统媒体控制(蓝牙耳机/键盘媒体键/系统面板,支持 Linux / Windows / macOS)
|
||||
---
|
||||
|
||||
### 发现与浏览
|
||||
</div>
|
||||
|
||||
- 🔍 关键词搜索歌曲 + 热门搜索标签
|
||||
- 📋 歌单浏览(推荐歌单 / 排行榜 / 用户歌单 / 收藏歌单)
|
||||
- 📋 歌单详情(歌曲列表 + 收藏 / 取消收藏 + 歌单评论)
|
||||
- 🎤 歌手详情(热门歌曲 / 专辑 / 简介)
|
||||
- 💿 专辑详情(歌曲列表 + 播放全部)
|
||||
- 📅 每日推荐歌曲
|
||||
## 🎵 播放
|
||||
|
||||
### 歌词与评论
|
||||
- 多音质选择(标准 / 较高 / HQ / SQ / Hi-Res)
|
||||
- 私人漫游 FM(个性化推荐)
|
||||
- 系统媒体控制集成(MPRIS / SMTC / Now Playing)
|
||||
- 音频输出设备选择
|
||||
|
||||
- 🎤 实时滚动歌词(自动滚动 / 点击跳转 / 渐变透明度)
|
||||
- 🎤 歌词翻译显示
|
||||
- 🎤 全屏漫游模式(大封面 + 歌词 / 评论双标签页)
|
||||
- 💬 歌曲评论查看(热门评论 + 无限滚动加载 + 点赞)
|
||||
## 🔍 发现
|
||||
|
||||
### 收藏与下载
|
||||
- 关键词搜索(歌曲 / 歌手 / 专辑)+ 搜索建议 + 热门搜索
|
||||
- 歌单浏览(推荐 / 排行榜 / 用户 / 收藏)
|
||||
- 歌手详情(热门歌曲 / 专辑 / 简介 + 关注)
|
||||
- 专辑详情
|
||||
- 每日推荐歌曲
|
||||
|
||||
- ❤️ 一键喜欢 / 取消喜欢(同步到网易云账号)
|
||||
- ⬇️ 歌曲下载(带进度显示 / VIP 拦截 / 元数据保存)
|
||||
- 🎵 本地音乐管理(列出 / 播放 / 删除 / 音频元数据与封面读取)
|
||||
- 🕐 本地播放历史记录(最多 200 首)
|
||||
## 🎤 歌词与评论
|
||||
|
||||
### 账号
|
||||
- 实时滚动歌词(ease-out 缓动 / 点击跳转 / 渐变透明度)
|
||||
- 歌词翻译
|
||||
- 全屏漫游模式(封面主色提取 + 歌词/评论双标签)
|
||||
- 歌曲评论(无限滚动 + 点赞)
|
||||
|
||||
- 🔴 网易云账号登录(二维码扫码 / 手机号密码)
|
||||
- 🔑 登录态持久化(重启后自动恢复)
|
||||
## ❤️ 收藏与下载
|
||||
|
||||
### 系统与设置
|
||||
- 一键喜欢 / 取消喜欢(同步到网易云账号)
|
||||
- 歌曲下载
|
||||
- 音乐云盘(上传 / 删除 / 详情 / 存储空间 / 上传进度)
|
||||
- 本地音乐(多文件夹扫描 / 封面补全)
|
||||
- 下载音乐(独立管理 / 删除)
|
||||
|
||||
- 📡 系统托盘(播放控制 / 显示窗口 / 退出)
|
||||
- 🛡 单实例运行(防止重复启动)
|
||||
- ⌨️ 自定义快捷键(应用内 + 系统全局)
|
||||
- 🎨 多主题切换(天蓝 / 翠绿 / 玫红 / 紫罗兰 / 橙色 / 青色 / 粉色)
|
||||
- ⚙️ 关闭窗口行为设置(每次询问 / 最小化到托盘 / 直接退出)
|
||||
- 🔄 自动更新(启动静默检测 + 自定义弹窗 + 忽略版本 + 下载进度)
|
||||
- 📝 更新日志查看
|
||||
- 📶 网络状态检测(断网/恢复 Toast 提示 + 自动重试加载)
|
||||
## 🎨 个性化
|
||||
|
||||
## 📦️ 安装
|
||||
- 多主题色(天蓝 / 翠绿 / 玫红 / 紫罗兰 / 橙色 / 青色 / 粉色)
|
||||
- 自定义快捷键(应用内 + 系统全局)
|
||||
- 关闭行为设置
|
||||
- 自动更新
|
||||
|
||||
访问本项目的 [Releases](https://github.com/atdunbg/Nekosonic-Music/releases) 页面下载安装包。
|
||||
---
|
||||
|
||||
## 💻 配置开发环境
|
||||
## 安装
|
||||
|
||||
前往 [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 |
|
||||
| 音频解码 | symphonia + ringbuf (Rust) |
|
||||
| 媒体控制 | souvlaki (Linux MPRIS / Windows SMTC / macOS Now Playing) |
|
||||
| 前端 | 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] 歌词翻译
|
||||
- [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 框架
|
||||
- [symphonia](https://crates.io/crates/symphonia) — Rust 纯音频解码库
|
||||
- [souvlaki](https://crates.io/crates/souvlaki) — 跨平台 OS 媒体控制库
|
||||
本项目仅供个人学习研究使用,禁止用于商业及非法用途。基于 [MIT License](https://opensource.org/licenses/MIT) 开源。
|
||||
|
||||
728
package-lock.json
generated
728
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "nekosonic",
|
||||
"version": "0.3.0",
|
||||
"version": "0.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "nekosonic",
|
||||
"version": "0.3.0",
|
||||
"version": "0.6.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
@ -14,21 +14,19 @@
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"axios": "^1.16.0",
|
||||
"howler": "^2.2.4",
|
||||
"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",
|
||||
@ -538,6 +536,16 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@iconify-json/lucide": {
|
||||
"version": "1.2.110",
|
||||
"resolved": "https://registry.npmmirror.com/@iconify-json/lucide/-/lucide-1.2.110.tgz",
|
||||
"integrity": "sha512-rLeHqnZZBxZbprbVwf6uY7HB5GkGVgvT9VujhjvaUEqFDLKZON6zR8K1f8uD1brBwf5TJ0TIvvW8mz5u2XJU+w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@iconify/types": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@iconify/types": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/@iconify/types/-/types-2.0.0.tgz",
|
||||
@ -546,15 +554,15 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@iconify/utils": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/@iconify/utils/-/utils-3.1.1.tgz",
|
||||
"integrity": "sha512-MwzoDtw9rO1x+qfgLTV/IVXsHDBqeYZoMIQC8SfxfYSlaSUG+oWiAcoiB1yajAda6mqblm4/1/w2E8tRu7a7Tw==",
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/@iconify/utils/-/utils-3.1.3.tgz",
|
||||
"integrity": "sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@antfu/install-pkg": "^1.1.0",
|
||||
"@iconify/types": "^2.0.0",
|
||||
"mlly": "^1.8.2"
|
||||
"import-meta-resolve": "^4.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
@ -606,40 +614,6 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt/kit": {
|
||||
"version": "3.21.4",
|
||||
"resolved": "https://registry.npmmirror.com/@nuxt/kit/-/kit-3.21.4.tgz",
|
||||
"integrity": "sha512-XDWhQJsA5hpdFpVSmImQIVXcsANJI07TjT1LZC/AUKJxl/dcM52Rq4uU+b3uqyVl4LZR1fODSDEzLxcdXq4Rmg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"c12": "^3.3.4",
|
||||
"consola": "^3.4.2",
|
||||
"defu": "^6.1.7",
|
||||
"destr": "^2.0.5",
|
||||
"errx": "^0.1.0",
|
||||
"exsolve": "^1.0.8",
|
||||
"ignore": "^7.0.5",
|
||||
"jiti": "^2.6.1",
|
||||
"klona": "^2.0.6",
|
||||
"knitwork": "^1.3.0",
|
||||
"mlly": "^1.8.2",
|
||||
"ohash": "^2.0.11",
|
||||
"pathe": "^2.0.3",
|
||||
"pkg-types": "^2.3.1",
|
||||
"rc9": "^3.0.1",
|
||||
"scule": "^1.3.0",
|
||||
"semver": "^7.7.4",
|
||||
"tinyglobby": "^0.2.16",
|
||||
"ufo": "^1.6.4",
|
||||
"unctx": "^2.5.0",
|
||||
"untyped": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz",
|
||||
@ -1561,20 +1535,6 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/web-bluetooth": {
|
||||
"version": "0.0.21",
|
||||
"resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
|
||||
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vicons/ionicons5": {
|
||||
"version": "0.13.0",
|
||||
"resolved": "https://registry.npmmirror.com/@vicons/ionicons5/-/ionicons5-0.13.0.tgz",
|
||||
"integrity": "sha512-zvZKBPjEXKN7AXNo2Na2uy+nvuv6SP4KAMQxpKL2vfHMj0fSvuw7JZcOPCjQC3e7ayssKnaoFVAhbYcW6v41qQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-vue": {
|
||||
"version": "5.2.4",
|
||||
"resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
|
||||
@ -1787,68 +1747,6 @@
|
||||
"integrity": "sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vueuse/core": {
|
||||
"version": "13.9.0",
|
||||
"resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-13.9.0.tgz",
|
||||
"integrity": "sha512-ts3regBQyURfCE2BcytLqzm8+MmLlo5Ln/KLoxDVcsZ2gzIwVNnQpQOL/UKV8alUqjSZOlpFZcRNsLRqj+OzyA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/web-bluetooth": "^0.0.21",
|
||||
"@vueuse/metadata": "13.9.0",
|
||||
"@vueuse/shared": "13.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/metadata": {
|
||||
"version": "13.9.0",
|
||||
"resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-13.9.0.tgz",
|
||||
"integrity": "sha512-1AFRvuiGphfF7yWixZa0KwjYH8ulyjDCC0aFgrGRz8+P4kvDFSdXLVfTk5xAN9wEuD1J6z4/myMoYbnHoX07zg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/motion": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/@vueuse/motion/-/motion-3.0.3.tgz",
|
||||
"integrity": "sha512-4B+ITsxCI9cojikvrpaJcLXyq0spj3sdlzXjzesWdMRd99hhtFI6OJ/1JsqwtF73YooLe0hUn/xDR6qCtmn5GQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vueuse/core": "^13.0.0",
|
||||
"@vueuse/shared": "^13.0.0",
|
||||
"defu": "^6.1.4",
|
||||
"framesync": "^6.1.2",
|
||||
"popmotion": "^11.0.5",
|
||||
"style-value-types": "^5.1.2"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@nuxt/kit": "^3.13.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": ">=3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/shared": {
|
||||
"version": "13.9.0",
|
||||
"resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-13.9.0.tgz",
|
||||
"integrity": "sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz",
|
||||
@ -1893,23 +1791,6 @@
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmmirror.com/axios/-/axios-1.16.0.tgz",
|
||||
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.16.0",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
@ -1936,57 +1817,6 @@
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/c12": {
|
||||
"version": "3.3.4",
|
||||
"resolved": "https://registry.npmmirror.com/c12/-/c12-3.3.4.tgz",
|
||||
"integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"chokidar": "^5.0.0",
|
||||
"confbox": "^0.2.4",
|
||||
"defu": "^6.1.6",
|
||||
"dotenv": "^17.3.1",
|
||||
"exsolve": "^1.0.8",
|
||||
"giget": "^3.2.0",
|
||||
"jiti": "^2.6.1",
|
||||
"ohash": "^2.0.11",
|
||||
"pathe": "^2.0.3",
|
||||
"perfect-debounce": "^2.1.0",
|
||||
"pkg-types": "^2.3.0",
|
||||
"rc9": "^3.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"magicast": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"magicast": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/c12/node_modules/perfect-debounce": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
|
||||
"integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/camelcase/-/camelcase-5.3.1.tgz",
|
||||
@ -1996,34 +1826,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-5.0.0.tgz",
|
||||
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"readdirp": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/citty": {
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmmirror.com/citty/-/citty-0.1.6.tgz",
|
||||
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"consola": "^3.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/cliui/-/cliui-6.0.0.tgz",
|
||||
@ -2053,18 +1855,6 @@
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/confbox": {
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.2.4.tgz",
|
||||
@ -2072,17 +1862,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/consola": {
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmmirror.com/consola/-/consola-3.4.2.tgz",
|
||||
"integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": "^14.18.0 || >=16.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/copy-anything": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmmirror.com/copy-anything/-/copy-anything-4.0.5.tgz",
|
||||
@ -2120,30 +1899,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/defu": {
|
||||
"version": "6.1.7",
|
||||
"resolved": "https://registry.npmmirror.com/defu/-/defu-6.1.7.tgz",
|
||||
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/destr": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmmirror.com/destr/-/destr-2.0.5.tgz",
|
||||
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@ -2160,34 +1915,6 @@
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.4.2",
|
||||
"resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-17.4.2.tgz",
|
||||
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
@ -2220,59 +1947,6 @@
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/errx": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/errx/-/errx-0.1.0.tgz",
|
||||
"integrity": "sha512-fZmsRiDNv07K6s2KkKFTiD2aIvECa7++PKyD5NC32tpRw46qZA3sOz+aM+/V9V0GDHxVTKLziveV4JhzBHDp9Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz",
|
||||
@ -2359,52 +2033,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/framesync": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/framesync/-/framesync-6.1.2.tgz",
|
||||
"integrity": "sha512-jBTqhX6KaQVDyus8muwZbBeGGP0XgujBRbQ7gM7BRdS3CadCZIHiawyzYLnafYcvZIh5j8WE7cxZKFn7dXhu9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@ -2420,15 +2048,6 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
@ -2438,66 +2057,6 @@
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/giget": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/giget/-/giget-3.2.0.tgz",
|
||||
"integrity": "sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"giget": "dist/cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
@ -2505,45 +2064,6 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.3.tgz",
|
||||
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/he": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz",
|
||||
@ -2554,34 +2074,21 @@
|
||||
"he": "bin/he"
|
||||
}
|
||||
},
|
||||
"node_modules/hey-listen": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmmirror.com/hey-listen/-/hey-listen-1.0.8.tgz",
|
||||
"integrity": "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/hookable": {
|
||||
"version": "5.5.3",
|
||||
"resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz",
|
||||
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/howler": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmmirror.com/howler/-/howler-2.2.4.tgz",
|
||||
"integrity": "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmmirror.com/ignore/-/ignore-7.0.5.tgz",
|
||||
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
|
||||
"node_modules/import-meta-resolve": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz",
|
||||
"integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
@ -2615,25 +2122,6 @@
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/klona": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmmirror.com/klona/-/klona-2.0.6.tgz",
|
||||
"integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/knitwork": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/knitwork/-/knitwork-1.3.0.tgz",
|
||||
"integrity": "sha512-4LqMNoONzR43B1W0ek0fhXMsDNW/zxa1NdFAVMY+k28pgZLovR4G3PB5MrpTxCy1QaZCqNoiaKPr5w5qZHfSNw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
@ -2934,36 +2422,6 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz",
|
||||
@ -3054,14 +2512,6 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ohash": {
|
||||
"version": "2.0.11",
|
||||
"resolved": "https://registry.npmmirror.com/ohash/-/ohash-2.0.11.tgz",
|
||||
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz",
|
||||
@ -3195,19 +2645,6 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/popmotion": {
|
||||
"version": "11.0.5",
|
||||
"resolved": "https://registry.npmmirror.com/popmotion/-/popmotion-11.0.5.tgz",
|
||||
"integrity": "sha512-la8gPM1WYeFznb/JqF4GiTkRRPZsfaj2+kCxqQgr2MJylMmIKUwBfWW8Wa5fml/8gmtlD5yI01MP1QCZPWmppA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"framesync": "6.1.2",
|
||||
"hey-listen": "^1.0.8",
|
||||
"style-value-types": "5.1.2",
|
||||
"tslib": "2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.14",
|
||||
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.14.tgz",
|
||||
@ -3236,15 +2673,6 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmmirror.com/qrcode/-/qrcode-1.5.4.tgz",
|
||||
@ -3279,33 +2707,6 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rc9": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/rc9/-/rc9-3.0.1.tgz",
|
||||
"integrity": "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"defu": "^6.1.6",
|
||||
"destr": "^2.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-5.0.0.tgz",
|
||||
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz",
|
||||
@ -3372,28 +2773,6 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/scule": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/scule/-/scule-1.3.0.tgz",
|
||||
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
@ -3444,17 +2823,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/style-value-types": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/style-value-types/-/style-value-types-5.1.2.tgz",
|
||||
"integrity": "sha512-Vs9fNreYF9j6W2VvuDTP7kepALi7sk0xtk2Tu8Yxi9UoajJdEVpNpCov0HsLTqXvNGKX+Uv09pkozVITi1jf3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hey-listen": "^1.0.8",
|
||||
"tslib": "2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/superjson": {
|
||||
"version": "2.2.6",
|
||||
"resolved": "https://registry.npmmirror.com/superjson/-/superjson-2.2.6.tgz",
|
||||
@ -3520,7 +2888,8 @@
|
||||
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.4.0.tgz",
|
||||
"integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.6.3",
|
||||
@ -3544,31 +2913,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unctx": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/unctx/-/unctx-2.5.0.tgz",
|
||||
"integrity": "sha512-p+Rz9x0R7X+CYDkT+Xg8/GhpcShTlU8n+cf9OtOEf7zEQsNcCZO1dPKNRDqvUTaq+P32PMMkxWHwfrxkqfqAYg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"acorn": "^8.15.0",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21",
|
||||
"unplugin": "^2.3.11"
|
||||
}
|
||||
},
|
||||
"node_modules/unctx/node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.19.2",
|
||||
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.19.2.tgz",
|
||||
@ -3629,24 +2973,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/untyped": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/untyped/-/untyped-2.0.0.tgz",
|
||||
"integrity": "sha512-nwNCjxJTjNuLCgFr42fEak5OcLuB3ecca+9ksPFNvtfYSLpjf+iJqSIaSnIile6ZPbKYxI5k2AfXqeopGudK/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"citty": "^0.1.6",
|
||||
"defu": "^6.1.4",
|
||||
"jiti": "^2.4.2",
|
||||
"knitwork": "^1.2.0",
|
||||
"scule": "^1.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"untyped": "dist/cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.4.2",
|
||||
"resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.2.tgz",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "nekosonic",
|
||||
"private": true,
|
||||
"version": "0.3.0",
|
||||
"version": "0.7.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@ -16,21 +16,19 @@
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"axios": "^1.16.0",
|
||||
"howler": "^2.2.4",
|
||||
"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",
|
||||
|
||||
471
src-tauri/Cargo.lock
generated
471
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "Nekosonic"
|
||||
version = "0.5.0"
|
||||
version = "0.7.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 = [] }
|
||||
@ -33,6 +33,7 @@ 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"] }
|
||||
@ -49,3 +50,9 @@ 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"
|
||||
|
||||
@ -2,6 +2,7 @@ use ncm_api_rs::{create_client, ApiClient, Query};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use tauri::{Manager, State, Emitter};
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
@ -44,14 +45,14 @@ use base64::Engine;
|
||||
/// ```
|
||||
macro_rules! api_call {
|
||||
($state:expr, $method:ident) => {{
|
||||
let client = $state.client.lock().unwrap().clone();
|
||||
let client = $state.client.lock().await.clone();
|
||||
let q = $state.build_query();
|
||||
client.$method(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
}};
|
||||
($state:expr, $method:ident, params: [$(($key:expr, $val:expr)),* $(,)?]) => {{
|
||||
let client = $state.client.lock().unwrap().clone();
|
||||
let client = $state.client.lock().await.clone();
|
||||
let mut q = $state.build_query();
|
||||
$(q = q.param($key, $val);)*
|
||||
client.$method(&q).await
|
||||
@ -59,7 +60,7 @@ macro_rules! api_call {
|
||||
.map_err(|e| e.to_string())
|
||||
}};
|
||||
($state:expr, $method:ident, query: $q:expr) => {{
|
||||
let client = $state.client.lock().unwrap().clone();
|
||||
let client = $state.client.lock().await.clone();
|
||||
client.$method(&$q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
@ -67,7 +68,7 @@ macro_rules! api_call {
|
||||
}
|
||||
|
||||
pub struct ApiController {
|
||||
client: StdMutex<ApiClient>,
|
||||
client: AsyncMutex<ApiClient>,
|
||||
cookie: StdMutex<Option<String>>,
|
||||
cookie_path: PathBuf,
|
||||
}
|
||||
@ -94,7 +95,7 @@ impl ApiController {
|
||||
|
||||
let client = create_client(None);
|
||||
ApiController {
|
||||
client: StdMutex::new(client),
|
||||
client: AsyncMutex::new(client),
|
||||
cookie: StdMutex::new(saved_cookie),
|
||||
cookie_path,
|
||||
}
|
||||
@ -111,18 +112,31 @@ impl ApiController {
|
||||
query
|
||||
}
|
||||
/// 将 Cookie 字符串持久化到本地文件并同步到 API 客户端
|
||||
fn save_cookie(&self, cookie_str: &str) {
|
||||
async fn save_cookie(&self, cookie_str: &str) {
|
||||
let _ = fs::write(&self.cookie_path, cookie_str);
|
||||
if let Ok(mut client) = self.client.lock() {
|
||||
let mut client = self.client.lock().await;
|
||||
client.set_cookie(cookie_str.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 搜索查询参数
|
||||
#[derive(Deserialize)]
|
||||
pub struct SearchQuery { pub keyword: String }
|
||||
|
||||
/// 多类型搜索查询参数
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CloudSearchQuery {
|
||||
pub keyword: String,
|
||||
pub search_type: Option<i64>,
|
||||
pub limit: Option<i64>,
|
||||
pub offset: Option<i64>,
|
||||
}
|
||||
|
||||
/// 搜索建议查询参数
|
||||
#[derive(Deserialize)]
|
||||
pub struct SearchSuggestQuery { pub keyword: String }
|
||||
|
||||
/// 手机号登录查询参数
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginQuery { pub phone: String, pub password: String }
|
||||
@ -137,6 +151,23 @@ pub async fn search_songs(query: SearchQuery, state: State<'_, ApiController>) -
|
||||
api_call!(state, cloudsearch, params: [("keywords", &query.keyword), ("type", "1"), ("limit", "30")])
|
||||
}
|
||||
|
||||
/// 多类型搜索(歌曲/歌手/专辑)
|
||||
#[tauri::command]
|
||||
pub async fn cloudsearch(query: CloudSearchQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
api_call!(state, cloudsearch, params: [
|
||||
("keywords", &query.keyword),
|
||||
("type", &query.search_type.unwrap_or(1).to_string()),
|
||||
("limit", &query.limit.unwrap_or(30).to_string()),
|
||||
("offset", &query.offset.unwrap_or(0).to_string())
|
||||
])
|
||||
}
|
||||
|
||||
/// 搜索建议
|
||||
#[tauri::command]
|
||||
pub async fn search_suggest(query: SearchSuggestQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
api_call!(state, search_suggest, params: [("keywords", &query.keyword)])
|
||||
}
|
||||
|
||||
/// 获取热搜词列表
|
||||
#[tauri::command]
|
||||
pub async fn get_hot_search(state: State<'_, ApiController>) -> Result<String, String> {
|
||||
@ -160,7 +191,7 @@ pub struct SongUrlQuery { pub id: u64, pub level: Option<String>, pub fm_mode: O
|
||||
/// 获取歌曲播放地址(返回完整 data 对象,包含 url、freeTrialInfo 等)
|
||||
#[tauri::command]
|
||||
pub async fn get_song_url(query: SongUrlQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().unwrap().clone();
|
||||
let client = state.client.lock().await.clone();
|
||||
let level = query.level.as_deref().unwrap_or("standard");
|
||||
|
||||
let resp = if query.fm_mode.unwrap_or(false) {
|
||||
@ -220,7 +251,7 @@ pub async fn get_playlist_detail(id: u64, state: State<'_, ApiController>) -> Re
|
||||
/// 手机号密码登录
|
||||
#[tauri::command]
|
||||
pub async fn login(query: LoginQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().unwrap().clone();
|
||||
let client = state.client.lock().await.clone();
|
||||
let q = Query::new()
|
||||
.param("phone", &query.phone)
|
||||
.param("password", &query.password);
|
||||
@ -229,7 +260,7 @@ pub async fn login(query: LoginQuery, state: State<'_, ApiController>) -> Result
|
||||
if !resp.cookie.is_empty() {
|
||||
let cookie_str = cookies_to_key_values(&resp.cookie);
|
||||
*state.cookie.lock().map_err(|e| e.to_string())? = Some(cookie_str.clone());
|
||||
state.save_cookie(&cookie_str);
|
||||
state.save_cookie(&cookie_str).await;
|
||||
}
|
||||
|
||||
Ok(resp.body.to_string())
|
||||
@ -246,7 +277,7 @@ pub async fn logout(state: State<'_, ApiController>) -> Result<(), String> {
|
||||
/// 获取二维码登录密钥
|
||||
#[tauri::command]
|
||||
pub async fn get_qr_key(state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().unwrap().clone();
|
||||
let client = state.client.lock().await.clone();
|
||||
let q = state.build_query();
|
||||
let resp = client.login_qr_key(&q).await.map_err(|e| e.to_string())?;
|
||||
resp.body["unikey"]
|
||||
@ -261,7 +292,7 @@ pub async fn create_qr(
|
||||
query: QrKeyQuery,
|
||||
state: State<'_, ApiController>,
|
||||
) -> Result<String, String> {
|
||||
let client = state.client.lock().unwrap().clone();
|
||||
let client = state.client.lock().await.clone();
|
||||
let q = state
|
||||
.build_query()
|
||||
.param("key", &query.key)
|
||||
@ -277,13 +308,13 @@ pub async fn create_qr(
|
||||
/// 检查二维码扫码状态
|
||||
#[tauri::command]
|
||||
pub async fn check_qr_status(query: QrKeyQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().unwrap().clone();
|
||||
let client = state.client.lock().await.clone();
|
||||
let q = state.build_query().param("key", &query.key);
|
||||
let resp = client.login_qr_check(&q).await.map_err(|e| e.to_string())?;
|
||||
if resp.body["code"].as_u64() == Some(803) && !resp.cookie.is_empty() {
|
||||
let cookie_str = cookies_to_key_values(&resp.cookie);
|
||||
*state.cookie.lock().map_err(|e| e.to_string())? = Some(cookie_str.clone());
|
||||
state.save_cookie(&cookie_str);
|
||||
state.save_cookie(&cookie_str).await;
|
||||
}
|
||||
Ok(resp.body.to_string())
|
||||
}
|
||||
@ -312,12 +343,102 @@ pub async fn recommend_resource(state: State<'_, ApiController>) -> Result<Strin
|
||||
api_call!(state, recommend_resource)
|
||||
}
|
||||
|
||||
/// 私人漫游模式查询参数
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PersonalFmModeQuery {
|
||||
pub mode: Option<String>,
|
||||
pub sub_mode: Option<String>,
|
||||
pub limit: Option<i64>,
|
||||
}
|
||||
|
||||
/// 私人漫游(带模式)
|
||||
#[tauri::command]
|
||||
pub async fn personal_fm_mode(query: PersonalFmModeQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
api_call!(state, personal_fm_mode, params: [
|
||||
("mode", query.mode.as_deref().unwrap_or("DEFAULT")),
|
||||
("submode", query.sub_mode.as_deref().unwrap_or("")),
|
||||
("limit", &query.limit.unwrap_or(3).to_string())
|
||||
])
|
||||
}
|
||||
|
||||
/// FM 不喜欢查询参数
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FmTrashQuery {
|
||||
pub id: u64,
|
||||
pub time: Option<i64>,
|
||||
}
|
||||
|
||||
/// FM 不喜欢(减少推荐)
|
||||
#[tauri::command]
|
||||
pub async fn fm_trash(query: FmTrashQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
api_call!(state, fm_trash, params: [
|
||||
("id", &query.id.to_string()),
|
||||
("time", &query.time.unwrap_or(25).to_string())
|
||||
])
|
||||
}
|
||||
|
||||
/// 获取私人漫游歌曲
|
||||
#[tauri::command]
|
||||
pub async fn personal_fm(state: State<'_, ApiController>) -> Result<String, String> {
|
||||
api_call!(state, personal_fm)
|
||||
}
|
||||
|
||||
/// 听歌打卡查询参数
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ScrobbleQuery {
|
||||
pub id: u64,
|
||||
pub sourceid: Option<String>,
|
||||
pub time: u64,
|
||||
pub alg: Option<String>,
|
||||
pub source: Option<String>,
|
||||
pub bitrate: Option<u64>,
|
||||
}
|
||||
|
||||
/// 听歌打卡
|
||||
#[tauri::command]
|
||||
pub async fn scrobble(query: ScrobbleQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await.clone();
|
||||
let cookie = state.cookie.lock().ok().and_then(|g| g.clone()).unwrap_or_default();
|
||||
let option = ncm_api_rs::request::RequestOption {
|
||||
crypto: ncm_api_rs::request::CryptoType::Weapi,
|
||||
cookie: Some(cookie),
|
||||
ua: None,
|
||||
proxy: None,
|
||||
real_ip: None,
|
||||
random_cn_ip: false,
|
||||
e_r: None,
|
||||
domain: None,
|
||||
check_token: false,
|
||||
};
|
||||
let data = json!({
|
||||
"logs": serde_json::to_string(&json!([{
|
||||
"action": "play",
|
||||
"json": {
|
||||
"download": 0,
|
||||
"end": "playend",
|
||||
"id": query.id.to_string(),
|
||||
"sourceId": query.sourceid.as_deref().unwrap_or(""),
|
||||
"time": query.time as i64,
|
||||
"type": "song",
|
||||
"wifi": 0,
|
||||
"source": query.source.as_deref().unwrap_or("list"),
|
||||
"alg": query.alg.as_deref().unwrap_or(""),
|
||||
"bitrate": query.bitrate.unwrap_or(0),
|
||||
"mainsite": 1,
|
||||
"content": ""
|
||||
}
|
||||
}])).unwrap_or_default()
|
||||
});
|
||||
let result = client.request("/api/feedback/weblog", data.clone(), option)
|
||||
.await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string());
|
||||
result
|
||||
}
|
||||
|
||||
/// 获取歌曲详情
|
||||
#[tauri::command]
|
||||
pub async fn get_song_detail(id: String, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
@ -418,7 +539,7 @@ pub async fn download_song(
|
||||
let q = state.build_query()
|
||||
.param("id", &query.id.to_string())
|
||||
.param("level", level);
|
||||
let client = state.client.lock().unwrap().clone();
|
||||
let client = state.client.lock().await.clone();
|
||||
let resp = client.song_url_v1(&q).await.map_err(|e| e.to_string())?;
|
||||
let data = &resp.body["data"][0];
|
||||
let url = data["url"].as_str().filter(|s| !s.is_empty());
|
||||
@ -500,18 +621,17 @@ pub async fn download_song(
|
||||
Ok(filename)
|
||||
}
|
||||
|
||||
/// 列出本地已下载的歌曲,优先使用元数据文件补充信息
|
||||
#[tauri::command]
|
||||
pub fn list_local_songs(app_handle: tauri::AppHandle, download_path: Option<String>) -> Result<Vec<LocalSongInfo>, String> {
|
||||
let download_dir = resolve_download_dir(&app_handle, download_path.as_deref());
|
||||
if !download_dir.exists() {
|
||||
/// 扫描指定目录下的音频文件,优先使用元数据文件补充信息
|
||||
/// `downloaded_only` 为 true 时,只返回有对应 .json 元数据的文件(即通过应用下载的)
|
||||
fn scan_dir_for_songs(dir: &PathBuf, downloaded_only: bool) -> Result<Vec<LocalSongInfo>, String> {
|
||||
if !dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let audio_exts = ["mp3", "flac", "wav", "ogg", "aac", "m4a", "wma", "opus"];
|
||||
|
||||
let mut meta_map: std::collections::HashMap<String, serde_json::Value> = std::collections::HashMap::new();
|
||||
let entries = fs::read_dir(&download_dir).map_err(|e| format!("读取目录失败: {}", e))?;
|
||||
let entries = fs::read_dir(dir).map_err(|e| format!("读取目录失败: {}", e))?;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
@ -527,7 +647,7 @@ pub fn list_local_songs(app_handle: tauri::AppHandle, download_path: Option<Stri
|
||||
}
|
||||
|
||||
let mut songs: Vec<LocalSongInfo> = Vec::new();
|
||||
let entries = fs::read_dir(&download_dir).map_err(|e| format!("读取目录失败: {}", e))?;
|
||||
let entries = fs::read_dir(dir).map_err(|e| format!("读取目录失败: {}", e))?;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
@ -539,6 +659,11 @@ pub fn list_local_songs(app_handle: tauri::AppHandle, download_path: Option<Stri
|
||||
let filename = path.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||||
let file_size = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
|
||||
|
||||
// 下载音乐模式:只显示有 .json 元数据的文件
|
||||
if downloaded_only && !meta_map.contains_key(&filename) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (title, artist, album, duration_ms, cover_b64) = read_audio_metadata(&path);
|
||||
|
||||
if let Some(meta) = meta_map.get(&filename) {
|
||||
@ -596,6 +721,36 @@ pub fn list_local_songs(app_handle: tauri::AppHandle, download_path: Option<Stri
|
||||
Ok(songs)
|
||||
}
|
||||
|
||||
/// 列出本地已下载的歌曲(下载目录)
|
||||
#[tauri::command]
|
||||
pub async fn list_local_songs(app_handle: tauri::AppHandle, download_path: Option<String>) -> Result<Vec<LocalSongInfo>, String> {
|
||||
let download_dir = resolve_download_dir(&app_handle, download_path.as_deref());
|
||||
tokio::task::spawn_blocking(move || {
|
||||
scan_dir_for_songs(&download_dir, true) // 只显示下载的歌曲
|
||||
}).await.map_err(|e| format!("扫描任务失败: {}", e))?
|
||||
}
|
||||
|
||||
/// 扫描多个本地文件夹中的音频文件
|
||||
#[tauri::command]
|
||||
pub async fn scan_local_folders(paths: Vec<String>) -> Result<Vec<LocalSongInfo>, String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut all_songs: Vec<LocalSongInfo> = Vec::new();
|
||||
let mut seen_paths: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
|
||||
for p in &paths {
|
||||
let dir = PathBuf::from(p);
|
||||
let songs = scan_dir_for_songs(&dir, false)?; // 本地音乐:显示所有音频
|
||||
for song in songs {
|
||||
if seen_paths.insert(song.path.clone()) {
|
||||
all_songs.push(song);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(all_songs)
|
||||
}).await.map_err(|e| format!("扫描任务失败: {}", e))?
|
||||
}
|
||||
|
||||
/// 读取音频文件的元数据(标题、艺术家、专辑、时长、封面)
|
||||
fn read_audio_metadata(path: &PathBuf) -> (String, String, String, u64, Option<String>) {
|
||||
match lofty::read_from_path(path) {
|
||||
@ -661,11 +816,12 @@ pub struct DeleteLocalSongQuery {
|
||||
|
||||
/// 删除本地已下载的歌曲文件及其元数据
|
||||
#[tauri::command]
|
||||
pub fn delete_local_song(
|
||||
pub async fn delete_local_song(
|
||||
app_handle: tauri::AppHandle,
|
||||
query: DeleteLocalSongQuery,
|
||||
) -> Result<(), String> {
|
||||
let download_dir = resolve_download_dir(&app_handle, query.download_path.as_deref());
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let file_path = download_dir.join(&query.filename);
|
||||
let meta_path = download_dir.join(format!("{}.json", query.id));
|
||||
|
||||
@ -676,14 +832,17 @@ pub fn delete_local_song(
|
||||
fs::remove_file(&meta_path).map_err(|e| format!("删除元数据失败: {}", e))?;
|
||||
}
|
||||
Ok(())
|
||||
}).await.map_err(|e| format!("删除任务失败: {}", e))?
|
||||
}
|
||||
|
||||
/// 检查指定歌曲是否已下载到本地
|
||||
#[tauri::command]
|
||||
pub fn check_local_song(app_handle: tauri::AppHandle, id: u64, download_path: Option<String>) -> Result<bool, String> {
|
||||
pub async fn check_local_song(app_handle: tauri::AppHandle, id: u64, download_path: Option<String>) -> Result<bool, String> {
|
||||
let download_dir = resolve_download_dir(&app_handle, download_path.as_deref());
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let meta_path = download_dir.join(format!("{}.json", id));
|
||||
Ok(meta_path.exists())
|
||||
}).await.map_err(|e| format!("检查任务失败: {}", e))?
|
||||
}
|
||||
|
||||
/// 解析下载目录,优先使用自定义路径,否则使用默认目录
|
||||
@ -713,7 +872,7 @@ fn get_default_download_dir(app_handle: &tauri::AppHandle) -> PathBuf {
|
||||
|
||||
/// 获取默认下载路径字符串,供前端使用
|
||||
#[tauri::command]
|
||||
pub fn get_default_download_path(app_handle: tauri::AppHandle) -> String {
|
||||
pub async fn get_default_download_path(app_handle: tauri::AppHandle) -> String {
|
||||
get_default_download_dir(&app_handle).to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
@ -734,6 +893,79 @@ fn sanitize_filename(name: &str) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// 读取本地图片文件并转为 base64 data URL,供前端壁纸等场景使用
|
||||
#[tauri::command]
|
||||
pub async fn read_image_as_data_url(path: String) -> Result<String, String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let file_path = PathBuf::from(&path);
|
||||
if !file_path.exists() {
|
||||
return Err(format!("文件不存在: {}", path));
|
||||
}
|
||||
let bytes = fs::read(&file_path).map_err(|e| format!("读取文件失败: {}", e))?;
|
||||
let mime = match file_path.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase().as_str() {
|
||||
"png" => "image/png",
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"webp" => "image/webp",
|
||||
"gif" => "image/gif",
|
||||
"bmp" => "image/bmp",
|
||||
"svg" => "image/svg+xml",
|
||||
_ => "image/jpeg", // 默认 jpeg
|
||||
};
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
Ok(format!("data:{};base64,{}", mime, b64))
|
||||
}).await.map_err(|e| format!("任务失败: {}", e))?
|
||||
}
|
||||
|
||||
/// 在系统文件管理器中显示指定文件(选中)
|
||||
#[tauri::command]
|
||||
pub fn show_item_in_folder(path: String) -> Result<(), String> {
|
||||
let p = PathBuf::from(&path);
|
||||
if !p.exists() {
|
||||
return Err(format!("文件不存在: {}", path));
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
std::process::Command::new("explorer")
|
||||
.args(["/select,", &p.to_string_lossy()])
|
||||
.spawn()
|
||||
.map_err(|e| format!("打开文件夹失败: {}", e))?;
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
std::process::Command::new("open")
|
||||
.args(["-R", &p.to_string_lossy()])
|
||||
.spawn()
|
||||
.map_err(|e| format!("打开文件夹失败: {}", e))?;
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let uri = format!("file://{}", p.to_string_lossy());
|
||||
// 优先使用 freedesktop DBus FileManager1 接口(支持选中文件,Nautilus/Dolphin 等均实现)
|
||||
let dbus_ok = std::process::Command::new("dbus-send")
|
||||
.args([
|
||||
"--session",
|
||||
"--print-reply",
|
||||
"--dest=org.freedesktop.FileManager1",
|
||||
"/org/freedesktop/FileManager1",
|
||||
"org.freedesktop.FileManager1.ShowItems",
|
||||
&format!("array:string:{}", uri),
|
||||
"string:",
|
||||
])
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false);
|
||||
// fallback:仅打开父目录(无法选中文件)
|
||||
if !dbus_ok {
|
||||
let parent = p.parent().unwrap_or(&p).to_string_lossy().to_string();
|
||||
std::process::Command::new("xdg-open")
|
||||
.arg(&parent)
|
||||
.spawn()
|
||||
.map_err(|e| format!("打开文件夹失败: {}", e))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取歌手详情
|
||||
#[tauri::command]
|
||||
pub async fn artist_detail(id: u64, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
@ -785,6 +1017,28 @@ pub async fn artist_desc(id: u64, state: State<'_, ApiController>) -> Result<Str
|
||||
api_call!(state, artist_desc, params: [("id", &id.to_string())])
|
||||
}
|
||||
|
||||
/// 关注/取消关注歌手
|
||||
#[derive(Deserialize)]
|
||||
pub struct ArtistSubQuery { pub id: u64, pub sub: Option<bool> }
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn artist_sub(query: ArtistSubQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let t = if query.sub.unwrap_or(true) { "1" } else { "0" };
|
||||
api_call!(state, artist_sub, params: [("id", &query.id.to_string()), ("t", t)])
|
||||
}
|
||||
|
||||
/// 获取已关注的歌手列表
|
||||
#[derive(Deserialize)]
|
||||
pub struct ArtistSublistQuery { pub limit: Option<u32>, pub offset: Option<u32> }
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn artist_sublist(query: ArtistSublistQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let q = state.build_query()
|
||||
.param("limit", &query.limit.unwrap_or(100).to_string())
|
||||
.param("offset", &query.offset.unwrap_or(0).to_string());
|
||||
api_call!(state, artist_sublist, query: &q)
|
||||
}
|
||||
|
||||
/// 获取专辑详情
|
||||
#[tauri::command]
|
||||
pub async fn album_detail(id: u64, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
@ -895,3 +1149,288 @@ pub struct CommentLikeQuery {
|
||||
pub id: u64,
|
||||
pub cid: u64,
|
||||
}
|
||||
|
||||
// ==================== 云盘 ====================
|
||||
|
||||
/// 获取云盘列表
|
||||
#[tauri::command]
|
||||
pub async fn user_cloud(limit: Option<u32>, offset: Option<u32>, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
api_call!(state, user_cloud, params: [("limit", &limit.unwrap_or(30).to_string()), ("offset", &offset.unwrap_or(0).to_string())])
|
||||
}
|
||||
|
||||
/// 获取云盘歌曲详情
|
||||
#[tauri::command]
|
||||
pub async fn user_cloud_detail(id: String, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
api_call!(state, user_cloud_detail, params: [("id", &id)])
|
||||
}
|
||||
|
||||
/// 删除云盘歌曲
|
||||
#[tauri::command]
|
||||
pub async fn user_cloud_del(id: u64, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
api_call!(state, user_cloud_del, params: [("id", &id.to_string())])
|
||||
}
|
||||
|
||||
/// 查询 NOS LBS 获取上传节点域名
|
||||
///
|
||||
/// 通过 `http://wannos.127.net/lbs` 查询指定 bucket 的上传节点,
|
||||
/// 从返回的 `nosup-<region><n>.127.net` 中提取区域标识,
|
||||
/// 构造 multipart upload 所需的 `<bucket>.nos-<region>.163yun.com` 域名。
|
||||
async fn query_nos_upload_host(bucket: &str) -> Result<String, String> {
|
||||
let lbs_url = format!("http://wannos.127.net/lbs?version=1.0&bucketname={}", bucket);
|
||||
let http = reqwest::Client::new();
|
||||
let resp = http.get(&lbs_url).send().await
|
||||
.map_err(|e| format!("LBS查询请求失败: {}", e))?;
|
||||
|
||||
let lbs_data: serde_json::Value = resp.json().await
|
||||
.map_err(|e| format!("LBS响应解析失败: {}", e))?;
|
||||
|
||||
let nosup_url = lbs_data["upload"][0].as_str()
|
||||
.ok_or_else(|| format!("LBS响应缺少upload字段: {}", lbs_data))?;
|
||||
|
||||
// 从 "nosup-jd1.127.net" 中提取区域 "jd"
|
||||
let region = extract_nos_region(nosup_url)?;
|
||||
Ok(format!("https://{}.nos-{}.163yun.com", bucket, region))
|
||||
}
|
||||
|
||||
/// 从 nosup 上传节点 URL 中提取 NOS 区域标识
|
||||
///
|
||||
/// 例如: `http://nosup-jd1.127.net` → `jd`
|
||||
/// `http://nosup-hz1.127.net` → `hz`
|
||||
fn extract_nos_region(nosup_url: &str) -> Result<String, String> {
|
||||
let start = nosup_url.find("nosup-")
|
||||
.ok_or_else(|| format!("无法从LBS响应中解析区域: {}", nosup_url))?;
|
||||
let after = &nosup_url[start + 6..];
|
||||
let dot = after.find('.')
|
||||
.ok_or_else(|| format!("无法从LBS响应中解析区域: {}", nosup_url))?;
|
||||
let region_with_num = &after[..dot];
|
||||
// 去掉末尾数字: "jd1" → "jd"
|
||||
let region: String = region_with_num
|
||||
.chars()
|
||||
.take_while(|c| !c.is_ascii_digit())
|
||||
.collect();
|
||||
if region.is_empty() {
|
||||
return Err(format!("区域标识为空: {}", nosup_url));
|
||||
}
|
||||
Ok(region)
|
||||
}
|
||||
|
||||
/// 云盘上传:完整流程(检查 → [获取Token → LBS查询 → NOS上传] → 提交信息 → 发布)
|
||||
///
|
||||
/// 关键发现:upload check 返回的 songId 是十六进制字符串(如 MD5 摘要),不是数字 ID。
|
||||
/// needUpload=false 表示文件已在 NOS 上,无需重复上传,直接走 info+pub 即可。
|
||||
/// 参考 ydq/netease-cloud-disk-music-upload 实现。
|
||||
#[tauri::command]
|
||||
pub async fn cloud_upload(file_path: String, app_handle: tauri::AppHandle, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let path = PathBuf::from(&file_path);
|
||||
if !path.exists() {
|
||||
return Err("文件不存在".to_string());
|
||||
}
|
||||
|
||||
let file_bytes = fs::read(&path).map_err(|e| format!("读取文件失败: {}", e))?;
|
||||
let file_size = file_bytes.len() as i64;
|
||||
let filename = path.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||||
|
||||
// 计算 MD5
|
||||
let md5_hex = format!("{:x}", md5::compute(&file_bytes));
|
||||
|
||||
// 读取音频元数据
|
||||
let (song_name, artist, album, _duration, _cover) = read_audio_metadata(&path);
|
||||
let bitrate = {
|
||||
match lofty::read_from_path(&path) {
|
||||
Ok(tf) => {
|
||||
let br = tf.properties().audio_bitrate().unwrap_or(0) * 1000;
|
||||
if br > 0 { br.to_string() } else { "999000".to_string() }
|
||||
}
|
||||
Err(_) => "999000".to_string()
|
||||
}
|
||||
};
|
||||
|
||||
// 文件扩展名
|
||||
let ext = if filename.contains('.') {
|
||||
filename.rsplit('.').next().unwrap_or("mp3").to_string()
|
||||
} else {
|
||||
"mp3".to_string()
|
||||
};
|
||||
|
||||
let client = state.client.lock().await.clone();
|
||||
|
||||
// Step 1: 上传检查
|
||||
let check_q = state.build_query()
|
||||
.param("bitrate", &bitrate)
|
||||
.param("length", &file_size.to_string())
|
||||
.param("md5", &md5_hex)
|
||||
.param("ext", &ext)
|
||||
.param("songId", "0");
|
||||
|
||||
let check_res = client.cloud_upload_check(&check_q).await
|
||||
.map_err(|e| format!("上传检查失败: {}", e))?;
|
||||
let check_data = &check_res.body;
|
||||
|
||||
// songId 是十六进制字符串(非数字),需要保持字符串传递
|
||||
let song_id = check_data["songId"].as_str().unwrap_or("0").to_string();
|
||||
let need_upload = check_data["needUpload"].as_bool().unwrap_or(true);
|
||||
|
||||
let mut resource_id = String::new();
|
||||
|
||||
// Step 2-4: 仅在 needUpload=true 时执行 NOS 上传
|
||||
if need_upload {
|
||||
// Step 2: 获取 NOS 上传 Token
|
||||
let token_q = state.build_query()
|
||||
.param("filename", &filename)
|
||||
.param("md5", &md5_hex);
|
||||
|
||||
let token_res = client.cloud_upload_token_alloc(&token_q).await
|
||||
.map_err(|e| format!("获取上传Token失败: {}", e))?;
|
||||
let token_data = &token_res.body;
|
||||
|
||||
resource_id = token_data["result"]["resourceId"].as_str().unwrap_or("").to_string();
|
||||
let object_key_raw = token_data["result"]["objectKey"].as_str().unwrap_or("").to_string();
|
||||
let object_key = object_key_raw.replace('/', "%2F");
|
||||
let token_str = token_data["result"]["token"].as_str().unwrap_or("").to_string();
|
||||
|
||||
if token_str.is_empty() {
|
||||
return Err(format!("获取上传Token为空, 响应: {}", token_data));
|
||||
}
|
||||
|
||||
// Step 3: 查询 LBS 获取正确的 NOS 上传节点
|
||||
let bucket = "jd-musicrep-privatecloud-audio-public";
|
||||
let nos_host = query_nos_upload_host(bucket).await
|
||||
.unwrap_or_else(|_| format!("https://{}.nos-jd.163yun.com", bucket));
|
||||
|
||||
let content_type = match ext.as_str() {
|
||||
"flac" => "audio/flac",
|
||||
"wav" => "audio/wav",
|
||||
"ogg" => "audio/ogg",
|
||||
"aac" | "m4a" => "audio/aac",
|
||||
_ => "audio/mpeg",
|
||||
};
|
||||
|
||||
// Step 4: 上传文件到 NOS(multipart upload)
|
||||
let http_client = reqwest::Client::new();
|
||||
|
||||
// 4a: 初始化 multipart upload
|
||||
let init_url = format!("{}/{}?uploads", nos_host, object_key);
|
||||
let init_res = http_client.post(&init_url)
|
||||
.header("x-nos-token", &token_str)
|
||||
.header("X-Nos-Meta-Content-Type", content_type)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("初始化NOS上传失败: {}", e))?;
|
||||
|
||||
let init_status = init_res.status();
|
||||
let init_xml = init_res.text().await.map_err(|e| format!("读取NOS响应失败: {}", e))?;
|
||||
|
||||
if !init_status.is_success() {
|
||||
return Err(format!("初始化NOS上传失败: HTTP {} 响应: {}", init_status, init_xml));
|
||||
}
|
||||
|
||||
// 解析 UploadId
|
||||
let upload_id = init_xml
|
||||
.split("<UploadId>")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split("</UploadId>").next())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
|
||||
if upload_id.is_empty() {
|
||||
return Err(format!("获取UploadId失败, NOS响应: {}", init_xml));
|
||||
}
|
||||
|
||||
// 4b: 分块上传(每块 10MB)
|
||||
let block_size = 10 * 1024 * 1024;
|
||||
let file_size_usize = file_bytes.len();
|
||||
let mut offset = 0usize;
|
||||
let mut block_index = 1u32;
|
||||
let mut etags = Vec::new();
|
||||
let total_blocks = ((file_size_usize + block_size - 1) / block_size).max(1);
|
||||
|
||||
while offset < file_size_usize {
|
||||
let end = (offset + block_size).min(file_size_usize);
|
||||
let chunk = file_bytes[offset..end].to_vec();
|
||||
|
||||
let part_url = format!(
|
||||
"{}/{}?partNumber={}&uploadId={}",
|
||||
nos_host, object_key, block_index, upload_id
|
||||
);
|
||||
|
||||
let part_res = http_client.put(&part_url)
|
||||
.header("x-nos-token", &token_str)
|
||||
.header("Content-Type", content_type)
|
||||
.body(chunk)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("上传分块{}失败: {}", block_index, e))?;
|
||||
|
||||
if let Some(etag) = part_res.headers().get("etag") {
|
||||
etags.push(etag.to_str().unwrap_or_default().to_string());
|
||||
}
|
||||
|
||||
// 发送上传进度
|
||||
let progress = (block_index as f64 / total_blocks as f64 * 100.0).min(100.0);
|
||||
let _ = app_handle.emit("cloud-upload-progress", json!({
|
||||
"filename": filename,
|
||||
"progress": progress,
|
||||
"uploaded": end,
|
||||
"total": file_size_usize,
|
||||
}));
|
||||
|
||||
offset = end;
|
||||
block_index += 1;
|
||||
}
|
||||
|
||||
// 4c: 完成 multipart upload
|
||||
let mut complete_xml = String::from("<CompleteMultipartUpload>");
|
||||
for (i, etag) in etags.iter().enumerate() {
|
||||
complete_xml.push_str(&format!(
|
||||
"<Part><PartNumber>{}</PartNumber><ETag>{}</ETag></Part>",
|
||||
i + 1, etag
|
||||
));
|
||||
}
|
||||
complete_xml.push_str("</CompleteMultipartUpload>");
|
||||
|
||||
let complete_url = format!("{}/{}?uploadId={}", nos_host, object_key, upload_id);
|
||||
let complete_res = http_client.post(&complete_url)
|
||||
.header("Content-Type", "text/plain;charset=UTF-8")
|
||||
.header("X-Nos-Meta-Content-Type", content_type)
|
||||
.header("x-nos-token", &token_str)
|
||||
.body(complete_xml)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("完成NOS上传失败: {}", e))?;
|
||||
|
||||
if !complete_res.status().is_success() {
|
||||
let status = complete_res.status();
|
||||
let body = complete_res.text().await.unwrap_or_default();
|
||||
return Err(format!("完成NOS上传失败: HTTP {} 响应: {}", status, body));
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: 提交歌曲信息(songId 使用 check 返回的字符串值)
|
||||
let info_q = state.build_query()
|
||||
.param("md5", &md5_hex)
|
||||
.param("songId", &song_id)
|
||||
.param("filename", &filename)
|
||||
.param("song", &song_name)
|
||||
.param("album", &album)
|
||||
.param("artist", &artist)
|
||||
.param("bitrate", &bitrate)
|
||||
.param("resourceId", &resource_id);
|
||||
|
||||
let info_res = client.cloud_upload_info(&info_q).await
|
||||
.map_err(|e| format!("提交歌曲信息失败: {}", e))?;
|
||||
let info_data = &info_res.body;
|
||||
|
||||
// info 可能返回新的 songId,优先使用
|
||||
let final_song_id = info_data["songId"].as_str()
|
||||
.filter(|s| s != &"0" && !s.is_empty())
|
||||
.unwrap_or(&song_id)
|
||||
.to_string();
|
||||
|
||||
// Step 6: 发布
|
||||
let pub_q = state.build_query().param("songId", &final_song_id);
|
||||
let pub_res = client.cloud_publish(&pub_q).await
|
||||
.map_err(|e| format!("发布失败: {}", e))?;
|
||||
|
||||
let _ = app_handle.emit("cloud-upload-complete", &filename);
|
||||
Ok(pub_res.body.to_string())
|
||||
}
|
||||
|
||||
@ -35,6 +35,7 @@ pub struct AudioController {
|
||||
tx: Sender<AudioCmd>,
|
||||
current_url: Arc<Mutex<Option<String>>>,
|
||||
position: Arc<Mutex<f64>>,
|
||||
is_playing: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl AudioController {
|
||||
@ -43,11 +44,13 @@ impl AudioController {
|
||||
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, ah_clone));
|
||||
AudioController { tx, current_url, position }
|
||||
thread::spawn(move || audio_thread(rx, url_clone, pos_clone, playing_clone, ah_clone));
|
||||
AudioController { tx, current_url, position, is_playing }
|
||||
}
|
||||
|
||||
/// 播放指定URL的网络音频
|
||||
@ -78,6 +81,9 @@ impl AudioController {
|
||||
pub fn get_position(&self) -> f64 {
|
||||
*self.position.lock().unwrap()
|
||||
}
|
||||
pub fn get_is_playing(&self) -> bool {
|
||||
self.is_playing.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
/// 缓冲区内部状态,存储已下载的字节数据及完成/取消标志
|
||||
@ -908,7 +914,7 @@ fn restart_playback_on_device_change(
|
||||
}
|
||||
|
||||
/// 音频线程主循环,接收命令并管理播放生命周期,包括设备热切换和播放结束检测
|
||||
fn audio_thread(rx: Receiver<AudioCmd>, _current_url: Arc<Mutex<Option<String>>>, shared_position: Arc<Mutex<f64>>, app_handle: AppHandle) {
|
||||
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;
|
||||
@ -925,6 +931,7 @@ fn audio_thread(rx: Receiver<AudioCmd>, _current_url: Arc<Mutex<Option<String>>>
|
||||
AudioCmd::Play(url) => {
|
||||
audio_active = false;
|
||||
audio_paused = false;
|
||||
is_playing.store(false, Ordering::Relaxed);
|
||||
manual_stop = false;
|
||||
current_local_path = None;
|
||||
|
||||
@ -971,6 +978,7 @@ fn audio_thread(rx: Receiver<AudioCmd>, _current_url: Arc<Mutex<Option<String>>>
|
||||
Ok(ctx) => {
|
||||
output_ctx = Some(ctx);
|
||||
audio_active = true;
|
||||
is_playing.store(true, Ordering::Relaxed);
|
||||
let _ = app_handle.emit("audio-started", ());
|
||||
}
|
||||
Err(e) => {
|
||||
@ -982,6 +990,7 @@ fn audio_thread(rx: Receiver<AudioCmd>, _current_url: Arc<Mutex<Option<String>>>
|
||||
AudioCmd::PlayLocal(path) => {
|
||||
audio_active = false;
|
||||
audio_paused = false;
|
||||
is_playing.store(false, Ordering::Relaxed);
|
||||
manual_stop = false;
|
||||
current_local_path = Some(path.clone());
|
||||
|
||||
@ -1008,6 +1017,7 @@ fn audio_thread(rx: Receiver<AudioCmd>, _current_url: Arc<Mutex<Option<String>>>
|
||||
Ok(ctx) => {
|
||||
output_ctx = Some(ctx);
|
||||
audio_active = true;
|
||||
is_playing.store(true, Ordering::Relaxed);
|
||||
let _ = app_handle.emit("audio-started", ());
|
||||
}
|
||||
Err(e) => {
|
||||
@ -1018,6 +1028,7 @@ fn audio_thread(rx: Receiver<AudioCmd>, _current_url: Arc<Mutex<Option<String>>>
|
||||
|
||||
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);
|
||||
}
|
||||
@ -1025,6 +1036,9 @@ fn audio_thread(rx: Receiver<AudioCmd>, _current_url: Arc<Mutex<Option<String>>>
|
||||
|
||||
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);
|
||||
}
|
||||
@ -1033,6 +1047,7 @@ fn audio_thread(rx: Receiver<AudioCmd>, _current_url: Arc<Mutex<Option<String>>>
|
||||
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 {
|
||||
@ -1051,9 +1066,14 @@ fn audio_thread(rx: Receiver<AudioCmd>, _current_url: Arc<Mutex<Option<String>>>
|
||||
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;
|
||||
audio_paused = false;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[audio] seek 播放失败: {}", e);
|
||||
@ -1097,6 +1117,7 @@ fn audio_thread(rx: Receiver<AudioCmd>, _current_url: Arc<Mutex<Option<String>>>
|
||||
&& 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();
|
||||
@ -1204,3 +1225,8 @@ pub fn get_audio_position(state: State<'_, AppAudio>) -> f64 {
|
||||
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 }
|
||||
}
|
||||
|
||||
@ -137,6 +137,8 @@ pub fn run() {
|
||||
api::logout,
|
||||
|
||||
api::search_songs,
|
||||
api::cloudsearch,
|
||||
api::search_suggest,
|
||||
api::get_song_url,
|
||||
api::get_hot_search,
|
||||
api::get_playlist_detail,
|
||||
@ -145,6 +147,9 @@ pub fn run() {
|
||||
api::recommend_resource,
|
||||
api::recommend_songs,
|
||||
api::personal_fm,
|
||||
api::personal_fm_mode,
|
||||
api::fm_trash,
|
||||
api::scrobble,
|
||||
api::get_song_detail,
|
||||
api::get_qr_key,
|
||||
api::create_qr,
|
||||
@ -168,9 +173,11 @@ pub fn run() {
|
||||
audio::seek_audio,
|
||||
audio::get_audio_position,
|
||||
audio::set_volume,
|
||||
audio::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,
|
||||
@ -179,11 +186,19 @@ pub fn run() {
|
||||
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())
|
||||
|
||||
@ -2,7 +2,7 @@ use std::sync::{Arc, Mutex};
|
||||
use tauri::{AppHandle, Emitter, Listener};
|
||||
use souvlaki::{
|
||||
MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback,
|
||||
PlatformConfig, SeekDirection,
|
||||
MediaPosition, PlatformConfig, SeekDirection,
|
||||
};
|
||||
|
||||
struct MediaState {
|
||||
@ -79,9 +79,15 @@ pub fn start_media_controls(app_handle: AppHandle, hwnd: Option<*mut std::ffi::c
|
||||
};
|
||||
|
||||
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: None },
|
||||
"paused" => MediaPlayback::Paused { progress: None },
|
||||
"playing" => MediaPlayback::Playing { progress },
|
||||
"paused" => MediaPlayback::Paused { progress },
|
||||
_ => MediaPlayback::Stopped,
|
||||
};
|
||||
let _ = s.controls.set_playback(playback);
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Nekosonic",
|
||||
"version": "0.5.0",
|
||||
"version": "0.7.0",
|
||||
"identifier": "com.atdunbg.Nekosonic",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
549
src/App.vue
549
src/App.vue
@ -1,221 +1,39 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-screen bg-base text-content overflow-hidden">
|
||||
<!-- 壁纸层:fixed 全屏最底层 -->
|
||||
<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"
|
||||
v-if="settings.currentWallpaper.path"
|
||||
class="fixed inset-0 z-0 pointer-events-none overflow-hidden"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<div
|
||||
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">
|
||||
<TitleBar @close="closeWindow" />
|
||||
|
||||
<div class="flex flex-1 overflow-hidden" v-if="windowVisible">
|
||||
<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>
|
||||
<router-link to="/local-music"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
|
||||
active-class="!text-content !bg-muted">
|
||||
<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="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></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>
|
||||
<Sidebar />
|
||||
|
||||
<main class="flex-1 overflow-y-auto pb-24">
|
||||
<router-view v-slot="{ Component }">
|
||||
<keep-alive :max="5" :include="keepAliveInclude">
|
||||
<keep-alive :max="10" :include="keepAliveInclude">
|
||||
<component :is="Component" />
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<Transition name="drawer">
|
||||
<div
|
||||
v-if="windowVisible && 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
|
||||
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 bg-white/10 flex items-center justify-center shadow-2xl mb-4"
|
||||
>
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="text-white/30"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-white text-center">{{ roamSong?.name }}</h1>
|
||||
<p class="text-content-2 mt-2 text-center">
|
||||
<template v-for="(a, i) in roamSong?.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="a.id && navigateFromDrawer({ name: 'artist', params: { id: a.id } })">{{ a.name }}</span>
|
||||
</template>
|
||||
<template v-if="roamSong?.al?.name">
|
||||
<span class="text-content-3 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="roamTab = 'lyric'"
|
||||
class="px-3 py-1 rounded-full text-sm transition"
|
||||
:class="roamTab === 'lyric' ? 'bg-white/15 text-white font-medium' : 'text-white/50 hover:text-white/80'">
|
||||
歌词
|
||||
</button>
|
||||
<button @click="roamTab = 'comment'"
|
||||
class="px-3 py-1 rounded-full text-sm transition"
|
||||
:class="roamTab === 'comment' ? 'bg-white/15 text-white font-medium' : 'text-white/50 hover:text-white/80'">
|
||||
评论
|
||||
</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="showTranslation ? 'bg-white/15 text-white font-medium' : 'text-white/40 hover:text-white/70'">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 8l6 6"/><path d="M4 14l6-6 2-3"/><path d="M2 5h12"/><path d="M7 2h1"/><path d="M22 22l-5-10-5 10"/><path d="M14 18h6"/></svg>
|
||||
译
|
||||
</button>
|
||||
</div>
|
||||
<div v-show="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-all duration-300"
|
||||
@click="seekToRoamLyric(line.time)"
|
||||
@mouseenter="roamLyricHovering = true"
|
||||
@mouseleave="roamLyricHovering = false"
|
||||
>
|
||||
{{ line.text }}
|
||||
<span v-if="showTranslation && line.translation" class="block text-sm opacity-60 mt-1">{{ line.translation }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="text-content-3 text-center mt-8">暂无歌词</div>
|
||||
</div>
|
||||
<div v-show="roamTab === 'comment'" class="flex-1 min-h-0 overflow-y-auto px-4 pb-4">
|
||||
<CommentSection v-if="roamSong" :type="0" :id="player.commentSongId || roamSong.id" :key="player.commentSongId || roamSong.id" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<RoamDrawer :visible="windowVisible && player.showRoamDrawer" />
|
||||
|
||||
<PlayerBar v-if="player.currentSong" />
|
||||
<ToastContainer />
|
||||
@ -229,69 +47,36 @@
|
||||
@ignore="updater.ignoreVersion(updater.updateInfo.value?.version || '')"
|
||||
/>
|
||||
|
||||
<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>
|
||||
<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 TitleBar from './components/TitleBar.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 CommentSection from './components/CommentSection.vue';
|
||||
import CloseModal from './components/CloseModal.vue';
|
||||
import UpdateDialog from './components/UpdateDialog.vue';
|
||||
import { usePlayerStore } from './stores/player';
|
||||
import { getCoverUrl } from './utils/song';
|
||||
import { useOnlineStatus } from './composables/useOnlineStatus';
|
||||
import { showToast } from './composables/useToast';
|
||||
import { useLyric } from './composables/UserLyric';
|
||||
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 settings = useSettingsStore();
|
||||
@ -303,150 +88,130 @@ watch(isOnline, (val, old) => {
|
||||
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);
|
||||
const keepAliveInclude = ref<string[]>(['HomeView', 'DiscoverView', 'FavoriteSongsView', 'DailySongsView', 'LocalMusicView']);
|
||||
|
||||
watch(() => settings.theme, (val) => {
|
||||
document.documentElement.setAttribute('data-theme', val);
|
||||
// --- Keep-alive 缓存管理 ---
|
||||
// 规则:30秒未访问的页面自动清除缓存;多级跳转时保留导航链上的页面;FavoriteSongs 常驻
|
||||
const route = useRoute();
|
||||
|
||||
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 } });
|
||||
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];
|
||||
}
|
||||
|
||||
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 roamTab = ref<'lyric' | 'comment'>('lyric');
|
||||
const roamCoverUrl = computed(() => {
|
||||
if (!roamSong.value) return '';
|
||||
return getCoverUrl(roamSong.value) || '';
|
||||
});
|
||||
watch(roamCoverUrl, () => { roamCoverError.value = false; });
|
||||
let roamResizeObserver: ResizeObserver | null = null;
|
||||
const keepAliveInclude = ref<string[]>(computeInclude());
|
||||
let cleanupTimer: ReturnType<typeof setInterval>;
|
||||
function startCleanup() { cleanupTimer = setInterval(() => { keepAliveInclude.value = computeInclude(); }, 10_000); }
|
||||
function stopCleanup() { clearInterval(cleanupTimer); }
|
||||
|
||||
function updateRoamLyricPad() {
|
||||
if (lyricScrollContainer.value) {
|
||||
roamLyricPadPx.value = Math.floor(lyricScrollContainer.value.clientHeight / 2);
|
||||
}
|
||||
}
|
||||
watch(() => settings.skin, () => {
|
||||
settings.applySkin();
|
||||
}, { immediate: true });
|
||||
|
||||
watch(() => player.showRoamDrawer, (val) => {
|
||||
if (val) {
|
||||
roamTab.value = player.roamInitialTab;
|
||||
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;
|
||||
}
|
||||
}
|
||||
// 壁纸样式:通过 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,
|
||||
};
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (roamResizeObserver) {
|
||||
roamResizeObserver.disconnect();
|
||||
roamResizeObserver = null;
|
||||
// 监听壁纸路径变化,异步加载图片
|
||||
watch(() => settings.currentWallpaper.path, async (path) => {
|
||||
if (!path) {
|
||||
wallpaperDataUrl.value = '';
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
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-[1rem]';
|
||||
return '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 }) {
|
||||
player.closeRoamDrawer();
|
||||
router.push(routeLocation);
|
||||
}
|
||||
|
||||
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) { /* 忽略 */ }
|
||||
wallpaperDataUrl.value = await AppApi.readImageAsDataUrl(path);
|
||||
} catch (e) {
|
||||
console.error('加载壁纸失败:', e);
|
||||
wallpaperDataUrl.value = '';
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
function goPlaylist(id: number) {
|
||||
router.push({ name: 'playlist', params: { id } });
|
||||
// 根容器背景:有壁纸时透明(遮罩层已保证文字可读),无壁纸时不透明
|
||||
const rootBgStyle = computed(() => {
|
||||
const wp = settings.currentWallpaper;
|
||||
if (wp.path) {
|
||||
return {}; // 透明,遮罩层统一处理
|
||||
}
|
||||
return {
|
||||
backgroundColor: 'var(--c-bg)',
|
||||
};
|
||||
});
|
||||
|
||||
function isPlaylistActive(id: number): boolean {
|
||||
return route.name === 'playlist' && Number(route.params.id) === id;
|
||||
}
|
||||
// 主题色遮罩层:用 --c-bg 的半透明版本覆盖壁纸,保证文字对比度
|
||||
// 这是网易云式设计的核心:壁纸色调透出遮罩,文字始终清晰
|
||||
const overlayStyle = computed(() => {
|
||||
const bgColor = settings.currentColors.bg;
|
||||
const rgba = hexToRgba(bgColor, 0.82);
|
||||
return {
|
||||
backgroundColor: rgba,
|
||||
};
|
||||
});
|
||||
|
||||
watch(() => userStore.isLoggedIn, (val) => {
|
||||
if (val) {
|
||||
loadPlaylists();
|
||||
player.loadLikedIds();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
onMounted(() => {
|
||||
document.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||
startCleanup();
|
||||
|
||||
AudioApi.stopAudio().catch(() => {});
|
||||
|
||||
if (userStore.isLoggedIn) {
|
||||
loadPlaylists();
|
||||
player.loadLikedIds();
|
||||
}
|
||||
try { await invoke('stop_audio'); } catch {}
|
||||
try {
|
||||
const jsonStr: string = await invoke('get_login_status');
|
||||
MusicApi.getLoginStatus().then(jsonStr => {
|
||||
if (!jsonStr) return;
|
||||
const data = JSON.parse(jsonStr);
|
||||
if (data.account || data.profile) {
|
||||
const profile = data.profile || data.account;
|
||||
@ -456,44 +221,37 @@ onMounted(async () => {
|
||||
avatarUrl: profile.avatarUrl,
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
updater.checkForUpdate(true);
|
||||
|
||||
// 恢复保存的输出设备设置
|
||||
if (settings.outputDevice) {
|
||||
try {
|
||||
await invoke('set_output_device', { device: settings.outputDevice });
|
||||
}
|
||||
catch{}
|
||||
DeviceApi.setOutputDevice(settings.outputDevice).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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -510,10 +268,12 @@ onMounted(() => {
|
||||
const unlisten4 = listen('window-hidden', () => {
|
||||
windowVisible.value = false;
|
||||
keepAliveInclude.value = [];
|
||||
stopCleanup();
|
||||
});
|
||||
const unlisten5 = listen('window-shown', () => {
|
||||
windowVisible.value = true;
|
||||
keepAliveInclude.value = ['HomeView', 'DiscoverView', 'FavoriteSongsView', 'DailySongsView', 'LocalMusicView'];
|
||||
keepAliveInclude.value = computeInclude();
|
||||
startCleanup();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@ -522,6 +282,7 @@ onMounted(() => {
|
||||
unlisten3.then(fn => fn());
|
||||
unlisten4.then(fn => fn());
|
||||
unlisten5.then(fn => fn());
|
||||
stopCleanup();
|
||||
});
|
||||
});
|
||||
|
||||
@ -536,12 +297,12 @@ async function registerGlobalShortcuts() {
|
||||
for (const [id, action] of Object.entries(globalActions)) {
|
||||
const key = settings.shortcuts[id]?.key;
|
||||
if (!key) continue;
|
||||
try { await unregister(key); } catch {}
|
||||
try { await unregister(key); } catch { /* 忽略 */ }
|
||||
try {
|
||||
await register(key, (event) => {
|
||||
if (event.state === 'Pressed') action();
|
||||
});
|
||||
} catch {}
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
}
|
||||
|
||||
@ -599,21 +360,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);
|
||||
}
|
||||
.roam-lyric-active:hover {
|
||||
background: var(--c-subtle) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
233
src/api.ts
Normal file
233
src/api.ts
Normal file
@ -0,0 +1,233 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
export namespace MusicApi {
|
||||
export async function getLoginStatus(): Promise<string> {
|
||||
return invoke('get_login_status');
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
return invoke('logout');
|
||||
}
|
||||
|
||||
export async function getQrKey(): Promise<string> {
|
||||
return invoke('get_qr_key');
|
||||
}
|
||||
|
||||
export async function checkQrStatus(key: string): Promise<string> {
|
||||
return invoke('check_qr_status', { query: { key } });
|
||||
}
|
||||
|
||||
export async function likelist(uid: number): Promise<string> {
|
||||
return invoke('likelist', { uid });
|
||||
}
|
||||
|
||||
export async function likeSong(id: number, like: boolean): Promise<void> {
|
||||
return invoke('like_song', { query: { id, like: like ? 'true' : 'false' } });
|
||||
}
|
||||
|
||||
export async function userPlaylist(uid: number): Promise<string> {
|
||||
return invoke('user_playlist', { uid });
|
||||
}
|
||||
|
||||
export async function getPlaylistDetail(id: number): Promise<string> {
|
||||
return invoke('get_playlist_detail', { id });
|
||||
}
|
||||
|
||||
export async function playlistTrackAll(id: number): Promise<string> {
|
||||
return invoke('playlist_track_all', { query: { id } });
|
||||
}
|
||||
|
||||
export async function playlistSubscribe(id: number, subscribe: boolean): Promise<void> {
|
||||
return invoke('playlist_subscribe', { query: { id, subscribe } });
|
||||
}
|
||||
|
||||
export async function recommendResource(): Promise<string> {
|
||||
return invoke('recommend_resource');
|
||||
}
|
||||
|
||||
export async function recommendSongs(): Promise<string> {
|
||||
return invoke('recommend_songs');
|
||||
}
|
||||
|
||||
export async function getSongDetail(id: string): Promise<string> {
|
||||
return invoke('get_song_detail', { id });
|
||||
}
|
||||
|
||||
export async function getSongUrl(query: { id: number; level: string; fm_mode?: boolean }): Promise<string> {
|
||||
return invoke('get_song_url', { query });
|
||||
}
|
||||
|
||||
export async function getLyric(id: number): Promise<string> {
|
||||
return invoke('get_lyric', { id });
|
||||
}
|
||||
|
||||
export async function searchSuggest(keyword: string): Promise<string> {
|
||||
return invoke('search_suggest', { query: { keyword } });
|
||||
}
|
||||
|
||||
export async function getHotSearch(): Promise<string> {
|
||||
return invoke('get_hot_search');
|
||||
}
|
||||
|
||||
export async function cloudsearch(query: { keyword: string; searchType: number; limit: number }): Promise<string> {
|
||||
return invoke('cloudsearch', { query });
|
||||
}
|
||||
|
||||
export async function albumDetail(id: number): Promise<string> {
|
||||
return invoke('album_detail', { id });
|
||||
}
|
||||
|
||||
export async function artistDetail(id: number): Promise<string> {
|
||||
return invoke('artist_detail', { id });
|
||||
}
|
||||
|
||||
export async function artistSongs(query: { id: number; order: string; limit: number; offset: number }): Promise<string> {
|
||||
return invoke('artist_songs', { query });
|
||||
}
|
||||
|
||||
export async function artistAlbum(id: number, limit: number, offset: number): Promise<string> {
|
||||
return invoke('artist_album', { id, limit, offset });
|
||||
}
|
||||
|
||||
export async function artistDesc(id: number): Promise<string> {
|
||||
return invoke('artist_desc', { id });
|
||||
}
|
||||
|
||||
export async function artistSub(id: number, sub: boolean): Promise<string> {
|
||||
return invoke('artist_sub', { query: { id, sub } });
|
||||
}
|
||||
|
||||
export async function artistSublist(limit = 100, offset = 0): Promise<string> {
|
||||
return invoke('artist_sublist', { query: { limit, offset } });
|
||||
}
|
||||
|
||||
export async function commentHot(query: { type: number; id: number; limit: number; offset: number }): Promise<string> {
|
||||
return invoke('comment_hot', { query });
|
||||
}
|
||||
|
||||
export async function commentLike(query: { t: number; type: number; id: number; cid: number }): Promise<void> {
|
||||
return invoke('comment_like', { query });
|
||||
}
|
||||
|
||||
export async function personalFm(): Promise<string> {
|
||||
return invoke('personal_fm');
|
||||
}
|
||||
|
||||
export async function personalFmMode(query: { mode: string; subMode: string; limit: number }): Promise<string> {
|
||||
return invoke('personal_fm_mode', { query });
|
||||
}
|
||||
|
||||
export async function fmTrash(id: number, time: number): Promise<void> {
|
||||
return invoke('fm_trash', { query: { id, time } });
|
||||
}
|
||||
|
||||
export async function scrobble(query: { id: number; sourceid: string; time: number; alg?: string; source?: string; bitrate?: number }): Promise<void> {
|
||||
return invoke('scrobble', { query });
|
||||
}
|
||||
|
||||
// 云盘
|
||||
export async function userCloud(limit = 30, offset = 0): Promise<string> {
|
||||
return invoke('user_cloud', { limit, offset });
|
||||
}
|
||||
|
||||
export async function userCloudDel(id: number): Promise<string> {
|
||||
return invoke('user_cloud_del', { id });
|
||||
}
|
||||
|
||||
export async function cloudUpload(filePath: string): Promise<string> {
|
||||
return invoke('cloud_upload', { filePath });
|
||||
}
|
||||
}
|
||||
|
||||
export namespace AudioApi {
|
||||
export async function playAudio(url: string): Promise<void> {
|
||||
return invoke('play_audio', { url });
|
||||
}
|
||||
|
||||
export async function playLocalAudio(path: string): Promise<void> {
|
||||
return invoke('play_local_audio', { path });
|
||||
}
|
||||
|
||||
export async function pauseAudio(): Promise<void> {
|
||||
return invoke('pause_audio');
|
||||
}
|
||||
|
||||
export async function resumeAudio(): Promise<void> {
|
||||
return invoke('resume_audio');
|
||||
}
|
||||
|
||||
export async function stopAudio(): Promise<void> {
|
||||
return invoke('stop_audio');
|
||||
}
|
||||
|
||||
export async function seekAudio(time: number): Promise<void> {
|
||||
return invoke('seek_audio', { time });
|
||||
}
|
||||
|
||||
export async function setVolume(vol: number): Promise<void> {
|
||||
return invoke('set_volume', { vol });
|
||||
}
|
||||
|
||||
export async function getAudioPosition(): Promise<number> {
|
||||
return invoke('get_audio_position');
|
||||
}
|
||||
|
||||
export async function isAudioPlaying(): Promise<boolean> {
|
||||
return invoke('is_audio_playing');
|
||||
}
|
||||
}
|
||||
|
||||
export namespace DeviceApi {
|
||||
export async function getOutputDevices(): Promise<string[]> {
|
||||
return invoke('get_output_devices');
|
||||
}
|
||||
|
||||
export async function setOutputDevice(device: string | null): Promise<void> {
|
||||
return invoke('set_output_device', { device });
|
||||
}
|
||||
}
|
||||
|
||||
export namespace DownloadApi {
|
||||
export async function downloadSong(query: {
|
||||
id: number;
|
||||
name: string;
|
||||
artist: string;
|
||||
album: string | null;
|
||||
duration: number | null;
|
||||
coverUrl: string | null;
|
||||
level: string;
|
||||
downloadPath: string | null;
|
||||
}): Promise<void> {
|
||||
return invoke('download_song', { query });
|
||||
}
|
||||
|
||||
export async function listLocalSongs(downloadPath: string | null): Promise<any[]> {
|
||||
return invoke('list_local_songs', { downloadPath });
|
||||
}
|
||||
|
||||
export async function scanLocalFolders(paths: string[]): Promise<any[]> {
|
||||
return invoke('scan_local_folders', { paths });
|
||||
}
|
||||
|
||||
export async function deleteLocalSong(query: { id: number; filename: string; downloadPath: string | null }): Promise<void> {
|
||||
return invoke('delete_local_song', { query });
|
||||
}
|
||||
|
||||
export async function getDefaultDownloadPath(): Promise<string> {
|
||||
return invoke('get_default_download_path');
|
||||
}
|
||||
}
|
||||
|
||||
export namespace AppApi {
|
||||
export function exitApp(): Promise<void> {
|
||||
return invoke('exit_app');
|
||||
}
|
||||
|
||||
export async function readImageAsDataUrl(path: string): Promise<string> {
|
||||
return invoke('read_image_as_data_url', { path });
|
||||
}
|
||||
|
||||
export async function showItemInFolder(path: string): Promise<void> {
|
||||
return invoke('show_item_in_folder', { path });
|
||||
}
|
||||
}
|
||||
69
src/components/CloseModal.vue
Normal file
69
src/components/CloseModal.vue
Normal file
@ -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>
|
||||
@ -1,32 +1,34 @@
|
||||
<template>
|
||||
<div class="bg-subtle rounded-xl p-3" ref="scrollContainer">
|
||||
<div v-if="loading" class="py-8 text-center text-content-2 text-sm">加载中...</div>
|
||||
<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">
|
||||
<svg class="mx-auto mb-2 text-content-3" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
|
||||
<p class="text-content-3 text-sm">暂无评论</p>
|
||||
<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 bg-surface/50"
|
||||
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 text-content truncate">{{ comment.user.nickname }}</p>
|
||||
<p class="text-xs text-content-3">{{ new Date(comment.time).toLocaleDateString('zh-CN') }}</p>
|
||||
<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 text-content-2 leading-relaxed">{{ comment.content }}</p>
|
||||
<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 text-content-3 hover:text-danger transition text-xs"
|
||||
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')"
|
||||
>
|
||||
<svg width="14" height="14" 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>
|
||||
<IconHeart style="font-size: 14px" :class="comment.liked ? '[&>path]:fill-current [&>path]:stroke-0' : ''" />
|
||||
<span>{{ comment.likedCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
@ -34,17 +36,20 @@
|
||||
<div ref="sentinel" class="h-1"></div>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingMore" class="py-4 text-center text-content-3 text-sm">加载中...</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 { invoke } from '@tauri-apps/api/core'
|
||||
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[]>([])
|
||||
@ -72,13 +77,11 @@ async function fetchComments(reset = false) {
|
||||
}
|
||||
|
||||
try {
|
||||
const jsonStr: string = await invoke('comment_hot', {
|
||||
query: {
|
||||
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 || []
|
||||
@ -102,22 +105,27 @@ function loadMore() {
|
||||
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 invoke('comment_like', {
|
||||
query: {
|
||||
t: 1,
|
||||
await MusicApi.commentLike({
|
||||
t: liked ? 0 : 1,
|
||||
type: props.type,
|
||||
id: props.id,
|
||||
cid
|
||||
}
|
||||
})
|
||||
const target = comments.value.find(c => c.commentId === cid)
|
||||
if (target) {
|
||||
target.likedCount++
|
||||
}
|
||||
target.liked = !liked
|
||||
target.likedCount += liked ? -1 : 1
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
likingSet.value.delete(cid)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
:class="{ 'border-accent shadow-[0_0_0_2px_var(--c-accent-dim)]': isOpen }"
|
||||
>
|
||||
<span class="truncate">{{ 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>
|
||||
<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 max-w-[360px] overflow-hidden">
|
||||
@ -18,7 +18,7 @@
|
||||
:class="modelValue === key ? 'bg-accent-dim text-accent-text' : 'text-content-2 hover:bg-subtle hover:text-content'"
|
||||
>
|
||||
<span class="truncate">{{ 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>
|
||||
<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;
|
||||
|
||||
15
src/components/PageHeader.vue
Normal file
15
src/components/PageHeader.vue
Normal file
@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<!-- 普通头部:随内容滚动,返回独占一行,标题和按钮在第二行 -->
|
||||
<div class="-mx-8 px-8 pt-3 pb-2">
|
||||
<button @click="$router.back()" class="mb-1 text-content-2 hover:text-content transition text-sm">
|
||||
← 返回
|
||||
</button>
|
||||
<div class="flex items-center gap-4">
|
||||
<slot />
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
@ -1,75 +1,84 @@
|
||||
<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="player.dominantColor"
|
||||
class="absolute inset-0 pointer-events-none transition-opacity duration-300"
|
||||
:class="drawerActive ? 'opacity-100' : 'opacity-0'"
|
||||
:style="{ backgroundColor: player.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">
|
||||
<div v-if="getCoverUrl(player.currentSong)" class="w-10 h-10 rounded-md overflow-hidden flex-shrink-0 cursor-pointer hover:scale-105 transition-transform" @click="player.toggleRoamDrawer()" title="全屏展示">
|
||||
<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 bg-muted flex items-center justify-center cursor-pointer hover:scale-105 transition-transform" @click="player.toggleRoamDrawer()" title="全屏展示">
|
||||
<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-content-3"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||||
<div v-else class="w-10 h-10 rounded-md flex-shrink-0 flex items-center justify-center cursor-pointer hover:scale-105 transition-transform" @click="player.toggleRoamDrawer()" title="全屏展示"
|
||||
: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">
|
||||
<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="text-content-3">/</span>
|
||||
<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="text-content-3 mx-1">·</span>
|
||||
<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="player.openRoamDrawer('comment')" class="flex-shrink-0 text-content-3 hover:text-accent-text transition" title="评论">
|
||||
<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="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
|
||||
<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 text-content-3 hover:text-accent-text transition" title="下载">
|
||||
<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="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
<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>
|
||||
<svg v-if="player.currentSong && download.isDownloading(player.currentSong!.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="flex-shrink-0 animate-spin text-content-3"><path d="M21 12a9 9 0 11-6.22-8.56"/></svg>
|
||||
<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">
|
||||
<div class="flex items-center gap-5 relative">
|
||||
<button @click="player.prev()" :disabled="player.isFmMode" :class="[
|
||||
'transition',
|
||||
player.isFmMode ? 'text-content-4 cursor-not-allowed' : 'text-content-2 hover:text-content',
|
||||
player.isFmMode ? (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>
|
||||
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'">
|
||||
<IconPause v-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()" :class="drawerActive ? 'text-white/70 hover:text-white transition' : 'text-content-2 hover:text-content transition'">
|
||||
<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>
|
||||
@ -78,33 +87,60 @@
|
||||
|
||||
<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="player.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="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>
|
||||
|
||||
<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="showDislikeModal = false"
|
||||
class="w-full py-2 rounded-lg bg-muted hover:bg-emphasis text-sm text-content-2 transition">
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
@ -125,7 +161,7 @@
|
||||
</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">
|
||||
<svg width="16" height="16" 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>
|
||||
<IconX class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -135,9 +171,7 @@
|
||||
|
||||
<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">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="opacity-40">
|
||||
<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>
|
||||
</svg>
|
||||
<IconMusic class="w-10 h-10 opacity-40" />
|
||||
<p class="text-sm">播放列表为空</p>
|
||||
<p class="text-xs text-content-4">去发现好听的音乐吧</p>
|
||||
</div>
|
||||
@ -157,7 +191,7 @@
|
||||
<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">
|
||||
<svg width="12" height="12" 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>
|
||||
<IconX class="w-3 h-3" />
|
||||
</button>
|
||||
</template>
|
||||
</SongListItem>
|
||||
@ -168,7 +202,7 @@
|
||||
@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="定位到正在播放">
|
||||
<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"/><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><line x1="12" y1="6" x2="12" y2="2"/><line x1="12" y1="22" x2="12" y2="18"/></svg>
|
||||
<IconCrosshair class="w-[18px] h-[18px]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -180,18 +214,55 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onBeforeUnmount, onMounted, nextTick } from 'vue';
|
||||
import { usePlayerStore, PlayMode } from '../stores/player';
|
||||
import { useSettingsStore } from '../stores/settings';
|
||||
import { useDownload } from '../composables/useDownload';
|
||||
import { formatTime } from '../utils/format';
|
||||
import { getCoverUrl } from '../utils/song';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
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 settings = useSettingsStore();
|
||||
const download = useDownload();
|
||||
const drawerActive = computed(() => player.showRoamDrawer && !!player.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);
|
||||
@ -212,6 +283,16 @@ onBeforeUnmount(() => {
|
||||
if (unlistenCache) unlistenCache();
|
||||
});
|
||||
|
||||
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() {
|
||||
@ -227,7 +308,7 @@ function toggleMute() {
|
||||
} else {
|
||||
player.volume = prevVolume.value || 100;
|
||||
}
|
||||
invoke('set_volume', { vol: player.volume / 100 });
|
||||
AudioApi.setVolume(player.volume / 100);
|
||||
}
|
||||
|
||||
let onDocMove: ((e: MouseEvent) => void) | null = null;
|
||||
@ -285,6 +366,36 @@ 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) {
|
||||
@ -333,12 +444,13 @@ async function handleVolumeChange(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const val = parseInt(target.value, 10);
|
||||
player.volume = val;
|
||||
await invoke('set_volume', { vol: val / 100 });
|
||||
await AudioApi.setVolume(val / 100);
|
||||
}
|
||||
|
||||
const volumeBarBg = computed(() => {
|
||||
const pct = player.volume;
|
||||
return `linear-gradient(to right, var(--c-accent) 0%, var(--c-accent) ${pct}%, var(--c-muted) ${pct}%)`;
|
||||
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>
|
||||
|
||||
@ -398,12 +510,4 @@ const volumeBarBg = computed(() => {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
@keyframes eq-bounce-sm {
|
||||
0%, 100% { height: 2px; }
|
||||
50% { height: 10px; }
|
||||
}
|
||||
|
||||
.eq-bar-sm {
|
||||
animation: eq-bounce-sm 0.6s ease-in-out infinite;
|
||||
}
|
||||
</style>
|
||||
|
||||
263
src/components/RoamDrawer.vue
Normal file
263
src/components/RoamDrawer.vue
Normal file
@ -0,0 +1,263 @@
|
||||
<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="!player.dominantColor && 'bg-surface/95'"
|
||||
:style="player.dominantColor ? { backgroundColor: player.dominantColor } : {}"
|
||||
></div>
|
||||
<div v-if="player.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="!!player.dominantColor" transparent @close="player.closeRoamDrawer()">
|
||||
<template #left>
|
||||
<button @click="player.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="player.roamTab = 'lyric'"
|
||||
class="px-3 py-1 rounded-full text-sm transition"
|
||||
:class="tabClass(player.roamTab === 'lyric')">
|
||||
歌词
|
||||
</button>
|
||||
<button @click="player.roamTab = 'comment'"
|
||||
class="px-3 py-1 rounded-full text-sm transition"
|
||||
:class="tabClass(player.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="player.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 whitespace-nowrap transition-[font-size] 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="player.roamTab === 'comment'" class="flex-1 min-h-0 overflow-y-auto px-4 pb-4">
|
||||
<CommentSection v-if="roamSong" :type="0" :id="player.commentSongId || roamSong.id" :key="player.commentSongId || roamSong.id" :dark-mode="!!player.dominantColor" />
|
||||
</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 { 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();
|
||||
|
||||
// dominantColor 是否存在(模板中频繁使用)
|
||||
const dc = computed(() => !!player.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);
|
||||
player.dominantColor = color;
|
||||
} else {
|
||||
player.dominantColor = '';
|
||||
}
|
||||
});
|
||||
|
||||
let roamResizeObserver: ResizeObserver | null = null;
|
||||
|
||||
function updateRoamLyricPad() {
|
||||
if (lyricScrollContainer.value) {
|
||||
roamLyricPadPx.value = Math.floor(lyricScrollContainer.value.clientHeight / 2);
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => player.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 (player.showRoamDrawer && !roamLyricHovering.value) {
|
||||
nextTick(() => scrollToRoamActiveLyric());
|
||||
}
|
||||
});
|
||||
|
||||
watch(showTranslation, () => {
|
||||
if (player.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 }) {
|
||||
player.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; }
|
||||
</style>
|
||||
206
src/components/Sidebar.vue
Normal file
206
src/components/Sidebar.vue
Normal file
@ -0,0 +1,206 @@
|
||||
<template>
|
||||
<nav class="w-56 flex-shrink-0 flex flex-col" :style="sidebarBgStyle">
|
||||
<div class="flex-1 p-4 overflow-y-auto min-h-0">
|
||||
<div class="flex flex-col min-h-full">
|
||||
<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">
|
||||
<IconHome class="w-[18px] h-[18px]" />
|
||||
推荐
|
||||
</router-link>
|
||||
<router-link to="/discover"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
|
||||
active-class="!text-content !bg-muted">
|
||||
<IconSearch class="w-[18px] h-[18px]" />
|
||||
发现
|
||||
</router-link>
|
||||
<button
|
||||
@click="openRoamFromSidebar"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle w-full text-left"
|
||||
>
|
||||
<IconRadio class="w-[18px] h-[18px]" />
|
||||
漫游
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div 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">
|
||||
<IconHeart class="w-[18px] h-[18px]" />
|
||||
我喜欢的音乐
|
||||
</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">
|
||||
<IconClock class="w-[18px] h-[18px]" />
|
||||
最近播放
|
||||
</router-link>
|
||||
<router-link to="/local-music"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
|
||||
active-class="!text-content !bg-muted">
|
||||
<IconMusic class="w-[18px] h-[18px]" />
|
||||
本地音乐
|
||||
</router-link>
|
||||
<router-link to="/downloaded-music"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
|
||||
active-class="!text-content !bg-muted">
|
||||
<IconDownload class="w-[18px] h-[18px]" />
|
||||
下载音乐
|
||||
</router-link>
|
||||
<router-link v-if="userStore.isLoggedIn" to="/cloud-music"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
|
||||
active-class="!text-content !bg-muted">
|
||||
<IconCloud class="w-[18px] h-[18px]" />
|
||||
音乐云盘
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 mb-1 pt-2" v-if="userStore.isLoggedIn">
|
||||
<div class="flex items-center justify-between px-3 mb-1 cursor-pointer group"
|
||||
@click="showCreatedPlaylists = !showCreatedPlaylists">
|
||||
<p class="text-xs text-content-3">我的歌单</p>
|
||||
<IconChevronRight class="w-3 h-3 text-content-3 transition-transform" :class="{ 'rotate-90': showCreatedPlaylists }" />
|
||||
</div>
|
||||
<div v-show="showCreatedPlaylists" class="space-y-0.5">
|
||||
<div v-for="pl in createdPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
|
||||
class="flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all duration-200"
|
||||
:class="isPlaylistActive(pl.id) ? 'bg-muted' : 'hover:bg-subtle'">
|
||||
<img :src="pl.coverImgUrl + '?param=80y80'" class="w-8 h-8 rounded object-cover flex-shrink-0" />
|
||||
<span class="text-sm truncate"
|
||||
:class="isPlaylistActive(pl.id) ? 'text-content font-medium' : 'text-content-2'">{{ pl.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 mb-1 pt-2" v-if="userStore.isLoggedIn">
|
||||
<div class="flex items-center justify-between px-3 mb-1 cursor-pointer group"
|
||||
@click="showSubPlaylists = !showSubPlaylists">
|
||||
<p class="text-xs text-content-3">收藏的歌单</p>
|
||||
<IconChevronRight class="w-3 h-3 text-content-3 transition-transform" :class="{ 'rotate-90': showSubPlaylists }" />
|
||||
</div>
|
||||
<div v-show="showSubPlaylists" class="space-y-0.5">
|
||||
<div v-for="pl in subPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
|
||||
class="flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all duration-200"
|
||||
:class="isPlaylistActive(pl.id) ? 'bg-muted' : 'hover:bg-subtle'">
|
||||
<img :src="pl.coverImgUrl + '?param=80y80'" class="w-8 h-8 rounded object-cover flex-shrink-0" />
|
||||
<span class="text-sm truncate"
|
||||
:class="isPlaylistActive(pl.id) ? 'text-content font-medium' : 'text-content-2'">{{ pl.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto pt-4" :class="player.currentSong ? 'pb-20' : 'pb-2'">
|
||||
<div class="px-1">
|
||||
<router-link to="/settings"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
|
||||
active-class="!text-content !bg-muted">
|
||||
<IconSettings class="w-[18px] h-[18px]" />
|
||||
设置
|
||||
</router-link>
|
||||
</div>
|
||||
<div v-if="!userStore.isLoggedIn" class="mt-3 p-3 rounded-xl bg-subtle/60">
|
||||
<p class="text-xs text-content-3 mb-2">强烈建议登录以提升体验</p>
|
||||
<router-link to="/login"
|
||||
class="flex items-center justify-center gap-2 w-full px-4 py-2 rounded-lg bg-accent hover:bg-accent-hover transition text-sm font-medium text-white">
|
||||
<IconLogIn class="w-4 h-4" />
|
||||
立即登录
|
||||
</router-link>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-3 px-2 mt-3">
|
||||
<img :src="userStore.user?.avatarUrl" class="w-8 h-8 rounded-full ring-2 ring-accent/50" />
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium truncate">{{ userStore.user?.nickname }}</p>
|
||||
<button @click="userStore.logout(); player.stop()"
|
||||
class="text-xs text-content-3 hover:text-danger transition">退出登录</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, computed } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useUserStore } from '../stores/user';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { useSettingsStore } from '../stores/settings';
|
||||
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';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const userStore = useUserStore();
|
||||
const player = usePlayerStore();
|
||||
const settings = useSettingsStore();
|
||||
|
||||
// 有壁纸时侧栏轻微半透明区分区域,无壁纸时保持原样
|
||||
const sidebarBgStyle = computed(() => {
|
||||
if (settings.currentWallpaper.path) return {}; // 有壁纸时透明,由遮罩层统一提供背景
|
||||
return { backgroundColor: settings.currentColors.surface };
|
||||
});
|
||||
|
||||
const createdPlaylists = ref<any[]>([]);
|
||||
const subPlaylists = ref<any[]>([]);
|
||||
const showCreatedPlaylists = ref(true);
|
||||
const showSubPlaylists = ref(true);
|
||||
|
||||
async function loadPlaylists() {
|
||||
if (!userStore.isLoggedIn || !userStore.user) return;
|
||||
try {
|
||||
const jsonStr: string = await MusicApi.userPlaylist(userStore.user.userId);
|
||||
const data = JSON.parse(jsonStr);
|
||||
createdPlaylists.value = (data.playlist || []).filter((p: any) => !p.subscribed).slice(1);
|
||||
subPlaylists.value = (data.playlist || []).filter((p: any) => p.subscribed);
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
|
||||
function goPlaylist(id: number) {
|
||||
router.push({ name: 'playlist', params: { id } });
|
||||
}
|
||||
|
||||
function isPlaylistActive(id: number): boolean {
|
||||
return route.name === 'playlist' && Number(route.params.id) === id;
|
||||
}
|
||||
|
||||
async function openRoamFromSidebar() {
|
||||
if (!userStore.isLoggedIn) {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
if (player.isFmMode) {
|
||||
player.openRoamDrawer();
|
||||
} else {
|
||||
await player.loadFm();
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => userStore.isLoggedIn, (val) => {
|
||||
if (val) {
|
||||
loadPlaylists();
|
||||
player.loadLikedIds();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (userStore.isLoggedIn) {
|
||||
loadPlaylists();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@ -1,14 +1,18 @@
|
||||
<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">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg>
|
||||
<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">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
|
||||
<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>
|
||||
@ -16,6 +20,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onBeforeUnmount, onMounted } from 'vue';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
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 player = usePlayerStore();
|
||||
const props = defineProps<{ songId: number }>();
|
||||
@ -31,6 +39,17 @@ function handleComment() {
|
||||
player.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;
|
||||
|
||||
@ -1,13 +1,25 @@
|
||||
<template>
|
||||
<div :class="['flex items-center gap-4 p-3 rounded-xl cursor-pointer transition group', containerClass]">
|
||||
<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">
|
||||
<span v-if="showIndex" class="text-xs text-content-3 w-6 text-right flex-shrink-0">{{ index + 1 }}</span>
|
||||
<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">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-content-4"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||||
<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">
|
||||
@ -37,13 +49,13 @@
|
||||
|
||||
<slot name="actions">
|
||||
<button v-if="showLike" @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>
|
||||
<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) ? '已下载' : '下载'">
|
||||
<svg v-if="download.isDownloading(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="animate-spin"><path d="M21 12a9 9 0 11-6.22-8.56"/></svg>
|
||||
<svg v-else-if="download.isDownloaded(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-accent-text"><polyline points="20 6 9 17 4 12"/></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="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
<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>
|
||||
@ -60,6 +72,12 @@ import { useDownload } from '../composables/useDownload';
|
||||
import { getCoverUrl, 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();
|
||||
|
||||
55
src/components/TitleBar.vue
Normal file
55
src/components/TitleBar.vue
Normal file
@ -0,0 +1,55 @@
|
||||
<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 { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { useSettingsStore } from '../stores/settings';
|
||||
|
||||
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 };
|
||||
});
|
||||
|
||||
const currentWindow = getCurrentWindow();
|
||||
|
||||
function minimizeWindow() {
|
||||
currentWindow.minimize();
|
||||
}
|
||||
|
||||
async function toggleMaximize() {
|
||||
const isMaximized = await currentWindow.isMaximized();
|
||||
if (isMaximized) {
|
||||
currentWindow.unmaximize();
|
||||
} else {
|
||||
currentWindow.maximize();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@ -5,7 +5,7 @@
|
||||
<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">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-accent-text"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
<IconDownload class="w-5 h-5 text-accent-text" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-content">发现新版本</h2>
|
||||
@ -54,6 +54,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { UpdateInfo } from '../composables/useUpdater'
|
||||
import IconDownload from '~icons/lucide/download'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { ref, watch } from 'vue';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { parseLrc, mergeTranslation, getCurrentLyricIndex, LyricLine } from '../utils/lyric';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { MusicApi } from '../api';
|
||||
|
||||
export function useLyric() {
|
||||
const player = usePlayerStore();
|
||||
@ -19,7 +19,7 @@ export function useLyric() {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const jsonStr: string = await invoke('get_lyric', { id: song.id });
|
||||
const jsonStr: string = await MusicApi.getLyric(song.id);
|
||||
const data = JSON.parse(jsonStr);
|
||||
const lrc = data?.lrc?.lyric || '';
|
||||
const tLrc = data?.tlyric?.lyric || '';
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { reactive, watch } from 'vue';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
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;
|
||||
@ -42,12 +42,12 @@ async function setupDownloadListener() {
|
||||
async function refreshLocalIds() {
|
||||
try {
|
||||
const settings = useSettingsStore();
|
||||
const list: { id: number }[] = await invoke('list_local_songs', { downloadPath: settings.downloadPath || null });
|
||||
const list: { id: number }[] = await DownloadApi.listLocalSongs(settings.downloadPath || null);
|
||||
localSongIds.clear();
|
||||
for (const s of list) {
|
||||
localSongIds.add(s.id);
|
||||
}
|
||||
} catch {}
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
|
||||
function ensureStoreSetup() {
|
||||
@ -90,8 +90,7 @@ async function downloadSong(song: Song) {
|
||||
tasks.push({ id: song.id, name: song.name, progress: 0 });
|
||||
|
||||
try {
|
||||
await invoke('download_song', {
|
||||
query: {
|
||||
await DownloadApi.downloadSong({
|
||||
id: song.id,
|
||||
name: song.name,
|
||||
artist,
|
||||
@ -100,7 +99,6 @@ async function downloadSong(song: Song) {
|
||||
coverUrl,
|
||||
level: settings.audioQuality,
|
||||
downloadPath: settings.downloadPath || null,
|
||||
},
|
||||
});
|
||||
localSongIds.add(song.id);
|
||||
} catch (e: any) {
|
||||
|
||||
52
src/composables/useLocalMusic.ts
Normal file
52
src/composables/useLocalMusic.ts
Normal file
@ -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 { /* 忽略 */ }
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
const cache = new Map<string, { data: any; ts: number }>();
|
||||
const TTL = 5 * 60 * 1000;
|
||||
const TTL = 30 * 60 * 1000;
|
||||
const MAX_ENTRIES = 30;
|
||||
|
||||
export function pageCacheGet(key: string): any | null {
|
||||
const entry = cache.get(key);
|
||||
@ -12,6 +13,11 @@ export function pageCacheGet(key: string): any | null {
|
||||
}
|
||||
|
||||
export function pageCacheSet(key: string, data: any) {
|
||||
if (cache.size >= MAX_ENTRIES && !cache.has(key)) {
|
||||
// 淘汰最旧的条目
|
||||
const firstKey = cache.keys().next().value;
|
||||
if (firstKey !== undefined) cache.delete(firstKey);
|
||||
}
|
||||
cache.set(key, { data, ts: Date.now() });
|
||||
}
|
||||
|
||||
@ -22,3 +28,9 @@ export function pageCacheDelete(key: string) {
|
||||
export function pageCacheInvalidate(key: string) {
|
||||
cache.delete(key);
|
||||
}
|
||||
|
||||
export function pageCacheIsStale(key: string): boolean {
|
||||
const entry = cache.get(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(() => {
|
||||
|
||||
@ -39,7 +39,7 @@ export function useUpdater() {
|
||||
function setIgnoredVersion(version: string) {
|
||||
try {
|
||||
localStorage.setItem(IGNORED_VERSION_KEY, version)
|
||||
} catch {}
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
|
||||
async function checkForUpdate(silent = false): Promise<UpdateInfo | null> {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -1,28 +1,20 @@
|
||||
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 LocalMusic from '@/views/LocalMusic.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: '/local-music', name: 'local-music', component: LocalMusic },
|
||||
{ path: '/login', name: 'login', component: Login, meta: { guest: true } },
|
||||
{ path: '/playlist/:id', name: 'playlist', component: PlaylistDetail },
|
||||
{ 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: Settings },
|
||||
{ path: '/settings', name: 'settings', component: () => import('@/views/Settings.vue') },
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
@ -32,12 +24,12 @@ const router = createRouter({
|
||||
|
||||
router.beforeEach((to) => {
|
||||
if (to.meta.guest) {
|
||||
const raw = localStorage.getItem('user');
|
||||
const raw = localStorage.getItem('user_profile');
|
||||
if (raw) {
|
||||
try {
|
||||
const data = JSON.parse(raw);
|
||||
if (data?.userId) return { name: 'home' };
|
||||
} catch {}
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
5
src/shims-icons.d.ts
vendored
Normal file
5
src/shims-icons.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
declare module '~icons/lucide/*' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
152
src/skins.ts
Normal file
152
src/skins.ts
Normal file
@ -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]);
|
||||
}
|
||||
}
|
||||
@ -1,10 +1,10 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, watch, nextTick } from 'vue';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { ref, watch } from 'vue';
|
||||
import { normalizeSong, type Song } from '../utils/song';
|
||||
import { useSettingsStore } from './settings';
|
||||
import { useUserStore } from './user';
|
||||
import { showToast } from '../composables/useToast';
|
||||
import { MusicApi, AudioApi } from '../api';
|
||||
|
||||
export type PlayMode = 'loop' | 'shuffle' | 'repeat-one';
|
||||
export type { Song };
|
||||
@ -16,18 +16,10 @@ function loadRecentLocal(): Song[] {
|
||||
try {
|
||||
const raw = localStorage.getItem('recent_local');
|
||||
if (raw) return JSON.parse(raw);
|
||||
} catch {}
|
||||
} catch { /* 忽略 */ }
|
||||
return [];
|
||||
}
|
||||
|
||||
function loadLikedIdsFromStorage(): Set<number> {
|
||||
try {
|
||||
const raw = localStorage.getItem('liked_ids');
|
||||
if (raw) return new Set(JSON.parse(raw));
|
||||
} catch {}
|
||||
return new Set();
|
||||
}
|
||||
|
||||
export const usePlayerStore = defineStore('player', () => {
|
||||
const currentSong = ref<Song | null>(null);
|
||||
const playing = ref(false);
|
||||
@ -43,12 +35,18 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
watch(volume, (val) => { settings.volume = val; });
|
||||
|
||||
let tickInterval: ReturnType<typeof setInterval> | null = null;
|
||||
function setTickInterval(v: ReturnType<typeof setInterval> | null) { _tickInterval = v; tickInterval = v; }
|
||||
function clearTick() {
|
||||
if (tickInterval) { clearInterval(tickInterval); tickInterval = null; }
|
||||
}
|
||||
function setTick(v: ReturnType<typeof setInterval>) {
|
||||
tickInterval = v;
|
||||
}
|
||||
|
||||
const recentLocal = ref<Song[]>(loadRecentLocal());
|
||||
const MAX_RECENT = 200;
|
||||
let recentLocalTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const likedIds = ref<Set<number>>(loadLikedIdsFromStorage());
|
||||
const likedIds = ref<Set<number>>(new Set());
|
||||
|
||||
function emitPlaybackState() {
|
||||
const song = currentSong.value;
|
||||
@ -73,18 +71,20 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
const userStore = useUserStore();
|
||||
if (!userStore.isLoggedIn) return;
|
||||
try {
|
||||
const json: string = await invoke('likelist', { uid: userStore.user!.userId });
|
||||
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 { /* 忽略 */ }
|
||||
} catch (e) {
|
||||
console.error('加载喜欢列表失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleLike(songId: number) {
|
||||
const wasLiked = likedIds.value.has(songId);
|
||||
const newLike = !wasLiked;
|
||||
try {
|
||||
await invoke('like_song', { query: { id: songId, like: newLike ? 'true' : 'false' } });
|
||||
await MusicApi.likeSong(songId, newLike);
|
||||
if (newLike) {
|
||||
likedIds.value.add(songId);
|
||||
} else {
|
||||
@ -103,16 +103,58 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
}
|
||||
|
||||
watch(recentLocal, (val) => {
|
||||
clearTimeout(recentLocalTimer);
|
||||
recentLocalTimer = setTimeout(() => {
|
||||
localStorage.setItem('recent_local', JSON.stringify(val));
|
||||
}, { deep: true });
|
||||
|
||||
watch(likedIds, (val) => {
|
||||
localStorage.setItem('liked_ids', JSON.stringify([...val]));
|
||||
}, 2000);
|
||||
}, { deep: true });
|
||||
|
||||
const isFmMode = ref(false);
|
||||
const fmQueue: Song[] = [];
|
||||
let fmNextCallback: (() => void) | null = null;
|
||||
|
||||
const fmMode = ref<string>('DEFAULT');
|
||||
const fmSubMode = ref<string>('');
|
||||
|
||||
let lastScrobbleId: number | null = null;
|
||||
let lastScrobbleStartTime: number = 0;
|
||||
let lastScrobbleAlg: string | undefined;
|
||||
let lastScrobbleSource: string | undefined;
|
||||
let lastScrobbleBitrate: number | undefined;
|
||||
|
||||
/// 上报上一首歌的听歌记录(scrobble),然后记录当前歌的开始时间
|
||||
function reportScrobble() {
|
||||
// 先上报:如果有正在记录的歌曲且播放超过 5 秒,发送 scrobble
|
||||
if (lastScrobbleId != null && lastScrobbleStartTime > 0) {
|
||||
const playedSec = Math.round((Date.now() - lastScrobbleStartTime) / 1000);
|
||||
if (playedSec > 5 && navigator.onLine) {
|
||||
MusicApi.scrobble({
|
||||
id: lastScrobbleId,
|
||||
sourceid: isFmMode.value ? String(lastScrobbleId) : '',
|
||||
time: playedSec,
|
||||
alg: lastScrobbleAlg || '',
|
||||
source: lastScrobbleSource || 'list',
|
||||
bitrate: lastScrobbleBitrate || 0,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
// 再记录:当前歌曲作为新的 scrobble 起点
|
||||
const song = currentSong.value;
|
||||
if (!song || song.localPath || song.id == null) {
|
||||
lastScrobbleId = null;
|
||||
lastScrobbleStartTime = 0;
|
||||
lastScrobbleAlg = undefined;
|
||||
lastScrobbleSource = undefined;
|
||||
lastScrobbleBitrate = undefined;
|
||||
} else {
|
||||
lastScrobbleId = song.id;
|
||||
lastScrobbleStartTime = Date.now();
|
||||
lastScrobbleAlg = song.alg;
|
||||
lastScrobbleSource = isFmMode.value ? 'personal_fm' : 'list';
|
||||
lastScrobbleBitrate = song.br;
|
||||
}
|
||||
}
|
||||
|
||||
function enableFmMode(onNext: () => void) {
|
||||
isFmMode.value = true;
|
||||
fmNextCallback = onNext;
|
||||
@ -121,16 +163,53 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
function disableFmMode() {
|
||||
isFmMode.value = false;
|
||||
fmNextCallback = null;
|
||||
fmQueue.length = 0;
|
||||
fmMode.value = 'DEFAULT';
|
||||
fmSubMode.value = '';
|
||||
fmSong.value = null;
|
||||
fmPlaying.value = false;
|
||||
}
|
||||
|
||||
function clearFmQueue() {
|
||||
fmQueue.length = 0;
|
||||
}
|
||||
|
||||
async function fmTrash(songId: number) {
|
||||
try {
|
||||
await MusicApi.fmTrash(songId, 25);
|
||||
} catch (e) {
|
||||
console.error('fm_trash 失败', e);
|
||||
showToast('减少推荐失败', 'error');
|
||||
}
|
||||
await nextFm();
|
||||
}
|
||||
|
||||
async function fetchFmBatch(): Promise<Song[]> {
|
||||
const isDefault = fmMode.value === 'DEFAULT' && !fmSubMode.value;
|
||||
const jsonStr: string = isDefault
|
||||
? await MusicApi.personalFm()
|
||||
: await MusicApi.personalFmMode({
|
||||
mode: fmMode.value,
|
||||
subMode: fmSubMode.value,
|
||||
limit: 3,
|
||||
});
|
||||
const data = JSON.parse(jsonStr);
|
||||
const raw = data.data || data;
|
||||
if (!Array.isArray(raw) || raw.length === 0) return [];
|
||||
return raw.map((s: any) => normalizeSong(s));
|
||||
}
|
||||
|
||||
let fmVipSkipCount = 0;
|
||||
const MAX_FM_VIP_SKIP = 10;
|
||||
|
||||
async function playFmSong(song: Song) {
|
||||
if (tickInterval) { clearInterval(tickInterval); setTickInterval(null); }
|
||||
const seq = ++_playSeq;
|
||||
_switchingSong = true;
|
||||
clearTick();
|
||||
reportScrobble();
|
||||
if (!song.dt || song.dt === 0) {
|
||||
try {
|
||||
const jsonStr: string = await invoke('get_song_detail', { id: String(song.id) });
|
||||
const jsonStr = await MusicApi.getSongDetail(String(song.id));
|
||||
const data = JSON.parse(jsonStr);
|
||||
const full = data.songs?.[0];
|
||||
if (full) {
|
||||
@ -138,10 +217,10 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
song.al = full.al || song.al;
|
||||
song.ar = full.ar || song.ar;
|
||||
}
|
||||
} catch (e) { /* 忽略 */ }
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
|
||||
await invoke('stop_audio');
|
||||
await AudioApi.stopAudio();
|
||||
queue.value = [];
|
||||
currentIndex.value = -1;
|
||||
playing.value = false;
|
||||
@ -149,10 +228,33 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
fmSong.value = song;
|
||||
currentSong.value = song;
|
||||
try {
|
||||
const jsonStr: string = await invoke('get_song_url', { query: { id: Number(song.id), level: settings.audioQuality, fm_mode: true } });
|
||||
const jsonStr = await MusicApi.getSongUrl({ id: Number(song.id), level: settings.audioQuality, fm_mode: true });
|
||||
if (seq !== _playSeq) return;
|
||||
const data = JSON.parse(jsonStr);
|
||||
const url: string | undefined = data.url;
|
||||
if (!url) throw new Error('无播放源');
|
||||
if (!url) {
|
||||
const fee = data.fee;
|
||||
if (fee === 4) {
|
||||
showToast(`${song.name} 为数字专辑,已跳过`, 'info');
|
||||
} else if (fee === 1) {
|
||||
showToast(`${song.name} 为 VIP 专属歌曲,已跳过`, 'info');
|
||||
} else {
|
||||
showToast(`${song.name} 暂无播放源`, 'info');
|
||||
}
|
||||
fmVipSkipCount++;
|
||||
if (fmVipSkipCount >= MAX_FM_VIP_SKIP) {
|
||||
fmVipSkipCount = 0;
|
||||
disableFmMode();
|
||||
return;
|
||||
}
|
||||
_switchingSong = false;
|
||||
if (fmNextCallback) {
|
||||
fmNextCallback();
|
||||
} else {
|
||||
disableFmMode();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.freeTrialInfo) {
|
||||
console.warn('FM VIP 试听歌曲,自动跳过', song.name);
|
||||
@ -164,6 +266,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
disableFmMode();
|
||||
return;
|
||||
}
|
||||
_switchingSong = false;
|
||||
if (fmNextCallback) {
|
||||
fmNextCallback();
|
||||
} else {
|
||||
@ -173,22 +276,36 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
}
|
||||
|
||||
fmVipSkipCount = 0;
|
||||
await invoke('play_audio', { url });
|
||||
await waitForAudioStart();
|
||||
await AudioApi.playAudio(url);
|
||||
if (seq !== _playSeq) return;
|
||||
const started = await waitForPlaybackStart();
|
||||
if (seq !== _playSeq) return;
|
||||
if (started) {
|
||||
playing.value = true;
|
||||
duration.value = (song.dt || 0) / 1000;
|
||||
currentTime.value = 0;
|
||||
startTick();
|
||||
addRecent(song);
|
||||
emitPlaybackState();
|
||||
} else {
|
||||
playing.value = false;
|
||||
showToast('FM 播放启动超时,仍在尝试加载…', 'info');
|
||||
watchForLatePlayback(seq, song);
|
||||
}
|
||||
} catch (e) {
|
||||
if (seq !== _playSeq) return;
|
||||
console.error('FM播放失败', e);
|
||||
playing.value = false;
|
||||
showToast('FM 播放失败', 'error');
|
||||
if (fmNextCallback) {
|
||||
fmNextCallback();
|
||||
} else {
|
||||
disableFmMode();
|
||||
}
|
||||
} finally {
|
||||
if (seq === _playSeq) {
|
||||
_switchingSong = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -198,7 +315,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
const idx = queue.value.findIndex(s => s.id === song.id);
|
||||
if (idx !== -1 && idx === currentIndex.value && currentSong.value?.id === song.id) {
|
||||
if (!playing.value) {
|
||||
await invoke('resume_audio');
|
||||
await AudioApi.resumeAudio();
|
||||
playing.value = true;
|
||||
startTick();
|
||||
}
|
||||
@ -224,7 +341,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
&& queue.value.every((s, i) => s.id === songs[i].id);
|
||||
if (sameQueue) {
|
||||
if (!playing.value) {
|
||||
await invoke('resume_audio');
|
||||
await AudioApi.resumeAudio();
|
||||
playing.value = true;
|
||||
startTick();
|
||||
}
|
||||
@ -240,17 +357,31 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
let vipSkipCount = 0;
|
||||
const MAX_VIP_SKIP = 10;
|
||||
|
||||
function waitForAudioStart(): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
_audioStartedResolve = resolve;
|
||||
});
|
||||
let _playSeq = 0;
|
||||
let _switchingSong = false;
|
||||
|
||||
async function waitForPlaybackStart(timeoutMs: number = 5000): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
try {
|
||||
if (await AudioApi.isAudioPlaying()) return true;
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
try {
|
||||
return await AudioApi.isAudioPlaying();
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
async function playCurrent() {
|
||||
if (tickInterval) { clearInterval(tickInterval); setTickInterval(null); }
|
||||
const seq = ++_playSeq;
|
||||
_switchingSong = true;
|
||||
clearTick();
|
||||
reportScrobble();
|
||||
const song = queue.value[currentIndex.value];
|
||||
if (!song?.id) {
|
||||
console.error('无效的歌曲数据', song);
|
||||
_switchingSong = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@ -261,21 +392,44 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
duration.value = (song.dt || 0) / 1000;
|
||||
|
||||
if (song.localPath) {
|
||||
await invoke('play_local_audio', { path: song.localPath });
|
||||
await waitForAudioStart();
|
||||
await AudioApi.playLocalAudio(song.localPath);
|
||||
if (seq !== _playSeq) return;
|
||||
const started = await waitForPlaybackStart();
|
||||
if (seq !== _playSeq) return;
|
||||
if (started) {
|
||||
playing.value = true;
|
||||
startTick();
|
||||
addRecent(song);
|
||||
emitPlaybackState();
|
||||
} else {
|
||||
showToast('播放启动超时,仍在尝试加载…', 'info');
|
||||
watchForLatePlayback(seq, song);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const jsonStr: string = await invoke('get_song_url', { query: { id: Number(song.id), level: settings.audioQuality } });
|
||||
const jsonStr = await MusicApi.getSongUrl({ id: Number(song.id), level: settings.audioQuality });
|
||||
if (seq !== _playSeq) return;
|
||||
const data = JSON.parse(jsonStr);
|
||||
const url: string | undefined = data.url;
|
||||
|
||||
if (!url) {
|
||||
console.error('未获取到有效播放地址', song);
|
||||
// url 为空:可能是数字专辑/付费歌曲,根据 fee 字段判断
|
||||
const fee = data.fee;
|
||||
if (fee === 4) {
|
||||
showToast(`${song.name} 为数字专辑,需购买后播放`, 'info');
|
||||
} else if (fee === 1) {
|
||||
showToast(`${song.name} 为 VIP 专属歌曲`, 'info');
|
||||
} else {
|
||||
showToast(`${song.name} 暂无播放源`, 'info');
|
||||
}
|
||||
vipSkipCount++;
|
||||
if (vipSkipCount >= MAX_VIP_SKIP) {
|
||||
vipSkipCount = 0;
|
||||
return;
|
||||
}
|
||||
_switchingSong = false;
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -288,40 +442,85 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
vipSkipCount = 0;
|
||||
return;
|
||||
}
|
||||
_switchingSong = false;
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
await invoke('play_audio', { url });
|
||||
await waitForAudioStart();
|
||||
await AudioApi.playAudio(url);
|
||||
if (seq !== _playSeq) return;
|
||||
const started = await waitForPlaybackStart();
|
||||
if (seq !== _playSeq) return;
|
||||
if (started) {
|
||||
playing.value = true;
|
||||
startTick();
|
||||
addRecent(song);
|
||||
vipSkipCount = 0;
|
||||
emitPlaybackState();
|
||||
} else {
|
||||
playing.value = false;
|
||||
showToast('播放启动超时,仍在尝试加载…', 'info');
|
||||
watchForLatePlayback(seq, song);
|
||||
}
|
||||
} catch (e) {
|
||||
if (seq !== _playSeq) return;
|
||||
console.error('播放失败', e);
|
||||
playing.value = false;
|
||||
showToast('播放失败,请稍后重试', 'error');
|
||||
} finally {
|
||||
if (seq === _playSeq) {
|
||||
_switchingSong = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 超时后继续监听后端播放状态,如果后端实际开始播放则恢复状态
|
||||
function watchForLatePlayback(seq: number, song: Song) {
|
||||
let attempts = 0;
|
||||
const maxAttempts = 15;
|
||||
const check = async () => {
|
||||
if (seq !== _playSeq) return;
|
||||
if (playing.value) return;
|
||||
attempts++;
|
||||
if (attempts > maxAttempts) return;
|
||||
try {
|
||||
const backendPlaying = await AudioApi.isAudioPlaying();
|
||||
if (seq !== _playSeq) return;
|
||||
if (backendPlaying) {
|
||||
playing.value = true;
|
||||
startTick();
|
||||
addRecent(song);
|
||||
vipSkipCount = 0;
|
||||
emitPlaybackState();
|
||||
return;
|
||||
}
|
||||
} catch { /* 忽略 */ }
|
||||
if (seq === _playSeq && !playing.value) {
|
||||
setTimeout(check, 1000);
|
||||
}
|
||||
};
|
||||
setTimeout(check, 1000);
|
||||
}
|
||||
|
||||
let onSeekStart: (() => void) | null = null;
|
||||
|
||||
function startTick() {
|
||||
if (tickInterval) clearInterval(tickInterval);
|
||||
clearTick();
|
||||
let seekGuard = false;
|
||||
onSeekStart = () => { seekGuard = true; };
|
||||
let syncCounter = 1;
|
||||
let lastSyncPos = -1;
|
||||
let backendFrozen = false;
|
||||
setTickInterval(setInterval(async () => {
|
||||
let stateSyncCounter = 0;
|
||||
setTick(setInterval(async () => {
|
||||
if (playing.value && duration.value > 0) {
|
||||
if (seekGuard) return;
|
||||
syncCounter++;
|
||||
stateSyncCounter++;
|
||||
if (syncCounter >= 2) {
|
||||
syncCounter = 0;
|
||||
try {
|
||||
const pos = await invoke<number>('get_audio_position');
|
||||
const pos = await AudioApi.getAudioPosition();
|
||||
if (pos >= currentTime.value - 0.5) {
|
||||
currentTime.value = pos;
|
||||
}
|
||||
@ -334,10 +533,20 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
backendFrozen = false;
|
||||
lastSyncPos = pos;
|
||||
}
|
||||
} catch {}
|
||||
} catch { /* 忽略 */ }
|
||||
|
||||
if (stateSyncCounter >= 4) {
|
||||
stateSyncCounter = 0;
|
||||
try {
|
||||
const backendPlaying = await AudioApi.isAudioPlaying();
|
||||
if (backendPlaying !== playing.value) {
|
||||
playing.value = backendPlaying;
|
||||
}
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
} else {
|
||||
if (!backendFrozen) {
|
||||
const next = currentTime.value + 0.25;
|
||||
const next = currentTime.value + 0.5;
|
||||
if (next <= duration.value) {
|
||||
currentTime.value = next;
|
||||
}
|
||||
@ -347,26 +556,33 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
currentTime.value = duration.value;
|
||||
}
|
||||
}
|
||||
}, 250));
|
||||
}, 500));
|
||||
}
|
||||
|
||||
async function toggle() {
|
||||
try {
|
||||
const backendPlaying = await AudioApi.isAudioPlaying();
|
||||
if (backendPlaying !== playing.value) {
|
||||
playing.value = backendPlaying;
|
||||
}
|
||||
} catch { /* 忽略查询失败 */ }
|
||||
|
||||
if (playing.value) {
|
||||
await invoke('pause_audio');
|
||||
await AudioApi.pauseAudio();
|
||||
playing.value = false;
|
||||
} else {
|
||||
await invoke('resume_audio');
|
||||
await AudioApi.resumeAudio();
|
||||
playing.value = true;
|
||||
}
|
||||
emitPlaybackState();
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
await invoke('stop_audio');
|
||||
await AudioApi.stopAudio();
|
||||
playing.value = false;
|
||||
currentSong.value = null;
|
||||
currentTime.value = 0;
|
||||
if (tickInterval) { clearInterval(tickInterval); setTickInterval(null); }
|
||||
clearTick();
|
||||
disableFmMode();
|
||||
emitPlaybackState();
|
||||
}
|
||||
@ -417,7 +633,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
try {
|
||||
currentTime.value = time;
|
||||
if (onSeekStart) onSeekStart();
|
||||
await invoke('seek_audio', { time });
|
||||
await AudioApi.seekAudio(time);
|
||||
startTick();
|
||||
emitPlaybackState();
|
||||
} catch (e) {
|
||||
@ -428,7 +644,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
async function adjustVolume(delta: number) {
|
||||
const newVol = Math.max(0, Math.min(100, volume.value + delta));
|
||||
volume.value = newVol;
|
||||
await invoke('set_volume', { vol: newVol / 100 });
|
||||
await AudioApi.setVolume(newVol / 100);
|
||||
emitPlaybackState();
|
||||
}
|
||||
|
||||
@ -470,13 +686,13 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
}
|
||||
|
||||
const showRoamDrawer = ref(false);
|
||||
const roamInitialTab = ref<'lyric' | 'comment'>('lyric');
|
||||
const roamTab = ref<'lyric' | 'comment'>('lyric');
|
||||
const commentSongId = ref<number | null>(null);
|
||||
const dominantColor = ref('');
|
||||
|
||||
function openRoamDrawer(tab: 'lyric' | 'comment' = 'lyric') {
|
||||
roamInitialTab.value = tab;
|
||||
roamTab.value = tab;
|
||||
showRoamDrawer.value = true;
|
||||
nextTick(() => { roamInitialTab.value = 'lyric'; });
|
||||
}
|
||||
|
||||
function openCommentForSong(songId: number) {
|
||||
@ -486,6 +702,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
|
||||
function closeRoamDrawer() {
|
||||
showRoamDrawer.value = false;
|
||||
roamTab.value = 'lyric';
|
||||
}
|
||||
|
||||
function toggleRoamDrawer() {
|
||||
@ -494,40 +711,42 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
|
||||
async function loadFirstFmSong() {
|
||||
try {
|
||||
const jsonStr: string = await invoke('personal_fm');
|
||||
const data = JSON.parse(jsonStr);
|
||||
const songs = data.data || data;
|
||||
if (songs && songs.length > 0) {
|
||||
const song = normalizeSong(songs[0]);
|
||||
enableFmMode(() => loadFirstFmSong());
|
||||
const batch = await fetchFmBatch();
|
||||
if (batch.length > 0) {
|
||||
fmQueue.push(...batch);
|
||||
const song = fmQueue.shift()!;
|
||||
enableFmMode(nextFm);
|
||||
await playFmSong(song);
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast('FM 加载失败', 'error');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// -------- FM 专属状态 --------
|
||||
const fmSong = ref<Song | null>(null);
|
||||
const fmPlaying = ref(false);
|
||||
|
||||
async function loadFm() {
|
||||
try {
|
||||
const jsonStr: string = await invoke('personal_fm');
|
||||
const data = JSON.parse(jsonStr);
|
||||
const songs = data.data || data;
|
||||
if (songs && songs.length > 0) {
|
||||
const song = normalizeSong(songs[0]);
|
||||
if (fmQueue.length === 0) {
|
||||
const batch = await fetchFmBatch();
|
||||
if (batch.length === 0) return;
|
||||
fmQueue.push(...batch);
|
||||
}
|
||||
const song = fmQueue.shift()!;
|
||||
fmSong.value = song;
|
||||
enableFmMode(nextFm);
|
||||
await playFmSong(song);
|
||||
fmPlaying.value = true;
|
||||
if (fmQueue.length <= 1) {
|
||||
fetchFmBatch().then(batch => { fmQueue.push(...batch); }).catch(() => {});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('FM加载失败', e);
|
||||
showToast('FM 加载失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@ -552,19 +771,11 @@ async function nextFm() {
|
||||
await loadFm();
|
||||
}
|
||||
|
||||
let _audioStartedResolve: (() => void) | null = null;
|
||||
let _tickInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
listen('audio-started', () => {
|
||||
if (_audioStartedResolve) {
|
||||
_audioStartedResolve();
|
||||
_audioStartedResolve = null;
|
||||
}
|
||||
});
|
||||
|
||||
listen('audio-ended', () => {
|
||||
if (_tickInterval) { clearInterval(_tickInterval); _tickInterval = null; }
|
||||
if (_switchingSong) return;
|
||||
const player = usePlayerStore();
|
||||
player.clearTick();
|
||||
player.reportScrobble();
|
||||
player.next();
|
||||
});
|
||||
|
||||
@ -587,7 +798,7 @@ listen<string>('mpris-command', (event) => {
|
||||
const vol = parseFloat(cmd.slice(10));
|
||||
if (!isNaN(vol)) {
|
||||
player.volume = Math.round(vol * 100);
|
||||
invoke('set_volume', { vol }).catch(() => {});
|
||||
AudioApi.setVolume(vol).catch(() => {});
|
||||
}
|
||||
} else if (cmd.startsWith('Seek:')) {
|
||||
const offsetUs = parseInt(cmd.slice(5), 10);
|
||||
@ -597,6 +808,9 @@ listen<string>('mpris-command', (event) => {
|
||||
} else if (cmd.startsWith('SetPosition:')) {
|
||||
const posUs = parseInt(cmd.slice(13), 10);
|
||||
const posSec = posUs / 1_000_000;
|
||||
if (posSec < 1 && player.currentTime > 5) {
|
||||
return;
|
||||
}
|
||||
player.seek(posSec);
|
||||
} else if (cmd === 'Raise') {
|
||||
getCurrentWindow().show().catch(() => {});
|
||||
@ -658,16 +872,25 @@ watch(playing, (val) => {
|
||||
toggleLike,
|
||||
|
||||
showRoamDrawer,
|
||||
roamInitialTab,
|
||||
roamTab,
|
||||
commentSongId,
|
||||
dominantColor,
|
||||
openCommentForSong,
|
||||
openRoamDrawer,
|
||||
closeRoamDrawer,
|
||||
toggleRoamDrawer,
|
||||
loadFirstFmSong,
|
||||
|
||||
fetchFmBatch,
|
||||
clearFmQueue,
|
||||
fmTrash,
|
||||
reportScrobble,
|
||||
clearTick,
|
||||
|
||||
fmSong,
|
||||
fmPlaying,
|
||||
fmMode,
|
||||
fmSubMode,
|
||||
loadFm,
|
||||
toggleFm,
|
||||
nextFm,
|
||||
|
||||
@ -1,30 +1,10 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, watch } from 'vue';
|
||||
import { ref, watch, computed } from 'vue';
|
||||
import { getPresetSkin, isPresetSkinId, applySkinColors, type SkinColors } from '../skins';
|
||||
|
||||
export type AudioQuality = 'standard' | 'higher' | 'exhigh' | 'lossless' | 'hires';
|
||||
export type ThemeName = 'blue' | 'green' | 'rose' | 'violet' | 'orange' | 'cyan' | 'pink';
|
||||
export type CloseAction = 'ask' | 'minimize' | 'exit';
|
||||
|
||||
export const themeLabels: Record<ThemeName, string> = {
|
||||
blue: '天蓝',
|
||||
green: '翠绿',
|
||||
rose: '玫红',
|
||||
violet: '紫罗兰',
|
||||
orange: '橙色',
|
||||
cyan: '青色',
|
||||
pink: '粉色',
|
||||
};
|
||||
|
||||
export const themeColors: Record<ThemeName, string> = {
|
||||
blue: '#3b82f6',
|
||||
green: '#22c55e',
|
||||
rose: '#f43f5e',
|
||||
violet: '#8b5cf6',
|
||||
orange: '#f97316',
|
||||
cyan: '#06b6d4',
|
||||
pink: '#ec4899',
|
||||
};
|
||||
|
||||
export const qualityLabels: Record<AudioQuality, string> = {
|
||||
standard: '标准',
|
||||
higher: '较高',
|
||||
@ -57,10 +37,31 @@ export const defaultShortcuts: Record<string, ShortcutBinding> = {
|
||||
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: ThemeName;
|
||||
localMusicPaths: string[]; // 旧格式,迁移用
|
||||
localMusicFolders: MusicFolder[];
|
||||
skin: string; // 预设皮肤 id 或 custom-xxx
|
||||
customSkins: CustomSkin[];
|
||||
closeAction: CloseAction;
|
||||
shortcuts: Record<string, ShortcutBinding>;
|
||||
outputDevice: string | null;
|
||||
@ -72,23 +73,57 @@ function loadSettings(): SettingsData {
|
||||
const raw = localStorage.getItem('app_settings');
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
const theme = parsed.theme || parsed.accentColor || 'blue';
|
||||
const validThemes: ThemeName[] = ['blue', 'green', 'rose', 'violet', 'orange', 'cyan', 'pink'];
|
||||
|
||||
// 迁移旧版 theme + appearance → skin
|
||||
let skin = parsed.skin || 'dark-blue';
|
||||
if (!parsed.skin && (parsed.theme || parsed.appearance)) {
|
||||
const appearance = parsed.appearance || 'dark';
|
||||
const theme = parsed.theme || 'blue';
|
||||
const validThemes = ['blue', 'green', 'rose', 'violet', 'orange', 'cyan', 'pink'];
|
||||
const t = validThemes.includes(theme) ? theme : 'blue';
|
||||
skin = appearance === 'light' ? `light-${t}` : `dark-${t}`;
|
||||
}
|
||||
|
||||
// 迁移旧版全局壁纸 → 移入自定义皮肤(如果有自定义皮肤且没有壁纸)
|
||||
let customSkins = parsed.customSkins || [];
|
||||
if (parsed.wallpaper && customSkins.length > 0) {
|
||||
customSkins = customSkins.map((s: any) => {
|
||||
if (!s.wallpaper) {
|
||||
return { ...s, wallpaper: parsed.wallpaper, wallpaperBlur: parsed.wallpaperBlur ?? 10, wallpaperOpacity: parsed.wallpaperOpacity ?? 0.3 };
|
||||
}
|
||||
return s;
|
||||
});
|
||||
}
|
||||
|
||||
// 迁移旧格式 localMusicPaths → localMusicFolders
|
||||
let folders: MusicFolder[] = (parsed.localMusicFolders || []).map((f: any) =>
|
||||
typeof f === 'string' ? { path: f, enabled: true } : f
|
||||
);
|
||||
if (!parsed.localMusicFolders && parsed.localMusicPaths?.length) {
|
||||
folders = parsed.localMusicPaths.map((p: string) => ({ path: p, enabled: true }));
|
||||
}
|
||||
|
||||
return {
|
||||
audioQuality: parsed.audioQuality || 'standard',
|
||||
downloadPath: parsed.downloadPath || '',
|
||||
theme: validThemes.includes(theme) ? theme : 'blue',
|
||||
localMusicPaths: [],
|
||||
localMusicFolders: folders,
|
||||
skin,
|
||||
customSkins,
|
||||
closeAction: parsed.closeAction || 'ask',
|
||||
shortcuts: { ...defaultShortcuts, ...(parsed.shortcuts || {}) },
|
||||
outputDevice: parsed.outputDevice || null,
|
||||
volume: typeof parsed.volume === 'number' ? parsed.volume : 100,
|
||||
};
|
||||
}
|
||||
} catch {}
|
||||
} catch { /* 忽略 */ }
|
||||
return {
|
||||
audioQuality: 'standard',
|
||||
downloadPath: '',
|
||||
theme: 'blue',
|
||||
localMusicPaths: [],
|
||||
localMusicFolders: [],
|
||||
skin: 'dark-blue',
|
||||
customSkins: [],
|
||||
closeAction: 'ask',
|
||||
shortcuts: { ...defaultShortcuts },
|
||||
outputDevice: null,
|
||||
@ -101,12 +136,99 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
|
||||
const audioQuality = ref<AudioQuality>(saved.audioQuality);
|
||||
const downloadPath = ref<string>(saved.downloadPath);
|
||||
const theme = ref<ThemeName>(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 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
|
||||
);
|
||||
// 如果正在使用该皮肤,立即刷新 CSS 变量
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
/** 应用当前皮肤到 DOM(统一通过 JS 设置 CSS 变量) */
|
||||
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;
|
||||
}
|
||||
@ -115,9 +237,26 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
downloadPath.value = p;
|
||||
}
|
||||
|
||||
function setTheme(t: ThemeName) {
|
||||
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;
|
||||
@ -138,18 +277,23 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
function resetAll() {
|
||||
audioQuality.value = 'standard';
|
||||
downloadPath.value = '';
|
||||
theme.value = 'blue';
|
||||
localMusicFolders.value = [];
|
||||
skin.value = 'dark-blue';
|
||||
customSkins.value = [];
|
||||
closeAction.value = 'ask';
|
||||
shortcuts.value = { ...defaultShortcuts };
|
||||
outputDevice.value = null;
|
||||
volume.value = 100;
|
||||
}
|
||||
|
||||
watch([audioQuality, downloadPath, theme, closeAction, shortcuts, outputDevice, volume], () => {
|
||||
watch([audioQuality, downloadPath, localMusicFolders, skin, customSkins, closeAction, shortcuts, outputDevice, volume], () => {
|
||||
const data: SettingsData = {
|
||||
audioQuality: audioQuality.value,
|
||||
downloadPath: downloadPath.value,
|
||||
theme: theme.value,
|
||||
localMusicPaths: [],
|
||||
localMusicFolders: localMusicFolders.value,
|
||||
skin: skin.value,
|
||||
customSkins: customSkins.value,
|
||||
closeAction: closeAction.value,
|
||||
shortcuts: shortcuts.value,
|
||||
outputDevice: outputDevice.value,
|
||||
@ -161,14 +305,29 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
return {
|
||||
audioQuality,
|
||||
downloadPath,
|
||||
theme,
|
||||
localMusicFolders,
|
||||
enabledMusicPaths,
|
||||
skin,
|
||||
customSkins,
|
||||
isPreset,
|
||||
currentCustomSkin,
|
||||
currentColors,
|
||||
skinPreview,
|
||||
currentWallpaper,
|
||||
closeAction,
|
||||
shortcuts,
|
||||
outputDevice,
|
||||
volume,
|
||||
setSkin,
|
||||
addCustomSkin,
|
||||
updateCustomSkin,
|
||||
removeCustomSkin,
|
||||
applySkin,
|
||||
setAudioQuality,
|
||||
setDownloadPath,
|
||||
setTheme,
|
||||
addLocalMusicPath,
|
||||
removeLocalMusicPath,
|
||||
toggleLocalMusicFolder,
|
||||
setCloseAction,
|
||||
setOutputDevice,
|
||||
setShortcut,
|
||||
|
||||
@ -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');
|
||||
|
||||
144
src/style.css
144
src/style.css
@ -23,52 +23,8 @@
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root,
|
||||
[data-theme="green"] {
|
||||
--c-bg: #020c06;
|
||||
--c-surface: #0a1a10;
|
||||
--c-subtle: rgba(34, 197, 94, 0.06);
|
||||
--c-muted: rgba(34, 197, 94, 0.10);
|
||||
--c-emphasis: rgba(34, 197, 94, 0.18);
|
||||
--c-content: #ffffff;
|
||||
--c-content-2: #9ca3af;
|
||||
--c-content-3: #6b7280;
|
||||
--c-content-4: #4b5563;
|
||||
--c-line: rgba(255, 255, 255, 0.08);
|
||||
--c-line-2: rgba(255, 255, 255, 0.04);
|
||||
--c-accent: #22c55e;
|
||||
--c-accent-hover: #16a34a;
|
||||
--c-accent-text: #4ade80;
|
||||
--c-accent-dim: rgba(34, 197, 94, 0.20);
|
||||
--c-danger: #ef4444;
|
||||
--c-danger-dim: rgba(239, 68, 68, 0.20);
|
||||
--c-warning: #eab308;
|
||||
--c-info: #3b82f6;
|
||||
}
|
||||
|
||||
[data-theme="rose"] {
|
||||
--c-bg: #0c0206;
|
||||
--c-surface: #1a0a10;
|
||||
--c-subtle: rgba(244, 63, 94, 0.06);
|
||||
--c-muted: rgba(244, 63, 94, 0.10);
|
||||
--c-emphasis: rgba(244, 63, 94, 0.18);
|
||||
--c-content: #ffffff;
|
||||
--c-content-2: #9ca3af;
|
||||
--c-content-3: #6b7280;
|
||||
--c-content-4: #4b5563;
|
||||
--c-line: rgba(255, 255, 255, 0.08);
|
||||
--c-line-2: rgba(255, 255, 255, 0.04);
|
||||
--c-accent: #f43f5e;
|
||||
--c-accent-hover: #e11d48;
|
||||
--c-accent-text: #fb7185;
|
||||
--c-accent-dim: rgba(244, 63, 94, 0.20);
|
||||
--c-danger: #ef4444;
|
||||
--c-danger-dim: rgba(239, 68, 68, 0.20);
|
||||
--c-warning: #eab308;
|
||||
--c-info: #3b82f6;
|
||||
}
|
||||
|
||||
[data-theme="blue"] {
|
||||
/* 默认值(首次加载 fallback,JS 会立即覆盖) */
|
||||
:root {
|
||||
--c-bg: #02060c;
|
||||
--c-surface: #0a101a;
|
||||
--c-subtle: rgba(59, 130, 246, 0.06);
|
||||
@ -90,96 +46,7 @@
|
||||
--c-info: #8b5cf6;
|
||||
}
|
||||
|
||||
[data-theme="violet"] {
|
||||
--c-bg: #06020c;
|
||||
--c-surface: #120a1a;
|
||||
--c-subtle: rgba(139, 92, 246, 0.06);
|
||||
--c-muted: rgba(139, 92, 246, 0.10);
|
||||
--c-emphasis: rgba(139, 92, 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.08);
|
||||
--c-line-2: rgba(255, 255, 255, 0.04);
|
||||
--c-accent: #8b5cf6;
|
||||
--c-accent-hover: #7c3aed;
|
||||
--c-accent-text: #a78bfa;
|
||||
--c-accent-dim: rgba(139, 92, 246, 0.20);
|
||||
--c-danger: #ef4444;
|
||||
--c-danger-dim: rgba(239, 68, 68, 0.20);
|
||||
--c-warning: #eab308;
|
||||
--c-info: #3b82f6;
|
||||
}
|
||||
|
||||
[data-theme="orange"] {
|
||||
--c-bg: #0c0602;
|
||||
--c-surface: #1a120a;
|
||||
--c-subtle: rgba(249, 115, 22, 0.06);
|
||||
--c-muted: rgba(249, 115, 22, 0.10);
|
||||
--c-emphasis: rgba(249, 115, 22, 0.18);
|
||||
--c-content: #ffffff;
|
||||
--c-content-2: #9ca3af;
|
||||
--c-content-3: #6b7280;
|
||||
--c-content-4: #4b5563;
|
||||
--c-line: rgba(255, 255, 255, 0.08);
|
||||
--c-line-2: rgba(255, 255, 255, 0.04);
|
||||
--c-accent: #f97316;
|
||||
--c-accent-hover: #ea580c;
|
||||
--c-accent-text: #fb923c;
|
||||
--c-accent-dim: rgba(249, 115, 22, 0.20);
|
||||
--c-danger: #ef4444;
|
||||
--c-danger-dim: rgba(239, 68, 68, 0.20);
|
||||
--c-warning: #eab308;
|
||||
--c-info: #3b82f6;
|
||||
}
|
||||
|
||||
[data-theme="cyan"] {
|
||||
--c-bg: #020c0c;
|
||||
--c-surface: #0a1a1a;
|
||||
--c-subtle: rgba(6, 182, 212, 0.06);
|
||||
--c-muted: rgba(6, 182, 212, 0.10);
|
||||
--c-emphasis: rgba(6, 182, 212, 0.18);
|
||||
--c-content: #ffffff;
|
||||
--c-content-2: #9ca3af;
|
||||
--c-content-3: #6b7280;
|
||||
--c-content-4: #4b5563;
|
||||
--c-line: rgba(255, 255, 255, 0.08);
|
||||
--c-line-2: rgba(255, 255, 255, 0.04);
|
||||
--c-accent: #06b6d4;
|
||||
--c-accent-hover: #0891b2;
|
||||
--c-accent-text: #22d3ee;
|
||||
--c-accent-dim: rgba(6, 182, 212, 0.20);
|
||||
--c-danger: #ef4444;
|
||||
--c-danger-dim: rgba(239, 68, 68, 0.20);
|
||||
--c-warning: #eab308;
|
||||
--c-info: #3b82f6;
|
||||
}
|
||||
|
||||
[data-theme="pink"] {
|
||||
--c-bg: #0c020a;
|
||||
--c-surface: #1a0a16;
|
||||
--c-subtle: rgba(236, 72, 153, 0.06);
|
||||
--c-muted: rgba(236, 72, 153, 0.10);
|
||||
--c-emphasis: rgba(236, 72, 153, 0.18);
|
||||
--c-content: #ffffff;
|
||||
--c-content-2: #9ca3af;
|
||||
--c-content-3: #6b7280;
|
||||
--c-content-4: #4b5563;
|
||||
--c-line: rgba(255, 255, 255, 0.08);
|
||||
--c-line-2: rgba(255, 255, 255, 0.04);
|
||||
--c-accent: #ec4899;
|
||||
--c-accent-hover: #db2777;
|
||||
--c-accent-text: #f472b6;
|
||||
--c-accent-dim: rgba(236, 72, 153, 0.20);
|
||||
--c-danger: #ef4444;
|
||||
--c-danger-dim: rgba(239, 68, 68, 0.20);
|
||||
--c-warning: #eab308;
|
||||
--c-info: #3b82f6;
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--c-bg);
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
overscroll-behavior: none;
|
||||
@ -188,7 +55,6 @@
|
||||
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;
|
||||
@ -212,9 +78,13 @@
|
||||
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);
|
||||
}
|
||||
|
||||
32
src/utils/color.ts
Normal file
32
src/utils/color.ts
Normal file
@ -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}`;
|
||||
}
|
||||
@ -5,6 +5,8 @@ export interface Song {
|
||||
al: { id?: number; picUrl: string; name?: string };
|
||||
dt?: number;
|
||||
localPath?: string;
|
||||
alg?: string;
|
||||
br?: number;
|
||||
}
|
||||
|
||||
export function normalizeSong(song: any): Song {
|
||||
@ -13,7 +15,9 @@ export function normalizeSong(song: any): Song {
|
||||
picUrl: song.al?.picUrl || song.album?.picUrl || '',
|
||||
name: song.al?.name || song.album?.name,
|
||||
};
|
||||
const ar = (song.ar && song.ar.length > 0) ? song.ar : (song.artists || []);
|
||||
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;
|
||||
return {
|
||||
@ -23,6 +27,8 @@ export function normalizeSong(song: any): Song {
|
||||
al,
|
||||
dt,
|
||||
localPath: song.localPath,
|
||||
alg: song.alg || undefined,
|
||||
br: song.br || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@ -33,3 +39,65 @@ export function getCoverUrl(song: Song | null, sizeParam = ''): string {
|
||||
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(' / ') : '未知歌手';
|
||||
}
|
||||
|
||||
export function getAlbumDisplay(song: Song): string {
|
||||
if (!song.al?.id || !song.al?.name) return '未知专辑';
|
||||
return song.al.name;
|
||||
}
|
||||
|
||||
const colorCache = new Map<string, string>();
|
||||
const 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;
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,19 +1,29 @@
|
||||
<template>
|
||||
<div class="p-8 text-content">
|
||||
<button @click="$router.back()" class="mb-4 text-content-2 hover:text-content transition">
|
||||
← 返回
|
||||
</button>
|
||||
<PageHeader />
|
||||
|
||||
<div v-if="album" class="flex gap-6 mb-8">
|
||||
<!-- 头部骨架 -->
|
||||
<div v-if="!album && albumLoading" class="flex gap-6 mb-8">
|
||||
<div class="w-44 h-44 rounded-xl bg-muted animate-pulse flex-shrink-0"></div>
|
||||
<div class="flex-1 space-y-3">
|
||||
<div class="h-7 bg-muted rounded w-1/2 animate-pulse"></div>
|
||||
<div class="h-4 bg-muted rounded w-1/3 animate-pulse"></div>
|
||||
<div class="h-4 bg-muted rounded w-1/4 animate-pulse"></div>
|
||||
<div class="h-10 w-28 bg-muted rounded-full animate-pulse mt-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 头部信息 -->
|
||||
<div v-else-if="album" class="flex gap-6 mb-8">
|
||||
<img :src="album.picUrl" class="w-44 h-44 rounded-xl object-cover shadow-lg flex-shrink-0" />
|
||||
<div class="flex flex-col justify-between min-w-0">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold leading-tight">{{ album.name }}</h1>
|
||||
<div v-if="album.artists?.length" class="flex items-center gap-1 mt-2 text-sm text-content-2">
|
||||
<div v-if="album.artists?.length" class="flex flex-wrap items-center gap-x-1 gap-y-0.5 mt-2 text-sm text-content-2">
|
||||
<template v-for="(ar, idx) in album.artists" :key="ar.id">
|
||||
<span v-if="(idx as number) > 0" class="text-content-3">/</span>
|
||||
<span
|
||||
class="hover:text-accent-text cursor-pointer transition"
|
||||
class="hover:text-accent-text cursor-pointer transition whitespace-nowrap"
|
||||
@click="ar.id && router.push({ name: 'artist', params: { id: ar.id } })"
|
||||
>{{ ar.name }}</span>
|
||||
</template>
|
||||
@ -27,59 +37,59 @@
|
||||
@click="playAll"
|
||||
class="px-5 py-2 bg-accent hover:bg-accent-hover rounded-full text-white font-medium transition flex items-center gap-2"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M4 2.5v11l9-5.5z"/></svg>
|
||||
<IconPlay class="w-4 h-4 fill-current" />
|
||||
播放全部
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-content-2">加载中...</div>
|
||||
<!-- 加载失败 -->
|
||||
<div v-if="loadError" class="flex flex-col items-center justify-center py-16 gap-3">
|
||||
<p class="text-content-2 text-sm">加载失败</p>
|
||||
<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 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"
|
||||
<!-- 歌曲列表骨架 -->
|
||||
<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>
|
||||
|
||||
<!-- 歌曲列表 -->
|
||||
<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
|
||||
:container-class="player.currentSong?.id === song.id ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
|
||||
@click="player.playFromList(songs, index)"
|
||||
>
|
||||
<template #index="{ index: idx, isCurrent }">
|
||||
<div class="w-6 text-right flex-shrink-0 flex items-center justify-end h-5">
|
||||
<div v-if="isCurrent" 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">{{ idx + 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>
|
||||
</div>
|
||||
</template>
|
||||
</SongListItem>
|
||||
</div>
|
||||
@song-click="(_s, i) => player.playFromList(songs, i)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import { ref, onMounted, watch, onActivated } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { MusicApi } from '../api';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { normalizeSong, type Song } from '../utils/song';
|
||||
import { formatDate } from '../utils/format';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
import { pageCacheGet, pageCacheSet } from '../composables/usePageCache';
|
||||
import VirtualSongList from '../components/VirtualSongList.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import IconPlay from '~icons/lucide/play';
|
||||
|
||||
defineOptions({ name: 'AlbumDetailView' });
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@ -87,21 +97,42 @@ const player = usePlayerStore();
|
||||
|
||||
const album = ref<any>(null);
|
||||
const songs = ref<Song[]>([]);
|
||||
const loading = ref(true);
|
||||
const albumLoading = ref(true);
|
||||
const songsLoading = ref(false);
|
||||
const loadError = ref(false);
|
||||
|
||||
async function fetchAlbum(id: number) {
|
||||
loading.value = true;
|
||||
async function fetchAlbum(id: number, force = false) {
|
||||
const cacheKey = `album_${id}`;
|
||||
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 invoke('album_detail', { id });
|
||||
const jsonStr: string = await MusicApi.albumDetail(id);
|
||||
const data = JSON.parse(jsonStr);
|
||||
album.value = data.album;
|
||||
albumLoading.value = false;
|
||||
songs.value = (data.songs || []).map(normalizeSong);
|
||||
songsLoading.value = false;
|
||||
pageCacheSet(cacheKey, { album: album.value, songs: songs.value });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
loadError.value = true;
|
||||
albumLoading.value = false;
|
||||
songsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@ -110,7 +141,11 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
watch(() => route.params.id, (newId) => {
|
||||
if (newId) fetchAlbum(Number(newId));
|
||||
if (newId && route.name === 'album') fetchAlbum(Number(newId));
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
if (loadError.value) fetchAlbum(Number(route.params.id), true);
|
||||
});
|
||||
|
||||
function playAll() {
|
||||
|
||||
@ -1,30 +1,96 @@
|
||||
<template>
|
||||
<div class="p-8 text-content">
|
||||
<button @click="$router.back()" class="mb-4 text-content-2 hover:text-content transition">
|
||||
← 返回
|
||||
</button>
|
||||
<PageHeader />
|
||||
|
||||
<div v-if="artist" class="flex gap-6 mb-8">
|
||||
<img :src="artist.cover" class="w-44 h-44 rounded-xl object-cover shadow-lg flex-shrink-0" />
|
||||
<div class="flex flex-col justify-between min-w-0">
|
||||
<!-- 头部骨架 -->
|
||||
<div v-if="!artist && !songs.length && !albums.length" class="flex gap-6 mb-4">
|
||||
<div class="w-44 h-44 rounded-full bg-muted animate-pulse flex-shrink-0"></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">
|
||||
<!-- 头部:头像 + 简介 -->
|
||||
<div v-if="artist || songs.length || albums.length" class="flex gap-6 mb-4">
|
||||
<img v-if="artistCover" :src="artistCover" class="w-44 h-44 rounded-full object-cover shadow-lg flex-shrink-0" />
|
||||
<div v-else class="w-44 h-44 rounded-full bg-muted flex items-center justify-center flex-shrink-0">
|
||||
<IconMusic class="w-12 h-12 text-content-4" />
|
||||
</div>
|
||||
<div class="flex flex-col min-w-0 flex-1">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold leading-tight">{{ artist.name }}</h1>
|
||||
<p class="text-xs text-content-3 mt-2">
|
||||
{{ formatPlayCount(artist.followeds || 0) }} 粉丝 · {{ artist.musicSize || 0 }} 首歌曲
|
||||
<h1 class="text-2xl font-bold leading-tight">{{ artistName }}</h1>
|
||||
<p v-if="artistFollowers || artist?.musicSize" class="text-xs text-content-3 mt-1">
|
||||
<span v-if="artistFollowers">{{ formatPlayCount(artistFollowers) }} 粉丝</span>
|
||||
<span v-if="artistFollowers && artist?.musicSize"> · </span>
|
||||
<span v-if="artist?.musicSize">{{ artist.musicSize }} 首歌曲</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-4">
|
||||
<div v-if="briefDesc" class="mt-3">
|
||||
<p
|
||||
ref="descEl"
|
||||
class="text-sm text-content-2 leading-relaxed whitespace-pre-wrap overflow-hidden"
|
||||
style="max-height: 4.5em"
|
||||
>{{ briefDesc }}</p>
|
||||
<button
|
||||
v-if="descOverflow"
|
||||
@click="showDescModal = true"
|
||||
class="inline-flex items-center gap-1 text-xs text-accent-text hover:text-accent-text/80 mt-1.5 px-2 py-0.5 rounded-full bg-accent-text/10 transition"
|
||||
>
|
||||
<IconChevronDown class="w-3 h-3" />
|
||||
查看完整介绍
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-auto pt-4">
|
||||
<button
|
||||
@click="playAll"
|
||||
class="px-5 py-2 bg-accent hover:bg-accent-hover rounded-full text-white font-medium transition flex items-center gap-2"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M4 2.5v11l9-5.5z"/></svg>
|
||||
<IconPlay class="w-4 h-4 fill-current" />
|
||||
播放全部
|
||||
</button>
|
||||
<button
|
||||
@click="toggleFollow"
|
||||
:disabled="followLoading"
|
||||
class="px-5 py-2 rounded-full font-medium transition flex items-center gap-2"
|
||||
:class="isFollowed
|
||||
? 'bg-subtle text-content-2 hover:bg-muted'
|
||||
: 'bg-accent/15 text-accent-text hover:bg-accent/25'"
|
||||
>
|
||||
{{ isFollowed ? '已关注' : '关注' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 简介弹窗 -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showDescModal" class="fixed inset-0 z-50 flex items-center justify-center" @click.self="showDescModal = false">
|
||||
<div class="absolute inset-0 bg-black/50" @click="showDescModal = false"></div>
|
||||
<div class="relative bg-surface rounded-2xl shadow-2xl max-w-lg w-full mx-4 max-h-[70vh] flex flex-col">
|
||||
<div class="flex items-center justify-between p-5 border-b border-line-2">
|
||||
<h2 class="text-lg font-semibold">{{ artistName }} 的介绍</h2>
|
||||
<button @click="showDescModal = false" class="text-content-3 hover:text-content transition">
|
||||
<IconX class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-5 overflow-y-auto text-sm text-content-2 leading-relaxed whitespace-pre-wrap">{{ briefDesc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- 内容区:热门歌曲 / 专辑 -->
|
||||
<div class="flex gap-2 mb-6">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
@ -37,44 +103,43 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-content-2">加载中...</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="activeTab === 'songs'" 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"
|
||||
<!-- 歌曲列表 -->
|
||||
<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
|
||||
:container-class="player.currentSong?.id === song.id ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
|
||||
@click="player.playFromList(songs, index)"
|
||||
>
|
||||
<template #index="{ index: idx, isCurrent }">
|
||||
<div class="w-6 text-right flex-shrink-0 flex items-center justify-end h-5">
|
||||
<div v-if="isCurrent" 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">{{ idx + 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>
|
||||
</div>
|
||||
</template>
|
||||
</SongListItem>
|
||||
@song-click="(_s, i) => player.playFromList(songs, i)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'albums'" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<!-- 专辑列表 -->
|
||||
<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"
|
||||
@ -88,22 +153,27 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'desc'" class="max-w-2xl">
|
||||
<p class="text-sm text-content-2 leading-relaxed whitespace-pre-wrap">{{ briefDesc }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import { ref, computed, onMounted, watch, onActivated, nextTick } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { MusicApi } from '../api';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { formatPlayCount, formatDate } from '../utils/format';
|
||||
import { normalizeSong, type Song } from '../utils/song';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
import { pageCacheGet, pageCacheSet } from '../composables/usePageCache';
|
||||
import VirtualSongList from '../components/VirtualSongList.vue';
|
||||
import PageHeader from '../components/PageHeader.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();
|
||||
@ -113,41 +183,128 @@ const artist = ref<any>(null);
|
||||
const songs = ref<Song[]>([]);
|
||||
const albums = ref<any[]>([]);
|
||||
const briefDesc = ref('');
|
||||
const loading = ref(true);
|
||||
const loadError = ref(false);
|
||||
const songsLoading = ref(false);
|
||||
const albumsLoading = ref(false);
|
||||
const activeTab = ref('songs');
|
||||
const showDescModal = ref(false);
|
||||
const descOverflow = ref(false);
|
||||
const descEl = ref<HTMLElement | null>(null);
|
||||
const isFollowed = ref(false);
|
||||
const followLoading = ref(false);
|
||||
|
||||
const tabs = [
|
||||
{ key: 'songs', label: '热门歌曲' },
|
||||
{ key: 'albums', label: '专辑' },
|
||||
{ key: 'desc', label: '简介' },
|
||||
];
|
||||
|
||||
async function fetchArtist(id: number) {
|
||||
loading.value = true;
|
||||
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() {
|
||||
nextTick(() => {
|
||||
if (descEl.value) {
|
||||
descOverflow.value = descEl.value.scrollHeight > descEl.value.clientHeight + 2;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchArtist(id: number, force = false) {
|
||||
const cacheKey = `artist_${id}`;
|
||||
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 [detailStr, songsStr, albumStr, descStr] = await Promise.all([
|
||||
invoke('artist_detail', { id }) as Promise<string>,
|
||||
invoke('artist_songs', { query: { id, order: 'hot', limit: 50, offset: 0 } }) as Promise<string>,
|
||||
invoke('artist_album', { id, limit: 30, offset: 0 }) as Promise<string>,
|
||||
invoke('artist_desc', { id }) as Promise<string>,
|
||||
]);
|
||||
const detailData = JSON.parse(detailStr);
|
||||
artist.value = detailData.artist;
|
||||
const songsData = JSON.parse(songsStr);
|
||||
songs.value = (songsData.songs || []).map(normalizeSong);
|
||||
const albumData = JSON.parse(albumStr);
|
||||
albums.value = albumData.hotAlbums || [];
|
||||
const descData = JSON.parse(descStr);
|
||||
briefDesc.value = descData.briefDesc || '';
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
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);
|
||||
songs.value = (data.songs || []).map(normalizeSong);
|
||||
} 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(() => {
|
||||
@ -155,11 +312,29 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
watch(() => route.params.id, (newId) => {
|
||||
if (newId) fetchArtist(Number(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>
|
||||
|
||||
436
src/views/CloudMusic.vue
Normal file
436
src/views/CloudMusic.vue
Normal file
@ -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>
|
||||
@ -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,79 +9,71 @@
|
||||
>
|
||||
播放全部
|
||||
</button>
|
||||
</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>
|
||||
<div v-if="loading" class="text-content-2">加载中...</div>
|
||||
<div v-else class="space-y-2">
|
||||
<SongListItem
|
||||
v-for="(song, index) in songs"
|
||||
:key="song.id"
|
||||
:song="song"
|
||||
:index="index"
|
||||
:is-current="isCurrentSong(song.id)"
|
||||
</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
|
||||
:container-class="isCurrentSong(song.id) ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
|
||||
@click="player.playFromList(songs, index)"
|
||||
>
|
||||
<template #index="{ index: idx, isCurrent }">
|
||||
<div class="w-6 text-right flex-shrink-0 flex items-center justify-end h-5">
|
||||
<div v-if="isCurrent" 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">{{ idx + 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>
|
||||
</div>
|
||||
</template>
|
||||
</SongListItem>
|
||||
</div>
|
||||
@song-click="(_s, i) => player.playFromList(songs, i)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
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 { pageCacheGet, pageCacheSet, pageCacheInvalidate } from '../composables/usePageCache';
|
||||
import { pageCacheGet, pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
|
||||
import { normalizeSong, type Song } from '../utils/song';
|
||||
import { useOnlineStatus } from '../composables/useOnlineStatus';
|
||||
|
||||
defineOptions({ name: 'DailySongsView' });
|
||||
|
||||
const player = usePlayerStore();
|
||||
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;
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
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 || []).map(normalizeSong);
|
||||
pageCacheSet('dailySongs', songs.value);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
loadError.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@ -92,7 +81,15 @@ async function loadData() {
|
||||
|
||||
onMounted(loadData);
|
||||
|
||||
watch(isOnline, (val, old) => {
|
||||
onActivated(() => {
|
||||
if (loadError.value) {
|
||||
loadData(true);
|
||||
} else if (pageCacheIsStale('dailySongs')) {
|
||||
loadData();
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => navigator.onLine, (val, old) => {
|
||||
if (val && !old && songs.value.length === 0) {
|
||||
pageCacheInvalidate('dailySongs');
|
||||
loadData();
|
||||
|
||||
@ -1,13 +1,55 @@
|
||||
<template>
|
||||
<div class="p-8 text-content">
|
||||
<h1 class="text-2xl font-bold mb-4">发现音乐</h1>
|
||||
|
||||
<div class="p-8 text-content" @click="showSuggestions = false">
|
||||
<div class="relative mb-6" @click.stop>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative flex-1">
|
||||
<IconSearch class="absolute left-3.5 top-1/2 -translate-y-1/2 text-content-3 w-[18px] h-[18px]" />
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="keyword"
|
||||
@keyup.enter="handleSearch"
|
||||
@input="onInputChange"
|
||||
@keydown.enter="handleSearch"
|
||||
@focus="onInputFocus"
|
||||
placeholder="搜索歌曲、歌手、专辑..."
|
||||
class="mb-4 w-full rounded-xl bg-muted p-3 text-content placeholder-content-2 outline-none backdrop-blur"
|
||||
class="w-full rounded-xl bg-muted pl-10 pr-10 py-3 text-content placeholder-content-3 outline-none focus:bg-subtle focus:ring-1 focus:ring-accent/30 transition"
|
||||
/>
|
||||
<button v-if="keyword" @click="clearSearch" class="absolute right-3 top-1/2 -translate-y-1/2 text-content-3 hover:text-content transition">
|
||||
<IconX class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showSuggestions && !hasSearched"
|
||||
class="absolute z-30 left-0 right-0 top-full mt-2 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-2">
|
||||
<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 style="font-size: 14px" class="text-content-3 flex-shrink-0" />
|
||||
<span>{{ s }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="searchHistory.length && !suggestions.length" class="p-2">
|
||||
<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 style="font-size: 14px" class="text-content-3 flex-shrink-0" />
|
||||
<span>{{ h }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="hotTags.length && !suggestions.length && !searchHistory.length" class="p-2">
|
||||
<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">
|
||||
<IconClock style="font-size: 14px" class="text-content-3 flex-shrink-0" />
|
||||
<span>{{ tag.searchWord }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!hasSearched && !loading && hotTags.length" class="mb-6">
|
||||
<h2 class="text-sm font-semibold mb-3">🔥 热门搜索</h2>
|
||||
@ -16,27 +58,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 v-if="loading" class="text-content-2">搜索中...</div>
|
||||
<div v-else class="space-y-3">
|
||||
<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="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>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="activeTab === 1">
|
||||
<div v-if="currentResults.length" class="space-y-2">
|
||||
<SongListItem
|
||||
v-for="(song, index) in results"
|
||||
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="backdrop-blur-md bg-subtle hover:bg-muted border border-line-2"
|
||||
@click="player.playFromList(results, index)"
|
||||
container-class="bg-subtle hover:bg-muted border border-line-2"
|
||||
@click="player.playFromList(currentResults, index)"
|
||||
/>
|
||||
<p v-if="!loading && hasSearched && results.length === 0" class="text-content-2">无结果</p>
|
||||
</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>
|
||||
@ -44,25 +143,110 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'DiscoverView' });
|
||||
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import { ref, computed, onMounted, onActivated, watch, nextTick } 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 SongListItem from '../components/SongListItem.vue';
|
||||
import { normalizeSong, type Song } from '../utils/song';
|
||||
import { pageCacheGet, pageCacheSet, pageCacheInvalidate } from '../composables/usePageCache';
|
||||
import { formatDate } from '../utils/format';
|
||||
import { pageCacheGet, pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
|
||||
import { useOnlineStatus } from '../composables/useOnlineStatus';
|
||||
import IconSearch from '~icons/lucide/search';
|
||||
import IconX from '~icons/lucide/x';
|
||||
import IconHistory from '~icons/lucide/history';
|
||||
import IconClock from '~icons/lucide/clock';
|
||||
import IconUserRound from '~icons/lucide/user-round';
|
||||
import IconDisc from '~icons/lucide/disc';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const player = usePlayerStore();
|
||||
const { isOnline } = useOnlineStatus();
|
||||
|
||||
const searchInput = ref<HTMLInputElement | null>(null);
|
||||
const keyword = ref('');
|
||||
const results = ref<Song[]>([]);
|
||||
const loading = ref(false);
|
||||
const hasSearched = ref(false);
|
||||
const hotTags = ref<any[]>([]);
|
||||
const suggestions = ref<string[]>([]);
|
||||
const showSuggestions = ref(false);
|
||||
const activeTab = ref(1);
|
||||
const cacheError = ref(false);
|
||||
|
||||
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: '专辑' },
|
||||
];
|
||||
|
||||
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 = [];
|
||||
showSuggestions.value = true;
|
||||
return;
|
||||
}
|
||||
suggestTimer = setTimeout(async () => {
|
||||
try {
|
||||
const jsonStr: string = await MusicApi.searchSuggest(keyword.value.trim());
|
||||
const data = JSON.parse(jsonStr);
|
||||
const all = data.result?.allMatch || [];
|
||||
suggestions.value = all.map((m: any) => m.keyword).slice(0, 8);
|
||||
showSuggestions.value = true;
|
||||
} catch {
|
||||
suggestions.value = [];
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function onInputFocus() {
|
||||
if (!hasSearched.value) {
|
||||
showSuggestions.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHotTags() {
|
||||
const cached = pageCacheGet('discover_hotTags');
|
||||
@ -70,17 +254,16 @@ async function loadHotTags() {
|
||||
hotTags.value = cached;
|
||||
} else {
|
||||
try {
|
||||
const json = await invoke('get_hot_search');
|
||||
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 {}
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadHotTags();
|
||||
|
||||
const q = route.query.q as string;
|
||||
if (q) {
|
||||
keyword.value = q;
|
||||
@ -89,6 +272,16 @@ onMounted(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
onActivated(async () => {
|
||||
if (pageCacheIsStale('discover_hotTags')) loadHotTags();
|
||||
const q = route.query.q as string;
|
||||
if (q && q !== lastSearchKeyword.value) {
|
||||
keyword.value = q;
|
||||
await handleSearch();
|
||||
router.replace({ query: {} });
|
||||
}
|
||||
});
|
||||
|
||||
watch(isOnline, (val, old) => {
|
||||
if (val && !old && hotTags.value.length === 0) {
|
||||
pageCacheInvalidate('discover_hotTags');
|
||||
@ -97,22 +290,81 @@ watch(isOnline, (val, old) => {
|
||||
});
|
||||
|
||||
async function handleSearch() {
|
||||
if (!keyword.value.trim()) return;
|
||||
loading.value = true;
|
||||
const q = keyword.value.trim();
|
||||
if (!q) return;
|
||||
showSuggestions.value = false;
|
||||
hasSearched.value = true;
|
||||
cacheError.value = false;
|
||||
saveSearchHistory(q);
|
||||
searchHistory.value = loadSearchHistory();
|
||||
|
||||
if (q === lastSearchKeyword.value && resultCache.value.size > 0) return;
|
||||
|
||||
lastSearchKeyword.value = q;
|
||||
resultCache.value.clear();
|
||||
|
||||
await Promise.all([
|
||||
fetchTabResults(1),
|
||||
fetchTabResults(100),
|
||||
fetchTabResults(10),
|
||||
]);
|
||||
}
|
||||
|
||||
async function fetchTabResults(type: number) {
|
||||
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 jsonStr: string = await MusicApi.cloudsearch({
|
||||
keyword: lastSearchKeyword.value, searchType: type, limit: 30
|
||||
});
|
||||
const data = JSON.parse(jsonStr);
|
||||
results.value = (data.result?.songs || []).map(normalizeSong);
|
||||
const result = data.result || {};
|
||||
|
||||
let items: any[] = [];
|
||||
if (type === 1) {
|
||||
items = (result.songs || []).map(normalizeSong);
|
||||
} 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();
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
keyword.value = '';
|
||||
hasSearched.value = false;
|
||||
resultCache.value.clear();
|
||||
lastSearchKeyword.value = '';
|
||||
cacheError.value = false;
|
||||
suggestions.value = [];
|
||||
showSuggestions.value = true;
|
||||
nextTick(() => searchInput.value?.focus());
|
||||
}
|
||||
</script>
|
||||
|
||||
177
src/views/DownloadedMusic.vue
Normal file
177
src/views/DownloadedMusic.vue
Normal file
@ -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>
|
||||
@ -1,72 +1,85 @@
|
||||
<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="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>
|
||||
</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>
|
||||
<div v-else class="space-y-2">
|
||||
<SongListItem
|
||||
v-for="(song, index) in songs"
|
||||
:key="song.id"
|
||||
:song="song"
|
||||
:index="index"
|
||||
<VirtualSongList
|
||||
v-else
|
||||
:songs="songs"
|
||||
:current-song-id="player.currentSong?.id"
|
||||
show-index
|
||||
show-like
|
||||
show-download
|
||||
show-menu
|
||||
show-duration
|
||||
@click="player.playFromList(songs, index)"
|
||||
show-playing-overlay
|
||||
@song-click="(_s, i) => player.playFromList(songs, i)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
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, type Song } from '../utils/song';
|
||||
import { pageCacheGet, pageCacheSet, pageCacheInvalidate } from '../composables/usePageCache';
|
||||
import { useOnlineStatus } from '../composables/useOnlineStatus';
|
||||
import { pageCacheGet, pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
|
||||
import IconPlay from '~icons/lucide/play';
|
||||
|
||||
defineOptions({ name: 'FavoriteSongsView' });
|
||||
|
||||
const player = usePlayerStore();
|
||||
const userStore = useUserStore();
|
||||
const { isOnline } = useOnlineStatus();
|
||||
const songs = ref<Song[]>([]);
|
||||
const loading = ref(true);
|
||||
const loadError = ref(false);
|
||||
|
||||
async function loadData() {
|
||||
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) {
|
||||
@ -74,12 +87,13 @@ async function loadData() {
|
||||
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);
|
||||
pageCacheSet('favoriteSongs', songs.value);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
loadError.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@ -87,7 +101,15 @@ async function loadData() {
|
||||
|
||||
onMounted(loadData);
|
||||
|
||||
watch(isOnline, (val, old) => {
|
||||
onActivated(() => {
|
||||
if (loadError.value) {
|
||||
loadData(true);
|
||||
} else if (pageCacheIsStale('favoriteSongs')) {
|
||||
loadData();
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => navigator.onLine, (val, old) => {
|
||||
if (val && !old && userStore.isLoggedIn && songs.value.length === 0) {
|
||||
pageCacheInvalidate('favoriteSongs');
|
||||
loadData();
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
<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">根据你的口味生成,每天凌晨更新</p>
|
||||
</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,24 +50,17 @@
|
||||
<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>
|
||||
@ -78,15 +71,37 @@
|
||||
</div>
|
||||
|
||||
<!-- 第二行:为你推荐(需登录) -->
|
||||
<div v-if="userStore.isLoggedIn && recPlaylists.length" class="mb-10">
|
||||
<div v-if="userStore.isLoggedIn" 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 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
|
||||
|
||||
<!-- 加载中:骨架屏 -->
|
||||
<div v-if="recLoading && !recPlaylists.length" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
|
||||
<div v-for="i in 6" :key="'skel-'+i" class="bg-subtle rounded-xl overflow-hidden max-w-[220px] justify-self-center w-full animate-pulse">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- 加载失败 -->
|
||||
<div v-else-if="recError && !recPlaylists.length" class="flex flex-col items-center justify-center py-12 gap-3">
|
||||
<p class="text-content-2 text-sm">推荐加载失败</p>
|
||||
<button @click="fetchRecPlaylists"
|
||||
class="px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 正常内容 -->
|
||||
<div v-else-if="recPlaylists.length" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
|
||||
<div v-for="pl in recPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
|
||||
class="bg-subtle rounded-xl overflow-hidden hover:bg-muted transition cursor-pointer max-w-[220px] justify-self-center w-full">
|
||||
<img :src="pl.picUrl" class="w-full aspect-square object-cover" />
|
||||
<div class="p-3">
|
||||
<p class="text-sm font-medium truncate">{{ pl.name }}</p>
|
||||
<p class="text-xs text-content-2 mt-1">{{ pl.copywriter || '' }}</p>
|
||||
<p class="text-xs text-content-2 mt-1 truncate">{{ pl.copywriter || pl.description || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -95,12 +110,34 @@
|
||||
<!-- 第三行:热门歌单(排行榜) -->
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold mb-4">📈 热门歌单</h2>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
|
||||
|
||||
<!-- 加载中:骨架屏 -->
|
||||
<div v-if="rankLoading && !rankPlaylists.length" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
|
||||
<div v-for="i in 4" :key="'rskel-'+i" class="bg-subtle rounded-xl overflow-hidden max-w-[220px] justify-self-center w-full animate-pulse">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载失败 -->
|
||||
<div v-else-if="rankError && !rankPlaylists.length" class="flex flex-col items-center justify-center py-12 gap-3">
|
||||
<p class="text-content-2 text-sm">热门歌单加载失败</p>
|
||||
<button @click="fetchRankPlaylists"
|
||||
class="px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 正常内容 -->
|
||||
<div v-else-if="rankPlaylists.length" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
|
||||
<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 max-w-[220px] justify-self-center w-full">
|
||||
<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>
|
||||
<p v-if="pl.description || pl.copywriter" class="text-xs text-content-2 mt-1 truncate">{{ pl.description || pl.copywriter }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -109,12 +146,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import { ref, onMounted, onActivated, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { MusicApi } from '../api';
|
||||
import { useUserStore } from '../stores/user';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { pageCacheGet, pageCacheSet, pageCacheInvalidate } from '../composables/usePageCache';
|
||||
import { pageCacheGet, pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
|
||||
import { useOnlineStatus } from '../composables/useOnlineStatus';
|
||||
import { getCoverUrl } from '../utils/song';
|
||||
|
||||
@ -126,11 +163,19 @@ const userStore = useUserStore();
|
||||
const { isOnline } = useOnlineStatus();
|
||||
|
||||
const rankPlaylists = ref<any[]>([]);
|
||||
const rankLoading = ref(false);
|
||||
const rankError = ref(false);
|
||||
const recPlaylists = ref<any[]>([]);
|
||||
const recLoading = ref(false);
|
||||
const recError = ref(false);
|
||||
const todayStr = ref('');
|
||||
const RANK_IDS = [3778678, 3779629, 19723756, 2884035];
|
||||
|
||||
import { computed } from 'vue';
|
||||
import IconRadio from '~icons/lucide/radio';
|
||||
import IconPlay from '~icons/lucide/play';
|
||||
import IconPause from '~icons/lucide/pause';
|
||||
import IconSkipForward from '~icons/lucide/skip-forward';
|
||||
|
||||
|
||||
const fmCoverUrl = computed(() => {
|
||||
@ -163,16 +208,18 @@ function onFmCardClick() {
|
||||
player.openRoamDrawer();
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
const cached = pageCacheGet('home');
|
||||
async function fetchRankPlaylists() {
|
||||
const cacheKey = 'home_rank';
|
||||
const cached = pageCacheGet(cacheKey);
|
||||
if (cached) {
|
||||
rankPlaylists.value = cached.rankPlaylists || [];
|
||||
recPlaylists.value = cached.recPlaylists || [];
|
||||
rankPlaylists.value = cached;
|
||||
return;
|
||||
}
|
||||
|
||||
rankLoading.value = true;
|
||||
rankError.value = false;
|
||||
try {
|
||||
const results = await Promise.allSettled(
|
||||
RANK_IDS.map(id => invoke('get_playlist_detail', { id }))
|
||||
RANK_IDS.map(id => MusicApi.getPlaylistDetail(id))
|
||||
);
|
||||
rankPlaylists.value = results
|
||||
.filter(r => r.status === 'fulfilled')
|
||||
@ -181,16 +228,46 @@ async function loadData() {
|
||||
return data.playlist;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
if (userStore.isLoggedIn) {
|
||||
try {
|
||||
const json = await invoke('recommend_resource');
|
||||
const data = JSON.parse(json as string);
|
||||
recPlaylists.value = data.recommend || [];
|
||||
} catch { }
|
||||
pageCacheSet(cacheKey, rankPlaylists.value);
|
||||
} catch {
|
||||
rankError.value = true;
|
||||
} finally {
|
||||
rankLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
pageCacheSet('home', { rankPlaylists: rankPlaylists.value, recPlaylists: recPlaylists.value });
|
||||
async function fetchRecPlaylists() {
|
||||
if (!userStore.isLoggedIn) return;
|
||||
const cacheKey = 'home_rec';
|
||||
const cached = pageCacheGet(cacheKey);
|
||||
if (cached) {
|
||||
recPlaylists.value = cached;
|
||||
return;
|
||||
}
|
||||
recLoading.value = true;
|
||||
recError.value = false;
|
||||
try {
|
||||
const json = await MusicApi.recommendResource();
|
||||
const data = JSON.parse(json as string);
|
||||
recPlaylists.value = data.recommend || [];
|
||||
pageCacheSet(cacheKey, recPlaylists.value);
|
||||
} catch {
|
||||
recError.value = true;
|
||||
} finally {
|
||||
recLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
const cached = pageCacheGet('home');
|
||||
if (cached) {
|
||||
rankPlaylists.value = cached.rankPlaylists || [];
|
||||
recPlaylists.value = cached.recPlaylists || [];
|
||||
return;
|
||||
}
|
||||
|
||||
fetchRankPlaylists();
|
||||
fetchRecPlaylists();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@ -199,10 +276,27 @@ onMounted(async () => {
|
||||
await loadData();
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
if (pageCacheIsStale('home')) loadData();
|
||||
});
|
||||
|
||||
watch(isOnline, (val, old) => {
|
||||
if (val && !old && rankPlaylists.value.length === 0 && recPlaylists.value.length === 0) {
|
||||
if (val && !old) {
|
||||
if (rankPlaylists.value.length === 0 && recPlaylists.value.length === 0) {
|
||||
pageCacheInvalidate('home');
|
||||
pageCacheInvalidate('home_rank');
|
||||
pageCacheInvalidate('home_rec');
|
||||
loadData();
|
||||
} else {
|
||||
if (rankError.value) {
|
||||
pageCacheInvalidate('home_rank');
|
||||
fetchRankPlaylists();
|
||||
}
|
||||
if (recError.value) {
|
||||
pageCacheInvalidate('home_rec');
|
||||
fetchRecPlaylists();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -1,73 +1,96 @@
|
||||
<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>
|
||||
<span v-if="songs.length" class="text-xs text-content-3">{{ songs.length }} 首</span>
|
||||
<button
|
||||
v-if="songs.length"
|
||||
@click="refresh"
|
||||
class="px-3 py-1 bg-muted hover:bg-emphasis rounded-full text-xs transition"
|
||||
>
|
||||
刷新
|
||||
<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-if="loading" class="text-content-2">加载中...</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 normalizedSongs"
|
||||
v-for="(song, index) in sortedSongs"
|
||||
:key="song.id + '-' + index"
|
||||
:song="song"
|
||||
:song="sortedNormalized[index]"
|
||||
:index="index"
|
||||
:is-current="player.currentSong?.id === song.id"
|
||||
show-index
|
||||
show-duration
|
||||
:container-class="player.currentSong?.id === song.id ? 'bg-subtle hover:bg-subtle' : 'hover:bg-subtle'"
|
||||
@click="player.playFromList(normalizedSongs, index)"
|
||||
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(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="更多"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/></svg>
|
||||
<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>
|
||||
<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">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
|
||||
从磁盘中删除
|
||||
<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>
|
||||
</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">
|
||||
删除
|
||||
<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>
|
||||
@ -75,62 +98,111 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { ref, computed, onMounted, onActivated, watch, onBeforeUnmount } from 'vue';
|
||||
import { AppApi, DownloadApi } from '../api';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { useDownload } from '../composables/useDownload';
|
||||
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 { pageCacheSet, pageCacheInvalidate } from '../composables/usePageCache';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
import type { Song } from '../utils/song';
|
||||
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 download = useDownload();
|
||||
const settings = useSettingsStore();
|
||||
|
||||
interface LocalSong {
|
||||
id: number;
|
||||
name: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
duration: number;
|
||||
cover: string | null;
|
||||
filename: string;
|
||||
fileSize: number;
|
||||
path: string;
|
||||
local: boolean;
|
||||
}
|
||||
|
||||
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 showFolderModal = ref(false);
|
||||
|
||||
const normalizedSongs = computed(() => songs.value.map(toSong));
|
||||
// 排序:点击循环切换
|
||||
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;
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
async function openFolder(path: string) {
|
||||
openMenuId.value = null;
|
||||
try {
|
||||
await AppApi.showItemInFolder(path);
|
||||
} catch (e: any) {
|
||||
showToast(e.toString(), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { document.addEventListener('click', closeMenu); });
|
||||
onBeforeUnmount(() => { document.removeEventListener('click', closeMenu); });
|
||||
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;
|
||||
pageCacheInvalidate('localMusic');
|
||||
try {
|
||||
const list = await invoke<LocalSong[]>('list_local_songs', { downloadPath: settings.downloadPath || null });
|
||||
const list = await DownloadApi.scanLocalFolders(paths);
|
||||
songs.value = list;
|
||||
pageCacheSet('localMusic', list);
|
||||
fetchMissingCovers();
|
||||
fetchMissingCovers(songs.value);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
@ -138,64 +210,13 @@ async function refresh() {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMissingCovers() {
|
||||
const missing = songs.value.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 invoke('get_song_detail', { id: 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 {}
|
||||
}
|
||||
|
||||
onMounted(refresh);
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
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];
|
||||
}
|
||||
onActivated(() => {
|
||||
if (pageCacheIsStale('localMusic')) refresh();
|
||||
});
|
||||
|
||||
function toSong(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,
|
||||
};
|
||||
}
|
||||
|
||||
function confirmDelete(song: LocalSong) {
|
||||
openMenuId.value = null;
|
||||
deleteTarget.value = song;
|
||||
showDeleteConfirm.value = true;
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
if (!deleteTarget.value) return;
|
||||
try {
|
||||
await invoke('delete_local_song', { query: { 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);
|
||||
showToast('已删除', 'success');
|
||||
} catch (e) {
|
||||
showToast('删除失败', 'error');
|
||||
}
|
||||
showDeleteConfirm.value = false;
|
||||
deleteTarget.value = null;
|
||||
}
|
||||
watch(() => settings.enabledMusicPaths, () => { refresh(); }, { deep: true });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@ -20,7 +20,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { MusicApi } from '../api';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useUserStore } from '../stores/user';
|
||||
import QRCode from 'qrcode';
|
||||
@ -52,7 +52,7 @@ async function refreshQr() {
|
||||
qrError.value = '';
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
try {
|
||||
qrKey = await invoke('get_qr_key');
|
||||
qrKey = await MusicApi.getQrKey();
|
||||
if (!qrKey) {
|
||||
qrError.value = '未获取到登录密钥';
|
||||
qrLoading.value = false;
|
||||
@ -76,7 +76,7 @@ async function refreshQr() {
|
||||
function startPolling() {
|
||||
pollTimer = setInterval(async () => {
|
||||
try {
|
||||
const jsonStr: string = await invoke('check_qr_status', { query: { key: qrKey } });
|
||||
const jsonStr: string = await MusicApi.checkQrStatus(qrKey);
|
||||
const data = JSON.parse(jsonStr);
|
||||
const code = data.code;
|
||||
if (code === 800) {
|
||||
@ -104,7 +104,7 @@ function startPolling() {
|
||||
|
||||
async function fetchUserProfile() {
|
||||
try {
|
||||
const profileJson: string = await invoke('get_login_status');
|
||||
const profileJson: string = await MusicApi.getLoginStatus();
|
||||
const profile = JSON.parse(profileJson);
|
||||
if (profile.profile) {
|
||||
userStore.setUser({
|
||||
|
||||
@ -1,10 +1,23 @@
|
||||
<template>
|
||||
<div class="p-8 text-content">
|
||||
<button @click="$router.back()" class="mb-4 text-content-2 hover:text-content transition">
|
||||
← 返回
|
||||
</button>
|
||||
<PageHeader />
|
||||
|
||||
<div v-if="playlist" class="flex gap-6 mb-8">
|
||||
<!-- 头部骨架 -->
|
||||
<div v-if="!playlist && playlistLoading" class="flex gap-6 mb-8">
|
||||
<div class="w-44 h-44 rounded-xl bg-muted animate-pulse flex-shrink-0"></div>
|
||||
<div class="flex-1 space-y-3">
|
||||
<div class="h-7 bg-muted rounded w-1/2 animate-pulse"></div>
|
||||
<div class="h-4 bg-muted rounded w-1/3 animate-pulse"></div>
|
||||
<div class="h-4 bg-muted rounded w-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="playlist" class="flex gap-6 mb-8">
|
||||
<img :src="playlist.coverImgUrl" class="w-44 h-44 rounded-xl object-cover shadow-lg flex-shrink-0" />
|
||||
<div class="flex flex-col justify-between min-w-0">
|
||||
<div>
|
||||
@ -13,7 +26,21 @@
|
||||
<img :src="playlist.creator.avatarUrl" class="w-5 h-5 rounded-full" />
|
||||
<span class="text-sm text-content-2">{{ playlist.creator.nickname }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-content-2 mt-2 line-clamp-2">{{ playlist.description }}</p>
|
||||
<div v-if="playlist.description" class="mt-2">
|
||||
<p
|
||||
ref="descEl"
|
||||
class="text-sm text-content-2 leading-relaxed overflow-hidden"
|
||||
style="max-height: 3em"
|
||||
>{{ playlist.description }}</p>
|
||||
<button
|
||||
v-if="descOverflow"
|
||||
@click="showDescModal = true"
|
||||
class="inline-flex items-center gap-1 text-xs text-accent-text hover:text-accent-text/80 mt-1 px-2 py-0.5 rounded-full bg-accent-text/10 transition"
|
||||
>
|
||||
<IconChevronDown class="w-3 h-3" />
|
||||
查看完整介绍
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-content-3 mt-2">
|
||||
{{ playlist.trackCount }} 首歌曲 · 播放 {{ formatPlayCount(playlist.playCount) }} 次
|
||||
</p>
|
||||
@ -23,7 +50,7 @@
|
||||
@click="playAll"
|
||||
class="px-5 py-2 bg-accent hover:bg-accent-hover rounded-full text-white font-medium transition flex items-center gap-2"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M4 2.5v11l9-5.5z"/></svg>
|
||||
<IconPlay class="w-4 h-4 fill-current" />
|
||||
播放全部
|
||||
</button>
|
||||
<button
|
||||
@ -32,51 +59,61 @@
|
||||
class="px-4 py-2 bg-muted hover:bg-emphasis rounded-full text-sm transition flex items-center gap-2"
|
||||
:class="subscribed ? 'text-accent-text' : 'text-content/70'"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path v-if="subscribed" d="M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z"/>
|
||||
<path v-else d="M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z"/>
|
||||
</svg>
|
||||
<IconBookmark class="w-4 h-4" :class="subscribed ? 'fill-current' : ''" />
|
||||
{{ subscribed ? '已收藏' : '收藏歌单' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-content-2">加载中...</div>
|
||||
<!-- 简介弹窗 -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showDescModal" class="fixed inset-0 z-50 flex items-center justify-center" @click.self="showDescModal = false">
|
||||
<div class="absolute inset-0 bg-black/50" @click="showDescModal = false"></div>
|
||||
<div class="relative bg-surface rounded-2xl shadow-2xl max-w-lg w-full mx-4 max-h-[70vh] flex flex-col">
|
||||
<div class="flex items-center justify-between p-5 border-b border-line-2">
|
||||
<h2 class="text-lg font-semibold">{{ playlist?.name }} 的介绍</h2>
|
||||
<button @click="showDescModal = false" class="text-content-3 hover:text-content transition">
|
||||
<IconX class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-5 overflow-y-auto text-sm text-content-2 leading-relaxed whitespace-pre-wrap">{{ playlist?.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<div v-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"
|
||||
<!-- 加载失败 -->
|
||||
<div v-if="loadError" class="flex flex-col items-center justify-center py-16 gap-3">
|
||||
<p class="text-content-2 text-sm">加载失败</p>
|
||||
<button @click="fetchPlaylist(Number(route.params.id), true)" class="px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition">重试</button>
|
||||
</div>
|
||||
|
||||
<!-- 歌曲列表骨架 -->
|
||||
<div v-else-if="songsLoading" class="space-y-1">
|
||||
<div v-for="i in 8" :key="i" class="flex items-center gap-3 px-3 py-2">
|
||||
<div class="w-12 h-12 bg-muted rounded animate-pulse flex-shrink-0"></div>
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="h-4 bg-muted rounded w-2/3 animate-pulse"></div>
|
||||
<div class="h-3 bg-muted rounded w-1/3 animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 歌曲列表 -->
|
||||
<VirtualSongList
|
||||
v-else-if="songs.length"
|
||||
:songs="songs"
|
||||
:current-song-id="player.currentSong?.id"
|
||||
show-index
|
||||
show-like
|
||||
show-download
|
||||
show-menu
|
||||
show-duration
|
||||
show-playing-overlay
|
||||
:container-class="player.currentSong?.id === song.id ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
|
||||
@click="player.playFromList(songs, index)"
|
||||
>
|
||||
<template #index="{ index: idx, isCurrent }">
|
||||
<div class="w-6 text-right flex-shrink-0 flex items-center justify-end h-5">
|
||||
<div v-if="isCurrent" 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">{{ idx + 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>
|
||||
</div>
|
||||
</template>
|
||||
</SongListItem>
|
||||
</div>
|
||||
@song-click="(_s, i) => player.playFromList(songs, i)"
|
||||
/>
|
||||
|
||||
<div v-else-if="!songsLoading && !loadError" class="text-content-2">暂无歌曲</div>
|
||||
|
||||
<div v-if="playlist" class="mt-8">
|
||||
<CommentSection :type="2" :id="Number(route.params.id)" />
|
||||
@ -85,16 +122,24 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { ref, computed, onMounted, watch, onActivated, nextTick } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { MusicApi } from '../api';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { useUserStore } from '../stores/user';
|
||||
import { showToast } from '../composables/useToast';
|
||||
import { formatPlayCount } from '../utils/format';
|
||||
import { normalizeSong, type Song } from '../utils/song';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
import { pageCacheGet, pageCacheSet } from '../composables/usePageCache';
|
||||
import VirtualSongList from '../components/VirtualSongList.vue';
|
||||
import CommentSection from '../components/CommentSection.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import IconPlay from '~icons/lucide/play';
|
||||
import IconBookmark from '~icons/lucide/bookmark';
|
||||
import IconX from '~icons/lucide/x';
|
||||
import IconChevronDown from '~icons/lucide/chevron-down';
|
||||
|
||||
defineOptions({ name: 'PlaylistDetailView' });
|
||||
|
||||
const route = useRoute();
|
||||
const player = usePlayerStore();
|
||||
@ -102,29 +147,64 @@ const userStore = useUserStore();
|
||||
|
||||
const playlist = ref<any>(null);
|
||||
const songs = ref<Song[]>([]);
|
||||
const loading = ref(true);
|
||||
const playlistLoading = ref(true);
|
||||
const songsLoading = ref(false);
|
||||
const loadError = ref(false);
|
||||
const subscribed = ref(false);
|
||||
const showDescModal = ref(false);
|
||||
const descOverflow = ref(false);
|
||||
const descEl = ref<HTMLElement | null>(null);
|
||||
|
||||
const isOwnPlaylist = computed(() => {
|
||||
if (!playlist.value || !userStore.user) return false;
|
||||
return playlist.value.creator?.userId === userStore.user.userId;
|
||||
});
|
||||
|
||||
async function fetchPlaylist(id: number) {
|
||||
loading.value = true;
|
||||
function checkDescOverflow() {
|
||||
nextTick(() => {
|
||||
if (descEl.value) {
|
||||
descOverflow.value = descEl.value.scrollHeight > descEl.value.clientHeight + 2;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchPlaylist(id: number, force = false) {
|
||||
const cacheKey = `playlist_${id}`;
|
||||
if (!force) {
|
||||
const cached = pageCacheGet(cacheKey);
|
||||
if (cached) {
|
||||
playlist.value = cached.playlist;
|
||||
songs.value = cached.songs;
|
||||
subscribed.value = cached.subscribed;
|
||||
playlistLoading.value = false;
|
||||
songsLoading.value = false;
|
||||
loadError.value = false;
|
||||
checkDescOverflow();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
playlistLoading.value = true;
|
||||
songsLoading.value = true;
|
||||
loadError.value = false;
|
||||
playlist.value = null;
|
||||
songs.value = [];
|
||||
try {
|
||||
const jsonStr: string = await invoke('get_playlist_detail', { id });
|
||||
const jsonStr: string = await MusicApi.getPlaylistDetail(id);
|
||||
const data = JSON.parse(jsonStr);
|
||||
playlist.value = data.playlist;
|
||||
playlistLoading.value = false;
|
||||
songs.value = (data.playlist.tracks || []).map(normalizeSong);
|
||||
songsLoading.value = false;
|
||||
subscribed.value = data.playlist.subscribed || false;
|
||||
pageCacheSet(cacheKey, { playlist: playlist.value, songs: songs.value, subscribed: subscribed.value });
|
||||
checkDescOverflow();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
loadError.value = true;
|
||||
playlistLoading.value = false;
|
||||
songsLoading.value = false;
|
||||
showToast('获取歌单详情失败', 'error');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@ -133,7 +213,11 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
watch(() => route.params.id, (newId) => {
|
||||
if (newId) fetchPlaylist(Number(newId));
|
||||
if (newId && route.name === 'playlist') fetchPlaylist(Number(newId));
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
if (loadError.value) fetchPlaylist(Number(route.params.id), true);
|
||||
});
|
||||
|
||||
function playAll() {
|
||||
@ -145,7 +229,7 @@ async function toggleSubscribe() {
|
||||
if (!playlist.value) return;
|
||||
const newSubscribed = !subscribed.value;
|
||||
try {
|
||||
await invoke('playlist_subscribe', { query: { id: Number(playlist.value.id), subscribe: newSubscribed } });
|
||||
await MusicApi.playlistSubscribe(Number(playlist.value.id), newSubscribed);
|
||||
subscribed.value = newSubscribed;
|
||||
showToast(subscribed.value ? '已收藏歌单' : '已取消收藏', 'success');
|
||||
} catch {
|
||||
|
||||
@ -1,49 +1,28 @@
|
||||
<template>
|
||||
<div class="p-8 text-content">
|
||||
<button @click="$router.back()" class="mb-4 text-content-2 hover:text-content transition">
|
||||
← 返回
|
||||
</button>
|
||||
<h1 class="text-2xl font-bold mb-6">最近播放</h1>
|
||||
<PageHeader>
|
||||
<h1 class="text-2xl font-bold">最近播放</h1>
|
||||
</PageHeader>
|
||||
<div v-if="player.recentLocal.length === 0" class="text-content-3">还没有播放记录,去听首歌吧</div>
|
||||
<div v-else class="space-y-2">
|
||||
<SongListItem
|
||||
v-for="(song, index) in player.recentLocal"
|
||||
:key="song.id"
|
||||
:song="song"
|
||||
:index="index"
|
||||
:is-current="player.currentSong?.id === song.id"
|
||||
<VirtualSongList
|
||||
v-else
|
||||
:songs="player.recentLocal"
|
||||
:current-song-id="player.currentSong?.id"
|
||||
show-index
|
||||
show-like
|
||||
show-download
|
||||
show-menu
|
||||
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(player.recentLocal, index)"
|
||||
>
|
||||
<template #index="{ index: idx, isCurrent }">
|
||||
<div class="w-6 text-right flex-shrink-0 flex items-center justify-end h-5">
|
||||
<div v-if="isCurrent" 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">{{ idx + 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>
|
||||
</div>
|
||||
</template>
|
||||
</SongListItem>
|
||||
</div>
|
||||
@song-click="(_s, i) => player.playFromList(player.recentLocal, i)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
import VirtualSongList from '../components/VirtualSongList.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
|
||||
const player = usePlayerStore();
|
||||
</script>
|
||||
|
||||
@ -1,120 +0,0 @@
|
||||
<template>
|
||||
<div class="p-8 text-content flex flex-col items-center justify-center min-h-full">
|
||||
<div v-if="!currentSong" class="text-center">
|
||||
<p class="text-content-2 mb-4">私人漫游未启动</p>
|
||||
<button
|
||||
@click="startFm"
|
||||
class="px-6 py-2 bg-muted hover:bg-emphasis rounded-full transition"
|
||||
>
|
||||
开始漫游
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<img
|
||||
v-if="coverUrl && !coverError"
|
||||
:src="coverUrl"
|
||||
class="w-80 h-80 rounded-3xl object-cover shadow-2xl mb-8"
|
||||
@error="coverError = true"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="w-80 h-80 rounded-3xl bg-muted flex items-center justify-center shadow-2xl mb-8"
|
||||
>
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="text-content-3"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||||
</div>
|
||||
|
||||
<h1 class="text-3xl font-bold mb-2">{{ currentSong.name }}</h1>
|
||||
<p class="text-lg text-content-2 mb-8">
|
||||
<template v-for="(a, i) in currentSong.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="a.id && router.push({ name: 'artist', params: { id: a.id } })">{{ a.name }}</span>
|
||||
</template>
|
||||
<template v-if="currentSong.al?.name">
|
||||
<span class="text-content-3 mx-1">·</span>
|
||||
<span class="hover:text-accent-text cursor-pointer transition" @click="currentSong.al.id && router.push({ name: 'album', params: { id: currentSong.al.id } })">{{ currentSong.al.name }}</span>
|
||||
</template>
|
||||
</p>
|
||||
|
||||
<div class="flex items-center gap-8">
|
||||
<button
|
||||
@click="player.toggle()"
|
||||
class="w-16 h-16 flex items-center justify-center rounded-full bg-muted hover:bg-emphasis transition border border-emphasis"
|
||||
>
|
||||
<svg v-if="player.playing" width="28" height="28" viewBox="0 0 16 16" fill="currentColor">
|
||||
<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="28" height="28" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M4 2.5v11l9-5.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
@click="nextSong"
|
||||
class="text-content-2 hover:text-content transition"
|
||||
>
|
||||
<svg width="28" height="28" 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>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { normalizeSong, getCoverUrl } from '../utils/song';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useOnlineStatus } from '../composables/useOnlineStatus';
|
||||
|
||||
const player = usePlayerStore();
|
||||
const router = useRouter();
|
||||
const { isOnline } = useOnlineStatus();
|
||||
const coverError = ref(false);
|
||||
|
||||
const currentSong = computed(() => {
|
||||
if (player.isFmMode && player.currentSong) {
|
||||
return player.currentSong;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const coverUrl = computed(() => {
|
||||
if (!currentSong.value) return '';
|
||||
return getCoverUrl(currentSong.value) || '';
|
||||
});
|
||||
|
||||
watch(coverUrl, () => { coverError.value = false; });
|
||||
|
||||
onMounted(async () => {
|
||||
if (!player.isFmMode || !player.currentSong) {
|
||||
await startFm();
|
||||
}
|
||||
});
|
||||
|
||||
async function startFm() {
|
||||
try {
|
||||
const jsonStr: string = await invoke('personal_fm');
|
||||
const data = JSON.parse(jsonStr);
|
||||
const songs = data.data || data;
|
||||
if (songs && songs.length > 0) {
|
||||
const song = normalizeSong(songs[0]);
|
||||
player.enableFmMode(nextSong);
|
||||
await player.playFmSong(song);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('启动漫游失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function nextSong() {
|
||||
await startFm();
|
||||
}
|
||||
|
||||
watch(isOnline, (val, old) => {
|
||||
if (val && !old && !currentSong.value) {
|
||||
startFm();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@ -1,9 +1,8 @@
|
||||
<template>
|
||||
<div class="p-8 text-content">
|
||||
<button @click="$router.back()" class="mb-4 text-content-2 hover:text-content transition">
|
||||
← 返回
|
||||
</button>
|
||||
<h1 class="text-2xl font-bold mb-8">设置</h1>
|
||||
<PageHeader>
|
||||
<h1 class="text-2xl font-bold">设置</h1>
|
||||
</PageHeader>
|
||||
|
||||
<section class="mb-8">
|
||||
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">播放</h2>
|
||||
@ -27,20 +26,85 @@
|
||||
</section>
|
||||
|
||||
<section class="mb-8">
|
||||
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">外观</h2>
|
||||
<div>
|
||||
<p class="text-sm font-medium mb-3">主题色</p>
|
||||
<div class="grid grid-cols-4 gap-3">
|
||||
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">皮肤</h2>
|
||||
<div class="space-y-4">
|
||||
<!-- 明暗切换 -->
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-for="(color, key) in themeColors"
|
||||
:key="key"
|
||||
@click="settings.setTheme(key)"
|
||||
class="flex flex-col items-center gap-2 p-3 rounded-xl transition-all border-2"
|
||||
:class="settings.theme === key ? 'border-white/30 bg-white/5 scale-[1.02]' : 'border-transparent bg-subtle hover:bg-muted'"
|
||||
@click="settings.setSkin(toSkinId(currentThemeColor, 'dark'))"
|
||||
class="flex items-center gap-2 px-4 py-2 rounded-xl transition-all border-2"
|
||||
:class="!settings.isPreset || currentAppearance !== 'dark' ? 'border-transparent bg-subtle hover:bg-muted' : 'border-accent/40 bg-accent/10'"
|
||||
>
|
||||
<div class="w-8 h-8 rounded-full shadow-md" :style="{ backgroundColor: color }"></div>
|
||||
<span class="text-xs" :class="settings.theme === key ? 'text-content font-medium' : 'text-content-3'">{{ themeLabels[key] }}</span>
|
||||
<IconMoon class="w-4 h-4" :class="currentAppearance === 'dark' && settings.isPreset ? 'text-accent-text' : 'text-content-3'" />
|
||||
<span class="text-sm" :class="currentAppearance === 'dark' && settings.isPreset ? 'text-content font-medium' : 'text-content-3'">深色</span>
|
||||
</button>
|
||||
<button
|
||||
@click="settings.setSkin(toSkinId(currentThemeColor, 'light'))"
|
||||
class="flex items-center gap-2 px-4 py-2 rounded-xl transition-all border-2"
|
||||
:class="!settings.isPreset || currentAppearance !== 'light' ? 'border-transparent bg-subtle hover:bg-muted' : 'border-accent/40 bg-accent/10'"
|
||||
>
|
||||
<IconSun class="w-4 h-4" :class="currentAppearance === 'light' && settings.isPreset ? 'text-accent-text' : 'text-content-3'" />
|
||||
<span class="text-sm" :class="currentAppearance === 'light' && settings.isPreset ? 'text-content font-medium' : 'text-content-3'">浅色</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 主题色选择 -->
|
||||
<div class="grid grid-cols-7 gap-2">
|
||||
<div
|
||||
v-for="tc in themeColorOptions"
|
||||
:key="tc.id"
|
||||
@click="settings.setSkin(toSkinId(tc.id, currentAppearance))"
|
||||
class="flex flex-col items-center gap-1.5 p-2 rounded-xl transition-all border-2 cursor-pointer"
|
||||
:class="currentThemeColor === tc.id && settings.isPreset ? 'border-accent/40 bg-accent/10 scale-[1.02]' : 'border-transparent bg-subtle hover:bg-muted'"
|
||||
>
|
||||
<div class="w-8 h-8 rounded-full shadow-md" :style="{ backgroundColor: tc.color }"></div>
|
||||
<span class="text-[11px]" :class="currentThemeColor === tc.id && settings.isPreset ? 'text-content font-medium' : 'text-content-3'">{{ tc.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 自定义皮肤 -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs text-content-3">自定义</p>
|
||||
<div class="grid grid-cols-5 gap-2">
|
||||
<div
|
||||
v-for="s in settings.customSkins"
|
||||
:key="s.id"
|
||||
@click="settings.setSkin(s.id)"
|
||||
class="flex flex-col items-center gap-1.5 p-2 rounded-xl transition-all border-2 cursor-pointer relative group"
|
||||
:class="settings.skin === s.id ? 'border-accent/40 bg-accent/10 scale-[1.02]' : 'border-transparent bg-subtle hover:bg-muted'"
|
||||
>
|
||||
<div class="w-8 h-8 rounded-full shadow-md relative overflow-hidden" :style="{ backgroundColor: s.preview }">
|
||||
<div v-if="s.wallpaper && skinWallpaperDataUrls[s.wallpaper]" class="absolute inset-0 bg-cover bg-center opacity-40" :style="{ backgroundImage: `url(${skinWallpaperDataUrls[s.wallpaper]})` }"></div>
|
||||
</div>
|
||||
<span class="text-[11px] truncate w-full text-center" :class="settings.skin === s.id ? 'text-content font-medium' : 'text-content-3'">{{ s.name }}</span>
|
||||
<!-- 编辑按钮 -->
|
||||
<button
|
||||
@click.stop="openSkinEditor(s.id)"
|
||||
class="absolute -top-1 -right-1 w-4 h-4 flex items-center justify-center rounded-full bg-accent/60 text-white opacity-0 group-hover:opacity-100 transition"
|
||||
title="编辑"
|
||||
>
|
||||
<svg class="w-2 h-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
</button>
|
||||
<!-- 删除按钮 -->
|
||||
<button
|
||||
@click.stop="handleDeleteCustomSkin(s.id)"
|
||||
class="absolute -top-1 -left-1 w-4 h-4 flex items-center justify-center rounded-full bg-danger/80 text-white opacity-0 group-hover:opacity-100 transition"
|
||||
title="删除"
|
||||
>
|
||||
<IconX style="font-size: 8px" />
|
||||
</button>
|
||||
</div>
|
||||
<!-- 创建自定义皮肤(永远排最后) -->
|
||||
<div
|
||||
@click="openSkinEditor()"
|
||||
class="flex flex-col items-center justify-center gap-1.5 p-2 rounded-xl transition-all border-2 border-dashed border-line cursor-pointer hover:border-accent/40 hover:bg-accent/5"
|
||||
>
|
||||
<div class="w-8 h-8 rounded-full flex items-center justify-center bg-subtle">
|
||||
<IconPalette class="w-4 h-4 text-content-3" />
|
||||
</div>
|
||||
<span class="text-[11px] text-content-3">自定义</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@ -109,7 +173,7 @@
|
||||
class="w-6 h-6 flex items-center justify-center rounded-md text-content-4 hover:text-danger hover:bg-danger/10 transition"
|
||||
title="恢复默认"
|
||||
>
|
||||
<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>
|
||||
<IconX style="font-size: 14px" />
|
||||
</button>
|
||||
<button
|
||||
@click="startRecording(String(id))"
|
||||
@ -167,8 +231,8 @@
|
||||
:disabled="updater.checking.value"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition"
|
||||
>
|
||||
<svg v-if="!updater.checking.value" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
||||
<svg v-else class="animate-spin" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 11-6.22-8.56"/></svg>
|
||||
<IconFileText v-if="!updater.checking.value" class="w-4 h-4" />
|
||||
<IconLoader2 v-else class="w-4 h-4 animate-spin" />
|
||||
{{ updater.checking.value ? '检查中...' : '检查更新' }}
|
||||
</button>
|
||||
<button
|
||||
@ -176,7 +240,7 @@
|
||||
:disabled="fetchingChangelog"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition"
|
||||
>
|
||||
<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="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
|
||||
<IconFileText class="w-4 h-4" />
|
||||
{{ fetchingChangelog ? '获取中...' : '更新日志' }}
|
||||
</button>
|
||||
</div>
|
||||
@ -184,7 +248,7 @@
|
||||
</div>
|
||||
</section>
|
||||
<Transition name="fade">
|
||||
<div v-if="showResetConfirm" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showResetConfirm = false">
|
||||
<div v-if="showResetConfirm" class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showResetConfirm = 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>
|
||||
@ -203,7 +267,7 @@
|
||||
</Transition>
|
||||
|
||||
<Transition name="fade">
|
||||
<div v-if="showChangelogModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showChangelogModal = false">
|
||||
<div v-if="showChangelogModal" class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showChangelogModal = false">
|
||||
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-[480px] max-h-[80vh] flex flex-col select-auto">
|
||||
<div class="p-6 pb-4">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
@ -232,24 +296,592 @@
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- 皮肤编辑器弹窗(Teleport 到 body 避免 z-index 问题) -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="showSkinEditor" class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showSkinEditor = false">
|
||||
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-[960px] max-h-[90vh] flex flex-col select-auto">
|
||||
<!-- 顶栏 -->
|
||||
<div class="flex items-center justify-between p-5 border-b border-line-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-lg font-semibold">{{ editingSkinId ? '编辑皮肤' : '创建自定义皮肤' }}</h2>
|
||||
<input v-model="editorName" class="px-3 py-1.5 bg-subtle border border-line rounded-lg text-sm text-content focus:border-accent focus:outline-none transition w-40" placeholder="皮肤名称" />
|
||||
</div>
|
||||
<button @click="showSkinEditor = false" class="text-content-3 hover:text-content transition">
|
||||
<IconX class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<!-- 左侧:实时预览 -->
|
||||
<div class="w-[420px] flex-shrink-0 p-5 border-r border-line-2 flex flex-col gap-4">
|
||||
<p class="text-xs text-content-3 font-medium">实时预览</p>
|
||||
<!-- 横向桌面比例预览 -->
|
||||
<div class="rounded-xl overflow-hidden border border-line relative" style="aspect-ratio: 16/10;" :style="{ backgroundColor: getEditorColor('bg') }">
|
||||
<!-- 壁纸层 -->
|
||||
<div v-if="editorWallpaper && editorWallpaperDataUrl" class="absolute inset-0 bg-cover bg-center" :style="{ backgroundImage: `url(${editorWallpaperDataUrl})`, filter: `blur(${editorWallpaperBlur}px)`, opacity: editorWallpaperOpacity }"></div>
|
||||
<!-- 无壁纸时的提示 -->
|
||||
<div v-if="!editorWallpaper" class="absolute inset-0 flex items-center justify-center">
|
||||
<span class="text-[10px] opacity-20" :style="{ color: getEditorColor('content3') }">纯色背景</span>
|
||||
</div>
|
||||
<!-- 模拟内容 -->
|
||||
<div class="relative z-[1] flex flex-col h-full">
|
||||
<!-- 模拟 TitleBar -->
|
||||
<div class="h-5 flex items-center justify-end px-2 flex-shrink-0" :style="{ backgroundColor: `${getEditorColor('surface')}cc` }">
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-red-500"></div>
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-yellow-500 ml-1"></div>
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-green-500 ml-1"></div>
|
||||
</div>
|
||||
<div class="flex flex-1 min-h-0">
|
||||
<!-- 模拟 Sidebar (w-56 比例) -->
|
||||
<div class="w-[30%] flex-shrink-0 flex flex-col p-1.5 gap-0.5" :style="{ backgroundColor: `${getEditorColor('surface')}cc`, borderRight: `1px solid ${getEditorColor('line')}` }">
|
||||
<div class="flex items-center gap-1 px-1.5 py-1 rounded" :style="{ backgroundColor: getEditorColor('muted') }">
|
||||
<div class="w-1.5 h-1.5 rounded-sm" :style="{ backgroundColor: getEditorColor('accent') }"></div>
|
||||
<div class="h-1 rounded-full w-6" :style="{ backgroundColor: getEditorColor('content') }"></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 px-1.5 py-1 rounded">
|
||||
<div class="w-1.5 h-1.5 rounded-sm" :style="{ backgroundColor: getEditorColor('content3') }"></div>
|
||||
<div class="h-1 rounded-full w-5" :style="{ backgroundColor: getEditorColor('content2') }"></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 px-1.5 py-1 rounded">
|
||||
<div class="w-1.5 h-1.5 rounded-sm" :style="{ backgroundColor: getEditorColor('content3') }"></div>
|
||||
<div class="h-1 rounded-full w-4" :style="{ backgroundColor: getEditorColor('content2') }"></div>
|
||||
</div>
|
||||
<div class="mt-1 pt-1" :style="{ borderTop: `1px solid ${getEditorColor('line2')}` }">
|
||||
<div class="h-0.5 rounded-full w-4 mb-0.5" :style="{ backgroundColor: getEditorColor('content4') }"></div>
|
||||
<div class="flex items-center gap-1 px-1.5 py-1 rounded">
|
||||
<div class="w-1.5 h-1.5 rounded-sm" :style="{ backgroundColor: getEditorColor('content3') }"></div>
|
||||
<div class="h-1 rounded-full w-7" :style="{ backgroundColor: getEditorColor('content2') }"></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 px-1.5 py-1 rounded">
|
||||
<div class="w-1.5 h-1.5 rounded-sm" :style="{ backgroundColor: getEditorColor('content3') }"></div>
|
||||
<div class="h-1 rounded-full w-6" :style="{ backgroundColor: getEditorColor('content2') }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 底部设置+头像 -->
|
||||
<div class="mt-auto flex items-center gap-1 px-1.5 py-1">
|
||||
<div class="w-3 h-3 rounded-full" :style="{ backgroundColor: getEditorColor('subtle') }"></div>
|
||||
<div class="h-1 rounded-full w-5" :style="{ backgroundColor: getEditorColor('content3') }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 模拟主内容 -->
|
||||
<div class="flex-1 p-2 flex flex-col gap-1.5 overflow-hidden" :style="editorWallpaper ? { backgroundColor: `${getEditorColor('bg')}cc` } : {}">
|
||||
<div class="h-2 rounded-full w-14" :style="{ backgroundColor: getEditorColor('content') }"></div>
|
||||
<div class="h-1 rounded-full w-20" :style="{ backgroundColor: getEditorColor('content3') }"></div>
|
||||
<!-- 模拟歌曲行 -->
|
||||
<div class="mt-1 flex items-center gap-1 px-1 py-0.5 rounded" :style="{ backgroundColor: `${getEditorColor('surface')}99` }">
|
||||
<div class="w-2 text-right flex-shrink-0"><div class="h-0.5 rounded-full w-1.5 ml-auto" :style="{ backgroundColor: getEditorColor('content4') }"></div></div>
|
||||
<div class="w-4 h-4 rounded flex-shrink-0" :style="{ backgroundColor: getEditorColor('subtle') }"></div>
|
||||
<div class="flex-1 flex flex-col gap-0.5 min-w-0">
|
||||
<div class="h-0.5 rounded-full w-12" :style="{ backgroundColor: getEditorColor('content') }"></div>
|
||||
<div class="h-0.5 rounded-full w-8" :style="{ backgroundColor: getEditorColor('content3') }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 px-1 py-0.5 rounded">
|
||||
<div class="w-2 text-right flex-shrink-0"><div class="h-0.5 rounded-full w-1.5 ml-auto" :style="{ backgroundColor: getEditorColor('content4') }"></div></div>
|
||||
<div class="w-4 h-4 rounded flex-shrink-0" :style="{ backgroundColor: getEditorColor('subtle') }"></div>
|
||||
<div class="flex-1 flex flex-col gap-0.5 min-w-0">
|
||||
<div class="h-0.5 rounded-full w-10" :style="{ backgroundColor: getEditorColor('content2') }"></div>
|
||||
<div class="h-0.5 rounded-full w-14" :style="{ backgroundColor: getEditorColor('content3') }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 选中行(均衡器动画) -->
|
||||
<div class="flex items-center gap-1 px-1 py-0.5 rounded" :style="{ backgroundColor: getEditorColor('accentDim') }">
|
||||
<div class="w-2 flex items-center justify-end flex-shrink-0 gap-[1px]">
|
||||
<div class="w-[1px] rounded-full" :style="{ backgroundColor: getEditorColor('accentText'), height: '3px' }"></div>
|
||||
<div class="w-[1px] rounded-full" :style="{ backgroundColor: getEditorColor('accentText'), height: '5px' }"></div>
|
||||
<div class="w-[1px] rounded-full" :style="{ backgroundColor: getEditorColor('accentText'), height: '2px' }"></div>
|
||||
</div>
|
||||
<div class="w-4 h-4 rounded flex-shrink-0" :style="{ backgroundColor: getEditorColor('subtle') }"></div>
|
||||
<div class="flex-1 flex flex-col gap-0.5 min-w-0">
|
||||
<div class="h-0.5 rounded-full w-11" :style="{ backgroundColor: getEditorColor('accentText') }"></div>
|
||||
<div class="h-0.5 rounded-full w-8" :style="{ backgroundColor: getEditorColor('content3') }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 模拟按钮 -->
|
||||
<div class="flex gap-1 mt-0.5">
|
||||
<div class="px-2 py-0.5 rounded text-[6px] font-medium text-white" :style="{ backgroundColor: getEditorColor('accent') }">播放全部</div>
|
||||
<div class="px-2 py-0.5 rounded text-[6px]" :style="{ backgroundColor: getEditorColor('muted'), color: getEditorColor('content2') }">收藏</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 模拟 PlayerBar -->
|
||||
<div class="flex-shrink-0 flex flex-col" :style="{ backgroundColor: `${getEditorColor('surface')}f2` }">
|
||||
<!-- 进度条 -->
|
||||
<div class="h-0.5 w-full" :style="{ backgroundColor: getEditorColor('muted') }">
|
||||
<div class="h-full w-1/3" :style="{ backgroundColor: getEditorColor('accent') }"></div>
|
||||
</div>
|
||||
<div class="flex items-center px-2 h-6 gap-1.5">
|
||||
<!-- 封面+歌名 -->
|
||||
<div class="flex items-center gap-1 w-[30%] min-w-0">
|
||||
<div class="w-4 h-4 rounded flex-shrink-0" :style="{ backgroundColor: getEditorColor('subtle') }"></div>
|
||||
<div class="flex-1 flex flex-col gap-0.5 min-w-0">
|
||||
<div class="h-0.5 rounded-full w-10" :style="{ backgroundColor: getEditorColor('content') }"></div>
|
||||
<div class="h-0.5 rounded-full w-6" :style="{ backgroundColor: getEditorColor('content3') }"></div>
|
||||
</div>
|
||||
<svg class="w-1.5 h-1.5 flex-shrink-0" :style="{ color: getEditorColor('content3') }" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
|
||||
</div>
|
||||
<!-- 播放控制 -->
|
||||
<div class="flex-1 flex items-center justify-center gap-2">
|
||||
<svg class="w-2 h-2" :style="{ color: getEditorColor('content2') }" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polygon points="19 20 9 12 19 4" fill="currentColor"/><line x1="5" y1="4" x2="5" y2="20"/></svg>
|
||||
<div class="w-4 h-4 rounded-full flex items-center justify-center" :style="{ backgroundColor: getEditorColor('muted'), border: `1px solid ${getEditorColor('emphasis')}` }">
|
||||
<svg class="w-2 h-2" :style="{ color: getEditorColor('content') }" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21"/></svg>
|
||||
</div>
|
||||
<svg class="w-2 h-2" :style="{ color: getEditorColor('content2') }" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polygon points="5 4 15 12 5 20" fill="currentColor"/><line x1="19" y1="4" x2="19" y2="20"/></svg>
|
||||
</div>
|
||||
<!-- 右侧 -->
|
||||
<div class="w-[30%] flex items-center justify-end gap-1">
|
||||
<svg class="w-1.5 h-1.5" :style="{ color: getEditorColor('content3') }" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19" fill="currentColor"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/></svg>
|
||||
<div class="w-4 h-0.5 rounded-full" :style="{ backgroundColor: getEditorColor('muted') }">
|
||||
<div class="h-full w-2/3 rounded-full" :style="{ backgroundColor: getEditorColor('accent') }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 基础风格快选 -->
|
||||
<div>
|
||||
<p class="text-xs text-content-3 mb-2">基于预设风格</p>
|
||||
<div class="flex gap-1.5 flex-wrap">
|
||||
<div
|
||||
v-for="s in presetSkins"
|
||||
:key="s.id"
|
||||
@click="editorBaseSkin = s.id; onBaseSkinChange()"
|
||||
class="w-6 h-6 rounded-full cursor-pointer border-2 transition-all"
|
||||
:class="editorBaseSkin === s.id ? 'border-white scale-125' : 'border-transparent hover:scale-110'"
|
||||
:style="{ backgroundColor: s.preview }"
|
||||
:title="s.name"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:控制面板 -->
|
||||
<div class="flex-1 overflow-y-auto p-5 space-y-5">
|
||||
<!-- 背景与壁纸 -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-1 h-4 rounded-full" :style="{ backgroundColor: getEditorColor('accent') }"></div>
|
||||
<p class="text-sm font-medium">背景与壁纸</p>
|
||||
</div>
|
||||
<div class="pl-3 space-y-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="color-swatch"><input type="color" :value="toHex(getEditorColor('bg'))" @input="setEditorColor('bg', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<div>
|
||||
<p class="text-sm">背景色</p>
|
||||
<p class="text-[11px] text-content-3">整个页面的底色,壁纸会覆盖在上面</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="pickEditorWallpaper" class="flex items-center gap-2 px-3 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition">
|
||||
<IconImage class="w-4 h-4" />
|
||||
{{ editorWallpaper ? '更换图片' : '选择壁纸图片' }}
|
||||
</button>
|
||||
<button v-if="editorWallpaper" @click="editorWallpaper = ''" class="px-3 py-2 bg-subtle hover:bg-danger/10 rounded-lg text-sm text-content-2 hover:text-danger transition">移除</button>
|
||||
</div>
|
||||
<template v-if="editorWallpaper">
|
||||
<div>
|
||||
<div class="flex justify-between mb-1">
|
||||
<span class="text-xs text-content-3">模糊</span>
|
||||
<span class="text-xs text-content-4">{{ editorWallpaperBlur }}px</span>
|
||||
</div>
|
||||
<input type="range" min="0" max="30" step="1" v-model.number="editorWallpaperBlur" class="w-full h-1.5 bg-muted rounded-full appearance-none cursor-pointer accent-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex justify-between mb-1">
|
||||
<span class="text-xs text-content-3">透明度</span>
|
||||
<span class="text-xs text-content-4">{{ Math.round(editorWallpaperOpacity * 100) }}%</span>
|
||||
</div>
|
||||
<input type="range" min="0" max="100" step="5" :value="Math.round(editorWallpaperOpacity * 100)" @input="editorWallpaperOpacity = Number(($event.target as HTMLInputElement).value) / 100" class="w-full h-1.5 bg-muted rounded-full appearance-none cursor-pointer accent-accent" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主题色 -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-1 h-4 rounded-full" :style="{ backgroundColor: getEditorColor('accent') }"></div>
|
||||
<p class="text-sm font-medium">主题色</p>
|
||||
</div>
|
||||
<p class="text-[11px] text-content-3 pl-3">按钮、链接、高亮、播放图标等使用这个颜色</p>
|
||||
<div class="pl-3 space-y-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="color-swatch"><input type="color" :value="toHex(getEditorColor('accent'))" @input="setEditorColor('accent', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<div>
|
||||
<p class="text-sm">主题色</p>
|
||||
<p class="text-[11px] text-content-3">按钮、进度条、选中状态</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="color-swatch color-swatch-sm"><input type="color" :value="toHex(getEditorColor('accentDim'))" @input="setEditorColor('accentDim', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<div>
|
||||
<p class="text-sm text-content-2">主题色淡</p>
|
||||
<p class="text-[11px] text-content-3">选中项的背景、淡色高亮</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文字颜色 -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-1 h-4 rounded-full" :style="{ backgroundColor: getEditorColor('content') }"></div>
|
||||
<p class="text-sm font-medium">文字</p>
|
||||
</div>
|
||||
<div class="pl-3 space-y-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="color-swatch"><input type="color" :value="toHex(getEditorColor('content'))" @input="setEditorColor('content', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<div>
|
||||
<p class="text-sm">主要文字</p>
|
||||
<p class="text-[11px] text-content-3">标题、歌曲名等最重要的文字</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="color-swatch color-swatch-sm"><input type="color" :value="toHex(getEditorColor('content2'))" @input="setEditorColor('content2', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<div>
|
||||
<p class="text-sm text-content-2">次要文字</p>
|
||||
<p class="text-[11px] text-content-3">歌手名、专辑名</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="color-swatch color-swatch-sm"><input type="color" :value="toHex(getEditorColor('content3'))" @input="setEditorColor('content3', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<div>
|
||||
<p class="text-sm text-content-2">辅助文字</p>
|
||||
<p class="text-[11px] text-content-3">描述、时间、播放量等</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表面与卡片 -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-1 h-4 rounded-full" :style="{ backgroundColor: getEditorColor('surface') }"></div>
|
||||
<p class="text-sm font-medium">表面与卡片</p>
|
||||
</div>
|
||||
<p class="text-[11px] text-content-3 pl-3">侧栏、底栏、弹窗、歌曲卡片的背景色</p>
|
||||
<div class="pl-3 space-y-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="color-swatch"><input type="color" :value="toHex(getEditorColor('surface'))" @input="setEditorColor('surface', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<div>
|
||||
<p class="text-sm">卡片背景</p>
|
||||
<p class="text-[11px] text-content-3">弹窗、侧栏、底栏的主色</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="color-swatch color-swatch-sm"><input type="color" :value="toHex(getEditorColor('line'))" @input="setEditorColor('line', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<div>
|
||||
<p class="text-sm text-content-2">分割线</p>
|
||||
<p class="text-[11px] text-content-3">卡片边框、区域之间的分隔</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 更多细节(折叠) -->
|
||||
<div>
|
||||
<button @click="showAdvancedEditor = !showAdvancedEditor" class="flex items-center gap-1.5 text-xs text-content-3 hover:text-content-2 transition">
|
||||
<svg class="w-3 h-3 transition-transform" :class="showAdvancedEditor ? 'rotate-90' : ''" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
更多细节调整
|
||||
</button>
|
||||
<div v-if="showAdvancedEditor" class="mt-3 space-y-4 pl-1">
|
||||
<div>
|
||||
<p class="text-[11px] text-content-3 mb-1.5">悬停与交互</p>
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="color-swatch color-swatch-xs"><input type="color" :value="toHex(getEditorColor('subtle'))" @input="setEditorColor('subtle', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<span class="text-xs text-content-2">微弱背景</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="color-swatch color-swatch-xs"><input type="color" :value="toHex(getEditorColor('muted'))" @input="setEditorColor('muted', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<span class="text-xs text-content-2">悬停背景</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="color-swatch color-swatch-xs"><input type="color" :value="toHex(getEditorColor('emphasis'))" @input="setEditorColor('emphasis', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<span class="text-xs text-content-2">强调背景</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[11px] text-content-3 mb-1.5">主题色变体</p>
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="color-swatch color-swatch-xs"><input type="color" :value="toHex(getEditorColor('accentHover'))" @input="setEditorColor('accentHover', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<span class="text-xs text-content-2">按钮悬停</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="color-swatch color-swatch-xs"><input type="color" :value="toHex(getEditorColor('accentText'))" @input="setEditorColor('accentText', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<span class="text-xs text-content-2">主题文字</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[11px] text-content-3 mb-1.5">功能色</p>
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="color-swatch color-swatch-xs"><input type="color" :value="toHex(getEditorColor('danger'))" @input="setEditorColor('danger', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<span class="text-xs text-content-2">危险/错误</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="color-swatch color-swatch-xs"><input type="color" :value="toHex(getEditorColor('warning'))" @input="setEditorColor('warning', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<span class="text-xs text-content-2">警告</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底栏 -->
|
||||
<div class="p-4 border-t border-line flex gap-3">
|
||||
<button @click="showSkinEditor = false" class="flex-1 py-2.5 rounded-lg bg-muted hover:bg-emphasis text-sm text-content-2 transition">取消</button>
|
||||
<button @click="handleSaveSkin" :disabled="!editorName.trim()" class="flex-1 py-2.5 rounded-lg bg-accent hover:bg-accent-hover text-white text-sm font-medium transition disabled:opacity-50">{{ editingSkinId ? '保存修改' : '创建皮肤' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useSettingsStore, qualityLabels, closeActionLabels, defaultShortcuts, themeLabels, themeColors, type CloseAction } from '../stores/settings';
|
||||
import { ref, computed, onMounted, onBeforeUnmount, reactive, watch } from 'vue';
|
||||
import { useSettingsStore, qualityLabels, closeActionLabels, defaultShortcuts, type CloseAction } from '../stores/settings';
|
||||
import { presetSkins, getPresetSkin, type SkinColors } from '../skins';
|
||||
import { toHex } from '../utils/color';
|
||||
import { useToast } from '../composables/useToast';
|
||||
import { useUpdater } from '../composables/useUpdater';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { DeviceApi, DownloadApi, AppApi } from '../api';
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import CustomSelect from '../components/CustomSelect.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import IconX from '~icons/lucide/x';
|
||||
import IconFileText from '~icons/lucide/file-text';
|
||||
import IconLoader2 from '~icons/lucide/loader-2';
|
||||
import IconPalette from '~icons/lucide/palette';
|
||||
import IconSun from '~icons/lucide/sun';
|
||||
import IconMoon from '~icons/lucide/moon';
|
||||
import IconImage from '~icons/lucide/image';
|
||||
|
||||
const settings = useSettingsStore();
|
||||
const { showToast } = useToast();
|
||||
const updater = useUpdater();
|
||||
|
||||
// 主题色选项(7色,不分深浅)
|
||||
const themeColorOptions = [
|
||||
{ id: 'blue', name: '蓝', color: '#3b82f6' },
|
||||
{ id: 'green', name: '翠', color: '#22c55e' },
|
||||
{ id: 'rose', name: '红', color: '#f43f5e' },
|
||||
{ id: 'violet', name: '紫', color: '#8b5cf6' },
|
||||
{ id: 'orange', name: '橙', color: '#f97316' },
|
||||
{ id: 'cyan', name: '青', color: '#06b6d4' },
|
||||
{ id: 'pink', name: '粉', color: '#ec4899' },
|
||||
];
|
||||
|
||||
// 从当前 skin id 解析出 appearance 和 themeColor
|
||||
const currentAppearance = computed(() => {
|
||||
if (settings.skin.startsWith('light')) return 'light';
|
||||
return 'dark';
|
||||
});
|
||||
|
||||
const currentThemeColor = computed(() => {
|
||||
const id = settings.skin;
|
||||
if (id.startsWith('dark-')) return id.slice(5);
|
||||
if (id.startsWith('light-')) return id.slice(6);
|
||||
return 'blue'; // 自定义皮肤默认蓝
|
||||
});
|
||||
|
||||
function toSkinId(color: string, appearance: 'dark' | 'light'): string {
|
||||
return `${appearance}-${color}`;
|
||||
}
|
||||
|
||||
// 壁纸路径转可访问 URL(通过 Rust 命令读取本地图片转 base64 data URL)
|
||||
const wallpaperCache = new Map<string, string>();
|
||||
const MAX_WALLPAPER_CACHE = 10;
|
||||
async function wallpaperSrc(path: string): Promise<string> {
|
||||
if (!path) return '';
|
||||
if (wallpaperCache.has(path)) return wallpaperCache.get(path)!;
|
||||
try {
|
||||
const dataUrl = await AppApi.readImageAsDataUrl(path);
|
||||
if (wallpaperCache.size >= MAX_WALLPAPER_CACHE) {
|
||||
const firstKey = wallpaperCache.keys().next().value;
|
||||
if (firstKey !== undefined) wallpaperCache.delete(firstKey);
|
||||
}
|
||||
wallpaperCache.set(path, dataUrl);
|
||||
return dataUrl;
|
||||
} catch (e) {
|
||||
console.error('加载壁纸预览失败:', e);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
// 用于模板中同步绑定壁纸预览的响应式数据
|
||||
const editorWallpaperDataUrl = ref('');
|
||||
const skinWallpaperDataUrls = ref<Record<string, string>>({});
|
||||
|
||||
async function loadEditorWallpaper() {
|
||||
if (!editorWallpaper.value) {
|
||||
editorWallpaperDataUrl.value = '';
|
||||
return;
|
||||
}
|
||||
editorWallpaperDataUrl.value = await wallpaperSrc(editorWallpaper.value);
|
||||
}
|
||||
|
||||
async function loadSkinWallpaperPreviews() {
|
||||
for (const s of settings.customSkins) {
|
||||
if (s.wallpaper && !skinWallpaperDataUrls.value[s.wallpaper]) {
|
||||
const url = await wallpaperSrc(s.wallpaper);
|
||||
if (url) skinWallpaperDataUrls.value[s.wallpaper] = url;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 皮肤编辑器
|
||||
const showSkinEditor = ref(false);
|
||||
const showAdvancedEditor = ref(false);
|
||||
const editorName = ref('');
|
||||
const editorBaseSkin = ref('dark-blue');
|
||||
const editorColors = reactive<Partial<SkinColors>>({});
|
||||
const editorWallpaper = ref('');
|
||||
const editorWallpaperBlur = ref(10);
|
||||
const editorWallpaperOpacity = ref(0.3);
|
||||
/** 正在编辑的已有皮肤 id,为空则表示创建新皮肤 */
|
||||
const editingSkinId = ref<string | null>(null);
|
||||
|
||||
function openSkinEditor(skinId?: string) {
|
||||
if (skinId) {
|
||||
// 编辑已有自定义皮肤
|
||||
const existing = settings.customSkins.find(s => s.id === skinId);
|
||||
if (!existing) return;
|
||||
editingSkinId.value = skinId;
|
||||
editorName.value = existing.name;
|
||||
editorBaseSkin.value = 'dark-blue';
|
||||
// 将已有颜色完整填入 editorColors
|
||||
Object.keys(editorColors).forEach(k => delete editorColors[k as keyof SkinColors]);
|
||||
for (const [key, value] of Object.entries(existing.colors)) {
|
||||
(editorColors as any)[key] = value;
|
||||
}
|
||||
editorWallpaper.value = existing.wallpaper || '';
|
||||
editorWallpaperBlur.value = existing.wallpaperBlur ?? 10;
|
||||
editorWallpaperOpacity.value = existing.wallpaperOpacity ?? 0.3;
|
||||
} else {
|
||||
// 创建新皮肤:基于当前皮肤或默认
|
||||
editingSkinId.value = null;
|
||||
editorName.value = '';
|
||||
const baseSkinId = settings.isPreset ? settings.skin : 'dark-blue';
|
||||
editorBaseSkin.value = baseSkinId;
|
||||
// 将基础皮肤颜色完整填入 editorColors
|
||||
Object.keys(editorColors).forEach(k => delete editorColors[k as keyof SkinColors]);
|
||||
const base = getPresetSkin(baseSkinId);
|
||||
if (base) {
|
||||
for (const [key, value] of Object.entries(base.colors)) {
|
||||
(editorColors as any)[key] = value;
|
||||
}
|
||||
}
|
||||
editorWallpaper.value = '';
|
||||
editorWallpaperBlur.value = 10;
|
||||
editorWallpaperOpacity.value = 0.3;
|
||||
}
|
||||
showSkinEditor.value = true;
|
||||
loadEditorWallpaper();
|
||||
loadSkinWallpaperPreviews();
|
||||
}
|
||||
|
||||
function onBaseSkinChange() {
|
||||
// 切换基础风格时,将该风格的完整颜色填入 editorColors
|
||||
Object.keys(editorColors).forEach(k => delete editorColors[k as keyof SkinColors]);
|
||||
const base = getPresetSkin(editorBaseSkin.value);
|
||||
if (base) {
|
||||
for (const [key, value] of Object.entries(base.colors)) {
|
||||
(editorColors as any)[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getEditorColor(key: keyof SkinColors): string {
|
||||
return editorColors[key] || '#000000';
|
||||
}
|
||||
|
||||
function setEditorColor(key: keyof SkinColors, value: string) {
|
||||
editorColors[key] = value;
|
||||
}
|
||||
|
||||
function handleSaveSkin() {
|
||||
if (!editorName.value.trim()) return;
|
||||
// 确保颜色完整:缺失字段从基础皮肤补齐
|
||||
const base = getPresetSkin(editorBaseSkin.value);
|
||||
const baseColors = base ? base.colors : getPresetSkin('dark-blue')!.colors;
|
||||
const colors = { ...baseColors } as SkinColors;
|
||||
for (const key of Object.keys(editorColors) as (keyof SkinColors)[]) {
|
||||
if (editorColors[key]) {
|
||||
colors[key] = editorColors[key]!;
|
||||
}
|
||||
}
|
||||
|
||||
if (editingSkinId.value) {
|
||||
settings.updateCustomSkin(editingSkinId.value, {
|
||||
name: editorName.value.trim(),
|
||||
preview: colors.accent,
|
||||
colors,
|
||||
wallpaper: editorWallpaper.value,
|
||||
wallpaperBlur: editorWallpaperBlur.value,
|
||||
wallpaperOpacity: editorWallpaperOpacity.value,
|
||||
});
|
||||
showSkinEditor.value = false;
|
||||
showToast('皮肤已更新', 'success');
|
||||
} else {
|
||||
const id = `custom-${Date.now()}`;
|
||||
settings.addCustomSkin({
|
||||
id,
|
||||
name: editorName.value.trim(),
|
||||
preview: colors.accent,
|
||||
colors,
|
||||
wallpaper: editorWallpaper.value,
|
||||
wallpaperBlur: editorWallpaperBlur.value,
|
||||
wallpaperOpacity: editorWallpaperOpacity.value,
|
||||
});
|
||||
showSkinEditor.value = false;
|
||||
showToast('自定义皮肤已创建', 'success');
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteCustomSkin(id: string) {
|
||||
settings.removeCustomSkin(id);
|
||||
showToast('已删除自定义皮肤', 'success');
|
||||
}
|
||||
|
||||
async function pickEditorWallpaper() {
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
title: '选择壁纸图片',
|
||||
filters: [{
|
||||
name: '图片',
|
||||
extensions: ['png', 'jpg', 'jpeg', 'webp', 'bmp', 'gif'],
|
||||
}],
|
||||
});
|
||||
if (selected) {
|
||||
editorWallpaper.value = selected;
|
||||
wallpaperCache.delete(selected);
|
||||
loadEditorWallpaper();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 监听编辑器壁纸变化
|
||||
watch(editorWallpaper, () => {
|
||||
loadEditorWallpaper();
|
||||
});
|
||||
|
||||
// 监听自定义皮肤列表变化,加载壁纸预览
|
||||
watch(() => settings.customSkins, () => {
|
||||
loadSkinWallpaperPreviews();
|
||||
}, { deep: true });
|
||||
|
||||
const devices = ref<string[]>([]);
|
||||
const deviceOptions = computed(() => {
|
||||
const options: Record<string, string> = { '': '跟随系统默认' };
|
||||
@ -264,7 +896,7 @@ const selectedDevice = computed({
|
||||
set: (val: string) => {
|
||||
const device = val === '' ? null : val;
|
||||
settings.setOutputDevice(device);
|
||||
invoke('set_output_device', { device }).then(() => {
|
||||
DeviceApi.setOutputDevice(device).then(() => {
|
||||
showToast(device ? `已切换到: ${device}` : '已切换到系统默认', 'success');
|
||||
}).catch((e) => {
|
||||
console.error('切换设备失败: ', e);
|
||||
@ -275,7 +907,7 @@ const selectedDevice = computed({
|
||||
|
||||
async function loadDevices() {
|
||||
try {
|
||||
devices.value = await invoke<string[]>('get_output_devices');
|
||||
devices.value = await DeviceApi.getOutputDevices();
|
||||
} catch (e) {
|
||||
console.error('获取设备失败: ', e);
|
||||
}
|
||||
@ -286,8 +918,8 @@ const defaultDownloadPath = ref('');
|
||||
onMounted(async () => {
|
||||
appVersion.value = await getVersion();
|
||||
try {
|
||||
defaultDownloadPath.value = await invoke<string>('get_default_download_path');
|
||||
} catch { }
|
||||
defaultDownloadPath.value = await DownloadApi.getDefaultDownloadPath();
|
||||
} catch { /* 忽略 */ }
|
||||
loadDevices();
|
||||
});
|
||||
|
||||
@ -427,3 +1059,50 @@ onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', onRecordingKeydown, true);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 颜色选择器:让 input[type=color] 填满外层 label,消除内部小方块 */
|
||||
.color-swatch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-line);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.color-swatch-sm {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.color-swatch-xs {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.color-swatch input[type="color"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: calc(100% + 8px);
|
||||
height: calc(100% + 8px);
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
}
|
||||
.color-swatch input[type="color"]::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
.color-swatch input[type="color"]::-webkit-color-swatch {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
.color-swatch input[type="color"]::-moz-color-swatch {
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -4,11 +4,8 @@ import tailwindcss from "@tailwindcss/vite";
|
||||
import Icons from "unplugin-icons/vite";
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
|
||||
|
||||
// \@ts-expect-error process is a nodejs global
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [
|
||||
vue(),
|
||||
@ -21,11 +18,19 @@ export default defineConfig(async () => ({
|
||||
},
|
||||
},
|
||||
|
||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||
//
|
||||
// 1. prevent Vite from obscuring rust errors
|
||||
build: {
|
||||
target: "esnext",
|
||||
cssCodeSplit: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
vendor: ["vue", "vue-router", "pinia"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
clearScreen: false,
|
||||
// 2. tauri expects a fixed port, fail if that port is not available
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
@ -38,7 +43,6 @@ export default defineConfig(async () => ({
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
// 3. tell Vite to ignore watching `src-tauri`
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user