feat: 添加音乐播放和歌单推荐功能
- 新增music_url命令用于获取音乐直链 - 实现playlist_recommand命令支持歌单推荐 - 添加HTTP客户端服务用于API请求 - 集成serde_urlencoded支持查询参数编码 - 扩展PlayerBar组件功能并优化UI - 调整窗口尺寸适配新功能界面
This commit is contained in:
parent
605d025d12
commit
4ba4338502
|
|
@ -3367,6 +3367,7 @@ dependencies = [
|
||||||
"rustls-platform-verifier",
|
"rustls-platform-verifier",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"serde_urlencoded",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
|
|
@ -3504,6 +3505,12 @@ version = "1.0.23"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ryu"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "same-file"
|
name = "same-file"
|
||||||
version = "1.0.6"
|
version = "1.0.6"
|
||||||
|
|
@ -3726,6 +3733,18 @@ dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_urlencoded"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||||
|
dependencies = [
|
||||||
|
"form_urlencoded",
|
||||||
|
"itoa",
|
||||||
|
"ryu",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_with"
|
name = "serde_with"
|
||||||
version = "3.22.0"
|
version = "3.22.0"
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ tauri = { version = "2", features = [] }
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
reqwest = { version = "0.13.4", features = ["json"] }
|
reqwest = { version = "0.13.4", features = ["json", "query"] }
|
||||||
tokio = { version = "1.53.1", features = ["full"] }
|
tokio = { version = "1.53.1", features = ["full"] }
|
||||||
tauri-plugin-store = "2"
|
tauri-plugin-store = "2"
|
||||||
tauri-helper = "0.2.1"
|
tauri-helper = "0.2.1"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
use tauri::State;
|
||||||
|
use tauri_helper::auto_collect_command;
|
||||||
|
|
||||||
|
use crate::models::METING_API_URL;
|
||||||
|
use crate::{
|
||||||
|
models::ApiResponse,
|
||||||
|
services::http::{ApiEndpoint, HttpClient},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[auto_collect_command]
|
||||||
|
pub async fn music_url(
|
||||||
|
state: State<'_, HttpClient>,
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,23 @@
|
||||||
use reqwest;
|
use tauri::State;
|
||||||
use tauri_helper::auto_collect_command;
|
use tauri_helper::auto_collect_command;
|
||||||
|
|
||||||
|
use crate::{models::ApiResponse, services::http::HttpClient};
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[auto_collect_command]
|
#[auto_collect_command]
|
||||||
pub async fn playlist_recommand() -> Result<String, String> {
|
pub async fn playlist_recommand(
|
||||||
Ok("playlist_recommand".to_string())
|
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/recommend", ¶ms)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string());
|
||||||
|
|
||||||
|
Ok(ApiResponse::success(data?))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
mod commands;
|
mod commands;
|
||||||
mod services;
|
mod services;
|
||||||
|
mod models;
|
||||||
|
|
||||||
use commands::album::*;
|
use commands::album::*;
|
||||||
use commands::music::*;
|
use commands::music::*;
|
||||||
use commands::playlist::*;
|
use commands::playlist::*;
|
||||||
use services::db::init_pool;
|
use services::db::init_pool;
|
||||||
|
use services::http::HttpClient;
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
|
@ -16,6 +18,7 @@ pub fn run() {
|
||||||
init_pool().await.expect("sqlite数据库连接池初始化失败!")
|
init_pool().await.expect("sqlite数据库连接池初始化失败!")
|
||||||
});
|
});
|
||||||
app.manage(pool);
|
app.manage(pool);
|
||||||
|
app.manage(HttpClient::default());
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.plugin(tauri_plugin_store::Builder::default().build())
|
.plugin(tauri_plugin_store::Builder::default().build())
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// 音乐聚合后端基础地址
|
||||||
|
pub const GO_MUSIC_URL: &str = "http://127.0.0.1:8080";
|
||||||
|
pub const METING_API_URL: &str = "http://127.0.0.1:81";
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct PageResult<T> {
|
||||||
|
pub total: i64,
|
||||||
|
pub list: Vec<T>,
|
||||||
|
pub page: i32,
|
||||||
|
pub page_size: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Go_Music_Api 标准响应结构:`{ code: number, msg: string, data: T }`
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct GoMusicApiResponse<T> {
|
||||||
|
pub code: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub msg: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub data: Option<T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ApiResponse<T> {
|
||||||
|
success: bool,
|
||||||
|
code: i32,
|
||||||
|
msg: String,
|
||||||
|
data: Option<T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> ApiResponse<T> {
|
||||||
|
// 成功响应
|
||||||
|
pub fn success(data: T) -> Self {
|
||||||
|
Self {
|
||||||
|
success: true,
|
||||||
|
code: 200,
|
||||||
|
msg: "操作成功".to_string(),
|
||||||
|
data: Some(data),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 成功响应,数据为空
|
||||||
|
pub fn success_empty() -> ApiResponse<()> {
|
||||||
|
ApiResponse {
|
||||||
|
success: true,
|
||||||
|
code: 200,
|
||||||
|
msg: "操作成功".to_string(),
|
||||||
|
data: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 错误响应
|
||||||
|
pub fn error(code: i32, message: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
success: false,
|
||||||
|
code,
|
||||||
|
msg: message.to_string(),
|
||||||
|
data: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,515 @@
|
||||||
|
//! 基于 reqwest 的 HTTP 请求工具。
|
||||||
|
//!
|
||||||
|
//! 目标是与前端 `src/utils/request.ts` 的行为保持一致:
|
||||||
|
//! - 统一 base URL 与超时时间
|
||||||
|
//! - 自动携带 `Authorization: Bearer <token>`
|
||||||
|
//! - 统一解包后端返回结构 `{ code, msg, data }`,`code == 200` 时直接返回 `data`
|
||||||
|
//! - 同时提供原始字节 / 文本接口,用于封面图、音频流、歌词文件下载等非 JSON 场景
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
use std::sync::RwLock;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use reqwest::header::{HeaderMap, HeaderValue, ACCEPT};
|
||||||
|
use reqwest::{Method, StatusCode};
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::models::GO_MUSIC_URL;
|
||||||
|
use crate::models::METING_API_URL;
|
||||||
|
|
||||||
|
/// 默认超时时间(秒),与前端 axios 的 `timeout: 15000` 一致。
|
||||||
|
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||||
|
|
||||||
|
/// 默认 User-Agent。
|
||||||
|
pub const DEFAULT_USER_AGENT: &str = "YwYMusic/0.1";
|
||||||
|
|
||||||
|
/// 业务成功状态码,与前端 `SUCCESS_CODE` 一致。
|
||||||
|
const SUCCESS_CODE: i64 = 200;
|
||||||
|
|
||||||
|
/// 请求使用的 API 地址。
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum ApiEndpoint {
|
||||||
|
/// 使用 `HttpClient` 配置的地址,默认是 `GO_MUSIC_URL`。
|
||||||
|
GoMusic,
|
||||||
|
/// 使用 `METING_API_URL`。
|
||||||
|
Meting,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTTP 请求过程中可能产生的错误。
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum ApiError {
|
||||||
|
/// 创建 `reqwest::Client` 失败。
|
||||||
|
Build(reqwest::Error),
|
||||||
|
/// 请求地址无效。
|
||||||
|
InvalidUrl(String),
|
||||||
|
/// 网络请求失败。
|
||||||
|
Request(reqwest::Error),
|
||||||
|
/// 服务端返回非成功 HTTP 状态码。
|
||||||
|
HttpStatus { status: StatusCode, body: String },
|
||||||
|
/// 业务码非 200。
|
||||||
|
Business { code: i64, msg: String },
|
||||||
|
/// 响应体解析失败。
|
||||||
|
Decode { body: String, source: serde_json::Error },
|
||||||
|
/// 响应不是标准 `{ code, msg, data }` 结构。
|
||||||
|
Unexpected { body: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for ApiError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
ApiError::Build(e) => write!(f, "创建 HTTP 客户端失败: {e}"),
|
||||||
|
ApiError::InvalidUrl(e) => write!(f, "无效的请求地址: {e}"),
|
||||||
|
ApiError::Request(e) => write!(f, "网络请求失败: {e}"),
|
||||||
|
ApiError::HttpStatus { status, body } => {
|
||||||
|
write!(f, "HTTP {status}: {body}")
|
||||||
|
}
|
||||||
|
ApiError::Business { code, msg } => write!(f, "业务错误[{code}]: {msg}"),
|
||||||
|
ApiError::Decode { body, source } => {
|
||||||
|
let preview: String = body.chars().take(300).collect();
|
||||||
|
write!(f, "响应解析失败: {source}; body: {preview}")
|
||||||
|
}
|
||||||
|
ApiError::Unexpected { body } => write!(f, "响应格式异常: {body}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for ApiError {
|
||||||
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||||
|
match self {
|
||||||
|
ApiError::Build(e) | ApiError::Request(e) => Some(e),
|
||||||
|
ApiError::Decode { source, .. } => Some(source),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<reqwest::Error> for ApiError {
|
||||||
|
fn from(e: reqwest::Error) -> Self {
|
||||||
|
ApiError::Request(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 共享的 HTTP 客户端。
|
||||||
|
///
|
||||||
|
/// `reqwest::Client` 内部维护连接池,建议整个应用只创建一次并通过 Tauri 状态共享。
|
||||||
|
pub struct HttpClient {
|
||||||
|
client: reqwest::Client,
|
||||||
|
base_url: String,
|
||||||
|
token: RwLock<Option<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for HttpClient {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::builder()
|
||||||
|
.build()
|
||||||
|
.expect("默认 HTTP 客户端构建失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpClient {
|
||||||
|
/// 使用指定 base URL 创建客户端。
|
||||||
|
pub fn new(base_url: impl Into<String>) -> Result<Self, ApiError> {
|
||||||
|
Self::builder().base_url(base_url).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn builder() -> HttpClientBuilder {
|
||||||
|
HttpClientBuilder::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn base_url(&self) -> &str {
|
||||||
|
&self.base_url
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新鉴权 token,`None` 表示清除。
|
||||||
|
pub fn set_token(&self, token: Option<String>) {
|
||||||
|
*self.token.write().expect("token 锁已污染") = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn token(&self) -> Option<String> {
|
||||||
|
self.token.read().expect("token 锁已污染").clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET 请求并解包标准业务结构。
|
||||||
|
pub async fn get<T>(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
query: &[(String, String)],
|
||||||
|
) -> Result<T, ApiError>
|
||||||
|
where
|
||||||
|
T: DeserializeOwned,
|
||||||
|
{
|
||||||
|
self.get_at(ApiEndpoint::GoMusic, path, query).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET 请求并解包标准业务结构,可在单次调用时选择 API 地址。
|
||||||
|
pub async fn get_at<T>(
|
||||||
|
&self,
|
||||||
|
endpoint: ApiEndpoint,
|
||||||
|
path: &str,
|
||||||
|
query: &[(String, String)],
|
||||||
|
) -> Result<T, ApiError>
|
||||||
|
where
|
||||||
|
T: DeserializeOwned,
|
||||||
|
{
|
||||||
|
self.request_at::<T, serde_json::Value>(endpoint, Method::GET, path, query, None)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DELETE 请求并解包标准业务结构。
|
||||||
|
pub async fn delete<T>(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
query: &[(String, String)],
|
||||||
|
) -> Result<T, ApiError>
|
||||||
|
where
|
||||||
|
T: DeserializeOwned,
|
||||||
|
{
|
||||||
|
self.request_at::<T, serde_json::Value>(
|
||||||
|
ApiEndpoint::GoMusic,
|
||||||
|
Method::DELETE,
|
||||||
|
path,
|
||||||
|
query,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DELETE 请求并解包标准业务结构,可在单次调用时选择 API 地址。
|
||||||
|
pub async fn delete_at<T>(
|
||||||
|
&self,
|
||||||
|
endpoint: ApiEndpoint,
|
||||||
|
path: &str,
|
||||||
|
query: &[(String, String)],
|
||||||
|
) -> Result<T, ApiError>
|
||||||
|
where
|
||||||
|
T: DeserializeOwned,
|
||||||
|
{
|
||||||
|
self.request_at::<T, serde_json::Value>(endpoint, Method::DELETE, path, query, None)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST 请求(JSON 请求体)并解包标准业务结构。
|
||||||
|
pub async fn post<T, B>(&self, path: &str, body: &B) -> Result<T, ApiError>
|
||||||
|
where
|
||||||
|
T: DeserializeOwned,
|
||||||
|
B: Serialize + ?Sized,
|
||||||
|
{
|
||||||
|
self.request_at(ApiEndpoint::GoMusic, Method::POST, path, &[], Some(body))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST 请求(JSON 请求体),可在单次调用时选择 API 地址。
|
||||||
|
pub async fn post_at<T, B>(
|
||||||
|
&self,
|
||||||
|
endpoint: ApiEndpoint,
|
||||||
|
path: &str,
|
||||||
|
body: &B,
|
||||||
|
) -> Result<T, ApiError>
|
||||||
|
where
|
||||||
|
T: DeserializeOwned,
|
||||||
|
B: Serialize + ?Sized,
|
||||||
|
{
|
||||||
|
self.request_at(endpoint, Method::POST, path, &[], Some(body))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST 请求(无请求体)并解包标准业务结构。
|
||||||
|
pub async fn post_empty<T>(&self, path: &str) -> Result<T, ApiError>
|
||||||
|
where
|
||||||
|
T: DeserializeOwned,
|
||||||
|
{
|
||||||
|
self.request_at::<T, serde_json::Value>(
|
||||||
|
ApiEndpoint::GoMusic,
|
||||||
|
Method::POST,
|
||||||
|
path,
|
||||||
|
&[],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST 请求(无请求体),可在单次调用时选择 API 地址。
|
||||||
|
pub async fn post_empty_at<T>(&self, endpoint: ApiEndpoint, path: &str) -> Result<T, ApiError>
|
||||||
|
where
|
||||||
|
T: DeserializeOwned,
|
||||||
|
{
|
||||||
|
self.request_at::<T, serde_json::Value>(endpoint, Method::POST, path, &[], None)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUT 请求(JSON 请求体)并解包标准业务结构。
|
||||||
|
pub async fn put<T, B>(&self, path: &str, body: &B) -> Result<T, ApiError>
|
||||||
|
where
|
||||||
|
T: DeserializeOwned,
|
||||||
|
B: Serialize + ?Sized,
|
||||||
|
{
|
||||||
|
self.request_at(ApiEndpoint::GoMusic, Method::PUT, path, &[], Some(body))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUT 请求(JSON 请求体),可在单次调用时选择 API 地址。
|
||||||
|
pub async fn put_at<T, B>(
|
||||||
|
&self,
|
||||||
|
endpoint: ApiEndpoint,
|
||||||
|
path: &str,
|
||||||
|
body: &B,
|
||||||
|
) -> Result<T, ApiError>
|
||||||
|
where
|
||||||
|
T: DeserializeOwned,
|
||||||
|
B: Serialize + ?Sized,
|
||||||
|
{
|
||||||
|
self.request_at(endpoint, Method::PUT, path, &[], Some(body))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取原始字节(封面图、音频流等非 JSON 场景)。
|
||||||
|
pub async fn get_bytes(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
query: &[(String, String)],
|
||||||
|
) -> Result<Vec<u8>, ApiError> {
|
||||||
|
self.get_bytes_at(ApiEndpoint::GoMusic, path, query).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取原始字节,可在单次调用时选择 API 地址。
|
||||||
|
pub async fn get_bytes_at(
|
||||||
|
&self,
|
||||||
|
endpoint: ApiEndpoint,
|
||||||
|
path: &str,
|
||||||
|
query: &[(String, String)],
|
||||||
|
) -> Result<Vec<u8>, ApiError> {
|
||||||
|
let response = self.build(endpoint, Method::GET, path, query)?.send().await?;
|
||||||
|
let status = response.status();
|
||||||
|
let bytes = response.bytes().await?;
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(ApiError::HttpStatus {
|
||||||
|
status,
|
||||||
|
body: String::from_utf8_lossy(&bytes).into_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(bytes.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取原始文本(歌词文件等非 JSON 场景)。
|
||||||
|
pub async fn get_text(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
query: &[(String, String)],
|
||||||
|
) -> Result<String, ApiError> {
|
||||||
|
self.get_text_at(ApiEndpoint::GoMusic, path, query).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取原始文本,可在单次调用时选择 API 地址。
|
||||||
|
pub async fn get_text_at(
|
||||||
|
&self,
|
||||||
|
endpoint: ApiEndpoint,
|
||||||
|
path: &str,
|
||||||
|
query: &[(String, String)],
|
||||||
|
) -> Result<String, ApiError> {
|
||||||
|
let response = self.build(endpoint, Method::GET, path, query)?.send().await?;
|
||||||
|
let status = response.status();
|
||||||
|
let text = response.text().await?;
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(ApiError::HttpStatus { status, body: text });
|
||||||
|
}
|
||||||
|
Ok(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 底层通用请求,`body` 为 `None` 时不发送请求体。
|
||||||
|
pub async fn request<T, B>(
|
||||||
|
&self,
|
||||||
|
method: Method,
|
||||||
|
path: &str,
|
||||||
|
query: &[(String, String)],
|
||||||
|
body: Option<&B>,
|
||||||
|
) -> Result<T, ApiError>
|
||||||
|
where
|
||||||
|
T: DeserializeOwned,
|
||||||
|
B: Serialize + ?Sized,
|
||||||
|
{
|
||||||
|
self.request_at(ApiEndpoint::GoMusic, method, path, query, body)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 底层通用请求,可在单次调用时选择 API 地址。
|
||||||
|
pub async fn request_at<T, B>(
|
||||||
|
&self,
|
||||||
|
endpoint: ApiEndpoint,
|
||||||
|
method: Method,
|
||||||
|
path: &str,
|
||||||
|
query: &[(String, String)],
|
||||||
|
body: Option<&B>,
|
||||||
|
) -> Result<T, ApiError>
|
||||||
|
where
|
||||||
|
T: DeserializeOwned,
|
||||||
|
B: Serialize + ?Sized,
|
||||||
|
{
|
||||||
|
let mut builder = self.build(endpoint, method, path, query)?;
|
||||||
|
if let Some(body) = body {
|
||||||
|
builder = builder.json(body);
|
||||||
|
}
|
||||||
|
let response = builder.send().await?;
|
||||||
|
unwrap_json(response).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build(
|
||||||
|
&self,
|
||||||
|
endpoint: ApiEndpoint,
|
||||||
|
method: Method,
|
||||||
|
path: &str,
|
||||||
|
query: &[(String, String)],
|
||||||
|
) -> Result<reqwest::RequestBuilder, ApiError> {
|
||||||
|
let url = self.join_url(endpoint, path)?;
|
||||||
|
let mut builder = self.client.request(method, &url);
|
||||||
|
|
||||||
|
if let Some(token) = self.token() {
|
||||||
|
builder = builder.bearer_auth(token);
|
||||||
|
}
|
||||||
|
if !query.is_empty() {
|
||||||
|
builder = builder.query(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(builder)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn join_url(&self, endpoint: ApiEndpoint, path: &str) -> Result<String, ApiError> {
|
||||||
|
let base_url = match endpoint {
|
||||||
|
ApiEndpoint::GoMusic => &self.base_url,
|
||||||
|
ApiEndpoint::Meting => METING_API_URL,
|
||||||
|
};
|
||||||
|
let base = base_url.trim_end_matches('/');
|
||||||
|
let url = format!("{base}{path}");
|
||||||
|
reqwest::Url::parse(&url).map_err(|e| ApiError::InvalidUrl(e.to_string()))?;
|
||||||
|
Ok(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `HttpClient` 构建器。
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct HttpClientBuilder {
|
||||||
|
base_url: String,
|
||||||
|
timeout: Duration,
|
||||||
|
user_agent: String,
|
||||||
|
token: Option<String>,
|
||||||
|
default_headers: HeaderMap,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for HttpClientBuilder {
|
||||||
|
fn default() -> Self {
|
||||||
|
let mut default_headers = HeaderMap::new();
|
||||||
|
default_headers.insert(
|
||||||
|
ACCEPT,
|
||||||
|
HeaderValue::from_static("application/json, text/plain, */*"),
|
||||||
|
);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
base_url: GO_MUSIC_URL.to_string(),
|
||||||
|
timeout: DEFAULT_TIMEOUT,
|
||||||
|
user_agent: DEFAULT_USER_AGENT.to_string(),
|
||||||
|
token: None,
|
||||||
|
default_headers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpClientBuilder {
|
||||||
|
pub fn base_url(mut self, value: impl Into<String>) -> Self {
|
||||||
|
self.base_url = value.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn timeout(mut self, value: Duration) -> Self {
|
||||||
|
self.timeout = value;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn user_agent(mut self, value: impl Into<String>) -> Self {
|
||||||
|
self.user_agent = value.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn token(mut self, value: impl Into<String>) -> Self {
|
||||||
|
self.token = Some(value.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn header(
|
||||||
|
mut self,
|
||||||
|
name: reqwest::header::HeaderName,
|
||||||
|
value: HeaderValue,
|
||||||
|
) -> Self {
|
||||||
|
self.default_headers.insert(name, value);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build(self) -> Result<HttpClient, ApiError> {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.user_agent(&self.user_agent)
|
||||||
|
.timeout(self.timeout)
|
||||||
|
.default_headers(self.default_headers)
|
||||||
|
.build()
|
||||||
|
.map_err(ApiError::Build)?;
|
||||||
|
|
||||||
|
Ok(HttpClient {
|
||||||
|
client,
|
||||||
|
base_url: self.base_url,
|
||||||
|
token: RwLock::new(self.token),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解包标准业务响应结构 `{ code, msg, data }`。
|
||||||
|
async fn unwrap_json<T: DeserializeOwned>(
|
||||||
|
response: reqwest::Response,
|
||||||
|
) -> Result<T, ApiError> {
|
||||||
|
let status = response.status();
|
||||||
|
let bytes = response.bytes().await?;
|
||||||
|
let body = String::from_utf8_lossy(&bytes).to_string();
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(ApiError::HttpStatus { status, body });
|
||||||
|
}
|
||||||
|
|
||||||
|
let value: serde_json::Value = serde_json::from_slice(&bytes).map_err(|source| {
|
||||||
|
ApiError::Decode {
|
||||||
|
body: body.clone(),
|
||||||
|
source,
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let obj = value
|
||||||
|
.as_object()
|
||||||
|
.ok_or_else(|| ApiError::Unexpected { body: body.clone() })?;
|
||||||
|
|
||||||
|
if !obj.contains_key("code") {
|
||||||
|
return Err(ApiError::Unexpected { body });
|
||||||
|
}
|
||||||
|
|
||||||
|
let code = code_of(&value).ok_or_else(|| ApiError::Unexpected { body: body.clone() })?;
|
||||||
|
|
||||||
|
if code == SUCCESS_CODE {
|
||||||
|
let data = obj
|
||||||
|
.get("data")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(serde_json::Value::Null);
|
||||||
|
return serde_json::from_value(data).map_err(|source| ApiError::Decode { body, source });
|
||||||
|
}
|
||||||
|
|
||||||
|
let msg = obj
|
||||||
|
.get("msg")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
Err(ApiError::Business { code, msg })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 兼容 `code` 为数字或字符串两种形式。
|
||||||
|
fn code_of(value: &serde_json::Value) -> Option<i64> {
|
||||||
|
match value.get("code") {
|
||||||
|
Some(serde_json::Value::Number(n)) => n.as_i64(),
|
||||||
|
Some(serde_json::Value::String(s)) => s.parse::<i64>().ok(),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
pub mod db;
|
pub mod db;
|
||||||
|
pub mod http;
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,8 @@
|
||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"title": "YwYMusic",
|
"title": "YwYMusic",
|
||||||
"width": 860,
|
"width": 1160,
|
||||||
"height": 600
|
"height": 660
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,14 @@
|
||||||
import { get } from "@/utils/request"
|
import { get } from "@/utils/request"
|
||||||
import Source from "@/types/global"
|
import Source, { ApiResponse } from "@/types/global"
|
||||||
|
import { invoke } from "@tauri-apps/api/core"
|
||||||
|
|
||||||
/** 音乐直链 */
|
/** 音乐直链 */
|
||||||
export interface MusicUrl {
|
export interface MusicUrl {
|
||||||
|
title: string
|
||||||
|
author: string
|
||||||
url: string
|
url: string
|
||||||
|
pic: string
|
||||||
|
lrc: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 搜索结果-单曲 */
|
/** 搜索结果-单曲 */
|
||||||
|
|
@ -148,6 +153,10 @@ const MusicApi = {
|
||||||
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}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
musicUrl: (id: string, source: Source) => {
|
||||||
|
return invoke<ApiResponse<MusicUrl[]>>("music_url", { id, server: source, type: "song" })
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export default MusicApi
|
export default MusicApi
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { get } from "@/utils/request"
|
import { get } from "@/utils/request"
|
||||||
import Source from "@/types/global"
|
import Source, { type ApiResponse } from "@/types/global"
|
||||||
|
import { invoke } from "@tauri-apps/api/core"
|
||||||
|
|
||||||
/** 歌单分类 */
|
/** 歌单分类 */
|
||||||
interface PlayListCategory {
|
interface PlayListCategory {
|
||||||
|
|
@ -28,8 +29,8 @@ interface PlayListSummary {
|
||||||
description: string
|
description: string
|
||||||
source: string
|
source: string
|
||||||
link: string
|
link: string
|
||||||
/** 平台相关的附加元数据,字段随 source 变化 */
|
/** 平台相关的附加元数据,字段随 source 变化(搜索结果可能缺失) */
|
||||||
extra: {
|
extra?: {
|
||||||
category_id: string
|
category_id: string
|
||||||
global_specialid: string
|
global_specialid: string
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -132,8 +133,9 @@ const PlayListApi = {
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
recommend: (sources?: Source[]) => {
|
recommend: (sources?: Source[]) => {
|
||||||
const sourceParams = (sources ?? []).map((s) => `sources=${encodeURIComponent(s)}`).join("&")
|
// const sourceParams = (sources ?? []).map((s) => `sources=${encodeURIComponent(s)}`).join("&")
|
||||||
return get<PlayListSummary[]>(`/api/v1/playlist/recommend${sourceParams ? `?${sourceParams}` : ""}`)
|
// return get<PlayListSummary[]>(`/api/v1/playlist/recommend${sourceParams ? `?${sourceParams}` : ""}`)
|
||||||
|
return invoke<ApiResponse<PlayListSummary[]>>("playlist_recommand", { sources })
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,41 +14,41 @@
|
||||||
</button>
|
</button>
|
||||||
<span class="play-count">
|
<span class="play-count">
|
||||||
<AppIcon name="headphones" :size="12" />
|
<AppIcon name="headphones" :size="12" />
|
||||||
{{ playlist.playCount }}
|
{{ formatPlayCount(playlist.play_count) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="name" :title="playlist.name">{{ playlist.name }}</p>
|
<p class="name" :title="playlist.name">{{ playlist.name }}</p>
|
||||||
<p class="desc" :title="playlist.desc">{{ playlist.desc }}</p>
|
<p class="desc" :title="playlist.description">{{ playlist.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
import { useMessage } from "naive-ui";
|
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import type { Playlist } from "@/mocks/music";
|
import { cachePlayListSummary, type PlayListSummary } from "@/api/playlist";
|
||||||
import { playSong } from "@/stores/player";
|
import { displayCover } from "@/utils/cover";
|
||||||
import { coverBackground } from "@/utils/cover";
|
import { formatPlayCount } from "@/utils/format";
|
||||||
import AppIcon from "./AppIcon.vue";
|
import AppIcon from "./AppIcon.vue";
|
||||||
|
|
||||||
const props = defineProps<{ playlist: Playlist }>();
|
const props = defineProps<{ playlist: PlayListSummary }>();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const message = useMessage();
|
|
||||||
|
|
||||||
/** 图片 URL 需要用 url() 包裹,渐变字符串则原样透传 */
|
/** 封面为平台 URL,走本地代理防防盗链;空值回退为渐变占位 */
|
||||||
const coverStyle = computed(() => ({
|
const coverStyle = computed(() => ({
|
||||||
background: coverBackground(props.playlist.cover),
|
background:
|
||||||
|
displayCover(props.playlist.cover) ||
|
||||||
|
"linear-gradient(135deg, #4338ca 0%, #7c3aed 100%)",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function openDetail() {
|
function openDetail() {
|
||||||
|
// 详情页依赖该缓存读取歌单元信息,跳转前先写入
|
||||||
|
cachePlayListSummary(props.playlist);
|
||||||
router.push(`/playlist/${props.playlist.id}`);
|
router.push(`/playlist/${props.playlist.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function play() {
|
function play() {
|
||||||
const songs = props.playlist.songs;
|
// 摘要不含歌曲列表,进入详情页后由「播放全部」触发播放
|
||||||
if (songs.length === 0) return;
|
openDetail();
|
||||||
playSong(songs[0], songs);
|
|
||||||
message.success(`正在播放:${songs[0].title}`);
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,45 +1,23 @@
|
||||||
<template>
|
<template>
|
||||||
<footer class="player-bar">
|
<footer class="player-bar">
|
||||||
<audio
|
<audio ref="audioRef" class="audio-hidden" preload="auto" @timeupdate="handleTimeUpdate" @ended="handleEnded" />
|
||||||
ref="audioRef"
|
|
||||||
class="audio-hidden"
|
|
||||||
preload="auto"
|
|
||||||
@timeupdate="handleTimeUpdate"
|
|
||||||
@ended="handleEnded"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="player-left">
|
<div class="player-left">
|
||||||
<div
|
<div class="cover-mini" :style="current ? { background: coverBackground(current.cover) } : undefined" @click="openFullscreen">
|
||||||
class="cover-mini"
|
|
||||||
:style="current ? { background: coverBackground(current.cover) } : undefined"
|
|
||||||
@click="openFullscreen"
|
|
||||||
>
|
|
||||||
<AppIcon name="music" :size="20" class="cover-mini-mark" />
|
<AppIcon name="music" :size="20" class="cover-mini-mark" />
|
||||||
</div>
|
</div>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<p class="song-title">{{ current?.title ?? "未在播放" }}</p>
|
<p class="song-title">{{ current?.title ?? "未在播放" }}</p>
|
||||||
<p class="song-artist">{{ current?.artist ?? "选择一首歌开始播放" }}</p>
|
<p class="song-artist">{{ current?.artist ?? "选择一首歌开始播放" }}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button class="icon-btn like-btn" type="button" :class="{ active: isLiked }" :aria-label="isLiked ? '取消喜欢' : '喜欢'" @click="toggleLike">
|
||||||
class="icon-btn like-btn"
|
|
||||||
type="button"
|
|
||||||
:class="{ active: isLiked }"
|
|
||||||
:aria-label="isLiked ? '取消喜欢' : '喜欢'"
|
|
||||||
@click="toggleLike"
|
|
||||||
>
|
|
||||||
<AppIcon name="heart" :size="18" />
|
<AppIcon name="heart" :size="18" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="player-center">
|
<div class="player-center">
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<button
|
<button class="ctrl-btn" type="button" :class="{ active: shuffle }" aria-label="随机播放" @click="shuffle = !shuffle">
|
||||||
class="ctrl-btn"
|
|
||||||
type="button"
|
|
||||||
:class="{ active: shuffle }"
|
|
||||||
aria-label="随机播放"
|
|
||||||
@click="shuffle = !shuffle"
|
|
||||||
>
|
|
||||||
<AppIcon name="shuffle" :size="17" />
|
<AppIcon name="shuffle" :size="17" />
|
||||||
</button>
|
</button>
|
||||||
<button class="ctrl-btn" type="button" aria-label="上一首" @click="prev">
|
<button class="ctrl-btn" type="button" aria-label="上一首" @click="prev">
|
||||||
|
|
@ -51,13 +29,7 @@
|
||||||
<button class="ctrl-btn" type="button" aria-label="下一首" @click="next">
|
<button class="ctrl-btn" type="button" aria-label="下一首" @click="next">
|
||||||
<AppIcon name="skip-forward" :size="19" />
|
<AppIcon name="skip-forward" :size="19" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button class="ctrl-btn" type="button" :class="{ active: repeat }" aria-label="单曲循环" @click="repeat = !repeat">
|
||||||
class="ctrl-btn"
|
|
||||||
type="button"
|
|
||||||
:class="{ active: repeat }"
|
|
||||||
aria-label="单曲循环"
|
|
||||||
@click="repeat = !repeat"
|
|
||||||
>
|
|
||||||
<AppIcon name="repeat" :size="17" />
|
<AppIcon name="repeat" :size="17" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -76,17 +48,9 @@
|
||||||
|
|
||||||
<div class="player-right">
|
<div class="player-right">
|
||||||
<button class="icon-btn" type="button" aria-label="静音或恢复音量" @click="toggleMute">
|
<button class="icon-btn" type="button" aria-label="静音或恢复音量" @click="toggleMute">
|
||||||
<AppIcon
|
<AppIcon :name="playerState.volume === 0 ? 'volume-mute' : 'volume-high'" :size="18" />
|
||||||
:name="playerState.volume === 0 ? 'volume-mute' : 'volume-high'"
|
|
||||||
:size="18"
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
<n-slider
|
<n-slider v-model:value="playerState.volume" :max="100" :tooltip="false" class="volume-slider" />
|
||||||
v-model:value="playerState.volume"
|
|
||||||
:max="100"
|
|
||||||
:tooltip="false"
|
|
||||||
class="volume-slider"
|
|
||||||
/>
|
|
||||||
<n-popover trigger="click" placement="top-end" :width="340">
|
<n-popover trigger="click" placement="top-end" :width="340">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<button class="icon-btn queue-btn" type="button" aria-label="播放队列">
|
<button class="icon-btn queue-btn" type="button" aria-label="播放队列">
|
||||||
|
|
@ -116,12 +80,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</n-popover>
|
</n-popover>
|
||||||
<button
|
<button class="icon-btn lyrics-btn" type="button" aria-label="歌词" @click="openFullscreen">
|
||||||
class="icon-btn lyrics-btn"
|
|
||||||
type="button"
|
|
||||||
aria-label="歌词"
|
|
||||||
@click="openFullscreen"
|
|
||||||
>
|
|
||||||
<AppIcon name="mic" :size="18" />
|
<AppIcon name="mic" :size="18" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -138,169 +97,165 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onUnmounted, ref, watch } from "vue";
|
import { computed, onUnmounted, ref, watch } from "vue"
|
||||||
import { NPopover, NSlider, useMessage } from "naive-ui";
|
import { NPopover, NSlider, useMessage } from "naive-ui"
|
||||||
import { playlists } from "@/mocks/music";
|
import { playlists } from "@/mocks/music"
|
||||||
import { playerState, playSong } from "@/stores/player";
|
import { playerState, playSong } from "@/stores/player"
|
||||||
import { coverBackground } from "@/utils/cover";
|
import { coverBackground } from "@/utils/cover"
|
||||||
import { formatTime } from "@/utils/time";
|
import { formatTime } from "@/utils/time"
|
||||||
import AppIcon from "./AppIcon.vue";
|
import AppIcon from "./AppIcon.vue"
|
||||||
import FullscreenPlayer from "./FullscreenPlayer.vue";
|
import FullscreenPlayer from "./FullscreenPlayer.vue"
|
||||||
|
|
||||||
const message = useMessage();
|
const message = useMessage()
|
||||||
|
|
||||||
const showFullscreen = ref(false);
|
const showFullscreen = ref(false)
|
||||||
|
|
||||||
const liked = ref(new Set<string | number>());
|
const liked = ref(new Set<string | number>())
|
||||||
const shuffle = ref(false);
|
const shuffle = ref(false)
|
||||||
const repeat = ref(false);
|
const repeat = ref(false)
|
||||||
|
|
||||||
const audioRef = ref<HTMLAudioElement | null>(null);
|
const audioRef = ref<HTMLAudioElement | null>(null)
|
||||||
|
|
||||||
const current = computed(() => playerState.current);
|
const current = computed(() => playerState.current)
|
||||||
|
|
||||||
const currentIndex = computed(() =>
|
const currentIndex = computed(() => playerState.queue.findIndex((song) => song.id === current.value?.id))
|
||||||
playerState.queue.findIndex((song) => song.id === current.value?.id),
|
|
||||||
);
|
|
||||||
|
|
||||||
const isLiked = computed(() =>
|
const isLiked = computed(() => (current.value ? liked.value.has(current.value.id) : false))
|
||||||
current.value ? liked.value.has(current.value.id) : false,
|
|
||||||
);
|
|
||||||
|
|
||||||
function toggleLike() {
|
function toggleLike() {
|
||||||
if (!current.value) return;
|
if (!current.value) return
|
||||||
const next = new Set(liked.value);
|
const next = new Set(liked.value)
|
||||||
if (next.has(current.value.id)) {
|
if (next.has(current.value.id)) {
|
||||||
next.delete(current.value.id);
|
next.delete(current.value.id)
|
||||||
} else {
|
} else {
|
||||||
next.add(current.value.id);
|
next.add(current.value.id)
|
||||||
}
|
}
|
||||||
liked.value = next;
|
liked.value = next
|
||||||
}
|
}
|
||||||
|
|
||||||
function next() {
|
function next() {
|
||||||
if (playerState.queue.length === 0) return;
|
if (playerState.queue.length === 0) return
|
||||||
let index = currentIndex.value;
|
let index = currentIndex.value
|
||||||
if (shuffle.value) {
|
if (shuffle.value) {
|
||||||
index = Math.floor(Math.random() * playerState.queue.length);
|
index = Math.floor(Math.random() * playerState.queue.length)
|
||||||
} else {
|
} else {
|
||||||
index = (index + 1) % playerState.queue.length;
|
index = (index + 1) % playerState.queue.length
|
||||||
}
|
}
|
||||||
playSong(playerState.queue[index]);
|
playSong(playerState.queue[index])
|
||||||
}
|
}
|
||||||
|
|
||||||
function prev() {
|
function prev() {
|
||||||
if (playerState.queue.length === 0) return;
|
if (playerState.queue.length === 0) return
|
||||||
let index = currentIndex.value;
|
let index = currentIndex.value
|
||||||
if (shuffle.value) {
|
if (shuffle.value) {
|
||||||
index = Math.floor(Math.random() * playerState.queue.length);
|
index = Math.floor(Math.random() * playerState.queue.length)
|
||||||
} else {
|
} else {
|
||||||
index = (index - 1 + playerState.queue.length) % playerState.queue.length;
|
index = (index - 1 + playerState.queue.length) % playerState.queue.length
|
||||||
}
|
}
|
||||||
playSong(playerState.queue[index]);
|
playSong(playerState.queue[index])
|
||||||
}
|
}
|
||||||
|
|
||||||
function togglePlay() {
|
function togglePlay() {
|
||||||
if (!playerState.current) {
|
if (!playerState.current) {
|
||||||
const songs = playlists[0].songs;
|
const songs = playlists[0].songs
|
||||||
playSong(songs[0], songs);
|
playSong(songs[0], songs)
|
||||||
message.success(`正在播放:${songs[0].title}`);
|
message.success(`正在播放:${songs[0].title}`)
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
playerState.isPlaying = !playerState.isPlaying;
|
playerState.isPlaying = !playerState.isPlaying
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleMute() {
|
function toggleMute() {
|
||||||
if (playerState.volume === 0) {
|
if (playerState.volume === 0) {
|
||||||
playerState.volume = 70;
|
playerState.volume = 70
|
||||||
} else {
|
} else {
|
||||||
playerState.volume = 0;
|
playerState.volume = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openFullscreen() {
|
function openFullscreen() {
|
||||||
if (!playerState.current) {
|
if (!playerState.current) {
|
||||||
const songs = playlists[0].songs;
|
const songs = playlists[0].songs
|
||||||
playSong(songs[0], songs);
|
playSong(songs[0], songs)
|
||||||
message.success(`正在播放:${songs[0].title}`);
|
message.success(`正在播放:${songs[0].title}`)
|
||||||
}
|
}
|
||||||
showFullscreen.value = true;
|
showFullscreen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTimeUpdate() {
|
function handleTimeUpdate() {
|
||||||
if (audioRef.value) {
|
if (audioRef.value) {
|
||||||
playerState.currentTime = audioRef.value.currentTime;
|
playerState.currentTime = audioRef.value.currentTime
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEnded() {
|
function handleEnded() {
|
||||||
next();
|
next()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 切换歌曲时加载新的播放直链
|
// 切换歌曲时加载新的播放直链
|
||||||
watch(
|
watch(
|
||||||
() => playerState.src,
|
() => playerState.src,
|
||||||
(src) => {
|
(src) => {
|
||||||
const audio = audioRef.value;
|
const audio = audioRef.value
|
||||||
if (!audio) return;
|
if (!audio) return
|
||||||
if (src) {
|
if (src) {
|
||||||
audio.src = src;
|
audio.src = src
|
||||||
audio.load();
|
audio.load()
|
||||||
if (playerState.isPlaying) {
|
if (playerState.isPlaying) {
|
||||||
audio.play().catch(() => {});
|
audio.play().catch(() => {})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
audio.removeAttribute("src");
|
audio.removeAttribute("src")
|
||||||
audio.load();
|
audio.load()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
)
|
||||||
|
|
||||||
// 播放/暂停
|
// 播放/暂停
|
||||||
watch(
|
watch(
|
||||||
() => playerState.isPlaying,
|
() => playerState.isPlaying,
|
||||||
(playing) => {
|
(playing) => {
|
||||||
const audio = audioRef.value;
|
const audio = audioRef.value
|
||||||
if (!audio || !playerState.src) return;
|
if (!audio || !playerState.src) return
|
||||||
if (playing) {
|
if (playing) {
|
||||||
audio.play().catch(() => {});
|
audio.play().catch(() => {})
|
||||||
} else {
|
} else {
|
||||||
audio.pause();
|
audio.pause()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
)
|
||||||
|
|
||||||
// 音量同步
|
// 音量同步
|
||||||
watch(
|
watch(
|
||||||
() => playerState.volume,
|
() => playerState.volume,
|
||||||
(vol) => {
|
(vol) => {
|
||||||
if (audioRef.value) {
|
if (audioRef.value) {
|
||||||
audioRef.value.volume = vol / 100;
|
audioRef.value.volume = vol / 100
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
)
|
||||||
|
|
||||||
// 进度跳转(拖动进度条时同步到 audio)
|
// 进度跳转(拖动进度条时同步到 audio)
|
||||||
watch(
|
watch(
|
||||||
() => playerState.currentTime,
|
() => playerState.currentTime,
|
||||||
(time) => {
|
(time) => {
|
||||||
const audio = audioRef.value;
|
const audio = audioRef.value
|
||||||
if (!audio || !Number.isFinite(audio.duration)) return;
|
if (!audio || !Number.isFinite(audio.duration)) return
|
||||||
if (Math.abs(audio.currentTime - time) > 0.8) {
|
if (Math.abs(audio.currentTime - time) > 0.8) {
|
||||||
audio.currentTime = time;
|
audio.currentTime = time
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
)
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
const audio = audioRef.value;
|
const audio = audioRef.value
|
||||||
if (audio) {
|
if (audio) {
|
||||||
audio.pause();
|
audio.pause()
|
||||||
audio.removeAttribute("src");
|
audio.removeAttribute("src")
|
||||||
audio.load();
|
audio.load()
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
||||||
|
|
@ -32,12 +32,7 @@
|
||||||
<span class="col-album">{{ song.album }}</span>
|
<span class="col-album">{{ song.album }}</span>
|
||||||
<span class="col-duration">{{ formatTime(song.duration) }}</span>
|
<span class="col-duration">{{ formatTime(song.duration) }}</span>
|
||||||
<span class="col-action">
|
<span class="col-action">
|
||||||
<button
|
<button class="icon-btn" type="button" :aria-label="liked.has(song.id) ? '取消喜欢' : '喜欢'" @click="toggleLike(song.id, $event)">
|
||||||
class="icon-btn"
|
|
||||||
type="button"
|
|
||||||
:aria-label="liked.has(song.id) ? '取消喜欢' : '喜欢'"
|
|
||||||
@click="toggleLike(song.id, $event)"
|
|
||||||
>
|
|
||||||
<AppIcon name="heart" :size="16" :class="{ liked: liked.has(song.id) }" />
|
<AppIcon name="heart" :size="16" :class="{ liked: liked.has(song.id) }" />
|
||||||
</button>
|
</button>
|
||||||
<button class="icon-btn more-btn" type="button" aria-label="更多操作">
|
<button class="icon-btn more-btn" type="button" aria-label="更多操作">
|
||||||
|
|
@ -49,32 +44,32 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive } from "vue";
|
import { reactive } from "vue"
|
||||||
import type { Song } from "@/mocks/music";
|
import type { Song } from "@/mocks/music"
|
||||||
import { playerState } from "@/stores/player";
|
import { playerState } from "@/stores/player"
|
||||||
import { formatTime } from "@/utils/time";
|
import { formatTime } from "@/utils/time"
|
||||||
import AppIcon from "./AppIcon.vue";
|
import AppIcon from "./AppIcon.vue"
|
||||||
|
|
||||||
defineProps<{ songs: Song[] }>();
|
defineProps<{ songs: Song[] }>()
|
||||||
const emit = defineEmits<{ play: [song: Song] }>();
|
const emit = defineEmits<{ play: [song: Song] }>()
|
||||||
|
|
||||||
const liked = reactive(new Set<string | number>());
|
const liked = reactive(new Set<string | number>())
|
||||||
|
|
||||||
function toggleLike(id: string | number, event: MouseEvent) {
|
function toggleLike(id: string | number, event: MouseEvent) {
|
||||||
event.stopPropagation();
|
event.stopPropagation()
|
||||||
if (liked.has(id)) {
|
if (liked.has(id)) {
|
||||||
liked.delete(id);
|
liked.delete(id)
|
||||||
} else {
|
} else {
|
||||||
liked.add(id);
|
liked.add(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isCurrent(song: Song) {
|
function isCurrent(song: Song) {
|
||||||
return playerState.current?.id === song.id;
|
return playerState.current?.id === song.id
|
||||||
}
|
}
|
||||||
|
|
||||||
function play(song: Song) {
|
function play(song: Song) {
|
||||||
emit("play", song);
|
emit("play", song)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,14 +48,14 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from "vue";
|
import { ref, watch } from "vue"
|
||||||
import { RouterLink, RouterView, useRoute } from "vue-router";
|
import { RouterLink, RouterView, useRoute } from "vue-router"
|
||||||
import AppIcon from "@/components/AppIcon.vue";
|
import AppIcon from "@/components/AppIcon.vue"
|
||||||
import PlayerBar from "@/components/PlayerBar.vue";
|
import PlayerBar from "@/components/PlayerBar.vue"
|
||||||
import { myPlaylists } from "@/mocks/music";
|
import { myPlaylists } from "@/mocks/music"
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute()
|
||||||
const contentRef = ref<HTMLElement | null>(null);
|
const contentRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ path: "/", label: "发现音乐", icon: "home" },
|
{ path: "/", label: "发现音乐", icon: "home" },
|
||||||
|
|
@ -64,19 +64,19 @@ const navItems = [
|
||||||
{ path: "/search", label: "搜索", icon: "search" },
|
{ path: "/search", label: "搜索", icon: "search" },
|
||||||
{ path: "/settings", label: "设置", icon: "settings" },
|
{ path: "/settings", label: "设置", icon: "settings" },
|
||||||
{ path: "/demo", label: "演示", icon: "play" },
|
{ path: "/demo", label: "演示", icon: "play" },
|
||||||
] as const;
|
] as const
|
||||||
|
|
||||||
function isActive(path: string) {
|
function isActive(path: string) {
|
||||||
if (path === "/") return route.path === "/";
|
if (path === "/") return route.path === "/"
|
||||||
return route.path.startsWith(path);
|
return route.path.startsWith(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => route.path,
|
() => route.path,
|
||||||
() => {
|
() => {
|
||||||
contentRef.value?.scrollTo({ top: 0 });
|
contentRef.value?.scrollTo({ top: 0 })
|
||||||
},
|
},
|
||||||
);
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<n-carousel autoplay draggable :interval="5000" class="banner-carousel">
|
|
||||||
<div v-for="banner in banners" :key="banner.title" class="banner" :style="{ background: banner.gradient }">
|
|
||||||
<div class="banner-text">
|
|
||||||
<h2>{{ banner.title }}</h2>
|
|
||||||
<p>{{ banner.sub }}</p>
|
|
||||||
</div>
|
|
||||||
<AppIcon name="music" :size="72" class="banner-mark" />
|
|
||||||
</div>
|
|
||||||
</n-carousel>
|
|
||||||
|
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h3>分类</h3>
|
<h3>分类</h3>
|
||||||
|
|
@ -22,7 +12,7 @@
|
||||||
</n-scrollbar>
|
</n-scrollbar>
|
||||||
<n-spin :show="loading" class="card-spin">
|
<n-spin :show="loading" class="card-spin">
|
||||||
<div class="card-grid">
|
<div class="card-grid">
|
||||||
<MusicCard v-for="playlist in recommendedPlaylists" :key="playlist.id" :playlist="playlist" />
|
<MusicCard v-for="playlist in categoryPlaylists" :key="playlist.id" :playlist="playlist" />
|
||||||
</div>
|
</div>
|
||||||
</n-spin>
|
</n-spin>
|
||||||
<n-pagination
|
<n-pagination
|
||||||
|
|
@ -40,122 +30,33 @@
|
||||||
<h3>推荐歌单</h3>
|
<h3>推荐歌单</h3>
|
||||||
<span class="section-sub">为你精选</span>
|
<span class="section-sub">为你精选</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- <div class="card-grid">
|
<div class="card-grid">
|
||||||
<MusicCard v-for="playlist in recommendedPlaylists" :key="playlist.id" :playlist="playlist" />
|
<MusicCard v-for="playlist in recommendedPlaylists" :key="playlist.id" :playlist="playlist" />
|
||||||
</div> -->
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="section">
|
|
||||||
<div class="section-head">
|
|
||||||
<h3>新歌速递</h3>
|
|
||||||
<span class="section-sub">每日 20:00 更新</span>
|
|
||||||
</div>
|
|
||||||
<div class="new-song-row">
|
|
||||||
<div
|
|
||||||
v-for="(song, i) in newSongs"
|
|
||||||
:key="song.id"
|
|
||||||
class="new-song"
|
|
||||||
tabindex="0"
|
|
||||||
role="button"
|
|
||||||
:aria-label="`播放 ${song.title}`"
|
|
||||||
@click="playNewSong(i)"
|
|
||||||
@keydown.enter="playNewSong(i)"
|
|
||||||
>
|
|
||||||
<div class="mini-cover" :style="{ background: song.cover }">
|
|
||||||
<AppIcon name="music" :size="18" class="mini-mark" />
|
|
||||||
<span class="mini-play"><AppIcon name="play" :size="11" /></span>
|
|
||||||
</div>
|
|
||||||
<p class="mini-title" :title="song.title">{{ song.title }}</p>
|
|
||||||
<p class="mini-artist">{{ song.artist }} · {{ formatTime(song.duration) }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section">
|
|
||||||
<div class="section-head">
|
|
||||||
<h3>排行榜精选</h3>
|
|
||||||
<span class="section-sub">总有一个榜单属于你</span>
|
|
||||||
</div>
|
|
||||||
<div class="rank-preview-grid">
|
|
||||||
<div
|
|
||||||
v-for="rank in rankLists.slice(0, 3)"
|
|
||||||
:key="rank.id"
|
|
||||||
class="rank-preview"
|
|
||||||
tabindex="0"
|
|
||||||
role="button"
|
|
||||||
:aria-label="`查看榜单 ${rank.name}`"
|
|
||||||
@click="router.push(`/playlist/${rank.id}`)"
|
|
||||||
@keydown.enter="router.push(`/playlist/${rank.id}`)"
|
|
||||||
>
|
|
||||||
<div class="rank-head" :style="{ background: rank.cover }">
|
|
||||||
<span class="rank-name">{{ rank.name }}</span>
|
|
||||||
<AppIcon name="chevron-right" :size="16" />
|
|
||||||
</div>
|
|
||||||
<ol class="rank-list">
|
|
||||||
<li v-for="(song, i) in rank.songs.slice(0, 5)" :key="song.id">
|
|
||||||
<span class="rank-no" :class="{ top: i < 3 }">{{ i + 1 }}</span>
|
|
||||||
<span class="rank-title" :title="song.title">{{ song.title }}</span>
|
|
||||||
<span class="rank-artist">{{ song.artist }}</span>
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NCarousel, NPagination, NSpin, useMessage } from "naive-ui"
|
import PlayListApi, { type PlayListCategory, type PlayListSummary } from "@/api/playlist"
|
||||||
import { useRouter } from "vue-router"
|
|
||||||
import AppIcon from "@/components/AppIcon.vue"
|
|
||||||
import MusicCard from "@/components/MusicCard.vue"
|
import MusicCard from "@/components/MusicCard.vue"
|
||||||
import { newSongs, rankLists, type Playlist } from "@/mocks/music"
|
|
||||||
import { playSong } from "@/stores/player"
|
|
||||||
import { proxyCover } from "@/utils/cover"
|
|
||||||
import { formatPlayCount } from "@/utils/format"
|
|
||||||
import { formatTime } from "@/utils/time"
|
|
||||||
import PlayListApi from "@/api/playlist"
|
|
||||||
import { cachePlayListSummary, type PlayListCategory } from "@/api/playlist"
|
|
||||||
import { settingsState } from "@/stores/settings"
|
import { settingsState } from "@/stores/settings"
|
||||||
|
import { NPagination, NSpin } from "naive-ui"
|
||||||
const message = useMessage()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const categories = ref<PlayListCategory[]>([])
|
const categories = ref<PlayListCategory[]>([])
|
||||||
const activeCategory = ref<string>("")
|
const activeCategory = ref<string>("")
|
||||||
const recommendedPlaylists = ref<Playlist[]>([])
|
const categoryPlaylists = ref<PlayListSummary[]>([])
|
||||||
|
const recommendedPlaylists = ref<PlayListSummary[]>([])
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = 30
|
const pageSize = 10
|
||||||
const itemCount = ref(pageSize)
|
const itemCount = ref(pageSize)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
/** 防止快速翻页时旧响应覆盖新响应 */
|
/** 防止快速翻页时旧响应覆盖新响应 */
|
||||||
let loadSeq = 0
|
let loadSeq = 0
|
||||||
|
|
||||||
const banners = [
|
/** 获取歌单分类 */
|
||||||
{
|
|
||||||
title: "本周主打",
|
|
||||||
sub: "把耳朵交给这 12 首新单曲",
|
|
||||||
gradient: "linear-gradient(120deg, #4338ca 0%, #7c3aed 55%, #ec4899 100%)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "私人雷达",
|
|
||||||
sub: "根据你的口味生成的专属歌单",
|
|
||||||
gradient: "linear-gradient(120deg, #0ea5e9 0%, #22d3ee 55%, #22c55e 100%)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "热歌榜",
|
|
||||||
sub: "全站播放量最高的 100 首歌",
|
|
||||||
gradient: "linear-gradient(120deg, #f59e0b 0%, #ef4444 55%, #f97316 100%)",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
function playNewSong(songIndex: number) {
|
|
||||||
const song = newSongs[songIndex]
|
|
||||||
playSong(song, newSongs)
|
|
||||||
message.success(`正在播放:${song.title}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const list = async () => {
|
const list = async () => {
|
||||||
const res = await PlayListApi.categoryList([settingsState.defaultSource])
|
const res = await PlayListApi.categoryList([settingsState.defaultSource])
|
||||||
categories.value = res[0].categories
|
categories.value = res[0].categories
|
||||||
|
|
@ -165,26 +66,16 @@ const list = async () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 获取分类下的歌单 */
|
||||||
async function loadCategoryPlayList(categoryId: string, p: number) {
|
async function loadCategoryPlayList(categoryId: string, p: number) {
|
||||||
const seq = ++loadSeq
|
const seq = ++loadSeq
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await PlayListApi.categoryPlayList(categoryId, settingsState.defaultSource, p, pageSize)
|
const res = await PlayListApi.categoryPlayList(categoryId, settingsState.defaultSource, p, pageSize)
|
||||||
|
console.log(res)
|
||||||
if (seq !== loadSeq) return
|
if (seq !== loadSeq) return
|
||||||
const items = res.playlists
|
categoryPlaylists.value = res.playlists
|
||||||
recommendedPlaylists.value = items.map((item) => {
|
const n = categoryPlaylists.value.length
|
||||||
cachePlayListSummary(item)
|
|
||||||
return {
|
|
||||||
id: item.id,
|
|
||||||
name: item.name,
|
|
||||||
desc: item.description,
|
|
||||||
cover: proxyCover(item.cover),
|
|
||||||
tags: [],
|
|
||||||
playCount: formatPlayCount(item.play_count),
|
|
||||||
songs: [],
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const n = items.length
|
|
||||||
if (n === pageSize) {
|
if (n === pageSize) {
|
||||||
// 满页:推测还有下一页
|
// 满页:推测还有下一页
|
||||||
itemCount.value = Math.max(itemCount.value, p * pageSize + pageSize)
|
itemCount.value = Math.max(itemCount.value, p * pageSize + pageSize)
|
||||||
|
|
@ -214,6 +105,12 @@ async function loadCategoryPlayList(categoryId: string, p: number) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getRecommend = async () => {
|
||||||
|
const res = await PlayListApi.recommend(settingsState.enabledSources)
|
||||||
|
recommendedPlaylists.value = res.data
|
||||||
|
console.log(res)
|
||||||
|
}
|
||||||
|
|
||||||
function playList(categoryId: string) {
|
function playList(categoryId: string) {
|
||||||
page.value = 1
|
page.value = 1
|
||||||
itemCount.value = pageSize
|
itemCount.value = pageSize
|
||||||
|
|
@ -226,6 +123,7 @@ function onPageChange(p: number) {
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
list()
|
list()
|
||||||
|
getRecommend()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,16 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
|
<img :src="img" />
|
||||||
<audio :src="url" controls autoplay />
|
<audio :src="url" controls autoplay />
|
||||||
|
<p>{{ lyric }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const url = ref("http://localhost:81/api?server=kugou&type=url&id=A2893B3ED8A63DD3BE73ABA509F8116A&auth=ac5193c14df7d136fbc3f8e9f67ae8fb83f098e4");
|
const baseUrl = ref("http://localhost:81");
|
||||||
|
const url = ref(`${baseUrl.value}/api?server=kugou&type=url&id=7de2123314993994df18ee5868ebe4db&auth=6f3bbab9f99d27e6c7b395bf5901fc3c92cb740b`);
|
||||||
|
const img = ref(`${baseUrl.value}/api?server=kugou&type=pic&id=7de2123314993994df18ee5868ebe4db&auth=3e901a387dc52eab07527a078419a7830ffacf11`)
|
||||||
|
const lyric = ref(`${baseUrl.value}/api?server=kugou&type=lrc&id=7de2123314993994df18ee5868ebe4db&auth=cf4f297474bb697ccc2d213f8cb25712be6cbb3a`);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,15 @@
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<div class="search-hero">
|
<div class="search-hero">
|
||||||
<h2>搜索</h2>
|
<h2>搜索</h2>
|
||||||
<n-input v-model:value="keyword" size="large" round clearable placeholder="搜索音乐、歌手、专辑" class="search-input" @keydown.enter="submitSearch">
|
<n-input
|
||||||
|
v-model:value="keyword"
|
||||||
|
size="large"
|
||||||
|
round
|
||||||
|
clearable
|
||||||
|
placeholder="搜索音乐、歌手、专辑"
|
||||||
|
class="search-input"
|
||||||
|
@keydown.enter="submitSearch"
|
||||||
|
>
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<AppIcon name="search" :size="17" class="search-icon" />
|
<AppIcon name="search" :size="17" class="search-icon" />
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -19,7 +27,17 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="hot-tags">
|
<div class="hot-tags">
|
||||||
<n-tag v-for="item in searchHistory" :key="item" size="large" round :bordered="false" closable class="hot-tag" @click="searchWith(item)" @close="(event: MouseEvent) => removeItem(item, event)">
|
<n-tag
|
||||||
|
v-for="item in searchHistory"
|
||||||
|
:key="item"
|
||||||
|
size="large"
|
||||||
|
round
|
||||||
|
:bordered="false"
|
||||||
|
closable
|
||||||
|
class="hot-tag"
|
||||||
|
@click="searchWith(item)"
|
||||||
|
@close="(event: MouseEvent) => removeItem(item, event)"
|
||||||
|
>
|
||||||
{{ item }}
|
{{ item }}
|
||||||
</n-tag>
|
</n-tag>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -67,19 +85,13 @@ import AppIcon from "@/components/AppIcon.vue"
|
||||||
import MusicCard from "@/components/MusicCard.vue"
|
import MusicCard from "@/components/MusicCard.vue"
|
||||||
import SongTable from "@/components/SongTable.vue"
|
import SongTable from "@/components/SongTable.vue"
|
||||||
import MusicApi, { type MusicSearchPlaylist, type MusicSearchSong } from "@/api/music"
|
import MusicApi, { type MusicSearchPlaylist, type MusicSearchSong } from "@/api/music"
|
||||||
import { hotKeywords, type Playlist, type Song } from "@/mocks/music"
|
import type { PlayListSummary } from "@/api/playlist"
|
||||||
|
import { hotKeywords, type Song } from "@/mocks/music"
|
||||||
import { playSong } from "@/stores/player"
|
import { playSong } from "@/stores/player"
|
||||||
import { settingsState } from "@/stores/settings"
|
import { settingsState } from "@/stores/settings"
|
||||||
import { displayCover } from "@/utils/cover"
|
import { displayCover } from "@/utils/cover"
|
||||||
import { formatPlayCount } from "@/utils/format"
|
|
||||||
import Source from "@/types/global"
|
import Source from "@/types/global"
|
||||||
import {
|
import { addSearchHistory, clearSearchHistory, loadSearchHistory, removeSearchHistory, searchHistory } from "@/stores/searchHistory"
|
||||||
addSearchHistory,
|
|
||||||
clearSearchHistory,
|
|
||||||
loadSearchHistory,
|
|
||||||
removeSearchHistory,
|
|
||||||
searchHistory,
|
|
||||||
} from "@/stores/searchHistory"
|
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
|
@ -90,7 +102,7 @@ const normalized = computed(() => keyword.value.trim().toLowerCase())
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const songResults = ref<Song[]>([])
|
const songResults = ref<Song[]>([])
|
||||||
const playlistResults = ref<Playlist[]>([])
|
const playlistResults = ref<PlayListSummary[]>([])
|
||||||
|
|
||||||
let searchTimer: number | undefined
|
let searchTimer: number | undefined
|
||||||
let requestSeq = 0
|
let requestSeq = 0
|
||||||
|
|
@ -145,22 +157,28 @@ function toSong(item: MusicSearchSong): Song {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toPlaylist(item: MusicSearchPlaylist): Playlist {
|
function toPlaylist(item: MusicSearchPlaylist): PlayListSummary {
|
||||||
return {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
desc: item.description,
|
|
||||||
cover: displayCover(item.cover),
|
cover: displayCover(item.cover),
|
||||||
tags: [],
|
track_count: item.track_count,
|
||||||
playCount: formatPlayCount(item.play_count),
|
play_count: item.play_count,
|
||||||
songs: [],
|
creator: item.creator,
|
||||||
|
description: item.description,
|
||||||
|
source: item.source,
|
||||||
|
link: item.link,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function playSearchSong(song: Song) {
|
async function playSearchSong(song: Song) {
|
||||||
const source = (song.source ?? settingsState.defaultSource) as Source
|
const source = (song.source ?? settingsState.defaultSource) as Source
|
||||||
try {
|
try {
|
||||||
|
// const res = await MusicApi.musicUrl(String(song.id), source)
|
||||||
|
// const url = "http://127.0.0.1:81" + res.data[0].url
|
||||||
const { url } = await MusicApi.url(String(song.id), source)
|
const { url } = await MusicApi.url(String(song.id), source)
|
||||||
|
// const url = "http://127.0.0.1:81" + res.data[0].url
|
||||||
|
console.log("url: ", url)
|
||||||
playSong(song, songResults.value, url)
|
playSong(song, songResults.value, url)
|
||||||
} catch {
|
} catch {
|
||||||
// 拿不到直链时仍切歌,后续可尝试 MusicApi.stream 代理播放
|
// 拿不到直链时仍切歌,后续可尝试 MusicApi.stream 代理播放
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue