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",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
|
|
@ -3504,6 +3505,12 @@ version = "1.0.23"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
|
|
@ -3726,6 +3733,18 @@ dependencies = [
|
|||
"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]]
|
||||
name = "serde_with"
|
||||
version = "3.22.0"
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ tauri = { version = "2", features = [] }
|
|||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
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"] }
|
||||
tauri-plugin-store = "2"
|
||||
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 crate::{models::ApiResponse, services::http::HttpClient};
|
||||
|
||||
#[tauri::command]
|
||||
#[auto_collect_command]
|
||||
pub async fn playlist_recommand() -> Result<String, String> {
|
||||
Ok("playlist_recommand".to_string())
|
||||
pub async fn playlist_recommand(
|
||||
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 services;
|
||||
mod models;
|
||||
|
||||
use commands::album::*;
|
||||
use commands::music::*;
|
||||
use commands::playlist::*;
|
||||
use services::db::init_pool;
|
||||
use services::http::HttpClient;
|
||||
use tauri::Manager;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
|
|
@ -16,6 +18,7 @@ pub fn run() {
|
|||
init_pool().await.expect("sqlite数据库连接池初始化失败!")
|
||||
});
|
||||
app.manage(pool);
|
||||
app.manage(HttpClient::default());
|
||||
Ok(())
|
||||
})
|
||||
.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": [
|
||||
{
|
||||
"title": "YwYMusic",
|
||||
"width": 860,
|
||||
"height": 600
|
||||
"width": 1160,
|
||||
"height": 660
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
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 {
|
||||
title: string
|
||||
author: string
|
||||
url: string
|
||||
pic: string
|
||||
lrc: string
|
||||
}
|
||||
|
||||
/** 搜索结果-单曲 */
|
||||
|
|
@ -148,6 +153,10 @@ const MusicApi = {
|
|||
url: (id: string, 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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
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 {
|
||||
|
|
@ -28,8 +29,8 @@ interface PlayListSummary {
|
|||
description: string
|
||||
source: string
|
||||
link: string
|
||||
/** 平台相关的附加元数据,字段随 source 变化 */
|
||||
extra: {
|
||||
/** 平台相关的附加元数据,字段随 source 变化(搜索结果可能缺失) */
|
||||
extra?: {
|
||||
category_id: string
|
||||
global_specialid: string
|
||||
id: string
|
||||
|
|
@ -132,8 +133,9 @@ const PlayListApi = {
|
|||
* @returns
|
||||
*/
|
||||
recommend: (sources?: Source[]) => {
|
||||
const sourceParams = (sources ?? []).map((s) => `sources=${encodeURIComponent(s)}`).join("&")
|
||||
return get<PlayListSummary[]>(`/api/v1/playlist/recommend${sourceParams ? `?${sourceParams}` : ""}`)
|
||||
// const sourceParams = (sources ?? []).map((s) => `sources=${encodeURIComponent(s)}`).join("&")
|
||||
// return get<PlayListSummary[]>(`/api/v1/playlist/recommend${sourceParams ? `?${sourceParams}` : ""}`)
|
||||
return invoke<ApiResponse<PlayListSummary[]>>("playlist_recommand", { sources })
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,41 +14,41 @@
|
|||
</button>
|
||||
<span class="play-count">
|
||||
<AppIcon name="headphones" :size="12" />
|
||||
{{ playlist.playCount }}
|
||||
{{ formatPlayCount(playlist.play_count) }}
|
||||
</span>
|
||||
</div>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useMessage } from "naive-ui";
|
||||
import { useRouter } from "vue-router";
|
||||
import type { Playlist } from "@/mocks/music";
|
||||
import { playSong } from "@/stores/player";
|
||||
import { coverBackground } from "@/utils/cover";
|
||||
import { cachePlayListSummary, type PlayListSummary } from "@/api/playlist";
|
||||
import { displayCover } from "@/utils/cover";
|
||||
import { formatPlayCount } from "@/utils/format";
|
||||
import AppIcon from "./AppIcon.vue";
|
||||
|
||||
const props = defineProps<{ playlist: Playlist }>();
|
||||
const props = defineProps<{ playlist: PlayListSummary }>();
|
||||
const router = useRouter();
|
||||
const message = useMessage();
|
||||
|
||||
/** 图片 URL 需要用 url() 包裹,渐变字符串则原样透传 */
|
||||
/** 封面为平台 URL,走本地代理防防盗链;空值回退为渐变占位 */
|
||||
const coverStyle = computed(() => ({
|
||||
background: coverBackground(props.playlist.cover),
|
||||
background:
|
||||
displayCover(props.playlist.cover) ||
|
||||
"linear-gradient(135deg, #4338ca 0%, #7c3aed 100%)",
|
||||
}));
|
||||
|
||||
function openDetail() {
|
||||
// 详情页依赖该缓存读取歌单元信息,跳转前先写入
|
||||
cachePlayListSummary(props.playlist);
|
||||
router.push(`/playlist/${props.playlist.id}`);
|
||||
}
|
||||
|
||||
function play() {
|
||||
const songs = props.playlist.songs;
|
||||
if (songs.length === 0) return;
|
||||
playSong(songs[0], songs);
|
||||
message.success(`正在播放:${songs[0].title}`);
|
||||
// 摘要不含歌曲列表,进入详情页后由「播放全部」触发播放
|
||||
openDetail();
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,45 +1,23 @@
|
|||
<template>
|
||||
<footer class="player-bar">
|
||||
<audio
|
||||
ref="audioRef"
|
||||
class="audio-hidden"
|
||||
preload="auto"
|
||||
@timeupdate="handleTimeUpdate"
|
||||
@ended="handleEnded"
|
||||
/>
|
||||
<audio ref="audioRef" class="audio-hidden" preload="auto" @timeupdate="handleTimeUpdate" @ended="handleEnded" />
|
||||
|
||||
<div class="player-left">
|
||||
<div
|
||||
class="cover-mini"
|
||||
:style="current ? { background: coverBackground(current.cover) } : undefined"
|
||||
@click="openFullscreen"
|
||||
>
|
||||
<div class="cover-mini" :style="current ? { background: coverBackground(current.cover) } : undefined" @click="openFullscreen">
|
||||
<AppIcon name="music" :size="20" class="cover-mini-mark" />
|
||||
</div>
|
||||
<div class="meta">
|
||||
<p class="song-title">{{ current?.title ?? "未在播放" }}</p>
|
||||
<p class="song-artist">{{ current?.artist ?? "选择一首歌开始播放" }}</p>
|
||||
</div>
|
||||
<button
|
||||
class="icon-btn like-btn"
|
||||
type="button"
|
||||
:class="{ active: isLiked }"
|
||||
:aria-label="isLiked ? '取消喜欢' : '喜欢'"
|
||||
@click="toggleLike"
|
||||
>
|
||||
<button class="icon-btn like-btn" type="button" :class="{ active: isLiked }" :aria-label="isLiked ? '取消喜欢' : '喜欢'" @click="toggleLike">
|
||||
<AppIcon name="heart" :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="player-center">
|
||||
<div class="controls">
|
||||
<button
|
||||
class="ctrl-btn"
|
||||
type="button"
|
||||
:class="{ active: shuffle }"
|
||||
aria-label="随机播放"
|
||||
@click="shuffle = !shuffle"
|
||||
>
|
||||
<button class="ctrl-btn" type="button" :class="{ active: shuffle }" aria-label="随机播放" @click="shuffle = !shuffle">
|
||||
<AppIcon name="shuffle" :size="17" />
|
||||
</button>
|
||||
<button class="ctrl-btn" type="button" aria-label="上一首" @click="prev">
|
||||
|
|
@ -51,13 +29,7 @@
|
|||
<button class="ctrl-btn" type="button" aria-label="下一首" @click="next">
|
||||
<AppIcon name="skip-forward" :size="19" />
|
||||
</button>
|
||||
<button
|
||||
class="ctrl-btn"
|
||||
type="button"
|
||||
:class="{ active: repeat }"
|
||||
aria-label="单曲循环"
|
||||
@click="repeat = !repeat"
|
||||
>
|
||||
<button class="ctrl-btn" type="button" :class="{ active: repeat }" aria-label="单曲循环" @click="repeat = !repeat">
|
||||
<AppIcon name="repeat" :size="17" />
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -76,17 +48,9 @@
|
|||
|
||||
<div class="player-right">
|
||||
<button class="icon-btn" type="button" aria-label="静音或恢复音量" @click="toggleMute">
|
||||
<AppIcon
|
||||
:name="playerState.volume === 0 ? 'volume-mute' : 'volume-high'"
|
||||
:size="18"
|
||||
/>
|
||||
<AppIcon :name="playerState.volume === 0 ? 'volume-mute' : 'volume-high'" :size="18" />
|
||||
</button>
|
||||
<n-slider
|
||||
v-model:value="playerState.volume"
|
||||
:max="100"
|
||||
:tooltip="false"
|
||||
class="volume-slider"
|
||||
/>
|
||||
<n-slider v-model:value="playerState.volume" :max="100" :tooltip="false" class="volume-slider" />
|
||||
<n-popover trigger="click" placement="top-end" :width="340">
|
||||
<template #trigger>
|
||||
<button class="icon-btn queue-btn" type="button" aria-label="播放队列">
|
||||
|
|
@ -116,12 +80,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</n-popover>
|
||||
<button
|
||||
class="icon-btn lyrics-btn"
|
||||
type="button"
|
||||
aria-label="歌词"
|
||||
@click="openFullscreen"
|
||||
>
|
||||
<button class="icon-btn lyrics-btn" type="button" aria-label="歌词" @click="openFullscreen">
|
||||
<AppIcon name="mic" :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -138,169 +97,165 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, ref, watch } from "vue";
|
||||
import { NPopover, NSlider, useMessage } from "naive-ui";
|
||||
import { playlists } from "@/mocks/music";
|
||||
import { playerState, playSong } from "@/stores/player";
|
||||
import { coverBackground } from "@/utils/cover";
|
||||
import { formatTime } from "@/utils/time";
|
||||
import AppIcon from "./AppIcon.vue";
|
||||
import FullscreenPlayer from "./FullscreenPlayer.vue";
|
||||
import { computed, onUnmounted, ref, watch } from "vue"
|
||||
import { NPopover, NSlider, useMessage } from "naive-ui"
|
||||
import { playlists } from "@/mocks/music"
|
||||
import { playerState, playSong } from "@/stores/player"
|
||||
import { coverBackground } from "@/utils/cover"
|
||||
import { formatTime } from "@/utils/time"
|
||||
import AppIcon from "./AppIcon.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 shuffle = ref(false);
|
||||
const repeat = ref(false);
|
||||
const liked = ref(new Set<string | number>())
|
||||
const shuffle = 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(() =>
|
||||
playerState.queue.findIndex((song) => song.id === current.value?.id),
|
||||
);
|
||||
const currentIndex = computed(() => playerState.queue.findIndex((song) => song.id === current.value?.id))
|
||||
|
||||
const isLiked = computed(() =>
|
||||
current.value ? liked.value.has(current.value.id) : false,
|
||||
);
|
||||
const isLiked = computed(() => (current.value ? liked.value.has(current.value.id) : false))
|
||||
|
||||
function toggleLike() {
|
||||
if (!current.value) return;
|
||||
const next = new Set(liked.value);
|
||||
if (!current.value) return
|
||||
const next = new Set(liked.value)
|
||||
if (next.has(current.value.id)) {
|
||||
next.delete(current.value.id);
|
||||
next.delete(current.value.id)
|
||||
} else {
|
||||
next.add(current.value.id);
|
||||
next.add(current.value.id)
|
||||
}
|
||||
liked.value = next;
|
||||
liked.value = next
|
||||
}
|
||||
|
||||
function next() {
|
||||
if (playerState.queue.length === 0) return;
|
||||
let index = currentIndex.value;
|
||||
if (playerState.queue.length === 0) return
|
||||
let index = currentIndex.value
|
||||
if (shuffle.value) {
|
||||
index = Math.floor(Math.random() * playerState.queue.length);
|
||||
index = Math.floor(Math.random() * playerState.queue.length)
|
||||
} else {
|
||||
index = (index + 1) % playerState.queue.length;
|
||||
index = (index + 1) % playerState.queue.length
|
||||
}
|
||||
playSong(playerState.queue[index]);
|
||||
playSong(playerState.queue[index])
|
||||
}
|
||||
|
||||
function prev() {
|
||||
if (playerState.queue.length === 0) return;
|
||||
let index = currentIndex.value;
|
||||
if (playerState.queue.length === 0) return
|
||||
let index = currentIndex.value
|
||||
if (shuffle.value) {
|
||||
index = Math.floor(Math.random() * playerState.queue.length);
|
||||
index = Math.floor(Math.random() * playerState.queue.length)
|
||||
} 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() {
|
||||
if (!playerState.current) {
|
||||
const songs = playlists[0].songs;
|
||||
playSong(songs[0], songs);
|
||||
message.success(`正在播放:${songs[0].title}`);
|
||||
return;
|
||||
const songs = playlists[0].songs
|
||||
playSong(songs[0], songs)
|
||||
message.success(`正在播放:${songs[0].title}`)
|
||||
return
|
||||
}
|
||||
playerState.isPlaying = !playerState.isPlaying;
|
||||
playerState.isPlaying = !playerState.isPlaying
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
if (playerState.volume === 0) {
|
||||
playerState.volume = 70;
|
||||
playerState.volume = 70
|
||||
} else {
|
||||
playerState.volume = 0;
|
||||
playerState.volume = 0
|
||||
}
|
||||
}
|
||||
|
||||
function openFullscreen() {
|
||||
if (!playerState.current) {
|
||||
const songs = playlists[0].songs;
|
||||
playSong(songs[0], songs);
|
||||
message.success(`正在播放:${songs[0].title}`);
|
||||
const songs = playlists[0].songs
|
||||
playSong(songs[0], songs)
|
||||
message.success(`正在播放:${songs[0].title}`)
|
||||
}
|
||||
showFullscreen.value = true;
|
||||
showFullscreen.value = true
|
||||
}
|
||||
|
||||
function handleTimeUpdate() {
|
||||
if (audioRef.value) {
|
||||
playerState.currentTime = audioRef.value.currentTime;
|
||||
playerState.currentTime = audioRef.value.currentTime
|
||||
}
|
||||
}
|
||||
|
||||
function handleEnded() {
|
||||
next();
|
||||
next()
|
||||
}
|
||||
|
||||
// 切换歌曲时加载新的播放直链
|
||||
watch(
|
||||
() => playerState.src,
|
||||
(src) => {
|
||||
const audio = audioRef.value;
|
||||
if (!audio) return;
|
||||
const audio = audioRef.value
|
||||
if (!audio) return
|
||||
if (src) {
|
||||
audio.src = src;
|
||||
audio.load();
|
||||
audio.src = src
|
||||
audio.load()
|
||||
if (playerState.isPlaying) {
|
||||
audio.play().catch(() => {});
|
||||
audio.play().catch(() => {})
|
||||
}
|
||||
} else {
|
||||
audio.removeAttribute("src");
|
||||
audio.load();
|
||||
audio.removeAttribute("src")
|
||||
audio.load()
|
||||
}
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
// 播放/暂停
|
||||
watch(
|
||||
() => playerState.isPlaying,
|
||||
(playing) => {
|
||||
const audio = audioRef.value;
|
||||
if (!audio || !playerState.src) return;
|
||||
const audio = audioRef.value
|
||||
if (!audio || !playerState.src) return
|
||||
if (playing) {
|
||||
audio.play().catch(() => {});
|
||||
audio.play().catch(() => {})
|
||||
} else {
|
||||
audio.pause();
|
||||
audio.pause()
|
||||
}
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
// 音量同步
|
||||
watch(
|
||||
() => playerState.volume,
|
||||
(vol) => {
|
||||
if (audioRef.value) {
|
||||
audioRef.value.volume = vol / 100;
|
||||
audioRef.value.volume = vol / 100
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
)
|
||||
|
||||
// 进度跳转(拖动进度条时同步到 audio)
|
||||
watch(
|
||||
() => playerState.currentTime,
|
||||
(time) => {
|
||||
const audio = audioRef.value;
|
||||
if (!audio || !Number.isFinite(audio.duration)) return;
|
||||
const audio = audioRef.value
|
||||
if (!audio || !Number.isFinite(audio.duration)) return
|
||||
if (Math.abs(audio.currentTime - time) > 0.8) {
|
||||
audio.currentTime = time;
|
||||
audio.currentTime = time
|
||||
}
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
const audio = audioRef.value;
|
||||
const audio = audioRef.value
|
||||
if (audio) {
|
||||
audio.pause();
|
||||
audio.removeAttribute("src");
|
||||
audio.load();
|
||||
audio.pause()
|
||||
audio.removeAttribute("src")
|
||||
audio.load()
|
||||
}
|
||||
});
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
|||
|
|
@ -32,12 +32,7 @@
|
|||
<span class="col-album">{{ song.album }}</span>
|
||||
<span class="col-duration">{{ formatTime(song.duration) }}</span>
|
||||
<span class="col-action">
|
||||
<button
|
||||
class="icon-btn"
|
||||
type="button"
|
||||
:aria-label="liked.has(song.id) ? '取消喜欢' : '喜欢'"
|
||||
@click="toggleLike(song.id, $event)"
|
||||
>
|
||||
<button 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) }" />
|
||||
</button>
|
||||
<button class="icon-btn more-btn" type="button" aria-label="更多操作">
|
||||
|
|
@ -49,32 +44,32 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive } from "vue";
|
||||
import type { Song } from "@/mocks/music";
|
||||
import { playerState } from "@/stores/player";
|
||||
import { formatTime } from "@/utils/time";
|
||||
import AppIcon from "./AppIcon.vue";
|
||||
import { reactive } from "vue"
|
||||
import type { Song } from "@/mocks/music"
|
||||
import { playerState } from "@/stores/player"
|
||||
import { formatTime } from "@/utils/time"
|
||||
import AppIcon from "./AppIcon.vue"
|
||||
|
||||
defineProps<{ songs: Song[] }>();
|
||||
const emit = defineEmits<{ play: [song: Song] }>();
|
||||
defineProps<{ songs: 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) {
|
||||
event.stopPropagation();
|
||||
event.stopPropagation()
|
||||
if (liked.has(id)) {
|
||||
liked.delete(id);
|
||||
liked.delete(id)
|
||||
} else {
|
||||
liked.add(id);
|
||||
liked.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
function isCurrent(song: Song) {
|
||||
return playerState.current?.id === song.id;
|
||||
return playerState.current?.id === song.id
|
||||
}
|
||||
|
||||
function play(song: Song) {
|
||||
emit("play", song);
|
||||
emit("play", song)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -48,14 +48,14 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { RouterLink, RouterView, useRoute } from "vue-router";
|
||||
import AppIcon from "@/components/AppIcon.vue";
|
||||
import PlayerBar from "@/components/PlayerBar.vue";
|
||||
import { myPlaylists } from "@/mocks/music";
|
||||
import { ref, watch } from "vue"
|
||||
import { RouterLink, RouterView, useRoute } from "vue-router"
|
||||
import AppIcon from "@/components/AppIcon.vue"
|
||||
import PlayerBar from "@/components/PlayerBar.vue"
|
||||
import { myPlaylists } from "@/mocks/music"
|
||||
|
||||
const route = useRoute();
|
||||
const contentRef = ref<HTMLElement | null>(null);
|
||||
const route = useRoute()
|
||||
const contentRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const navItems = [
|
||||
{ path: "/", label: "发现音乐", icon: "home" },
|
||||
|
|
@ -64,19 +64,19 @@ const navItems = [
|
|||
{ path: "/search", label: "搜索", icon: "search" },
|
||||
{ path: "/settings", label: "设置", icon: "settings" },
|
||||
{ path: "/demo", label: "演示", icon: "play" },
|
||||
] as const;
|
||||
] as const
|
||||
|
||||
function isActive(path: string) {
|
||||
if (path === "/") return route.path === "/";
|
||||
return route.path.startsWith(path);
|
||||
if (path === "/") return route.path === "/"
|
||||
return route.path.startsWith(path)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
contentRef.value?.scrollTo({ top: 0 });
|
||||
contentRef.value?.scrollTo({ top: 0 })
|
||||
},
|
||||
);
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,5 @@
|
|||
<template>
|
||||
<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">
|
||||
<div class="section-head">
|
||||
<h3>分类</h3>
|
||||
|
|
@ -22,7 +12,7 @@
|
|||
</n-scrollbar>
|
||||
<n-spin :show="loading" class="card-spin">
|
||||
<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>
|
||||
</n-spin>
|
||||
<n-pagination
|
||||
|
|
@ -40,122 +30,33 @@
|
|||
<h3>推荐歌单</h3>
|
||||
<span class="section-sub">为你精选</span>
|
||||
</div>
|
||||
<!-- <div class="card-grid">
|
||||
<div class="card-grid">
|
||||
<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>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { NCarousel, NPagination, NSpin, useMessage } from "naive-ui"
|
||||
import { useRouter } from "vue-router"
|
||||
import AppIcon from "@/components/AppIcon.vue"
|
||||
import PlayListApi, { type PlayListCategory, type PlayListSummary } from "@/api/playlist"
|
||||
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"
|
||||
|
||||
const message = useMessage()
|
||||
const router = useRouter()
|
||||
import { NPagination, NSpin } from "naive-ui"
|
||||
|
||||
const categories = ref<PlayListCategory[]>([])
|
||||
const activeCategory = ref<string>("")
|
||||
const recommendedPlaylists = ref<Playlist[]>([])
|
||||
const categoryPlaylists = ref<PlayListSummary[]>([])
|
||||
const recommendedPlaylists = ref<PlayListSummary[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = 30
|
||||
const pageSize = 10
|
||||
const itemCount = ref(pageSize)
|
||||
const loading = ref(false)
|
||||
|
||||
/** 防止快速翻页时旧响应覆盖新响应 */
|
||||
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 res = await PlayListApi.categoryList([settingsState.defaultSource])
|
||||
categories.value = res[0].categories
|
||||
|
|
@ -165,26 +66,16 @@ const list = async () => {
|
|||
}
|
||||
}
|
||||
|
||||
/** 获取分类下的歌单 */
|
||||
async function loadCategoryPlayList(categoryId: string, p: number) {
|
||||
const seq = ++loadSeq
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await PlayListApi.categoryPlayList(categoryId, settingsState.defaultSource, p, pageSize)
|
||||
console.log(res)
|
||||
if (seq !== loadSeq) return
|
||||
const items = res.playlists
|
||||
recommendedPlaylists.value = items.map((item) => {
|
||||
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
|
||||
categoryPlaylists.value = res.playlists
|
||||
const n = categoryPlaylists.value.length
|
||||
if (n === 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) {
|
||||
page.value = 1
|
||||
itemCount.value = pageSize
|
||||
|
|
@ -226,6 +123,7 @@ function onPageChange(p: number) {
|
|||
|
||||
onMounted(() => {
|
||||
list()
|
||||
getRecommend()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
<template>
|
||||
<div>
|
||||
<img :src="img" />
|
||||
<audio :src="url" controls autoplay />
|
||||
<p>{{ lyric }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,15 @@
|
|||
<div class="page">
|
||||
<div class="search-hero">
|
||||
<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>
|
||||
<AppIcon name="search" :size="17" class="search-icon" />
|
||||
</template>
|
||||
|
|
@ -19,7 +27,17 @@
|
|||
</button>
|
||||
</div>
|
||||
<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 }}
|
||||
</n-tag>
|
||||
</div>
|
||||
|
|
@ -67,19 +85,13 @@ import AppIcon from "@/components/AppIcon.vue"
|
|||
import MusicCard from "@/components/MusicCard.vue"
|
||||
import SongTable from "@/components/SongTable.vue"
|
||||
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 { settingsState } from "@/stores/settings"
|
||||
import { displayCover } from "@/utils/cover"
|
||||
import { formatPlayCount } from "@/utils/format"
|
||||
import Source from "@/types/global"
|
||||
import {
|
||||
addSearchHistory,
|
||||
clearSearchHistory,
|
||||
loadSearchHistory,
|
||||
removeSearchHistory,
|
||||
searchHistory,
|
||||
} from "@/stores/searchHistory"
|
||||
import { addSearchHistory, clearSearchHistory, loadSearchHistory, removeSearchHistory, searchHistory } from "@/stores/searchHistory"
|
||||
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
|
|
@ -90,7 +102,7 @@ const normalized = computed(() => keyword.value.trim().toLowerCase())
|
|||
|
||||
const loading = ref(false)
|
||||
const songResults = ref<Song[]>([])
|
||||
const playlistResults = ref<Playlist[]>([])
|
||||
const playlistResults = ref<PlayListSummary[]>([])
|
||||
|
||||
let searchTimer: number | undefined
|
||||
let requestSeq = 0
|
||||
|
|
@ -145,22 +157,28 @@ function toSong(item: MusicSearchSong): Song {
|
|||
}
|
||||
}
|
||||
|
||||
function toPlaylist(item: MusicSearchPlaylist): Playlist {
|
||||
function toPlaylist(item: MusicSearchPlaylist): PlayListSummary {
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
desc: item.description,
|
||||
cover: displayCover(item.cover),
|
||||
tags: [],
|
||||
playCount: formatPlayCount(item.play_count),
|
||||
songs: [],
|
||||
track_count: item.track_count,
|
||||
play_count: item.play_count,
|
||||
creator: item.creator,
|
||||
description: item.description,
|
||||
source: item.source,
|
||||
link: item.link,
|
||||
}
|
||||
}
|
||||
|
||||
async function playSearchSong(song: Song) {
|
||||
const source = (song.source ?? settingsState.defaultSource) as Source
|
||||
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 = "http://127.0.0.1:81" + res.data[0].url
|
||||
console.log("url: ", url)
|
||||
playSong(song, songResults.value, url)
|
||||
} catch {
|
||||
// 拿不到直链时仍切歌,后续可尝试 MusicApi.stream 代理播放
|
||||
|
|
|
|||
Loading…
Reference in New Issue