第一次提交

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

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

7
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,7 @@
{
"recommendations": [
"Vue.volar",
"tauri-apps.tauri-vscode",
"rust-lang.rust-analyzer"
]
}

7
README.md Normal file
View File

@ -0,0 +1,7 @@
# Tauri + Vue + TypeScript
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Vue - Official](https://marketplace.visualstudio.com/items?itemName=Vue.volar) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)

14
index.html Normal file
View File

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tauri + Vue + Typescript App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

3823
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
package.json Normal file
View File

@ -0,0 +1,36 @@
{
"name": "nekosonic",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"axios": "^1.16.0",
"howler": "^2.2.4",
"pinia": "^3.0.4",
"qrcode": "^1.5.4",
"vue": "^3.5.13",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.4",
"@tauri-apps/cli": "^2",
"@types/node": "^25.6.0",
"@types/qrcode": "^1.5.6",
"@vicons/ionicons5": "^0.13.0",
"@vitejs/plugin-vue": "^5.2.1",
"@vueuse/motion": "^3.0.3",
"tailwindcss": "^4.2.4",
"typescript": "~5.6.2",
"unplugin-icons": "^23.0.1",
"vite": "^6.0.3",
"vue-tsc": "^2.1.10"
}
}

6
public/tauri.svg Normal file
View File

@ -0,0 +1,6 @@
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

1
public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

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"
]
}
}

285
src/App.vue Normal file
View File

@ -0,0 +1,285 @@
<template>
<div class="flex flex-col h-screen bg-gray-950 text-white overflow-hidden">
<!-- ========= 自定义标题栏可拖拽无边框 ========= -->
<div
data-tauri-drag-region
class="h-10 flex items-center justify-between px-4 bg-gray-900/90 backdrop-blur select-none flex-shrink-0"
>
<span class="text-xs text-gray-400 font-medium ml-2">Nekosonic Music</span>
<div class="flex items-center gap-1.5">
<!-- 最小化 -->
<button @click="minimizeWindow" class="w-3 h-3 rounded-full bg-yellow-500 hover:bg-yellow-400 transition" title="最小化"></button>
<!-- 最大化 / 还原 -->
<button @click="toggleMaximize" class="w-3 h-3 rounded-full bg-green-500 hover:bg-green-400 transition" title="最大化/还原"></button>
<!-- 关闭 -->
<button @click="closeWindow" class="w-3 h-3 rounded-full bg-red-500 hover:bg-red-400 transition" title="关闭"></button>
</div>
</div>
<!-- 主体内容区 -->
<div class="flex flex-1 overflow-hidden">
<!-- 左侧导航无边框 -->
<nav class="w-56 flex-shrink-0 flex flex-col bg-gray-900/80 backdrop-blur">
<div class="flex-1 p-4 overflow-y-auto pb-24 flex flex-col">
<!-- 推荐 & 发现 -->
<div class="space-y-0.5">
<router-link to="/"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-white/60 hover:text-white hover:bg-white/5"
active-class="!text-white !bg-white/10">
<span>🏠</span> 推荐
</router-link>
<router-link to="/discover"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-white/60 hover:text-white hover:bg-white/5"
active-class="!text-white !bg-white/10">
<span>🔍</span> 发现
</router-link>
<button
@click="openRoamFromSidebar"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-white/60 hover:text-white hover:bg-white/5 w-full text-left"
>
<span>🌀</span> 漫游
</button>
</div>
<!-- 我的 -->
<div class="mt-4 mb-1 pt-2">
<p class="text-xs text-gray-500 px-3 mb-1">我的</p>
<router-link to="/favorites"
class="flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm text-white/60 hover:text-white hover:bg-white/5 transition">
<span></span> 我喜欢的音乐
</router-link>
<router-link to="/recent"
class="flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm text-white/60 hover:text-white hover:bg-white/5 transition">
<span>🕐</span> 最近播放
</router-link>
</div>
<!-- 创建的歌单可折叠 -->
<div class="mt-4 mb-1 pt-2" v-if="userStore.isLoggedIn">
<div class="flex items-center justify-between px-3 mb-1 cursor-pointer"
@click="showCreatedPlaylists = !showCreatedPlaylists">
<p class="text-xs text-gray-500">我的歌单</p>
<span class="text-xs text-gray-500 transition-transform"
:class="{ 'rotate-90': showCreatedPlaylists }"></span>
</div>
<div v-show="showCreatedPlaylists" class="space-y-0.5">
<div v-for="pl in createdPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
class="px-3 py-1.5 rounded-lg text-sm text-white/60 hover:text-white hover:bg-white/5 cursor-pointer truncate transition">
{{ pl.name }}
</div>
</div>
</div>
<!-- 收藏的歌单可折叠 -->
<div class="mt-4 mb-1 pt-2" v-if="userStore.isLoggedIn">
<div class="flex items-center justify-between px-3 mb-1 cursor-pointer"
@click="showSubPlaylists = !showSubPlaylists">
<p class="text-xs text-gray-500">收藏的歌单</p>
<span class="text-xs text-gray-500 transition-transform" :class="{ 'rotate-90': showSubPlaylists }"></span>
</div>
<div v-show="showSubPlaylists" class="space-y-0.5">
<div v-for="pl in subPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
class="px-3 py-1.5 rounded-lg text-sm text-white/60 hover:text-white hover:bg-white/5 cursor-pointer truncate transition">
{{ pl.name }}
</div>
</div>
</div>
<!-- 用户区域 -->
<div class="mt-auto pt-4">
<div v-if="!userStore.isLoggedIn" class="px-2 space-y-2">
<p class="text-xs text-gray-500">登录后享受个人歌单</p>
<router-link to="/login"
class="flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-white/5 hover:bg-white/10 transition text-sm font-medium text-green-400">
<span>🔑</span> 立即登录
</router-link>
</div>
<div v-else class="flex items-center gap-3 px-2">
<img :src="userStore.user?.avatarUrl" class="w-8 h-8 rounded-full ring-2 ring-green-400/50" />
<div class="min-w-0">
<p class="text-sm font-medium truncate">{{ userStore.user?.nickname }}</p>
<button @click="userStore.logout()"
class="text-xs text-gray-500 hover:text-red-400 transition">退出登录</button>
</div>
</div>
</div>
</div>
</nav>
<!-- 主内容区 -->
<main class="flex-1 overflow-y-auto pb-24">
<router-view v-slot="{ Component }">
<keep-alive :max="3" include="HomeView,DiscoverView">
<component :is="Component" />
</keep-alive>
</router-view>
</main>
</div>
<!-- 全屏漫游抽屉 -->
<Transition name="drawer">
<div
v-if="player.showRoamDrawer"
class="fixed inset-0 z-50 flex flex-col backdrop-blur-xl bg-black/80"
>
<div class="h-16 flex items-center px-6 flex-shrink-0">
<button @click="player.closeRoamDrawer()" class="text-white/80 hover:text-white transition">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
<div class="flex-1 min-h-0 flex px-8 pb-8">
<div class="flex-shrink-0 mr-12 flex flex-col items-center self-center">
<img
:src="roamSong?.al?.picUrl || roamSong?.album?.picUrl"
class="w-72 h-72 rounded-3xl object-cover shadow-2xl mb-4"
/>
<h1 class="text-2xl font-bold text-white">{{ roamSong?.name }}</h1>
<p class="text-gray-400 mt-2">{{ roamArtists }}</p>
</div>
<div ref="lyricScrollContainer" class="flex-1 min-h-0 overflow-y-auto custom-scroll px-4">
<div v-if="lyrics.length > 0" class="w-full max-w-lg mx-auto text-center space-y-3 py-8">
<p
v-for="(line, idx) in lyrics"
:key="idx"
:class="idx === currentLyricIdx ? 'text-green-400 font-medium text-lg transition' : 'text-gray-400 text-base'"
>
{{ line.text }}
</p>
</div>
<div v-else class="text-gray-500 text-center mt-8">暂无歌词</div>
</div>
</div>
</div>
</Transition>
<!-- 底部播放栏 -->
<PlayerBar />
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, onBeforeUnmount, computed, nextTick } from 'vue';
import { useRouter } from 'vue-router';
import { invoke } from '@tauri-apps/api/core';
import { useUserStore } from './stores/user';
import PlayerBar from './components/PlayerBar.vue';
import { usePlayerStore } from './stores/player';
import { useLyric } from './composables/UserLyric';
import { getCurrentWindow } from '@tauri-apps/api/window';
const router = useRouter();
const userStore = useUserStore();
const player = usePlayerStore();
const createdPlaylists = ref<any[]>([]);
const subPlaylists = ref<any[]>([]);
const showCreatedPlaylists = ref(true);
const showSubPlaylists = ref(true);
// 歌词
const { lyrics, currentLyricIdx } = useLyric();
const lyricScrollContainer = ref<HTMLElement | null>(null);
const roamSong = computed(() => player.currentSong);
const roamArtists = computed(() => {
if (!roamSong.value) return '';
return roamSong.value.ar?.map((a: any) => a.name).join(' / ') || '';
});
watch(currentLyricIdx, () => {
if (player.showRoamDrawer && lyricScrollContainer.value) {
nextTick(() => {
const active = lyricScrollContainer.value?.querySelector('.text-green-400');
active?.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
}
});
async function openRoamFromSidebar() {
if (player.isFmMode) {
player.openRoamDrawer();
} else {
await player.loadFm();
}
}
async function loadPlaylists() {
if (!userStore.isLoggedIn || !userStore.user) return;
try {
const jsonStr: string = await invoke('user_playlist', { uid: userStore.user.userId });
const data = JSON.parse(jsonStr);
createdPlaylists.value = (data.playlist || []).filter((p: any) => !p.subscribed);
subPlaylists.value = (data.playlist || []).filter((p: any) => p.subscribed);
} catch (e) { /* 忽略 */ }
}
function goPlaylist(id: number) {
router.push({ name: 'playlist', params: { id } });
}
watch(() => userStore.isLoggedIn, (val) => {
if (val) loadPlaylists();
});
onMounted(async () => {
if (userStore.isLoggedIn) loadPlaylists();
try { await invoke('stop_audio'); } catch {}
try {
const jsonStr: string = await invoke('get_login_status');
const data = JSON.parse(jsonStr);
if (data.account || data.profile) {
const profile = data.profile || data.account;
userStore.setUser({
userId: profile.userId,
nickname: profile.nickname,
avatarUrl: profile.avatarUrl,
});
}
} catch {}
});
// ---------- 窗口控制 ----------
const currentWindow = getCurrentWindow();
function minimizeWindow() { currentWindow.minimize(); }
async function toggleMaximize() {
const isMaximized = await currentWindow.isMaximized();
if (isMaximized) { currentWindow.unmaximize(); } else { currentWindow.maximize(); }
}
function closeWindow() { currentWindow.close(); }
import { listen } from '@tauri-apps/api/event';
onMounted(() => {
const unlisten1 = listen('tray-play-pause', () => {
player.toggle(); // 假设 player 是 usePlayerStore 的实例
});
const unlisten2 = listen('tray-next', () => {
player.next();
});
const unlisten3 = listen('tray-prev', () => {
player.prev();
});
// 在组件卸载时取消监听
onBeforeUnmount(() => {
unlisten1.then(fn => fn());
unlisten2.then(fn => fn());
unlisten3.then(fn => fn());
});
});
</script>
<style>
.drawer-enter-active,
.drawer-leave-active { transition: transform 0.3s ease; }
.drawer-enter-from,
.drawer-leave-to { transform: translateY(100%); }
.custom-scroll::-webkit-scrollbar { width: 4px; }
.custom-scroll::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 2px;
}
</style>

1
src/assets/vue.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@ -0,0 +1,324 @@
<template>
<div v-if="player.currentSong"
class="fixed bottom-0 left-0 right-0 bg-gray-900/95 backdrop-blur border-t border-white/10 z-50 select-none">
<!-- 歌词精简条仅在非漫游全屏时显示 -->
<div v-if="currentLyricText && !player.showRoamDrawer" @click="showFullLyric = !showFullLyric"
class="px-6 py-1 text-center text-xs text-green-200/80 cursor-pointer hover:bg-white/5 transition truncate">
{{ currentLyricText }}
</div>
<!-- 进度条 -->
<div ref="progressBar" class="w-full h-1.5 bg-white/10 rounded-full relative group cursor-pointer overflow-visible"
@mousedown.prevent="startSeek">
<!-- 缓存进度灰白 -->
<div class="absolute left-0 top-0 h-full bg-white/20 rounded-full" :style="{ width: cacheProgress + '%' }"></div>
<!-- 播放进度绿色渐变 -->
<div class="absolute left-0 top-0 h-full bg-gradient-to-r from-green-400 to-emerald-500 rounded-full"
:style="{ width: displayProgress + '%' }"></div>
<!-- 拖动圆点基于容器定位left 百分比 -->
<div
class="absolute top-1/2 -translate-y-1/2 w-3.5 h-3.5 rounded-full bg-white shadow-lg opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
:style="{ left: `calc(${displayProgress}% - 7px)` }"></div>
</div>
<!-- 主控制区 -->
<div class="flex items-center px-6 h-16">
<!-- 左侧歌曲信息 -->
<div class="flex items-center gap-3 w-56 min-w-0">
<img :src="player.currentSong.al?.picUrl"
class="w-10 h-10 rounded-md object-cover flex-shrink-0 cursor-pointer hover:scale-105 transition-transform"
@click="player.openRoamDrawer()" title="全屏展示" />
<div class="min-w-0">
<p class="text-sm font-medium truncate">{{ player.currentSong.name }}</p>
<p class="text-xs text-gray-400 truncate">
{{player.currentSong.ar?.map((a: any) => a.name).join('/')}}
</p>
</div>
</div>
<!-- 中间控制按钮 + 时间 -->
<div class="flex-1 flex flex-col items-center justify-center gap-1">
<div class="flex items-center gap-5">
<button @click="player.prev()" :disabled="player.isFmMode" :class="[
'text-xl transition',
player.isFmMode ? 'text-gray-600 cursor-not-allowed' : 'text-gray-400 hover:text-white',
]">
</button>
<button @click="player.toggle()"
class="w-9 h-9 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 transition border border-white/20">
<svg v-if="player.playing" width="16" height="16" viewBox="0 0 16 16" fill="currentColor"
class="text-white">
<rect x="3" y="2" width="3" height="12" rx="0.5" />
<rect x="10" y="2" width="3" height="12" rx="0.5" />
</svg>
<svg v-else width="16" height="16" viewBox="0 0 16 16" fill="currentColor" class="text-white">
<path d="M4 2.5v11l9-5.5z" />
</svg>
</button>
<button @click="player.next()" class="text-xl text-gray-400 hover:text-white transition"></button>
</div>
<div class="flex items-center gap-2 text-xs text-gray-400">
<span>{{ formatTime(player.currentTime) }}</span>
<span>/</span>
<span>{{ formatTime(player.duration) }}</span>
</div>
</div>
<!-- 右侧音量模式播放列表 -->
<div class="w-56 flex justify-end items-center gap-2">
<div class="flex items-center gap-1">
<span class="text-sm text-gray-400">🔊</span>
<div class="relative w-24 h-6 flex items-center">
<input ref="volumeSlider" type="range" min="0" max="100" :value="volume"
:style="{ background: volumeBarBg }" @input="handleVolumeChange"
class="vol-slider w-full h-1.5 rounded-full appearance-none cursor-pointer bg-white/20 outline-none" />
</div>
</div>
<button @click="togglePlayMode" class="text-gray-400 hover:text-white transition text-lg" :title="modeTitle">
{{ modeIcon }}
</button>
<button @click="showQueuePanel = !showQueuePanel"
class="text-gray-400 hover:text-white transition text-xl relative" title="播放列表">
📋
<span v-if="player.queue.length > 0"
class="absolute -top-1 -right-1 bg-green-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">
{{ player.queue.length }}
</span>
</button>
</div>
</div>
<!-- 队列面板 -->
<Transition name="slide-up">
<div v-if="showQueuePanel"
class="border-t border-white/10 bg-gray-900/95 backdrop-blur p-4 max-h-64 overflow-y-auto">
<div class="flex justify-between items-center mb-3">
<h3 class="text-sm font-semibold">播放列表 ({{ player.queue.length }})</h3>
<button @click="player.clearQueue()" class="text-xs text-red-400 hover:text-red-300 transition">清空</button>
</div>
<div class="space-y-1">
<div v-for="(song, idx) in player.queue" :key="song.id + '-' + idx" @click="playFromQueue(idx)" :class="[
'flex items-center gap-3 p-2 rounded-lg cursor-pointer transition',
idx === player.currentIndex ? 'bg-green-500/20 text-white' : 'hover:bg-white/5 text-gray-300',
]">
<span class="text-xs w-6 text-center">{{ idx + 1 }}</span>
<div class="flex-1 min-w-0">
<p class="text-xs font-medium truncate">{{ song.name }}</p>
<p class="text-xs text-gray-500 truncate">
{{song.ar?.map((a: any) => a.name).join('/')}}
</p>
</div>
<button @click.stop="player.removeFromQueue(idx)"
class="text-gray-500 hover:text-red-400 transition text-sm">
</button>
</div>
</div>
</div>
</Transition>
<!-- 全屏歌词浮层 -->
<Transition name="slide-up">
<div v-if="showFullLyric && lyrics.length > 0 && !player.showRoamDrawer"
class="border-t border-white/10 bg-gray-900/95 backdrop-blur p-4 max-h-72 overflow-hidden flex flex-col"
@click.self="showFullLyric = false">
<div class="flex justify-between mb-2">
<h3 class="text-xs font-semibold">歌词</h3>
<button @click="showFullLyric = false" class="text-gray-400 hover:text-white">收起</button>
</div>
<div ref="lyricContainer"
class="flex-1 overflow-y-auto overflow-x-hidden whitespace-normal break-words space-y-1 text-sm text-center">
<p v-for="(line, idx) in lyrics" :key="idx" :class="idx === currentLyricIdx
? 'text-green-400 font-medium scale-105 transition'
: 'text-gray-400'
" class="px-4 py-0.5">
{{ line.text }}
</p>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, onBeforeUnmount, watch, onMounted } from 'vue';
import { usePlayerStore, PlayMode } from '../stores/player';
import { invoke } from '@tauri-apps/api/core';
import { useLyric } from '../composables/UserLyric';
import { listen } from '@tauri-apps/api/event';
const player = usePlayerStore();
const showQueuePanel = ref(false);
const { lyrics, currentLyricIdx, currentLyricText } = useLyric();
const showFullLyric = ref(false);
const lyricContainer = ref<HTMLElement | null>(null);
const progressBar = ref<HTMLElement | null>(null);
const isSeeking = ref(false);
const previewTime = ref(0);
const cacheProgress = ref(0);
const volume = ref(100);
let unlistenCache: (() => void) | null = null;
// 缓存进度监听
onMounted(async () => {
const fn = await listen<number>('cache-progress', (event) => {
cacheProgress.value = event.payload;
});
unlistenCache = fn;
});
onBeforeUnmount(() => {
if (unlistenCache) unlistenCache();
});
// 播放模式
const modeTexts = { loop: '列表循环', shuffle: '随机播放', 'repeat-one': '单曲循环' };
const modeIcons = { loop: '🔁', shuffle: '🔀', 'repeat-one': '🔂' };
const modeIcon = computed(() => modeIcons[player.playMode] || '🔁');
const modeTitle = computed(() => modeTexts[player.playMode] || '列表循环');
function togglePlayMode() {
const modes: PlayMode[] = ['loop', 'shuffle', 'repeat-one'];
const next = modes[(modes.indexOf(player.playMode) + 1) % modes.length];
player.setPlayMode(next);
}
// 进度条拖拽逻辑
let onDocMove: ((e: MouseEvent) => void) | null = null;
let onDocUp: (() => void) | null = null;
function startSeek(e: MouseEvent) {
isSeeking.value = true;
updatePreview(e);
onDocMove = (ev: MouseEvent) => updatePreview(ev);
onDocUp = () => finishSeek();
document.addEventListener('mousemove', onDocMove);
document.addEventListener('mouseup', onDocUp);
}
function updatePreview(e: MouseEvent) {
const bar = progressBar.value;
if (!bar || player.duration === 0) return;
const rect = bar.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
previewTime.value = ratio * player.duration;
}
function finishSeek() {
if (!isSeeking.value) return;
isSeeking.value = false;
if (player.duration > 0) {
player.seek(previewTime.value);
}
if (onDocMove) document.removeEventListener('mousemove', onDocMove);
if (onDocUp) document.removeEventListener('mouseup', onDocUp);
onDocMove = null;
onDocUp = null;
}
onBeforeUnmount(() => {
if (onDocMove) document.removeEventListener('mousemove', onDocMove);
if (onDocUp) document.removeEventListener('mouseup', onDocUp);
});
const previewPercent = computed(() => {
if (!player.duration || player.duration === 0) return 0;
return (previewTime.value / player.duration) * 100;
});
const progressPercent = computed(() => {
if (!player.duration || player.duration === 0) return 0;
return (player.currentTime / player.duration) * 100;
});
const displayProgress = computed(() => {
return isSeeking.value ? previewPercent.value : progressPercent.value;
});
function formatTime(sec: number): string {
if (!sec || isNaN(sec)) return '0:00';
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
}
function playFromQueue(index: number) {
player.currentIndex = index;
player.playCurrent();
}
async function handleVolumeChange(e: Event) {
const target = e.target as HTMLInputElement;
const val = parseInt(target.value, 10);
volume.value = val;
await invoke('set_volume', { vol: val / 100 });
}
const volumeBarBg = computed(() => {
const pct = volume.value;
return `linear-gradient(to right, #34d399 0%, #10b981 ${pct}%, rgba(255,255,255,0.15) ${pct}%)`;
});
// 歌词浮层自动滚动
watch(
() => currentLyricIdx.value,
() => {
if (showFullLyric.value && lyricContainer.value) {
nextTick(() => {
const active = lyricContainer.value?.querySelector('.text-green-400');
active?.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
}
}
);
</script>
<style scoped>
/* 样式保持不变(原有歌词浮层过渡、滑块样式等) */
.slide-up-enter-active,
.slide-up-leave-active {
transition: all 0.2s ease;
}
.slide-up-enter-from,
.slide-up-leave-to {
opacity: 0;
transform: translateY(10px);
}
.vol-slider {
-webkit-appearance: none;
appearance: none;
background: transparent;
width: 100%;
height: 6px;
border-radius: 3px;
outline: none;
cursor: pointer;
}
.vol-slider::-webkit-slider-runnable-track {
height: 6px;
border-radius: 3px;
background: linear-gradient(to right,
#34d399 0%,
#10b981 var(--vol-fill),
rgba(255, 255, 255, 0.15) var(--vol-fill));
}
.vol-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: white;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.3);
margin-top: -3px;
cursor: pointer;
transition: transform 0.15s;
}
.vol-slider::-webkit-slider-thumb:hover {
transform: scale(1.2);
}
</style>

