feat: 架构重构与跨平台媒体控制集成

## 后端

- 替换 rodio 为 symphonia + ringbuf,重构 audio.rs 播放引擎
- 重构 api.rs,使用 api_call! 宏统一 API 调用模式
- 新增 media_controls.rs,使用 souvlaki 实现跨平台系统媒体控制
  (Linux MPRIS / Windows SMTC / macOS Now Playing)
- 版本号升至 v0.5.0

## 前端 - 新增

- 新增 SongListItem 通用组件
- 新增 useOnlineStatus composable,检测网络状态
- 新增 usePageCache composable,页面数据缓存与失效
- 新增 getCoverUrl()、formatDate() 工具函数
- 新增 emitPlaybackState() 同步播放状态到系统媒体控制
- 新增 mpris-command 事件监听,响应系统媒体控制命令
- 新增 Toast 离线/恢复在线提示
- 各页面新增断网恢复后自动重试加载
- 新增路由守卫:已登录用户访问 /login 重定向至首页
- 新增音量持久化(settings store + localStorage)
- 新增禁用右键菜单与用户选择限制(输入框除外)

## 前端 - 变更

- Song 接口从 player.ts 迁移至 song.ts 并导出
- AlbumDetail/ArtistDetail/PlaylistDetail/RecentPlays/LocalMusic 迁移至 SongListItem
- PlayerBar 队列列表迁移至 SongListItem,封面使用 getCoverUrl()
- downloadSong 参数类型从内联对象改为 Song,使用 getCoverUrl()
- 默认主题从 green 改为 blue,ThemeName 及相关列表中 blue 移至首位
- 全局快捷键从 Alt+Control 改为 Control+Alt 顺序
- formatShortcut 新增 KeyP → P 显示
- keep-alive 从 max=3 固定 include 改为 max=5 动态列表,窗口隐藏时释放
- App.vue 封面使用 getCoverUrl() 替代手动 al/album 回退
- formatPlayCount 提取常量
- Login.vue text-warning 改为 text-yellow-400

## 前端 - 删除

- 删除 Search.vue(与 Discover.vue 重复)
- 删除 SongItemMenu.vue(被 SongListItem 替代)

## 修复

- 更新器跳过版本逻辑:仅静默检查时跳过已忽略版本,手动检查不再跳过
- 重复播放同一首歌时无法恢复播放
- settings.ts 重复的 ThemeName 定义
- PlayerBar.vue modeTexts 缺少类型注解
- Home.vue map 回调参数缺少类型
- Settings.vue v-for key 类型不匹配
This commit is contained in:
2026-05-23 14:43:47 +08:00
parent 970fb15f5a
commit 65ed71503e
35 changed files with 2771 additions and 1328 deletions

View File

@ -1,3 +1,30 @@
## v0.5.0
### ✨ 新功能
- **蓝牙耳机/键盘媒体键控制**:支持通过蓝牙耳机按钮、键盘媒体键、系统通知栏/锁屏面板控制播放、暂停、切歌Windows / Linux / macOS
- **网络状态检测**:断网和恢复时弹出提示,网络恢复后自动重新加载页面内容
- **音量记忆**:关闭应用后音量设置不丢失,下次打开自动恢复
- **歌词翻译**:支持显示歌词翻译,可在漫游页面切换开关
- **登录页优化**:已登录用户访问登录页会自动跳转回首页
### 🎨 变更
- 默认主题色改为天蓝色
- 全局快捷键显示顺序调整为 Ctrl + Alt之前是 Alt + Ctrl
- 快捷键显示优化:按键名更简洁,如 KeyP 显示为 P
- 页面缓存优化:更多页面切换时保留状态,窗口隐藏时自动释放
- 登录页等待确认时的文字颜色修正
### 🐛 修复
- 手动检查更新时,之前跳过的版本现在会正常弹出更新提示
- 点击正在播放的歌曲无法恢复播放的问题
- 部分内部类型定义问题导致的潜在隐患
### ⚡ 底层优化
- 音频播放引擎全面重构,播放更稳定
- 后端 API 调用模式统一,代码更易维护
- 歌曲数据模型统一,各页面显示更一致
## v0.4.1 ## v0.4.1
添加音频输出外设选择 添加音频输出外设选择

View File

