mirror of
https://github.com/atdunbg/Nekosonic-Music.git
synced 2026-06-22 00:58:51 +08:00
feat: 云盘/下载音乐分离/粘性头部/播放状态同步/歌手关注
新增: - 音乐云盘页面(列表/详情弹窗/删除/存储空间, NOS multipart上传+LBS区域查询+进度事件) - 下载音乐页面(独立于本地音乐, 只显示应用下载的歌曲) - PageHeader粘性头部组件(IntersectionObserver控制显隐, 渐变模糊背景) - useLocalMusic composable(LocalSong类型/formatFileSize/localSongToSong/fetchMissingCovers) - 云盘上传完整流程(cloud_upload命令: check->token->LBS->NOS分块上传->info->publish) - 云盘API(user_cloud/user_cloud_detail/user_cloud_del) - 歌手关注/取关(artist_sub/artist_sublist命令, ArtistDetail关注按钮+artistSublist查询状态) - 本地音乐多文件夹扫描(scan_local_folders命令, settings.localMusicPaths, 模态框管理) - 侧边栏下载音乐和云盘导航项, 路由新增downloaded-music和cloud-music - md5 crate依赖 改进: - 路由全部改为懒加载 - keep-alive缓存管理重写(30s TTL+导航栈保护+FavoriteSongs常驻+10s定时清理) - 播放器状态同步改为轮询isAudioPlaying(替代audio-started事件), 超时后watchForLatePlayback继续监听 - audio.rs新增is_playing原子状态+is_audio_playing命令 - 同步命令改async+spawn_blocking(list_local_songs/delete_local_song/check_local_song/get_default_download_path) - scan_dir_for_songs抽取为公共函数, 新增downloaded_only参数 - RoamDrawer tab状态从组件本地ref移至store(roamTab替换roamInitialTab) - App.vue onMounted改为非阻塞 - 多页面添加骨架屏加载态和加载失败重试 - 多页面使用PageHeader替代手动返回按钮 - PlaylistDetail/ArtistDetail添加简介弹窗(溢出时显示查看完整介绍) - Home推荐/排行榜拆分为独立fetch函数支持分别重试 - Toast去重(3s窗口)+数量限制(最多3条) - LocalMusic移除删除功能改文件夹模态框, ArtistDetail头像改圆形简介内嵌 - README重写 修复: - 播放超时后后端实际开始播放但UI显示暂停(watchForLatePlayback+tick定期同步isAudioPlaying) - FM播放缺少playSeq竞态保护 - scrobble离线时仍发送(添加navigator.onLine检查) - RoamDrawer已打开时点击评论按钮无法切换(roamTab移至store) - 关闭RoamDrawer后再打开永远显示评论(closeRoamDrawer重置roamTab) - 歌手详情页关注状态离开后丢失(artist_detail不返回followed, 改用artistSublist查询) - audio-ended事件在切歌时误触发(新增_switchingSong标志拦截) - 路由beforeEach中localStorage key从user改为user_profile - toggle播放前先同步后端状态
This commit is contained in:
34
CHANGELOG.md
34
CHANGELOG.md
@ -1,3 +1,37 @@
|
|||||||
|
## v0.7.0
|
||||||
|
|
||||||
|
### ✨ 新功能
|
||||||
|
- **音乐云盘**:新增云盘页面,可浏览、播放云盘中的歌曲,查看文件详情(文件名、大小、比特率、上传时间),删除云盘歌曲,查看存储空间使用情况
|
||||||
|
- **云盘上传**:支持上传本地音频文件到云盘,上传过程显示实时进度,支持 mp3/flac/wav/ogg/aac/m4a 格式
|
||||||
|
- **下载音乐**:本地音乐拆分为「本地音乐」和「下载音乐」两个独立页面,下载音乐只显示通过应用下载的歌曲
|
||||||
|
- **本地音乐多文件夹**:本地音乐支持添加多个扫描文件夹,通过三点按钮+弹窗管理文件夹路径
|
||||||
|
- **歌手关注**:歌手详情页新增关注/取关按钮,关注状态在离开页面后不会丢失
|
||||||
|
- **粘性导航栏**:页面滚动较深时顶部自动显示返回按钮和功能按钮,渐变模糊效果,不影响阅读
|
||||||
|
- **骨架屏加载**:首页、歌单、歌手、专辑、云盘等多个页面加载时显示骨架占位动画,不再只有"加载中"文字
|
||||||
|
|
||||||
|
### 🐛 修复
|
||||||
|
- 网络较差时播放启动超时,音乐实际已开始播放但界面仍显示暂停
|
||||||
|
- 全屏漫游抽屉打开时点击评论按钮无法切换到评论页
|
||||||
|
- 关闭漫游抽屉后再打开,始终显示评论而非歌词
|
||||||
|
- 歌手详情页关注后离开再回来,关注状态丢失
|
||||||
|
- 切歌时偶尔触发上一首歌的播放结束事件导致异常
|
||||||
|
- 评论点赞无限叠加(改为服务端状态驱动)
|
||||||
|
- 播放栏进度条上方多余分隔线
|
||||||
|
|
||||||
|
### 🎨 变更
|
||||||
|
- 歌手详情页头像改为圆形,简介从独立标签页移至头部内嵌显示,溢出时可展开查看完整介绍
|
||||||
|
- 歌单详情页描述溢出时显示"查看完整介绍"按钮
|
||||||
|
- 首页推荐和排行榜加载失败时显示重试按钮,支持分别重试
|
||||||
|
- 多个页面的返回按钮统一为粘性导航栏组件
|
||||||
|
- 消息提示增加去重和数量限制,避免重复弹出
|
||||||
|
|
||||||
|
### ⚡ 优化
|
||||||
|
- 页面切换更流畅,路由全部改为懒加载
|
||||||
|
- 页面缓存管理优化,30 秒未访问自动释放,多级跳转时保留导航链上的页面,「我喜欢的音乐」常驻缓存
|
||||||
|
- 本地音乐扫描不再阻塞界面导航
|
||||||
|
- 应用启动不再等待网络请求完成
|
||||||
|
|
||||||
|
|
||||||
## v0.6.0
|
## v0.6.0
|
||||||
|
|
||||||
### ✨ 新功能
|
### ✨ 新功能
|
||||||
|
|||||||
142
README.md
142
README.md
@ -1,126 +1,102 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
# Nekosonic
|
# 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
|
```bash
|
||||||
# 安装前端依赖
|
|
||||||
npm install
|
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 |
|
| 桌面框架 | Tauri 2 |
|
||||||
| 前端 | Vue 3 + TypeScript |
|
| 前端 | Vue 3 + TypeScript + Pinia |
|
||||||
| 样式 | Tailwind CSS v4 + CSS 变量主题系统 |
|
| 样式 | Tailwind CSS v4 + CSS 变量主题 |
|
||||||
| 状态管理 | Pinia |
|
| 音频解码 | symphonia + ringbuf |
|
||||||
| 路由 | Vue Router 4 |
|
| 媒体控制 | souvlaki |
|
||||||
| 音频解码 | symphonia + ringbuf (Rust) |
|
|
||||||
| 媒体控制 | souvlaki (Linux MPRIS / Windows SMTC / macOS Now Playing) |
|
|
||||||
| 网易云 API | ncm-api-rs |
|
| 网易云 API | ncm-api-rs |
|
||||||
| 构建工具 | Vite 6 |
|
| 构建 | Vite 6 |
|
||||||
|
|
||||||
## ☑️ Todo
|
## Todo
|
||||||
|
|
||||||
- [x] 评论系统
|
- [x] 评论查看
|
||||||
- [x] 歌曲下载
|
- [x] 歌曲下载
|
||||||
- [x] 本地音乐管理
|
- [x] 本地音乐
|
||||||
- [x] 歌手详情页
|
- [x] 歌手详情页
|
||||||
- [x] 专辑详情页
|
- [x] 专辑详情页
|
||||||
- [x] 自定义全局快捷键
|
- [x] 自定义全局快捷键
|
||||||
- [x] 自动更新
|
- [x] 自动更新
|
||||||
- [x] 歌词翻译
|
- [x] 歌词翻译
|
||||||
- [x] 更多主题
|
- [x] 更多主题
|
||||||
- [x] 系统媒体控制(蓝牙耳机/键盘媒体键)
|
- [x] 音乐云盘
|
||||||
- [ ] MV 播放
|
- [ ] MV 播放
|
||||||
- [ ] 音乐云盘
|
|
||||||
- [ ] 桌面歌词
|
- [ ] 桌面歌词
|
||||||
|
|
||||||
欢迎提 Issue 和 Pull request。
|
欢迎提 Issue 和 Pull request。
|
||||||
|
|
||||||
## 📜 开源许可
|
## 开源许可
|
||||||
|
|
||||||
本项目仅供个人学习研究使用,禁止用于商业及非法用途。
|
本项目仅供个人学习研究使用,禁止用于商业及非法用途。基于 [MIT License](https://opensource.org/licenses/MIT) 开源。
|
||||||
|
|
||||||
基于 [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 媒体控制库
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "nekosonic",
|
"name": "nekosonic",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.6.0",
|
"version": "0.7.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
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]
|
[package]
|
||||||
name = "Nekosonic"
|
name = "Nekosonic"
|
||||||
version = "0.6.0"
|
version = "0.7.0"
|
||||||
description = "A Simple music app"
|
description = "A Simple music app"
|
||||||
authors = ["atdunbg"]
|
authors = ["atdunbg"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
@ -33,6 +33,7 @@ futures-util = "0.3"
|
|||||||
dirs = "5"
|
dirs = "5"
|
||||||
lofty = "0.22"
|
lofty = "0.22"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
md5 = "0.7"
|
||||||
|
|
||||||
ncm-api-rs = "0.1"
|
ncm-api-rs = "0.1"
|
||||||
tokio = { version = "1", features = ["rt", "sync"] }
|
tokio = { version = "1", features = ["rt", "sync"] }
|
||||||
|
|||||||
@ -2,6 +2,7 @@ use ncm_api_rs::{create_client, ApiClient, Query};
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tauri::{Manager, State, Emitter};
|
use tauri::{Manager, State, Emitter};
|
||||||
|
use tokio::sync::Mutex as AsyncMutex;
|
||||||
use std::sync::Mutex as StdMutex;
|
use std::sync::Mutex as StdMutex;
|
||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
@ -44,14 +45,14 @@ use base64::Engine;
|
|||||||
/// ```
|
/// ```
|
||||||
macro_rules! api_call {
|
macro_rules! api_call {
|
||||||
($state:expr, $method:ident) => {{
|
($state:expr, $method:ident) => {{
|
||||||
let client = $state.client.lock().unwrap().clone();
|
let client = $state.client.lock().await.clone();
|
||||||
let q = $state.build_query();
|
let q = $state.build_query();
|
||||||
client.$method(&q).await
|
client.$method(&q).await
|
||||||
.map(|r| r.body.to_string())
|
.map(|r| r.body.to_string())
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}};
|
}};
|
||||||
($state:expr, $method:ident, params: [$(($key:expr, $val:expr)),* $(,)?]) => {{
|
($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();
|
let mut q = $state.build_query();
|
||||||
$(q = q.param($key, $val);)*
|
$(q = q.param($key, $val);)*
|
||||||
client.$method(&q).await
|
client.$method(&q).await
|
||||||
@ -59,7 +60,7 @@ macro_rules! api_call {
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}};
|
}};
|
||||||
($state:expr, $method:ident, query: $q:expr) => {{
|
($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
|
client.$method(&$q).await
|
||||||
.map(|r| r.body.to_string())
|
.map(|r| r.body.to_string())
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
@ -67,7 +68,7 @@ macro_rules! api_call {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct ApiController {
|
pub struct ApiController {
|
||||||
client: StdMutex<ApiClient>,
|
client: AsyncMutex<ApiClient>,
|
||||||
cookie: StdMutex<Option<String>>,
|
cookie: StdMutex<Option<String>>,
|
||||||
cookie_path: PathBuf,
|
cookie_path: PathBuf,
|
||||||
}
|
}
|
||||||
@ -94,7 +95,7 @@ impl ApiController {
|
|||||||
|
|
||||||
let client = create_client(None);
|
let client = create_client(None);
|
||||||
ApiController {
|
ApiController {
|
||||||
client: StdMutex::new(client),
|
client: AsyncMutex::new(client),
|
||||||
cookie: StdMutex::new(saved_cookie),
|
cookie: StdMutex::new(saved_cookie),
|
||||||
cookie_path,
|
cookie_path,
|
||||||
}
|
}
|
||||||
@ -111,13 +112,12 @@ impl ApiController {
|
|||||||
query
|
query
|
||||||
}
|
}
|
||||||
/// 将 Cookie 字符串持久化到本地文件并同步到 API 客户端
|
/// 将 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);
|
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());
|
client.set_cookie(cookie_str.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// 搜索查询参数
|
/// 搜索查询参数
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@ -191,7 +191,7 @@ pub struct SongUrlQuery { pub id: u64, pub level: Option<String>, pub fm_mode: O
|
|||||||
/// 获取歌曲播放地址(返回完整 data 对象,包含 url、freeTrialInfo 等)
|
/// 获取歌曲播放地址(返回完整 data 对象,包含 url、freeTrialInfo 等)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_song_url(query: SongUrlQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
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 level = query.level.as_deref().unwrap_or("standard");
|
||||||
|
|
||||||
let resp = if query.fm_mode.unwrap_or(false) {
|
let resp = if query.fm_mode.unwrap_or(false) {
|
||||||
@ -251,7 +251,7 @@ pub async fn get_playlist_detail(id: u64, state: State<'_, ApiController>) -> Re
|
|||||||
/// 手机号密码登录
|
/// 手机号密码登录
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn login(query: LoginQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
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()
|
let q = Query::new()
|
||||||
.param("phone", &query.phone)
|
.param("phone", &query.phone)
|
||||||
.param("password", &query.password);
|
.param("password", &query.password);
|
||||||
@ -260,7 +260,7 @@ pub async fn login(query: LoginQuery, state: State<'_, ApiController>) -> Result
|
|||||||
if !resp.cookie.is_empty() {
|
if !resp.cookie.is_empty() {
|
||||||
let cookie_str = cookies_to_key_values(&resp.cookie);
|
let cookie_str = cookies_to_key_values(&resp.cookie);
|
||||||
*state.cookie.lock().map_err(|e| e.to_string())? = Some(cookie_str.clone());
|
*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())
|
Ok(resp.body.to_string())
|
||||||
@ -277,7 +277,7 @@ pub async fn logout(state: State<'_, ApiController>) -> Result<(), String> {
|
|||||||
/// 获取二维码登录密钥
|
/// 获取二维码登录密钥
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_qr_key(state: State<'_, ApiController>) -> Result<String, String> {
|
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 q = state.build_query();
|
||||||
let resp = client.login_qr_key(&q).await.map_err(|e| e.to_string())?;
|
let resp = client.login_qr_key(&q).await.map_err(|e| e.to_string())?;
|
||||||
resp.body["unikey"]
|
resp.body["unikey"]
|
||||||
@ -292,7 +292,7 @@ pub async fn create_qr(
|
|||||||
query: QrKeyQuery,
|
query: QrKeyQuery,
|
||||||
state: State<'_, ApiController>,
|
state: State<'_, ApiController>,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let client = state.client.lock().unwrap().clone();
|
let client = state.client.lock().await.clone();
|
||||||
let q = state
|
let q = state
|
||||||
.build_query()
|
.build_query()
|
||||||
.param("key", &query.key)
|
.param("key", &query.key)
|
||||||
@ -308,13 +308,13 @@ pub async fn create_qr(
|
|||||||
/// 检查二维码扫码状态
|
/// 检查二维码扫码状态
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn check_qr_status(query: QrKeyQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
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 q = state.build_query().param("key", &query.key);
|
||||||
let resp = client.login_qr_check(&q).await.map_err(|e| e.to_string())?;
|
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() {
|
if resp.body["code"].as_u64() == Some(803) && !resp.cookie.is_empty() {
|
||||||
let cookie_str = cookies_to_key_values(&resp.cookie);
|
let cookie_str = cookies_to_key_values(&resp.cookie);
|
||||||
*state.cookie.lock().map_err(|e| e.to_string())? = Some(cookie_str.clone());
|
*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())
|
Ok(resp.body.to_string())
|
||||||
}
|
}
|
||||||
@ -500,7 +500,7 @@ pub async fn download_song(
|
|||||||
let q = state.build_query()
|
let q = state.build_query()
|
||||||
.param("id", &query.id.to_string())
|
.param("id", &query.id.to_string())
|
||||||
.param("level", level);
|
.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 resp = client.song_url_v1(&q).await.map_err(|e| e.to_string())?;
|
||||||
let data = &resp.body["data"][0];
|
let data = &resp.body["data"][0];
|
||||||
let url = data["url"].as_str().filter(|s| !s.is_empty());
|
let url = data["url"].as_str().filter(|s| !s.is_empty());
|
||||||
@ -582,18 +582,17 @@ pub async fn download_song(
|
|||||||
Ok(filename)
|
Ok(filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 列出本地已下载的歌曲,优先使用元数据文件补充信息
|
/// 扫描指定目录下的音频文件,优先使用元数据文件补充信息
|
||||||
#[tauri::command]
|
/// `downloaded_only` 为 true 时,只返回有对应 .json 元数据的文件(即通过应用下载的)
|
||||||
pub fn list_local_songs(app_handle: tauri::AppHandle, download_path: Option<String>) -> Result<Vec<LocalSongInfo>, String> {
|
fn scan_dir_for_songs(dir: &PathBuf, downloaded_only: bool) -> Result<Vec<LocalSongInfo>, String> {
|
||||||
let download_dir = resolve_download_dir(&app_handle, download_path.as_deref());
|
if !dir.exists() {
|
||||||
if !download_dir.exists() {
|
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
let audio_exts = ["mp3", "flac", "wav", "ogg", "aac", "m4a", "wma", "opus"];
|
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 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() {
|
for entry in entries.flatten() {
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
@ -609,7 +608,7 @@ pub fn list_local_songs(app_handle: tauri::AppHandle, download_path: Option<Stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut songs: Vec<LocalSongInfo> = Vec::new();
|
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() {
|
for entry in entries.flatten() {
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
@ -621,6 +620,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 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);
|
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);
|
let (title, artist, album, duration_ms, cover_b64) = read_audio_metadata(&path);
|
||||||
|
|
||||||
if let Some(meta) = meta_map.get(&filename) {
|
if let Some(meta) = meta_map.get(&filename) {
|
||||||
@ -678,6 +682,36 @@ pub fn list_local_songs(app_handle: tauri::AppHandle, download_path: Option<Stri
|
|||||||
Ok(songs)
|
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>) {
|
fn read_audio_metadata(path: &PathBuf) -> (String, String, String, u64, Option<String>) {
|
||||||
match lofty::read_from_path(path) {
|
match lofty::read_from_path(path) {
|
||||||
@ -743,11 +777,12 @@ pub struct DeleteLocalSongQuery {
|
|||||||
|
|
||||||
/// 删除本地已下载的歌曲文件及其元数据
|
/// 删除本地已下载的歌曲文件及其元数据
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn delete_local_song(
|
pub async fn delete_local_song(
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
query: DeleteLocalSongQuery,
|
query: DeleteLocalSongQuery,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let download_dir = resolve_download_dir(&app_handle, query.download_path.as_deref());
|
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 file_path = download_dir.join(&query.filename);
|
||||||
let meta_path = download_dir.join(format!("{}.json", query.id));
|
let meta_path = download_dir.join(format!("{}.json", query.id));
|
||||||
|
|
||||||
@ -758,14 +793,17 @@ pub fn delete_local_song(
|
|||||||
fs::remove_file(&meta_path).map_err(|e| format!("删除元数据失败: {}", e))?;
|
fs::remove_file(&meta_path).map_err(|e| format!("删除元数据失败: {}", e))?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
}).await.map_err(|e| format!("删除任务失败: {}", e))?
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 检查指定歌曲是否已下载到本地
|
/// 检查指定歌曲是否已下载到本地
|
||||||
#[tauri::command]
|
#[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());
|
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));
|
let meta_path = download_dir.join(format!("{}.json", id));
|
||||||
Ok(meta_path.exists())
|
Ok(meta_path.exists())
|
||||||
|
}).await.map_err(|e| format!("检查任务失败: {}", e))?
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 解析下载目录,优先使用自定义路径,否则使用默认目录
|
/// 解析下载目录,优先使用自定义路径,否则使用默认目录
|
||||||
@ -795,7 +833,7 @@ fn get_default_download_dir(app_handle: &tauri::AppHandle) -> PathBuf {
|
|||||||
|
|
||||||
/// 获取默认下载路径字符串,供前端使用
|
/// 获取默认下载路径字符串,供前端使用
|
||||||
#[tauri::command]
|
#[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()
|
get_default_download_dir(&app_handle).to_string_lossy().to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -867,6 +905,28 @@ pub async fn artist_desc(id: u64, state: State<'_, ApiController>) -> Result<Str
|
|||||||
api_call!(state, artist_desc, params: [("id", &id.to_string())])
|
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]
|
#[tauri::command]
|
||||||
pub async fn album_detail(id: u64, state: State<'_, ApiController>) -> Result<String, String> {
|
pub async fn album_detail(id: u64, state: State<'_, ApiController>) -> Result<String, String> {
|
||||||
@ -977,3 +1037,288 @@ pub struct CommentLikeQuery {
|
|||||||
pub id: u64,
|
pub id: u64,
|
||||||
pub cid: 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>,
|
tx: Sender<AudioCmd>,
|
||||||
current_url: Arc<Mutex<Option<String>>>,
|
current_url: Arc<Mutex<Option<String>>>,
|
||||||
position: Arc<Mutex<f64>>,
|
position: Arc<Mutex<f64>>,
|
||||||
|
is_playing: Arc<AtomicBool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AudioController {
|
impl AudioController {
|
||||||
@ -43,11 +44,13 @@ impl AudioController {
|
|||||||
let (tx, rx) = channel();
|
let (tx, rx) = channel();
|
||||||
let current_url = Arc::new(Mutex::new(None));
|
let current_url = Arc::new(Mutex::new(None));
|
||||||
let position = Arc::new(Mutex::new(0.0));
|
let position = Arc::new(Mutex::new(0.0));
|
||||||
|
let is_playing = Arc::new(AtomicBool::new(false));
|
||||||
let url_clone = current_url.clone();
|
let url_clone = current_url.clone();
|
||||||
let pos_clone = position.clone();
|
let pos_clone = position.clone();
|
||||||
|
let playing_clone = is_playing.clone();
|
||||||
let ah_clone = app_handle.clone();
|
let ah_clone = app_handle.clone();
|
||||||
thread::spawn(move || audio_thread(rx, url_clone, pos_clone, ah_clone));
|
thread::spawn(move || audio_thread(rx, url_clone, pos_clone, playing_clone, ah_clone));
|
||||||
AudioController { tx, current_url, position }
|
AudioController { tx, current_url, position, is_playing }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 播放指定URL的网络音频
|
/// 播放指定URL的网络音频
|
||||||
@ -78,6 +81,9 @@ impl AudioController {
|
|||||||
pub fn get_position(&self) -> f64 {
|
pub fn get_position(&self) -> f64 {
|
||||||
*self.position.lock().unwrap()
|
*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 selected_device: Option<String> = None;
|
||||||
let mut current_volume: f32 = 1.0;
|
let mut current_volume: f32 = 1.0;
|
||||||
let mut output_ctx: Option<OutputContext> = None;
|
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) => {
|
AudioCmd::Play(url) => {
|
||||||
audio_active = false;
|
audio_active = false;
|
||||||
audio_paused = false;
|
audio_paused = false;
|
||||||
|
is_playing.store(false, Ordering::Relaxed);
|
||||||
manual_stop = false;
|
manual_stop = false;
|
||||||
current_local_path = None;
|
current_local_path = None;
|
||||||
|
|
||||||
@ -971,6 +978,7 @@ fn audio_thread(rx: Receiver<AudioCmd>, _current_url: Arc<Mutex<Option<String>>>
|
|||||||
Ok(ctx) => {
|
Ok(ctx) => {
|
||||||
output_ctx = Some(ctx);
|
output_ctx = Some(ctx);
|
||||||
audio_active = true;
|
audio_active = true;
|
||||||
|
is_playing.store(true, Ordering::Relaxed);
|
||||||
let _ = app_handle.emit("audio-started", ());
|
let _ = app_handle.emit("audio-started", ());
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@ -982,6 +990,7 @@ fn audio_thread(rx: Receiver<AudioCmd>, _current_url: Arc<Mutex<Option<String>>>
|
|||||||
AudioCmd::PlayLocal(path) => {
|
AudioCmd::PlayLocal(path) => {
|
||||||
audio_active = false;
|
audio_active = false;
|
||||||
audio_paused = false;
|
audio_paused = false;
|
||||||
|
is_playing.store(false, Ordering::Relaxed);
|
||||||
manual_stop = false;
|
manual_stop = false;
|
||||||
current_local_path = Some(path.clone());
|
current_local_path = Some(path.clone());
|
||||||
|
|
||||||
@ -1008,6 +1017,7 @@ fn audio_thread(rx: Receiver<AudioCmd>, _current_url: Arc<Mutex<Option<String>>>
|
|||||||
Ok(ctx) => {
|
Ok(ctx) => {
|
||||||
output_ctx = Some(ctx);
|
output_ctx = Some(ctx);
|
||||||
audio_active = true;
|
audio_active = true;
|
||||||
|
is_playing.store(true, Ordering::Relaxed);
|
||||||
let _ = app_handle.emit("audio-started", ());
|
let _ = app_handle.emit("audio-started", ());
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@ -1018,6 +1028,7 @@ fn audio_thread(rx: Receiver<AudioCmd>, _current_url: Arc<Mutex<Option<String>>>
|
|||||||
|
|
||||||
AudioCmd::Pause => {
|
AudioCmd::Pause => {
|
||||||
audio_paused = true;
|
audio_paused = true;
|
||||||
|
is_playing.store(false, Ordering::Relaxed);
|
||||||
if let Some(ref ctx) = output_ctx {
|
if let Some(ref ctx) = output_ctx {
|
||||||
ctx.playback.playing.store(false, Ordering::Relaxed);
|
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 => {
|
AudioCmd::Resume => {
|
||||||
audio_paused = false;
|
audio_paused = false;
|
||||||
|
if audio_active {
|
||||||
|
is_playing.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
if let Some(ref ctx) = output_ctx {
|
if let Some(ref ctx) = output_ctx {
|
||||||
ctx.playback.playing.store(true, Ordering::Relaxed);
|
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 => {
|
AudioCmd::Stop => {
|
||||||
audio_active = false;
|
audio_active = false;
|
||||||
audio_paused = false;
|
audio_paused = false;
|
||||||
|
is_playing.store(false, Ordering::Relaxed);
|
||||||
manual_stop = true;
|
manual_stop = true;
|
||||||
stop_playback(&mut output_ctx, &shared_position);
|
stop_playback(&mut output_ctx, &shared_position);
|
||||||
if let Some(ref buf) = current_audio_buffer {
|
if let Some(ref buf) = current_audio_buffer {
|
||||||
@ -1054,6 +1069,7 @@ fn audio_thread(rx: Receiver<AudioCmd>, _current_url: Arc<Mutex<Option<String>>>
|
|||||||
output_ctx = Some(ctx);
|
output_ctx = Some(ctx);
|
||||||
audio_active = true;
|
audio_active = true;
|
||||||
audio_paused = false;
|
audio_paused = false;
|
||||||
|
is_playing.store(true, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("[audio] seek 播放失败: {}", e);
|
eprintln!("[audio] seek 播放失败: {}", e);
|
||||||
@ -1097,6 +1113,7 @@ fn audio_thread(rx: Receiver<AudioCmd>, _current_url: Arc<Mutex<Option<String>>>
|
|||||||
&& ctx.playback.buffer_exhausted.load(Ordering::Relaxed)
|
&& ctx.playback.buffer_exhausted.load(Ordering::Relaxed)
|
||||||
&& !manual_stop && !audio_paused {
|
&& !manual_stop && !audio_paused {
|
||||||
audio_active = false;
|
audio_active = false;
|
||||||
|
is_playing.store(false, Ordering::Relaxed);
|
||||||
let _ = app_handle.emit("audio-ended", ());
|
let _ = app_handle.emit("audio-ended", ());
|
||||||
}
|
}
|
||||||
let pos = ctx.playback.position();
|
let pos = ctx.playback.position();
|
||||||
@ -1204,3 +1221,8 @@ pub fn get_audio_position(state: State<'_, AppAudio>) -> f64 {
|
|||||||
pub fn set_volume(state: State<'_, AppAudio>, vol: f32) {
|
pub fn set_volume(state: State<'_, AppAudio>, vol: f32) {
|
||||||
if let Ok(ctrl) = state.0.lock() { ctrl.set_volume(vol); }
|
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 }
|
||||||
|
}
|
||||||
|
|||||||
@ -173,9 +173,11 @@ pub fn run() {
|
|||||||
audio::seek_audio,
|
audio::seek_audio,
|
||||||
audio::get_audio_position,
|
audio::get_audio_position,
|
||||||
audio::set_volume,
|
audio::set_volume,
|
||||||
|
audio::is_audio_playing,
|
||||||
|
|
||||||
api::download_song,
|
api::download_song,
|
||||||
api::list_local_songs,
|
api::list_local_songs,
|
||||||
|
api::scan_local_folders,
|
||||||
api::delete_local_song,
|
api::delete_local_song,
|
||||||
api::check_local_song,
|
api::check_local_song,
|
||||||
api::get_default_download_path,
|
api::get_default_download_path,
|
||||||
@ -184,11 +186,17 @@ pub fn run() {
|
|||||||
api::artist_songs,
|
api::artist_songs,
|
||||||
api::artist_album,
|
api::artist_album,
|
||||||
api::artist_desc,
|
api::artist_desc,
|
||||||
|
api::artist_sub,
|
||||||
|
api::artist_sublist,
|
||||||
api::album_detail,
|
api::album_detail,
|
||||||
api::comment_new,
|
api::comment_new,
|
||||||
api::comment_hot,
|
api::comment_hot,
|
||||||
api::comment_floor,
|
api::comment_floor,
|
||||||
api::comment_like,
|
api::comment_like,
|
||||||
|
api::user_cloud,
|
||||||
|
api::user_cloud_detail,
|
||||||
|
api::user_cloud_del,
|
||||||
|
api::cloud_upload,
|
||||||
])
|
])
|
||||||
.plugin(tauri_plugin_process::init())
|
.plugin(tauri_plugin_process::init())
|
||||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Nekosonic",
|
"productName": "Nekosonic",
|
||||||
"version": "0.6.0",
|
"version": "0.7.0",
|
||||||
"identifier": "com.atdunbg.Nekosonic",
|
"identifier": "com.atdunbg.Nekosonic",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
81
src/App.vue
81
src/App.vue
@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
<main class="flex-1 overflow-y-auto pb-24">
|
<main class="flex-1 overflow-y-auto pb-24">
|
||||||
<router-view v-slot="{ Component }">
|
<router-view v-slot="{ Component }">
|
||||||
<keep-alive :max="5" :include="keepAliveInclude">
|
<keep-alive :max="10" :include="keepAliveInclude">
|
||||||
<component :is="Component" />
|
<component :is="Component" />
|
||||||
</keep-alive>
|
</keep-alive>
|
||||||
</router-view>
|
</router-view>
|
||||||
@ -38,6 +38,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, onMounted, onBeforeUnmount } from 'vue';
|
import { ref, watch, onMounted, onBeforeUnmount } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
import { useUserStore } from './stores/user';
|
import { useUserStore } from './stores/user';
|
||||||
import { useSettingsStore, type CloseAction } from './stores/settings';
|
import { useSettingsStore, type CloseAction } from './stores/settings';
|
||||||
import { usePlayerStore } from './stores/player';
|
import { usePlayerStore } from './stores/player';
|
||||||
@ -69,7 +70,60 @@ watch(isOnline, (val, old) => {
|
|||||||
|
|
||||||
const showCloseModal = ref(false);
|
const showCloseModal = ref(false);
|
||||||
const windowVisible = ref(true);
|
const windowVisible = ref(true);
|
||||||
const keepAliveInclude = ref<string[]>(['HomeView', 'DiscoverView', 'FavoriteSongsView', 'DailySongsView', 'LocalMusicView']);
|
|
||||||
|
// --- 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 = 30_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 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 keepAliveInclude = ref<string[]>(computeInclude());
|
||||||
|
let cleanupTimer: ReturnType<typeof setInterval>;
|
||||||
|
function startCleanup() { cleanupTimer = setInterval(() => { keepAliveInclude.value = computeInclude(); }, 10_000); }
|
||||||
|
function stopCleanup() { clearInterval(cleanupTimer); }
|
||||||
|
|
||||||
watch(() => settings.dataTheme, (val) => {
|
watch(() => settings.dataTheme, (val) => {
|
||||||
document.documentElement.setAttribute('data-theme', val);
|
document.documentElement.setAttribute('data-theme', val);
|
||||||
@ -81,15 +135,16 @@ watch(() => userStore.isLoggedIn, (val) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(() => {
|
||||||
document.addEventListener('contextmenu', (e) => e.preventDefault());
|
document.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||||
|
startCleanup();
|
||||||
|
|
||||||
|
AudioApi.stopAudio().catch(() => {});
|
||||||
|
|
||||||
if (userStore.isLoggedIn) {
|
if (userStore.isLoggedIn) {
|
||||||
player.loadLikedIds();
|
player.loadLikedIds();
|
||||||
}
|
MusicApi.getLoginStatus().then(jsonStr => {
|
||||||
try { await AudioApi.stopAudio(); } catch { /* 忽略 */ }
|
if (!jsonStr) return;
|
||||||
try {
|
|
||||||
const jsonStr: string = await MusicApi.getLoginStatus();
|
|
||||||
const data = JSON.parse(jsonStr);
|
const data = JSON.parse(jsonStr);
|
||||||
if (data.account || data.profile) {
|
if (data.account || data.profile) {
|
||||||
const profile = data.profile || data.account;
|
const profile = data.profile || data.account;
|
||||||
@ -99,14 +154,13 @@ onMounted(async () => {
|
|||||||
avatarUrl: profile.avatarUrl,
|
avatarUrl: profile.avatarUrl,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch { /* 忽略 */ }
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
updater.checkForUpdate(true);
|
updater.checkForUpdate(true);
|
||||||
|
|
||||||
if (settings.outputDevice) {
|
if (settings.outputDevice) {
|
||||||
try {
|
DeviceApi.setOutputDevice(settings.outputDevice).catch(() => {});
|
||||||
await DeviceApi.setOutputDevice(settings.outputDevice);
|
|
||||||
} catch { /* 忽略 */ }
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -147,10 +201,12 @@ onMounted(() => {
|
|||||||
const unlisten4 = listen('window-hidden', () => {
|
const unlisten4 = listen('window-hidden', () => {
|
||||||
windowVisible.value = false;
|
windowVisible.value = false;
|
||||||
keepAliveInclude.value = [];
|
keepAliveInclude.value = [];
|
||||||
|
stopCleanup();
|
||||||
});
|
});
|
||||||
const unlisten5 = listen('window-shown', () => {
|
const unlisten5 = listen('window-shown', () => {
|
||||||
windowVisible.value = true;
|
windowVisible.value = true;
|
||||||
keepAliveInclude.value = ['HomeView', 'DiscoverView', 'FavoriteSongsView', 'DailySongsView', 'LocalMusicView'];
|
keepAliveInclude.value = computeInclude();
|
||||||
|
startCleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
@ -159,6 +215,7 @@ onMounted(() => {
|
|||||||
unlisten3.then(fn => fn());
|
unlisten3.then(fn => fn());
|
||||||
unlisten4.then(fn => fn());
|
unlisten4.then(fn => fn());
|
||||||
unlisten5.then(fn => fn());
|
unlisten5.then(fn => fn());
|
||||||
|
stopCleanup();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
29
src/api.ts
29
src/api.ts
@ -93,6 +93,14 @@ export namespace MusicApi {
|
|||||||
return invoke('artist_desc', { id });
|
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> {
|
export async function commentHot(query: { type: number; id: number; limit: number; offset: number }): Promise<string> {
|
||||||
return invoke('comment_hot', { query });
|
return invoke('comment_hot', { query });
|
||||||
}
|
}
|
||||||
@ -116,6 +124,19 @@ export namespace MusicApi {
|
|||||||
export async function scrobble(query: { id: number; sourceid: string; time: number }): Promise<void> {
|
export async function scrobble(query: { id: number; sourceid: string; time: number }): Promise<void> {
|
||||||
return invoke('scrobble', { query });
|
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 namespace AudioApi {
|
||||||
@ -150,6 +171,10 @@ export namespace AudioApi {
|
|||||||
export async function getAudioPosition(): Promise<number> {
|
export async function getAudioPosition(): Promise<number> {
|
||||||
return invoke('get_audio_position');
|
return invoke('get_audio_position');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function isAudioPlaying(): Promise<boolean> {
|
||||||
|
return invoke('is_audio_playing');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export namespace DeviceApi {
|
export namespace DeviceApi {
|
||||||
@ -180,6 +205,10 @@ export namespace DownloadApi {
|
|||||||
return invoke('list_local_songs', { downloadPath });
|
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> {
|
export async function deleteLocalSong(query: { id: number; filename: string; downloadPath: string | null }): Promise<void> {
|
||||||
return invoke('delete_local_song', { query });
|
return invoke('delete_local_song', { query });
|
||||||
}
|
}
|
||||||
|
|||||||
56
src/components/PageHeader.vue
Normal file
56
src/components/PageHeader.vue
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
<template>
|
||||||
|
<!-- 普通头部:随内容滚动,返回独占一行,标题和按钮在第二行 -->
|
||||||
|
<div ref="headerRef" 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>
|
||||||
|
<!-- 粘性操作栏:滚动后显示,只有返回+小功能按钮 -->
|
||||||
|
<div class="sticky top-0 z-10 -mx-8 px-8 py-1.5">
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 backdrop-blur-md transition-opacity duration-300"
|
||||||
|
:class="isStuck ? 'opacity-100' : 'opacity-0'"
|
||||||
|
style="mask-image: linear-gradient(to bottom, black 0%, black 50%, transparent 100%); -webkit-mask-image: linear-gradient(to bottom, black 0%, black 50%, transparent 100%);"
|
||||||
|
>
|
||||||
|
<div class="w-full h-full bg-base/80"></div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="relative flex items-center gap-2 transition-opacity duration-300"
|
||||||
|
:class="isStuck ? 'opacity-100' : 'opacity-0 pointer-events-none'"
|
||||||
|
>
|
||||||
|
<button @click="$router.back()" class="text-content-2 hover:text-content transition text-sm">
|
||||||
|
← 返回
|
||||||
|
</button>
|
||||||
|
<div class="flex-1" />
|
||||||
|
<slot name="actions" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
const headerRef = ref<HTMLElement | null>(null)
|
||||||
|
const isStuck = ref(false)
|
||||||
|
|
||||||
|
let observer: IntersectionObserver | null = null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!headerRef.value) return
|
||||||
|
observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
isStuck.value = !entry.isIntersecting
|
||||||
|
},
|
||||||
|
{ threshold: 0 }
|
||||||
|
)
|
||||||
|
observer.observe(headerRef.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
observer?.disconnect()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@ -43,18 +43,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="w-3/5 relative min-h-0 overflow-hidden flex flex-col">
|
<div class="w-3/5 relative min-h-0 overflow-hidden flex flex-col">
|
||||||
<div class="flex items-center gap-1 mb-3 px-4">
|
<div class="flex items-center gap-1 mb-3 px-4">
|
||||||
<button @click="roamTab = 'lyric'"
|
<button @click="player.roamTab = 'lyric'"
|
||||||
class="px-3 py-1 rounded-full text-sm transition"
|
class="px-3 py-1 rounded-full text-sm transition"
|
||||||
:class="player.dominantColor
|
:class="player.dominantColor
|
||||||
? (roamTab === 'lyric' ? 'bg-white/15 text-white font-medium' : 'text-white/50 hover:text-white/80')
|
? (player.roamTab === 'lyric' ? 'bg-white/15 text-white font-medium' : 'text-white/50 hover:text-white/80')
|
||||||
: (roamTab === 'lyric' ? 'bg-muted text-content font-medium' : 'text-content-3 hover:text-content')">
|
: (player.roamTab === 'lyric' ? 'bg-muted text-content font-medium' : 'text-content-3 hover:text-content')">
|
||||||
歌词
|
歌词
|
||||||
</button>
|
</button>
|
||||||
<button @click="roamTab = 'comment'"
|
<button @click="player.roamTab = 'comment'"
|
||||||
class="px-3 py-1 rounded-full text-sm transition"
|
class="px-3 py-1 rounded-full text-sm transition"
|
||||||
:class="player.dominantColor
|
:class="player.dominantColor
|
||||||
? (roamTab === 'comment' ? 'bg-white/15 text-white font-medium' : 'text-white/50 hover:text-white/80')
|
? (player.roamTab === 'comment' ? 'bg-white/15 text-white font-medium' : 'text-white/50 hover:text-white/80')
|
||||||
: (roamTab === 'comment' ? 'bg-muted text-content font-medium' : 'text-content-3 hover:text-content')">
|
: (player.roamTab === 'comment' ? 'bg-muted text-content font-medium' : 'text-content-3 hover:text-content')">
|
||||||
评论
|
评论
|
||||||
</button>
|
</button>
|
||||||
<button v-if="hasTranslation" @click="toggleTranslation"
|
<button v-if="hasTranslation" @click="toggleTranslation"
|
||||||
@ -66,7 +66,7 @@
|
|||||||
译
|
译
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="roamTab === 'lyric'" ref="lyricScrollContainer" class="flex-1 min-h-0 overflow-y-auto custom-scroll px-4">
|
<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"
|
<div v-if="lyrics.length > 0" class="w-full max-w-lg mx-auto text-center"
|
||||||
:style="{ paddingTop: roamLyricPadPx + 'px', paddingBottom: roamLyricPadPx + 'px' }">
|
:style="{ paddingTop: roamLyricPadPx + 'px', paddingBottom: roamLyricPadPx + 'px' }">
|
||||||
<p
|
<p
|
||||||
@ -84,7 +84,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-else :class="player.dominantColor ? 'text-white/40' : 'text-content-3'" class="text-center mt-8">暂无歌词</div>
|
<div v-else :class="player.dominantColor ? 'text-white/40' : 'text-content-3'" class="text-center mt-8">暂无歌词</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="roamTab === 'comment'" class="flex-1 min-h-0 overflow-y-auto px-4 pb-4">
|
<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" />
|
<CommentSection v-if="roamSong" :type="0" :id="player.commentSongId || roamSong.id" :key="player.commentSongId || roamSong.id" :dark-mode="!!player.dominantColor" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -118,7 +118,6 @@ const roamLyricHovering = ref(false);
|
|||||||
const roamLyricPadPx = ref(0);
|
const roamLyricPadPx = ref(0);
|
||||||
const roamSong = computed(() => player.currentSong);
|
const roamSong = computed(() => player.currentSong);
|
||||||
const roamCoverError = ref(false);
|
const roamCoverError = ref(false);
|
||||||
const roamTab = ref<'lyric' | 'comment'>('lyric');
|
|
||||||
const roamCoverUrl = computed(() => {
|
const roamCoverUrl = computed(() => {
|
||||||
if (!roamSong.value) return '';
|
if (!roamSong.value) return '';
|
||||||
return getCoverUrl(roamSong.value) || '';
|
return getCoverUrl(roamSong.value) || '';
|
||||||
@ -144,7 +143,6 @@ function updateRoamLyricPad() {
|
|||||||
|
|
||||||
watch(() => player.showRoamDrawer, (val) => {
|
watch(() => player.showRoamDrawer, (val) => {
|
||||||
if (val) {
|
if (val) {
|
||||||
roamTab.value = player.roamInitialTab;
|
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
updateRoamLyricPad();
|
updateRoamLyricPad();
|
||||||
if (roamResizeObserver) roamResizeObserver.disconnect();
|
if (roamResizeObserver) roamResizeObserver.disconnect();
|
||||||
|
|||||||
@ -45,6 +45,18 @@
|
|||||||
<IconMusic class="w-[18px] h-[18px]" />
|
<IconMusic class="w-[18px] h-[18px]" />
|
||||||
本地音乐
|
本地音乐
|
||||||
</router-link>
|
</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>
|
</div>
|
||||||
|
|
||||||
@ -128,6 +140,8 @@ import IconLogIn from '~icons/lucide/log-in';
|
|||||||
import IconChevronRight from '~icons/lucide/chevron-right';
|
import IconChevronRight from '~icons/lucide/chevron-right';
|
||||||
import IconClock from '~icons/lucide/clock';
|
import IconClock from '~icons/lucide/clock';
|
||||||
import IconMusic from '~icons/lucide/music';
|
import IconMusic from '~icons/lucide/music';
|
||||||
|
import IconCloud from '~icons/lucide/cloud';
|
||||||
|
import IconDownload from '~icons/lucide/download';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|||||||
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 { /* 忽略 */ }
|
||||||
|
}
|
||||||
@ -9,7 +9,22 @@ export interface Toast {
|
|||||||
const toasts = ref<Toast[]>([]);
|
const toasts = ref<Toast[]>([]);
|
||||||
let nextId = 0;
|
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) {
|
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++;
|
const id = nextId++;
|
||||||
toasts.value.push({ id, message, type });
|
toasts.value.push({ id, message, type });
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@ -1,27 +1,20 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router';
|
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 = [
|
const routes = [
|
||||||
{ path: '/', name: 'home', component: Home },
|
{ path: '/', name: 'home', component: () => import('@/views/Home.vue') },
|
||||||
{ path: '/discover', name: 'discover', component: Discover },
|
{ path: '/discover', name: 'discover', component: () => import('@/views/Discover.vue') },
|
||||||
{ path: '/search', name: 'search', component: Discover },
|
{ path: '/search', name: 'search', component: () => import('@/views/Discover.vue') },
|
||||||
{ path: '/favorites', name: 'favorites', component: FavoriteSongs },
|
{ path: '/favorites', name: 'favorites', component: () => import('@/views/FavoriteSongs.vue') },
|
||||||
{ path: '/recent', name: 'recent', component: RecentPlays },
|
{ path: '/recent', name: 'recent', component: () => import('@/views/RecentPlays.vue') },
|
||||||
{ path: '/daily', name: 'daily', component: DailySongs },
|
{ path: '/daily', name: 'daily', component: () => import('@/views/DailySongs.vue') },
|
||||||
{ path: '/local-music', name: 'local-music', component: LocalMusic },
|
{ path: '/local-music', name: 'local-music', component: () => import('@/views/LocalMusic.vue') },
|
||||||
{ path: '/login', name: 'login', component: Login, meta: { guest: true } },
|
{ path: '/downloaded-music', name: 'downloaded-music', component: () => import('@/views/DownloadedMusic.vue') },
|
||||||
{ path: '/playlist/:id', name: 'playlist', component: PlaylistDetail },
|
{ 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: '/artist/:id', name: 'artist', component: () => import('@/views/ArtistDetail.vue') },
|
||||||
{ path: '/album/:id', name: 'album', component: () => import('@/views/AlbumDetail.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({
|
const router = createRouter({
|
||||||
@ -31,7 +24,7 @@ const router = createRouter({
|
|||||||
|
|
||||||
router.beforeEach((to) => {
|
router.beforeEach((to) => {
|
||||||
if (to.meta.guest) {
|
if (to.meta.guest) {
|
||||||
const raw = localStorage.getItem('user');
|
const raw = localStorage.getItem('user_profile');
|
||||||
if (raw) {
|
if (raw) {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(raw);
|
const data = JSON.parse(raw);
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { ref, watch, nextTick } from 'vue';
|
import { ref, watch } from 'vue';
|
||||||
import { normalizeSong, type Song } from '../utils/song';
|
import { normalizeSong, type Song } from '../utils/song';
|
||||||
import { useSettingsStore } from './settings';
|
import { useSettingsStore } from './settings';
|
||||||
import { useUserStore } from './user';
|
import { useUserStore } from './user';
|
||||||
@ -123,7 +123,7 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
}
|
}
|
||||||
if (lastScrobbleId === song.id && lastScrobbleStartTime > 0) {
|
if (lastScrobbleId === song.id && lastScrobbleStartTime > 0) {
|
||||||
const playedSec = Math.round((Date.now() - lastScrobbleStartTime) / 1000);
|
const playedSec = Math.round((Date.now() - lastScrobbleStartTime) / 1000);
|
||||||
if (playedSec > 5) {
|
if (playedSec > 5 && navigator.onLine) {
|
||||||
MusicApi.scrobble({
|
MusicApi.scrobble({
|
||||||
id: song.id,
|
id: song.id,
|
||||||
sourceid: isFmMode.value ? String(song.id) : '',
|
sourceid: isFmMode.value ? String(song.id) : '',
|
||||||
@ -183,6 +183,8 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
const MAX_FM_VIP_SKIP = 10;
|
const MAX_FM_VIP_SKIP = 10;
|
||||||
|
|
||||||
async function playFmSong(song: Song) {
|
async function playFmSong(song: Song) {
|
||||||
|
const seq = ++_playSeq;
|
||||||
|
_switchingSong = true;
|
||||||
clearTick();
|
clearTick();
|
||||||
reportScrobble();
|
reportScrobble();
|
||||||
if (!song.dt || song.dt === 0) {
|
if (!song.dt || song.dt === 0) {
|
||||||
@ -207,6 +209,7 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
currentSong.value = song;
|
currentSong.value = song;
|
||||||
try {
|
try {
|
||||||
const jsonStr = await MusicApi.getSongUrl({ 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 data = JSON.parse(jsonStr);
|
||||||
const url: string | undefined = data.url;
|
const url: string | undefined = data.url;
|
||||||
if (!url) throw new Error('无播放源');
|
if (!url) throw new Error('无播放源');
|
||||||
@ -221,6 +224,7 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
disableFmMode();
|
disableFmMode();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
_switchingSong = false;
|
||||||
if (fmNextCallback) {
|
if (fmNextCallback) {
|
||||||
fmNextCallback();
|
fmNextCallback();
|
||||||
} else {
|
} else {
|
||||||
@ -231,14 +235,23 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
|
|
||||||
fmVipSkipCount = 0;
|
fmVipSkipCount = 0;
|
||||||
await AudioApi.playAudio(url);
|
await AudioApi.playAudio(url);
|
||||||
await waitForAudioStart();
|
if (seq !== _playSeq) return;
|
||||||
|
const started = await waitForPlaybackStart();
|
||||||
|
if (seq !== _playSeq) return;
|
||||||
|
if (started) {
|
||||||
playing.value = true;
|
playing.value = true;
|
||||||
duration.value = (song.dt || 0) / 1000;
|
duration.value = (song.dt || 0) / 1000;
|
||||||
currentTime.value = 0;
|
currentTime.value = 0;
|
||||||
startTick();
|
startTick();
|
||||||
addRecent(song);
|
addRecent(song);
|
||||||
emitPlaybackState();
|
emitPlaybackState();
|
||||||
|
} else {
|
||||||
|
playing.value = false;
|
||||||
|
showToast('FM 播放启动超时,仍在尝试加载…', 'info');
|
||||||
|
watchForLatePlayback(seq, song);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (seq !== _playSeq) return;
|
||||||
console.error('FM播放失败', e);
|
console.error('FM播放失败', e);
|
||||||
playing.value = false;
|
playing.value = false;
|
||||||
showToast('FM 播放失败', 'error');
|
showToast('FM 播放失败', 'error');
|
||||||
@ -247,6 +260,10 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
} else {
|
} else {
|
||||||
disableFmMode();
|
disableFmMode();
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
if (seq === _playSeq) {
|
||||||
|
_switchingSong = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -298,18 +315,31 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
let vipSkipCount = 0;
|
let vipSkipCount = 0;
|
||||||
const MAX_VIP_SKIP = 10;
|
const MAX_VIP_SKIP = 10;
|
||||||
|
|
||||||
function waitForAudioStart(): Promise<void> {
|
let _playSeq = 0;
|
||||||
return new Promise<void>((resolve) => {
|
let _switchingSong = false;
|
||||||
_audioStartedResolve = resolve;
|
|
||||||
});
|
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() {
|
async function playCurrent() {
|
||||||
|
const seq = ++_playSeq;
|
||||||
|
_switchingSong = true;
|
||||||
clearTick();
|
clearTick();
|
||||||
reportScrobble();
|
reportScrobble();
|
||||||
const song = queue.value[currentIndex.value];
|
const song = queue.value[currentIndex.value];
|
||||||
if (!song?.id) {
|
if (!song?.id) {
|
||||||
console.error('无效的歌曲数据', song);
|
console.error('无效的歌曲数据', song);
|
||||||
|
_switchingSong = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -321,15 +351,23 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
|
|
||||||
if (song.localPath) {
|
if (song.localPath) {
|
||||||
await AudioApi.playLocalAudio(song.localPath);
|
await AudioApi.playLocalAudio(song.localPath);
|
||||||
await waitForAudioStart();
|
if (seq !== _playSeq) return;
|
||||||
|
const started = await waitForPlaybackStart();
|
||||||
|
if (seq !== _playSeq) return;
|
||||||
|
if (started) {
|
||||||
playing.value = true;
|
playing.value = true;
|
||||||
startTick();
|
startTick();
|
||||||
addRecent(song);
|
addRecent(song);
|
||||||
emitPlaybackState();
|
emitPlaybackState();
|
||||||
|
} else {
|
||||||
|
showToast('播放启动超时,仍在尝试加载…', 'info');
|
||||||
|
watchForLatePlayback(seq, song);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const jsonStr = await MusicApi.getSongUrl({ 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 data = JSON.parse(jsonStr);
|
||||||
const url: string | undefined = data.url;
|
const url: string | undefined = data.url;
|
||||||
|
|
||||||
@ -347,23 +385,65 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
vipSkipCount = 0;
|
vipSkipCount = 0;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
_switchingSong = false;
|
||||||
next();
|
next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await AudioApi.playAudio(url);
|
await AudioApi.playAudio(url);
|
||||||
await waitForAudioStart();
|
if (seq !== _playSeq) return;
|
||||||
|
const started = await waitForPlaybackStart();
|
||||||
|
if (seq !== _playSeq) return;
|
||||||
|
if (started) {
|
||||||
playing.value = true;
|
playing.value = true;
|
||||||
startTick();
|
startTick();
|
||||||
addRecent(song);
|
addRecent(song);
|
||||||
vipSkipCount = 0;
|
vipSkipCount = 0;
|
||||||
emitPlaybackState();
|
emitPlaybackState();
|
||||||
|
} else {
|
||||||
|
playing.value = false;
|
||||||
|
showToast('播放启动超时,仍在尝试加载…', 'info');
|
||||||
|
watchForLatePlayback(seq, song);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (seq !== _playSeq) return;
|
||||||
console.error('播放失败', e);
|
console.error('播放失败', e);
|
||||||
playing.value = false;
|
playing.value = false;
|
||||||
showToast('播放失败,请稍后重试', 'error');
|
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;
|
let onSeekStart: (() => void) | null = null;
|
||||||
|
|
||||||
@ -374,10 +454,12 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
let syncCounter = 1;
|
let syncCounter = 1;
|
||||||
let lastSyncPos = -1;
|
let lastSyncPos = -1;
|
||||||
let backendFrozen = false;
|
let backendFrozen = false;
|
||||||
|
let stateSyncCounter = 0;
|
||||||
setTick(setInterval(async () => {
|
setTick(setInterval(async () => {
|
||||||
if (playing.value && duration.value > 0) {
|
if (playing.value && duration.value > 0) {
|
||||||
if (seekGuard) return;
|
if (seekGuard) return;
|
||||||
syncCounter++;
|
syncCounter++;
|
||||||
|
stateSyncCounter++;
|
||||||
if (syncCounter >= 2) {
|
if (syncCounter >= 2) {
|
||||||
syncCounter = 0;
|
syncCounter = 0;
|
||||||
try {
|
try {
|
||||||
@ -395,6 +477,16 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
lastSyncPos = pos;
|
lastSyncPos = pos;
|
||||||
}
|
}
|
||||||
} catch { /* 忽略 */ }
|
} catch { /* 忽略 */ }
|
||||||
|
|
||||||
|
if (stateSyncCounter >= 8) {
|
||||||
|
stateSyncCounter = 0;
|
||||||
|
try {
|
||||||
|
const backendPlaying = await AudioApi.isAudioPlaying();
|
||||||
|
if (backendPlaying !== playing.value) {
|
||||||
|
playing.value = backendPlaying;
|
||||||
|
}
|
||||||
|
} catch { /* 忽略 */ }
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if (!backendFrozen) {
|
if (!backendFrozen) {
|
||||||
const next = currentTime.value + 0.25;
|
const next = currentTime.value + 0.25;
|
||||||
@ -411,6 +503,13 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function toggle() {
|
async function toggle() {
|
||||||
|
try {
|
||||||
|
const backendPlaying = await AudioApi.isAudioPlaying();
|
||||||
|
if (backendPlaying !== playing.value) {
|
||||||
|
playing.value = backendPlaying;
|
||||||
|
}
|
||||||
|
} catch { /* 忽略查询失败 */ }
|
||||||
|
|
||||||
if (playing.value) {
|
if (playing.value) {
|
||||||
await AudioApi.pauseAudio();
|
await AudioApi.pauseAudio();
|
||||||
playing.value = false;
|
playing.value = false;
|
||||||
@ -530,14 +629,13 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const showRoamDrawer = ref(false);
|
const showRoamDrawer = ref(false);
|
||||||
const roamInitialTab = ref<'lyric' | 'comment'>('lyric');
|
const roamTab = ref<'lyric' | 'comment'>('lyric');
|
||||||
const commentSongId = ref<number | null>(null);
|
const commentSongId = ref<number | null>(null);
|
||||||
const dominantColor = ref('');
|
const dominantColor = ref('');
|
||||||
|
|
||||||
function openRoamDrawer(tab: 'lyric' | 'comment' = 'lyric') {
|
function openRoamDrawer(tab: 'lyric' | 'comment' = 'lyric') {
|
||||||
roamInitialTab.value = tab;
|
roamTab.value = tab;
|
||||||
showRoamDrawer.value = true;
|
showRoamDrawer.value = true;
|
||||||
nextTick(() => { roamInitialTab.value = 'lyric'; });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCommentForSong(songId: number) {
|
function openCommentForSong(songId: number) {
|
||||||
@ -547,6 +645,7 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
|
|
||||||
function closeRoamDrawer() {
|
function closeRoamDrawer() {
|
||||||
showRoamDrawer.value = false;
|
showRoamDrawer.value = false;
|
||||||
|
roamTab.value = 'lyric';
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleRoamDrawer() {
|
function toggleRoamDrawer() {
|
||||||
@ -615,16 +714,8 @@ async function nextFm() {
|
|||||||
await loadFm();
|
await loadFm();
|
||||||
}
|
}
|
||||||
|
|
||||||
let _audioStartedResolve: (() => void) | null = null;
|
|
||||||
|
|
||||||
listen('audio-started', () => {
|
|
||||||
if (_audioStartedResolve) {
|
|
||||||
_audioStartedResolve();
|
|
||||||
_audioStartedResolve = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
listen('audio-ended', () => {
|
listen('audio-ended', () => {
|
||||||
|
if (_switchingSong) return;
|
||||||
const player = usePlayerStore();
|
const player = usePlayerStore();
|
||||||
player.clearTick();
|
player.clearTick();
|
||||||
player.reportScrobble();
|
player.reportScrobble();
|
||||||
@ -724,7 +815,7 @@ watch(playing, (val) => {
|
|||||||
toggleLike,
|
toggleLike,
|
||||||
|
|
||||||
showRoamDrawer,
|
showRoamDrawer,
|
||||||
roamInitialTab,
|
roamTab,
|
||||||
commentSongId,
|
commentSongId,
|
||||||
dominantColor,
|
dominantColor,
|
||||||
openCommentForSong,
|
openCommentForSong,
|
||||||
|
|||||||
@ -66,6 +66,7 @@ export const defaultShortcuts: Record<string, ShortcutBinding> = {
|
|||||||
interface SettingsData {
|
interface SettingsData {
|
||||||
audioQuality: AudioQuality;
|
audioQuality: AudioQuality;
|
||||||
downloadPath: string;
|
downloadPath: string;
|
||||||
|
localMusicPaths: string[];
|
||||||
theme: ThemeColor;
|
theme: ThemeColor;
|
||||||
appearance: Appearance;
|
appearance: Appearance;
|
||||||
closeAction: CloseAction;
|
closeAction: CloseAction;
|
||||||
@ -87,6 +88,7 @@ function loadSettings(): SettingsData {
|
|||||||
return {
|
return {
|
||||||
audioQuality: parsed.audioQuality || 'standard',
|
audioQuality: parsed.audioQuality || 'standard',
|
||||||
downloadPath: parsed.downloadPath || '',
|
downloadPath: parsed.downloadPath || '',
|
||||||
|
localMusicPaths: parsed.localMusicPaths || [],
|
||||||
theme: validThemes.includes(parsed.theme.slice(6)) ? parsed.theme.slice(6) : 'blue',
|
theme: validThemes.includes(parsed.theme.slice(6)) ? parsed.theme.slice(6) : 'blue',
|
||||||
appearance: 'light',
|
appearance: 'light',
|
||||||
closeAction: parsed.closeAction || 'ask',
|
closeAction: parsed.closeAction || 'ask',
|
||||||
@ -98,6 +100,7 @@ function loadSettings(): SettingsData {
|
|||||||
return {
|
return {
|
||||||
audioQuality: parsed.audioQuality || 'standard',
|
audioQuality: parsed.audioQuality || 'standard',
|
||||||
downloadPath: parsed.downloadPath || '',
|
downloadPath: parsed.downloadPath || '',
|
||||||
|
localMusicPaths: parsed.localMusicPaths || [],
|
||||||
theme: validThemes.includes(theme) ? theme : 'blue',
|
theme: validThemes.includes(theme) ? theme : 'blue',
|
||||||
appearance,
|
appearance,
|
||||||
closeAction: parsed.closeAction || 'ask',
|
closeAction: parsed.closeAction || 'ask',
|
||||||
@ -110,6 +113,7 @@ function loadSettings(): SettingsData {
|
|||||||
return {
|
return {
|
||||||
audioQuality: 'standard',
|
audioQuality: 'standard',
|
||||||
downloadPath: '',
|
downloadPath: '',
|
||||||
|
localMusicPaths: [],
|
||||||
theme: 'blue',
|
theme: 'blue',
|
||||||
appearance: 'dark',
|
appearance: 'dark',
|
||||||
closeAction: 'ask',
|
closeAction: 'ask',
|
||||||
@ -124,6 +128,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
|
|
||||||
const audioQuality = ref<AudioQuality>(saved.audioQuality);
|
const audioQuality = ref<AudioQuality>(saved.audioQuality);
|
||||||
const downloadPath = ref<string>(saved.downloadPath);
|
const downloadPath = ref<string>(saved.downloadPath);
|
||||||
|
const localMusicPaths = ref<string[]>(saved.localMusicPaths);
|
||||||
const theme = ref<ThemeColor>(saved.theme);
|
const theme = ref<ThemeColor>(saved.theme);
|
||||||
const appearance = ref<Appearance>(saved.appearance);
|
const appearance = ref<Appearance>(saved.appearance);
|
||||||
const closeAction = ref<CloseAction>(saved.closeAction || 'ask');
|
const closeAction = ref<CloseAction>(saved.closeAction || 'ask');
|
||||||
@ -143,6 +148,16 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
downloadPath.value = p;
|
downloadPath.value = p;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addLocalMusicPath(p: string) {
|
||||||
|
if (!localMusicPaths.value.includes(p)) {
|
||||||
|
localMusicPaths.value = [...localMusicPaths.value, p];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLocalMusicPath(p: string) {
|
||||||
|
localMusicPaths.value = localMusicPaths.value.filter(v => v !== p);
|
||||||
|
}
|
||||||
|
|
||||||
function setTheme(t: ThemeColor) {
|
function setTheme(t: ThemeColor) {
|
||||||
theme.value = t;
|
theme.value = t;
|
||||||
}
|
}
|
||||||
@ -170,6 +185,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
function resetAll() {
|
function resetAll() {
|
||||||
audioQuality.value = 'standard';
|
audioQuality.value = 'standard';
|
||||||
downloadPath.value = '';
|
downloadPath.value = '';
|
||||||
|
localMusicPaths.value = [];
|
||||||
theme.value = 'blue';
|
theme.value = 'blue';
|
||||||
appearance.value = 'dark';
|
appearance.value = 'dark';
|
||||||
closeAction.value = 'ask';
|
closeAction.value = 'ask';
|
||||||
@ -178,10 +194,11 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
volume.value = 100;
|
volume.value = 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
watch([audioQuality, downloadPath, theme, appearance, closeAction, shortcuts, outputDevice, volume], () => {
|
watch([audioQuality, downloadPath, localMusicPaths, theme, appearance, closeAction, shortcuts, outputDevice, volume], () => {
|
||||||
const data: SettingsData = {
|
const data: SettingsData = {
|
||||||
audioQuality: audioQuality.value,
|
audioQuality: audioQuality.value,
|
||||||
downloadPath: downloadPath.value,
|
downloadPath: downloadPath.value,
|
||||||
|
localMusicPaths: localMusicPaths.value,
|
||||||
theme: theme.value,
|
theme: theme.value,
|
||||||
appearance: appearance.value,
|
appearance: appearance.value,
|
||||||
closeAction: closeAction.value,
|
closeAction: closeAction.value,
|
||||||
@ -195,6 +212,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
return {
|
return {
|
||||||
audioQuality,
|
audioQuality,
|
||||||
downloadPath,
|
downloadPath,
|
||||||
|
localMusicPaths,
|
||||||
theme,
|
theme,
|
||||||
appearance,
|
appearance,
|
||||||
dataTheme,
|
dataTheme,
|
||||||
@ -204,6 +222,8 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
volume,
|
volume,
|
||||||
setAudioQuality,
|
setAudioQuality,
|
||||||
setDownloadPath,
|
setDownloadPath,
|
||||||
|
addLocalMusicPath,
|
||||||
|
removeLocalMusicPath,
|
||||||
setTheme,
|
setTheme,
|
||||||
setAppearance,
|
setAppearance,
|
||||||
setCloseAction,
|
setCloseAction,
|
||||||
|
|||||||
@ -1,10 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="p-8 text-content">
|
<div class="p-8 text-content">
|
||||||
<button @click="$router.back()" class="mb-4 text-content-2 hover:text-content transition">
|
<PageHeader />
|
||||||
← 返回
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<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" />
|
<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 class="flex flex-col justify-between min-w-0">
|
||||||
<div>
|
<div>
|
||||||
@ -34,9 +44,25 @@
|
|||||||
</div>
|
</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">
|
<!-- 歌曲列表骨架 -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- 歌曲列表 -->
|
||||||
|
<div v-else-if="songs.length" class="space-y-1">
|
||||||
<SongListItem
|
<SongListItem
|
||||||
v-for="(song, index) in songs"
|
v-for="(song, index) in songs"
|
||||||
:key="song.id"
|
:key="song.id"
|
||||||
@ -57,36 +83,61 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, watch } from 'vue';
|
import { ref, onMounted, watch, onActivated } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { MusicApi } from '../api';
|
import { MusicApi } from '../api';
|
||||||
import { usePlayerStore } from '../stores/player';
|
import { usePlayerStore } from '../stores/player';
|
||||||
import { normalizeSong, type Song } from '../utils/song';
|
import { normalizeSong, type Song } from '../utils/song';
|
||||||
import { formatDate } from '../utils/format';
|
import { formatDate } from '../utils/format';
|
||||||
|
import { pageCacheGet, pageCacheSet } from '../composables/usePageCache';
|
||||||
import SongListItem from '../components/SongListItem.vue';
|
import SongListItem from '../components/SongListItem.vue';
|
||||||
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import IconPlay from '~icons/lucide/play';
|
import IconPlay from '~icons/lucide/play';
|
||||||
|
|
||||||
|
defineOptions({ name: 'AlbumDetailView' });
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const player = usePlayerStore();
|
const player = usePlayerStore();
|
||||||
|
|
||||||
const album = ref<any>(null);
|
const album = ref<any>(null);
|
||||||
const songs = ref<Song[]>([]);
|
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) {
|
async function fetchAlbum(id: number, force = false) {
|
||||||
loading.value = true;
|
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;
|
album.value = null;
|
||||||
songs.value = [];
|
songs.value = [];
|
||||||
try {
|
try {
|
||||||
const jsonStr: string = await MusicApi.albumDetail(id);
|
const jsonStr: string = await MusicApi.albumDetail(id);
|
||||||
const data = JSON.parse(jsonStr);
|
const data = JSON.parse(jsonStr);
|
||||||
album.value = data.album;
|
album.value = data.album;
|
||||||
|
albumLoading.value = false;
|
||||||
songs.value = (data.songs || []).map(normalizeSong);
|
songs.value = (data.songs || []).map(normalizeSong);
|
||||||
|
songsLoading.value = false;
|
||||||
|
pageCacheSet(cacheKey, { album: album.value, songs: songs.value });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
} finally {
|
loadError.value = true;
|
||||||
loading.value = false;
|
albumLoading.value = false;
|
||||||
|
songsLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -95,7 +146,11 @@ onMounted(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
watch(() => route.params.id, (newId) => {
|
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() {
|
function playAll() {
|
||||||
|
|||||||
@ -1,19 +1,58 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="p-8 text-content">
|
<div class="p-8 text-content">
|
||||||
<button @click="$router.back()" class="mb-4 text-content-2 hover:text-content transition">
|
<PageHeader />
|
||||||
← 返回
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<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 v-if="!artist && !songs.length && !albums.length" class="flex gap-6 mb-4">
|
||||||
<div class="flex flex-col justify-between min-w-0">
|
<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>
|
<div>
|
||||||
<h1 class="text-2xl font-bold leading-tight">{{ artist.name }}</h1>
|
<h1 class="text-2xl font-bold leading-tight">{{ artistName }}</h1>
|
||||||
<p class="text-xs text-content-3 mt-2">
|
<p v-if="artistFollowers || artist?.musicSize" class="text-xs text-content-3 mt-1">
|
||||||
{{ formatPlayCount(artist.followeds || 0) }} 粉丝 · {{ artist.musicSize || 0 }} 首歌曲
|
<span v-if="artistFollowers">{{ formatPlayCount(artistFollowers) }} 粉丝</span>
|
||||||
|
<span v-if="artistFollowers && artist?.musicSize"> · </span>
|
||||||
|
<span v-if="artist?.musicSize">{{ artist.musicSize }} 首歌曲</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
<button
|
||||||
@click="playAll"
|
@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"
|
class="px-5 py-2 bg-accent hover:bg-accent-hover rounded-full text-white font-medium transition flex items-center gap-2"
|
||||||
@ -21,10 +60,37 @@
|
|||||||
<IconPlay class="w-4 h-4 fill-current" />
|
<IconPlay class="w-4 h-4 fill-current" />
|
||||||
播放全部
|
播放全部
|
||||||
</button>
|
</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>
|
</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">
|
<div class="flex gap-2 mb-6">
|
||||||
<button
|
<button
|
||||||
v-for="tab in tabs"
|
v-for="tab in tabs"
|
||||||
@ -37,10 +103,18 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loading" class="text-content-2">加载中...</div>
|
<!-- 歌曲列表 -->
|
||||||
|
<div v-if="activeTab === 'songs'">
|
||||||
<template v-else>
|
<div v-if="songsLoading" class="space-y-1">
|
||||||
<div v-if="activeTab === 'songs'" 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" class="space-y-1">
|
||||||
<SongListItem
|
<SongListItem
|
||||||
v-for="(song, index) in songs"
|
v-for="(song, index) in songs"
|
||||||
:key="song.id"
|
:key="song.id"
|
||||||
@ -57,8 +131,20 @@
|
|||||||
@click="player.playFromList(songs, index)"
|
@click="player.playFromList(songs, index)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</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
|
<div
|
||||||
v-for="album in albums"
|
v-for="album in albums"
|
||||||
:key="album.id"
|
:key="album.id"
|
||||||
@ -72,23 +158,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useRoute, useRouter } from 'vue-router';
|
||||||
import { MusicApi } from '../api';
|
import { MusicApi } from '../api';
|
||||||
import { usePlayerStore } from '../stores/player';
|
import { usePlayerStore } from '../stores/player';
|
||||||
import { formatPlayCount, formatDate } from '../utils/format';
|
import { formatPlayCount, formatDate } from '../utils/format';
|
||||||
import { normalizeSong, type Song } from '../utils/song';
|
import { normalizeSong, type Song } from '../utils/song';
|
||||||
|
import { pageCacheGet, pageCacheSet } from '../composables/usePageCache';
|
||||||
import SongListItem from '../components/SongListItem.vue';
|
import SongListItem from '../components/SongListItem.vue';
|
||||||
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import IconPlay from '~icons/lucide/play';
|
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 route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -98,41 +188,128 @@ const artist = ref<any>(null);
|
|||||||
const songs = ref<Song[]>([]);
|
const songs = ref<Song[]>([]);
|
||||||
const albums = ref<any[]>([]);
|
const albums = ref<any[]>([]);
|
||||||
const briefDesc = ref('');
|
const briefDesc = ref('');
|
||||||
const loading = ref(true);
|
const loadError = ref(false);
|
||||||
|
const songsLoading = ref(false);
|
||||||
|
const albumsLoading = ref(false);
|
||||||
const activeTab = ref('songs');
|
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 = [
|
const tabs = [
|
||||||
{ key: 'songs', label: '热门歌曲' },
|
{ key: 'songs', label: '热门歌曲' },
|
||||||
{ key: 'albums', label: '专辑' },
|
{ key: 'albums', label: '专辑' },
|
||||||
{ key: 'desc', label: '简介' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
async function fetchArtist(id: number) {
|
const artistName = computed(() => {
|
||||||
loading.value = true;
|
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;
|
artist.value = null;
|
||||||
songs.value = [];
|
songs.value = [];
|
||||||
albums.value = [];
|
albums.value = [];
|
||||||
briefDesc.value = '';
|
briefDesc.value = '';
|
||||||
|
|
||||||
|
const loadDetail = async () => {
|
||||||
try {
|
try {
|
||||||
const [detailStr, songsStr, albumStr, descStr] = await Promise.all([
|
const jsonStr = await MusicApi.artistDetail(id);
|
||||||
MusicApi.artistDetail(id),
|
const data = JSON.parse(jsonStr);
|
||||||
MusicApi.artistSongs({ id, order: 'hot', limit: 50, offset: 0 }),
|
const a = data.artist || data.data?.artist || data;
|
||||||
MusicApi.artistAlbum(id, 30, 0),
|
artist.value = a;
|
||||||
MusicApi.artistDesc(id),
|
} catch { /* 忽略 */ }
|
||||||
]);
|
};
|
||||||
const detailData = JSON.parse(detailStr);
|
|
||||||
artist.value = detailData.artist;
|
const loadFollowStatus = async () => {
|
||||||
const songsData = JSON.parse(songsStr);
|
try {
|
||||||
songs.value = (songsData.songs || []).map(normalizeSong);
|
const jsonStr = await MusicApi.artistSublist(100, 0);
|
||||||
const albumData = JSON.parse(albumStr);
|
const data = JSON.parse(jsonStr);
|
||||||
albums.value = albumData.hotAlbums || [];
|
const list = data.data || [];
|
||||||
const descData = JSON.parse(descStr);
|
isFollowed.value = list.some((item: any) => item.id === id);
|
||||||
briefDesc.value = descData.briefDesc || '';
|
} catch { /* 忽略 */ }
|
||||||
} catch (e) {
|
};
|
||||||
console.error(e);
|
|
||||||
} finally {
|
const loadSongs = async () => {
|
||||||
loading.value = false;
|
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(() => {
|
onMounted(() => {
|
||||||
@ -140,11 +317,29 @@ onMounted(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
watch(() => route.params.id, (newId) => {
|
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() {
|
function playAll() {
|
||||||
if (songs.value.length === 0) return;
|
if (songs.value.length === 0) return;
|
||||||
player.playAll(songs.value);
|
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>
|
</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>
|
<template>
|
||||||
<div class="p-8 text-content">
|
<div class="p-8 text-content">
|
||||||
<button @click="$router.back()" class="mb-4 text-content-2 hover:text-content transition">
|
<PageHeader>
|
||||||
← 返回
|
|
||||||
</button>
|
|
||||||
<div class="flex items-center justify-between mb-6">
|
|
||||||
<h1 class="text-2xl font-bold">每日推荐</h1>
|
<h1 class="text-2xl font-bold">每日推荐</h1>
|
||||||
<button
|
<button
|
||||||
v-if="songs.length > 0"
|
v-if="songs.length > 0"
|
||||||
@ -12,8 +9,20 @@
|
|||||||
>
|
>
|
||||||
播放全部
|
播放全部
|
||||||
</button>
|
</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>
|
||||||
|
</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>
|
||||||
<div v-if="loading" class="text-content-2">加载中...</div>
|
|
||||||
<div v-else class="space-y-2">
|
<div v-else class="space-y-2">
|
||||||
<SongListItem
|
<SongListItem
|
||||||
v-for="(song, index) in songs"
|
v-for="(song, index) in songs"
|
||||||
@ -38,37 +47,42 @@
|
|||||||
import { ref, onMounted, onActivated, watch } from 'vue';
|
import { ref, onMounted, onActivated, watch } from 'vue';
|
||||||
import { MusicApi } from '../api';
|
import { MusicApi } from '../api';
|
||||||
import SongListItem from '../components/SongListItem.vue';
|
import SongListItem from '../components/SongListItem.vue';
|
||||||
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import { usePlayerStore } from '../stores/player';
|
import { usePlayerStore } from '../stores/player';
|
||||||
import { pageCacheGet, pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
|
import { pageCacheGet, pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
|
||||||
import { normalizeSong, type Song } from '../utils/song';
|
import { normalizeSong, type Song } from '../utils/song';
|
||||||
import { useOnlineStatus } from '../composables/useOnlineStatus';
|
|
||||||
|
|
||||||
defineOptions({ name: 'DailySongsView' });
|
defineOptions({ name: 'DailySongsView' });
|
||||||
|
|
||||||
const player = usePlayerStore();
|
const player = usePlayerStore();
|
||||||
const { isOnline } = useOnlineStatus();
|
|
||||||
const songs = ref<Song[]>([]);
|
const songs = ref<Song[]>([]);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
|
const loadError = ref(false);
|
||||||
|
|
||||||
function isCurrentSong(songId: number): boolean {
|
function isCurrentSong(songId: number): boolean {
|
||||||
return player.currentSong?.id === songId;
|
return player.currentSong?.id === songId;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData(force = false) {
|
||||||
|
if (!force) {
|
||||||
const cached = pageCacheGet('dailySongs');
|
const cached = pageCacheGet('dailySongs');
|
||||||
if (cached) {
|
if (cached) {
|
||||||
songs.value = cached;
|
songs.value = cached;
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
|
loadError.value = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
|
loadError.value = false;
|
||||||
const jsonStr: string = await MusicApi.recommendSongs();
|
const jsonStr: string = await MusicApi.recommendSongs();
|
||||||
const data = JSON.parse(jsonStr);
|
const data = JSON.parse(jsonStr);
|
||||||
songs.value = (data.data?.dailySongs || []).map(normalizeSong);
|
songs.value = (data.data?.dailySongs || []).map(normalizeSong);
|
||||||
pageCacheSet('dailySongs', songs.value);
|
pageCacheSet('dailySongs', songs.value);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
loadError.value = true;
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@ -77,10 +91,14 @@ async function loadData() {
|
|||||||
onMounted(loadData);
|
onMounted(loadData);
|
||||||
|
|
||||||
onActivated(() => {
|
onActivated(() => {
|
||||||
if (pageCacheIsStale('dailySongs')) loadData();
|
if (loadError.value) {
|
||||||
|
loadData(true);
|
||||||
|
} else if (pageCacheIsStale('dailySongs')) {
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(isOnline, (val, old) => {
|
watch(() => navigator.onLine, (val, old) => {
|
||||||
if (val && !old && songs.value.length === 0) {
|
if (val && !old && songs.value.length === 0) {
|
||||||
pageCacheInvalidate('dailySongs');
|
pageCacheInvalidate('dailySongs');
|
||||||
loadData();
|
loadData();
|
||||||
|
|||||||
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,9 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="p-8 text-content">
|
<div class="p-8 text-content">
|
||||||
<button @click="$router.back()" class="mb-4 text-content-2 hover:text-content transition">
|
<PageHeader>
|
||||||
← 返回
|
|
||||||
</button>
|
|
||||||
<div class="flex items-center justify-between mb-6">
|
|
||||||
<h1 class="text-2xl font-bold">我喜欢的音乐</h1>
|
<h1 class="text-2xl font-bold">我喜欢的音乐</h1>
|
||||||
<button
|
<button
|
||||||
v-if="songs.length"
|
v-if="songs.length"
|
||||||
@ -13,11 +10,23 @@
|
|||||||
<IconPlay class="w-4 h-4 fill-current" />
|
<IconPlay class="w-4 h-4 fill-current" />
|
||||||
播放全部
|
播放全部
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</PageHeader>
|
||||||
<div v-if="!userStore.isLoggedIn" class="text-content-2">
|
<div v-if="!userStore.isLoggedIn" class="text-content-2">
|
||||||
请先登录后查看喜欢的音乐
|
请先登录后查看喜欢的音乐
|
||||||
</div>
|
</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-if="songs.length === 0" class="text-content-2">暂无喜欢的音乐</div>
|
||||||
<div v-else class="space-y-1">
|
<div v-else class="space-y-1">
|
||||||
<SongListItem
|
<SongListItem
|
||||||
@ -43,34 +52,38 @@
|
|||||||
import { ref, onMounted, onActivated, watch } from 'vue';
|
import { ref, onMounted, onActivated, watch } from 'vue';
|
||||||
import { MusicApi } from '../api';
|
import { MusicApi } from '../api';
|
||||||
import SongListItem from '../components/SongListItem.vue';
|
import SongListItem from '../components/SongListItem.vue';
|
||||||
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import { usePlayerStore } from '../stores/player';
|
import { usePlayerStore } from '../stores/player';
|
||||||
import { useUserStore } from '../stores/user';
|
import { useUserStore } from '../stores/user';
|
||||||
import { normalizeSong, type Song } from '../utils/song';
|
import { normalizeSong, type Song } from '../utils/song';
|
||||||
import { pageCacheGet, pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
|
import { pageCacheGet, pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
|
||||||
import { useOnlineStatus } from '../composables/useOnlineStatus';
|
|
||||||
import IconPlay from '~icons/lucide/play';
|
import IconPlay from '~icons/lucide/play';
|
||||||
|
|
||||||
defineOptions({ name: 'FavoriteSongsView' });
|
defineOptions({ name: 'FavoriteSongsView' });
|
||||||
|
|
||||||
const player = usePlayerStore();
|
const player = usePlayerStore();
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const { isOnline } = useOnlineStatus();
|
|
||||||
const songs = ref<Song[]>([]);
|
const songs = ref<Song[]>([]);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
|
const loadError = ref(false);
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData(force = false) {
|
||||||
if (!userStore.isLoggedIn) {
|
if (!userStore.isLoggedIn) {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!force) {
|
||||||
const cached = pageCacheGet('favoriteSongs');
|
const cached = pageCacheGet('favoriteSongs');
|
||||||
if (cached) {
|
if (cached) {
|
||||||
songs.value = cached;
|
songs.value = cached;
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
|
loadError.value = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
|
loadError.value = false;
|
||||||
const playlistJson: string = await MusicApi.userPlaylist(userStore.user!.userId);
|
const playlistJson: string = await MusicApi.userPlaylist(userStore.user!.userId);
|
||||||
const playlistData = JSON.parse(playlistJson);
|
const playlistData = JSON.parse(playlistJson);
|
||||||
const created = (playlistData.playlist || []).filter((p: any) => !p.subscribed);
|
const created = (playlistData.playlist || []).filter((p: any) => !p.subscribed);
|
||||||
@ -85,6 +98,7 @@ async function loadData() {
|
|||||||
pageCacheSet('favoriteSongs', songs.value);
|
pageCacheSet('favoriteSongs', songs.value);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
loadError.value = true;
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@ -93,10 +107,14 @@ async function loadData() {
|
|||||||
onMounted(loadData);
|
onMounted(loadData);
|
||||||
|
|
||||||
onActivated(() => {
|
onActivated(() => {
|
||||||
if (pageCacheIsStale('favoriteSongs')) loadData();
|
if (loadError.value) {
|
||||||
|
loadData(true);
|
||||||
|
} else if (pageCacheIsStale('favoriteSongs')) {
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(isOnline, (val, old) => {
|
watch(() => navigator.onLine, (val, old) => {
|
||||||
if (val && !old && userStore.isLoggedIn && songs.value.length === 0) {
|
if (val && !old && userStore.isLoggedIn && songs.value.length === 0) {
|
||||||
pageCacheInvalidate('favoriteSongs');
|
pageCacheInvalidate('favoriteSongs');
|
||||||
loadData();
|
loadData();
|
||||||
|
|||||||
@ -71,15 +71,37 @@
|
|||||||
</div>
|
</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>
|
<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)"
|
<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">
|
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" />
|
<img :src="pl.picUrl" class="w-full aspect-square object-cover" />
|
||||||
<div class="p-3">
|
<div class="p-3">
|
||||||
<p class="text-sm font-medium truncate">{{ pl.name }}</p>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -88,12 +110,34 @@
|
|||||||
<!-- 第三行:热门歌单(排行榜) -->
|
<!-- 第三行:热门歌单(排行榜) -->
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-semibold mb-4">📈 热门歌单</h2>
|
<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)"
|
<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">
|
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" />
|
<img :src="pl.coverImgUrl" class="w-full aspect-square object-cover" />
|
||||||
<div class="p-3">
|
<div class="p-3">
|
||||||
<p class="text-sm font-medium truncate">{{ pl.name }}</p>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -119,7 +163,11 @@ const userStore = useUserStore();
|
|||||||
const { isOnline } = useOnlineStatus();
|
const { isOnline } = useOnlineStatus();
|
||||||
|
|
||||||
const rankPlaylists = ref<any[]>([]);
|
const rankPlaylists = ref<any[]>([]);
|
||||||
|
const rankLoading = ref(false);
|
||||||
|
const rankError = ref(false);
|
||||||
const recPlaylists = ref<any[]>([]);
|
const recPlaylists = ref<any[]>([]);
|
||||||
|
const recLoading = ref(false);
|
||||||
|
const recError = ref(false);
|
||||||
const todayStr = ref('');
|
const todayStr = ref('');
|
||||||
const RANK_IDS = [3778678, 3779629, 19723756, 2884035];
|
const RANK_IDS = [3778678, 3779629, 19723756, 2884035];
|
||||||
|
|
||||||
@ -160,14 +208,16 @@ function onFmCardClick() {
|
|||||||
player.openRoamDrawer();
|
player.openRoamDrawer();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadData() {
|
async function fetchRankPlaylists() {
|
||||||
const cached = pageCacheGet('home');
|
const cacheKey = 'home_rank';
|
||||||
|
const cached = pageCacheGet(cacheKey);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
rankPlaylists.value = cached.rankPlaylists || [];
|
rankPlaylists.value = cached;
|
||||||
recPlaylists.value = cached.recPlaylists || [];
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
rankLoading.value = true;
|
||||||
|
rankError.value = false;
|
||||||
|
try {
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
RANK_IDS.map(id => MusicApi.getPlaylistDetail(id))
|
RANK_IDS.map(id => MusicApi.getPlaylistDetail(id))
|
||||||
);
|
);
|
||||||
@ -178,16 +228,46 @@ async function loadData() {
|
|||||||
return data.playlist;
|
return data.playlist;
|
||||||
})
|
})
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
pageCacheSet(cacheKey, rankPlaylists.value);
|
||||||
|
} catch {
|
||||||
|
rankError.value = true;
|
||||||
|
} finally {
|
||||||
|
rankLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (userStore.isLoggedIn) {
|
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 {
|
try {
|
||||||
const json = await MusicApi.recommendResource();
|
const json = await MusicApi.recommendResource();
|
||||||
const data = JSON.parse(json as string);
|
const data = JSON.parse(json as string);
|
||||||
recPlaylists.value = data.recommend || [];
|
recPlaylists.value = data.recommend || [];
|
||||||
} catch { /* 忽略 */ }
|
pageCacheSet(cacheKey, recPlaylists.value);
|
||||||
|
} catch {
|
||||||
|
recError.value = true;
|
||||||
|
} finally {
|
||||||
|
recLoading.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pageCacheSet('home', { rankPlaylists: rankPlaylists.value, recPlaylists: recPlaylists.value });
|
async function loadData() {
|
||||||
|
const cached = pageCacheGet('home');
|
||||||
|
if (cached) {
|
||||||
|
rankPlaylists.value = cached.rankPlaylists || [];
|
||||||
|
recPlaylists.value = cached.recPlaylists || [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchRankPlaylists();
|
||||||
|
fetchRecPlaylists();
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@ -201,9 +281,22 @@ onActivated(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
watch(isOnline, (val, old) => {
|
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');
|
||||||
|
pageCacheInvalidate('home_rank');
|
||||||
|
pageCacheInvalidate('home_rec');
|
||||||
loadData();
|
loadData();
|
||||||
|
} else {
|
||||||
|
if (rankError.value) {
|
||||||
|
pageCacheInvalidate('home_rank');
|
||||||
|
fetchRankPlaylists();
|
||||||
|
}
|
||||||
|
if (recError.value) {
|
||||||
|
pageCacheInvalidate('home_rec');
|
||||||
|
fetchRecPlaylists();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,19 +1,37 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="p-8 text-content">
|
<div class="p-8 text-content">
|
||||||
<button @click="$router.back()" class="mb-4 text-content-2 hover:text-content transition">
|
<PageHeader>
|
||||||
← 返回
|
|
||||||
</button>
|
|
||||||
<div class="flex items-center gap-4 mb-6">
|
|
||||||
<h1 class="text-2xl font-bold">本地音乐</h1>
|
<h1 class="text-2xl font-bold">本地音乐</h1>
|
||||||
<span v-if="songs.length" class="text-xs text-content-3">{{ songs.length }} 首</span>
|
<span v-if="songs.length" class="text-xs text-content-3">{{ songs.length }} 首</span>
|
||||||
|
<template #actions>
|
||||||
<button
|
<button
|
||||||
@click="refresh"
|
@click="refresh"
|
||||||
class="px-3 py-1 bg-muted hover:bg-emphasis rounded-full text-xs transition"
|
class="px-3 py-1 bg-muted hover:bg-emphasis rounded-full text-xs transition"
|
||||||
>
|
>
|
||||||
刷新
|
刷新
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
@click="showFolderModal = true"
|
||||||
|
class="text-content-3 hover:text-content transition p-1 rounded hover:bg-muted"
|
||||||
|
title="文件夹管理"
|
||||||
|
>
|
||||||
|
<IconEllipsis class="w-5 h-5 fill-current" />
|
||||||
|
</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.localMusicPaths.length === 0" class="text-content-3 py-4">
|
||||||
|
请先添加要扫描的文件夹
|
||||||
</div>
|
</div>
|
||||||
<div v-if="loading" class="text-content-2">加载中...</div>
|
|
||||||
<div v-else-if="songs.length === 0" class="text-content-3">
|
<div v-else-if="songs.length === 0" class="text-content-3">
|
||||||
当前文件夹下没有音乐文件,支持 mp3、flac、wav、ogg、aac、m4a 格式
|
当前文件夹下没有音乐文件,支持 mp3、flac、wav、ogg、aac、m4a 格式
|
||||||
</div>
|
</div>
|
||||||
@ -32,42 +50,46 @@
|
|||||||
>
|
>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<span class="text-xs text-content-3 flex-shrink-0">{{ formatFileSize(songs[index].fileSize) }}</span>
|
<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>
|
</template>
|
||||||
</SongListItem>
|
</SongListItem>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 文件夹管理弹窗 -->
|
||||||
<Transition name="fade">
|
<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 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-80 p-6 select-auto">
|
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-[420px] p-6 select-auto">
|
||||||
<h2 class="text-lg font-semibold text-content mb-1">确认删除</h2>
|
<div class="flex items-center justify-between mb-4">
|
||||||
<p class="text-sm text-content-2 mb-5">确定要删除「{{ deleteTarget?.name }}」吗?此操作不可撤销。</p>
|
<h2 class="text-lg font-semibold">扫描文件夹</h2>
|
||||||
<div class="flex gap-3">
|
<button @click="showFolderModal = false" class="text-content-3 hover:text-content transition">
|
||||||
<button @click="showDeleteConfirm = false"
|
<IconX class="w-5 h-5" />
|
||||||
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="settings.localMusicPaths.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="p in settings.localMusicPaths"
|
||||||
|
:key="p"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-subtle rounded-lg group"
|
||||||
|
>
|
||||||
|
<IconFolder class="w-4 h-4 text-content-3 flex-shrink-0" />
|
||||||
|
<span class="text-sm text-content-2 truncate flex-1" :title="p">{{ p }}</span>
|
||||||
|
<button
|
||||||
|
@click="settings.removeLocalMusicPath(p)"
|
||||||
|
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>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
@ -75,64 +97,53 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onActivated, onBeforeUnmount, watch } from 'vue';
|
import { ref, computed, onMounted, onActivated, watch } from 'vue';
|
||||||
import { MusicApi, DownloadApi } from '../api';
|
import { DownloadApi } from '../api';
|
||||||
import { usePlayerStore } from '../stores/player';
|
import { usePlayerStore } from '../stores/player';
|
||||||
import { useDownload } from '../composables/useDownload';
|
|
||||||
import { useSettingsStore } from '../stores/settings';
|
import { useSettingsStore } from '../stores/settings';
|
||||||
import { showToast } from '../composables/useToast';
|
import { pageCacheSet, pageCacheIsStale } from '../composables/usePageCache';
|
||||||
import { pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
|
import { formatFileSize, localSongToSong, fetchMissingCovers, type LocalSong } from '../composables/useLocalMusic';
|
||||||
|
import { open } from '@tauri-apps/plugin-dialog';
|
||||||
import SongListItem from '../components/SongListItem.vue';
|
import SongListItem from '../components/SongListItem.vue';
|
||||||
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import IconEllipsis from '~icons/lucide/ellipsis';
|
import IconEllipsis from '~icons/lucide/ellipsis';
|
||||||
import IconTrash2 from '~icons/lucide/trash-2';
|
import IconFolder from '~icons/lucide/folder';
|
||||||
import type { Song } from '../utils/song';
|
import IconX from '~icons/lucide/x';
|
||||||
|
|
||||||
defineOptions({ name: 'LocalMusicView' });
|
defineOptions({ name: 'LocalMusicView' });
|
||||||
|
|
||||||
const player = usePlayerStore();
|
const player = usePlayerStore();
|
||||||
const download = useDownload();
|
|
||||||
const settings = useSettingsStore();
|
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 songs = ref<LocalSong[]>([]);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const showDeleteConfirm = ref(false);
|
const showFolderModal = ref(false);
|
||||||
const deleteTarget = ref<LocalSong | null>(null);
|
|
||||||
const openMenuId = ref<number | null>(null);
|
|
||||||
|
|
||||||
const normalizedSongs = computed(() => songs.value.map(toSong));
|
const normalizedSongs = computed(() => songs.value.map(localSongToSong));
|
||||||
|
|
||||||
function toggleMenu(id: number) {
|
async function addFolder() {
|
||||||
openMenuId.value = openMenuId.value === id ? null : id;
|
const selected = await open({
|
||||||
|
directory: true,
|
||||||
|
multiple: false,
|
||||||
|
title: '选择音乐文件夹',
|
||||||
|
});
|
||||||
|
if (selected) {
|
||||||
|
settings.addLocalMusicPath(selected);
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeMenu() {
|
|
||||||
openMenuId.value = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => { document.addEventListener('click', closeMenu); });
|
|
||||||
onBeforeUnmount(() => { document.removeEventListener('click', closeMenu); });
|
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
|
if (settings.localMusicPaths.length === 0) {
|
||||||
|
songs.value = [];
|
||||||
|
loading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
pageCacheInvalidate('localMusic');
|
|
||||||
try {
|
try {
|
||||||
const list = await DownloadApi.listLocalSongs(settings.downloadPath || null);
|
const list = await DownloadApi.scanLocalFolders(settings.localMusicPaths);
|
||||||
songs.value = list;
|
songs.value = list;
|
||||||
pageCacheSet('localMusic', list);
|
pageCacheSet('localMusic', list);
|
||||||
fetchMissingCovers();
|
fetchMissingCovers(songs.value);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
} finally {
|
} finally {
|
||||||
@ -140,70 +151,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 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 { /* 忽略 */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(refresh);
|
onMounted(refresh);
|
||||||
|
|
||||||
onActivated(() => {
|
onActivated(() => {
|
||||||
if (pageCacheIsStale('localMusic')) refresh();
|
if (pageCacheIsStale('localMusic')) refresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(() => settings.downloadPath, () => { refresh(); });
|
watch(() => settings.localMusicPaths, () => { refresh(); }, { deep: true });
|
||||||
|
|
||||||
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];
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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);
|
|
||||||
showToast('已删除', 'success');
|
|
||||||
} catch (e) {
|
|
||||||
showToast('删除失败', 'error');
|
|
||||||
}
|
|
||||||
showDeleteConfirm.value = false;
|
|
||||||
deleteTarget.value = null;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@ -1,10 +1,23 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="p-8 text-content">
|
<div class="p-8 text-content">
|
||||||
<button @click="$router.back()" class="mb-4 text-content-2 hover:text-content transition">
|
<PageHeader />
|
||||||
← 返回
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<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" />
|
<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 class="flex flex-col justify-between min-w-0">
|
||||||
<div>
|
<div>
|
||||||
@ -13,7 +26,21 @@
|
|||||||
<img :src="playlist.creator.avatarUrl" class="w-5 h-5 rounded-full" />
|
<img :src="playlist.creator.avatarUrl" class="w-5 h-5 rounded-full" />
|
||||||
<span class="text-sm text-content-2">{{ playlist.creator.nickname }}</span>
|
<span class="text-sm text-content-2">{{ playlist.creator.nickname }}</span>
|
||||||
</div>
|
</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">
|
<p class="text-xs text-content-3 mt-2">
|
||||||
{{ playlist.trackCount }} 首歌曲 · 播放 {{ formatPlayCount(playlist.playCount) }} 次
|
{{ playlist.trackCount }} 首歌曲 · 播放 {{ formatPlayCount(playlist.playCount) }} 次
|
||||||
</p>
|
</p>
|
||||||
@ -39,9 +66,41 @@
|
|||||||
</div>
|
</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">
|
<!-- 加载失败 -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- 歌曲列表 -->
|
||||||
|
<div v-else-if="songs.length" class="space-y-1">
|
||||||
<SongListItem
|
<SongListItem
|
||||||
v-for="(song, index) in songs"
|
v-for="(song, index) in songs"
|
||||||
:key="song.id"
|
:key="song.id"
|
||||||
@ -59,6 +118,8 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!songsLoading && !loadError" class="text-content-2">暂无歌曲</div>
|
||||||
|
|
||||||
<div v-if="playlist" class="mt-8">
|
<div v-if="playlist" class="mt-8">
|
||||||
<CommentSection :type="2" :id="Number(route.params.id)" />
|
<CommentSection :type="2" :id="Number(route.params.id)" />
|
||||||
</div>
|
</div>
|
||||||
@ -66,7 +127,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useRoute } from 'vue-router';
|
||||||
import { MusicApi } from '../api';
|
import { MusicApi } from '../api';
|
||||||
import { usePlayerStore } from '../stores/player';
|
import { usePlayerStore } from '../stores/player';
|
||||||
@ -74,10 +135,16 @@ import { useUserStore } from '../stores/user';
|
|||||||
import { showToast } from '../composables/useToast';
|
import { showToast } from '../composables/useToast';
|
||||||
import { formatPlayCount } from '../utils/format';
|
import { formatPlayCount } from '../utils/format';
|
||||||
import { normalizeSong, type Song } from '../utils/song';
|
import { normalizeSong, type Song } from '../utils/song';
|
||||||
|
import { pageCacheGet, pageCacheSet } from '../composables/usePageCache';
|
||||||
import SongListItem from '../components/SongListItem.vue';
|
import SongListItem from '../components/SongListItem.vue';
|
||||||
import CommentSection from '../components/CommentSection.vue';
|
import CommentSection from '../components/CommentSection.vue';
|
||||||
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import IconPlay from '~icons/lucide/play';
|
import IconPlay from '~icons/lucide/play';
|
||||||
import IconBookmark from '~icons/lucide/bookmark';
|
import IconBookmark from '~icons/lucide/bookmark';
|
||||||
|
import IconX from '~icons/lucide/x';
|
||||||
|
import IconChevronDown from '~icons/lucide/chevron-down';
|
||||||
|
|
||||||
|
defineOptions({ name: 'PlaylistDetailView' });
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const player = usePlayerStore();
|
const player = usePlayerStore();
|
||||||
@ -85,29 +152,64 @@ const userStore = useUserStore();
|
|||||||
|
|
||||||
const playlist = ref<any>(null);
|
const playlist = ref<any>(null);
|
||||||
const songs = ref<Song[]>([]);
|
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 subscribed = ref(false);
|
||||||
|
const showDescModal = ref(false);
|
||||||
|
const descOverflow = ref(false);
|
||||||
|
const descEl = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
const isOwnPlaylist = computed(() => {
|
const isOwnPlaylist = computed(() => {
|
||||||
if (!playlist.value || !userStore.user) return false;
|
if (!playlist.value || !userStore.user) return false;
|
||||||
return playlist.value.creator?.userId === userStore.user.userId;
|
return playlist.value.creator?.userId === userStore.user.userId;
|
||||||
});
|
});
|
||||||
|
|
||||||
async function fetchPlaylist(id: number) {
|
function checkDescOverflow() {
|
||||||
loading.value = true;
|
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;
|
playlist.value = null;
|
||||||
songs.value = [];
|
songs.value = [];
|
||||||
try {
|
try {
|
||||||
const jsonStr: string = await MusicApi.getPlaylistDetail(id);
|
const jsonStr: string = await MusicApi.getPlaylistDetail(id);
|
||||||
const data = JSON.parse(jsonStr);
|
const data = JSON.parse(jsonStr);
|
||||||
playlist.value = data.playlist;
|
playlist.value = data.playlist;
|
||||||
|
playlistLoading.value = false;
|
||||||
songs.value = (data.playlist.tracks || []).map(normalizeSong);
|
songs.value = (data.playlist.tracks || []).map(normalizeSong);
|
||||||
|
songsLoading.value = false;
|
||||||
subscribed.value = data.playlist.subscribed || false;
|
subscribed.value = data.playlist.subscribed || false;
|
||||||
|
pageCacheSet(cacheKey, { playlist: playlist.value, songs: songs.value, subscribed: subscribed.value });
|
||||||
|
checkDescOverflow();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
loadError.value = true;
|
||||||
|
playlistLoading.value = false;
|
||||||
|
songsLoading.value = false;
|
||||||
showToast('获取歌单详情失败', 'error');
|
showToast('获取歌单详情失败', 'error');
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -116,7 +218,11 @@ onMounted(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
watch(() => route.params.id, (newId) => {
|
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() {
|
function playAll() {
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="p-8 text-content">
|
<div class="p-8 text-content">
|
||||||
<button @click="$router.back()" class="mb-4 text-content-2 hover:text-content transition">
|
<PageHeader>
|
||||||
← 返回
|
<h1 class="text-2xl font-bold">最近播放</h1>
|
||||||
</button>
|
</PageHeader>
|
||||||
<h1 class="text-2xl font-bold mb-6">最近播放</h1>
|
|
||||||
<div v-if="player.recentLocal.length === 0" class="text-content-3">还没有播放记录,去听首歌吧</div>
|
<div v-if="player.recentLocal.length === 0" class="text-content-3">还没有播放记录,去听首歌吧</div>
|
||||||
<div v-else class="space-y-2">
|
<div v-else class="space-y-2">
|
||||||
<SongListItem
|
<SongListItem
|
||||||
@ -28,6 +27,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { usePlayerStore } from '../stores/player';
|
import { usePlayerStore } from '../stores/player';
|
||||||
import SongListItem from '../components/SongListItem.vue';
|
import SongListItem from '../components/SongListItem.vue';
|
||||||
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
|
|
||||||
const player = usePlayerStore();
|
const player = usePlayerStore();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="p-8 text-content">
|
<div class="p-8 text-content">
|
||||||
<button @click="$router.back()" class="mb-4 text-content-2 hover:text-content transition">
|
<PageHeader>
|
||||||
← 返回
|
<h1 class="text-2xl font-bold">设置</h1>
|
||||||
</button>
|
</PageHeader>
|
||||||
<h1 class="text-2xl font-bold mb-8">设置</h1>
|
|
||||||
|
|
||||||
<section class="mb-8">
|
<section class="mb-8">
|
||||||
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">播放</h2>
|
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">播放</h2>
|
||||||
@ -263,6 +262,7 @@ import { getVersion } from '@tauri-apps/api/app';
|
|||||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||||
import { open } from '@tauri-apps/plugin-dialog';
|
import { open } from '@tauri-apps/plugin-dialog';
|
||||||
import CustomSelect from '../components/CustomSelect.vue';
|
import CustomSelect from '../components/CustomSelect.vue';
|
||||||
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import IconX from '~icons/lucide/x';
|
import IconX from '~icons/lucide/x';
|
||||||
import IconFileText from '~icons/lucide/file-text';
|
import IconFileText from '~icons/lucide/file-text';
|
||||||
import IconLoader2 from '~icons/lucide/loader-2';
|
import IconLoader2 from '~icons/lucide/loader-2';
|
||||||
|
|||||||
Reference in New Issue
Block a user