View File

@ -0,0 +1,48 @@
import { ref, computed, watch } from 'vue';
import { invoke } from '@tauri-apps/api/core';
import { parseLrc, getCurrentLyricIndex, LyricLine } from '../utils/lyric';
import { usePlayerStore } from '../stores/player';
export function useLyric() {
const player = usePlayerStore();
const lyrics = ref<LyricLine[]>([]);
const currentLyricIdx = ref(-1);
const currentLyricText = computed(() => {
if (lyrics.value.length === 0) return '';
const idx = currentLyricIdx.value;
return idx >= 0 && idx < lyrics.value.length ? lyrics.value[idx].text : '';
});
watch(() => player.currentSong, async (song) => {
if (!song) {
lyrics.value = [];
currentLyricIdx.value = -1;
return;
}
try {
const jsonStr: string = await invoke('get_lyric', { id: song.id });
const data = JSON.parse(jsonStr);
const lrc = data?.lrc?.lyric || '';
lyrics.value = lrc ? parseLrc(lrc) : [];
currentLyricIdx.value = -1;
} catch {
lyrics.value = [];
}
}, { immediate: true });
watch(() => player.currentTime, (t) => {
if (lyrics.value.length === 0) return;
const idx = getCurrentLyricIndex(lyrics.value, t);
if (idx !== currentLyricIdx.value) {
currentLyricIdx.value = idx;
}
});
return {
lyrics,
currentLyricIdx,
currentLyricText,
};
}

34
src/main.ts Normal file
View File

@ -0,0 +1,34 @@
import { createApp } from 'vue';
import App from './App.vue';
import './style.css';
import router from './router';
import { createPinia } from 'pinia';
// ---------- 彻底阻止双指拖动和手势 ----------
const preventGesture = (e: Event) => e.preventDefault();
// 阻止 iOS / macOS 手势缩放和页面拖动
document.addEventListener('gesturestart', preventGesture, { passive: false });
document.addEventListener('gesturechange', preventGesture, { passive: false });
document.addEventListener('gestureend', preventGesture, { passive: false });
// 阻止触控板双指水平滑动(若仍存在)
window.addEventListener('wheel', (e: WheelEvent) => {
// 只阻止水平方向,保留垂直滚动(内部容器会处理)
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
e.preventDefault();
}
}, { passive: false });
// 阻止移动端双指触摸移动(不影响单指滚动)
window.addEventListener('touchmove', (e: TouchEvent) => {
if (e.touches.length >= 2) {
e.preventDefault();
}
}, { passive: false });
// -------------------------------------------
const app = createApp(App);
app.use(router);
app.use(createPinia());
app.mount('#app');

