mirror of
https://github.com/atdunbg/Nekosonic-Music.git
synced 2026-06-22 00:58:51 +08:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d718ee5b42 | |||
| 966825c885 | |||
| e8efc7275a | |||
| 0740d9be29 | |||
| d2546ca93c | |||
| 29df8ca491 | |||
| 3158338d0b | |||
| 79fb001ae7 | |||
| fd4bbb4a0a | |||
| 3b800e451f | |||
| 718d3ed641 | |||
| 02f7df4201 |
81
.github/workflows/release.yml
vendored
Normal file
81
.github/workflows/release.yml
vendored
Normal file
@ -0,0 +1,81 @@
|
||||
name: Release with Updater
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
platform: linux
|
||||
- os: windows-latest
|
||||
platform: windows
|
||||
- os: macos-latest
|
||||
platform: macos
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install Linux dependencies
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libasound2-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
build-essential \
|
||||
curl \
|
||||
wget \
|
||||
file \
|
||||
libssl-dev \
|
||||
libgtk-3-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Extract release notes for version
|
||||
id: release_notes
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION="${{ github.ref_name }}"
|
||||
# 从 CHANGELOG.md 中提取 "## v0.3.0" 到下一个 "## " 之间的内容
|
||||
NOTES=$(awk -v ver="## $VERSION" '$0 ~ ver {flag=1; next} /^## / && flag {exit} flag' CHANGELOG.md)
|
||||
|
||||
# 如果没有提取到内容,使用默认文本
|
||||
if [ -z "$NOTES" ]; then
|
||||
NOTES="See CHANGELOG.md for details."
|
||||
fi
|
||||
|
||||
# 将多行内容写入环境变量(GitHub Actions 支持多行输入)
|
||||
{
|
||||
echo 'NOTES<<EOF'
|
||||
echo "$NOTES"
|
||||
echo EOF
|
||||
} >> $GITHUB_ENV
|
||||
|
||||
- name: Build and publish with Tauri Action
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
with:
|
||||
tagName: ${{ github.ref_name }}
|
||||
releaseName: 'v__VERSION__'
|
||||
releaseBody: ${{ env.NOTES }}
|
||||
releaseDraft: false
|
||||
prerelease: false
|
||||
70
CHANGELOG.md
Normal file
70
CHANGELOG.md
Normal file
@ -0,0 +1,70 @@
|
||||
## v0.4.0
|
||||
|
||||
### ✨ 新功能
|
||||
- 添加歌曲的艺术家入口,歌曲的艺术家现可点击查看其他歌曲、专辑和介绍
|
||||
- 添加歌曲的评论查看功能
|
||||
|
||||
### 🐛 修复
|
||||
- 修复私人漫游自动播放下一首调用多次问题
|
||||
|
||||
### ⚡ 优化
|
||||
- 优化播放逻辑,歌曲列表在点击时候不再单首累加,而是直接获取当前列表所有的歌曲作为播放内容
|
||||
|
||||
|
||||
## v0.3.0
|
||||
|
||||
### ✨ 新功能
|
||||
- **本地音乐页面**:支持浏览、播放本地歌曲,横向菜单添加「从磁盘删除」功能
|
||||
- **下载系统**:支持下载歌曲到自定义路径,保存完整元数据(封面/专辑/时长)
|
||||
- **封面补全**:本地音乐缺少封面时尝试从网易云 API 获取
|
||||
- **更新信息**:添加查看最新版更新日志按钮
|
||||
- **下载路径**:支持自定义下载路径
|
||||
- **本地音乐**:支持本地音乐播放
|
||||
- **下载提示**:下载进度与完成提示
|
||||
- **快捷键绑定**:支持自定义全局和本地快捷键(播放/暂停、上一首/下一首、音量调节)
|
||||
|
||||
### 🐛 修复
|
||||
- 修复私人漫游播完一首歌后跳三首的问题
|
||||
- 修复全屏漫游抽屉和漫游页面无封面歌曲显示破损图片
|
||||
- 修复 PlayerBar 无封面歌曲显示破损图片
|
||||
- 修复播放网络歌曲时进度条先走但无声音
|
||||
|
||||
### ⚡ 优化
|
||||
- **流式播放**:边下载边播放,缓冲 64KB 后即刻开始,无需等待完整下载
|
||||
|
||||
|
||||
## v0.2.0
|
||||
|
||||
### 🎵 播放
|
||||
- 优化私人漫游(个人 FM)功能
|
||||
- 新增歌曲喜欢/取消喜欢红心
|
||||
- 新增播放历史本地记录(最近 200 首)
|
||||
|
||||
### 📋 歌单
|
||||
- 修改逻辑 我的歌单 不再显示收藏按钮
|
||||
- 收藏歌单支持取消收藏
|
||||
- 实现我的音乐功能
|
||||
- 实现历史播放记录功能
|
||||
|
||||
### 🎨 外观
|
||||
- 全局复选框与选择框优化
|
||||
- 部分UI优化,统一风格
|
||||
|
||||
### 🖥️ 窗口
|
||||
- 关闭窗口弹出确认弹窗:最小化到托盘 / 退出程序
|
||||
- 支持"不再询问"选项,可在设置中修改
|
||||
- 修复退出时 WebView2 报错(Error 1410)
|
||||
- 修复歌词抽屉全屏时候顶栏无法接收事件问题
|
||||
|
||||
### 💾 持久化
|
||||
- Cookie 存储迁移至 Tauri app_data_dir
|
||||
- 播放历史持久化到 localStorage
|
||||
|
||||
### ⚙️ 其他
|
||||
- 添加设置功能
|
||||
- 关于添加链接可直接访问仓库
|
||||
|
||||
|
||||
## v0.1.0
|
||||
|
||||
Nekosonic 是一款基于 Tauri 2 + Rust 的跨平台桌面音乐播放器,音源主要来自网易云音乐,开箱即用。
|
||||
49
package-lock.json
generated
49
package-lock.json
generated
@ -1,15 +1,19 @@
|
||||
{
|
||||
"name": "demo",
|
||||
"version": "0.1.0",
|
||||
"name": "nekosonic",
|
||||
"version": "0.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "demo",
|
||||
"version": "0.1.0",
|
||||
"name": "nekosonic",
|
||||
"version": "0.3.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"axios": "^1.16.0",
|
||||
"howler": "^2.2.4",
|
||||
"pinia": "^3.0.4",
|
||||
@ -1485,6 +1489,24 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-dialog": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmmirror.com/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz",
|
||||
"integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-global-shortcut": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/@tauri-apps/plugin-global-shortcut/-/plugin-global-shortcut-2.3.1.tgz",
|
||||
"integrity": "sha512-vr40W2N6G63dmBPaha1TsBQLLURXG538RQbH5vAm0G/ovVZyXJrmZR1HF1W+WneNloQvwn4dm8xzwpEXRW560g==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-opener": {
|
||||
"version": "2.5.4",
|
||||
"resolved": "https://registry.npmmirror.com/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz",
|
||||
@ -1494,6 +1516,24 @@
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-process": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz",
|
||||
"integrity": "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-updater": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmmirror.com/@tauri-apps/plugin-updater/-/plugin-updater-2.10.1.tgz",
|
||||
"integrity": "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz",
|
||||
@ -1507,7 +1547,6 @@
|
||||
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.19.0"
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "nekosonic",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@ -11,7 +11,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"axios": "^1.16.0",
|
||||
"howler": "^2.2.4",
|
||||
"pinia": "^3.0.4",
|
||||
|
||||
700
src-tauri/Cargo.lock
generated
700
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "Nekosonic"
|
||||
version = "0.1.0"
|
||||
version = "0.4.0"
|
||||
description = "A Simple music app"
|
||||
authors = ["atdunbg"]
|
||||
edition = "2021"
|
||||
@ -20,12 +20,21 @@ tauri-build = { version = "2", features = [] }
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
rodio = "0.20"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
cpal = { version = "0.15" }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls", "stream"] }
|
||||
futures-util = "0.3"
|
||||
dirs = "5"
|
||||
lofty = "0.22"
|
||||
base64 = "0.22"
|
||||
|
||||
ncm-api-rs = "0.1"
|
||||
tokio = { version = "1", features = ["rt", "sync"] }
|
||||
tauri-plugin-process = "2.3.1"
|
||||
tauri-plugin-updater = "2"
|
||||
|
||||
|
||||
@ -12,6 +12,15 @@
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-toggle-maximize"
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-unminimize",
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-set-focus",
|
||||
"global-shortcut:allow-is-registered",
|
||||
"global-shortcut:allow-register",
|
||||
"global-shortcut:allow-unregister",
|
||||
"dialog:allow-open",
|
||||
"process:allow-restart",
|
||||
"updater:default"
|
||||
]
|
||||
}
|
||||
|
||||
@ -1,12 +1,18 @@
|
||||
use ncm_api_rs::{create_client, ApiClient, Query};
|
||||
use serde::Deserialize;
|
||||
use tauri::{Manager, State};
|
||||
use serde_json::json;
|
||||
use tauri::{Manager, State, Emitter};
|
||||
use tokio::sync::Mutex;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::io::Write;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use lofty::file::{AudioFile, TaggedFileExt};
|
||||
use lofty::tag::Accessor;
|
||||
use base64::Engine;
|
||||
|
||||
pub struct ApiController {
|
||||
client: Mutex<ApiClient>,
|
||||
@ -103,21 +109,54 @@ pub async fn playlist_track_all(query: PlaylistTrackAllQuery, state: State<'_, A
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SongUrlQuery { pub id: u64, pub level: Option<String> }
|
||||
pub struct SongUrlQuery { pub id: u64, pub level: Option<String>, pub fm_mode: Option<bool> }
|
||||
|
||||
/// 获取歌曲播放地址
|
||||
/// 获取歌曲播放地址(返回完整 data 对象,包含 url、freeTrialInfo 等)
|
||||
#[tauri::command]
|
||||
pub async fn get_song_url(query: SongUrlQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let level = query.level.as_deref().unwrap_or("standard");
|
||||
|
||||
let resp = if query.fm_mode.unwrap_or(false) {
|
||||
let mut fm_cookie = state.cookie.lock().ok().and_then(|g| g.clone()).unwrap_or_default();
|
||||
if !fm_cookie.contains("os=") {
|
||||
fm_cookie = format!("{}; os=android; appver=8.10.05", fm_cookie);
|
||||
}
|
||||
let data = serde_json::json!({
|
||||
"ids": format!("[{}]", query.id),
|
||||
"level": level,
|
||||
"encodeType": "flac",
|
||||
"feeProcess": "true"
|
||||
});
|
||||
let option = ncm_api_rs::request::RequestOption {
|
||||
crypto: ncm_api_rs::request::CryptoType::default(),
|
||||
cookie: Some(fm_cookie),
|
||||
ua: None,
|
||||
proxy: None,
|
||||
real_ip: None,
|
||||
random_cn_ip: false,
|
||||
e_r: None,
|
||||
domain: None,
|
||||
check_token: false,
|
||||
};
|
||||
client.request(
|
||||
"/api/song/enhance/player/url/v1",
|
||||
data,
|
||||
option,
|
||||
).await.map_err(|e| e.to_string())?
|
||||
} else {
|
||||
let q = state.build_query()
|
||||
.param("id", &query.id.to_string())
|
||||
.param("level", level);
|
||||
let resp = client.song_url_v1(&q).await.map_err(|e| e.to_string())?;
|
||||
resp.body["data"][0]["url"].as_str()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| "暂无播放源".into())
|
||||
client.song_url_v1(&q).await.map_err(|e| e.to_string())?
|
||||
};
|
||||
|
||||
let data = &resp.body["data"][0];
|
||||
let url = data["url"].as_str().filter(|s| !s.is_empty());
|
||||
if url.is_none() {
|
||||
return Err("暂无播放源".into());
|
||||
}
|
||||
Ok(data.to_string())
|
||||
}
|
||||
|
||||
/// 获取歌词
|
||||
@ -340,3 +379,535 @@ pub async fn exit_app(app_handle: tauri::AppHandle) {
|
||||
let _ = window.close();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LocalSongInfo {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
pub artist: String,
|
||||
pub album: String,
|
||||
pub duration: u64,
|
||||
pub cover: Option<String>,
|
||||
pub filename: String,
|
||||
pub file_size: u64,
|
||||
pub path: String,
|
||||
pub local: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DownloadSongQuery {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
pub artist: String,
|
||||
pub album: Option<String>,
|
||||
pub duration: Option<u64>,
|
||||
pub cover_url: Option<String>,
|
||||
pub level: Option<String>,
|
||||
pub download_path: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn download_song(
|
||||
app_handle: tauri::AppHandle,
|
||||
query: DownloadSongQuery,
|
||||
state: State<'_, ApiController>,
|
||||
) -> Result<String, String> {
|
||||
let level = query.level.as_deref().unwrap_or("standard");
|
||||
|
||||
let q = state.build_query()
|
||||
.param("id", &query.id.to_string())
|
||||
.param("level", level);
|
||||
let client = state.client.lock().await;
|
||||
let resp = client.song_url_v1(&q).await.map_err(|e| e.to_string())?;
|
||||
let data = &resp.body["data"][0];
|
||||
let url = data["url"].as_str().filter(|s| !s.is_empty());
|
||||
if url.is_none() {
|
||||
let free_trial = data.get("freeTrialInfo");
|
||||
if free_trial.is_some() && !free_trial.unwrap().is_null() {
|
||||
return Err("VIP歌曲无法下载".into());
|
||||
}
|
||||
return Err("暂无下载源,可能需要 VIP 权限".into());
|
||||
}
|
||||
let url = url.unwrap();
|
||||
let free_trial = data.get("freeTrialInfo");
|
||||
if free_trial.is_some() && !free_trial.unwrap().is_null() {
|
||||
return Err("VIP歌曲无法下载".into());
|
||||
}
|
||||
let ext = if url.contains(".flac") { "flac" } else { "mp3" };
|
||||
drop(client);
|
||||
|
||||
let download_dir = resolve_download_dir(&app_handle, query.download_path.as_deref());
|
||||
let _ = fs::create_dir_all(&download_dir);
|
||||
|
||||
let safe_name = sanitize_filename(&query.name);
|
||||
let safe_artist = sanitize_filename(&query.artist);
|
||||
let filename = format!("{} - {}.{}", safe_artist, safe_name, ext);
|
||||
let filepath = download_dir.join(&filename);
|
||||
|
||||
if filepath.exists() {
|
||||
return Err("文件已存在".into());
|
||||
}
|
||||
|
||||
let resp = reqwest::get(url).await.map_err(|e| format!("下载失败: {}", e))?;
|
||||
let total_size = resp.content_length().unwrap_or(0);
|
||||
let mut downloaded: u64 = 0;
|
||||
|
||||
let temp_path = filepath.with_extension(format!("{}.tmp", ext));
|
||||
let mut file = fs::File::create(&temp_path).map_err(|e| format!("创建文件失败: {}", e))?;
|
||||
|
||||
let mut stream = resp.bytes_stream();
|
||||
use futures_util::StreamExt;
|
||||
let mut chunk_count: u64 = 0;
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|e| format!("读取失败: {}", e))?;
|
||||
file.write_all(&chunk).map_err(|e| format!("写入失败: {}", e))?;
|
||||
downloaded += chunk.len() as u64;
|
||||
chunk_count += 1;
|
||||
if chunk_count % 8 == 0 || downloaded == total_size {
|
||||
let progress = if total_size > 0 {
|
||||
(downloaded as f64 / total_size as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let _ = app_handle.emit("download-progress", json!({
|
||||
"id": query.id,
|
||||
"progress": progress,
|
||||
"name": query.name,
|
||||
}));
|
||||
}
|
||||
}
|
||||
drop(file);
|
||||
|
||||
fs::rename(&temp_path, &filepath).map_err(|e| format!("重命名失败: {}", e))?;
|
||||
|
||||
let meta = json!({
|
||||
"id": query.id,
|
||||
"name": query.name,
|
||||
"artist": query.artist,
|
||||
"album": query.album,
|
||||
"duration": query.duration,
|
||||
"coverUrl": query.cover_url,
|
||||
"filename": filename,
|
||||
});
|
||||
let meta_path = download_dir.join(format!("{}.json", query.id));
|
||||
let mut meta_file = fs::File::create(&meta_path).map_err(|e| format!("创建元数据失败: {}", e))?;
|
||||
meta_file.write_all(serde_json::to_string_pretty(&meta).unwrap().as_bytes())
|
||||
.map_err(|e| format!("写入元数据失败: {}", e))?;
|
||||
|
||||
let _ = app_handle.emit("download-progress", json!({
|
||||
"id": query.id,
|
||||
"progress": 100.0,
|
||||
"name": query.name,
|
||||
}));
|
||||
|
||||
Ok(filename)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_local_songs(app_handle: tauri::AppHandle, download_path: Option<String>) -> Result<Vec<LocalSongInfo>, String> {
|
||||
let download_dir = resolve_download_dir(&app_handle, download_path.as_deref());
|
||||
if !download_dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let audio_exts = ["mp3", "flac", "wav", "ogg", "aac", "m4a", "wma", "opus"];
|
||||
|
||||
let mut meta_map: std::collections::HashMap<String, serde_json::Value> = std::collections::HashMap::new();
|
||||
let entries = fs::read_dir(&download_dir).map_err(|e| format!("读取目录失败: {}", e))?;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().map_or(false, |e| e == "json") {
|
||||
if let Ok(content) = fs::read_to_string(&path) {
|
||||
if let Ok(meta) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
if let Some(filename) = meta["filename"].as_str() {
|
||||
meta_map.insert(filename.to_string(), meta);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut songs: Vec<LocalSongInfo> = Vec::new();
|
||||
let entries = fs::read_dir(&download_dir).map_err(|e| format!("读取目录失败: {}", e))?;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
|
||||
if !audio_exts.contains(&ext.to_lowercase().as_str()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let filename = path.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||||
let file_size = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
|
||||
|
||||
let (title, artist, album, duration_ms, cover_b64) = read_audio_metadata(&path);
|
||||
|
||||
if let Some(meta) = meta_map.get(&filename) {
|
||||
let meta_title = meta["name"].as_str().unwrap_or("");
|
||||
let meta_artist = meta["artist"].as_str().unwrap_or("");
|
||||
let meta_album = meta["album"].as_str().unwrap_or("");
|
||||
let meta_duration = meta["duration"].as_u64().unwrap_or(0);
|
||||
let meta_cover_url = meta["coverUrl"].as_str().unwrap_or("");
|
||||
|
||||
let final_title = if title.is_empty() { meta_title.to_string() } else { title };
|
||||
let final_artist = if artist.is_empty() { meta_artist.to_string() } else { artist };
|
||||
let final_album = if album.is_empty() { meta_album.to_string() } else { album };
|
||||
let final_duration = if duration_ms == 0 { meta_duration } else { duration_ms };
|
||||
let final_cover = cover_b64.or_else(|| {
|
||||
if meta_cover_url.is_empty() { None } else { Some(meta_cover_url.to_string()) }
|
||||
});
|
||||
|
||||
songs.push(LocalSongInfo {
|
||||
id: meta["id"].as_u64().unwrap_or(0),
|
||||
name: final_title,
|
||||
artist: final_artist,
|
||||
album: final_album,
|
||||
duration: final_duration,
|
||||
cover: final_cover,
|
||||
filename,
|
||||
file_size,
|
||||
path: path.to_string_lossy().to_string(),
|
||||
local: true,
|
||||
});
|
||||
} else {
|
||||
let stem = path.file_stem().unwrap_or_default().to_string_lossy().to_string();
|
||||
let (parsed_artist, parsed_name) = parse_filename(&stem);
|
||||
let final_title = if title.is_empty() { parsed_name } else { title };
|
||||
let final_artist = if artist.is_empty() { parsed_artist } else { artist };
|
||||
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
filename.hash(&mut hasher);
|
||||
let hash_id = hasher.finish();
|
||||
|
||||
songs.push(LocalSongInfo {
|
||||
id: hash_id,
|
||||
name: final_title,
|
||||
artist: final_artist,
|
||||
album,
|
||||
duration: duration_ms,
|
||||
cover: cover_b64,
|
||||
filename,
|
||||
file_size,
|
||||
path: path.to_string_lossy().to_string(),
|
||||
local: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(songs)
|
||||
}
|
||||
|
||||
fn read_audio_metadata(path: &PathBuf) -> (String, String, String, u64, Option<String>) {
|
||||
match lofty::read_from_path(path) {
|
||||
Ok(tagged_file) => {
|
||||
let properties = tagged_file.properties();
|
||||
let duration_ms = properties.duration().as_millis() as u64;
|
||||
|
||||
let tag = tagged_file.primary_tag();
|
||||
let (title, artist, album) = if let Some(t) = tag {
|
||||
let title = t.title().map(|s| s.to_string()).unwrap_or_default();
|
||||
let artist = t.artist().map(|s| s.to_string()).unwrap_or_default();
|
||||
let album = t.album().map(|s| s.to_string()).unwrap_or_default();
|
||||
(title, artist, album)
|
||||
} else {
|
||||
(String::new(), String::new(), String::new())
|
||||
};
|
||||
|
||||
let cover_b64 = if let Some(t) = tag {
|
||||
if let Some(pic) = t.pictures().first() {
|
||||
let data = pic.data();
|
||||
let mime = pic.mime_type().map(|m| m.to_string()).unwrap_or_else(|| "image/jpeg".to_string());
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(data);
|
||||
Some(format!("data:{};base64,{}", mime, b64))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
(title, artist, album, duration_ms, cover_b64)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[api] 读取音频元数据失败 {}: {}", path.display(), e);
|
||||
(String::new(), String::new(), String::new(), 0, None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_filename(stem: &str) -> (String, String) {
|
||||
if let Some(pos) = stem.find(" - ") {
|
||||
let artist = &stem[..pos];
|
||||
let name = &stem[pos + 3..];
|
||||
(artist.trim().to_string(), name.trim().to_string())
|
||||
} else if let Some(pos) = stem.find('-') {
|
||||
let artist = &stem[..pos];
|
||||
let name = &stem[pos + 1..];
|
||||
(artist.trim().to_string(), name.trim().to_string())
|
||||
} else {
|
||||
("".to_string(), stem.trim().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeleteLocalSongQuery {
|
||||
pub id: u64,
|
||||
pub filename: String,
|
||||
pub download_path: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_local_song(
|
||||
app_handle: tauri::AppHandle,
|
||||
query: DeleteLocalSongQuery,
|
||||
) -> Result<(), String> {
|
||||
let download_dir = resolve_download_dir(&app_handle, query.download_path.as_deref());
|
||||
let file_path = download_dir.join(&query.filename);
|
||||
let meta_path = download_dir.join(format!("{}.json", query.id));
|
||||
|
||||
if file_path.exists() {
|
||||
fs::remove_file(&file_path).map_err(|e| format!("删除文件失败: {}", e))?;
|
||||
}
|
||||
if meta_path.exists() {
|
||||
fs::remove_file(&meta_path).map_err(|e| format!("删除元数据失败: {}", e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn check_local_song(app_handle: tauri::AppHandle, id: u64, download_path: Option<String>) -> Result<bool, String> {
|
||||
let download_dir = resolve_download_dir(&app_handle, download_path.as_deref());
|
||||
let meta_path = download_dir.join(format!("{}.json", id));
|
||||
Ok(meta_path.exists())
|
||||
}
|
||||
|
||||
fn resolve_download_dir(app_handle: &tauri::AppHandle, custom_path: Option<&str>) -> PathBuf {
|
||||
if let Some(path) = custom_path {
|
||||
if !path.is_empty() {
|
||||
return PathBuf::from(path);
|
||||
}
|
||||
}
|
||||
get_default_download_dir(app_handle)
|
||||
}
|
||||
|
||||
fn get_default_download_dir(app_handle: &tauri::AppHandle) -> PathBuf {
|
||||
if let Ok(dir) = app_handle.path().app_data_dir() {
|
||||
let download_dir = dir.join("downloads");
|
||||
return download_dir;
|
||||
}
|
||||
let music_dir = dirs::audio_dir().unwrap_or_else(|| {
|
||||
std::env::var("HOME")
|
||||
.or_else(|_| std::env::var("USERPROFILE"))
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| PathBuf::from("."))
|
||||
});
|
||||
music_dir.join("Nekosonic")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_default_download_path(app_handle: tauri::AppHandle) -> String {
|
||||
get_default_download_dir(&app_handle).to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
fn sanitize_filename(name: &str) -> String {
|
||||
name.chars()
|
||||
.map(|c| {
|
||||
if c == '/' || c == '\\' || c == ':' || c == '*' || c == '?'
|
||||
|| c == '"' || c == '<' || c == '>' || c == '|'
|
||||
{
|
||||
'_'
|
||||
} else {
|
||||
c
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn artist_detail(id: u64, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let q = state.build_query().param("id", &id.to_string());
|
||||
client.artist_detail(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn artist_songs(query: ArtistSongsQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let mut q = state.build_query().param("id", &query.id.to_string());
|
||||
if let Some(ref order) = query.order {
|
||||
q = q.param("order", order);
|
||||
}
|
||||
if let Some(limit) = query.limit {
|
||||
q = q.param("limit", &limit.to_string());
|
||||
}
|
||||
if let Some(offset) = query.offset {
|
||||
q = q.param("offset", &offset.to_string());
|
||||
}
|
||||
client.artist_songs(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ArtistSongsQuery {
|
||||
pub id: u64,
|
||||
pub order: Option<String>,
|
||||
pub limit: Option<u32>,
|
||||
pub offset: Option<u32>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn artist_album(id: u64, limit: Option<u32>, offset: Option<u32>, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let mut q = state.build_query().param("id", &id.to_string());
|
||||
if let Some(limit) = limit {
|
||||
q = q.param("limit", &limit.to_string());
|
||||
}
|
||||
if let Some(offset) = offset {
|
||||
q = q.param("offset", &offset.to_string());
|
||||
}
|
||||
client.artist_album(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn artist_desc(id: u64, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let q = state.build_query().param("id", &id.to_string());
|
||||
client.artist_desc(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn album_detail(id: u64, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let q = state.build_query().param("id", &id.to_string());
|
||||
client.album(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn comment_new(query: CommentNewQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let mut q = state.build_query()
|
||||
.param("type", &query.r#type.to_string())
|
||||
.param("id", &query.id.to_string());
|
||||
if let Some(sort_type) = query.sort_type {
|
||||
q = q.param("sortType", &sort_type.to_string());
|
||||
}
|
||||
if let Some(page_no) = query.page_no {
|
||||
q = q.param("pageNo", &page_no.to_string());
|
||||
}
|
||||
if let Some(page_size) = query.page_size {
|
||||
q = q.param("pageSize", &page_size.to_string());
|
||||
}
|
||||
if let Some(cursor) = query.cursor {
|
||||
q = q.param("cursor", &cursor.to_string());
|
||||
}
|
||||
client.comment_new(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CommentNewQuery {
|
||||
pub r#type: u8,
|
||||
pub id: u64,
|
||||
#[serde(rename = "sortType")]
|
||||
pub sort_type: Option<u8>,
|
||||
#[serde(rename = "pageNo")]
|
||||
pub page_no: Option<u32>,
|
||||
#[serde(rename = "pageSize")]
|
||||
pub page_size: Option<u32>,
|
||||
pub cursor: Option<u64>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn comment_hot(query: CommentHotQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let mut q = state.build_query()
|
||||
.param("type", &query.r#type.to_string())
|
||||
.param("id", &query.id.to_string());
|
||||
if let Some(limit) = query.limit {
|
||||
q = q.param("limit", &limit.to_string());
|
||||
}
|
||||
if let Some(offset) = query.offset {
|
||||
q = q.param("offset", &offset.to_string());
|
||||
}
|
||||
if let Some(before) = query.before {
|
||||
q = q.param("before", &before.to_string());
|
||||
}
|
||||
client.comment_hot(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CommentHotQuery {
|
||||
pub r#type: u8,
|
||||
pub id: u64,
|
||||
pub limit: Option<u32>,
|
||||
pub offset: Option<u32>,
|
||||
pub before: Option<u64>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn comment_floor(query: CommentFloorQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let mut q = state.build_query()
|
||||
.param("parentCommentId", &query.parent_comment_id.to_string())
|
||||
.param("type", &query.r#type.to_string())
|
||||
.param("id", &query.id.to_string());
|
||||
if let Some(limit) = query.limit {
|
||||
q = q.param("limit", &limit.to_string());
|
||||
}
|
||||
if let Some(time) = query.time {
|
||||
q = q.param("time", &time.to_string());
|
||||
}
|
||||
client.comment_floor(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CommentFloorQuery {
|
||||
#[serde(rename = "parentCommentId")]
|
||||
pub parent_comment_id: u64,
|
||||
pub r#type: u8,
|
||||
pub id: u64,
|
||||
pub limit: Option<u32>,
|
||||
pub time: Option<u64>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn comment_like(query: CommentLikeQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let q = state.build_query()
|
||||
.param("t", &query.t.to_string())
|
||||
.param("type", &query.r#type.to_string())
|
||||
.param("id", &query.id.to_string())
|
||||
.param("cid", &query.cid.to_string());
|
||||
client.comment_like(&q).await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CommentLikeQuery {
|
||||
pub t: u8,
|
||||
pub r#type: u8,
|
||||
pub id: u64,
|
||||
pub cid: u64,
|
||||
}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
use rodio::{Decoder, OutputStream, Sink, Source};
|
||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||
use std::io::Cursor;
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::sync::mpsc::{channel, Receiver, Sender};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use tauri::AppHandle;
|
||||
@ -11,6 +11,7 @@ use tauri::Emitter;
|
||||
// ---------- 命令 ----------
|
||||
enum AudioCmd {
|
||||
Play(String),
|
||||
PlayLocal(String),
|
||||
Pause,
|
||||
Resume,
|
||||
Stop,
|
||||
@ -29,7 +30,7 @@ impl AudioController {
|
||||
let (tx, rx) = channel();
|
||||
let current_url = Arc::new(Mutex::new(None));
|
||||
let url_clone = current_url.clone();
|
||||
let ah_clone = app_handle.clone(); // 克隆一个用于闭包
|
||||
let ah_clone = app_handle.clone();
|
||||
thread::spawn(move || audio_thread(rx, url_clone, ah_clone));
|
||||
AudioController {
|
||||
tx,
|
||||
@ -41,6 +42,10 @@ impl AudioController {
|
||||
*self.current_url.lock().unwrap() = Some(url.to_string());
|
||||
let _ = self.tx.send(AudioCmd::Play(url.to_string()));
|
||||
}
|
||||
pub fn play_local(&self, path: &str) {
|
||||
*self.current_url.lock().unwrap() = Some(path.to_string());
|
||||
let _ = self.tx.send(AudioCmd::PlayLocal(path.to_string()));
|
||||
}
|
||||
pub fn pause(&self) { let _ = self.tx.send(AudioCmd::Pause); }
|
||||
pub fn resume(&self) { let _ = self.tx.send(AudioCmd::Resume); }
|
||||
pub fn stop(&self) { let _ = self.tx.send(AudioCmd::Stop); }
|
||||
@ -55,44 +60,179 @@ impl AudioController {
|
||||
}
|
||||
}
|
||||
|
||||
use std::io::Read;
|
||||
// ---------- 流式缓冲区 ----------
|
||||
|
||||
fn download_audio_with_progress(
|
||||
struct BufferState {
|
||||
bytes: Vec<u8>,
|
||||
done: bool,
|
||||
cancelled: bool,
|
||||
}
|
||||
|
||||
struct SharedBuffer {
|
||||
state: Mutex<BufferState>,
|
||||
available: Condvar,
|
||||
}
|
||||
|
||||
impl SharedBuffer {
|
||||
fn new() -> Self {
|
||||
SharedBuffer {
|
||||
state: Mutex::new(BufferState {
|
||||
bytes: Vec::new(),
|
||||
done: false,
|
||||
cancelled: false,
|
||||
}),
|
||||
available: Condvar::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_chunk(&self, chunk: &[u8]) {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
state.bytes.extend_from_slice(chunk);
|
||||
self.available.notify_all();
|
||||
}
|
||||
|
||||
fn mark_done(&self) {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
state.done = true;
|
||||
self.available.notify_all();
|
||||
}
|
||||
|
||||
fn cancel(&self) {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
state.cancelled = true;
|
||||
self.available.notify_all();
|
||||
}
|
||||
|
||||
fn len(&self) -> usize {
|
||||
self.state.lock().unwrap().bytes.len()
|
||||
}
|
||||
|
||||
fn is_done(&self) -> bool {
|
||||
self.state.lock().unwrap().done
|
||||
}
|
||||
|
||||
fn is_cancelled(&self) -> bool {
|
||||
self.state.lock().unwrap().cancelled
|
||||
}
|
||||
}
|
||||
|
||||
struct StreamingReader {
|
||||
buffer: Arc<SharedBuffer>,
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl StreamingReader {
|
||||
fn new(buffer: Arc<SharedBuffer>) -> Self {
|
||||
StreamingReader { buffer, pos: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl Read for StreamingReader {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
let mut state = self.buffer.state.lock().unwrap();
|
||||
loop {
|
||||
let available = state.bytes.len().saturating_sub(self.pos);
|
||||
if available > 0 {
|
||||
let to_read = std::cmp::min(buf.len(), available);
|
||||
buf[..to_read].copy_from_slice(&state.bytes[self.pos..self.pos + to_read]);
|
||||
self.pos += to_read;
|
||||
return Ok(to_read);
|
||||
}
|
||||
if state.done {
|
||||
return Ok(0);
|
||||
}
|
||||
if state.cancelled {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::Interrupted, "cancelled"));
|
||||
}
|
||||
let result = self.buffer.available.wait_timeout(state, Duration::from_millis(500)).unwrap();
|
||||
state = result.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Seek for StreamingReader {
|
||||
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
|
||||
let new_pos = match pos {
|
||||
SeekFrom::Start(offset) => offset as i64,
|
||||
SeekFrom::Current(offset) => self.pos as i64 + offset,
|
||||
SeekFrom::End(offset) => {
|
||||
let mut state = self.buffer.state.lock().unwrap();
|
||||
loop {
|
||||
if state.done {
|
||||
break state.bytes.len() as i64 + offset;
|
||||
}
|
||||
if state.cancelled {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::Interrupted, "cancelled"));
|
||||
}
|
||||
let result = self.buffer.available.wait_timeout(state, Duration::from_millis(500)).unwrap();
|
||||
state = result.0;
|
||||
}
|
||||
}
|
||||
};
|
||||
if new_pos < 0 {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "seek before start"));
|
||||
}
|
||||
let mut state = self.buffer.state.lock().unwrap();
|
||||
loop {
|
||||
if new_pos as usize <= state.bytes.len() {
|
||||
self.pos = new_pos as usize;
|
||||
return Ok(self.pos as u64);
|
||||
}
|
||||
if state.done {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "seek past end"));
|
||||
}
|
||||
if state.cancelled {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::Interrupted, "cancelled"));
|
||||
}
|
||||
let result = self.buffer.available.wait_timeout(state, Duration::from_millis(500)).unwrap();
|
||||
state = result.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn download_audio_streaming(
|
||||
url: &str,
|
||||
buffer: &SharedBuffer,
|
||||
app_handle: &AppHandle,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
) -> Result<(), String> {
|
||||
let resp = reqwest::blocking::get(url)
|
||||
.map_err(|e| format!("下载失败: {}", e))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("HTTP 错误: {}", resp.status()));
|
||||
}
|
||||
|
||||
let total_size = resp.content_length().unwrap_or(0);
|
||||
let mut downloaded: u64 = 0;
|
||||
let mut buffer = Vec::new();
|
||||
let mut reader = resp;
|
||||
|
||||
loop {
|
||||
if buffer.is_cancelled() {
|
||||
return Err("下载已取消".to_string());
|
||||
}
|
||||
|
||||
let mut chunk = [0u8; 8192];
|
||||
let read_size = reader.read(&mut chunk)
|
||||
.map_err(|e| format!("读取失败: {}", e))?;
|
||||
if read_size == 0 {
|
||||
break;
|
||||
}
|
||||
buffer.extend_from_slice(&chunk[..read_size]);
|
||||
buffer.write_chunk(&chunk[..read_size]);
|
||||
downloaded += read_size as u64;
|
||||
|
||||
// 发送进度事件给前端(每 8192 字节发一次,不必太频繁)
|
||||
let progress = if total_size > 0 {
|
||||
(downloaded as f64 / total_size as f64) * 100.0
|
||||
} else {
|
||||
0.0 // 未知大小时为 0
|
||||
0.0
|
||||
};
|
||||
let _ = app_handle.emit("cache-progress", progress);
|
||||
}
|
||||
|
||||
// 下载完成,确保进度为 100
|
||||
let _ = app_handle.emit("cache-progress", 100f64);
|
||||
Ok(buffer)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const INITIAL_BUFFER_SIZE: usize = 65536;
|
||||
|
||||
// ---------- 音频线程 ----------
|
||||
fn audio_thread(rx: Receiver<AudioCmd>, current_url: Arc<Mutex<Option<String>>>, app_handle: AppHandle) {
|
||||
let mut selected_device: Option<String> = None;
|
||||
@ -104,47 +244,148 @@ fn audio_thread(rx: Receiver<AudioCmd>, current_url: Arc<Mutex<Option<String>>>,
|
||||
sink.set_volume(current_volume);
|
||||
}
|
||||
|
||||
let mut current_audio_data: Option<Vec<u8>> = None; // 缓存原始音频字节
|
||||
let mut current_audio_buffer: Option<Arc<SharedBuffer>> = None;
|
||||
let mut audio_active = false;
|
||||
let mut audio_paused = false;
|
||||
let mut manual_stop = false;
|
||||
|
||||
loop {
|
||||
match rx.recv_timeout(Duration::from_millis(200)) {
|
||||
Ok(cmd) => {
|
||||
match cmd {
|
||||
AudioCmd::Play(url) => {
|
||||
// 停止旧播放并重建干净输出
|
||||
if let Some(ref sink) = output.sink {
|
||||
sink.stop();
|
||||
audio_active = false;
|
||||
audio_paused = false;
|
||||
manual_stop = false;
|
||||
if let Some(ref buf) = current_audio_buffer {
|
||||
buf.cancel();
|
||||
}
|
||||
if let Some(ref sink) = output.sink { sink.stop(); }
|
||||
output = create_output(&selected_device);
|
||||
if let Some(ref sink) = output.sink {
|
||||
sink.set_volume(current_volume);
|
||||
|
||||
match download_audio_with_progress(&url, &app_handle) {
|
||||
let buffer = Arc::new(SharedBuffer::new());
|
||||
current_audio_buffer = Some(buffer.clone());
|
||||
|
||||
let buffer_clone = buffer.clone();
|
||||
let ah_clone = app_handle.clone();
|
||||
let url_clone = url.clone();
|
||||
thread::spawn(move || {
|
||||
if let Err(e) = download_audio_streaming(&url_clone, &buffer_clone, &ah_clone) {
|
||||
if !buffer_clone.is_cancelled() {
|
||||
eprintln!("[audio] 流式下载失败: {}", e);
|
||||
}
|
||||
}
|
||||
buffer_clone.mark_done();
|
||||
});
|
||||
|
||||
loop {
|
||||
let len = buffer.len();
|
||||
if len >= INITIAL_BUFFER_SIZE || buffer.is_done() || buffer.is_cancelled() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
|
||||
if buffer.is_cancelled() || buffer.len() == 0 {
|
||||
current_audio_buffer = None;
|
||||
continue;
|
||||
}
|
||||
|
||||
let reader = StreamingReader::new(buffer.clone());
|
||||
match Decoder::new(reader) {
|
||||
Ok(source) => {
|
||||
sink.append(source);
|
||||
sink.play();
|
||||
audio_active = true;
|
||||
let _ = app_handle.emit("audio-started", ());
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[audio] 流式解码失败: {}, 等待完整下载后重试", e);
|
||||
loop {
|
||||
if buffer.is_done() || buffer.is_cancelled() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
if buffer.is_cancelled() || buffer.len() == 0 {
|
||||
current_audio_buffer = None;
|
||||
continue;
|
||||
}
|
||||
let buf = current_audio_buffer.as_ref().unwrap().clone();
|
||||
let reader2 = StreamingReader::new(buf);
|
||||
match Decoder::new(reader2) {
|
||||
Ok(source) => {
|
||||
sink.append(source);
|
||||
sink.play();
|
||||
audio_active = true;
|
||||
let _ = app_handle.emit("audio-started", ());
|
||||
}
|
||||
Err(e2) => {
|
||||
eprintln!("[audio] 完整下载后解码也失败: {}", e2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AudioCmd::PlayLocal(path) => {
|
||||
audio_active = false;
|
||||
audio_paused = false;
|
||||
manual_stop = false;
|
||||
if let Some(ref buf) = current_audio_buffer {
|
||||
buf.cancel();
|
||||
}
|
||||
if let Some(ref sink) = output.sink { sink.stop(); }
|
||||
output = create_output(&selected_device);
|
||||
if let Some(ref sink) = output.sink {
|
||||
sink.set_volume(current_volume);
|
||||
|
||||
match std::fs::read(&path) {
|
||||
Ok(bytes) => {
|
||||
current_audio_data = Some(bytes.clone());
|
||||
let play_res = play_bytes(&bytes, sink);
|
||||
if let Err(e) = play_res {
|
||||
eprintln!("[audio] 播放失败: {}", e);
|
||||
let buffer = Arc::new(SharedBuffer::new());
|
||||
buffer.write_chunk(&bytes);
|
||||
buffer.mark_done();
|
||||
current_audio_buffer = Some(buffer.clone());
|
||||
|
||||
let reader = StreamingReader::new(buffer);
|
||||
match Decoder::new(reader) {
|
||||
Ok(source) => {
|
||||
sink.append(source);
|
||||
sink.play();
|
||||
audio_active = true;
|
||||
let _ = app_handle.emit("audio-started", ());
|
||||
}
|
||||
Err(e) => eprintln!("[audio] 本地播放失败: {}", e),
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("[audio] 下载失败: {}", e),
|
||||
Err(e) => eprintln!("[audio] 读取本地文件失败: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AudioCmd::Pause => {
|
||||
audio_paused = true;
|
||||
if let Some(ref sink) = output.sink { sink.pause(); }
|
||||
}
|
||||
AudioCmd::Resume => {
|
||||
audio_paused = false;
|
||||
if let Some(ref sink) = output.sink { sink.play(); }
|
||||
}
|
||||
AudioCmd::Stop => {
|
||||
audio_active = false;
|
||||
audio_paused = false;
|
||||
manual_stop = true;
|
||||
if let Some(ref buf) = current_audio_buffer {
|
||||
buf.cancel();
|
||||
}
|
||||
if let Some(ref sink) = output.sink { sink.stop(); }
|
||||
}
|
||||
|
||||
AudioCmd::Seek(time) => {
|
||||
if let Some(ref sink) = output.sink {
|
||||
// 优先尝试高效的 sink.try_seek(毫秒级)
|
||||
let seek_res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
sink.try_seek(Duration::from_secs_f64(time))
|
||||
}));
|
||||
@ -153,19 +394,34 @@ fn audio_thread(rx: Receiver<AudioCmd>, current_url: Arc<Mutex<Option<String>>>,
|
||||
Ok(Ok(_)) => { /* 成功 */ }
|
||||
Ok(Err(e)) => {
|
||||
eprintln!("[audio] try_seek 失败: {:?}, 回退重建解码", e);
|
||||
// 回退方案:重新解码并从目标时间开始
|
||||
if let Some(ref bytes) = current_audio_data {
|
||||
if let Some(ref buffer) = current_audio_buffer {
|
||||
sink.stop();
|
||||
sink.clear();
|
||||
let _ = play_bytes_with_seek(bytes, sink, time);
|
||||
let reader = StreamingReader::new(buffer.clone());
|
||||
match Decoder::new(reader) {
|
||||
Ok(source) => {
|
||||
let source = source.skip_duration(Duration::from_secs_f64(time));
|
||||
sink.append(source);
|
||||
sink.play();
|
||||
}
|
||||
Err(e) => eprintln!("[audio] seek 解码失败: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
eprintln!("[audio] try_seek 崩溃,回退重建解码");
|
||||
if let Some(ref bytes) = current_audio_data {
|
||||
if let Some(ref buffer) = current_audio_buffer {
|
||||
sink.stop();
|
||||
sink.clear();
|
||||
let _ = play_bytes_with_seek(bytes, sink, time);
|
||||
let reader = StreamingReader::new(buffer.clone());
|
||||
match Decoder::new(reader) {
|
||||
Ok(source) => {
|
||||
let source = source.skip_duration(Duration::from_secs_f64(time));
|
||||
sink.append(source);
|
||||
sink.play();
|
||||
}
|
||||
Err(e) => eprintln!("[audio] seek 解码失败: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -184,10 +440,16 @@ fn audio_thread(rx: Receiver<AudioCmd>, current_url: Arc<Mutex<Option<String>>>,
|
||||
output = create_output(&selected_device);
|
||||
if let Some(ref sink) = output.sink {
|
||||
sink.set_volume(current_volume);
|
||||
// 如果正在播放,恢复播放
|
||||
if current_url.lock().unwrap().is_some() {
|
||||
if let Some(ref bytes) = current_audio_data {
|
||||
let _ = play_bytes(bytes, sink);
|
||||
if let Some(ref buffer) = current_audio_buffer {
|
||||
let reader = StreamingReader::new(buffer.clone());
|
||||
match Decoder::new(reader) {
|
||||
Ok(source) => {
|
||||
sink.append(source);
|
||||
sink.play();
|
||||
}
|
||||
Err(e) => eprintln!("[audio] 设备切换解码失败: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -199,7 +461,16 @@ fn audio_thread(rx: Receiver<AudioCmd>, current_url: Arc<Mutex<Option<String>>>,
|
||||
}
|
||||
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
|
||||
// 跟随系统默认设备变化
|
||||
if audio_active && !audio_paused {
|
||||
if let Some(ref sink) = output.sink {
|
||||
if sink.empty() {
|
||||
audio_active = false;
|
||||
if !manual_stop {
|
||||
let _ = app_handle.emit("audio-ended", ());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if selected_device.is_none() {
|
||||
let current_default = get_system_default_device_name();
|
||||
if current_default != last_default_name {
|
||||
@ -208,8 +479,12 @@ fn audio_thread(rx: Receiver<AudioCmd>, current_url: Arc<Mutex<Option<String>>>,
|
||||
output = create_output(&selected_device);
|
||||
if let Some(ref sink) = output.sink {
|
||||
sink.set_volume(current_volume);
|
||||
if let Some(ref bytes) = current_audio_data {
|
||||
let _ = play_bytes(bytes, sink);
|
||||
if let Some(ref buffer) = current_audio_buffer {
|
||||
let reader = StreamingReader::new(buffer.clone());
|
||||
let _ = Decoder::new(reader).map(|source| {
|
||||
sink.append(source);
|
||||
sink.play();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -220,28 +495,7 @@ fn audio_thread(rx: Receiver<AudioCmd>, current_url: Arc<Mutex<Option<String>>>,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 播放辅助函数 ----------
|
||||
|
||||
/// 直接播放字节数据
|
||||
fn play_bytes(bytes: &[u8], sink: &Sink) -> Result<(), String> {
|
||||
let cursor = Cursor::new(bytes.to_vec());
|
||||
let source = Decoder::new(cursor).map_err(|e| format!("解码失败: {}", e))?;
|
||||
sink.append(source);
|
||||
sink.play();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 播放字节数据并跳过指定秒数(用于 seek 回退)
|
||||
fn play_bytes_with_seek(bytes: &[u8], sink: &Sink, seek_secs: f64) -> Result<(), String> {
|
||||
let cursor = Cursor::new(bytes.to_vec());
|
||||
let source = Decoder::new(cursor).map_err(|e| format!("解码失败: {}", e))?;
|
||||
let source = source.skip_duration(Duration::from_secs_f64(seek_secs));
|
||||
sink.append(source);
|
||||
sink.play();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------- 其余函数保持不变(获取设备、创建输出等) ----------
|
||||
// ---------- 其余函数保持不变 ----------
|
||||
|
||||
fn get_system_default_device_name() -> Option<String> {
|
||||
rodio::cpal::default_host()
|
||||
@ -340,6 +594,13 @@ pub fn play_audio(state: State<'_, AppAudio>, url: String) -> Result<(), String>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn play_local_audio(state: State<'_, AppAudio>, path: String) -> Result<(), String> {
|
||||
let ctrl = state.0.lock().map_err(|e| e.to_string())?;
|
||||
ctrl.play_local(&path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn pause_audio(state: State<'_, AppAudio>) {
|
||||
if let Ok(ctrl) = state.0.lock() { ctrl.pause(); }
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
use tauri::{
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
menu::{MenuBuilder, MenuItemBuilder},
|
||||
menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem},
|
||||
Manager, Emitter,
|
||||
};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@ -17,7 +17,6 @@ pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.setup(|app| {
|
||||
|
||||
// 注入控制器
|
||||
let app_data_dir = app.path().app_data_dir().expect("无法获取应用数据目录");
|
||||
let api_controller = ApiController::new(app_data_dir);
|
||||
app.manage(api_controller);
|
||||
@ -26,36 +25,38 @@ pub fn run() {
|
||||
let app_audio = AppAudio(std::sync::Mutex::new(audio_controller));
|
||||
app.manage(app_audio);
|
||||
|
||||
// 托盘菜单
|
||||
let show = MenuItemBuilder::with_id("show", "显示窗口").build(app)?;
|
||||
let _sep1 = PredefinedMenuItem::separator(app)?;
|
||||
let prev = MenuItemBuilder::with_id("prev", "上一首").build(app)?;
|
||||
let play_pause = MenuItemBuilder::with_id("play_pause", "播放/暂停").build(app)?;
|
||||
let next = MenuItemBuilder::with_id("next", "下一首").build(app)?;
|
||||
let prev = MenuItemBuilder::with_id("prev", "上一首").build(app)?;
|
||||
let _sep2 = PredefinedMenuItem::separator(app)?;
|
||||
let quit = MenuItemBuilder::with_id("quit", "退出").build(app)?;
|
||||
|
||||
let menu = MenuBuilder::new(app)
|
||||
.item(&show)
|
||||
.separator()
|
||||
.item(&play_pause)
|
||||
.item(&next)
|
||||
.item(&prev)
|
||||
.items(&[&prev, &play_pause, &next])
|
||||
.separator()
|
||||
.item(&quit)
|
||||
.build()?;
|
||||
|
||||
// 托盘图标(使用应用默认图标)
|
||||
let icon = app.default_window_icon().cloned().unwrap();
|
||||
|
||||
let _tray = TrayIconBuilder::with_id("main-tray")
|
||||
.tooltip("Nekosonic")
|
||||
.tooltip("Nekosonic Music")
|
||||
.icon(icon)
|
||||
.show_menu_on_left_click(false)
|
||||
.menu(&menu)
|
||||
.on_menu_event(|app, event| {
|
||||
let window = app.get_webview_window("main").unwrap();
|
||||
match event.id().as_ref() {
|
||||
"show" => {
|
||||
window.show().unwrap();
|
||||
window.set_focus().unwrap();
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
let _ = app.emit("window-shown", ());
|
||||
}
|
||||
}
|
||||
"play_pause" => {
|
||||
let _ = app.emit("tray-play-pause", ());
|
||||
@ -83,16 +84,19 @@ pub fn run() {
|
||||
} = event
|
||||
{
|
||||
let app = tray.app_handle();
|
||||
let window = app.get_webview_window("main").unwrap();
|
||||
window.show().unwrap();
|
||||
window.set_focus().unwrap();
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
let _ = app.emit("window-shown", ());
|
||||
}
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
// 点击关闭按钮时隐藏到托盘
|
||||
let window = app.get_webview_window("main").unwrap();
|
||||
let window_clone = window.clone();
|
||||
let app_handle = app.handle().clone();
|
||||
window.on_window_event(move |event| {
|
||||
if let tauri::WindowEvent::CloseRequested { api: close_api, .. } = event {
|
||||
if ALLOW_EXIT.load(Ordering::SeqCst) {
|
||||
@ -100,6 +104,7 @@ pub fn run() {
|
||||
}
|
||||
close_api.prevent_close();
|
||||
let _ = window_clone.hide();
|
||||
let _ = app_handle.emit("window-hidden", ());
|
||||
}
|
||||
});
|
||||
|
||||
@ -132,15 +137,44 @@ pub fn run() {
|
||||
api::exit_app,
|
||||
|
||||
audio::play_audio,
|
||||
audio::play_local_audio,
|
||||
audio::pause_audio,
|
||||
audio::resume_audio,
|
||||
audio::stop_audio,
|
||||
audio::get_output_devices,
|
||||
audio::set_output_device,
|
||||
audio::seek_audio,
|
||||
audio::set_volume
|
||||
audio::set_volume,
|
||||
|
||||
api::download_song,
|
||||
api::list_local_songs,
|
||||
api::delete_local_song,
|
||||
api::check_local_song,
|
||||
api::get_default_download_path,
|
||||
|
||||
api::artist_detail,
|
||||
api::artist_songs,
|
||||
api::artist_album,
|
||||
api::artist_desc,
|
||||
api::album_detail,
|
||||
api::comment_new,
|
||||
api::comment_hot,
|
||||
api::comment_floor,
|
||||
api::comment_like,
|
||||
])
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
let _ = window.unminimize();
|
||||
let _ = app.emit("window-shown", ());
|
||||
}
|
||||
}))
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running Nekosonic");
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Nekosonic",
|
||||
"version": "0.2.0",
|
||||
"version": "0.4.0",
|
||||
"identifier": "com.atdunbg.Nekosonic",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@ -27,6 +27,7 @@
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"createUpdaterArtifacts": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
@ -34,6 +35,21 @@
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
],
|
||||
"windows": {
|
||||
"webviewInstallMode": {
|
||||
"type": "downloadBootstrapper"
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"active": true,
|
||||
"endpoints": [
|
||||
"https://github.com/atdunbg/Nekosonic-Music/releases/latest/download/latest.json"
|
||||
],
|
||||
"dialog": false,
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDM1MDdCMTJCRTE3MUI4N0QKUldSOXVISGhLN0VITmM3ZkJlbjF3UGJrK3h6ellWZ2xSUG03b3d1RWlDeldSWk1nc0pic2J2MVkK"
|
||||
}
|
||||
}
|
||||
}
|
||||
151
src/App.vue
151
src/App.vue
@ -12,7 +12,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<div class="flex flex-1 overflow-hidden" v-if="windowVisible">
|
||||
<nav class="w-56 flex-shrink-0 flex flex-col bg-surface/80 backdrop-blur">
|
||||
<div class="flex-1 p-4 overflow-y-auto min-h-0">
|
||||
<div class="flex flex-col min-h-full">
|
||||
@ -52,6 +52,12 @@
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
|
||||
最近播放
|
||||
</router-link>
|
||||
<router-link to="/local-music"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
|
||||
active-class="!text-content !bg-muted">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||||
本地音乐
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -127,7 +133,7 @@
|
||||
|
||||
<Transition name="drawer">
|
||||
<div
|
||||
v-if="player.showRoamDrawer"
|
||||
v-if="windowVisible && player.showRoamDrawer"
|
||||
class="fixed inset-0 z-50 flex flex-col backdrop-blur-xl bg-black/80"
|
||||
>
|
||||
<div class="h-10 flex items-center justify-between px-4 flex-shrink-0" data-tauri-drag-region>
|
||||
@ -143,14 +149,43 @@
|
||||
<div class="flex-1 min-h-0 flex px-8 pb-8 gap-0">
|
||||
<div class="w-2/5 flex flex-col items-center justify-center flex-shrink-0">
|
||||
<img
|
||||
:src="roamSong?.al?.picUrl || roamSong?.album?.picUrl"
|
||||
v-if="roamCoverUrl && !roamCoverError"
|
||||
:src="roamCoverUrl"
|
||||
class="w-72 h-72 rounded-3xl object-cover shadow-2xl mb-4"
|
||||
@error="roamCoverError = true"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="w-72 h-72 rounded-3xl bg-white/10 flex items-center justify-center shadow-2xl mb-4"
|
||||
>
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="text-white/30"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-white text-center">{{ roamSong?.name }}</h1>
|
||||
<p class="text-content-2 mt-2 text-center">{{ roamArtists }}</p>
|
||||
<p class="text-content-2 mt-2 text-center">
|
||||
<template v-for="(a, i) in roamSong?.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="a.id && navigateFromDrawer({ name: 'artist', params: { id: a.id } })">{{ a.name }}</span>
|
||||
</template>
|
||||
<template v-if="roamSong?.al?.name">
|
||||
<span class="text-content-3 mx-1">·</span>
|
||||
<span class="hover:text-accent-text cursor-pointer transition" @click="roamSong!.al.id && navigateFromDrawer({ name: 'album', params: { id: roamSong!.al.id } })">{{ roamSong.al.name }}</span>
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-3/5 relative min-h-0 overflow-hidden flex flex-col">
|
||||
<div ref="lyricScrollContainer" class="h-full overflow-y-auto custom-scroll px-4">
|
||||
<div class="flex items-center gap-1 mb-3 px-4">
|
||||
<button @click="roamTab = 'lyric'"
|
||||
class="px-3 py-1 rounded-full text-sm transition"
|
||||
:class="roamTab === 'lyric' ? 'bg-white/15 text-white font-medium' : 'text-white/50 hover:text-white/80'">
|
||||
歌词
|
||||
</button>
|
||||
<button @click="roamTab = 'comment'"
|
||||
class="px-3 py-1 rounded-full text-sm transition"
|
||||
:class="roamTab === 'comment' ? 'bg-white/15 text-white font-medium' : 'text-white/50 hover:text-white/80'">
|
||||
评论
|
||||
</button>
|
||||
</div>
|
||||
<div v-show="roamTab === 'lyric'" ref="lyricScrollContainer" class="flex-1 min-h-0 overflow-y-auto custom-scroll px-4">
|
||||
<div v-if="lyrics.length > 0" class="w-full max-w-lg mx-auto text-center"
|
||||
:style="{ paddingTop: roamLyricPadPx + 'px', paddingBottom: roamLyricPadPx + 'px' }">
|
||||
<p
|
||||
@ -167,6 +202,9 @@
|
||||
</div>
|
||||
<div v-else class="text-content-3 text-center mt-8">暂无歌词</div>
|
||||
</div>
|
||||
<div v-show="roamTab === 'comment'" class="flex-1 min-h-0 overflow-y-auto px-4 pb-4">
|
||||
<CommentSection v-if="roamSong" :type="0" :id="player.commentSongId || roamSong.id" :key="player.commentSongId || roamSong.id" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -175,6 +213,15 @@
|
||||
<PlayerBar v-if="player.currentSong" />
|
||||
<ToastContainer />
|
||||
|
||||
<UpdateDialog
|
||||
:visible="updater.updateAvailable.value && !!updater.updateInfo.value"
|
||||
:info="{ version: updater.updateInfo.value?.version || '', date: updater.updateInfo.value?.date ?? null, body: updater.updateInfo.value?.body ?? null, currentVersion: updater.currentVersion.value }"
|
||||
:downloading="updater.downloading.value"
|
||||
:download-progress="updater.downloadProgress.value"
|
||||
@update="updater.downloadAndInstall()"
|
||||
@ignore="updater.ignoreVersion(updater.updateInfo.value?.version || '')"
|
||||
/>
|
||||
|
||||
<Transition name="fade">
|
||||
<div v-if="showCloseModal" class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showCloseModal = false">
|
||||
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-80 p-6 select-auto">
|
||||
@ -224,16 +271,21 @@ import { useUserStore } from './stores/user';
|
||||
import { useSettingsStore, type CloseAction } from './stores/settings';
|
||||
import PlayerBar from './components/PlayerBar.vue';
|
||||
import ToastContainer from './components/ToastContainer.vue';
|
||||
import CommentSection from './components/CommentSection.vue';
|
||||
import UpdateDialog from './components/UpdateDialog.vue';
|
||||
import { usePlayerStore } from './stores/player';
|
||||
import { useLyric } from './composables/UserLyric';
|
||||
import { useUpdater } from './composables/useUpdater';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { register, unregister } from '@tauri-apps/plugin-global-shortcut';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const userStore = useUserStore();
|
||||
const player = usePlayerStore();
|
||||
const settings = useSettingsStore();
|
||||
const updater = useUpdater();
|
||||
|
||||
const createdPlaylists = ref<any[]>([]);
|
||||
const subPlaylists = ref<any[]>([]);
|
||||
@ -242,6 +294,7 @@ const showSubPlaylists = ref(true);
|
||||
const searchQuery = ref('');
|
||||
const showCloseModal = ref(false);
|
||||
const closeDontAskAgain = ref(false);
|
||||
const windowVisible = ref(true);
|
||||
|
||||
watch(() => settings.theme, (val) => {
|
||||
document.documentElement.setAttribute('data-theme', val);
|
||||
@ -257,6 +310,13 @@ const lyricScrollContainer = ref<HTMLElement | null>(null);
|
||||
const roamLyricHovering = ref(false);
|
||||
const roamLyricPadPx = ref(0);
|
||||
const roamSong = computed(() => player.currentSong);
|
||||
const roamCoverError = ref(false);
|
||||
const roamTab = ref<'lyric' | 'comment'>('lyric');
|
||||
const roamCoverUrl = computed(() => {
|
||||
if (!roamSong.value) return '';
|
||||
return roamSong.value.al?.picUrl || roamSong.value.album?.picUrl || '';
|
||||
});
|
||||
watch(roamCoverUrl, () => { roamCoverError.value = false; });
|
||||
let roamResizeObserver: ResizeObserver | null = null;
|
||||
|
||||
function updateRoamLyricPad() {
|
||||
@ -267,6 +327,7 @@ function updateRoamLyricPad() {
|
||||
|
||||
watch(() => player.showRoamDrawer, (val) => {
|
||||
if (val) {
|
||||
roamTab.value = player.roamInitialTab;
|
||||
nextTick(() => {
|
||||
updateRoamLyricPad();
|
||||
if (roamResizeObserver) roamResizeObserver.disconnect();
|
||||
@ -290,10 +351,6 @@ onBeforeUnmount(() => {
|
||||
roamResizeObserver = null;
|
||||
}
|
||||
});
|
||||
const roamArtists = computed(() => {
|
||||
if (!roamSong.value) return '';
|
||||
return roamSong.value.ar?.map((a: any) => a.name).join(' / ') || '';
|
||||
});
|
||||
|
||||
watch(currentLyricIdx, () => {
|
||||
if (player.showRoamDrawer && !roamLyricHovering.value) {
|
||||
@ -325,6 +382,11 @@ function seekToRoamLyric(time: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function navigateFromDrawer(routeLocation: { name: string; params: any }) {
|
||||
player.closeRoamDrawer();
|
||||
router.push(routeLocation);
|
||||
}
|
||||
|
||||
async function openRoamFromSidebar() {
|
||||
if (player.isFmMode) {
|
||||
player.openRoamDrawer();
|
||||
@ -376,6 +438,8 @@ onMounted(async () => {
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
|
||||
updater.checkForUpdate(true);
|
||||
});
|
||||
|
||||
const currentWindow = getCurrentWindow();
|
||||
@ -416,14 +480,59 @@ onMounted(() => {
|
||||
const unlisten3 = listen('tray-prev', () => {
|
||||
player.prev();
|
||||
});
|
||||
const unlisten4 = listen('window-hidden', () => {
|
||||
windowVisible.value = false;
|
||||
});
|
||||
const unlisten5 = listen('window-shown', () => {
|
||||
windowVisible.value = true;
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unlisten1.then(fn => fn());
|
||||
unlisten2.then(fn => fn());
|
||||
unlisten3.then(fn => fn());
|
||||
unlisten4.then(fn => fn());
|
||||
unlisten5.then(fn => fn());
|
||||
});
|
||||
});
|
||||
|
||||
async function registerGlobalShortcuts() {
|
||||
const globalActions: Record<string, () => void> = {
|
||||
globalPrev: () => player.prev(),
|
||||
globalNext: () => player.next(),
|
||||
globalVolUp: () => player.adjustVolume(5),
|
||||
globalVolDown: () => player.adjustVolume(-5),
|
||||
};
|
||||
for (const [id, action] of Object.entries(globalActions)) {
|
||||
const key = settings.shortcuts[id]?.key;
|
||||
if (!key) continue;
|
||||
try { await unregister(key); } catch {}
|
||||
try {
|
||||
await register(key, (event) => {
|
||||
if (event.state === 'Pressed') action();
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => settings.shortcuts, () => {
|
||||
registerGlobalShortcuts();
|
||||
}, { deep: true });
|
||||
|
||||
onMounted(() => {
|
||||
registerGlobalShortcuts();
|
||||
});
|
||||
|
||||
function parseShortcutKey(combo: string): { ctrl: boolean; alt: boolean; shift: boolean; code: string } {
|
||||
const parts = combo.split('+');
|
||||
return {
|
||||
ctrl: parts.includes('Control'),
|
||||
alt: parts.includes('Alt'),
|
||||
shift: parts.includes('Shift'),
|
||||
code: parts.find(p => !['Control', 'Alt', 'Shift'].includes(p)) || '',
|
||||
};
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
const el = e.target as HTMLElement;
|
||||
@ -432,13 +541,25 @@ onMounted(() => {
|
||||
e.preventDefault();
|
||||
player.toggle();
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.code === 'ArrowRight') {
|
||||
|
||||
const localActions: Record<string, () => void> = {
|
||||
prev: () => player.prev(),
|
||||
next: () => player.next(),
|
||||
volUp: () => player.adjustVolume(5),
|
||||
volDown: () => player.adjustVolume(-5),
|
||||
};
|
||||
for (const [id, action] of Object.entries(localActions)) {
|
||||
const key = settings.shortcuts[id]?.key;
|
||||
if (!key) continue;
|
||||
const parsed = parseShortcutKey(key);
|
||||
const ctrlMatch = parsed.ctrl ? (e.ctrlKey || e.metaKey) : !e.ctrlKey && !e.metaKey;
|
||||
const altMatch = parsed.alt ? e.altKey : !e.altKey;
|
||||
const shiftMatch = parsed.shift ? e.shiftKey : !e.shiftKey;
|
||||
if (ctrlMatch && altMatch && shiftMatch && e.code === parsed.code) {
|
||||
e.preventDefault();
|
||||
player.next();
|
||||
action();
|
||||
return;
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.code === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
player.prev();
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeydown);
|
||||
@ -460,10 +581,8 @@ onMounted(() => {
|
||||
.custom-scroll::-webkit-scrollbar { width: 0; display: none; }
|
||||
.roam-lyric-line:hover {
|
||||
background: var(--c-subtle);
|
||||
color: var(--c-content) !important;
|
||||
}
|
||||
.roam-lyric-active:hover {
|
||||
background: var(--c-subtle) !important;
|
||||
color: var(--c-content) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
149
src/components/CommentSection.vue
Normal file
149
src/components/CommentSection.vue
Normal file
@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<div class="bg-subtle rounded-xl p-3" ref="scrollContainer">
|
||||
<div v-if="loading" class="py-8 text-center text-content-2 text-sm">加载中...</div>
|
||||
|
||||
<div v-else-if="comments.length === 0" class="py-8 text-center">
|
||||
<svg class="mx-auto mb-2 text-content-3" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
|
||||
<p class="text-content-3 text-sm">暂无评论</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="comment in comments"
|
||||
:key="comment.commentId"
|
||||
class="p-3 rounded-xl bg-surface/50"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<img :src="comment.user.avatarUrl" class="w-8 h-8 rounded-full object-cover flex-shrink-0" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-content truncate">{{ comment.user.nickname }}</p>
|
||||
<p class="text-xs text-content-3">{{ new Date(comment.time).toLocaleDateString('zh-CN') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-content-2 leading-relaxed">{{ comment.content }}</p>
|
||||
<div class="mt-2 flex justify-end">
|
||||
<button
|
||||
@click="likeComment(comment.commentId)"
|
||||
class="flex items-center gap-1 text-content-3 hover:text-danger transition text-xs"
|
||||
>
|
||||
<svg width="14" height="14" 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>
|
||||
<span>{{ comment.likedCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="sentinel" class="h-1"></div>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingMore" class="py-4 text-center text-content-3 text-sm">加载中...</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
const props = defineProps<{
|
||||
type: number
|
||||
id: number
|
||||
}>()
|
||||
|
||||
const comments = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
const hasMore = ref(true)
|
||||
const pageNo = ref(1)
|
||||
const pageSize = 20
|
||||
const sentinel = ref<HTMLElement | null>(null)
|
||||
let observer: IntersectionObserver | null = null
|
||||
|
||||
async function fetchComments(reset = false) {
|
||||
if (reset) {
|
||||
pageNo.value = 1
|
||||
comments.value = []
|
||||
hasMore.value = true
|
||||
}
|
||||
|
||||
if (!hasMore.value) return
|
||||
|
||||
if (reset) {
|
||||
loading.value = true
|
||||
} else {
|
||||
loadingMore.value = true
|
||||
}
|
||||
|
||||
try {
|
||||
const jsonStr: string = await invoke('comment_hot', {
|
||||
query: {
|
||||
type: props.type,
|
||||
id: props.id,
|
||||
limit: pageSize,
|
||||
offset: (pageNo.value - 1) * pageSize
|
||||
}
|
||||
})
|
||||
const data = JSON.parse(jsonStr)
|
||||
const list = data.hotComments || []
|
||||
if (reset) {
|
||||
comments.value = list
|
||||
} else {
|
||||
comments.value.push(...list)
|
||||
}
|
||||
hasMore.value = list.length >= pageSize
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
if (loadingMore.value || !hasMore.value) return
|
||||
pageNo.value++
|
||||
fetchComments()
|
||||
}
|
||||
|
||||
async function likeComment(cid: number) {
|
||||
try {
|
||||
await invoke('comment_like', {
|
||||
query: {
|
||||
t: 1,
|
||||
type: props.type,
|
||||
id: props.id,
|
||||
cid
|
||||
}
|
||||
})
|
||||
const target = comments.value.find(c => c.commentId === cid)
|
||||
if (target) {
|
||||
target.likedCount++
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
function setupObserver() {
|
||||
if (observer) observer.disconnect()
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
if (entries[0]?.isIntersecting) {
|
||||
loadMore()
|
||||
}
|
||||
}, { rootMargin: '200px' })
|
||||
nextTick(() => {
|
||||
if (sentinel.value) {
|
||||
observer!.observe(sentinel.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchComments(true).then(() => setupObserver())
|
||||
})
|
||||
|
||||
watch(() => props.id, () => {
|
||||
fetchComments(true).then(() => setupObserver())
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (observer) observer.disconnect()
|
||||
})
|
||||
</script>
|
||||
@ -14,19 +14,36 @@
|
||||
|
||||
<div class="flex items-center px-6 h-16">
|
||||
<div class="flex items-center gap-3 w-56 min-w-0">
|
||||
<img :src="player.currentSong?.al?.picUrl"
|
||||
class="w-10 h-10 rounded-md object-cover flex-shrink-0 cursor-pointer hover:scale-105 transition-transform"
|
||||
@click="player.toggleRoamDrawer()" title="全屏展示" />
|
||||
<div v-if="player.currentSong?.al?.picUrl" class="w-10 h-10 rounded-md overflow-hidden flex-shrink-0 cursor-pointer hover:scale-105 transition-transform" @click="player.toggleRoamDrawer()" title="全屏展示">
|
||||
<img :src="player.currentSong.al.picUrl" class="w-full h-full object-cover" />
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium truncate">{{ player.currentSong?.name }}</p>
|
||||
<p class="text-xs text-content-2 truncate">
|
||||
{{player.currentSong?.ar?.map((a: any) => a.name).join('/')}}
|
||||
<template v-for="(a, i) in player.currentSong?.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="player.currentSong?.al?.name">
|
||||
<span class="text-content-3 mx-1">·</span>
|
||||
<span class="hover:text-accent-text cursor-pointer transition" @click.stop="player.currentSong!.al.id && router.push({ name: 'album', params: { id: player.currentSong!.al.id } })">{{ player.currentSong.al.name }}</span>
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
<button @click="player.currentSong && player.toggleLike(player.currentSong.id)" class="flex-shrink-0 transition" :class="player.currentSong && player.isLiked(player.currentSong.id) ? 'text-danger' : 'text-content-3 hover:text-danger'">
|
||||
<svg v-if="player.currentSong && player.isLiked(player.currentSong.id)" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
|
||||
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
|
||||
</button>
|
||||
<button v-if="player.currentSong" @click="player.openRoamDrawer('comment')" class="flex-shrink-0 text-content-3 hover:text-accent-text transition" title="评论">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
|
||||
</button>
|
||||
<button v-if="player.currentSong && !download.isDownloaded(player.currentSong!.id) && !download.isDownloading(player.currentSong!.id)" @click="download.downloadSong(player.currentSong)" class="flex-shrink-0 text-content-3 hover:text-accent-text transition" title="下载">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="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>
|
||||
<svg v-if="player.currentSong && download.isDownloading(player.currentSong!.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="flex-shrink-0 animate-spin text-content-3"><path d="M21 12a9 9 0 11-6.22-8.56"/></svg>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 flex flex-col items-center justify-center gap-1">
|
||||
@ -62,11 +79,11 @@
|
||||
<div class="w-56 flex justify-end items-center gap-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="toggleMute" class="text-content-2 hover:text-content transition">
|
||||
<svg v-if="volume === 0" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg>
|
||||
<svg v-if="player.volume === 0" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg>
|
||||
<svg v-else width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 010 14.14M15.54 8.46a5 5 0 010 7.07"/></svg>
|
||||
</button>
|
||||
<div class="relative w-20 h-6 flex items-center">
|
||||
<input ref="volumeSlider" type="range" min="0" max="100" :value="volume"
|
||||
<input ref="volumeSlider" type="range" min="0" max="100" :value="player.volume"
|
||||
:style="{ background: volumeBarBg }" @input="handleVolumeChange"
|
||||
class="vol-slider w-full h-1.5 rounded-full appearance-none cursor-pointer bg-emphasis outline-none" />
|
||||
</div>
|
||||
@ -103,7 +120,10 @@
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs font-medium truncate">{{ song.name }}</p>
|
||||
<p class="text-xs text-content-3 truncate">
|
||||
{{song.ar?.map((a: any) => a.name).join('/')}}
|
||||
<template v-for="(a, i) in song.ar || []" :key="a.id || i">
|
||||
<span v-if="i > 0" class="text-content-3">/</span>
|
||||
<span class="hover:text-accent-text cursor-pointer transition" @click.stop="a.id && router.push({ name: 'artist', params: { id: a.id } })">{{ a.name }}</span>
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
<button @click.stop="player.removeFromQueue(idx)"
|
||||
@ -120,17 +140,20 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onBeforeUnmount, onMounted } from 'vue';
|
||||
import { usePlayerStore, PlayMode } from '../stores/player';
|
||||
import { useDownload } from '../composables/useDownload';
|
||||
import { formatTime } from '../utils/format';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
const player = usePlayerStore();
|
||||
const download = useDownload();
|
||||
const showQueuePanel = ref(false);
|
||||
const progressBar = ref<HTMLElement | null>(null);
|
||||
const isSeeking = ref(false);
|
||||
const previewTime = ref(0);
|
||||
const cacheProgress = ref(0);
|
||||
const volume = ref(100);
|
||||
const prevVolume = ref(100);
|
||||
|
||||
let unlistenCache: (() => void) | null = null;
|
||||
@ -154,13 +177,13 @@ function togglePlayMode() {
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
if (volume.value > 0) {
|
||||
prevVolume.value = volume.value;
|
||||
volume.value = 0;
|
||||
if (player.volume > 0) {
|
||||
prevVolume.value = player.volume;
|
||||
player.volume = 0;
|
||||
} else {
|
||||
volume.value = prevVolume.value || 100;
|
||||
player.volume = prevVolume.value || 100;
|
||||
}
|
||||
invoke('set_volume', { vol: volume.value / 100 });
|
||||
invoke('set_volume', { vol: player.volume / 100 });
|
||||
}
|
||||
|
||||
let onDocMove: ((e: MouseEvent) => void) | null = null;
|
||||
@ -221,17 +244,26 @@ function playFromQueue(index: number) {
|
||||
async function handleVolumeChange(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const val = parseInt(target.value, 10);
|
||||
volume.value = val;
|
||||
player.volume = val;
|
||||
await invoke('set_volume', { vol: val / 100 });
|
||||
}
|
||||
|
||||
const volumeBarBg = computed(() => {
|
||||
const pct = volume.value;
|
||||
const pct = player.volume;
|
||||
return `linear-gradient(to right, var(--c-accent) 0%, var(--c-accent) ${pct}%, var(--c-muted) ${pct}%)`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
|
||||
42
src/components/SongItemMenu.vue
Normal file
42
src/components/SongItemMenu.vue
Normal file
@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div class="relative flex-shrink-0" ref="menuRef">
|
||||
<button @click.stop="toggle" class="text-content-3 hover:text-content transition p-1 rounded-md hover:bg-subtle">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg>
|
||||
</button>
|
||||
<div v-if="open"
|
||||
class="absolute right-0 top-full mt-1 bg-surface border border-line rounded-xl shadow-xl z-50 py-1 min-w-[120px]">
|
||||
<button @click.stop="handleComment" class="w-full flex items-center gap-2 px-3 py-2 text-sm text-content-2 hover:bg-subtle hover:text-content transition">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
|
||||
评论
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onBeforeUnmount, onMounted } from 'vue';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
|
||||
const player = usePlayerStore();
|
||||
const props = defineProps<{ songId: number }>();
|
||||
const open = ref(false);
|
||||
const menuRef = ref<HTMLElement | null>(null);
|
||||
|
||||
function toggle() {
|
||||
open.value = !open.value;
|
||||
}
|
||||
|
||||
function handleComment() {
|
||||
open.value = false;
|
||||
player.openCommentForSong(props.songId);
|
||||
}
|
||||
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
if (menuRef.value && !menuRef.value.contains(e.target as Node)) {
|
||||
open.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('click', onClickOutside));
|
||||
onBeforeUnmount(() => document.removeEventListener('click', onClickOutside));
|
||||
</script>
|
||||
87
src/components/UpdateDialog.vue
Normal file
87
src/components/UpdateDialog.vue
Normal file
@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<Transition name="fade">
|
||||
<div v-if="visible" class="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="handleIgnore">
|
||||
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-[440px] max-h-[80vh] flex flex-col select-auto">
|
||||
<div class="p-6 pb-4">
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<div class="w-10 h-10 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-accent-text"><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>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-content">发现新版本</h2>
|
||||
<p class="text-xs text-content-3 mt-0.5">
|
||||
v{{ info.currentVersion }} → <span class="text-accent-text font-medium">v{{ info.version }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="info.date" class="text-xs text-content-3 mt-2 ml-[52px]">{{ formatDate(info.date) }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="info.body" class="px-6 pb-4 flex-1 overflow-y-auto max-h-60 ml-[52px]">
|
||||
<div class="text-sm text-content-2 leading-relaxed whitespace-pre-wrap">{{ info.body }}</div>
|
||||
</div>
|
||||
<div v-else class="px-6 pb-4 ml-[52px]">
|
||||
<p class="text-sm text-content-3">暂无更新日志</p>
|
||||
</div>
|
||||
|
||||
<div v-if="downloading" class="px-6 pb-2">
|
||||
<div class="w-full bg-subtle rounded-full h-2 overflow-hidden">
|
||||
<div class="h-full bg-accent rounded-full transition-all duration-300" :style="{ width: downloadProgress + '%' }"></div>
|
||||
</div>
|
||||
<p class="text-xs text-content-3 mt-1 text-center">正在下载更新 {{ downloadProgress }}%</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 border-t border-line flex gap-3">
|
||||
<button
|
||||
@click="handleIgnore"
|
||||
:disabled="downloading"
|
||||
class="flex-1 py-2 rounded-lg bg-muted hover:bg-emphasis text-sm text-content-2 transition disabled:opacity-50"
|
||||
>
|
||||
忽略此版本
|
||||
</button>
|
||||
<button
|
||||
@click="handleUpdate"
|
||||
:disabled="downloading"
|
||||
class="flex-1 py-2 rounded-lg bg-accent hover:bg-accent-hover text-white text-sm font-medium transition disabled:opacity-50"
|
||||
>
|
||||
{{ downloading ? `下载中 ${downloadProgress}%` : '立即更新' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { UpdateInfo } from '../composables/useUpdater'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
info: UpdateInfo & { currentVersion: string }
|
||||
downloading: boolean
|
||||
downloadProgress: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
update: []
|
||||
ignore: []
|
||||
}>()
|
||||
|
||||
function handleUpdate() {
|
||||
emit('update')
|
||||
}
|
||||
|
||||
function handleIgnore() {
|
||||
if (props.downloading) return
|
||||
emit('ignore')
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
try {
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })
|
||||
} catch {
|
||||
return dateStr
|
||||
}
|
||||
}
|
||||
</script>
|
||||
135
src/composables/useDownload.ts
Normal file
135
src/composables/useDownload.ts
Normal file
@ -0,0 +1,135 @@
|
||||
import { reactive, watch } from 'vue';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { useSettingsStore } from '../stores/settings';
|
||||
import { showToast } from '../composables/useToast';
|
||||
|
||||
interface DownloadTask {
|
||||
id: number;
|
||||
name: string;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
const downloadingIds = reactive<Set<number>>(new Set());
|
||||
const tasks = reactive<DownloadTask[]>([]);
|
||||
const localSongIds = reactive<Set<number>>(new Set());
|
||||
|
||||
let listenerSetup = false;
|
||||
let storeSetup = false;
|
||||
|
||||
async function setupDownloadListener() {
|
||||
if (listenerSetup) return;
|
||||
listenerSetup = true;
|
||||
await listen<{ id: number; progress: number; name: string }>('download-progress', (event) => {
|
||||
const { id, progress, name } = event.payload;
|
||||
if (progress >= 100) {
|
||||
const idx = tasks.findIndex(t => t.id === id);
|
||||
if (idx >= 0) {
|
||||
tasks.splice(idx, 1);
|
||||
downloadingIds.delete(id);
|
||||
showToast(`${name} 下载完成`, 'success');
|
||||
}
|
||||
} else {
|
||||
const task = tasks.find(t => t.id === id);
|
||||
if (task) {
|
||||
task.progress = progress;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshLocalIds() {
|
||||
try {
|
||||
const settings = useSettingsStore();
|
||||
const list: { id: number }[] = await invoke('list_local_songs', { downloadPath: settings.downloadPath || null });
|
||||
localSongIds.clear();
|
||||
for (const s of list) {
|
||||
localSongIds.add(s.id);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function ensureStoreSetup() {
|
||||
if (storeSetup) return;
|
||||
storeSetup = true;
|
||||
const settings = useSettingsStore();
|
||||
refreshLocalIds();
|
||||
watch(() => settings.downloadPath, () => {
|
||||
refreshLocalIds();
|
||||
});
|
||||
}
|
||||
|
||||
function isDownloaded(songId: number): boolean {
|
||||
return localSongIds.has(songId);
|
||||
}
|
||||
|
||||
function isDownloading(songId: number): boolean {
|
||||
return downloadingIds.has(songId);
|
||||
}
|
||||
|
||||
function getDownloadProgress(songId: number): number {
|
||||
const task = tasks.find(t => t.id === songId);
|
||||
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 }) {
|
||||
if (downloadingIds.has(song.id)) return;
|
||||
if (localSongIds.has(song.id)) {
|
||||
showToast(`${song.name} 已下载`, 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = useSettingsStore();
|
||||
const artist = song.ar?.map(a => a.name).join(' / ') || song.artists?.map(a => a.name).join(' / ') || '未知';
|
||||
const albumName = song.al?.name || song.album?.name || null;
|
||||
const durationVal = song.dt || song.duration || null;
|
||||
const coverUrl = song.al?.picUrl || song.album?.picUrl || null;
|
||||
|
||||
downloadingIds.add(song.id);
|
||||
tasks.push({ id: song.id, name: song.name, progress: 0 });
|
||||
|
||||
try {
|
||||
await invoke('download_song', {
|
||||
query: {
|
||||
id: song.id,
|
||||
name: song.name,
|
||||
artist,
|
||||
album: albumName,
|
||||
duration: durationVal,
|
||||
coverUrl,
|
||||
level: settings.audioQuality,
|
||||
downloadPath: settings.downloadPath || null,
|
||||
},
|
||||
});
|
||||
localSongIds.add(song.id);
|
||||
} catch (e: any) {
|
||||
downloadingIds.delete(song.id);
|
||||
const idx = tasks.findIndex(t => t.id === song.id);
|
||||
if (idx >= 0) tasks.splice(idx, 1);
|
||||
if (e === '文件已存在') {
|
||||
localSongIds.add(song.id);
|
||||
showToast(`${song.name} 已下载`, 'info');
|
||||
} else if (e === 'VIP歌曲无法下载') {
|
||||
showToast(`${song.name} 为 VIP 歌曲,无法下载`, 'error');
|
||||
} else if (typeof e === 'string' && e.includes('VIP')) {
|
||||
showToast(`${song.name} 需要 VIP 权限才能下载`, 'error');
|
||||
} else {
|
||||
showToast(`下载失败: ${e}`, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function useDownload() {
|
||||
setupDownloadListener();
|
||||
ensureStoreSetup();
|
||||
return {
|
||||
downloadingIds,
|
||||
tasks,
|
||||
localSongIds,
|
||||
isDownloaded,
|
||||
isDownloading,
|
||||
getDownloadProgress,
|
||||
downloadSong,
|
||||
refreshLocalIds,
|
||||
};
|
||||
}
|
||||
143
src/composables/useUpdater.ts
Normal file
143
src/composables/useUpdater.ts
Normal file
@ -0,0 +1,143 @@
|
||||
import { ref } from 'vue'
|
||||
import { check } from '@tauri-apps/plugin-updater'
|
||||
import { relaunch } from '@tauri-apps/plugin-process'
|
||||
import { getVersion } from '@tauri-apps/api/app'
|
||||
|
||||
export interface UpdateInfo {
|
||||
version: string
|
||||
date: string | null
|
||||
body: string | null
|
||||
}
|
||||
|
||||
const IGNORED_VERSION_KEY = 'updater_ignored_version'
|
||||
|
||||
export function useUpdater() {
|
||||
const checking = ref(false)
|
||||
const downloading = ref(false)
|
||||
const downloadProgress = ref(0)
|
||||
const updateAvailable = ref(false)
|
||||
const updateInfo = ref<UpdateInfo | null>(null)
|
||||
const currentVersion = ref('')
|
||||
const error = ref('')
|
||||
|
||||
async function getCurrentVersion() {
|
||||
try {
|
||||
currentVersion.value = await getVersion()
|
||||
} catch {
|
||||
currentVersion.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function getIgnoredVersion(): string {
|
||||
try {
|
||||
return localStorage.getItem(IGNORED_VERSION_KEY) || ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function setIgnoredVersion(version: string) {
|
||||
try {
|
||||
localStorage.setItem(IGNORED_VERSION_KEY, version)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function checkForUpdate(silent = false): Promise<UpdateInfo | null> {
|
||||
if (checking.value) return null
|
||||
checking.value = true
|
||||
error.value = ''
|
||||
updateAvailable.value = false
|
||||
updateInfo.value = null
|
||||
|
||||
try {
|
||||
await getCurrentVersion()
|
||||
const result = await check()
|
||||
|
||||
if (!result) {
|
||||
if (!silent) error.value = '当前已是最新版本'
|
||||
return null
|
||||
}
|
||||
|
||||
const info: UpdateInfo = {
|
||||
version: result.version,
|
||||
date: result.date ?? null,
|
||||
body: result.body ?? null,
|
||||
}
|
||||
|
||||
const ignored = getIgnoredVersion()
|
||||
if (info.version === ignored) {
|
||||
if (!silent) error.value = '当前已是最新版本'
|
||||
return null
|
||||
}
|
||||
|
||||
updateAvailable.value = true
|
||||
updateInfo.value = info
|
||||
return info
|
||||
} catch (e: any) {
|
||||
if (!silent) error.value = `检查更新失败: ${e}`
|
||||
return null
|
||||
} finally {
|
||||
checking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadAndInstall() {
|
||||
if (downloading.value) return
|
||||
downloading.value = true
|
||||
downloadProgress.value = 0
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const result = await check()
|
||||
if (!result) {
|
||||
error.value = '未找到可用更新'
|
||||
return
|
||||
}
|
||||
|
||||
let downloaded = 0
|
||||
let contentLength = 0
|
||||
await result.downloadAndInstall((event) => {
|
||||
switch (event.event) {
|
||||
case 'Started':
|
||||
contentLength = event.data.contentLength ?? 0
|
||||
break
|
||||
case 'Progress':
|
||||
downloaded += event.data.chunkLength
|
||||
if (contentLength > 0) {
|
||||
downloadProgress.value = Math.round((downloaded / contentLength) * 100)
|
||||
}
|
||||
break
|
||||
case 'Finished':
|
||||
downloadProgress.value = 100
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
await relaunch()
|
||||
} catch (e: any) {
|
||||
error.value = `更新失败: ${e}`
|
||||
} finally {
|
||||
downloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function ignoreVersion(version: string) {
|
||||
setIgnoredVersion(version)
|
||||
updateAvailable.value = false
|
||||
updateInfo.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
checking,
|
||||
downloading,
|
||||
downloadProgress,
|
||||
updateAvailable,
|
||||
updateInfo,
|
||||
currentVersion,
|
||||
error,
|
||||
checkForUpdate,
|
||||
downloadAndInstall,
|
||||
ignoreVersion,
|
||||
getCurrentVersion,
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,7 @@ import Login from '@/views/Login.vue';
|
||||
import FavoriteSongs from '@/views/FavoriteSongs.vue';
|
||||
import RecentPlays from '@/views/RecentPlays.vue';
|
||||
import DailySongs from '@/views/DailySongs.vue';
|
||||
import LocalMusic from '@/views/LocalMusic.vue';
|
||||
import Settings from '@/views/Settings.vue';
|
||||
|
||||
|
||||
@ -17,8 +18,11 @@ const routes = [
|
||||
{ path: '/favorites', name: 'favorites', component: FavoriteSongs },
|
||||
{ path: '/recent', name: 'recent', component: RecentPlays },
|
||||
{ path: '/daily', name: 'daily', component: DailySongs },
|
||||
{ path: '/local-music', name: 'local-music', component: LocalMusic },
|
||||
{ path: '/login', name: 'login', component: Login },
|
||||
{ path: '/playlist/:id', name: 'playlist', component: PlaylistDetail },
|
||||
{ path: '/artist/:id', name: 'artist', component: () => import('@/views/ArtistDetail.vue') },
|
||||
{ path: '/album/:id', name: 'album', component: () => import('@/views/AlbumDetail.vue') },
|
||||
{ path: '/settings', name: 'settings', component: Settings },
|
||||
];
|
||||
|
||||
|
||||
@ -4,25 +4,25 @@ import { invoke } from '@tauri-apps/api/core';
|
||||
import { normalizeSong } from '../utils/song';
|
||||
import { useSettingsStore } from './settings';
|
||||
import { useUserStore } from './user';
|
||||
import { showToast } from '../composables/useToast';
|
||||
|
||||
export type PlayMode = 'loop' | 'shuffle' | 'repeat-one';
|
||||
|
||||
export interface Song {
|
||||
id: number;
|
||||
name: string;
|
||||
ar: { name: string }[];
|
||||
al: { picUrl: string };
|
||||
ar: { id?: number; name: string }[];
|
||||
al: { id?: number; picUrl: string; name?: string };
|
||||
dt?: number;
|
||||
|
||||
// 兼容不同接口返回的可选字段
|
||||
album?: { picUrl?: string };
|
||||
album?: { picUrl?: string; name?: string };
|
||||
artists?: { name: string }[];
|
||||
duration?: number; // 某些接口的时长字段(单位可能是秒)
|
||||
duration?: number;
|
||||
localPath?: string;
|
||||
}
|
||||
|
||||
const cacheProgress = ref(0);
|
||||
|
||||
// 监听 Tauri 事件(需要在适当位置初始化一次)
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
|
||||
export function setupCacheProgressListener() {
|
||||
@ -31,9 +31,6 @@ export function setupCacheProgressListener() {
|
||||
});
|
||||
}
|
||||
|
||||
// 在 store 定义外调用 setupCacheProgressListener(),或者在应用入口调用
|
||||
|
||||
|
||||
function loadRecentLocal(): Song[] {
|
||||
try {
|
||||
const raw = localStorage.getItem('recent_local');
|
||||
@ -58,6 +55,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
|
||||
const queue = ref<Song[]>([]);
|
||||
const currentIndex = ref(-1);
|
||||
const volume = ref(100);
|
||||
|
||||
let tickInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
@ -124,9 +122,11 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
fmNextCallback = null;
|
||||
}
|
||||
|
||||
// 播放私人漫游歌曲(清空队列,只播放这一首)
|
||||
let fmVipSkipCount = 0;
|
||||
const MAX_FM_VIP_SKIP = 10;
|
||||
|
||||
async function playFmSong(song: any) {
|
||||
// 如果缺少时长,尝试从详情接口获取
|
||||
if (tickInterval) { clearInterval(tickInterval); tickInterval = null; }
|
||||
if (!song.dt || song.dt === 0) {
|
||||
try {
|
||||
const jsonStr: string = await invoke('get_song_detail', { id: String(song.id) });
|
||||
@ -145,12 +145,36 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
currentIndex.value = -1;
|
||||
playing.value = false;
|
||||
|
||||
fmSong.value = song;
|
||||
currentSong.value = song;
|
||||
try {
|
||||
const settings = useSettingsStore();
|
||||
const url: 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, fm_mode: true } });
|
||||
const data = JSON.parse(jsonStr);
|
||||
const url: string | undefined = data.url;
|
||||
if (!url) throw new Error('无播放源');
|
||||
|
||||
if (data.freeTrialInfo) {
|
||||
console.warn('FM VIP 试听歌曲,自动跳过', song.name);
|
||||
showToast(`${song.name} 为 VIP 试听,已跳过`, 'info');
|
||||
fmVipSkipCount++;
|
||||
if (fmVipSkipCount >= MAX_FM_VIP_SKIP) {
|
||||
console.warn('FM 连续跳过 VIP 歌曲过多,停止');
|
||||
fmVipSkipCount = 0;
|
||||
disableFmMode();
|
||||
return;
|
||||
}
|
||||
if (fmNextCallback) {
|
||||
fmNextCallback();
|
||||
} else {
|
||||
disableFmMode();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
fmVipSkipCount = 0;
|
||||
await invoke('play_audio', { url });
|
||||
await waitForAudioStart();
|
||||
playing.value = true;
|
||||
duration.value = (song.dt || 0) / 1000;
|
||||
currentTime.value = 0;
|
||||
@ -159,16 +183,19 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
} catch (e) {
|
||||
console.error('FM播放失败', e);
|
||||
playing.value = false;
|
||||
if (fmNextCallback) {
|
||||
fmNextCallback();
|
||||
} else {
|
||||
disableFmMode();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 播放指定歌曲(如果不在队列中则加入并切换)
|
||||
async function play(song: Song) {
|
||||
disableFmMode();
|
||||
|
||||
const idx = queue.value.findIndex(s => s.id === song.id);
|
||||
if (idx === -1) {
|
||||
// 未在队列中,添加到队列并播放该位置
|
||||
queue.value.push(song);
|
||||
currentIndex.value = queue.value.length - 1;
|
||||
} else {
|
||||
@ -177,7 +204,34 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
await playCurrent();
|
||||
}
|
||||
|
||||
async function playFromList(songs: Song[], startIndex: number) {
|
||||
disableFmMode();
|
||||
if (songs.length === 0) return;
|
||||
queue.value = [...songs];
|
||||
currentIndex.value = Math.max(0, Math.min(startIndex, songs.length - 1));
|
||||
await playCurrent();
|
||||
}
|
||||
|
||||
let vipSkipCount = 0;
|
||||
const MAX_VIP_SKIP = 10;
|
||||
|
||||
let audioStartedResolve: (() => void) | null = null;
|
||||
|
||||
listen('audio-started', () => {
|
||||
if (audioStartedResolve) {
|
||||
audioStartedResolve();
|
||||
audioStartedResolve = null;
|
||||
}
|
||||
});
|
||||
|
||||
function waitForAudioStart(): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
audioStartedResolve = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
async function playCurrent() {
|
||||
if (tickInterval) { clearInterval(tickInterval); tickInterval = null; }
|
||||
const song = queue.value[currentIndex.value];
|
||||
if (!song?.id) {
|
||||
console.error('无效的歌曲数据', song);
|
||||
@ -185,23 +239,49 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
}
|
||||
|
||||
try {
|
||||
// 重置状态
|
||||
currentSong.value = song;
|
||||
playing.value = false;
|
||||
currentTime.value = 0;
|
||||
duration.value = (song.dt || 0) / 1000;
|
||||
duration.value = (song.dt || song.duration || 0) / 1000;
|
||||
|
||||
if (song.localPath) {
|
||||
await invoke('play_local_audio', { path: song.localPath });
|
||||
await waitForAudioStart();
|
||||
playing.value = true;
|
||||
startTick();
|
||||
addRecent(song);
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = useSettingsStore();
|
||||
const url: 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 url: string | undefined = data.url;
|
||||
|
||||
if (!url) {
|
||||
console.error('未获取到有效播放地址', song);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.freeTrialInfo) {
|
||||
console.warn('VIP 试听歌曲,自动跳过', song.name);
|
||||
showToast(`${song.name} 为 VIP 试听,已跳过`, 'info');
|
||||
vipSkipCount++;
|
||||
if (vipSkipCount >= MAX_VIP_SKIP) {
|
||||
console.warn('连续跳过 VIP 歌曲过多,停止跳过');
|
||||
vipSkipCount = 0;
|
||||
return;
|
||||
}
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
await invoke('play_audio', { url });
|
||||
await waitForAudioStart();
|
||||
playing.value = true;
|
||||
startTick();
|
||||
addRecent(song);
|
||||
vipSkipCount = 0;
|
||||
} catch (e) {
|
||||
console.error('播放失败', e);
|
||||
playing.value = false;
|
||||
@ -212,10 +292,11 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
if (tickInterval) clearInterval(tickInterval);
|
||||
tickInterval = setInterval(() => {
|
||||
if (playing.value && duration.value > 0) {
|
||||
if (currentTime.value < duration.value) {
|
||||
currentTime.value += 0.25;
|
||||
if (currentTime.value >= duration.value) {
|
||||
if (currentTime.value > duration.value) {
|
||||
currentTime.value = duration.value;
|
||||
next(); // 自动下一首
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 250);
|
||||
@ -237,7 +318,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
currentSong.value = null;
|
||||
currentTime.value = 0;
|
||||
if (tickInterval) clearInterval(tickInterval);
|
||||
disableFmMode(); // 停止时退出漫游
|
||||
disableFmMode();
|
||||
}
|
||||
|
||||
|
||||
@ -248,7 +329,6 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
playCurrent();
|
||||
}
|
||||
|
||||
// 批量添加歌曲到队列并播放第一首(用于“播放全部”)
|
||||
async function playAll(songs: Song[]) {
|
||||
if (songs.length === 0) return;
|
||||
queue.value = [...songs];
|
||||
@ -260,22 +340,17 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
if (index < 0 || index >= queue.value.length) return;
|
||||
const isCurrent = index === currentIndex.value;
|
||||
if (isCurrent) {
|
||||
// 如果移除的是当前正在播放的歌曲,先停止,然后调整索引
|
||||
stop();
|
||||
queue.value.splice(index, 1);
|
||||
// 如果队列变空,则重置
|
||||
if (queue.value.length === 0) {
|
||||
currentIndex.value = -1;
|
||||
return;
|
||||
}
|
||||
// 保持索引不变,但如果删的是最后一个,索引需要退一位
|
||||
if (currentIndex.value >= queue.value.length) {
|
||||
currentIndex.value = queue.value.length - 1;
|
||||
}
|
||||
// 不自动播放,等用户手动选择
|
||||
} else {
|
||||
queue.value.splice(index, 1);
|
||||
// 调整当前索引
|
||||
if (index < currentIndex.value) {
|
||||
currentIndex.value -= 1;
|
||||
}
|
||||
@ -297,15 +372,19 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function adjustVolume(delta: number) {
|
||||
const newVol = Math.max(0, Math.min(100, volume.value + delta));
|
||||
volume.value = newVol;
|
||||
await invoke('set_volume', { vol: newVol / 100 });
|
||||
}
|
||||
|
||||
|
||||
// 在 defineStore 内部添加
|
||||
const playMode = ref<PlayMode>('loop');
|
||||
|
||||
function setPlayMode(mode: PlayMode) {
|
||||
playMode.value = mode;
|
||||
}
|
||||
|
||||
// 重写 next() 以根据模式选择下一首
|
||||
function next() {
|
||||
if (isFmMode.value && fmNextCallback) {
|
||||
fmNextCallback();
|
||||
@ -316,11 +395,9 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
let nextIndex: number;
|
||||
switch (playMode.value) {
|
||||
case 'repeat-one':
|
||||
// 单曲循环,不改变索引,只重新播放当前
|
||||
playCurrent();
|
||||
return;
|
||||
case 'shuffle':
|
||||
// 随机下一首,且不与当前重复(除非只剩一首)
|
||||
if (queue.value.length === 1) {
|
||||
nextIndex = 0;
|
||||
} else {
|
||||
@ -331,7 +408,6 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
break;
|
||||
case 'loop':
|
||||
default:
|
||||
// 顺序循环
|
||||
nextIndex = (currentIndex.value + 1) % queue.value.length;
|
||||
break;
|
||||
}
|
||||
@ -340,11 +416,19 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
}
|
||||
|
||||
const showRoamDrawer = ref(false);
|
||||
const roamInitialTab = ref<'lyric' | 'comment'>('lyric');
|
||||
const commentSongId = ref<number | null>(null);
|
||||
|
||||
function openRoamDrawer() {
|
||||
function openRoamDrawer(tab: 'lyric' | 'comment' = 'lyric') {
|
||||
roamInitialTab.value = tab;
|
||||
showRoamDrawer.value = true;
|
||||
}
|
||||
|
||||
function openCommentForSong(songId: number) {
|
||||
commentSongId.value = songId;
|
||||
openRoamDrawer('comment');
|
||||
}
|
||||
|
||||
function closeRoamDrawer() {
|
||||
showRoamDrawer.value = false;
|
||||
}
|
||||
@ -360,7 +444,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
const songs = data.data || data;
|
||||
if (songs && songs.length > 0) {
|
||||
const song = normalizeSong(songs[0]);
|
||||
enableFmMode(() => loadFirstFmSong()); // 下一首回调
|
||||
enableFmMode(() => loadFirstFmSong());
|
||||
await playFmSong(song);
|
||||
return true;
|
||||
}
|
||||
@ -383,10 +467,9 @@ async function loadFm() {
|
||||
if (songs && songs.length > 0) {
|
||||
const song = normalizeSong(songs[0]);
|
||||
fmSong.value = song;
|
||||
enableFmMode(nextFm); // 设置下一首回调为 store 内的 nextFm
|
||||
await playFmSong(song); // 使用 FM 专用播放方法
|
||||
enableFmMode(nextFm);
|
||||
await playFmSong(song);
|
||||
fmPlaying.value = true;
|
||||
// showRoamDrawer.value = true; // 自动打开全屏抽屉
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('FM加载失败', e);
|
||||
@ -396,17 +479,13 @@ async function loadFm() {
|
||||
async function toggleFm() {
|
||||
if (!fmSong.value) return;
|
||||
if (fmPlaying.value) {
|
||||
// 当前 FM 正在播放,切换暂停/恢复
|
||||
await toggle(); // 全局暂停/播放
|
||||
fmPlaying.value = playing.value;
|
||||
} else {
|
||||
// FM 处于暂停状态,或者当前被其他歌曲打断
|
||||
if (currentSong.value?.id === fmSong.value.id) {
|
||||
// FM 歌曲还是当前歌曲,直接恢复
|
||||
await toggle();
|
||||
fmPlaying.value = playing.value;
|
||||
} else {
|
||||
// 当前播放的是其他歌曲,重新以 FM 模式播放 FM 歌曲
|
||||
if (currentSong.value?.id === fmSong.value.id) {
|
||||
await toggle();
|
||||
fmPlaying.value = playing.value;
|
||||
} else {
|
||||
enableFmMode(nextFm);
|
||||
await playFmSong(fmSong.value);
|
||||
fmPlaying.value = true;
|
||||
@ -415,20 +494,26 @@ async function toggleFm() {
|
||||
}
|
||||
|
||||
async function nextFm() {
|
||||
await loadFm(); // 加载下一首 FM 歌曲
|
||||
await loadFm();
|
||||
}
|
||||
|
||||
// 监听全局播放变化,若用户选择了非 FM 歌曲,自动退出 FM 状态
|
||||
listen('audio-ended', () => {
|
||||
if (tickInterval) { clearInterval(tickInterval); tickInterval = null; }
|
||||
if (isFmMode.value && fmNextCallback) {
|
||||
fmNextCallback();
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
watch(currentSong, (newSong) => {
|
||||
if (isFmMode.value && newSong?.id !== fmSong.value?.id) {
|
||||
fmPlaying.value = false;
|
||||
// 注意:不调用 disableFmMode,因为可能只是临时切歌,但卡片需要知道 FM 已停止
|
||||
disableFmMode(); // 退出 FM 模式,让上一首按钮恢复
|
||||
disableFmMode();
|
||||
}
|
||||
});
|
||||
|
||||
watch(playing, (val) => {
|
||||
// 只有当前正在播放的是 FM 歌曲时,才同步 fmPlaying
|
||||
if (currentSong.value?.id === fmSong.value?.id) {
|
||||
fmPlaying.value = val;
|
||||
} else {
|
||||
@ -451,6 +536,7 @@ watch(playing, (val) => {
|
||||
playFmSong,
|
||||
setPlayMode,
|
||||
play,
|
||||
playFromList,
|
||||
playAll,
|
||||
toggle,
|
||||
stop,
|
||||
@ -458,6 +544,8 @@ watch(playing, (val) => {
|
||||
next,
|
||||
seek,
|
||||
playCurrent,
|
||||
volume,
|
||||
adjustVolume,
|
||||
|
||||
removeFromQueue,
|
||||
clearQueue,
|
||||
@ -470,6 +558,9 @@ watch(playing, (val) => {
|
||||
toggleLike,
|
||||
|
||||
showRoamDrawer,
|
||||
roamInitialTab,
|
||||
commentSongId,
|
||||
openCommentForSong,
|
||||
openRoamDrawer,
|
||||
closeRoamDrawer,
|
||||
toggleRoamDrawer,
|
||||
|
||||
@ -19,23 +19,50 @@ export const closeActionLabels: Record<CloseAction, string> = {
|
||||
exit: '直接退出',
|
||||
};
|
||||
|
||||
export interface ShortcutBinding {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const defaultShortcuts: Record<string, ShortcutBinding> = {
|
||||
prev: { key: 'Control+ArrowLeft', label: '上一首' },
|
||||
next: { key: 'Control+ArrowRight', label: '下一首' },
|
||||
volUp: { key: 'Control+ArrowUp', label: '音量增加' },
|
||||
volDown: { key: 'Control+ArrowDown', label: '音量减小' },
|
||||
globalPrev: { key: 'Alt+Control+ArrowLeft', label: '上一首(全局)' },
|
||||
globalNext: { key: 'Alt+Control+ArrowRight', label: '下一首(全局)' },
|
||||
globalVolUp: { key: 'Alt+Control+ArrowUp', label: '音量增加(全局)' },
|
||||
globalVolDown: { key: 'Alt+Control+ArrowDown', label: '音量减小(全局)' },
|
||||
};
|
||||
|
||||
interface SettingsData {
|
||||
audioQuality: AudioQuality;
|
||||
downloadPath: string;
|
||||
theme: ThemeMode;
|
||||
closeAction: CloseAction;
|
||||
shortcuts: Record<string, ShortcutBinding>;
|
||||
}
|
||||
|
||||
function loadSettings(): SettingsData {
|
||||
try {
|
||||
const raw = localStorage.getItem('app_settings');
|
||||
if (raw) return JSON.parse(raw);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
return {
|
||||
audioQuality: parsed.audioQuality || 'standard',
|
||||
downloadPath: parsed.downloadPath || '',
|
||||
theme: parsed.theme || 'dark',
|
||||
closeAction: parsed.closeAction || 'ask',
|
||||
shortcuts: { ...defaultShortcuts, ...(parsed.shortcuts || {}) },
|
||||
};
|
||||
}
|
||||
} catch {}
|
||||
return {
|
||||
audioQuality: 'standard',
|
||||
downloadPath: '',
|
||||
theme: 'dark',
|
||||
closeAction: 'ask',
|
||||
shortcuts: { ...defaultShortcuts },
|
||||
};
|
||||
}
|
||||
|
||||
@ -46,6 +73,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
const downloadPath = ref<string>(saved.downloadPath);
|
||||
const theme = ref<ThemeMode>(saved.theme);
|
||||
const closeAction = ref<CloseAction>(saved.closeAction || 'ask');
|
||||
const shortcuts = ref<Record<string, ShortcutBinding>>(saved.shortcuts);
|
||||
|
||||
function setAudioQuality(q: AudioQuality) {
|
||||
audioQuality.value = q;
|
||||
@ -63,12 +91,29 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
closeAction.value = a;
|
||||
}
|
||||
|
||||
watch([audioQuality, downloadPath, theme, closeAction], () => {
|
||||
function setShortcut(id: string, key: string) {
|
||||
shortcuts.value = { ...shortcuts.value, [id]: { ...shortcuts.value[id], key } };
|
||||
}
|
||||
|
||||
function resetShortcuts() {
|
||||
shortcuts.value = { ...defaultShortcuts };
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
audioQuality.value = 'standard';
|
||||
downloadPath.value = '';
|
||||
theme.value = 'dark';
|
||||
closeAction.value = 'ask';
|
||||
shortcuts.value = { ...defaultShortcuts };
|
||||
}
|
||||
|
||||
watch([audioQuality, downloadPath, theme, closeAction, shortcuts], () => {
|
||||
const data: SettingsData = {
|
||||
audioQuality: audioQuality.value,
|
||||
downloadPath: downloadPath.value,
|
||||
theme: theme.value,
|
||||
closeAction: closeAction.value,
|
||||
shortcuts: shortcuts.value,
|
||||
};
|
||||
localStorage.setItem('app_settings', JSON.stringify(data));
|
||||
}, { deep: true });
|
||||
@ -78,9 +123,13 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
downloadPath,
|
||||
theme,
|
||||
closeAction,
|
||||
shortcuts,
|
||||
setAudioQuality,
|
||||
setDownloadPath,
|
||||
setTheme,
|
||||
setCloseAction,
|
||||
setShortcut,
|
||||
resetShortcuts,
|
||||
resetAll,
|
||||
};
|
||||
});
|
||||
|
||||
@ -3,14 +3,18 @@
|
||||
*/
|
||||
export function normalizeSong(song: any) {
|
||||
const normalized = { ...song };
|
||||
// 封面 / 艺术家兼容
|
||||
if (!normalized.al?.picUrl && normalized.album?.picUrl) {
|
||||
normalized.al = { ...normalized.al, picUrl: normalized.album.picUrl };
|
||||
}
|
||||
if (!normalized.al?.name && normalized.album?.name) {
|
||||
normalized.al = { ...normalized.al, name: normalized.album.name };
|
||||
}
|
||||
if (!normalized.al?.id && normalized.album?.id) {
|
||||
normalized.al = { ...normalized.al, id: normalized.album.id };
|
||||
}
|
||||
if (!normalized.ar || normalized.ar.length === 0) {
|
||||
normalized.ar = normalized.artists || [];
|
||||
}
|
||||
// 时长:只保留合理的 dt(100ms ~ 2小时),否则置 0
|
||||
if (!normalized.dt || normalized.dt < 100 || normalized.dt > 7200000) {
|
||||
normalized.dt = 0;
|
||||
}
|
||||
|
||||
156
src/views/AlbumDetail.vue
Normal file
156
src/views/AlbumDetail.vue
Normal file
@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<div class="p-8 text-content">
|
||||
<button @click="$router.back()" class="mb-4 text-content-2 hover:text-content transition">
|
||||
← 返回
|
||||
</button>
|
||||
|
||||
<div v-if="album" class="flex gap-6 mb-8">
|
||||
<img :src="album.picUrl" class="w-44 h-44 rounded-xl object-cover shadow-lg flex-shrink-0" />
|
||||
<div class="flex flex-col justify-between min-w-0">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold leading-tight">{{ album.name }}</h1>
|
||||
<div v-if="album.artists?.length" class="flex items-center gap-1 mt-2 text-sm text-content-2">
|
||||
<template v-for="(ar, idx) in album.artists" :key="ar.id">
|
||||
<span v-if="idx > 0" class="text-content-3">/</span>
|
||||
<span
|
||||
class="hover:text-accent-text cursor-pointer transition"
|
||||
@click="ar.id && router.push({ name: 'artist', params: { id: ar.id } })"
|
||||
>{{ ar.name }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<p class="text-xs text-content-3 mt-2">
|
||||
{{ formatDate(album.publishTime) }} · {{ songs.length }} 首歌曲
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-4">
|
||||
<button
|
||||
@click="playAll"
|
||||
class="px-5 py-2 bg-accent hover:bg-accent-hover rounded-full text-white font-medium transition flex items-center gap-2"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M4 2.5v11l9-5.5z"/></svg>
|
||||
播放全部
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-content-2">加载中...</div>
|
||||
|
||||
<div v-else class="space-y-1">
|
||||
<div
|
||||
v-for="(song, index) in songs"
|
||||
:key="song.id"
|
||||
@click="playSingle(song)"
|
||||
class="flex items-center gap-4 p-3 rounded-xl hover:bg-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">
|
||||
<div v-if="isCurrentSong(song.id)" class="flex items-center justify-end">
|
||||
<div class="flex items-center gap-[3px] h-4">
|
||||
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 50%; animation-delay: 0ms"></span>
|
||||
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 100%; animation-delay: 150ms"></span>
|
||||
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 35%; animation-delay: 300ms"></span>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<span class="text-xs text-content-3 group-hover:hidden">{{ index + 1 }}</span>
|
||||
<svg class="hidden group-hover:block text-content" width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4 2.5v11l9-5.5z"/></svg>
|
||||
</template>
|
||||
</div>
|
||||
<img :src="song.al?.picUrl" class="w-10 h-10 rounded object-cover flex-shrink-0" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { useDownload } from '../composables/useDownload';
|
||||
import { formatDuration } from '../utils/format';
|
||||
import SongItemMenu from '../components/SongItemMenu.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const player = usePlayerStore();
|
||||
const download = useDownload();
|
||||
|
||||
const album = ref<any>(null);
|
||||
const songs = ref<any[]>([]);
|
||||
const loading = ref(true);
|
||||
|
||||
function formatDate(ts: number): string {
|
||||
if (!ts) return '';
|
||||
const d = new Date(ts);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
async function fetchAlbum(id: number) {
|
||||
loading.value = true;
|
||||
album.value = null;
|
||||
songs.value = [];
|
||||
try {
|
||||
const jsonStr: string = await invoke('album_detail', { id });
|
||||
const data = JSON.parse(jsonStr);
|
||||
const a = data.album;
|
||||
if (a) {
|
||||
delete a.uid;
|
||||
if (a.artists) a.artists.forEach((ar: any) => delete ar.uid);
|
||||
}
|
||||
album.value = a;
|
||||
songs.value = data.songs || [];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchAlbum(Number(route.params.id));
|
||||
});
|
||||
|
||||
watch(() => route.params.id, (newId) => {
|
||||
if (newId) fetchAlbum(Number(newId));
|
||||
});
|
||||
|
||||
function isCurrentSong(songId: number): boolean {
|
||||
return player.currentSong?.id === songId;
|
||||
}
|
||||
|
||||
async function playSingle(song: any) {
|
||||
const idx = songs.value.findIndex((s: any) => s.id === song.id);
|
||||
player.playFromList(songs.value, idx >= 0 ? idx : 0);
|
||||
}
|
||||
|
||||
function playAll() {
|
||||
if (songs.value.length === 0) return;
|
||||
player.playAll(songs.value);
|
||||
}
|
||||
</script>
|
||||
202
src/views/ArtistDetail.vue
Normal file
202
src/views/ArtistDetail.vue
Normal file
@ -0,0 +1,202 @@
|
||||
<template>
|
||||
<div class="p-8 text-content">
|
||||
<button @click="$router.back()" class="mb-4 text-content-2 hover:text-content transition">
|
||||
← 返回
|
||||
</button>
|
||||
|
||||
<div v-if="artist" class="flex gap-6 mb-8">
|
||||
<img :src="artist.cover" class="w-44 h-44 rounded-xl object-cover shadow-lg flex-shrink-0" />
|
||||
<div class="flex flex-col justify-between min-w-0">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold leading-tight">{{ artist.name }}</h1>
|
||||
<p class="text-xs text-content-3 mt-2">
|
||||
{{ formatPlayCount(artist.followeds || 0) }} 粉丝 · {{ artist.musicSize || 0 }} 首歌曲
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-4">
|
||||
<button
|
||||
@click="playAll"
|
||||
class="px-5 py-2 bg-accent hover:bg-accent-hover rounded-full text-white font-medium transition flex items-center gap-2"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M4 2.5v11l9-5.5z"/></svg>
|
||||
播放全部
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mb-6">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
@click="activeTab = tab.key"
|
||||
class="px-4 py-1.5 rounded-full text-sm transition"
|
||||
:class="activeTab === tab.key ? 'bg-accent text-white' : 'bg-subtle text-content-2 hover:bg-muted'"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-content-2">加载中...</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="activeTab === 'songs'" class="space-y-1">
|
||||
<div
|
||||
v-for="(song, index) in songs"
|
||||
:key="song.id"
|
||||
@click="playSingle(song)"
|
||||
class="flex items-center gap-4 p-3 rounded-xl hover:bg-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">
|
||||
<div v-if="isCurrentSong(song.id)" class="flex items-center justify-end">
|
||||
<div class="flex items-center gap-[3px] h-4">
|
||||
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 50%; animation-delay: 0ms"></span>
|
||||
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 100%; animation-delay: 150ms"></span>
|
||||
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 35%; animation-delay: 300ms"></span>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<span class="text-xs text-content-3 group-hover:hidden">{{ index + 1 }}</span>
|
||||
<svg class="hidden group-hover:block text-content" width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4 2.5v11l9-5.5z"/></svg>
|
||||
</template>
|
||||
</div>
|
||||
<img :src="song.al?.picUrl" class="w-10 h-10 rounded object-cover flex-shrink-0" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<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 v-if="activeTab === 'albums'" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<div
|
||||
v-for="album in albums"
|
||||
:key="album.id"
|
||||
@click="router.push({ name: 'album', params: { id: album.id } })"
|
||||
class="bg-subtle rounded-xl overflow-hidden hover:bg-muted transition cursor-pointer"
|
||||
>
|
||||
<img :src="album.picUrl" class="w-full aspect-square object-cover" />
|
||||
<div class="p-3">
|
||||
<p class="text-sm font-medium truncate">{{ album.name }}</p>
|
||||
<p class="text-xs text-content-2 mt-1">{{ formatAlbumDate(album.publishTime) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'desc'" class="max-w-2xl">
|
||||
<p class="text-sm text-content-2 leading-relaxed whitespace-pre-wrap">{{ briefDesc }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { useDownload } from '../composables/useDownload';
|
||||
import { formatDuration, formatPlayCount } from '../utils/format';
|
||||
import SongItemMenu from '../components/SongItemMenu.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const player = usePlayerStore();
|
||||
const download = useDownload();
|
||||
|
||||
const artist = ref<any>(null);
|
||||
const songs = ref<any[]>([]);
|
||||
const albums = ref<any[]>([]);
|
||||
const briefDesc = ref('');
|
||||
const loading = ref(true);
|
||||
const activeTab = ref('songs');
|
||||
|
||||
const tabs = [
|
||||
{ key: 'songs', label: '热门歌曲' },
|
||||
{ key: 'albums', label: '专辑' },
|
||||
{ key: 'desc', label: '简介' },
|
||||
];
|
||||
|
||||
async function fetchArtist(id: number) {
|
||||
loading.value = true;
|
||||
artist.value = null;
|
||||
songs.value = [];
|
||||
albums.value = [];
|
||||
briefDesc.value = '';
|
||||
try {
|
||||
const [detailStr, songsStr, albumStr, descStr] = await Promise.all([
|
||||
invoke('artist_detail', { id }) as Promise<string>,
|
||||
invoke('artist_songs', { query: { id, order: 'hot', limit: 50, offset: 0 } }) as Promise<string>,
|
||||
invoke('artist_album', { id, limit: 30, offset: 0 }) as Promise<string>,
|
||||
invoke('artist_desc', { id }) as Promise<string>,
|
||||
]);
|
||||
const detailData = JSON.parse(detailStr);
|
||||
artist.value = detailData.artist;
|
||||
const songsData = JSON.parse(songsStr);
|
||||
songs.value = songsData.songs || [];
|
||||
const albumData = JSON.parse(albumStr);
|
||||
const rawAlbums = albumData.hotAlbums || [];
|
||||
rawAlbums.forEach((a: any) => {
|
||||
delete a.uid;
|
||||
if (a.artist) delete a.artist.uid;
|
||||
if (a.artists) a.artists.forEach((ar: any) => delete ar.uid);
|
||||
});
|
||||
albums.value = rawAlbums;
|
||||
const descData = JSON.parse(descStr);
|
||||
briefDesc.value = descData.briefDesc || '';
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchArtist(Number(route.params.id));
|
||||
});
|
||||
|
||||
watch(() => route.params.id, (newId) => {
|
||||
if (newId) fetchArtist(Number(newId));
|
||||
});
|
||||
|
||||
function formatAlbumDate(ts: number): string {
|
||||
if (!ts) return '';
|
||||
const d = new Date(ts);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function isCurrentSong(songId: number): boolean {
|
||||
return player.currentSong?.id === songId;
|
||||
}
|
||||
|
||||
async function playSingle(song: any) {
|
||||
const idx = songs.value.findIndex((s: any) => s.id === song.id);
|
||||
player.playFromList(songs.value, idx >= 0 ? idx : 0);
|
||||
}
|
||||
|
||||
function playAll() {
|
||||
if (songs.value.length === 0) return;
|
||||
player.playAll(songs.value);
|
||||
}
|
||||
</script>
|
||||
@ -18,7 +18,7 @@
|
||||
<div
|
||||
v-for="(song, index) in songs"
|
||||
:key="song.id"
|
||||
@click="player.play(song)"
|
||||
@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) }"
|
||||
>
|
||||
@ -39,13 +39,26 @@
|
||||
<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">
|
||||
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
|
||||
<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>
|
||||
@ -54,11 +67,16 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import SongItemMenu from '../components/SongItemMenu.vue';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { useDownload } from '../composables/useDownload';
|
||||
import { formatDuration } from '../utils/format';
|
||||
|
||||
const player = usePlayerStore();
|
||||
const download = useDownload();
|
||||
const router = useRouter();
|
||||
const songs = ref<any[]>([]);
|
||||
const loading = ref(true);
|
||||
|
||||
|
||||
@ -38,18 +38,31 @@
|
||||
<div v-if="loading" class="text-content-2">搜索中...</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="song in results"
|
||||
v-for="(song, index) in results"
|
||||
:key="song.id"
|
||||
@click="playSong(song)"
|
||||
@click="playSong(song, index)"
|
||||
class="flex items-center gap-4 p-3 rounded-xl backdrop-blur-md bg-subtle hover:bg-muted border border-line-2 cursor-pointer transition"
|
||||
>
|
||||
<img :src="song.al?.picUrl" class="w-12 h-12 rounded-lg object-cover" />
|
||||
<div>
|
||||
<p class="font-medium">{{ song.name }}</p>
|
||||
<p class="text-sm text-content-2">
|
||||
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium truncate">{{ song.name }}</p>
|
||||
<p class="text-sm text-content-2 truncate">
|
||||
<template v-for="(a, i) in song.ar || []" :key="a.id || i">
|
||||
<span v-if="i > 0" class="text-content-3">/</span>
|
||||
<span class="hover:text-accent-text cursor-pointer transition" @click.stop="a.id && router.push({ name: 'artist', params: { id: a.id } })">{{ a.name }}</span>
|
||||
</template>
|
||||
<template v-if="song.al?.name">
|
||||
<span class="text-content-3 mx-1">·</span>
|
||||
<span class="hover:text-accent-text cursor-pointer transition" @click.stop="song.al.id && router.push({ name: 'album', params: { id: song.al.id } })">{{ song.al.name }}</span>
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
<button @click.stop="download.downloadSong(song)" class="text-content-3 hover:text-accent-text transition flex-shrink-0" :title="download.isDownloaded(song.id) ? '已下载' : '下载'">
|
||||
<svg v-if="download.isDownloading(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="animate-spin"><path d="M21 12a9 9 0 11-6.22-8.56"/></svg>
|
||||
<svg v-else-if="download.isDownloaded(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-accent-text"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
</button>
|
||||
<SongItemMenu :song-id="song.id" />
|
||||
</div>
|
||||
<p v-if="!loading && hasSearched && results.length === 0" class="text-content-2">无结果</p>
|
||||
</div>
|
||||
@ -63,10 +76,13 @@ import { ref, onMounted } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { useDownload } from '../composables/useDownload';
|
||||
import SongItemMenu from '../components/SongItemMenu.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const player = usePlayerStore();
|
||||
const download = useDownload();
|
||||
|
||||
const keyword = ref('');
|
||||
const results = ref<any[]>([]);
|
||||
@ -116,8 +132,15 @@ function searchTag(tag: string) {
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
async function playSong(song: any) {
|
||||
player.play(song);
|
||||
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>
|
||||
|
||||
@ -22,7 +22,7 @@
|
||||
<div
|
||||
v-for="(song, index) in songs"
|
||||
:key="song.id"
|
||||
@click="player.play(song)"
|
||||
@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>
|
||||
@ -30,13 +30,26 @@
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium truncate">{{ song.name }}</p>
|
||||
<p class="text-xs text-content-2 truncate">
|
||||
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
|
||||
<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>
|
||||
@ -45,14 +58,19 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import SongItemMenu from '../components/SongItemMenu.vue';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { useUserStore } from '../stores/user';
|
||||
import { useDownload } from '../composables/useDownload';
|
||||
import { normalizeSong } from '../utils/song';
|
||||
import { formatDuration } from '../utils/format';
|
||||
|
||||
const player = usePlayerStore();
|
||||
const userStore = useUserStore();
|
||||
const download = useDownload();
|
||||
const router = useRouter();
|
||||
const songs = ref<any[]>([]);
|
||||
const loading = ref(true);
|
||||
|
||||
|
||||
225
src/views/LocalMusic.vue
Normal file
225
src/views/LocalMusic.vue
Normal file
@ -0,0 +1,225 @@
|
||||
<template>
|
||||
<div class="p-8 text-content">
|
||||
<button @click="$router.back()" class="mb-4 text-content-2 hover:text-content transition">
|
||||
← 返回
|
||||
</button>
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<h1 class="text-2xl font-bold">本地音乐</h1>
|
||||
<span v-if="songs.length" class="text-xs text-content-3">{{ songs.length }} 首</span>
|
||||
<button
|
||||
v-if="songs.length"
|
||||
@click="refresh"
|
||||
class="px-3 py-1 bg-muted hover:bg-emphasis rounded-full text-xs transition"
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="loading" class="text-content-2">加载中...</div>
|
||||
<div v-else-if="songs.length === 0" class="text-content-3">
|
||||
当前文件夹下没有音乐文件,支持 mp3、flac、wav、ogg、aac、m4a 格式
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="(song, index) in songs"
|
||||
:key="song.id + '-' + index"
|
||||
@click="playLocalSong(song, index)"
|
||||
class="flex items-center gap-4 p-3 rounded-xl hover:bg-subtle transition cursor-pointer"
|
||||
:class="{ 'bg-subtle': player.currentSong?.id === song.id }"
|
||||
>
|
||||
<span class="text-xs text-content-3 w-6 text-right flex-shrink-0">{{ index + 1 }}</span>
|
||||
<div class="w-10 h-10 rounded-lg overflow-hidden flex-shrink-0 bg-muted">
|
||||
<img v-if="song.cover" :src="song.cover" class="w-full h-full object-cover" />
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-content-3"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium truncate">{{ song.name }}</p>
|
||||
<p class="text-xs text-content-2 truncate">
|
||||
{{ song.artist }}<template v-if="song.album"> · {{ song.album }}</template>
|
||||
</p>
|
||||
</div>
|
||||
<span class="text-xs text-content-3 flex-shrink-0">{{ formatDuration(song.duration) }}</span>
|
||||
<span class="text-xs text-content-3 flex-shrink-0">{{ formatFileSize(song.fileSize) }}</span>
|
||||
<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>
|
||||
|
||||
<Transition name="fade">
|
||||
<div v-if="showDeleteConfirm" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showDeleteConfirm = false">
|
||||
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-80 p-6 select-auto">
|
||||
<h2 class="text-lg font-semibold text-content mb-1">确认删除</h2>
|
||||
<p class="text-sm text-content-2 mb-5">确定要删除「{{ deleteTarget?.name }}」吗?此操作不可撤销。</p>
|
||||
<div class="flex gap-3">
|
||||
<button @click="showDeleteConfirm = false"
|
||||
class="flex-1 py-2 rounded-lg bg-muted hover:bg-emphasis text-sm text-content-2 transition">
|
||||
取消
|
||||
</button>
|
||||
<button @click="doDelete"
|
||||
class="flex-1 py-2 rounded-lg bg-danger/20 hover:bg-danger/30 text-danger text-sm font-medium transition">
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { usePlayerStore, type Song } from '../stores/player';
|
||||
import { useDownload } from '../composables/useDownload';
|
||||
import { useSettingsStore } from '../stores/settings';
|
||||
import { showToast } from '../composables/useToast';
|
||||
|
||||
const player = usePlayerStore();
|
||||
const download = useDownload();
|
||||
const settings = useSettingsStore();
|
||||
|
||||
interface LocalSong {
|
||||
id: number;
|
||||
name: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
duration: number;
|
||||
cover: string | null;
|
||||
filename: string;
|
||||
fileSize: number;
|
||||
path: string;
|
||||
local: boolean;
|
||||
}
|
||||
|
||||
const songs = ref<LocalSong[]>([]);
|
||||
const loading = ref(true);
|
||||
const showDeleteConfirm = ref(false);
|
||||
const deleteTarget = ref<LocalSong | null>(null);
|
||||
const openMenuId = ref<number | null>(null);
|
||||
|
||||
function toggleMenu(id: number) {
|
||||
openMenuId.value = openMenuId.value === id ? null : id;
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
openMenuId.value = null;
|
||||
}
|
||||
|
||||
onMounted(() => { document.addEventListener('click', closeMenu); });
|
||||
onBeforeUnmount(() => { document.removeEventListener('click', closeMenu); });
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const list = await invoke<LocalSong[]>('list_local_songs', { downloadPath: settings.downloadPath || null });
|
||||
songs.value = list;
|
||||
fetchMissingCovers();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMissingCovers() {
|
||||
const missing = songs.value.filter(s => !s.cover && s.id > 0 && s.id < 1e12);
|
||||
if (missing.length === 0) return;
|
||||
const ids = [...new Set(missing.map(s => s.id))];
|
||||
try {
|
||||
const jsonStr: string = await invoke('get_song_detail', { id: JSON.stringify(ids) });
|
||||
const data = JSON.parse(jsonStr);
|
||||
const detailMap = new Map<number, string>();
|
||||
for (const s of data.songs || []) {
|
||||
const url = s.al?.picUrl;
|
||||
if (url && s.id) detailMap.set(s.id, url + '?param=100y100');
|
||||
}
|
||||
for (const song of missing) {
|
||||
const url = detailMap.get(song.id);
|
||||
if (url) song.cover = url;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
onMounted(refresh);
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i];
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (!ms || ms === 0) return '--:--';
|
||||
const totalSec = Math.floor(ms / 1000);
|
||||
const min = Math.floor(totalSec / 60);
|
||||
const sec = totalSec % 60;
|
||||
return `${min}:${sec.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function toSong(local: LocalSong): Song {
|
||||
return {
|
||||
id: local.id,
|
||||
name: local.name,
|
||||
ar: local.artist ? [{ name: local.artist }] : [],
|
||||
al: { picUrl: local.cover || '', name: local.album || undefined },
|
||||
dt: local.duration || undefined,
|
||||
artists: local.artist ? [{ name: local.artist }] : [],
|
||||
album: { picUrl: local.cover || undefined, name: local.album || undefined },
|
||||
duration: local.duration || undefined,
|
||||
localPath: local.path,
|
||||
};
|
||||
}
|
||||
|
||||
async function playLocalSong(_song: LocalSong, index: number) {
|
||||
const normalized = songs.value.map(s => toSong(s));
|
||||
player.playFromList(normalized, index);
|
||||
}
|
||||
|
||||
function confirmDelete(song: LocalSong) {
|
||||
openMenuId.value = null;
|
||||
deleteTarget.value = song;
|
||||
showDeleteConfirm.value = true;
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
if (!deleteTarget.value) return;
|
||||
try {
|
||||
await invoke('delete_local_song', { query: { id: deleteTarget.value.id, filename: deleteTarget.value.filename, downloadPath: settings.downloadPath || null } });
|
||||
songs.value = songs.value.filter(s => s.id !== deleteTarget.value!.id);
|
||||
download.localSongIds.delete(deleteTarget.value.id);
|
||||
showToast('已删除', 'success');
|
||||
} catch (e) {
|
||||
showToast('删除失败', 'error');
|
||||
}
|
||||
showDeleteConfirm.value = false;
|
||||
deleteTarget.value = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@ -69,31 +69,53 @@
|
||||
<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">
|
||||
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
|
||||
<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 v-if="playlist" class="mt-8">
|
||||
<CommentSection :type="2" :id="Number(route.params.id)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { useUserStore } from '../stores/user';
|
||||
import { useDownload } from '../composables/useDownload';
|
||||
import { showToast } from '../composables/useToast';
|
||||
import { formatDuration, formatPlayCount } from '../utils/format';
|
||||
import SongItemMenu from '../components/SongItemMenu.vue';
|
||||
import CommentSection from '../components/CommentSection.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const player = usePlayerStore();
|
||||
const userStore = useUserStore();
|
||||
const download = useDownload();
|
||||
|
||||
const playlist = ref<any>(null);
|
||||
const songs = ref<any[]>([]);
|
||||
@ -136,7 +158,8 @@ function isCurrentSong(songId: number): boolean {
|
||||
}
|
||||
|
||||
async function playSingle(song: any) {
|
||||
player.play(song);
|
||||
const idx = songs.value.findIndex((s: any) => s.id === song.id);
|
||||
player.playFromList(songs.value, idx >= 0 ? idx : 0);
|
||||
}
|
||||
|
||||
function playAll() {
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
<div
|
||||
v-for="(song, index) in player.recentLocal"
|
||||
:key="song.id"
|
||||
@click="player.play(song)"
|
||||
@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>
|
||||
@ -17,13 +17,26 @@
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium truncate">{{ song.name }}</p>
|
||||
<p class="text-xs text-content-2 truncate">
|
||||
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
|
||||
<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 ?? 0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -32,7 +45,12 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { useDownload } from '../composables/useDownload';
|
||||
import { formatDuration } from '../utils/format';
|
||||
import { useRouter } from 'vue-router';
|
||||
import SongItemMenu from '../components/SongItemMenu.vue';
|
||||
|
||||
const player = usePlayerStore();
|
||||
const download = useDownload();
|
||||
const router = useRouter();
|
||||
</script>
|
||||
|
||||
@ -12,13 +12,28 @@
|
||||
|
||||
<template v-else>
|
||||
<img
|
||||
:src="currentSong.al?.picUrl || currentSong.album?.picUrl"
|
||||
v-if="coverUrl && !coverError"
|
||||
:src="coverUrl"
|
||||
class="w-80 h-80 rounded-3xl object-cover shadow-2xl mb-8"
|
||||
@error="coverError = true"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="w-80 h-80 rounded-3xl bg-muted flex items-center justify-center shadow-2xl mb-8"
|
||||
>
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="text-content-3"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||||
</div>
|
||||
|
||||
<h1 class="text-3xl font-bold mb-2">{{ currentSong.name }}</h1>
|
||||
<p class="text-lg text-content-2 mb-8">
|
||||
{{ artists }}
|
||||
<template v-for="(a, i) in currentSong.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="a.id && router.push({ name: 'artist', params: { id: a.id } })">{{ a.name }}</span>
|
||||
</template>
|
||||
<template v-if="currentSong.al?.name">
|
||||
<span class="text-content-3 mx-1">·</span>
|
||||
<span class="hover:text-accent-text cursor-pointer transition" @click="currentSong.al.id && router.push({ name: 'album', params: { id: currentSong.al.id } })">{{ currentSong.al.name }}</span>
|
||||
</template>
|
||||
</p>
|
||||
|
||||
<div class="flex items-center gap-8">
|
||||
@ -46,12 +61,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { ref, computed, watch, onMounted } from 'vue';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { normalizeSong } from '../utils/song';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const player = usePlayerStore();
|
||||
const router = useRouter();
|
||||
const coverError = ref(false);
|
||||
|
||||
const currentSong = computed(() => {
|
||||
if (player.isFmMode && player.currentSong) {
|
||||
@ -60,12 +78,13 @@ const currentSong = computed(() => {
|
||||
return null;
|
||||
});
|
||||
|
||||
const artists = computed(() => {
|
||||
const coverUrl = computed(() => {
|
||||
if (!currentSong.value) return '';
|
||||
return currentSong.value.ar?.map((a: any) => a.name).join(' / ') ||
|
||||
currentSong.value.artists?.map((a: any) => a.name).join(' / ') || '';
|
||||
return currentSong.value.al?.picUrl || currentSong.value.album?.picUrl || '';
|
||||
});
|
||||
|
||||
watch(coverUrl, () => { coverError.value = false; });
|
||||
|
||||
onMounted(async () => {
|
||||
if (!player.isFmMode || !player.currentSong) {
|
||||
await startFm();
|
||||
|
||||
@ -12,18 +12,23 @@
|
||||
<div v-if="loading" class="text-content-2">搜索中...</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="song in results"
|
||||
v-for="(song, index) in results"
|
||||
:key="song.id"
|
||||
@click="playSong(song)"
|
||||
@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>
|
||||
<p class="font-medium">{{ song.name }}</p>
|
||||
<p class="text-sm text-content-2">
|
||||
<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>
|
||||
@ -39,6 +44,7 @@ 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();
|
||||
|
||||
@ -47,6 +53,7 @@ const results = ref<any[]>([]);
|
||||
const loading = ref(false);
|
||||
const hasSearched = ref(false);
|
||||
const player = usePlayerStore();
|
||||
const download = useDownload();
|
||||
|
||||
const route = useRoute();
|
||||
watch(
|
||||
@ -76,9 +83,16 @@ async function handleSearch() {
|
||||
}
|
||||
}
|
||||
|
||||
async function playSong(song: any) {
|
||||
async function playSong(_song: any, index: number) {
|
||||
try {
|
||||
await player.play(song);
|
||||
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('暂无播放源或需登录');
|
||||
}
|
||||
|
||||
@ -64,28 +64,89 @@
|
||||
<p class="text-xs text-content-3 mt-0.5">歌曲下载保存位置</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="downloadPathInput"
|
||||
type="text"
|
||||
placeholder="例如:~/Music/Nekosonic"
|
||||
class="flex-1 bg-subtle border border-line rounded-lg px-3 py-2 text-sm text-content placeholder-content-4 outline-none focus:border-accent/50 transition"
|
||||
/>
|
||||
<div class="flex gap-2 items-center">
|
||||
<div class="flex-1 bg-subtle border border-line rounded-lg px-3 py-2 text-sm text-content-2 truncate" :title="settings.downloadPath || defaultDownloadPath">
|
||||
{{ settings.downloadPath || defaultDownloadPath }}
|
||||
</div>
|
||||
<button
|
||||
@click="saveDownloadPath"
|
||||
class="px-4 py-2 bg-accent-dim hover:bg-accent-dim text-accent-text rounded-lg text-sm transition"
|
||||
@click="pickDownloadFolder"
|
||||
class="px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 bg-accent/15 text-accent-text hover:bg-accent/25 active:scale-95"
|
||||
>
|
||||
保存
|
||||
选择文件夹
|
||||
</button>
|
||||
<button
|
||||
v-if="settings.downloadPath"
|
||||
@click="clearDownloadPath"
|
||||
class="px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200 bg-muted text-content-2 hover:bg-emphasis hover:text-content active:scale-95"
|
||||
title="重置为默认路径"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-8">
|
||||
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">快捷键</h2>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="(sc, id) in settings.shortcuts"
|
||||
:key="id"
|
||||
class="flex items-center justify-between p-3 bg-subtle rounded-xl"
|
||||
>
|
||||
<div>
|
||||
<p class="text-sm font-medium">{{ sc.label }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button
|
||||
v-if="sc.key !== defaultShortcuts[id]?.key"
|
||||
@click="settings.setShortcut(id, defaultShortcuts[id].key)"
|
||||
class="w-6 h-6 flex items-center justify-center rounded-md text-content-4 hover:text-danger hover:bg-danger/10 transition"
|
||||
title="恢复默认"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
<button
|
||||
@click="startRecording(id)"
|
||||
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'"
|
||||
>
|
||||
{{ recordingId === id ? '按下新快捷键...' : formatShortcut(sc.key) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="resetShortcuts"
|
||||
class="text-xs text-content-3 hover:text-danger transition"
|
||||
>
|
||||
恢复默认快捷键
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-8">
|
||||
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">其他</h2>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between p-3 bg-subtle rounded-xl">
|
||||
<div>
|
||||
<p class="text-sm font-medium">恢复默认设置</p>
|
||||
<p class="text-xs text-content-3 mt-0.5">重置所有设置为初始状态</p>
|
||||
</div>
|
||||
<button
|
||||
@click="handleResetAll"
|
||||
class="px-3 py-1.5 rounded-lg text-sm bg-muted hover:bg-emphasis text-danger transition"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-8">
|
||||
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">关于</h2>
|
||||
<div class="space-y-4">
|
||||
<a @click.prevent="openUrl('https://gitea.atdunbg.xyz/atdunbg/Nekosonic-Music')"
|
||||
<a @click.prevent="openUrl('https://github.com/atdunbg/Nekosonic-Music')"
|
||||
class="flex items-center gap-4 p-4 bg-subtle rounded-xl hover:bg-muted transition cursor-pointer">
|
||||
<img src="../assets/app-icon.png" class="w-12 h-12 rounded-xl flex-shrink-0" alt="Nekosonic" />
|
||||
<div>
|
||||
@ -97,34 +158,61 @@
|
||||
Nekosonic 是一款高颜值的跨平台第三方网易云音乐桌面客户端,基于 Tauri 2 + Vue 3 构建,提供轻量流畅的音乐播放体验。
|
||||
</p>
|
||||
<button
|
||||
@click="checkUpdate"
|
||||
:disabled="checkingUpdate"
|
||||
@click="handleCheckUpdate"
|
||||
:disabled="updater.checking.value"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition"
|
||||
>
|
||||
<svg v-if="!checkingUpdate" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.66 0 3-4.03 3-9s-1.34-9-3-9m0 18c-1.66 0-3-4.03-3-9s1.34-9 3-9m-9 9a9 9 0 019-9"/></svg>
|
||||
<svg v-if="!updater.checking.value" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
||||
<svg v-else class="animate-spin" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 11-6.22-8.56"/></svg>
|
||||
{{ checkingUpdate ? '检查中...' : '检查更新(暂未实现)' }}
|
||||
{{ updater.checking.value ? '检查中...' : '检查更新' }}
|
||||
</button>
|
||||
<p v-if="updateMessage" class="text-xs" :class="updateMessageClass">{{ updateMessage }}</p>
|
||||
<p v-if="updater.error.value" class="text-xs text-content-3">{{ updater.error.value }}</p>
|
||||
</div>
|
||||
</section>
|
||||
<Transition name="fade">
|
||||
<div v-if="showResetConfirm" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showResetConfirm = false">
|
||||
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-80 p-6 select-auto">
|
||||
<h2 class="text-lg font-semibold text-content mb-1">确认重置</h2>
|
||||
<p class="text-sm text-content-2 mb-5">所有设置将恢复为默认值,此操作不可撤销。</p>
|
||||
<div class="flex gap-3">
|
||||
<button @click="showResetConfirm = false"
|
||||
class="flex-1 py-2 rounded-lg bg-muted hover:bg-emphasis text-sm text-content-2 transition">
|
||||
取消
|
||||
</button>
|
||||
<button @click="confirmResetAll"
|
||||
class="flex-1 py-2 rounded-lg bg-danger/20 hover:bg-danger/30 text-danger text-sm font-medium transition">
|
||||
确认重置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useSettingsStore, qualityLabels, closeActionLabels, type CloseAction } from '../stores/settings';
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useSettingsStore, qualityLabels, closeActionLabels, defaultShortcuts, type CloseAction } from '../stores/settings';
|
||||
import { useToast } from '../composables/useToast';
|
||||
import { useUpdater } from '../composables/useUpdater';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import CustomSelect from '../components/CustomSelect.vue';
|
||||
|
||||
const settings = useSettingsStore();
|
||||
const { showToast } = useToast();
|
||||
const updater = useUpdater();
|
||||
|
||||
const appVersion = ref('');
|
||||
const defaultDownloadPath = ref('');
|
||||
onMounted(async () => {
|
||||
appVersion.value = await getVersion();
|
||||
try {
|
||||
defaultDownloadPath.value = await invoke<string>('get_default_download_path');
|
||||
} catch {}
|
||||
});
|
||||
|
||||
const closeActionValue = computed({
|
||||
@ -132,33 +220,100 @@ const closeActionValue = computed({
|
||||
set: (val: CloseAction) => settings.setCloseAction(val),
|
||||
});
|
||||
|
||||
const downloadPathInput = ref(settings.downloadPath);
|
||||
const checkingUpdate = ref(false);
|
||||
const updateMessage = ref('');
|
||||
const updateMessageClass = ref('text-content-2');
|
||||
async function pickDownloadFolder() {
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: '选择下载路径',
|
||||
});
|
||||
if (selected) {
|
||||
settings.setDownloadPath(selected);
|
||||
showToast('下载路径已更新', 'success');
|
||||
}
|
||||
}
|
||||
|
||||
function clearDownloadPath() {
|
||||
settings.setDownloadPath('');
|
||||
showToast('已重置为默认路径', 'success');
|
||||
}
|
||||
|
||||
const themeOptions = [
|
||||
{ label: '深色', value: 'dark' as const },
|
||||
{ label: '浅色', value: 'light' as const },
|
||||
];
|
||||
|
||||
function saveDownloadPath() {
|
||||
settings.setDownloadPath(downloadPathInput.value.trim());
|
||||
showToast('下载路径已保存', 'success');
|
||||
async function handleCheckUpdate() {
|
||||
const result = await updater.checkForUpdate(false);
|
||||
if (!result) {
|
||||
showToast(updater.error.value || '当前已是最新版本', 'info');
|
||||
}
|
||||
}
|
||||
|
||||
async function checkUpdate() {
|
||||
checkingUpdate.value = true;
|
||||
updateMessage.value = '';
|
||||
try {
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
updateMessage.value = '当前已是最新版本';
|
||||
updateMessageClass.value = 'text-accent-text';
|
||||
} catch {
|
||||
updateMessage.value = '检查更新失败,请稍后重试';
|
||||
updateMessageClass.value = 'text-danger';
|
||||
} finally {
|
||||
checkingUpdate.value = false;
|
||||
const recordingId = ref<string | null>(null);
|
||||
|
||||
function formatShortcut(key: string): string {
|
||||
return key
|
||||
.replace('Control', 'Ctrl')
|
||||
.replace('ArrowLeft', '←')
|
||||
.replace('ArrowRight', '→')
|
||||
.replace('ArrowUp', '↑')
|
||||
.replace('ArrowDown', '↓')
|
||||
.replace(/\+/g, ' + ');
|
||||
}
|
||||
|
||||
function startRecording(id: string) {
|
||||
recordingId.value = id;
|
||||
}
|
||||
|
||||
function resetShortcuts() {
|
||||
settings.resetShortcuts();
|
||||
showToast('快捷键已恢复默认', 'success');
|
||||
}
|
||||
|
||||
const showResetConfirm = ref(false);
|
||||
|
||||
function handleResetAll() {
|
||||
showResetConfirm.value = true;
|
||||
}
|
||||
|
||||
function confirmResetAll() {
|
||||
settings.resetAll();
|
||||
showResetConfirm.value = false;
|
||||
showToast('已恢复默认设置', 'success');
|
||||
}
|
||||
|
||||
function onRecordingKeydown(e: KeyboardEvent) {
|
||||
if (!recordingId.value) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
recordingId.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
if (e.ctrlKey || e.metaKey) parts.push('Control');
|
||||
if (e.altKey) parts.push('Alt');
|
||||
if (e.shiftKey) parts.push('Shift');
|
||||
|
||||
const ignoredKeys = ['Control', 'Alt', 'Shift', 'Meta'];
|
||||
if (!ignoredKeys.includes(e.key)) {
|
||||
parts.push(e.code);
|
||||
}
|
||||
|
||||
if (parts.length > 0 && !ignoredKeys.includes(e.key)) {
|
||||
const combo = parts.join('+');
|
||||
settings.setShortcut(recordingId.value, combo);
|
||||
recordingId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', onRecordingKeydown, true);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', onRecordingKeydown, true);
|
||||
});
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user