mirror of
https://github.com/atdunbg/Nekosonic-Music.git
synced 2026-06-21 16:48:48 +08:00
feat: v0.6.0 - 亮色主题、封面主色、发现页重做、漫游页重做、减少推荐、列表风格统一
新功能: - 亮色主题:新增浅色外观模式,7种主题色各有对应亮色变体 - 封面主色背景:漫游抽屉自动提取封面主色,PlayerBar跟随继承 - 发现页重做:多类型搜索(歌曲/歌手/专辑)+搜索建议+搜索历史 - 漫游页重做:进入即播放,布局改为封面+歌名+播放/下一首/减少推荐 - 减少推荐:FM模式下可标记不推荐歌曲或歌手 - 列表风格统一:播放指示器跳动动画+hover播放图标+图标统一使用Lucide 修复: - 专辑页艺术家过多时窗口缩小竖排,改为自动换行 - FM播放时退出登录后首页仍可点击下一首 - 本地音乐播放时缓冲进度条未重置 - 亮色主题下多处文字不可见 - 退出FM模式时状态未正确清理 - 暗色模式下关闭抽屉时PlayerBar闪烁亮色(改用opacity过渡) - player.ts tickInterval双变量状态不同步,统一为clearTick/setTick 变更: - 移除播放列表按钮数字角标 - 主页卡片标题固定白色不随主题变化 - 全项目空catch块格式统一 - 清理冗余注释和代码
This commit is contained in:
28
CHANGELOG.md
28
CHANGELOG.md
@ -1,3 +1,29 @@
|
||||
## v0.6.0
|
||||
|
||||
### ✨ 新功能
|
||||
- **亮色主题**:新增浅色外观模式,支持深色/浅色切换,7 种主题色各有对应亮色变体
|
||||
- **封面主色背景**:全屏漫游抽屉背景自动提取封面图主色调,沉浸感更强;抽屉打开时底部播放栏也跟随封面主色,视觉融为一体
|
||||
- **发现页重做**:支持多类型搜索(歌曲/歌手/专辑),输入时自动显示搜索建议,搜索历史和热门搜索
|
||||
- **漫游增强**:全屏抽屉支持歌词/评论切换,播放栏新增减少推荐按钮
|
||||
- **减少推荐**:FM 模式下可标记"不推荐这首歌"或"不推荐这个歌手",后续不会再收到类似推荐
|
||||
- **列表风格统一**:正在播放的歌曲序号位置显示跳动动画,鼠标悬停显示播放图标;红心/下载等图标统一使用图标库
|
||||
|
||||
### 🐛 修复
|
||||
- 专辑页艺术家过多时窗口缩小会竖排显示,现在支持自动换行
|
||||
- FM 播放时退出登录后首页仍可点击下一首
|
||||
- 本地音乐播放时缓冲进度条未重置
|
||||
- 亮色主题下多处文字看不见
|
||||
- 退出 FM 模式时状态未正确清理
|
||||
- 暗色模式下关闭抽屉时播放栏短暂闪烁亮色
|
||||
|
||||
### 🎨 变更
|
||||
- 移除播放列表按钮上的数字角标
|
||||
- 主页每日推荐和 FM 卡片标题固定为白色,不随主题变化
|
||||
|
||||
### 🧹 清理
|
||||
- 内部代码优化和冗余清理
|
||||
|
||||
|
||||
## v0.5.1
|
||||
|
||||
### 🐛 修复
|
||||
@ -113,4 +139,4 @@
|
||||
|
||||
## v0.1.0
|
||||
|
||||
Nekosonic 是一款基于 Tauri 2 + Rust 的跨平台桌面音乐播放器,音源主要来自网易云音乐,开箱即用。
|
||||
Nekosonic 是一款基于 Tauri 2 + Rust 的跨平台桌面音乐播放器,音源主要来自网易云音乐,开箱即用。
|
||||
|
||||
35
package-lock.json
generated
35
package-lock.json
generated
@ -1,13 +1,14 @@
|
||||
{
|
||||
"name": "nekosonic",
|
||||
"version": "0.3.0",
|
||||
"version": "0.5.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "nekosonic",
|
||||
"version": "0.3.0",
|
||||
"version": "0.5.1",
|
||||
"dependencies": {
|
||||
"@iconify-json/lucide": "^1.2.110",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
|
||||
@ -22,6 +23,7 @@
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify/utils": "^3.1.3",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/node": "^25.6.0",
|
||||
@ -538,23 +540,31 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@iconify-json/lucide": {
|
||||
"version": "1.2.110",
|
||||
"resolved": "https://registry.npmmirror.com/@iconify-json/lucide/-/lucide-1.2.110.tgz",
|
||||
"integrity": "sha512-rLeHqnZZBxZbprbVwf6uY7HB5GkGVgvT9VujhjvaUEqFDLKZON6zR8K1f8uD1brBwf5TJ0TIvvW8mz5u2XJU+w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@iconify/types": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@iconify/types": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/@iconify/types/-/types-2.0.0.tgz",
|
||||
"integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@iconify/utils": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/@iconify/utils/-/utils-3.1.1.tgz",
|
||||
"integrity": "sha512-MwzoDtw9rO1x+qfgLTV/IVXsHDBqeYZoMIQC8SfxfYSlaSUG+oWiAcoiB1yajAda6mqblm4/1/w2E8tRu7a7Tw==",
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/@iconify/utils/-/utils-3.1.3.tgz",
|
||||
"integrity": "sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@antfu/install-pkg": "^1.1.0",
|
||||
"@iconify/types": "^2.0.0",
|
||||
"mlly": "^1.8.2"
|
||||
"import-meta-resolve": "^4.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
@ -2584,6 +2594,17 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/import-meta-resolve": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz",
|
||||
"integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "nekosonic",
|
||||
"private": true,
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@ -10,6 +10,7 @@
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@iconify-json/lucide": "^1.2.110",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
|
||||
@ -24,6 +25,7 @@
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify/utils": "^3.1.3",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/node": "^25.6.0",
|
||||
|
||||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@ -4,7 +4,7 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "Nekosonic"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"cpal",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "Nekosonic"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
description = "A Simple music app"
|
||||
authors = ["atdunbg"]
|
||||
edition = "2021"
|
||||
|
||||
@ -123,6 +123,20 @@ impl ApiController {
|
||||
#[derive(Deserialize)]
|
||||
pub struct SearchQuery { pub keyword: String }
|
||||
|
||||
/// 多类型搜索查询参数
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CloudSearchQuery {
|
||||
pub keyword: String,
|
||||
pub search_type: Option<i64>,
|
||||
pub limit: Option<i64>,
|
||||
pub offset: Option<i64>,
|
||||
}
|
||||
|
||||
/// 搜索建议查询参数
|
||||
#[derive(Deserialize)]
|
||||
pub struct SearchSuggestQuery { pub keyword: String }
|
||||
|
||||
/// 手机号登录查询参数
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginQuery { pub phone: String, pub password: String }
|
||||
@ -137,6 +151,23 @@ pub async fn search_songs(query: SearchQuery, state: State<'_, ApiController>) -
|
||||
api_call!(state, cloudsearch, params: [("keywords", &query.keyword), ("type", "1"), ("limit", "30")])
|
||||
}
|
||||
|
||||
/// 多类型搜索(歌曲/歌手/专辑)
|
||||
#[tauri::command]
|
||||
pub async fn cloudsearch(query: CloudSearchQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
api_call!(state, cloudsearch, params: [
|
||||
("keywords", &query.keyword),
|
||||
("type", &query.search_type.unwrap_or(1).to_string()),
|
||||
("limit", &query.limit.unwrap_or(30).to_string()),
|
||||
("offset", &query.offset.unwrap_or(0).to_string())
|
||||
])
|
||||
}
|
||||
|
||||
/// 搜索建议
|
||||
#[tauri::command]
|
||||
pub async fn search_suggest(query: SearchSuggestQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
api_call!(state, search_suggest, params: [("keywords", &query.keyword)])
|
||||
}
|
||||
|
||||
/// 获取热搜词列表
|
||||
#[tauri::command]
|
||||
pub async fn get_hot_search(state: State<'_, ApiController>) -> Result<String, String> {
|
||||
@ -312,6 +343,42 @@ pub async fn recommend_resource(state: State<'_, ApiController>) -> Result<Strin
|
||||
api_call!(state, recommend_resource)
|
||||
}
|
||||
|
||||
/// 私人漫游模式查询参数
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PersonalFmModeQuery {
|
||||
pub mode: Option<String>,
|
||||
pub sub_mode: Option<String>,
|
||||
pub limit: Option<i64>,
|
||||
}
|
||||
|
||||
/// 私人漫游(带模式)
|
||||
#[tauri::command]
|
||||
pub async fn personal_fm_mode(query: PersonalFmModeQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
api_call!(state, personal_fm_mode, params: [
|
||||
("mode", query.mode.as_deref().unwrap_or("DEFAULT")),
|
||||
("submode", query.sub_mode.as_deref().unwrap_or("")),
|
||||
("limit", &query.limit.unwrap_or(3).to_string())
|
||||
])
|
||||
}
|
||||
|
||||
/// FM 不喜欢查询参数
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FmTrashQuery {
|
||||
pub id: u64,
|
||||
pub time: Option<i64>,
|
||||
}
|
||||
|
||||
/// FM 不喜欢(减少推荐)
|
||||
#[tauri::command]
|
||||
pub async fn fm_trash(query: FmTrashQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
api_call!(state, fm_trash, params: [
|
||||
("id", &query.id.to_string()),
|
||||
("time", &query.time.unwrap_or(25).to_string())
|
||||
])
|
||||
}
|
||||
|
||||
/// 获取私人漫游歌曲
|
||||
#[tauri::command]
|
||||
pub async fn personal_fm(state: State<'_, ApiController>) -> Result<String, String> {
|
||||
|
||||
@ -137,6 +137,8 @@ pub fn run() {
|
||||
api::logout,
|
||||
|
||||
api::search_songs,
|
||||
api::cloudsearch,
|
||||
api::search_suggest,
|
||||
api::get_song_url,
|
||||
api::get_hot_search,
|
||||
api::get_playlist_detail,
|
||||
@ -145,6 +147,8 @@ pub fn run() {
|
||||
api::recommend_resource,
|
||||
api::recommend_songs,
|
||||
api::personal_fm,
|
||||
api::personal_fm_mode,
|
||||
api::fm_trash,
|
||||
api::scrobble,
|
||||
api::get_song_detail,
|
||||
api::get_qr_key,
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Nekosonic",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"identifier": "com.atdunbg.Nekosonic",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
155
src/App.vue
155
src/App.vue
@ -16,23 +16,24 @@
|
||||
<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">
|
||||
<div class="relative mb-4">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 text-content-3" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
|
||||
<input v-model="searchQuery" @keydown.enter="doSearch" type="text" placeholder="搜索音乐..."
|
||||
class="w-full rounded-lg bg-subtle pl-9 pr-3 py-2 text-sm text-content placeholder-content-3 outline-none focus:bg-muted transition" />
|
||||
</div>
|
||||
<div class="space-y-0.5">
|
||||
<router-link to="/"
|
||||
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="M3 12l9-9 9 9"/><path d="M5 10v10a1 1 0 001 1h3v-6h6v6h3a1 1 0 001-1V10"/></svg>
|
||||
<IconHome class="w-[18px] h-[18px]" />
|
||||
推荐
|
||||
</router-link>
|
||||
<router-link to="/discover"
|
||||
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">
|
||||
<IconSearch class="w-[18px] h-[18px]" />
|
||||
发现
|
||||
</router-link>
|
||||
<button
|
||||
@click="openRoamFromSidebar"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle w-full text-left"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.4"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.4"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>
|
||||
<IconRadio class="w-[18px] h-[18px]" />
|
||||
漫游
|
||||
</button>
|
||||
</div>
|
||||
@ -43,51 +44,54 @@
|
||||
<router-link to="/favorites"
|
||||
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="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>
|
||||
<IconHeart class="w-[18px] h-[18px]" />
|
||||
我喜欢的音乐
|
||||
</router-link>
|
||||
<router-link to="/recent"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
|
||||
active-class="!text-content !bg-muted">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
|
||||
<IconClock class="w-[18px] h-[18px]" />
|
||||
最近播放
|
||||
</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>
|
||||
<IconMusic class="w-[18px] h-[18px]" />
|
||||
本地音乐
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 mb-1 pt-2" v-if="userStore.isLoggedIn">
|
||||
<div class="flex items-center justify-between px-3 mb-1 cursor-pointer"
|
||||
<div class="flex items-center justify-between px-3 mb-1 cursor-pointer group"
|
||||
@click="showCreatedPlaylists = !showCreatedPlaylists">
|
||||
<p class="text-xs text-content-3">我的歌单</p>
|
||||
<span class="text-xs text-content-3 transition-transform"
|
||||
:class="{ 'rotate-90': showCreatedPlaylists }">▶</span>
|
||||
<IconChevronRight class="w-3 h-3 text-content-3 transition-transform" :class="{ 'rotate-90': showCreatedPlaylists }" />
|
||||
</div>
|
||||
<div v-show="showCreatedPlaylists" class="space-y-0.5">
|
||||
<div v-for="pl in createdPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
|
||||
class="px-3 py-1.5 rounded-lg text-sm cursor-pointer truncate transition-all duration-200"
|
||||
:class="isPlaylistActive(pl.id) ? 'text-content bg-muted' : 'text-content-2 hover:text-content hover:bg-subtle'">
|
||||
{{ pl.name }}
|
||||
class="flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all duration-200"
|
||||
:class="isPlaylistActive(pl.id) ? 'bg-muted' : 'hover:bg-subtle'">
|
||||
<img :src="pl.coverImgUrl + '?param=80y80'" class="w-8 h-8 rounded object-cover flex-shrink-0" />
|
||||
<span class="text-sm truncate"
|
||||
:class="isPlaylistActive(pl.id) ? 'text-content font-medium' : 'text-content-2'">{{ pl.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 mb-1 pt-2" v-if="userStore.isLoggedIn">
|
||||
<div class="flex items-center justify-between px-3 mb-1 cursor-pointer"
|
||||
<div class="flex items-center justify-between px-3 mb-1 cursor-pointer group"
|
||||
@click="showSubPlaylists = !showSubPlaylists">
|
||||
<p class="text-xs text-content-3">收藏的歌单</p>
|
||||
<span class="text-xs text-content-3 transition-transform" :class="{ 'rotate-90': showSubPlaylists }">▶</span>
|
||||
<IconChevronRight class="w-3 h-3 text-content-3 transition-transform" :class="{ 'rotate-90': showSubPlaylists }" />
|
||||
</div>
|
||||
<div v-show="showSubPlaylists" class="space-y-0.5">
|
||||
<div v-for="pl in subPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
|
||||
class="px-3 py-1.5 rounded-lg text-sm cursor-pointer truncate transition-all duration-200"
|
||||
:class="isPlaylistActive(pl.id) ? 'text-content bg-muted' : 'text-content-2 hover:text-content hover:bg-subtle'">
|
||||
{{ pl.name }}
|
||||
class="flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all duration-200"
|
||||
:class="isPlaylistActive(pl.id) ? 'bg-muted' : 'hover:bg-subtle'">
|
||||
<img :src="pl.coverImgUrl + '?param=80y80'" class="w-8 h-8 rounded object-cover flex-shrink-0" />
|
||||
<span class="text-sm truncate"
|
||||
:class="isPlaylistActive(pl.id) ? 'text-content font-medium' : 'text-content-2'">{{ pl.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -97,7 +101,7 @@
|
||||
<router-link to="/settings"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
|
||||
active-class="!text-content !bg-muted">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
|
||||
<IconSettings class="w-[18px] h-[18px]" />
|
||||
设置
|
||||
</router-link>
|
||||
</div>
|
||||
@ -105,7 +109,7 @@
|
||||
<p class="text-xs text-content-3 mb-2">强烈建议登录以提升体验</p>
|
||||
<router-link to="/login"
|
||||
class="flex items-center justify-center gap-2 w-full px-4 py-2 rounded-lg bg-accent hover:bg-accent-hover transition text-sm font-medium text-white">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h4a2 2 0 012 2v14a2 2 0 01-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>
|
||||
<IconLogIn class="w-4 h-4" />
|
||||
立即登录
|
||||
</router-link>
|
||||
</div>
|
||||
@ -113,7 +117,7 @@
|
||||
<img :src="userStore.user?.avatarUrl" class="w-8 h-8 rounded-full ring-2 ring-accent/50" />
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium truncate">{{ userStore.user?.nickname }}</p>
|
||||
<button @click="userStore.logout()"
|
||||
<button @click="userStore.logout(); player.stop()"
|
||||
class="text-xs text-content-3 hover:text-danger transition">退出登录</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -134,11 +138,14 @@
|
||||
<Transition name="drawer">
|
||||
<div
|
||||
v-if="windowVisible && player.showRoamDrawer"
|
||||
class="fixed inset-0 z-50 flex flex-col backdrop-blur-xl bg-black/80"
|
||||
class="fixed inset-0 z-50 flex flex-col backdrop-blur-xl"
|
||||
:class="!player.dominantColor && 'bg-surface/95'"
|
||||
:style="player.dominantColor ? { backgroundColor: player.dominantColor } : {}"
|
||||
>
|
||||
<div class="h-10 flex items-center justify-between px-4 flex-shrink-0" data-tauri-drag-region>
|
||||
<button @click="player.closeRoamDrawer()" class="text-content-2 hover:text-content transition">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
<div v-if="player.dominantColor" class="absolute inset-0 bg-black/60 pointer-events-none"></div>
|
||||
<div class="h-10 flex items-center justify-between px-4 flex-shrink-0 relative z-10" data-tauri-drag-region>
|
||||
<button @click="player.closeRoamDrawer()" :class="player.dominantColor ? 'text-white/60 hover:text-white' : 'text-content-2 hover:text-content'" class="transition">
|
||||
<IconChevronDown class="w-5 h-5" />
|
||||
</button>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button @click="minimizeWindow" class="w-3 h-3 rounded-full bg-yellow-500 hover:bg-yellow-400 transition" title="最小化"></button>
|
||||
@ -146,7 +153,7 @@
|
||||
<button @click="closeWindow" class="w-3 h-3 rounded-full bg-red-500 hover:bg-red-400 transition" title="关闭"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-h-0 flex px-8 pb-8 gap-0">
|
||||
<div class="flex-1 min-h-0 flex px-8 pb-8 gap-0 relative z-10">
|
||||
<div class="w-2/5 flex flex-col items-center justify-center flex-shrink-0">
|
||||
<img
|
||||
v-if="roamCoverUrl && !roamCoverError"
|
||||
@ -156,18 +163,19 @@
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="w-72 h-72 rounded-3xl bg-white/10 flex items-center justify-center shadow-2xl mb-4"
|
||||
class="w-72 h-72 rounded-3xl flex items-center justify-center shadow-2xl mb-4"
|
||||
:class="player.dominantColor ? 'bg-white/10' : 'bg-muted'"
|
||||
>
|
||||
<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>
|
||||
<IconMusic class="w-16 h-16" :class="player.dominantColor ? 'text-white/30' : 'text-content-4'" />
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-white text-center">{{ roamSong?.name }}</h1>
|
||||
<p class="text-content-2 mt-2 text-center">
|
||||
<h1 class="text-2xl font-bold text-center" :class="player.dominantColor ? 'text-white' : 'text-content'">{{ roamSong?.name }}</h1>
|
||||
<p class="mt-2 text-center" :class="player.dominantColor ? 'text-white/70' : 'text-content-2'">
|
||||
<template v-for="(a, i) in roamSong?.ar || []" :key="a.id || i">
|
||||
<span v-if="i > 0" class="text-content-3">/</span>
|
||||
<span v-if="i > 0" :class="player.dominantColor ? 'text-white/40' : '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="player.dominantColor ? 'text-white/40' : 'text-content-3'" class="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>
|
||||
@ -176,18 +184,24 @@
|
||||
<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'">
|
||||
:class="player.dominantColor
|
||||
? (roamTab === 'lyric' ? 'bg-white/15 text-white font-medium' : 'text-white/50 hover:text-white/80')
|
||||
: (roamTab === 'lyric' ? 'bg-muted text-content font-medium' : 'text-content-3 hover:text-content')">
|
||||
歌词
|
||||
</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'">
|
||||
:class="player.dominantColor
|
||||
? (roamTab === 'comment' ? 'bg-white/15 text-white font-medium' : 'text-white/50 hover:text-white/80')
|
||||
: (roamTab === 'comment' ? 'bg-muted text-content font-medium' : 'text-content-3 hover:text-content')">
|
||||
评论
|
||||
</button>
|
||||
<button v-if="hasTranslation" @click="toggleTranslation"
|
||||
class="ml-auto px-2.5 py-1 rounded-full text-xs transition flex items-center gap-1"
|
||||
:class="showTranslation ? 'bg-white/15 text-white font-medium' : 'text-white/40 hover:text-white/70'">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 8l6 6"/><path d="M4 14l6-6 2-3"/><path d="M2 5h12"/><path d="M7 2h1"/><path d="M22 22l-5-10-5 10"/><path d="M14 18h6"/></svg>
|
||||
:class="player.dominantColor
|
||||
? (showTranslation ? 'bg-white/15 text-white font-medium' : 'text-white/40 hover:text-white/70')
|
||||
: (showTranslation ? 'bg-muted text-content font-medium' : 'text-content-4 hover:text-content-2')">
|
||||
<IconLanguages class="w-3 h-3" />
|
||||
译
|
||||
</button>
|
||||
</div>
|
||||
@ -207,7 +221,7 @@
|
||||
<span v-if="showTranslation && line.translation" class="block text-sm opacity-60 mt-1">{{ line.translation }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="text-content-3 text-center mt-8">暂无歌词</div>
|
||||
<div v-else :class="player.dominantColor ? 'text-white/40' : 'text-content-3'" class="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" />
|
||||
@ -238,7 +252,7 @@
|
||||
<button @click="handleCloseAction('minimize')"
|
||||
class="w-full flex items-center gap-3 px-4 py-3 rounded-xl bg-subtle hover:bg-muted transition text-left">
|
||||
<div class="w-9 h-9 rounded-lg bg-accent-dim flex items-center justify-center flex-shrink-0">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-accent-text"><path d="M8 3v3a2 2 0 01-2 2H3m18 0h-3a2 2 0 01-2-2V3m0 18v-3a2 2 0 012-2h3M3 16h3a2 2 0 012 2v3"/></svg>
|
||||
<IconMaximize2 class="w-[18px] h-[18px] text-accent-text" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-content">最小化到托盘</p>
|
||||
@ -248,7 +262,7 @@
|
||||
<button @click="handleCloseAction('exit')"
|
||||
class="w-full flex items-center gap-3 px-4 py-3 rounded-xl bg-subtle hover:bg-muted transition text-left">
|
||||
<div class="w-9 h-9 rounded-lg bg-danger-dim flex items-center justify-center flex-shrink-0">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-danger"><path d="M18 6L6 18M6 6l12 12"/></svg>
|
||||
<IconX class="w-[18px] h-[18px] text-danger" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-content">退出程序</p>
|
||||
@ -281,7 +295,7 @@ import ToastContainer from './components/ToastContainer.vue';
|
||||
import CommentSection from './components/CommentSection.vue';
|
||||
import UpdateDialog from './components/UpdateDialog.vue';
|
||||
import { usePlayerStore } from './stores/player';
|
||||
import { getCoverUrl } from './utils/song';
|
||||
import { getCoverUrl, extractDominantColor } from './utils/song';
|
||||
import { useOnlineStatus } from './composables/useOnlineStatus';
|
||||
import { showToast } from './composables/useToast';
|
||||
import { useLyric } from './composables/UserLyric';
|
||||
@ -289,6 +303,19 @@ 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';
|
||||
import IconHome from '~icons/lucide/home';
|
||||
import IconSearch from '~icons/lucide/search';
|
||||
import IconRadio from '~icons/lucide/radio';
|
||||
import IconHeart from '~icons/lucide/heart';
|
||||
import IconSettings from '~icons/lucide/settings';
|
||||
import IconLogIn from '~icons/lucide/log-in';
|
||||
import IconChevronDown from '~icons/lucide/chevron-down';
|
||||
import IconChevronRight from '~icons/lucide/chevron-right';
|
||||
import IconMaximize2 from '~icons/lucide/maximize-2';
|
||||
import IconX from '~icons/lucide/x';
|
||||
import IconClock from '~icons/lucide/clock';
|
||||
import IconMusic from '~icons/lucide/music';
|
||||
import IconLanguages from '~icons/lucide/languages';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
@ -307,21 +334,15 @@ const createdPlaylists = ref<any[]>([]);
|
||||
const subPlaylists = ref<any[]>([]);
|
||||
const showCreatedPlaylists = ref(true);
|
||||
const showSubPlaylists = ref(true);
|
||||
const searchQuery = ref('');
|
||||
const showCloseModal = ref(false);
|
||||
const closeDontAskAgain = ref(false);
|
||||
const windowVisible = ref(true);
|
||||
const keepAliveInclude = ref<string[]>(['HomeView', 'DiscoverView', 'FavoriteSongsView', 'DailySongsView', 'LocalMusicView']);
|
||||
|
||||
watch(() => settings.theme, (val) => {
|
||||
watch(() => settings.dataTheme, (val) => {
|
||||
document.documentElement.setAttribute('data-theme', val);
|
||||
}, { immediate: true });
|
||||
|
||||
function doSearch() {
|
||||
const q = searchQuery.value.trim();
|
||||
if (q) router.push({ path: '/discover', query: { q } });
|
||||
}
|
||||
|
||||
const { lyrics, currentLyricIdx, hasTranslation, showTranslation, toggleTranslation } = useLyric();
|
||||
const lyricScrollContainer = ref<HTMLElement | null>(null);
|
||||
const roamLyricHovering = ref(false);
|
||||
@ -333,7 +354,15 @@ const roamCoverUrl = computed(() => {
|
||||
if (!roamSong.value) return '';
|
||||
return getCoverUrl(roamSong.value) || '';
|
||||
});
|
||||
watch(roamCoverUrl, () => { roamCoverError.value = false; });
|
||||
watch(roamCoverUrl, async (url) => {
|
||||
roamCoverError.value = false;
|
||||
if (url) {
|
||||
const color = await extractDominantColor(url);
|
||||
player.dominantColor = color;
|
||||
} else {
|
||||
player.dominantColor = '';
|
||||
}
|
||||
});
|
||||
let roamResizeObserver: ResizeObserver | null = null;
|
||||
|
||||
function updateRoamLyricPad() {
|
||||
@ -391,12 +420,13 @@ function scrollToRoamActiveLyric() {
|
||||
|
||||
function getRoamLyricClass(idx: number): string {
|
||||
const diff = Math.abs(idx - currentLyricIdx.value);
|
||||
const hasColor = !!player.dominantColor;
|
||||
if (idx === currentLyricIdx.value) {
|
||||
return 'roam-lyric-active text-accent-text font-semibold text-xl';
|
||||
}
|
||||
if (diff === 1) return 'text-content/70 text-lg';
|
||||
if (diff === 2) return 'text-content-2/50 text-[1rem]';
|
||||
return 'text-content-3/35 text-[1rem]';
|
||||
if (diff === 1) return hasColor ? 'text-white/70 text-lg' : 'text-content/70 text-lg';
|
||||
if (diff === 2) return hasColor ? 'text-white/50 text-[1rem]' : 'text-content-2/50 text-[1rem]';
|
||||
return hasColor ? 'text-white/35 text-[1rem]' : 'text-content-3/35 text-[1rem]';
|
||||
}
|
||||
|
||||
function seekToRoamLyric(time: number) {
|
||||
@ -411,6 +441,10 @@ function navigateFromDrawer(routeLocation: { name: string; params: any }) {
|
||||
}
|
||||
|
||||
async function openRoamFromSidebar() {
|
||||
if (!userStore.isLoggedIn) {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
if (player.isFmMode) {
|
||||
player.openRoamDrawer();
|
||||
} else {
|
||||
@ -425,7 +459,7 @@ async function loadPlaylists() {
|
||||
const data = JSON.parse(jsonStr);
|
||||
createdPlaylists.value = (data.playlist || []).filter((p: any) => !p.subscribed).slice(1);
|
||||
subPlaylists.value = (data.playlist || []).filter((p: any) => p.subscribed);
|
||||
} catch (e) { /* 忽略 */ }
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
|
||||
function goPlaylist(id: number) {
|
||||
@ -450,7 +484,7 @@ onMounted(async () => {
|
||||
loadPlaylists();
|
||||
player.loadLikedIds();
|
||||
}
|
||||
try { await invoke('stop_audio'); } catch {}
|
||||
try { await invoke('stop_audio'); } catch { /* 忽略 */ }
|
||||
try {
|
||||
const jsonStr: string = await invoke('get_login_status');
|
||||
const data = JSON.parse(jsonStr);
|
||||
@ -462,7 +496,7 @@ onMounted(async () => {
|
||||
avatarUrl: profile.avatarUrl,
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
} catch { /* 忽略 */ }
|
||||
|
||||
updater.checkForUpdate(true);
|
||||
|
||||
@ -470,8 +504,7 @@ onMounted(async () => {
|
||||
if(settings.outputDevice) {
|
||||
try {
|
||||
await invoke('set_output_device', { device: settings.outputDevice });
|
||||
}
|
||||
catch{}
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
});
|
||||
|
||||
@ -542,12 +575,12 @@ async function registerGlobalShortcuts() {
|
||||
for (const [id, action] of Object.entries(globalActions)) {
|
||||
const key = settings.shortcuts[id]?.key;
|
||||
if (!key) continue;
|
||||
try { await unregister(key); } catch {}
|
||||
try { await unregister(key); } catch { /* 忽略 */ }
|
||||
try {
|
||||
await register(key, (event) => {
|
||||
if (event.state === 'Pressed') action();
|
||||
});
|
||||
} catch {}
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
<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>
|
||||
<IconMessageSquare class="mx-auto mb-2 text-content-3 w-10 h-10" />
|
||||
<p class="text-content-3 text-sm">暂无评论</p>
|
||||
</div>
|
||||
|
||||
@ -26,7 +26,7 @@
|
||||
@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>
|
||||
<IconHeart style="font-size: 14px" />
|
||||
<span>{{ comment.likedCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
@ -41,6 +41,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import IconMessageSquare from '~icons/lucide/message-square'
|
||||
import IconHeart from '~icons/lucide/heart'
|
||||
|
||||
const props = defineProps<{
|
||||
type: number
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
:class="{ 'border-accent shadow-[0_0_0_2px_var(--c-accent-dim)]': isOpen }"
|
||||
>
|
||||
<span class="truncate">{{ currentLabel }}</span>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="transition-transform flex-shrink-0 ml-2" :class="{ 'rotate-180': isOpen }"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
<IconChevronDown style="font-size: 12px" class="transition-transform flex-shrink-0 ml-2" :class="{ 'rotate-180': isOpen }" />
|
||||
</button>
|
||||
<Transition name="dropdown">
|
||||
<div v-if="isOpen" class="absolute right-0 top-full mt-1 bg-surface border border-line rounded-lg shadow-xl z-50 py-1 min-w-full max-w-[360px] overflow-hidden">
|
||||
@ -18,7 +18,7 @@
|
||||
:class="modelValue === key ? 'bg-accent-dim text-accent-text' : 'text-content-2 hover:bg-subtle hover:text-content'"
|
||||
>
|
||||
<span class="truncate">{{ label }}</span>
|
||||
<svg v-if="modelValue === key" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
<IconCheck v-if="modelValue === key" style="font-size: 14px" />
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
@ -27,6 +27,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import IconChevronDown from '~icons/lucide/chevron-down';
|
||||
import IconCheck from '~icons/lucide/check';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string;
|
||||
|
||||
@ -1,75 +1,83 @@
|
||||
<template>
|
||||
<div
|
||||
class="fixed bottom-0 left-0 right-0 bg-surface/95 backdrop-blur border-t border-line z-50 select-none">
|
||||
class="fixed bottom-0 left-0 right-0 bg-surface/95 backdrop-blur border-t border-line z-50 select-none"
|
||||
>
|
||||
<div v-if="player.dominantColor"
|
||||
class="absolute inset-0 pointer-events-none transition-opacity duration-300"
|
||||
:class="drawerActive ? 'opacity-100' : 'opacity-0'"
|
||||
:style="{ backgroundColor: player.dominantColor }"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/60"></div>
|
||||
</div>
|
||||
|
||||
<div ref="progressBar" class="w-full h-1.5 bg-muted rounded-full relative group cursor-pointer overflow-visible"
|
||||
<div ref="progressBar" class="w-full h-1.5 rounded-full relative group cursor-pointer overflow-visible"
|
||||
:class="drawerActive ? 'bg-white/10' : 'bg-muted'"
|
||||
@mousedown.prevent="startSeek">
|
||||
<div class="absolute left-0 top-0 h-full bg-emphasis rounded-full" :style="{ width: cacheProgress + '%' }"></div>
|
||||
<div class="absolute left-0 top-0 h-full rounded-full" :class="drawerActive ? 'bg-white/20' : 'bg-emphasis'" :style="{ width: cacheProgress + '%' }"></div>
|
||||
<div class="absolute left-0 top-0 h-full bg-accent rounded-full"
|
||||
:style="{ width: displayProgress + '%' }"></div>
|
||||
<div
|
||||
class="absolute top-1/2 -translate-y-1/2 w-3.5 h-3.5 rounded-full bg-white shadow-lg opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
|
||||
class="absolute top-1/2 -translate-y-1/2 w-3.5 h-3.5 rounded-full bg-white shadow-lg border border-line opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
|
||||
:style="{ left: `calc(${displayProgress}% - 7px)` }"></div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center px-6 h-16">
|
||||
<div class="flex items-center px-6 h-16 relative z-10">
|
||||
<div class="flex items-center gap-3 w-56 min-w-0">
|
||||
<div v-if="getCoverUrl(player.currentSong)" class="w-10 h-10 rounded-md overflow-hidden flex-shrink-0 cursor-pointer hover:scale-105 transition-transform" @click="player.toggleRoamDrawer()" title="全屏展示">
|
||||
<img :src="getCoverUrl(player.currentSong)" class="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div v-else class="w-10 h-10 rounded-md flex-shrink-0 bg-muted flex items-center justify-center cursor-pointer hover:scale-105 transition-transform" @click="player.toggleRoamDrawer()" title="全屏展示">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-content-3"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||||
<div v-else class="w-10 h-10 rounded-md flex-shrink-0 flex items-center justify-center cursor-pointer hover:scale-105 transition-transform" @click="player.toggleRoamDrawer()" title="全屏展示"
|
||||
:class="drawerActive ? 'bg-white/10' : 'bg-muted'">
|
||||
<IconMusic class="w-[18px] h-[18px]" :class="drawerActive ? 'text-white/50' : 'text-content-3'" />
|
||||
</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">
|
||||
<p class="text-sm font-medium truncate" :class="drawerActive ? 'text-white' : ''">{{ player.currentSong?.name }}</p>
|
||||
<p class="text-xs truncate" :class="drawerActive ? 'text-white/70' : 'text-content-2'">
|
||||
<template v-for="(a, i) in player.currentSong?.ar || []" :key="a.id || i">
|
||||
<span v-if="i > 0" class="text-content-3">/</span>
|
||||
<span v-if="i > 0" :class="drawerActive ? 'text-white/40' : '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="drawerActive ? 'text-white/40' : 'text-content-3'" class="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 @click="player.currentSong && player.toggleLike(player.currentSong.id)" class="flex-shrink-0 transition" :class="player.currentSong && player.isLiked(player.currentSong.id) ? 'text-danger' : (drawerActive ? 'text-white/50 hover:text-danger' : 'text-content-3 hover:text-danger')">
|
||||
<IconHeart v-if="player.currentSong && player.isLiked(player.currentSong.id)" class="w-4 h-4 text-danger [&>path]:fill-current [&>path]:stroke-0" />
|
||||
<IconHeart v-else class="w-4 h-4" />
|
||||
</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 v-if="player.currentSong" @click="player.openRoamDrawer('comment')" class="flex-shrink-0 transition" :class="drawerActive ? 'text-white/50 hover:text-white' : 'text-content-3 hover:text-accent-text'" title="评论">
|
||||
<IconMessageSquare class="w-4 h-4" />
|
||||
</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 v-if="player.currentSong && !download.isDownloaded(player.currentSong!.id) && !download.isDownloading(player.currentSong!.id)" @click="download.downloadSong(player.currentSong)" class="flex-shrink-0 transition" :class="drawerActive ? 'text-white/50 hover:text-white' : 'text-content-3 hover:text-accent-text'" title="下载">
|
||||
<IconDownload class="w-4 h-4" />
|
||||
</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>
|
||||
<IconLoader2 v-if="player.currentSong && download.isDownloading(player.currentSong!.id)" class="w-4 h-4 flex-shrink-0 animate-spin" :class="drawerActive ? 'text-white/50' : 'text-content-3'" />
|
||||
</div>
|
||||
|
||||
<div class="flex-1 flex flex-col items-center justify-center gap-1">
|
||||
<div class="flex items-center gap-5">
|
||||
<button @click="player.prev()" :disabled="player.isFmMode" :class="[
|
||||
'transition',
|
||||
player.isFmMode ? 'text-content-4 cursor-not-allowed' : 'text-content-2 hover:text-content',
|
||||
player.isFmMode ? (drawerActive ? 'text-white/20 cursor-not-allowed' : 'text-content-4 cursor-not-allowed') : (drawerActive ? 'text-white/70 hover:text-white' : 'text-content-2 hover:text-content'),
|
||||
]">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="19 20 9 12 19 4 19 20"/><line x1="5" y1="19" x2="5" y2="5"/></svg>
|
||||
<IconSkipBack class="w-5 h-5" />
|
||||
</button>
|
||||
<button @click="player.toggle()"
|
||||
class="w-9 h-9 flex items-center justify-center rounded-full bg-muted hover:bg-emphasis transition border border-emphasis">
|
||||
<svg v-if="player.playing" width="16" height="16" viewBox="0 0 16 16" fill="currentColor"
|
||||
class="text-white">
|
||||
<rect x="3" y="2" width="3" height="12" rx="0.5" />
|
||||
<rect x="10" y="2" width="3" height="12" rx="0.5" />
|
||||
</svg>
|
||||
<svg v-else width="16" height="16" viewBox="0 0 16 16" fill="currentColor" class="text-white">
|
||||
<path d="M4 2.5v11l9-5.5z" />
|
||||
</svg>
|
||||
class="w-9 h-9 flex items-center justify-center rounded-full transition"
|
||||
:class="drawerActive ? 'bg-white/15 hover:bg-white/25 border border-white/20' : 'bg-muted hover:bg-emphasis border border-emphasis'">
|
||||
<IconPause v-if="player.playing" class="w-4 h-4" :class="drawerActive ? 'text-white' : 'text-content'" />
|
||||
<IconPlay v-else class="w-4 h-4" :class="drawerActive ? 'text-white' : 'text-content'" />
|
||||
</button>
|
||||
<button @click="player.next()" class="text-content-2 hover:text-content transition">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 4 15 12 5 20 5 4"/><line x1="19" y1="5" x2="19" y2="19"/></svg>
|
||||
<button @click="player.next()" :class="drawerActive ? 'text-white/70 hover:text-white transition' : 'text-content-2 hover:text-content transition'">
|
||||
<IconSkipForward class="w-5 h-5" />
|
||||
</button>
|
||||
<button v-if="player.isFmMode && player.currentSong" @click="showDislikeModal = true" :class="drawerActive ? 'text-white/50 hover:text-danger transition' : 'text-content-3 hover:text-danger transition'" title="减少推荐">
|
||||
<IconHeartOff class="w-[18px] h-[18px]" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs text-content-2">
|
||||
<div class="flex items-center gap-2 text-xs" :class="drawerActive ? 'text-white/70' : 'text-content-2'">
|
||||
<span>{{ formatTime(player.currentTime) }}</span>
|
||||
<span>/</span>
|
||||
<span>{{ formatTime(player.duration) }}</span>
|
||||
@ -78,33 +86,60 @@
|
||||
|
||||
<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="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 @click="toggleMute" :class="drawerActive ? 'text-white/70 hover:text-white transition' : 'text-content-2 hover:text-content transition'">
|
||||
<IconVolumeX v-if="player.volume === 0" class="w-[18px] h-[18px]" />
|
||||
<IconVolume2 v-else class="w-[18px] h-[18px]" />
|
||||
</button>
|
||||
<div class="relative w-20 h-6 flex items-center">
|
||||
<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" />
|
||||
class="vol-slider w-full h-1.5 rounded-full appearance-none cursor-pointer outline-none"
|
||||
:class="drawerActive ? 'bg-white/20' : 'bg-emphasis'" />
|
||||
</div>
|
||||
</div>
|
||||
<button @click="togglePlayMode" class="text-content-2 hover:text-content transition" :title="modeTitle">
|
||||
<svg v-if="player.playMode === 'loop'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 014-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 01-4 4H3"/></svg>
|
||||
<svg v-else-if="player.playMode === 'shuffle'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg>
|
||||
<svg v-else width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 014-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 01-4 4H3"/><text x="11" y="15" font-size="8" fill="currentColor" stroke="none" font-weight="bold">1</text></svg>
|
||||
<button @click="togglePlayMode" :class="drawerActive ? 'text-white/70 hover:text-white transition' : 'text-content-2 hover:text-content transition'" :title="modeTitle">
|
||||
<IconRepeat v-if="player.playMode === 'loop'" class="w-[18px] h-[18px]" />
|
||||
<IconShuffle v-else-if="player.playMode === 'shuffle'" class="w-[18px] h-[18px]" />
|
||||
<IconRepeat1 v-else class="w-[18px] h-[18px]" />
|
||||
</button>
|
||||
<button @click="showQueuePanel = !showQueuePanel"
|
||||
class="text-content-2 hover:text-content transition relative" title="播放列表">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
|
||||
<span v-if="player.queue.length > 0"
|
||||
class="absolute -top-1 -right-1 bg-accent text-content text-xs rounded-full w-4 h-4 flex items-center justify-center">
|
||||
{{ player.queue.length }}
|
||||
</span>
|
||||
:class="drawerActive ? 'text-white/70 hover:text-white transition' : 'text-content-2 hover:text-content transition'" title="播放列表">
|
||||
<IconListMusic class="w-[18px] h-[18px]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition name="queue-fade">
|
||||
<div v-if="showDislikeModal && player.currentSong" class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showDislikeModal = false">
|
||||
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-80 p-5 select-auto">
|
||||
<h2 class="text-base font-semibold text-content mb-1">减少推荐</h2>
|
||||
<p class="text-xs text-content-3 mb-4">选择要减少的推荐类型</p>
|
||||
<div class="flex flex-col gap-2 mb-4">
|
||||
<button @click="dislikeSong"
|
||||
class="w-full flex items-center gap-3 px-4 py-3 rounded-xl bg-muted hover:bg-emphasis transition text-left">
|
||||
<IconMusic class="w-[18px] h-[18px] text-content-2 flex-shrink-0" />
|
||||
<div>
|
||||
<p class="text-sm font-medium">不推荐这首歌曲</p>
|
||||
<p class="text-xs text-content-3 truncate max-w-[200px]">{{ player.currentSong.name }}</p>
|
||||
</div>
|
||||
</button>
|
||||
<button v-if="dislikeArtistName" @click="dislikeArtist"
|
||||
class="w-full flex items-center gap-3 px-4 py-3 rounded-xl bg-muted hover:bg-emphasis transition text-left">
|
||||
<IconUserRound class="w-[18px] h-[18px] text-content-2 flex-shrink-0" />
|
||||
<div>
|
||||
<p class="text-sm font-medium">不推荐这个歌手</p>
|
||||
<p class="text-xs text-content-3 truncate max-w-[200px]">{{ dislikeArtistName }}</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<button @click="showDislikeModal = false"
|
||||
class="w-full py-2 rounded-lg bg-muted hover:bg-emphasis text-sm text-content-2 transition">
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<Transition name="queue-fade">
|
||||
<div v-if="showQueuePanel" class="fixed inset-0 z-[55] bg-black/40 backdrop-blur-[2px]" @click="showQueuePanel = false"></div>
|
||||
</Transition>
|
||||
@ -125,7 +160,7 @@
|
||||
</button>
|
||||
<button @click="showQueuePanel = false"
|
||||
class="w-7 h-7 flex items-center justify-center rounded-lg text-content-3 hover:text-content hover:bg-subtle transition">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
<IconX class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -135,9 +170,7 @@
|
||||
|
||||
<div ref="queueListEl" class="flex-1 overflow-y-auto px-3 py-2 relative">
|
||||
<div v-if="player.queue.length === 0" class="flex flex-col items-center justify-center h-full text-content-4 gap-3">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="opacity-40">
|
||||
<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>
|
||||
</svg>
|
||||
<IconMusic class="w-10 h-10 opacity-40" />
|
||||
<p class="text-sm">播放列表为空</p>
|
||||
<p class="text-xs text-content-4">去发现好听的音乐吧</p>
|
||||
</div>
|
||||
@ -157,7 +190,7 @@
|
||||
<template #actions>
|
||||
<button @click.stop="player.removeFromQueue(idx)"
|
||||
class="w-6 h-6 flex items-center justify-center rounded-md text-content-4 hover:text-danger hover:bg-danger-dim transition opacity-0 group-hover:opacity-100 flex-shrink-0">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
<IconX class="w-3 h-3" />
|
||||
</button>
|
||||
</template>
|
||||
</SongListItem>
|
||||
@ -168,7 +201,7 @@
|
||||
@click="scrollToCurrent"
|
||||
class="sticky bottom-3 float-right mr-1 w-9 h-9 flex items-center justify-center rounded-full bg-surface/90 backdrop-blur shadow-lg shadow-black/30 text-content-3 hover:text-accent-text hover:bg-accent-dim/50 transition-all duration-300"
|
||||
title="定位到正在播放">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><line x1="12" y1="6" x2="12" y2="2"/><line x1="12" y1="22" x2="12" y2="18"/></svg>
|
||||
<IconCrosshair class="w-[18px] h-[18px]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -184,14 +217,36 @@ import { useDownload } from '../composables/useDownload';
|
||||
import { formatTime } from '../utils/format';
|
||||
import { getCoverUrl } from '../utils/song';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { showToast } from '../composables/useToast';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { useRouter } from 'vue-router';
|
||||
import SongListItem from './SongListItem.vue';
|
||||
import IconSkipBack from '~icons/lucide/skip-back';
|
||||
import IconPlay from '~icons/lucide/play';
|
||||
import IconPause from '~icons/lucide/pause';
|
||||
import IconSkipForward from '~icons/lucide/skip-forward';
|
||||
import IconHeartOff from '~icons/lucide/heart-off';
|
||||
import IconVolumeX from '~icons/lucide/volume-x';
|
||||
import IconVolume2 from '~icons/lucide/volume-2';
|
||||
import IconRepeat from '~icons/lucide/repeat';
|
||||
import IconShuffle from '~icons/lucide/shuffle';
|
||||
import IconRepeat1 from '~icons/lucide/repeat-1';
|
||||
import IconListMusic from '~icons/lucide/list-music';
|
||||
import IconMessageSquare from '~icons/lucide/message-square';
|
||||
import IconDownload from '~icons/lucide/download';
|
||||
import IconLoader2 from '~icons/lucide/loader-2';
|
||||
import IconHeart from '~icons/lucide/heart';
|
||||
import IconX from '~icons/lucide/x';
|
||||
import IconMusic from '~icons/lucide/music';
|
||||
import IconCrosshair from '~icons/lucide/crosshair';
|
||||
import IconUserRound from '~icons/lucide/user-round';
|
||||
|
||||
const router = useRouter();
|
||||
const player = usePlayerStore();
|
||||
const download = useDownload();
|
||||
const drawerActive = computed(() => player.showRoamDrawer && !!player.dominantColor);
|
||||
const showQueuePanel = ref(false);
|
||||
const showDislikeModal = ref(false);
|
||||
const queueListEl = ref<HTMLElement | null>(null);
|
||||
const currentSongVisible = ref(true);
|
||||
const progressBar = ref<HTMLElement | null>(null);
|
||||
@ -212,6 +267,16 @@ onBeforeUnmount(() => {
|
||||
if (unlistenCache) unlistenCache();
|
||||
});
|
||||
|
||||
watch(() => player.currentSong, (song) => {
|
||||
if (!song) {
|
||||
cacheProgress.value = 0;
|
||||
} else if (song.localPath) {
|
||||
cacheProgress.value = 100;
|
||||
} else {
|
||||
cacheProgress.value = 0;
|
||||
}
|
||||
});
|
||||
|
||||
const modeTexts: Record<PlayMode, string> = { loop: '列表循环', shuffle: '随机播放', 'repeat-one': '单曲循环' };
|
||||
const modeTitle = computed(() => modeTexts[player.playMode] || '列表循环');
|
||||
function togglePlayMode() {
|
||||
@ -285,6 +350,26 @@ function playFromQueue(index: number) {
|
||||
player.playCurrent();
|
||||
}
|
||||
|
||||
const dislikeArtistName = computed(() => {
|
||||
const song = player.currentSong;
|
||||
if (!song?.ar?.length) return '';
|
||||
return song.ar.map(a => a.name).join(' / ');
|
||||
});
|
||||
|
||||
async function dislikeSong() {
|
||||
if (!player.currentSong) return;
|
||||
showDislikeModal.value = false;
|
||||
await player.fmTrash(player.currentSong.id);
|
||||
showToast('已减少该歌曲推荐', 'success');
|
||||
}
|
||||
|
||||
async function dislikeArtist() {
|
||||
if (!player.currentSong) return;
|
||||
showDislikeModal.value = false;
|
||||
await player.fmTrash(player.currentSong.id);
|
||||
showToast('已减少该歌手推荐', 'success');
|
||||
}
|
||||
|
||||
function scrollToCurrent() {
|
||||
const el = document.getElementById('queue-item-' + player.currentIndex);
|
||||
if (el) {
|
||||
@ -338,7 +423,8 @@ async function handleVolumeChange(e: Event) {
|
||||
|
||||
const volumeBarBg = computed(() => {
|
||||
const pct = player.volume;
|
||||
return `linear-gradient(to right, var(--c-accent) 0%, var(--c-accent) ${pct}%, var(--c-muted) ${pct}%)`;
|
||||
const track = drawerActive.value ? 'rgba(255,255,255,0.2)' : 'var(--c-muted)';
|
||||
return `linear-gradient(to right, var(--c-accent) 0%, var(--c-accent) ${pct}%, ${track} ${pct}%)`;
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -398,12 +484,4 @@ const volumeBarBg = computed(() => {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
@keyframes eq-bounce-sm {
|
||||
0%, 100% { height: 2px; }
|
||||
50% { height: 10px; }
|
||||
}
|
||||
|
||||
.eq-bar-sm {
|
||||
animation: eq-bounce-sm 0.6s ease-in-out infinite;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
<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>
|
||||
<IconEllipsis class="w-4 h-4 fill-current" />
|
||||
</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>
|
||||
<IconMessageSquare style="font-size: 14px" />
|
||||
评论
|
||||
</button>
|
||||
</div>
|
||||
@ -16,6 +16,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onBeforeUnmount, onMounted } from 'vue';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import IconEllipsis from '~icons/lucide/ellipsis';
|
||||
import IconMessageSquare from '~icons/lucide/message-square';
|
||||
|
||||
const player = usePlayerStore();
|
||||
const props = defineProps<{ songId: number }>();
|
||||
|
||||
@ -1,13 +1,25 @@
|
||||
<template>
|
||||
<div :class="['flex items-center gap-4 p-3 rounded-xl cursor-pointer transition group', containerClass]">
|
||||
<slot name="index" :index="index" :is-current="isCurrent">
|
||||
<span v-if="showIndex" class="text-xs text-content-3 w-6 text-right flex-shrink-0">{{ index + 1 }}</span>
|
||||
<div v-if="showIndex" class="w-6 text-right flex-shrink-0 flex items-center justify-end h-5">
|
||||
<div v-if="isCurrent && showPlayingOverlay" 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>
|
||||
<IconPlay class="hidden group-hover:block text-content" style="font-size: 14px" />
|
||||
</template>
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
<div :class="['rounded-md overflow-hidden flex-shrink-0 relative', coverClass]">
|
||||
<img v-if="coverSrc" :src="coverSrc" class="w-full h-full object-cover" loading="lazy" />
|
||||
<div v-else class="w-full h-full bg-muted flex items-center justify-center">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-content-4"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||||
<IconMusic style="font-size: 14px" class="text-content-4" />
|
||||
</div>
|
||||
<div v-if="isCurrent && showPlayingOverlay"
|
||||
class="absolute inset-0 bg-black/30 flex items-center justify-center">
|
||||
@ -37,13 +49,13 @@
|
||||
|
||||
<slot name="actions">
|
||||
<button v-if="showLike" @click.stop="player.toggleLike(song.id)" class="text-content-3 hover:text-danger transition flex-shrink-0">
|
||||
<svg v-if="player.isLiked(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" class="text-danger"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
|
||||
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
|
||||
<IconHeart v-if="player.isLiked(song.id)" class="w-4 h-4 text-danger [&>path]:fill-current [&>path]:stroke-0" />
|
||||
<IconHeart v-else class="w-4 h-4" />
|
||||
</button>
|
||||
<button v-if="showDownload" @click.stop="download.downloadSong(song)" class="text-content-3 hover:text-accent-text transition flex-shrink-0" :title="download.isDownloaded(song.id) ? '已下载' : '下载'">
|
||||
<svg v-if="download.isDownloading(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="animate-spin"><path d="M21 12a9 9 0 11-6.22-8.56"/></svg>
|
||||
<svg v-else-if="download.isDownloaded(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-accent-text"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
<IconLoader2 v-if="download.isDownloading(song.id)" class="w-4 h-4 animate-spin" />
|
||||
<IconCheck v-else-if="download.isDownloaded(song.id)" class="w-4 h-4 text-accent-text" />
|
||||
<IconDownload v-else class="w-4 h-4" />
|
||||
</button>
|
||||
<SongItemMenu v-if="showMenu" :song-id="song.id" />
|
||||
</slot>
|
||||
@ -60,6 +72,12 @@ import { useDownload } from '../composables/useDownload';
|
||||
import { getCoverUrl, type Song } from '../utils/song';
|
||||
import { formatDuration } from '../utils/format';
|
||||
import SongItemMenu from './SongItemMenu.vue';
|
||||
import IconPlay from '~icons/lucide/play';
|
||||
import IconMusic from '~icons/lucide/music';
|
||||
import IconHeart from '~icons/lucide/heart';
|
||||
import IconLoader2 from '~icons/lucide/loader-2';
|
||||
import IconCheck from '~icons/lucide/check';
|
||||
import IconDownload from '~icons/lucide/download';
|
||||
|
||||
const router = useRouter();
|
||||
const player = usePlayerStore();
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
<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>
|
||||
<IconDownload class="w-5 h-5 text-accent-text" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-content">发现新版本</h2>
|
||||
@ -54,6 +54,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { UpdateInfo } from '../composables/useUpdater'
|
||||
import IconDownload from '~icons/lucide/download'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
|
||||
@ -47,7 +47,7 @@ async function refreshLocalIds() {
|
||||
for (const s of list) {
|
||||
localSongIds.add(s.id);
|
||||
}
|
||||
} catch {}
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
|
||||
function ensureStoreSetup() {
|
||||
|
||||
@ -39,7 +39,7 @@ export function useUpdater() {
|
||||
function setIgnoredVersion(version: string) {
|
||||
try {
|
||||
localStorage.setItem(IGNORED_VERSION_KEY, version)
|
||||
} catch {}
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
|
||||
async function checkForUpdate(silent = false): Promise<UpdateInfo | null> {
|
||||
|
||||
@ -4,29 +4,22 @@ import './style.css';
|
||||
import router from './router';
|
||||
import { createPinia } from 'pinia';
|
||||
|
||||
// ---------- 彻底阻止双指拖动和手势 ----------
|
||||
const preventGesture = (e: Event) => e.preventDefault();
|
||||
|
||||
// 阻止 iOS / macOS 手势缩放和页面拖动
|
||||
document.addEventListener('gesturestart', preventGesture, { passive: false });
|
||||
document.addEventListener('gesturechange', preventGesture, { passive: false });
|
||||
document.addEventListener('gestureend', preventGesture, { passive: false });
|
||||
|
||||
// 阻止触控板双指水平滑动(若仍存在)
|
||||
window.addEventListener('wheel', (e: WheelEvent) => {
|
||||
// 只阻止水平方向,保留垂直滚动(内部容器会处理)
|
||||
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
// 阻止移动端双指触摸移动(不影响单指滚动)
|
||||
window.addEventListener('touchmove', (e: TouchEvent) => {
|
||||
if (e.touches.length >= 2) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}, { passive: false });
|
||||
// -------------------------------------------
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(router);
|
||||
|
||||
@ -13,7 +13,6 @@ const routes = [
|
||||
{ path: '/', name: 'home', component: Home },
|
||||
{ path: '/discover', name: 'discover', component: Discover },
|
||||
{ path: '/search', name: 'search', component: Discover },
|
||||
{ path: '/roam', name: 'roam', component: () => import('@/views/Roam.vue') },
|
||||
{ path: '/favorites', name: 'favorites', component: FavoriteSongs },
|
||||
{ path: '/recent', name: 'recent', component: RecentPlays },
|
||||
{ path: '/daily', name: 'daily', component: DailySongs },
|
||||
@ -37,7 +36,7 @@ router.beforeEach((to) => {
|
||||
try {
|
||||
const data = JSON.parse(raw);
|
||||
if (data?.userId) return { name: 'home' };
|
||||
} catch {}
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
5
src/shims-icons.d.ts
vendored
Normal file
5
src/shims-icons.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
declare module '~icons/lucide/*' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
@ -16,7 +16,7 @@ function loadRecentLocal(): Song[] {
|
||||
try {
|
||||
const raw = localStorage.getItem('recent_local');
|
||||
if (raw) return JSON.parse(raw);
|
||||
} catch {}
|
||||
} catch { /* 忽略 */ }
|
||||
return [];
|
||||
}
|
||||
|
||||
@ -24,7 +24,7 @@ function loadLikedIdsFromStorage(): Set<number> {
|
||||
try {
|
||||
const raw = localStorage.getItem('liked_ids');
|
||||
if (raw) return new Set(JSON.parse(raw));
|
||||
} catch {}
|
||||
} catch { /* 忽略 */ }
|
||||
return new Set();
|
||||
}
|
||||
|
||||
@ -43,7 +43,12 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
watch(volume, (val) => { settings.volume = val; });
|
||||
|
||||
let tickInterval: ReturnType<typeof setInterval> | null = null;
|
||||
function setTickInterval(v: ReturnType<typeof setInterval> | null) { _tickInterval = v; tickInterval = v; }
|
||||
function clearTick() {
|
||||
if (tickInterval) { clearInterval(tickInterval); tickInterval = null; }
|
||||
}
|
||||
function setTick(v: ReturnType<typeof setInterval>) {
|
||||
tickInterval = v;
|
||||
}
|
||||
|
||||
const recentLocal = ref<Song[]>(loadRecentLocal());
|
||||
const MAX_RECENT = 200;
|
||||
@ -114,6 +119,9 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
const fmQueue: Song[] = [];
|
||||
let fmNextCallback: (() => void) | null = null;
|
||||
|
||||
const fmMode = ref<string>('DEFAULT');
|
||||
const fmSubMode = ref<string>('');
|
||||
|
||||
let lastScrobbleId: number | null = null;
|
||||
let lastScrobbleStartTime: number = 0;
|
||||
|
||||
@ -148,10 +156,36 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
isFmMode.value = false;
|
||||
fmNextCallback = null;
|
||||
fmQueue.length = 0;
|
||||
fmMode.value = 'DEFAULT';
|
||||
fmSubMode.value = '';
|
||||
fmSong.value = null;
|
||||
fmPlaying.value = false;
|
||||
}
|
||||
|
||||
function clearFmQueue() {
|
||||
fmQueue.length = 0;
|
||||
}
|
||||
|
||||
async function fmTrash(songId: number) {
|
||||
try {
|
||||
await invoke('fm_trash', { query: { id: songId, time: 25 } });
|
||||
} catch (e) {
|
||||
console.error('fm_trash 失败', e);
|
||||
}
|
||||
await nextFm();
|
||||
}
|
||||
|
||||
async function fetchFmBatch(): Promise<Song[]> {
|
||||
const jsonStr: string = await invoke('personal_fm');
|
||||
const isDefault = fmMode.value === 'DEFAULT' && !fmSubMode.value;
|
||||
const jsonStr: string = isDefault
|
||||
? await invoke('personal_fm')
|
||||
: await invoke('personal_fm_mode', {
|
||||
query: {
|
||||
mode: fmMode.value,
|
||||
subMode: fmSubMode.value,
|
||||
limit: 3,
|
||||
},
|
||||
});
|
||||
const data = JSON.parse(jsonStr);
|
||||
const raw = data.data || data;
|
||||
if (!Array.isArray(raw) || raw.length === 0) return [];
|
||||
@ -162,7 +196,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
const MAX_FM_VIP_SKIP = 10;
|
||||
|
||||
async function playFmSong(song: Song) {
|
||||
if (tickInterval) { clearInterval(tickInterval); setTickInterval(null); }
|
||||
clearTick();
|
||||
reportScrobble();
|
||||
if (!song.dt || song.dt === 0) {
|
||||
try {
|
||||
@ -174,7 +208,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
song.al = full.al || song.al;
|
||||
song.ar = full.ar || song.ar;
|
||||
}
|
||||
} catch (e) { /* 忽略 */ }
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
|
||||
await invoke('stop_audio');
|
||||
@ -283,7 +317,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
}
|
||||
|
||||
async function playCurrent() {
|
||||
if (tickInterval) { clearInterval(tickInterval); setTickInterval(null); }
|
||||
clearTick();
|
||||
reportScrobble();
|
||||
const song = queue.value[currentIndex.value];
|
||||
if (!song?.id) {
|
||||
@ -345,13 +379,13 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
let onSeekStart: (() => void) | null = null;
|
||||
|
||||
function startTick() {
|
||||
if (tickInterval) clearInterval(tickInterval);
|
||||
clearTick();
|
||||
let seekGuard = false;
|
||||
onSeekStart = () => { seekGuard = true; };
|
||||
let syncCounter = 1;
|
||||
let lastSyncPos = -1;
|
||||
let backendFrozen = false;
|
||||
setTickInterval(setInterval(async () => {
|
||||
setTick(setInterval(async () => {
|
||||
if (playing.value && duration.value > 0) {
|
||||
if (seekGuard) return;
|
||||
syncCounter++;
|
||||
@ -371,7 +405,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
backendFrozen = false;
|
||||
lastSyncPos = pos;
|
||||
}
|
||||
} catch {}
|
||||
} catch { /* 忽略 */ }
|
||||
} else {
|
||||
if (!backendFrozen) {
|
||||
const next = currentTime.value + 0.25;
|
||||
@ -403,7 +437,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
playing.value = false;
|
||||
currentSong.value = null;
|
||||
currentTime.value = 0;
|
||||
if (tickInterval) { clearInterval(tickInterval); setTickInterval(null); }
|
||||
clearTick();
|
||||
disableFmMode();
|
||||
emitPlaybackState();
|
||||
}
|
||||
@ -509,6 +543,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
const showRoamDrawer = ref(false);
|
||||
const roamInitialTab = ref<'lyric' | 'comment'>('lyric');
|
||||
const commentSongId = ref<number | null>(null);
|
||||
const dominantColor = ref('');
|
||||
|
||||
function openRoamDrawer(tab: 'lyric' | 'comment' = 'lyric') {
|
||||
roamInitialTab.value = tab;
|
||||
@ -545,8 +580,6 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// -------- FM 专属状态 --------
|
||||
const fmSong = ref<Song | null>(null);
|
||||
const fmPlaying = ref(false);
|
||||
|
||||
@ -592,7 +625,6 @@ async function nextFm() {
|
||||
}
|
||||
|
||||
let _audioStartedResolve: (() => void) | null = null;
|
||||
let _tickInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
listen('audio-started', () => {
|
||||
if (_audioStartedResolve) {
|
||||
@ -602,8 +634,8 @@ listen('audio-started', () => {
|
||||
});
|
||||
|
||||
listen('audio-ended', () => {
|
||||
if (_tickInterval) { clearInterval(_tickInterval); _tickInterval = null; }
|
||||
const player = usePlayerStore();
|
||||
player.clearTick();
|
||||
player.reportScrobble();
|
||||
player.next();
|
||||
});
|
||||
@ -703,16 +735,23 @@ watch(playing, (val) => {
|
||||
showRoamDrawer,
|
||||
roamInitialTab,
|
||||
commentSongId,
|
||||
dominantColor,
|
||||
openCommentForSong,
|
||||
openRoamDrawer,
|
||||
closeRoamDrawer,
|
||||
toggleRoamDrawer,
|
||||
loadFirstFmSong,
|
||||
|
||||
fetchFmBatch,
|
||||
clearFmQueue,
|
||||
fmTrash,
|
||||
reportScrobble,
|
||||
clearTick,
|
||||
|
||||
fmSong,
|
||||
fmPlaying,
|
||||
fmMode,
|
||||
fmSubMode,
|
||||
loadFm,
|
||||
toggleFm,
|
||||
nextFm,
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, watch } from 'vue';
|
||||
import { ref, watch, computed } from 'vue';
|
||||
|
||||
export type AudioQuality = 'standard' | 'higher' | 'exhigh' | 'lossless' | 'hires';
|
||||
export type ThemeName = 'blue' | 'green' | 'rose' | 'violet' | 'orange' | 'cyan' | 'pink';
|
||||
export type ThemeColor = 'blue' | 'green' | 'rose' | 'violet' | 'orange' | 'cyan' | 'pink';
|
||||
export type Appearance = 'dark' | 'light';
|
||||
export type CloseAction = 'ask' | 'minimize' | 'exit';
|
||||
|
||||
export const themeLabels: Record<ThemeName, string> = {
|
||||
export const themeLabels: Record<ThemeColor, string> = {
|
||||
blue: '天蓝',
|
||||
green: '翠绿',
|
||||
rose: '玫红',
|
||||
@ -15,7 +16,7 @@ export const themeLabels: Record<ThemeName, string> = {
|
||||
pink: '粉色',
|
||||
};
|
||||
|
||||
export const themeColors: Record<ThemeName, string> = {
|
||||
export const themeColors: Record<ThemeColor, string> = {
|
||||
blue: '#3b82f6',
|
||||
green: '#22c55e',
|
||||
rose: '#f43f5e',
|
||||
@ -25,6 +26,11 @@ export const themeColors: Record<ThemeName, string> = {
|
||||
pink: '#ec4899',
|
||||
};
|
||||
|
||||
export const appearanceLabels: Record<Appearance, string> = {
|
||||
dark: '深色',
|
||||
light: '浅色',
|
||||
};
|
||||
|
||||
export const qualityLabels: Record<AudioQuality, string> = {
|
||||
standard: '标准',
|
||||
higher: '较高',
|
||||
@ -60,7 +66,8 @@ export const defaultShortcuts: Record<string, ShortcutBinding> = {
|
||||
interface SettingsData {
|
||||
audioQuality: AudioQuality;
|
||||
downloadPath: string;
|
||||
theme: ThemeName;
|
||||
theme: ThemeColor;
|
||||
appearance: Appearance;
|
||||
closeAction: CloseAction;
|
||||
shortcuts: Record<string, ShortcutBinding>;
|
||||
outputDevice: string | null;
|
||||
@ -73,22 +80,38 @@ function loadSettings(): SettingsData {
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
const theme = parsed.theme || parsed.accentColor || 'blue';
|
||||
const validThemes: ThemeName[] = ['blue', 'green', 'rose', 'violet', 'orange', 'cyan', 'pink'];
|
||||
const validThemes: ThemeColor[] = ['blue', 'green', 'rose', 'violet', 'orange', 'cyan', 'pink'];
|
||||
const validAppearances: Appearance[] = ['dark', 'light'];
|
||||
const appearance = validAppearances.includes(parsed.appearance) ? parsed.appearance : 'dark';
|
||||
if (parsed.theme && parsed.theme.startsWith('light-')) {
|
||||
return {
|
||||
audioQuality: parsed.audioQuality || 'standard',
|
||||
downloadPath: parsed.downloadPath || '',
|
||||
theme: validThemes.includes(parsed.theme.slice(6)) ? parsed.theme.slice(6) : 'blue',
|
||||
appearance: 'light',
|
||||
closeAction: parsed.closeAction || 'ask',
|
||||
shortcuts: { ...defaultShortcuts, ...(parsed.shortcuts || {}) },
|
||||
outputDevice: parsed.outputDevice || null,
|
||||
volume: typeof parsed.volume === 'number' ? parsed.volume : 100,
|
||||
};
|
||||
}
|
||||
return {
|
||||
audioQuality: parsed.audioQuality || 'standard',
|
||||
downloadPath: parsed.downloadPath || '',
|
||||
theme: validThemes.includes(theme) ? theme : 'blue',
|
||||
appearance,
|
||||
closeAction: parsed.closeAction || 'ask',
|
||||
shortcuts: { ...defaultShortcuts, ...(parsed.shortcuts || {}) },
|
||||
outputDevice: parsed.outputDevice || null,
|
||||
volume: typeof parsed.volume === 'number' ? parsed.volume : 100,
|
||||
};
|
||||
}
|
||||
} catch {}
|
||||
} catch { /* 忽略 */ }
|
||||
return {
|
||||
audioQuality: 'standard',
|
||||
downloadPath: '',
|
||||
theme: 'blue',
|
||||
appearance: 'dark',
|
||||
closeAction: 'ask',
|
||||
shortcuts: { ...defaultShortcuts },
|
||||
outputDevice: null,
|
||||
@ -101,12 +124,17 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
|
||||
const audioQuality = ref<AudioQuality>(saved.audioQuality);
|
||||
const downloadPath = ref<string>(saved.downloadPath);
|
||||
const theme = ref<ThemeName>(saved.theme);
|
||||
const theme = ref<ThemeColor>(saved.theme);
|
||||
const appearance = ref<Appearance>(saved.appearance);
|
||||
const closeAction = ref<CloseAction>(saved.closeAction || 'ask');
|
||||
const shortcuts = ref<Record<string, ShortcutBinding>>(saved.shortcuts);
|
||||
const outputDevice = ref<string | null>(saved.outputDevice);
|
||||
const volume = ref<number>(saved.volume);
|
||||
|
||||
const dataTheme = computed(() =>
|
||||
appearance.value === 'light' ? `light-${theme.value}` : theme.value
|
||||
);
|
||||
|
||||
function setAudioQuality(q: AudioQuality) {
|
||||
audioQuality.value = q;
|
||||
}
|
||||
@ -115,10 +143,14 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
downloadPath.value = p;
|
||||
}
|
||||
|
||||
function setTheme(t: ThemeName) {
|
||||
function setTheme(t: ThemeColor) {
|
||||
theme.value = t;
|
||||
}
|
||||
|
||||
function setAppearance(a: Appearance) {
|
||||
appearance.value = a;
|
||||
}
|
||||
|
||||
function setCloseAction(a: CloseAction) {
|
||||
closeAction.value = a;
|
||||
}
|
||||
@ -139,17 +171,19 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
audioQuality.value = 'standard';
|
||||
downloadPath.value = '';
|
||||
theme.value = 'blue';
|
||||
appearance.value = 'dark';
|
||||
closeAction.value = 'ask';
|
||||
shortcuts.value = { ...defaultShortcuts };
|
||||
outputDevice.value = null;
|
||||
volume.value = 100;
|
||||
}
|
||||
|
||||
watch([audioQuality, downloadPath, theme, closeAction, shortcuts, outputDevice, volume], () => {
|
||||
watch([audioQuality, downloadPath, theme, appearance, closeAction, shortcuts, outputDevice, volume], () => {
|
||||
const data: SettingsData = {
|
||||
audioQuality: audioQuality.value,
|
||||
downloadPath: downloadPath.value,
|
||||
theme: theme.value,
|
||||
appearance: appearance.value,
|
||||
closeAction: closeAction.value,
|
||||
shortcuts: shortcuts.value,
|
||||
outputDevice: outputDevice.value,
|
||||
@ -162,6 +196,8 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
audioQuality,
|
||||
downloadPath,
|
||||
theme,
|
||||
appearance,
|
||||
dataTheme,
|
||||
closeAction,
|
||||
shortcuts,
|
||||
outputDevice,
|
||||
@ -169,6 +205,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
setAudioQuality,
|
||||
setDownloadPath,
|
||||
setTheme,
|
||||
setAppearance,
|
||||
setCloseAction,
|
||||
setOutputDevice,
|
||||
setShortcut,
|
||||
|
||||
@ -21,7 +21,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try { await invoke('logout'); } catch {}
|
||||
try { await invoke('logout'); } catch { /* 忽略 */ }
|
||||
user.value = null;
|
||||
isLoggedIn.value = false;
|
||||
localStorage.removeItem('user_profile');
|
||||
|
||||
154
src/style.css
154
src/style.css
@ -178,6 +178,160 @@
|
||||
--c-info: #3b82f6;
|
||||
}
|
||||
|
||||
[data-theme="light-green"] {
|
||||
--c-bg: #f8faf9;
|
||||
--c-surface: #ffffff;
|
||||
--c-subtle: rgba(34, 197, 94, 0.06);
|
||||
--c-muted: rgba(34, 197, 94, 0.10);
|
||||
--c-emphasis: rgba(34, 197, 94, 0.16);
|
||||
--c-content: #111827;
|
||||
--c-content-2: #4b5563;
|
||||
--c-content-3: #6b7280;
|
||||
--c-content-4: #9ca3af;
|
||||
--c-line: rgba(0, 0, 0, 0.08);
|
||||
--c-line-2: rgba(0, 0, 0, 0.04);
|
||||
--c-accent: #16a34a;
|
||||
--c-accent-hover: #15803d;
|
||||
--c-accent-text: #15803d;
|
||||
--c-accent-dim: rgba(34, 197, 94, 0.15);
|
||||
--c-danger: #dc2626;
|
||||
--c-danger-dim: rgba(220, 38, 38, 0.12);
|
||||
--c-warning: #ca8a04;
|
||||
--c-info: #2563eb;
|
||||
}
|
||||
|
||||
[data-theme="light-rose"] {
|
||||
--c-bg: #faf8f9;
|
||||
--c-surface: #ffffff;
|
||||
--c-subtle: rgba(244, 63, 94, 0.06);
|
||||
--c-muted: rgba(244, 63, 94, 0.10);
|
||||
--c-emphasis: rgba(244, 63, 94, 0.16);
|
||||
--c-content: #111827;
|
||||
--c-content-2: #4b5563;
|
||||
--c-content-3: #6b7280;
|
||||
--c-content-4: #9ca3af;
|
||||
--c-line: rgba(0, 0, 0, 0.08);
|
||||
--c-line-2: rgba(0, 0, 0, 0.04);
|
||||
--c-accent: #e11d48;
|
||||
--c-accent-hover: #be123c;
|
||||
--c-accent-text: #be123c;
|
||||
--c-accent-dim: rgba(244, 63, 94, 0.15);
|
||||
--c-danger: #dc2626;
|
||||
--c-danger-dim: rgba(220, 38, 38, 0.12);
|
||||
--c-warning: #ca8a04;
|
||||
--c-info: #2563eb;
|
||||
}
|
||||
|
||||
[data-theme="light-blue"] {
|
||||
--c-bg: #f8f9fb;
|
||||
--c-surface: #ffffff;
|
||||
--c-subtle: rgba(59, 130, 246, 0.06);
|
||||
--c-muted: rgba(59, 130, 246, 0.10);
|
||||
--c-emphasis: rgba(59, 130, 246, 0.16);
|
||||
--c-content: #111827;
|
||||
--c-content-2: #4b5563;
|
||||
--c-content-3: #6b7280;
|
||||
--c-content-4: #9ca3af;
|
||||
--c-line: rgba(0, 0, 0, 0.08);
|
||||
--c-line-2: rgba(0, 0, 0, 0.04);
|
||||
--c-accent: #2563eb;
|
||||
--c-accent-hover: #1d4ed8;
|
||||
--c-accent-text: #1d4ed8;
|
||||
--c-accent-dim: rgba(59, 130, 246, 0.15);
|
||||
--c-danger: #dc2626;
|
||||
--c-danger-dim: rgba(220, 38, 38, 0.12);
|
||||
--c-warning: #ca8a04;
|
||||
--c-info: #7c3aed;
|
||||
}
|
||||
|
||||
[data-theme="light-violet"] {
|
||||
--c-bg: #f9f8fb;
|
||||
--c-surface: #ffffff;
|
||||
--c-subtle: rgba(139, 92, 246, 0.06);
|
||||
--c-muted: rgba(139, 92, 246, 0.10);
|
||||
--c-emphasis: rgba(139, 92, 246, 0.16);
|
||||
--c-content: #111827;
|
||||
--c-content-2: #4b5563;
|
||||
--c-content-3: #6b7280;
|
||||
--c-content-4: #9ca3af;
|
||||
--c-line: rgba(0, 0, 0, 0.08);
|
||||
--c-line-2: rgba(0, 0, 0, 0.04);
|
||||
--c-accent: #7c3aed;
|
||||
--c-accent-hover: #6d28d9;
|
||||
--c-accent-text: #6d28d9;
|
||||
--c-accent-dim: rgba(139, 92, 246, 0.15);
|
||||
--c-danger: #dc2626;
|
||||
--c-danger-dim: rgba(220, 38, 38, 0.12);
|
||||
--c-warning: #ca8a04;
|
||||
--c-info: #2563eb;
|
||||
}
|
||||
|
||||
[data-theme="light-orange"] {
|
||||
--c-bg: #faf9f8;
|
||||
--c-surface: #ffffff;
|
||||
--c-subtle: rgba(249, 115, 22, 0.06);
|
||||
--c-muted: rgba(249, 115, 22, 0.10);
|
||||
--c-emphasis: rgba(249, 115, 22, 0.16);
|
||||
--c-content: #111827;
|
||||
--c-content-2: #4b5563;
|
||||
--c-content-3: #6b7280;
|
||||
--c-content-4: #9ca3af;
|
||||
--c-line: rgba(0, 0, 0, 0.08);
|
||||
--c-line-2: rgba(0, 0, 0, 0.04);
|
||||
--c-accent: #ea580c;
|
||||
--c-accent-hover: #c2410c;
|
||||
--c-accent-text: #c2410c;
|
||||
--c-accent-dim: rgba(249, 115, 22, 0.15);
|
||||
--c-danger: #dc2626;
|
||||
--c-danger-dim: rgba(220, 38, 38, 0.12);
|
||||
--c-warning: #ca8a04;
|
||||
--c-info: #2563eb;
|
||||
}
|
||||
|
||||
[data-theme="light-cyan"] {
|
||||
--c-bg: #f8fbfb;
|
||||
--c-surface: #ffffff;
|
||||
--c-subtle: rgba(6, 182, 212, 0.06);
|
||||
--c-muted: rgba(6, 182, 212, 0.10);
|
||||
--c-emphasis: rgba(6, 182, 212, 0.16);
|
||||
--c-content: #111827;
|
||||
--c-content-2: #4b5563;
|
||||
--c-content-3: #6b7280;
|
||||
--c-content-4: #9ca3af;
|
||||
--c-line: rgba(0, 0, 0, 0.08);
|
||||
--c-line-2: rgba(0, 0, 0, 0.04);
|
||||
--c-accent: #0891b2;
|
||||
--c-accent-hover: #0e7490;
|
||||
--c-accent-text: #0e7490;
|
||||
--c-accent-dim: rgba(6, 182, 212, 0.15);
|
||||
--c-danger: #dc2626;
|
||||
--c-danger-dim: rgba(220, 38, 38, 0.12);
|
||||
--c-warning: #ca8a04;
|
||||
--c-info: #2563eb;
|
||||
}
|
||||
|
||||
[data-theme="light-pink"] {
|
||||
--c-bg: #faf8f9;
|
||||
--c-surface: #ffffff;
|
||||
--c-subtle: rgba(236, 72, 153, 0.06);
|
||||
--c-muted: rgba(236, 72, 153, 0.10);
|
||||
--c-emphasis: rgba(236, 72, 153, 0.16);
|
||||
--c-content: #111827;
|
||||
--c-content-2: #4b5563;
|
||||
--c-content-3: #6b7280;
|
||||
--c-content-4: #9ca3af;
|
||||
--c-line: rgba(0, 0, 0, 0.08);
|
||||
--c-line-2: rgba(0, 0, 0, 0.04);
|
||||
--c-accent: #db2777;
|
||||
--c-accent-hover: #be185d;
|
||||
--c-accent-text: #be185d;
|
||||
--c-accent-dim: rgba(236, 72, 153, 0.15);
|
||||
--c-danger: #dc2626;
|
||||
--c-danger-dim: rgba(220, 38, 38, 0.12);
|
||||
--c-warning: #ca8a04;
|
||||
--c-info: #2563eb;
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--c-bg);
|
||||
overflow: hidden;
|
||||
|
||||
@ -33,3 +33,47 @@ export function getCoverUrl(song: Song | null, sizeParam = ''): string {
|
||||
if (!sizeParam || raw.startsWith('data:')) return raw;
|
||||
return raw + sizeParam;
|
||||
}
|
||||
|
||||
const colorCache = new Map<string, string>();
|
||||
|
||||
export function extractDominantColor(imageUrl: string): Promise<string> {
|
||||
if (colorCache.has(imageUrl)) {
|
||||
return Promise.resolve(colorCache.get(imageUrl)!);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
const size = 8;
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) { resolve(''); return; }
|
||||
ctx.drawImage(img, 0, 0, size, size);
|
||||
const data = ctx.getImageData(0, 0, size, size).data;
|
||||
|
||||
let r = 0, g = 0, b = 0, count = 0;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
r += data[i];
|
||||
g += data[i + 1];
|
||||
b += data[i + 2];
|
||||
count++;
|
||||
}
|
||||
r = Math.round(r / count);
|
||||
g = Math.round(g / count);
|
||||
b = Math.round(b / count);
|
||||
|
||||
const color = `rgb(${r}, ${g}, ${b})`;
|
||||
colorCache.set(imageUrl, color);
|
||||
resolve(color);
|
||||
} catch {
|
||||
resolve('');
|
||||
}
|
||||
};
|
||||
img.onerror = () => resolve('');
|
||||
img.src = imageUrl;
|
||||
});
|
||||
}
|
||||
|
||||
@ -9,11 +9,11 @@
|
||||
<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">
|
||||
<div v-if="album.artists?.length" class="flex flex-wrap items-center gap-x-1 gap-y-0.5 mt-2 text-sm text-content-2">
|
||||
<template v-for="(ar, idx) in album.artists" :key="ar.id">
|
||||
<span v-if="(idx as number) > 0" class="text-content-3">/</span>
|
||||
<span
|
||||
class="hover:text-accent-text cursor-pointer transition"
|
||||
class="hover:text-accent-text cursor-pointer transition whitespace-nowrap"
|
||||
@click="ar.id && router.push({ name: 'artist', params: { id: ar.id } })"
|
||||
>{{ ar.name }}</span>
|
||||
</template>
|
||||
@ -27,7 +27,7 @@
|
||||
@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>
|
||||
<IconPlay class="w-4 h-4 fill-current" />
|
||||
播放全部
|
||||
</button>
|
||||
</div>
|
||||
@ -51,23 +51,7 @@
|
||||
show-playing-overlay
|
||||
:container-class="player.currentSong?.id === song.id ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
|
||||
@click="player.playFromList(songs, index)"
|
||||
>
|
||||
<template #index="{ index: idx, isCurrent }">
|
||||
<div class="w-6 text-right flex-shrink-0 flex items-center justify-end h-5">
|
||||
<div v-if="isCurrent" class="flex items-center justify-end">
|
||||
<div class="flex items-center gap-[3px] h-4">
|
||||
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 50%; animation-delay: 0ms"></span>
|
||||
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 100%; animation-delay: 150ms"></span>
|
||||
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 35%; animation-delay: 300ms"></span>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<span class="text-xs text-content-3 group-hover:hidden">{{ idx + 1 }}</span>
|
||||
<svg class="hidden group-hover:block text-content" width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4 2.5v11l9-5.5z"/></svg>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</SongListItem>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -80,6 +64,7 @@ import { usePlayerStore } from '../stores/player';
|
||||
import { normalizeSong, type Song } from '../utils/song';
|
||||
import { formatDate } from '../utils/format';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
import IconPlay from '~icons/lucide/play';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
@ -18,7 +18,7 @@
|
||||
@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>
|
||||
<IconPlay class="w-4 h-4 fill-current" />
|
||||
播放全部
|
||||
</button>
|
||||
</div>
|
||||
@ -55,23 +55,7 @@
|
||||
show-playing-overlay
|
||||
:container-class="player.currentSong?.id === song.id ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
|
||||
@click="player.playFromList(songs, index)"
|
||||
>
|
||||
<template #index="{ index: idx, isCurrent }">
|
||||
<div class="w-6 text-right flex-shrink-0 flex items-center justify-end h-5">
|
||||
<div v-if="isCurrent" class="flex items-center justify-end">
|
||||
<div class="flex items-center gap-[3px] h-4">
|
||||
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 50%; animation-delay: 0ms"></span>
|
||||
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 100%; animation-delay: 150ms"></span>
|
||||
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 35%; animation-delay: 300ms"></span>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<span class="text-xs text-content-3 group-hover:hidden">{{ idx + 1 }}</span>
|
||||
<svg class="hidden group-hover:block text-content" width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4 2.5v11l9-5.5z"/></svg>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</SongListItem>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'albums'" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
@ -104,6 +88,7 @@ import { usePlayerStore } from '../stores/player';
|
||||
import { formatPlayCount, formatDate } from '../utils/format';
|
||||
import { normalizeSong, type Song } from '../utils/song';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
import IconPlay from '~icons/lucide/play';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
@ -29,23 +29,7 @@
|
||||
show-playing-overlay
|
||||
:container-class="isCurrentSong(song.id) ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
|
||||
@click="player.playFromList(songs, index)"
|
||||
>
|
||||
<template #index="{ index: idx, isCurrent }">
|
||||
<div class="w-6 text-right flex-shrink-0 flex items-center justify-end h-5">
|
||||
<div v-if="isCurrent" class="flex items-center justify-end">
|
||||
<div class="flex items-center gap-[3px] h-4">
|
||||
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 50%; animation-delay: 0ms"></span>
|
||||
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 100%; animation-delay: 150ms"></span>
|
||||
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 35%; animation-delay: 300ms"></span>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<span class="text-xs text-content-3 group-hover:hidden">{{ idx + 1 }}</span>
|
||||
<svg class="hidden group-hover:block text-content" width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4 2.5v11l9-5.5z"/></svg>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</SongListItem>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -1,13 +1,55 @@
|
||||
<template>
|
||||
<div class="p-8 text-content">
|
||||
<h1 class="text-2xl font-bold mb-4">发现音乐</h1>
|
||||
<div class="p-8 text-content" @click="showSuggestions = false">
|
||||
<div class="relative mb-6" @click.stop>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative flex-1">
|
||||
<IconSearch class="absolute left-3.5 top-1/2 -translate-y-1/2 text-content-3 w-[18px] h-[18px]" />
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="keyword"
|
||||
@input="onInputChange"
|
||||
@keydown.enter="handleSearch"
|
||||
@focus="onInputFocus"
|
||||
placeholder="搜索歌曲、歌手、专辑..."
|
||||
class="w-full rounded-xl bg-muted pl-10 pr-10 py-3 text-content placeholder-content-3 outline-none focus:bg-subtle focus:ring-1 focus:ring-accent/30 transition"
|
||||
/>
|
||||
<button v-if="keyword" @click="clearSearch" class="absolute right-3 top-1/2 -translate-y-1/2 text-content-3 hover:text-content transition">
|
||||
<IconX class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
v-model="keyword"
|
||||
@keyup.enter="handleSearch"
|
||||
placeholder="搜索歌曲、歌手、专辑..."
|
||||
class="mb-4 w-full rounded-xl bg-muted p-3 text-content placeholder-content-2 outline-none backdrop-blur"
|
||||
/>
|
||||
<div v-if="showSuggestions && !hasSearched"
|
||||
class="absolute z-30 left-0 right-0 top-full mt-2 bg-surface border border-line-2 rounded-xl shadow-xl overflow-hidden max-h-[60vh] overflow-y-auto">
|
||||
<div v-if="suggestions.length" class="p-2">
|
||||
<p class="text-xs text-content-3 px-3 py-1.5">搜索建议</p>
|
||||
<button v-for="s in suggestions" :key="s" @click="searchTag(s)"
|
||||
class="w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-muted transition flex items-center gap-2">
|
||||
<IconSearch style="font-size: 14px" class="text-content-3 flex-shrink-0" />
|
||||
<span>{{ s }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="searchHistory.length && !suggestions.length" class="p-2">
|
||||
<div class="flex items-center justify-between px-3 py-1.5">
|
||||
<p class="text-xs text-content-3">搜索历史</p>
|
||||
<button @click.stop="clearHistory" class="text-xs text-content-3 hover:text-danger transition">清空</button>
|
||||
</div>
|
||||
<button v-for="h in searchHistory" :key="h" @click="searchTag(h)"
|
||||
class="w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-muted transition flex items-center gap-2">
|
||||
<IconHistory style="font-size: 14px" class="text-content-3 flex-shrink-0" />
|
||||
<span>{{ h }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="hotTags.length && !suggestions.length && !searchHistory.length" class="p-2">
|
||||
<p class="text-xs text-content-3 px-3 py-1.5">热门搜索</p>
|
||||
<button v-for="tag in hotTags" :key="tag.searchWord" @click="searchTag(tag.searchWord)"
|
||||
class="w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-muted transition flex items-center gap-2">
|
||||
<IconClock style="font-size: 14px" class="text-content-3 flex-shrink-0" />
|
||||
<span>{{ tag.searchWord }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!hasSearched && !loading && hotTags.length" class="mb-6">
|
||||
<h2 class="text-sm font-semibold mb-3">🔥 热门搜索</h2>
|
||||
@ -16,27 +58,84 @@
|
||||
v-for="tag in hotTags"
|
||||
:key="tag.searchWord"
|
||||
@click="searchTag(tag.searchWord)"
|
||||
class="px-3 py-1 rounded-full bg-muted hover:bg-emphasis cursor-pointer transition text-sm"
|
||||
class="px-3 py-1.5 rounded-full bg-muted hover:bg-emphasis cursor-pointer transition text-sm"
|
||||
>
|
||||
{{ tag.searchWord }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-content-2">搜索中...</div>
|
||||
<div v-else class="space-y-3">
|
||||
<SongListItem
|
||||
v-for="(song, index) in results"
|
||||
:key="song.id"
|
||||
:song="song"
|
||||
:index="index"
|
||||
show-download
|
||||
show-menu
|
||||
cover-size="w-12 h-12"
|
||||
container-class="backdrop-blur-md bg-subtle hover:bg-muted border border-line-2"
|
||||
@click="player.playFromList(results, index)"
|
||||
/>
|
||||
<p v-if="!loading && hasSearched && results.length === 0" class="text-content-2">无结果</p>
|
||||
<div v-if="hasSearched">
|
||||
<div class="flex items-center gap-1 mb-4 bg-muted rounded-lg p-1 w-fit">
|
||||
<button v-for="tab in tabs" :key="tab.type" @click="switchTab(tab.type)"
|
||||
:class="['px-4 py-1.5 rounded-md text-sm font-medium transition', activeTab === tab.type ? 'bg-surface text-content shadow-sm' : 'text-content-2 hover:text-content']">
|
||||
{{ tab.label }}
|
||||
<span v-if="resultCache.has(tab.type) && resultCache.get(tab.type)!.count > 0" class="text-xs text-content-3 ml-1">{{ resultCache.get(tab.type)!.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<div class="flex items-end gap-1 h-6">
|
||||
<span class="eq-bar w-[3px] bg-accent rounded-full" style="animation-delay: 0s"></span>
|
||||
<span class="eq-bar w-[3px] bg-accent rounded-full" style="animation-delay: 0.12s"></span>
|
||||
<span class="eq-bar w-[3px] bg-accent rounded-full" style="animation-delay: 0.24s"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="activeTab === 1">
|
||||
<div v-if="currentResults.length" class="space-y-2">
|
||||
<SongListItem
|
||||
v-for="(song, index) in currentResults"
|
||||
:key="song.id"
|
||||
:song="song"
|
||||
:index="index"
|
||||
show-download
|
||||
show-menu
|
||||
cover-size="w-12 h-12"
|
||||
container-class="bg-subtle hover:bg-muted border border-line-2"
|
||||
@click="player.playFromList(currentResults, index)"
|
||||
/>
|
||||
</div>
|
||||
<p v-else class="text-content-2 text-center py-8">{{ cacheError ? '搜索失败,点击其他标签页刷新重试' : '未找到相关歌曲' }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeTab === 100">
|
||||
<div v-if="currentResults.length" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<div v-for="artist in currentResults" :key="artist.id" @click="router.push({ name: 'artist', params: { id: artist.id } })"
|
||||
class="bg-subtle hover:bg-muted border border-line-2 rounded-xl p-4 cursor-pointer transition flex items-center gap-3">
|
||||
<img v-if="artist.picUrl" :src="artist.picUrl + '?param=100y100'" class="w-14 h-14 rounded-full object-cover flex-shrink-0" />
|
||||
<div v-else class="w-14 h-14 rounded-full bg-muted flex items-center justify-center flex-shrink-0">
|
||||
<IconUserRound class="w-5 h-5 text-content-3" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium truncate">{{ artist.name }}</p>
|
||||
<p v-if="artist.alias?.length" class="text-xs text-content-3 truncate">{{ artist.alias[0] }}</p>
|
||||
<p v-if="artist.musicSize" class="text-xs text-content-3">{{ artist.musicSize }} 首歌曲</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-content-2 text-center py-8">{{ cacheError ? '搜索失败,点击其他标签页刷新重试' : '未找到相关歌手' }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeTab === 10">
|
||||
<div v-if="currentResults.length" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
<div v-for="album in currentResults" :key="album.id" @click="router.push({ name: 'album', params: { id: album.id } })"
|
||||
class="bg-subtle hover:bg-muted border border-line-2 rounded-xl overflow-hidden cursor-pointer transition">
|
||||
<img v-if="album.picUrl" :src="album.picUrl + '?param=200y200'" class="w-full aspect-square object-cover" />
|
||||
<div v-else class="w-full aspect-square bg-muted flex items-center justify-center">
|
||||
<IconDisc class="w-8 h-8 text-content-3" />
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<p class="text-sm font-medium truncate">{{ album.name }}</p>
|
||||
<p class="text-xs text-content-2 truncate mt-0.5">{{ album.artist?.name || '' }}</p>
|
||||
<p v-if="album.publishTime" class="text-xs text-content-3 mt-0.5">{{ formatDate(album.publishTime) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-content-2 text-center py-8">{{ cacheError ? '搜索失败,点击其他标签页刷新重试' : '未找到相关专辑' }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -44,25 +143,110 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'DiscoverView' });
|
||||
|
||||
import { ref, onMounted, onActivated, watch } from 'vue';
|
||||
import { ref, computed, onMounted, onActivated, watch, nextTick } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
import { normalizeSong, type Song } from '../utils/song';
|
||||
import { formatDate } from '../utils/format';
|
||||
import { pageCacheGet, pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
|
||||
import { useOnlineStatus } from '../composables/useOnlineStatus';
|
||||
import IconSearch from '~icons/lucide/search';
|
||||
import IconX from '~icons/lucide/x';
|
||||
import IconHistory from '~icons/lucide/history';
|
||||
import IconClock from '~icons/lucide/clock';
|
||||
import IconUserRound from '~icons/lucide/user-round';
|
||||
import IconDisc from '~icons/lucide/disc';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const player = usePlayerStore();
|
||||
const { isOnline } = useOnlineStatus();
|
||||
|
||||
const searchInput = ref<HTMLInputElement | null>(null);
|
||||
const keyword = ref('');
|
||||
const results = ref<Song[]>([]);
|
||||
const loading = ref(false);
|
||||
const hasSearched = ref(false);
|
||||
const hotTags = ref<any[]>([]);
|
||||
const suggestions = ref<string[]>([]);
|
||||
const showSuggestions = ref(false);
|
||||
const activeTab = ref(1);
|
||||
const cacheError = ref(false);
|
||||
|
||||
interface CacheEntry {
|
||||
data: Song[] | any[];
|
||||
count: number;
|
||||
dirty: boolean;
|
||||
}
|
||||
|
||||
const resultCache = ref<Map<number, CacheEntry>>(new Map());
|
||||
const lastSearchKeyword = ref('');
|
||||
|
||||
const currentResults = computed(() => {
|
||||
const entry = resultCache.value.get(activeTab.value);
|
||||
return entry ? entry.data : [];
|
||||
});
|
||||
|
||||
const tabs = [
|
||||
{ type: 1, label: '歌曲' },
|
||||
{ type: 100, label: '歌手' },
|
||||
{ type: 10, label: '专辑' },
|
||||
];
|
||||
|
||||
const HISTORY_KEY = 'search_history';
|
||||
const MAX_HISTORY = 15;
|
||||
|
||||
function loadSearchHistory(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(HISTORY_KEY);
|
||||
if (raw) return JSON.parse(raw);
|
||||
} catch { /* 忽略 */ }
|
||||
return [];
|
||||
}
|
||||
|
||||
function saveSearchHistory(q: string) {
|
||||
let history = loadSearchHistory();
|
||||
history = history.filter(h => h !== q);
|
||||
history.unshift(q);
|
||||
if (history.length > MAX_HISTORY) history = history.slice(0, MAX_HISTORY);
|
||||
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
|
||||
}
|
||||
|
||||
const searchHistory = ref<string[]>(loadSearchHistory());
|
||||
|
||||
function clearHistory() {
|
||||
searchHistory.value = [];
|
||||
localStorage.removeItem(HISTORY_KEY);
|
||||
}
|
||||
|
||||
let suggestTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function onInputChange() {
|
||||
if (suggestTimer) clearTimeout(suggestTimer);
|
||||
if (!keyword.value.trim()) {
|
||||
suggestions.value = [];
|
||||
showSuggestions.value = true;
|
||||
return;
|
||||
}
|
||||
suggestTimer = setTimeout(async () => {
|
||||
try {
|
||||
const jsonStr: string = await invoke('search_suggest', { query: { keyword: keyword.value.trim() } });
|
||||
const data = JSON.parse(jsonStr);
|
||||
const all = data.result?.allMatch || [];
|
||||
suggestions.value = all.map((m: any) => m.keyword).slice(0, 8);
|
||||
showSuggestions.value = true;
|
||||
} catch {
|
||||
suggestions.value = [];
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function onInputFocus() {
|
||||
if (!hasSearched.value) {
|
||||
showSuggestions.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHotTags() {
|
||||
const cached = pageCacheGet('discover_hotTags');
|
||||
@ -74,13 +258,12 @@ async function loadHotTags() {
|
||||
const data = JSON.parse(json as string);
|
||||
hotTags.value = (data.data || []).slice(0, 12);
|
||||
pageCacheSet('discover_hotTags', hotTags.value);
|
||||
} catch {}
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadHotTags();
|
||||
|
||||
const q = route.query.q as string;
|
||||
if (q) {
|
||||
keyword.value = q;
|
||||
@ -89,8 +272,14 @@ onMounted(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
onActivated(async () => {
|
||||
if (pageCacheIsStale('discover_hotTags')) loadHotTags();
|
||||
const q = route.query.q as string;
|
||||
if (q && q !== lastSearchKeyword.value) {
|
||||
keyword.value = q;
|
||||
await handleSearch();
|
||||
router.replace({ query: {} });
|
||||
}
|
||||
});
|
||||
|
||||
watch(isOnline, (val, old) => {
|
||||
@ -101,22 +290,81 @@ watch(isOnline, (val, old) => {
|
||||
});
|
||||
|
||||
async function handleSearch() {
|
||||
if (!keyword.value.trim()) return;
|
||||
loading.value = true;
|
||||
const q = keyword.value.trim();
|
||||
if (!q) return;
|
||||
showSuggestions.value = false;
|
||||
hasSearched.value = true;
|
||||
cacheError.value = false;
|
||||
saveSearchHistory(q);
|
||||
searchHistory.value = loadSearchHistory();
|
||||
|
||||
if (q === lastSearchKeyword.value && resultCache.value.size > 0) return;
|
||||
|
||||
lastSearchKeyword.value = q;
|
||||
resultCache.value.clear();
|
||||
|
||||
await Promise.all([
|
||||
fetchTabResults(1),
|
||||
fetchTabResults(100),
|
||||
fetchTabResults(10),
|
||||
]);
|
||||
}
|
||||
|
||||
async function fetchTabResults(type: number) {
|
||||
const entry = resultCache.value.get(type);
|
||||
if (entry && !entry.dirty) return;
|
||||
|
||||
loading.value = true;
|
||||
cacheError.value = false;
|
||||
try {
|
||||
const jsonStr: string = await invoke('search_songs', { query: { keyword: keyword.value } });
|
||||
const jsonStr: string = await invoke('cloudsearch', {
|
||||
query: { keyword: lastSearchKeyword.value, searchType: type, limit: 30 }
|
||||
});
|
||||
const data = JSON.parse(jsonStr);
|
||||
results.value = (data.result?.songs || []).map(normalizeSong);
|
||||
const result = data.result || {};
|
||||
|
||||
let items: any[] = [];
|
||||
if (type === 1) {
|
||||
items = (result.songs || []).map(normalizeSong);
|
||||
} else if (type === 100) {
|
||||
items = result.artists || [];
|
||||
} else if (type === 10) {
|
||||
items = result.albums || [];
|
||||
}
|
||||
|
||||
resultCache.value.set(type, { data: items, count: items.length, dirty: false });
|
||||
} catch (e) {
|
||||
console.error('搜索出错:', e);
|
||||
resultCache.value.set(type, { data: [], count: 0, dirty: true });
|
||||
cacheError.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function switchTab(type: number) {
|
||||
if (type === activeTab.value) return;
|
||||
activeTab.value = type;
|
||||
|
||||
const entry = resultCache.value.get(type);
|
||||
if (!entry || entry.dirty) {
|
||||
await fetchTabResults(type);
|
||||
}
|
||||
}
|
||||
|
||||
function searchTag(tag: string) {
|
||||
keyword.value = tag;
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
keyword.value = '';
|
||||
hasSearched.value = false;
|
||||
resultCache.value.clear();
|
||||
lastSearchKeyword.value = '';
|
||||
cacheError.value = false;
|
||||
suggestions.value = [];
|
||||
showSuggestions.value = true;
|
||||
nextTick(() => searchInput.value?.focus());
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -3,13 +3,14 @@
|
||||
<button @click="$router.back()" class="mb-4 text-content-2 hover:text-content transition">
|
||||
← 返回
|
||||
</button>
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold">我喜欢的音乐</h1>
|
||||
<button
|
||||
v-if="songs.length"
|
||||
@click="player.playAll(songs)"
|
||||
class="px-4 py-1.5 bg-muted hover:bg-emphasis rounded-full text-sm transition"
|
||||
class="px-5 py-2 bg-accent hover:bg-accent-hover rounded-full text-white font-medium transition flex items-center gap-2"
|
||||
>
|
||||
<IconPlay class="w-4 h-4 fill-current" />
|
||||
播放全部
|
||||
</button>
|
||||
</div>
|
||||
@ -18,17 +19,20 @@
|
||||
</div>
|
||||
<div v-else-if="loading" class="text-content-2">加载中...</div>
|
||||
<div v-else-if="songs.length === 0" class="text-content-2">暂无喜欢的音乐</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div v-else class="space-y-1">
|
||||
<SongListItem
|
||||
v-for="(song, index) in songs"
|
||||
:key="song.id"
|
||||
:song="song"
|
||||
:index="index"
|
||||
:is-current="player.currentSong?.id === song.id"
|
||||
show-index
|
||||
show-like
|
||||
show-download
|
||||
show-menu
|
||||
show-duration
|
||||
show-playing-overlay
|
||||
:container-class="player.currentSong?.id === song.id ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
|
||||
@click="player.playFromList(songs, index)"
|
||||
/>
|
||||
</div>
|
||||
@ -44,6 +48,7 @@ import { useUserStore } from '../stores/user';
|
||||
import { normalizeSong, type Song } from '../utils/song';
|
||||
import { pageCacheGet, pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
|
||||
import { useOnlineStatus } from '../composables/useOnlineStatus';
|
||||
import IconPlay from '~icons/lucide/play';
|
||||
|
||||
defineOptions({ name: 'FavoriteSongsView' });
|
||||
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
<div class="relative z-10 p-6 flex flex-col justify-between h-full">
|
||||
<div>
|
||||
<p class="text-xs text-white/60 mb-1">📅 {{ todayStr }}</p>
|
||||
<h2 class="text-2xl font-bold">每日推荐</h2>
|
||||
<h2 class="text-2xl font-bold text-white">每日推荐</h2>
|
||||
</div>
|
||||
<p class="text-xs text-white/60">根据你的口味生成,每天凌晨更新</p>
|
||||
</div>
|
||||
@ -33,15 +33,15 @@
|
||||
|
||||
<div class="relative z-10 h-full flex flex-col justify-between p-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-white/50"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.4"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.4"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>
|
||||
<IconRadio class="w-4 h-4 text-white/50" />
|
||||
<span class="text-xs text-white/50 font-medium">私人漫游</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="text-xl font-bold" v-if="!player.fmSong && userStore.isLoggedIn">发现新音乐</h2>
|
||||
<h2 class="text-xl font-bold" v-else-if="!userStore.isLoggedIn">私人漫游</h2>
|
||||
<h2 class="text-lg font-bold truncate" v-else>{{ fmDisplayName }}</h2>
|
||||
<h2 class="text-xl font-bold text-white" v-if="!player.fmSong && userStore.isLoggedIn">发现新音乐</h2>
|
||||
<h2 class="text-xl font-bold text-white" v-else-if="!userStore.isLoggedIn">私人漫游</h2>
|
||||
<h2 class="text-lg font-bold truncate text-white" v-else>{{ fmDisplayName }}</h2>
|
||||
<p v-if="!userStore.isLoggedIn" class="text-xs text-white/50 mt-1">登录后开启沉浸式音乐探索</p>
|
||||
<p v-else-if="!player.fmSong" class="text-xs text-white/50 mt-1">根据你的喜好,为你推荐意想不到的好歌</p>
|
||||
<p v-else class="text-xs text-white/60 truncate mt-1">{{ fmDisplayArtists }}</p>
|
||||
@ -50,24 +50,17 @@
|
||||
<button v-if="userStore.isLoggedIn && !player.fmSong"
|
||||
@click.stop="startFmPlay"
|
||||
class="w-10 h-10 flex items-center justify-center rounded-full bg-white/15 hover:bg-white/25 backdrop-blur-sm transition">
|
||||
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" class="text-white">
|
||||
<path d="M4 2.5v11l9-5.5z" />
|
||||
</svg>
|
||||
<IconPlay class="w-4 h-4 fill-current text-white" />
|
||||
</button>
|
||||
<template v-if="player.fmSong">
|
||||
<button @click.stop="player.toggleFm"
|
||||
class="w-10 h-10 flex items-center justify-center rounded-full bg-white/15 hover:bg-white/25 backdrop-blur-sm transition">
|
||||
<svg v-if="player.fmPlaying" width="18" height="18" viewBox="0 0 16 16" fill="currentColor" class="text-white">
|
||||
<rect x="3" y="2" width="3" height="12" rx="0.5" />
|
||||
<rect x="10" y="2" width="3" height="12" rx="0.5" />
|
||||
</svg>
|
||||
<svg v-else width="18" height="18" viewBox="0 0 16 16" fill="currentColor" class="text-white">
|
||||
<path d="M4 2.5v11l9-5.5z" />
|
||||
</svg>
|
||||
<IconPause v-if="player.fmPlaying" class="w-[18px] h-[18px] fill-current text-white" />
|
||||
<IconPlay v-else class="w-[18px] h-[18px] fill-current text-white" />
|
||||
</button>
|
||||
<button @click.stop="player.nextFm"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 backdrop-blur-sm transition">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="text-white"><polygon points="5 4 15 12 5 20 5 4"/><line x1="19" y1="5" x2="19" y2="19"/></svg>
|
||||
<IconSkipForward class="w-[14px] h-[14px] text-white" />
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
@ -131,6 +124,10 @@ const todayStr = ref('');
|
||||
const RANK_IDS = [3778678, 3779629, 19723756, 2884035];
|
||||
|
||||
import { computed } from 'vue';
|
||||
import IconRadio from '~icons/lucide/radio';
|
||||
import IconPlay from '~icons/lucide/play';
|
||||
import IconPause from '~icons/lucide/pause';
|
||||
import IconSkipForward from '~icons/lucide/skip-forward';
|
||||
|
||||
|
||||
const fmCoverUrl = computed(() => {
|
||||
@ -187,7 +184,7 @@ async function loadData() {
|
||||
const json = await invoke('recommend_resource');
|
||||
const data = JSON.parse(json as string);
|
||||
recPlaylists.value = data.recommend || [];
|
||||
} catch { }
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
|
||||
pageCacheSet('home', { rankPlaylists: rankPlaylists.value, recPlaylists: recPlaylists.value });
|
||||
|
||||
@ -26,7 +26,8 @@
|
||||
:is-current="player.currentSong?.id === song.id"
|
||||
show-index
|
||||
show-duration
|
||||
:container-class="player.currentSong?.id === song.id ? 'bg-subtle hover:bg-subtle' : 'hover:bg-subtle'"
|
||||
show-playing-overlay
|
||||
:container-class="player.currentSong?.id === song.id ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
|
||||
@click="player.playFromList(normalizedSongs, index)"
|
||||
>
|
||||
<template #actions>
|
||||
@ -37,12 +38,12 @@
|
||||
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>
|
||||
<IconEllipsis class="w-4 h-4 fill-current" />
|
||||
</button>
|
||||
<Transition name="fade">
|
||||
<div v-if="openMenuId === songs[index].id" class="absolute right-0 top-full mt-1 w-44 bg-surface border border-line rounded-xl shadow-2xl overflow-hidden z-50" @click.stop>
|
||||
<button @click="confirmDelete(songs[index])" class="w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-danger/80 hover:bg-danger/10 transition">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
|
||||
<IconTrash2 style="font-size: 14px" />
|
||||
从磁盘中删除
|
||||
</button>
|
||||
</div>
|
||||
@ -82,6 +83,8 @@ import { useSettingsStore } from '../stores/settings';
|
||||
import { showToast } from '../composables/useToast';
|
||||
import { pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
import IconEllipsis from '~icons/lucide/ellipsis';
|
||||
import IconTrash2 from '~icons/lucide/trash-2';
|
||||
import type { Song } from '../utils/song';
|
||||
|
||||
defineOptions({ name: 'LocalMusicView' });
|
||||
@ -153,7 +156,7 @@ async function fetchMissingCovers() {
|
||||
const url = detailMap.get(song.id);
|
||||
if (url) song.cover = url;
|
||||
}
|
||||
} catch {}
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
|
||||
onMounted(refresh);
|
||||
|
||||
@ -23,7 +23,7 @@
|
||||
@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>
|
||||
<IconPlay class="w-4 h-4 fill-current" />
|
||||
播放全部
|
||||
</button>
|
||||
<button
|
||||
@ -32,10 +32,7 @@
|
||||
class="px-4 py-2 bg-muted hover:bg-emphasis rounded-full text-sm transition flex items-center gap-2"
|
||||
:class="subscribed ? 'text-accent-text' : 'text-content/70'"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path v-if="subscribed" d="M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z"/>
|
||||
<path v-else d="M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z"/>
|
||||
</svg>
|
||||
<IconBookmark class="w-4 h-4" :class="subscribed ? 'fill-current' : ''" />
|
||||
{{ subscribed ? '已收藏' : '收藏歌单' }}
|
||||
</button>
|
||||
</div>
|
||||
@ -59,23 +56,7 @@
|
||||
show-playing-overlay
|
||||
:container-class="player.currentSong?.id === song.id ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
|
||||
@click="player.playFromList(songs, index)"
|
||||
>
|
||||
<template #index="{ index: idx, isCurrent }">
|
||||
<div class="w-6 text-right flex-shrink-0 flex items-center justify-end h-5">
|
||||
<div v-if="isCurrent" class="flex items-center justify-end">
|
||||
<div class="flex items-center gap-[3px] h-4">
|
||||
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 50%; animation-delay: 0ms"></span>
|
||||
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 100%; animation-delay: 150ms"></span>
|
||||
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 35%; animation-delay: 300ms"></span>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<span class="text-xs text-content-3 group-hover:hidden">{{ idx + 1 }}</span>
|
||||
<svg class="hidden group-hover:block text-content" width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4 2.5v11l9-5.5z"/></svg>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</SongListItem>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="playlist" class="mt-8">
|
||||
@ -95,6 +76,8 @@ import { formatPlayCount } from '../utils/format';
|
||||
import { normalizeSong, type Song } from '../utils/song';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
import CommentSection from '../components/CommentSection.vue';
|
||||
import IconPlay from '~icons/lucide/play';
|
||||
import IconBookmark from '~icons/lucide/bookmark';
|
||||
|
||||
const route = useRoute();
|
||||
const player = usePlayerStore();
|
||||
|
||||
@ -20,23 +20,7 @@
|
||||
show-playing-overlay
|
||||
:container-class="player.currentSong?.id === song.id ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
|
||||
@click="player.playFromList(player.recentLocal, index)"
|
||||
>
|
||||
<template #index="{ index: idx, isCurrent }">
|
||||
<div class="w-6 text-right flex-shrink-0 flex items-center justify-end h-5">
|
||||
<div v-if="isCurrent" class="flex items-center justify-end">
|
||||
<div class="flex items-center gap-[3px] h-4">
|
||||
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 50%; animation-delay: 0ms"></span>
|
||||
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 100%; animation-delay: 150ms"></span>
|
||||
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 35%; animation-delay: 300ms"></span>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<span class="text-xs text-content-3 group-hover:hidden">{{ idx + 1 }}</span>
|
||||
<svg class="hidden group-hover:block text-content" width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4 2.5v11l9-5.5z"/></svg>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</SongListItem>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -1,120 +0,0 @@
|
||||
<template>
|
||||
<div class="p-8 text-content flex flex-col items-center justify-center min-h-full">
|
||||
<div v-if="!currentSong" class="text-center">
|
||||
<p class="text-content-2 mb-4">私人漫游未启动</p>
|
||||
<button
|
||||
@click="startFm"
|
||||
class="px-6 py-2 bg-muted hover:bg-emphasis rounded-full transition"
|
||||
>
|
||||
开始漫游
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<img
|
||||
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">
|
||||
<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">
|
||||
<button
|
||||
@click="player.toggle()"
|
||||
class="w-16 h-16 flex items-center justify-center rounded-full bg-muted hover:bg-emphasis transition border border-emphasis"
|
||||
>
|
||||
<svg v-if="player.playing" width="28" height="28" viewBox="0 0 16 16" fill="currentColor">
|
||||
<rect x="3" y="2" width="3" height="12" rx="0.5" />
|
||||
<rect x="10" y="2" width="3" height="12" rx="0.5" />
|
||||
</svg>
|
||||
<svg v-else width="28" height="28" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M4 2.5v11l9-5.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
@click="nextSong"
|
||||
class="text-content-2 hover:text-content transition"
|
||||
>
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 4 15 12 5 20 5 4"/><line x1="19" y1="5" x2="19" y2="19"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { normalizeSong, getCoverUrl } from '../utils/song';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useOnlineStatus } from '../composables/useOnlineStatus';
|
||||
|
||||
const player = usePlayerStore();
|
||||
const router = useRouter();
|
||||
const { isOnline } = useOnlineStatus();
|
||||
const coverError = ref(false);
|
||||
|
||||
const currentSong = computed(() => {
|
||||
if (player.isFmMode && player.currentSong) {
|
||||
return player.currentSong;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const coverUrl = computed(() => {
|
||||
if (!currentSong.value) return '';
|
||||
return getCoverUrl(currentSong.value) || '';
|
||||
});
|
||||
|
||||
watch(coverUrl, () => { coverError.value = false; });
|
||||
|
||||
onMounted(async () => {
|
||||
if (!player.isFmMode || !player.currentSong) {
|
||||
await startFm();
|
||||
}
|
||||
});
|
||||
|
||||
async function startFm() {
|
||||
try {
|
||||
const jsonStr: string = await invoke('personal_fm');
|
||||
const data = JSON.parse(jsonStr);
|
||||
const songs = data.data || data;
|
||||
if (songs && songs.length > 0) {
|
||||
const song = normalizeSong(songs[0]);
|
||||
player.enableFmMode(nextSong);
|
||||
await player.playFmSong(song);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('启动漫游失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function nextSong() {
|
||||
await startFm();
|
||||
}
|
||||
|
||||
watch(isOnline, (val, old) => {
|
||||
if (val && !old && !currentSong.value) {
|
||||
startFm();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@ -28,19 +28,37 @@
|
||||
|
||||
<section class="mb-8">
|
||||
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">外观</h2>
|
||||
<div>
|
||||
<p class="text-sm font-medium mb-3">主题色</p>
|
||||
<div class="grid grid-cols-4 gap-3">
|
||||
<button
|
||||
v-for="(color, key) in themeColors"
|
||||
:key="key"
|
||||
@click="settings.setTheme(key)"
|
||||
class="flex flex-col items-center gap-2 p-3 rounded-xl transition-all border-2"
|
||||
:class="settings.theme === key ? 'border-white/30 bg-white/5 scale-[1.02]' : 'border-transparent bg-subtle hover:bg-muted'"
|
||||
>
|
||||
<div class="w-8 h-8 rounded-full shadow-md" :style="{ backgroundColor: color }"></div>
|
||||
<span class="text-xs" :class="settings.theme === key ? 'text-content font-medium' : 'text-content-3'">{{ themeLabels[key] }}</span>
|
||||
</button>
|
||||
<div class="space-y-5">
|
||||
<div>
|
||||
<p class="text-sm font-medium mb-3">外观模式</p>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
v-for="(label, key) in appearanceLabels"
|
||||
:key="key"
|
||||
@click="settings.setAppearance(key)"
|
||||
class="flex items-center gap-2 px-4 py-2.5 rounded-xl transition-all border-2"
|
||||
:class="settings.appearance === key ? 'border-accent/40 bg-accent/10 scale-[1.02]' : 'border-transparent bg-subtle hover:bg-muted'"
|
||||
>
|
||||
<IconSun v-if="key === 'light'" class="w-4 h-4" :class="settings.appearance === key ? 'text-accent-text' : 'text-content-3'" />
|
||||
<IconMoon v-else class="w-4 h-4" :class="settings.appearance === key ? 'text-accent-text' : 'text-content-3'" />
|
||||
<span class="text-sm" :class="settings.appearance === key ? 'text-content font-medium' : 'text-content-3'">{{ label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium mb-3">主题色</p>
|
||||
<div class="grid grid-cols-4 gap-3">
|
||||
<button
|
||||
v-for="(color, key) in themeColors"
|
||||
:key="key"
|
||||
@click="settings.setTheme(key)"
|
||||
class="flex flex-col items-center gap-2 p-3 rounded-xl transition-all border-2"
|
||||
:class="settings.theme === key ? 'border-accent/40 bg-accent/10 scale-[1.02]' : 'border-transparent bg-subtle hover:bg-muted'"
|
||||
>
|
||||
<div class="w-8 h-8 rounded-full shadow-md" :style="{ backgroundColor: color }"></div>
|
||||
<span class="text-xs" :class="settings.theme === key ? 'text-content font-medium' : 'text-content-3'">{{ themeLabels[key] }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@ -109,7 +127,7 @@
|
||||
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>
|
||||
<IconX style="font-size: 14px" />
|
||||
</button>
|
||||
<button
|
||||
@click="startRecording(String(id))"
|
||||
@ -167,8 +185,8 @@
|
||||
: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="!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>
|
||||
<IconFileText v-if="!updater.checking.value" class="w-4 h-4" />
|
||||
<IconLoader2 v-else class="w-4 h-4 animate-spin" />
|
||||
{{ updater.checking.value ? '检查中...' : '检查更新' }}
|
||||
</button>
|
||||
<button
|
||||
@ -176,7 +194,7 @@
|
||||
:disabled="fetchingChangelog"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
|
||||
<IconFileText class="w-4 h-4" />
|
||||
{{ fetchingChangelog ? '获取中...' : '更新日志' }}
|
||||
</button>
|
||||
</div>
|
||||
@ -237,7 +255,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useSettingsStore, qualityLabels, closeActionLabels, defaultShortcuts, themeLabels, themeColors, type CloseAction } from '../stores/settings';
|
||||
import { useSettingsStore, qualityLabels, closeActionLabels, defaultShortcuts, themeLabels, themeColors, appearanceLabels, type CloseAction } from '../stores/settings';
|
||||
import { useToast } from '../composables/useToast';
|
||||
import { useUpdater } from '../composables/useUpdater';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
@ -245,6 +263,11 @@ 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';
|
||||
import IconX from '~icons/lucide/x';
|
||||
import IconFileText from '~icons/lucide/file-text';
|
||||
import IconLoader2 from '~icons/lucide/loader-2';
|
||||
import IconSun from '~icons/lucide/sun';
|
||||
import IconMoon from '~icons/lucide/moon';
|
||||
|
||||
const settings = useSettingsStore();
|
||||
const { showToast } = useToast();
|
||||
@ -287,7 +310,7 @@ onMounted(async () => {
|
||||
appVersion.value = await getVersion();
|
||||
try {
|
||||
defaultDownloadPath.value = await invoke<string>('get_default_download_path');
|
||||
} catch { }
|
||||
} catch { /* 忽略 */ }
|
||||
loadDevices();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user