mirror of
https://github.com/atdunbg/Nekosonic-Music.git
synced 2026-06-22 00:58:51 +08:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6da544cffb | |||
| 65ed71503e | |||
| 970fb15f5a | |||
| cf21c96eaf | |||
| 987d34f58b | |||
| baa6235c56 | |||
| 38c079ed5c | |||
| 68e3b92a6a |
46
CHANGELOG.md
46
CHANGELOG.md
@ -1,3 +1,49 @@
|
||||
## v0.5.1
|
||||
|
||||
### 🐛 修复
|
||||
- 修复页面缓存不刷新的问题:切换回已缓存的页面时数据永远不更新,现在超过 5 分钟会自动重新加载
|
||||
- 修复本地音乐页面空列表时刷新按钮不显示的问题
|
||||
- 修复修改下载路径后本地音乐列表不更新的问题,现在会自动刷新
|
||||
- 修复私人 FM 播放约二三十首后循环重复的问题:新增听歌打卡上报,服务端推荐不再重复
|
||||
- 修复歌词界面切换翻译开关时歌词未居中的问题
|
||||
- 修复 Linux 下从外部控制暂停时进度条跳回 0 的问题:MPRIS 现在正确报告播放进度位置
|
||||
|
||||
### ⚡ 优化
|
||||
- 私人 FM 预取队列优化,队列剩余不足时自动后台拉取下一批
|
||||
|
||||
|
||||
## v0.5.0
|
||||
|
||||
### ✨ 新功能
|
||||
- **蓝牙耳机/键盘媒体键控制**:支持通过蓝牙耳机按钮、键盘媒体键、系统通知栏/锁屏面板控制播放、暂停、切歌(Windows / Linux / macOS)
|
||||
- **网络状态检测**:断网和恢复时弹出提示,网络恢复后自动重新加载页面内容
|
||||
- **音量记忆**:关闭应用后音量设置不丢失,下次打开自动恢复
|
||||
- **歌词翻译**:支持显示歌词翻译,可在漫游页面切换开关
|
||||
- **登录页优化**:已登录用户访问登录页会自动跳转回首页
|
||||
|
||||
### 🎨 变更
|
||||
- 默认主题色改为天蓝色
|
||||
- 全局快捷键显示顺序调整为 Ctrl + Alt(之前是 Alt + Ctrl)
|
||||
- 快捷键显示优化:按键名更简洁,如 KeyP 显示为 P
|
||||
- 页面缓存优化:更多页面切换时保留状态,窗口隐藏时自动释放
|
||||
- 登录页等待确认时的文字颜色修正
|
||||
|
||||
### 🐛 修复
|
||||
- 手动检查更新时,之前跳过的版本现在会正常弹出更新提示
|
||||
- 点击正在播放的歌曲无法恢复播放的问题
|
||||
- 部分内部类型定义问题导致的潜在隐患
|
||||
|
||||
### ⚡ 底层优化
|
||||
- 音频播放引擎全面重构,播放更稳定
|
||||
- 后端 API 调用模式统一,代码更易维护
|
||||
- 歌曲数据模型统一,各页面显示更一致
|
||||
|
||||
|
||||
## v0.4.1
|
||||
|
||||
添加音频输出外设选择
|
||||
|
||||
|
||||
## v0.4.0
|
||||
|
||||
### ✨ 新功能
|
||||
|
||||
87
README.md
87
README.md
@ -1,25 +1,63 @@
|
||||
# Nekosonic
|
||||
|
||||
一款轻量的跨平台的音乐播放器,支持Windows/Linux系统,音源主要源自的网易云音乐。
|
||||
一款轻量的跨平台音乐播放器,支持 Windows / Linux / macOS,音源源自网易云音乐。
|
||||
|
||||
## ✨ 特性
|
||||
|
||||
- 🔴 网易云账号登录(扫码)
|
||||
- 🎵 多音质播放(标准 / 较高 / 极高 / 无损 / Hi-Res)
|
||||
- 📻 私人漫游,沉浸式全屏歌词体验
|
||||
- ❤️ 一键喜欢 / 取消喜欢
|
||||
- 📋 歌单管理,收藏 / 取消收藏歌单
|
||||
### 播放
|
||||
|
||||
- 🎵 在线音乐播放,流式缓冲边下边播
|
||||
- 🎵 多音质选择(标准 / 较高 / 极高 HQ / 无损 SQ / Hi-Res)
|
||||
- 🔄 播放模式切换(列表循环 / 随机播放 / 单曲循环)
|
||||
- ⏯ 播放控制(播放 / 暂停 / 上一首 / 下一首 / 进度跳转 / 音量调节)
|
||||
- 📋 播放队列管理(查看队列 / 移除歌曲 / 清空队列)
|
||||
- 📻 私人漫游 FM(个性化推荐,VIP 试听自动跳过)
|
||||
- 🎵 本地音乐播放(支持 mp3 / flac / wav / ogg / aac / m4a / wma / opus)
|
||||
- 🔊 音频输出设备选择
|
||||
- 🎧 系统媒体控制(蓝牙耳机/键盘媒体键/系统面板,支持 Linux / Windows / macOS)
|
||||
|
||||
### 发现与浏览
|
||||
|
||||
- 🔍 关键词搜索歌曲 + 热门搜索标签
|
||||
- 📋 歌单浏览(推荐歌单 / 排行榜 / 用户歌单 / 收藏歌单)
|
||||
- 📋 歌单详情(歌曲列表 + 收藏 / 取消收藏 + 歌单评论)
|
||||
- 🎤 歌手详情(热门歌曲 / 专辑 / 简介)
|
||||
- 💿 专辑详情(歌曲列表 + 播放全部)
|
||||
- 📅 每日推荐歌曲
|
||||
- 🕐 本地播放历史记录
|
||||
- 🔍 关键词搜索歌曲
|
||||
- 🎤 实时滚动歌词
|
||||
- 🌚 Light / Dark Mode 主题切换
|
||||
- 🛠 更多特性添加中
|
||||
|
||||
### 歌词与评论
|
||||
|
||||
- 🎤 实时滚动歌词(自动滚动 / 点击跳转 / 渐变透明度)
|
||||
- 🎤 歌词翻译显示
|
||||
- 🎤 全屏漫游模式(大封面 + 歌词 / 评论双标签页)
|
||||
- 💬 歌曲评论查看(热门评论 + 无限滚动加载 + 点赞)
|
||||
|
||||
### 收藏与下载
|
||||
|
||||
- ❤️ 一键喜欢 / 取消喜欢(同步到网易云账号)
|
||||
- ⬇️ 歌曲下载(带进度显示 / VIP 拦截 / 元数据保存)
|
||||
- 🎵 本地音乐管理(列出 / 播放 / 删除 / 音频元数据与封面读取)
|
||||
- 🕐 本地播放历史记录(最多 200 首)
|
||||
|
||||
### 账号
|
||||
|
||||
- 🔴 网易云账号登录(二维码扫码 / 手机号密码)
|
||||
- 🔑 登录态持久化(重启后自动恢复)
|
||||
|
||||
### 系统与设置
|
||||
|
||||
- 📡 系统托盘(播放控制 / 显示窗口 / 退出)
|
||||
- 🛡 单实例运行(防止重复启动)
|
||||
- ⌨️ 自定义快捷键(应用内 + 系统全局)
|
||||
- 🎨 多主题切换(天蓝 / 翠绿 / 玫红 / 紫罗兰 / 橙色 / 青色 / 粉色)
|
||||
- ⚙️ 关闭窗口行为设置(每次询问 / 最小化到托盘 / 直接退出)
|
||||
- 🔄 自动更新(启动静默检测 + 自定义弹窗 + 忽略版本 + 下载进度)
|
||||
- 📝 更新日志查看
|
||||
- 📶 网络状态检测(断网/恢复 Toast 提示 + 自动重试加载)
|
||||
|
||||
## 📦️ 安装
|
||||
|
||||
访问本项目的 [Releases](https://gitea.atdunbg.xyz/atdunbg/Nekosonic-Music/releases) 页面下载安装包。
|
||||
|
||||
访问本项目的 [Releases](https://github.com/atdunbg/Nekosonic-Music/releases) 页面下载安装包。
|
||||
|
||||
## 💻 配置开发环境
|
||||
|
||||
@ -49,19 +87,26 @@ npm run tauri build
|
||||
| 样式 | Tailwind CSS v4 + CSS 变量主题系统 |
|
||||
| 状态管理 | Pinia |
|
||||
| 路由 | Vue Router 4 |
|
||||
| 音频播放 | rodio (Rust) |
|
||||
| 音频解码 | symphonia + ringbuf (Rust) |
|
||||
| 媒体控制 | souvlaki (Linux MPRIS / Windows SMTC / macOS Now Playing) |
|
||||
| 网易云 API | ncm-api-rs |
|
||||
| 构建工具 | Vite 6 |
|
||||
|
||||
## ☑️ Todo
|
||||
|
||||
- [x] 评论系统
|
||||
- [x] 歌曲下载
|
||||
- [x] 本地音乐管理
|
||||
- [x] 歌手详情页
|
||||
- [x] 专辑详情页
|
||||
- [x] 自定义全局快捷键
|
||||
- [x] 自动更新
|
||||
- [x] 歌词翻译
|
||||
- [x] 更多主题
|
||||
- [x] 系统媒体控制(蓝牙耳机/键盘媒体键)
|
||||
- [ ] MV 播放
|
||||
- [ ] 音乐云盘
|
||||
- [ ] 评论系统
|
||||
- [ ] 下载功能
|
||||
- [ ] 自定义全局快捷键
|
||||
- [ ] 歌词翻译
|
||||
- [ ] 更多主题
|
||||
- [ ] 桌面歌词
|
||||
|
||||
欢迎提 Issue 和 Pull request。
|
||||
|
||||
@ -71,11 +116,11 @@ npm run tauri build
|
||||
|
||||
基于 [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 音频播放库
|
||||
- [symphonia](https://crates.io/crates/symphonia) — Rust 纯音频解码库
|
||||
- [souvlaki](https://crates.io/crates/souvlaki) — 跨平台 OS 媒体控制库
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "nekosonic",
|
||||
"private": true,
|
||||
"version": "0.3.0",
|
||||
"version": "0.5.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
839
src-tauri/Cargo.lock
generated
839
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "Nekosonic"
|
||||
version = "0.4.0"
|
||||
version = "0.5.1"
|
||||
description = "A Simple music app"
|
||||
authors = ["atdunbg"]
|
||||
edition = "2021"
|
||||
@ -23,7 +23,8 @@ tauri-plugin-opener = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
rodio = "0.20"
|
||||
symphonia = { version = "0.5", features = ["mp3", "aac", "flac", "wav", "ogg", "vorbis", "isomp4", "mkv"] }
|
||||
ringbuf = "0.4"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
cpal = { version = "0.15" }
|
||||
@ -38,3 +39,13 @@ tokio = { version = "1", features = ["rt", "sync"] }
|
||||
tauri-plugin-process = "2.3.1"
|
||||
tauri-plugin-updater = "2"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
souvlaki = "0.8"
|
||||
raw-window-handle = "0.6"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
souvlaki = "0.8"
|
||||
|
||||
|
||||
@ -2,7 +2,6 @@ use ncm_api_rs::{create_client, ApiClient, Query};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use tauri::{Manager, State, Emitter};
|
||||
use tokio::sync::Mutex;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
@ -14,12 +13,66 @@ use lofty::file::{AudioFile, TaggedFileExt};
|
||||
use lofty::tag::Accessor;
|
||||
use base64::Engine;
|
||||
|
||||
/// 统一的 API 调用宏,封装了「获取客户端 → 构建请求 → 发送 → 提取响应体」的完整流程。
|
||||
///
|
||||
/// 消除了每个 Tauri 命令中重复的 `client.lock().unwrap().clone()` + `build_query()` + `.map(|r| r.body.to_string())` 样板代码。
|
||||
///
|
||||
/// 提供三种调用方式:
|
||||
///
|
||||
/// 1. 无额外参数 — 仅使用 cookie 中的默认参数构建请求
|
||||
/// ```
|
||||
/// api_call!(state, get_playlist_detail)
|
||||
/// // 等价于:
|
||||
/// // let client = state.client.lock().unwrap().clone();
|
||||
/// // let q = state.build_query();
|
||||
/// // client.get_playlist_detail(&q).await.map(|r| r.body.to_string()).map_err(|e| e.to_string())
|
||||
/// ```
|
||||
///
|
||||
/// 2. 附加参数 — 在默认参数基础上追加键值对
|
||||
/// ```
|
||||
/// api_call!(state, song_url_v1, params: [("id", id), ("level", "standard")])
|
||||
/// // 等价于:
|
||||
/// // let q = state.build_query().param("id", id).param("level", "standard");
|
||||
/// // client.song_url_v1(&q).await...
|
||||
/// ```
|
||||
///
|
||||
/// 3. 预构建查询 — 直接传入已构建好的 Query 对象,跳过 build_query()
|
||||
/// ```
|
||||
/// api_call!(state, playlist_track_all, query: my_query)
|
||||
/// // 等价于:
|
||||
/// // client.playlist_track_all(&my_query).await...
|
||||
/// ```
|
||||
macro_rules! api_call {
|
||||
($state:expr, $method:ident) => {{
|
||||
let client = $state.client.lock().unwrap().clone();
|
||||
let q = $state.build_query();
|
||||
client.$method(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
}};
|
||||
($state:expr, $method:ident, params: [$(($key:expr, $val:expr)),* $(,)?]) => {{
|
||||
let client = $state.client.lock().unwrap().clone();
|
||||
let mut q = $state.build_query();
|
||||
$(q = q.param($key, $val);)*
|
||||
client.$method(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
}};
|
||||
($state:expr, $method:ident, query: $q:expr) => {{
|
||||
let client = $state.client.lock().unwrap().clone();
|
||||
client.$method(&$q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
}};
|
||||
}
|
||||
|
||||
pub struct ApiController {
|
||||
client: Mutex<ApiClient>,
|
||||
client: StdMutex<ApiClient>,
|
||||
cookie: StdMutex<Option<String>>,
|
||||
cookie_path: PathBuf,
|
||||
}
|
||||
|
||||
/// 将 Cookie 字符串列表转换为 `key=value; key=value` 格式
|
||||
fn cookies_to_key_values(cookies: &[String]) -> String {
|
||||
cookies
|
||||
.iter()
|
||||
@ -31,6 +84,7 @@ fn cookies_to_key_values(cookies: &[String]) -> String {
|
||||
|
||||
impl ApiController {
|
||||
|
||||
/// 创建新的 API 控制器,从本地文件恢复已保存的 Cookie
|
||||
pub fn new(app_data_dir: PathBuf) -> Self {
|
||||
let _ = fs::create_dir_all(&app_data_dir);
|
||||
let cookie_path = app_data_dir.join("netease_cookies.json");
|
||||
@ -40,13 +94,14 @@ impl ApiController {
|
||||
|
||||
let client = create_client(None);
|
||||
ApiController {
|
||||
client: Mutex::new(client),
|
||||
client: StdMutex::new(client),
|
||||
cookie: StdMutex::new(saved_cookie),
|
||||
cookie_path,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_query(&self) -> Query {
|
||||
/// 构建带当前 Cookie 的 API 查询对象
|
||||
fn build_query(&self) -> Query {
|
||||
let mut query = Query::new();
|
||||
if let Ok(cookie_guard) = self.cookie.lock() {
|
||||
if let Some(c) = cookie_guard.as_ref() {
|
||||
@ -55,66 +110,57 @@ fn build_query(&self) -> Query {
|
||||
}
|
||||
query
|
||||
}
|
||||
/// 将 Cookie 字符串持久化到本地文件并同步到 API 客户端
|
||||
fn save_cookie(&self, cookie_str: &str) {
|
||||
let _ = fs::write(&self.cookie_path, cookie_str);
|
||||
if let Ok(mut client) = self.client.lock() {
|
||||
client.set_cookie(cookie_str.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 搜索查询参数
|
||||
#[derive(Deserialize)]
|
||||
pub struct SearchQuery { pub keyword: String }
|
||||
|
||||
/// 手机号登录查询参数
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginQuery { pub phone: String, pub password: String }
|
||||
|
||||
/// 二维码登录密钥查询参数
|
||||
#[derive(Deserialize)]
|
||||
pub struct QrKeyQuery { pub key: String }
|
||||
|
||||
/// 搜索歌曲
|
||||
#[tauri::command]
|
||||
pub async fn search_songs(query: SearchQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let q = state.build_query()
|
||||
.param("keywords", &query.keyword)
|
||||
.param("type", "1")
|
||||
.param("limit", "30");
|
||||
client.cloudsearch(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
api_call!(state, cloudsearch, params: [("keywords", &query.keyword), ("type", "1"), ("limit", "30")])
|
||||
}
|
||||
|
||||
/// 获取热搜词列表
|
||||
#[tauri::command]
|
||||
pub async fn get_hot_search(state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let q = state.build_query();
|
||||
client.search_hot_detail(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
api_call!(state, search_hot_detail)
|
||||
}
|
||||
|
||||
/// 歌单全部曲目查询参数
|
||||
#[derive(Deserialize)]
|
||||
pub struct PlaylistTrackAllQuery { pub id: u64, pub limit: Option<i64>, pub offset: Option<i64> }
|
||||
|
||||
/// 获取歌单全部歌曲
|
||||
#[tauri::command]
|
||||
pub async fn playlist_track_all(query: PlaylistTrackAllQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let q = state.build_query()
|
||||
.param("id", &query.id.to_string())
|
||||
.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())
|
||||
api_call!(state, playlist_track_all, params: [("id", &query.id.to_string()), ("limit", &query.limit.unwrap_or(1000).to_string()), ("offset", &query.offset.unwrap_or(0).to_string())])
|
||||
}
|
||||
|
||||
/// 歌曲播放地址查询参数
|
||||
#[derive(Deserialize)]
|
||||
pub struct SongUrlQuery { pub id: u64, pub level: Option<String>, pub fm_mode: Option<bool> }
|
||||
|
||||
/// 获取歌曲播放地址(返回完整 data 对象,包含 url、freeTrialInfo 等)
|
||||
#[tauri::command]
|
||||
pub async fn get_song_url(query: SongUrlQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let client = state.client.lock().unwrap().clone();
|
||||
let level = query.level.as_deref().unwrap_or("standard");
|
||||
|
||||
let resp = if query.fm_mode.unwrap_or(false) {
|
||||
@ -162,27 +208,19 @@ pub async fn get_song_url(query: SongUrlQuery, state: State<'_, ApiController>)
|
||||
/// 获取歌词
|
||||
#[tauri::command]
|
||||
pub async fn get_lyric(id: u64, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let q = state.build_query().param("id", &id.to_string());
|
||||
client.lyric(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
api_call!(state, lyric, params: [("id", &id.to_string())])
|
||||
}
|
||||
|
||||
/// 获取歌单详情
|
||||
#[tauri::command]
|
||||
pub async fn get_playlist_detail(id: u64, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let q = state.build_query().param("id", &id.to_string());
|
||||
client.playlist_detail(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
api_call!(state, playlist_detail, params: [("id", &id.to_string())])
|
||||
}
|
||||
|
||||
/// 手机号密码登录
|
||||
#[tauri::command]
|
||||
pub async fn login(query: LoginQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let client = state.client.lock().unwrap().clone();
|
||||
let q = Query::new()
|
||||
.param("phone", &query.phone)
|
||||
.param("password", &query.password);
|
||||
@ -208,7 +246,7 @@ pub async fn logout(state: State<'_, ApiController>) -> Result<(), String> {
|
||||
/// 获取二维码登录密钥
|
||||
#[tauri::command]
|
||||
pub async fn get_qr_key(state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let client = state.client.lock().unwrap().clone();
|
||||
let q = state.build_query();
|
||||
let resp = client.login_qr_key(&q).await.map_err(|e| e.to_string())?;
|
||||
resp.body["unikey"]
|
||||
@ -223,7 +261,7 @@ pub async fn create_qr(
|
||||
query: QrKeyQuery,
|
||||
state: State<'_, ApiController>,
|
||||
) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let client = state.client.lock().unwrap().clone();
|
||||
let q = state
|
||||
.build_query()
|
||||
.param("key", &query.key)
|
||||
@ -239,7 +277,7 @@ pub async fn create_qr(
|
||||
/// 检查二维码扫码状态
|
||||
#[tauri::command]
|
||||
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().unwrap().clone();
|
||||
let q = state.build_query().param("key", &query.key);
|
||||
let resp = client.login_qr_check(&q).await.map_err(|e| e.to_string())?;
|
||||
if resp.body["code"].as_u64() == Some(803) && !resp.cookie.is_empty() {
|
||||
@ -253,122 +291,95 @@ pub async fn check_qr_status(query: QrKeyQuery, state: State<'_, ApiController>)
|
||||
/// 获取当前登录状态
|
||||
#[tauri::command]
|
||||
pub async fn get_login_status(state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let q = state.build_query();
|
||||
client.user_account(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
api_call!(state, user_account)
|
||||
}
|
||||
|
||||
/// 获取用户歌单列表
|
||||
#[tauri::command]
|
||||
pub async fn user_playlist(uid: u64, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let q = state.build_query().param("uid", &uid.to_string());
|
||||
let resp = client.user_playlist(&q).await.map_err(|e| e.to_string())?;
|
||||
Ok(resp.body.to_string())
|
||||
api_call!(state, user_playlist, params: [("uid", &uid.to_string())])
|
||||
}
|
||||
|
||||
/// 获取每日推荐歌曲
|
||||
#[tauri::command]
|
||||
pub async fn recommend_songs(state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let q = state.build_query();
|
||||
let resp = client.recommend_songs(&q).await.map_err(|e| e.to_string())?;
|
||||
Ok(resp.body.to_string())
|
||||
api_call!(state, recommend_songs)
|
||||
}
|
||||
|
||||
/// 获取推荐歌单
|
||||
#[tauri::command]
|
||||
pub async fn recommend_resource(state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let q = state.build_query();
|
||||
let resp = client.recommend_resource(&q).await.map_err(|e| e.to_string())?;
|
||||
Ok(resp.body.to_string())
|
||||
api_call!(state, recommend_resource)
|
||||
}
|
||||
|
||||
/// 获取私人漫游歌曲
|
||||
#[tauri::command]
|
||||
pub async fn personal_fm(state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let q = state.build_query();
|
||||
let resp = client.personal_fm(&q).await.map_err(|e| e.to_string())?;
|
||||
Ok(resp.body.to_string())
|
||||
api_call!(state, personal_fm)
|
||||
}
|
||||
|
||||
/// 听歌打卡查询参数
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ScrobbleQuery {
|
||||
pub id: u64,
|
||||
pub sourceid: Option<String>,
|
||||
pub time: u64,
|
||||
}
|
||||
|
||||
/// 听歌打卡
|
||||
#[tauri::command]
|
||||
pub async fn scrobble(query: ScrobbleQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
api_call!(state, scrobble, params: [("id", &query.id.to_string()), ("sourceid", query.sourceid.as_deref().unwrap_or("")), ("time", &query.time.to_string())])
|
||||
}
|
||||
|
||||
/// 获取歌曲详情
|
||||
#[tauri::command]
|
||||
pub async fn get_song_detail(id: String, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let q = state.build_query().param("ids", &id);
|
||||
let resp = client.song_detail(&q).await.map_err(|e| e.to_string())?;
|
||||
Ok(resp.body.to_string())
|
||||
api_call!(state, song_detail, params: [("ids", &id)])
|
||||
}
|
||||
|
||||
/// 用户播放记录查询参数
|
||||
#[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())
|
||||
api_call!(state, likelist, params: [("uid", &uid.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())
|
||||
api_call!(state, user_record, params: [("uid", &query.uid.to_string()), ("type", &query.r#type)])
|
||||
}
|
||||
|
||||
/// 喜欢/取消喜欢歌曲
|
||||
#[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())
|
||||
api_call!(state, like, params: [("id", &query.id.to_string()), ("like", &query.like)])
|
||||
}
|
||||
|
||||
/// 上报最近播放歌曲
|
||||
#[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())
|
||||
api_call!(state, record_recent_song, params: [("limit", &limit.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())
|
||||
api_call!(state, playlist_subscribe, params: [("id", &query.id.to_string()), ("t", t)])
|
||||
}
|
||||
|
||||
/// 退出应用
|
||||
@ -380,6 +391,7 @@ pub async fn exit_app(app_handle: tauri::AppHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 本地歌曲信息结构体
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LocalSongInfo {
|
||||
@ -395,6 +407,7 @@ pub struct LocalSongInfo {
|
||||
pub local: bool,
|
||||
}
|
||||
|
||||
/// 下载歌曲查询参数
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DownloadSongQuery {
|
||||
@ -408,6 +421,7 @@ pub struct DownloadSongQuery {
|
||||
pub download_path: Option<String>,
|
||||
}
|
||||
|
||||
/// 下载歌曲到本地,支持进度回调,并保存元数据文件
|
||||
#[tauri::command]
|
||||
pub async fn download_song(
|
||||
app_handle: tauri::AppHandle,
|
||||
@ -419,22 +433,18 @@ pub async fn download_song(
|
||||
let q = state.build_query()
|
||||
.param("id", &query.id.to_string())
|
||||
.param("level", level);
|
||||
let client = state.client.lock().await;
|
||||
let client = state.client.lock().unwrap().clone();
|
||||
let resp = client.song_url_v1(&q).await.map_err(|e| e.to_string())?;
|
||||
let data = &resp.body["data"][0];
|
||||
let url = data["url"].as_str().filter(|s| !s.is_empty());
|
||||
if url.is_none() {
|
||||
let free_trial = data.get("freeTrialInfo");
|
||||
if free_trial.is_some() && !free_trial.unwrap().is_null() {
|
||||
let is_vip = data.get("freeTrialInfo").is_some_and(|v| !v.is_null());
|
||||
if is_vip {
|
||||
return Err("VIP歌曲无法下载".into());
|
||||
}
|
||||
if url.is_none() {
|
||||
return Err("暂无下载源,可能需要 VIP 权限".into());
|
||||
}
|
||||
let url = url.unwrap();
|
||||
let free_trial = data.get("freeTrialInfo");
|
||||
if free_trial.is_some() && !free_trial.unwrap().is_null() {
|
||||
return Err("VIP歌曲无法下载".into());
|
||||
}
|
||||
let ext = if url.contains(".flac") { "flac" } else { "mp3" };
|
||||
drop(client);
|
||||
|
||||
@ -505,6 +515,7 @@ pub async fn download_song(
|
||||
Ok(filename)
|
||||
}
|
||||
|
||||
/// 列出本地已下载的歌曲,优先使用元数据文件补充信息
|
||||
#[tauri::command]
|
||||
pub fn list_local_songs(app_handle: tauri::AppHandle, download_path: Option<String>) -> Result<Vec<LocalSongInfo>, String> {
|
||||
let download_dir = resolve_download_dir(&app_handle, download_path.as_deref());
|
||||
@ -600,6 +611,7 @@ pub fn list_local_songs(app_handle: tauri::AppHandle, download_path: Option<Stri
|
||||
Ok(songs)
|
||||
}
|
||||
|
||||
/// 读取音频文件的元数据(标题、艺术家、专辑、时长、封面)
|
||||
fn read_audio_metadata(path: &PathBuf) -> (String, String, String, u64, Option<String>) {
|
||||
match lofty::read_from_path(path) {
|
||||
Ok(tagged_file) => {
|
||||
@ -638,6 +650,7 @@ fn read_audio_metadata(path: &PathBuf) -> (String, String, String, u64, Option<S
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析文件名,提取艺术家和歌曲名称(支持 "艺术家 - 歌名" 格式)
|
||||
fn parse_filename(stem: &str) -> (String, String) {
|
||||
if let Some(pos) = stem.find(" - ") {
|
||||
let artist = &stem[..pos];
|
||||
@ -652,6 +665,7 @@ fn parse_filename(stem: &str) -> (String, String) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除本地歌曲查询参数
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeleteLocalSongQuery {
|
||||
@ -660,6 +674,7 @@ pub struct DeleteLocalSongQuery {
|
||||
pub download_path: Option<String>,
|
||||
}
|
||||
|
||||
/// 删除本地已下载的歌曲文件及其元数据
|
||||
#[tauri::command]
|
||||
pub fn delete_local_song(
|
||||
app_handle: tauri::AppHandle,
|
||||
@ -678,6 +693,7 @@ pub fn delete_local_song(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 检查指定歌曲是否已下载到本地
|
||||
#[tauri::command]
|
||||
pub fn check_local_song(app_handle: tauri::AppHandle, id: u64, download_path: Option<String>) -> Result<bool, String> {
|
||||
let download_dir = resolve_download_dir(&app_handle, download_path.as_deref());
|
||||
@ -685,6 +701,7 @@ pub fn check_local_song(app_handle: tauri::AppHandle, id: u64, download_path: Op
|
||||
Ok(meta_path.exists())
|
||||
}
|
||||
|
||||
/// 解析下载目录,优先使用自定义路径,否则使用默认目录
|
||||
fn resolve_download_dir(app_handle: &tauri::AppHandle, custom_path: Option<&str>) -> PathBuf {
|
||||
if let Some(path) = custom_path {
|
||||
if !path.is_empty() {
|
||||
@ -694,6 +711,7 @@ fn resolve_download_dir(app_handle: &tauri::AppHandle, custom_path: Option<&str>
|
||||
get_default_download_dir(app_handle)
|
||||
}
|
||||
|
||||
/// 获取默认下载目录,优先使用应用数据目录下的 downloads 子目录
|
||||
fn get_default_download_dir(app_handle: &tauri::AppHandle) -> PathBuf {
|
||||
if let Ok(dir) = app_handle.path().app_data_dir() {
|
||||
let download_dir = dir.join("downloads");
|
||||
@ -708,11 +726,13 @@ fn get_default_download_dir(app_handle: &tauri::AppHandle) -> PathBuf {
|
||||
music_dir.join("Nekosonic")
|
||||
}
|
||||
|
||||
/// 获取默认下载路径字符串,供前端使用
|
||||
#[tauri::command]
|
||||
pub fn get_default_download_path(app_handle: tauri::AppHandle) -> String {
|
||||
get_default_download_dir(&app_handle).to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
/// 清理文件名中的非法字符,将 `/ \ : * ? " < > |` 替换为下划线
|
||||
fn sanitize_filename(name: &str) -> String {
|
||||
name.chars()
|
||||
.map(|c| {
|
||||
@ -729,18 +749,15 @@ fn sanitize_filename(name: &str) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// 获取歌手详情
|
||||
#[tauri::command]
|
||||
pub async fn artist_detail(id: u64, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let q = state.build_query().param("id", &id.to_string());
|
||||
client.artist_detail(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
api_call!(state, artist_detail, params: [("id", &id.to_string())])
|
||||
}
|
||||
|
||||
/// 获取歌手歌曲列表
|
||||
#[tauri::command]
|
||||
pub async fn artist_songs(query: ArtistSongsQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let mut q = state.build_query().param("id", &query.id.to_string());
|
||||
if let Some(ref order) = query.order {
|
||||
q = q.param("order", order);
|
||||
@ -751,11 +768,10 @@ pub async fn artist_songs(query: ArtistSongsQuery, state: State<'_, ApiControlle
|
||||
if let Some(offset) = query.offset {
|
||||
q = q.param("offset", &offset.to_string());
|
||||
}
|
||||
client.artist_songs(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
api_call!(state, artist_songs, query: q)
|
||||
}
|
||||
|
||||
/// 歌手歌曲查询参数
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ArtistSongsQuery {
|
||||
@ -765,9 +781,9 @@ pub struct ArtistSongsQuery {
|
||||
pub offset: Option<u32>,
|
||||
}
|
||||
|
||||
/// 获取歌手专辑列表
|
||||
#[tauri::command]
|
||||
pub async fn artist_album(id: u64, limit: Option<u32>, offset: Option<u32>, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let mut q = state.build_query().param("id", &id.to_string());
|
||||
if let Some(limit) = limit {
|
||||
q = q.param("limit", &limit.to_string());
|
||||
@ -775,32 +791,24 @@ pub async fn artist_album(id: u64, limit: Option<u32>, offset: Option<u32>, stat
|
||||
if let Some(offset) = offset {
|
||||
q = q.param("offset", &offset.to_string());
|
||||
}
|
||||
client.artist_album(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
api_call!(state, artist_album, query: q)
|
||||
}
|
||||
|
||||
/// 获取歌手简介
|
||||
#[tauri::command]
|
||||
pub async fn artist_desc(id: u64, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let q = state.build_query().param("id", &id.to_string());
|
||||
client.artist_desc(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
api_call!(state, artist_desc, params: [("id", &id.to_string())])
|
||||
}
|
||||
|
||||
/// 获取专辑详情
|
||||
#[tauri::command]
|
||||
pub async fn album_detail(id: u64, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let q = state.build_query().param("id", &id.to_string());
|
||||
client.album(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
api_call!(state, album, params: [("id", &id.to_string())])
|
||||
}
|
||||
|
||||
/// 获取最新评论
|
||||
#[tauri::command]
|
||||
pub async fn comment_new(query: CommentNewQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let mut q = state.build_query()
|
||||
.param("type", &query.r#type.to_string())
|
||||
.param("id", &query.id.to_string());
|
||||
@ -816,11 +824,10 @@ pub async fn comment_new(query: CommentNewQuery, state: State<'_, ApiController>
|
||||
if let Some(cursor) = query.cursor {
|
||||
q = q.param("cursor", &cursor.to_string());
|
||||
}
|
||||
client.comment_new(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
api_call!(state, comment_new, query: q)
|
||||
}
|
||||
|
||||
/// 最新评论查询参数
|
||||
#[derive(Deserialize)]
|
||||
pub struct CommentNewQuery {
|
||||
pub r#type: u8,
|
||||
@ -834,9 +841,9 @@ pub struct CommentNewQuery {
|
||||
pub cursor: Option<u64>,
|
||||
}
|
||||
|
||||
/// 获取热门评论
|
||||
#[tauri::command]
|
||||
pub async fn comment_hot(query: CommentHotQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let mut q = state.build_query()
|
||||
.param("type", &query.r#type.to_string())
|
||||
.param("id", &query.id.to_string());
|
||||
@ -849,11 +856,10 @@ pub async fn comment_hot(query: CommentHotQuery, state: State<'_, ApiController>
|
||||
if let Some(before) = query.before {
|
||||
q = q.param("before", &before.to_string());
|
||||
}
|
||||
client.comment_hot(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
api_call!(state, comment_hot, query: q)
|
||||
}
|
||||
|
||||
/// 热门评论查询参数
|
||||
#[derive(Deserialize)]
|
||||
pub struct CommentHotQuery {
|
||||
pub r#type: u8,
|
||||
@ -863,9 +869,9 @@ pub struct CommentHotQuery {
|
||||
pub before: Option<u64>,
|
||||
}
|
||||
|
||||
/// 获取评论楼层(子评论)
|
||||
#[tauri::command]
|
||||
pub async fn comment_floor(query: CommentFloorQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let mut q = state.build_query()
|
||||
.param("parentCommentId", &query.parent_comment_id.to_string())
|
||||
.param("type", &query.r#type.to_string())
|
||||
@ -876,11 +882,10 @@ pub async fn comment_floor(query: CommentFloorQuery, state: State<'_, ApiControl
|
||||
if let Some(time) = query.time {
|
||||
q = q.param("time", &time.to_string());
|
||||
}
|
||||
client.comment_floor(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
api_call!(state, comment_floor, query: q)
|
||||
}
|
||||
|
||||
/// 评论楼层查询参数
|
||||
#[derive(Deserialize)]
|
||||
pub struct CommentFloorQuery {
|
||||
#[serde(rename = "parentCommentId")]
|
||||
@ -891,19 +896,13 @@ pub struct CommentFloorQuery {
|
||||
pub time: Option<u64>,
|
||||
}
|
||||
|
||||
/// 点赞/取消点赞评论
|
||||
#[tauri::command]
|
||||
pub async fn comment_like(query: CommentLikeQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let q = state.build_query()
|
||||
.param("t", &query.t.to_string())
|
||||
.param("type", &query.r#type.to_string())
|
||||
.param("id", &query.id.to_string())
|
||||
.param("cid", &query.cid.to_string());
|
||||
client.comment_like(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
api_call!(state, comment_like, params: [("t", &query.t.to_string()), ("type", &query.r#type.to_string()), ("id", &query.id.to_string()), ("cid", &query.cid.to_string())])
|
||||
}
|
||||
|
||||
/// 评论点赞查询参数
|
||||
#[derive(Deserialize)]
|
||||
pub struct CommentLikeQuery {
|
||||
pub t: u8,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -7,6 +7,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
mod api;
|
||||
mod audio;
|
||||
mod media_controls;
|
||||
use api::ApiController;
|
||||
use audio::AppAudio;
|
||||
|
||||
@ -25,6 +26,27 @@ pub fn run() {
|
||||
let app_audio = AppAudio(std::sync::Mutex::new(audio_controller));
|
||||
app.manage(app_audio);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use raw_window_handle::HasWindowHandle;
|
||||
use raw_window_handle::RawWindowHandle;
|
||||
let hwnd = if let Some(win) = app.get_webview_window("main") {
|
||||
win.window_handle().ok().and_then(|h| {
|
||||
if let RawWindowHandle::Win32(h) = h.as_raw() {
|
||||
Some(h.hwnd.get() as *mut std::ffi::c_void)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
media_controls::start_media_controls(app.handle().clone(), hwnd);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
media_controls::start_media_controls(app.handle().clone(), None);
|
||||
|
||||
let show = MenuItemBuilder::with_id("show", "显示窗口").build(app)?;
|
||||
let _sep1 = PredefinedMenuItem::separator(app)?;
|
||||
let prev = MenuItemBuilder::with_id("prev", "上一首").build(app)?;
|
||||
@ -123,6 +145,7 @@ pub fn run() {
|
||||
api::recommend_resource,
|
||||
api::recommend_songs,
|
||||
api::personal_fm,
|
||||
api::scrobble,
|
||||
api::get_song_detail,
|
||||
api::get_qr_key,
|
||||
api::create_qr,
|
||||
@ -144,6 +167,7 @@ pub fn run() {
|
||||
audio::get_output_devices,
|
||||
audio::set_output_device,
|
||||
audio::seek_audio,
|
||||
audio::get_audio_position,
|
||||
audio::set_volume,
|
||||
|
||||
api::download_song,
|
||||
|
||||
127
src-tauri/src/media_controls.rs
Normal file
127
src-tauri/src/media_controls.rs
Normal file
@ -0,0 +1,127 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tauri::{AppHandle, Emitter, Listener};
|
||||
use souvlaki::{
|
||||
MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback,
|
||||
MediaPosition, PlatformConfig, SeekDirection,
|
||||
};
|
||||
|
||||
struct MediaState {
|
||||
controls: MediaControls,
|
||||
}
|
||||
|
||||
pub fn start_media_controls(app_handle: AppHandle, hwnd: Option<*mut std::ffi::c_void>) {
|
||||
let config = PlatformConfig {
|
||||
dbus_name: "nekosonic",
|
||||
display_name: "Nekosonic",
|
||||
hwnd,
|
||||
};
|
||||
|
||||
let mut controls = match MediaControls::new(config) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to create media controls: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let ah = app_handle.clone();
|
||||
if let Err(e) = controls.attach(move |event: MediaControlEvent| {
|
||||
let cmd = match &event {
|
||||
MediaControlEvent::Play => "Play",
|
||||
MediaControlEvent::Pause => "Pause",
|
||||
MediaControlEvent::Toggle => "PlayPause",
|
||||
MediaControlEvent::Next => "Next",
|
||||
MediaControlEvent::Previous => "Previous",
|
||||
MediaControlEvent::Stop => "Stop",
|
||||
MediaControlEvent::Raise => "Raise",
|
||||
MediaControlEvent::Quit => "Quit",
|
||||
MediaControlEvent::SetVolume(v) => {
|
||||
let _ = ah.emit("mpris-command", format!("SetVolume:{v}"));
|
||||
return;
|
||||
}
|
||||
MediaControlEvent::Seek(dir) => {
|
||||
let offset_us = match dir {
|
||||
SeekDirection::Forward => 5_000_000i64,
|
||||
SeekDirection::Backward => -5_000_000i64,
|
||||
};
|
||||
let _ = ah.emit("mpris-command", format!("Seek:{offset_us}"));
|
||||
return;
|
||||
}
|
||||
MediaControlEvent::SeekBy(dir, duration) => {
|
||||
let offset_us: i64 = match dir {
|
||||
SeekDirection::Forward => duration.as_micros() as i64,
|
||||
SeekDirection::Backward => -(duration.as_micros() as i64),
|
||||
};
|
||||
let _ = ah.emit("mpris-command", format!("Seek:{offset_us}"));
|
||||
return;
|
||||
}
|
||||
MediaControlEvent::SetPosition(pos) => {
|
||||
let pos_us = pos.0.as_micros() as i64;
|
||||
let _ = ah.emit("mpris-command", format!("SetPosition:{pos_us}"));
|
||||
return;
|
||||
}
|
||||
MediaControlEvent::OpenUri(_) => return,
|
||||
};
|
||||
let _ = ah.emit("mpris-command", cmd);
|
||||
}) {
|
||||
eprintln!("Failed to attach media control handler: {e}");
|
||||
return;
|
||||
}
|
||||
|
||||
let state = Arc::new(Mutex::new(MediaState { controls }));
|
||||
let state_for_listener = state.clone();
|
||||
|
||||
app_handle.listen("playback-state", move |event| {
|
||||
if let Ok(data) = serde_json::from_str::<serde_json::Value>(event.payload()) {
|
||||
let mut s = match state_for_listener.lock() {
|
||||
Ok(s) => s,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
if let Some(status) = data.get("status").and_then(|v| v.as_str()) {
|
||||
let position_us = data.get("positionUs").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let progress = if position_us > 0 {
|
||||
Some(MediaPosition(std::time::Duration::from_micros(position_us as u64)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let playback = match status {
|
||||
"playing" => MediaPlayback::Playing { progress },
|
||||
"paused" => MediaPlayback::Paused { progress },
|
||||
_ => MediaPlayback::Stopped,
|
||||
};
|
||||
let _ = s.controls.set_playback(playback);
|
||||
}
|
||||
|
||||
let title = data.get("title").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let album = data.get("album").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let artists = data
|
||||
.get("artists")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|a| a.as_str().map(|s| s.to_owned()))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let artist_str = artists.join(", ");
|
||||
let cover_url = data.get("coverUrl").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let duration_us = data.get("durationUs").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
|
||||
let metadata = MediaMetadata {
|
||||
title: if title.is_empty() { None } else { Some(title) },
|
||||
album: if album.is_empty() { None } else { Some(album) },
|
||||
artist: if artist_str.is_empty() { None } else { Some(&artist_str) },
|
||||
cover_url: if cover_url.is_empty() { None } else { Some(cover_url) },
|
||||
duration: if duration_us > 0 {
|
||||
Some(std::time::Duration::from_micros(duration_us as u64))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
};
|
||||
let _ = s.controls.set_metadata(metadata);
|
||||
}
|
||||
});
|
||||
|
||||
std::mem::forget(state);
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Nekosonic",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.1",
|
||||
"identifier": "com.atdunbg.Nekosonic",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
47
src/App.vue
47
src/App.vue
@ -124,7 +124,7 @@
|
||||
|
||||
<main class="flex-1 overflow-y-auto pb-24">
|
||||
<router-view v-slot="{ Component }">
|
||||
<keep-alive :max="3" include="HomeView,DiscoverView">
|
||||
<keep-alive :max="5" :include="keepAliveInclude">
|
||||
<component :is="Component" />
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
@ -184,6 +184,12 @@
|
||||
:class="roamTab === 'comment' ? 'bg-white/15 text-white font-medium' : 'text-white/50 hover:text-white/80'">
|
||||
评论
|
||||
</button>
|
||||
<button v-if="hasTranslation" @click="toggleTranslation"
|
||||
class="ml-auto px-2.5 py-1 rounded-full text-xs transition flex items-center gap-1"
|
||||
:class="showTranslation ? 'bg-white/15 text-white font-medium' : 'text-white/40 hover:text-white/70'">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 8l6 6"/><path d="M4 14l6-6 2-3"/><path d="M2 5h12"/><path d="M7 2h1"/><path d="M22 22l-5-10-5 10"/><path d="M14 18h6"/></svg>
|
||||
译
|
||||
</button>
|
||||
</div>
|
||||
<div v-show="roamTab === 'lyric'" ref="lyricScrollContainer" class="flex-1 min-h-0 overflow-y-auto custom-scroll px-4">
|
||||
<div v-if="lyrics.length > 0" class="w-full max-w-lg mx-auto text-center"
|
||||
@ -198,6 +204,7 @@
|
||||
@mouseleave="roamLyricHovering = false"
|
||||
>
|
||||
{{ line.text }}
|
||||
<span v-if="showTranslation && line.translation" class="block text-sm opacity-60 mt-1">{{ line.translation }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="text-content-3 text-center mt-8">暂无歌词</div>
|
||||
@ -274,6 +281,9 @@ import ToastContainer from './components/ToastContainer.vue';
|
||||
import CommentSection from './components/CommentSection.vue';
|
||||
import UpdateDialog from './components/UpdateDialog.vue';
|
||||
import { usePlayerStore } from './stores/player';
|
||||
import { getCoverUrl } from './utils/song';
|
||||
import { useOnlineStatus } from './composables/useOnlineStatus';
|
||||
import { showToast } from './composables/useToast';
|
||||
import { useLyric } from './composables/UserLyric';
|
||||
import { useUpdater } from './composables/useUpdater';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
@ -286,6 +296,12 @@ const userStore = useUserStore();
|
||||
const player = usePlayerStore();
|
||||
const settings = useSettingsStore();
|
||||
const updater = useUpdater();
|
||||
const { isOnline } = useOnlineStatus();
|
||||
|
||||
watch(isOnline, (val, old) => {
|
||||
if (val && !old) showToast('网络已恢复', 'success');
|
||||
else if (!val && old) showToast('网络已断开,部分功能不可用', 'error');
|
||||
});
|
||||
|
||||
const createdPlaylists = ref<any[]>([]);
|
||||
const subPlaylists = ref<any[]>([]);
|
||||
@ -295,6 +311,7 @@ const searchQuery = ref('');
|
||||
const showCloseModal = ref(false);
|
||||
const closeDontAskAgain = ref(false);
|
||||
const windowVisible = ref(true);
|
||||
const keepAliveInclude = ref<string[]>(['HomeView', 'DiscoverView', 'FavoriteSongsView', 'DailySongsView', 'LocalMusicView']);
|
||||
|
||||
watch(() => settings.theme, (val) => {
|
||||
document.documentElement.setAttribute('data-theme', val);
|
||||
@ -305,7 +322,7 @@ function doSearch() {
|
||||
if (q) router.push({ path: '/discover', query: { q } });
|
||||
}
|
||||
|
||||
const { lyrics, currentLyricIdx } = useLyric();
|
||||
const { lyrics, currentLyricIdx, hasTranslation, showTranslation, toggleTranslation } = useLyric();
|
||||
const lyricScrollContainer = ref<HTMLElement | null>(null);
|
||||
const roamLyricHovering = ref(false);
|
||||
const roamLyricPadPx = ref(0);
|
||||
@ -314,7 +331,7 @@ const roamCoverError = ref(false);
|
||||
const roamTab = ref<'lyric' | 'comment'>('lyric');
|
||||
const roamCoverUrl = computed(() => {
|
||||
if (!roamSong.value) return '';
|
||||
return roamSong.value.al?.picUrl || roamSong.value.album?.picUrl || '';
|
||||
return getCoverUrl(roamSong.value) || '';
|
||||
});
|
||||
watch(roamCoverUrl, () => { roamCoverError.value = false; });
|
||||
let roamResizeObserver: ResizeObserver | null = null;
|
||||
@ -358,6 +375,12 @@ watch(currentLyricIdx, () => {
|
||||
}
|
||||
});
|
||||
|
||||
watch(showTranslation, () => {
|
||||
if (player.showRoamDrawer && !roamLyricHovering.value) {
|
||||
nextTick(() => scrollToRoamActiveLyric());
|
||||
}
|
||||
});
|
||||
|
||||
function scrollToRoamActiveLyric() {
|
||||
if (!lyricScrollContainer.value || roamLyricHovering.value) return;
|
||||
const active = lyricScrollContainer.value.querySelector('.roam-lyric-active') as HTMLElement | null;
|
||||
@ -372,8 +395,8 @@ function getRoamLyricClass(idx: number): string {
|
||||
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';
|
||||
if (diff === 2) return 'text-content-2/50 text-[1rem]';
|
||||
return 'text-content-3/35 text-[1rem]';
|
||||
}
|
||||
|
||||
function seekToRoamLyric(time: number) {
|
||||
@ -421,6 +444,8 @@ watch(() => userStore.isLoggedIn, (val) => {
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
document.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||
|
||||
if (userStore.isLoggedIn) {
|
||||
loadPlaylists();
|
||||
player.loadLikedIds();
|
||||
@ -440,6 +465,14 @@ onMounted(async () => {
|
||||
} catch {}
|
||||
|
||||
updater.checkForUpdate(true);
|
||||
|
||||
// 恢复保存的输出设备设置
|
||||
if(settings.outputDevice) {
|
||||
try {
|
||||
await invoke('set_output_device', { device: settings.outputDevice });
|
||||
}
|
||||
catch{}
|
||||
}
|
||||
});
|
||||
|
||||
const currentWindow = getCurrentWindow();
|
||||
@ -482,9 +515,11 @@ onMounted(() => {
|
||||
});
|
||||
const unlisten4 = listen('window-hidden', () => {
|
||||
windowVisible.value = false;
|
||||
keepAliveInclude.value = [];
|
||||
});
|
||||
const unlisten5 = listen('window-shown', () => {
|
||||
windowVisible.value = true;
|
||||
keepAliveInclude.value = ['HomeView', 'DiscoverView', 'FavoriteSongsView', 'DailySongsView', 'LocalMusicView'];
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@ -498,6 +533,7 @@ onMounted(() => {
|
||||
|
||||
async function registerGlobalShortcuts() {
|
||||
const globalActions: Record<string, () => void> = {
|
||||
globalPlayPause: () => player.toggle(),
|
||||
globalPrev: () => player.prev(),
|
||||
globalNext: () => player.next(),
|
||||
globalVolUp: () => player.adjustVolume(5),
|
||||
@ -543,6 +579,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
const localActions: Record<string, () => void> = {
|
||||
playPause: () => player.toggle(),
|
||||
prev: () => player.prev(),
|
||||
next: () => player.next(),
|
||||
volUp: () => player.adjustVolume(5),
|
||||
|
||||
@ -2,22 +2,22 @@
|
||||
<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="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] max-w-[320px] 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>
|
||||
<span class="truncate">{{ 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">
|
||||
<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 max-w-[360px] 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="w-full text-left px-3 py-2 text-sm transition flex items-center justify-between gap-2"
|
||||
:class="modelValue === key ? 'bg-accent-dim text-accent-text' : 'text-content-2 hover:bg-subtle hover:text-content'"
|
||||
>
|
||||
<span>{{ label }}</span>
|
||||
<span class="truncate">{{ 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>
|
||||
|
||||
@ -14,8 +14,8 @@
|
||||
|
||||
<div class="flex items-center px-6 h-16">
|
||||
<div class="flex items-center gap-3 w-56 min-w-0">
|
||||
<div v-if="player.currentSong?.al?.picUrl" class="w-10 h-10 rounded-md overflow-hidden flex-shrink-0 cursor-pointer hover:scale-105 transition-transform" @click="player.toggleRoamDrawer()" title="全屏展示">
|
||||
<img :src="player.currentSong.al.picUrl" class="w-full h-full object-cover" />
|
||||
<div v-if="getCoverUrl(player.currentSong)" class="w-10 h-10 rounded-md overflow-hidden flex-shrink-0 cursor-pointer hover:scale-105 transition-transform" @click="player.toggleRoamDrawer()" title="全屏展示">
|
||||
<img :src="getCoverUrl(player.currentSong)" class="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div v-else class="w-10 h-10 rounded-md flex-shrink-0 bg-muted flex items-center justify-center cursor-pointer hover:scale-105 transition-transform" @click="player.toggleRoamDrawer()" title="全屏展示">
|
||||
<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-content-3"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||||
@ -104,52 +104,96 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition name="slide-up">
|
||||
<Teleport to="body">
|
||||
<Transition name="queue-fade">
|
||||
<div v-if="showQueuePanel" class="fixed inset-0 z-[55] bg-black/40 backdrop-blur-[2px]" @click="showQueuePanel = false"></div>
|
||||
</Transition>
|
||||
<Transition name="queue-slide">
|
||||
<div v-if="showQueuePanel"
|
||||
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">
|
||||
<h3 class="text-sm font-semibold">播放列表 ({{ player.queue.length }})</h3>
|
||||
<button @click="player.clearQueue()" class="text-xs text-danger hover:text-danger transition">清空</button>
|
||||
class="fixed right-0 top-0 bottom-0 z-[56] w-[340px] bg-base/95 backdrop-blur border-l border-line flex flex-col shadow-2xl shadow-black/40">
|
||||
|
||||
<div class="px-5 pt-5 pb-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-[1rem] font-semibold text-content">播放列表</h3>
|
||||
<p class="text-xs text-content-3 mt-0.5">{{ player.queue.length }} 首歌曲</p>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div v-for="(song, idx) in player.queue" :key="song.id + '-' + idx" @click="playFromQueue(idx)" :class="[
|
||||
'flex items-center gap-3 p-2 rounded-lg cursor-pointer transition',
|
||||
idx === player.currentIndex ? 'bg-accent-dim text-content' : 'hover:bg-subtle text-content-2',
|
||||
]">
|
||||
<span class="text-xs w-6 text-center">{{ idx + 1 }}</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs font-medium truncate">{{ song.name }}</p>
|
||||
<p class="text-xs text-content-3 truncate">
|
||||
<template v-for="(a, i) in song.ar || []" :key="a.id || i">
|
||||
<span v-if="i > 0" class="text-content-3">/</span>
|
||||
<span class="hover:text-accent-text cursor-pointer transition" @click.stop="a.id && router.push({ name: 'artist', params: { id: a.id } })">{{ a.name }}</span>
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
<button @click.stop="player.removeFromQueue(idx)"
|
||||
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>
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="player.clearQueue()"
|
||||
class="px-2.5 py-1 text-xs text-content-3 hover:text-danger hover:bg-danger-dim rounded-lg transition">
|
||||
清空
|
||||
</button>
|
||||
<button @click="showQueuePanel = false"
|
||||
class="w-7 h-7 flex items-center justify-center rounded-lg text-content-3 hover:text-content hover:bg-subtle transition">
|
||||
<svg width="16" height="16" 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-px mx-5 bg-line"></div>
|
||||
|
||||
<div ref="queueListEl" class="flex-1 overflow-y-auto px-3 py-2 relative">
|
||||
<div v-if="player.queue.length === 0" class="flex flex-col items-center justify-center h-full text-content-4 gap-3">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="opacity-40">
|
||||
<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>
|
||||
</svg>
|
||||
<p class="text-sm">播放列表为空</p>
|
||||
<p class="text-xs text-content-4">去发现好听的音乐吧</p>
|
||||
</div>
|
||||
|
||||
<SongListItem
|
||||
v-for="(song, idx) in player.queue" :key="song.id + '-' + idx"
|
||||
:id="'queue-item-' + idx"
|
||||
:song="song"
|
||||
:index="idx"
|
||||
:is-current="idx === player.currentIndex"
|
||||
show-playing-overlay
|
||||
cover-size="w-9 h-9"
|
||||
cover-size-param="?param=80y80"
|
||||
:container-class="idx === player.currentIndex ? 'bg-muted hover:bg-muted gap-3 px-3 py-2 rounded-lg' : 'hover:bg-subtle gap-3 px-3 py-2 rounded-lg'"
|
||||
@click="playFromQueue(idx)"
|
||||
>
|
||||
<template #actions>
|
||||
<button @click.stop="player.removeFromQueue(idx)"
|
||||
class="w-6 h-6 flex items-center justify-center rounded-md text-content-4 hover:text-danger hover:bg-danger-dim transition opacity-0 group-hover:opacity-100 flex-shrink-0">
|
||||
<svg width="12" height="12" 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>
|
||||
</template>
|
||||
</SongListItem>
|
||||
|
||||
<div class="h-2"></div>
|
||||
|
||||
<button v-if="player.currentIndex >= 0 && player.queue.length > 0" v-show="!currentSongVisible"
|
||||
@click="scrollToCurrent"
|
||||
class="sticky bottom-3 float-right mr-1 w-9 h-9 flex items-center justify-center rounded-full bg-surface/90 backdrop-blur shadow-lg shadow-black/30 text-content-3 hover:text-accent-text hover:bg-accent-dim/50 transition-all duration-300"
|
||||
title="定位到正在播放">
|
||||
<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"/><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><line x1="12" y1="6" x2="12" y2="2"/><line x1="12" y1="22" x2="12" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onBeforeUnmount, onMounted } from 'vue';
|
||||
import { ref, computed, watch, onBeforeUnmount, onMounted, nextTick } from 'vue';
|
||||
import { usePlayerStore, PlayMode } from '../stores/player';
|
||||
import { useDownload } from '../composables/useDownload';
|
||||
import { formatTime } from '../utils/format';
|
||||
import { getCoverUrl } from '../utils/song';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { useRouter } from 'vue-router';
|
||||
import SongListItem from './SongListItem.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const player = usePlayerStore();
|
||||
const download = useDownload();
|
||||
const showQueuePanel = ref(false);
|
||||
const queueListEl = ref<HTMLElement | null>(null);
|
||||
const currentSongVisible = ref(true);
|
||||
const progressBar = ref<HTMLElement | null>(null);
|
||||
const isSeeking = ref(false);
|
||||
const previewTime = ref(0);
|
||||
@ -168,7 +212,7 @@ onBeforeUnmount(() => {
|
||||
if (unlistenCache) unlistenCache();
|
||||
});
|
||||
|
||||
const modeTexts = { loop: '列表循环', shuffle: '随机播放', 'repeat-one': '单曲循环' };
|
||||
const modeTexts: Record<PlayMode, string> = { loop: '列表循环', shuffle: '随机播放', 'repeat-one': '单曲循环' };
|
||||
const modeTitle = computed(() => modeTexts[player.playMode] || '列表循环');
|
||||
function togglePlayMode() {
|
||||
const modes: PlayMode[] = ['loop', 'shuffle', 'repeat-one'];
|
||||
@ -241,6 +285,50 @@ function playFromQueue(index: number) {
|
||||
player.playCurrent();
|
||||
}
|
||||
|
||||
function scrollToCurrent() {
|
||||
const el = document.getElementById('queue-item-' + player.currentIndex);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
|
||||
let currentSongObserver: IntersectionObserver | null = null;
|
||||
|
||||
function setupCurrentSongObserver() {
|
||||
if (currentSongObserver) {
|
||||
currentSongObserver.disconnect();
|
||||
currentSongObserver = null;
|
||||
}
|
||||
nextTick(() => {
|
||||
const el = document.getElementById('queue-item-' + player.currentIndex);
|
||||
if (!el || !queueListEl.value) return;
|
||||
currentSongObserver = new IntersectionObserver(
|
||||
([entry]) => { currentSongVisible.value = entry.isIntersecting; },
|
||||
{ root: queueListEl.value, threshold: 0.5 }
|
||||
);
|
||||
currentSongObserver.observe(el);
|
||||
});
|
||||
}
|
||||
|
||||
watch(() => [showQueuePanel.value, player.currentIndex, player.queue.length], () => {
|
||||
if (showQueuePanel.value) {
|
||||
setupCurrentSongObserver();
|
||||
} else {
|
||||
if (currentSongObserver) {
|
||||
currentSongObserver.disconnect();
|
||||
currentSongObserver = null;
|
||||
}
|
||||
currentSongVisible.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (currentSongObserver) {
|
||||
currentSongObserver.disconnect();
|
||||
currentSongObserver = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function handleVolumeChange(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const val = parseInt(target.value, 10);
|
||||
@ -255,26 +343,6 @@ const volumeBarBg = computed(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.slide-up-enter-from,
|
||||
.slide-up-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
.vol-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
@ -311,4 +379,31 @@ const volumeBarBg = computed(() => {
|
||||
.vol-slider::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.queue-fade-enter-active,
|
||||
.queue-fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.queue-fade-enter-from,
|
||||
.queue-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.queue-slide-enter-active,
|
||||
.queue-slide-leave-active {
|
||||
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.queue-slide-enter-from,
|
||||
.queue-slide-leave-to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
@keyframes eq-bounce-sm {
|
||||
0%, 100% { height: 2px; }
|
||||
50% { height: 10px; }
|
||||
}
|
||||
|
||||
.eq-bar-sm {
|
||||
animation: eq-bounce-sm 0.6s ease-in-out infinite;
|
||||
}
|
||||
</style>
|
||||
|
||||
97
src/components/SongListItem.vue
Normal file
97
src/components/SongListItem.vue
Normal file
@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div :class="['flex items-center gap-4 p-3 rounded-xl cursor-pointer transition group', containerClass]">
|
||||
<slot name="index" :index="index" :is-current="isCurrent">
|
||||
<span v-if="showIndex" class="text-xs text-content-3 w-6 text-right flex-shrink-0">{{ index + 1 }}</span>
|
||||
</slot>
|
||||
|
||||
<div :class="['rounded-md overflow-hidden flex-shrink-0 relative', coverClass]">
|
||||
<img v-if="coverSrc" :src="coverSrc" class="w-full h-full object-cover" loading="lazy" />
|
||||
<div v-else class="w-full h-full bg-muted flex items-center justify-center">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-content-4"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||||
</div>
|
||||
<div v-if="isCurrent && showPlayingOverlay"
|
||||
class="absolute inset-0 bg-black/30 flex items-center justify-center">
|
||||
<div class="flex items-end gap-[2px] h-3">
|
||||
<span class="eq-bar-sm w-[2px] bg-white rounded-full" style="animation-delay: 0s"></span>
|
||||
<span class="eq-bar-sm w-[2px] bg-white rounded-full" style="animation-delay: 0.12s"></span>
|
||||
<span class="eq-bar-sm w-[2px] bg-white rounded-full" style="animation-delay: 0.24s"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium truncate" :class="nameClass">{{ song.name }}</p>
|
||||
<p class="text-xs text-content-2 truncate">
|
||||
<template v-if="song.ar?.length">
|
||||
<template v-for="(a, i) in song.ar" :key="a.id || i">
|
||||
<span v-if="i > 0" class="text-content-3">/</span>
|
||||
<span class="hover:text-accent-text cursor-pointer transition" @click.stop="a.id && router.push({ name: 'artist', params: { id: a.id } })">{{ a.name }}</span>
|
||||
</template>
|
||||
</template>
|
||||
<template v-if="song.al?.name">
|
||||
<span class="text-content-3 mx-1">·</span>
|
||||
<span class="hover:text-accent-text cursor-pointer transition" @click.stop="song.al.id && router.push({ name: 'album', params: { id: song.al.id } })">{{ song.al.name }}</span>
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<slot name="actions">
|
||||
<button v-if="showLike" @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>
|
||||
<button v-if="showDownload" @click.stop="download.downloadSong(song)" class="text-content-3 hover:text-accent-text transition flex-shrink-0" :title="download.isDownloaded(song.id) ? '已下载' : '下载'">
|
||||
<svg v-if="download.isDownloading(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="animate-spin"><path d="M21 12a9 9 0 11-6.22-8.56"/></svg>
|
||||
<svg v-else-if="download.isDownloaded(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-accent-text"><polyline points="20 6 9 17 4 12"/></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="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
</button>
|
||||
<SongItemMenu v-if="showMenu" :song-id="song.id" />
|
||||
</slot>
|
||||
|
||||
<span v-if="showDuration && song.dt" class="text-xs text-content-3 flex-shrink-0">{{ formatDuration(song.dt) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { useDownload } from '../composables/useDownload';
|
||||
import { getCoverUrl, type Song } from '../utils/song';
|
||||
import { formatDuration } from '../utils/format';
|
||||
import SongItemMenu from './SongItemMenu.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const player = usePlayerStore();
|
||||
const download = useDownload();
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
song: Song;
|
||||
index: number;
|
||||
isCurrent?: boolean;
|
||||
showIndex?: boolean;
|
||||
showLike?: boolean;
|
||||
showDownload?: boolean;
|
||||
showMenu?: boolean;
|
||||
showDuration?: boolean;
|
||||
showPlayingOverlay?: boolean;
|
||||
coverSize?: string;
|
||||
coverSizeParam?: string;
|
||||
containerClass?: string;
|
||||
}>(), {
|
||||
isCurrent: false,
|
||||
showIndex: false,
|
||||
showLike: false,
|
||||
showDownload: false,
|
||||
showMenu: false,
|
||||
showDuration: false,
|
||||
showPlayingOverlay: false,
|
||||
coverSize: 'w-10 h-10',
|
||||
coverSizeParam: '',
|
||||
containerClass: 'hover:bg-subtle',
|
||||
});
|
||||
|
||||
const coverClass = computed(() => props.coverSize);
|
||||
const coverSrc = computed(() => getCoverUrl(props.song, props.coverSizeParam));
|
||||
const nameClass = computed(() => props.isCurrent ? 'text-accent-text' : '');
|
||||
</script>
|
||||
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<Transition name="fade">
|
||||
<div v-if="visible" class="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="handleIgnore">
|
||||
<div v-if="visible" class="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="!downloading && handleIgnore()">
|
||||
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-[440px] max-h-[80vh] flex flex-col select-auto">
|
||||
<div class="p-6 pb-4">
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { ref, watch } from 'vue';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { parseLrc, getCurrentLyricIndex, LyricLine } from '../utils/lyric';
|
||||
import { parseLrc, mergeTranslation, getCurrentLyricIndex, LyricLine } from '../utils/lyric';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
|
||||
export function useLyric() {
|
||||
@ -8,21 +8,33 @@ export function useLyric() {
|
||||
|
||||
const lyrics = ref<LyricLine[]>([]);
|
||||
const currentLyricIdx = ref(-1);
|
||||
const showTranslation = ref(true);
|
||||
const hasTranslation = ref(false);
|
||||
|
||||
watch(() => player.currentSong, async (song) => {
|
||||
if (!song) {
|
||||
lyrics.value = [];
|
||||
currentLyricIdx.value = -1;
|
||||
hasTranslation.value = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const jsonStr: string = await invoke('get_lyric', { id: song.id });
|
||||
const data = JSON.parse(jsonStr);
|
||||
const lrc = data?.lrc?.lyric || '';
|
||||
lyrics.value = lrc ? parseLrc(lrc) : [];
|
||||
const tLrc = data?.tlyric?.lyric || '';
|
||||
let parsed = lrc ? parseLrc(lrc) : [];
|
||||
if (tLrc && parsed.length > 0) {
|
||||
parsed = mergeTranslation(parsed, tLrc);
|
||||
hasTranslation.value = parsed.some(l => l.translation);
|
||||
} else {
|
||||
hasTranslation.value = false;
|
||||
}
|
||||
lyrics.value = parsed;
|
||||
currentLyricIdx.value = -1;
|
||||
} catch {
|
||||
lyrics.value = [];
|
||||
hasTranslation.value = false;
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
@ -34,8 +46,15 @@ export function useLyric() {
|
||||
}
|
||||
});
|
||||
|
||||
function toggleTranslation() {
|
||||
showTranslation.value = !showTranslation.value;
|
||||
}
|
||||
|
||||
return {
|
||||
lyrics,
|
||||
currentLyricIdx,
|
||||
hasTranslation,
|
||||
showTranslation,
|
||||
toggleTranslation,
|
||||
};
|
||||
}
|
||||
@ -3,6 +3,7 @@ import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { useSettingsStore } from '../stores/settings';
|
||||
import { showToast } from '../composables/useToast';
|
||||
import { getCoverUrl, type Song } from '../utils/song';
|
||||
|
||||
interface DownloadTask {
|
||||
id: number;
|
||||
@ -72,7 +73,7 @@ function getDownloadProgress(songId: number): number {
|
||||
return task?.progress ?? 0;
|
||||
}
|
||||
|
||||
async function downloadSong(song: { id: number; name: string; ar?: { name: string }[]; artists?: { name: string }[]; al?: { picUrl?: string; name?: string }; album?: { picUrl?: string; name?: string }; dt?: number; duration?: number }) {
|
||||
async function downloadSong(song: Song) {
|
||||
if (downloadingIds.has(song.id)) return;
|
||||
if (localSongIds.has(song.id)) {
|
||||
showToast(`${song.name} 已下载`, 'info');
|
||||
@ -80,10 +81,10 @@ async function downloadSong(song: { id: number; name: string; ar?: { name: strin
|
||||
}
|
||||
|
||||
const settings = useSettingsStore();
|
||||
const artist = song.ar?.map(a => a.name).join(' / ') || song.artists?.map(a => a.name).join(' / ') || '未知';
|
||||
const albumName = song.al?.name || song.album?.name || null;
|
||||
const durationVal = song.dt || song.duration || null;
|
||||
const coverUrl = song.al?.picUrl || song.album?.picUrl || null;
|
||||
const artist = song.ar?.map(a => a.name).join(' / ') || '未知';
|
||||
const albumName = song.al?.name || null;
|
||||
const durationVal = song.dt || null;
|
||||
const coverUrl = getCoverUrl(song) || null;
|
||||
|
||||
downloadingIds.add(song.id);
|
||||
tasks.push({ id: song.id, name: song.name, progress: 0 });
|
||||
|
||||
30
src/composables/useOnlineStatus.ts
Normal file
30
src/composables/useOnlineStatus.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue';
|
||||
|
||||
const isOnline = ref(navigator.onLine);
|
||||
|
||||
function update() {
|
||||
isOnline.value = navigator.onLine;
|
||||
}
|
||||
|
||||
let refCount = 0;
|
||||
|
||||
export function useOnlineStatus() {
|
||||
onMounted(() => {
|
||||
refCount++;
|
||||
if (refCount === 1) {
|
||||
window.addEventListener('online', update);
|
||||
window.addEventListener('offline', update);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
refCount--;
|
||||
if (refCount <= 0) {
|
||||
refCount = 0;
|
||||
window.removeEventListener('online', update);
|
||||
window.removeEventListener('offline', update);
|
||||
}
|
||||
});
|
||||
|
||||
return { isOnline };
|
||||
}
|
||||
30
src/composables/usePageCache.ts
Normal file
30
src/composables/usePageCache.ts
Normal file
@ -0,0 +1,30 @@
|
||||
const cache = new Map<string, { data: any; ts: number }>();
|
||||
const TTL = 5 * 60 * 1000;
|
||||
|
||||
export function pageCacheGet(key: string): any | null {
|
||||
const entry = cache.get(key);
|
||||
if (!entry) return null;
|
||||
if (Date.now() - entry.ts > TTL) {
|
||||
cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
export function pageCacheSet(key: string, data: any) {
|
||||
cache.set(key, { data, ts: Date.now() });
|
||||
}
|
||||
|
||||
export function pageCacheDelete(key: string) {
|
||||
cache.delete(key);
|
||||
}
|
||||
|
||||
export function pageCacheInvalidate(key: string) {
|
||||
cache.delete(key);
|
||||
}
|
||||
|
||||
export function pageCacheIsStale(key: string): boolean {
|
||||
const entry = cache.get(key);
|
||||
if (!entry) return true;
|
||||
return Date.now() - entry.ts > TTL;
|
||||
}
|
||||
@ -65,8 +65,7 @@ export function useUpdater() {
|
||||
}
|
||||
|
||||
const ignored = getIgnoredVersion()
|
||||
if (info.version === ignored) {
|
||||
if (!silent) error.value = '当前已是最新版本'
|
||||
if (info.version === ignored && silent) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@ -9,7 +9,6 @@ import DailySongs from '@/views/DailySongs.vue';
|
||||
import LocalMusic from '@/views/LocalMusic.vue';
|
||||
import Settings from '@/views/Settings.vue';
|
||||
|
||||
|
||||
const routes = [
|
||||
{ path: '/', name: 'home', component: Home },
|
||||
{ path: '/discover', name: 'discover', component: Discover },
|
||||
@ -19,14 +18,28 @@ const routes = [
|
||||
{ path: '/recent', name: 'recent', component: RecentPlays },
|
||||
{ path: '/daily', name: 'daily', component: DailySongs },
|
||||
{ path: '/local-music', name: 'local-music', component: LocalMusic },
|
||||
{ path: '/login', name: 'login', component: Login },
|
||||
{ path: '/login', name: 'login', component: Login, meta: { guest: true } },
|
||||
{ path: '/playlist/:id', name: 'playlist', component: PlaylistDetail },
|
||||
{ path: '/artist/:id', name: 'artist', component: () => import('@/views/ArtistDetail.vue') },
|
||||
{ path: '/album/:id', name: 'album', component: () => import('@/views/AlbumDetail.vue') },
|
||||
{ path: '/settings', name: 'settings', component: Settings },
|
||||
];
|
||||
|
||||
export default createRouter({
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
});
|
||||
|
||||
router.beforeEach((to) => {
|
||||
if (to.meta.guest) {
|
||||
const raw = localStorage.getItem('user');
|
||||
if (raw) {
|
||||
try {
|
||||
const data = JSON.parse(raw);
|
||||
if (data?.userId) return { name: 'home' };
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@ -1,35 +1,16 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref , watch } from 'vue';
|
||||
import { ref, watch, nextTick } from 'vue';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { normalizeSong } from '../utils/song';
|
||||
import { normalizeSong, type Song } from '../utils/song';
|
||||
import { useSettingsStore } from './settings';
|
||||
import { useUserStore } from './user';
|
||||
import { showToast } from '../composables/useToast';
|
||||
|
||||
export type PlayMode = 'loop' | 'shuffle' | 'repeat-one';
|
||||
export type { Song };
|
||||
|
||||
export interface Song {
|
||||
id: number;
|
||||
name: string;
|
||||
ar: { id?: number; name: string }[];
|
||||
al: { id?: number; picUrl: string; name?: string };
|
||||
dt?: number;
|
||||
|
||||
album?: { picUrl?: string; name?: string };
|
||||
artists?: { name: string }[];
|
||||
duration?: number;
|
||||
localPath?: string;
|
||||
}
|
||||
|
||||
const cacheProgress = ref(0);
|
||||
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
|
||||
export function setupCacheProgressListener() {
|
||||
listen<number>('cache-progress', (event) => {
|
||||
cacheProgress.value = event.payload;
|
||||
});
|
||||
}
|
||||
import { listen, emit } from '@tauri-apps/api/event';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
|
||||
function loadRecentLocal(): Song[] {
|
||||
try {
|
||||
@ -55,15 +36,35 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
|
||||
const queue = ref<Song[]>([]);
|
||||
const currentIndex = ref(-1);
|
||||
const volume = ref(100);
|
||||
|
||||
const settings = useSettingsStore();
|
||||
const volume = ref(settings.volume);
|
||||
|
||||
watch(volume, (val) => { settings.volume = val; });
|
||||
|
||||
let tickInterval: ReturnType<typeof setInterval> | null = null;
|
||||
function setTickInterval(v: ReturnType<typeof setInterval> | null) { _tickInterval = v; tickInterval = v; }
|
||||
|
||||
const recentLocal = ref<Song[]>(loadRecentLocal());
|
||||
const MAX_RECENT = 200;
|
||||
|
||||
const likedIds = ref<Set<number>>(loadLikedIdsFromStorage());
|
||||
|
||||
function emitPlaybackState() {
|
||||
const song = currentSong.value;
|
||||
const status = playing.value ? 'playing' : (song ? 'paused' : 'stopped');
|
||||
emit('playback-state', {
|
||||
status,
|
||||
title: song?.name || '',
|
||||
album: song?.al?.name || '',
|
||||
artists: song?.ar?.map(a => a.name) || [],
|
||||
coverUrl: song?.al?.picUrl || '',
|
||||
durationUs: (song?.dt || 0) * 1000,
|
||||
positionUs: Math.round(currentTime.value * 1_000_000),
|
||||
volume: volume.value / 100,
|
||||
});
|
||||
}
|
||||
|
||||
function isLiked(songId: number): boolean {
|
||||
return likedIds.value.has(songId);
|
||||
}
|
||||
@ -110,8 +111,34 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
}, { deep: true });
|
||||
|
||||
const isFmMode = ref(false);
|
||||
const fmQueue: Song[] = [];
|
||||
let fmNextCallback: (() => void) | null = null;
|
||||
|
||||
let lastScrobbleId: number | null = null;
|
||||
let lastScrobbleStartTime: number = 0;
|
||||
|
||||
function reportScrobble() {
|
||||
const song = currentSong.value;
|
||||
if (!song || song.localPath || song.id == null) {
|
||||
lastScrobbleId = null;
|
||||
return;
|
||||
}
|
||||
if (lastScrobbleId === song.id && lastScrobbleStartTime > 0) {
|
||||
const playedSec = Math.round((Date.now() - lastScrobbleStartTime) / 1000);
|
||||
if (playedSec > 5) {
|
||||
invoke('scrobble', {
|
||||
query: {
|
||||
id: song.id,
|
||||
sourceid: isFmMode.value ? String(song.id) : '',
|
||||
time: playedSec,
|
||||
},
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
lastScrobbleId = song.id;
|
||||
lastScrobbleStartTime = Date.now();
|
||||
}
|
||||
|
||||
function enableFmMode(onNext: () => void) {
|
||||
isFmMode.value = true;
|
||||
fmNextCallback = onNext;
|
||||
@ -120,13 +147,23 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
function disableFmMode() {
|
||||
isFmMode.value = false;
|
||||
fmNextCallback = null;
|
||||
fmQueue.length = 0;
|
||||
}
|
||||
|
||||
async function fetchFmBatch(): Promise<Song[]> {
|
||||
const jsonStr: string = await invoke('personal_fm');
|
||||
const data = JSON.parse(jsonStr);
|
||||
const raw = data.data || data;
|
||||
if (!Array.isArray(raw) || raw.length === 0) return [];
|
||||
return raw.map((s: any) => normalizeSong(s));
|
||||
}
|
||||
|
||||
let fmVipSkipCount = 0;
|
||||
const MAX_FM_VIP_SKIP = 10;
|
||||
|
||||
async function playFmSong(song: any) {
|
||||
if (tickInterval) { clearInterval(tickInterval); tickInterval = null; }
|
||||
async function playFmSong(song: Song) {
|
||||
if (tickInterval) { clearInterval(tickInterval); setTickInterval(null); }
|
||||
reportScrobble();
|
||||
if (!song.dt || song.dt === 0) {
|
||||
try {
|
||||
const jsonStr: string = await invoke('get_song_detail', { id: String(song.id) });
|
||||
@ -148,7 +185,6 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
fmSong.value = song;
|
||||
currentSong.value = song;
|
||||
try {
|
||||
const settings = useSettingsStore();
|
||||
const jsonStr: string = await invoke('get_song_url', { query: { id: Number(song.id), level: settings.audioQuality, fm_mode: true } });
|
||||
const data = JSON.parse(jsonStr);
|
||||
const url: string | undefined = data.url;
|
||||
@ -180,6 +216,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
currentTime.value = 0;
|
||||
startTick();
|
||||
addRecent(song);
|
||||
emitPlaybackState();
|
||||
} catch (e) {
|
||||
console.error('FM播放失败', e);
|
||||
playing.value = false;
|
||||
@ -195,6 +232,15 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
disableFmMode();
|
||||
|
||||
const idx = queue.value.findIndex(s => s.id === song.id);
|
||||
if (idx !== -1 && idx === currentIndex.value && currentSong.value?.id === song.id) {
|
||||
if (!playing.value) {
|
||||
await invoke('resume_audio');
|
||||
playing.value = true;
|
||||
startTick();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (idx === -1) {
|
||||
queue.value.push(song);
|
||||
currentIndex.value = queue.value.length - 1;
|
||||
@ -207,6 +253,21 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
async function playFromList(songs: Song[], startIndex: number) {
|
||||
disableFmMode();
|
||||
if (songs.length === 0) return;
|
||||
|
||||
const targetSong = songs[startIndex];
|
||||
if (targetSong && currentSong.value?.id === targetSong.id && currentIndex.value >= 0) {
|
||||
const sameQueue = queue.value.length === songs.length
|
||||
&& queue.value.every((s, i) => s.id === songs[i].id);
|
||||
if (sameQueue) {
|
||||
if (!playing.value) {
|
||||
await invoke('resume_audio');
|
||||
playing.value = true;
|
||||
startTick();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
queue.value = [...songs];
|
||||
currentIndex.value = Math.max(0, Math.min(startIndex, songs.length - 1));
|
||||
await playCurrent();
|
||||
@ -215,23 +276,15 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
let vipSkipCount = 0;
|
||||
const MAX_VIP_SKIP = 10;
|
||||
|
||||
let audioStartedResolve: (() => void) | null = null;
|
||||
|
||||
listen('audio-started', () => {
|
||||
if (audioStartedResolve) {
|
||||
audioStartedResolve();
|
||||
audioStartedResolve = null;
|
||||
}
|
||||
});
|
||||
|
||||
function waitForAudioStart(): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
audioStartedResolve = resolve;
|
||||
_audioStartedResolve = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
async function playCurrent() {
|
||||
if (tickInterval) { clearInterval(tickInterval); tickInterval = null; }
|
||||
if (tickInterval) { clearInterval(tickInterval); setTickInterval(null); }
|
||||
reportScrobble();
|
||||
const song = queue.value[currentIndex.value];
|
||||
if (!song?.id) {
|
||||
console.error('无效的歌曲数据', song);
|
||||
@ -242,7 +295,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
currentSong.value = song;
|
||||
playing.value = false;
|
||||
currentTime.value = 0;
|
||||
duration.value = (song.dt || song.duration || 0) / 1000;
|
||||
duration.value = (song.dt || 0) / 1000;
|
||||
|
||||
if (song.localPath) {
|
||||
await invoke('play_local_audio', { path: song.localPath });
|
||||
@ -250,10 +303,10 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
playing.value = true;
|
||||
startTick();
|
||||
addRecent(song);
|
||||
emitPlaybackState();
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = useSettingsStore();
|
||||
const jsonStr: string = await invoke('get_song_url', { query: { id: Number(song.id), level: settings.audioQuality } });
|
||||
const data = JSON.parse(jsonStr);
|
||||
const url: string | undefined = data.url;
|
||||
@ -282,24 +335,56 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
startTick();
|
||||
addRecent(song);
|
||||
vipSkipCount = 0;
|
||||
emitPlaybackState();
|
||||
} catch (e) {
|
||||
console.error('播放失败', e);
|
||||
playing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
let onSeekStart: (() => void) | null = null;
|
||||
|
||||
function startTick() {
|
||||
if (tickInterval) clearInterval(tickInterval);
|
||||
tickInterval = setInterval(() => {
|
||||
let seekGuard = false;
|
||||
onSeekStart = () => { seekGuard = true; };
|
||||
let syncCounter = 1;
|
||||
let lastSyncPos = -1;
|
||||
let backendFrozen = false;
|
||||
setTickInterval(setInterval(async () => {
|
||||
if (playing.value && duration.value > 0) {
|
||||
if (currentTime.value < duration.value) {
|
||||
currentTime.value += 0.25;
|
||||
if (seekGuard) return;
|
||||
syncCounter++;
|
||||
if (syncCounter >= 2) {
|
||||
syncCounter = 0;
|
||||
try {
|
||||
const pos = await invoke<number>('get_audio_position');
|
||||
if (pos >= currentTime.value - 0.5) {
|
||||
currentTime.value = pos;
|
||||
}
|
||||
if (lastSyncPos < 0) {
|
||||
lastSyncPos = pos;
|
||||
} else if (pos <= lastSyncPos + 0.05) {
|
||||
backendFrozen = true;
|
||||
lastSyncPos = pos;
|
||||
} else {
|
||||
backendFrozen = false;
|
||||
lastSyncPos = pos;
|
||||
}
|
||||
} catch {}
|
||||
} else {
|
||||
if (!backendFrozen) {
|
||||
const next = currentTime.value + 0.25;
|
||||
if (next <= duration.value) {
|
||||
currentTime.value = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (currentTime.value > duration.value) {
|
||||
currentTime.value = duration.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 250);
|
||||
}, 250));
|
||||
}
|
||||
|
||||
async function toggle() {
|
||||
@ -310,6 +395,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
await invoke('resume_audio');
|
||||
playing.value = true;
|
||||
}
|
||||
emitPlaybackState();
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
@ -317,8 +403,9 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
playing.value = false;
|
||||
currentSong.value = null;
|
||||
currentTime.value = 0;
|
||||
if (tickInterval) clearInterval(tickInterval);
|
||||
if (tickInterval) { clearInterval(tickInterval); setTickInterval(null); }
|
||||
disableFmMode();
|
||||
emitPlaybackState();
|
||||
}
|
||||
|
||||
|
||||
@ -365,8 +452,11 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
|
||||
async function seek(time: number) {
|
||||
try {
|
||||
await invoke('seek_audio', { time });
|
||||
currentTime.value = time;
|
||||
if (onSeekStart) onSeekStart();
|
||||
await invoke('seek_audio', { time });
|
||||
startTick();
|
||||
emitPlaybackState();
|
||||
} catch (e) {
|
||||
console.error('seek 失败', e);
|
||||
}
|
||||
@ -376,6 +466,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
const newVol = Math.max(0, Math.min(100, volume.value + delta));
|
||||
volume.value = newVol;
|
||||
await invoke('set_volume', { vol: newVol / 100 });
|
||||
emitPlaybackState();
|
||||
}
|
||||
|
||||
|
||||
@ -422,6 +513,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
function openRoamDrawer(tab: 'lyric' | 'comment' = 'lyric') {
|
||||
roamInitialTab.value = tab;
|
||||
showRoamDrawer.value = true;
|
||||
nextTick(() => { roamInitialTab.value = 'lyric'; });
|
||||
}
|
||||
|
||||
function openCommentForSong(songId: number) {
|
||||
@ -439,12 +531,11 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
|
||||
async function loadFirstFmSong() {
|
||||
try {
|
||||
const jsonStr: string = await invoke('personal_fm');
|
||||
const data = JSON.parse(jsonStr);
|
||||
const songs = data.data || data;
|
||||
if (songs && songs.length > 0) {
|
||||
const song = normalizeSong(songs[0]);
|
||||
enableFmMode(() => loadFirstFmSong());
|
||||
const batch = await fetchFmBatch();
|
||||
if (batch.length > 0) {
|
||||
fmQueue.push(...batch);
|
||||
const song = fmQueue.shift()!;
|
||||
enableFmMode(nextFm);
|
||||
await playFmSong(song);
|
||||
return true;
|
||||
}
|
||||
@ -456,20 +547,23 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
|
||||
|
||||
// -------- FM 专属状态 --------
|
||||
const fmSong = ref<any>(null);
|
||||
const fmSong = ref<Song | null>(null);
|
||||
const fmPlaying = ref(false);
|
||||
|
||||
async function loadFm() {
|
||||
try {
|
||||
const jsonStr: string = await invoke('personal_fm');
|
||||
const data = JSON.parse(jsonStr);
|
||||
const songs = data.data || data;
|
||||
if (songs && songs.length > 0) {
|
||||
const song = normalizeSong(songs[0]);
|
||||
if (fmQueue.length === 0) {
|
||||
const batch = await fetchFmBatch();
|
||||
if (batch.length === 0) return;
|
||||
fmQueue.push(...batch);
|
||||
}
|
||||
const song = fmQueue.shift()!;
|
||||
fmSong.value = song;
|
||||
enableFmMode(nextFm);
|
||||
await playFmSong(song);
|
||||
fmPlaying.value = true;
|
||||
if (fmQueue.length <= 1) {
|
||||
fetchFmBatch().then(batch => { fmQueue.push(...batch); }).catch(() => {});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('FM加载失败', e);
|
||||
@ -497,12 +591,61 @@ async function nextFm() {
|
||||
await loadFm();
|
||||
}
|
||||
|
||||
let _audioStartedResolve: (() => void) | null = null;
|
||||
let _tickInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
listen('audio-started', () => {
|
||||
if (_audioStartedResolve) {
|
||||
_audioStartedResolve();
|
||||
_audioStartedResolve = null;
|
||||
}
|
||||
});
|
||||
|
||||
listen('audio-ended', () => {
|
||||
if (tickInterval) { clearInterval(tickInterval); tickInterval = null; }
|
||||
if (isFmMode.value && fmNextCallback) {
|
||||
fmNextCallback();
|
||||
} else {
|
||||
next();
|
||||
if (_tickInterval) { clearInterval(_tickInterval); _tickInterval = null; }
|
||||
const player = usePlayerStore();
|
||||
player.reportScrobble();
|
||||
player.next();
|
||||
});
|
||||
|
||||
listen<string>('mpris-command', (event) => {
|
||||
const cmd = event.payload;
|
||||
const player = usePlayerStore();
|
||||
if (cmd === 'Next') {
|
||||
player.next();
|
||||
} else if (cmd === 'Previous') {
|
||||
player.prev();
|
||||
} else if (cmd === 'PlayPause') {
|
||||
player.toggle();
|
||||
} else if (cmd === 'Play') {
|
||||
if (!player.playing) player.toggle();
|
||||
} else if (cmd === 'Pause') {
|
||||
if (player.playing) player.toggle();
|
||||
} else if (cmd === 'Stop') {
|
||||
player.stop();
|
||||
} else if (cmd.startsWith('SetVolume:')) {
|
||||
const vol = parseFloat(cmd.slice(10));
|
||||
if (!isNaN(vol)) {
|
||||
player.volume = Math.round(vol * 100);
|
||||
invoke('set_volume', { vol }).catch(() => {});
|
||||
}
|
||||
} else if (cmd.startsWith('Seek:')) {
|
||||
const offsetUs = parseInt(cmd.slice(5), 10);
|
||||
const offsetSec = offsetUs / 1_000_000;
|
||||
const newPos = Math.max(0, Math.min(player.currentTime + offsetSec, player.duration));
|
||||
player.seek(newPos);
|
||||
} else if (cmd.startsWith('SetPosition:')) {
|
||||
const posUs = parseInt(cmd.slice(13), 10);
|
||||
const posSec = posUs / 1_000_000;
|
||||
if (posSec < 1 && player.currentTime > 5) {
|
||||
return;
|
||||
}
|
||||
player.seek(posSec);
|
||||
} else if (cmd === 'Raise') {
|
||||
getCurrentWindow().show().catch(() => {});
|
||||
getCurrentWindow().setFocus().catch(() => {});
|
||||
} else if (cmd === 'Quit') {
|
||||
getCurrentWindow().close().catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
@ -566,6 +709,8 @@ watch(playing, (val) => {
|
||||
toggleRoamDrawer,
|
||||
loadFirstFmSong,
|
||||
|
||||
reportScrobble,
|
||||
|
||||
fmSong,
|
||||
fmPlaying,
|
||||
loadFm,
|
||||
|
||||
@ -2,9 +2,29 @@ import { defineStore } from 'pinia';
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
export type AudioQuality = 'standard' | 'higher' | 'exhigh' | 'lossless' | 'hires';
|
||||
export type ThemeMode = 'dark' | 'light';
|
||||
export type ThemeName = 'blue' | 'green' | 'rose' | 'violet' | 'orange' | 'cyan' | 'pink';
|
||||
export type CloseAction = 'ask' | 'minimize' | 'exit';
|
||||
|
||||
export const themeLabels: Record<ThemeName, string> = {
|
||||
blue: '天蓝',
|
||||
green: '翠绿',
|
||||
rose: '玫红',
|
||||
violet: '紫罗兰',
|
||||
orange: '橙色',
|
||||
cyan: '青色',
|
||||
pink: '粉色',
|
||||
};
|
||||
|
||||
export const themeColors: Record<ThemeName, string> = {
|
||||
blue: '#3b82f6',
|
||||
green: '#22c55e',
|
||||
rose: '#f43f5e',
|
||||
violet: '#8b5cf6',
|
||||
orange: '#f97316',
|
||||
cyan: '#06b6d4',
|
||||
pink: '#ec4899',
|
||||
};
|
||||
|
||||
export const qualityLabels: Record<AudioQuality, string> = {
|
||||
standard: '标准',
|
||||
higher: '较高',
|
||||
@ -25,22 +45,26 @@ export interface ShortcutBinding {
|
||||
}
|
||||
|
||||
export const defaultShortcuts: Record<string, ShortcutBinding> = {
|
||||
playPause: { key: 'Control+KeyP', label: '播放/暂停' },
|
||||
prev: { key: 'Control+ArrowLeft', label: '上一首' },
|
||||
next: { key: 'Control+ArrowRight', label: '下一首' },
|
||||
volUp: { key: 'Control+ArrowUp', label: '音量增加' },
|
||||
volDown: { key: 'Control+ArrowDown', label: '音量减小' },
|
||||
globalPrev: { key: 'Alt+Control+ArrowLeft', label: '上一首(全局)' },
|
||||
globalNext: { key: 'Alt+Control+ArrowRight', label: '下一首(全局)' },
|
||||
globalVolUp: { key: 'Alt+Control+ArrowUp', label: '音量增加(全局)' },
|
||||
globalVolDown: { key: 'Alt+Control+ArrowDown', label: '音量减小(全局)' },
|
||||
globalPlayPause: { key: 'Control+Alt+KeyP', label: '播放/暂停(全局)' },
|
||||
globalPrev: { key: 'Control+Alt+ArrowLeft', label: '上一首(全局)' },
|
||||
globalNext: { key: 'Control+Alt+ArrowRight', label: '下一首(全局)' },
|
||||
globalVolUp: { key: 'Control+Alt+ArrowUp', label: '音量增加(全局)' },
|
||||
globalVolDown: { key: 'Control+Alt+ArrowDown', label: '音量减小(全局)' },
|
||||
};
|
||||
|
||||
interface SettingsData {
|
||||
audioQuality: AudioQuality;
|
||||
downloadPath: string;
|
||||
theme: ThemeMode;
|
||||
theme: ThemeName;
|
||||
closeAction: CloseAction;
|
||||
shortcuts: Record<string, ShortcutBinding>;
|
||||
outputDevice: string | null;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
function loadSettings(): SettingsData {
|
||||
@ -48,21 +72,27 @@ function loadSettings(): SettingsData {
|
||||
const raw = localStorage.getItem('app_settings');
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
const theme = parsed.theme || parsed.accentColor || 'blue';
|
||||
const validThemes: ThemeName[] = ['blue', 'green', 'rose', 'violet', 'orange', 'cyan', 'pink'];
|
||||
return {
|
||||
audioQuality: parsed.audioQuality || 'standard',
|
||||
downloadPath: parsed.downloadPath || '',
|
||||
theme: parsed.theme || 'dark',
|
||||
theme: validThemes.includes(theme) ? theme : 'blue',
|
||||
closeAction: parsed.closeAction || 'ask',
|
||||
shortcuts: { ...defaultShortcuts, ...(parsed.shortcuts || {}) },
|
||||
outputDevice: parsed.outputDevice || null,
|
||||
volume: typeof parsed.volume === 'number' ? parsed.volume : 100,
|
||||
};
|
||||
}
|
||||
} catch {}
|
||||
return {
|
||||
audioQuality: 'standard',
|
||||
downloadPath: '',
|
||||
theme: 'dark',
|
||||
theme: 'blue',
|
||||
closeAction: 'ask',
|
||||
shortcuts: { ...defaultShortcuts },
|
||||
outputDevice: null,
|
||||
volume: 100,
|
||||
};
|
||||
}
|
||||
|
||||
@ -71,9 +101,11 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
|
||||
const audioQuality = ref<AudioQuality>(saved.audioQuality);
|
||||
const downloadPath = ref<string>(saved.downloadPath);
|
||||
const theme = ref<ThemeMode>(saved.theme);
|
||||
const theme = ref<ThemeName>(saved.theme);
|
||||
const closeAction = ref<CloseAction>(saved.closeAction || 'ask');
|
||||
const shortcuts = ref<Record<string, ShortcutBinding>>(saved.shortcuts);
|
||||
const outputDevice = ref<string | null>(saved.outputDevice);
|
||||
const volume = ref<number>(saved.volume);
|
||||
|
||||
function setAudioQuality(q: AudioQuality) {
|
||||
audioQuality.value = q;
|
||||
@ -83,7 +115,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
downloadPath.value = p;
|
||||
}
|
||||
|
||||
function setTheme(t: ThemeMode) {
|
||||
function setTheme(t: ThemeName) {
|
||||
theme.value = t;
|
||||
}
|
||||
|
||||
@ -99,21 +131,29 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
shortcuts.value = { ...defaultShortcuts };
|
||||
}
|
||||
|
||||
function setOutputDevice(device: string | null) {
|
||||
outputDevice.value = device;
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
audioQuality.value = 'standard';
|
||||
downloadPath.value = '';
|
||||
theme.value = 'dark';
|
||||
theme.value = 'blue';
|
||||
closeAction.value = 'ask';
|
||||
shortcuts.value = { ...defaultShortcuts };
|
||||
outputDevice.value = null;
|
||||
volume.value = 100;
|
||||
}
|
||||
|
||||
watch([audioQuality, downloadPath, theme, closeAction, shortcuts], () => {
|
||||
watch([audioQuality, downloadPath, theme, closeAction, shortcuts, outputDevice, volume], () => {
|
||||
const data: SettingsData = {
|
||||
audioQuality: audioQuality.value,
|
||||
downloadPath: downloadPath.value,
|
||||
theme: theme.value,
|
||||
closeAction: closeAction.value,
|
||||
shortcuts: shortcuts.value,
|
||||
outputDevice: outputDevice.value,
|
||||
volume: volume.value,
|
||||
};
|
||||
localStorage.setItem('app_settings', JSON.stringify(data));
|
||||
}, { deep: true });
|
||||
@ -124,10 +164,13 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
theme,
|
||||
closeAction,
|
||||
shortcuts,
|
||||
outputDevice,
|
||||
volume,
|
||||
setAudioQuality,
|
||||
setDownloadPath,
|
||||
setTheme,
|
||||
setCloseAction,
|
||||
setOutputDevice,
|
||||
setShortcut,
|
||||
resetShortcuts,
|
||||
resetAll,
|
||||
|
||||
173
src/style.css
173
src/style.css
@ -23,18 +23,19 @@
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--c-bg: #030712;
|
||||
--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);
|
||||
:root,
|
||||
[data-theme="green"] {
|
||||
--c-bg: #020c06;
|
||||
--c-surface: #0a1a10;
|
||||
--c-subtle: rgba(34, 197, 94, 0.06);
|
||||
--c-muted: rgba(34, 197, 94, 0.10);
|
||||
--c-emphasis: rgba(34, 197, 94, 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-line: rgba(255, 255, 255, 0.08);
|
||||
--c-line-2: rgba(255, 255, 255, 0.04);
|
||||
--c-accent: #22c55e;
|
||||
--c-accent-hover: #16a34a;
|
||||
--c-accent-text: #4ade80;
|
||||
@ -45,26 +46,136 @@
|
||||
--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;
|
||||
[data-theme="rose"] {
|
||||
--c-bg: #0c0206;
|
||||
--c-surface: #1a0a10;
|
||||
--c-subtle: rgba(244, 63, 94, 0.06);
|
||||
--c-muted: rgba(244, 63, 94, 0.10);
|
||||
--c-emphasis: rgba(244, 63, 94, 0.18);
|
||||
--c-content: #ffffff;
|
||||
--c-content-2: #9ca3af;
|
||||
--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;
|
||||
--c-content-4: #4b5563;
|
||||
--c-line: rgba(255, 255, 255, 0.08);
|
||||
--c-line-2: rgba(255, 255, 255, 0.04);
|
||||
--c-accent: #f43f5e;
|
||||
--c-accent-hover: #e11d48;
|
||||
--c-accent-text: #fb7185;
|
||||
--c-accent-dim: rgba(244, 63, 94, 0.20);
|
||||
--c-danger: #ef4444;
|
||||
--c-danger-dim: rgba(239, 68, 68, 0.20);
|
||||
--c-warning: #eab308;
|
||||
--c-info: #3b82f6;
|
||||
}
|
||||
|
||||
[data-theme="blue"] {
|
||||
--c-bg: #02060c;
|
||||
--c-surface: #0a101a;
|
||||
--c-subtle: rgba(59, 130, 246, 0.06);
|
||||
--c-muted: rgba(59, 130, 246, 0.10);
|
||||
--c-emphasis: rgba(59, 130, 246, 0.18);
|
||||
--c-content: #ffffff;
|
||||
--c-content-2: #9ca3af;
|
||||
--c-content-3: #6b7280;
|
||||
--c-content-4: #4b5563;
|
||||
--c-line: rgba(255, 255, 255, 0.08);
|
||||
--c-line-2: rgba(255, 255, 255, 0.04);
|
||||
--c-accent: #3b82f6;
|
||||
--c-accent-hover: #2563eb;
|
||||
--c-accent-text: #60a5fa;
|
||||
--c-accent-dim: rgba(59, 130, 246, 0.20);
|
||||
--c-danger: #ef4444;
|
||||
--c-danger-dim: rgba(239, 68, 68, 0.20);
|
||||
--c-warning: #eab308;
|
||||
--c-info: #8b5cf6;
|
||||
}
|
||||
|
||||
[data-theme="violet"] {
|
||||
--c-bg: #06020c;
|
||||
--c-surface: #120a1a;
|
||||
--c-subtle: rgba(139, 92, 246, 0.06);
|
||||
--c-muted: rgba(139, 92, 246, 0.10);
|
||||
--c-emphasis: rgba(139, 92, 246, 0.18);
|
||||
--c-content: #ffffff;
|
||||
--c-content-2: #9ca3af;
|
||||
--c-content-3: #6b7280;
|
||||
--c-content-4: #4b5563;
|
||||
--c-line: rgba(255, 255, 255, 0.08);
|
||||
--c-line-2: rgba(255, 255, 255, 0.04);
|
||||
--c-accent: #8b5cf6;
|
||||
--c-accent-hover: #7c3aed;
|
||||
--c-accent-text: #a78bfa;
|
||||
--c-accent-dim: rgba(139, 92, 246, 0.20);
|
||||
--c-danger: #ef4444;
|
||||
--c-danger-dim: rgba(239, 68, 68, 0.20);
|
||||
--c-warning: #eab308;
|
||||
--c-info: #3b82f6;
|
||||
}
|
||||
|
||||
[data-theme="orange"] {
|
||||
--c-bg: #0c0602;
|
||||
--c-surface: #1a120a;
|
||||
--c-subtle: rgba(249, 115, 22, 0.06);
|
||||
--c-muted: rgba(249, 115, 22, 0.10);
|
||||
--c-emphasis: rgba(249, 115, 22, 0.18);
|
||||
--c-content: #ffffff;
|
||||
--c-content-2: #9ca3af;
|
||||
--c-content-3: #6b7280;
|
||||
--c-content-4: #4b5563;
|
||||
--c-line: rgba(255, 255, 255, 0.08);
|
||||
--c-line-2: rgba(255, 255, 255, 0.04);
|
||||
--c-accent: #f97316;
|
||||
--c-accent-hover: #ea580c;
|
||||
--c-accent-text: #fb923c;
|
||||
--c-accent-dim: rgba(249, 115, 22, 0.20);
|
||||
--c-danger: #ef4444;
|
||||
--c-danger-dim: rgba(239, 68, 68, 0.20);
|
||||
--c-warning: #eab308;
|
||||
--c-info: #3b82f6;
|
||||
}
|
||||
|
||||
[data-theme="cyan"] {
|
||||
--c-bg: #020c0c;
|
||||
--c-surface: #0a1a1a;
|
||||
--c-subtle: rgba(6, 182, 212, 0.06);
|
||||
--c-muted: rgba(6, 182, 212, 0.10);
|
||||
--c-emphasis: rgba(6, 182, 212, 0.18);
|
||||
--c-content: #ffffff;
|
||||
--c-content-2: #9ca3af;
|
||||
--c-content-3: #6b7280;
|
||||
--c-content-4: #4b5563;
|
||||
--c-line: rgba(255, 255, 255, 0.08);
|
||||
--c-line-2: rgba(255, 255, 255, 0.04);
|
||||
--c-accent: #06b6d4;
|
||||
--c-accent-hover: #0891b2;
|
||||
--c-accent-text: #22d3ee;
|
||||
--c-accent-dim: rgba(6, 182, 212, 0.20);
|
||||
--c-danger: #ef4444;
|
||||
--c-danger-dim: rgba(239, 68, 68, 0.20);
|
||||
--c-warning: #eab308;
|
||||
--c-info: #3b82f6;
|
||||
}
|
||||
|
||||
[data-theme="pink"] {
|
||||
--c-bg: #0c020a;
|
||||
--c-surface: #1a0a16;
|
||||
--c-subtle: rgba(236, 72, 153, 0.06);
|
||||
--c-muted: rgba(236, 72, 153, 0.10);
|
||||
--c-emphasis: rgba(236, 72, 153, 0.18);
|
||||
--c-content: #ffffff;
|
||||
--c-content-2: #9ca3af;
|
||||
--c-content-3: #6b7280;
|
||||
--c-content-4: #4b5563;
|
||||
--c-line: rgba(255, 255, 255, 0.08);
|
||||
--c-line-2: rgba(255, 255, 255, 0.04);
|
||||
--c-accent: #ec4899;
|
||||
--c-accent-hover: #db2777;
|
||||
--c-accent-text: #f472b6;
|
||||
--c-accent-dim: rgba(236, 72, 153, 0.20);
|
||||
--c-danger: #ef4444;
|
||||
--c-danger-dim: rgba(239, 68, 68, 0.20);
|
||||
--c-warning: #eab308;
|
||||
--c-info: #3b82f6;
|
||||
}
|
||||
|
||||
html {
|
||||
@ -78,11 +189,19 @@
|
||||
@apply antialiased;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
background: var(--c-bg);
|
||||
color: var(--c-content);
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
input, textarea, [contenteditable="true"] {
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
|
||||
@ -12,9 +12,18 @@ export function formatTime(sec: number): string {
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const YI = 100_000_000;
|
||||
const WAN = 10_000;
|
||||
|
||||
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) + '万';
|
||||
if (count >= YI) return (count / YI).toFixed(1) + '亿';
|
||||
if (count >= WAN) return (count / WAN).toFixed(1) + '万';
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
export function formatDate(ts: number): string {
|
||||
if (!ts) return '';
|
||||
const d = new Date(ts);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
export interface LyricLine {
|
||||
time: number; // 秒
|
||||
time: number;
|
||||
text: string;
|
||||
translation?: string;
|
||||
}
|
||||
|
||||
export function parseLrc(lrcStr: string): LyricLine[] {
|
||||
@ -20,11 +21,37 @@ export function parseLrc(lrcStr: string): LyricLine[] {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 按时长排序
|
||||
result.sort((a, b) => a.time - b.time);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function mergeTranslation(lyrics: LyricLine[], tLrcStr: string): LyricLine[] {
|
||||
if (!tLrcStr) return lyrics;
|
||||
const tLines = parseLrc(tLrcStr);
|
||||
if (tLines.length === 0) return lyrics;
|
||||
|
||||
const tMap = new Map<number, string>();
|
||||
for (const t of tLines) {
|
||||
const key = Math.round(t.time * 100);
|
||||
tMap.set(key, t.text);
|
||||
}
|
||||
|
||||
return lyrics.map(line => {
|
||||
const key = Math.round(line.time * 100);
|
||||
const translation = tMap.get(key);
|
||||
if (translation) {
|
||||
return { ...line, translation };
|
||||
}
|
||||
for (let offset = -3; offset <= 3; offset++) {
|
||||
const t = tMap.get(key + offset);
|
||||
if (t) {
|
||||
return { ...line, translation: t };
|
||||
}
|
||||
}
|
||||
return line;
|
||||
});
|
||||
}
|
||||
|
||||
export function getCurrentLyricIndex(lyrics: LyricLine[], currentTime: number): number {
|
||||
let index = -1;
|
||||
for (let i = 0; i < lyrics.length; i++) {
|
||||
|
||||
@ -1,22 +1,35 @@
|
||||
/**
|
||||
* 统一规范化歌曲对象,确保 al.picUrl、ar、dt 字段存在且合理
|
||||
*/
|
||||
export function normalizeSong(song: any) {
|
||||
const normalized = { ...song };
|
||||
if (!normalized.al?.picUrl && normalized.album?.picUrl) {
|
||||
normalized.al = { ...normalized.al, picUrl: normalized.album.picUrl };
|
||||
}
|
||||
if (!normalized.al?.name && normalized.album?.name) {
|
||||
normalized.al = { ...normalized.al, name: normalized.album.name };
|
||||
}
|
||||
if (!normalized.al?.id && normalized.album?.id) {
|
||||
normalized.al = { ...normalized.al, id: normalized.album.id };
|
||||
}
|
||||
if (!normalized.ar || normalized.ar.length === 0) {
|
||||
normalized.ar = normalized.artists || [];
|
||||
}
|
||||
if (!normalized.dt || normalized.dt < 100 || normalized.dt > 7200000) {
|
||||
normalized.dt = 0;
|
||||
}
|
||||
return normalized;
|
||||
export interface Song {
|
||||
id: number;
|
||||
name: string;
|
||||
ar: { id?: number; name: string }[];
|
||||
al: { id?: number; picUrl: string; name?: string };
|
||||
dt?: number;
|
||||
localPath?: string;
|
||||
}
|
||||
|
||||
export function normalizeSong(song: any): Song {
|
||||
const al = {
|
||||
id: song.al?.id || song.album?.id,
|
||||
picUrl: song.al?.picUrl || song.album?.picUrl || '',
|
||||
name: song.al?.name || song.album?.name,
|
||||
};
|
||||
const ar = (song.ar && song.ar.length > 0) ? song.ar : (song.artists || []);
|
||||
let dt = song.dt || song.duration || 0;
|
||||
if (dt < 100 || dt > 7200000) dt = 0;
|
||||
return {
|
||||
id: song.id,
|
||||
name: song.name,
|
||||
ar,
|
||||
al,
|
||||
dt,
|
||||
localPath: song.localPath,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCoverUrl(song: Song | null, sizeParam = ''): string {
|
||||
if (!song) return '';
|
||||
const raw = song.al?.picUrl || '';
|
||||
if (!raw) return '';
|
||||
if (!sizeParam || raw.startsWith('data:')) return raw;
|
||||
return raw + sizeParam;
|
||||
}
|
||||
@ -11,7 +11,7 @@
|
||||
<h1 class="text-2xl font-bold leading-tight">{{ album.name }}</h1>
|
||||
<div v-if="album.artists?.length" class="flex items-center gap-1 mt-2 text-sm text-content-2">
|
||||
<template v-for="(ar, idx) in album.artists" :key="ar.id">
|
||||
<span v-if="idx > 0" class="text-content-3">/</span>
|
||||
<span v-if="(idx as number) > 0" class="text-content-3">/</span>
|
||||
<span
|
||||
class="hover:text-accent-text cursor-pointer transition"
|
||||
@click="ar.id && router.push({ name: 'artist', params: { id: ar.id } })"
|
||||
@ -37,15 +37,24 @@
|
||||
<div v-if="loading" class="text-content-2">加载中...</div>
|
||||
|
||||
<div v-else class="space-y-1">
|
||||
<div
|
||||
<SongListItem
|
||||
v-for="(song, index) in songs"
|
||||
:key="song.id"
|
||||
@click="playSingle(song)"
|
||||
class="flex items-center gap-4 p-3 rounded-xl hover:bg-subtle transition cursor-pointer group"
|
||||
:class="{ 'bg-accent-dim': isCurrentSong(song.id) }"
|
||||
:song="song"
|
||||
:index="index"
|
||||
:is-current="player.currentSong?.id === song.id"
|
||||
show-index
|
||||
show-like
|
||||
show-download
|
||||
show-menu
|
||||
show-duration
|
||||
show-playing-overlay
|
||||
:container-class="player.currentSong?.id === song.id ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
|
||||
@click="player.playFromList(songs, index)"
|
||||
>
|
||||
<template #index="{ index: idx, isCurrent }">
|
||||
<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 v-if="isCurrent" 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>
|
||||
@ -53,36 +62,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<span class="text-xs text-content-3 group-hover:hidden">{{ index + 1 }}</span>
|
||||
<span class="text-xs text-content-3 group-hover:hidden">{{ idx + 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">
|
||||
<p class="text-sm font-medium truncate" :class="isCurrentSong(song.id) ? 'text-accent-text' : ''">{{ song.name }}</p>
|
||||
<p class="text-xs text-content-2 truncate">
|
||||
<template v-for="(a, i) in song.ar || []" :key="a.id || i">
|
||||
<span v-if="i > 0" class="text-content-3">/</span>
|
||||
<span class="hover:text-accent-text cursor-pointer transition" @click.stop="a.id && router.push({ name: 'artist', params: { id: a.id } })">{{ a.name }}</span>
|
||||
</template>
|
||||
<template v-if="song.al?.name">
|
||||
<span class="text-content-3 mx-1">·</span>
|
||||
<span class="hover:text-accent-text cursor-pointer transition" @click.stop="song.al.id && router.push({ name: 'album', params: { id: song.al.id } })">{{ song.al.name }}</span>
|
||||
</template>
|
||||
</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>
|
||||
<button @click.stop="download.downloadSong(song)" class="text-content-3 hover:text-accent-text transition flex-shrink-0" :title="download.isDownloaded(song.id) ? '已下载' : '下载'">
|
||||
<svg v-if="download.isDownloading(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="animate-spin"><path d="M21 12a9 9 0 11-6.22-8.56"/></svg>
|
||||
<svg v-else-if="download.isDownloaded(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-accent-text"><polyline points="20 6 9 17 4 12"/></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="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
</button>
|
||||
<SongItemMenu :song-id="song.id" />
|
||||
<span class="text-xs text-content-3">{{ formatDuration(song.dt) }}</span>
|
||||
</div>
|
||||
</SongListItem>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -92,25 +77,18 @@ import { ref, onMounted, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { useDownload } from '../composables/useDownload';
|
||||
import { formatDuration } from '../utils/format';
|
||||
import SongItemMenu from '../components/SongItemMenu.vue';
|
||||
import { normalizeSong, type Song } from '../utils/song';
|
||||
import { formatDate } from '../utils/format';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const player = usePlayerStore();
|
||||
const download = useDownload();
|
||||
|
||||
const album = ref<any>(null);
|
||||
const songs = ref<any[]>([]);
|
||||
const songs = ref<Song[]>([]);
|
||||
const loading = ref(true);
|
||||
|
||||
function formatDate(ts: number): string {
|
||||
if (!ts) return '';
|
||||
const d = new Date(ts);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
async function fetchAlbum(id: number) {
|
||||
loading.value = true;
|
||||
album.value = null;
|
||||
@ -118,13 +96,8 @@ async function fetchAlbum(id: number) {
|
||||
try {
|
||||
const jsonStr: string = await invoke('album_detail', { id });
|
||||
const data = JSON.parse(jsonStr);
|
||||
const a = data.album;
|
||||
if (a) {
|
||||
delete a.uid;
|
||||
if (a.artists) a.artists.forEach((ar: any) => delete ar.uid);
|
||||
}
|
||||
album.value = a;
|
||||
songs.value = data.songs || [];
|
||||
album.value = data.album;
|
||||
songs.value = (data.songs || []).map(normalizeSong);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
@ -140,15 +113,6 @@ watch(() => route.params.id, (newId) => {
|
||||
if (newId) fetchAlbum(Number(newId));
|
||||
});
|
||||
|
||||
function isCurrentSong(songId: number): boolean {
|
||||
return player.currentSong?.id === songId;
|
||||
}
|
||||
|
||||
async function playSingle(song: any) {
|
||||
const idx = songs.value.findIndex((s: any) => s.id === song.id);
|
||||
player.playFromList(songs.value, idx >= 0 ? idx : 0);
|
||||
}
|
||||
|
||||
function playAll() {
|
||||
if (songs.value.length === 0) return;
|
||||
player.playAll(songs.value);
|
||||
|
||||
@ -41,15 +41,24 @@
|
||||
|
||||
<template v-else>
|
||||
<div v-if="activeTab === 'songs'" class="space-y-1">
|
||||
<div
|
||||
<SongListItem
|
||||
v-for="(song, index) in songs"
|
||||
:key="song.id"
|
||||
@click="playSingle(song)"
|
||||
class="flex items-center gap-4 p-3 rounded-xl hover:bg-subtle transition cursor-pointer group"
|
||||
:class="{ 'bg-accent-dim': isCurrentSong(song.id) }"
|
||||
:song="song"
|
||||
:index="index"
|
||||
:is-current="player.currentSong?.id === song.id"
|
||||
show-index
|
||||
show-like
|
||||
show-download
|
||||
show-menu
|
||||
show-duration
|
||||
show-playing-overlay
|
||||
:container-class="player.currentSong?.id === song.id ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
|
||||
@click="player.playFromList(songs, index)"
|
||||
>
|
||||
<template #index="{ index: idx, isCurrent }">
|
||||
<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 v-if="isCurrent" 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>
|
||||
@ -57,36 +66,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<span class="text-xs text-content-3 group-hover:hidden">{{ index + 1 }}</span>
|
||||
<span class="text-xs text-content-3 group-hover:hidden">{{ idx + 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">
|
||||
<p class="text-sm font-medium truncate" :class="isCurrentSong(song.id) ? 'text-accent-text' : ''">{{ song.name }}</p>
|
||||
<p class="text-xs text-content-2 truncate">
|
||||
<template v-for="(a, i) in song.ar || []" :key="a.id || i">
|
||||
<span v-if="i > 0" class="text-content-3">/</span>
|
||||
<span class="hover:text-accent-text cursor-pointer transition" @click.stop="a.id && router.push({ name: 'artist', params: { id: a.id } })">{{ a.name }}</span>
|
||||
</template>
|
||||
<template v-if="song.al?.name">
|
||||
<span class="text-content-3 mx-1">·</span>
|
||||
<span class="hover:text-accent-text cursor-pointer transition" @click.stop="song.al.id && router.push({ name: 'album', params: { id: song.al.id } })">{{ song.al.name }}</span>
|
||||
</template>
|
||||
</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>
|
||||
<button @click.stop="download.downloadSong(song)" class="text-content-3 hover:text-accent-text transition flex-shrink-0" :title="download.isDownloaded(song.id) ? '已下载' : '下载'">
|
||||
<svg v-if="download.isDownloading(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="animate-spin"><path d="M21 12a9 9 0 11-6.22-8.56"/></svg>
|
||||
<svg v-else-if="download.isDownloaded(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-accent-text"><polyline points="20 6 9 17 4 12"/></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="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
</button>
|
||||
<SongItemMenu :song-id="song.id" />
|
||||
<span class="text-xs text-content-3">{{ formatDuration(song.dt) }}</span>
|
||||
</div>
|
||||
</SongListItem>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'albums'" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
@ -99,7 +84,7 @@
|
||||
<img :src="album.picUrl" class="w-full aspect-square object-cover" />
|
||||
<div class="p-3">
|
||||
<p class="text-sm font-medium truncate">{{ album.name }}</p>
|
||||
<p class="text-xs text-content-2 mt-1">{{ formatAlbumDate(album.publishTime) }}</p>
|
||||
<p class="text-xs text-content-2 mt-1">{{ formatDate(album.publishTime) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -116,17 +101,16 @@ import { ref, onMounted, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { useDownload } from '../composables/useDownload';
|
||||
import { formatDuration, formatPlayCount } from '../utils/format';
|
||||
import SongItemMenu from '../components/SongItemMenu.vue';
|
||||
import { formatPlayCount, formatDate } from '../utils/format';
|
||||
import { normalizeSong, type Song } from '../utils/song';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const player = usePlayerStore();
|
||||
const download = useDownload();
|
||||
|
||||
const artist = ref<any>(null);
|
||||
const songs = ref<any[]>([]);
|
||||
const songs = ref<Song[]>([]);
|
||||
const albums = ref<any[]>([]);
|
||||
const briefDesc = ref('');
|
||||
const loading = ref(true);
|
||||
@ -154,15 +138,9 @@ async function fetchArtist(id: number) {
|
||||
const detailData = JSON.parse(detailStr);
|
||||
artist.value = detailData.artist;
|
||||
const songsData = JSON.parse(songsStr);
|
||||
songs.value = songsData.songs || [];
|
||||
songs.value = (songsData.songs || []).map(normalizeSong);
|
||||
const albumData = JSON.parse(albumStr);
|
||||
const rawAlbums = albumData.hotAlbums || [];
|
||||
rawAlbums.forEach((a: any) => {
|
||||
delete a.uid;
|
||||
if (a.artist) delete a.artist.uid;
|
||||
if (a.artists) a.artists.forEach((ar: any) => delete ar.uid);
|
||||
});
|
||||
albums.value = rawAlbums;
|
||||
albums.value = albumData.hotAlbums || [];
|
||||
const descData = JSON.parse(descStr);
|
||||
briefDesc.value = descData.briefDesc || '';
|
||||
} catch (e) {
|
||||
@ -180,21 +158,6 @@ watch(() => route.params.id, (newId) => {
|
||||
if (newId) fetchArtist(Number(newId));
|
||||
});
|
||||
|
||||
function formatAlbumDate(ts: number): string {
|
||||
if (!ts) return '';
|
||||
const d = new Date(ts);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function isCurrentSong(songId: number): boolean {
|
||||
return player.currentSong?.id === songId;
|
||||
}
|
||||
|
||||
async function playSingle(song: any) {
|
||||
const idx = songs.value.findIndex((s: any) => s.id === song.id);
|
||||
player.playFromList(songs.value, idx >= 0 ? idx : 0);
|
||||
}
|
||||
|
||||
function playAll() {
|
||||
if (songs.value.length === 0) return;
|
||||
player.playAll(songs.value);
|
||||
|
||||
@ -15,15 +15,24 @@
|
||||
</div>
|
||||
<div v-if="loading" class="text-content-2">加载中...</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
<SongListItem
|
||||
v-for="(song, index) in songs"
|
||||
:key="song.id"
|
||||
:song="song"
|
||||
:index="index"
|
||||
:is-current="isCurrentSong(song.id)"
|
||||
show-index
|
||||
show-like
|
||||
show-download
|
||||
show-menu
|
||||
show-duration
|
||||
show-playing-overlay
|
||||
:container-class="isCurrentSong(song.id) ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
|
||||
@click="player.playFromList(songs, index)"
|
||||
class="flex items-center gap-4 p-3 rounded-xl hover:bg-subtle transition cursor-pointer group"
|
||||
:class="{ 'bg-accent-dim': isCurrentSong(song.id) }"
|
||||
>
|
||||
<template #index="{ index: idx, isCurrent }">
|
||||
<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 v-if="isCurrent" 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>
|
||||
@ -31,68 +40,66 @@
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<span class="text-xs text-content-3 group-hover:hidden">{{ index + 1 }}</span>
|
||||
<span class="text-xs text-content-3 group-hover:hidden">{{ idx + 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">
|
||||
<p class="text-sm font-medium truncate" :class="isCurrentSong(song.id) ? 'text-accent-text' : ''">{{ song.name }}</p>
|
||||
<p class="text-xs text-content-2 truncate">
|
||||
<template v-for="(a, i) in song.ar || []" :key="a.id || i">
|
||||
<span v-if="i > 0" class="text-content-3">/</span>
|
||||
<span class="hover:text-accent-text cursor-pointer transition" @click.stop="a.id && router.push({ name: 'artist', params: { id: a.id } })">{{ a.name }}</span>
|
||||
</template>
|
||||
<template v-if="song.al?.name">
|
||||
<span class="text-content-3 mx-1">·</span>
|
||||
<span class="hover:text-accent-text cursor-pointer transition" @click.stop="song.al.id && router.push({ name: 'album', params: { id: song.al.id } })">{{ song.al.name }}</span>
|
||||
</template>
|
||||
</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>
|
||||
<button @click.stop="download.downloadSong(song)" class="text-content-3 hover:text-accent-text transition flex-shrink-0" :title="download.isDownloaded(song.id) ? '已下载' : '下载'">
|
||||
<svg v-if="download.isDownloading(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="animate-spin"><path d="M21 12a9 9 0 11-6.22-8.56"/></svg>
|
||||
<svg v-else-if="download.isDownloaded(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-accent-text"><polyline points="20 6 9 17 4 12"/></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="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
</button>
|
||||
<SongItemMenu :song-id="song.id" />
|
||||
<span class="text-xs text-content-3">{{ formatDuration(song.dt) }}</span>
|
||||
</div>
|
||||
</SongListItem>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ref, onMounted, onActivated, watch } from 'vue';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import SongItemMenu from '../components/SongItemMenu.vue';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { useDownload } from '../composables/useDownload';
|
||||
import { formatDuration } from '../utils/format';
|
||||
import { pageCacheGet, pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
|
||||
import { normalizeSong, type Song } from '../utils/song';
|
||||
import { useOnlineStatus } from '../composables/useOnlineStatus';
|
||||
|
||||
defineOptions({ name: 'DailySongsView' });
|
||||
|
||||
const player = usePlayerStore();
|
||||
const download = useDownload();
|
||||
const router = useRouter();
|
||||
const songs = ref<any[]>([]);
|
||||
const { isOnline } = useOnlineStatus();
|
||||
const songs = ref<Song[]>([]);
|
||||
const loading = ref(true);
|
||||
|
||||
function isCurrentSong(songId: number): boolean {
|
||||
return player.currentSong?.id === songId;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
async function loadData() {
|
||||
const cached = pageCacheGet('dailySongs');
|
||||
if (cached) {
|
||||
songs.value = cached;
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const jsonStr: string = await invoke('recommend_songs');
|
||||
const data = JSON.parse(jsonStr);
|
||||
songs.value = data.data?.dailySongs || [];
|
||||
songs.value = (data.data?.dailySongs || []).map(normalizeSong);
|
||||
pageCacheSet('dailySongs', songs.value);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData);
|
||||
|
||||
onActivated(() => {
|
||||
if (pageCacheIsStale('dailySongs')) loadData();
|
||||
});
|
||||
|
||||
watch(isOnline, (val, old) => {
|
||||
if (val && !old && songs.value.length === 0) {
|
||||
pageCacheInvalidate('dailySongs');
|
||||
loadData();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
<div class="p-8 text-content">
|
||||
<h1 class="text-2xl font-bold mb-4">发现音乐</h1>
|
||||
|
||||
<!-- 搜索框 -->
|
||||
<input
|
||||
v-model="keyword"
|
||||
@keyup.enter="handleSearch"
|
||||
@ -10,7 +9,6 @@
|
||||
class="mb-4 w-full rounded-xl bg-muted p-3 text-content placeholder-content-2 outline-none backdrop-blur"
|
||||
/>
|
||||
|
||||
<!-- 热门搜索标签(仅在没有搜索且未显示结果时出现) -->
|
||||
<div v-if="!hasSearched && !loading && hotTags.length" class="mb-6">
|
||||
<h2 class="text-sm font-semibold mb-3">🔥 热门搜索</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@ -25,45 +23,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 输出设备选择 -->
|
||||
<!-- <div class="mb-4">
|
||||
<label class="mr-2 text-sm text-content-2">输出设备:</label>
|
||||
<select v-model="selectedDevice" @change="changeDevice" class="bg-muted text-white rounded p-1 text-sm">
|
||||
<option :value="null">跟随系统默认</option>
|
||||
<option v-for="dev in devices" :key="dev" :value="dev">{{ dev }}</option>
|
||||
</select>
|
||||
</div> -->
|
||||
|
||||
<!-- 搜索结果 -->
|
||||
<div v-if="loading" class="text-content-2">搜索中...</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
<SongListItem
|
||||
v-for="(song, index) in results"
|
||||
:key="song.id"
|
||||
@click="playSong(song, index)"
|
||||
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" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium truncate">{{ song.name }}</p>
|
||||
<p class="text-sm text-content-2 truncate">
|
||||
<template v-for="(a, i) in song.ar || []" :key="a.id || i">
|
||||
<span v-if="i > 0" class="text-content-3">/</span>
|
||||
<span class="hover:text-accent-text cursor-pointer transition" @click.stop="a.id && router.push({ name: 'artist', params: { id: a.id } })">{{ a.name }}</span>
|
||||
</template>
|
||||
<template v-if="song.al?.name">
|
||||
<span class="text-content-3 mx-1">·</span>
|
||||
<span class="hover:text-accent-text cursor-pointer transition" @click.stop="song.al.id && router.push({ name: 'album', params: { id: song.al.id } })">{{ song.al.name }}</span>
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
<button @click.stop="download.downloadSong(song)" class="text-content-3 hover:text-accent-text transition flex-shrink-0" :title="download.isDownloaded(song.id) ? '已下载' : '下载'">
|
||||
<svg v-if="download.isDownloading(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="animate-spin"><path d="M21 12a9 9 0 11-6.22-8.56"/></svg>
|
||||
<svg v-else-if="download.isDownloaded(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-accent-text"><polyline points="20 6 9 17 4 12"/></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="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
</button>
|
||||
<SongItemMenu :song-id="song.id" />
|
||||
</div>
|
||||
:song="song"
|
||||
:index="index"
|
||||
show-download
|
||||
show-menu
|
||||
cover-size="w-12 h-12"
|
||||
container-class="backdrop-blur-md bg-subtle hover:bg-muted border border-line-2"
|
||||
@click="player.playFromList(results, index)"
|
||||
/>
|
||||
<p v-if="!loading && hasSearched && results.length === 0" class="text-content-2">无结果</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -72,38 +44,43 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'DiscoverView' });
|
||||
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ref, onMounted, onActivated, watch } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { useDownload } from '../composables/useDownload';
|
||||
import SongItemMenu from '../components/SongItemMenu.vue';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
import { normalizeSong, type Song } from '../utils/song';
|
||||
import { pageCacheGet, pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
|
||||
import { useOnlineStatus } from '../composables/useOnlineStatus';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const player = usePlayerStore();
|
||||
const download = useDownload();
|
||||
const { isOnline } = useOnlineStatus();
|
||||
|
||||
const keyword = ref('');
|
||||
const results = ref<any[]>([]);
|
||||
const results = ref<Song[]>([]);
|
||||
const loading = ref(false);
|
||||
const hasSearched = ref(false);
|
||||
const hotTags = ref<any[]>([]);
|
||||
|
||||
const devices = ref<string[]>([]);
|
||||
|
||||
onMounted(async () => {
|
||||
// 获取输出设备列表
|
||||
try { devices.value = await invoke('get_output_devices'); } catch {}
|
||||
|
||||
// 获取热门搜索
|
||||
async function loadHotTags() {
|
||||
const cached = pageCacheGet('discover_hotTags');
|
||||
if (cached) {
|
||||
hotTags.value = cached;
|
||||
} else {
|
||||
try {
|
||||
const json = await invoke('get_hot_search');
|
||||
const data = JSON.parse(json as string);
|
||||
hotTags.value = (data.data || []).slice(0, 12);
|
||||
pageCacheSet('discover_hotTags', hotTags.value);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadHotTags();
|
||||
|
||||
// 检查路由是否有查询关键词,自动搜索
|
||||
const q = route.query.q as string;
|
||||
if (q) {
|
||||
keyword.value = q;
|
||||
@ -112,6 +89,17 @@ onMounted(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
if (pageCacheIsStale('discover_hotTags')) loadHotTags();
|
||||
});
|
||||
|
||||
watch(isOnline, (val, old) => {
|
||||
if (val && !old && hotTags.value.length === 0) {
|
||||
pageCacheInvalidate('discover_hotTags');
|
||||
loadHotTags();
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSearch() {
|
||||
if (!keyword.value.trim()) return;
|
||||
loading.value = true;
|
||||
@ -119,7 +107,7 @@ async function handleSearch() {
|
||||
try {
|
||||
const jsonStr: string = await invoke('search_songs', { query: { keyword: keyword.value } });
|
||||
const data = JSON.parse(jsonStr);
|
||||
results.value = data.result?.songs || [];
|
||||
results.value = (data.result?.songs || []).map(normalizeSong);
|
||||
} catch (e) {
|
||||
console.error('搜索出错:', e);
|
||||
} finally {
|
||||
@ -131,16 +119,4 @@ function searchTag(tag: string) {
|
||||
keyword.value = tag;
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
async function playSong(_song: any, index: number) {
|
||||
const normalized = results.value.map((s: any) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
ar: s.ar || s.artists || [],
|
||||
al: s.al || s.album || { picUrl: '' },
|
||||
dt: s.dt || 0,
|
||||
}));
|
||||
player.playFromList(normalized, index);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
@ -19,66 +19,52 @@
|
||||
<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
|
||||
<SongListItem
|
||||
v-for="(song, index) in songs"
|
||||
:key="song.id"
|
||||
:song="song"
|
||||
:index="index"
|
||||
show-index
|
||||
show-like
|
||||
show-download
|
||||
show-menu
|
||||
show-duration
|
||||
@click="player.playFromList(songs, index)"
|
||||
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">
|
||||
<template v-for="(a, i) in song.ar || []" :key="a.id || i">
|
||||
<span v-if="i > 0" class="text-content-3">/</span>
|
||||
<span class="hover:text-accent-text cursor-pointer transition" @click.stop="a.id && router.push({ name: 'artist', params: { id: a.id } })">{{ a.name }}</span>
|
||||
</template>
|
||||
<template v-if="song.al?.name">
|
||||
<span class="text-content-3 mx-1">·</span>
|
||||
<span class="hover:text-accent-text cursor-pointer transition" @click.stop="song.al.id && router.push({ name: 'album', params: { id: song.al.id } })">{{ song.al.name }}</span>
|
||||
</template>
|
||||
</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>
|
||||
<button @click.stop="download.downloadSong(song)" class="text-content-3 hover:text-accent-text transition flex-shrink-0" :title="download.isDownloaded(song.id) ? '已下载' : '下载'">
|
||||
<svg v-if="download.isDownloading(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="animate-spin"><path d="M21 12a9 9 0 11-6.22-8.56"/></svg>
|
||||
<svg v-else-if="download.isDownloaded(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-accent-text"><polyline points="20 6 9 17 4 12"/></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="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
</button>
|
||||
<SongItemMenu :song-id="song.id" />
|
||||
<span class="text-xs text-content-3">{{ formatDuration(song.dt) }}</span>
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ref, onMounted, onActivated, watch } from 'vue';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import SongItemMenu from '../components/SongItemMenu.vue';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { useUserStore } from '../stores/user';
|
||||
import { useDownload } from '../composables/useDownload';
|
||||
import { normalizeSong } from '../utils/song';
|
||||
import { formatDuration } from '../utils/format';
|
||||
import { normalizeSong, type Song } from '../utils/song';
|
||||
import { pageCacheGet, pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
|
||||
import { useOnlineStatus } from '../composables/useOnlineStatus';
|
||||
|
||||
defineOptions({ name: 'FavoriteSongsView' });
|
||||
|
||||
const player = usePlayerStore();
|
||||
const userStore = useUserStore();
|
||||
const download = useDownload();
|
||||
const router = useRouter();
|
||||
const songs = ref<any[]>([]);
|
||||
const { isOnline } = useOnlineStatus();
|
||||
const songs = ref<Song[]>([]);
|
||||
const loading = ref(true);
|
||||
|
||||
onMounted(async () => {
|
||||
async function loadData() {
|
||||
if (!userStore.isLoggedIn) {
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
const cached = pageCacheGet('favoriteSongs');
|
||||
if (cached) {
|
||||
songs.value = cached;
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const playlistJson: string = await invoke('user_playlist', { uid: userStore.user!.userId });
|
||||
const playlistData = JSON.parse(playlistJson);
|
||||
@ -91,10 +77,24 @@ onMounted(async () => {
|
||||
const trackJson: string = await invoke('playlist_track_all', { query: { id: likePlaylistId } });
|
||||
const trackData = JSON.parse(trackJson);
|
||||
songs.value = (trackData.songs || []).map(normalizeSong);
|
||||
pageCacheSet('favoriteSongs', songs.value);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData);
|
||||
|
||||
onActivated(() => {
|
||||
if (pageCacheIsStale('favoriteSongs')) loadData();
|
||||
});
|
||||
|
||||
watch(isOnline, (val, old) => {
|
||||
if (val && !old && userStore.isLoggedIn && songs.value.length === 0) {
|
||||
pageCacheInvalidate('favoriteSongs');
|
||||
loadData();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
<p class="text-xs text-white/60 mb-1">📅 {{ todayStr }}</p>
|
||||
<h2 class="text-2xl font-bold">每日推荐</h2>
|
||||
</div>
|
||||
<p class="text-xs text-white/60">根据你的口味生成,每天 6:00 更新</p>
|
||||
<p class="text-xs text-white/60">根据你的口味生成,每天凌晨更新</p>
|
||||
</div>
|
||||
<div class="absolute right-4 top-1/2 -translate-y-1/2 text-6xl opacity-20">🎧</div>
|
||||
</div>
|
||||
@ -80,9 +80,9 @@
|
||||
<!-- 第二行:为你推荐(需登录) -->
|
||||
<div v-if="userStore.isLoggedIn && recPlaylists.length" class="mb-10">
|
||||
<h2 class="text-xl font-semibold mb-4">🎯 为你推荐</h2>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
|
||||
<div v-for="pl in recPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
|
||||
class="bg-subtle rounded-xl overflow-hidden hover:bg-muted transition cursor-pointer">
|
||||
class="bg-subtle rounded-xl overflow-hidden hover:bg-muted transition cursor-pointer max-w-[220px] justify-self-center w-full">
|
||||
<img :src="pl.picUrl" class="w-full aspect-square object-cover" />
|
||||
<div class="p-3">
|
||||
<p class="text-sm font-medium truncate">{{ pl.name }}</p>
|
||||
@ -95,9 +95,9 @@
|
||||
<!-- 第三行:热门歌单(排行榜) -->
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold mb-4">📈 热门歌单</h2>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
|
||||
<div v-for="pl in rankPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
|
||||
class="bg-subtle rounded-xl overflow-hidden hover:bg-muted transition cursor-pointer backdrop-blur-sm">
|
||||
class="bg-subtle rounded-xl overflow-hidden hover:bg-muted transition cursor-pointer backdrop-blur-sm max-w-[220px] justify-self-center w-full">
|
||||
<img :src="pl.coverImgUrl" class="w-full aspect-square object-cover" />
|
||||
<div class="p-3">
|
||||
<p class="text-sm font-medium truncate">{{ pl.name }}</p>
|
||||
@ -109,15 +109,21 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ref, onMounted, onActivated, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useUserStore } from '../stores/user';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { pageCacheGet, pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
|
||||
import { useOnlineStatus } from '../composables/useOnlineStatus';
|
||||
import { getCoverUrl } from '../utils/song';
|
||||
|
||||
defineOptions({ name: 'HomeView' });
|
||||
|
||||
const player = usePlayerStore();
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const { isOnline } = useOnlineStatus();
|
||||
|
||||
const rankPlaylists = ref<any[]>([]);
|
||||
const recPlaylists = ref<any[]>([]);
|
||||
@ -128,17 +134,15 @@ import { computed } from 'vue';
|
||||
|
||||
|
||||
const fmCoverUrl = computed(() => {
|
||||
return player.fmSong?.al?.picUrl || player.fmSong?.album?.picUrl || '';
|
||||
return getCoverUrl(player.fmSong) || '';
|
||||
});
|
||||
const fmDisplayName = computed(() => player.fmSong?.name || '私人漫游');
|
||||
const fmDisplayArtists = computed(() => {
|
||||
if (!player.fmSong) return '';
|
||||
return player.fmSong.ar?.map((a: any) => a.name).join(' / ') ||
|
||||
player.fmSong.artists?.map((a: any) => a.name).join(' / ') || '';
|
||||
return player.fmSong.ar?.map((a: { name: string }) => a.name).join(' / ') || '';
|
||||
});
|
||||
|
||||
|
||||
// 首次点击播放按钮:开始 FM 并播放
|
||||
async function startFmPlay() {
|
||||
if (!player.fmSong) {
|
||||
await player.loadFm();
|
||||
@ -159,11 +163,14 @@ function onFmCardClick() {
|
||||
player.openRoamDrawer();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const d = new Date();
|
||||
todayStr.value = `${d.getMonth() + 1}月${d.getDate()}日`;
|
||||
async function loadData() {
|
||||
const cached = pageCacheGet('home');
|
||||
if (cached) {
|
||||
rankPlaylists.value = cached.rankPlaylists || [];
|
||||
recPlaylists.value = cached.recPlaylists || [];
|
||||
return;
|
||||
}
|
||||
|
||||
// 排行榜
|
||||
const results = await Promise.allSettled(
|
||||
RANK_IDS.map(id => invoke('get_playlist_detail', { id }))
|
||||
);
|
||||
@ -175,7 +182,6 @@ onMounted(async () => {
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
// 推荐歌单(需登录)
|
||||
if (userStore.isLoggedIn) {
|
||||
try {
|
||||
const json = await invoke('recommend_resource');
|
||||
@ -183,6 +189,25 @@ onMounted(async () => {
|
||||
recPlaylists.value = data.recommend || [];
|
||||
} catch { }
|
||||
}
|
||||
|
||||
pageCacheSet('home', { rankPlaylists: rankPlaylists.value, recPlaylists: recPlaylists.value });
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const d = new Date();
|
||||
todayStr.value = `${d.getMonth() + 1}月${d.getDate()}日`;
|
||||
await loadData();
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
if (pageCacheIsStale('home')) loadData();
|
||||
});
|
||||
|
||||
watch(isOnline, (val, old) => {
|
||||
if (val && !old && rankPlaylists.value.length === 0 && recPlaylists.value.length === 0) {
|
||||
pageCacheInvalidate('home');
|
||||
loadData();
|
||||
}
|
||||
});
|
||||
|
||||
function goDaily() {
|
||||
|
||||
@ -7,7 +7,6 @@
|
||||
<h1 class="text-2xl font-bold">本地音乐</h1>
|
||||
<span v-if="songs.length" class="text-xs text-content-3">{{ songs.length }} 首</span>
|
||||
<button
|
||||
v-if="songs.length"
|
||||
@click="refresh"
|
||||
class="px-3 py-1 bg-muted hover:bg-emphasis rounded-full text-xs transition"
|
||||
>
|
||||
@ -19,46 +18,38 @@
|
||||
当前文件夹下没有音乐文件,支持 mp3、flac、wav、ogg、aac、m4a 格式
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="(song, index) in songs"
|
||||
<SongListItem
|
||||
v-for="(song, index) in normalizedSongs"
|
||||
:key="song.id + '-' + index"
|
||||
@click="playLocalSong(song, index)"
|
||||
class="flex items-center gap-4 p-3 rounded-xl hover:bg-subtle transition cursor-pointer"
|
||||
:class="{ 'bg-subtle': player.currentSong?.id === song.id }"
|
||||
:song="song"
|
||||
:index="index"
|
||||
:is-current="player.currentSong?.id === song.id"
|
||||
show-index
|
||||
show-duration
|
||||
:container-class="player.currentSong?.id === song.id ? 'bg-subtle hover:bg-subtle' : 'hover:bg-subtle'"
|
||||
@click="player.playFromList(normalizedSongs, index)"
|
||||
>
|
||||
<span class="text-xs text-content-3 w-6 text-right flex-shrink-0">{{ index + 1 }}</span>
|
||||
<div class="w-10 h-10 rounded-lg overflow-hidden flex-shrink-0 bg-muted">
|
||||
<img v-if="song.cover" :src="song.cover" class="w-full h-full object-cover" />
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<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-content-3"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
<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.artist }}<template v-if="song.album"> · {{ song.album }}</template>
|
||||
</p>
|
||||
</div>
|
||||
<span class="text-xs text-content-3 flex-shrink-0">{{ formatDuration(song.duration) }}</span>
|
||||
<span class="text-xs text-content-3 flex-shrink-0">{{ formatFileSize(song.fileSize) }}</span>
|
||||
<template #actions>
|
||||
<span class="text-xs text-content-3 flex-shrink-0">{{ formatFileSize(songs[index].fileSize) }}</span>
|
||||
<div class="relative flex-shrink-0">
|
||||
<button
|
||||
@click.stop="toggleMenu(song.id)"
|
||||
@click.stop="toggleMenu(songs[index].id)"
|
||||
class="text-content-3 hover:text-content transition p-1 rounded hover:bg-muted"
|
||||
title="更多"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/></svg>
|
||||
</button>
|
||||
<Transition name="fade">
|
||||
<div v-if="openMenuId === song.id" class="absolute right-0 top-full mt-1 w-44 bg-surface border border-line rounded-xl shadow-2xl overflow-hidden z-50" @click.stop>
|
||||
<button @click="confirmDelete(song)" class="w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-danger/80 hover:bg-danger/10 transition">
|
||||
<div v-if="openMenuId === songs[index].id" class="absolute right-0 top-full mt-1 w-44 bg-surface border border-line rounded-xl shadow-2xl overflow-hidden z-50" @click.stop>
|
||||
<button @click="confirmDelete(songs[index])" class="w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-danger/80 hover:bg-danger/10 transition">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
|
||||
从磁盘中删除
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SongListItem>
|
||||
</div>
|
||||
|
||||
<Transition name="fade">
|
||||
@ -83,12 +74,17 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { ref, computed, onMounted, onActivated, onBeforeUnmount, watch } from 'vue';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { usePlayerStore, type Song } from '../stores/player';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { useDownload } from '../composables/useDownload';
|
||||
import { useSettingsStore } from '../stores/settings';
|
||||
import { showToast } from '../composables/useToast';
|
||||
import { pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
import type { Song } from '../utils/song';
|
||||
|
||||
defineOptions({ name: 'LocalMusicView' });
|
||||
|
||||
const player = usePlayerStore();
|
||||
const download = useDownload();
|
||||
@ -113,6 +109,8 @@ const showDeleteConfirm = ref(false);
|
||||
const deleteTarget = ref<LocalSong | null>(null);
|
||||
const openMenuId = ref<number | null>(null);
|
||||
|
||||
const normalizedSongs = computed(() => songs.value.map(toSong));
|
||||
|
||||
function toggleMenu(id: number) {
|
||||
openMenuId.value = openMenuId.value === id ? null : id;
|
||||
}
|
||||
@ -126,9 +124,11 @@ onBeforeUnmount(() => { document.removeEventListener('click', closeMenu); });
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true;
|
||||
pageCacheInvalidate('localMusic');
|
||||
try {
|
||||
const list = await invoke<LocalSong[]>('list_local_songs', { downloadPath: settings.downloadPath || null });
|
||||
songs.value = list;
|
||||
pageCacheSet('localMusic', list);
|
||||
fetchMissingCovers();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@ -158,6 +158,12 @@ async function fetchMissingCovers() {
|
||||
|
||||
onMounted(refresh);
|
||||
|
||||
onActivated(() => {
|
||||
if (pageCacheIsStale('localMusic')) refresh();
|
||||
});
|
||||
|
||||
watch(() => settings.downloadPath, () => { refresh(); });
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
@ -165,14 +171,6 @@ function formatFileSize(bytes: number): string {
|
||||
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i];
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (!ms || ms === 0) return '--:--';
|
||||
const totalSec = Math.floor(ms / 1000);
|
||||
const min = Math.floor(totalSec / 60);
|
||||
const sec = totalSec % 60;
|
||||
return `${min}:${sec.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function toSong(local: LocalSong): Song {
|
||||
return {
|
||||
id: local.id,
|
||||
@ -180,18 +178,10 @@ function toSong(local: LocalSong): Song {
|
||||
ar: local.artist ? [{ name: local.artist }] : [],
|
||||
al: { picUrl: local.cover || '', name: local.album || undefined },
|
||||
dt: local.duration || undefined,
|
||||
artists: local.artist ? [{ name: local.artist }] : [],
|
||||
album: { picUrl: local.cover || undefined, name: local.album || undefined },
|
||||
duration: local.duration || undefined,
|
||||
localPath: local.path,
|
||||
};
|
||||
}
|
||||
|
||||
async function playLocalSong(_song: LocalSong, index: number) {
|
||||
const normalized = songs.value.map(s => toSong(s));
|
||||
player.playFromList(normalized, index);
|
||||
}
|
||||
|
||||
function confirmDelete(song: LocalSong) {
|
||||
openMenuId.value = null;
|
||||
deleteTarget.value = song;
|
||||
|
||||
@ -88,7 +88,7 @@ function startPolling() {
|
||||
statusColor.value = 'text-content-2';
|
||||
} else if (code === 802) {
|
||||
statusText.value = '请在手机上确认登录';
|
||||
statusColor.value = 'text-warning';
|
||||
statusColor.value = 'text-yellow-400';
|
||||
} else if (code === 803) {
|
||||
clearInterval(pollTimer!);
|
||||
statusText.value = '登录成功!';
|
||||
|
||||
@ -45,15 +45,24 @@
|
||||
<div v-if="loading" class="text-content-2">加载中...</div>
|
||||
|
||||
<div v-else class="space-y-1">
|
||||
<div
|
||||
<SongListItem
|
||||
v-for="(song, index) in songs"
|
||||
:key="song.id"
|
||||
@click="playSingle(song)"
|
||||
class="flex items-center gap-4 p-3 rounded-xl hover:bg-subtle transition cursor-pointer group"
|
||||
:class="{ 'bg-accent-dim': isCurrentSong(song.id) }"
|
||||
:song="song"
|
||||
:index="index"
|
||||
:is-current="player.currentSong?.id === song.id"
|
||||
show-index
|
||||
show-like
|
||||
show-download
|
||||
show-menu
|
||||
show-duration
|
||||
show-playing-overlay
|
||||
:container-class="player.currentSong?.id === song.id ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
|
||||
@click="player.playFromList(songs, index)"
|
||||
>
|
||||
<template #index="{ index: idx, isCurrent }">
|
||||
<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 v-if="isCurrent" 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>
|
||||
@ -61,36 +70,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<span class="text-xs text-content-3 group-hover:hidden">{{ index + 1 }}</span>
|
||||
<span class="text-xs text-content-3 group-hover:hidden">{{ idx + 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">
|
||||
<p class="text-sm font-medium truncate" :class="isCurrentSong(song.id) ? 'text-accent-text' : ''">{{ song.name }}</p>
|
||||
<p class="text-xs text-content-2 truncate">
|
||||
<template v-for="(a, i) in song.ar || []" :key="a.id || i">
|
||||
<span v-if="i > 0" class="text-content-3">/</span>
|
||||
<span class="hover:text-accent-text cursor-pointer transition" @click.stop="a.id && router.push({ name: 'artist', params: { id: a.id } })">{{ a.name }}</span>
|
||||
</template>
|
||||
<template v-if="song.al?.name">
|
||||
<span class="text-content-3 mx-1">·</span>
|
||||
<span class="hover:text-accent-text cursor-pointer transition" @click.stop="song.al.id && router.push({ name: 'album', params: { id: song.al.id } })">{{ song.al.name }}</span>
|
||||
</template>
|
||||
</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>
|
||||
<button @click.stop="download.downloadSong(song)" class="text-content-3 hover:text-accent-text transition flex-shrink-0" :title="download.isDownloaded(song.id) ? '已下载' : '下载'">
|
||||
<svg v-if="download.isDownloading(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="animate-spin"><path d="M21 12a9 9 0 11-6.22-8.56"/></svg>
|
||||
<svg v-else-if="download.isDownloaded(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-accent-text"><polyline points="20 6 9 17 4 12"/></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="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
</button>
|
||||
<SongItemMenu :song-id="song.id" />
|
||||
<span class="text-xs text-content-3">{{ formatDuration(song.dt) }}</span>
|
||||
</div>
|
||||
</SongListItem>
|
||||
</div>
|
||||
|
||||
<div v-if="playlist" class="mt-8">
|
||||
@ -101,24 +86,22 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { useUserStore } from '../stores/user';
|
||||
import { useDownload } from '../composables/useDownload';
|
||||
import { showToast } from '../composables/useToast';
|
||||
import { formatDuration, formatPlayCount } from '../utils/format';
|
||||
import SongItemMenu from '../components/SongItemMenu.vue';
|
||||
import { formatPlayCount } from '../utils/format';
|
||||
import { normalizeSong, type Song } from '../utils/song';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
import CommentSection from '../components/CommentSection.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const player = usePlayerStore();
|
||||
const userStore = useUserStore();
|
||||
const download = useDownload();
|
||||
|
||||
const playlist = ref<any>(null);
|
||||
const songs = ref<any[]>([]);
|
||||
const songs = ref<Song[]>([]);
|
||||
const loading = ref(true);
|
||||
const subscribed = ref(false);
|
||||
|
||||
@ -135,7 +118,7 @@ async function fetchPlaylist(id: number) {
|
||||
const jsonStr: string = await invoke('get_playlist_detail', { id });
|
||||
const data = JSON.parse(jsonStr);
|
||||
playlist.value = data.playlist;
|
||||
songs.value = data.playlist.tracks || [];
|
||||
songs.value = (data.playlist.tracks || []).map(normalizeSong);
|
||||
subscribed.value = data.playlist.subscribed || false;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@ -153,15 +136,6 @@ watch(() => route.params.id, (newId) => {
|
||||
if (newId) fetchPlaylist(Number(newId));
|
||||
});
|
||||
|
||||
function isCurrentSong(songId: number): boolean {
|
||||
return player.currentSong?.id === songId;
|
||||
}
|
||||
|
||||
async function playSingle(song: any) {
|
||||
const idx = songs.value.findIndex((s: any) => s.id === song.id);
|
||||
player.playFromList(songs.value, idx >= 0 ? idx : 0);
|
||||
}
|
||||
|
||||
function playAll() {
|
||||
if (songs.value.length === 0) return;
|
||||
player.playAll(songs.value);
|
||||
|
||||
@ -6,51 +6,44 @@
|
||||
<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
|
||||
<SongListItem
|
||||
v-for="(song, index) in player.recentLocal"
|
||||
:key="song.id"
|
||||
:song="song"
|
||||
:index="index"
|
||||
:is-current="player.currentSong?.id === song.id"
|
||||
show-index
|
||||
show-like
|
||||
show-download
|
||||
show-menu
|
||||
show-duration
|
||||
show-playing-overlay
|
||||
:container-class="player.currentSong?.id === song.id ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
|
||||
@click="player.playFromList(player.recentLocal, index)"
|
||||
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">
|
||||
<template v-for="(a, i) in song.ar || []" :key="a.id || i">
|
||||
<span v-if="i > 0" class="text-content-3">/</span>
|
||||
<span class="hover:text-accent-text cursor-pointer transition" @click.stop="a.id && router.push({ name: 'artist', params: { id: a.id } })">{{ a.name }}</span>
|
||||
</template>
|
||||
<template v-if="song.al?.name">
|
||||
<span class="text-content-3 mx-1">·</span>
|
||||
<span class="hover:text-accent-text cursor-pointer transition" @click.stop="song.al.id && router.push({ name: 'album', params: { id: song.al.id } })">{{ song.al.name }}</span>
|
||||
</template>
|
||||
</p>
|
||||
<template #index="{ index: idx, isCurrent }">
|
||||
<div class="w-6 text-right flex-shrink-0 flex items-center justify-end h-5">
|
||||
<div v-if="isCurrent" 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>
|
||||
<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>
|
||||
<button @click.stop="download.downloadSong(song)" class="text-content-3 hover:text-accent-text transition flex-shrink-0" :title="download.isDownloaded(song.id) ? '已下载' : '下载'">
|
||||
<svg v-if="download.isDownloading(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="animate-spin"><path d="M21 12a9 9 0 11-6.22-8.56"/></svg>
|
||||
<svg v-else-if="download.isDownloaded(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-accent-text"><polyline points="20 6 9 17 4 12"/></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="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
</button>
|
||||
<SongItemMenu :song-id="song.id" />
|
||||
<span class="text-xs text-content-3">{{ formatDuration(song.dt ?? 0) }}</span>
|
||||
</div>
|
||||
<template v-else>
|
||||
<span class="text-xs text-content-3 group-hover:hidden">{{ idx + 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>
|
||||
</template>
|
||||
</SongListItem>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { useDownload } from '../composables/useDownload';
|
||||
import { formatDuration } from '../utils/format';
|
||||
import { useRouter } from 'vue-router';
|
||||
import SongItemMenu from '../components/SongItemMenu.vue';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
|
||||
const player = usePlayerStore();
|
||||
const download = useDownload();
|
||||
const router = useRouter();
|
||||
</script>
|
||||
|
||||
@ -64,11 +64,13 @@
|
||||
import { ref, computed, watch, onMounted } from 'vue';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { normalizeSong } from '../utils/song';
|
||||
import { normalizeSong, getCoverUrl } from '../utils/song';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useOnlineStatus } from '../composables/useOnlineStatus';
|
||||
|
||||
const player = usePlayerStore();
|
||||
const router = useRouter();
|
||||
const { isOnline } = useOnlineStatus();
|
||||
const coverError = ref(false);
|
||||
|
||||
const currentSong = computed(() => {
|
||||
@ -80,7 +82,7 @@ const currentSong = computed(() => {
|
||||
|
||||
const coverUrl = computed(() => {
|
||||
if (!currentSong.value) return '';
|
||||
return currentSong.value.al?.picUrl || currentSong.value.album?.picUrl || '';
|
||||
return getCoverUrl(currentSong.value) || '';
|
||||
});
|
||||
|
||||
watch(coverUrl, () => { coverError.value = false; });
|
||||
@ -109,4 +111,10 @@ async function startFm() {
|
||||
async function nextSong() {
|
||||
await startFm();
|
||||
}
|
||||
|
||||
watch(isOnline, (val, old) => {
|
||||
if (val && !old && !currentSong.value) {
|
||||
startFm();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -1,106 +0,0 @@
|
||||
<template>
|
||||
<div class="text-content">
|
||||
<h1 class="text-2xl font-bold mb-4">搜索</h1>
|
||||
|
||||
<input
|
||||
v-model="keyword"
|
||||
@keyup.enter="handleSearch"
|
||||
placeholder="搜索歌曲..."
|
||||
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-content-2">搜索中...</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="(song, index) in results"
|
||||
:key="song.id"
|
||||
@click="playSong(song, index)"
|
||||
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" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium truncate">{{ song.name }}</p>
|
||||
<p class="text-sm text-content-2 truncate">
|
||||
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
|
||||
</p>
|
||||
</div>
|
||||
<button @click.stop="download.downloadSong(song)" class="text-content-3 hover:text-accent-text transition flex-shrink-0" :title="download.isDownloaded(song.id) ? '已下载' : '下载'">
|
||||
<svg v-if="download.isDownloading(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="animate-spin"><path d="M21 12a9 9 0 11-6.22-8.56"/></svg>
|
||||
<svg v-else-if="download.isDownloaded(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-accent-text"><polyline points="20 6 9 17 4 12"/></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="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="!loading && hasSearched && results.length === 0" class="text-content-2">无结果</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'SearchView' });
|
||||
|
||||
import { useRoute } from 'vue-router';
|
||||
import { watch } from 'vue';
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { useDownload } from '../composables/useDownload';
|
||||
import { useRouter } from 'vue-router';
|
||||
const router = useRouter();
|
||||
|
||||
const keyword = ref('');
|
||||
const results = ref<any[]>([]);
|
||||
const loading = ref(false);
|
||||
const hasSearched = ref(false);
|
||||
const player = usePlayerStore();
|
||||
const download = useDownload();
|
||||
|
||||
const route = useRoute();
|
||||
watch(
|
||||
() => route.query.q,
|
||||
(newQ) => {
|
||||
if (newQ) {
|
||||
keyword.value = newQ as string;
|
||||
handleSearch();
|
||||
router.replace({ query: {} });
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
async function handleSearch() {
|
||||
if (!keyword.value.trim()) return;
|
||||
loading.value = true;
|
||||
hasSearched.value = true;
|
||||
try {
|
||||
const jsonStr: string = await invoke('search_songs', { query: { keyword: keyword.value } });
|
||||
const data = JSON.parse(jsonStr);
|
||||
results.value = data.result?.songs || [];
|
||||
} catch (e) {
|
||||
console.error('搜索出错:', e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function playSong(_song: any, index: number) {
|
||||
try {
|
||||
const normalized = results.value.map((s: any) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
ar: s.ar || s.artists || [],
|
||||
al: s.al || s.album || { picUrl: '' },
|
||||
dt: s.dt || 0,
|
||||
}));
|
||||
await player.playFromList(normalized, index);
|
||||
} catch (e) {
|
||||
alert('暂无播放源或需登录');
|
||||
}
|
||||
}
|
||||
|
||||
const devices = ref<string[]>([]);
|
||||
|
||||
onMounted(async () => {
|
||||
devices.value = await invoke('get_output_devices');
|
||||
});
|
||||
</script>
|
||||
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="p-8 text-content max-w-2xl">
|
||||
<div class="p-8 text-content">
|
||||
<button @click="$router.back()" class="mb-4 text-content-2 hover:text-content transition">
|
||||
← 返回
|
||||
</button>
|
||||
@ -8,6 +8,14 @@
|
||||
<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="selectedDevice" :options="deviceOptions" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium">音质选择</p>
|
||||
@ -20,25 +28,21 @@
|
||||
|
||||
<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">
|
||||
<p class="text-sm font-medium mb-3">主题色</p>
|
||||
<div class="grid grid-cols-4 gap-3">
|
||||
<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'"
|
||||
v-for="(color, key) in themeColors"
|
||||
:key="key"
|
||||
@click="settings.setTheme(key)"
|
||||
class="flex flex-col items-center gap-2 p-3 rounded-xl transition-all border-2"
|
||||
:class="settings.theme === key ? 'border-white/30 bg-white/5 scale-[1.02]' : 'border-transparent bg-subtle hover:bg-muted'"
|
||||
>
|
||||
{{ t.label }}
|
||||
<div class="w-8 h-8 rounded-full shadow-md" :style="{ backgroundColor: color }"></div>
|
||||
<span class="text-xs" :class="settings.theme === key ? 'text-content font-medium' : 'text-content-3'">{{ themeLabels[key] }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-8">
|
||||
@ -101,18 +105,18 @@
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button
|
||||
v-if="sc.key !== defaultShortcuts[id]?.key"
|
||||
@click="settings.setShortcut(id, defaultShortcuts[id].key)"
|
||||
@click="settings.setShortcut(String(id), defaultShortcuts[id].key)"
|
||||
class="w-6 h-6 flex items-center justify-center rounded-md text-content-4 hover:text-danger hover:bg-danger/10 transition"
|
||||
title="恢复默认"
|
||||
>
|
||||
<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
|
||||
@click="startRecording(id)"
|
||||
@click="startRecording(String(id))"
|
||||
class="px-3 py-1.5 rounded-lg text-sm transition min-w-[120px] text-center"
|
||||
:class="recordingId === id ? 'bg-accent text-white' : 'bg-muted hover:bg-emphasis text-content-2'"
|
||||
:class="recordingId === String(id) ? 'bg-accent text-white' : 'bg-muted hover:bg-emphasis text-content-2'"
|
||||
>
|
||||
{{ recordingId === id ? '按下新快捷键...' : formatShortcut(sc.key) }}
|
||||
{{ recordingId === String(id) ? '按下新快捷键...' : formatShortcut(sc.key) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -157,6 +161,7 @@
|
||||
<p class="text-xs text-content-3 leading-relaxed">
|
||||
Nekosonic 是一款高颜值的跨平台第三方网易云音乐桌面客户端,基于 Tauri 2 + Vue 3 构建,提供轻量流畅的音乐播放体验。
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
@click="handleCheckUpdate"
|
||||
:disabled="updater.checking.value"
|
||||
@ -166,6 +171,15 @@
|
||||
<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>
|
||||
{{ updater.checking.value ? '检查中...' : '检查更新' }}
|
||||
</button>
|
||||
<button
|
||||
@click="fetchChangelog"
|
||||
:disabled="fetchingChangelog"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition"
|
||||
>
|
||||
<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="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
|
||||
{{ fetchingChangelog ? '获取中...' : '更新日志' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="updater.error.value" class="text-xs text-content-3">{{ updater.error.value }}</p>
|
||||
</div>
|
||||
</section>
|
||||
@ -188,12 +202,42 @@
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<Transition name="fade">
|
||||
<div v-if="showChangelogModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showChangelogModal = false">
|
||||
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-[480px] max-h-[80vh] flex flex-col select-auto">
|
||||
<div class="p-6 pb-4">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<h2 class="text-lg font-semibold text-content">更新日志</h2>
|
||||
<span v-if="changelogRelease" class="text-xs font-medium px-2 py-0.5 rounded-full bg-accent/15 text-accent-text">v{{ changelogRelease.tag_name?.replace('v', '') }}</span>
|
||||
</div>
|
||||
<p v-if="changelogRelease?.published_at" class="text-xs text-content-3 mt-1">{{ formatDate(changelogRelease.published_at) }}</p>
|
||||
</div>
|
||||
<div v-if="changelogRelease?.body" class="px-6 pb-4 flex-1 overflow-y-auto max-h-60">
|
||||
<div class="text-sm text-content-2 leading-relaxed whitespace-pre-wrap">{{ changelogRelease.body }}</div>
|
||||
</div>
|
||||
<div v-else class="px-6 pb-4">
|
||||
<p class="text-sm text-content-3">暂无更新日志</p>
|
||||
</div>
|
||||
<div class="p-4 border-t border-line flex gap-3">
|
||||
<button @click="showChangelogModal = false"
|
||||
class="flex-1 py-2 rounded-lg bg-muted hover:bg-emphasis text-sm text-content-2 transition">
|
||||
关闭
|
||||
</button>
|
||||
<button v-if="changelogRelease?.html_url" @click="openUrl(changelogRelease.html_url)"
|
||||
class="flex-1 py-2 rounded-lg bg-accent/20 hover:bg-accent/30 text-accent-text text-sm font-medium transition">
|
||||
在 GitHub 中查看
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useSettingsStore, qualityLabels, closeActionLabels, defaultShortcuts, type CloseAction } from '../stores/settings';
|
||||
import { useSettingsStore, qualityLabels, closeActionLabels, defaultShortcuts, themeLabels, themeColors, type CloseAction } from '../stores/settings';
|
||||
import { useToast } from '../composables/useToast';
|
||||
import { useUpdater } from '../composables/useUpdater';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
@ -206,13 +250,45 @@ const settings = useSettingsStore();
|
||||
const { showToast } = useToast();
|
||||
const updater = useUpdater();
|
||||
|
||||
const devices = ref<string[]>([]);
|
||||
const deviceOptions = computed(() => {
|
||||
const options: Record<string, string> = { '': '跟随系统默认' };
|
||||
for (const name of devices.value) {
|
||||
options[name] = name;
|
||||
}
|
||||
return options;
|
||||
});
|
||||
|
||||
const selectedDevice = computed({
|
||||
get: () => settings.outputDevice || '',
|
||||
set: (val: string) => {
|
||||
const device = val === '' ? null : val;
|
||||
settings.setOutputDevice(device);
|
||||
invoke('set_output_device', { device }).then(() => {
|
||||
showToast(device ? `已切换到: ${device}` : '已切换到系统默认', 'success');
|
||||
}).catch((e) => {
|
||||
console.error('切换设备失败: ', e);
|
||||
showToast('切换设备失败', 'error');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function loadDevices() {
|
||||
try {
|
||||
devices.value = await invoke<string[]>('get_output_devices');
|
||||
} catch (e) {
|
||||
console.error('获取设备失败: ', e);
|
||||
}
|
||||
}
|
||||
|
||||
const appVersion = ref('');
|
||||
const defaultDownloadPath = ref('');
|
||||
onMounted(async () => {
|
||||
appVersion.value = await getVersion();
|
||||
try {
|
||||
defaultDownloadPath.value = await invoke<string>('get_default_download_path');
|
||||
} catch {}
|
||||
} catch { }
|
||||
loadDevices();
|
||||
});
|
||||
|
||||
const closeActionValue = computed({
|
||||
@ -237,11 +313,6 @@ function clearDownloadPath() {
|
||||
showToast('已重置为默认路径', 'success');
|
||||
}
|
||||
|
||||
const themeOptions = [
|
||||
{ label: '深色', value: 'dark' as const },
|
||||
{ label: '浅色', value: 'light' as const },
|
||||
];
|
||||
|
||||
async function handleCheckUpdate() {
|
||||
const result = await updater.checkForUpdate(false);
|
||||
if (!result) {
|
||||
@ -249,6 +320,38 @@ async function handleCheckUpdate() {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchingChangelog = ref(false);
|
||||
const changelogRelease = ref<any>(null);
|
||||
const showChangelogModal = ref(false);
|
||||
|
||||
async function fetchChangelog() {
|
||||
fetchingChangelog.value = true;
|
||||
try {
|
||||
const resp = await fetch('https://api.github.com/repos/atdunbg/Nekosonic-Music/releases?per_page=1');
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const releases = await resp.json();
|
||||
if (releases && releases.length > 0) {
|
||||
changelogRelease.value = releases[0];
|
||||
showChangelogModal.value = true;
|
||||
} else {
|
||||
showToast('暂无发布版本', 'info');
|
||||
}
|
||||
} catch (e: any) {
|
||||
showToast(`获取失败: ${e}`, 'error');
|
||||
} finally {
|
||||
fetchingChangelog.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
const recordingId = ref<string | null>(null);
|
||||
|
||||
function formatShortcut(key: string): string {
|
||||
@ -258,6 +361,7 @@ function formatShortcut(key: string): string {
|
||||
.replace('ArrowRight', '→')
|
||||
.replace('ArrowUp', '↑')
|
||||
.replace('ArrowDown', '↓')
|
||||
.replace(/Key([A-Z])/g, '$1')
|
||||
.replace(/\+/g, ' + ');
|
||||
}
|
||||
|
||||
@ -303,6 +407,12 @@ function onRecordingKeydown(e: KeyboardEvent) {
|
||||
}
|
||||
|
||||
if (parts.length > 0 && !ignoredKeys.includes(e.key)) {
|
||||
const hasModifier = parts.includes('Control') || parts.includes('Alt') || parts.includes('Shift');
|
||||
if (!hasModifier) {
|
||||
showToast('快捷键必须包含 Ctrl、Alt 或 Shift', 'error');
|
||||
recordingId.value = null;
|
||||
return;
|
||||
}
|
||||
const combo = parts.join('+');
|
||||
settings.setShortcut(recordingId.value, combo);
|
||||
recordingId.value = null;
|
||||
|
||||
Reference in New Issue
Block a user