feat: 云盘/下载音乐分离/粘性头部/播放状态同步/歌手关注

新增:
- 音乐云盘页面(列表/详情弹窗/删除/存储空间, NOS multipart上传+LBS区域查询+进度事件)
- 下载音乐页面(独立于本地音乐, 只显示应用下载的歌曲)
- PageHeader粘性头部组件(IntersectionObserver控制显隐, 渐变模糊背景)
- useLocalMusic composable(LocalSong类型/formatFileSize/localSongToSong/fetchMissingCovers)
- 云盘上传完整流程(cloud_upload命令: check->token->LBS->NOS分块上传->info->publish)
- 云盘API(user_cloud/user_cloud_detail/user_cloud_del)
- 歌手关注/取关(artist_sub/artist_sublist命令, ArtistDetail关注按钮+artistSublist查询状态)
- 本地音乐多文件夹扫描(scan_local_folders命令, settings.localMusicPaths, 模态框管理)
- 侧边栏下载音乐和云盘导航项, 路由新增downloaded-music和cloud-music
- md5 crate依赖

改进:
- 路由全部改为懒加载
- keep-alive缓存管理重写(30s TTL+导航栈保护+FavoriteSongs常驻+10s定时清理)
- 播放器状态同步改为轮询isAudioPlaying(替代audio-started事件), 超时后watchForLatePlayback继续监听
- audio.rs新增is_playing原子状态+is_audio_playing命令
- 同步命令改async+spawn_blocking(list_local_songs/delete_local_song/check_local_song/get_default_download_path)
- scan_dir_for_songs抽取为公共函数, 新增downloaded_only参数
- RoamDrawer tab状态从组件本地ref移至store(roamTab替换roamInitialTab)
- App.vue onMounted改为非阻塞
- 多页面添加骨架屏加载态和加载失败重试
- 多页面使用PageHeader替代手动返回按钮
- PlaylistDetail/ArtistDetail添加简介弹窗(溢出时显示查看完整介绍)
- Home推荐/排行榜拆分为独立fetch函数支持分别重试
- Toast去重(3s窗口)+数量限制(最多3条)
- LocalMusic移除删除功能改文件夹模态框, ArtistDetail头像改圆形简介内嵌
- README重写

修复:
- 播放超时后后端实际开始播放但UI显示暂停(watchForLatePlayback+tick定期同步isAudioPlaying)
- FM播放缺少playSeq竞态保护
- scrobble离线时仍发送(添加navigator.onLine检查)
- RoamDrawer已打开时点击评论按钮无法切换(roamTab移至store)
- 关闭RoamDrawer后再打开永远显示评论(closeRoamDrawer重置roamTab)
- 歌手详情页关注状态离开后丢失(artist_detail不返回followed, 改用artistSublist查询)
- audio-ended事件在切歌时误触发(新增_switchingSong标志拦截)
- 路由beforeEach中localStorage key从user改为user_profile
- toggle播放前先同步后端状态
This commit is contained in:
2026-06-04 07:03:27 +08:00
parent e40f82cc51
commit 3535e2e8a0
30 changed files with 2516 additions and 782 deletions

471
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[package]
name = "Nekosonic"
version = "0.6.0"
version = "0.7.0"
description = "A Simple music app"
authors = ["atdunbg"]
edition = "2021"
@ -33,6 +33,7 @@ futures-util = "0.3"
dirs = "5"
lofty = "0.22"
base64 = "0.22"
md5 = "0.7"
ncm-api-rs = "0.1"
tokio = { version = "1", features = ["rt", "sync"] }

View File