@ -14,6 +14,7 @@
- 📻 私人漫游 FM个性化推荐VIP 试听自动跳过) - 📻 私人漫游 FM个性化推荐VIP 试听自动跳过)
- 🎵 本地音乐播放(支持 mp3 / flac / wav / ogg / aac / m4a / wma / opus - 🎵 本地音乐播放(支持 mp3 / flac / wav / ogg / aac / m4a / wma / opus
- 🔊 音频输出设备选择 - 🔊 音频输出设备选择
- 🎧 系统媒体控制(蓝牙耳机/键盘媒体键/系统面板,支持 Linux / Windows / macOS
### 发现与浏览 ### 发现与浏览
@ -27,6 +28,7 @@
### 歌词与评论 ### 歌词与评论
- 🎤 实时滚动歌词(自动滚动 / 点击跳转 / 渐变透明度) - 🎤 实时滚动歌词(自动滚动 / 点击跳转 / 渐变透明度)
- 🎤 歌词翻译显示
- 🎤 全屏漫游模式(大封面 + 歌词 / 评论双标签页) - 🎤 全屏漫游模式(大封面 + 歌词 / 评论双标签页)
- 💬 歌曲评论查看(热门评论 + 无限滚动加载 + 点赞) - 💬 歌曲评论查看(热门评论 + 无限滚动加载 + 点赞)
@ -47,10 +49,11 @@
- 📡 系统托盘(播放控制 / 显示窗口 / 退出) - 📡 系统托盘(播放控制 / 显示窗口 / 退出)
- 🛡 单实例运行(防止重复启动) - 🛡 单实例运行(防止重复启动)
- ⌨️ 自定义快捷键(应用内 + 系统全局) - ⌨️ 自定义快捷键(应用内 + 系统全局)
- 🌚 Light / Dark Mode 主题切换 - 🎨 多主题切换(天蓝 / 翠绿 / 玫红 / 紫罗兰 / 橙色 / 青色 / 粉色)
- ⚙️ 关闭窗口行为设置(每次询问 / 最小化到托盘 / 直接退出) - ⚙️ 关闭窗口行为设置(每次询问 / 最小化到托盘 / 直接退出)
- 🔄 自动更新(启动静默检测 + 自定义弹窗 + 忽略版本 + 下载进度) - 🔄 自动更新(启动静默检测 + 自定义弹窗 + 忽略版本 + 下载进度)
- 📝 更新日志查看 - 📝 更新日志查看
- 📶 网络状态检测(断网/恢复 Toast 提示 + 自动重试加载)
## 📦️ 安装 ## 📦️ 安装
@ -84,7 +87,8 @@ npm run tauri build
| 样式 | Tailwind CSS v4 + CSS 变量主题系统 | | 样式 | Tailwind CSS v4 + CSS 变量主题系统 |
| 状态管理 | Pinia | | 状态管理 | Pinia |
| 路由 | Vue Router 4 | | 路由 | Vue Router 4 |
| 音频播放 | rodio (Rust) | | 音频解码 | symphonia + ringbuf (Rust) |
| 媒体控制 | souvlaki (Linux MPRIS / Windows SMTC / macOS Now Playing) |
| 网易云 API | ncm-api-rs | | 网易云 API | ncm-api-rs |
| 构建工具 | Vite 6 | | 构建工具 | Vite 6 |
@ -97,10 +101,11 @@ npm run tauri build
- [x] 专辑详情页 - [x] 专辑详情页
- [x] 自定义全局快捷键 - [x] 自定义全局快捷键
- [x] 自动更新 - [x] 自动更新
- [x] 歌词翻译
- [x] 更多主题
- [x] 系统媒体控制(蓝牙耳机/键盘媒体键)
- [ ] MV 播放 - [ ] MV 播放
- [ ] 音乐云盘 - [ ] 音乐云盘
- [ ] 歌词翻译
- [ ] 更多主题
- [ ] 桌面歌词 - [ ] 桌面歌词
欢迎提 Issue 和 Pull request。 欢迎提 Issue 和 Pull request。
@ -117,4 +122,5 @@ npm run tauri build
- [Tauri](https://tauri.app/) — 跨平台桌面应用框架 - [Tauri](https://tauri.app/) — 跨平台桌面应用框架
- [Vue.js](https://vuejs.org/) — 渐进式 JavaScript 框架 - [Vue.js](https://vuejs.org/) — 渐进式 JavaScript 框架
- [Tailwind CSS](https://tailwindcss.com/) — 实用优先的 CSS 框架 - [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 媒体控制库

839
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[package] [package]
name = "Nekosonic" name = "Nekosonic"
version = "0.4.1" version = "0.5.0"
description = "A Simple music app" description = "A Simple music app"
authors = ["atdunbg"] authors = ["atdunbg"]
edition = "2021" edition = "2021"
@ -23,7 +23,8 @@ tauri-plugin-opener = "2"
tauri-plugin-single-instance = "2" tauri-plugin-single-instance = "2"
tauri-plugin-global-shortcut = "2" tauri-plugin-global-shortcut = "2"
tauri-plugin-dialog = "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 = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
cpal = { version = "0.15" } cpal = { version = "0.15" }
@ -38,3 +39,13 @@ tokio = { version = "1", features = ["rt", "sync"] }
tauri-plugin-process = "2.3.1" tauri-plugin-process = "2.3.1"
tauri-plugin-updater = "2" 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"

View File

@ -2,7 +2,6 @@ use ncm_api_rs::{create_client, ApiClient, Query};
use serde::Deserialize; use serde::Deserialize;
use serde_json::json; use serde_json::json;
use tauri::{Manager, State, Emitter}; use tauri::{Manager, State, Emitter};
use tokio::sync::Mutex;
use std::sync::Mutex as StdMutex; use std::sync::Mutex as StdMutex;
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
@ -14,12 +13,66 @@ use lofty::file::{AudioFile, TaggedFileExt};
use lofty::tag::Accessor; use lofty::tag::Accessor;
use base64::Engine; 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 { pub struct ApiController {
client: Mutex<ApiClient>, client: StdMutex<ApiClient>,
cookie: StdMutex<Option<String>>, cookie: StdMutex<Option<String>>,
cookie_path: PathBuf, cookie_path: PathBuf,
} }
/// 将 Cookie 字符串列表转换为 `key=value; key=value` 格式
fn cookies_to_key_values(cookies: &[String]) -> String { fn cookies_to_key_values(cookies: &[String]) -> String {
cookies cookies
.iter() .iter()
@ -31,6 +84,7 @@ fn cookies_to_key_values(cookies: &[String]) -> String {
impl ApiController { impl ApiController {
/// 创建新的 API 控制器,从本地文件恢复已保存的 Cookie
pub fn new(app_data_dir: PathBuf) -> Self { pub fn new(app_data_dir: PathBuf) -> Self {
let _ = fs::create_dir_all(&app_data_dir); let _ = fs::create_dir_all(&app_data_dir);
let cookie_path = app_data_dir.join("netease_cookies.json"); let cookie_path = app_data_dir.join("netease_cookies.json");
@ -40,13 +94,14 @@ impl ApiController {
let client = create_client(None); let client = create_client(None);
ApiController { ApiController {
client: Mutex::new(client), client: StdMutex::new(client),
cookie: StdMutex::new(saved_cookie), cookie: StdMutex::new(saved_cookie),
cookie_path, cookie_path,
} }
} }
fn build_query(&self) -> Query { /// 构建带当前 Cookie 的 API 查询对象
fn build_query(&self) -> Query {
let mut query = Query::new(); let mut query = Query::new();
if let Ok(cookie_guard) = self.cookie.lock() { if let Ok(cookie_guard) = self.cookie.lock() {
if let Some(c) = cookie_guard.as_ref() { if let Some(c) = cookie_guard.as_ref() {
@ -55,66 +110,57 @@ fn build_query(&self) -> Query {
} }
query query
} }
/// 将 Cookie 字符串持久化到本地文件并同步到 API 客户端
fn save_cookie(&self, cookie_str: &str) { fn save_cookie(&self, cookie_str: &str) {
let _ = fs::write(&self.cookie_path, cookie_str); let _ = fs::write(&self.cookie_path, cookie_str);
if let Ok(mut client) = self.client.lock() {
client.set_cookie(cookie_str.to_string());
}
} }
} }
/// 搜索查询参数
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct SearchQuery { pub keyword: String } pub struct SearchQuery { pub keyword: String }
/// 手机号登录查询参数
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct LoginQuery { pub phone: String, pub password: String } pub struct LoginQuery { pub phone: String, pub password: String }
/// 二维码登录密钥查询参数
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct QrKeyQuery { pub key: String } pub struct QrKeyQuery { pub key: String }
/// 搜索歌曲 /// 搜索歌曲
#[tauri::command] #[tauri::command]
pub async fn search_songs(query: SearchQuery, state: State<'_, ApiController>) -> Result<String, String> { pub async fn search_songs(query: SearchQuery, state: State<'_, ApiController>) -> Result<String, String> {
let client = state.client.lock().await; api_call!(state, cloudsearch, params: [("keywords", &query.keyword), ("type", "1"), ("limit", "30")])
let q = state.build_query()
.param("keywords", &query.keyword)
.param("type", "1")
.param("limit", "30");
client.cloudsearch(&q).await
.map(|r| r.body.to_string())
.map_err(|e| e.to_string())
} }
/// 获取热搜词列表 /// 获取热搜词列表
#[tauri::command] #[tauri::command]
pub async fn get_hot_search(state: State<'_, ApiController>) -> Result<String, String> { pub async fn get_hot_search(state: State<'_, ApiController>) -> Result<String, String> {
let client = state.client.lock().await; api_call!(state, search_hot_detail)
let q = state.build_query();
client.search_hot_detail(&q).await
.map(|r| r.body.to_string())
.map_err(|e| e.to_string())
} }
/// 歌单全部曲目查询参数
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct PlaylistTrackAllQuery { pub id: u64, pub limit: Option<i64>, pub offset: Option<i64> } pub struct PlaylistTrackAllQuery { pub id: u64, pub limit: Option<i64>, pub offset: Option<i64> }
/// 获取歌单全部歌曲 /// 获取歌单全部歌曲
#[tauri::command] #[tauri::command]
pub async fn playlist_track_all(query: PlaylistTrackAllQuery, state: State<'_, ApiController>) -> Result<String, String> { pub async fn playlist_track_all(query: PlaylistTrackAllQuery, state: State<'_, ApiController>) -> Result<String, String> {
let client = state.client.lock().await; 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())])
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())
} }
/// 歌曲播放地址查询参数
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct SongUrlQuery { pub id: u64, pub level: Option<String>, pub fm_mode: Option<bool> } pub struct SongUrlQuery { pub id: u64, pub level: Option<String>, pub fm_mode: Option<bool> }
/// 获取歌曲播放地址(返回完整 data 对象,包含 url、freeTrialInfo 等) /// 获取歌曲播放地址(返回完整 data 对象,包含 url、freeTrialInfo 等)
#[tauri::command] #[tauri::command]
pub async fn get_song_url(query: SongUrlQuery, state: State<'_, ApiController>) -> Result<String, String> { 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 level = query.level.as_deref().unwrap_or("standard");
let resp = if query.fm_mode.unwrap_or(false) { 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] #[tauri::command]
pub async fn get_lyric(id: u64, state: State<'_, ApiController>) -> Result<String, String> { pub async fn get_lyric(id: u64, state: State<'_, ApiController>) -> Result<String, String> {
let client = state.client.lock().await; api_call!(state, lyric, params: [("id", &id.to_string())])
let q = state.build_query().param("id", &id.to_string());
client.lyric(&q).await
.map(|r| r.body.to_string())
.map_err(|e| e.to_string())
} }
/// 获取歌单详情 /// 获取歌单详情
#[tauri::command] #[tauri::command]
pub async fn get_playlist_detail(id: u64, state: State<'_, ApiController>) -> Result<String, String> { pub async fn get_playlist_detail(id: u64, state: State<'_, ApiController>) -> Result<String, String> {
let client = state.client.lock().await; api_call!(state, playlist_detail, params: [("id", &id.to_string())])
let q = state.build_query().param("id", &id.to_string());
client.playlist_detail(&q).await
.map(|r| r.body.to_string())
.map_err(|e| e.to_string())
} }
/// 手机号密码登录 /// 手机号密码登录
#[tauri::command] #[tauri::command]
pub async fn login(query: LoginQuery, state: State<'_, ApiController>) -> Result<String, String> { pub async fn login(query: LoginQuery, state: State<'_, ApiController>) -> Result<String, String> {
let client = state.client.lock().await; let client = state.client.lock().unwrap().clone();
let q = Query::new() let q = Query::new()
.param("phone", &query.phone) .param("phone", &query.phone)
.param("password", &query.password); .param("password", &query.password);
@ -208,7 +246,7 @@ pub async fn logout(state: State<'_, ApiController>) -> Result<(), String> {
/// 获取二维码登录密钥 /// 获取二维码登录密钥
#[tauri::command] #[tauri::command]
pub async fn get_qr_key(state: State<'_, ApiController>) -> Result<String, String> { pub async fn get_qr_key(state: State<'_, ApiController>) -> Result<String, String> {
let client = state.client.lock().await; let client = state.client.lock().unwrap().clone();
let q = state.build_query(); let q = state.build_query();
let resp = client.login_qr_key(&q).await.map_err(|e| e.to_string())?; let resp = client.login_qr_key(&q).await.map_err(|e| e.to_string())?;
resp.body["unikey"] resp.body["unikey"]
@ -223,7 +261,7 @@ pub async fn create_qr(
query: QrKeyQuery, query: QrKeyQuery,
state: State<'_, ApiController>, state: State<'_, ApiController>,
) -> Result<String, String> { ) -> Result<String, String> {
let client = state.client.lock().await; let client = state.client.lock().unwrap().clone();
let q = state let q = state
.build_query() .build_query()
.param("key", &query.key) .param("key", &query.key)
@ -239,7 +277,7 @@ pub async fn create_qr(
/// 检查二维码扫码状态 /// 检查二维码扫码状态
#[tauri::command] #[tauri::command]
pub async fn check_qr_status(query: QrKeyQuery, state: State<'_, ApiController>) -> Result<String, String> { pub async fn check_qr_status(query: QrKeyQuery, state: State<'_, ApiController>) -> Result<String, String> {
let client = state.client.lock().await; let client = state.client.lock().unwrap().clone();
let q = state.build_query().param("key", &query.key); let q = state.build_query().param("key", &query.key);
let resp = client.login_qr_check(&q).await.map_err(|e| e.to_string())?; 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() { if resp.body["code"].as_u64() == Some(803) && !resp.cookie.is_empty() {
@ -253,122 +291,80 @@ pub async fn check_qr_status(query: QrKeyQuery, state: State<'_, ApiController>)
/// 获取当前登录状态 /// 获取当前登录状态
#[tauri::command] #[tauri::command]
pub async fn get_login_status(state: State<'_, ApiController>) -> Result<String, String> { pub async fn get_login_status(state: State<'_, ApiController>) -> Result<String, String> {
let client = state.client.lock().await; api_call!(state, user_account)
let q = state.build_query();
client.user_account(&q).await
.map(|r| r.body.to_string())
.map_err(|e| e.to_string())
} }
/// 获取用户歌单列表 /// 获取用户歌单列表
#[tauri::command] #[tauri::command]
pub async fn user_playlist(uid: u64, state: State<'_, ApiController>) -> Result<String, String> { pub async fn user_playlist(uid: u64, state: State<'_, ApiController>) -> Result<String, String> {
let client = state.client.lock().await; api_call!(state, user_playlist, params: [("uid", &uid.to_string())])
let q = state.build_query().param("uid", &uid.to_string());
let resp = client.user_playlist(&q).await.map_err(|e| e.to_string())?;
Ok(resp.body.to_string())
} }
/// 获取每日推荐歌曲 /// 获取每日推荐歌曲
#[tauri::command] #[tauri::command]
pub async fn recommend_songs(state: State<'_, ApiController>) -> Result<String, String> { pub async fn recommend_songs(state: State<'_, ApiController>) -> Result<String, String> {
let client = state.client.lock().await; api_call!(state, recommend_songs)
let q = state.build_query();
let resp = client.recommend_songs(&q).await.map_err(|e| e.to_string())?;
Ok(resp.body.to_string())
} }
/// 获取推荐歌单 /// 获取推荐歌单
#[tauri::command] #[tauri::command]
pub async fn recommend_resource(state: State<'_, ApiController>) -> Result<String, String> { pub async fn recommend_resource(state: State<'_, ApiController>) -> Result<String, String> {
let client = state.client.lock().await; api_call!(state, recommend_resource)
let q = state.build_query();
let resp = client.recommend_resource(&q).await.map_err(|e| e.to_string())?;
Ok(resp.body.to_string())
} }
/// 获取私人漫游歌曲 /// 获取私人漫游歌曲
#[tauri::command] #[tauri::command]
pub async fn personal_fm(state: State<'_, ApiController>) -> Result<String, String> { pub async fn personal_fm(state: State<'_, ApiController>) -> Result<String, String> {
let client = state.client.lock().await; api_call!(state, personal_fm)
let q = state.build_query();
let resp = client.personal_fm(&q).await.map_err(|e| e.to_string())?;
Ok(resp.body.to_string())
} }
/// 获取歌曲详情 /// 获取歌曲详情
#[tauri::command] #[tauri::command]
pub async fn get_song_detail(id: String, state: State<'_, ApiController>) -> Result<String, String> { pub async fn get_song_detail(id: String, state: State<'_, ApiController>) -> Result<String, String> {
let client = state.client.lock().await; api_call!(state, song_detail, params: [("ids", &id)])
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())
} }
/// 用户播放记录查询参数
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct UserRecordQuery { pub uid: u64, pub r#type: String } pub struct UserRecordQuery { pub uid: u64, pub r#type: String }
/// 喜欢/取消喜欢歌曲查询参数
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct LikeSongQuery { pub id: u64, pub like: String } pub struct LikeSongQuery { pub id: u64, pub like: String }
/// 获取喜欢的歌曲ID列表 /// 获取喜欢的歌曲ID列表
#[tauri::command] #[tauri::command]
pub async fn likelist(uid: u64, state: State<'_, ApiController>) -> Result<String, String> { pub async fn likelist(uid: u64, state: State<'_, ApiController>) -> Result<String, String> {
let client = state.client.lock().await; api_call!(state, likelist, params: [("uid", &uid.to_string())])
let q = state.build_query().param("uid", &uid.to_string());
client.likelist(&q).await
.map(|r| r.body.to_string())
.map_err(|e| e.to_string())
} }
/// 获取用户播放记录 /// 获取用户播放记录
#[tauri::command] #[tauri::command]
pub async fn user_record(query: UserRecordQuery, state: State<'_, ApiController>) -> Result<String, String> { pub async fn user_record(query: UserRecordQuery, state: State<'_, ApiController>) -> Result<String, String> {
let client = state.client.lock().await; api_call!(state, user_record, params: [("uid", &query.uid.to_string()), ("type", &query.r#type)])
let q = state.build_query()
.param("uid", &query.uid.to_string())
.param("type", &query.r#type);
client.user_record(&q).await
.map(|r| r.body.to_string())
.map_err(|e| e.to_string())
} }
/// 喜欢/取消喜欢歌曲 /// 喜欢/取消喜欢歌曲
#[tauri::command] #[tauri::command]
pub async fn like_song(query: LikeSongQuery, state: State<'_, ApiController>) -> Result<String, String> { pub async fn like_song(query: LikeSongQuery, state: State<'_, ApiController>) -> Result<String, String> {
let client = state.client.lock().await; api_call!(state, like, params: [("id", &query.id.to_string()), ("like", &query.like)])
let q = state.build_query()
.param("id", &query.id.to_string())
.param("like", &query.like);
client.like(&q).await
.map(|r| r.body.to_string())
.map_err(|e| e.to_string())
} }
/// 上报最近播放歌曲 /// 上报最近播放歌曲
#[tauri::command] #[tauri::command]
pub async fn record_recent_song(limit: u64, state: State<'_, ApiController>) -> Result<String, String> { pub async fn record_recent_song(limit: u64, state: State<'_, ApiController>) -> Result<String, String> {
let client = state.client.lock().await; api_call!(state, record_recent_song, params: [("limit", &limit.to_string())])
let q = state.build_query().param("limit", &limit.to_string());
client.record_recent_song(&q).await
.map(|r| r.body.to_string())
.map_err(|e| e.to_string())
} }
/// 歌单收藏/取消收藏查询参数
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct PlaylistSubscribeQuery { pub id: u64, pub subscribe: Option<bool> } pub struct PlaylistSubscribeQuery { pub id: u64, pub subscribe: Option<bool> }
/// 收藏/取消收藏歌单 /// 收藏/取消收藏歌单
#[tauri::command] #[tauri::command]
pub async fn playlist_subscribe(query: PlaylistSubscribeQuery, state: State<'_, ApiController>) -> Result<String, String> { 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 t = if query.subscribe.unwrap_or(true) { "1" } else { "0" };
let q = state.build_query() api_call!(state, playlist_subscribe, params: [("id", &query.id.to_string()), ("t", t)])
.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())
} }
/// 退出应用 /// 退出应用
@ -380,6 +376,7 @@ pub async fn exit_app(app_handle: tauri::AppHandle) {
} }
} }
/// 本地歌曲信息结构体
#[derive(serde::Serialize)] #[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct LocalSongInfo { pub struct LocalSongInfo {
@ -395,6 +392,7 @@ pub struct LocalSongInfo {
pub local: bool, pub local: bool,
} }
/// 下载歌曲查询参数
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct DownloadSongQuery { pub struct DownloadSongQuery {
@ -408,6 +406,7 @@ pub struct DownloadSongQuery {
pub download_path: Option<String>, pub download_path: Option<String>,
} }
/// 下载歌曲到本地,支持进度回调,并保存元数据文件
#[tauri::command] #[tauri::command]
pub async fn download_song( pub async fn download_song(
app_handle: tauri::AppHandle, app_handle: tauri::AppHandle,
@ -419,22 +418,18 @@ pub async fn download_song(
let q = state.build_query() let q = state.build_query()
.param("id", &query.id.to_string()) .param("id", &query.id.to_string())
.param("level", level); .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 resp = client.song_url_v1(&q).await.map_err(|e| e.to_string())?;
let data = &resp.body["data"][0]; let data = &resp.body["data"][0];
let url = data["url"].as_str().filter(|s| !s.is_empty()); let url = data["url"].as_str().filter(|s| !s.is_empty());
let is_vip = data.get("freeTrialInfo").is_some_and(|v| !v.is_null());
if is_vip {
return Err("VIP歌曲无法下载".into());
}
if url.is_none() { if url.is_none() {
let free_trial = data.get("freeTrialInfo");
if free_trial.is_some() && !free_trial.unwrap().is_null() {
return Err("VIP歌曲无法下载".into());
}
return Err("暂无下载源,可能需要 VIP 权限".into()); return Err("暂无下载源,可能需要 VIP 权限".into());
} }
let url = url.unwrap(); 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" }; let ext = if url.contains(".flac") { "flac" } else { "mp3" };
drop(client); drop(client);
@ -505,6 +500,7 @@ pub async fn download_song(
Ok(filename) Ok(filename)
} }
/// 列出本地已下载的歌曲,优先使用元数据文件补充信息
#[tauri::command] #[tauri::command]
pub fn list_local_songs(app_handle: tauri::AppHandle, download_path: Option<String>) -> Result<Vec<LocalSongInfo>, String> { 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()); let download_dir = resolve_download_dir(&app_handle, download_path.as_deref());
@ -600,6 +596,7 @@ pub fn list_local_songs(app_handle: tauri::AppHandle, download_path: Option<Stri
Ok(songs) Ok(songs)
} }
/// 读取音频文件的元数据(标题、艺术家、专辑、时长、封面)
fn read_audio_metadata(path: &PathBuf) -> (String, String, String, u64, Option<String>) { fn read_audio_metadata(path: &PathBuf) -> (String, String, String, u64, Option<String>) {
match lofty::read_from_path(path) { match lofty::read_from_path(path) {
Ok(tagged_file) => { Ok(tagged_file) => {
@ -638,6 +635,7 @@ fn read_audio_metadata(path: &PathBuf) -> (String, String, String, u64, Option<S
} }
} }
/// 解析文件名,提取艺术家和歌曲名称(支持 "艺术家 - 歌名" 格式)
fn parse_filename(stem: &str) -> (String, String) { fn parse_filename(stem: &str) -> (String, String) {
if let Some(pos) = stem.find(" - ") { if let Some(pos) = stem.find(" - ") {
let artist = &stem[..pos]; let artist = &stem[..pos];
@ -652,6 +650,7 @@ fn parse_filename(stem: &str) -> (String, String) {
} }
} }
/// 删除本地歌曲查询参数
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct DeleteLocalSongQuery { pub struct DeleteLocalSongQuery {
@ -660,6 +659,7 @@ pub struct DeleteLocalSongQuery {
pub download_path: Option<String>, pub download_path: Option<String>,
} }
/// 删除本地已下载的歌曲文件及其元数据
#[tauri::command] #[tauri::command]
pub fn delete_local_song( pub fn delete_local_song(
app_handle: tauri::AppHandle, app_handle: tauri::AppHandle,
@ -678,6 +678,7 @@ pub fn delete_local_song(
Ok(()) Ok(())
} }
/// 检查指定歌曲是否已下载到本地
#[tauri::command] #[tauri::command]
pub fn check_local_song(app_handle: tauri::AppHandle, id: u64, download_path: Option<String>) -> Result<bool, String> { 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()); let download_dir = resolve_download_dir(&app_handle, download_path.as_deref());
@ -685,6 +686,7 @@ pub fn check_local_song(app_handle: tauri::AppHandle, id: u64, download_path: Op
Ok(meta_path.exists()) Ok(meta_path.exists())
} }
/// 解析下载目录,优先使用自定义路径,否则使用默认目录
fn resolve_download_dir(app_handle: &tauri::AppHandle, custom_path: Option<&str>) -> PathBuf { fn resolve_download_dir(app_handle: &tauri::AppHandle, custom_path: Option<&str>) -> PathBuf {
if let Some(path) = custom_path { if let Some(path) = custom_path {
if !path.is_empty() { if !path.is_empty() {
@ -694,6 +696,7 @@ fn resolve_download_dir(app_handle: &tauri::AppHandle, custom_path: Option<&str>
get_default_download_dir(app_handle) get_default_download_dir(app_handle)
} }
/// 获取默认下载目录,优先使用应用数据目录下的 downloads 子目录
fn get_default_download_dir(app_handle: &tauri::AppHandle) -> PathBuf { fn get_default_download_dir(app_handle: &tauri::AppHandle) -> PathBuf {
if let Ok(dir) = app_handle.path().app_data_dir() { if let Ok(dir) = app_handle.path().app_data_dir() {
let download_dir = dir.join("downloads"); let download_dir = dir.join("downloads");
@ -708,11 +711,13 @@ fn get_default_download_dir(app_handle: &tauri::AppHandle) -> PathBuf {
music_dir.join("Nekosonic") music_dir.join("Nekosonic")
} }
/// 获取默认下载路径字符串,供前端使用
#[tauri::command] #[tauri::command]
pub fn get_default_download_path(app_handle: tauri::AppHandle) -> String { pub fn get_default_download_path(app_handle: tauri::AppHandle) -> String {
get_default_download_dir(&app_handle).to_string_lossy().to_string() get_default_download_dir(&app_handle).to_string_lossy().to_string()
} }
/// 清理文件名中的非法字符,将 `/ \ : * ? " < > |` 替换为下划线
fn sanitize_filename(name: &str) -> String { fn sanitize_filename(name: &str) -> String {
name.chars() name.chars()
.map(|c| { .map(|c| {
@ -729,18 +734,15 @@ fn sanitize_filename(name: &str) -> String {
.to_string() .to_string()
} }
/// 获取歌手详情
#[tauri::command] #[tauri::command]
pub async fn artist_detail(id: u64, state: State<'_, ApiController>) -> Result<String, String> { pub async fn artist_detail(id: u64, state: State<'_, ApiController>) -> Result<String, String> {
let client = state.client.lock().await; api_call!(state, artist_detail, params: [("id", &id.to_string())])
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())
} }
/// 获取歌手歌曲列表
#[tauri::command] #[tauri::command]
pub async fn artist_songs(query: ArtistSongsQuery, state: State<'_, ApiController>) -> Result<String, String> { 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()); let mut q = state.build_query().param("id", &query.id.to_string());
if let Some(ref order) = query.order { if let Some(ref order) = query.order {
q = q.param("order", order); q = q.param("order", order);
@ -751,11 +753,10 @@ pub async fn artist_songs(query: ArtistSongsQuery, state: State<'_, ApiControlle
if let Some(offset) = query.offset { if let Some(offset) = query.offset {
q = q.param("offset", &offset.to_string()); q = q.param("offset", &offset.to_string());
} }
client.artist_songs(&q).await api_call!(state, artist_songs, query: q)
.map(|r| r.body.to_string())
.map_err(|e| e.to_string())
} }
/// 歌手歌曲查询参数
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ArtistSongsQuery { pub struct ArtistSongsQuery {
@ -765,9 +766,9 @@ pub struct ArtistSongsQuery {
pub offset: Option<u32>, pub offset: Option<u32>,
} }
/// 获取歌手专辑列表
#[tauri::command] #[tauri::command]
pub async fn artist_album(id: u64, limit: Option<u32>, offset: Option<u32>, state: State<'_, ApiController>) -> Result<String, String> { 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()); let mut q = state.build_query().param("id", &id.to_string());
if let Some(limit) = limit { if let Some(limit) = limit {
q = q.param("limit", &limit.to_string()); q = q.param("limit", &limit.to_string());
@ -775,32 +776,24 @@ pub async fn artist_album(id: u64, limit: Option<u32>, offset: Option<u32>, stat
if let Some(offset) = offset { if let Some(offset) = offset {
q = q.param("offset", &offset.to_string()); q = q.param("offset", &offset.to_string());
} }
client.artist_album(&q).await api_call!(state, artist_album, query: q)
.map(|r| r.body.to_string())
.map_err(|e| e.to_string())
} }
/// 获取歌手简介
#[tauri::command] #[tauri::command]
pub async fn artist_desc(id: u64, state: State<'_, ApiController>) -> Result<String, String> { pub async fn artist_desc(id: u64, state: State<'_, ApiController>) -> Result<String, String> {
let client = state.client.lock().await; api_call!(state, artist_desc, params: [("id", &id.to_string())])
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())
} }
/// 获取专辑详情
#[tauri::command] #[tauri::command]
pub async fn album_detail(id: u64, state: State<'_, ApiController>) -> Result<String, String> { pub async fn album_detail(id: u64, state: State<'_, ApiController>) -> Result<String, String> {
let client = state.client.lock().await; api_call!(state, album, params: [("id", &id.to_string())])
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())
} }
/// 获取最新评论
#[tauri::command] #[tauri::command]
pub async fn comment_new(query: CommentNewQuery, state: State<'_, ApiController>) -> Result<String, String> { 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() let mut q = state.build_query()
.param("type", &query.r#type.to_string()) .param("type", &query.r#type.to_string())
.param("id", &query.id.to_string()); .param("id", &query.id.to_string());
@ -816,11 +809,10 @@ pub async fn comment_new(query: CommentNewQuery, state: State<'_, ApiController>
if let Some(cursor) = query.cursor { if let Some(cursor) = query.cursor {
q = q.param("cursor", &cursor.to_string()); q = q.param("cursor", &cursor.to_string());
} }
client.comment_new(&q).await api_call!(state, comment_new, query: q)
.map(|r| r.body.to_string())
.map_err(|e| e.to_string())
} }
/// 最新评论查询参数
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct CommentNewQuery { pub struct CommentNewQuery {
pub r#type: u8, pub r#type: u8,
@ -834,9 +826,9 @@ pub struct CommentNewQuery {
pub cursor: Option<u64>, pub cursor: Option<u64>,
} }
/// 获取热门评论
#[tauri::command] #[tauri::command]
pub async fn comment_hot(query: CommentHotQuery, state: State<'_, ApiController>) -> Result<String, String> { 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() let mut q = state.build_query()
.param("type", &query.r#type.to_string()) .param("type", &query.r#type.to_string())
.param("id", &query.id.to_string()); .param("id", &query.id.to_string());
@ -849,11 +841,10 @@ pub async fn comment_hot(query: CommentHotQuery, state: State<'_, ApiController>
if let Some(before) = query.before { if let Some(before) = query.before {
q = q.param("before", &before.to_string()); q = q.param("before", &before.to_string());
} }
client.comment_hot(&q).await api_call!(state, comment_hot, query: q)
.map(|r| r.body.to_string())
.map_err(|e| e.to_string())
} }
/// 热门评论查询参数
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct CommentHotQuery { pub struct CommentHotQuery {
pub r#type: u8, pub r#type: u8,
@ -863,9 +854,9 @@ pub struct CommentHotQuery {
pub before: Option<u64>, pub before: Option<u64>,
} }
/// 获取评论楼层(子评论)
#[tauri::command] #[tauri::command]
pub async fn comment_floor(query: CommentFloorQuery, state: State<'_, ApiController>) -> Result<String, String> { 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() let mut q = state.build_query()
.param("parentCommentId", &query.parent_comment_id.to_string()) .param("parentCommentId", &query.parent_comment_id.to_string())
.param("type", &query.r#type.to_string()) .param("type", &query.r#type.to_string())
@ -876,11 +867,10 @@ pub async fn comment_floor(query: CommentFloorQuery, state: State<'_, ApiControl
if let Some(time) = query.time { if let Some(time) = query.time {
q = q.param("time", &time.to_string()); q = q.param("time", &time.to_string());
} }
client.comment_floor(&q).await api_call!(state, comment_floor, query: q)
.map(|r| r.body.to_string())
.map_err(|e| e.to_string())
} }
/// 评论楼层查询参数
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct CommentFloorQuery { pub struct CommentFloorQuery {
#[serde(rename = "parentCommentId")] #[serde(rename = "parentCommentId")]
@ -891,19 +881,13 @@ pub struct CommentFloorQuery {
pub time: Option<u64>, pub time: Option<u64>,
} }
/// 点赞/取消点赞评论
#[tauri::command] #[tauri::command]
pub async fn comment_like(query: CommentLikeQuery, state: State<'_, ApiController>) -> Result<String, String> { pub async fn comment_like(query: CommentLikeQuery, state: State<'_, ApiController>) -> Result<String, String> {
let client = state.client.lock().await; 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())])
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())
} }
/// 评论点赞查询参数
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct CommentLikeQuery { pub struct CommentLikeQuery {
pub t: u8, pub t: u8,

File diff suppressed because it is too large Load Diff

View File

@ -7,6 +7,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
mod api; mod api;
mod audio; mod audio;
mod media_controls;
use api::ApiController; use api::ApiController;
use audio::AppAudio; use audio::AppAudio;
@ -25,6 +26,27 @@ pub fn run() {
let app_audio = AppAudio(std::sync::Mutex::new(audio_controller)); let app_audio = AppAudio(std::sync::Mutex::new(audio_controller));
app.manage(app_audio); 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 show = MenuItemBuilder::with_id("show", "显示窗口").build(app)?;
let _sep1 = PredefinedMenuItem::separator(app)?; let _sep1 = PredefinedMenuItem::separator(app)?;
let prev = MenuItemBuilder::with_id("prev", "上一首").build(app)?; let prev = MenuItemBuilder::with_id("prev", "上一首").build(app)?;
@ -144,6 +166,7 @@ pub fn run() {
audio::get_output_devices, audio::get_output_devices,
audio::set_output_device, audio::set_output_device,
audio::seek_audio, audio::seek_audio,
audio::get_audio_position,
audio::set_volume, audio::set_volume,
api::download_song, api::download_song,

View File

@ -0,0 +1,121 @@
use std::sync::{Arc, Mutex};
use tauri::{AppHandle, Emitter, Listener};
use souvlaki::{
MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback,
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 playback = match status {
"playing" => MediaPlayback::Playing { progress: None },
"paused" => MediaPlayback::Paused { progress: None },
_ => 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);
}

View File

@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Nekosonic", "productName": "Nekosonic",
"version": "0.4.1", "version": "0.5.0",
"identifier": "com.atdunbg.Nekosonic", "identifier": "com.atdunbg.Nekosonic",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",

View File

@ -124,7 +124,7 @@
<main class="flex-1 overflow-y-auto pb-24"> <main class="flex-1 overflow-y-auto pb-24">
<router-view v-slot="{ Component }"> <router-view v-slot="{ Component }">
<keep-alive :max="3" include="HomeView,DiscoverView"> <keep-alive :max="5" :include="keepAliveInclude">
<component :is="Component" /> <component :is="Component" />
</keep-alive> </keep-alive>
</router-view> </router-view>
@ -281,6 +281,9 @@ import ToastContainer from './components/ToastContainer.vue';
import CommentSection from './components/CommentSection.vue'; import CommentSection from './components/CommentSection.vue';
import UpdateDialog from './components/UpdateDialog.vue'; import UpdateDialog from './components/UpdateDialog.vue';
import { usePlayerStore } from './stores/player'; 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 { useLyric } from './composables/UserLyric';
import { useUpdater } from './composables/useUpdater'; import { useUpdater } from './composables/useUpdater';
import { getCurrentWindow } from '@tauri-apps/api/window'; import { getCurrentWindow } from '@tauri-apps/api/window';
@ -293,6 +296,12 @@ const userStore = useUserStore();
const player = usePlayerStore(); const player = usePlayerStore();
const settings = useSettingsStore(); const settings = useSettingsStore();
const updater = useUpdater(); 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 createdPlaylists = ref<any[]>([]);
const subPlaylists = ref<any[]>([]); const subPlaylists = ref<any[]>([]);
@ -302,6 +311,7 @@ const searchQuery = ref('');
const showCloseModal = ref(false); const showCloseModal = ref(false);
const closeDontAskAgain = ref(false); const closeDontAskAgain = ref(false);
const windowVisible = ref(true); const windowVisible = ref(true);
const keepAliveInclude = ref<string[]>(['HomeView', 'DiscoverView', 'FavoriteSongsView', 'DailySongsView', 'LocalMusicView']);
watch(() => settings.theme, (val) => { watch(() => settings.theme, (val) => {
document.documentElement.setAttribute('data-theme', val); document.documentElement.setAttribute('data-theme', val);
@ -321,7 +331,7 @@ const roamCoverError = ref(false);
const roamTab = ref<'lyric' | 'comment'>('lyric'); const roamTab = ref<'lyric' | 'comment'>('lyric');
const roamCoverUrl = computed(() => { const roamCoverUrl = computed(() => {
if (!roamSong.value) return ''; if (!roamSong.value) return '';
return roamSong.value.al?.picUrl || roamSong.value.album?.picUrl || ''; return getCoverUrl(roamSong.value) || '';
}); });
watch(roamCoverUrl, () => { roamCoverError.value = false; }); watch(roamCoverUrl, () => { roamCoverError.value = false; });
let roamResizeObserver: ResizeObserver | null = null; let roamResizeObserver: ResizeObserver | null = null;
@ -428,6 +438,8 @@ watch(() => userStore.isLoggedIn, (val) => {
}); });
onMounted(async () => { onMounted(async () => {
document.addEventListener('contextmenu', (e) => e.preventDefault());
if (userStore.isLoggedIn) { if (userStore.isLoggedIn) {
loadPlaylists(); loadPlaylists();
player.loadLikedIds(); player.loadLikedIds();
@ -497,9 +509,11 @@ onMounted(() => {
}); });
const unlisten4 = listen('window-hidden', () => { const unlisten4 = listen('window-hidden', () => {
windowVisible.value = false; windowVisible.value = false;
keepAliveInclude.value = [];
}); });
const unlisten5 = listen('window-shown', () => { const unlisten5 = listen('window-shown', () => {
windowVisible.value = true; windowVisible.value = true;
keepAliveInclude.value = ['HomeView', 'DiscoverView', 'FavoriteSongsView', 'DailySongsView', 'LocalMusicView'];
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {

View File

@ -14,8 +14,8 @@
<div class="flex items-center px-6 h-16"> <div class="flex items-center px-6 h-16">
<div class="flex items-center gap-3 w-56 min-w-0"> <div class="flex items-center gap-3 w-56 min-w-0">
<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="全屏展示"> <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="player.currentSong.al.picUrl" class="w-full h-full object-cover" /> <img :src="getCoverUrl(player.currentSong)" class="w-full h-full object-cover" />
</div> </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="全屏展示"> <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> <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>
@ -142,43 +142,25 @@
<p class="text-xs text-content-4">去发现好听的音乐吧</p> <p class="text-xs text-content-4">去发现好听的音乐吧</p>
</div> </div>
<div v-for="(song, idx) in player.queue" :key="song.id + '-' + idx" :id="'queue-item-' + idx" <SongListItem
@click="playFromQueue(idx)" :class="[ v-for="(song, idx) in player.queue" :key="song.id + '-' + idx"
'flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer transition-all duration-200 group', :id="'queue-item-' + idx"
idx === player.currentIndex :song="song"
? 'bg-muted' :index="idx"
: 'hover:bg-subtle', :is-current="idx === player.currentIndex"
]"> show-playing-overlay
<div class="w-9 h-9 rounded-md overflow-hidden flex-shrink-0 relative"> cover-size="w-9 h-9"
<img v-if="song.al?.picUrl" :src="song.al.picUrl + '?param=80y80'" class="w-full h-full object-cover" loading="lazy" /> cover-size-param="?param=80y80"
<div v-else class="w-full h-full bg-muted flex items-center justify-center"> :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'"
<svg width="12" height="12" 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> @click="playFromQueue(idx)"
</div> >
<div v-if="idx === player.currentIndex" <template #actions>
class="absolute inset-0 bg-black/30 flex items-center justify-center"> <button @click.stop="player.removeFromQueue(idx)"
<div class="flex items-end gap-[2px] h-3"> 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">
<span class="eq-bar-sm w-[2px] bg-white rounded-full" style="animation-delay: 0s"></span> <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>
<span class="eq-bar-sm w-[2px] bg-white rounded-full" style="animation-delay: 0.12s"></span> </button>
<span class="eq-bar-sm w-[2px] bg-white rounded-full" style="animation-delay: 0.24s"></span> </template>
</div> </SongListItem>
</div>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm truncate" :class="idx === player.currentIndex ? 'text-accent-text font-medium' : 'text-content-2'">
{{ song.name }}
</p>
<p class="text-xs text-content-3 truncate mt-0.5">
<template v-for="(a, i) in song.ar || []" :key="a.id || i">
<span v-if="i > 0" class="text-content-4">/</span>
<span>{{ a.name }}</span>
</template>
</p>
</div>
<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>
</div>
<div class="h-2"></div> <div class="h-2"></div>
@ -200,9 +182,11 @@ import { ref, computed, watch, onBeforeUnmount, onMounted, nextTick } from 'vue'
import { usePlayerStore, PlayMode } from '../stores/player'; import { usePlayerStore, PlayMode } from '../stores/player';
import { useDownload } from '../composables/useDownload'; import { useDownload } from '../composables/useDownload';
import { formatTime } from '../utils/format'; import { formatTime } from '../utils/format';
import { getCoverUrl } from '../utils/song';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import SongListItem from './SongListItem.vue';
const router = useRouter(); const router = useRouter();
const player = usePlayerStore(); const player = usePlayerStore();
@ -228,7 +212,7 @@ onBeforeUnmount(() => {
if (unlistenCache) unlistenCache(); if (unlistenCache) unlistenCache();
}); });
const modeTexts = { loop: '列表循环', shuffle: '随机播放', 'repeat-one': '单曲循环' }; const modeTexts: Record<PlayMode, string> = { loop: '列表循环', shuffle: '随机播放', 'repeat-one': '单曲循环' };
const modeTitle = computed(() => modeTexts[player.playMode] || '列表循环'); const modeTitle = computed(() => modeTexts[player.playMode] || '列表循环');
function togglePlayMode() { function togglePlayMode() {
const modes: PlayMode[] = ['loop', 'shuffle', 'repeat-one']; const modes: PlayMode[] = ['loop', 'shuffle', 'repeat-one'];

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

View File

@ -3,6 +3,7 @@ import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import { useSettingsStore } from '../stores/settings'; import { useSettingsStore } from '../stores/settings';
import { showToast } from '../composables/useToast'; import { showToast } from '../composables/useToast';
import { getCoverUrl, type Song } from '../utils/song';
interface DownloadTask { interface DownloadTask {
id: number; id: number;
@ -72,7 +73,7 @@ function getDownloadProgress(songId: number): number {
return task?.progress ?? 0; 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 (downloadingIds.has(song.id)) return;
if (localSongIds.has(song.id)) { if (localSongIds.has(song.id)) {
showToast(`${song.name} 已下载`, 'info'); showToast(`${song.name} 已下载`, 'info');
@ -80,10 +81,10 @@ async function downloadSong(song: { id: number; name: string; ar?: { name: strin
} }
const settings = useSettingsStore(); const settings = useSettingsStore();
const artist = song.ar?.map(a => a.name).join(' / ') || song.artists?.map(a => a.name).join(' / ') || '未知'; const artist = song.ar?.map(a => a.name).join(' / ') || '未知';
const albumName = song.al?.name || song.album?.name || null; const albumName = song.al?.name || null;
const durationVal = song.dt || song.duration || null; const durationVal = song.dt || null;
const coverUrl = song.al?.picUrl || song.album?.picUrl || null; const coverUrl = getCoverUrl(song) || null;
downloadingIds.add(song.id); downloadingIds.add(song.id);
tasks.push({ id: song.id, name: song.name, progress: 0 }); tasks.push({ id: song.id, name: song.name, progress: 0 });

View 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 };
}

View File

@ -0,0 +1,24 @@
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);
}

View File

@ -65,8 +65,7 @@ export function useUpdater() {
} }
const ignored = getIgnoredVersion() const ignored = getIgnoredVersion()
if (info.version === ignored) { if (info.version === ignored && silent) {
if (!silent) error.value = '当前已是最新版本'
return null return null
} }

View File

@ -9,7 +9,6 @@ import DailySongs from '@/views/DailySongs.vue';
import LocalMusic from '@/views/LocalMusic.vue'; import LocalMusic from '@/views/LocalMusic.vue';
import Settings from '@/views/Settings.vue'; import Settings from '@/views/Settings.vue';
const routes = [ const routes = [
{ path: '/', name: 'home', component: Home }, { path: '/', name: 'home', component: Home },
{ path: '/discover', name: 'discover', component: Discover }, { path: '/discover', name: 'discover', component: Discover },
@ -19,14 +18,28 @@ const routes = [
{ path: '/recent', name: 'recent', component: RecentPlays }, { path: '/recent', name: 'recent', component: RecentPlays },
{ path: '/daily', name: 'daily', component: DailySongs }, { path: '/daily', name: 'daily', component: DailySongs },
{ path: '/local-music', name: 'local-music', component: LocalMusic }, { 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: '/playlist/:id', name: 'playlist', component: PlaylistDetail },
{ path: '/artist/:id', name: 'artist', component: () => import('@/views/ArtistDetail.vue') }, { path: '/artist/:id', name: 'artist', component: () => import('@/views/ArtistDetail.vue') },
{ path: '/album/:id', name: 'album', component: () => import('@/views/AlbumDetail.vue') }, { path: '/album/:id', name: 'album', component: () => import('@/views/AlbumDetail.vue') },
{ path: '/settings', name: 'settings', component: Settings }, { path: '/settings', name: 'settings', component: Settings },
]; ];
export default createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes, 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;

View File

@ -1,35 +1,16 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { ref, watch, nextTick } from 'vue'; import { ref, watch, nextTick } from 'vue';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { normalizeSong } from '../utils/song'; import { normalizeSong, type Song } from '../utils/song';
import { useSettingsStore } from './settings'; import { useSettingsStore } from './settings';
import { useUserStore } from './user'; import { useUserStore } from './user';
import { showToast } from '../composables/useToast'; import { showToast } from '../composables/useToast';
export type PlayMode = 'loop' | 'shuffle' | 'repeat-one'; export type PlayMode = 'loop' | 'shuffle' | 'repeat-one';
export type { Song };
export interface Song { import { listen, emit } from '@tauri-apps/api/event';
id: number; import { getCurrentWindow } from '@tauri-apps/api/window';
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;
});
}
function loadRecentLocal(): Song[] { function loadRecentLocal(): Song[] {
try { try {
@ -55,15 +36,35 @@ export const usePlayerStore = defineStore('player', () => {
const queue = ref<Song[]>([]); const queue = ref<Song[]>([]);
const currentIndex = ref(-1); 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; let tickInterval: ReturnType<typeof setInterval> | null = null;
function setTickInterval(v: ReturnType<typeof setInterval> | null) { _tickInterval = v; tickInterval = v; }
const recentLocal = ref<Song[]>(loadRecentLocal()); const recentLocal = ref<Song[]>(loadRecentLocal());
const MAX_RECENT = 200; const MAX_RECENT = 200;
const likedIds = ref<Set<number>>(loadLikedIdsFromStorage()); 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 { function isLiked(songId: number): boolean {
return likedIds.value.has(songId); return likedIds.value.has(songId);
} }
@ -125,8 +126,8 @@ export const usePlayerStore = defineStore('player', () => {
let fmVipSkipCount = 0; let fmVipSkipCount = 0;
const MAX_FM_VIP_SKIP = 10; const MAX_FM_VIP_SKIP = 10;
async function playFmSong(song: any) { async function playFmSong(song: Song) {
if (tickInterval) { clearInterval(tickInterval); tickInterval = null; } if (tickInterval) { clearInterval(tickInterval); setTickInterval(null); }
if (!song.dt || song.dt === 0) { if (!song.dt || song.dt === 0) {
try { try {
const jsonStr: string = await invoke('get_song_detail', { id: String(song.id) }); const jsonStr: string = await invoke('get_song_detail', { id: String(song.id) });
@ -148,7 +149,6 @@ export const usePlayerStore = defineStore('player', () => {
fmSong.value = song; fmSong.value = song;
currentSong.value = song; currentSong.value = song;
try { try {
const settings = useSettingsStore();
const jsonStr: string = await invoke('get_song_url', { query: { id: Number(song.id), level: settings.audioQuality, fm_mode: true } }); 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 data = JSON.parse(jsonStr);
const url: string | undefined = data.url; const url: string | undefined = data.url;
@ -180,6 +180,7 @@ export const usePlayerStore = defineStore('player', () => {
currentTime.value = 0; currentTime.value = 0;
startTick(); startTick();
addRecent(song); addRecent(song);
emitPlaybackState();
} catch (e) { } catch (e) {
console.error('FM播放失败', e); console.error('FM播放失败', e);
playing.value = false; playing.value = false;
@ -195,6 +196,15 @@ export const usePlayerStore = defineStore('player', () => {
disableFmMode(); disableFmMode();
const idx = queue.value.findIndex(s => s.id === song.id); 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) { if (idx === -1) {
queue.value.push(song); queue.value.push(song);
currentIndex.value = queue.value.length - 1; currentIndex.value = queue.value.length - 1;
@ -207,6 +217,21 @@ export const usePlayerStore = defineStore('player', () => {
async function playFromList(songs: Song[], startIndex: number) { async function playFromList(songs: Song[], startIndex: number) {
disableFmMode(); disableFmMode();
if (songs.length === 0) return; 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]; queue.value = [...songs];
currentIndex.value = Math.max(0, Math.min(startIndex, songs.length - 1)); currentIndex.value = Math.max(0, Math.min(startIndex, songs.length - 1));
await playCurrent(); await playCurrent();
@ -215,23 +240,14 @@ export const usePlayerStore = defineStore('player', () => {
let vipSkipCount = 0; let vipSkipCount = 0;
const MAX_VIP_SKIP = 10; const MAX_VIP_SKIP = 10;
let audioStartedResolve: (() => void) | null = null;
listen('audio-started', () => {
if (audioStartedResolve) {
audioStartedResolve();
audioStartedResolve = null;
}
});
function waitForAudioStart(): Promise<void> { function waitForAudioStart(): Promise<void> {
return new Promise<void>((resolve) => { return new Promise<void>((resolve) => {
audioStartedResolve = resolve; _audioStartedResolve = resolve;
}); });
} }
async function playCurrent() { async function playCurrent() {
if (tickInterval) { clearInterval(tickInterval); tickInterval = null; } if (tickInterval) { clearInterval(tickInterval); setTickInterval(null); }
const song = queue.value[currentIndex.value]; const song = queue.value[currentIndex.value];
if (!song?.id) { if (!song?.id) {
console.error('无效的歌曲数据', song); console.error('无效的歌曲数据', song);
@ -242,18 +258,18 @@ export const usePlayerStore = defineStore('player', () => {
currentSong.value = song; currentSong.value = song;
playing.value = false; playing.value = false;
currentTime.value = 0; currentTime.value = 0;
duration.value = (song.dt || song.duration || 0) / 1000; duration.value = (song.dt || 0) / 1000;
if (song.localPath) { if (song.localPath) {
await invoke('play_local_audio', { path: song.localPath }); await invoke('play_local_audio', { path: song.localPath });
await waitForAudioStart(); await waitForAudioStart();
playing.value = true; playing.value = true;
startTick(); startTick();
addRecent(song); addRecent(song);
return; emitPlaybackState();
} return;
}
const settings = useSettingsStore();
const jsonStr: string = await invoke('get_song_url', { query: { id: Number(song.id), level: settings.audioQuality } }); const jsonStr: string = await invoke('get_song_url', { query: { id: Number(song.id), level: settings.audioQuality } });
const data = JSON.parse(jsonStr); const data = JSON.parse(jsonStr);
const url: string | undefined = data.url; const url: string | undefined = data.url;
@ -282,24 +298,56 @@ export const usePlayerStore = defineStore('player', () => {
startTick(); startTick();
addRecent(song); addRecent(song);
vipSkipCount = 0; vipSkipCount = 0;
emitPlaybackState();
} catch (e) { } catch (e) {
console.error('播放失败', e); console.error('播放失败', e);
playing.value = false; playing.value = false;
} }
} }
let onSeekStart: (() => void) | null = null;
function startTick() { function startTick() {
if (tickInterval) clearInterval(tickInterval); 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 (playing.value && duration.value > 0) {
if (currentTime.value < duration.value) { if (seekGuard) return;
currentTime.value += 0.25; syncCounter++;
if (currentTime.value > duration.value) { if (syncCounter >= 2) {
currentTime.value = duration.value; 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() { async function toggle() {
@ -310,6 +358,7 @@ export const usePlayerStore = defineStore('player', () => {
await invoke('resume_audio'); await invoke('resume_audio');
playing.value = true; playing.value = true;
} }
emitPlaybackState();
} }
async function stop() { async function stop() {
@ -317,8 +366,9 @@ export const usePlayerStore = defineStore('player', () => {
playing.value = false; playing.value = false;
currentSong.value = null; currentSong.value = null;
currentTime.value = 0; currentTime.value = 0;
if (tickInterval) clearInterval(tickInterval); if (tickInterval) { clearInterval(tickInterval); setTickInterval(null); }
disableFmMode(); disableFmMode();
emitPlaybackState();
} }
@ -365,8 +415,11 @@ export const usePlayerStore = defineStore('player', () => {
async function seek(time: number) { async function seek(time: number) {
try { try {
await invoke('seek_audio', { time });
currentTime.value = time; currentTime.value = time;
if (onSeekStart) onSeekStart();
await invoke('seek_audio', { time });
startTick();
emitPlaybackState();
} catch (e) { } catch (e) {
console.error('seek 失败', e); console.error('seek 失败', e);
} }
@ -376,6 +429,7 @@ export const usePlayerStore = defineStore('player', () => {
const newVol = Math.max(0, Math.min(100, volume.value + delta)); const newVol = Math.max(0, Math.min(100, volume.value + delta));
volume.value = newVol; volume.value = newVol;
await invoke('set_volume', { vol: newVol / 100 }); await invoke('set_volume', { vol: newVol / 100 });
emitPlaybackState();
} }
@ -457,7 +511,7 @@ export const usePlayerStore = defineStore('player', () => {
// -------- FM 专属状态 -------- // -------- FM 专属状态 --------
const fmSong = ref<any>(null); const fmSong = ref<Song | null>(null);
const fmPlaying = ref(false); const fmPlaying = ref(false);
async function loadFm() { async function loadFm() {
@ -498,12 +552,57 @@ async function nextFm() {
await loadFm(); 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', () => { listen('audio-ended', () => {
if (tickInterval) { clearInterval(tickInterval); tickInterval = null; } if (_tickInterval) { clearInterval(_tickInterval); _tickInterval = null; }
if (isFmMode.value && fmNextCallback) { const player = usePlayerStore();
fmNextCallback(); player.next();
} else { });
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;
player.seek(posSec);
} else if (cmd === 'Raise') {
getCurrentWindow().show().catch(() => {});
getCurrentWindow().setFocus().catch(() => {});
} else if (cmd === 'Quit') {
getCurrentWindow().close().catch(() => {});
} }
}); });

View File

@ -2,13 +2,13 @@ import { defineStore } from 'pinia';
import { ref, watch } from 'vue'; import { ref, watch } from 'vue';
export type AudioQuality = 'standard' | 'higher' | 'exhigh' | 'lossless' | 'hires'; export type AudioQuality = 'standard' | 'higher' | 'exhigh' | 'lossless' | 'hires';
export type ThemeName = 'green' | 'rose' | 'blue' | 'violet' | 'orange' | 'cyan' | 'pink'; export type ThemeName = 'blue' | 'green' | 'rose' | 'violet' | 'orange' | 'cyan' | 'pink';
export type CloseAction = 'ask' | 'minimize' | 'exit'; export type CloseAction = 'ask' | 'minimize' | 'exit';
export const themeLabels: Record<ThemeName, string> = { export const themeLabels: Record<ThemeName, string> = {
blue: '天蓝',
green: '翠绿', green: '翠绿',
rose: '玫红', rose: '玫红',
blue: '天蓝',
violet: '紫罗兰', violet: '紫罗兰',
orange: '橙色', orange: '橙色',
cyan: '青色', cyan: '青色',
@ -16,9 +16,9 @@ export const themeLabels: Record<ThemeName, string> = {
}; };
export const themeColors: Record<ThemeName, string> = { export const themeColors: Record<ThemeName, string> = {
blue: '#3b82f6',
green: '#22c55e', green: '#22c55e',
rose: '#f43f5e', rose: '#f43f5e',
blue: '#3b82f6',
violet: '#8b5cf6', violet: '#8b5cf6',
orange: '#f97316', orange: '#f97316',
cyan: '#06b6d4', cyan: '#06b6d4',
@ -50,11 +50,11 @@ export const defaultShortcuts: Record<string, ShortcutBinding> = {
next: { key: 'Control+ArrowRight', label: '下一首' }, next: { key: 'Control+ArrowRight', label: '下一首' },
volUp: { key: 'Control+ArrowUp', label: '音量增加' }, volUp: { key: 'Control+ArrowUp', label: '音量增加' },
volDown: { key: 'Control+ArrowDown', label: '音量减小' }, volDown: { key: 'Control+ArrowDown', label: '音量减小' },
globalPlayPause: { key: 'Alt+Control+KeyP', label: '播放/暂停(全局)' }, globalPlayPause: { key: 'Control+Alt+KeyP', label: '播放/暂停(全局)' },
globalPrev: { key: 'Alt+Control+ArrowLeft', label: '上一首(全局)' }, globalPrev: { key: 'Control+Alt+ArrowLeft', label: '上一首(全局)' },
globalNext: { key: 'Alt+Control+ArrowRight', label: '下一首(全局)' }, globalNext: { key: 'Control+Alt+ArrowRight', label: '下一首(全局)' },
globalVolUp: { key: 'Alt+Control+ArrowUp', label: '音量增加(全局)' }, globalVolUp: { key: 'Control+Alt+ArrowUp', label: '音量增加(全局)' },
globalVolDown: { key: 'Alt+Control+ArrowDown', label: '音量减小(全局)' }, globalVolDown: { key: 'Control+Alt+ArrowDown', label: '音量减小(全局)' },
}; };
interface SettingsData { interface SettingsData {
@ -64,6 +64,7 @@ interface SettingsData {
closeAction: CloseAction; closeAction: CloseAction;
shortcuts: Record<string, ShortcutBinding>; shortcuts: Record<string, ShortcutBinding>;
outputDevice: string | null; outputDevice: string | null;
volume: number;
} }
function loadSettings(): SettingsData { function loadSettings(): SettingsData {
@ -71,25 +72,27 @@ function loadSettings(): SettingsData {
const raw = localStorage.getItem('app_settings'); const raw = localStorage.getItem('app_settings');
if (raw) { if (raw) {
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);
const theme = parsed.theme || parsed.accentColor || 'green'; const theme = parsed.theme || parsed.accentColor || 'blue';
const validThemes: ThemeName[] = ['green', 'rose', 'blue', 'violet', 'orange', 'cyan', 'pink']; const validThemes: ThemeName[] = ['blue', 'green', 'rose', 'violet', 'orange', 'cyan', 'pink'];
return { return {
audioQuality: parsed.audioQuality || 'standard', audioQuality: parsed.audioQuality || 'standard',
downloadPath: parsed.downloadPath || '', downloadPath: parsed.downloadPath || '',
theme: validThemes.includes(theme) ? theme : 'green', theme: validThemes.includes(theme) ? theme : 'blue',
closeAction: parsed.closeAction || 'ask', closeAction: parsed.closeAction || 'ask',
shortcuts: { ...defaultShortcuts, ...(parsed.shortcuts || {}) }, shortcuts: { ...defaultShortcuts, ...(parsed.shortcuts || {}) },
outputDevice: parsed.outputDevice || null, outputDevice: parsed.outputDevice || null,
volume: typeof parsed.volume === 'number' ? parsed.volume : 100,
}; };
} }
} catch {} } catch {}
return { return {
audioQuality: 'standard', audioQuality: 'standard',
downloadPath: '', downloadPath: '',
theme: 'green', theme: 'blue',
closeAction: 'ask', closeAction: 'ask',
shortcuts: { ...defaultShortcuts }, shortcuts: { ...defaultShortcuts },
outputDevice: null, outputDevice: null,
volume: 100,
}; };
} }
@ -102,6 +105,7 @@ export const useSettingsStore = defineStore('settings', () => {
const closeAction = ref<CloseAction>(saved.closeAction || 'ask'); const closeAction = ref<CloseAction>(saved.closeAction || 'ask');
const shortcuts = ref<Record<string, ShortcutBinding>>(saved.shortcuts); const shortcuts = ref<Record<string, ShortcutBinding>>(saved.shortcuts);
const outputDevice = ref<string | null>(saved.outputDevice); const outputDevice = ref<string | null>(saved.outputDevice);
const volume = ref<number>(saved.volume);
function setAudioQuality(q: AudioQuality) { function setAudioQuality(q: AudioQuality) {
audioQuality.value = q; audioQuality.value = q;
@ -134,13 +138,14 @@ export const useSettingsStore = defineStore('settings', () => {
function resetAll() { function resetAll() {
audioQuality.value = 'standard'; audioQuality.value = 'standard';
downloadPath.value = ''; downloadPath.value = '';
theme.value = 'green'; theme.value = 'blue';
closeAction.value = 'ask'; closeAction.value = 'ask';
shortcuts.value = { ...defaultShortcuts }; shortcuts.value = { ...defaultShortcuts };
outputDevice.value = null; outputDevice.value = null;
volume.value = 100;
} }
watch([audioQuality, downloadPath, theme, closeAction, shortcuts, outputDevice], () => { watch([audioQuality, downloadPath, theme, closeAction, shortcuts, outputDevice, volume], () => {
const data: SettingsData = { const data: SettingsData = {
audioQuality: audioQuality.value, audioQuality: audioQuality.value,
downloadPath: downloadPath.value, downloadPath: downloadPath.value,
@ -148,6 +153,7 @@ export const useSettingsStore = defineStore('settings', () => {
closeAction: closeAction.value, closeAction: closeAction.value,
shortcuts: shortcuts.value, shortcuts: shortcuts.value,
outputDevice: outputDevice.value, outputDevice: outputDevice.value,
volume: volume.value,
}; };
localStorage.setItem('app_settings', JSON.stringify(data)); localStorage.setItem('app_settings', JSON.stringify(data));
}, { deep: true }); }, { deep: true });
@ -159,6 +165,7 @@ export const useSettingsStore = defineStore('settings', () => {
closeAction, closeAction,
shortcuts, shortcuts,
outputDevice, outputDevice,
volume,
setAudioQuality, setAudioQuality,
setDownloadPath, setDownloadPath,
setTheme, setTheme,

View File

@ -195,6 +195,13 @@
overflow: hidden; overflow: hidden;
overscroll-behavior: none; overscroll-behavior: none;
touch-action: none; touch-action: none;
user-select: none;
-webkit-user-select: none;
}
input, textarea, [contenteditable="true"] {
user-select: text;
-webkit-user-select: text;
} }
::-webkit-scrollbar { ::-webkit-scrollbar {

View File

@ -12,9 +12,18 @@ export function formatTime(sec: number): string {
return `${m}:${s.toString().padStart(2, '0')}`; return `${m}:${s.toString().padStart(2, '0')}`;
} }
const YI = 100_000_000;
const WAN = 10_000;
export function formatPlayCount(count: number): string { export function formatPlayCount(count: number): string {
if (!count) return '0'; if (!count) return '0';
if (count >= 100000000) return (count / 100000000).toFixed(1) + '亿'; if (count >= YI) return (count / YI).toFixed(1) + '亿';
if (count >= 10000) return (count / 10000).toFixed(1) + '万'; if (count >= WAN) return (count / WAN).toFixed(1) + '万';
return count.toString(); 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')}`;
}

View File

@ -1,22 +1,35 @@
/** export interface Song {
* 统一规范化歌曲对象,确保 al.picUrl、ar、dt 字段存在且合理 id: number;
*/ name: string;
export function normalizeSong(song: any) { ar: { id?: number; name: string }[];
const normalized = { ...song }; al: { id?: number; picUrl: string; name?: string };
if (!normalized.al?.picUrl && normalized.album?.picUrl) { dt?: number;
normalized.al = { ...normalized.al, picUrl: normalized.album.picUrl }; localPath?: string;
} }
if (!normalized.al?.name && normalized.album?.name) {
normalized.al = { ...normalized.al, name: normalized.album.name }; export function normalizeSong(song: any): Song {
} const al = {
if (!normalized.al?.id && normalized.album?.id) { id: song.al?.id || song.album?.id,
normalized.al = { ...normalized.al, id: normalized.album.id }; picUrl: song.al?.picUrl || song.album?.picUrl || '',
} name: song.al?.name || song.album?.name,
if (!normalized.ar || normalized.ar.length === 0) { };
normalized.ar = normalized.artists || []; const ar = (song.ar && song.ar.length > 0) ? song.ar : (song.artists || []);
} let dt = song.dt || song.duration || 0;
if (!normalized.dt || normalized.dt < 100 || normalized.dt > 7200000) { if (dt < 100 || dt > 7200000) dt = 0;
normalized.dt = 0; return {
} id: song.id,
return normalized; 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;
}

View File

@ -11,7 +11,7 @@
<h1 class="text-2xl font-bold leading-tight">{{ album.name }}</h1> <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"> <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"> <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 <span
class="hover:text-accent-text cursor-pointer transition" class="hover:text-accent-text cursor-pointer transition"
@click="ar.id && router.push({ name: 'artist', params: { id: ar.id } })" @click="ar.id && router.push({ name: 'artist', params: { id: ar.id } })"
@ -37,52 +37,37 @@
<div v-if="loading" class="text-content-2">加载中...</div> <div v-if="loading" class="text-content-2">加载中...</div>
<div v-else class="space-y-1"> <div v-else class="space-y-1">
<div <SongListItem
v-for="(song, index) in songs" v-for="(song, index) in songs"
:key="song.id" :key="song.id"
@click="playSingle(song)" :song="song"
class="flex items-center gap-4 p-3 rounded-xl hover:bg-subtle transition cursor-pointer group" :index="index"
:class="{ 'bg-accent-dim': isCurrentSong(song.id) }" :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)"
> >
<div class="w-6 text-right flex-shrink-0 flex items-center justify-end h-5"> <template #index="{ index: idx, isCurrent }">
<div v-if="isCurrentSong(song.id)" class="flex items-center justify-end"> <div class="w-6 text-right flex-shrink-0 flex items-center justify-end h-5">
<div class="flex items-center gap-[3px] h-4"> <div v-if="isCurrent" class="flex items-center justify-end">
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 50%; animation-delay: 0ms"></span> <div class="flex items-center gap-[3px] h-4">
<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: 50%; animation-delay: 0ms"></span>
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 35%; animation-delay: 300ms"></span> <span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 100%; animation-delay: 150ms"></span>
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 35%; animation-delay: 300ms"></span>
</div>
</div> </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> </div>
<template v-else> </template>
<span class="text-xs text-content-3 group-hover:hidden">{{ index + 1 }}</span> </SongListItem>
<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>
</div> </div>
</div> </div>
</template> </template>
@ -92,25 +77,18 @@ import { ref, onMounted, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '../stores/player'; import { usePlayerStore } from '../stores/player';
import { useDownload } from '../composables/useDownload'; import { normalizeSong, type Song } from '../utils/song';
import { formatDuration } from '../utils/format'; import { formatDate } from '../utils/format';
import SongItemMenu from '../components/SongItemMenu.vue'; import SongListItem from '../components/SongListItem.vue';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const player = usePlayerStore(); const player = usePlayerStore();
const download = useDownload();
const album = ref<any>(null); const album = ref<any>(null);
const songs = ref<any[]>([]); const songs = ref<Song[]>([]);
const loading = ref(true); 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) { async function fetchAlbum(id: number) {
loading.value = true; loading.value = true;
album.value = null; album.value = null;
@ -118,13 +96,8 @@ async function fetchAlbum(id: number) {
try { try {
const jsonStr: string = await invoke('album_detail', { id }); const jsonStr: string = await invoke('album_detail', { id });
const data = JSON.parse(jsonStr); const data = JSON.parse(jsonStr);
const a = data.album; album.value = data.album;
if (a) { songs.value = (data.songs || []).map(normalizeSong);
delete a.uid;
if (a.artists) a.artists.forEach((ar: any) => delete ar.uid);
}
album.value = a;
songs.value = data.songs || [];
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} finally { } finally {
@ -140,15 +113,6 @@ watch(() => route.params.id, (newId) => {
if (newId) fetchAlbum(Number(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() { function playAll() {
if (songs.value.length === 0) return; if (songs.value.length === 0) return;
player.playAll(songs.value); player.playAll(songs.value);

View File

@ -41,52 +41,37 @@
<template v-else> <template v-else>
<div v-if="activeTab === 'songs'" class="space-y-1"> <div v-if="activeTab === 'songs'" class="space-y-1">
<div <SongListItem
v-for="(song, index) in songs" v-for="(song, index) in songs"
:key="song.id" :key="song.id"
@click="playSingle(song)" :song="song"
class="flex items-center gap-4 p-3 rounded-xl hover:bg-subtle transition cursor-pointer group" :index="index"
:class="{ 'bg-accent-dim': isCurrentSong(song.id) }" :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)"
> >
<div class="w-6 text-right flex-shrink-0 flex items-center justify-end h-5"> <template #index="{ index: idx, isCurrent }">
<div v-if="isCurrentSong(song.id)" class="flex items-center justify-end"> <div class="w-6 text-right flex-shrink-0 flex items-center justify-end h-5">
<div class="flex items-center gap-[3px] h-4"> <div v-if="isCurrent" class="flex items-center justify-end">
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 50%; animation-delay: 0ms"></span> <div class="flex items-center gap-[3px] h-4">
<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: 50%; animation-delay: 0ms"></span>
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 35%; animation-delay: 300ms"></span> <span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 100%; animation-delay: 150ms"></span>
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 35%; animation-delay: 300ms"></span>
</div>
</div> </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> </div>
<template v-else> </template>
<span class="text-xs text-content-3 group-hover:hidden">{{ index + 1 }}</span> </SongListItem>
<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>
</div> </div>
<div v-if="activeTab === 'albums'" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"> <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" /> <img :src="album.picUrl" class="w-full aspect-square object-cover" />
<div class="p-3"> <div class="p-3">
<p class="text-sm font-medium truncate">{{ album.name }}</p> <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> </div>
</div> </div>
@ -116,17 +101,16 @@ import { ref, onMounted, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '../stores/player'; import { usePlayerStore } from '../stores/player';
import { useDownload } from '../composables/useDownload'; import { formatPlayCount, formatDate } from '../utils/format';
import { formatDuration, formatPlayCount } from '../utils/format'; import { normalizeSong, type Song } from '../utils/song';
import SongItemMenu from '../components/SongItemMenu.vue'; import SongListItem from '../components/SongListItem.vue';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const player = usePlayerStore(); const player = usePlayerStore();
const download = useDownload();
const artist = ref<any>(null); const artist = ref<any>(null);
const songs = ref<any[]>([]); const songs = ref<Song[]>([]);
const albums = ref<any[]>([]); const albums = ref<any[]>([]);
const briefDesc = ref(''); const briefDesc = ref('');
const loading = ref(true); const loading = ref(true);
@ -154,15 +138,9 @@ async function fetchArtist(id: number) {
const detailData = JSON.parse(detailStr); const detailData = JSON.parse(detailStr);
artist.value = detailData.artist; artist.value = detailData.artist;
const songsData = JSON.parse(songsStr); const songsData = JSON.parse(songsStr);
songs.value = songsData.songs || []; songs.value = (songsData.songs || []).map(normalizeSong);
const albumData = JSON.parse(albumStr); const albumData = JSON.parse(albumStr);
const rawAlbums = albumData.hotAlbums || []; albums.value = 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;
const descData = JSON.parse(descStr); const descData = JSON.parse(descStr);
briefDesc.value = descData.briefDesc || ''; briefDesc.value = descData.briefDesc || '';
} catch (e) { } catch (e) {
@ -180,21 +158,6 @@ watch(() => route.params.id, (newId) => {
if (newId) fetchArtist(Number(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() { function playAll() {
if (songs.value.length === 0) return; if (songs.value.length === 0) return;
player.playAll(songs.value); player.playAll(songs.value);

View File

@ -15,84 +15,87 @@
</div> </div>
<div v-if="loading" class="text-content-2">加载中...</div> <div v-if="loading" class="text-content-2">加载中...</div>
<div v-else class="space-y-2"> <div v-else class="space-y-2">
<div <SongListItem
v-for="(song, index) in songs" v-for="(song, index) in songs"
:key="song.id" :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)" @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) }"
> >
<div class="w-6 text-right flex-shrink-0 flex items-center justify-end h-5"> <template #index="{ index: idx, isCurrent }">
<div v-if="isCurrentSong(song.id)" class="flex items-center justify-end"> <div class="w-6 text-right flex-shrink-0 flex items-center justify-end h-5">
<div class="flex items-center gap-[3px] h-4"> <div v-if="isCurrent" class="flex items-center justify-end">
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 50%; animation-delay: 0ms"></span> <div class="flex items-center gap-[3px] h-4">
<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: 50%; animation-delay: 0ms"></span>
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 35%; animation-delay: 300ms"></span> <span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 100%; animation-delay: 150ms"></span>
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 35%; animation-delay: 300ms"></span>
</div>
</div> </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> </div>
<template v-else> </template>
<span class="text-xs text-content-3 group-hover:hidden">{{ index + 1 }}</span> </SongListItem>
<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>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue'; import { ref, onMounted, watch } from 'vue';
import { useRouter } from 'vue-router';
import { invoke } from '@tauri-apps/api/core'; 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 { usePlayerStore } from '../stores/player';
import { useDownload } from '../composables/useDownload'; import { pageCacheGet, pageCacheSet, pageCacheInvalidate } from '../composables/usePageCache';
import { formatDuration } from '../utils/format'; import { normalizeSong, type Song } from '../utils/song';
import { useOnlineStatus } from '../composables/useOnlineStatus';
defineOptions({ name: 'DailySongsView' });
const player = usePlayerStore(); const player = usePlayerStore();
const download = useDownload(); const { isOnline } = useOnlineStatus();
const router = useRouter(); const songs = ref<Song[]>([]);
const songs = ref<any[]>([]);
const loading = ref(true); const loading = ref(true);
function isCurrentSong(songId: number): boolean { function isCurrentSong(songId: number): boolean {
return player.currentSong?.id === songId; 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 { try {
const jsonStr: string = await invoke('recommend_songs'); const jsonStr: string = await invoke('recommend_songs');
const data = JSON.parse(jsonStr); const data = JSON.parse(jsonStr);
songs.value = data.data?.dailySongs || []; songs.value = (data.data?.dailySongs || []).map(normalizeSong);
pageCacheSet('dailySongs', songs.value);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} finally { } finally {
loading.value = false; loading.value = false;
} }
}
onMounted(loadData);
watch(isOnline, (val, old) => {
if (val && !old && songs.value.length === 0) {
pageCacheInvalidate('dailySongs');
loadData();
}
}); });
</script> </script>

View File

@ -2,7 +2,6 @@
<div class="p-8 text-content"> <div class="p-8 text-content">
<h1 class="text-2xl font-bold mb-4">发现音乐</h1> <h1 class="text-2xl font-bold mb-4">发现音乐</h1>
<!-- 搜索框 -->
<input <input
v-model="keyword" v-model="keyword"
@keyup.enter="handleSearch" @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" 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"> <div v-if="!hasSearched && !loading && hotTags.length" class="mb-6">
<h2 class="text-sm font-semibold mb-3">🔥 热门搜索</h2> <h2 class="text-sm font-semibold mb-3">🔥 热门搜索</h2>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
@ -25,45 +23,19 @@
</div> </div>
</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-if="loading" class="text-content-2">搜索中...</div>
<div v-else class="space-y-3"> <div v-else class="space-y-3">
<div <SongListItem
v-for="(song, index) in results" v-for="(song, index) in results"
:key="song.id" :key="song.id"
@click="playSong(song, index)" :song="song"
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" :index="index"
> show-download
<img :src="song.al?.picUrl" class="w-12 h-12 rounded-lg object-cover" /> show-menu
<div class="flex-1 min-w-0"> cover-size="w-12 h-12"
<p class="font-medium truncate">{{ song.name }}</p> container-class="backdrop-blur-md bg-subtle hover:bg-muted border border-line-2"
<p class="text-sm text-content-2 truncate"> @click="player.playFromList(results, index)"
<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>
<p v-if="!loading && hasSearched && results.length === 0" class="text-content-2">无结果</p> <p v-if="!loading && hasSearched && results.length === 0" class="text-content-2">无结果</p>
</div> </div>
</div> </div>
@ -72,38 +44,43 @@
<script setup lang="ts"> <script setup lang="ts">
defineOptions({ name: 'DiscoverView' }); defineOptions({ name: 'DiscoverView' });
import { ref, onMounted } from 'vue'; import { ref, onMounted, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router'; import { useRouter, useRoute } from 'vue-router';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '../stores/player'; import { usePlayerStore } from '../stores/player';
import { useDownload } from '../composables/useDownload'; import SongListItem from '../components/SongListItem.vue';
import SongItemMenu from '../components/SongItemMenu.vue'; import { normalizeSong, type Song } from '../utils/song';
import { pageCacheGet, pageCacheSet, pageCacheInvalidate } from '../composables/usePageCache';
import { useOnlineStatus } from '../composables/useOnlineStatus';
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
const player = usePlayerStore(); const player = usePlayerStore();
const download = useDownload(); const { isOnline } = useOnlineStatus();
const keyword = ref(''); const keyword = ref('');
const results = ref<any[]>([]); const results = ref<Song[]>([]);
const loading = ref(false); const loading = ref(false);
const hasSearched = ref(false); const hasSearched = ref(false);
const hotTags = ref<any[]>([]); const hotTags = ref<any[]>([]);
const devices = ref<string[]>([]); 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 () => { onMounted(async () => {
// 获取输出设备列表 await loadHotTags();
try { devices.value = await invoke('get_output_devices'); } catch {}
// 获取热门搜索
try {
const json = await invoke('get_hot_search');
const data = JSON.parse(json as string);
hotTags.value = (data.data || []).slice(0, 12);
} catch {}
// 检查路由是否有查询关键词,自动搜索
const q = route.query.q as string; const q = route.query.q as string;
if (q) { if (q) {
keyword.value = q; keyword.value = q;
@ -112,6 +89,13 @@ onMounted(async () => {
} }
}); });
watch(isOnline, (val, old) => {
if (val && !old && hotTags.value.length === 0) {
pageCacheInvalidate('discover_hotTags');
loadHotTags();
}
});
async function handleSearch() { async function handleSearch() {
if (!keyword.value.trim()) return; if (!keyword.value.trim()) return;
loading.value = true; loading.value = true;
@ -119,7 +103,7 @@ async function handleSearch() {
try { try {
const jsonStr: string = await invoke('search_songs', { query: { keyword: keyword.value } }); const jsonStr: string = await invoke('search_songs', { query: { keyword: keyword.value } });
const data = JSON.parse(jsonStr); const data = JSON.parse(jsonStr);
results.value = data.result?.songs || []; results.value = (data.result?.songs || []).map(normalizeSong);
} catch (e) { } catch (e) {
console.error('搜索出错:', e); console.error('搜索出错:', e);
} finally { } finally {
@ -131,16 +115,4 @@ function searchTag(tag: string) {
keyword.value = tag; keyword.value = tag;
handleSearch(); 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> </script>

View File

@ -19,66 +19,52 @@
<div v-else-if="loading" class="text-content-2">加载中...</div> <div v-else-if="loading" class="text-content-2">加载中...</div>
<div v-else-if="songs.length === 0" class="text-content-2">暂无喜欢的音乐</div> <div v-else-if="songs.length === 0" class="text-content-2">暂无喜欢的音乐</div>
<div v-else class="space-y-2"> <div v-else class="space-y-2">
<div <SongListItem
v-for="(song, index) in songs" v-for="(song, index) in songs"
:key="song.id" :key="song.id"
:song="song"
:index="index"
show-index
show-like
show-download
show-menu
show-duration
@click="player.playFromList(songs, index)" @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>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue'; import { ref, onMounted, watch } from 'vue';
import { useRouter } from 'vue-router';
import { invoke } from '@tauri-apps/api/core'; 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 { usePlayerStore } from '../stores/player';
import { useUserStore } from '../stores/user'; import { useUserStore } from '../stores/user';
import { useDownload } from '../composables/useDownload'; import { normalizeSong, type Song } from '../utils/song';
import { normalizeSong } from '../utils/song'; import { pageCacheGet, pageCacheSet, pageCacheInvalidate } from '../composables/usePageCache';
import { formatDuration } from '../utils/format'; import { useOnlineStatus } from '../composables/useOnlineStatus';
defineOptions({ name: 'FavoriteSongsView' });
const player = usePlayerStore(); const player = usePlayerStore();
const userStore = useUserStore(); const userStore = useUserStore();
const download = useDownload(); const { isOnline } = useOnlineStatus();
const router = useRouter(); const songs = ref<Song[]>([]);
const songs = ref<any[]>([]);
const loading = ref(true); const loading = ref(true);
onMounted(async () => { async function loadData() {
if (!userStore.isLoggedIn) { if (!userStore.isLoggedIn) {
loading.value = false; loading.value = false;
return; return;
} }
const cached = pageCacheGet('favoriteSongs');
if (cached) {
songs.value = cached;
loading.value = false;
return;
}
loading.value = true;
try { try {
const playlistJson: string = await invoke('user_playlist', { uid: userStore.user!.userId }); const playlistJson: string = await invoke('user_playlist', { uid: userStore.user!.userId });
const playlistData = JSON.parse(playlistJson); const playlistData = JSON.parse(playlistJson);
@ -91,10 +77,20 @@ onMounted(async () => {
const trackJson: string = await invoke('playlist_track_all', { query: { id: likePlaylistId } }); const trackJson: string = await invoke('playlist_track_all', { query: { id: likePlaylistId } });
const trackData = JSON.parse(trackJson); const trackData = JSON.parse(trackJson);
songs.value = (trackData.songs || []).map(normalizeSong); songs.value = (trackData.songs || []).map(normalizeSong);
pageCacheSet('favoriteSongs', songs.value);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} finally { } finally {
loading.value = false; loading.value = false;
} }
}
onMounted(loadData);
watch(isOnline, (val, old) => {
if (val && !old && userStore.isLoggedIn && songs.value.length === 0) {
pageCacheInvalidate('favoriteSongs');
loadData();
}
}); });
</script> </script>

View File

@ -13,7 +13,7 @@
<p class="text-xs text-white/60 mb-1">📅 {{ todayStr }}</p> <p class="text-xs text-white/60 mb-1">📅 {{ todayStr }}</p>
<h2 class="text-2xl font-bold">每日推荐</h2> <h2 class="text-2xl font-bold">每日推荐</h2>
</div> </div>
<p class="text-xs text-white/60">根据你的口味生成每天 6:00 更新</p> <p class="text-xs text-white/60">根据你的口味生成每天凌晨更新</p>
</div> </div>
<div class="absolute right-4 top-1/2 -translate-y-1/2 text-6xl opacity-20">🎧</div> <div class="absolute right-4 top-1/2 -translate-y-1/2 text-6xl opacity-20">🎧</div>
</div> </div>
@ -80,9 +80,9 @@
<!-- 第二行为你推荐需登录 --> <!-- 第二行为你推荐需登录 -->
<div v-if="userStore.isLoggedIn && recPlaylists.length" class="mb-10"> <div v-if="userStore.isLoggedIn && recPlaylists.length" class="mb-10">
<h2 class="text-xl font-semibold mb-4">🎯 为你推荐</h2> <h2 class="text-xl font-semibold mb-4">🎯 为你推荐</h2>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"> <div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
<div v-for="pl in recPlaylists" :key="pl.id" @click="goPlaylist(pl.id)" <div v-for="pl in recPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
class="bg-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" /> <img :src="pl.picUrl" class="w-full aspect-square object-cover" />
<div class="p-3"> <div class="p-3">
<p class="text-sm font-medium truncate">{{ pl.name }}</p> <p class="text-sm font-medium truncate">{{ pl.name }}</p>
@ -95,9 +95,9 @@
<!-- 第三行热门歌单排行榜 --> <!-- 第三行热门歌单排行榜 -->
<div> <div>
<h2 class="text-xl font-semibold mb-4">📈 热门歌单</h2> <h2 class="text-xl font-semibold mb-4">📈 热门歌单</h2>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"> <div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
<div v-for="pl in rankPlaylists" :key="pl.id" @click="goPlaylist(pl.id)" <div v-for="pl in rankPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
class="bg-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" /> <img :src="pl.coverImgUrl" class="w-full aspect-square object-cover" />
<div class="p-3"> <div class="p-3">
<p class="text-sm font-medium truncate">{{ pl.name }}</p> <p class="text-sm font-medium truncate">{{ pl.name }}</p>
@ -109,15 +109,21 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue'; import { ref, onMounted, watch } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { useUserStore } from '../stores/user'; import { useUserStore } from '../stores/user';
import { usePlayerStore } from '../stores/player'; import { usePlayerStore } from '../stores/player';
import { pageCacheGet, pageCacheSet, pageCacheInvalidate } from '../composables/usePageCache';
import { useOnlineStatus } from '../composables/useOnlineStatus';
import { getCoverUrl } from '../utils/song';
defineOptions({ name: 'HomeView' });
const player = usePlayerStore(); const player = usePlayerStore();
const router = useRouter(); const router = useRouter();
const userStore = useUserStore(); const userStore = useUserStore();
const { isOnline } = useOnlineStatus();
const rankPlaylists = ref<any[]>([]); const rankPlaylists = ref<any[]>([]);
const recPlaylists = ref<any[]>([]); const recPlaylists = ref<any[]>([]);
@ -128,17 +134,15 @@ import { computed } from 'vue';
const fmCoverUrl = computed(() => { const fmCoverUrl = computed(() => {
return player.fmSong?.al?.picUrl || player.fmSong?.album?.picUrl || ''; return getCoverUrl(player.fmSong) || '';
}); });
const fmDisplayName = computed(() => player.fmSong?.name || '私人漫游'); const fmDisplayName = computed(() => player.fmSong?.name || '私人漫游');
const fmDisplayArtists = computed(() => { const fmDisplayArtists = computed(() => {
if (!player.fmSong) return ''; if (!player.fmSong) return '';
return player.fmSong.ar?.map((a: any) => a.name).join(' / ') || return player.fmSong.ar?.map((a: { name: string }) => a.name).join(' / ') || '';
player.fmSong.artists?.map((a: any) => a.name).join(' / ') || '';
}); });
// 首次点击播放按钮:开始 FM 并播放
async function startFmPlay() { async function startFmPlay() {
if (!player.fmSong) { if (!player.fmSong) {
await player.loadFm(); await player.loadFm();
@ -159,11 +163,14 @@ function onFmCardClick() {
player.openRoamDrawer(); player.openRoamDrawer();
} }
onMounted(async () => { async function loadData() {
const d = new Date(); const cached = pageCacheGet('home');
todayStr.value = `${d.getMonth() + 1}${d.getDate()}`; if (cached) {
rankPlaylists.value = cached.rankPlaylists || [];
recPlaylists.value = cached.recPlaylists || [];
return;
}
// 排行榜
const results = await Promise.allSettled( const results = await Promise.allSettled(
RANK_IDS.map(id => invoke('get_playlist_detail', { id })) RANK_IDS.map(id => invoke('get_playlist_detail', { id }))
); );
@ -175,7 +182,6 @@ onMounted(async () => {
}) })
.filter(Boolean); .filter(Boolean);
// 推荐歌单(需登录)
if (userStore.isLoggedIn) { if (userStore.isLoggedIn) {
try { try {
const json = await invoke('recommend_resource'); const json = await invoke('recommend_resource');
@ -183,6 +189,21 @@ onMounted(async () => {
recPlaylists.value = data.recommend || []; recPlaylists.value = data.recommend || [];
} catch { } } catch { }
} }
pageCacheSet('home', { rankPlaylists: rankPlaylists.value, recPlaylists: recPlaylists.value });
}
onMounted(async () => {
const d = new Date();
todayStr.value = `${d.getMonth() + 1}${d.getDate()}`;
await loadData();
});
watch(isOnline, (val, old) => {
if (val && !old && rankPlaylists.value.length === 0 && recPlaylists.value.length === 0) {
pageCacheInvalidate('home');
loadData();
}
}); });
function goDaily() { function goDaily() {

View File

@ -19,46 +19,38 @@
当前文件夹下没有音乐文件支持 mp3flacwavoggaacm4a 格式 当前文件夹下没有音乐文件支持 mp3flacwavoggaacm4a 格式
</div> </div>
<div v-else class="space-y-2"> <div v-else class="space-y-2">
<div <SongListItem
v-for="(song, index) in songs" v-for="(song, index) in normalizedSongs"
:key="song.id + '-' + index" :key="song.id + '-' + index"
@click="playLocalSong(song, index)" :song="song"
class="flex items-center gap-4 p-3 rounded-xl hover:bg-subtle transition cursor-pointer" :index="index"
:class="{ 'bg-subtle': player.currentSong?.id === song.id }" :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> <template #actions>
<div class="w-10 h-10 rounded-lg overflow-hidden flex-shrink-0 bg-muted"> <span class="text-xs text-content-3 flex-shrink-0">{{ formatFileSize(songs[index].fileSize) }}</span>
<img v-if="song.cover" :src="song.cover" class="w-full h-full object-cover" /> <div class="relative flex-shrink-0">
<div v-else class="w-full h-full flex items-center justify-center"> <button
<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> @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 === 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>
</div> </template>
<div class="flex-1 min-w-0"> </SongListItem>
<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>
<div class="relative flex-shrink-0">
<button
@click.stop="toggleMenu(song.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">
<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>
</div> </div>
<Transition name="fade"> <Transition name="fade">
@ -83,12 +75,17 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'; import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { invoke } from '@tauri-apps/api/core'; 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 { useDownload } from '../composables/useDownload';
import { useSettingsStore } from '../stores/settings'; import { useSettingsStore } from '../stores/settings';
import { showToast } from '../composables/useToast'; import { showToast } from '../composables/useToast';
import { pageCacheSet, pageCacheInvalidate } from '../composables/usePageCache';
import SongListItem from '../components/SongListItem.vue';
import type { Song } from '../utils/song';
defineOptions({ name: 'LocalMusicView' });
const player = usePlayerStore(); const player = usePlayerStore();
const download = useDownload(); const download = useDownload();
@ -113,6 +110,8 @@ const showDeleteConfirm = ref(false);
const deleteTarget = ref<LocalSong | null>(null); const deleteTarget = ref<LocalSong | null>(null);
const openMenuId = ref<number | null>(null); const openMenuId = ref<number | null>(null);
const normalizedSongs = computed(() => songs.value.map(toSong));
function toggleMenu(id: number) { function toggleMenu(id: number) {
openMenuId.value = openMenuId.value === id ? null : id; openMenuId.value = openMenuId.value === id ? null : id;
} }
@ -126,9 +125,11 @@ onBeforeUnmount(() => { document.removeEventListener('click', closeMenu); });
async function refresh() { async function refresh() {
loading.value = true; loading.value = true;
pageCacheInvalidate('localMusic');
try { try {
const list = await invoke<LocalSong[]>('list_local_songs', { downloadPath: settings.downloadPath || null }); const list = await invoke<LocalSong[]>('list_local_songs', { downloadPath: settings.downloadPath || null });
songs.value = list; songs.value = list;
pageCacheSet('localMusic', list);
fetchMissingCovers(); fetchMissingCovers();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@ -165,14 +166,6 @@ function formatFileSize(bytes: number): string {
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i]; 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 { function toSong(local: LocalSong): Song {
return { return {
id: local.id, id: local.id,
@ -180,18 +173,10 @@ function toSong(local: LocalSong): Song {
ar: local.artist ? [{ name: local.artist }] : [], ar: local.artist ? [{ name: local.artist }] : [],
al: { picUrl: local.cover || '', name: local.album || undefined }, al: { picUrl: local.cover || '', name: local.album || undefined },
dt: local.duration || 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, 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) { function confirmDelete(song: LocalSong) {
openMenuId.value = null; openMenuId.value = null;
deleteTarget.value = song; deleteTarget.value = song;

View File

@ -88,7 +88,7 @@ function startPolling() {
statusColor.value = 'text-content-2'; statusColor.value = 'text-content-2';
} else if (code === 802) { } else if (code === 802) {
statusText.value = '请在手机上确认登录'; statusText.value = '请在手机上确认登录';
statusColor.value = 'text-warning'; statusColor.value = 'text-yellow-400';
} else if (code === 803) { } else if (code === 803) {
clearInterval(pollTimer!); clearInterval(pollTimer!);
statusText.value = '登录成功!'; statusText.value = '登录成功!';

View File

@ -45,52 +45,37 @@
<div v-if="loading" class="text-content-2">加载中...</div> <div v-if="loading" class="text-content-2">加载中...</div>
<div v-else class="space-y-1"> <div v-else class="space-y-1">
<div <SongListItem
v-for="(song, index) in songs" v-for="(song, index) in songs"
:key="song.id" :key="song.id"
@click="playSingle(song)" :song="song"
class="flex items-center gap-4 p-3 rounded-xl hover:bg-subtle transition cursor-pointer group" :index="index"
:class="{ 'bg-accent-dim': isCurrentSong(song.id) }" :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)"
> >
<div class="w-6 text-right flex-shrink-0 flex items-center justify-end h-5"> <template #index="{ index: idx, isCurrent }">
<div v-if="isCurrentSong(song.id)" class="flex items-center justify-end"> <div class="w-6 text-right flex-shrink-0 flex items-center justify-end h-5">
<div class="flex items-center gap-[3px] h-4"> <div v-if="isCurrent" class="flex items-center justify-end">
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 50%; animation-delay: 0ms"></span> <div class="flex items-center gap-[3px] h-4">
<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: 50%; animation-delay: 0ms"></span>
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 35%; animation-delay: 300ms"></span> <span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 100%; animation-delay: 150ms"></span>
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 35%; animation-delay: 300ms"></span>
</div>
</div> </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> </div>
<template v-else> </template>
<span class="text-xs text-content-3 group-hover:hidden">{{ index + 1 }}</span> </SongListItem>
<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>
</div> </div>
<div v-if="playlist" class="mt-8"> <div v-if="playlist" class="mt-8">
@ -101,24 +86,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'; 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 { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '../stores/player'; import { usePlayerStore } from '../stores/player';
import { useUserStore } from '../stores/user'; import { useUserStore } from '../stores/user';
import { useDownload } from '../composables/useDownload';
import { showToast } from '../composables/useToast'; import { showToast } from '../composables/useToast';
import { formatDuration, formatPlayCount } from '../utils/format'; import { formatPlayCount } from '../utils/format';
import SongItemMenu from '../components/SongItemMenu.vue'; import { normalizeSong, type Song } from '../utils/song';
import SongListItem from '../components/SongListItem.vue';
import CommentSection from '../components/CommentSection.vue'; import CommentSection from '../components/CommentSection.vue';
const route = useRoute(); const route = useRoute();
const router = useRouter();
const player = usePlayerStore(); const player = usePlayerStore();
const userStore = useUserStore(); const userStore = useUserStore();
const download = useDownload();
const playlist = ref<any>(null); const playlist = ref<any>(null);
const songs = ref<any[]>([]); const songs = ref<Song[]>([]);
const loading = ref(true); const loading = ref(true);
const subscribed = ref(false); const subscribed = ref(false);
@ -135,7 +118,7 @@ async function fetchPlaylist(id: number) {
const jsonStr: string = await invoke('get_playlist_detail', { id }); const jsonStr: string = await invoke('get_playlist_detail', { id });
const data = JSON.parse(jsonStr); const data = JSON.parse(jsonStr);
playlist.value = data.playlist; playlist.value = data.playlist;
songs.value = data.playlist.tracks || []; songs.value = (data.playlist.tracks || []).map(normalizeSong);
subscribed.value = data.playlist.subscribed || false; subscribed.value = data.playlist.subscribed || false;
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@ -153,15 +136,6 @@ watch(() => route.params.id, (newId) => {
if (newId) fetchPlaylist(Number(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() { function playAll() {
if (songs.value.length === 0) return; if (songs.value.length === 0) return;
player.playAll(songs.value); player.playAll(songs.value);

View File

@ -6,51 +6,44 @@
<h1 class="text-2xl font-bold mb-6">最近播放</h1> <h1 class="text-2xl font-bold mb-6">最近播放</h1>
<div v-if="player.recentLocal.length === 0" class="text-content-3">还没有播放记录去听首歌吧</div> <div v-if="player.recentLocal.length === 0" class="text-content-3">还没有播放记录去听首歌吧</div>
<div v-else class="space-y-2"> <div v-else class="space-y-2">
<div <SongListItem
v-for="(song, index) in player.recentLocal" v-for="(song, index) in player.recentLocal"
:key="song.id" :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)" @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> <template #index="{ index: idx, isCurrent }">
<img :src="song.al?.picUrl" class="w-10 h-10 rounded object-cover" /> <div class="w-6 text-right flex-shrink-0 flex items-center justify-end h-5">
<div class="flex-1 min-w-0"> <div v-if="isCurrent" class="flex items-center justify-end">
<p class="text-sm font-medium truncate">{{ song.name }}</p> <div class="flex items-center gap-[3px] h-4">
<p class="text-xs text-content-2 truncate"> <span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 50%; animation-delay: 0ms"></span>
<template v-for="(a, i) in song.ar || []" :key="a.id || i"> <span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 100%; animation-delay: 150ms"></span>
<span v-if="i > 0" class="text-content-3">/</span> <span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 35%; animation-delay: 300ms"></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> </div>
</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> </template>
<template v-if="song.al?.name"> </div>
<span class="text-content-3 mx-1">·</span> </template>
<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> </SongListItem>
</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 ?? 0) }}</span>
</div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { usePlayerStore } from '../stores/player'; import { usePlayerStore } from '../stores/player';
import { useDownload } from '../composables/useDownload'; import SongListItem from '../components/SongListItem.vue';
import { formatDuration } from '../utils/format';
import { useRouter } from 'vue-router';
import SongItemMenu from '../components/SongItemMenu.vue';
const player = usePlayerStore(); const player = usePlayerStore();
const download = useDownload();
const router = useRouter();
</script> </script>

View File

@ -64,11 +64,13 @@
import { ref, computed, watch, onMounted } from 'vue'; import { ref, computed, watch, onMounted } from 'vue';
import { usePlayerStore } from '../stores/player'; import { usePlayerStore } from '../stores/player';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { normalizeSong } from '../utils/song'; import { normalizeSong, getCoverUrl } from '../utils/song';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useOnlineStatus } from '../composables/useOnlineStatus';
const player = usePlayerStore(); const player = usePlayerStore();
const router = useRouter(); const router = useRouter();
const { isOnline } = useOnlineStatus();
const coverError = ref(false); const coverError = ref(false);
const currentSong = computed(() => { const currentSong = computed(() => {
@ -80,7 +82,7 @@ const currentSong = computed(() => {
const coverUrl = computed(() => { const coverUrl = computed(() => {
if (!currentSong.value) return ''; if (!currentSong.value) return '';
return currentSong.value.al?.picUrl || currentSong.value.album?.picUrl || ''; return getCoverUrl(currentSong.value) || '';
}); });
watch(coverUrl, () => { coverError.value = false; }); watch(coverUrl, () => { coverError.value = false; });
@ -109,4 +111,10 @@ async function startFm() {
async function nextSong() { async function nextSong() {
await startFm(); await startFm();
} }
watch(isOnline, (val, old) => {
if (val && !old && !currentSong.value) {
startFm();
}
});
</script> </script>

View File

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

View File

@ -105,18 +105,18 @@
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<button <button
v-if="sc.key !== defaultShortcuts[id]?.key" 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" class="w-6 h-6 flex items-center justify-center rounded-md text-content-4 hover:text-danger hover:bg-danger/10 transition"
title="恢复默认" 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> <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>
<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="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> </button>
</div> </div>
</div> </div>
@ -361,6 +361,7 @@ function formatShortcut(key: string): string {
.replace('ArrowRight', '→') .replace('ArrowRight', '→')
.replace('ArrowUp', '↑') .replace('ArrowUp', '↑')
.replace('ArrowDown', '↓') .replace('ArrowDown', '↓')
.replace(/Key([A-Z])/g, '$1')
.replace(/\+/g, ' + '); .replace(/\+/g, ' + ');
} }