feat(stores): 将轻量状态管理替换为Pinia并添加持久化功能
- 使用Pinia替代原有的reactive store实现状态管理 - 集成pinia-plugin-persistedstate插件实现设置和搜索历史的持久化 - 新增searchHistory store用于存储搜索历史记录 - 更新README文档中的状态管理相关说明
30
README.md
|
|
@ -1,4 +1,3 @@
|
||||||
<<<<<<< HEAD
|
|
||||||
# YwYMusic 🎵
|
# YwYMusic 🎵
|
||||||
|
|
||||||
一款基于 **Tauri 2 + Vue 3** 的轻量在线音乐播放器。前端负责界面与播放,音乐数据、音频直链解析、扫码登录等能力由独立的音乐聚合后端提供。
|
一款基于 **Tauri 2 + Vue 3** 的轻量在线音乐播放器。前端负责界面与播放,音乐数据、音频直链解析、扫码登录等能力由独立的音乐聚合后端提供。
|
||||||
|
|
@ -34,7 +33,7 @@
|
||||||
| 路由 | Vue Router(Hash 模式,兼容 Tauri `file://` 协议) |
|
| 路由 | Vue Router(Hash 模式,兼容 Tauri `file://` 协议) |
|
||||||
| 网络请求 | Axios(统一拦截器 + 业务码处理) |
|
| 网络请求 | Axios(统一拦截器 + 业务码处理) |
|
||||||
| 二维码 | `qrcode`(本地生成登录二维码) |
|
| 二维码 | `qrcode`(本地生成登录二维码) |
|
||||||
| 状态管理 | 轻量 `reactive` store(无 Pinia 依赖) |
|
| 状态管理 | Pinia + pinia-plugin-persistedstate(持久化设置与搜索历史) |
|
||||||
| 工程化 | pnpm workspace、unplugin-auto-import、unplugin-vue-components |
|
| 工程化 | pnpm workspace、unplugin-auto-import、unplugin-vue-components |
|
||||||
|
|
||||||
## 📁 项目结构
|
## 📁 项目结构
|
||||||
|
|
@ -46,13 +45,18 @@ YwYMusic
|
||||||
│ ├─ components/ # 通用组件(PlayerBar、SongTable、MusicCard、QrLogin 等)
|
│ ├─ components/ # 通用组件(PlayerBar、SongTable、MusicCard、QrLogin 等)
|
||||||
│ ├─ layouts/ # 主布局:侧边栏导航 + 内容区 + 底部播放条
|
│ ├─ layouts/ # 主布局:侧边栏导航 + 内容区 + 底部播放条
|
||||||
│ ├─ router/ # 路由定义
|
│ ├─ router/ # 路由定义
|
||||||
│ ├─ stores/ # 轻量状态:player(播放器)、settings(音乐源设置)
|
│ ├─ stores/ # Pinia 状态:player(播放器)、settings(音乐源设置)、searchHistory(搜索历史)
|
||||||
│ ├─ utils/ # 请求封装、封面防盗链代理、时间/数量格式化
|
│ ├─ utils/ # 请求封装、封面防盗链代理、时间/数量格式化
|
||||||
│ ├─ views/ # 页面:发现 / 排行榜 / 我的音乐 / 搜索 / 歌单详情 / 设置
|
│ ├─ views/ # 页面:发现 / 排行榜 / 我的音乐 / 搜索 / 歌单详情 / 设置
|
||||||
│ ├─ mocks/ # 本地兜底数据(排行榜、新歌、我的音乐)
|
│ ├─ mocks/ # 本地兜底数据(排行榜、新歌、我的音乐)
|
||||||
│ ├─ styles/ # 全局样式
|
│ ├─ styles/ # 全局样式
|
||||||
│ └─ types/ # 全局类型与自动生成声明
|
│ └─ types/ # 全局类型与自动生成声明
|
||||||
├─ src-tauri/ # Tauri(Rust)壳与打包配置
|
├─ src-tauri/ # Tauri(Rust)壳与打包配置
|
||||||
|
│ ├─ src/
|
||||||
|
│ │ ├─ commands/ # Tauri 命令(music_url 等)
|
||||||
|
│ │ ├─ services/ # HTTP 客户端(支持 GO_MUSIC_URL / METING_API_URL 切换)
|
||||||
|
│ │ └─ models.rs # 数据模型与 API 地址常量
|
||||||
|
│ └─ Cargo.toml
|
||||||
├─ index.html
|
├─ index.html
|
||||||
└─ package.json
|
└─ package.json
|
||||||
```
|
```
|
||||||
|
|
@ -92,6 +96,25 @@ pnpm tauri build
|
||||||
VITE_BASE_API_URL = "http://127.0.0.1:8080"
|
VITE_BASE_API_URL = "http://127.0.0.1:8080"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Rust 后端同时配置了两个 API 地址常量(`src-tauri/src/models.rs`):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub const GO_MUSIC_URL: &str = "http://127.0.0.1:8080"; // 音乐聚合后端
|
||||||
|
pub const METING_API_URL: &str = "http://127.0.0.1:81"; // Meting API
|
||||||
|
```
|
||||||
|
|
||||||
|
`HttpClient` 支持通过 `ApiEndpoint` 枚举在请求时选择目标地址:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use crate::services::http::{ApiEndpoint, HttpClient};
|
||||||
|
|
||||||
|
// 默认请求 GO_MUSIC_URL
|
||||||
|
state.get("/api/v1/playlist/recommend", ¶ms).await?;
|
||||||
|
|
||||||
|
// 显式请求 METING_API_URL
|
||||||
|
state.get_at(ApiEndpoint::Meting, "/api", ¶ms).await?;
|
||||||
|
```
|
||||||
|
|
||||||
请先启动后端服务,否则页面将回退到本地 mock 数据或提示网络异常。
|
请先启动后端服务,否则页面将回退到本地 mock 数据或提示网络异常。
|
||||||
|
|
||||||
## 🔌 后端接口概览
|
## 🔌 后端接口概览
|
||||||
|
|
@ -107,6 +130,7 @@ VITE_BASE_API_URL = "http://127.0.0.1:8080"
|
||||||
| 专辑 | `/api/v1/album/detail` | 专辑详情 |
|
| 专辑 | `/api/v1/album/detail` | 专辑详情 |
|
||||||
| 系统 | `/api/v1/system/qr_login/*` | 扫码登录会话与状态轮询 |
|
| 系统 | `/api/v1/system/qr_login/*` | 扫码登录会话与状态轮询 |
|
||||||
| 系统 | `/api/v1/system/cookies` | 平台 Cookies 读取 / 写入 |
|
| 系统 | `/api/v1/system/cookies` | 平台 Cookies 读取 / 写入 |
|
||||||
|
| Meting | `/api` | Meting API(通过 `music_url` 命令调用) |
|
||||||
|
|
||||||
> 多源参数使用重复键形式传递,例如 `sources=kugou&sources=netease`。
|
> 多源参数使用重复键形式传递,例如 `sources=kugou&sources=netease`。
|
||||||
|
|
||||||
|
|
|
||||||
|
After Width: | Height: | Size: 916 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 974 B After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 903 B After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.9 KiB |
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#fff</color>
|
||||||
|
</resources>
|
||||||
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 704 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 227 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
|
@ -0,0 +1,19 @@
|
||||||
|
use crate::{
|
||||||
|
models::ApiResponse,
|
||||||
|
services::http::{ApiEndpoint, HttpClient},
|
||||||
|
};
|
||||||
|
use tauri::State;
|
||||||
|
use tauri_helper::auto_collect_command;
|
||||||
|
|
||||||
|
// 获取专辑详情
|
||||||
|
#[tauri::command]
|
||||||
|
#[auto_collect_command]
|
||||||
|
pub async fn album_detail(
|
||||||
|
http: State<'_, HttpClient>,
|
||||||
|
id: String,
|
||||||
|
source: String,
|
||||||
|
) -> Result<ApiResponse<serde_json::Value>, String> {
|
||||||
|
let params = [("id".to_string(), id), ("source".to_string(), source)];
|
||||||
|
let data = http.get_at(ApiEndpoint::GoMusic, "/api/v1/album/detail", ¶ms).await.map_err(|e| e.to_string())?;
|
||||||
|
Ok(ApiResponse::success(data))
|
||||||
|
}
|
||||||
|
|
@ -7,26 +7,185 @@ use crate::{
|
||||||
services::http::{ApiEndpoint, HttpClient},
|
services::http::{ApiEndpoint, HttpClient},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 代理请求并下载封面图
|
||||||
|
#[tauri::command]
|
||||||
|
#[auto_collect_command]
|
||||||
|
pub async fn music_cover(
|
||||||
|
http: State<'_, HttpClient>,
|
||||||
|
url: String,
|
||||||
|
artist: String,
|
||||||
|
) -> Result<ApiResponse<serde_json::Value>, String> {
|
||||||
|
let params: Vec<(String, String)> =
|
||||||
|
vec![("url".to_string(), url), ("artist".to_string(), artist)];
|
||||||
|
let data = http
|
||||||
|
.get_at(ApiEndpoint::GoMusic, "/api/v1/music/cover", ¶ms)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string());
|
||||||
|
Ok(ApiResponse::success(data?))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 探测音频大小与码率
|
||||||
|
#[tauri::command]
|
||||||
|
#[auto_collect_command]
|
||||||
|
pub async fn music_inspect(
|
||||||
|
http: State<'_, HttpClient>,
|
||||||
|
id: String,
|
||||||
|
source: String,
|
||||||
|
duration: u32,
|
||||||
|
) -> Result<ApiResponse<serde_json::Value>, String> {
|
||||||
|
let params = vec![
|
||||||
|
("id".to_string(), id),
|
||||||
|
("source".to_string(), source),
|
||||||
|
("duration".to_string(), duration.to_string()),
|
||||||
|
];
|
||||||
|
let data = http
|
||||||
|
.get_at(ApiEndpoint::GoMusic, "/api/v1/music/inspect", ¶ms)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string());
|
||||||
|
Ok(ApiResponse::success(data?))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 JSON 格式歌词
|
||||||
|
#[tauri::command]
|
||||||
|
#[auto_collect_command]
|
||||||
|
pub async fn music_lyric(
|
||||||
|
http: State<'_, HttpClient>,
|
||||||
|
id: String,
|
||||||
|
source: String,
|
||||||
|
) -> Result<ApiResponse<serde_json::Value>, String> {
|
||||||
|
let params = vec![("id".to_string(), id), ("source".to_string(), source)];
|
||||||
|
let data = http
|
||||||
|
.get_at(ApiEndpoint::GoMusic, "/api/v1/music/lyric", ¶ms)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string());
|
||||||
|
Ok(ApiResponse::success(data?))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载 LRC 歌词文件
|
||||||
|
#[tauri::command]
|
||||||
|
#[auto_collect_command]
|
||||||
|
pub async fn music_lrc_file(
|
||||||
|
http: State<'_, HttpClient>,
|
||||||
|
id: String,
|
||||||
|
source: String,
|
||||||
|
name: String,
|
||||||
|
artist: String,
|
||||||
|
) -> Result<ApiResponse<serde_json::Value>, String> {
|
||||||
|
let params = vec![
|
||||||
|
("id".to_string(), id),
|
||||||
|
("source".to_string(), source),
|
||||||
|
("name".to_string(), name),
|
||||||
|
("artist".to_string(), artist),
|
||||||
|
];
|
||||||
|
let data = http
|
||||||
|
.get_at(ApiEndpoint::GoMusic, "/api/v1/music/lyric/file", ¶ms)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string());
|
||||||
|
Ok(ApiResponse::success(data?))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 综合搜索与链接解析
|
||||||
|
#[tauri::command]
|
||||||
|
#[auto_collect_command]
|
||||||
|
pub async fn music_search(
|
||||||
|
http: State<'_, HttpClient>,
|
||||||
|
keyword: String,
|
||||||
|
type_: String,
|
||||||
|
sources: Vec<String>,
|
||||||
|
) -> Result<ApiResponse<serde_json::Value>, String> {
|
||||||
|
let mut params = vec![("q".to_string(), keyword), ("type".to_string(), type_)];
|
||||||
|
// 追加动态 sources 参数
|
||||||
|
for source in sources {
|
||||||
|
params.push(("source".to_string(), source));
|
||||||
|
}
|
||||||
|
let data = http
|
||||||
|
.get_at(ApiEndpoint::GoMusic, "/api/v1/music/search", ¶ms)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string());
|
||||||
|
Ok(ApiResponse::success(data?))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 串流代理与下载音频
|
||||||
|
#[tauri::command]
|
||||||
|
#[auto_collect_command]
|
||||||
|
pub async fn music_stream(
|
||||||
|
http: State<'_, HttpClient>,
|
||||||
|
id: String,
|
||||||
|
source: String,
|
||||||
|
name: String,
|
||||||
|
artist: String,
|
||||||
|
) -> Result<ApiResponse<serde_json::Value>, String> {
|
||||||
|
let params = vec![
|
||||||
|
("id".to_string(), id),
|
||||||
|
("source".to_string(), source),
|
||||||
|
("name".to_string(), name),
|
||||||
|
("artist".to_string(), artist),
|
||||||
|
];
|
||||||
|
let data = http
|
||||||
|
.get_at(ApiEndpoint::GoMusic, "/api/v1/music/stream", ¶ms)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string());
|
||||||
|
Ok(ApiResponse::success(data?))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 智能切换可用的平替音源
|
||||||
|
pub async fn music_switch(
|
||||||
|
http: State<'_, HttpClient>,
|
||||||
|
name: String,
|
||||||
|
artist: String,
|
||||||
|
source: String,
|
||||||
|
target: String,
|
||||||
|
duration: u32,
|
||||||
|
) -> Result<ApiResponse<serde_json::Value>, String> {
|
||||||
|
let params = vec![
|
||||||
|
("name".to_string(), name),
|
||||||
|
("artist".to_string(), artist),
|
||||||
|
("source".to_string(), source),
|
||||||
|
("target".to_string(), target),
|
||||||
|
("duration".to_string(), duration.to_string()),
|
||||||
|
];
|
||||||
|
let data = http
|
||||||
|
.get_at(ApiEndpoint::GoMusic, "/api/v1/music/switch", ¶ms)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string());
|
||||||
|
Ok(ApiResponse::success(data?))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取音频裸直链
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[auto_collect_command]
|
#[auto_collect_command]
|
||||||
pub async fn music_url(
|
pub async fn music_url(
|
||||||
state: State<'_, HttpClient>,
|
http: State<'_, HttpClient>,
|
||||||
id: String,
|
id: String,
|
||||||
server: String,
|
source: String,
|
||||||
type_: String,
|
|
||||||
) -> Result<ApiResponse<serde_json::Value>, String> {
|
) -> Result<ApiResponse<serde_json::Value>, String> {
|
||||||
let client = reqwest::Client::new();
|
let params = vec![("id".to_string(), id), ("source".to_string(), source)];
|
||||||
let params = vec![
|
let data = http
|
||||||
("id".to_string(), id),
|
.get_at(ApiEndpoint::GoMusic, "/api/v1/music/url", ¶ms)
|
||||||
("server".to_string(), server),
|
|
||||||
("type".to_string(), type_),
|
|
||||||
];
|
|
||||||
let resp = client
|
|
||||||
.get(format!("{}/api", METING_API_URL))
|
|
||||||
.query(¶ms)
|
|
||||||
.send()
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let data = resp.json().await.map_err(|e| e.to_string())?;
|
|
||||||
Ok(ApiResponse::success(data))
|
Ok(ApiResponse::success(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #[tauri::command]
|
||||||
|
// #[auto_collect_command]
|
||||||
|
// pub async fn music_url(
|
||||||
|
// id: String,
|
||||||
|
// server: String,
|
||||||
|
// type_: String,
|
||||||
|
// ) -> Result<ApiResponse<serde_json::Value>, String> {
|
||||||
|
// let client = reqwest::Client::new();
|
||||||
|
// let params = vec![
|
||||||
|
// ("id".to_string(), id),
|
||||||
|
// ("server".to_string(), server),
|
||||||
|
// ("type".to_string(), type_),
|
||||||
|
// ];
|
||||||
|
// let resp = client
|
||||||
|
// .get(format!("{}/api", METING_API_URL))
|
||||||
|
// .query(¶ms)
|
||||||
|
// .send()
|
||||||
|
// .await
|
||||||
|
// .map_err(|e| e.to_string())?;
|
||||||
|
// let data = resp.json().await.map_err(|e| e.to_string())?;
|
||||||
|
// Ok(ApiResponse::success(data))
|
||||||
|
// }
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,61 @@ use tauri_helper::auto_collect_command;
|
||||||
|
|
||||||
use crate::{models::ApiResponse, services::http::HttpClient};
|
use crate::{models::ApiResponse, services::http::HttpClient};
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[auto_collect_command]
|
||||||
|
pub async fn playlist_categories(
|
||||||
|
state: State<'_, HttpClient>,
|
||||||
|
sources: Vec<String>,
|
||||||
|
) -> Result<ApiResponse<serde_json::Value>, String> {
|
||||||
|
let params: Vec<(String, String)> = sources
|
||||||
|
.iter()
|
||||||
|
.map(|source| ("sources".to_string(), source.clone()))
|
||||||
|
.collect();
|
||||||
|
let data = state
|
||||||
|
.get("/api/v1/playlist/categories", ¶ms)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string());
|
||||||
|
Ok(ApiResponse::success(data?))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[auto_collect_command]
|
||||||
|
pub async fn playlist_category_list(
|
||||||
|
state: State<'_, HttpClient>,
|
||||||
|
category_id: String,
|
||||||
|
source: String,
|
||||||
|
page: u32,
|
||||||
|
limit: u32,
|
||||||
|
) -> Result<ApiResponse<serde_json::Value>, String> {
|
||||||
|
let params: Vec<(String, String)> = vec![
|
||||||
|
("category_id".to_string(), category_id),
|
||||||
|
("source".to_string(), source),
|
||||||
|
("page".to_string(), page.to_string()),
|
||||||
|
("limit".to_string(), limit.to_string()),
|
||||||
|
];
|
||||||
|
let data = state
|
||||||
|
.get("/api/v1/playlist/category", ¶ms)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string());
|
||||||
|
Ok(ApiResponse::success(data?))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[auto_collect_command]
|
||||||
|
pub async fn playlist_detail(
|
||||||
|
state: State<'_, HttpClient>,
|
||||||
|
id: String,
|
||||||
|
source: String,
|
||||||
|
) -> Result<ApiResponse<serde_json::Value>, String> {
|
||||||
|
let params: Vec<(String, String)> =
|
||||||
|
vec![("id".to_string(), id), ("source".to_string(), source)];
|
||||||
|
let data = state
|
||||||
|
.get("/api/v1/playlist/detail", ¶ms)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string());
|
||||||
|
Ok(ApiResponse::success(data?))
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[auto_collect_command]
|
#[auto_collect_command]
|
||||||
pub async fn playlist_recommand(
|
pub async fn playlist_recommand(
|
||||||
|
|
@ -21,3 +76,23 @@ pub async fn playlist_recommand(
|
||||||
|
|
||||||
Ok(ApiResponse::success(data?))
|
Ok(ApiResponse::success(data?))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[auto_collect_command]
|
||||||
|
pub async fn playlist_user(
|
||||||
|
state: State<'_, HttpClient>,
|
||||||
|
source: String,
|
||||||
|
page: u32,
|
||||||
|
limit: u32,
|
||||||
|
) -> Result<ApiResponse<serde_json::Value>, String> {
|
||||||
|
let params: Vec<(String, String)> = vec![
|
||||||
|
("source".to_string(), source),
|
||||||
|
("page".to_string(), page.to_string()),
|
||||||
|
("limit".to_string(), limit.to_string()),
|
||||||
|
];
|
||||||
|
let data = state
|
||||||
|
.get("/api/v1/playlist/user", ¶ms)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string());
|
||||||
|
Ok(ApiResponse::success(data?))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { get } from "@/utils/request"
|
import Source, { ApiResponse } from "@/types/global"
|
||||||
import Source from "@/types/global"
|
import { invoke } from "@tauri-apps/api/core"
|
||||||
|
|
||||||
const AlbumApi = {
|
const AlbumApi = {
|
||||||
/**
|
/**
|
||||||
|
|
@ -10,7 +10,8 @@ const AlbumApi = {
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
detail: (id: string, source: Source) => {
|
detail: (id: string, source: Source) => {
|
||||||
return get<any>(`/api/v1/album/detail?id=${id}&source=${source}`)
|
// return get<any>(`/api/v1/album/detail?id=${id}&source=${source}`)
|
||||||
|
return invoke<ApiResponse<any>>("album_detail", { id, source })
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { get } from "@/utils/request"
|
|
||||||
import Source, { ApiResponse } from "@/types/global"
|
import Source, { ApiResponse } from "@/types/global"
|
||||||
import { invoke } from "@tauri-apps/api/core"
|
import { invoke } from "@tauri-apps/api/core"
|
||||||
|
|
||||||
|
|
@ -72,7 +71,8 @@ const MusicApi = {
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
cover: (url: string, name?: string, artist?: string) => {
|
cover: (url: string, name?: string, artist?: string) => {
|
||||||
return get<any>(`/api/v1/music/cover?url=${url}&name=${name}&artist=${artist}`)
|
// return get<any>(`/api/v1/music/cover?url=${url}&name=${name}&artist=${artist}`)
|
||||||
|
return invoke<ApiResponse<any>>("music_cover", { url, name, artist })
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* 探测音频大小与码率
|
* 探测音频大小与码率
|
||||||
|
|
@ -82,7 +82,8 @@ const MusicApi = {
|
||||||
* @param duration 音乐时长(秒),提供可精确预估码率(kbps)
|
* @param duration 音乐时长(秒),提供可精确预估码率(kbps)
|
||||||
*/
|
*/
|
||||||
inspect: (id: string, source: Source, duration?: string) => {
|
inspect: (id: string, source: Source, duration?: string) => {
|
||||||
return get<any>(`/api/v1/music/inspect?id=${id}&source=${source}&duration=${duration}`)
|
// return get<any>(`/api/v1/music/inspect?id=${id}&source=${source}&duration=${duration}`)
|
||||||
|
return invoke<ApiResponse<any>>("music_inspect", { id, source, duration })
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* 获取 JSON 格式歌词
|
* 获取 JSON 格式歌词
|
||||||
|
|
@ -92,7 +93,8 @@ const MusicApi = {
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
lyric: (id: string, source: Source) => {
|
lyric: (id: string, source: Source) => {
|
||||||
return get<any>(`/api/v1/music/lyric?id=${id}&source=${source}`)
|
// return get<any>(`/api/v1/music/lyric?id=${id}&source=${source}`)
|
||||||
|
return invoke<ApiResponse<any>>("music_lyric", { id, source })
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* 下载 LRC 歌词文件
|
* 下载 LRC 歌词文件
|
||||||
|
|
@ -104,7 +106,8 @@ const MusicApi = {
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
lyricFile: (id: string, source: Source, name?: string, artist?: string) => {
|
lyricFile: (id: string, source: Source, name?: string, artist?: string) => {
|
||||||
return get<any>(`/api/v1/music/lyric/file?id=${id}&source=${source}&name=${name}&artist=${artist}`)
|
// return get<any>(`/api/v1/music/lyric/file?id=${id}&source=${source}&name=${name}&artist=${artist}`)
|
||||||
|
return invoke<ApiResponse<any>>("music_lyric_file", { id, source, name, artist })
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* 综合搜索与链接解析
|
* 综合搜索与链接解析
|
||||||
|
|
@ -115,8 +118,9 @@ const MusicApi = {
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
search: (keyword: string, type: string, sources?: Source[]) => {
|
search: (keyword: string, type: string, sources?: Source[]) => {
|
||||||
const sourceParams = (sources ?? []).map((s) => `&sources=${encodeURIComponent(s)}`).join("")
|
// const sourceParams = (sources ?? []).map((s) => `&sources=${encodeURIComponent(s)}`).join("")
|
||||||
return get<MusicSearchResult>(`/api/v1/music/search?q=${encodeURIComponent(keyword)}&type=${type}${sourceParams}`)
|
// return get<MusicSearchResult>(`/api/v1/music/search?q=${encodeURIComponent(keyword)}&type=${type}${sourceParams}`)
|
||||||
|
return invoke<ApiResponse<MusicSearchResult>>("music_search", { keyword, type, sources })
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* 串流代理与下载音频
|
* 串流代理与下载音频
|
||||||
|
|
@ -128,7 +132,8 @@ const MusicApi = {
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
stream: (id: string, source: Source, name?: string, artist?: string) => {
|
stream: (id: string, source: Source, name?: string, artist?: string) => {
|
||||||
return get<any>(`/api/v1/music/stream?id=${id}&source=${source}&name=${name}&artist=${artist}`)
|
// return get<any>(`/api/v1/music/stream?id=${id}&source=${source}&name=${name}&artist=${artist}`)
|
||||||
|
return invoke<ApiResponse<any>>("music_stream", { id, source, name, artist })
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* 智能切换可用的平替音源
|
* 智能切换可用的平替音源
|
||||||
|
|
@ -141,7 +146,8 @@ const MusicApi = {
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
switch: (name: string, source: Source, artist?: string, target?: string, duration?: string) => {
|
switch: (name: string, source: Source, artist?: string, target?: string, duration?: string) => {
|
||||||
return get<any>(`/api/v1/music/switch?name=${name}&source=${source}&artist=${artist}&target=${target}&duration=${duration}`)
|
// return get<any>(`/api/v1/music/switch?name=${name}&source=${source}&artist=${artist}&target=${target}&duration=${duration}`)
|
||||||
|
return invoke<ApiResponse<any>>("music_switch", { name, source, artist, target, duration })
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* 获取音频裸直链
|
* 获取音频裸直链
|
||||||
|
|
@ -151,7 +157,8 @@ const MusicApi = {
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
url: (id: string, source: Source) => {
|
url: (id: string, source: Source) => {
|
||||||
return get<MusicUrl>(`/api/v1/music/url?id=${id}&source=${source}`)
|
// return get<MusicUrl>(`/api/v1/music/url?id=${id}&source=${source}`)
|
||||||
|
return invoke<ApiResponse<MusicUrl>>("music_url", { id, source })
|
||||||
},
|
},
|
||||||
|
|
||||||
musicUrl: (id: string, source: Source) => {
|
musicUrl: (id: string, source: Source) => {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { get } from "@/utils/request"
|
|
||||||
import Source, { type ApiResponse } from "@/types/global"
|
import Source, { type ApiResponse } from "@/types/global"
|
||||||
import { invoke } from "@tauri-apps/api/core"
|
import { invoke } from "@tauri-apps/api/core"
|
||||||
|
|
||||||
|
|
@ -101,8 +100,9 @@ const PlayListApi = {
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
categoryList: (sources?: Source[]) => {
|
categoryList: (sources?: Source[]) => {
|
||||||
const sourceParams = (sources ?? []).map((s) => `sources=${encodeURIComponent(s)}`).join("&")
|
// const sourceParams = (sources ?? []).map((s) => `sources=${encodeURIComponent(s)}`).join("&")
|
||||||
return get<PlayListCategoryRes[]>(`/api/v1/playlist/categories${sourceParams ? `?${sourceParams}` : ""}`)
|
// return get<PlayListCategoryRes[]>(`/api/v1/playlist/categories${sourceParams ? `?${sourceParams}` : ""}`)
|
||||||
|
return invoke<ApiResponse<PlayListCategoryRes[]>>("playlist_categories", { sources })
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* 获取分类歌单
|
* 获取分类歌单
|
||||||
|
|
@ -114,7 +114,8 @@ const PlayListApi = {
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
categoryPlayList: (categoryId: string, source?: Source, page?: number, limit?: number) => {
|
categoryPlayList: (categoryId: string, source?: Source, page?: number, limit?: number) => {
|
||||||
return get<CategoryPlayListRes>(`/api/v1/playlist/category?source=${source}&category_id=${categoryId}&page=${page}&limit=${limit}`)
|
// return get<CategoryPlayListRes>(`/api/v1/playlist/category?source=${source}&category_id=${categoryId}&page=${page}&limit=${limit}`)
|
||||||
|
return invoke<ApiResponse<CategoryPlayListRes>>("playlist_category_list", { categoryId, source, page, limit })
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* 获取歌单详情
|
* 获取歌单详情
|
||||||
|
|
@ -123,7 +124,8 @@ const PlayListApi = {
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
detail: (source: Source, id: string) => {
|
detail: (source: Source, id: string) => {
|
||||||
return get<PlayListSong[]>(`/api/v1/playlist/detail?source=${source}&id=${id}`)
|
// return get<PlayListSong[]>(`/api/v1/playlist/detail?source=${source}&id=${id}`)
|
||||||
|
return invoke<ApiResponse<PlayListSong[]>>("playlist_detail", { source, id })
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
After Width: | Height: | Size: 916 KiB |
|
After Width: | Height: | Size: 658 KiB |
|
|
@ -2,8 +2,9 @@
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<span class="brand-icon"><AppIcon name="disc" :size="21" /></span>
|
<!-- <span class="brand-icon"><AppIcon name="disc" :size="21" /></span> -->
|
||||||
<span class="brand-name">YwYMusic</span>
|
<img class="brand-logo" src="/src/assets/logo.png" alt="YwYMusic logo" />
|
||||||
|
<!-- <span class="brand-name">YwYMusic</span> -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="nav" aria-label="主导航">
|
<nav class="nav" aria-label="主导航">
|
||||||
|
|
@ -20,7 +21,19 @@
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="sidebar-footer">v0.1.0 · 轻量在线播放</div>
|
<nav class="nav-settings" aria-label="设置">
|
||||||
|
<RouterLink
|
||||||
|
to="/settings"
|
||||||
|
title="设置"
|
||||||
|
class="nav-item"
|
||||||
|
:class="{ active: isActive('/settings') }"
|
||||||
|
>
|
||||||
|
<AppIcon name="settings" :size="18" />
|
||||||
|
<span>设置</span>
|
||||||
|
</RouterLink>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- <div class="sidebar-footer">v0.1.0 · 轻量在线播放</div> -->
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div class="main-area">
|
<div class="main-area">
|
||||||
|
|
@ -46,7 +59,6 @@ const navItems = [
|
||||||
{ path: "/", label: "歌单", icon: "home" },
|
{ path: "/", label: "歌单", icon: "home" },
|
||||||
{ path: "/ranking", label: "排行榜", icon: "trophy" },
|
{ path: "/ranking", label: "排行榜", icon: "trophy" },
|
||||||
{ path: "/library", label: "我的音乐", icon: "music" },
|
{ path: "/library", label: "我的音乐", icon: "music" },
|
||||||
{ path: "/settings", label: "设置", icon: "settings" },
|
|
||||||
{ path: "/demo", label: "演示", icon: "play" },
|
{ path: "/demo", label: "演示", icon: "play" },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
|
@ -74,7 +86,7 @@ watch(
|
||||||
.sidebar {
|
.sidebar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: clamp(200px, 16vw, 232px);
|
width: clamp(168px, 13vw, 196px);
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 20px 14px 14px;
|
padding: 20px 14px 14px;
|
||||||
|
|
@ -84,8 +96,9 @@ watch(
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 6px;
|
||||||
padding: 0 10px 18px;
|
padding: 0 10px 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,6 +113,14 @@ watch(
|
||||||
box-shadow: 0 6px 14px rgba(34, 197, 94, 0.35);
|
box-shadow: 0 6px 14px rgba(34, 197, 94, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brand-logo {
|
||||||
|
display: block;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
object-fit: contain;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.brand-name {
|
.brand-name {
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|
@ -112,6 +133,15 @@ watch(
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-settings {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--app-border);
|
||||||
|
}
|
||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -236,6 +266,11 @@ watch(
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-settings {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 10px 0;
|
padding: 10px 0;
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ let loadSeq = 0
|
||||||
/** 获取歌单分类 */
|
/** 获取歌单分类 */
|
||||||
const list = async () => {
|
const list = async () => {
|
||||||
const res = await PlayListApi.categoryList([settingsStore.defaultSource])
|
const res = await PlayListApi.categoryList([settingsStore.defaultSource])
|
||||||
categories.value = res[0].categories
|
categories.value = res.data[0].categories
|
||||||
if (categories.value.length > 0) {
|
if (categories.value.length > 0) {
|
||||||
activeCategory.value = categories.value[1].id
|
activeCategory.value = categories.value[1].id
|
||||||
playList(activeCategory.value)
|
playList(activeCategory.value)
|
||||||
|
|
@ -75,7 +75,7 @@ async function loadCategoryPlayList(categoryId: string, p: number) {
|
||||||
const res = await PlayListApi.categoryPlayList(categoryId, settingsStore.defaultSource, p, pageSize)
|
const res = await PlayListApi.categoryPlayList(categoryId, settingsStore.defaultSource, p, pageSize)
|
||||||
console.log(res)
|
console.log(res)
|
||||||
if (seq !== loadSeq) return
|
if (seq !== loadSeq) return
|
||||||
categoryPlaylists.value = res.playlists
|
categoryPlaylists.value = res.data.playlists
|
||||||
const n = categoryPlaylists.value.length
|
const n = categoryPlaylists.value.length
|
||||||
if (n === pageSize) {
|
if (n === pageSize) {
|
||||||
// 满页:推测还有下一页
|
// 满页:推测还有下一页
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ const playlistSource = computed<Source>(
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await PlayListApi.detail(playlistSource.value, props.id)
|
const res = await PlayListApi.detail(playlistSource.value, props.id)
|
||||||
songs.value = res.map((song) => ({
|
songs.value = res.data.map((song) => ({
|
||||||
id: song.id,
|
id: song.id,
|
||||||
title: song.name,
|
title: song.name,
|
||||||
artist: song.artist,
|
artist: song.artist,
|
||||||
|
|
@ -113,7 +113,8 @@ function playAll() {
|
||||||
async function playWithUrl(song: Song, queue?: Song[]) {
|
async function playWithUrl(song: Song, queue?: Song[]) {
|
||||||
try {
|
try {
|
||||||
const source = (song.source ?? playlistSource.value) as Source
|
const source = (song.source ?? playlistSource.value) as Source
|
||||||
const { url } = await MusicApi.url(String(song.id), source)
|
const res = await MusicApi.url(String(song.id), source)
|
||||||
|
const url = res.data.url
|
||||||
playerStore.playSong(song, queue, url)
|
playerStore.playSong(song, queue, url)
|
||||||
} catch {
|
} catch {
|
||||||
// 拿不到直链时仍切歌,后续可尝试 MusicApi.stream 代理播放
|
// 拿不到直链时仍切歌,后续可尝试 MusicApi.stream 代理播放
|
||||||
|
|
|
||||||
|
|
@ -136,12 +136,12 @@ async function doSearch(q: string) {
|
||||||
])
|
])
|
||||||
if (seq !== requestSeq) return
|
if (seq !== requestSeq) return
|
||||||
if (songRes.status === "fulfilled") {
|
if (songRes.status === "fulfilled") {
|
||||||
songResults.value = (songRes.value.songs ?? []).map(toSong)
|
songResults.value = (songRes.value.data.songs ?? []).map(toSong)
|
||||||
} else {
|
} else {
|
||||||
songResults.value = []
|
songResults.value = []
|
||||||
}
|
}
|
||||||
if (playlistRes.status === "fulfilled") {
|
if (playlistRes.status === "fulfilled") {
|
||||||
playlistResults.value = (playlistRes.value.playlists ?? []).map(toPlaylist)
|
playlistResults.value = (playlistRes.value.data.playlists ?? []).map(toPlaylist)
|
||||||
} else {
|
} else {
|
||||||
playlistResults.value = []
|
playlistResults.value = []
|
||||||
}
|
}
|
||||||
|
|
@ -179,8 +179,8 @@ async function playSearchSong(song: Song) {
|
||||||
try {
|
try {
|
||||||
// const res = await MusicApi.musicUrl(String(song.id), source)
|
// const res = await MusicApi.musicUrl(String(song.id), source)
|
||||||
// const url = "http://127.0.0.1:81" + res.data[0].url
|
// const url = "http://127.0.0.1:81" + res.data[0].url
|
||||||
const { url } = await MusicApi.url(String(song.id), source)
|
const res = await MusicApi.url(String(song.id), source)
|
||||||
// const url = "http://127.0.0.1:81" + res.data[0].url
|
const url = res.data.url
|
||||||
console.log("url: ", url)
|
console.log("url: ", url)
|
||||||
playerStore.playSong(song, songResults.value, url)
|
playerStore.playSong(song, songResults.value, url)
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||