@ -2,6 +2,7 @@ use ncm_api_rs::{create_client, ApiClient, Query};
use serde::Deserialize;
use serde_json::json;
use tauri::{Manager, State, Emitter};
use tokio::sync::Mutex as AsyncMutex;
use std::sync::Mutex as StdMutex;
use std::sync::atomic::Ordering;
@ -44,14 +45,14 @@ use base64::Engine;
/// ```
macro_rules! api_call {
($state:expr, $method:ident) => {{
let client = $state.client.lock().unwrap().clone();
let client = $state.client.lock().await.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 client = $state.client.lock().await.clone();
let mut q = $state.build_query();
$(q = q.param($key, $val);)*
client.$method(&q).await
@ -59,7 +60,7 @@ macro_rules! api_call {
.map_err(|e| e.to_string())
}};
($state:expr, $method:ident, query: $q:expr) => {{
let client = $state.client.lock().unwrap().clone();
let client = $state.client.lock().await.clone();
client.$method(&$q).await
.map(|r| r.body.to_string())
.map_err(|e| e.to_string())
@ -67,7 +68,7 @@ macro_rules! api_call {
}
pub struct ApiController {
client: StdMutex<ApiClient>,
client: AsyncMutex<ApiClient>,
cookie: StdMutex<Option<String>>,
cookie_path: PathBuf,
}
@ -94,7 +95,7 @@ impl ApiController {
let client = create_client(None);
ApiController {
client: StdMutex::new(client),
client: AsyncMutex::new(client),
cookie: StdMutex::new(saved_cookie),
cookie_path,
}
@ -111,11 +112,10 @@ impl ApiController {
query
}
/// 将 Cookie 字符串持久化到本地文件并同步到 API 客户端
fn save_cookie(&self, cookie_str: &str) {
async 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());
}
let mut client = self.client.lock().await;
client.set_cookie(cookie_str.to_string());
}
}
@ -191,7 +191,7 @@ pub struct SongUrlQuery { pub id: u64, pub level: Option<String>, pub fm_mode: O
/// 获取歌曲播放地址(返回完整 data 对象,包含 url、freeTrialInfo 等)
#[tauri::command]
pub async fn get_song_url(query: SongUrlQuery, state: State<'_, ApiController>) -> Result<String, String> {
let client = state.client.lock().unwrap().clone();
let client = state.client.lock().await.clone();
let level = query.level.as_deref().unwrap_or("standard");
let resp = if query.fm_mode.unwrap_or(false) {
@ -251,7 +251,7 @@ pub async fn get_playlist_detail(id: u64, state: State<'_, ApiController>) -> Re
/// 手机号密码登录
#[tauri::command]
pub async fn login(query: LoginQuery, state: State<'_, ApiController>) -> Result<String, String> {
let client = state.client.lock().unwrap().clone();
let client = state.client.lock().await.clone();
let q = Query::new()
.param("phone", &query.phone)
.param("password", &query.password);
@ -260,7 +260,7 @@ pub async fn login(query: LoginQuery, state: State<'_, ApiController>) -> Result
if !resp.cookie.is_empty() {
let cookie_str = cookies_to_key_values(&resp.cookie);
*state.cookie.lock().map_err(|e| e.to_string())? = Some(cookie_str.clone());
state.save_cookie(&cookie_str);
state.save_cookie(&cookie_str).await;
}
Ok(resp.body.to_string())
@ -277,7 +277,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().unwrap().clone();
let client = state.client.lock().await.clone();
let q = state.build_query();
let resp = client.login_qr_key(&q).await.map_err(|e| e.to_string())?;
resp.body["unikey"]
@ -292,7 +292,7 @@ pub async fn create_qr(
query: QrKeyQuery,
state: State<'_, ApiController>,
) -> Result<String, String> {
let client = state.client.lock().unwrap().clone();
let client = state.client.lock().await.clone();
let q = state
.build_query()
.param("key", &query.key)
@ -308,13 +308,13 @@ 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().unwrap().clone();
let client = state.client.lock().await.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() {
let cookie_str = cookies_to_key_values(&resp.cookie);
*state.cookie.lock().map_err(|e| e.to_string())? = Some(cookie_str.clone());
state.save_cookie(&cookie_str);
state.save_cookie(&cookie_str).await;
}
Ok(resp.body.to_string())
}
@ -500,7 +500,7 @@ pub async fn download_song(
let q = state.build_query()
.param("id", &query.id.to_string())
.param("level", level);
let client = state.client.lock().unwrap().clone();
let client = state.client.lock().await.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());
@ -582,18 +582,17 @@ 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());
if !download_dir.exists() {
/// 扫描指定目录下的音频文件,优先使用元数据文件补充信息
/// `downloaded_only` 为 true 时,只返回有对应 .json 元数据的文件(即通过应用下载的)
fn scan_dir_for_songs(dir: &PathBuf, downloaded_only: bool) -> Result<Vec<LocalSongInfo>, String> {
if !dir.exists() {
return Ok(Vec::new());
}
let audio_exts = ["mp3", "flac", "wav", "ogg", "aac", "m4a", "wma", "opus"];
let mut meta_map: std::collections::HashMap<String, serde_json::Value> = std::collections::HashMap::new();
let entries = fs::read_dir(&download_dir).map_err(|e| format!("读取目录失败: {}", e))?;
let entries = fs::read_dir(dir).map_err(|e| format!("读取目录失败: {}", e))?;
for entry in entries.flatten() {
let path = entry.path();
@ -609,7 +608,7 @@ pub fn list_local_songs(app_handle: tauri::AppHandle, download_path: Option<Stri
}
let mut songs: Vec<LocalSongInfo> = Vec::new();
let entries = fs::read_dir(&download_dir).map_err(|e| format!("读取目录失败: {}", e))?;
let entries = fs::read_dir(dir).map_err(|e| format!("读取目录失败: {}", e))?;
for entry in entries.flatten() {
let path = entry.path();
@ -621,6 +620,11 @@ pub fn list_local_songs(app_handle: tauri::AppHandle, download_path: Option<Stri
let filename = path.file_name().unwrap_or_default().to_string_lossy().to_string();
let file_size = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
// 下载音乐模式:只显示有 .json 元数据的文件
if downloaded_only && !meta_map.contains_key(&filename) {
continue;
}
let (title, artist, album, duration_ms, cover_b64) = read_audio_metadata(&path);
if let Some(meta) = meta_map.get(&filename) {
@ -678,6 +682,36 @@ pub fn list_local_songs(app_handle: tauri::AppHandle, download_path: Option<Stri
Ok(songs)
}
/// 列出本地已下载的歌曲(下载目录)
#[tauri::command]
pub async 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());
tokio::task::spawn_blocking(move || {
scan_dir_for_songs(&download_dir, true) // 只显示下载的歌曲
}).await.map_err(|e| format!("扫描任务失败: {}", e))?
}
/// 扫描多个本地文件夹中的音频文件
#[tauri::command]
pub async fn scan_local_folders(paths: Vec<String>) -> Result<Vec<LocalSongInfo>, String> {
tokio::task::spawn_blocking(move || {
let mut all_songs: Vec<LocalSongInfo> = Vec::new();
let mut seen_paths: std::collections::HashSet<String> = std::collections::HashSet::new();
for p in &paths {
let dir = PathBuf::from(p);
let songs = scan_dir_for_songs(&dir, false)?; // 本地音乐:显示所有音频
for song in songs {
if seen_paths.insert(song.path.clone()) {
all_songs.push(song);
}
}
}
Ok(all_songs)
}).await.map_err(|e| format!("扫描任务失败: {}", e))?
}
/// 读取音频文件的元数据(标题、艺术家、专辑、时长、封面)
fn read_audio_metadata(path: &PathBuf) -> (String, String, String, u64, Option<String>) {
match lofty::read_from_path(path) {
@ -743,29 +777,33 @@ pub struct DeleteLocalSongQuery {
/// 删除本地已下载的歌曲文件及其元数据
#[tauri::command]
pub fn delete_local_song(
pub async fn delete_local_song(
app_handle: tauri::AppHandle,
query: DeleteLocalSongQuery,
) -> Result<(), String> {
let download_dir = resolve_download_dir(&app_handle, query.download_path.as_deref());
let file_path = download_dir.join(&query.filename);
let meta_path = download_dir.join(format!("{}.json", query.id));
tokio::task::spawn_blocking(move || {
let file_path = download_dir.join(&query.filename);
let meta_path = download_dir.join(format!("{}.json", query.id));
if file_path.exists() {
fs::remove_file(&file_path).map_err(|e| format!("删除文件失败: {}", e))?;
}
if meta_path.exists() {
fs::remove_file(&meta_path).map_err(|e| format!("删除元数据失败: {}", e))?;
}
Ok(())
if file_path.exists() {
fs::remove_file(&file_path).map_err(|e| format!("删除文件失败: {}", e))?;
}
if meta_path.exists() {
fs::remove_file(&meta_path).map_err(|e| format!("删除元数据失败: {}", e))?;
}
Ok(())
}).await.map_err(|e| format!("删除任务失败: {}", e))?
}
/// 检查指定歌曲是否已下载到本地
#[tauri::command]
pub fn check_local_song(app_handle: tauri::AppHandle, id: u64, download_path: Option<String>) -> Result<bool, String> {
pub async 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());
let meta_path = download_dir.join(format!("{}.json", id));
Ok(meta_path.exists())
tokio::task::spawn_blocking(move || {
let meta_path = download_dir.join(format!("{}.json", id));
Ok(meta_path.exists())
}).await.map_err(|e| format!("检查任务失败: {}", e))?
}
/// 解析下载目录,优先使用自定义路径,否则使用默认目录
@ -795,7 +833,7 @@ fn get_default_download_dir(app_handle: &tauri::AppHandle) -> PathBuf {
/// 获取默认下载路径字符串,供前端使用
#[tauri::command]
pub fn get_default_download_path(app_handle: tauri::AppHandle) -> String {
pub async fn get_default_download_path(app_handle: tauri::AppHandle) -> String {
get_default_download_dir(&app_handle).to_string_lossy().to_string()
}
@ -867,6 +905,28 @@ pub async fn artist_desc(id: u64, state: State<'_, ApiController>) -> Result<Str
api_call!(state, artist_desc, params: [("id", &id.to_string())])
}
/// 关注/取消关注歌手
#[derive(Deserialize)]
pub struct ArtistSubQuery { pub id: u64, pub sub: Option<bool> }
#[tauri::command]
pub async fn artist_sub(query: ArtistSubQuery, state: State<'_, ApiController>) -> Result<String, String> {
let t = if query.sub.unwrap_or(true) { "1" } else { "0" };
api_call!(state, artist_sub, params: [("id", &query.id.to_string()), ("t", t)])
}
/// 获取已关注的歌手列表
#[derive(Deserialize)]
pub struct ArtistSublistQuery { pub limit: Option<u32>, pub offset: Option<u32> }
#[tauri::command]
pub async fn artist_sublist(query: ArtistSublistQuery, state: State<'_, ApiController>) -> Result<String, String> {
let q = state.build_query()
.param("limit", &query.limit.unwrap_or(100).to_string())
.param("offset", &query.offset.unwrap_or(0).to_string());
api_call!(state, artist_sublist, query: &q)
}
/// 获取专辑详情
#[tauri::command]
pub async fn album_detail(id: u64, state: State<'_, ApiController>) -> Result<String, String> {
@ -977,3 +1037,288 @@ pub struct CommentLikeQuery {
pub id: u64,
pub cid: u64,
}
// ==================== 云盘 ====================
/// 获取云盘列表
#[tauri::command]
pub async fn user_cloud(limit: Option<u32>, offset: Option<u32>, state: State<'_, ApiController>) -> Result<String, String> {
api_call!(state, user_cloud, params: [("limit", &limit.unwrap_or(30).to_string()), ("offset", &offset.unwrap_or(0).to_string())])
}
/// 获取云盘歌曲详情
#[tauri::command]
pub async fn user_cloud_detail(id: String, state: State<'_, ApiController>) -> Result<String, String> {
api_call!(state, user_cloud_detail, params: [("id", &id)])
}
/// 删除云盘歌曲
#[tauri::command]
pub async fn user_cloud_del(id: u64, state: State<'_, ApiController>) -> Result<String, String> {
api_call!(state, user_cloud_del, params: [("id", &id.to_string())])
}
/// 查询 NOS LBS 获取上传节点域名
///
/// 通过 `http://wannos.127.net/lbs` 查询指定 bucket 的上传节点,
/// 从返回的 `nosup-<region><n>.127.net` 中提取区域标识,
/// 构造 multipart upload 所需的 `<bucket>.nos-<region>.163yun.com` 域名。
async fn query_nos_upload_host(bucket: &str) -> Result<String, String> {
let lbs_url = format!("http://wannos.127.net/lbs?version=1.0&bucketname={}", bucket);
let http = reqwest::Client::new();
let resp = http.get(&lbs_url).send().await
.map_err(|e| format!("LBS查询请求失败: {}", e))?;
let lbs_data: serde_json::Value = resp.json().await
.map_err(|e| format!("LBS响应解析失败: {}", e))?;
let nosup_url = lbs_data["upload"][0].as_str()
.ok_or_else(|| format!("LBS响应缺少upload字段: {}", lbs_data))?;
// 从 "nosup-jd1.127.net" 中提取区域 "jd"
let region = extract_nos_region(nosup_url)?;
Ok(format!("https://{}.nos-{}.163yun.com", bucket, region))
}
/// 从 nosup 上传节点 URL 中提取 NOS 区域标识
///
/// 例如: `http://nosup-jd1.127.net` → `jd`
/// `http://nosup-hz1.127.net` → `hz`
fn extract_nos_region(nosup_url: &str) -> Result<String, String> {
let start = nosup_url.find("nosup-")
.ok_or_else(|| format!("无法从LBS响应中解析区域: {}", nosup_url))?;
let after = &nosup_url[start + 6..];
let dot = after.find('.')
.ok_or_else(|| format!("无法从LBS响应中解析区域: {}", nosup_url))?;
let region_with_num = &after[..dot];
// 去掉末尾数字: "jd1" → "jd"
let region: String = region_with_num
.chars()
.take_while(|c| !c.is_ascii_digit())
.collect();
if region.is_empty() {
return Err(format!("区域标识为空: {}", nosup_url));
}
Ok(region)
}
/// 云盘上传:完整流程(检查 → [获取Token → LBS查询 → NOS上传] → 提交信息 → 发布)
///
/// 关键发现upload check 返回的 songId 是十六进制字符串(如 MD5 摘要),不是数字 ID。
/// needUpload=false 表示文件已在 NOS 上,无需重复上传,直接走 info+pub 即可。
/// 参考 ydq/netease-cloud-disk-music-upload 实现。
#[tauri::command]
pub async fn cloud_upload(file_path: String, app_handle: tauri::AppHandle, state: State<'_, ApiController>) -> Result<String, String> {
let path = PathBuf::from(&file_path);
if !path.exists() {
return Err("文件不存在".to_string());
}
let file_bytes = fs::read(&path).map_err(|e| format!("读取文件失败: {}", e))?;
let file_size = file_bytes.len() as i64;
let filename = path.file_name().unwrap_or_default().to_string_lossy().to_string();
// 计算 MD5
let md5_hex = format!("{:x}", md5::compute(&file_bytes));
// 读取音频元数据
let (song_name, artist, album, _duration, _cover) = read_audio_metadata(&path);
let bitrate = {
match lofty::read_from_path(&path) {
Ok(tf) => {
let br = tf.properties().audio_bitrate().unwrap_or(0) * 1000;
if br > 0 { br.to_string() } else { "999000".to_string() }
}
Err(_) => "999000".to_string()
}
};
// 文件扩展名
let ext = if filename.contains('.') {
filename.rsplit('.').next().unwrap_or("mp3").to_string()
} else {
"mp3".to_string()
};
let client = state.client.lock().await.clone();
// Step 1: 上传检查
let check_q = state.build_query()
.param("bitrate", &bitrate)
.param("length", &file_size.to_string())
.param("md5", &md5_hex)
.param("ext", &ext)
.param("songId", "0");
let check_res = client.cloud_upload_check(&check_q).await
.map_err(|e| format!("上传检查失败: {}", e))?;
let check_data = &check_res.body;
// songId 是十六进制字符串(非数字),需要保持字符串传递
let song_id = check_data["songId"].as_str().unwrap_or("0").to_string();
let need_upload = check_data["needUpload"].as_bool().unwrap_or(true);
let mut resource_id = String::new();
// Step 2-4: 仅在 needUpload=true 时执行 NOS 上传
if need_upload {
// Step 2: 获取 NOS 上传 Token
let token_q = state.build_query()
.param("filename", &filename)
.param("md5", &md5_hex);
let token_res = client.cloud_upload_token_alloc(&token_q).await
.map_err(|e| format!("获取上传Token失败: {}", e))?;
let token_data = &token_res.body;
resource_id = token_data["result"]["resourceId"].as_str().unwrap_or("").to_string();
let object_key_raw = token_data["result"]["objectKey"].as_str().unwrap_or("").to_string();
let object_key = object_key_raw.replace('/', "%2F");
let token_str = token_data["result"]["token"].as_str().unwrap_or("").to_string();
if token_str.is_empty() {
return Err(format!("获取上传Token为空, 响应: {}", token_data));
}
// Step 3: 查询 LBS 获取正确的 NOS 上传节点
let bucket = "jd-musicrep-privatecloud-audio-public";
let nos_host = query_nos_upload_host(bucket).await
.unwrap_or_else(|_| format!("https://{}.nos-jd.163yun.com", bucket));
let content_type = match ext.as_str() {
"flac" => "audio/flac",
"wav" => "audio/wav",
"ogg" => "audio/ogg",
"aac" | "m4a" => "audio/aac",
_ => "audio/mpeg",
};
// Step 4: 上传文件到 NOSmultipart upload
let http_client = reqwest::Client::new();
// 4a: 初始化 multipart upload
let init_url = format!("{}/{}?uploads", nos_host, object_key);
let init_res = http_client.post(&init_url)
.header("x-nos-token", &token_str)
.header("X-Nos-Meta-Content-Type", content_type)
.send()
.await
.map_err(|e| format!("初始化NOS上传失败: {}", e))?;
let init_status = init_res.status();
let init_xml = init_res.text().await.map_err(|e| format!("读取NOS响应失败: {}", e))?;
if !init_status.is_success() {
return Err(format!("初始化NOS上传失败: HTTP {} 响应: {}", init_status, init_xml));
}
// 解析 UploadId
let upload_id = init_xml
.split("<UploadId>")
.nth(1)
.and_then(|s| s.split("</UploadId>").next())
.unwrap_or_default()
.to_string();
if upload_id.is_empty() {
return Err(format!("获取UploadId失败, NOS响应: {}", init_xml));
}
// 4b: 分块上传(每块 10MB
let block_size = 10 * 1024 * 1024;
let file_size_usize = file_bytes.len();
let mut offset = 0usize;
let mut block_index = 1u32;
let mut etags = Vec::new();
let total_blocks = ((file_size_usize + block_size - 1) / block_size).max(1);
while offset < file_size_usize {
let end = (offset + block_size).min(file_size_usize);
let chunk = file_bytes[offset..end].to_vec();
let part_url = format!(
"{}/{}?partNumber={}&uploadId={}",
nos_host, object_key, block_index, upload_id
);
let part_res = http_client.put(&part_url)
.header("x-nos-token", &token_str)
.header("Content-Type", content_type)
.body(chunk)
.send()
.await
.map_err(|e| format!("上传分块{}失败: {}", block_index, e))?;
if let Some(etag) = part_res.headers().get("etag") {
etags.push(etag.to_str().unwrap_or_default().to_string());
}
// 发送上传进度
let progress = (block_index as f64 / total_blocks as f64 * 100.0).min(100.0);
let _ = app_handle.emit("cloud-upload-progress", json!({
"filename": filename,
"progress": progress,
"uploaded": end,
"total": file_size_usize,
}));
offset = end;
block_index += 1;
}
// 4c: 完成 multipart upload
let mut complete_xml = String::from("<CompleteMultipartUpload>");
for (i, etag) in etags.iter().enumerate() {
complete_xml.push_str(&format!(
"<Part><PartNumber>{}</PartNumber><ETag>{}</ETag></Part>",
i + 1, etag
));
}
complete_xml.push_str("</CompleteMultipartUpload>");
let complete_url = format!("{}/{}?uploadId={}", nos_host, object_key, upload_id);
let complete_res = http_client.post(&complete_url)
.header("Content-Type", "text/plain;charset=UTF-8")
.header("X-Nos-Meta-Content-Type", content_type)
.header("x-nos-token", &token_str)
.body(complete_xml)
.send()
.await
.map_err(|e| format!("完成NOS上传失败: {}", e))?;
if !complete_res.status().is_success() {
let status = complete_res.status();
let body = complete_res.text().await.unwrap_or_default();
return Err(format!("完成NOS上传失败: HTTP {} 响应: {}", status, body));
}
}
// Step 5: 提交歌曲信息songId 使用 check 返回的字符串值)
let info_q = state.build_query()
.param("md5", &md5_hex)
.param("songId", &song_id)
.param("filename", &filename)
.param("song", &song_name)
.param("album", &album)
.param("artist", &artist)
.param("bitrate", &bitrate)
.param("resourceId", &resource_id);
let info_res = client.cloud_upload_info(&info_q).await
.map_err(|e| format!("提交歌曲信息失败: {}", e))?;
let info_data = &info_res.body;
// info 可能返回新的 songId优先使用
let final_song_id = info_data["songId"].as_str()
.filter(|s| s != &"0" && !s.is_empty())
.unwrap_or(&song_id)
.to_string();
// Step 6: 发布
let pub_q = state.build_query().param("songId", &final_song_id);
let pub_res = client.cloud_publish(&pub_q).await
.map_err(|e| format!("发布失败: {}", e))?;
let _ = app_handle.emit("cloud-upload-complete", &filename);
Ok(pub_res.body.to_string())
}

View File

@ -35,6 +35,7 @@ pub struct AudioController {
tx: Sender<AudioCmd>,
current_url: Arc<Mutex<Option<String>>>,
position: Arc<Mutex<f64>>,
is_playing: Arc<AtomicBool>,
}
impl AudioController {
@ -43,11 +44,13 @@ impl AudioController {
let (tx, rx) = channel();
let current_url = Arc::new(Mutex::new(None));
let position = Arc::new(Mutex::new(0.0));
let is_playing = Arc::new(AtomicBool::new(false));
let url_clone = current_url.clone();
let pos_clone = position.clone();
let playing_clone = is_playing.clone();
let ah_clone = app_handle.clone();
thread::spawn(move || audio_thread(rx, url_clone, pos_clone, ah_clone));
AudioController { tx, current_url, position }
thread::spawn(move || audio_thread(rx, url_clone, pos_clone, playing_clone, ah_clone));
AudioController { tx, current_url, position, is_playing }
}
/// 播放指定URL的网络音频
@ -78,6 +81,9 @@ impl AudioController {
pub fn get_position(&self) -> f64 {
*self.position.lock().unwrap()
}
pub fn get_is_playing(&self) -> bool {
self.is_playing.load(Ordering::Relaxed)
}
}
/// 缓冲区内部状态,存储已下载的字节数据及完成/取消标志
@ -908,7 +914,7 @@ fn restart_playback_on_device_change(
}
/// 音频线程主循环,接收命令并管理播放生命周期,包括设备热切换和播放结束检测
fn audio_thread(rx: Receiver<AudioCmd>, _current_url: Arc<Mutex<Option<String>>>, shared_position: Arc<Mutex<f64>>, app_handle: AppHandle) {
fn audio_thread(rx: Receiver<AudioCmd>, _current_url: Arc<Mutex<Option<String>>>, shared_position: Arc<Mutex<f64>>, is_playing: Arc<AtomicBool>, app_handle: AppHandle) {
let mut selected_device: Option<String> = None;
let mut current_volume: f32 = 1.0;
let mut output_ctx: Option<OutputContext> = None;
@ -925,6 +931,7 @@ fn audio_thread(rx: Receiver<AudioCmd>, _current_url: Arc<Mutex<Option<String>>>
AudioCmd::Play(url) => {
audio_active = false;
audio_paused = false;
is_playing.store(false, Ordering::Relaxed);
manual_stop = false;
current_local_path = None;
@ -971,6 +978,7 @@ fn audio_thread(rx: Receiver<AudioCmd>, _current_url: Arc<Mutex<Option<String>>>
Ok(ctx) => {
output_ctx = Some(ctx);
audio_active = true;
is_playing.store(true, Ordering::Relaxed);
let _ = app_handle.emit("audio-started", ());
}
Err(e) => {
@ -982,6 +990,7 @@ fn audio_thread(rx: Receiver<AudioCmd>, _current_url: Arc<Mutex<Option<String>>>
AudioCmd::PlayLocal(path) => {
audio_active = false;
audio_paused = false;
is_playing.store(false, Ordering::Relaxed);
manual_stop = false;
current_local_path = Some(path.clone());
@ -1008,6 +1017,7 @@ fn audio_thread(rx: Receiver<AudioCmd>, _current_url: Arc<Mutex<Option<String>>>
Ok(ctx) => {
output_ctx = Some(ctx);
audio_active = true;
is_playing.store(true, Ordering::Relaxed);
let _ = app_handle.emit("audio-started", ());
}
Err(e) => {
@ -1018,6 +1028,7 @@ fn audio_thread(rx: Receiver<AudioCmd>, _current_url: Arc<Mutex<Option<String>>>
AudioCmd::Pause => {
audio_paused = true;
is_playing.store(false, Ordering::Relaxed);
if let Some(ref ctx) = output_ctx {
ctx.playback.playing.store(false, Ordering::Relaxed);
}
@ -1025,6 +1036,9 @@ fn audio_thread(rx: Receiver<AudioCmd>, _current_url: Arc<Mutex<Option<String>>>
AudioCmd::Resume => {
audio_paused = false;
if audio_active {
is_playing.store(true, Ordering::Relaxed);
}
if let Some(ref ctx) = output_ctx {
ctx.playback.playing.store(true, Ordering::Relaxed);
}
@ -1033,6 +1047,7 @@ fn audio_thread(rx: Receiver<AudioCmd>, _current_url: Arc<Mutex<Option<String>>>
AudioCmd::Stop => {
audio_active = false;
audio_paused = false;
is_playing.store(false, Ordering::Relaxed);
manual_stop = true;
stop_playback(&mut output_ctx, &shared_position);
if let Some(ref buf) = current_audio_buffer {
@ -1054,6 +1069,7 @@ fn audio_thread(rx: Receiver<AudioCmd>, _current_url: Arc<Mutex<Option<String>>>
output_ctx = Some(ctx);
audio_active = true;
audio_paused = false;
is_playing.store(true, Ordering::Relaxed);
}
Err(e) => {
eprintln!("[audio] seek 播放失败: {}", e);
@ -1097,6 +1113,7 @@ fn audio_thread(rx: Receiver<AudioCmd>, _current_url: Arc<Mutex<Option<String>>>
&& ctx.playback.buffer_exhausted.load(Ordering::Relaxed)
&& !manual_stop && !audio_paused {
audio_active = false;
is_playing.store(false, Ordering::Relaxed);
let _ = app_handle.emit("audio-ended", ());
}
let pos = ctx.playback.position();
@ -1204,3 +1221,8 @@ pub fn get_audio_position(state: State<'_, AppAudio>) -> f64 {
pub fn set_volume(state: State<'_, AppAudio>, vol: f32) {
if let Ok(ctrl) = state.0.lock() { ctrl.set_volume(vol); }
}
#[tauri::command]
pub fn is_audio_playing(state: State<'_, AppAudio>) -> bool {
if let Ok(ctrl) = state.0.lock() { ctrl.get_is_playing() } else { false }
}

View File

@ -173,9 +173,11 @@ pub fn run() {
audio::seek_audio,
audio::get_audio_position,
audio::set_volume,
audio::is_audio_playing,
api::download_song,
api::list_local_songs,
api::scan_local_folders,
api::delete_local_song,
api::check_local_song,
api::get_default_download_path,
@ -184,11 +186,17 @@ pub fn run() {
api::artist_songs,
api::artist_album,
api::artist_desc,
api::artist_sub,
api::artist_sublist,
api::album_detail,
api::comment_new,
api::comment_hot,
api::comment_floor,
api::comment_like,
api::user_cloud,
api::user_cloud_detail,
api::user_cloud_del,
api::cloud_upload,
])
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_updater::Builder::new().build())

View File

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Nekosonic",
"version": "0.6.0",
"version": "0.7.0",
"identifier": "com.atdunbg.Nekosonic",
"build": {
"beforeDevCommand": "npm run dev",