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