26
src/router/index.ts Normal file
View File

@ -0,0 +1,26 @@
import { createRouter, createWebHistory } from 'vue-router';
import Home from '@/views/Home.vue';
import Discover from '@/views/Discover.vue';
import PlaylistDetail from '@/views/PlaylistDetail.vue';
import Login from '@/views/Login.vue';
import FavoriteSongs from '@/views/FavoriteSongs.vue';
import RecentPlays from '@/views/RecentPlays.vue';
import DailySongs from '@/views/DailySongs.vue';
const routes = [
{ path: '/', name: 'home', component: Home },
{ path: '/discover', name: 'discover', component: Discover },
{ path: '/search', name: 'search', component: Discover }, // 同样指向Discover保留兼容
{ path: '/roam', name: 'roam', component: () => import('@/views/Roam.vue') }, // 漫游页面
{ path: '/favorites', name: 'favorites', component: FavoriteSongs },
{ path: '/recent', name: 'recent', component: RecentPlays },
{ path: '/daily', name: 'daily', component: DailySongs }, // 每日推荐
{ path: '/login', name: 'login', component: Login },
{ path: '/playlist/:id', name: 'playlist', component: PlaylistDetail },
];
export default createRouter({
history: createWebHistory(),
routes,
});

402
src/stores/player.ts Normal file
View File

@ -0,0 +1,402 @@
import { defineStore } from 'pinia';
import { ref , watch } from 'vue';
import { invoke } from '@tauri-apps/api/core';
import { normalizeSong } from '../utils/song';
// 设置播放模式,目前只有顺序循环,后续可扩展
export type PlayMode = 'loop' | 'shuffle' | 'repeat-one';
export interface Song {
id: number;
name: string;
ar: { name: string }[];
al: { picUrl: string };
dt?: number;
// 兼容不同接口返回的可选字段
album?: { picUrl?: string };
artists?: { name: string }[];
duration?: number; // 某些接口的时长字段(单位可能是秒)
}
const cacheProgress = ref(0);
// 监听 Tauri 事件(需要在适当位置初始化一次)
import { listen } from '@tauri-apps/api/event';
export function setupCacheProgressListener() {
listen<number>('cache-progress', (event) => {
cacheProgress.value = event.payload;
});
}
// 在 store 定义外调用 setupCacheProgressListener(),或者在应用入口调用
export const usePlayerStore = defineStore('player', () => {
const currentSong = ref<Song | null>(null);
const playing = ref(false);
const currentTime = ref(0);
const duration = ref(0);
const queue = ref<Song[]>([]);
const currentIndex = ref(-1);
let tickInterval: ReturnType<typeof setInterval> | null = null;
const isFmMode = ref(false);
let fmNextCallback: (() => void) | null = null;
function enableFmMode(onNext: () => void) {
isFmMode.value = true;
fmNextCallback = onNext;
}
function disableFmMode() {
isFmMode.value = false;
fmNextCallback = null;
}
// 播放私人漫游歌曲(清空队列,只播放这一首)
async function playFmSong(song: any) {
// 如果缺少时长,尝试从详情接口获取
if (!song.dt || song.dt === 0) {
try {
const jsonStr: string = await invoke('get_song_detail', { id: Number(song.id) });
const data = JSON.parse(jsonStr);
const full = data.songs?.[0];
if (full) {
song.dt = full.dt || 0;
song.al = full.al || song.al;
song.ar = full.ar || song.ar;
}
} catch (e) { /* 忽略 */ }
}
await invoke('stop_audio');
queue.value = [];
currentIndex.value = -1;
playing.value = false;
currentSong.value = song;
try {
const url: string = await invoke('get_song_url', { id: Number(song.id) });
if (!url) throw new Error('无播放源');
await invoke('play_audio', { url });
playing.value = true;
duration.value = (song.dt || 0) / 1000;
currentTime.value = 0;
startTick();
} catch (e) {
console.error('FM播放失败', e);
playing.value = false;
}
}
// 播放指定歌曲(如果不在队列中则加入并切换)
async function play(song: Song) {
disableFmMode();
const idx = queue.value.findIndex(s => s.id === song.id);
if (idx === -1) {
// 未在队列中,添加到队列并播放该位置
queue.value.push(song);
currentIndex.value = queue.value.length - 1;
} else {
currentIndex.value = idx;
}
await playCurrent();
}
async function playCurrent() {
const song = queue.value[currentIndex.value];
if (!song?.id) {
console.error('无效的歌曲数据', song);
return;
}
try {
// 重置状态
currentSong.value = song;
playing.value = false;
currentTime.value = 0;
duration.value = (song.dt || 0) / 1000;
// 获取 URL 并播放
const url: string = await invoke('get_song_url', { id: Number(song.id) });
if (!url) {
console.error('未获取到有效播放地址', song);
return;
}
await invoke('play_audio', { url });
playing.value = true;
startTick();
} catch (e) {
console.error('播放失败', e);
playing.value = false;
}
}
function startTick() {
if (tickInterval) clearInterval(tickInterval);
tickInterval = setInterval(() => {
if (playing.value && duration.value > 0) {
currentTime.value += 0.25;
if (currentTime.value >= duration.value) {
currentTime.value = duration.value;
next(); // 自动下一首
}
}
}, 250);
}
async function toggle() {
if (playing.value) {
await invoke('pause_audio');
playing.value = false;
} else {
await invoke('resume_audio');
playing.value = true;
}
}
async function stop() {
await invoke('stop_audio');
playing.value = false;
currentSong.value = null;
currentTime.value = 0;
if (tickInterval) clearInterval(tickInterval);
disableFmMode(); // 停止时退出漫游
}
function prev() {
if (isFmMode.value) return;
if (queue.value.length === 0) return;
currentIndex.value = (currentIndex.value - 1 + queue.value.length) % queue.value.length;
playCurrent();
}
// 批量添加歌曲到队列并播放第一首(用于“播放全部”)
async function playAll(songs: Song[]) {
if (songs.length === 0) return;
queue.value = [...songs];
currentIndex.value = 0;
await playCurrent();
}
function removeFromQueue(index: number) {
if (index < 0 || index >= queue.value.length) return;
const isCurrent = index === currentIndex.value;
if (isCurrent) {
// 如果移除的是当前正在播放的歌曲,先停止,然后调整索引
stop();
queue.value.splice(index, 1);
// 如果队列变空,则重置
if (queue.value.length === 0) {
currentIndex.value = -1;
return;
}
// 保持索引不变,但如果删的是最后一个,索引需要退一位
if (currentIndex.value >= queue.value.length) {
currentIndex.value = queue.value.length - 1;
}
// 不自动播放,等用户手动选择
} else {
queue.value.splice(index, 1);
// 调整当前索引
if (index < currentIndex.value) {
currentIndex.value -= 1;
}
}
}
function clearQueue() {
stop();
queue.value = [];
currentIndex.value = -1;
}
async function seek(time: number) {
try {
await invoke('seek_audio', { time });
currentTime.value = time;
} catch (e) {
console.error('seek 失败', e);
}
}
// 在 defineStore 内部添加
const playMode = ref<PlayMode>('loop');
function setPlayMode(mode: PlayMode) {
playMode.value = mode;
}
// 重写 next() 以根据模式选择下一首
function next() {
if (isFmMode.value && fmNextCallback) {
fmNextCallback();
return;
}
if (queue.value.length === 0) return;
let nextIndex: number;
switch (playMode.value) {
case 'repeat-one':
// 单曲循环,不改变索引,只重新播放当前
playCurrent();
return;
case 'shuffle':
// 随机下一首,且不与当前重复(除非只剩一首)
if (queue.value.length === 1) {
nextIndex = 0;
} else {
do {
nextIndex = Math.floor(Math.random() * queue.value.length);
} while (nextIndex === currentIndex.value);
}
break;
case 'loop':
default:
// 顺序循环
nextIndex = (currentIndex.value + 1) % queue.value.length;
break;
}
currentIndex.value = nextIndex;
playCurrent();
}
const showRoamDrawer = ref(false);
function openRoamDrawer() {
showRoamDrawer.value = true;
}
function closeRoamDrawer() {
showRoamDrawer.value = false;
}
async function loadFirstFmSong() {
try {
const jsonStr: string = await invoke('personal_fm');
const data = JSON.parse(jsonStr);
const songs = data.data || data;
if (songs && songs.length > 0) {
const song = normalizeSong(songs[0]);
enableFmMode(() => loadFirstFmSong()); // 下一首回调
await playFmSong(song);
return true;
}
} catch (e) {
console.error(e);
}
return false;
}
// -------- FM 专属状态 --------
const fmSong = ref<any>(null);
const fmPlaying = ref(false);
async function loadFm() {
try {
const jsonStr: string = await invoke('personal_fm');
const data = JSON.parse(jsonStr);
const songs = data.data || data;
if (songs && songs.length > 0) {
const song = normalizeSong(songs[0]);
fmSong.value = song;
enableFmMode(nextFm); // 设置下一首回调为 store 内的 nextFm
await playFmSong(song); // 使用 FM 专用播放方法
fmPlaying.value = true;
// showRoamDrawer.value = true; // 自动打开全屏抽屉
}
} catch (e) {
console.error('FM加载失败', e);
}
}
async function toggleFm() {
if (!fmSong.value) return;
if (fmPlaying.value) {
// 当前 FM 正在播放,切换暂停/恢复
await toggle(); // 全局暂停/播放
fmPlaying.value = playing.value;
} else {
// FM 处于暂停状态,或者当前被其他歌曲打断
if (currentSong.value?.id === fmSong.value.id) {
// FM 歌曲还是当前歌曲,直接恢复
await toggle();
fmPlaying.value = playing.value;
} else {
// 当前播放的是其他歌曲,重新以 FM 模式播放 FM 歌曲
enableFmMode(nextFm);
await playFmSong(fmSong.value);
fmPlaying.value = true;
}
}
}
async function nextFm() {
await loadFm(); // 加载下一首 FM 歌曲
}
// 监听全局播放变化,若用户选择了非 FM 歌曲,自动退出 FM 状态
watch(currentSong, (newSong) => {
if (isFmMode.value && newSong?.id !== fmSong.value?.id) {
fmPlaying.value = false;
// 注意:不调用 disableFmMode因为可能只是临时切歌但卡片需要知道 FM 已停止
disableFmMode(); // 退出 FM 模式,让上一首按钮恢复
}
});
watch(playing, (val) => {
// 只有当前正在播放的是 FM 歌曲时,才同步 fmPlaying
if (currentSong.value?.id === fmSong.value?.id) {
fmPlaying.value = val;
} else {
fmPlaying.value = false;
}
});
return {
currentSong,
playing,
currentTime,
duration,
queue,
currentIndex,
playMode,
isFmMode,
enableFmMode,
disableFmMode,
playFmSong,
setPlayMode,
play,
playAll,
toggle,
stop,
prev,
next,
seek,
playCurrent,
removeFromQueue,
clearQueue,
showRoamDrawer,
openRoamDrawer,
closeRoamDrawer,
loadFirstFmSong,
fmSong,
fmPlaying,
loadFm,
toggleFm,
nextFm,
};
});

