feat: 跨平台持久化与版本管理优化
- Cookie 存储从 temp_dir 迁移至 Tauri app_data_dir,兼容 Linux - 简单统一风格,UI优化 - recentLocal 播放历史持久化到 localStorage - 添加设置界面可以修改简单的设置
This commit is contained in:
82
README.md
82
README.md
@ -1,7 +1,81 @@
|
|||||||
# Tauri + Vue + TypeScript
|
# Nekosonic
|
||||||
|
|
||||||
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.
|
一款轻量的跨平台的音乐播放器,支持Windows/Linux系统,音源主要源自的网易云音乐。
|
||||||
|
|
||||||
## 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)
|
- 🔴 网易云账号登录(扫码)
|
||||||
|
- 🎵 多音质播放(标准 / 较高 / 极高 / 无损 / Hi-Res)
|
||||||
|
- 📻 私人漫游,沉浸式全屏歌词体验
|
||||||
|
- ❤️ 一键喜欢 / 取消喜欢
|
||||||
|
- 📋 歌单管理,收藏 / 取消收藏歌单
|
||||||
|
- 📅 每日推荐歌曲
|
||||||
|
- 🕐 本地播放历史记录
|
||||||
|
- 🔍 关键词搜索歌曲
|
||||||
|
- 🎤 实时滚动歌词
|
||||||
|
- 🌚 Light / Dark Mode 主题切换
|
||||||
|
- 🛠 更多特性添加中
|
||||||
|
|
||||||
|
## 📦️ 安装
|
||||||
|
|
||||||
|
访问本项目的 [Releases](https://gitea.atdunbg.xyz/atdunbg/Nekosonic-Music/releases) 页面下载安装包。
|
||||||
|
|
||||||
|
|
||||||
|
## 💻 配置开发环境
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装前端依赖
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 启动开发服务器
|
||||||
|
npm run tauri dev
|
||||||
|
|
||||||
|
# 构建发布
|
||||||
|
npm run tauri build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 环境要求
|
||||||
|
|
||||||
|
- Node.js >= 18
|
||||||
|
- Rust >= 1.70
|
||||||
|
- Tauri CLI 2
|
||||||
|
|
||||||
|
## 🛠 技术栈
|
||||||
|
|
||||||
|
| 层级 | 技术 |
|
||||||
|
|------|------|
|
||||||
|
| 桌面框架 | Tauri 2 |
|
||||||
|
| 前端 | Vue 3 + TypeScript |
|
||||||
|
| 样式 | Tailwind CSS v4 + CSS 变量主题系统 |
|
||||||
|
| 状态管理 | Pinia |
|
||||||
|
| 路由 | Vue Router 4 |
|
||||||
|
| 音频播放 | rodio (Rust) |
|
||||||
|
| 网易云 API | ncm-api-rs |
|
||||||
|
| 构建工具 | Vite 6 |
|
||||||
|
|
||||||
|
## ☑️ Todo
|
||||||
|
|
||||||
|
- [ ] MV 播放
|
||||||
|
- [ ] 音乐云盘
|
||||||
|
- [ ] 评论系统
|
||||||
|
- [ ] 下载功能
|
||||||
|
- [ ] 自定义全局快捷键
|
||||||
|
- [ ] 歌词翻译
|
||||||
|
- [ ] 更多主题
|
||||||
|
|
||||||
|
欢迎提 Issue 和 Pull request。
|
||||||
|
|
||||||
|
## 📜 开源许可
|
||||||
|
|
||||||
|
本项目仅供个人学习研究使用,禁止用于商业及非法用途。
|
||||||
|
|
||||||
|
基于 [MIT license](https://opensource.org/licenses/MIT) 许可进行开源。
|
||||||
|
|
||||||
|
|
||||||
|
## 致谢
|
||||||
|
|
||||||
|
- [ncm-api-rs](https://crates.io/crates/ncm-api-rs) — 网易云音乐 API 的 Rust 封装
|
||||||
|
- [Tauri](https://tauri.app/) — 跨平台桌面应用框架
|
||||||
|
- [Vue.js](https://vuejs.org/) — 渐进式 JavaScript 框架
|
||||||
|
- [Tailwind CSS](https://tailwindcss.com/) — 实用优先的 CSS 框架
|
||||||
|
- [rodio](https://crates.io/crates/rodio) — Rust 音频播放库
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
"core:window:allow-maximize",
|
"core:window:allow-maximize",
|
||||||
"core:window:allow-unmaximize",
|
"core:window:allow-unmaximize",
|
||||||
"core:window:allow-close",
|
"core:window:allow-close",
|
||||||
|
"core:window:allow-hide",
|
||||||
"core:window:allow-start-dragging",
|
"core:window:allow-start-dragging",
|
||||||
"core:window:allow-toggle-maximize"
|
"core:window:allow-toggle-maximize"
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
NMTID=00OvETy78e8ay9VhUTKgcUSdB6-yKQAAAGeAJacOg
|
|
||||||
@ -1,8 +1,9 @@
|
|||||||
use ncm_api_rs::{create_client, ApiClient, Query};
|
use ncm_api_rs::{create_client, ApiClient, Query};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tauri::State;
|
use tauri::{Manager, State};
|
||||||
use tokio::sync::Mutex; // 异步 Mutex
|
use tokio::sync::Mutex;
|
||||||
use std::sync::Mutex as StdMutex; // 同步 Mutex 用于 cookie
|
use std::sync::Mutex as StdMutex;
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@ -10,13 +11,13 @@ use std::path::PathBuf;
|
|||||||
pub struct ApiController {
|
pub struct ApiController {
|
||||||
client: Mutex<ApiClient>,
|
client: Mutex<ApiClient>,
|
||||||
cookie: StdMutex<Option<String>>,
|
cookie: StdMutex<Option<String>>,
|
||||||
cookie_path: PathBuf,
|
cookie_path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cookies_to_key_values(cookies: &[String]) -> String {
|
fn cookies_to_key_values(cookies: &[String]) -> String {
|
||||||
cookies
|
cookies
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|c| c.split(';').next()) // 取第一个键值对
|
.filter_map(|c| c.split(';').next())
|
||||||
.map(|s| s.trim().to_string())
|
.map(|s| s.trim().to_string())
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("; ")
|
.join("; ")
|
||||||
@ -24,14 +25,14 @@ fn cookies_to_key_values(cookies: &[String]) -> String {
|
|||||||
|
|
||||||
impl ApiController {
|
impl ApiController {
|
||||||
|
|
||||||
pub fn new() -> Self {
|
pub fn new(app_data_dir: PathBuf) -> Self {
|
||||||
let cookie_path = std::env::temp_dir().join("netease_cookies.json");
|
let _ = fs::create_dir_all(&app_data_dir);
|
||||||
|
let cookie_path = app_data_dir.join("netease_cookies.json");
|
||||||
let saved_cookie = fs::read_to_string(&cookie_path)
|
let saved_cookie = fs::read_to_string(&cookie_path)
|
||||||
.map(|s| s.trim().to_string())
|
.map(|s| s.trim().to_string())
|
||||||
.ok(); // 注意这里返回 Option<String>
|
.ok();
|
||||||
// eprintln!("[api] 启动时加载 cookie: {:?}", saved_cookie);
|
|
||||||
|
|
||||||
let client = create_client(None); // 不依赖客户端存储,我们自己管理
|
let client = create_client(None);
|
||||||
ApiController {
|
ApiController {
|
||||||
client: Mutex::new(client),
|
client: Mutex::new(client),
|
||||||
cookie: StdMutex::new(saved_cookie),
|
cookie: StdMutex::new(saved_cookie),
|
||||||
@ -43,13 +44,11 @@ fn build_query(&self) -> Query {
|
|||||||
let mut query = Query::new();
|
let mut query = Query::new();
|
||||||
if let Ok(cookie_guard) = self.cookie.lock() {
|
if let Ok(cookie_guard) = self.cookie.lock() {
|
||||||
if let Some(c) = cookie_guard.as_ref() {
|
if let Some(c) = cookie_guard.as_ref() {
|
||||||
// eprintln!("[api] 请求携带 cookie: {}", c);
|
|
||||||
query = query.cookie(c);
|
query = query.cookie(c);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
query
|
query
|
||||||
}
|
}
|
||||||
/// 保存 cookie 到文件
|
|
||||||
fn save_cookie(&self, cookie_str: &str) {
|
fn save_cookie(&self, cookie_str: &str) {
|
||||||
let _ = fs::write(&self.cookie_path, cookie_str);
|
let _ = fs::write(&self.cookie_path, cookie_str);
|
||||||
}
|
}
|
||||||
@ -58,14 +57,13 @@ fn build_query(&self) -> Query {
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct SearchQuery { pub keyword: String }
|
pub struct SearchQuery { pub keyword: String }
|
||||||
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct LoginQuery { pub phone: String, pub password: String }
|
pub struct LoginQuery { pub phone: String, pub password: String }
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct QrKeyQuery { pub key: String }
|
pub struct QrKeyQuery { pub key: String }
|
||||||
|
|
||||||
// 搜索歌曲
|
/// 搜索歌曲
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn search_songs(query: SearchQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
pub async fn search_songs(query: SearchQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||||
let client = state.client.lock().await;
|
let client = state.client.lock().await;
|
||||||
@ -78,7 +76,7 @@ pub async fn search_songs(query: SearchQuery, state: State<'_, ApiController>) -
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取热搜词
|
/// 获取热搜词列表
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_hot_search(state: State<'_, ApiController>) -> Result<String, String> {
|
pub async fn get_hot_search(state: State<'_, ApiController>) -> Result<String, String> {
|
||||||
let client = state.client.lock().await;
|
let client = state.client.lock().await;
|
||||||
@ -88,14 +86,33 @@ pub async fn get_hot_search(state: State<'_, ApiController>) -> Result<String, S
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct PlaylistTrackAllQuery { pub id: u64, pub limit: Option<i64>, pub offset: Option<i64> }
|
||||||
|
|
||||||
// 获取歌曲链接
|
/// 获取歌单全部歌曲
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_song_url(id: u64, state: State<'_, ApiController>) -> Result<String, String> {
|
pub async fn playlist_track_all(query: PlaylistTrackAllQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||||
let client = state.client.lock().await;
|
let client = state.client.lock().await;
|
||||||
let q = state.build_query()
|
let q = state.build_query()
|
||||||
.param("id", &id.to_string())
|
.param("id", &query.id.to_string())
|
||||||
.param("level", "standard");
|
.param("limit", &query.limit.unwrap_or(1000).to_string())
|
||||||
|
.param("offset", &query.offset.unwrap_or(0).to_string());
|
||||||
|
client.playlist_track_all(&q).await
|
||||||
|
.map(|r| r.body.to_string())
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SongUrlQuery { pub id: u64, pub level: Option<String> }
|
||||||
|
|
||||||
|
/// 获取歌曲播放地址
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_song_url(query: SongUrlQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||||
|
let client = state.client.lock().await;
|
||||||
|
let level = query.level.as_deref().unwrap_or("standard");
|
||||||
|
let q = state.build_query()
|
||||||
|
.param("id", &query.id.to_string())
|
||||||
|
.param("level", level);
|
||||||
let resp = client.song_url_v1(&q).await.map_err(|e| e.to_string())?;
|
let resp = client.song_url_v1(&q).await.map_err(|e| e.to_string())?;
|
||||||
resp.body["data"][0]["url"].as_str()
|
resp.body["data"][0]["url"].as_str()
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
@ -103,8 +120,7 @@ pub async fn get_song_url(id: u64, state: State<'_, ApiController>) -> Result<St
|
|||||||
.ok_or_else(|| "暂无播放源".into())
|
.ok_or_else(|| "暂无播放源".into())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 获取歌词
|
||||||
// 获取歌词
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_lyric(id: u64, state: State<'_, ApiController>) -> Result<String, String> {
|
pub async fn get_lyric(id: u64, state: State<'_, ApiController>) -> Result<String, String> {
|
||||||
let client = state.client.lock().await;
|
let client = state.client.lock().await;
|
||||||
@ -114,8 +130,7 @@ pub async fn get_lyric(id: u64, state: State<'_, ApiController>) -> Result<Strin
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 获取歌单详情
|
||||||
// 获取歌单详情
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_playlist_detail(id: u64, state: State<'_, ApiController>) -> Result<String, String> {
|
pub async fn get_playlist_detail(id: u64, state: State<'_, ApiController>) -> Result<String, String> {
|
||||||
let client = state.client.lock().await;
|
let client = state.client.lock().await;
|
||||||
@ -125,7 +140,7 @@ pub async fn get_playlist_detail(id: u64, state: State<'_, ApiController>) -> Re
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
// 登录
|
/// 手机号密码登录
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn login(query: LoginQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
pub async fn login(query: LoginQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||||
let client = state.client.lock().await;
|
let client = state.client.lock().await;
|
||||||
@ -143,17 +158,15 @@ pub async fn login(query: LoginQuery, state: State<'_, ApiController>) -> Result
|
|||||||
Ok(resp.body.to_string())
|
Ok(resp.body.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
// 登出
|
/// 退出登录
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn logout(state: State<'_, ApiController>) -> Result<(), String> {
|
pub async fn logout(state: State<'_, ApiController>) -> Result<(), String> {
|
||||||
// 清除内存中的 cookie
|
|
||||||
*state.cookie.lock().map_err(|e| e.to_string())? = None;
|
*state.cookie.lock().map_err(|e| e.to_string())? = None;
|
||||||
// 删除持久化文件
|
|
||||||
let _ = fs::remove_file(&state.cookie_path);
|
let _ = fs::remove_file(&state.cookie_path);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取二维码key
|
/// 获取二维码登录密钥
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_qr_key(state: State<'_, ApiController>) -> Result<String, String> {
|
pub async fn get_qr_key(state: State<'_, ApiController>) -> Result<String, String> {
|
||||||
let client = state.client.lock().await;
|
let client = state.client.lock().await;
|
||||||
@ -165,7 +178,7 @@ pub async fn get_qr_key(state: State<'_, ApiController>) -> Result<String, Strin
|
|||||||
.ok_or_else(|| "缺少 unikey".into())
|
.ok_or_else(|| "缺少 unikey".into())
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建二维码, 功能暂时有问题
|
/// 生成二维码图片
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn create_qr(
|
pub async fn create_qr(
|
||||||
query: QrKeyQuery,
|
query: QrKeyQuery,
|
||||||
@ -177,7 +190,6 @@ pub async fn create_qr(
|
|||||||
.param("key", &query.key)
|
.param("key", &query.key)
|
||||||
.param("qrimg", "true");
|
.param("qrimg", "true");
|
||||||
let resp = client.login_qr_create(&q).await.map_err(|e| e.to_string())?;
|
let resp = client.login_qr_create(&q).await.map_err(|e| e.to_string())?;
|
||||||
// 提取 qrurl 字段(网易云新的返回格式)
|
|
||||||
let qrurl = resp.body["data"]["qrurl"]
|
let qrurl = resp.body["data"]["qrurl"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.ok_or("未获取到二维码链接")?
|
.ok_or("未获取到二维码链接")?
|
||||||
@ -185,7 +197,7 @@ pub async fn create_qr(
|
|||||||
Ok(qrurl)
|
Ok(qrurl)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查二维码状态
|
/// 检查二维码扫码状态
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn check_qr_status(query: QrKeyQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
pub async fn check_qr_status(query: QrKeyQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||||
let client = state.client.lock().await;
|
let client = state.client.lock().await;
|
||||||
@ -199,7 +211,7 @@ pub async fn check_qr_status(query: QrKeyQuery, state: State<'_, ApiController>)
|
|||||||
Ok(resp.body.to_string())
|
Ok(resp.body.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取登录状态
|
/// 获取当前登录状态
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_login_status(state: State<'_, ApiController>) -> Result<String, String> {
|
pub async fn get_login_status(state: State<'_, ApiController>) -> Result<String, String> {
|
||||||
let client = state.client.lock().await;
|
let client = state.client.lock().await;
|
||||||
@ -209,7 +221,7 @@ pub async fn get_login_status(state: State<'_, ApiController>) -> Result<String,
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
// 用户歌单
|
/// 获取用户歌单列表
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn user_playlist(uid: u64, state: State<'_, ApiController>) -> Result<String, String> {
|
pub async fn user_playlist(uid: u64, state: State<'_, ApiController>) -> Result<String, String> {
|
||||||
let client = state.client.lock().await;
|
let client = state.client.lock().await;
|
||||||
@ -218,7 +230,7 @@ pub async fn user_playlist(uid: u64, state: State<'_, ApiController>) -> Result<
|
|||||||
Ok(resp.body.to_string())
|
Ok(resp.body.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
// 每日推荐歌曲
|
/// 获取每日推荐歌曲
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn recommend_songs(state: State<'_, ApiController>) -> Result<String, String> {
|
pub async fn recommend_songs(state: State<'_, ApiController>) -> Result<String, String> {
|
||||||
let client = state.client.lock().await;
|
let client = state.client.lock().await;
|
||||||
@ -227,7 +239,7 @@ pub async fn recommend_songs(state: State<'_, ApiController>) -> Result<String,
|
|||||||
Ok(resp.body.to_string())
|
Ok(resp.body.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
// 推荐歌单(需要登录)
|
/// 获取推荐歌单
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn recommend_resource(state: State<'_, ApiController>) -> Result<String, String> {
|
pub async fn recommend_resource(state: State<'_, ApiController>) -> Result<String, String> {
|
||||||
let client = state.client.lock().await;
|
let client = state.client.lock().await;
|
||||||
@ -236,6 +248,7 @@ pub async fn recommend_resource(state: State<'_, ApiController>) -> Result<Strin
|
|||||||
Ok(resp.body.to_string())
|
Ok(resp.body.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 获取私人漫游歌曲
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn personal_fm(state: State<'_, ApiController>) -> Result<String, String> {
|
pub async fn personal_fm(state: State<'_, ApiController>) -> Result<String, String> {
|
||||||
let client = state.client.lock().await;
|
let client = state.client.lock().await;
|
||||||
@ -244,10 +257,86 @@ pub async fn personal_fm(state: State<'_, ApiController>) -> Result<String, Stri
|
|||||||
Ok(resp.body.to_string())
|
Ok(resp.body.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 获取歌曲详情
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_song_detail(id: u64, state: State<'_, ApiController>) -> Result<String, String> {
|
pub async fn get_song_detail(id: String, state: State<'_, ApiController>) -> Result<String, String> {
|
||||||
let client = state.client.lock().await;
|
let client = state.client.lock().await;
|
||||||
let q = state.build_query().param("ids", &id.to_string());
|
let q = state.build_query().param("ids", &id);
|
||||||
let resp = client.song_detail(&q).await.map_err(|e| e.to_string())?;
|
let resp = client.song_detail(&q).await.map_err(|e| e.to_string())?;
|
||||||
Ok(resp.body.to_string())
|
Ok(resp.body.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UserRecordQuery { pub uid: u64, pub r#type: String }
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct LikeSongQuery { pub id: u64, pub like: String }
|
||||||
|
|
||||||
|
/// 获取喜欢的歌曲ID列表
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn likelist(uid: u64, state: State<'_, ApiController>) -> Result<String, String> {
|
||||||
|
let client = state.client.lock().await;
|
||||||
|
let q = state.build_query().param("uid", &uid.to_string());
|
||||||
|
client.likelist(&q).await
|
||||||
|
.map(|r| r.body.to_string())
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取用户播放记录
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn user_record(query: UserRecordQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||||
|
let client = state.client.lock().await;
|
||||||
|
let q = state.build_query()
|
||||||
|
.param("uid", &query.uid.to_string())
|
||||||
|
.param("type", &query.r#type);
|
||||||
|
client.user_record(&q).await
|
||||||
|
.map(|r| r.body.to_string())
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 喜欢/取消喜欢歌曲
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn like_song(query: LikeSongQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||||
|
let client = state.client.lock().await;
|
||||||
|
let q = state.build_query()
|
||||||
|
.param("id", &query.id.to_string())
|
||||||
|
.param("like", &query.like);
|
||||||
|
client.like(&q).await
|
||||||
|
.map(|r| r.body.to_string())
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上报最近播放歌曲
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn record_recent_song(limit: u64, state: State<'_, ApiController>) -> Result<String, String> {
|
||||||
|
let client = state.client.lock().await;
|
||||||
|
let q = state.build_query().param("limit", &limit.to_string());
|
||||||
|
client.record_recent_song(&q).await
|
||||||
|
.map(|r| r.body.to_string())
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct PlaylistSubscribeQuery { pub id: u64, pub subscribe: Option<bool> }
|
||||||
|
|
||||||
|
/// 收藏/取消收藏歌单
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn playlist_subscribe(query: PlaylistSubscribeQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||||
|
let client = state.client.lock().await;
|
||||||
|
let t = if query.subscribe.unwrap_or(true) { "1" } else { "0" };
|
||||||
|
let q = state.build_query()
|
||||||
|
.param("id", &query.id.to_string())
|
||||||
|
.param("t", t);
|
||||||
|
client.playlist_subscribe(&q).await
|
||||||
|
.map(|r| r.body.to_string())
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 退出应用
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn exit_app(app_handle: tauri::AppHandle) {
|
||||||
|
crate::ALLOW_EXIT.store(true, Ordering::SeqCst);
|
||||||
|
if let Some(window) = app_handle.get_webview_window("main") {
|
||||||
|
let _ = window.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,24 +1,25 @@
|
|||||||
use tauri::{
|
use tauri::{
|
||||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||||
menu::{MenuBuilder, MenuItemBuilder},
|
menu::{MenuBuilder, MenuItemBuilder},
|
||||||
Manager, LogicalSize, Emitter,
|
Manager, Emitter,
|
||||||
};
|
};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
mod api;
|
mod api;
|
||||||
mod audio;
|
mod audio;
|
||||||
use api::ApiController;
|
use api::ApiController;
|
||||||
use audio::AppAudio;
|
use audio::AppAudio;
|
||||||
|
|
||||||
|
static ALLOW_EXIT: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.setup(|app| {
|
.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();
|
let app_data_dir = app.path().app_data_dir().expect("无法获取应用数据目录");
|
||||||
|
let api_controller = ApiController::new(app_data_dir);
|
||||||
app.manage(api_controller);
|
app.manage(api_controller);
|
||||||
|
|
||||||
let audio_controller = audio::AudioController::new(app.handle().clone());
|
let audio_controller = audio::AudioController::new(app.handle().clone());
|
||||||
@ -66,7 +67,10 @@ pub fn run() {
|
|||||||
let _ = app.emit("tray-prev", ());
|
let _ = app.emit("tray-prev", ());
|
||||||
}
|
}
|
||||||
"quit" => {
|
"quit" => {
|
||||||
app.exit(0);
|
ALLOW_EXIT.store(true, Ordering::SeqCst);
|
||||||
|
if let Some(w) = app.get_webview_window("main") {
|
||||||
|
let _ = w.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@ -87,11 +91,15 @@ pub fn run() {
|
|||||||
.build(app)?;
|
.build(app)?;
|
||||||
|
|
||||||
// 点击关闭按钮时隐藏到托盘
|
// 点击关闭按钮时隐藏到托盘
|
||||||
|
let window = app.get_webview_window("main").unwrap();
|
||||||
let window_clone = window.clone();
|
let window_clone = window.clone();
|
||||||
window.on_window_event(move |event| {
|
window.on_window_event(move |event| {
|
||||||
if let tauri::WindowEvent::CloseRequested { api: close_api, .. } = event {
|
if let tauri::WindowEvent::CloseRequested { api: close_api, .. } = event {
|
||||||
close_api.prevent_close(); // 阻止窗口关闭
|
if ALLOW_EXIT.load(Ordering::SeqCst) {
|
||||||
let _ = window_clone.hide(); // 隐藏到托盘
|
return;
|
||||||
|
}
|
||||||
|
close_api.prevent_close();
|
||||||
|
let _ = window_clone.hide();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -115,6 +123,13 @@ pub fn run() {
|
|||||||
api::create_qr,
|
api::create_qr,
|
||||||
api::check_qr_status,
|
api::check_qr_status,
|
||||||
api::get_login_status,
|
api::get_login_status,
|
||||||
|
api::likelist,
|
||||||
|
api::user_record,
|
||||||
|
api::like_song,
|
||||||
|
api::record_recent_song,
|
||||||
|
api::playlist_subscribe,
|
||||||
|
api::playlist_track_all,
|
||||||
|
api::exit_app,
|
||||||
|
|
||||||
audio::play_audio,
|
audio::play_audio,
|
||||||
audio::pause_audio,
|
audio::pause_audio,
|
||||||
@ -125,6 +140,7 @@ pub fn run() {
|
|||||||
audio::seek_audio,
|
audio::seek_audio,
|
||||||
audio::set_volume
|
audio::set_volume
|
||||||
])
|
])
|
||||||
|
.plugin(tauri_plugin_opener::init())
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running Nekosonic");
|
.expect("error while running Nekosonic");
|
||||||
}
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Nekosonic",
|
"productName": "Nekosonic",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"identifier": "com.atdunbg.Nekosonic",
|
"identifier": "com.atdunbg.Nekosonic",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
@ -13,10 +13,10 @@
|
|||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"title": "Nekosonic",
|
"title": "Nekosonic",
|
||||||
"width": 1200,
|
"width": 1100,
|
||||||
"height": 700,
|
"height": 680,
|
||||||
"minWidth": 1200,
|
"minWidth": 900,
|
||||||
"minHeight": 700,
|
"minHeight": 600,
|
||||||
"resizable": true,
|
"resizable": true,
|
||||||
"decorations": false
|
"decorations": false
|
||||||
}
|
}
|
||||||
|
|||||||
376
src/App.vue
376
src/App.vue
@ -1,112 +1,121 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col h-screen bg-gray-950 text-white overflow-hidden">
|
<div class="flex flex-col h-screen bg-base text-content overflow-hidden">
|
||||||
<!-- ========= 自定义标题栏(可拖拽、无边框) ========= -->
|
|
||||||
<div
|
<div
|
||||||
data-tauri-drag-region
|
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"
|
class="h-10 flex items-center justify-between px-4 bg-surface/90 backdrop-blur select-none flex-shrink-0"
|
||||||
>
|
>
|
||||||
<span class="text-xs text-gray-400 font-medium ml-2">Nekosonic Music</span>
|
<span class="text-xs text-content-3 font-medium ml-2">Nekosonic Music</span>
|
||||||
<div class="flex items-center gap-1.5">
|
<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="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="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>
|
<button @click="closeWindow" class="w-3 h-3 rounded-full bg-red-500 hover:bg-red-400 transition" title="关闭"></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 主体内容区 -->
|
|
||||||
<div class="flex flex-1 overflow-hidden">
|
<div class="flex flex-1 overflow-hidden">
|
||||||
<!-- 左侧导航(无边框) -->
|
<nav class="w-56 flex-shrink-0 flex flex-col bg-surface/80 backdrop-blur">
|
||||||
<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 min-h-0">
|
||||||
<div class="flex-1 p-4 overflow-y-auto pb-24 flex flex-col">
|
<div class="flex flex-col min-h-full">
|
||||||
<!-- 推荐 & 发现 -->
|
<div class="relative mb-4">
|
||||||
|
<svg class="absolute left-3 top-1/2 -translate-y-1/2 text-content-3" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
|
||||||
|
<input v-model="searchQuery" @keydown.enter="doSearch" type="text" placeholder="搜索音乐..."
|
||||||
|
class="w-full rounded-lg bg-subtle pl-9 pr-3 py-2 text-sm text-content placeholder-content-3 outline-none focus:bg-muted transition" />
|
||||||
|
</div>
|
||||||
<div class="space-y-0.5">
|
<div class="space-y-0.5">
|
||||||
<router-link to="/"
|
<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"
|
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
|
||||||
active-class="!text-white !bg-white/10">
|
active-class="!text-content !bg-muted">
|
||||||
<span>🏠</span> 推荐
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12l9-9 9 9"/><path d="M5 10v10a1 1 0 001 1h3v-6h6v6h3a1 1 0 001-1V10"/></svg>
|
||||||
</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>
|
</router-link>
|
||||||
<button
|
<button
|
||||||
@click="openRoamFromSidebar"
|
@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"
|
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle w-full text-left"
|
||||||
>
|
>
|
||||||
<span>🌀</span> 漫游
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.4"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.4"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>
|
||||||
|
漫游
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 我的 -->
|
|
||||||
<div class="mt-4 mb-1 pt-2">
|
<div class="mt-4 mb-1 pt-2">
|
||||||
<p class="text-xs text-gray-500 px-3 mb-1">我的</p>
|
<p class="text-xs text-content-3 px-3 mb-1">我的</p>
|
||||||
<router-link to="/favorites"
|
<div class="space-y-0.5">
|
||||||
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">
|
<router-link to="/favorites"
|
||||||
<span>❤️</span> 我喜欢的音乐
|
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
|
||||||
</router-link>
|
active-class="!text-content !bg-muted">
|
||||||
<router-link to="/recent"
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
|
||||||
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>
|
<router-link to="/recent"
|
||||||
|
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
|
||||||
|
active-class="!text-content !bg-muted">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
|
||||||
|
最近播放
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 创建的歌单(可折叠) -->
|
|
||||||
<div class="mt-4 mb-1 pt-2" v-if="userStore.isLoggedIn">
|
<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"
|
<div class="flex items-center justify-between px-3 mb-1 cursor-pointer"
|
||||||
@click="showCreatedPlaylists = !showCreatedPlaylists">
|
@click="showCreatedPlaylists = !showCreatedPlaylists">
|
||||||
<p class="text-xs text-gray-500">我的歌单</p>
|
<p class="text-xs text-content-3">我的歌单</p>
|
||||||
<span class="text-xs text-gray-500 transition-transform"
|
<span class="text-xs text-content-3 transition-transform"
|
||||||
:class="{ 'rotate-90': showCreatedPlaylists }">▶</span>
|
:class="{ 'rotate-90': showCreatedPlaylists }">▶</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="showCreatedPlaylists" class="space-y-0.5">
|
<div v-show="showCreatedPlaylists" class="space-y-0.5">
|
||||||
<div v-for="pl in createdPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
|
<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">
|
class="px-3 py-1.5 rounded-lg text-sm cursor-pointer truncate transition-all duration-200"
|
||||||
|
:class="isPlaylistActive(pl.id) ? 'text-content bg-muted' : 'text-content-2 hover:text-content hover:bg-subtle'">
|
||||||
{{ pl.name }}
|
{{ pl.name }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 收藏的歌单(可折叠) -->
|
|
||||||
<div class="mt-4 mb-1 pt-2" v-if="userStore.isLoggedIn">
|
<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"
|
<div class="flex items-center justify-between px-3 mb-1 cursor-pointer"
|
||||||
@click="showSubPlaylists = !showSubPlaylists">
|
@click="showSubPlaylists = !showSubPlaylists">
|
||||||
<p class="text-xs text-gray-500">收藏的歌单</p>
|
<p class="text-xs text-content-3">收藏的歌单</p>
|
||||||
<span class="text-xs text-gray-500 transition-transform" :class="{ 'rotate-90': showSubPlaylists }">▶</span>
|
<span class="text-xs text-content-3 transition-transform" :class="{ 'rotate-90': showSubPlaylists }">▶</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="showSubPlaylists" class="space-y-0.5">
|
<div v-show="showSubPlaylists" class="space-y-0.5">
|
||||||
<div v-for="pl in subPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
|
<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">
|
class="px-3 py-1.5 rounded-lg text-sm cursor-pointer truncate transition-all duration-200"
|
||||||
|
:class="isPlaylistActive(pl.id) ? 'text-content bg-muted' : 'text-content-2 hover:text-content hover:bg-subtle'">
|
||||||
{{ pl.name }}
|
{{ pl.name }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 用户区域 -->
|
<div class="mt-auto pt-4" :class="player.currentSong ? 'pb-20' : 'pb-2'">
|
||||||
<div class="mt-auto pt-4">
|
<div class="px-1">
|
||||||
<div v-if="!userStore.isLoggedIn" class="px-2 space-y-2">
|
<router-link to="/settings"
|
||||||
<p class="text-xs text-gray-500">登录后享受个人歌单</p>
|
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
|
||||||
<router-link to="/login"
|
active-class="!text-content !bg-muted">
|
||||||
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">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
|
||||||
<span>🔑</span> 立即登录
|
设置
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="flex items-center gap-3 px-2">
|
<div v-if="!userStore.isLoggedIn" class="mt-3 p-3 rounded-xl bg-subtle/60">
|
||||||
<img :src="userStore.user?.avatarUrl" class="w-8 h-8 rounded-full ring-2 ring-green-400/50" />
|
<p class="text-xs text-content-3 mb-2">强烈建议登录以提升体验</p>
|
||||||
|
<router-link to="/login"
|
||||||
|
class="flex items-center justify-center gap-2 w-full px-4 py-2 rounded-lg bg-accent hover:bg-accent-hover transition text-sm font-medium text-white">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h4a2 2 0 012 2v14a2 2 0 01-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>
|
||||||
|
立即登录
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex items-center gap-3 px-2 mt-3">
|
||||||
|
<img :src="userStore.user?.avatarUrl" class="w-8 h-8 rounded-full ring-2 ring-accent/50" />
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="text-sm font-medium truncate">{{ userStore.user?.nickname }}</p>
|
<p class="text-sm font-medium truncate">{{ userStore.user?.nickname }}</p>
|
||||||
<button @click="userStore.logout()"
|
<button @click="userStore.logout()"
|
||||||
class="text-xs text-gray-500 hover:text-red-400 transition">退出登录</button>
|
class="text-xs text-content-3 hover:text-danger transition">退出登录</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- 主内容区 -->
|
|
||||||
<main class="flex-1 overflow-y-auto pb-24">
|
<main class="flex-1 overflow-y-auto pb-24">
|
||||||
<router-view v-slot="{ Component }">
|
<router-view v-slot="{ Component }">
|
||||||
<keep-alive :max="3" include="HomeView,DiscoverView">
|
<keep-alive :max="3" include="HomeView,DiscoverView">
|
||||||
@ -116,86 +125,206 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 全屏漫游抽屉 -->
|
|
||||||
<Transition name="drawer">
|
<Transition name="drawer">
|
||||||
<div
|
<div
|
||||||
v-if="player.showRoamDrawer"
|
v-if="player.showRoamDrawer"
|
||||||
class="fixed inset-0 z-50 flex flex-col backdrop-blur-xl bg-black/80"
|
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">
|
<div class="h-10 flex items-center justify-between px-4 flex-shrink-0" data-tauri-drag-region>
|
||||||
<button @click="player.closeRoamDrawer()" class="text-white/80 hover:text-white transition">
|
<button @click="player.closeRoamDrawer()" class="text-content-2 hover:text-content transition">
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
|
||||||
<path d="M18 6L6 18M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
|
<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>
|
||||||
<div class="flex-1 min-h-0 flex px-8 pb-8">
|
<div class="flex-1 min-h-0 flex px-8 pb-8 gap-0">
|
||||||
<div class="flex-shrink-0 mr-12 flex flex-col items-center self-center">
|
<div class="w-2/5 flex flex-col items-center justify-center flex-shrink-0">
|
||||||
<img
|
<img
|
||||||
:src="roamSong?.al?.picUrl || roamSong?.album?.picUrl"
|
:src="roamSong?.al?.picUrl || roamSong?.album?.picUrl"
|
||||||
class="w-72 h-72 rounded-3xl object-cover shadow-2xl mb-4"
|
class="w-72 h-72 rounded-3xl object-cover shadow-2xl mb-4"
|
||||||
/>
|
/>
|
||||||
<h1 class="text-2xl font-bold text-white">{{ roamSong?.name }}</h1>
|
<h1 class="text-2xl font-bold text-white text-center">{{ roamSong?.name }}</h1>
|
||||||
<p class="text-gray-400 mt-2">{{ roamArtists }}</p>
|
<p class="text-content-2 mt-2 text-center">{{ roamArtists }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div ref="lyricScrollContainer" class="flex-1 min-h-0 overflow-y-auto custom-scroll px-4">
|
<div class="w-3/5 relative min-h-0 overflow-hidden flex flex-col">
|
||||||
<div v-if="lyrics.length > 0" class="w-full max-w-lg mx-auto text-center space-y-3 py-8">
|
<div ref="lyricScrollContainer" class="h-full overflow-y-auto custom-scroll px-4">
|
||||||
<p
|
<div v-if="lyrics.length > 0" class="w-full max-w-lg mx-auto text-center"
|
||||||
v-for="(line, idx) in lyrics"
|
:style="{ paddingTop: roamLyricPadPx + 'px', paddingBottom: roamLyricPadPx + 'px' }">
|
||||||
:key="idx"
|
<p
|
||||||
:class="idx === currentLyricIdx ? 'text-green-400 font-medium text-lg transition' : 'text-gray-400 text-base'"
|
v-for="(line, idx) in lyrics"
|
||||||
>
|
:key="idx"
|
||||||
{{ line.text }}
|
:class="getRoamLyricClass(idx)"
|
||||||
</p>
|
class="roam-lyric-line px-4 py-3 rounded-lg cursor-pointer transition-all duration-300"
|
||||||
|
@click="seekToRoamLyric(line.time)"
|
||||||
|
@mouseenter="roamLyricHovering = true"
|
||||||
|
@mouseleave="roamLyricHovering = false"
|
||||||
|
>
|
||||||
|
{{ line.text }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-content-3 text-center mt-8">暂无歌词</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-gray-500 text-center mt-8">暂无歌词</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
<!-- 底部播放栏 -->
|
<PlayerBar v-if="player.currentSong" />
|
||||||
<PlayerBar />
|
<ToastContainer />
|
||||||
|
|
||||||
|
<Transition name="fade">
|
||||||
|
<div v-if="showCloseModal" class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showCloseModal = false">
|
||||||
|
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-80 p-6 select-auto">
|
||||||
|
<h2 class="text-lg font-semibold text-content mb-1">关闭确认</h2>
|
||||||
|
<p class="text-sm text-content-2 mb-5">你希望如何处理?</p>
|
||||||
|
<div class="space-y-2.5 mb-4">
|
||||||
|
<button @click="handleCloseAction('minimize')"
|
||||||
|
class="w-full flex items-center gap-3 px-4 py-3 rounded-xl bg-subtle hover:bg-muted transition text-left">
|
||||||
|
<div class="w-9 h-9 rounded-lg bg-accent-dim flex items-center justify-center flex-shrink-0">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-accent-text"><path d="M8 3v3a2 2 0 01-2 2H3m18 0h-3a2 2 0 01-2-2V3m0 18v-3a2 2 0 012-2h3M3 16h3a2 2 0 012 2v3"/></svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-content">最小化到托盘</p>
|
||||||
|
<p class="text-xs text-content-3">程序继续在后台运行</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button @click="handleCloseAction('exit')"
|
||||||
|
class="w-full flex items-center gap-3 px-4 py-3 rounded-xl bg-subtle hover:bg-muted transition text-left">
|
||||||
|
<div class="w-9 h-9 rounded-lg bg-danger-dim flex items-center justify-center flex-shrink-0">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-danger"><path d="M18 6L6 18M6 6l12 12"/></svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-content">退出程序</p>
|
||||||
|
<p class="text-xs text-content-3">完全关闭应用程序</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer mb-4 select-none">
|
||||||
|
<input type="checkbox" v-model="closeDontAskAgain" />
|
||||||
|
<span class="text-xs text-content-2">不再询问,记住我的选择</span>
|
||||||
|
</label>
|
||||||
|
<button @click="showCloseModal = false"
|
||||||
|
class="w-full py-2 rounded-lg bg-muted hover:bg-emphasis text-sm text-content-2 transition">
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, onMounted, onBeforeUnmount, computed, nextTick } from 'vue';
|
import { ref, watch, onMounted, onBeforeUnmount, computed, nextTick } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { useUserStore } from './stores/user';
|
import { useUserStore } from './stores/user';
|
||||||
|
import { useSettingsStore, type CloseAction } from './stores/settings';
|
||||||
import PlayerBar from './components/PlayerBar.vue';
|
import PlayerBar from './components/PlayerBar.vue';
|
||||||
|
import ToastContainer from './components/ToastContainer.vue';
|
||||||
import { usePlayerStore } from './stores/player';
|
import { usePlayerStore } from './stores/player';
|
||||||
import { useLyric } from './composables/UserLyric';
|
import { useLyric } from './composables/UserLyric';
|
||||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||||
|
import { listen } from '@tauri-apps/api/event';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const player = usePlayerStore();
|
const player = usePlayerStore();
|
||||||
|
const settings = useSettingsStore();
|
||||||
|
|
||||||
const createdPlaylists = ref<any[]>([]);
|
const createdPlaylists = ref<any[]>([]);
|
||||||
const subPlaylists = ref<any[]>([]);
|
const subPlaylists = ref<any[]>([]);
|
||||||
const showCreatedPlaylists = ref(true);
|
const showCreatedPlaylists = ref(true);
|
||||||
const showSubPlaylists = ref(true);
|
const showSubPlaylists = ref(true);
|
||||||
|
const searchQuery = ref('');
|
||||||
|
const showCloseModal = ref(false);
|
||||||
|
const closeDontAskAgain = ref(false);
|
||||||
|
|
||||||
|
watch(() => settings.theme, (val) => {
|
||||||
|
document.documentElement.setAttribute('data-theme', val);
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
function doSearch() {
|
||||||
|
const q = searchQuery.value.trim();
|
||||||
|
if (q) router.push({ path: '/discover', query: { q } });
|
||||||
|
}
|
||||||
|
|
||||||
// 歌词
|
|
||||||
const { lyrics, currentLyricIdx } = useLyric();
|
const { lyrics, currentLyricIdx } = useLyric();
|
||||||
const lyricScrollContainer = ref<HTMLElement | null>(null);
|
const lyricScrollContainer = ref<HTMLElement | null>(null);
|
||||||
|
const roamLyricHovering = ref(false);
|
||||||
|
const roamLyricPadPx = ref(0);
|
||||||
const roamSong = computed(() => player.currentSong);
|
const roamSong = computed(() => player.currentSong);
|
||||||
|
let roamResizeObserver: ResizeObserver | null = null;
|
||||||
|
|
||||||
|
function updateRoamLyricPad() {
|
||||||
|
if (lyricScrollContainer.value) {
|
||||||
|
roamLyricPadPx.value = Math.floor(lyricScrollContainer.value.clientHeight / 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => player.showRoamDrawer, (val) => {
|
||||||
|
if (val) {
|
||||||
|
nextTick(() => {
|
||||||
|
updateRoamLyricPad();
|
||||||
|
if (roamResizeObserver) roamResizeObserver.disconnect();
|
||||||
|
if (lyricScrollContainer.value) {
|
||||||
|
roamResizeObserver = new ResizeObserver(() => updateRoamLyricPad());
|
||||||
|
roamResizeObserver.observe(lyricScrollContainer.value);
|
||||||
|
}
|
||||||
|
scrollToRoamActiveLyric();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (roamResizeObserver) {
|
||||||
|
roamResizeObserver.disconnect();
|
||||||
|
roamResizeObserver = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (roamResizeObserver) {
|
||||||
|
roamResizeObserver.disconnect();
|
||||||
|
roamResizeObserver = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
const roamArtists = computed(() => {
|
const roamArtists = computed(() => {
|
||||||
if (!roamSong.value) return '';
|
if (!roamSong.value) return '';
|
||||||
return roamSong.value.ar?.map((a: any) => a.name).join(' / ') || '';
|
return roamSong.value.ar?.map((a: any) => a.name).join(' / ') || '';
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(currentLyricIdx, () => {
|
watch(currentLyricIdx, () => {
|
||||||
if (player.showRoamDrawer && lyricScrollContainer.value) {
|
if (player.showRoamDrawer && !roamLyricHovering.value) {
|
||||||
nextTick(() => {
|
nextTick(() => scrollToRoamActiveLyric());
|
||||||
const active = lyricScrollContainer.value?.querySelector('.text-green-400');
|
|
||||||
active?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function scrollToRoamActiveLyric() {
|
||||||
|
if (!lyricScrollContainer.value || roamLyricHovering.value) return;
|
||||||
|
const active = lyricScrollContainer.value.querySelector('.roam-lyric-active') as HTMLElement | null;
|
||||||
|
if (active) {
|
||||||
|
active.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoamLyricClass(idx: number): string {
|
||||||
|
const diff = Math.abs(idx - currentLyricIdx.value);
|
||||||
|
if (idx === currentLyricIdx.value) {
|
||||||
|
return 'roam-lyric-active text-accent-text font-semibold text-xl';
|
||||||
|
}
|
||||||
|
if (diff === 1) return 'text-content/70 text-lg';
|
||||||
|
if (diff === 2) return 'text-content-2/50 text-base';
|
||||||
|
return 'text-content-3/35 text-base';
|
||||||
|
}
|
||||||
|
|
||||||
|
function seekToRoamLyric(time: number) {
|
||||||
|
if (time != null && player.duration > 0) {
|
||||||
|
player.seek(time);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function openRoamFromSidebar() {
|
async function openRoamFromSidebar() {
|
||||||
if (player.isFmMode) {
|
if (player.isFmMode) {
|
||||||
player.openRoamDrawer();
|
player.openRoamDrawer();
|
||||||
@ -209,7 +338,7 @@ async function loadPlaylists() {
|
|||||||
try {
|
try {
|
||||||
const jsonStr: string = await invoke('user_playlist', { uid: userStore.user.userId });
|
const jsonStr: string = await invoke('user_playlist', { uid: userStore.user.userId });
|
||||||
const data = JSON.parse(jsonStr);
|
const data = JSON.parse(jsonStr);
|
||||||
createdPlaylists.value = (data.playlist || []).filter((p: any) => !p.subscribed);
|
createdPlaylists.value = (data.playlist || []).filter((p: any) => !p.subscribed).slice(1);
|
||||||
subPlaylists.value = (data.playlist || []).filter((p: any) => p.subscribed);
|
subPlaylists.value = (data.playlist || []).filter((p: any) => p.subscribed);
|
||||||
} catch (e) { /* 忽略 */ }
|
} catch (e) { /* 忽略 */ }
|
||||||
}
|
}
|
||||||
@ -218,12 +347,22 @@ function goPlaylist(id: number) {
|
|||||||
router.push({ name: 'playlist', params: { id } });
|
router.push({ name: 'playlist', params: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPlaylistActive(id: number): boolean {
|
||||||
|
return route.name === 'playlist' && Number(route.params.id) === id;
|
||||||
|
}
|
||||||
|
|
||||||
watch(() => userStore.isLoggedIn, (val) => {
|
watch(() => userStore.isLoggedIn, (val) => {
|
||||||
if (val) loadPlaylists();
|
if (val) {
|
||||||
|
loadPlaylists();
|
||||||
|
player.loadLikedIds();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (userStore.isLoggedIn) loadPlaylists();
|
if (userStore.isLoggedIn) {
|
||||||
|
loadPlaylists();
|
||||||
|
player.loadLikedIds();
|
||||||
|
}
|
||||||
try { await invoke('stop_audio'); } catch {}
|
try { await invoke('stop_audio'); } catch {}
|
||||||
try {
|
try {
|
||||||
const jsonStr: string = await invoke('get_login_status');
|
const jsonStr: string = await invoke('get_login_status');
|
||||||
@ -239,21 +378,37 @@ onMounted(async () => {
|
|||||||
} catch {}
|
} catch {}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------- 窗口控制 ----------
|
|
||||||
const currentWindow = getCurrentWindow();
|
const currentWindow = getCurrentWindow();
|
||||||
function minimizeWindow() { currentWindow.minimize(); }
|
function minimizeWindow() { currentWindow.minimize(); }
|
||||||
async function toggleMaximize() {
|
async function toggleMaximize() {
|
||||||
const isMaximized = await currentWindow.isMaximized();
|
const isMaximized = await currentWindow.isMaximized();
|
||||||
if (isMaximized) { currentWindow.unmaximize(); } else { currentWindow.maximize(); }
|
if (isMaximized) { currentWindow.unmaximize(); } else { currentWindow.maximize(); }
|
||||||
}
|
}
|
||||||
function closeWindow() { currentWindow.close(); }
|
function closeWindow() {
|
||||||
|
if (settings.closeAction === 'ask') {
|
||||||
|
closeDontAskAgain.value = false;
|
||||||
import { listen } from '@tauri-apps/api/event';
|
showCloseModal.value = true;
|
||||||
|
} else if (settings.closeAction === 'minimize') {
|
||||||
|
currentWindow.hide();
|
||||||
|
} else {
|
||||||
|
invoke('exit_app');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function handleCloseAction(action: CloseAction) {
|
||||||
|
if (closeDontAskAgain.value) {
|
||||||
|
settings.setCloseAction(action);
|
||||||
|
}
|
||||||
|
showCloseModal.value = false;
|
||||||
|
if (action === 'minimize') {
|
||||||
|
currentWindow.hide();
|
||||||
|
} else {
|
||||||
|
invoke('exit_app');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const unlisten1 = listen('tray-play-pause', () => {
|
const unlisten1 = listen('tray-play-pause', () => {
|
||||||
player.toggle(); // 假设 player 是 usePlayerStore 的实例
|
player.toggle();
|
||||||
});
|
});
|
||||||
const unlisten2 = listen('tray-next', () => {
|
const unlisten2 = listen('tray-next', () => {
|
||||||
player.next();
|
player.next();
|
||||||
@ -262,14 +417,35 @@ onMounted(() => {
|
|||||||
player.prev();
|
player.prev();
|
||||||
});
|
});
|
||||||
|
|
||||||
// 在组件卸载时取消监听
|
onBeforeUnmount(() => {
|
||||||
onBeforeUnmount(() => {
|
|
||||||
unlisten1.then(fn => fn());
|
unlisten1.then(fn => fn());
|
||||||
unlisten2.then(fn => fn());
|
unlisten2.then(fn => fn());
|
||||||
unlisten3.then(fn => fn());
|
unlisten3.then(fn => fn());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
const el = e.target as HTMLElement;
|
||||||
|
const isEditable = el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable;
|
||||||
|
if (e.code === 'Space' && !isEditable) {
|
||||||
|
e.preventDefault();
|
||||||
|
player.toggle();
|
||||||
|
}
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.code === 'ArrowRight') {
|
||||||
|
e.preventDefault();
|
||||||
|
player.next();
|
||||||
|
}
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.code === 'ArrowLeft') {
|
||||||
|
e.preventDefault();
|
||||||
|
player.prev();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKeydown);
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('keydown', onKeydown);
|
||||||
|
});
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@ -277,9 +453,17 @@ onBeforeUnmount(() => {
|
|||||||
.drawer-leave-active { transition: transform 0.3s ease; }
|
.drawer-leave-active { transition: transform 0.3s ease; }
|
||||||
.drawer-enter-from,
|
.drawer-enter-from,
|
||||||
.drawer-leave-to { transform: translateY(100%); }
|
.drawer-leave-to { transform: translateY(100%); }
|
||||||
.custom-scroll::-webkit-scrollbar { width: 4px; }
|
.fade-enter-active,
|
||||||
.custom-scroll::-webkit-scrollbar-thumb {
|
.fade-leave-active { transition: opacity 0.2s ease; }
|
||||||
background: rgba(255, 255, 255, 0.2);
|
.fade-enter-from,
|
||||||
border-radius: 2px;
|
.fade-leave-to { opacity: 0; }
|
||||||
|
.custom-scroll::-webkit-scrollbar { width: 0; display: none; }
|
||||||
|
.roam-lyric-line:hover {
|
||||||
|
background: var(--c-subtle);
|
||||||
|
color: var(--c-content) !important;
|
||||||
}
|
}
|
||||||
</style>
|
.roam-lyric-active:hover {
|
||||||
|
background: var(--c-subtle) !important;
|
||||||
|
color: var(--c-content) !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
BIN
src/assets/app-icon.png
Normal file
BIN
src/assets/app-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
79
src/components/CustomSelect.vue
Normal file
79
src/components/CustomSelect.vue
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative" ref="container">
|
||||||
|
<button
|
||||||
|
@click="toggle"
|
||||||
|
class="flex items-center justify-between bg-subtle border border-line rounded-lg px-3 py-1.5 text-sm text-content outline-none transition min-w-[140px] hover:border-content-3 focus:border-accent focus:shadow-[0_0_0_2px_var(--c-accent-dim)]"
|
||||||
|
:class="{ 'border-accent shadow-[0_0_0_2px_var(--c-accent-dim)]': isOpen }"
|
||||||
|
>
|
||||||
|
<span>{{ currentLabel }}</span>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="transition-transform flex-shrink-0 ml-2" :class="{ 'rotate-180': isOpen }"><polyline points="6 9 12 15 18 9"/></svg>
|
||||||
|
</button>
|
||||||
|
<Transition name="dropdown">
|
||||||
|
<div v-if="isOpen" class="absolute right-0 top-full mt-1 bg-surface border border-line rounded-lg shadow-xl z-50 py-1 min-w-full overflow-hidden">
|
||||||
|
<button
|
||||||
|
v-for="(label, key) in options"
|
||||||
|
:key="key"
|
||||||
|
@click="select(key)"
|
||||||
|
class="w-full text-left px-3 py-2 text-sm transition flex items-center justify-between"
|
||||||
|
:class="modelValue === key ? 'bg-accent-dim text-accent-text' : 'text-content-2 hover:bg-subtle hover:text-content'"
|
||||||
|
>
|
||||||
|
<span>{{ label }}</span>
|
||||||
|
<svg v-if="modelValue === key" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string;
|
||||||
|
options: Record<string, string>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const isOpen = ref(false);
|
||||||
|
const container = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
const currentLabel = computed(() => props.options[props.modelValue] || '');
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
isOpen.value = !isOpen.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(key: string) {
|
||||||
|
emit('update:modelValue', key);
|
||||||
|
isOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClickOutside(e: MouseEvent) {
|
||||||
|
if (container.value && !container.value.contains(e.target as Node)) {
|
||||||
|
isOpen.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('click', onClickOutside);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('click', onClickOutside);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dropdown-enter-active,
|
||||||
|
.dropdown-leave-active {
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
.dropdown-enter-from,
|
||||||
|
.dropdown-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -1,52 +1,44 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="player.currentSong"
|
<div
|
||||||
class="fixed bottom-0 left-0 right-0 bg-gray-900/95 backdrop-blur border-t border-white/10 z-50 select-none">
|
class="fixed bottom-0 left-0 right-0 bg-surface/95 backdrop-blur border-t border-line 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-muted rounded-full relative group cursor-pointer overflow-visible"
|
||||||
<div ref="progressBar" class="w-full h-1.5 bg-white/10 rounded-full relative group cursor-pointer overflow-visible"
|
|
||||||
@mousedown.prevent="startSeek">
|
@mousedown.prevent="startSeek">
|
||||||
<!-- 缓存进度(灰白) -->
|
<div class="absolute left-0 top-0 h-full bg-emphasis rounded-full" :style="{ width: cacheProgress + '%' }"></div>
|
||||||
<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-accent rounded-full"
|
||||||
<!-- 播放进度(绿色渐变) -->
|
|
||||||
<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>
|
:style="{ width: displayProgress + '%' }"></div>
|
||||||
<!-- 拖动圆点(基于容器定位,left 百分比) -->
|
|
||||||
<div
|
<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"
|
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>
|
:style="{ left: `calc(${displayProgress}% - 7px)` }"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 主控制区 -->
|
|
||||||
<div class="flex items-center px-6 h-16">
|
<div class="flex items-center px-6 h-16">
|
||||||
<!-- 左侧:歌曲信息 -->
|
|
||||||
<div class="flex items-center gap-3 w-56 min-w-0">
|
<div class="flex items-center gap-3 w-56 min-w-0">
|
||||||
<img :src="player.currentSong.al?.picUrl"
|
<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"
|
class="w-10 h-10 rounded-md object-cover flex-shrink-0 cursor-pointer hover:scale-105 transition-transform"
|
||||||
@click="player.openRoamDrawer()" title="全屏展示" />
|
@click="player.toggleRoamDrawer()" title="全屏展示" />
|
||||||
<div class="min-w-0">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="text-sm font-medium truncate">{{ player.currentSong.name }}</p>
|
<p class="text-sm font-medium truncate">{{ player.currentSong?.name }}</p>
|
||||||
<p class="text-xs text-gray-400 truncate">
|
<p class="text-xs text-content-2 truncate">
|
||||||
{{player.currentSong.ar?.map((a: any) => a.name).join('/')}}
|
{{player.currentSong?.ar?.map((a: any) => a.name).join('/')}}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<button @click="player.currentSong && player.toggleLike(player.currentSong.id)" class="flex-shrink-0 transition" :class="player.currentSong && player.isLiked(player.currentSong.id) ? 'text-danger' : 'text-content-3 hover:text-danger'">
|
||||||
|
<svg v-if="player.currentSong && player.isLiked(player.currentSong.id)" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
|
||||||
|
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 中间:控制按钮 + 时间 -->
|
|
||||||
<div class="flex-1 flex flex-col items-center justify-center gap-1">
|
<div class="flex-1 flex flex-col items-center justify-center gap-1">
|
||||||
<div class="flex items-center gap-5">
|
<div class="flex items-center gap-5">
|
||||||
<button @click="player.prev()" :disabled="player.isFmMode" :class="[
|
<button @click="player.prev()" :disabled="player.isFmMode" :class="[
|
||||||
'text-xl transition',
|
'transition',
|
||||||
player.isFmMode ? 'text-gray-600 cursor-not-allowed' : 'text-gray-400 hover:text-white',
|
player.isFmMode ? 'text-content-4 cursor-not-allowed' : 'text-content-2 hover:text-content',
|
||||||
]">
|
]">
|
||||||
⏮
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="19 20 9 12 19 4 19 20"/><line x1="5" y1="19" x2="5" y2="5"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button @click="player.toggle()"
|
<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">
|
class="w-9 h-9 flex items-center justify-center rounded-full bg-muted hover:bg-emphasis transition border border-emphasis">
|
||||||
<svg v-if="player.playing" width="16" height="16" viewBox="0 0 16 16" fill="currentColor"
|
<svg v-if="player.playing" width="16" height="16" viewBox="0 0 16 16" fill="currentColor"
|
||||||
class="text-white">
|
class="text-white">
|
||||||
<rect x="3" y="2" width="3" height="12" rx="0.5" />
|
<rect x="3" y="2" width="3" height="12" rx="0.5" />
|
||||||
@ -56,112 +48,93 @@
|
|||||||
<path d="M4 2.5v11l9-5.5z" />
|
<path d="M4 2.5v11l9-5.5z" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button @click="player.next()" class="text-xl text-gray-400 hover:text-white transition">⏭</button>
|
<button @click="player.next()" class="text-content-2 hover:text-content transition">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 4 15 12 5 20 5 4"/><line x1="19" y1="5" x2="19" y2="19"/></svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2 text-xs text-gray-400">
|
<div class="flex items-center gap-2 text-xs text-content-2">
|
||||||
<span>{{ formatTime(player.currentTime) }}</span>
|
<span>{{ formatTime(player.currentTime) }}</span>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span>{{ formatTime(player.duration) }}</span>
|
<span>{{ formatTime(player.duration) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 右侧:音量、模式、播放列表 -->
|
|
||||||
<div class="w-56 flex justify-end items-center gap-2">
|
<div class="w-56 flex justify-end items-center gap-2">
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<span class="text-sm text-gray-400">🔊</span>
|
<button @click="toggleMute" class="text-content-2 hover:text-content transition">
|
||||||
<div class="relative w-24 h-6 flex items-center">
|
<svg v-if="volume === 0" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg>
|
||||||
|
<svg v-else width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 010 14.14M15.54 8.46a5 5 0 010 7.07"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="relative w-20 h-6 flex items-center">
|
||||||
<input ref="volumeSlider" type="range" min="0" max="100" :value="volume"
|
<input ref="volumeSlider" type="range" min="0" max="100" :value="volume"
|
||||||
:style="{ background: volumeBarBg }" @input="handleVolumeChange"
|
:style="{ background: volumeBarBg }" @input="handleVolumeChange"
|
||||||
class="vol-slider w-full h-1.5 rounded-full appearance-none cursor-pointer bg-white/20 outline-none" />
|
class="vol-slider w-full h-1.5 rounded-full appearance-none cursor-pointer bg-emphasis outline-none" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button @click="togglePlayMode" class="text-gray-400 hover:text-white transition text-lg" :title="modeTitle">
|
<button @click="togglePlayMode" class="text-content-2 hover:text-content transition" :title="modeTitle">
|
||||||
{{ modeIcon }}
|
<svg v-if="player.playMode === 'loop'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 014-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 01-4 4H3"/></svg>
|
||||||
|
<svg v-else-if="player.playMode === 'shuffle'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg>
|
||||||
|
<svg v-else width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 014-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 01-4 4H3"/><text x="11" y="15" font-size="8" fill="currentColor" stroke="none" font-weight="bold">1</text></svg>
|
||||||
</button>
|
</button>
|
||||||
<button @click="showQueuePanel = !showQueuePanel"
|
<button @click="showQueuePanel = !showQueuePanel"
|
||||||
class="text-gray-400 hover:text-white transition text-xl relative" title="播放列表">
|
class="text-content-2 hover:text-content transition relative" title="播放列表">
|
||||||
📋
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
|
||||||
<span v-if="player.queue.length > 0"
|
<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">
|
class="absolute -top-1 -right-1 bg-accent text-content text-xs rounded-full w-4 h-4 flex items-center justify-center">
|
||||||
{{ player.queue.length }}
|
{{ player.queue.length }}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 队列面板 -->
|
|
||||||
<Transition name="slide-up">
|
<Transition name="slide-up">
|
||||||
<div v-if="showQueuePanel"
|
<div v-if="showQueuePanel"
|
||||||
class="border-t border-white/10 bg-gray-900/95 backdrop-blur p-4 max-h-64 overflow-y-auto">
|
class="border-t border-line bg-surface/95 backdrop-blur p-4 max-h-64 overflow-y-auto">
|
||||||
<div class="flex justify-between items-center mb-3">
|
<div class="flex justify-between items-center mb-3">
|
||||||
<h3 class="text-sm font-semibold">播放列表 ({{ player.queue.length }})</h3>
|
<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>
|
<button @click="player.clearQueue()" class="text-xs text-danger hover:text-danger transition">清空</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<div v-for="(song, idx) in player.queue" :key="song.id + '-' + idx" @click="playFromQueue(idx)" :class="[
|
<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',
|
'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',
|
idx === player.currentIndex ? 'bg-accent-dim text-content' : 'hover:bg-subtle text-content-2',
|
||||||
]">
|
]">
|
||||||
<span class="text-xs w-6 text-center">{{ idx + 1 }}</span>
|
<span class="text-xs w-6 text-center">{{ idx + 1 }}</span>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="text-xs font-medium truncate">{{ song.name }}</p>
|
<p class="text-xs font-medium truncate">{{ song.name }}</p>
|
||||||
<p class="text-xs text-gray-500 truncate">
|
<p class="text-xs text-content-3 truncate">
|
||||||
{{song.ar?.map((a: any) => a.name).join('/')}}
|
{{song.ar?.map((a: any) => a.name).join('/')}}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button @click.stop="player.removeFromQueue(idx)"
|
<button @click.stop="player.removeFromQueue(idx)"
|
||||||
class="text-gray-500 hover:text-red-400 transition text-sm">
|
class="text-content-3 hover:text-danger transition text-sm">
|
||||||
✕
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, nextTick, onBeforeUnmount, watch, onMounted } from 'vue';
|
import { ref, computed, onBeforeUnmount, onMounted } from 'vue';
|
||||||
import { usePlayerStore, PlayMode } from '../stores/player';
|
import { usePlayerStore, PlayMode } from '../stores/player';
|
||||||
|
import { formatTime } from '../utils/format';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { useLyric } from '../composables/UserLyric';
|
|
||||||
import { listen } from '@tauri-apps/api/event';
|
import { listen } from '@tauri-apps/api/event';
|
||||||
|
|
||||||
const player = usePlayerStore();
|
const player = usePlayerStore();
|
||||||
const showQueuePanel = ref(false);
|
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 progressBar = ref<HTMLElement | null>(null);
|
||||||
const isSeeking = ref(false);
|
const isSeeking = ref(false);
|
||||||
const previewTime = ref(0);
|
const previewTime = ref(0);
|
||||||
const cacheProgress = ref(0);
|
const cacheProgress = ref(0);
|
||||||
const volume = ref(100);
|
const volume = ref(100);
|
||||||
|
const prevVolume = ref(100);
|
||||||
|
|
||||||
let unlistenCache: (() => void) | null = null;
|
let unlistenCache: (() => void) | null = null;
|
||||||
|
|
||||||
// 缓存进度监听
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const fn = await listen<number>('cache-progress', (event) => {
|
const fn = await listen<number>('cache-progress', (event) => {
|
||||||
cacheProgress.value = event.payload;
|
cacheProgress.value = event.payload;
|
||||||
@ -172,10 +145,7 @@ onBeforeUnmount(() => {
|
|||||||
if (unlistenCache) unlistenCache();
|
if (unlistenCache) unlistenCache();
|
||||||
});
|
});
|
||||||
|
|
||||||
// 播放模式
|
|
||||||
const modeTexts = { loop: '列表循环', shuffle: '随机播放', 'repeat-one': '单曲循环' };
|
const modeTexts = { loop: '列表循环', shuffle: '随机播放', 'repeat-one': '单曲循环' };
|
||||||
const modeIcons = { loop: '🔁', shuffle: '🔀', 'repeat-one': '🔂' };
|
|
||||||
const modeIcon = computed(() => modeIcons[player.playMode] || '🔁');
|
|
||||||
const modeTitle = computed(() => modeTexts[player.playMode] || '列表循环');
|
const modeTitle = computed(() => modeTexts[player.playMode] || '列表循环');
|
||||||
function togglePlayMode() {
|
function togglePlayMode() {
|
||||||
const modes: PlayMode[] = ['loop', 'shuffle', 'repeat-one'];
|
const modes: PlayMode[] = ['loop', 'shuffle', 'repeat-one'];
|
||||||
@ -183,7 +153,16 @@ function togglePlayMode() {
|
|||||||
player.setPlayMode(next);
|
player.setPlayMode(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 进度条拖拽逻辑
|
function toggleMute() {
|
||||||
|
if (volume.value > 0) {
|
||||||
|
prevVolume.value = volume.value;
|
||||||
|
volume.value = 0;
|
||||||
|
} else {
|
||||||
|
volume.value = prevVolume.value || 100;
|
||||||
|
}
|
||||||
|
invoke('set_volume', { vol: volume.value / 100 });
|
||||||
|
}
|
||||||
|
|
||||||
let onDocMove: ((e: MouseEvent) => void) | null = null;
|
let onDocMove: ((e: MouseEvent) => void) | null = null;
|
||||||
let onDocUp: (() => void) | null = null;
|
let onDocUp: (() => void) | null = null;
|
||||||
|
|
||||||
@ -234,13 +213,6 @@ const displayProgress = computed(() => {
|
|||||||
return isSeeking.value ? previewPercent.value : progressPercent.value;
|
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) {
|
function playFromQueue(index: number) {
|
||||||
player.currentIndex = index;
|
player.currentIndex = index;
|
||||||
player.playCurrent();
|
player.playCurrent();
|
||||||
@ -255,25 +227,11 @@ async function handleVolumeChange(e: Event) {
|
|||||||
|
|
||||||
const volumeBarBg = computed(() => {
|
const volumeBarBg = computed(() => {
|
||||||
const pct = volume.value;
|
const pct = volume.value;
|
||||||
return `linear-gradient(to right, #34d399 0%, #10b981 ${pct}%, rgba(255,255,255,0.15) ${pct}%)`;
|
return `linear-gradient(to right, var(--c-accent) 0%, var(--c-accent) ${pct}%, var(--c-muted) ${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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* 样式保持不变(原有歌词浮层过渡、滑块样式等) */
|
|
||||||
.slide-up-enter-active,
|
.slide-up-enter-active,
|
||||||
.slide-up-leave-active {
|
.slide-up-leave-active {
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
@ -300,9 +258,9 @@ watch(
|
|||||||
height: 6px;
|
height: 6px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
background: linear-gradient(to right,
|
background: linear-gradient(to right,
|
||||||
#34d399 0%,
|
var(--c-accent) 0%,
|
||||||
#10b981 var(--vol-fill),
|
var(--c-accent) var(--vol-fill),
|
||||||
rgba(255, 255, 255, 0.15) var(--vol-fill));
|
var(--c-muted) var(--vol-fill));
|
||||||
}
|
}
|
||||||
|
|
||||||
.vol-slider::-webkit-slider-thumb {
|
.vol-slider::-webkit-slider-thumb {
|
||||||
@ -321,4 +279,4 @@ watch(
|
|||||||
.vol-slider::-webkit-slider-thumb:hover {
|
.vol-slider::-webkit-slider-thumb:hover {
|
||||||
transform: scale(1.2);
|
transform: scale(1.2);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
60
src/components/ToastContainer.vue
Normal file
60
src/components/ToastContainer.vue
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
<template>
|
||||||
|
<TransitionGroup name="toast" tag="div" class="fixed top-4 right-4 z-[100] flex flex-col gap-2 pointer-events-none">
|
||||||
|
<div
|
||||||
|
v-for="toast in toasts"
|
||||||
|
:key="toast.id"
|
||||||
|
@click="dismiss(toast.id)"
|
||||||
|
class="pointer-events-auto min-w-[280px] max-w-[400px] px-4 py-3 rounded-lg shadow-lg bg-surface/95 backdrop-blur cursor-pointer border-l-4"
|
||||||
|
:class="borderClass(toast.type)"
|
||||||
|
>
|
||||||
|
<p class="text-sm font-medium" :class="textClass(toast.type)">{{ toast.message }}</p>
|
||||||
|
</div>
|
||||||
|
</TransitionGroup>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useToast, type Toast } from '../composables/useToast';
|
||||||
|
|
||||||
|
const { toasts } = useToast();
|
||||||
|
|
||||||
|
function borderClass(type: Toast['type']) {
|
||||||
|
return {
|
||||||
|
success: 'border-accent',
|
||||||
|
error: 'border-danger',
|
||||||
|
info: 'border-info',
|
||||||
|
}[type];
|
||||||
|
}
|
||||||
|
|
||||||
|
function textClass(type: Toast['type']) {
|
||||||
|
return {
|
||||||
|
success: 'text-accent-text',
|
||||||
|
error: 'text-danger',
|
||||||
|
info: 'text-info',
|
||||||
|
}[type];
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismiss(id: number) {
|
||||||
|
const idx = toasts.value.findIndex(t => t.id === id);
|
||||||
|
if (idx !== -1) toasts.value.splice(idx, 1);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toast-enter-active {
|
||||||
|
transition: all 0.3s ease-out;
|
||||||
|
}
|
||||||
|
.toast-leave-active {
|
||||||
|
transition: all 0.2s ease-in;
|
||||||
|
}
|
||||||
|
.toast-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
.toast-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
.toast-move {
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { ref, computed, watch } from 'vue';
|
import { ref, watch } from 'vue';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { parseLrc, getCurrentLyricIndex, LyricLine } from '../utils/lyric';
|
import { parseLrc, getCurrentLyricIndex, LyricLine } from '../utils/lyric';
|
||||||
import { usePlayerStore } from '../stores/player';
|
import { usePlayerStore } from '../stores/player';
|
||||||
@ -9,12 +9,6 @@ export function useLyric() {
|
|||||||
const lyrics = ref<LyricLine[]>([]);
|
const lyrics = ref<LyricLine[]>([]);
|
||||||
const currentLyricIdx = ref(-1);
|
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) => {
|
watch(() => player.currentSong, async (song) => {
|
||||||
if (!song) {
|
if (!song) {
|
||||||
lyrics.value = [];
|
lyrics.value = [];
|
||||||
@ -43,6 +37,5 @@ export function useLyric() {
|
|||||||
return {
|
return {
|
||||||
lyrics,
|
lyrics,
|
||||||
currentLyricIdx,
|
currentLyricIdx,
|
||||||
currentLyricText,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
22
src/composables/useToast.ts
Normal file
22
src/composables/useToast.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: number;
|
||||||
|
message: string;
|
||||||
|
type: 'success' | 'error' | 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
const toasts = ref<Toast[]>([]);
|
||||||
|
let nextId = 0;
|
||||||
|
|
||||||
|
export function showToast(message: string, type: 'success' | 'error' | 'info' = 'info', duration = 3000) {
|
||||||
|
const id = nextId++;
|
||||||
|
toasts.value.push({ id, message, type });
|
||||||
|
setTimeout(() => {
|
||||||
|
toasts.value = toasts.value.filter(t => t.id !== id);
|
||||||
|
}, duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
return { toasts, showToast };
|
||||||
|
}
|
||||||
@ -6,18 +6,20 @@ import Login from '@/views/Login.vue';
|
|||||||
import FavoriteSongs from '@/views/FavoriteSongs.vue';
|
import FavoriteSongs from '@/views/FavoriteSongs.vue';
|
||||||
import RecentPlays from '@/views/RecentPlays.vue';
|
import RecentPlays from '@/views/RecentPlays.vue';
|
||||||
import DailySongs from '@/views/DailySongs.vue';
|
import DailySongs from '@/views/DailySongs.vue';
|
||||||
|
import Settings from '@/views/Settings.vue';
|
||||||
|
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{ path: '/', name: 'home', component: Home },
|
{ path: '/', name: 'home', component: Home },
|
||||||
{ path: '/discover', name: 'discover', component: Discover },
|
{ path: '/discover', name: 'discover', component: Discover },
|
||||||
{ path: '/search', name: 'search', component: Discover }, // 同样指向Discover,保留兼容
|
{ path: '/search', name: 'search', component: Discover },
|
||||||
{ path: '/roam', name: 'roam', component: () => import('@/views/Roam.vue') }, // 漫游页面
|
{ path: '/roam', name: 'roam', component: () => import('@/views/Roam.vue') },
|
||||||
{ path: '/favorites', name: 'favorites', component: FavoriteSongs },
|
{ path: '/favorites', name: 'favorites', component: FavoriteSongs },
|
||||||
{ path: '/recent', name: 'recent', component: RecentPlays },
|
{ path: '/recent', name: 'recent', component: RecentPlays },
|
||||||
{ path: '/daily', name: 'daily', component: DailySongs }, // 每日推荐
|
{ path: '/daily', name: 'daily', component: DailySongs },
|
||||||
{ path: '/login', name: 'login', component: Login },
|
{ path: '/login', name: 'login', component: Login },
|
||||||
{ path: '/playlist/:id', name: 'playlist', component: PlaylistDetail },
|
{ path: '/playlist/:id', name: 'playlist', component: PlaylistDetail },
|
||||||
|
{ path: '/settings', name: 'settings', component: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default createRouter({
|
export default createRouter({
|
||||||
|
|||||||
@ -2,8 +2,9 @@ import { defineStore } from 'pinia';
|
|||||||
import { ref , watch } from 'vue';
|
import { ref , watch } from 'vue';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { normalizeSong } from '../utils/song';
|
import { normalizeSong } from '../utils/song';
|
||||||
|
import { useSettingsStore } from './settings';
|
||||||
|
import { useUserStore } from './user';
|
||||||
|
|
||||||
// 设置播放模式,目前只有顺序循环,后续可扩展
|
|
||||||
export type PlayMode = 'loop' | 'shuffle' | 'repeat-one';
|
export type PlayMode = 'loop' | 'shuffle' | 'repeat-one';
|
||||||
|
|
||||||
export interface Song {
|
export interface Song {
|
||||||
@ -33,6 +34,22 @@ export function setupCacheProgressListener() {
|
|||||||
// 在 store 定义外调用 setupCacheProgressListener(),或者在应用入口调用
|
// 在 store 定义外调用 setupCacheProgressListener(),或者在应用入口调用
|
||||||
|
|
||||||
|
|
||||||
|
function loadRecentLocal(): Song[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('recent_local');
|
||||||
|
if (raw) return JSON.parse(raw);
|
||||||
|
} catch {}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadLikedIdsFromStorage(): Set<number> {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('liked_ids');
|
||||||
|
if (raw) return new Set(JSON.parse(raw));
|
||||||
|
} catch {}
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
|
||||||
export const usePlayerStore = defineStore('player', () => {
|
export const usePlayerStore = defineStore('player', () => {
|
||||||
const currentSong = ref<Song | null>(null);
|
const currentSong = ref<Song | null>(null);
|
||||||
const playing = ref(false);
|
const playing = ref(false);
|
||||||
@ -44,6 +61,56 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
|
|
||||||
let tickInterval: ReturnType<typeof setInterval> | null = null;
|
let tickInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
const recentLocal = ref<Song[]>(loadRecentLocal());
|
||||||
|
const MAX_RECENT = 200;
|
||||||
|
|
||||||
|
const likedIds = ref<Set<number>>(loadLikedIdsFromStorage());
|
||||||
|
|
||||||
|
function isLiked(songId: number): boolean {
|
||||||
|
return likedIds.value.has(songId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLikedIds() {
|
||||||
|
const userStore = useUserStore();
|
||||||
|
if (!userStore.isLoggedIn) return;
|
||||||
|
try {
|
||||||
|
const json: string = await invoke('likelist', { uid: userStore.user!.userId });
|
||||||
|
const data = JSON.parse(json);
|
||||||
|
const ids: number[] = data.ids || data.data?.ids || [];
|
||||||
|
likedIds.value = new Set(ids);
|
||||||
|
} catch { /* 忽略 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleLike(songId: number) {
|
||||||
|
const wasLiked = likedIds.value.has(songId);
|
||||||
|
const newLike = !wasLiked;
|
||||||
|
try {
|
||||||
|
await invoke('like_song', { query: { id: songId, like: newLike ? 'true' : 'false' } });
|
||||||
|
if (newLike) {
|
||||||
|
likedIds.value.add(songId);
|
||||||
|
} else {
|
||||||
|
likedIds.value.delete(songId);
|
||||||
|
}
|
||||||
|
likedIds.value = new Set(likedIds.value);
|
||||||
|
} catch { /* 忽略 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRecent(song: Song) {
|
||||||
|
recentLocal.value = recentLocal.value.filter(s => s.id !== song.id);
|
||||||
|
recentLocal.value.unshift(song);
|
||||||
|
if (recentLocal.value.length > MAX_RECENT) {
|
||||||
|
recentLocal.value = recentLocal.value.slice(0, MAX_RECENT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(recentLocal, (val) => {
|
||||||
|
localStorage.setItem('recent_local', JSON.stringify(val));
|
||||||
|
}, { deep: true });
|
||||||
|
|
||||||
|
watch(likedIds, (val) => {
|
||||||
|
localStorage.setItem('liked_ids', JSON.stringify([...val]));
|
||||||
|
}, { deep: true });
|
||||||
|
|
||||||
const isFmMode = ref(false);
|
const isFmMode = ref(false);
|
||||||
let fmNextCallback: (() => void) | null = null;
|
let fmNextCallback: (() => void) | null = null;
|
||||||
|
|
||||||
@ -62,7 +129,7 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
// 如果缺少时长,尝试从详情接口获取
|
// 如果缺少时长,尝试从详情接口获取
|
||||||
if (!song.dt || song.dt === 0) {
|
if (!song.dt || song.dt === 0) {
|
||||||
try {
|
try {
|
||||||
const jsonStr: string = await invoke('get_song_detail', { id: Number(song.id) });
|
const jsonStr: string = await invoke('get_song_detail', { id: String(song.id) });
|
||||||
const data = JSON.parse(jsonStr);
|
const data = JSON.parse(jsonStr);
|
||||||
const full = data.songs?.[0];
|
const full = data.songs?.[0];
|
||||||
if (full) {
|
if (full) {
|
||||||
@ -80,13 +147,15 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
|
|
||||||
currentSong.value = song;
|
currentSong.value = song;
|
||||||
try {
|
try {
|
||||||
const url: string = await invoke('get_song_url', { id: Number(song.id) });
|
const settings = useSettingsStore();
|
||||||
|
const url: string = await invoke('get_song_url', { query: { id: Number(song.id), level: settings.audioQuality } });
|
||||||
if (!url) throw new Error('无播放源');
|
if (!url) throw new Error('无播放源');
|
||||||
await invoke('play_audio', { url });
|
await invoke('play_audio', { url });
|
||||||
playing.value = true;
|
playing.value = true;
|
||||||
duration.value = (song.dt || 0) / 1000;
|
duration.value = (song.dt || 0) / 1000;
|
||||||
currentTime.value = 0;
|
currentTime.value = 0;
|
||||||
startTick();
|
startTick();
|
||||||
|
addRecent(song);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('FM播放失败', e);
|
console.error('FM播放失败', e);
|
||||||
playing.value = false;
|
playing.value = false;
|
||||||
@ -122,8 +191,8 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
currentTime.value = 0;
|
currentTime.value = 0;
|
||||||
duration.value = (song.dt || 0) / 1000;
|
duration.value = (song.dt || 0) / 1000;
|
||||||
|
|
||||||
// 获取 URL 并播放
|
const settings = useSettingsStore();
|
||||||
const url: string = await invoke('get_song_url', { id: Number(song.id) });
|
const url: string = await invoke('get_song_url', { query: { id: Number(song.id), level: settings.audioQuality } });
|
||||||
if (!url) {
|
if (!url) {
|
||||||
console.error('未获取到有效播放地址', song);
|
console.error('未获取到有效播放地址', song);
|
||||||
return;
|
return;
|
||||||
@ -132,6 +201,7 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
await invoke('play_audio', { url });
|
await invoke('play_audio', { url });
|
||||||
playing.value = true;
|
playing.value = true;
|
||||||
startTick();
|
startTick();
|
||||||
|
addRecent(song);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('播放失败', e);
|
console.error('播放失败', e);
|
||||||
playing.value = false;
|
playing.value = false;
|
||||||
@ -279,6 +349,10 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
showRoamDrawer.value = false;
|
showRoamDrawer.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleRoamDrawer() {
|
||||||
|
showRoamDrawer.value = !showRoamDrawer.value;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadFirstFmSong() {
|
async function loadFirstFmSong() {
|
||||||
try {
|
try {
|
||||||
const jsonStr: string = await invoke('personal_fm');
|
const jsonStr: string = await invoke('personal_fm');
|
||||||
@ -388,9 +462,17 @@ watch(playing, (val) => {
|
|||||||
removeFromQueue,
|
removeFromQueue,
|
||||||
clearQueue,
|
clearQueue,
|
||||||
|
|
||||||
|
recentLocal,
|
||||||
|
|
||||||
|
likedIds,
|
||||||
|
isLiked,
|
||||||
|
loadLikedIds,
|
||||||
|
toggleLike,
|
||||||
|
|
||||||
showRoamDrawer,
|
showRoamDrawer,
|
||||||
openRoamDrawer,
|
openRoamDrawer,
|
||||||
closeRoamDrawer,
|
closeRoamDrawer,
|
||||||
|
toggleRoamDrawer,
|
||||||
loadFirstFmSong,
|
loadFirstFmSong,
|
||||||
|
|
||||||
fmSong,
|
fmSong,
|
||||||
|
|||||||
86
src/stores/settings.ts
Normal file
86
src/stores/settings.ts
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
|
||||||
|
export type AudioQuality = 'standard' | 'higher' | 'exhigh' | 'lossless' | 'hires';
|
||||||
|
export type ThemeMode = 'dark' | 'light';
|
||||||
|
export type CloseAction = 'ask' | 'minimize' | 'exit';
|
||||||
|
|
||||||
|
export const qualityLabels: Record<AudioQuality, string> = {
|
||||||
|
standard: '标准',
|
||||||
|
higher: '较高',
|
||||||
|
exhigh: '极高 (HQ)',
|
||||||
|
lossless: '无损 (SQ)',
|
||||||
|
hires: 'Hi-Res',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const closeActionLabels: Record<CloseAction, string> = {
|
||||||
|
ask: '每次询问',
|
||||||
|
minimize: '最小化到托盘',
|
||||||
|
exit: '直接退出',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SettingsData {
|
||||||
|
audioQuality: AudioQuality;
|
||||||
|
downloadPath: string;
|
||||||
|
theme: ThemeMode;
|
||||||
|
closeAction: CloseAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSettings(): SettingsData {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('app_settings');
|
||||||
|
if (raw) return JSON.parse(raw);
|
||||||
|
} catch {}
|
||||||
|
return {
|
||||||
|
audioQuality: 'standard',
|
||||||
|
downloadPath: '',
|
||||||
|
theme: 'dark',
|
||||||
|
closeAction: 'ask',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSettingsStore = defineStore('settings', () => {
|
||||||
|
const saved = loadSettings();
|
||||||
|
|
||||||
|
const audioQuality = ref<AudioQuality>(saved.audioQuality);
|
||||||
|
const downloadPath = ref<string>(saved.downloadPath);
|
||||||
|
const theme = ref<ThemeMode>(saved.theme);
|
||||||
|
const closeAction = ref<CloseAction>(saved.closeAction || 'ask');
|
||||||
|
|
||||||
|
function setAudioQuality(q: AudioQuality) {
|
||||||
|
audioQuality.value = q;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDownloadPath(p: string) {
|
||||||
|
downloadPath.value = p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTheme(t: ThemeMode) {
|
||||||
|
theme.value = t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCloseAction(a: CloseAction) {
|
||||||
|
closeAction.value = a;
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([audioQuality, downloadPath, theme, closeAction], () => {
|
||||||
|
const data: SettingsData = {
|
||||||
|
audioQuality: audioQuality.value,
|
||||||
|
downloadPath: downloadPath.value,
|
||||||
|
theme: theme.value,
|
||||||
|
closeAction: closeAction.value,
|
||||||
|
};
|
||||||
|
localStorage.setItem('app_settings', JSON.stringify(data));
|
||||||
|
}, { deep: true });
|
||||||
|
|
||||||
|
return {
|
||||||
|
audioQuality,
|
||||||
|
downloadPath,
|
||||||
|
theme,
|
||||||
|
closeAction,
|
||||||
|
setAudioQuality,
|
||||||
|
setDownloadPath,
|
||||||
|
setTheme,
|
||||||
|
setCloseAction,
|
||||||
|
};
|
||||||
|
});
|
||||||
133
src/style.css
133
src/style.css
@ -1,14 +1,74 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-base: var(--c-bg);
|
||||||
|
--color-surface: var(--c-surface);
|
||||||
|
--color-subtle: var(--c-subtle);
|
||||||
|
--color-muted: var(--c-muted);
|
||||||
|
--color-emphasis: var(--c-emphasis);
|
||||||
|
--color-content: var(--c-content);
|
||||||
|
--color-content-2: var(--c-content-2);
|
||||||
|
--color-content-3: var(--c-content-3);
|
||||||
|
--color-content-4: var(--c-content-4);
|
||||||
|
--color-line: var(--c-line);
|
||||||
|
--color-line-2: var(--c-line-2);
|
||||||
|
--color-accent: var(--c-accent);
|
||||||
|
--color-accent-hover: var(--c-accent-hover);
|
||||||
|
--color-accent-text: var(--c-accent-text);
|
||||||
|
--color-accent-dim: var(--c-accent-dim);
|
||||||
|
--color-danger: var(--c-danger);
|
||||||
|
--color-danger-dim: var(--c-danger-dim);
|
||||||
|
--color-warning: var(--c-warning);
|
||||||
|
--color-info: var(--c-info);
|
||||||
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--color-surface: 255 255 255;
|
--c-bg: #030712;
|
||||||
--color-primary: 34 197 94;
|
--c-surface: #111827;
|
||||||
|
--c-subtle: rgba(255, 255, 255, 0.05);
|
||||||
|
--c-muted: rgba(255, 255, 255, 0.10);
|
||||||
|
--c-emphasis: rgba(255, 255, 255, 0.18);
|
||||||
|
--c-content: #ffffff;
|
||||||
|
--c-content-2: #9ca3af;
|
||||||
|
--c-content-3: #6b7280;
|
||||||
|
--c-content-4: #4b5563;
|
||||||
|
--c-line: rgba(255, 255, 255, 0.10);
|
||||||
|
--c-line-2: rgba(255, 255, 255, 0.05);
|
||||||
|
--c-accent: #22c55e;
|
||||||
|
--c-accent-hover: #16a34a;
|
||||||
|
--c-accent-text: #4ade80;
|
||||||
|
--c-accent-dim: rgba(34, 197, 94, 0.20);
|
||||||
|
--c-danger: #ef4444;
|
||||||
|
--c-danger-dim: rgba(239, 68, 68, 0.20);
|
||||||
|
--c-warning: #eab308;
|
||||||
|
--c-info: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] {
|
||||||
|
--c-bg: #f3f4f6;
|
||||||
|
--c-surface: #ffffff;
|
||||||
|
--c-subtle: rgba(0, 0, 0, 0.04);
|
||||||
|
--c-muted: rgba(0, 0, 0, 0.08);
|
||||||
|
--c-emphasis: rgba(0, 0, 0, 0.12);
|
||||||
|
--c-content: #111827;
|
||||||
|
--c-content-2: #4b5563;
|
||||||
|
--c-content-3: #6b7280;
|
||||||
|
--c-content-4: #9ca3af;
|
||||||
|
--c-line: rgba(0, 0, 0, 0.10);
|
||||||
|
--c-line-2: rgba(0, 0, 0, 0.05);
|
||||||
|
--c-accent: #16a34a;
|
||||||
|
--c-accent-hover: #15803d;
|
||||||
|
--c-accent-text: #16a34a;
|
||||||
|
--c-accent-dim: rgba(22, 163, 74, 0.15);
|
||||||
|
--c-danger: #dc2626;
|
||||||
|
--c-danger-dim: rgba(220, 38, 38, 0.15);
|
||||||
|
--c-warning: #ca8a04;
|
||||||
|
--c-info: #2563eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 确保 html 也应用暗色背景,防止空白区域 */
|
|
||||||
html {
|
html {
|
||||||
background: #0f172a;
|
background: var(--c-bg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overscroll-behavior: none;
|
overscroll-behavior: none;
|
||||||
@ -17,17 +77,14 @@
|
|||||||
body {
|
body {
|
||||||
@apply antialiased;
|
@apply antialiased;
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||||
background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%);
|
background: var(--c-bg);
|
||||||
/* 关键:锁住 body,彻底消除整体拖动 */
|
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
overscroll-behavior: none;
|
overscroll-behavior: none;
|
||||||
/* 阻止触控板手势触发页面导航 */
|
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 自定义滚动条保持不变 */
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 5px;
|
width: 5px;
|
||||||
height: 5px;
|
height: 5px;
|
||||||
@ -36,10 +93,64 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background-color: rgba(255, 255, 255, 0.2);
|
background-color: var(--c-muted);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background-color: rgba(255, 255, 255, 0.4);
|
background-color: var(--c-emphasis);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
select {
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 10px center;
|
||||||
|
padding-right: 30px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
select:focus {
|
||||||
|
border-color: var(--c-accent);
|
||||||
|
box-shadow: 0 0 0 2px var(--c-accent-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
select option {
|
||||||
|
background: var(--c-surface);
|
||||||
|
color: var(--c-content);
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
appearance: none;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid var(--c-emphasis);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"]:hover {
|
||||||
|
border-color: var(--c-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"]:checked {
|
||||||
|
background: var(--c-accent);
|
||||||
|
border-color: var(--c-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"]:checked::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 4px;
|
||||||
|
top: 1px;
|
||||||
|
width: 4px;
|
||||||
|
height: 8px;
|
||||||
|
border: solid white;
|
||||||
|
border-width: 0 2px 2px 0;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
20
src/utils/format.ts
Normal file
20
src/utils/format.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
export 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')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPlayCount(count: number): string {
|
||||||
|
if (!count) return '0';
|
||||||
|
if (count >= 100000000) return (count / 100000000).toFixed(1) + '亿';
|
||||||
|
if (count >= 10000) return (count / 10000).toFixed(1) + '万';
|
||||||
|
return count.toString();
|
||||||
|
}
|
||||||
@ -1,26 +1,52 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="p-8 text-white">
|
<div class="p-8 text-content">
|
||||||
<button @click="$router.back()" class="mb-4 text-gray-400 hover:text-white transition">
|
<button @click="$router.back()" class="mb-4 text-content-2 hover:text-content transition">
|
||||||
← 返回
|
← 返回
|
||||||
</button>
|
</button>
|
||||||
<h1 class="text-2xl font-bold mb-6">每日推荐</h1>
|
<div class="flex items-center justify-between mb-6">
|
||||||
<div v-if="loading" class="text-gray-400">加载中...</div>
|
<h1 class="text-2xl font-bold">每日推荐</h1>
|
||||||
|
<button
|
||||||
|
v-if="songs.length > 0"
|
||||||
|
@click="player.playAll(songs)"
|
||||||
|
class="px-4 py-2 bg-accent hover:bg-accent-hover rounded-full text-white font-medium transition text-sm"
|
||||||
|
>
|
||||||
|
播放全部
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="loading" class="text-content-2">加载中...</div>
|
||||||
<div v-else class="space-y-2">
|
<div v-else class="space-y-2">
|
||||||
<div
|
<div
|
||||||
v-for="(song, index) in songs"
|
v-for="(song, index) in songs"
|
||||||
:key="song.id"
|
:key="song.id"
|
||||||
@click="player.play(song)"
|
@click="player.play(song)"
|
||||||
class="flex items-center gap-4 p-3 rounded-xl hover:bg-white/5 transition cursor-pointer"
|
class="flex items-center gap-4 p-3 rounded-xl hover:bg-subtle transition cursor-pointer group"
|
||||||
|
:class="{ 'bg-accent-dim': isCurrentSong(song.id) }"
|
||||||
>
|
>
|
||||||
<span class="text-xs text-gray-500 w-6 text-right">{{ index + 1 }}</span>
|
<div class="w-6 text-right flex-shrink-0 flex items-center justify-end h-5">
|
||||||
<img :src="song.al?.picUrl" class="w-10 h-10 rounded object-cover" />
|
<div v-if="isCurrentSong(song.id)" class="flex items-center justify-end">
|
||||||
|
<div class="flex items-center gap-[3px] h-4">
|
||||||
|
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 50%; animation-delay: 0ms"></span>
|
||||||
|
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 100%; animation-delay: 150ms"></span>
|
||||||
|
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 35%; animation-delay: 300ms"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<span class="text-xs text-content-3 group-hover:hidden">{{ index + 1 }}</span>
|
||||||
|
<svg class="hidden group-hover:block text-content" width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4 2.5v11l9-5.5z"/></svg>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<img :src="song.al?.picUrl" class="w-10 h-10 rounded object-cover flex-shrink-0" />
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="text-sm font-medium truncate">{{ song.name }}</p>
|
<p class="text-sm font-medium truncate" :class="isCurrentSong(song.id) ? 'text-accent-text' : ''">{{ song.name }}</p>
|
||||||
<p class="text-xs text-gray-400 truncate">
|
<p class="text-xs text-content-2 truncate">
|
||||||
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
|
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-xs text-gray-500">{{ formatDuration(song.dt) }}</span>
|
<button @click.stop="player.toggleLike(song.id)" class="text-content-3 hover:text-danger transition flex-shrink-0">
|
||||||
|
<svg v-if="player.isLiked(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" class="text-danger"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
|
||||||
|
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
|
||||||
|
</button>
|
||||||
|
<span class="text-xs text-content-3">{{ formatDuration(song.dt) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -30,11 +56,16 @@
|
|||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted } from 'vue';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { usePlayerStore } from '../stores/player';
|
import { usePlayerStore } from '../stores/player';
|
||||||
|
import { formatDuration } from '../utils/format';
|
||||||
|
|
||||||
const player = usePlayerStore();
|
const player = usePlayerStore();
|
||||||
const songs = ref<any[]>([]);
|
const songs = ref<any[]>([]);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
|
|
||||||
|
function isCurrentSong(songId: number): boolean {
|
||||||
|
return player.currentSong?.id === songId;
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const jsonStr: string = await invoke('recommend_songs');
|
const jsonStr: string = await invoke('recommend_songs');
|
||||||
@ -46,11 +77,4 @@ onMounted(async () => {
|
|||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
</script>
|
||||||
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>
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="p-8 text-white">
|
<div class="p-8 text-content">
|
||||||
<h1 class="text-2xl font-bold mb-4">发现音乐</h1>
|
<h1 class="text-2xl font-bold mb-4">发现音乐</h1>
|
||||||
|
|
||||||
<!-- 搜索框 -->
|
<!-- 搜索框 -->
|
||||||
@ -7,7 +7,7 @@
|
|||||||
v-model="keyword"
|
v-model="keyword"
|
||||||
@keyup.enter="handleSearch"
|
@keyup.enter="handleSearch"
|
||||||
placeholder="搜索歌曲、歌手、专辑..."
|
placeholder="搜索歌曲、歌手、专辑..."
|
||||||
class="mb-4 w-full rounded-xl bg-white/10 p-3 text-white placeholder-gray-400 outline-none backdrop-blur"
|
class="mb-4 w-full rounded-xl bg-muted p-3 text-content placeholder-content-2 outline-none backdrop-blur"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 热门搜索标签(仅在没有搜索且未显示结果时出现) -->
|
<!-- 热门搜索标签(仅在没有搜索且未显示结果时出现) -->
|
||||||
@ -18,7 +18,7 @@
|
|||||||
v-for="tag in hotTags"
|
v-for="tag in hotTags"
|
||||||
:key="tag.searchWord"
|
:key="tag.searchWord"
|
||||||
@click="searchTag(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"
|
class="px-3 py-1 rounded-full bg-muted hover:bg-emphasis cursor-pointer transition text-sm"
|
||||||
>
|
>
|
||||||
{{ tag.searchWord }}
|
{{ tag.searchWord }}
|
||||||
</span>
|
</span>
|
||||||
@ -27,31 +27,31 @@
|
|||||||
|
|
||||||
<!-- 输出设备选择 -->
|
<!-- 输出设备选择 -->
|
||||||
<!-- <div class="mb-4">
|
<!-- <div class="mb-4">
|
||||||
<label class="mr-2 text-sm text-gray-400">输出设备:</label>
|
<label class="mr-2 text-sm text-content-2">输出设备:</label>
|
||||||
<select v-model="selectedDevice" @change="changeDevice" class="bg-white/10 text-white rounded p-1 text-sm">
|
<select v-model="selectedDevice" @change="changeDevice" class="bg-muted text-white rounded p-1 text-sm">
|
||||||
<option :value="null">跟随系统默认</option>
|
<option :value="null">跟随系统默认</option>
|
||||||
<option v-for="dev in devices" :key="dev" :value="dev">{{ dev }}</option>
|
<option v-for="dev in devices" :key="dev" :value="dev">{{ dev }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div> -->
|
</div> -->
|
||||||
|
|
||||||
<!-- 搜索结果 -->
|
<!-- 搜索结果 -->
|
||||||
<div v-if="loading" class="text-gray-400">搜索中...</div>
|
<div v-if="loading" class="text-content-2">搜索中...</div>
|
||||||
<div v-else class="space-y-3">
|
<div v-else class="space-y-3">
|
||||||
<div
|
<div
|
||||||
v-for="song in results"
|
v-for="song in results"
|
||||||
:key="song.id"
|
:key="song.id"
|
||||||
@click="playSong(song)"
|
@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"
|
class="flex items-center gap-4 p-3 rounded-xl backdrop-blur-md bg-subtle hover:bg-muted border border-line-2 cursor-pointer transition"
|
||||||
>
|
>
|
||||||
<img :src="song.al?.picUrl" class="w-12 h-12 rounded-lg object-cover" />
|
<img :src="song.al?.picUrl" class="w-12 h-12 rounded-lg object-cover" />
|
||||||
<div>
|
<div>
|
||||||
<p class="font-medium">{{ song.name }}</p>
|
<p class="font-medium">{{ song.name }}</p>
|
||||||
<p class="text-sm text-gray-400">
|
<p class="text-sm text-content-2">
|
||||||
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
|
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!loading && hasSearched && results.length === 0" class="text-gray-400">无结果</p>
|
<p v-if="!loading && hasSearched && results.length === 0" class="text-content-2">无结果</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -120,4 +120,4 @@ async function playSong(song: any) {
|
|||||||
player.play(song);
|
player.play(song);
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,6 +1,82 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="p-8 text-white">
|
<div class="p-8 text-content">
|
||||||
<h1 class="text-2xl font-bold mb-4">❤️ 我喜欢的音乐</h1>
|
<button @click="$router.back()" class="mb-4 text-content-2 hover:text-content transition">
|
||||||
<p class="text-gray-400">正在施工...</p>
|
← 返回
|
||||||
|
</button>
|
||||||
|
<div class="flex items-center gap-4 mb-6">
|
||||||
|
<h1 class="text-2xl font-bold">我喜欢的音乐</h1>
|
||||||
|
<button
|
||||||
|
v-if="songs.length"
|
||||||
|
@click="player.playAll(songs)"
|
||||||
|
class="px-4 py-1.5 bg-muted hover:bg-emphasis rounded-full text-sm transition"
|
||||||
|
>
|
||||||
|
播放全部
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="!userStore.isLoggedIn" class="text-content-2">
|
||||||
|
请先登录后查看喜欢的音乐
|
||||||
|
</div>
|
||||||
|
<div v-else-if="loading" class="text-content-2">加载中...</div>
|
||||||
|
<div v-else-if="songs.length === 0" class="text-content-2">暂无喜欢的音乐</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-subtle transition cursor-pointer"
|
||||||
|
>
|
||||||
|
<span class="text-xs text-content-3 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-content-2 truncate">
|
||||||
|
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button @click.stop="player.toggleLike(song.id)" class="text-content-3 hover:text-danger transition flex-shrink-0">
|
||||||
|
<svg v-if="player.isLiked(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" class="text-danger"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
|
||||||
|
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
|
||||||
|
</button>
|
||||||
|
<span class="text-xs text-content-3">{{ formatDuration(song.dt) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { usePlayerStore } from '../stores/player';
|
||||||
|
import { useUserStore } from '../stores/user';
|
||||||
|
import { normalizeSong } from '../utils/song';
|
||||||
|
import { formatDuration } from '../utils/format';
|
||||||
|
|
||||||
|
const player = usePlayerStore();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const songs = ref<any[]>([]);
|
||||||
|
const loading = ref(true);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!userStore.isLoggedIn) {
|
||||||
|
loading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const playlistJson: string = await invoke('user_playlist', { uid: userStore.user!.userId });
|
||||||
|
const playlistData = JSON.parse(playlistJson);
|
||||||
|
const created = (playlistData.playlist || []).filter((p: any) => !p.subscribed);
|
||||||
|
if (created.length === 0) {
|
||||||
|
loading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const likePlaylistId = created[0].id;
|
||||||
|
const trackJson: string = await invoke('playlist_track_all', { query: { id: likePlaylistId } });
|
||||||
|
const trackData = JSON.parse(trackJson);
|
||||||
|
songs.value = (trackData.songs || []).map(normalizeSong);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="p-8 text-white">
|
<div class="p-8 text-content">
|
||||||
<!-- 第一行:每日推荐 & 私人漫游 卡片 -->
|
<!-- 第一行:每日推荐 & 私人漫游 卡片 -->
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-10">
|
<div class="grid grid-cols-2 gap-6 mb-10">
|
||||||
<!-- 每日推荐 -->
|
<!-- 每日推荐 -->
|
||||||
<div
|
<div
|
||||||
class="h-48 bg-gradient-to-br from-pink-600 to-purple-700 rounded-3xl overflow-hidden relative cursor-pointer group"
|
class="h-48 bg-gradient-to-br from-pink-600 to-purple-700 rounded-3xl overflow-hidden relative cursor-pointer group"
|
||||||
@ -19,76 +19,61 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 私人漫游 卡片 -->
|
<!-- 私人漫游 卡片 -->
|
||||||
<!-- 私人漫游 卡片 -->
|
<div
|
||||||
<div
|
class="h-48 rounded-3xl overflow-hidden relative group select-none cursor-pointer"
|
||||||
class="h-48 bg-gradient-to-br from-blue-600 to-cyan-500 rounded-3xl overflow-hidden relative group select-none"
|
:class="player.fmSong && fmCoverUrl ? '' : 'bg-gradient-to-br from-indigo-600 via-blue-600 to-cyan-500'"
|
||||||
@click="!userStore.isLoggedIn ? goLogin() : null"
|
@click="onFmCardClick"
|
||||||
>
|
|
||||||
<!-- 模糊封面层(仅在有歌曲且有封面时显示,低透明度模糊) -->
|
|
||||||
<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">
|
<div
|
||||||
<path d="M4 2.5v11l9-5.5z" />
|
v-if="player.fmSong && fmCoverUrl"
|
||||||
</svg>
|
class="absolute inset-0 bg-cover bg-center scale-110"
|
||||||
</button>
|
:style="{ backgroundImage: `url(${fmCoverUrl})` }"
|
||||||
</div>
|
></div>
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-t from-black/70 via-black/30 to-black/10 group-hover:from-black/60 transition"></div>
|
||||||
|
|
||||||
<!-- 有歌曲 → 横向布局:左侧信息,右侧按钮 -->
|
<div class="relative z-10 h-full flex flex-col justify-between p-6">
|
||||||
<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-2">
|
||||||
<!-- 左侧:封面 + 歌曲信息 -->
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-white/50"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.4"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.4"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>
|
||||||
<div class="flex items-center gap-3 min-w-0">
|
<span class="text-xs text-white/50 font-medium">私人漫游</span>
|
||||||
<img :src="fmCoverUrl" class="w-14 h-14 rounded-xl object-cover flex-shrink-0" />
|
</div>
|
||||||
<div class="min-w-0">
|
|
||||||
<p class="text-sm font-semibold truncate">{{ fmDisplayName }}</p>
|
<div class="flex items-end justify-between gap-4">
|
||||||
<p class="text-xs text-white/70 truncate">{{ fmDisplayArtists }}</p>
|
<div class="min-w-0 flex-1">
|
||||||
|
<h2 class="text-xl font-bold" v-if="!player.fmSong && userStore.isLoggedIn">发现新音乐</h2>
|
||||||
|
<h2 class="text-xl font-bold" v-else-if="!userStore.isLoggedIn">私人漫游</h2>
|
||||||
|
<h2 class="text-lg font-bold truncate" v-else>{{ fmDisplayName }}</h2>
|
||||||
|
<p v-if="!userStore.isLoggedIn" class="text-xs text-white/50 mt-1">登录后开启沉浸式音乐探索</p>
|
||||||
|
<p v-else-if="!player.fmSong" class="text-xs text-white/50 mt-1">根据你的喜好,为你推荐意想不到的好歌</p>
|
||||||
|
<p v-else class="text-xs text-white/60 truncate mt-1">{{ fmDisplayArtists }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<button v-if="userStore.isLoggedIn && !player.fmSong"
|
||||||
|
@click.stop="startFmPlay"
|
||||||
|
class="w-10 h-10 flex items-center justify-center rounded-full bg-white/15 hover:bg-white/25 backdrop-blur-sm transition">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" class="text-white">
|
||||||
|
<path d="M4 2.5v11l9-5.5z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<template v-if="player.fmSong">
|
||||||
|
<button @click.stop="player.toggleFm"
|
||||||
|
class="w-10 h-10 flex items-center justify-center rounded-full bg-white/15 hover:bg-white/25 backdrop-blur-sm 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="w-8 h-8 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 backdrop-blur-sm transition">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="text-white"><polygon points="5 4 15 12 5 20 5 4"/><line x1="19" y1="5" x2="19" y2="19"/></svg>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
@ -97,11 +82,11 @@
|
|||||||
<h2 class="text-xl font-semibold mb-4">🎯 为你推荐</h2>
|
<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 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)"
|
<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">
|
class="bg-subtle rounded-xl overflow-hidden hover:bg-muted transition cursor-pointer">
|
||||||
<img :src="pl.picUrl" class="w-full aspect-square object-cover" />
|
<img :src="pl.picUrl" class="w-full aspect-square object-cover" />
|
||||||
<div class="p-3">
|
<div class="p-3">
|
||||||
<p class="text-sm font-medium truncate">{{ pl.name }}</p>
|
<p class="text-sm font-medium truncate">{{ pl.name }}</p>
|
||||||
<p class="text-xs text-gray-400 mt-1">{{ pl.copywriter || '' }}</p>
|
<p class="text-xs text-content-2 mt-1">{{ pl.copywriter || '' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -112,7 +97,7 @@
|
|||||||
<h2 class="text-xl font-semibold mb-4">📈 热门歌单</h2>
|
<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 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)"
|
<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">
|
class="bg-subtle rounded-xl overflow-hidden hover:bg-muted transition cursor-pointer backdrop-blur-sm">
|
||||||
<img :src="pl.coverImgUrl" class="w-full aspect-square object-cover" />
|
<img :src="pl.coverImgUrl" class="w-full aspect-square object-cover" />
|
||||||
<div class="p-3">
|
<div class="p-3">
|
||||||
<p class="text-sm font-medium truncate">{{ pl.name }}</p>
|
<p class="text-sm font-medium truncate">{{ pl.name }}</p>
|
||||||
@ -155,15 +140,25 @@ const fmDisplayArtists = computed(() => {
|
|||||||
|
|
||||||
// 首次点击播放按钮:开始 FM 并播放
|
// 首次点击播放按钮:开始 FM 并播放
|
||||||
async function startFmPlay() {
|
async function startFmPlay() {
|
||||||
// 如果还没加载过 FM,或者之前加载了但被停止了,重新加载
|
|
||||||
if (!player.fmSong) {
|
if (!player.fmSong) {
|
||||||
await player.loadFm(); // loadFm 内部会设置 fmSong 并播放
|
await player.loadFm();
|
||||||
} else {
|
} else {
|
||||||
// 已有歌曲但未播放状态(比如之前暂停/停止了),直接播放
|
|
||||||
await player.toggleFm();
|
await player.toggleFm();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onFmCardClick() {
|
||||||
|
if (!userStore.isLoggedIn) {
|
||||||
|
goLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!player.fmSong) {
|
||||||
|
startFmPlay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
player.openRoamDrawer();
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
todayStr.value = `${d.getMonth() + 1}月${d.getDate()}日`;
|
todayStr.value = `${d.getMonth() + 1}月${d.getDate()}日`;
|
||||||
|
|||||||
@ -1,21 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen flex items-center justify-center bg-gray-950 text-white">
|
<div class="min-h-screen flex items-center justify-center bg-base text-content">
|
||||||
<div class="bg-white/5 backdrop-blur-md border border-white/10 p-8 rounded-2xl w-full max-w-sm text-center">
|
<div class="bg-subtle backdrop-blur-md border border-line p-8 rounded-2xl w-full max-w-sm text-center">
|
||||||
<h1 class="text-xl font-bold mb-4">扫码登录</h1>
|
<h1 class="text-xl font-bold mb-4">扫码登录</h1>
|
||||||
<p class="text-sm text-gray-400 mb-6">请使用网易云音乐 App 扫描二维码</p>
|
<p class="text-sm text-content-2 mb-6">请使用网易云音乐 App 扫描二维码</p>
|
||||||
|
|
||||||
<!-- 二维码展示区 -->
|
|
||||||
<div v-if="qrimg" class="bg-white p-3 rounded-xl inline-block mb-4">
|
<div v-if="qrimg" class="bg-white p-3 rounded-xl inline-block mb-4">
|
||||||
<img :src="qrimg" alt="二维码" class="w-48 h-48" />
|
<img :src="qrimg" alt="二维码" class="w-48 h-48" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="w-48 h-48 bg-white/5 rounded-xl flex items-center justify-center mx-auto mb-4">
|
<div v-else class="w-48 h-48 bg-subtle rounded-xl flex items-center justify-center mx-auto mb-4">
|
||||||
<span v-if="qrLoading" class="text-gray-400">加载中...</span>
|
<span v-if="qrLoading" class="text-content-2">加载中...</span>
|
||||||
<span v-else-if="qrError" class="text-red-400 text-sm">{{ qrError }}</span>
|
<span v-else-if="qrError" class="text-danger text-sm">{{ qrError }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 状态提示 -->
|
|
||||||
<p class="text-sm" :class="statusColor">{{ statusText }}</p>
|
<p class="text-sm" :class="statusColor">{{ statusText }}</p>
|
||||||
<button @click="refreshQr" class="mt-4 text-xs text-green-400 hover:underline">重新获取二维码</button>
|
<button @click="refreshQr" class="mt-4 text-xs text-accent-text hover:underline">重新获取二维码</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -33,7 +31,7 @@ const qrimg = ref('');
|
|||||||
const qrLoading = ref(true);
|
const qrLoading = ref(true);
|
||||||
const qrError = ref('');
|
const qrError = ref('');
|
||||||
const statusText = ref('等待扫码...');
|
const statusText = ref('等待扫码...');
|
||||||
const statusColor = ref('text-gray-400');
|
const statusColor = ref('text-content-2');
|
||||||
let qrKey = '';
|
let qrKey = '';
|
||||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
@ -54,7 +52,6 @@ async function refreshQr() {
|
|||||||
qrError.value = '';
|
qrError.value = '';
|
||||||
if (pollTimer) clearInterval(pollTimer);
|
if (pollTimer) clearInterval(pollTimer);
|
||||||
try {
|
try {
|
||||||
// 1. 获取 unikey
|
|
||||||
qrKey = await invoke('get_qr_key');
|
qrKey = await invoke('get_qr_key');
|
||||||
if (!qrKey) {
|
if (!qrKey) {
|
||||||
qrError.value = '未获取到登录密钥';
|
qrError.value = '未获取到登录密钥';
|
||||||
@ -62,16 +59,13 @@ async function refreshQr() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 拼接网易云标准扫码链接(无需 create_qr)
|
|
||||||
const qrUrl = `https://music.163.com/login?codekey=${qrKey}&type=1`;
|
const qrUrl = `https://music.163.com/login?codekey=${qrKey}&type=1`;
|
||||||
|
|
||||||
// 3. 用 qrcode 生成二维码图片
|
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
await QRCode.toCanvas(canvas, qrUrl, { width: 200, margin: 1 });
|
await QRCode.toCanvas(canvas, qrUrl, { width: 200, margin: 1 });
|
||||||
qrimg.value = canvas.toDataURL('image/png');
|
qrimg.value = canvas.toDataURL('image/png');
|
||||||
qrLoading.value = false;
|
qrLoading.value = false;
|
||||||
|
|
||||||
// 4. 开始轮询状态
|
|
||||||
startPolling();
|
startPolling();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
qrError.value = '获取二维码失败';
|
qrError.value = '获取二维码失败';
|
||||||
@ -79,20 +73,6 @@ async function refreshQr() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 新增函数:用 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() {
|
function startPolling() {
|
||||||
pollTimer = setInterval(async () => {
|
pollTimer = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
@ -101,23 +81,18 @@ function startPolling() {
|
|||||||
const code = data.code;
|
const code = data.code;
|
||||||
if (code === 800) {
|
if (code === 800) {
|
||||||
statusText.value = '二维码已过期,请刷新';
|
statusText.value = '二维码已过期,请刷新';
|
||||||
statusColor.value = 'text-red-400';
|
statusColor.value = 'text-danger';
|
||||||
clearInterval(pollTimer!);
|
clearInterval(pollTimer!);
|
||||||
} else if (code === 801) {
|
} else if (code === 801) {
|
||||||
statusText.value = '等待扫码...';
|
statusText.value = '等待扫码...';
|
||||||
statusColor.value = 'text-gray-400';
|
statusColor.value = 'text-content-2';
|
||||||
} else if (code === 802) {
|
} else if (code === 802) {
|
||||||
statusText.value = '请在手机上确认登录';
|
statusText.value = '请在手机上确认登录';
|
||||||
statusColor.value = 'text-yellow-400';
|
statusColor.value = 'text-warning';
|
||||||
} else if (code === 803) {
|
} else if (code === 803) {
|
||||||
// 登录成功
|
|
||||||
clearInterval(pollTimer!);
|
clearInterval(pollTimer!);
|
||||||
statusText.value = '登录成功!';
|
statusText.value = '登录成功!';
|
||||||
statusColor.value = 'text-green-400';
|
statusColor.value = 'text-accent-text';
|
||||||
// 存储 cookie 到 NcmApi(后台线程中自动保留,后续请求都带登录态)
|
|
||||||
// 获取用户信息(简化,可从 /login/status 获取)
|
|
||||||
// 这里需要额外调用获取用户详情的 API,但因为 NcmApi 已有 cookie,可以直接在后台线程中添加
|
|
||||||
// 暂时用简易方式:调用 /user/account 获取用户简档
|
|
||||||
await fetchUserProfile();
|
await fetchUserProfile();
|
||||||
setTimeout(() => router.push('/'), 500);
|
setTimeout(() => router.push('/'), 500);
|
||||||
}
|
}
|
||||||
@ -129,9 +104,6 @@ function startPolling() {
|
|||||||
|
|
||||||
async function fetchUserProfile() {
|
async function fetchUserProfile() {
|
||||||
try {
|
try {
|
||||||
// 添加一个快速获取用户信息的命令(可复用之前的 login 命令中获取 profile 的逻辑)
|
|
||||||
// 这里简化,由于后台 NcmApi 已有 cookie,我们可以直接用 reqwest 调 /user/account
|
|
||||||
// 但最好添加一个新命令,这里直接调用现有的 login 逻辑不适用,因此我们在 Rust 侧添加一个 get_login_status 命令
|
|
||||||
const profileJson: string = await invoke('get_login_status');
|
const profileJson: string = await invoke('get_login_status');
|
||||||
const profile = JSON.parse(profileJson);
|
const profile = JSON.parse(profileJson);
|
||||||
if (profile.profile) {
|
if (profile.profile) {
|
||||||
@ -145,4 +117,4 @@ async function fetchUserProfile() {
|
|||||||
console.error('获取用户信息失败', e);
|
console.error('获取用户信息失败', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,83 +1,138 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="p-8 text-white">
|
<div class="p-8 text-content">
|
||||||
<button @click="$router.back()" class="mb-4 text-gray-400 hover:text-white transition">
|
<button @click="$router.back()" class="mb-4 text-content-2 hover:text-content transition">
|
||||||
← 返回
|
← 返回
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- 歌单信息 -->
|
|
||||||
<div v-if="playlist" class="flex gap-6 mb-8">
|
<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" />
|
<img :src="playlist.coverImgUrl" class="w-44 h-44 rounded-xl object-cover shadow-lg flex-shrink-0" />
|
||||||
<div>
|
<div class="flex flex-col justify-between min-w-0">
|
||||||
<h1 class="text-2xl font-bold">{{ playlist.name }}</h1>
|
<div>
|
||||||
<p class="text-sm text-gray-400 mt-2">{{ playlist.description }}</p>
|
<h1 class="text-2xl font-bold leading-tight">{{ playlist.name }}</h1>
|
||||||
<p class="text-xs text-gray-500 mt-2">
|
<div v-if="playlist.creator" class="flex items-center gap-2 mt-2">
|
||||||
{{ playlist.trackCount }} 首歌曲 · 播放 {{ playlist.playCount }} 次
|
<img :src="playlist.creator.avatarUrl" class="w-5 h-5 rounded-full" />
|
||||||
</p>
|
<span class="text-sm text-content-2">{{ playlist.creator.nickname }}</span>
|
||||||
<button
|
</div>
|
||||||
@click="playAll"
|
<p class="text-sm text-content-2 mt-2 line-clamp-2">{{ playlist.description }}</p>
|
||||||
class="mt-4 px-4 py-2 bg-green-500 hover:bg-green-600 rounded-full text-white font-medium transition"
|
<p class="text-xs text-content-3 mt-2">
|
||||||
>
|
{{ playlist.trackCount }} 首歌曲 · 播放 {{ formatPlayCount(playlist.playCount) }} 次
|
||||||
播放全部
|
</p>
|
||||||
</button>
|
</div>
|
||||||
|
<div class="flex items-center gap-3 mt-4">
|
||||||
|
<button
|
||||||
|
@click="playAll"
|
||||||
|
class="px-5 py-2 bg-accent hover:bg-accent-hover rounded-full text-white font-medium transition flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M4 2.5v11l9-5.5z"/></svg>
|
||||||
|
播放全部
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="!isOwnPlaylist"
|
||||||
|
@click="toggleSubscribe"
|
||||||
|
class="px-4 py-2 bg-muted hover:bg-emphasis rounded-full text-sm transition flex items-center gap-2"
|
||||||
|
:class="subscribed ? 'text-accent-text' : 'text-content/70'"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path v-if="subscribed" d="M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z"/>
|
||||||
|
<path v-else d="M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z"/>
|
||||||
|
</svg>
|
||||||
|
{{ subscribed ? '已收藏' : '收藏歌单' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 加载中 -->
|
<div v-if="loading" class="text-content-2">加载中...</div>
|
||||||
<div v-if="loading" class="text-gray-400">加载中...</div>
|
|
||||||
|
|
||||||
<!-- 歌曲列表 -->
|
<div v-else class="space-y-1">
|
||||||
<div v-else class="space-y-2">
|
|
||||||
<div
|
<div
|
||||||
v-for="(song, index) in songs"
|
v-for="(song, index) in songs"
|
||||||
:key="song.id"
|
:key="song.id"
|
||||||
@click="playSingle(song)"
|
@click="playSingle(song)"
|
||||||
class="flex items-center gap-4 p-3 rounded-xl hover:bg-white/5 transition cursor-pointer"
|
class="flex items-center gap-4 p-3 rounded-xl hover:bg-subtle transition cursor-pointer group"
|
||||||
|
:class="{ 'bg-accent-dim': isCurrentSong(song.id) }"
|
||||||
>
|
>
|
||||||
<span class="text-xs text-gray-500 w-6 text-right">{{ index + 1 }}</span>
|
<div class="w-6 text-right flex-shrink-0 flex items-center justify-end h-5">
|
||||||
|
<div v-if="isCurrentSong(song.id)" class="flex items-center justify-end">
|
||||||
|
<div class="flex items-center gap-[3px] h-4">
|
||||||
|
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 50%; animation-delay: 0ms"></span>
|
||||||
|
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 100%; animation-delay: 150ms"></span>
|
||||||
|
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 35%; animation-delay: 300ms"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<span class="text-xs text-content-3 group-hover:hidden">{{ index + 1 }}</span>
|
||||||
|
<svg class="hidden group-hover:block text-content" width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4 2.5v11l9-5.5z"/></svg>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<img :src="song.al?.picUrl" class="w-10 h-10 rounded object-cover flex-shrink-0" />
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="text-sm font-medium truncate">{{ song.name }}</p>
|
<p class="text-sm font-medium truncate" :class="isCurrentSong(song.id) ? 'text-accent-text' : ''">{{ song.name }}</p>
|
||||||
<p class="text-xs text-gray-400 truncate">
|
<p class="text-xs text-content-2 truncate">
|
||||||
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
|
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-xs text-gray-500">{{ formatDuration(song.dt) }}</span>
|
<button @click.stop="player.toggleLike(song.id)" class="text-content-3 hover:text-danger transition flex-shrink-0">
|
||||||
|
<svg v-if="player.isLiked(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" class="text-danger"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
|
||||||
|
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
|
||||||
|
</button>
|
||||||
|
<span class="text-xs text-content-3">{{ formatDuration(song.dt) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, computed, onMounted, watch } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { usePlayerStore } from '../stores/player';
|
import { usePlayerStore } from '../stores/player';
|
||||||
|
import { useUserStore } from '../stores/user';
|
||||||
|
import { showToast } from '../composables/useToast';
|
||||||
|
import { formatDuration, formatPlayCount } from '../utils/format';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const player = usePlayerStore();
|
const player = usePlayerStore();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
|
||||||
const playlist = ref<any>(null);
|
const playlist = ref<any>(null);
|
||||||
const songs = ref<any[]>([]);
|
const songs = ref<any[]>([]);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
|
const subscribed = ref(false);
|
||||||
|
|
||||||
onMounted(async () => {
|
const isOwnPlaylist = computed(() => {
|
||||||
const id = Number(route.params.id);
|
if (!playlist.value || !userStore.user) return false;
|
||||||
|
return playlist.value.creator?.userId === userStore.user.userId;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fetchPlaylist(id: number) {
|
||||||
|
loading.value = true;
|
||||||
|
playlist.value = null;
|
||||||
|
songs.value = [];
|
||||||
try {
|
try {
|
||||||
const jsonStr: string = await invoke('get_playlist_detail', { id });
|
const jsonStr: string = await invoke('get_playlist_detail', { id });
|
||||||
const data = JSON.parse(jsonStr);
|
const data = JSON.parse(jsonStr);
|
||||||
playlist.value = data.playlist;
|
playlist.value = data.playlist;
|
||||||
songs.value = data.playlist.tracks || [];
|
songs.value = data.playlist.tracks || [];
|
||||||
|
subscribed.value = data.playlist.subscribed || false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('获取歌单详情失败', e);
|
console.error(e);
|
||||||
|
showToast('获取歌单详情失败', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchPlaylist(Number(route.params.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
function formatDuration(ms: number): string {
|
watch(() => route.params.id, (newId) => {
|
||||||
const sec = Math.floor(ms / 1000);
|
if (newId) fetchPlaylist(Number(newId));
|
||||||
const m = Math.floor(sec / 60);
|
});
|
||||||
const s = sec % 60;
|
|
||||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
function isCurrentSong(songId: number): boolean {
|
||||||
|
return player.currentSong?.id === songId;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function playSingle(song: any) {
|
async function playSingle(song: any) {
|
||||||
@ -88,4 +143,16 @@ function playAll() {
|
|||||||
if (songs.value.length === 0) return;
|
if (songs.value.length === 0) return;
|
||||||
player.playAll(songs.value);
|
player.playAll(songs.value);
|
||||||
}
|
}
|
||||||
</script>
|
|
||||||
|
async function toggleSubscribe() {
|
||||||
|
if (!playlist.value) return;
|
||||||
|
const newSubscribed = !subscribed.value;
|
||||||
|
try {
|
||||||
|
await invoke('playlist_subscribe', { query: { id: Number(playlist.value.id), subscribe: newSubscribed } });
|
||||||
|
subscribed.value = newSubscribed;
|
||||||
|
showToast(subscribed.value ? '已收藏歌单' : '已取消收藏', 'success');
|
||||||
|
} catch {
|
||||||
|
showToast('操作失败,请稍后重试', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|||||||
@ -1,6 +1,38 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="p-8 text-white">
|
<div class="p-8 text-content">
|
||||||
<h1 class="text-2xl font-bold mb-4">🕐 最近播放</h1>
|
<button @click="$router.back()" class="mb-4 text-content-2 hover:text-content transition">
|
||||||
<p class="text-gray-400">正在施工...</p>
|
← 返回
|
||||||
|
</button>
|
||||||
|
<h1 class="text-2xl font-bold mb-6">最近播放</h1>
|
||||||
|
<div v-if="player.recentLocal.length === 0" class="text-content-3">还没有播放记录,去听首歌吧</div>
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="(song, index) in player.recentLocal"
|
||||||
|
:key="song.id"
|
||||||
|
@click="player.play(song)"
|
||||||
|
class="flex items-center gap-4 p-3 rounded-xl hover:bg-subtle transition cursor-pointer"
|
||||||
|
>
|
||||||
|
<span class="text-xs text-content-3 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-content-2 truncate">
|
||||||
|
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button @click.stop="player.toggleLike(song.id)" class="text-content-3 hover:text-danger transition flex-shrink-0">
|
||||||
|
<svg v-if="player.isLiked(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" class="text-danger"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
|
||||||
|
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
|
||||||
|
</button>
|
||||||
|
<span class="text-xs text-content-3">{{ formatDuration(song.dt ?? 0) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { usePlayerStore } from '../stores/player';
|
||||||
|
import { formatDuration } from '../utils/format';
|
||||||
|
|
||||||
|
const player = usePlayerStore();
|
||||||
|
</script>
|
||||||
|
|||||||
@ -1,51 +1,44 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="p-8 text-white flex flex-col items-center justify-center min-h-full">
|
<div class="p-8 text-content flex flex-col items-center justify-center min-h-full">
|
||||||
<!-- 无歌曲时提示 -->
|
|
||||||
<div v-if="!currentSong" class="text-center">
|
<div v-if="!currentSong" class="text-center">
|
||||||
<p class="text-gray-400 mb-4">私人漫游未启动</p>
|
<p class="text-content-2 mb-4">私人漫游未启动</p>
|
||||||
<button
|
<button
|
||||||
@click="startFm"
|
@click="startFm"
|
||||||
class="px-6 py-2 bg-white/10 hover:bg-white/20 rounded-full transition"
|
class="px-6 py-2 bg-muted hover:bg-emphasis rounded-full transition"
|
||||||
>
|
>
|
||||||
开始漫游
|
开始漫游
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 歌曲信息展示 -->
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- 专辑封面 -->
|
|
||||||
<img
|
<img
|
||||||
:src="currentSong.al?.picUrl || currentSong.album?.picUrl"
|
:src="currentSong.al?.picUrl || currentSong.album?.picUrl"
|
||||||
class="w-80 h-80 rounded-3xl object-cover shadow-2xl mb-8"
|
class="w-80 h-80 rounded-3xl object-cover shadow-2xl mb-8"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 歌曲名和艺术家 -->
|
|
||||||
<h1 class="text-3xl font-bold mb-2">{{ currentSong.name }}</h1>
|
<h1 class="text-3xl font-bold mb-2">{{ currentSong.name }}</h1>
|
||||||
<p class="text-lg text-gray-400 mb-8">
|
<p class="text-lg text-content-2 mb-8">
|
||||||
{{ artists }}
|
{{ artists }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- 控制按钮 -->
|
|
||||||
<div class="flex items-center gap-8">
|
<div class="flex items-center gap-8">
|
||||||
<button
|
<button
|
||||||
@click="togglePlay"
|
@click="player.toggle()"
|
||||||
class="w-16 h-16 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 transition border border-white/20"
|
class="w-16 h-16 flex items-center justify-center rounded-full bg-muted hover:bg-emphasis transition border border-emphasis"
|
||||||
>
|
>
|
||||||
<!-- 暂停图标 -->
|
|
||||||
<svg v-if="player.playing" width="28" height="28" viewBox="0 0 16 16" fill="currentColor">
|
<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="3" y="2" width="3" height="12" rx="0.5" />
|
||||||
<rect x="10" y="2" width="3" height="12" rx="0.5" />
|
<rect x="10" y="2" width="3" height="12" rx="0.5" />
|
||||||
</svg>
|
</svg>
|
||||||
<!-- 播放图标 -->
|
|
||||||
<svg v-else width="28" height="28" viewBox="0 0 16 16" fill="currentColor">
|
<svg v-else width="28" height="28" viewBox="0 0 16 16" fill="currentColor">
|
||||||
<path d="M4 2.5v11l9-5.5z" />
|
<path d="M4 2.5v11l9-5.5z" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="nextSong"
|
@click="nextSong"
|
||||||
class="text-3xl text-gray-400 hover:text-white transition"
|
class="text-content-2 hover:text-content transition"
|
||||||
>
|
>
|
||||||
⏭
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 4 15 12 5 20 5 4"/><line x1="19" y1="5" x2="19" y2="19"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -56,12 +49,11 @@
|
|||||||
import { computed, onMounted } from 'vue';
|
import { computed, onMounted } from 'vue';
|
||||||
import { usePlayerStore } from '../stores/player';
|
import { usePlayerStore } from '../stores/player';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { normalizeSong } from '../utils/song';
|
||||||
|
|
||||||
const player = usePlayerStore();
|
const player = usePlayerStore();
|
||||||
|
|
||||||
// 当前正在播放的歌曲(如果处于FM模式,则显示当前歌曲)
|
|
||||||
const currentSong = computed(() => {
|
const currentSong = computed(() => {
|
||||||
// FM 模式下直接显示正在播放的歌曲(可能是FM歌曲)
|
|
||||||
if (player.isFmMode && player.currentSong) {
|
if (player.isFmMode && player.currentSong) {
|
||||||
return player.currentSong;
|
return player.currentSong;
|
||||||
}
|
}
|
||||||
@ -74,7 +66,6 @@ const artists = computed(() => {
|
|||||||
currentSong.value.artists?.map((a: any) => a.name).join(' / ') || '';
|
currentSong.value.artists?.map((a: any) => a.name).join(' / ') || '';
|
||||||
});
|
});
|
||||||
|
|
||||||
// 进入页面时,如果FM未启动,自动开始
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!player.isFmMode || !player.currentSong) {
|
if (!player.isFmMode || !player.currentSong) {
|
||||||
await startFm();
|
await startFm();
|
||||||
@ -96,31 +87,7 @@ async function startFm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
async function nextSong() {
|
||||||
await startFm();
|
await startFm();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,40 +1,31 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="text-white">
|
<div class="text-content">
|
||||||
<h1 class="text-2xl font-bold mb-4">搜索</h1>
|
<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
|
<input
|
||||||
v-model="keyword"
|
v-model="keyword"
|
||||||
@keyup.enter="handleSearch"
|
@keyup.enter="handleSearch"
|
||||||
placeholder="搜索歌曲..."
|
placeholder="搜索歌曲..."
|
||||||
class="mb-6 w-full rounded-xl bg-white/10 p-3 text-white placeholder-gray-400 outline-none backdrop-blur"
|
class="mb-6 w-full rounded-xl bg-muted p-3 text-content placeholder-content-2 outline-none backdrop-blur"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div v-if="loading" class="text-gray-400">搜索中...</div>
|
<div v-if="loading" class="text-content-2">搜索中...</div>
|
||||||
<div v-else class="space-y-3">
|
<div v-else class="space-y-3">
|
||||||
<div
|
<div
|
||||||
v-for="song in results"
|
v-for="song in results"
|
||||||
:key="song.id"
|
:key="song.id"
|
||||||
@click="playSong(song)"
|
@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"
|
class="flex items-center gap-4 p-3 rounded-xl backdrop-blur-md bg-subtle hover:bg-muted border border-line-2 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" />
|
<img :src="song.al?.picUrl" class="w-12 h-12 rounded-lg object-cover" />
|
||||||
<div>
|
<div>
|
||||||
<p class="font-medium">{{ song.name }}</p>
|
<p class="font-medium">{{ song.name }}</p>
|
||||||
<p class="text-sm text-gray-400">
|
<p class="text-sm text-content-2">
|
||||||
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
|
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!loading && hasSearched && results.length === 0" class="text-gray-400">无结果</p>
|
<p v-if="!loading && hasSearched && results.length === 0" class="text-content-2">无结果</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@ -58,14 +49,12 @@ const hasSearched = ref(false);
|
|||||||
const player = usePlayerStore();
|
const player = usePlayerStore();
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
// 监听从首页或其他地方传来的 query 参数,自动搜索
|
|
||||||
watch(
|
watch(
|
||||||
() => route.query.q,
|
() => route.query.q,
|
||||||
(newQ) => {
|
(newQ) => {
|
||||||
if (newQ) {
|
if (newQ) {
|
||||||
keyword.value = newQ as string;
|
keyword.value = newQ as string;
|
||||||
handleSearch();
|
handleSearch();
|
||||||
// 清除 query,防止刷新后重复搜索
|
|
||||||
router.replace({ query: {} });
|
router.replace({ query: {} });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -96,13 +85,8 @@ async function playSong(song: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const devices = ref<string[]>([]);
|
const devices = ref<string[]>([]);
|
||||||
// const selectedDevice = ref<string | null>(null);
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
devices.value = await invoke('get_output_devices');
|
devices.value = await invoke('get_output_devices');
|
||||||
});
|
});
|
||||||
|
</script>
|
||||||
// async function changeDevice() {
|
|
||||||
// await invoke('set_output_device', { device: selectedDevice.value });
|
|
||||||
// }
|
|
||||||
</script>
|
|
||||||
|
|||||||
164
src/views/Settings.vue
Normal file
164
src/views/Settings.vue
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
<template>
|
||||||
|
<div class="p-8 text-content max-w-2xl">
|
||||||
|
<button @click="$router.back()" class="mb-4 text-content-2 hover:text-content transition">
|
||||||
|
← 返回
|
||||||
|
</button>
|
||||||
|
<h1 class="text-2xl font-bold mb-8">设置</h1>
|
||||||
|
|
||||||
|
<section class="mb-8">
|
||||||
|
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">播放</h2>
|
||||||
|
<div class="space-y-5">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium">音质选择</p>
|
||||||
|
<p class="text-xs text-content-3 mt-0.5">更高音质需要 VIP 权限</p>
|
||||||
|
</div>
|
||||||
|
<CustomSelect v-model="settings.audioQuality" :options="qualityLabels" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-8">
|
||||||
|
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">外观</h2>
|
||||||
|
<div class="space-y-5">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium">主题</p>
|
||||||
|
<p class="text-xs text-content-3 mt-0.5">切换应用主题</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex bg-subtle rounded-lg p-0.5">
|
||||||
|
<button
|
||||||
|
v-for="t in themeOptions"
|
||||||
|
:key="t.value"
|
||||||
|
@click="settings.setTheme(t.value)"
|
||||||
|
class="px-3 py-1.5 rounded-md text-sm transition"
|
||||||
|
:class="settings.theme === t.value ? 'bg-muted text-content' : 'text-content-3 hover:text-content-2'"
|
||||||
|
>
|
||||||
|
{{ t.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-8">
|
||||||
|
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">窗口</h2>
|
||||||
|
<div class="space-y-5">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium">关闭窗口时</p>
|
||||||
|
<p class="text-xs text-content-3 mt-0.5">点击关闭按钮的默认行为</p>
|
||||||
|
</div>
|
||||||
|
<CustomSelect v-model="closeActionValue" :options="closeActionLabels" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-8">
|
||||||
|
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">下载</h2>
|
||||||
|
<div class="space-y-5">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium">下载路径</p>
|
||||||
|
<p class="text-xs text-content-3 mt-0.5">歌曲下载保存位置</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
v-model="downloadPathInput"
|
||||||
|
type="text"
|
||||||
|
placeholder="例如:~/Music/Nekosonic"
|
||||||
|
class="flex-1 bg-subtle border border-line rounded-lg px-3 py-2 text-sm text-content placeholder-content-4 outline-none focus:border-accent/50 transition"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
@click="saveDownloadPath"
|
||||||
|
class="px-4 py-2 bg-accent-dim hover:bg-accent-dim text-accent-text rounded-lg text-sm transition"
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-8">
|
||||||
|
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">关于</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<a @click.prevent="openUrl('https://gitea.atdunbg.xyz/atdunbg/Nekosonic-Music')"
|
||||||
|
class="flex items-center gap-4 p-4 bg-subtle rounded-xl hover:bg-muted transition cursor-pointer">
|
||||||
|
<img src="../assets/app-icon.png" class="w-12 h-12 rounded-xl flex-shrink-0" alt="Nekosonic" />
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold">Nekosonic</p>
|
||||||
|
<p class="text-xs text-content-3 mt-0.5">版本 {{ appVersion }}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<p class="text-xs text-content-3 leading-relaxed">
|
||||||
|
Nekosonic 是一款高颜值的跨平台第三方网易云音乐桌面客户端,基于 Tauri 2 + Vue 3 构建,提供轻量流畅的音乐播放体验。
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
@click="checkUpdate"
|
||||||
|
:disabled="checkingUpdate"
|
||||||
|
class="flex items-center gap-2 px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition"
|
||||||
|
>
|
||||||
|
<svg v-if="!checkingUpdate" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.66 0 3-4.03 3-9s-1.34-9-3-9m0 18c-1.66 0-3-4.03-3-9s1.34-9 3-9m-9 9a9 9 0 019-9"/></svg>
|
||||||
|
<svg v-else class="animate-spin" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 11-6.22-8.56"/></svg>
|
||||||
|
{{ checkingUpdate ? '检查中...' : '检查更新(暂未实现)' }}
|
||||||
|
</button>
|
||||||
|
<p v-if="updateMessage" class="text-xs" :class="updateMessageClass">{{ updateMessage }}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue';
|
||||||
|
import { useSettingsStore, qualityLabels, closeActionLabels, type CloseAction } from '../stores/settings';
|
||||||
|
import { useToast } from '../composables/useToast';
|
||||||
|
import { getVersion } from '@tauri-apps/api/app';
|
||||||
|
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||||
|
import CustomSelect from '../components/CustomSelect.vue';
|
||||||
|
|
||||||
|
const settings = useSettingsStore();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
|
||||||
|
const appVersion = ref('');
|
||||||
|
onMounted(async () => {
|
||||||
|
appVersion.value = await getVersion();
|
||||||
|
});
|
||||||
|
|
||||||
|
const closeActionValue = computed({
|
||||||
|
get: () => settings.closeAction,
|
||||||
|
set: (val: CloseAction) => settings.setCloseAction(val),
|
||||||
|
});
|
||||||
|
|
||||||
|
const downloadPathInput = ref(settings.downloadPath);
|
||||||
|
const checkingUpdate = ref(false);
|
||||||
|
const updateMessage = ref('');
|
||||||
|
const updateMessageClass = ref('text-content-2');
|
||||||
|
|
||||||
|
const themeOptions = [
|
||||||
|
{ label: '深色', value: 'dark' as const },
|
||||||
|
{ label: '浅色', value: 'light' as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
function saveDownloadPath() {
|
||||||
|
settings.setDownloadPath(downloadPathInput.value.trim());
|
||||||
|
showToast('下载路径已保存', 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkUpdate() {
|
||||||
|
checkingUpdate.value = true;
|
||||||
|
updateMessage.value = '';
|
||||||
|
try {
|
||||||
|
await new Promise(r => setTimeout(r, 1500));
|
||||||
|
updateMessage.value = '当前已是最新版本';
|
||||||
|
updateMessageClass.value = 'text-accent-text';
|
||||||
|
} catch {
|
||||||
|
updateMessage.value = '检查更新失败,请稍后重试';
|
||||||
|
updateMessageClass.value = 'text-danger';
|
||||||
|
} finally {
|
||||||
|
checkingUpdate.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user