mirror of
https://github.com/atdunbg/Nekosonic-Music.git
synced 2026-06-22 00:58:51 +08:00
feat: 架构重构与跨平台媒体控制集成
## 后端 - 替换 rodio 为 symphonia + ringbuf,重构 audio.rs 播放引擎 - 重构 api.rs,使用 api_call! 宏统一 API 调用模式 - 新增 media_controls.rs,使用 souvlaki 实现跨平台系统媒体控制 (Linux MPRIS / Windows SMTC / macOS Now Playing) - 版本号升至 v0.5.0 ## 前端 - 新增 - 新增 SongListItem 通用组件 - 新增 useOnlineStatus composable,检测网络状态 - 新增 usePageCache composable,页面数据缓存与失效 - 新增 getCoverUrl()、formatDate() 工具函数 - 新增 emitPlaybackState() 同步播放状态到系统媒体控制 - 新增 mpris-command 事件监听,响应系统媒体控制命令 - 新增 Toast 离线/恢复在线提示 - 各页面新增断网恢复后自动重试加载 - 新增路由守卫:已登录用户访问 /login 重定向至首页 - 新增音量持久化(settings store + localStorage) - 新增禁用右键菜单与用户选择限制(输入框除外) ## 前端 - 变更 - Song 接口从 player.ts 迁移至 song.ts 并导出 - AlbumDetail/ArtistDetail/PlaylistDetail/RecentPlays/LocalMusic 迁移至 SongListItem - PlayerBar 队列列表迁移至 SongListItem,封面使用 getCoverUrl() - downloadSong 参数类型从内联对象改为 Song,使用 getCoverUrl() - 默认主题从 green 改为 blue,ThemeName 及相关列表中 blue 移至首位 - 全局快捷键从 Alt+Control 改为 Control+Alt 顺序 - formatShortcut 新增 KeyP → P 显示 - keep-alive 从 max=3 固定 include 改为 max=5 动态列表,窗口隐藏时释放 - App.vue 封面使用 getCoverUrl() 替代手动 al/album 回退 - formatPlayCount 提取常量 - Login.vue text-warning 改为 text-yellow-400 ## 前端 - 删除 - 删除 Search.vue(与 Discover.vue 重复) - 删除 SongItemMenu.vue(被 SongListItem 替代) ## 修复 - 更新器跳过版本逻辑:仅静默检查时跳过已忽略版本,手动检查不再跳过 - 重复播放同一首歌时无法恢复播放 - settings.ts 重复的 ThemeName 定义 - PlayerBar.vue modeTexts 缺少类型注解 - Home.vue map 回调参数缺少类型 - Settings.vue v-for key 类型不匹配
This commit is contained in:
@ -2,7 +2,6 @@ use ncm_api_rs::{create_client, ApiClient, Query};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use tauri::{Manager, State, Emitter};
|
||||
use tokio::sync::Mutex;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
@ -14,12 +13,66 @@ use lofty::file::{AudioFile, TaggedFileExt};
|
||||
use lofty::tag::Accessor;
|
||||
use base64::Engine;
|
||||
|
||||
/// 统一的 API 调用宏,封装了「获取客户端 → 构建请求 → 发送 → 提取响应体」的完整流程。
|
||||
///
|
||||
/// 消除了每个 Tauri 命令中重复的 `client.lock().unwrap().clone()` + `build_query()` + `.map(|r| r.body.to_string())` 样板代码。
|
||||
///
|
||||
/// 提供三种调用方式:
|
||||
///
|
||||
/// 1. 无额外参数 — 仅使用 cookie 中的默认参数构建请求
|
||||
/// ```
|
||||
/// api_call!(state, get_playlist_detail)
|
||||
/// // 等价于:
|
||||
/// // let client = state.client.lock().unwrap().clone();
|
||||
/// // let q = state.build_query();
|
||||
/// // client.get_playlist_detail(&q).await.map(|r| r.body.to_string()).map_err(|e| e.to_string())
|
||||
/// ```
|
||||
///
|
||||
/// 2. 附加参数 — 在默认参数基础上追加键值对
|
||||
/// ```
|
||||
/// api_call!(state, song_url_v1, params: [("id", id), ("level", "standard")])
|
||||
/// // 等价于:
|
||||
/// // let q = state.build_query().param("id", id).param("level", "standard");
|
||||
/// // client.song_url_v1(&q).await...
|
||||
/// ```
|
||||
///
|
||||
/// 3. 预构建查询 — 直接传入已构建好的 Query 对象,跳过 build_query()
|
||||
/// ```
|
||||
/// api_call!(state, playlist_track_all, query: my_query)
|
||||
/// // 等价于:
|
||||
/// // client.playlist_track_all(&my_query).await...
|
||||
/// ```
|
||||
macro_rules! api_call {
|
||||
($state:expr, $method:ident) => {{
|
||||
let client = $state.client.lock().unwrap().clone();
|
||||
let q = $state.build_query();
|
||||
client.$method(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
}};
|
||||
($state:expr, $method:ident, params: [$(($key:expr, $val:expr)),* $(,)?]) => {{
|
||||
let client = $state.client.lock().unwrap().clone();
|
||||
let mut q = $state.build_query();
|
||||
$(q = q.param($key, $val);)*
|
||||
client.$method(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
}};
|
||||
($state:expr, $method:ident, query: $q:expr) => {{
|
||||
let client = $state.client.lock().unwrap().clone();
|
||||
client.$method(&$q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
}};
|
||||
}
|
||||
|
||||
pub struct ApiController {
|
||||
client: Mutex<ApiClient>,
|
||||
client: StdMutex<ApiClient>,
|
||||
cookie: StdMutex<Option<String>>,
|
||||
cookie_path: PathBuf,
|
||||
}
|
||||
|
||||
/// 将 Cookie 字符串列表转换为 `key=value; key=value` 格式
|
||||
fn cookies_to_key_values(cookies: &[String]) -> String {
|
||||
cookies
|
||||
.iter()
|
||||
@ -31,6 +84,7 @@ fn cookies_to_key_values(cookies: &[String]) -> String {
|
||||
|
||||
impl ApiController {
|
||||
|
||||
/// 创建新的 API 控制器,从本地文件恢复已保存的 Cookie
|
||||
pub fn new(app_data_dir: PathBuf) -> Self {
|
||||
let _ = fs::create_dir_all(&app_data_dir);
|
||||
let cookie_path = app_data_dir.join("netease_cookies.json");
|
||||
@ -40,13 +94,14 @@ impl ApiController {
|
||||
|
||||
let client = create_client(None);
|
||||
ApiController {
|
||||
client: Mutex::new(client),
|
||||
client: StdMutex::new(client),
|
||||
cookie: StdMutex::new(saved_cookie),
|
||||
cookie_path,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_query(&self) -> Query {
|
||||
/// 构建带当前 Cookie 的 API 查询对象
|
||||
fn build_query(&self) -> Query {
|
||||
let mut query = Query::new();
|
||||
if let Ok(cookie_guard) = self.cookie.lock() {
|
||||
if let Some(c) = cookie_guard.as_ref() {
|
||||
@ -55,66 +110,57 @@ fn build_query(&self) -> Query {
|
||||
}
|
||||
query
|
||||
}
|
||||
/// 将 Cookie 字符串持久化到本地文件并同步到 API 客户端
|
||||
fn save_cookie(&self, cookie_str: &str) {
|
||||
let _ = fs::write(&self.cookie_path, cookie_str);
|
||||
if let Ok(mut client) = self.client.lock() {
|
||||
client.set_cookie(cookie_str.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 搜索查询参数
|
||||
#[derive(Deserialize)]
|
||||
pub struct SearchQuery { pub keyword: String }
|
||||
|
||||
/// 手机号登录查询参数
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginQuery { pub phone: String, pub password: String }
|
||||
|
||||
/// 二维码登录密钥查询参数
|
||||
#[derive(Deserialize)]
|
||||
pub struct QrKeyQuery { pub key: String }
|
||||
|
||||
/// 搜索歌曲
|
||||
#[tauri::command]
|
||||
pub async fn search_songs(query: SearchQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let q = state.build_query()
|
||||
.param("keywords", &query.keyword)
|
||||
.param("type", "1")
|
||||
.param("limit", "30");
|
||||
client.cloudsearch(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
api_call!(state, cloudsearch, params: [("keywords", &query.keyword), ("type", "1"), ("limit", "30")])
|
||||
}
|
||||
|
||||
/// 获取热搜词列表
|
||||
#[tauri::command]
|
||||
pub async fn get_hot_search(state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let q = state.build_query();
|
||||
client.search_hot_detail(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
api_call!(state, search_hot_detail)
|
||||
}
|
||||
|
||||
/// 歌单全部曲目查询参数
|
||||
#[derive(Deserialize)]
|
||||
pub struct PlaylistTrackAllQuery { pub id: u64, pub limit: Option<i64>, pub offset: Option<i64> }
|
||||
|
||||
/// 获取歌单全部歌曲
|
||||
#[tauri::command]
|
||||
pub async fn playlist_track_all(query: PlaylistTrackAllQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let q = state.build_query()
|
||||
.param("id", &query.id.to_string())
|
||||
.param("limit", &query.limit.unwrap_or(1000).to_string())
|
||||
.param("offset", &query.offset.unwrap_or(0).to_string());
|
||||
client.playlist_track_all(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
api_call!(state, playlist_track_all, params: [("id", &query.id.to_string()), ("limit", &query.limit.unwrap_or(1000).to_string()), ("offset", &query.offset.unwrap_or(0).to_string())])
|
||||
}
|
||||
|
||||
/// 歌曲播放地址查询参数
|
||||
#[derive(Deserialize)]
|
||||
pub struct SongUrlQuery { pub id: u64, pub level: Option<String>, pub fm_mode: Option<bool> }
|
||||
|
||||
/// 获取歌曲播放地址(返回完整 data 对象,包含 url、freeTrialInfo 等)
|
||||
#[tauri::command]
|
||||
pub async fn get_song_url(query: SongUrlQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let client = state.client.lock().unwrap().clone();
|
||||
let level = query.level.as_deref().unwrap_or("standard");
|
||||
|
||||
let resp = if query.fm_mode.unwrap_or(false) {
|
||||
@ -162,27 +208,19 @@ pub async fn get_song_url(query: SongUrlQuery, state: State<'_, ApiController>)
|
||||
/// 获取歌词
|
||||
#[tauri::command]
|
||||
pub async fn get_lyric(id: u64, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let q = state.build_query().param("id", &id.to_string());
|
||||
client.lyric(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
api_call!(state, lyric, params: [("id", &id.to_string())])
|
||||
}
|
||||
|
||||
/// 获取歌单详情
|
||||
#[tauri::command]
|
||||
pub async fn get_playlist_detail(id: u64, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let q = state.build_query().param("id", &id.to_string());
|
||||
client.playlist_detail(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
api_call!(state, playlist_detail, params: [("id", &id.to_string())])
|
||||
}
|
||||
|
||||
/// 手机号密码登录
|
||||
#[tauri::command]
|
||||
pub async fn login(query: LoginQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let client = state.client.lock().unwrap().clone();
|
||||
let q = Query::new()
|
||||
.param("phone", &query.phone)
|
||||
.param("password", &query.password);
|
||||
@ -208,7 +246,7 @@ pub async fn logout(state: State<'_, ApiController>) -> Result<(), String> {
|
||||
/// 获取二维码登录密钥
|
||||
#[tauri::command]
|
||||
pub async fn get_qr_key(state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let client = state.client.lock().unwrap().clone();
|
||||
let q = state.build_query();
|
||||
let resp = client.login_qr_key(&q).await.map_err(|e| e.to_string())?;
|
||||
resp.body["unikey"]
|
||||
@ -223,7 +261,7 @@ pub async fn create_qr(
|
||||
query: QrKeyQuery,
|
||||
state: State<'_, ApiController>,
|
||||
) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let client = state.client.lock().unwrap().clone();
|
||||
let q = state
|
||||
.build_query()
|
||||
.param("key", &query.key)
|
||||
@ -239,7 +277,7 @@ pub async fn create_qr(
|
||||
/// 检查二维码扫码状态
|
||||
#[tauri::command]
|
||||
pub async fn check_qr_status(query: QrKeyQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let client = state.client.lock().unwrap().clone();
|
||||
let q = state.build_query().param("key", &query.key);
|
||||
let resp = client.login_qr_check(&q).await.map_err(|e| e.to_string())?;
|
||||
if resp.body["code"].as_u64() == Some(803) && !resp.cookie.is_empty() {
|
||||
@ -253,122 +291,80 @@ pub async fn check_qr_status(query: QrKeyQuery, state: State<'_, ApiController>)
|
||||
/// 获取当前登录状态
|
||||
#[tauri::command]
|
||||
pub async fn get_login_status(state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let q = state.build_query();
|
||||
client.user_account(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
api_call!(state, user_account)
|
||||
}
|
||||
|
||||
/// 获取用户歌单列表
|
||||
#[tauri::command]
|
||||
pub async fn user_playlist(uid: u64, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let q = state.build_query().param("uid", &uid.to_string());
|
||||
let resp = client.user_playlist(&q).await.map_err(|e| e.to_string())?;
|
||||
Ok(resp.body.to_string())
|
||||
api_call!(state, user_playlist, params: [("uid", &uid.to_string())])
|
||||
}
|
||||
|
||||
/// 获取每日推荐歌曲
|
||||
#[tauri::command]
|
||||
pub async fn recommend_songs(state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let q = state.build_query();
|
||||
let resp = client.recommend_songs(&q).await.map_err(|e| e.to_string())?;
|
||||
Ok(resp.body.to_string())
|
||||
api_call!(state, recommend_songs)
|
||||
}
|
||||
|
||||
/// 获取推荐歌单
|
||||
#[tauri::command]
|
||||
pub async fn recommend_resource(state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let q = state.build_query();
|
||||
let resp = client.recommend_resource(&q).await.map_err(|e| e.to_string())?;
|
||||
Ok(resp.body.to_string())
|
||||
api_call!(state, recommend_resource)
|
||||
}
|
||||
|
||||
/// 获取私人漫游歌曲
|
||||
#[tauri::command]
|
||||
pub async fn personal_fm(state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let q = state.build_query();
|
||||
let resp = client.personal_fm(&q).await.map_err(|e| e.to_string())?;
|
||||
Ok(resp.body.to_string())
|
||||
api_call!(state, personal_fm)
|
||||
}
|
||||
|
||||
/// 获取歌曲详情
|
||||
#[tauri::command]
|
||||
pub async fn get_song_detail(id: String, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let q = state.build_query().param("ids", &id);
|
||||
let resp = client.song_detail(&q).await.map_err(|e| e.to_string())?;
|
||||
Ok(resp.body.to_string())
|
||||
api_call!(state, song_detail, params: [("ids", &id)])
|
||||
}
|
||||
|
||||
/// 用户播放记录查询参数
|
||||
#[derive(Deserialize)]
|
||||
pub struct UserRecordQuery { pub uid: u64, pub r#type: String }
|
||||
|
||||
/// 喜欢/取消喜欢歌曲查询参数
|
||||
#[derive(Deserialize)]
|
||||
pub struct LikeSongQuery { pub id: u64, pub like: String }
|
||||
|
||||
/// 获取喜欢的歌曲ID列表
|
||||
#[tauri::command]
|
||||
pub async fn likelist(uid: u64, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let q = state.build_query().param("uid", &uid.to_string());
|
||||
client.likelist(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
api_call!(state, likelist, params: [("uid", &uid.to_string())])
|
||||
}
|
||||
|
||||
/// 获取用户播放记录
|
||||
#[tauri::command]
|
||||
pub async fn user_record(query: UserRecordQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let q = state.build_query()
|
||||
.param("uid", &query.uid.to_string())
|
||||
.param("type", &query.r#type);
|
||||
client.user_record(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
api_call!(state, user_record, params: [("uid", &query.uid.to_string()), ("type", &query.r#type)])
|
||||
}
|
||||
|
||||
/// 喜欢/取消喜欢歌曲
|
||||
#[tauri::command]
|
||||
pub async fn like_song(query: LikeSongQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let q = state.build_query()
|
||||
.param("id", &query.id.to_string())
|
||||
.param("like", &query.like);
|
||||
client.like(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
api_call!(state, like, params: [("id", &query.id.to_string()), ("like", &query.like)])
|
||||
}
|
||||
|
||||
/// 上报最近播放歌曲
|
||||
#[tauri::command]
|
||||
pub async fn record_recent_song(limit: u64, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let q = state.build_query().param("limit", &limit.to_string());
|
||||
client.record_recent_song(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
api_call!(state, record_recent_song, params: [("limit", &limit.to_string())])
|
||||
}
|
||||
|
||||
/// 歌单收藏/取消收藏查询参数
|
||||
#[derive(Deserialize)]
|
||||
pub struct PlaylistSubscribeQuery { pub id: u64, pub subscribe: Option<bool> }
|
||||
|
||||
/// 收藏/取消收藏歌单
|
||||
#[tauri::command]
|
||||
pub async fn playlist_subscribe(query: PlaylistSubscribeQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let t = if query.subscribe.unwrap_or(true) { "1" } else { "0" };
|
||||
let q = state.build_query()
|
||||
.param("id", &query.id.to_string())
|
||||
.param("t", t);
|
||||
client.playlist_subscribe(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
api_call!(state, playlist_subscribe, params: [("id", &query.id.to_string()), ("t", t)])
|
||||
}
|
||||
|
||||
/// 退出应用
|
||||
@ -380,6 +376,7 @@ pub async fn exit_app(app_handle: tauri::AppHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 本地歌曲信息结构体
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LocalSongInfo {
|
||||
@ -395,6 +392,7 @@ pub struct LocalSongInfo {
|
||||
pub local: bool,
|
||||
}
|
||||
|
||||
/// 下载歌曲查询参数
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DownloadSongQuery {
|
||||
@ -408,6 +406,7 @@ pub struct DownloadSongQuery {
|
||||
pub download_path: Option<String>,
|
||||
}
|
||||
|
||||
/// 下载歌曲到本地,支持进度回调,并保存元数据文件
|
||||
#[tauri::command]
|
||||
pub async fn download_song(
|
||||
app_handle: tauri::AppHandle,
|
||||
@ -419,22 +418,18 @@ pub async fn download_song(
|
||||
let q = state.build_query()
|
||||
.param("id", &query.id.to_string())
|
||||
.param("level", level);
|
||||
let client = state.client.lock().await;
|
||||
let client = state.client.lock().unwrap().clone();
|
||||
let resp = client.song_url_v1(&q).await.map_err(|e| e.to_string())?;
|
||||
let data = &resp.body["data"][0];
|
||||
let url = data["url"].as_str().filter(|s| !s.is_empty());
|
||||
let is_vip = data.get("freeTrialInfo").is_some_and(|v| !v.is_null());
|
||||
if is_vip {
|
||||
return Err("VIP歌曲无法下载".into());
|
||||
}
|
||||
if url.is_none() {
|
||||
let free_trial = data.get("freeTrialInfo");
|
||||
if free_trial.is_some() && !free_trial.unwrap().is_null() {
|
||||
return Err("VIP歌曲无法下载".into());
|
||||
}
|
||||
return Err("暂无下载源,可能需要 VIP 权限".into());
|
||||
}
|
||||
let url = url.unwrap();
|
||||
let free_trial = data.get("freeTrialInfo");
|
||||
if free_trial.is_some() && !free_trial.unwrap().is_null() {
|
||||
return Err("VIP歌曲无法下载".into());
|
||||
}
|
||||
let ext = if url.contains(".flac") { "flac" } else { "mp3" };
|
||||
drop(client);
|
||||
|
||||
@ -505,6 +500,7 @@ pub async fn download_song(
|
||||
Ok(filename)
|
||||
}
|
||||
|
||||
/// 列出本地已下载的歌曲,优先使用元数据文件补充信息
|
||||
#[tauri::command]
|
||||
pub fn list_local_songs(app_handle: tauri::AppHandle, download_path: Option<String>) -> Result<Vec<LocalSongInfo>, String> {
|
||||
let download_dir = resolve_download_dir(&app_handle, download_path.as_deref());
|
||||
@ -600,6 +596,7 @@ pub fn list_local_songs(app_handle: tauri::AppHandle, download_path: Option<Stri
|
||||
Ok(songs)
|
||||
}
|
||||
|
||||
/// 读取音频文件的元数据(标题、艺术家、专辑、时长、封面)
|
||||
fn read_audio_metadata(path: &PathBuf) -> (String, String, String, u64, Option<String>) {
|
||||
match lofty::read_from_path(path) {
|
||||
Ok(tagged_file) => {
|
||||
@ -638,6 +635,7 @@ fn read_audio_metadata(path: &PathBuf) -> (String, String, String, u64, Option<S
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析文件名,提取艺术家和歌曲名称(支持 "艺术家 - 歌名" 格式)
|
||||
fn parse_filename(stem: &str) -> (String, String) {
|
||||
if let Some(pos) = stem.find(" - ") {
|
||||
let artist = &stem[..pos];
|
||||
@ -652,6 +650,7 @@ fn parse_filename(stem: &str) -> (String, String) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除本地歌曲查询参数
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeleteLocalSongQuery {
|
||||
@ -660,6 +659,7 @@ pub struct DeleteLocalSongQuery {
|
||||
pub download_path: Option<String>,
|
||||
}
|
||||
|
||||
/// 删除本地已下载的歌曲文件及其元数据
|
||||
#[tauri::command]
|
||||
pub fn delete_local_song(
|
||||
app_handle: tauri::AppHandle,
|
||||
@ -678,6 +678,7 @@ pub fn delete_local_song(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 检查指定歌曲是否已下载到本地
|
||||
#[tauri::command]
|
||||
pub fn check_local_song(app_handle: tauri::AppHandle, id: u64, download_path: Option<String>) -> Result<bool, String> {
|
||||
let download_dir = resolve_download_dir(&app_handle, download_path.as_deref());
|
||||
@ -685,6 +686,7 @@ pub fn check_local_song(app_handle: tauri::AppHandle, id: u64, download_path: Op
|
||||
Ok(meta_path.exists())
|
||||
}
|
||||
|
||||
/// 解析下载目录,优先使用自定义路径,否则使用默认目录
|
||||
fn resolve_download_dir(app_handle: &tauri::AppHandle, custom_path: Option<&str>) -> PathBuf {
|
||||
if let Some(path) = custom_path {
|
||||
if !path.is_empty() {
|
||||
@ -694,6 +696,7 @@ fn resolve_download_dir(app_handle: &tauri::AppHandle, custom_path: Option<&str>
|
||||
get_default_download_dir(app_handle)
|
||||
}
|
||||
|
||||
/// 获取默认下载目录,优先使用应用数据目录下的 downloads 子目录
|
||||
fn get_default_download_dir(app_handle: &tauri::AppHandle) -> PathBuf {
|
||||
if let Ok(dir) = app_handle.path().app_data_dir() {
|
||||
let download_dir = dir.join("downloads");
|
||||
@ -708,11 +711,13 @@ fn get_default_download_dir(app_handle: &tauri::AppHandle) -> PathBuf {
|
||||
music_dir.join("Nekosonic")
|
||||
}
|
||||
|
||||
/// 获取默认下载路径字符串,供前端使用
|
||||
#[tauri::command]
|
||||
pub fn get_default_download_path(app_handle: tauri::AppHandle) -> String {
|
||||
get_default_download_dir(&app_handle).to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
/// 清理文件名中的非法字符,将 `/ \ : * ? " < > |` 替换为下划线
|
||||
fn sanitize_filename(name: &str) -> String {
|
||||
name.chars()
|
||||
.map(|c| {
|
||||
@ -729,18 +734,15 @@ fn sanitize_filename(name: &str) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// 获取歌手详情
|
||||
#[tauri::command]
|
||||
pub async fn artist_detail(id: u64, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let q = state.build_query().param("id", &id.to_string());
|
||||
client.artist_detail(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
api_call!(state, artist_detail, params: [("id", &id.to_string())])
|
||||
}
|
||||
|
||||
/// 获取歌手歌曲列表
|
||||
#[tauri::command]
|
||||
pub async fn artist_songs(query: ArtistSongsQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let mut q = state.build_query().param("id", &query.id.to_string());
|
||||
if let Some(ref order) = query.order {
|
||||
q = q.param("order", order);
|
||||
@ -751,11 +753,10 @@ pub async fn artist_songs(query: ArtistSongsQuery, state: State<'_, ApiControlle
|
||||
if let Some(offset) = query.offset {
|
||||
q = q.param("offset", &offset.to_string());
|
||||
}
|
||||
client.artist_songs(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
api_call!(state, artist_songs, query: q)
|
||||
}
|
||||
|
||||
/// 歌手歌曲查询参数
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ArtistSongsQuery {
|
||||
@ -765,9 +766,9 @@ pub struct ArtistSongsQuery {
|
||||
pub offset: Option<u32>,
|
||||
}
|
||||
|
||||
/// 获取歌手专辑列表
|
||||
#[tauri::command]
|
||||
pub async fn artist_album(id: u64, limit: Option<u32>, offset: Option<u32>, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let mut q = state.build_query().param("id", &id.to_string());
|
||||
if let Some(limit) = limit {
|
||||
q = q.param("limit", &limit.to_string());
|
||||
@ -775,32 +776,24 @@ pub async fn artist_album(id: u64, limit: Option<u32>, offset: Option<u32>, stat
|
||||
if let Some(offset) = offset {
|
||||
q = q.param("offset", &offset.to_string());
|
||||
}
|
||||
client.artist_album(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
api_call!(state, artist_album, query: q)
|
||||
}
|
||||
|
||||
/// 获取歌手简介
|
||||
#[tauri::command]
|
||||
pub async fn artist_desc(id: u64, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let q = state.build_query().param("id", &id.to_string());
|
||||
client.artist_desc(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
api_call!(state, artist_desc, params: [("id", &id.to_string())])
|
||||
}
|
||||
|
||||
/// 获取专辑详情
|
||||
#[tauri::command]
|
||||
pub async fn album_detail(id: u64, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let q = state.build_query().param("id", &id.to_string());
|
||||
client.album(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
api_call!(state, album, params: [("id", &id.to_string())])
|
||||
}
|
||||
|
||||
/// 获取最新评论
|
||||
#[tauri::command]
|
||||
pub async fn comment_new(query: CommentNewQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let mut q = state.build_query()
|
||||
.param("type", &query.r#type.to_string())
|
||||
.param("id", &query.id.to_string());
|
||||
@ -816,11 +809,10 @@ pub async fn comment_new(query: CommentNewQuery, state: State<'_, ApiController>
|
||||
if let Some(cursor) = query.cursor {
|
||||
q = q.param("cursor", &cursor.to_string());
|
||||
}
|
||||
client.comment_new(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
api_call!(state, comment_new, query: q)
|
||||
}
|
||||
|
||||
/// 最新评论查询参数
|
||||
#[derive(Deserialize)]
|
||||
pub struct CommentNewQuery {
|
||||
pub r#type: u8,
|
||||
@ -834,9 +826,9 @@ pub struct CommentNewQuery {
|
||||
pub cursor: Option<u64>,
|
||||
}
|
||||
|
||||
/// 获取热门评论
|
||||
#[tauri::command]
|
||||
pub async fn comment_hot(query: CommentHotQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let mut q = state.build_query()
|
||||
.param("type", &query.r#type.to_string())
|
||||
.param("id", &query.id.to_string());
|
||||
@ -849,11 +841,10 @@ pub async fn comment_hot(query: CommentHotQuery, state: State<'_, ApiController>
|
||||
if let Some(before) = query.before {
|
||||
q = q.param("before", &before.to_string());
|
||||
}
|
||||
client.comment_hot(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
api_call!(state, comment_hot, query: q)
|
||||
}
|
||||
|
||||
/// 热门评论查询参数
|
||||
#[derive(Deserialize)]
|
||||
pub struct CommentHotQuery {
|
||||
pub r#type: u8,
|
||||
@ -863,9 +854,9 @@ pub struct CommentHotQuery {
|
||||
pub before: Option<u64>,
|
||||
}
|
||||
|
||||
/// 获取评论楼层(子评论)
|
||||
#[tauri::command]
|
||||
pub async fn comment_floor(query: CommentFloorQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let mut q = state.build_query()
|
||||
.param("parentCommentId", &query.parent_comment_id.to_string())
|
||||
.param("type", &query.r#type.to_string())
|
||||
@ -876,11 +867,10 @@ pub async fn comment_floor(query: CommentFloorQuery, state: State<'_, ApiControl
|
||||
if let Some(time) = query.time {
|
||||
q = q.param("time", &time.to_string());
|
||||
}
|
||||
client.comment_floor(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
api_call!(state, comment_floor, query: q)
|
||||
}
|
||||
|
||||
/// 评论楼层查询参数
|
||||
#[derive(Deserialize)]
|
||||
pub struct CommentFloorQuery {
|
||||
#[serde(rename = "parentCommentId")]
|
||||
@ -891,19 +881,13 @@ pub struct CommentFloorQuery {
|
||||
pub time: Option<u64>,
|
||||
}
|
||||
|
||||
/// 点赞/取消点赞评论
|
||||
#[tauri::command]
|
||||
pub async fn comment_like(query: CommentLikeQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let q = state.build_query()
|
||||
.param("t", &query.t.to_string())
|
||||
.param("type", &query.r#type.to_string())
|
||||
.param("id", &query.id.to_string())
|
||||
.param("cid", &query.cid.to_string());
|
||||
client.comment_like(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
api_call!(state, comment_like, params: [("t", &query.t.to_string()), ("type", &query.r#type.to_string()), ("id", &query.id.to_string()), ("cid", &query.cid.to_string())])
|
||||
}
|
||||
|
||||
/// 评论点赞查询参数
|
||||
#[derive(Deserialize)]
|
||||
pub struct CommentLikeQuery {
|
||||
pub t: u8,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -7,6 +7,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
mod api;
|
||||
mod audio;
|
||||
mod media_controls;
|
||||
use api::ApiController;
|
||||
use audio::AppAudio;
|
||||
|
||||
@ -25,6 +26,27 @@ pub fn run() {
|
||||
let app_audio = AppAudio(std::sync::Mutex::new(audio_controller));
|
||||
app.manage(app_audio);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use raw_window_handle::HasWindowHandle;
|
||||
use raw_window_handle::RawWindowHandle;
|
||||
let hwnd = if let Some(win) = app.get_webview_window("main") {
|
||||
win.window_handle().ok().and_then(|h| {
|
||||
if let RawWindowHandle::Win32(h) = h.as_raw() {
|
||||
Some(h.hwnd.get() as *mut std::ffi::c_void)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
media_controls::start_media_controls(app.handle().clone(), hwnd);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
media_controls::start_media_controls(app.handle().clone(), None);
|
||||
|
||||
let show = MenuItemBuilder::with_id("show", "显示窗口").build(app)?;
|
||||
let _sep1 = PredefinedMenuItem::separator(app)?;
|
||||
let prev = MenuItemBuilder::with_id("prev", "上一首").build(app)?;
|
||||
@ -144,6 +166,7 @@ pub fn run() {
|
||||
audio::get_output_devices,
|
||||
audio::set_output_device,
|
||||
audio::seek_audio,
|
||||
audio::get_audio_position,
|
||||
audio::set_volume,
|
||||
|
||||
api::download_song,
|
||||
|
||||
121
src-tauri/src/media_controls.rs
Normal file
121
src-tauri/src/media_controls.rs
Normal file
@ -0,0 +1,121 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tauri::{AppHandle, Emitter, Listener};
|
||||
use souvlaki::{
|
||||
MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback,
|
||||
PlatformConfig, SeekDirection,
|
||||
};
|
||||
|
||||
struct MediaState {
|
||||
controls: MediaControls,
|
||||
}
|
||||
|
||||
pub fn start_media_controls(app_handle: AppHandle, hwnd: Option<*mut std::ffi::c_void>) {
|
||||
let config = PlatformConfig {
|
||||
dbus_name: "nekosonic",
|
||||
display_name: "Nekosonic",
|
||||
hwnd,
|
||||
};
|
||||
|
||||
let mut controls = match MediaControls::new(config) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to create media controls: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let ah = app_handle.clone();
|
||||
if let Err(e) = controls.attach(move |event: MediaControlEvent| {
|
||||
let cmd = match &event {
|
||||
MediaControlEvent::Play => "Play",
|
||||
MediaControlEvent::Pause => "Pause",
|
||||
MediaControlEvent::Toggle => "PlayPause",
|
||||
MediaControlEvent::Next => "Next",
|
||||
MediaControlEvent::Previous => "Previous",
|
||||
MediaControlEvent::Stop => "Stop",
|
||||
MediaControlEvent::Raise => "Raise",
|
||||
MediaControlEvent::Quit => "Quit",
|
||||
MediaControlEvent::SetVolume(v) => {
|
||||
let _ = ah.emit("mpris-command", format!("SetVolume:{v}"));
|
||||
return;
|
||||
}
|
||||
MediaControlEvent::Seek(dir) => {
|
||||
let offset_us = match dir {
|
||||
SeekDirection::Forward => 5_000_000i64,
|
||||
SeekDirection::Backward => -5_000_000i64,
|
||||
};
|
||||
let _ = ah.emit("mpris-command", format!("Seek:{offset_us}"));
|
||||
return;
|
||||
}
|
||||
MediaControlEvent::SeekBy(dir, duration) => {
|
||||
let offset_us: i64 = match dir {
|
||||
SeekDirection::Forward => duration.as_micros() as i64,
|
||||
SeekDirection::Backward => -(duration.as_micros() as i64),
|
||||
};
|
||||
let _ = ah.emit("mpris-command", format!("Seek:{offset_us}"));
|
||||
return;
|
||||
}
|
||||
MediaControlEvent::SetPosition(pos) => {
|
||||
let pos_us = pos.0.as_micros() as i64;
|
||||
let _ = ah.emit("mpris-command", format!("SetPosition:{pos_us}"));
|
||||
return;
|
||||
}
|
||||
MediaControlEvent::OpenUri(_) => return,
|
||||
};
|
||||
let _ = ah.emit("mpris-command", cmd);
|
||||
}) {
|
||||
eprintln!("Failed to attach media control handler: {e}");
|
||||
return;
|
||||
}
|
||||
|
||||
let state = Arc::new(Mutex::new(MediaState { controls }));
|
||||
let state_for_listener = state.clone();
|
||||
|
||||
app_handle.listen("playback-state", move |event| {
|
||||
if let Ok(data) = serde_json::from_str::<serde_json::Value>(event.payload()) {
|
||||
let mut s = match state_for_listener.lock() {
|
||||
Ok(s) => s,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
if let Some(status) = data.get("status").and_then(|v| v.as_str()) {
|
||||
let playback = match status {
|
||||
"playing" => MediaPlayback::Playing { progress: None },
|
||||
"paused" => MediaPlayback::Paused { progress: None },
|
||||
_ => MediaPlayback::Stopped,
|
||||
};
|
||||
let _ = s.controls.set_playback(playback);
|
||||
}
|
||||
|
||||
let title = data.get("title").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let album = data.get("album").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let artists = data
|
||||
.get("artists")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|a| a.as_str().map(|s| s.to_owned()))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let artist_str = artists.join(", ");
|
||||
let cover_url = data.get("coverUrl").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let duration_us = data.get("durationUs").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
|
||||
let metadata = MediaMetadata {
|
||||
title: if title.is_empty() { None } else { Some(title) },
|
||||
album: if album.is_empty() { None } else { Some(album) },
|
||||
artist: if artist_str.is_empty() { None } else { Some(&artist_str) },
|
||||
cover_url: if cover_url.is_empty() { None } else { Some(cover_url) },
|
||||
duration: if duration_us > 0 {
|
||||
Some(std::time::Duration::from_micros(duration_us as u64))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
};
|
||||
let _ = s.controls.set_metadata(metadata);
|
||||
}
|
||||
});
|
||||
|
||||
std::mem::forget(state);
|
||||
}
|
||||
Reference in New Issue
Block a user