31
src/stores/user.ts Normal file
View File

@ -0,0 +1,31 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { invoke } from '@tauri-apps/api/core';
export interface UserProfile {
userId: number;
nickname: string;
avatarUrl: string;
}
export const useUserStore = defineStore('user', () => {
const user = ref<UserProfile | null>(
JSON.parse(localStorage.getItem('user_profile') || 'null')
);
const isLoggedIn = ref(!!user.value);
function setUser(profile: UserProfile) {
user.value = profile;
isLoggedIn.value = true;
localStorage.setItem('user_profile', JSON.stringify(profile));
}
async function logout() {
try { await invoke('logout'); } catch {}
user.value = null;
isLoggedIn.value = false;
localStorage.removeItem('user_profile');
}
return { user, isLoggedIn, setUser, logout };
});

45
src/style.css Normal file
View File

@ -0,0 +1,45 @@
@import "tailwindcss";
@layer base {
:root {
--color-surface: 255 255 255;
--color-primary: 34 197 94;
}
/* 确保 html 也应用暗色背景,防止空白区域 */
html {
background: #0f172a;
overflow: hidden;
height: 100%;
overscroll-behavior: none;
}
body {
@apply antialiased;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%);
/* 关键:锁住 body彻底消除整体拖动 */
position: fixed;
inset: 0;
overflow: hidden;
overscroll-behavior: none;
/* 阻止触控板手势触发页面导航 */
touch-action: none;
}
/* 自定义滚动条保持不变 */
::-webkit-scrollbar {
width: 5px;
height: 5px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.2);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background-color: rgba(255, 255, 255, 0.4);
}
}

38
src/utils/lyric.ts Normal file
View File

@ -0,0 +1,38 @@
export interface LyricLine {
time: number; // 秒
text: string;
}
export function parseLrc(lrcStr: string): LyricLine[] {
const lines = lrcStr.split('\n');
const result: LyricLine[] = [];
const timeReg = /\[(\d{2}):(\d{2})\.(\d{2,3})\]/;
for (const line of lines) {
const match = line.match(timeReg);
if (match) {
const min = parseInt(match[1], 10);
const sec = parseInt(match[2], 10);
const ms = parseInt(match[3], 10) / (match[3].length === 3 ? 1000 : 100);
const time = min * 60 + sec + ms;
const text = line.replace(timeReg, '').trim();
if (text) {
result.push({ time, text });
}
}
}
// 按时长排序
result.sort((a, b) => a.time - b.time);
return result;
}
export function getCurrentLyricIndex(lyrics: LyricLine[], currentTime: number): number {
let index = -1;
for (let i = 0; i < lyrics.length; i++) {
if (currentTime >= lyrics[i].time) {
index = i;
} else {
break;
}
}
return index;
}

18
src/utils/song.ts Normal file
View File

@ -0,0 +1,18 @@
/**
* 统一规范化歌曲对象,确保 al.picUrl、ar、dt 字段存在且合理
*/
export function normalizeSong(song: any) {
const normalized = { ...song };
// 封面 / 艺术家兼容
if (!normalized.al?.picUrl && normalized.album?.picUrl) {
normalized.al = { ...normalized.al, picUrl: normalized.album.picUrl };
}
if (!normalized.ar || normalized.ar.length === 0) {
normalized.ar = normalized.artists || [];
}
// 时长:只保留合理的 dt100ms ~ 2小时否则置 0
if (!normalized.dt || normalized.dt < 100 || normalized.dt > 7200000) {
normalized.dt = 0;
}
return normalized;
}

56
src/views/DailySongs.vue Normal file
View File

