第一次提交

This commit is contained in:
2026-05-07 22:27:55 +08:00
commit 463e8e95b6
95 changed files with 13167 additions and 0 deletions

7
src-tauri/.gitignore vendored Normal file
View 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

File diff suppressed because it is too large Load Diff

31
src-tauri/Cargo.toml Normal file
View 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
View File

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
src-tauri/icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View File

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

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View File

@ -0,0 +1 @@
NMTID=00OvETy78e8ay9VhUTKgcUSdB6-yKQAAAGeAJacOg

253
src-tauri/src/api.rs Normal file
View 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
View 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
View 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
View 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
View 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"
]
}
}