feat: 添加音乐播放和歌单推荐功能

- 新增music_url命令用于获取音乐直链
- 实现playlist_recommand命令支持歌单推荐
- 添加HTTP客户端服务用于API请求
- 集成serde_urlencoded支持查询参数编码
- 扩展PlayerBar组件功能并优化UI
- 调整窗口尺寸适配新功能界面
This commit is contained in:
Yuhang Wu 2026-08-13 15:14:23 +08:00
parent 605d025d12
commit 4ba4338502
18 changed files with 847 additions and 319 deletions

19
src-tauri/Cargo.lock generated
View File

@ -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"

View File

@ -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"

View File

@ -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(&params)
.send()
.await
.map_err(|e| e.to_string())?;
let data = resp.json().await.map_err(|e| e.to_string())?;
Ok(ApiResponse::success(data))
}

View File

@ -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", &params)
.await
.map_err(|e| e.to_string());
Ok(ApiResponse::success(data?))
}

View File

@ -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())

61
src-tauri/src/models.rs Normal file
View File

@ -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,
}
}
}

View File

@ -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,
}
}

View File

@ -1 +1,2 @@
pub mod db;
pub mod db;
pub mod http;

View File

@ -13,8 +13,8 @@
"windows": [
{
"title": "YwYMusic",
"width": 860,
"height": 600
"width": 1160,
"height": 660
}
],
"security": {

View File

@ -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

View File

@ -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 })
},
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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