@ -0,0 +1,56 @@
<template>
<div class="p-8 text-white">
<button @click="$router.back()" class="mb-4 text-gray-400 hover:text-white transition">
返回
</button>
<h1 class="text-2xl font-bold mb-6">每日推荐</h1>
<div v-if="loading" class="text-gray-400">加载中...</div>
<div v-else class="space-y-2">
<div
v-for="(song, index) in songs"
:key="song.id"
@click="player.play(song)"
class="flex items-center gap-4 p-3 rounded-xl hover:bg-white/5 transition cursor-pointer"
>
<span class="text-xs text-gray-500 w-6 text-right">{{ index + 1 }}</span>
<img :src="song.al?.picUrl" class="w-10 h-10 rounded object-cover" />
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">{{ song.name }}</p>
<p class="text-xs text-gray-400 truncate">
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
</p>
</div>
<span class="text-xs text-gray-500">{{ formatDuration(song.dt) }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '../stores/player';
const player = usePlayerStore();
const songs = ref<any[]>([]);
const loading = ref(true);
onMounted(async () => {
try {
const jsonStr: string = await invoke('recommend_songs');
const data = JSON.parse(jsonStr);
songs.value = data.data?.dailySongs || [];
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
});
function formatDuration(ms: number): string {
const sec = Math.floor(ms / 1000);
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
</script>

123
src/views/Discover.vue Normal file
View File

@ -0,0 +1,123 @@
<template>
<div class="p-8 text-white">
<h1 class="text-2xl font-bold mb-4">发现音乐</h1>
<!-- 搜索框 -->
<input
v-model="keyword"
@keyup.enter="handleSearch"
placeholder="搜索歌曲、歌手、专辑..."
class="mb-4 w-full rounded-xl bg-white/10 p-3 text-white placeholder-gray-400 outline-none backdrop-blur"
/>
<!-- 热门搜索标签仅在没有搜索且未显示结果时出现 -->
<div v-if="!hasSearched && !loading && hotTags.length" class="mb-6">
<h2 class="text-sm font-semibold mb-3">🔥 热门搜索</h2>
<div class="flex flex-wrap gap-2">
<span
v-for="tag in hotTags"
:key="tag.searchWord"
@click="searchTag(tag.searchWord)"
class="px-3 py-1 rounded-full bg-white/10 hover:bg-white/20 cursor-pointer transition text-sm"
>
{{ tag.searchWord }}
</span>
</div>
</div>
<!-- 输出设备选择 -->
<!-- <div class="mb-4">
<label class="mr-2 text-sm text-gray-400">输出设备</label>
<select v-model="selectedDevice" @change="changeDevice" class="bg-white/10 text-white rounded p-1 text-sm">
<option :value="null">跟随系统默认</option>
<option v-for="dev in devices" :key="dev" :value="dev">{{ dev }}</option>
</select>
</div> -->
<!-- 搜索结果 -->
<div v-if="loading" class="text-gray-400">搜索中...</div>
<div v-else class="space-y-3">
<div
v-for="song in results"
:key="song.id"
@click="playSong(song)"
class="flex items-center gap-4 p-3 rounded-xl backdrop-blur-md bg-white/5 hover:bg-white/10 border border-white/5 cursor-pointer transition"
>
<img :src="song.al?.picUrl" class="w-12 h-12 rounded-lg object-cover" />
<div>
<p class="font-medium">{{ song.name }}</p>
<p class="text-sm text-gray-400">
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
</p>
</div>
</div>
<p v-if="!loading && hasSearched && results.length === 0" class="text-gray-400">无结果</p>
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'DiscoverView' });
import { ref, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '../stores/player';
const router = useRouter();
const route = useRoute();
const player = usePlayerStore();
const keyword = ref('');
const results = ref<any[]>([]);
const loading = ref(false);
const hasSearched = ref(false);
const hotTags = ref<any[]>([]);
const devices = ref<string[]>([]);
onMounted(async () => {
// 获取输出设备列表
try { devices.value = await invoke('get_output_devices'); } catch {}
// 获取热门搜索
try {
const json = await invoke('get_hot_search');
const data = JSON.parse(json as string);
hotTags.value = (data.data || []).slice(0, 12);
} catch {}
// 检查路由是否有查询关键词,自动搜索
const q = route.query.q as string;
if (q) {
keyword.value = q;
await handleSearch();
router.replace({ query: {} });
}
});
async function handleSearch() {
if (!keyword.value.trim()) return;
loading.value = true;
hasSearched.value = true;
try {
const jsonStr: string = await invoke('search_songs', { query: { keyword: keyword.value } });
const data = JSON.parse(jsonStr);
results.value = data.result?.songs || [];
} catch (e) {
console.error('搜索出错:', e);
} finally {
loading.value = false;
}
}
function searchTag(tag: string) {
keyword.value = tag;
handleSearch();
}
async function playSong(song: any) {
player.play(song);
}
</script>

View File

@ -0,0 +1,6 @@
<template>
<div class="p-8 text-white">
<h1 class="text-2xl font-bold mb-4"> 我喜欢的音乐</h1>
<p class="text-gray-400">正在施工...</p>
</div>
</template>

204
src/views/Home.vue Normal file
View File

@ -0,0 +1,204 @@
<template>
<div class="p-8 text-white">
<!-- 第一行每日推荐 & 私人漫游 卡片 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-10">
<!-- 每日推荐 -->
<div
class="h-48 bg-gradient-to-br from-pink-600 to-purple-700 rounded-3xl overflow-hidden relative cursor-pointer group"
@click="goDaily"
>
<div class="absolute inset-0 bg-black/20 group-hover:bg-black/10 transition"></div>
<div class="relative z-10 p-6 flex flex-col justify-between h-full">
<div>
<p class="text-xs text-white/60 mb-1">📅 {{ todayStr }}</p>
<h2 class="text-2xl font-bold">每日推荐</h2>
</div>
<p class="text-xs text-white/60">根据你的口味生成每天 6:00 更新</p>
</div>
<div class="absolute right-4 top-1/2 -translate-y-1/2 text-6xl opacity-20">🎧</div>
</div>
<!-- 私人漫游 卡片 -->
<!-- 私人漫游 卡片 -->
<div
class="h-48 bg-gradient-to-br from-blue-600 to-cyan-500 rounded-3xl overflow-hidden relative group select-none"
@click="!userStore.isLoggedIn ? goLogin() : null"
>
<!-- 模糊封面层仅在有歌曲且有封面时显示低透明度模糊 -->
<div
v-if="player.fmSong && fmCoverUrl"
class="absolute inset-0 bg-cover bg-center opacity-30 blur-md scale-110"
:style="{ backgroundImage: `url(${fmCoverUrl})` }"
></div>
<!-- 遮罩 -->
<div class="absolute inset-0 bg-black/30 group-hover:bg-black/20 transition"></div>
<!-- 内容 -->
<div class="relative z-10 h-full">
<!-- 未登录 -->
<div v-if="!userStore.isLoggedIn" class="flex flex-col items-center justify-center h-full">
<p class="text-xs text-white/60 mb-1">🌀 一键探索</p>
<h2 class="text-2xl font-bold">私人漫游</h2>
<p class="text-xs text-white/60 mt-2">登录后即可开启沉浸式音乐探索</p>
</div>
<!-- 登录后无歌曲 垂直居中播放按钮 -->
<div
v-else-if="!player.fmSong"
class="flex flex-col items-center justify-center h-full gap-3 cursor-pointer"
@click.stop="startFmPlay"
>
<p class="text-xs text-white/60">🌀 一键探索</p>
<h2 class="text-2xl font-bold">私人漫游</h2>
<button
class="w-12 h-12 flex items-center justify-center rounded-full bg-white/20 hover:bg-white/30 transition mt-2"
>
<svg width="22" height="22" viewBox="0 0 16 16" fill="currentColor" class="text-white">
<path d="M4 2.5v11l9-5.5z" />
</svg>
</button>
</div>
<!-- 有歌曲 横向布局左侧信息右侧按钮 -->
<div v-else class="flex items-center justify-between h-full px-6 cursor-pointer" @click.stop="player.toggleFm">
<!-- 左侧封面 + 歌曲信息 -->
<div class="flex items-center gap-3 min-w-0">
<img :src="fmCoverUrl" class="w-14 h-14 rounded-xl object-cover flex-shrink-0" />
<div class="min-w-0">
<p class="text-sm font-semibold truncate">{{ fmDisplayName }}</p>
<p class="text-xs text-white/70 truncate">{{ fmDisplayArtists }}</p>
</div>
</div>
<!-- 右侧控制按钮 -->
<div class="flex items-center gap-3 ml-4">
<button @click.stop="player.toggleFm"
class="w-10 h-10 flex items-center justify-center rounded-full bg-white/20 hover:bg-white/30 transition"
>
<svg v-if="player.fmPlaying" width="18" height="18" viewBox="0 0 16 16" fill="currentColor" class="text-white">
<rect x="3" y="2" width="3" height="12" rx="0.5" />
<rect x="10" y="2" width="3" height="12" rx="0.5" />
</svg>
<svg v-else width="18" height="18" viewBox="0 0 16 16" fill="currentColor" class="text-white">
<path d="M4 2.5v11l9-5.5z" />
</svg>
</button>
<button @click.stop="player.nextFm" class="text-xl text-white/80 hover:text-white transition"></button>
</div>
</div>
</div>
<div class="absolute right-4 top-1/2 -translate-y-1/2 text-6xl opacity-20 pointer-events-none">🌊</div>
</div>
</div>
<!-- 第二行为你推荐需登录 -->
<div v-if="userStore.isLoggedIn && recPlaylists.length" class="mb-10">
<h2 class="text-xl font-semibold mb-4">🎯 为你推荐</h2>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<div v-for="pl in recPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
class="bg-white/5 rounded-xl overflow-hidden hover:bg-white/10 transition cursor-pointer">
<img :src="pl.picUrl" class="w-full aspect-square object-cover" />
<div class="p-3">
<p class="text-sm font-medium truncate">{{ pl.name }}</p>
<p class="text-xs text-gray-400 mt-1">{{ pl.copywriter || '' }}</p>
</div>
</div>
</div>
</div>
<!-- 第三行热门歌单排行榜 -->
<div>
<h2 class="text-xl font-semibold mb-4">📈 热门歌单</h2>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<div v-for="pl in rankPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
class="bg-white/5 rounded-xl overflow-hidden hover:bg-white/10 transition cursor-pointer backdrop-blur-sm">
<img :src="pl.coverImgUrl" class="w-full aspect-square object-cover" />
<div class="p-3">
<p class="text-sm font-medium truncate">{{ pl.name }}</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { invoke } from '@tauri-apps/api/core';
import { useUserStore } from '../stores/user';
import { usePlayerStore } from '../stores/player';
const player = usePlayerStore();
const router = useRouter();
const userStore = useUserStore();
const rankPlaylists = ref<any[]>([]);
const recPlaylists = ref<any[]>([]);
const todayStr = ref('');
const RANK_IDS = [3778678, 3779629, 19723756, 2884035];
import { computed } from 'vue';
const fmCoverUrl = computed(() => {
return player.fmSong?.al?.picUrl || player.fmSong?.album?.picUrl || '';
});
const fmDisplayName = computed(() => player.fmSong?.name || '私人漫游');
const fmDisplayArtists = computed(() => {
if (!player.fmSong) return '';
return player.fmSong.ar?.map((a: any) => a.name).join(' / ') ||
player.fmSong.artists?.map((a: any) => a.name).join(' / ') || '';
});
// 首次点击播放按钮:开始 FM 并播放
async function startFmPlay() {
// 如果还没加载过 FM或者之前加载了但被停止了重新加载
if (!player.fmSong) {
await player.loadFm(); // loadFm 内部会设置 fmSong 并播放
} else {
// 已有歌曲但未播放状态(比如之前暂停/停止了),直接播放
await player.toggleFm();
}
}
onMounted(async () => {
const d = new Date();
todayStr.value = `${d.getMonth() + 1}${d.getDate()}`;
// 排行榜
const results = await Promise.allSettled(
RANK_IDS.map(id => invoke('get_playlist_detail', { id }))
);
rankPlaylists.value = results
.filter(r => r.status === 'fulfilled')
.map((r: any) => {
const data = JSON.parse(r.value);
return data.playlist;
})
.filter(Boolean);
// 推荐歌单(需登录)
if (userStore.isLoggedIn) {
try {
const json = await invoke('recommend_resource');
const data = JSON.parse(json as string);
recPlaylists.value = data.recommend || [];
} catch { }
}
});
function goDaily() {
router.push('/daily');
}
function goPlaylist(id: number) {
router.push({ name: 'playlist', params: { id } });
}
function goLogin() {
router.push('/login');
}
</script>

148
src/views/Login.vue Normal file
View File

@ -0,0 +1,148 @@
<template>
<div class="min-h-screen flex items-center justify-center bg-gray-950 text-white">
<div class="bg-white/5 backdrop-blur-md border border-white/10 p-8 rounded-2xl w-full max-w-sm text-center">
<h1 class="text-xl font-bold mb-4">扫码登录</h1>
<p class="text-sm text-gray-400 mb-6">请使用网易云音乐 App 扫描二维码</p>
<!-- 二维码展示区 -->
<div v-if="qrimg" class="bg-white p-3 rounded-xl inline-block mb-4">
<img :src="qrimg" alt="二维码" class="w-48 h-48" />
</div>
<div v-else class="w-48 h-48 bg-white/5 rounded-xl flex items-center justify-center mx-auto mb-4">
<span v-if="qrLoading" class="text-gray-400">加载中...</span>
<span v-else-if="qrError" class="text-red-400 text-sm">{{ qrError }}</span>
</div>
<!-- 状态提示 -->
<p class="text-sm" :class="statusColor">{{ statusText }}</p>
<button @click="refreshQr" class="mt-4 text-xs text-green-400 hover:underline">重新获取二维码</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { invoke } from '@tauri-apps/api/core';
import { useRouter } from 'vue-router';
import { useUserStore } from '../stores/user';
import QRCode from 'qrcode';
const router = useRouter();
const userStore = useUserStore();
const qrimg = ref('');
const qrLoading = ref(true);
const qrError = ref('');
const statusText = ref('等待扫码...');
const statusColor = ref('text-gray-400');
let qrKey = '';
let pollTimer: ReturnType<typeof setInterval> | null = null;
onMounted(async () => {
if (userStore.isLoggedIn) {
router.push('/');
return;
}
await refreshQr();
});
onBeforeUnmount(() => {
if (pollTimer) clearInterval(pollTimer);
});
async function refreshQr() {
qrLoading.value = true;
qrError.value = '';
if (pollTimer) clearInterval(pollTimer);
try {
// 1. 获取 unikey
qrKey = await invoke('get_qr_key');
if (!qrKey) {
qrError.value = '未获取到登录密钥';
qrLoading.value = false;
return;
}
// 2. 拼接网易云标准扫码链接(无需 create_qr
const qrUrl = `https://music.163.com/login?codekey=${qrKey}&type=1`;
// 3. 用 qrcode 生成二维码图片
const canvas = document.createElement('canvas');
await QRCode.toCanvas(canvas, qrUrl, { width: 200, margin: 1 });
qrimg.value = canvas.toDataURL('image/png');
qrLoading.value = false;
// 4. 开始轮询状态
startPolling();
} catch (e: any) {
qrError.value = '获取二维码失败';
qrLoading.value = false;
}
}
// 新增函数:用 Canvas 生成二维码并赋值给 qrimg
// async function drawQrCode(url: string) {
// try {
// // 等待 DOM 准备好 canvas 元素
// const canvas = document.createElement('canvas');
// await QRCode.toCanvas(canvas, url, { width: 201, margin: 1 });
// // 转为 data URL 赋值给响应式的图片地址
// qrimg.value = canvas.toDataURL('image/png');
// } catch (e) {
// console.error('生成二维码失败', e);
// qrError.value = '生成二维码失败';
// }
// }
function startPolling() {
pollTimer = setInterval(async () => {
try {
const jsonStr: string = await invoke('check_qr_status', { query: { key: qrKey } });
const data = JSON.parse(jsonStr);
const code = data.code;
if (code === 800) {
statusText.value = '二维码已过期,请刷新';
statusColor.value = 'text-red-400';
clearInterval(pollTimer!);
} else if (code === 801) {
statusText.value = '等待扫码...';
statusColor.value = 'text-gray-400';
} else if (code === 802) {
statusText.value = '请在手机上确认登录';
statusColor.value = 'text-yellow-400';
} else if (code === 803) {
// 登录成功
clearInterval(pollTimer!);
statusText.value = '登录成功!';
statusColor.value = 'text-green-400';
// 存储 cookie 到 NcmApi后台线程中自动保留后续请求都带登录态
// 获取用户信息(简化,可从 /login/status 获取)
// 这里需要额外调用获取用户详情的 API但因为 NcmApi 已有 cookie可以直接在后台线程中添加
// 暂时用简易方式:调用 /user/account 获取用户简档
await fetchUserProfile();
setTimeout(() => router.push('/'), 500);
}
} catch (e) {
console.error('轮询失败', e);
}
}, 3000);
}
async function fetchUserProfile() {
try {
// 添加一个快速获取用户信息的命令(可复用之前的 login 命令中获取 profile 的逻辑)
// 这里简化,由于后台 NcmApi 已有 cookie我们可以直接用 reqwest 调 /user/account
// 但最好添加一个新命令,这里直接调用现有的 login 逻辑不适用,因此我们在 Rust 侧添加一个 get_login_status 命令
const profileJson: string = await invoke('get_login_status');
const profile = JSON.parse(profileJson);
if (profile.profile) {
userStore.setUser({
userId: profile.profile.userId,
nickname: profile.profile.nickname,
avatarUrl: profile.profile.avatarUrl,
});
}
} catch (e) {
console.error('获取用户信息失败', e);
}
}
</script>

View File

@ -0,0 +1,91 @@
<template>
<div class="p-8 text-white">
<button @click="$router.back()" class="mb-4 text-gray-400 hover:text-white transition">
返回
</button>
<!-- 歌单信息 -->
<div v-if="playlist" class="flex gap-6 mb-8">
<img :src="playlist.coverImgUrl" class="w-40 h-40 rounded-xl object-cover shadow-lg" />
<div>
<h1 class="text-2xl font-bold">{{ playlist.name }}</h1>
<p class="text-sm text-gray-400 mt-2">{{ playlist.description }}</p>
<p class="text-xs text-gray-500 mt-2">
{{ playlist.trackCount }} 首歌曲 · 播放 {{ playlist.playCount }}
</p>
<button
@click="playAll"
class="mt-4 px-4 py-2 bg-green-500 hover:bg-green-600 rounded-full text-white font-medium transition"
>
播放全部
</button>
</div>
</div>
<!-- 加载中 -->
<div v-if="loading" class="text-gray-400">加载中...</div>
<!-- 歌曲列表 -->
<div v-else class="space-y-2">
<div
v-for="(song, index) in songs"
:key="song.id"
@click="playSingle(song)"
class="flex items-center gap-4 p-3 rounded-xl hover:bg-white/5 transition cursor-pointer"
>
<span class="text-xs text-gray-500 w-6 text-right">{{ index + 1 }}</span>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">{{ song.name }}</p>
<p class="text-xs text-gray-400 truncate">
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
</p>
</div>
<span class="text-xs text-gray-500">{{ formatDuration(song.dt) }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '../stores/player';
const route = useRoute();
const player = usePlayerStore();
const playlist = ref<any>(null);
const songs = ref<any[]>([]);
const loading = ref(true);
onMounted(async () => {
const id = Number(route.params.id);
try {
const jsonStr: string = await invoke('get_playlist_detail', { id });
const data = JSON.parse(jsonStr);
playlist.value = data.playlist;
songs.value = data.playlist.tracks || [];
} catch (e) {
console.error('获取歌单详情失败', e);
} finally {
loading.value = false;
}
});
function formatDuration(ms: number): string {
const sec = Math.floor(ms / 1000);
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
async function playSingle(song: any) {
player.play(song);
}
function playAll() {
if (songs.value.length === 0) return;
player.playAll(songs.value);
}
</script>

View File

@ -0,0 +1,6 @@
<template>
<div class="p-8 text-white">
<h1 class="text-2xl font-bold mb-4">🕐 最近播放</h1>
<p class="text-gray-400">正在施工...</p>
</div>
</template>

126
src/views/Roam.vue Normal file
View File

@ -0,0 +1,126 @@
<template>
<div class="p-8 text-white flex flex-col items-center justify-center min-h-full">
<!-- 无歌曲时提示 -->
<div v-if="!currentSong" class="text-center">
<p class="text-gray-400 mb-4">私人漫游未启动</p>
<button
@click="startFm"
class="px-6 py-2 bg-white/10 hover:bg-white/20 rounded-full transition"
>
开始漫游
</button>
</div>
<!-- 歌曲信息展示 -->
<template v-else>
<!-- 专辑封面 -->
<img
:src="currentSong.al?.picUrl || currentSong.album?.picUrl"
class="w-80 h-80 rounded-3xl object-cover shadow-2xl mb-8"
/>
<!-- 歌曲名和艺术家 -->
<h1 class="text-3xl font-bold mb-2">{{ currentSong.name }}</h1>
<p class="text-lg text-gray-400 mb-8">
{{ artists }}
</p>
<!-- 控制按钮 -->
<div class="flex items-center gap-8">
<button
@click="togglePlay"
class="w-16 h-16 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 transition border border-white/20"
>
<!-- 暂停图标 -->
<svg v-if="player.playing" width="28" height="28" viewBox="0 0 16 16" fill="currentColor">
<rect x="3" y="2" width="3" height="12" rx="0.5" />
<rect x="10" y="2" width="3" height="12" rx="0.5" />
</svg>
<!-- 播放图标 -->
<svg v-else width="28" height="28" viewBox="0 0 16 16" fill="currentColor">
<path d="M4 2.5v11l9-5.5z" />
</svg>
</button>
<button
@click="nextSong"
class="text-3xl text-gray-400 hover:text-white transition"
>
</button>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue';
import { usePlayerStore } from '../stores/player';
import { invoke } from '@tauri-apps/api/core';
const player = usePlayerStore();
// 当前正在播放的歌曲如果处于FM模式则显示当前歌曲
const currentSong = computed(() => {
// FM 模式下直接显示正在播放的歌曲可能是FM歌曲
if (player.isFmMode && player.currentSong) {
return player.currentSong;
}
return null;
});
const artists = computed(() => {
if (!currentSong.value) return '';
return currentSong.value.ar?.map((a: any) => a.name).join(' / ') ||
currentSong.value.artists?.map((a: any) => a.name).join(' / ') || '';
});
// 进入页面时如果FM未启动自动开始
onMounted(async () => {
if (!player.isFmMode || !player.currentSong) {
await startFm();
}
});
async function startFm() {
try {
const jsonStr: string = await invoke('personal_fm');
const data = JSON.parse(jsonStr);
const songs = data.data || data;
if (songs && songs.length > 0) {
const song = normalizeSong(songs[0]);
player.enableFmMode(nextSong);
await player.playFmSong(song);
}
} catch (e) {
console.error('启动漫游失败', e);
}
}
function normalizeSong(song: any) {
const normalized = { ...song };
if (!normalized.al?.picUrl && normalized.album?.picUrl) {
normalized.al = { ...normalized.al, picUrl: normalized.album.picUrl };
}
if (!normalized.ar || normalized.ar.length === 0) {
normalized.ar = normalized.artists || [];
}
return normalized;
}
async function togglePlay() {
if (player.playing) {
await invoke('pause_audio');
} else {
if (player.currentSong) {
// 恢复播放
await invoke('resume_audio');
} else {
await startFm();
}
}
}
async function nextSong() {
await startFm();
}
</script>

108
src/views/Search.vue Normal file
View File

@ -0,0 +1,108 @@
<template>
<div class="text-white">
<h1 class="text-2xl font-bold mb-4">搜索</h1>
<!-- 输出设备选择-->
<!-- <div class="mb-4">
<label class="mr-2">输出设备</label>
<select v-model="selectedDevice" @change="changeDevice" class="bg-white/10 text-white rounded p-1">
<option :value="null">跟随系统默认</option>
<option v-for="dev in devices" :key="dev" :value="dev">{{ dev }}</option>
</select>
</div> -->
<input
v-model="keyword"
@keyup.enter="handleSearch"
placeholder="搜索歌曲..."
class="mb-6 w-full rounded-xl bg-white/10 p-3 text-white placeholder-gray-400 outline-none backdrop-blur"
/>
<div v-if="loading" class="text-gray-400">搜索中...</div>
<div v-else class="space-y-3">
<div
v-for="song in results"
:key="song.id"
@click="playSong(song)"
class="flex items-center gap-4 p-3 rounded-xl backdrop-blur-md bg-white/5 hover:bg-white/10 border border-white/5 cursor-pointer transition-all duration-200 hover:scale-[1.01] active:scale-95"
>
<img :src="song.al?.picUrl" class="w-12 h-12 rounded-lg object-cover" />
<div>
<p class="font-medium">{{ song.name }}</p>
<p class="text-sm text-gray-400">
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
</p>
</div>
</div>
<p v-if="!loading && hasSearched && results.length === 0" class="text-gray-400">无结果</p>
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'SearchView' });
import { useRoute } from 'vue-router';
import { watch } from 'vue';
import { ref, onMounted } from 'vue';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '../stores/player';
import { useRouter } from 'vue-router';
const router = useRouter();
const keyword = ref('');
const results = ref<any[]>([]);
const loading = ref(false);
const hasSearched = ref(false);
const player = usePlayerStore();
const route = useRoute();
// 监听从首页或其他地方传来的 query 参数,自动搜索
watch(
() => route.query.q,
(newQ) => {
if (newQ) {
keyword.value = newQ as string;
handleSearch();
// 清除 query防止刷新后重复搜索
router.replace({ query: {} });
}
},
{ immediate: true }
);
async function handleSearch() {
if (!keyword.value.trim()) return;
loading.value = true;
hasSearched.value = true;
try {
const jsonStr: string = await invoke('search_songs', { query: { keyword: keyword.value } });
const data = JSON.parse(jsonStr);
results.value = data.result?.songs || [];
} catch (e) {
console.error('搜索出错:', e);
} finally {
loading.value = false;
}
}
async function playSong(song: any) {
try {
await player.play(song);
} catch (e) {
alert('暂无播放源或需登录');
}
}
const devices = ref<string[]>([]);
// const selectedDevice = ref<string | null>(null);
onMounted(async () => {
devices.value = await invoke('get_output_devices');
});
// async function changeDevice() {
// await invoke('set_output_device', { device: selectedDevice.value });
// }
</script>

7
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}

25
tsconfig.json Normal file
View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
tsconfig.node.json Normal file
View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

45
vite.config.ts Normal file
View File

@ -0,0 +1,45 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import tailwindcss from "@tailwindcss/vite";
import Icons from "unplugin-icons/vite";
import { fileURLToPath, URL } from "node:url";
// \@ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST;
// https://vite.dev/config/
export default defineConfig(async () => ({
plugins: [
vue(),
tailwindcss(),
Icons({ compiler: "vue3" }),
],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent Vite from obscuring rust errors
clearScreen: false,
// 2. tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host
? {
protocol: "ws",
host,
port: 1421,
}
: undefined,
watch: {
// 3. tell Vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"],
},
},
}));