第一次提交
7
src-tauri/.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
6165
src-tauri/Cargo.lock
generated
Normal file
31
src-tauri/Cargo.toml
Normal file
@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "Nekosonic"
|
||||
version = "0.1.0"
|
||||
description = "A Simple music app"
|
||||
authors = ["atdunbg"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "demo_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-opener = "2"
|
||||
rodio = "0.20"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
cpal = { version = "0.15" }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
|
||||
|
||||
ncm-api-rs = "0.1"
|
||||
tokio = { version = "1", features = ["rt", "sync"] }
|
||||
|
||||
3
src-tauri/build.rs
Normal file
@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
16
src-tauri/capabilities/default.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-maximize",
|
||||
"core:window:allow-unmaximize",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-toggle-maximize"
|
||||
]
|
||||
}
|
||||
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 141 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 238 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 324 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
1
src-tauri/netease_cookies.json
Normal file
@ -0,0 +1 @@
|
||||
NMTID=00OvETy78e8ay9VhUTKgcUSdB6-yKQAAAGeAJacOg
|
||||
253
src-tauri/src/api.rs
Normal file
@ -0,0 +1,253 @@
|
||||
use ncm_api_rs::{create_client, ApiClient, Query};
|
||||
use serde::Deserialize;
|
||||
use tauri::State;
|
||||
use tokio::sync::Mutex; // 异步 Mutex
|
||||
use std::sync::Mutex as StdMutex; // 同步 Mutex 用于 cookie
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct ApiController {
|
||||
client: Mutex<ApiClient>,
|
||||
cookie: StdMutex<Option<String>>,
|
||||
cookie_path: PathBuf,
|
||||
}
|
||||
|
||||
fn cookies_to_key_values(cookies: &[String]) -> String {
|
||||
cookies
|
||||
.iter()
|
||||
.filter_map(|c| c.split(';').next()) // 取第一个键值对
|
||||
.map(|s| s.trim().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
}
|
||||
|
||||
impl ApiController {
|
||||
|
||||
pub fn new() -> Self {
|
||||
let cookie_path = std::env::temp_dir().join("netease_cookies.json");
|
||||
let saved_cookie = fs::read_to_string(&cookie_path)
|
||||
.map(|s| s.trim().to_string())
|
||||
.ok(); // 注意这里返回 Option<String>
|
||||
// eprintln!("[api] 启动时加载 cookie: {:?}", saved_cookie);
|
||||
|
||||
let client = create_client(None); // 不依赖客户端存储,我们自己管理
|
||||
ApiController {
|
||||
client: Mutex::new(client),
|
||||
cookie: StdMutex::new(saved_cookie),
|
||||
cookie_path,
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
// eprintln!("[api] 请求携带 cookie: {}", c);
|
||||
query = query.cookie(c);
|
||||
}
|
||||
}
|
||||
query
|
||||
}
|
||||
/// 保存 cookie 到文件
|
||||
fn save_cookie(&self, cookie_str: &str) {
|
||||
let _ = fs::write(&self.cookie_path, cookie_str);
|
||||
}
|
||||
}
|
||||
|
||||
#[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())
|
||||
}
|
||||
|
||||
// 获取热搜词
|
||||
#[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())
|
||||
}
|
||||
|
||||
|
||||
// 获取歌曲链接
|
||||
#[tauri::command]
|
||||
pub async fn get_song_url(id: u64, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let q = state.build_query()
|
||||
.param("id", &id.to_string())
|
||||
.param("level", "standard");
|
||||
let resp = client.song_url_v1(&q).await.map_err(|e| e.to_string())?;
|
||||
resp.body["data"][0]["url"].as_str()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| "暂无播放源".into())
|
||||
}
|
||||
|
||||
|
||||
// 获取歌词
|
||||
#[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())
|
||||
}
|
||||
|
||||
|
||||
// 获取歌单详情
|
||||
#[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())
|
||||
}
|
||||
|
||||
// 登录
|
||||
#[tauri::command]
|
||||
pub async fn login(query: LoginQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let q = Query::new()
|
||||
.param("phone", &query.phone)
|
||||
.param("password", &query.password);
|
||||
let resp = client.login_cellphone(&q).await.map_err(|e| e.to_string())?;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
Ok(resp.body.to_string())
|
||||
}
|
||||
|
||||
// 登出
|
||||
#[tauri::command]
|
||||
pub async fn logout(state: State<'_, ApiController>) -> Result<(), String> {
|
||||
// 清除内存中的 cookie
|
||||
*state.cookie.lock().map_err(|e| e.to_string())? = None;
|
||||
// 删除持久化文件
|
||||
let _ = fs::remove_file(&state.cookie_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// 获取二维码key
|
||||
#[tauri::command]
|
||||
pub async fn get_qr_key(state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let q = state.build_query();
|
||||
let resp = client.login_qr_key(&q).await.map_err(|e| e.to_string())?;
|
||||
resp.body["unikey"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| "缺少 unikey".into())
|
||||
}
|
||||
|
||||
// 创建二维码, 功能暂时有问题
|
||||
#[tauri::command]
|
||||
pub async fn create_qr(
|
||||
query: QrKeyQuery,
|
||||
state: State<'_, ApiController>,
|
||||
) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let q = state
|
||||
.build_query()
|
||||
.param("key", &query.key)
|
||||
.param("qrimg", "true");
|
||||
let resp = client.login_qr_create(&q).await.map_err(|e| e.to_string())?;
|
||||
// 提取 qrurl 字段(网易云新的返回格式)
|
||||
let qrurl = resp.body["data"]["qrurl"]
|
||||
.as_str()
|
||||
.ok_or("未获取到二维码链接")?
|
||||
.to_string();
|
||||
Ok(qrurl)
|
||||
}
|
||||
|
||||
// 检查二维码状态
|
||||
#[tauri::command]
|
||||
pub async fn check_qr_status(query: QrKeyQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
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);
|
||||
}
|
||||
Ok(resp.body.to_string())
|
||||
}
|
||||
|
||||
// 获取登录状态
|
||||
#[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())
|
||||
}
|
||||
|
||||
// 用户歌单
|
||||
#[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())
|
||||
}
|
||||
|
||||
// 每日推荐歌曲
|
||||
#[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())
|
||||
}
|
||||
|
||||
// 推荐歌单(需要登录)
|
||||
#[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())
|
||||
}
|
||||
|
||||
#[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())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_song_detail(id: u64, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let q = state.build_query().param("ids", &id.to_string());
|
||||
let resp = client.song_detail(&q).await.map_err(|e| e.to_string())?;
|
||||
Ok(resp.body.to_string())
|
||||
}
|
||||
382
src-tauri/src/audio.rs
Normal file
@ -0,0 +1,382 @@
|
||||
use rodio::{Decoder, OutputStream, Sink, Source};
|
||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||
use std::io::Cursor;
|
||||
use std::sync::mpsc::{channel, Receiver, Sender};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use tauri::AppHandle;
|
||||
use tauri::Emitter;
|
||||
|
||||
// ---------- 命令 ----------
|
||||
enum AudioCmd {
|
||||
Play(String),
|
||||
Pause,
|
||||
Resume,
|
||||
Stop,
|
||||
Seek(f64),
|
||||
SetVolume(f32),
|
||||
SetDevice(Option<String>),
|
||||
}
|
||||
|
||||
pub struct AudioController {
|
||||
tx: Sender<AudioCmd>,
|
||||
current_url: Arc<Mutex<Option<String>>>,
|
||||
}
|
||||
|
||||
impl AudioController {
|
||||
pub fn new(app_handle: AppHandle) -> Self {
|
||||
let (tx, rx) = channel();
|
||||
let current_url = Arc::new(Mutex::new(None));
|
||||
let url_clone = current_url.clone();
|
||||
let ah_clone = app_handle.clone(); // 克隆一个用于闭包
|
||||
thread::spawn(move || audio_thread(rx, url_clone, ah_clone));
|
||||
AudioController {
|
||||
tx,
|
||||
current_url,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn play_url(&self, url: &str) {
|
||||
*self.current_url.lock().unwrap() = Some(url.to_string());
|
||||
let _ = self.tx.send(AudioCmd::Play(url.to_string()));
|
||||
}
|
||||
pub fn pause(&self) { let _ = self.tx.send(AudioCmd::Pause); }
|
||||
pub fn resume(&self) { let _ = self.tx.send(AudioCmd::Resume); }
|
||||
pub fn stop(&self) { let _ = self.tx.send(AudioCmd::Stop); }
|
||||
pub fn set_device(&self, device: Option<String>) {
|
||||
let _ = self.tx.send(AudioCmd::SetDevice(device));
|
||||
}
|
||||
pub fn seek(&self, time: f64) {
|
||||
let _ = self.tx.send(AudioCmd::Seek(time));
|
||||
}
|
||||
pub fn set_volume(&self, vol: f32) {
|
||||
let _ = self.tx.send(AudioCmd::SetVolume(vol));
|
||||
}
|
||||
}
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
fn download_audio_with_progress(
|
||||
url: &str,
|
||||
app_handle: &AppHandle,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let resp = reqwest::blocking::get(url)
|
||||
.map_err(|e| format!("下载失败: {}", e))?;
|
||||
|
||||
let total_size = resp.content_length().unwrap_or(0);
|
||||
let mut downloaded: u64 = 0;
|
||||
let mut buffer = Vec::new();
|
||||
let mut reader = resp;
|
||||
|
||||
loop {
|
||||
let mut chunk = [0u8; 8192];
|
||||
let read_size = reader.read(&mut chunk)
|
||||
.map_err(|e| format!("读取失败: {}", e))?;
|
||||
if read_size == 0 {
|
||||
break;
|
||||
}
|
||||
buffer.extend_from_slice(&chunk[..read_size]);
|
||||
downloaded += read_size as u64;
|
||||
|
||||
// 发送进度事件给前端(每 8192 字节发一次,不必太频繁)
|
||||
let progress = if total_size > 0 {
|
||||
(downloaded as f64 / total_size as f64) * 100.0
|
||||
} else {
|
||||
0.0 // 未知大小时为 0
|
||||
};
|
||||
let _ = app_handle.emit("cache-progress", progress);
|
||||
}
|
||||
|
||||
// 下载完成,确保进度为 100
|
||||
let _ = app_handle.emit("cache-progress", 100f64);
|
||||
Ok(buffer)
|
||||
}
|
||||
|
||||
// ---------- 音频线程 ----------
|
||||
fn audio_thread(rx: Receiver<AudioCmd>, current_url: Arc<Mutex<Option<String>>>, app_handle: AppHandle) {
|
||||
let mut selected_device: Option<String> = None;
|
||||
let mut output = create_output(&selected_device);
|
||||
let mut last_default_name = get_system_default_device_name();
|
||||
|
||||
let mut current_volume: f32 = 1.0;
|
||||
if let Some(ref sink) = output.sink {
|
||||
sink.set_volume(current_volume);
|
||||
}
|
||||
|
||||
let mut current_audio_data: Option<Vec<u8>> = None; // 缓存原始音频字节
|
||||
|
||||
loop {
|
||||
match rx.recv_timeout(Duration::from_millis(200)) {
|
||||
Ok(cmd) => {
|
||||
match cmd {
|
||||
AudioCmd::Play(url) => {
|
||||
// 停止旧播放并重建干净输出
|
||||
if let Some(ref sink) = output.sink {
|
||||
sink.stop();
|
||||
}
|
||||
output = create_output(&selected_device);
|
||||
if let Some(ref sink) = output.sink {
|
||||
sink.set_volume(current_volume);
|
||||
|
||||
match download_audio_with_progress(&url, &app_handle) {
|
||||
Ok(bytes) => {
|
||||
current_audio_data = Some(bytes.clone());
|
||||
let play_res = play_bytes(&bytes, sink);
|
||||
if let Err(e) = play_res {
|
||||
eprintln!("[audio] 播放失败: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("[audio] 下载失败: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AudioCmd::Pause => {
|
||||
if let Some(ref sink) = output.sink { sink.pause(); }
|
||||
}
|
||||
AudioCmd::Resume => {
|
||||
if let Some(ref sink) = output.sink { sink.play(); }
|
||||
}
|
||||
AudioCmd::Stop => {
|
||||
if let Some(ref sink) = output.sink { sink.stop(); }
|
||||
}
|
||||
|
||||
AudioCmd::Seek(time) => {
|
||||
if let Some(ref sink) = output.sink {
|
||||
// 优先尝试高效的 sink.try_seek(毫秒级)
|
||||
let seek_res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
sink.try_seek(Duration::from_secs_f64(time))
|
||||
}));
|
||||
|
||||
match seek_res {
|
||||
Ok(Ok(_)) => { /* 成功 */ }
|
||||
Ok(Err(e)) => {
|
||||
eprintln!("[audio] try_seek 失败: {:?}, 回退重建解码", e);
|
||||
// 回退方案:重新解码并从目标时间开始
|
||||
if let Some(ref bytes) = current_audio_data {
|
||||
sink.stop();
|
||||
sink.clear();
|
||||
let _ = play_bytes_with_seek(bytes, sink, time);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
eprintln!("[audio] try_seek 崩溃,回退重建解码");
|
||||
if let Some(ref bytes) = current_audio_data {
|
||||
sink.stop();
|
||||
sink.clear();
|
||||
let _ = play_bytes_with_seek(bytes, sink, time);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AudioCmd::SetVolume(vol) => {
|
||||
current_volume = vol;
|
||||
if let Some(ref sink) = output.sink {
|
||||
sink.set_volume(vol);
|
||||
}
|
||||
}
|
||||
|
||||
AudioCmd::SetDevice(dev) => {
|
||||
selected_device = dev;
|
||||
output = create_output(&selected_device);
|
||||
if let Some(ref sink) = output.sink {
|
||||
sink.set_volume(current_volume);
|
||||
// 如果正在播放,恢复播放
|
||||
if current_url.lock().unwrap().is_some() {
|
||||
if let Some(ref bytes) = current_audio_data {
|
||||
let _ = play_bytes(bytes, sink);
|
||||
}
|
||||
}
|
||||
}
|
||||
if selected_device.is_none() {
|
||||
last_default_name = get_system_default_device_name();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
|
||||
// 跟随系统默认设备变化
|
||||
if selected_device.is_none() {
|
||||
let current_default = get_system_default_device_name();
|
||||
if current_default != last_default_name {
|
||||
println!("[audio] 系统默认设备变化: {:?} -> {:?}", last_default_name, current_default);
|
||||
last_default_name = current_default;
|
||||
output = create_output(&selected_device);
|
||||
if let Some(ref sink) = output.sink {
|
||||
sink.set_volume(current_volume);
|
||||
if let Some(ref bytes) = current_audio_data {
|
||||
let _ = play_bytes(bytes, sink);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 播放辅助函数 ----------
|
||||
|
||||
/// 直接播放字节数据
|
||||
fn play_bytes(bytes: &[u8], sink: &Sink) -> Result<(), String> {
|
||||
let cursor = Cursor::new(bytes.to_vec());
|
||||
let source = Decoder::new(cursor).map_err(|e| format!("解码失败: {}", e))?;
|
||||
sink.append(source);
|
||||
sink.play();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 播放字节数据并跳过指定秒数(用于 seek 回退)
|
||||
fn play_bytes_with_seek(bytes: &[u8], sink: &Sink, seek_secs: f64) -> Result<(), String> {
|
||||
let cursor = Cursor::new(bytes.to_vec());
|
||||
let source = Decoder::new(cursor).map_err(|e| format!("解码失败: {}", e))?;
|
||||
let source = source.skip_duration(Duration::from_secs_f64(seek_secs));
|
||||
sink.append(source);
|
||||
sink.play();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------- 其余函数保持不变(获取设备、创建输出等) ----------
|
||||
|
||||
fn get_system_default_device_name() -> Option<String> {
|
||||
rodio::cpal::default_host()
|
||||
.default_output_device()
|
||||
.and_then(|d| d.name().ok())
|
||||
}
|
||||
|
||||
pub fn list_output_devices() -> Vec<String> {
|
||||
let host = rodio::cpal::default_host();
|
||||
if let Ok(devices) = host.output_devices() {
|
||||
let mut names: Vec<String> = devices.filter_map(|d| d.name().ok()).collect();
|
||||
names.sort();
|
||||
names.dedup();
|
||||
names
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
fn find_device_by_name(name: &str) -> Option<rodio::cpal::Device> {
|
||||
let host = rodio::cpal::default_host();
|
||||
if let Ok(devices) = host.output_devices() {
|
||||
for d in devices {
|
||||
if let Ok(n) = d.name() {
|
||||
if n == name {
|
||||
return Some(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
struct Output {
|
||||
_stream: OutputStream,
|
||||
sink: Option<Sink>,
|
||||
}
|
||||
|
||||
fn create_output(selected_device: &Option<String>) -> Output {
|
||||
match selected_device {
|
||||
Some(dev_name) => {
|
||||
if let Some(dev) = find_device_by_name(dev_name) {
|
||||
println!("[audio] 使用指定设备: {}", dev_name);
|
||||
match OutputStream::try_from_device(&dev) {
|
||||
Ok((stream, handle)) => {
|
||||
match Sink::try_new(&handle) {
|
||||
Ok(sink) => Output { _stream: stream, sink: Some(sink) },
|
||||
Err(e) => {
|
||||
eprintln!("[audio] Sink 创建失败: {}", e);
|
||||
Output { _stream: stream, sink: None }
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[audio] 指定设备无效,回退默认: {}", e);
|
||||
create_default_output()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
eprintln!("[audio] 未找到设备 `{}`,回退默认", dev_name);
|
||||
create_default_output()
|
||||
}
|
||||
}
|
||||
None => {
|
||||
println!("[audio] 跟随系统默认设备");
|
||||
create_default_output()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn create_default_output() -> Output {
|
||||
match OutputStream::try_default() {
|
||||
Ok((stream, handle)) => {
|
||||
match Sink::try_new(&handle) {
|
||||
Ok(sink) => Output { _stream: stream, sink: Some(sink) },
|
||||
Err(e) => {
|
||||
eprintln!("[audio] 默认 Sink 失败: {}", e);
|
||||
Output { _stream: stream, sink: None }
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => panic!("无法创建默认音频输出: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== Tauri 命令 =====================
|
||||
use tauri::State;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
|
||||
pub struct AppAudio(pub StdMutex<AudioController>);
|
||||
|
||||
#[tauri::command]
|
||||
pub fn play_audio(state: State<'_, AppAudio>, url: String) -> Result<(), String> {
|
||||
let ctrl = state.0.lock().map_err(|e| e.to_string())?;
|
||||
ctrl.play_url(&url);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn pause_audio(state: State<'_, AppAudio>) {
|
||||
if let Ok(ctrl) = state.0.lock() { ctrl.pause(); }
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn resume_audio(state: State<'_, AppAudio>) {
|
||||
if let Ok(ctrl) = state.0.lock() { ctrl.resume(); }
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn stop_audio(state: State<'_, AppAudio>) {
|
||||
if let Ok(ctrl) = state.0.lock() { ctrl.stop(); }
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_output_devices() -> Vec<String> {
|
||||
list_output_devices()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_output_device(state: State<'_, AppAudio>, device: Option<String>) {
|
||||
if let Ok(ctrl) = state.0.lock() {
|
||||
ctrl.set_device(device);
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn seek_audio(state: State<'_, AppAudio>, time: f64) {
|
||||
if let Ok(ctrl) = state.0.lock() {
|
||||
ctrl.seek(time);
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_volume(state: State<'_, AppAudio>, vol: f32) {
|
||||
if let Ok(ctrl) = state.0.lock() {
|
||||
ctrl.set_volume(vol);
|
||||
}
|
||||
}
|
||||
130
src-tauri/src/lib.rs
Normal file
@ -0,0 +1,130 @@
|
||||
use tauri::{
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
menu::{MenuBuilder, MenuItemBuilder},
|
||||
Manager, LogicalSize, Emitter,
|
||||
};
|
||||
|
||||
mod api;
|
||||
mod audio;
|
||||
use api::ApiController;
|
||||
use audio::AppAudio;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.setup(|app| {
|
||||
let window = app.get_webview_window("main").unwrap();
|
||||
// 窗口最小尺寸
|
||||
window.set_min_size(Some(LogicalSize::new(1280.0, 700.0)))?;
|
||||
|
||||
// 注入控制器
|
||||
let api_controller = ApiController::new();
|
||||
app.manage(api_controller);
|
||||
|
||||
let audio_controller = audio::AudioController::new(app.handle().clone());
|
||||
let app_audio = AppAudio(std::sync::Mutex::new(audio_controller));
|
||||
app.manage(app_audio);
|
||||
|
||||
// 托盘菜单
|
||||
let show = MenuItemBuilder::with_id("show", "显示窗口").build(app)?;
|
||||
let play_pause = MenuItemBuilder::with_id("play_pause", "播放/暂停").build(app)?;
|
||||
let next = MenuItemBuilder::with_id("next", "下一首").build(app)?;
|
||||
let prev = MenuItemBuilder::with_id("prev", "上一首").build(app)?;
|
||||
let quit = MenuItemBuilder::with_id("quit", "退出").build(app)?;
|
||||
|
||||
let menu = MenuBuilder::new(app)
|
||||
.item(&show)
|
||||
.separator()
|
||||
.item(&play_pause)
|
||||
.item(&next)
|
||||
.item(&prev)
|
||||
.separator()
|
||||
.item(&quit)
|
||||
.build()?;
|
||||
|
||||
// 托盘图标(使用应用默认图标)
|
||||
let icon = app.default_window_icon().cloned().unwrap();
|
||||
|
||||
let _tray = TrayIconBuilder::with_id("main-tray")
|
||||
.tooltip("Nekosonic")
|
||||
.icon(icon)
|
||||
.menu(&menu)
|
||||
.on_menu_event(|app, event| {
|
||||
let window = app.get_webview_window("main").unwrap();
|
||||
match event.id().as_ref() {
|
||||
"show" => {
|
||||
window.show().unwrap();
|
||||
window.set_focus().unwrap();
|
||||
}
|
||||
"play_pause" => {
|
||||
let _ = app.emit("tray-play-pause", ());
|
||||
}
|
||||
"next" => {
|
||||
let _ = app.emit("tray-next", ());
|
||||
}
|
||||
"prev" => {
|
||||
let _ = app.emit("tray-prev", ());
|
||||
}
|
||||
"quit" => {
|
||||
app.exit(0);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
let app = tray.app_handle();
|
||||
let window = app.get_webview_window("main").unwrap();
|
||||
window.show().unwrap();
|
||||
window.set_focus().unwrap();
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
// 点击关闭按钮时隐藏到托盘
|
||||
let window_clone = window.clone();
|
||||
window.on_window_event(move |event| {
|
||||
if let tauri::WindowEvent::CloseRequested { api: close_api, .. } = event {
|
||||
close_api.prevent_close(); // 阻止窗口关闭
|
||||
let _ = window_clone.hide(); // 隐藏到托盘
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
api::login,
|
||||
api::logout,
|
||||
|
||||
api::search_songs,
|
||||
api::get_song_url,
|
||||
api::get_hot_search,
|
||||
api::get_playlist_detail,
|
||||
api::get_lyric,
|
||||
api::user_playlist,
|
||||
api::recommend_resource,
|
||||
api::recommend_songs,
|
||||
api::personal_fm,
|
||||
api::get_song_detail,
|
||||
api::get_qr_key,
|
||||
api::create_qr,
|
||||
api::check_qr_status,
|
||||
api::get_login_status,
|
||||
|
||||
audio::play_audio,
|
||||
audio::pause_audio,
|
||||
audio::resume_audio,
|
||||
audio::stop_audio,
|
||||
audio::get_output_devices,
|
||||
audio::set_output_device,
|
||||
audio::seek_audio,
|
||||
audio::set_volume
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running Nekosonic");
|
||||
}
|
||||
6
src-tauri/src/main.rs
Normal file
@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
demo_lib::run()
|
||||
}
|
||||
39
src-tauri/tauri.conf.json
Normal file
@ -0,0 +1,39 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Nekosonic",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.atdunbg.Nekosonic",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Nekosonic",
|
||||
"width": 1200,
|
||||
"height": 700,
|
||||
"minWidth": 1200,
|
||||
"minHeight": 700,
|
||||
"resizable": true,
|
||||
"decorations": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||