mirror of
https://github.com/atdunbg/Nekosonic-Music.git
synced 2026-06-22 00:58:51 +08:00
- **歌手相关功能添加**: 添加歌曲的艺术家入口,
歌曲的艺术家现可点击查看其他歌曲,专辑和介绍 - **歌曲评论功能添加**: 添加歌曲的评论查看功能 - 修复私人漫游自动播放下一首调用多次问题 - 优化播放逻辑,歌曲列表在点击时候不在单首累加, 而是直接获取当前列表所有的歌曲作为播放内容
This commit is contained in:
57
.github/workflow/release.yml
vendored
Normal file
57
.github/workflow/release.yml
vendored
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
name: Release with Updater
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
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: actions-rs/toolchain@v1
|
||||||
|
with:
|
||||||
|
toolchain: stable
|
||||||
|
|
||||||
|
- name: Install Linux dependencies
|
||||||
|
if: matrix.os == 'ubuntu-latest'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential \
|
||||||
|
libssl-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev
|
||||||
|
|
||||||
|
- name: Install npm dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Build and publish with Tauri Action
|
||||||
|
uses: tauri-apps/tauri-action@v0
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
# 私钥:需要先保存在 GitHub Secrets 中,名字叫 TAURI_PRIVATE_KEY
|
||||||
|
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||||
|
with:
|
||||||
|
# 标签名(自动取触发标签)
|
||||||
|
tagName: ${{ github.ref_name }}
|
||||||
|
# 发布标题
|
||||||
|
releaseName: 'v__VERSION__'
|
||||||
|
# 发布说明文件(可选)
|
||||||
|
releaseBody: 'See CHANGELOG.md for details.'
|
||||||
|
# 如果该平台不生成某些包,不会报错
|
||||||
|
releaseDraft: false
|
||||||
|
prerelease: false
|
||||||
@ -728,3 +728,186 @@ fn sanitize_filename(name: &str) -> String {
|
|||||||
.trim()
|
.trim()
|
||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn artist_detail(id: u64, state: State<'_, ApiController>) -> Result<String, String> {
|
||||||
|
let client = state.client.lock().await;
|
||||||
|
let q = state.build_query().param("id", &id.to_string());
|
||||||
|
client.artist_detail(&q).await
|
||||||
|
.map(|r| r.body.to_string())
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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,6 +1,6 @@
|
|||||||
use tauri::{
|
use tauri::{
|
||||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||||
menu::{MenuBuilder, MenuItemBuilder},
|
menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem},
|
||||||
Manager, Emitter,
|
Manager, Emitter,
|
||||||
};
|
};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
@ -17,7 +17,6 @@ pub fn run() {
|
|||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
|
|
||||||
// 注入控制器
|
|
||||||
let app_data_dir = app.path().app_data_dir().expect("无法获取应用数据目录");
|
let app_data_dir = app.path().app_data_dir().expect("无法获取应用数据目录");
|
||||||
let api_controller = ApiController::new(app_data_dir);
|
let api_controller = ApiController::new(app_data_dir);
|
||||||
app.manage(api_controller);
|
app.manage(api_controller);
|
||||||
@ -26,37 +25,38 @@ pub fn run() {
|
|||||||
let app_audio = AppAudio(std::sync::Mutex::new(audio_controller));
|
let app_audio = AppAudio(std::sync::Mutex::new(audio_controller));
|
||||||
app.manage(app_audio);
|
app.manage(app_audio);
|
||||||
|
|
||||||
// 托盘菜单
|
|
||||||
let show = MenuItemBuilder::with_id("show", "显示窗口").build(app)?;
|
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 play_pause = MenuItemBuilder::with_id("play_pause", "播放/暂停").build(app)?;
|
||||||
let next = MenuItemBuilder::with_id("next", "下一首").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 quit = MenuItemBuilder::with_id("quit", "退出").build(app)?;
|
||||||
|
|
||||||
let menu = MenuBuilder::new(app)
|
let menu = MenuBuilder::new(app)
|
||||||
.item(&show)
|
.item(&show)
|
||||||
.separator()
|
.separator()
|
||||||
.item(&play_pause)
|
.items(&[&prev, &play_pause, &next])
|
||||||
.item(&next)
|
|
||||||
.item(&prev)
|
|
||||||
.separator()
|
.separator()
|
||||||
.item(&quit)
|
.item(&quit)
|
||||||
.build()?;
|
.build()?;
|
||||||
|
|
||||||
// 托盘图标(使用应用默认图标)
|
|
||||||
let icon = app.default_window_icon().cloned().unwrap();
|
let icon = app.default_window_icon().cloned().unwrap();
|
||||||
|
|
||||||
let _tray = TrayIconBuilder::with_id("main-tray")
|
let _tray = TrayIconBuilder::with_id("main-tray")
|
||||||
.tooltip("Nekosonic")
|
.tooltip("Nekosonic Music")
|
||||||
.icon(icon)
|
.icon(icon)
|
||||||
|
.show_menu_on_left_click(false)
|
||||||
.menu(&menu)
|
.menu(&menu)
|
||||||
.on_menu_event(|app, event| {
|
.on_menu_event(|app, event| {
|
||||||
let window = app.get_webview_window("main").unwrap();
|
|
||||||
match event.id().as_ref() {
|
match event.id().as_ref() {
|
||||||
"show" => {
|
"show" => {
|
||||||
window.show().unwrap();
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
window.set_focus().unwrap();
|
let _ = window.show();
|
||||||
let _ = app.emit("window-shown", ());
|
let _ = window.unminimize();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
let _ = app.emit("window-shown", ());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"play_pause" => {
|
"play_pause" => {
|
||||||
let _ = app.emit("tray-play-pause", ());
|
let _ = app.emit("tray-play-pause", ());
|
||||||
@ -84,15 +84,16 @@ pub fn run() {
|
|||||||
} = event
|
} = event
|
||||||
{
|
{
|
||||||
let app = tray.app_handle();
|
let app = tray.app_handle();
|
||||||
let window = app.get_webview_window("main").unwrap();
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
window.show().unwrap();
|
let _ = window.show();
|
||||||
window.set_focus().unwrap();
|
let _ = window.unminimize();
|
||||||
let _ = app.emit("window-shown", ());
|
let _ = window.set_focus();
|
||||||
|
let _ = app.emit("window-shown", ());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.build(app)?;
|
.build(app)?;
|
||||||
|
|
||||||
// 点击关闭按钮时隐藏到托盘
|
|
||||||
let window = app.get_webview_window("main").unwrap();
|
let window = app.get_webview_window("main").unwrap();
|
||||||
let window_clone = window.clone();
|
let window_clone = window.clone();
|
||||||
let app_handle = app.handle().clone();
|
let app_handle = app.handle().clone();
|
||||||
@ -149,7 +150,17 @@ pub fn run() {
|
|||||||
api::list_local_songs,
|
api::list_local_songs,
|
||||||
api::delete_local_song,
|
api::delete_local_song,
|
||||||
api::check_local_song,
|
api::check_local_song,
|
||||||
api::get_default_download_path
|
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_process::init())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
@ -165,4 +176,4 @@ pub fn run() {
|
|||||||
}))
|
}))
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running Nekosonic");
|
.expect("error while running Nekosonic");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,5 +40,13 @@
|
|||||||
"type": "downloadBootstrapper"
|
"type": "downloadBootstrapper"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"updater": {
|
||||||
|
"endpoints": [
|
||||||
|
"https://github.com/atdunbg/Nekosonic-Music/releases/latest/download/latest.json"
|
||||||
|
],
|
||||||
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDM1MDdCMTJCRTE3MUI4N0QKUldSOXVISGhLN0VITmM3ZkJlbjF3UGJrK3h6ellWZ2xSUG03b3d1RWlDeldSWk1nc0pic2J2MVkK"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
40
src/App.vue
40
src/App.vue
@ -161,10 +161,31 @@
|
|||||||
<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>
|
<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>
|
</div>
|
||||||
<h1 class="text-2xl font-bold text-white text-center">{{ roamSong?.name }}</h1>
|
<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>
|
||||||
<div class="w-3/5 relative min-h-0 overflow-hidden flex flex-col">
|
<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"
|
<div v-if="lyrics.length > 0" class="w-full max-w-lg mx-auto text-center"
|
||||||
:style="{ paddingTop: roamLyricPadPx + 'px', paddingBottom: roamLyricPadPx + 'px' }">
|
:style="{ paddingTop: roamLyricPadPx + 'px', paddingBottom: roamLyricPadPx + 'px' }">
|
||||||
<p
|
<p
|
||||||
@ -181,6 +202,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-else class="text-content-3 text-center mt-8">暂无歌词</div>
|
<div v-else class="text-content-3 text-center mt-8">暂无歌词</div>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -238,6 +262,7 @@ import { useUserStore } from './stores/user';
|
|||||||
import { useSettingsStore, type CloseAction } from './stores/settings';
|
import { useSettingsStore, type CloseAction } from './stores/settings';
|
||||||
import PlayerBar from './components/PlayerBar.vue';
|
import PlayerBar from './components/PlayerBar.vue';
|
||||||
import ToastContainer from './components/ToastContainer.vue';
|
import ToastContainer from './components/ToastContainer.vue';
|
||||||
|
import CommentSection from './components/CommentSection.vue';
|
||||||
import { usePlayerStore } from './stores/player';
|
import { usePlayerStore } from './stores/player';
|
||||||
import { useLyric } from './composables/UserLyric';
|
import { useLyric } from './composables/UserLyric';
|
||||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||||
@ -274,6 +299,7 @@ const roamLyricHovering = ref(false);
|
|||||||
const roamLyricPadPx = ref(0);
|
const roamLyricPadPx = ref(0);
|
||||||
const roamSong = computed(() => player.currentSong);
|
const roamSong = computed(() => player.currentSong);
|
||||||
const roamCoverError = ref(false);
|
const roamCoverError = ref(false);
|
||||||
|
const roamTab = ref<'lyric' | 'comment'>('lyric');
|
||||||
const roamCoverUrl = computed(() => {
|
const roamCoverUrl = computed(() => {
|
||||||
if (!roamSong.value) return '';
|
if (!roamSong.value) return '';
|
||||||
return roamSong.value.al?.picUrl || roamSong.value.album?.picUrl || '';
|
return roamSong.value.al?.picUrl || roamSong.value.album?.picUrl || '';
|
||||||
@ -289,6 +315,7 @@ function updateRoamLyricPad() {
|
|||||||
|
|
||||||
watch(() => player.showRoamDrawer, (val) => {
|
watch(() => player.showRoamDrawer, (val) => {
|
||||||
if (val) {
|
if (val) {
|
||||||
|
roamTab.value = player.roamInitialTab;
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
updateRoamLyricPad();
|
updateRoamLyricPad();
|
||||||
if (roamResizeObserver) roamResizeObserver.disconnect();
|
if (roamResizeObserver) roamResizeObserver.disconnect();
|
||||||
@ -312,10 +339,6 @@ onBeforeUnmount(() => {
|
|||||||
roamResizeObserver = null;
|
roamResizeObserver = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const roamArtists = computed(() => {
|
|
||||||
if (!roamSong.value) return '';
|
|
||||||
return roamSong.value.ar?.map((a: any) => a.name).join(' / ') || '';
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(currentLyricIdx, () => {
|
watch(currentLyricIdx, () => {
|
||||||
if (player.showRoamDrawer && !roamLyricHovering.value) {
|
if (player.showRoamDrawer && !roamLyricHovering.value) {
|
||||||
@ -347,6 +370,11 @@ function seekToRoamLyric(time: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function navigateFromDrawer(routeLocation: { name: string; params: any }) {
|
||||||
|
player.closeRoamDrawer();
|
||||||
|
router.push(routeLocation);
|
||||||
|
}
|
||||||
|
|
||||||
async function openRoamFromSidebar() {
|
async function openRoamFromSidebar() {
|
||||||
if (player.isFmMode) {
|
if (player.isFmMode) {
|
||||||
player.openRoamDrawer();
|
player.openRoamDrawer();
|
||||||
|
|||||||
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>
|
||||||
@ -23,13 +23,23 @@
|
|||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="text-sm font-medium truncate">{{ player.currentSong?.name }}</p>
|
<p class="text-sm font-medium truncate">{{ player.currentSong?.name }}</p>
|
||||||
<p class="text-xs text-content-2 truncate">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button @click="player.currentSong && player.toggleLike(player.currentSong.id)" class="flex-shrink-0 transition" :class="player.currentSong && player.isLiked(player.currentSong.id) ? 'text-danger' : 'text-content-3 hover:text-danger'">
|
<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-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>
|
<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>
|
||||||
|
<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="下载">
|
<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>
|
<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>
|
</button>
|
||||||
@ -110,7 +120,10 @@
|
|||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="text-xs font-medium truncate">{{ song.name }}</p>
|
<p class="text-xs font-medium truncate">{{ song.name }}</p>
|
||||||
<p class="text-xs text-content-3 truncate">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button @click.stop="player.removeFromQueue(idx)"
|
<button @click.stop="player.removeFromQueue(idx)"
|
||||||
@ -131,7 +144,9 @@ import { useDownload } from '../composables/useDownload';
|
|||||||
import { formatTime } from '../utils/format';
|
import { formatTime } from '../utils/format';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { listen } from '@tauri-apps/api/event';
|
import { listen } from '@tauri-apps/api/event';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
const player = usePlayerStore();
|
const player = usePlayerStore();
|
||||||
const download = useDownload();
|
const download = useDownload();
|
||||||
const showQueuePanel = ref(false);
|
const showQueuePanel = ref(false);
|
||||||
|
|||||||
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>
|
||||||
@ -21,6 +21,8 @@ const routes = [
|
|||||||
{ path: '/local-music', name: 'local-music', component: LocalMusic },
|
{ path: '/local-music', name: 'local-music', component: LocalMusic },
|
||||||
{ path: '/login', name: 'login', component: Login },
|
{ path: '/login', name: 'login', component: Login },
|
||||||
{ path: '/playlist/:id', name: 'playlist', component: PlaylistDetail },
|
{ path: '/playlist/:id', name: 'playlist', component: PlaylistDetail },
|
||||||
|
{ path: '/artist/:id', name: 'artist', component: () => import('@/views/ArtistDetail.vue') },
|
||||||
|
{ path: '/album/:id', name: 'album', component: () => import('@/views/AlbumDetail.vue') },
|
||||||
{ path: '/settings', name: 'settings', component: Settings },
|
{ path: '/settings', name: 'settings', component: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -11,8 +11,8 @@ export type PlayMode = 'loop' | 'shuffle' | 'repeat-one';
|
|||||||
export interface Song {
|
export interface Song {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
ar: { name: string }[];
|
ar: { id?: number; name: string }[];
|
||||||
al: { picUrl: string; name?: string };
|
al: { id?: number; picUrl: string; name?: string };
|
||||||
dt?: number;
|
dt?: number;
|
||||||
|
|
||||||
album?: { picUrl?: string; name?: string };
|
album?: { picUrl?: string; name?: string };
|
||||||
@ -292,11 +292,11 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
if (tickInterval) clearInterval(tickInterval);
|
if (tickInterval) clearInterval(tickInterval);
|
||||||
tickInterval = setInterval(() => {
|
tickInterval = setInterval(() => {
|
||||||
if (playing.value && duration.value > 0) {
|
if (playing.value && duration.value > 0) {
|
||||||
currentTime.value += 0.25;
|
if (currentTime.value < duration.value) {
|
||||||
if (currentTime.value >= duration.value) {
|
currentTime.value += 0.25;
|
||||||
currentTime.value = duration.value;
|
if (currentTime.value > duration.value) {
|
||||||
if (tickInterval) { clearInterval(tickInterval); tickInterval = null; }
|
currentTime.value = duration.value;
|
||||||
next();
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 250);
|
}, 250);
|
||||||
@ -416,11 +416,19 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const showRoamDrawer = ref(false);
|
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;
|
showRoamDrawer.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openCommentForSong(songId: number) {
|
||||||
|
commentSongId.value = songId;
|
||||||
|
openRoamDrawer('comment');
|
||||||
|
}
|
||||||
|
|
||||||
function closeRoamDrawer() {
|
function closeRoamDrawer() {
|
||||||
showRoamDrawer.value = false;
|
showRoamDrawer.value = false;
|
||||||
}
|
}
|
||||||
@ -493,9 +501,7 @@ listen('audio-ended', () => {
|
|||||||
if (tickInterval) { clearInterval(tickInterval); tickInterval = null; }
|
if (tickInterval) { clearInterval(tickInterval); tickInterval = null; }
|
||||||
if (isFmMode.value && fmNextCallback) {
|
if (isFmMode.value && fmNextCallback) {
|
||||||
fmNextCallback();
|
fmNextCallback();
|
||||||
return;
|
} else {
|
||||||
}
|
|
||||||
if (playing.value && !isFmMode.value) {
|
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -552,6 +558,9 @@ watch(playing, (val) => {
|
|||||||
toggleLike,
|
toggleLike,
|
||||||
|
|
||||||
showRoamDrawer,
|
showRoamDrawer,
|
||||||
|
roamInitialTab,
|
||||||
|
commentSongId,
|
||||||
|
openCommentForSong,
|
||||||
openRoamDrawer,
|
openRoamDrawer,
|
||||||
closeRoamDrawer,
|
closeRoamDrawer,
|
||||||
toggleRoamDrawer,
|
toggleRoamDrawer,
|
||||||
|
|||||||
@ -9,6 +9,9 @@ export function normalizeSong(song: any) {
|
|||||||
if (!normalized.al?.name && normalized.album?.name) {
|
if (!normalized.al?.name && normalized.album?.name) {
|
||||||
normalized.al = { ...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) {
|
if (!normalized.ar || normalized.ar.length === 0) {
|
||||||
normalized.ar = normalized.artists || [];
|
normalized.ar = normalized.artists || [];
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
@ -39,7 +39,14 @@
|
|||||||
<div class="flex-1 min-w-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-sm font-medium truncate" :class="isCurrentSong(song.id) ? 'text-accent-text' : ''">{{ song.name }}</p>
|
||||||
<p class="text-xs text-content-2 truncate">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button @click.stop="player.toggleLike(song.id)" class="text-content-3 hover:text-danger transition flex-shrink-0">
|
<button @click.stop="player.toggleLike(song.id)" class="text-content-3 hover:text-danger transition flex-shrink-0">
|
||||||
@ -51,6 +58,7 @@
|
|||||||
<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-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>
|
<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>
|
</button>
|
||||||
|
<SongItemMenu :song-id="song.id" />
|
||||||
<span class="text-xs text-content-3">{{ formatDuration(song.dt) }}</span>
|
<span class="text-xs text-content-3">{{ formatDuration(song.dt) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -59,13 +67,16 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import SongItemMenu from '../components/SongItemMenu.vue';
|
||||||
import { usePlayerStore } from '../stores/player';
|
import { usePlayerStore } from '../stores/player';
|
||||||
import { useDownload } from '../composables/useDownload';
|
import { useDownload } from '../composables/useDownload';
|
||||||
import { formatDuration } from '../utils/format';
|
import { formatDuration } from '../utils/format';
|
||||||
|
|
||||||
const player = usePlayerStore();
|
const player = usePlayerStore();
|
||||||
const download = useDownload();
|
const download = useDownload();
|
||||||
|
const router = useRouter();
|
||||||
const songs = ref<any[]>([]);
|
const songs = ref<any[]>([]);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
|
|
||||||
|
|||||||
@ -47,7 +47,14 @@
|
|||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="font-medium truncate">{{ song.name }}</p>
|
<p class="font-medium truncate">{{ song.name }}</p>
|
||||||
<p class="text-sm text-content-2 truncate">
|
<p class="text-sm 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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button @click.stop="download.downloadSong(song)" class="text-content-3 hover:text-accent-text transition flex-shrink-0" :title="download.isDownloaded(song.id) ? '已下载' : '下载'">
|
<button @click.stop="download.downloadSong(song)" class="text-content-3 hover:text-accent-text transition flex-shrink-0" :title="download.isDownloaded(song.id) ? '已下载' : '下载'">
|
||||||
@ -55,6 +62,7 @@
|
|||||||
<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-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>
|
<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>
|
</button>
|
||||||
|
<SongItemMenu :song-id="song.id" />
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!loading && hasSearched && results.length === 0" class="text-content-2">无结果</p>
|
<p v-if="!loading && hasSearched && results.length === 0" class="text-content-2">无结果</p>
|
||||||
</div>
|
</div>
|
||||||
@ -69,6 +77,7 @@ import { useRouter, useRoute } from 'vue-router';
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { usePlayerStore } from '../stores/player';
|
import { usePlayerStore } from '../stores/player';
|
||||||
import { useDownload } from '../composables/useDownload';
|
import { useDownload } from '../composables/useDownload';
|
||||||
|
import SongItemMenu from '../components/SongItemMenu.vue';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|||||||
@ -30,7 +30,14 @@
|
|||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="text-sm font-medium truncate">{{ song.name }}</p>
|
<p class="text-sm font-medium truncate">{{ song.name }}</p>
|
||||||
<p class="text-xs text-content-2 truncate">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button @click.stop="player.toggleLike(song.id)" class="text-content-3 hover:text-danger transition flex-shrink-0">
|
<button @click.stop="player.toggleLike(song.id)" class="text-content-3 hover:text-danger transition flex-shrink-0">
|
||||||
@ -42,6 +49,7 @@
|
|||||||
<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-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>
|
<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>
|
</button>
|
||||||
|
<SongItemMenu :song-id="song.id" />
|
||||||
<span class="text-xs text-content-3">{{ formatDuration(song.dt) }}</span>
|
<span class="text-xs text-content-3">{{ formatDuration(song.dt) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -50,7 +58,9 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import SongItemMenu from '../components/SongItemMenu.vue';
|
||||||
import { usePlayerStore } from '../stores/player';
|
import { usePlayerStore } from '../stores/player';
|
||||||
import { useUserStore } from '../stores/user';
|
import { useUserStore } from '../stores/user';
|
||||||
import { useDownload } from '../composables/useDownload';
|
import { useDownload } from '../composables/useDownload';
|
||||||
@ -60,6 +70,7 @@ import { formatDuration } from '../utils/format';
|
|||||||
const player = usePlayerStore();
|
const player = usePlayerStore();
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const download = useDownload();
|
const download = useDownload();
|
||||||
|
const router = useRouter();
|
||||||
const songs = ref<any[]>([]);
|
const songs = ref<any[]>([]);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
|
|
||||||
|
|||||||
@ -69,7 +69,14 @@
|
|||||||
<div class="flex-1 min-w-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-sm font-medium truncate" :class="isCurrentSong(song.id) ? 'text-accent-text' : ''">{{ song.name }}</p>
|
||||||
<p class="text-xs text-content-2 truncate">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button @click.stop="player.toggleLike(song.id)" class="text-content-3 hover:text-danger transition flex-shrink-0">
|
<button @click.stop="player.toggleLike(song.id)" class="text-content-3 hover:text-danger transition flex-shrink-0">
|
||||||
@ -81,23 +88,31 @@
|
|||||||
<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-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>
|
<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>
|
</button>
|
||||||
|
<SongItemMenu :song-id="song.id" />
|
||||||
<span class="text-xs text-content-3">{{ formatDuration(song.dt) }}</span>
|
<span class="text-xs text-content-3">{{ formatDuration(song.dt) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="playlist" class="mt-8">
|
||||||
|
<CommentSection :type="2" :id="Number(route.params.id)" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, watch } from 'vue';
|
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 { invoke } from '@tauri-apps/api/core';
|
||||||
import { usePlayerStore } from '../stores/player';
|
import { usePlayerStore } from '../stores/player';
|
||||||
import { useUserStore } from '../stores/user';
|
import { useUserStore } from '../stores/user';
|
||||||
import { useDownload } from '../composables/useDownload';
|
import { useDownload } from '../composables/useDownload';
|
||||||
import { showToast } from '../composables/useToast';
|
import { showToast } from '../composables/useToast';
|
||||||
import { formatDuration, formatPlayCount } from '../utils/format';
|
import { formatDuration, formatPlayCount } from '../utils/format';
|
||||||
|
import SongItemMenu from '../components/SongItemMenu.vue';
|
||||||
|
import CommentSection from '../components/CommentSection.vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
const player = usePlayerStore();
|
const player = usePlayerStore();
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const download = useDownload();
|
const download = useDownload();
|
||||||
|
|||||||
@ -17,7 +17,14 @@
|
|||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="text-sm font-medium truncate">{{ song.name }}</p>
|
<p class="text-sm font-medium truncate">{{ song.name }}</p>
|
||||||
<p class="text-xs text-content-2 truncate">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button @click.stop="player.toggleLike(song.id)" class="text-content-3 hover:text-danger transition flex-shrink-0">
|
<button @click.stop="player.toggleLike(song.id)" class="text-content-3 hover:text-danger transition flex-shrink-0">
|
||||||
@ -29,6 +36,7 @@
|
|||||||
<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-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>
|
<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>
|
</button>
|
||||||
|
<SongItemMenu :song-id="song.id" />
|
||||||
<span class="text-xs text-content-3">{{ formatDuration(song.dt ?? 0) }}</span>
|
<span class="text-xs text-content-3">{{ formatDuration(song.dt ?? 0) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -39,7 +47,10 @@
|
|||||||
import { usePlayerStore } from '../stores/player';
|
import { usePlayerStore } from '../stores/player';
|
||||||
import { useDownload } from '../composables/useDownload';
|
import { useDownload } from '../composables/useDownload';
|
||||||
import { formatDuration } from '../utils/format';
|
import { formatDuration } from '../utils/format';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import SongItemMenu from '../components/SongItemMenu.vue';
|
||||||
|
|
||||||
const player = usePlayerStore();
|
const player = usePlayerStore();
|
||||||
const download = useDownload();
|
const download = useDownload();
|
||||||
|
const router = useRouter();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -26,7 +26,14 @@
|
|||||||
|
|
||||||
<h1 class="text-3xl font-bold mb-2">{{ currentSong.name }}</h1>
|
<h1 class="text-3xl font-bold mb-2">{{ currentSong.name }}</h1>
|
||||||
<p class="text-lg text-content-2 mb-8">
|
<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>
|
</p>
|
||||||
|
|
||||||
<div class="flex items-center gap-8">
|
<div class="flex items-center gap-8">
|
||||||
@ -58,8 +65,10 @@ import { ref, computed, watch, onMounted } from 'vue';
|
|||||||
import { usePlayerStore } from '../stores/player';
|
import { usePlayerStore } from '../stores/player';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { normalizeSong } from '../utils/song';
|
import { normalizeSong } from '../utils/song';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
const player = usePlayerStore();
|
const player = usePlayerStore();
|
||||||
|
const router = useRouter();
|
||||||
const coverError = ref(false);
|
const coverError = ref(false);
|
||||||
|
|
||||||
const currentSong = computed(() => {
|
const currentSong = computed(() => {
|
||||||
@ -76,12 +85,6 @@ const coverUrl = computed(() => {
|
|||||||
|
|
||||||
watch(coverUrl, () => { coverError.value = false; });
|
watch(coverUrl, () => { coverError.value = false; });
|
||||||
|
|
||||||
const artists = computed(() => {
|
|
||||||
if (!currentSong.value) return '';
|
|
||||||
return currentSong.value.ar?.map((a: any) => a.name).join(' / ') ||
|
|
||||||
currentSong.value.artists?.map((a: any) => a.name).join(' / ') || '';
|
|
||||||
});
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!player.isFmMode || !player.currentSong) {
|
if (!player.isFmMode || !player.currentSong) {
|
||||||
await startFm();
|
await startFm();
|
||||||
|
|||||||
@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"composite": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowSyntheticDefaultImports": true
|
|
||||||
},
|
|
||||||
"include": ["vite.config.ts"]
|
|
||||||
}
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
import { defineConfig } from "vite";
|
|
||||||
import vue from "@vitejs/plugin-vue";
|
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
|
||||||
import Icons from "unplugin-icons/vite";
|
|
||||||
import { fileURLToPath, URL } from "node:url";
|
|
||||||
|
|
||||||
|
|
||||||
// \@ts-expect-error process is a nodejs global
|
|
||||||
const host = process.env.TAURI_DEV_HOST;
|
|
||||||
|
|
||||||
// https://vite.dev/config/
|
|
||||||
export default defineConfig(async () => ({
|
|
||||||
plugins: [
|
|
||||||
vue(),
|
|
||||||
tailwindcss(),
|
|
||||||
Icons({ compiler: "vue3" }),
|
|
||||||
],
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
|
||||||
//
|
|
||||||
// 1. prevent Vite from obscuring rust errors
|
|
||||||
clearScreen: false,
|
|
||||||
// 2. tauri expects a fixed port, fail if that port is not available
|
|
||||||
server: {
|
|
||||||
port: 1420,
|
|
||||||
strictPort: true,
|
|
||||||
host: host || false,
|
|
||||||
hmr: host
|
|
||||||
? {
|
|
||||||
protocol: "ws",
|
|
||||||
host,
|
|
||||||
port: 1421,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
watch: {
|
|
||||||
// 3. tell Vite to ignore watching `src-tauri`
|
|
||||||
ignored: ["**/src-tauri/**"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
Reference in New Issue
Block a user