mirror of
https://github.com/atdunbg/Nekosonic-Music.git
synced 2026-06-21 16:48:48 +08:00
feat: 皮肤系统重构、seek暂停修复、本地音乐优化、外观一体化
- 重构皮肤系统:提取 skins.ts 管理预设皮肤,CSS 变量由 JS 动态设置 - 提取公共 color.ts 工具函数(hexToRgba/toHex),消除重复定义 - 修复 seek 时暂停状态丢失的 bug(后端 audio_paused 状态保留) - 本地音乐页面:循环排序切换、三点菜单、打开所在文件夹 - 本地音乐文件夹管理:支持启用/禁用切换,兼容旧数据迁移 - 新增 show_item_in_folder 命令(Windows/macOS/Linux 跨平台) - 外观一体化:有壁纸时 TitleBar/Sidebar 透明,PlayerBar 统一透明度+backdrop-blur - 进度条外层直角、内层填充圆角 - 滚动条默认透明,悬停时显示 - 移除 PageHeader 粘性栏 - 内存优化:keep-alive TTL 5min、pageCache TTL 30min/上限30条、colorCache 上限200 - recentLocal 防抖写入、播放器 tick interval 500ms
This commit is contained in:
@ -392,12 +392,51 @@ pub struct ScrobbleQuery {
|
||||
pub id: u64,
|
||||
pub sourceid: Option<String>,
|
||||
pub time: u64,
|
||||
pub alg: Option<String>,
|
||||
pub source: Option<String>,
|
||||
pub bitrate: Option<u64>,
|
||||
}
|
||||
|
||||
/// 听歌打卡
|
||||
#[tauri::command]
|
||||
pub async fn scrobble(query: ScrobbleQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
api_call!(state, scrobble, params: [("id", &query.id.to_string()), ("sourceid", query.sourceid.as_deref().unwrap_or("")), ("time", &query.time.to_string())])
|
||||
let client = state.client.lock().await.clone();
|
||||
let cookie = state.cookie.lock().ok().and_then(|g| g.clone()).unwrap_or_default();
|
||||
let option = ncm_api_rs::request::RequestOption {
|
||||
crypto: ncm_api_rs::request::CryptoType::Weapi,
|
||||
cookie: Some(cookie),
|
||||
ua: None,
|
||||
proxy: None,
|
||||
real_ip: None,
|
||||
random_cn_ip: false,
|
||||
e_r: None,
|
||||
domain: None,
|
||||
check_token: false,
|
||||
};
|
||||
let data = json!({
|
||||
"logs": serde_json::to_string(&json!([{
|
||||
"action": "play",
|
||||
"json": {
|
||||
"download": 0,
|
||||
"end": "playend",
|
||||
"id": query.id.to_string(),
|
||||
"sourceId": query.sourceid.as_deref().unwrap_or(""),
|
||||
"time": query.time as i64,
|
||||
"type": "song",
|
||||
"wifi": 0,
|
||||
"source": query.source.as_deref().unwrap_or("list"),
|
||||
"alg": query.alg.as_deref().unwrap_or(""),
|
||||
"bitrate": query.bitrate.unwrap_or(0),
|
||||
"mainsite": 1,
|
||||
"content": ""
|
||||
}
|
||||
}])).unwrap_or_default()
|
||||
});
|
||||
let result = client.request("/api/feedback/weblog", data.clone(), option)
|
||||
.await
|
||||
.map(|r| r.body.to_string())
|
||||
.map_err(|e| e.to_string());
|
||||
result
|
||||
}
|
||||
|
||||
/// 获取歌曲详情
|
||||
@ -854,6 +893,79 @@ fn sanitize_filename(name: &str) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// 读取本地图片文件并转为 base64 data URL,供前端壁纸等场景使用
|
||||
#[tauri::command]
|
||||
pub async fn read_image_as_data_url(path: String) -> Result<String, String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let file_path = PathBuf::from(&path);
|
||||
if !file_path.exists() {
|
||||
return Err(format!("文件不存在: {}", path));
|
||||
}
|
||||
let bytes = fs::read(&file_path).map_err(|e| format!("读取文件失败: {}", e))?;
|
||||
let mime = match file_path.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase().as_str() {
|
||||
"png" => "image/png",
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"webp" => "image/webp",
|
||||
"gif" => "image/gif",
|
||||
"bmp" => "image/bmp",
|
||||
"svg" => "image/svg+xml",
|
||||
_ => "image/jpeg", // 默认 jpeg
|
||||
};
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
Ok(format!("data:{};base64,{}", mime, b64))
|
||||
}).await.map_err(|e| format!("任务失败: {}", e))?
|
||||
}
|
||||
|
||||
/// 在系统文件管理器中显示指定文件(选中)
|
||||
#[tauri::command]
|
||||
pub fn show_item_in_folder(path: String) -> Result<(), String> {
|
||||
let p = PathBuf::from(&path);
|
||||
if !p.exists() {
|
||||
return Err(format!("文件不存在: {}", path));
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
std::process::Command::new("explorer")
|
||||
.args(["/select,", &p.to_string_lossy()])
|
||||
.spawn()
|
||||
.map_err(|e| format!("打开文件夹失败: {}", e))?;
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
std::process::Command::new("open")
|
||||
.args(["-R", &p.to_string_lossy()])
|
||||
.spawn()
|
||||
.map_err(|e| format!("打开文件夹失败: {}", e))?;
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let uri = format!("file://{}", p.to_string_lossy());
|
||||
// 优先使用 freedesktop DBus FileManager1 接口(支持选中文件,Nautilus/Dolphin 等均实现)
|
||||
let dbus_ok = std::process::Command::new("dbus-send")
|
||||
.args([
|
||||
"--session",
|
||||
"--print-reply",
|
||||
"--dest=org.freedesktop.FileManager1",
|
||||
"/org/freedesktop/FileManager1",
|
||||
"org.freedesktop.FileManager1.ShowItems",
|
||||
&format!("array:string:{}", uri),
|
||||
"string:",
|
||||
])
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false);
|
||||
// fallback:仅打开父目录(无法选中文件)
|
||||
if !dbus_ok {
|
||||
let parent = p.parent().unwrap_or(&p).to_string_lossy().to_string();
|
||||
std::process::Command::new("xdg-open")
|
||||
.arg(&parent)
|
||||
.spawn()
|
||||
.map_err(|e| format!("打开文件夹失败: {}", e))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取歌手详情
|
||||
#[tauri::command]
|
||||
pub async fn artist_detail(id: u64, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
|
||||
@ -1066,10 +1066,14 @@ fn audio_thread(rx: Receiver<AudioCmd>, _current_url: Arc<Mutex<Option<String>>>
|
||||
let device = get_output_device(&selected_device);
|
||||
match start_playback(mss, &device, current_volume, Some(time)) {
|
||||
Ok(ctx) => {
|
||||
if audio_paused {
|
||||
is_playing.store(false, Ordering::Relaxed);
|
||||
ctx.playback.playing.store(false, Ordering::Relaxed);
|
||||
} else {
|
||||
is_playing.store(true, Ordering::Relaxed);
|
||||
}
|
||||
output_ctx = Some(ctx);
|
||||
audio_active = true;
|
||||
audio_paused = false;
|
||||
is_playing.store(true, Ordering::Relaxed);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[audio] seek 播放失败: {}", e);
|
||||
|
||||
@ -197,6 +197,8 @@ pub fn run() {
|
||||
api::user_cloud_detail,
|
||||
api::user_cloud_del,
|
||||
api::cloud_upload,
|
||||
api::read_image_as_data_url,
|
||||
api::show_item_in_folder,
|
||||
])
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
|
||||
77
src/App.vue
77
src/App.vue
@ -1,5 +1,24 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-screen bg-base text-content overflow-hidden">
|
||||
<!-- 壁纸层:fixed 全屏最底层 -->
|
||||
<div
|
||||
v-if="settings.currentWallpaper.path"
|
||||
class="fixed inset-0 z-0 pointer-events-none overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-[-20px] bg-cover bg-center bg-no-repeat"
|
||||
:style="wallpaperStyle"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- 主题色遮罩层:半透明主题色覆盖壁纸,保证文字可读 -->
|
||||
<div
|
||||
v-if="settings.currentWallpaper.path"
|
||||
class="fixed inset-0 z-[1] pointer-events-none"
|
||||
:style="overlayStyle"
|
||||
></div>
|
||||
|
||||
<!-- 主容器 -->
|
||||
<div class="flex flex-col h-screen text-content overflow-hidden relative z-[2]" :style="rootBgStyle">
|
||||
<TitleBar @close="closeWindow" />
|
||||
|
||||
<div class="flex flex-1 overflow-hidden" v-if="windowVisible">
|
||||
@ -37,7 +56,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { ref, watch, onMounted, onBeforeUnmount, computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useUserStore } from './stores/user';
|
||||
import { useSettingsStore, type CloseAction } from './stores/settings';
|
||||
@ -56,6 +75,7 @@ import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { register, unregister } from '@tauri-apps/plugin-global-shortcut';
|
||||
import { MusicApi, AudioApi, DeviceApi, AppApi } from './api';
|
||||
import { hexToRgba } from './utils/color';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const player = usePlayerStore();
|
||||
@ -84,7 +104,7 @@ const ROUTE_COMPONENT: Record<string, string> = {
|
||||
};
|
||||
const ALL_CACHEABLE = [...new Set(Object.values(ROUTE_COMPONENT))];
|
||||
const PERMANENT = new Set(['FavoriteSongsView']);
|
||||
const CACHE_TTL = 30_000;
|
||||
const CACHE_TTL = 300_000;
|
||||
|
||||
const lastActivatedAt: Record<string, number> = {};
|
||||
const navStack = ref<string[]>([]);
|
||||
@ -125,10 +145,57 @@ let cleanupTimer: ReturnType<typeof setInterval>;
|
||||
function startCleanup() { cleanupTimer = setInterval(() => { keepAliveInclude.value = computeInclude(); }, 10_000); }
|
||||
function stopCleanup() { clearInterval(cleanupTimer); }
|
||||
|
||||
watch(() => settings.dataTheme, (val) => {
|
||||
document.documentElement.setAttribute('data-theme', val);
|
||||
watch(() => settings.skin, () => {
|
||||
settings.applySkin();
|
||||
}, { immediate: true });
|
||||
|
||||
// 壁纸样式:通过 Rust 命令读取本地图片转 base64 data URL
|
||||
const wallpaperDataUrl = ref('');
|
||||
const wallpaperStyle = computed(() => {
|
||||
if (!wallpaperDataUrl.value) return {};
|
||||
const wp = settings.currentWallpaper;
|
||||
return {
|
||||
backgroundImage: `url(${wallpaperDataUrl.value})`,
|
||||
filter: `blur(${wp.blur}px)`,
|
||||
opacity: wp.opacity,
|
||||
};
|
||||
});
|
||||
|
||||
// 监听壁纸路径变化,异步加载图片
|
||||
watch(() => settings.currentWallpaper.path, async (path) => {
|
||||
if (!path) {
|
||||
wallpaperDataUrl.value = '';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
wallpaperDataUrl.value = await AppApi.readImageAsDataUrl(path);
|
||||
} catch (e) {
|
||||
console.error('加载壁纸失败:', e);
|
||||
wallpaperDataUrl.value = '';
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
// 根容器背景:有壁纸时透明(遮罩层已保证文字可读),无壁纸时不透明
|
||||
const rootBgStyle = computed(() => {
|
||||
const wp = settings.currentWallpaper;
|
||||
if (wp.path) {
|
||||
return {}; // 透明,遮罩层统一处理
|
||||
}
|
||||
return {
|
||||
backgroundColor: 'var(--c-bg)',
|
||||
};
|
||||
});
|
||||
|
||||
// 主题色遮罩层:用 --c-bg 的半透明版本覆盖壁纸,保证文字对比度
|
||||
// 这是网易云式设计的核心:壁纸色调透出遮罩,文字始终清晰
|
||||
const overlayStyle = computed(() => {
|
||||
const bgColor = settings.currentColors.bg;
|
||||
const rgba = hexToRgba(bgColor, 0.82);
|
||||
return {
|
||||
backgroundColor: rgba,
|
||||
};
|
||||
});
|
||||
|
||||
watch(() => userStore.isLoggedIn, (val) => {
|
||||
if (val) {
|
||||
player.loadLikedIds();
|
||||
|
||||
10
src/api.ts
10
src/api.ts
@ -121,7 +121,7 @@ export namespace MusicApi {
|
||||
return invoke('fm_trash', { query: { id, time } });
|
||||
}
|
||||
|
||||
export async function scrobble(query: { id: number; sourceid: string; time: number }): Promise<void> {
|
||||
export async function scrobble(query: { id: number; sourceid: string; time: number; alg?: string; source?: string; bitrate?: number }): Promise<void> {
|
||||
return invoke('scrobble', { query });
|
||||
}
|
||||
|
||||
@ -222,4 +222,12 @@ export namespace AppApi {
|
||||
export function exitApp(): Promise<void> {
|
||||
return invoke('exit_app');
|
||||
}
|
||||
|
||||
export async function readImageAsDataUrl(path: string): Promise<string> {
|
||||
return invoke('read_image_as_data_url', { path });
|
||||
}
|
||||
|
||||
export async function showItemInFolder(path: string): Promise<void> {
|
||||
return invoke('show_item_in_folder', { path });
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<!-- 普通头部:随内容滚动,返回独占一行,标题和按钮在第二行 -->
|
||||
<div ref="headerRef" class="-mx-8 px-8 pt-3 pb-2">
|
||||
<div class="-mx-8 px-8 pt-3 pb-2">
|
||||
<button @click="$router.back()" class="mb-1 text-content-2 hover:text-content transition text-sm">
|
||||
← 返回
|
||||
</button>
|
||||
@ -9,48 +9,7 @@
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- 粘性操作栏:滚动后显示,只有返回+小功能按钮 -->
|
||||
<div class="sticky top-0 z-10 -mx-8 px-8 py-1.5">
|
||||
<div
|
||||
class="absolute inset-0 backdrop-blur-md transition-opacity duration-300"
|
||||
:class="isStuck ? 'opacity-100' : 'opacity-0'"
|
||||
style="mask-image: linear-gradient(to bottom, black 0%, black 50%, transparent 100%); -webkit-mask-image: linear-gradient(to bottom, black 0%, black 50%, transparent 100%);"
|
||||
>
|
||||
<div class="w-full h-full bg-base/80"></div>
|
||||
</div>
|
||||
<div
|
||||
class="relative flex items-center gap-2 transition-opacity duration-300"
|
||||
:class="isStuck ? 'opacity-100' : 'opacity-0 pointer-events-none'"
|
||||
>
|
||||
<button @click="$router.back()" class="text-content-2 hover:text-content transition text-sm">
|
||||
← 返回
|
||||
</button>
|
||||
<div class="flex-1" />
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const headerRef = ref<HTMLElement | null>(null)
|
||||
const isStuck = ref(false)
|
||||
|
||||
let observer: IntersectionObserver | null = null
|
||||
|
||||
onMounted(() => {
|
||||
if (!headerRef.value) return
|
||||
observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
isStuck.value = !entry.isIntersecting
|
||||
},
|
||||
{ threshold: 0 }
|
||||
)
|
||||
observer.observe(headerRef.value)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
observer?.disconnect()
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
class="fixed bottom-0 left-0 right-0 bg-surface/95 backdrop-blur z-50 select-none"
|
||||
class="fixed bottom-0 left-0 right-0 z-50 select-none backdrop-blur-xl"
|
||||
:style="playerBarBgStyle"
|
||||
>
|
||||
<div v-if="player.dominantColor"
|
||||
class="absolute inset-0 pointer-events-none transition-opacity duration-300"
|
||||
@ -10,11 +11,11 @@
|
||||
<div class="absolute inset-0 bg-black/60"></div>
|
||||
</div>
|
||||
|
||||
<div ref="progressBar" class="w-full h-1.5 rounded-full relative group cursor-pointer overflow-visible"
|
||||
<div ref="progressBar" class="w-full h-1.5 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 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"
|
||||
<div class="absolute left-0 top-0 h-full rounded-full bg-accent"
|
||||
: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 border border-line opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
|
||||
@ -47,8 +48,8 @@
|
||||
<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 transition" :class="drawerActive ? 'text-white/50 hover:text-white' : 'text-content-3 hover:text-accent-text'" title="评论">
|
||||
<IconMessageSquare class="w-4 h-4" />
|
||||
<button v-if="player.currentSong" @click="shareSong(player.currentSong.id)" class="flex-shrink-0 transition" :class="drawerActive ? 'text-white/50 hover:text-white' : 'text-content-3 hover:text-accent-text'" title="分享">
|
||||
<IconShare2 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 transition" :class="drawerActive ? 'text-white/50 hover:text-white' : 'text-content-3 hover:text-accent-text'" title="下载">
|
||||
<IconDownload class="w-4 h-4" />
|
||||
@ -128,7 +129,7 @@
|
||||
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-sm font-medium">减少含此歌手的推荐</p>
|
||||
<p class="text-xs text-content-3 truncate max-w-[200px]">{{ dislikeArtistName }}</p>
|
||||
</div>
|
||||
</button>
|
||||
@ -213,6 +214,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onBeforeUnmount, onMounted, nextTick } from 'vue';
|
||||
import { usePlayerStore, PlayMode } from '../stores/player';
|
||||
import { useSettingsStore } from '../stores/settings';
|
||||
import { useDownload } from '../composables/useDownload';
|
||||
import { formatTime } from '../utils/format';
|
||||
import { getCoverUrl } from '../utils/song';
|
||||
@ -232,7 +234,6 @@ 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';
|
||||
@ -240,11 +241,26 @@ 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';
|
||||
import IconShare2 from '~icons/lucide/share-2';
|
||||
import { hexToRgba } from '../utils/color';
|
||||
|
||||
const router = useRouter();
|
||||
const player = usePlayerStore();
|
||||
const settings = useSettingsStore();
|
||||
const download = useDownload();
|
||||
const drawerActive = computed(() => player.showRoamDrawer && !!player.dominantColor);
|
||||
|
||||
// PlayerBar 背景:有壁纸时用 --c-bg 高不透明度(与遮罩层同色系,视觉融合),
|
||||
// 无壁纸时用 surface 色
|
||||
const playerBarBgStyle = computed(() => {
|
||||
if (settings.currentWallpaper.path) {
|
||||
const bgColor = settings.currentColors.bg;
|
||||
const rgba = hexToRgba(bgColor, 0.82);
|
||||
return { backgroundColor: rgba };
|
||||
}
|
||||
return { backgroundColor: settings.currentColors.surface };
|
||||
});
|
||||
|
||||
const showQueuePanel = ref(false);
|
||||
const showDislikeModal = ref(false);
|
||||
const queueListEl = ref<HTMLElement | null>(null);
|
||||
@ -367,7 +383,17 @@ async function dislikeArtist() {
|
||||
if (!player.currentSong) return;
|
||||
showDislikeModal.value = false;
|
||||
await player.fmTrash(player.currentSong.id);
|
||||
showToast('已减少该歌手推荐', 'success');
|
||||
showToast('已减少含该歌手的推荐', 'success');
|
||||
}
|
||||
|
||||
async function shareSong(id: number) {
|
||||
const url = `https://music.163.com/song?id=${id}`;
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
showToast('链接已复制', 'success');
|
||||
} catch {
|
||||
showToast('复制失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToCurrent() {
|
||||
|
||||
@ -1,91 +1,94 @@
|
||||
<template>
|
||||
<Transition name="drawer">
|
||||
<div
|
||||
v-if="visible"
|
||||
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 v-if="visible" class="fixed inset-0 z-50 flex flex-col">
|
||||
<!-- 背景层:fade in 覆盖全屏 -->
|
||||
<div
|
||||
class="absolute inset-0 backdrop-blur-xl"
|
||||
:class="!player.dominantColor && 'bg-surface/95'"
|
||||
:style="player.dominantColor ? { backgroundColor: player.dominantColor } : {}"
|
||||
></div>
|
||||
<div v-if="player.dominantColor" class="absolute inset-0 bg-black/60 pointer-events-none"></div>
|
||||
<TitleBar :dark-mode="!!player.dominantColor" @close="player.closeRoamDrawer()">
|
||||
<template #left>
|
||||
<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>
|
||||
</template>
|
||||
</TitleBar>
|
||||
<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"
|
||||
:src="roamCoverUrl"
|
||||
class="w-72 h-72 rounded-3xl object-cover shadow-2xl mb-4"
|
||||
@error="roamCoverError = true"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="w-72 h-72 rounded-3xl flex items-center justify-center shadow-2xl mb-4"
|
||||
:class="player.dominantColor ? 'bg-white/10' : 'bg-muted'"
|
||||
>
|
||||
<IconMusic class="w-16 h-16" :class="player.dominantColor ? 'text-white/30' : 'text-content-4'" />
|
||||
</div>
|
||||
<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="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="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>
|
||||
</div>
|
||||
<div class="w-3/5 relative min-h-0 overflow-hidden flex flex-col">
|
||||
<div class="flex items-center gap-1 mb-3 px-4">
|
||||
<button @click="player.roamTab = 'lyric'"
|
||||
class="px-3 py-1 rounded-full text-sm transition"
|
||||
:class="player.dominantColor
|
||||
? (player.roamTab === 'lyric' ? 'bg-white/15 text-white font-medium' : 'text-white/50 hover:text-white/80')
|
||||
: (player.roamTab === 'lyric' ? 'bg-muted text-content font-medium' : 'text-content-3 hover:text-content')">
|
||||
歌词
|
||||
|
||||
<!-- 内容层:slide up/down -->
|
||||
<div class="relative z-10 flex flex-col flex-1 min-h-0 drawer-content">
|
||||
<TitleBar :dark-mode="!!player.dominantColor" transparent @close="player.closeRoamDrawer()">
|
||||
<template #left>
|
||||
<button @click="player.closeRoamDrawer()" :class="dc ? 'text-white/60 hover:text-white' : 'text-content-2 hover:text-content'" class="transition">
|
||||
<IconChevronDown class="w-5 h-5" />
|
||||
</button>
|
||||
<button @click="player.roamTab = 'comment'"
|
||||
class="px-3 py-1 rounded-full text-sm transition"
|
||||
:class="player.dominantColor
|
||||
? (player.roamTab === 'comment' ? 'bg-white/15 text-white font-medium' : 'text-white/50 hover:text-white/80')
|
||||
: (player.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="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>
|
||||
<div v-show="player.roamTab === 'lyric'" ref="lyricScrollContainer" class="flex-1 min-h-0 overflow-y-auto custom-scroll px-4">
|
||||
<div v-if="lyrics.length > 0" class="w-full max-w-lg mx-auto text-center"
|
||||
:style="{ paddingTop: roamLyricPadPx + 'px', paddingBottom: roamLyricPadPx + 'px' }">
|
||||
<p
|
||||
v-for="(line, idx) in lyrics"
|
||||
:key="idx"
|
||||
:class="getRoamLyricClass(idx)"
|
||||
class="roam-lyric-line px-4 py-3 rounded-lg cursor-pointer whitespace-nowrap transition-[font-size] duration-300 ease-out"
|
||||
@click="seekToRoamLyric(line.time)"
|
||||
@mouseenter="roamLyricHovering = true"
|
||||
@mouseleave="roamLyricHovering = false"
|
||||
>
|
||||
{{ line.text }}
|
||||
<span v-if="showTranslation && line.translation" class="block text-sm mt-1" :class="getTranslationClass(idx)">{{ line.translation }}</span>
|
||||
</p>
|
||||
</template>
|
||||
</TitleBar>
|
||||
|
||||
<div class="flex-1 min-h-0 flex px-8 pb-8 gap-0">
|
||||
<!-- 左侧:封面 + 歌曲信息 -->
|
||||
<div class="w-2/5 flex flex-col items-center justify-center flex-shrink-0">
|
||||
<img
|
||||
v-if="roamCoverUrl && !roamCoverError"
|
||||
:src="roamCoverUrl"
|
||||
class="w-72 h-72 rounded-3xl object-cover shadow-2xl mb-4"
|
||||
@error="roamCoverError = true"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="w-72 h-72 rounded-3xl flex items-center justify-center shadow-2xl mb-4"
|
||||
:class="dc ? 'bg-white/10' : 'bg-muted'"
|
||||
>
|
||||
<IconMusic class="w-16 h-16" :class="dc ? 'text-white/30' : 'text-content-4'" />
|
||||
</div>
|
||||
<div v-else :class="player.dominantColor ? 'text-white/40' : 'text-content-3'" class="text-center mt-8">暂无歌词</div>
|
||||
<h1 class="text-2xl font-bold text-center" :class="dc ? 'text-white' : 'text-content'">{{ roamSong?.name }}</h1>
|
||||
<p class="mt-2 text-center" :class="dc ? 'text-white/70' : 'text-content-2'">
|
||||
<template v-for="(a, i) in roamSong?.ar || []" :key="a.id || i">
|
||||
<span v-if="i > 0" :class="dc ? '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="dc ? '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>
|
||||
</div>
|
||||
<div v-show="player.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" :dark-mode="!!player.dominantColor" />
|
||||
|
||||
<!-- 右侧:歌词/评论 -->
|
||||
<div class="w-3/5 relative min-h-0 overflow-hidden flex flex-col">
|
||||
<div class="flex items-center gap-1 mb-3 px-4">
|
||||
<button @click="player.roamTab = 'lyric'"
|
||||
class="px-3 py-1 rounded-full text-sm transition"
|
||||
:class="tabClass(player.roamTab === 'lyric')">
|
||||
歌词
|
||||
</button>
|
||||
<button @click="player.roamTab = 'comment'"
|
||||
class="px-3 py-1 rounded-full text-sm transition"
|
||||
:class="tabClass(player.roamTab === 'comment')">
|
||||
评论
|
||||
</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="tabClass(showTranslation)">
|
||||
<IconLanguages class="w-3 h-3" />
|
||||
译
|
||||
</button>
|
||||
</div>
|
||||
<div v-show="player.roamTab === 'lyric'" ref="lyricScrollContainer" class="flex-1 min-h-0 overflow-y-auto custom-scroll px-4">
|
||||
<div v-if="lyrics.length > 0" class="w-full max-w-lg mx-auto text-center"
|
||||
:style="{ paddingTop: roamLyricPadPx + 'px', paddingBottom: roamLyricPadPx + 'px' }">
|
||||
<p
|
||||
v-for="(line, idx) in lyrics"
|
||||
:key="idx"
|
||||
:class="getRoamLyricClass(idx)"
|
||||
class="roam-lyric-line px-4 py-3 rounded-lg cursor-pointer whitespace-nowrap transition-[font-size] duration-300 ease-out"
|
||||
@click="seekToRoamLyric(line.time)"
|
||||
@mouseenter="roamLyricHovering = true"
|
||||
@mouseleave="roamLyricHovering = false"
|
||||
>
|
||||
{{ line.text }}
|
||||
<span v-if="showTranslation && line.translation" class="block text-sm mt-1" :class="getTranslationClass(idx)">{{ line.translation }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div v-else :class="dc ? 'text-white/40' : 'text-content-3'" class="text-center mt-8">暂无歌词</div>
|
||||
</div>
|
||||
<div v-show="player.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" :dark-mode="!!player.dominantColor" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -112,6 +115,9 @@ defineProps<{
|
||||
const router = useRouter();
|
||||
const player = usePlayerStore();
|
||||
|
||||
// dominantColor 是否存在(模板中频繁使用)
|
||||
const dc = computed(() => !!player.dominantColor);
|
||||
|
||||
const { lyrics, currentLyricIdx, hasTranslation, showTranslation, toggleTranslation } = useLyric();
|
||||
const lyricScrollContainer = ref<HTMLElement | null>(null);
|
||||
const roamLyricHovering = ref(false);
|
||||
@ -200,26 +206,32 @@ function scrollToRoamActiveLyric() {
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
// Tab 按钮统一样式
|
||||
function tabClass(active: boolean): string {
|
||||
if (dc.value) {
|
||||
return active ? 'bg-white/15 text-white font-medium' : 'text-white/50 hover:text-white/80';
|
||||
}
|
||||
return active ? 'bg-muted text-content font-medium' : 'text-content-3 hover:text-content';
|
||||
}
|
||||
|
||||
function getTranslationClass(idx: number): string {
|
||||
const diff = Math.abs(idx - currentLyricIdx.value);
|
||||
const hasColor = !!player.dominantColor;
|
||||
if (idx === currentLyricIdx.value) return hasColor ? 'text-[var(--c-accent)]' : 'text-accent-text';
|
||||
if (diff === 1) return hasColor ? 'text-white/70' : 'text-content/70';
|
||||
if (diff === 2) return hasColor ? 'text-white/50' : 'text-content-2/50';
|
||||
return hasColor ? 'text-white/35' : 'text-content-3/35';
|
||||
if (idx === currentLyricIdx.value) return dc.value ? 'text-[var(--c-accent)]' : 'text-accent-text';
|
||||
if (diff === 1) return dc.value ? 'text-white/70' : 'text-content/70';
|
||||
if (diff === 2) return dc.value ? 'text-white/50' : 'text-content-2/50';
|
||||
return dc.value ? 'text-white/35' : 'text-content-3/35';
|
||||
}
|
||||
|
||||
function getRoamLyricClass(idx: number): string {
|
||||
const diff = Math.abs(idx - currentLyricIdx.value);
|
||||
const hasColor = !!player.dominantColor;
|
||||
if (idx === currentLyricIdx.value) {
|
||||
return hasColor
|
||||
return dc.value
|
||||
? 'roam-lyric-active font-bold text-xl text-[var(--c-accent)]'
|
||||
: 'roam-lyric-active text-accent-text font-semibold text-xl';
|
||||
}
|
||||
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]';
|
||||
if (diff === 1) return dc.value ? 'text-white/70 text-lg' : 'text-content/70 text-lg';
|
||||
if (diff === 2) return dc.value ? 'text-white/50 text-[1rem]' : 'text-content-2/50 text-[1rem]';
|
||||
return dc.value ? 'text-white/35 text-[1rem]' : 'text-content-3/35 text-[1rem]';
|
||||
}
|
||||
|
||||
function seekToRoamLyric(time: number) {
|
||||
@ -235,9 +247,17 @@ function navigateFromDrawer(routeLocation: { name: string; params: any }) {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 外层容器:fade in/out */
|
||||
.drawer-enter-active,
|
||||
.drawer-leave-active { transition: transform 0.3s ease; }
|
||||
.drawer-leave-active { transition: opacity 0.3s ease; }
|
||||
.drawer-enter-from,
|
||||
.drawer-leave-to { transform: translateY(100%); }
|
||||
.drawer-leave-to { opacity: 0; }
|
||||
|
||||
/* 内容层:slide up/down */
|
||||
.drawer-enter-active .drawer-content,
|
||||
.drawer-leave-active .drawer-content { transition: transform 0.3s ease; }
|
||||
.drawer-enter-from .drawer-content,
|
||||
.drawer-leave-to .drawer-content { transform: translateY(100%); }
|
||||
|
||||
.custom-scroll::-webkit-scrollbar { width: 0; display: none; }
|
||||
</style>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<nav class="w-56 flex-shrink-0 flex flex-col bg-surface/80 backdrop-blur">
|
||||
<nav class="w-56 flex-shrink-0 flex flex-col" :style="sidebarBgStyle">
|
||||
<div class="flex-1 p-4 overflow-y-auto min-h-0">
|
||||
<div class="flex flex-col min-h-full">
|
||||
<div class="space-y-0.5">
|
||||
@ -126,10 +126,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue';
|
||||
import { ref, watch, onMounted, computed } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useUserStore } from '../stores/user';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { useSettingsStore } from '../stores/settings';
|
||||
import { MusicApi } from '../api';
|
||||
import IconHome from '~icons/lucide/home';
|
||||
import IconSearch from '~icons/lucide/search';
|
||||
@ -147,6 +148,13 @@ const router = useRouter();
|
||||
const route = useRoute();
|
||||
const userStore = useUserStore();
|
||||
const player = usePlayerStore();
|
||||
const settings = useSettingsStore();
|
||||
|
||||
// 有壁纸时侧栏轻微半透明区分区域,无壁纸时保持原样
|
||||
const sidebarBgStyle = computed(() => {
|
||||
if (settings.currentWallpaper.path) return {}; // 有壁纸时透明,由遮罩层统一提供背景
|
||||
return { backgroundColor: settings.currentColors.surface };
|
||||
});
|
||||
|
||||
const createdPlaylists = ref<any[]>([]);
|
||||
const subPlaylists = ref<any[]>([]);
|
||||
|
||||
@ -9,6 +9,10 @@
|
||||
<IconMessageSquare style="font-size: 14px" />
|
||||
评论
|
||||
</button>
|
||||
<button @click.stop="handleShare" class="w-full flex items-center gap-2 px-3 py-2 text-sm text-content-2 hover:bg-subtle hover:text-content transition">
|
||||
<IconShare2 style="font-size: 14px" />
|
||||
分享
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -16,8 +20,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onBeforeUnmount, onMounted } from 'vue';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { showToast } from '../composables/useToast';
|
||||
import IconEllipsis from '~icons/lucide/ellipsis';
|
||||
import IconMessageSquare from '~icons/lucide/message-square';
|
||||
import IconShare2 from '~icons/lucide/share-2';
|
||||
|
||||
const player = usePlayerStore();
|
||||
const props = defineProps<{ songId: number }>();
|
||||
@ -33,6 +39,17 @@ function handleComment() {
|
||||
player.openCommentForSong(props.songId);
|
||||
}
|
||||
|
||||
async function handleShare() {
|
||||
open.value = false;
|
||||
const url = `https://music.163.com/song?id=${props.songId}`;
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
showToast('链接已复制', 'success');
|
||||
} catch {
|
||||
showToast('复制失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
if (menuRef.value && !menuRef.value.contains(e.target as Node)) {
|
||||
open.value = false;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div :class="['flex items-center gap-4 p-3 rounded-xl cursor-pointer transition group', containerClass]">
|
||||
<div :class="['flex items-center gap-4 p-3 rounded-xl cursor-pointer transition group', isCurrent ? 'bg-accent-dim hover:bg-accent-dim' : containerClass]">
|
||||
<slot name="index" :index="index" :is-current="isCurrent">
|
||||
<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">
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
class="h-10 flex items-center justify-between px-4 flex-shrink-0 select-none relative z-10"
|
||||
:class="darkMode ? '' : 'bg-surface/90 backdrop-blur'"
|
||||
:style="titleBarBgStyle"
|
||||
>
|
||||
<slot name="left">
|
||||
<span v-if="!darkMode" class="text-xs text-content-3 font-medium ml-2">Nekosonic Music</span>
|
||||
@ -16,16 +16,28 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { useSettingsStore } from '../stores/settings';
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
darkMode?: boolean;
|
||||
transparent?: boolean;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
const settings = useSettingsStore();
|
||||
|
||||
const titleBarBgStyle = computed(() => {
|
||||
if (props.transparent) return {};
|
||||
if (settings.currentWallpaper.path) return {}; // 有壁纸时透明,由遮罩层统一提供背景
|
||||
if (props.darkMode) return {};
|
||||
return { backgroundColor: settings.currentColors.surface };
|
||||
});
|
||||
|
||||
const currentWindow = getCurrentWindow();
|
||||
|
||||
function minimizeWindow() {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
const cache = new Map<string, { data: any; ts: number }>();
|
||||
const TTL = 5 * 60 * 1000;
|
||||
const TTL = 30 * 60 * 1000;
|
||||
const MAX_ENTRIES = 30;
|
||||
|
||||
export function pageCacheGet(key: string): any | null {
|
||||
const entry = cache.get(key);
|
||||
@ -12,6 +13,11 @@ export function pageCacheGet(key: string): any | null {
|
||||
}
|
||||
|
||||
export function pageCacheSet(key: string, data: any) {
|
||||
if (cache.size >= MAX_ENTRIES && !cache.has(key)) {
|
||||
// 淘汰最旧的条目
|
||||
const firstKey = cache.keys().next().value;
|
||||
if (firstKey !== undefined) cache.delete(firstKey);
|
||||
}
|
||||
cache.set(key, { data, ts: Date.now() });
|
||||
}
|
||||
|
||||
|
||||
152
src/skins.ts
Normal file
152
src/skins.ts
Normal file
@ -0,0 +1,152 @@
|
||||
export interface SkinColors {
|
||||
bg: string;
|
||||
surface: string;
|
||||
subtle: string;
|
||||
muted: string;
|
||||
emphasis: string;
|
||||
content: string;
|
||||
content2: string;
|
||||
content3: string;
|
||||
content4: string;
|
||||
line: string;
|
||||
line2: string;
|
||||
accent: string;
|
||||
accentHover: string;
|
||||
accentText: string;
|
||||
accentDim: string;
|
||||
danger: string;
|
||||
dangerDim: string;
|
||||
warning: string;
|
||||
info: string;
|
||||
}
|
||||
|
||||
interface PresetSkin {
|
||||
id: string;
|
||||
name: string;
|
||||
preview: string;
|
||||
colors: SkinColors;
|
||||
}
|
||||
|
||||
function darkSkin(accent: string, accentHover: string, accentText: string, name: string, id: string): PresetSkin {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
preview: accent,
|
||||
colors: {
|
||||
bg: '#02060c',
|
||||
surface: '#0a101a',
|
||||
subtle: `rgba(${hexToRgb(accent)}, 0.06)`,
|
||||
muted: `rgba(${hexToRgb(accent)}, 0.10)`,
|
||||
emphasis: `rgba(${hexToRgb(accent)}, 0.18)`,
|
||||
content: '#ffffff',
|
||||
content2: '#9ca3af',
|
||||
content3: '#6b7280',
|
||||
content4: '#4b5563',
|
||||
line: 'rgba(255, 255, 255, 0.08)',
|
||||
line2: 'rgba(255, 255, 255, 0.04)',
|
||||
accent,
|
||||
accentHover,
|
||||
accentText,
|
||||
accentDim: `rgba(${hexToRgb(accent)}, 0.20)`,
|
||||
danger: '#ef4444',
|
||||
dangerDim: 'rgba(239, 68, 68, 0.20)',
|
||||
warning: '#eab308',
|
||||
info: '#8b5cf6',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function lightSkin(accent: string, accentHover: string, accentText: string, name: string, id: string): PresetSkin {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
preview: accent,
|
||||
colors: {
|
||||
bg: '#f8fafc',
|
||||
surface: '#ffffff',
|
||||
subtle: `rgba(${hexToRgb(accent)}, 0.06)`,
|
||||
muted: `rgba(${hexToRgb(accent)}, 0.10)`,
|
||||
emphasis: `rgba(${hexToRgb(accent)}, 0.18)`,
|
||||
content: '#0f172a',
|
||||
content2: '#475569',
|
||||
content3: '#94a3b8',
|
||||
content4: '#cbd5e1',
|
||||
line: 'rgba(0, 0, 0, 0.08)',
|
||||
line2: 'rgba(0, 0, 0, 0.04)',
|
||||
accent,
|
||||
accentHover,
|
||||
accentText,
|
||||
accentDim: `rgba(${hexToRgb(accent)}, 0.15)`,
|
||||
danger: '#ef4444',
|
||||
dangerDim: 'rgba(239, 68, 68, 0.15)',
|
||||
warning: '#eab308',
|
||||
info: '#8b5cf6',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string): string {
|
||||
const h = hex.replace('#', '');
|
||||
const r = parseInt(h.substring(0, 2), 16);
|
||||
const g = parseInt(h.substring(2, 4), 16);
|
||||
const b = parseInt(h.substring(4, 6), 16);
|
||||
return `${r}, ${g}, ${b}`;
|
||||
}
|
||||
|
||||
export const presetSkins: PresetSkin[] = [
|
||||
// 深色
|
||||
darkSkin('#3b82f6', '#2563eb', '#60a5fa', '深蓝', 'dark-blue'),
|
||||
darkSkin('#22c55e', '#16a34a', '#4ade80', '深翠', 'dark-green'),
|
||||
darkSkin('#f43f5e', '#e11d48', '#fb7185', '深红', 'dark-rose'),
|
||||
darkSkin('#8b5cf6', '#7c3aed', '#a78bfa', '深紫', 'dark-violet'),
|
||||
darkSkin('#f97316', '#ea580c', '#fb923c', '深橙', 'dark-orange'),
|
||||
darkSkin('#06b6d4', '#0891b2', '#22d3ee', '深青', 'dark-cyan'),
|
||||
darkSkin('#ec4899', '#db2777', '#f472b6', '深粉', 'dark-pink'),
|
||||
// 浅色
|
||||
lightSkin('#3b82f6', '#2563eb', '#2563eb', '浅蓝', 'light-blue'),
|
||||
lightSkin('#22c55e', '#16a34a', '#16a34a', '浅翠', 'light-green'),
|
||||
lightSkin('#f43f5e', '#e11d48', '#e11d48', '浅红', 'light-rose'),
|
||||
lightSkin('#8b5cf6', '#7c3aed', '#7c3aed', '浅紫', 'light-violet'),
|
||||
lightSkin('#f97316', '#ea580c', '#ea580c', '浅橙', 'light-orange'),
|
||||
lightSkin('#06b6d4', '#0891b2', '#0891b2', '浅青', 'light-cyan'),
|
||||
lightSkin('#ec4899', '#db2777', '#db2777', '浅粉', 'light-pink'),
|
||||
];
|
||||
|
||||
const presetIdSet = new Set(presetSkins.map(s => s.id));
|
||||
|
||||
export function isPresetSkinId(id: string): boolean {
|
||||
return presetIdSet.has(id);
|
||||
}
|
||||
|
||||
export function getPresetSkin(id: string): PresetSkin | undefined {
|
||||
return presetSkins.find(s => s.id === id);
|
||||
}
|
||||
|
||||
/** 将皮肤颜色应用到 DOM CSS 变量 */
|
||||
export function applySkinColors(colors: SkinColors) {
|
||||
const root = document.documentElement;
|
||||
const map: Record<keyof SkinColors, string> = {
|
||||
bg: '--c-bg',
|
||||
surface: '--c-surface',
|
||||
subtle: '--c-subtle',
|
||||
muted: '--c-muted',
|
||||
emphasis: '--c-emphasis',
|
||||
content: '--c-content',
|
||||
content2: '--c-content-2',
|
||||
content3: '--c-content-3',
|
||||
content4: '--c-content-4',
|
||||
line: '--c-line',
|
||||
line2: '--c-line-2',
|
||||
accent: '--c-accent',
|
||||
accentHover: '--c-accent-hover',
|
||||
accentText: '--c-accent-text',
|
||||
accentDim: '--c-accent-dim',
|
||||
danger: '--c-danger',
|
||||
dangerDim: '--c-danger-dim',
|
||||
warning: '--c-warning',
|
||||
info: '--c-info',
|
||||
};
|
||||
for (const [key, cssVar] of Object.entries(map)) {
|
||||
root.style.setProperty(cssVar, colors[key as keyof SkinColors]);
|
||||
}
|
||||
}
|
||||
@ -44,6 +44,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
|
||||
const recentLocal = ref<Song[]>(loadRecentLocal());
|
||||
const MAX_RECENT = 200;
|
||||
let recentLocalTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const likedIds = ref<Set<number>>(new Set());
|
||||
|
||||
@ -102,7 +103,10 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
}
|
||||
|
||||
watch(recentLocal, (val) => {
|
||||
localStorage.setItem('recent_local', JSON.stringify(val));
|
||||
clearTimeout(recentLocalTimer);
|
||||
recentLocalTimer = setTimeout(() => {
|
||||
localStorage.setItem('recent_local', JSON.stringify(val));
|
||||
}, 2000);
|
||||
}, { deep: true });
|
||||
|
||||
const isFmMode = ref(false);
|
||||
@ -114,25 +118,41 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
|
||||
let lastScrobbleId: number | null = null;
|
||||
let lastScrobbleStartTime: number = 0;
|
||||
let lastScrobbleAlg: string | undefined;
|
||||
let lastScrobbleSource: string | undefined;
|
||||
let lastScrobbleBitrate: number | undefined;
|
||||
|
||||
/// 上报上一首歌的听歌记录(scrobble),然后记录当前歌的开始时间
|
||||
function reportScrobble() {
|
||||
const song = currentSong.value;
|
||||
if (!song || song.localPath || song.id == null) {
|
||||
lastScrobbleId = null;
|
||||
return;
|
||||
}
|
||||
if (lastScrobbleId === song.id && lastScrobbleStartTime > 0) {
|
||||
// 先上报:如果有正在记录的歌曲且播放超过 5 秒,发送 scrobble
|
||||
if (lastScrobbleId != null && lastScrobbleStartTime > 0) {
|
||||
const playedSec = Math.round((Date.now() - lastScrobbleStartTime) / 1000);
|
||||
if (playedSec > 5 && navigator.onLine) {
|
||||
MusicApi.scrobble({
|
||||
id: song.id,
|
||||
sourceid: isFmMode.value ? String(song.id) : '',
|
||||
id: lastScrobbleId,
|
||||
sourceid: isFmMode.value ? String(lastScrobbleId) : '',
|
||||
time: playedSec,
|
||||
alg: lastScrobbleAlg || '',
|
||||
source: lastScrobbleSource || 'list',
|
||||
bitrate: lastScrobbleBitrate || 0,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
lastScrobbleId = song.id;
|
||||
lastScrobbleStartTime = Date.now();
|
||||
// 再记录:当前歌曲作为新的 scrobble 起点
|
||||
const song = currentSong.value;
|
||||
if (!song || song.localPath || song.id == null) {
|
||||
lastScrobbleId = null;
|
||||
lastScrobbleStartTime = 0;
|
||||
lastScrobbleAlg = undefined;
|
||||
lastScrobbleSource = undefined;
|
||||
lastScrobbleBitrate = undefined;
|
||||
} else {
|
||||
lastScrobbleId = song.id;
|
||||
lastScrobbleStartTime = Date.now();
|
||||
lastScrobbleAlg = song.alg;
|
||||
lastScrobbleSource = isFmMode.value ? 'personal_fm' : 'list';
|
||||
lastScrobbleBitrate = song.br;
|
||||
}
|
||||
}
|
||||
|
||||
function enableFmMode(onNext: () => void) {
|
||||
@ -212,7 +232,29 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
if (seq !== _playSeq) return;
|
||||
const data = JSON.parse(jsonStr);
|
||||
const url: string | undefined = data.url;
|
||||
if (!url) throw new Error('无播放源');
|
||||
if (!url) {
|
||||
const fee = data.fee;
|
||||
if (fee === 4) {
|
||||
showToast(`${song.name} 为数字专辑,已跳过`, 'info');
|
||||
} else if (fee === 1) {
|
||||
showToast(`${song.name} 为 VIP 专属歌曲,已跳过`, 'info');
|
||||
} else {
|
||||
showToast(`${song.name} 暂无播放源`, 'info');
|
||||
}
|
||||
fmVipSkipCount++;
|
||||
if (fmVipSkipCount >= MAX_FM_VIP_SKIP) {
|
||||
fmVipSkipCount = 0;
|
||||
disableFmMode();
|
||||
return;
|
||||
}
|
||||
_switchingSong = false;
|
||||
if (fmNextCallback) {
|
||||
fmNextCallback();
|
||||
} else {
|
||||
disableFmMode();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.freeTrialInfo) {
|
||||
console.warn('FM VIP 试听歌曲,自动跳过', song.name);
|
||||
@ -372,7 +414,22 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
const url: string | undefined = data.url;
|
||||
|
||||
if (!url) {
|
||||
console.error('未获取到有效播放地址', song);
|
||||
// url 为空:可能是数字专辑/付费歌曲,根据 fee 字段判断
|
||||
const fee = data.fee;
|
||||
if (fee === 4) {
|
||||
showToast(`${song.name} 为数字专辑,需购买后播放`, 'info');
|
||||
} else if (fee === 1) {
|
||||
showToast(`${song.name} 为 VIP 专属歌曲`, 'info');
|
||||
} else {
|
||||
showToast(`${song.name} 暂无播放源`, 'info');
|
||||
}
|
||||
vipSkipCount++;
|
||||
if (vipSkipCount >= MAX_VIP_SKIP) {
|
||||
vipSkipCount = 0;
|
||||
return;
|
||||
}
|
||||
_switchingSong = false;
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -478,7 +535,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
}
|
||||
} catch { /* 忽略 */ }
|
||||
|
||||
if (stateSyncCounter >= 8) {
|
||||
if (stateSyncCounter >= 4) {
|
||||
stateSyncCounter = 0;
|
||||
try {
|
||||
const backendPlaying = await AudioApi.isAudioPlaying();
|
||||
@ -489,7 +546,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
}
|
||||
} else {
|
||||
if (!backendFrozen) {
|
||||
const next = currentTime.value + 0.25;
|
||||
const next = currentTime.value + 0.5;
|
||||
if (next <= duration.value) {
|
||||
currentTime.value = next;
|
||||
}
|
||||
@ -499,7 +556,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
currentTime.value = duration.value;
|
||||
}
|
||||
}
|
||||
}, 250));
|
||||
}, 500));
|
||||
}
|
||||
|
||||
async function toggle() {
|
||||
|
||||
@ -1,36 +1,10 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, watch, computed } from 'vue';
|
||||
import { getPresetSkin, isPresetSkinId, applySkinColors, type SkinColors } from '../skins';
|
||||
|
||||
export type AudioQuality = 'standard' | 'higher' | 'exhigh' | 'lossless' | 'hires';
|
||||
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<ThemeColor, string> = {
|
||||
blue: '天蓝',
|
||||
green: '翠绿',
|
||||
rose: '玫红',
|
||||
violet: '紫罗兰',
|
||||
orange: '橙色',
|
||||
cyan: '青色',
|
||||
pink: '粉色',
|
||||
};
|
||||
|
||||
export const themeColors: Record<ThemeColor, string> = {
|
||||
blue: '#3b82f6',
|
||||
green: '#22c55e',
|
||||
rose: '#f43f5e',
|
||||
violet: '#8b5cf6',
|
||||
orange: '#f97316',
|
||||
cyan: '#06b6d4',
|
||||
pink: '#ec4899',
|
||||
};
|
||||
|
||||
export const appearanceLabels: Record<Appearance, string> = {
|
||||
dark: '深色',
|
||||
light: '浅色',
|
||||
};
|
||||
|
||||
export const qualityLabels: Record<AudioQuality, string> = {
|
||||
standard: '标准',
|
||||
higher: '较高',
|
||||
@ -63,12 +37,31 @@ export const defaultShortcuts: Record<string, ShortcutBinding> = {
|
||||
globalVolDown: { key: 'Control+Alt+ArrowDown', label: '音量减小(全局)' },
|
||||
};
|
||||
|
||||
export interface CustomSkin {
|
||||
id: string;
|
||||
name: string;
|
||||
preview: string;
|
||||
colors: SkinColors;
|
||||
/** 壁纸图片路径,为空则使用纯色背景 */
|
||||
wallpaper: string;
|
||||
/** 壁纸模糊度 0-30 */
|
||||
wallpaperBlur: number;
|
||||
/** 壁纸透明度 0-1 */
|
||||
wallpaperOpacity: number;
|
||||
}
|
||||
|
||||
export interface MusicFolder {
|
||||
path: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface SettingsData {
|
||||
audioQuality: AudioQuality;
|
||||
downloadPath: string;
|
||||
localMusicPaths: string[];
|
||||
theme: ThemeColor;
|
||||
appearance: Appearance;
|
||||
localMusicPaths: string[]; // 旧格式,迁移用
|
||||
localMusicFolders: MusicFolder[];
|
||||
skin: string; // 预设皮肤 id 或 custom-xxx
|
||||
customSkins: CustomSkin[];
|
||||
closeAction: CloseAction;
|
||||
shortcuts: Record<string, ShortcutBinding>;
|
||||
outputDevice: string | null;
|
||||
@ -80,29 +73,43 @@ function loadSettings(): SettingsData {
|
||||
const raw = localStorage.getItem('app_settings');
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
const theme = parsed.theme || parsed.accentColor || 'blue';
|
||||
const validThemes: 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 || '',
|
||||
localMusicPaths: parsed.localMusicPaths || [],
|
||||
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,
|
||||
};
|
||||
|
||||
// 迁移旧版 theme + appearance → skin
|
||||
let skin = parsed.skin || 'dark-blue';
|
||||
if (!parsed.skin && (parsed.theme || parsed.appearance)) {
|
||||
const appearance = parsed.appearance || 'dark';
|
||||
const theme = parsed.theme || 'blue';
|
||||
const validThemes = ['blue', 'green', 'rose', 'violet', 'orange', 'cyan', 'pink'];
|
||||
const t = validThemes.includes(theme) ? theme : 'blue';
|
||||
skin = appearance === 'light' ? `light-${t}` : `dark-${t}`;
|
||||
}
|
||||
|
||||
// 迁移旧版全局壁纸 → 移入自定义皮肤(如果有自定义皮肤且没有壁纸)
|
||||
let customSkins = parsed.customSkins || [];
|
||||
if (parsed.wallpaper && customSkins.length > 0) {
|
||||
customSkins = customSkins.map((s: any) => {
|
||||
if (!s.wallpaper) {
|
||||
return { ...s, wallpaper: parsed.wallpaper, wallpaperBlur: parsed.wallpaperBlur ?? 10, wallpaperOpacity: parsed.wallpaperOpacity ?? 0.3 };
|
||||
}
|
||||
return s;
|
||||
});
|
||||
}
|
||||
|
||||
// 迁移旧格式 localMusicPaths → localMusicFolders
|
||||
let folders: MusicFolder[] = (parsed.localMusicFolders || []).map((f: any) =>
|
||||
typeof f === 'string' ? { path: f, enabled: true } : f
|
||||
);
|
||||
if (!parsed.localMusicFolders && parsed.localMusicPaths?.length) {
|
||||
folders = parsed.localMusicPaths.map((p: string) => ({ path: p, enabled: true }));
|
||||
}
|
||||
|
||||
return {
|
||||
audioQuality: parsed.audioQuality || 'standard',
|
||||
downloadPath: parsed.downloadPath || '',
|
||||
localMusicPaths: parsed.localMusicPaths || [],
|
||||
theme: validThemes.includes(theme) ? theme : 'blue',
|
||||
appearance,
|
||||
localMusicPaths: [],
|
||||
localMusicFolders: folders,
|
||||
skin,
|
||||
customSkins,
|
||||
closeAction: parsed.closeAction || 'ask',
|
||||
shortcuts: { ...defaultShortcuts, ...(parsed.shortcuts || {}) },
|
||||
outputDevice: parsed.outputDevice || null,
|
||||
@ -114,8 +121,9 @@ function loadSettings(): SettingsData {
|
||||
audioQuality: 'standard',
|
||||
downloadPath: '',
|
||||
localMusicPaths: [],
|
||||
theme: 'blue',
|
||||
appearance: 'dark',
|
||||
localMusicFolders: [],
|
||||
skin: 'dark-blue',
|
||||
customSkins: [],
|
||||
closeAction: 'ask',
|
||||
shortcuts: { ...defaultShortcuts },
|
||||
outputDevice: null,
|
||||
@ -128,17 +136,98 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
|
||||
const audioQuality = ref<AudioQuality>(saved.audioQuality);
|
||||
const downloadPath = ref<string>(saved.downloadPath);
|
||||
const localMusicPaths = ref<string[]>(saved.localMusicPaths);
|
||||
const theme = ref<ThemeColor>(saved.theme);
|
||||
const appearance = ref<Appearance>(saved.appearance);
|
||||
const localMusicFolders = ref<MusicFolder[]>(saved.localMusicFolders);
|
||||
const skin = ref<string>(saved.skin);
|
||||
const customSkins = ref<CustomSkin[]>(saved.customSkins);
|
||||
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
|
||||
);
|
||||
/** 当前皮肤是否为预设皮肤 */
|
||||
const isPreset = computed(() => isPresetSkinId(skin.value));
|
||||
|
||||
/** 获取当前自定义皮肤 */
|
||||
const currentCustomSkin = computed(() => {
|
||||
if (isPreset.value) return null;
|
||||
return customSkins.value.find(s => s.id === skin.value) || null;
|
||||
});
|
||||
|
||||
/** 获取当前皮肤的预览色 */
|
||||
const skinPreview = computed(() => {
|
||||
if (isPreset.value) {
|
||||
return getPresetSkin(skin.value)?.preview || '#3b82f6';
|
||||
}
|
||||
return currentCustomSkin.value?.preview || '#3b82f6';
|
||||
});
|
||||
|
||||
/** 获取当前皮肤的完整颜色集(响应式) */
|
||||
const currentColors = computed<SkinColors>(() => {
|
||||
if (isPreset.value) {
|
||||
return getPresetSkin(skin.value)!.colors;
|
||||
}
|
||||
const custom = currentCustomSkin.value;
|
||||
if (!custom) {
|
||||
return getPresetSkin('dark-blue')!.colors;
|
||||
}
|
||||
return custom.colors;
|
||||
});
|
||||
|
||||
/** 获取当前皮肤的壁纸信息 */
|
||||
const currentWallpaper = computed(() => {
|
||||
if (isPreset.value) return { path: '', blur: 10, opacity: 0.3 };
|
||||
const custom = currentCustomSkin.value;
|
||||
return {
|
||||
path: custom?.wallpaper || '',
|
||||
blur: custom?.wallpaperBlur ?? 10,
|
||||
opacity: custom?.wallpaperOpacity ?? 0.3,
|
||||
};
|
||||
});
|
||||
|
||||
function setSkin(id: string) {
|
||||
skin.value = id;
|
||||
}
|
||||
|
||||
function addCustomSkin(s: CustomSkin) {
|
||||
customSkins.value = [...customSkins.value, s];
|
||||
skin.value = s.id;
|
||||
}
|
||||
|
||||
function updateCustomSkin(id: string, updates: Partial<CustomSkin>) {
|
||||
customSkins.value = customSkins.value.map(s =>
|
||||
s.id === id ? { ...s, ...updates } : s
|
||||
);
|
||||
// 如果正在使用该皮肤,立即刷新 CSS 变量
|
||||
if (skin.value === id) {
|
||||
applySkin();
|
||||
}
|
||||
}
|
||||
|
||||
function removeCustomSkin(id: string) {
|
||||
customSkins.value = customSkins.value.filter(s => s.id !== id);
|
||||
if (skin.value === id) {
|
||||
skin.value = 'dark-blue';
|
||||
}
|
||||
}
|
||||
|
||||
/** 应用当前皮肤到 DOM(统一通过 JS 设置 CSS 变量) */
|
||||
function applySkin() {
|
||||
let colors: SkinColors;
|
||||
if (isPreset.value) {
|
||||
const preset = getPresetSkin(skin.value);
|
||||
colors = preset!.colors;
|
||||
} else {
|
||||
const custom = currentCustomSkin.value;
|
||||
if (!custom) {
|
||||
// 找不到自定义皮肤,回退到默认
|
||||
skin.value = 'dark-blue';
|
||||
colors = getPresetSkin('dark-blue')!.colors;
|
||||
} else {
|
||||
colors = custom.colors;
|
||||
}
|
||||
}
|
||||
applySkinColors(colors);
|
||||
}
|
||||
|
||||
function setAudioQuality(q: AudioQuality) {
|
||||
audioQuality.value = q;
|
||||
@ -149,22 +238,25 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
}
|
||||
|
||||
function addLocalMusicPath(p: string) {
|
||||
if (!localMusicPaths.value.includes(p)) {
|
||||
localMusicPaths.value = [...localMusicPaths.value, p];
|
||||
if (!localMusicFolders.value.some(f => f.path === p)) {
|
||||
localMusicFolders.value = [...localMusicFolders.value, { path: p, enabled: true }];
|
||||
}
|
||||
}
|
||||
|
||||
function removeLocalMusicPath(p: string) {
|
||||
localMusicPaths.value = localMusicPaths.value.filter(v => v !== p);
|
||||
localMusicFolders.value = localMusicFolders.value.filter(f => f.path !== p);
|
||||
}
|
||||
|
||||
function setTheme(t: ThemeColor) {
|
||||
theme.value = t;
|
||||
function toggleLocalMusicFolder(p: string) {
|
||||
localMusicFolders.value = localMusicFolders.value.map(f =>
|
||||
f.path === p ? { ...f, enabled: !f.enabled } : f
|
||||
);
|
||||
}
|
||||
|
||||
function setAppearance(a: Appearance) {
|
||||
appearance.value = a;
|
||||
}
|
||||
/** 已启用的扫描路径 */
|
||||
const enabledMusicPaths = computed(() =>
|
||||
localMusicFolders.value.filter(f => f.enabled).map(f => f.path)
|
||||
);
|
||||
|
||||
function setCloseAction(a: CloseAction) {
|
||||
closeAction.value = a;
|
||||
@ -185,22 +277,23 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
function resetAll() {
|
||||
audioQuality.value = 'standard';
|
||||
downloadPath.value = '';
|
||||
localMusicPaths.value = [];
|
||||
theme.value = 'blue';
|
||||
appearance.value = 'dark';
|
||||
localMusicFolders.value = [];
|
||||
skin.value = 'dark-blue';
|
||||
customSkins.value = [];
|
||||
closeAction.value = 'ask';
|
||||
shortcuts.value = { ...defaultShortcuts };
|
||||
outputDevice.value = null;
|
||||
volume.value = 100;
|
||||
}
|
||||
|
||||
watch([audioQuality, downloadPath, localMusicPaths, theme, appearance, closeAction, shortcuts, outputDevice, volume], () => {
|
||||
watch([audioQuality, downloadPath, localMusicFolders, skin, customSkins, closeAction, shortcuts, outputDevice, volume], () => {
|
||||
const data: SettingsData = {
|
||||
audioQuality: audioQuality.value,
|
||||
downloadPath: downloadPath.value,
|
||||
localMusicPaths: localMusicPaths.value,
|
||||
theme: theme.value,
|
||||
appearance: appearance.value,
|
||||
localMusicPaths: [],
|
||||
localMusicFolders: localMusicFolders.value,
|
||||
skin: skin.value,
|
||||
customSkins: customSkins.value,
|
||||
closeAction: closeAction.value,
|
||||
shortcuts: shortcuts.value,
|
||||
outputDevice: outputDevice.value,
|
||||
@ -212,20 +305,29 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
return {
|
||||
audioQuality,
|
||||
downloadPath,
|
||||
localMusicPaths,
|
||||
theme,
|
||||
appearance,
|
||||
dataTheme,
|
||||
localMusicFolders,
|
||||
enabledMusicPaths,
|
||||
skin,
|
||||
customSkins,
|
||||
isPreset,
|
||||
currentCustomSkin,
|
||||
currentColors,
|
||||
skinPreview,
|
||||
currentWallpaper,
|
||||
closeAction,
|
||||
shortcuts,
|
||||
outputDevice,
|
||||
volume,
|
||||
setSkin,
|
||||
addCustomSkin,
|
||||
updateCustomSkin,
|
||||
removeCustomSkin,
|
||||
applySkin,
|
||||
setAudioQuality,
|
||||
setDownloadPath,
|
||||
addLocalMusicPath,
|
||||
removeLocalMusicPath,
|
||||
setTheme,
|
||||
setAppearance,
|
||||
toggleLocalMusicFolder,
|
||||
setCloseAction,
|
||||
setOutputDevice,
|
||||
setShortcut,
|
||||
|
||||
298
src/style.css
298
src/style.css
@ -23,52 +23,8 @@
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root,
|
||||
[data-theme="green"] {
|
||||
--c-bg: #020c06;
|
||||
--c-surface: #0a1a10;
|
||||
--c-subtle: rgba(34, 197, 94, 0.06);
|
||||
--c-muted: rgba(34, 197, 94, 0.10);
|
||||
--c-emphasis: rgba(34, 197, 94, 0.18);
|
||||
--c-content: #ffffff;
|
||||
--c-content-2: #9ca3af;
|
||||
--c-content-3: #6b7280;
|
||||
--c-content-4: #4b5563;
|
||||
--c-line: rgba(255, 255, 255, 0.08);
|
||||
--c-line-2: rgba(255, 255, 255, 0.04);
|
||||
--c-accent: #22c55e;
|
||||
--c-accent-hover: #16a34a;
|
||||
--c-accent-text: #4ade80;
|
||||
--c-accent-dim: rgba(34, 197, 94, 0.20);
|
||||
--c-danger: #ef4444;
|
||||
--c-danger-dim: rgba(239, 68, 68, 0.20);
|
||||
--c-warning: #eab308;
|
||||
--c-info: #3b82f6;
|
||||
}
|
||||
|
||||
[data-theme="rose"] {
|
||||
--c-bg: #0c0206;
|
||||
--c-surface: #1a0a10;
|
||||
--c-subtle: rgba(244, 63, 94, 0.06);
|
||||
--c-muted: rgba(244, 63, 94, 0.10);
|
||||
--c-emphasis: rgba(244, 63, 94, 0.18);
|
||||
--c-content: #ffffff;
|
||||
--c-content-2: #9ca3af;
|
||||
--c-content-3: #6b7280;
|
||||
--c-content-4: #4b5563;
|
||||
--c-line: rgba(255, 255, 255, 0.08);
|
||||
--c-line-2: rgba(255, 255, 255, 0.04);
|
||||
--c-accent: #f43f5e;
|
||||
--c-accent-hover: #e11d48;
|
||||
--c-accent-text: #fb7185;
|
||||
--c-accent-dim: rgba(244, 63, 94, 0.20);
|
||||
--c-danger: #ef4444;
|
||||
--c-danger-dim: rgba(239, 68, 68, 0.20);
|
||||
--c-warning: #eab308;
|
||||
--c-info: #3b82f6;
|
||||
}
|
||||
|
||||
[data-theme="blue"] {
|
||||
/* 默认值(首次加载 fallback,JS 会立即覆盖) */
|
||||
:root {
|
||||
--c-bg: #02060c;
|
||||
--c-surface: #0a101a;
|
||||
--c-subtle: rgba(59, 130, 246, 0.06);
|
||||
@ -90,250 +46,7 @@
|
||||
--c-info: #8b5cf6;
|
||||
}
|
||||
|
||||
[data-theme="violet"] {
|
||||
--c-bg: #06020c;
|
||||
--c-surface: #120a1a;
|
||||
--c-subtle: rgba(139, 92, 246, 0.06);
|
||||
--c-muted: rgba(139, 92, 246, 0.10);
|
||||
--c-emphasis: rgba(139, 92, 246, 0.18);
|
||||
--c-content: #ffffff;
|
||||
--c-content-2: #9ca3af;
|
||||
--c-content-3: #6b7280;
|
||||
--c-content-4: #4b5563;
|
||||
--c-line: rgba(255, 255, 255, 0.08);
|
||||
--c-line-2: rgba(255, 255, 255, 0.04);
|
||||
--c-accent: #8b5cf6;
|
||||
--c-accent-hover: #7c3aed;
|
||||
--c-accent-text: #a78bfa;
|
||||
--c-accent-dim: rgba(139, 92, 246, 0.20);
|
||||
--c-danger: #ef4444;
|
||||
--c-danger-dim: rgba(239, 68, 68, 0.20);
|
||||
--c-warning: #eab308;
|
||||
--c-info: #3b82f6;
|
||||
}
|
||||
|
||||
[data-theme="orange"] {
|
||||
--c-bg: #0c0602;
|
||||
--c-surface: #1a120a;
|
||||
--c-subtle: rgba(249, 115, 22, 0.06);
|
||||
--c-muted: rgba(249, 115, 22, 0.10);
|
||||
--c-emphasis: rgba(249, 115, 22, 0.18);
|
||||
--c-content: #ffffff;
|
||||
--c-content-2: #9ca3af;
|
||||
--c-content-3: #6b7280;
|
||||
--c-content-4: #4b5563;
|
||||
--c-line: rgba(255, 255, 255, 0.08);
|
||||
--c-line-2: rgba(255, 255, 255, 0.04);
|
||||
--c-accent: #f97316;
|
||||
--c-accent-hover: #ea580c;
|
||||
--c-accent-text: #fb923c;
|
||||
--c-accent-dim: rgba(249, 115, 22, 0.20);
|
||||
--c-danger: #ef4444;
|
||||
--c-danger-dim: rgba(239, 68, 68, 0.20);
|
||||
--c-warning: #eab308;
|
||||
--c-info: #3b82f6;
|
||||
}
|
||||
|
||||
[data-theme="cyan"] {
|
||||
--c-bg: #020c0c;
|
||||
--c-surface: #0a1a1a;
|
||||
--c-subtle: rgba(6, 182, 212, 0.06);
|
||||
--c-muted: rgba(6, 182, 212, 0.10);
|
||||
--c-emphasis: rgba(6, 182, 212, 0.18);
|
||||
--c-content: #ffffff;
|
||||
--c-content-2: #9ca3af;
|
||||
--c-content-3: #6b7280;
|
||||
--c-content-4: #4b5563;
|
||||
--c-line: rgba(255, 255, 255, 0.08);
|
||||
--c-line-2: rgba(255, 255, 255, 0.04);
|
||||
--c-accent: #06b6d4;
|
||||
--c-accent-hover: #0891b2;
|
||||
--c-accent-text: #22d3ee;
|
||||
--c-accent-dim: rgba(6, 182, 212, 0.20);
|
||||
--c-danger: #ef4444;
|
||||
--c-danger-dim: rgba(239, 68, 68, 0.20);
|
||||
--c-warning: #eab308;
|
||||
--c-info: #3b82f6;
|
||||
}
|
||||
|
||||
[data-theme="pink"] {
|
||||
--c-bg: #0c020a;
|
||||
--c-surface: #1a0a16;
|
||||
--c-subtle: rgba(236, 72, 153, 0.06);
|
||||
--c-muted: rgba(236, 72, 153, 0.10);
|
||||
--c-emphasis: rgba(236, 72, 153, 0.18);
|
||||
--c-content: #ffffff;
|
||||
--c-content-2: #9ca3af;
|
||||
--c-content-3: #6b7280;
|
||||
--c-content-4: #4b5563;
|
||||
--c-line: rgba(255, 255, 255, 0.08);
|
||||
--c-line-2: rgba(255, 255, 255, 0.04);
|
||||
--c-accent: #ec4899;
|
||||
--c-accent-hover: #db2777;
|
||||
--c-accent-text: #f472b6;
|
||||
--c-accent-dim: rgba(236, 72, 153, 0.20);
|
||||
--c-danger: #ef4444;
|
||||
--c-danger-dim: rgba(239, 68, 68, 0.20);
|
||||
--c-warning: #eab308;
|
||||
--c-info: #3b82f6;
|
||||
}
|
||||
|
||||
[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;
|
||||
height: 100%;
|
||||
overscroll-behavior: none;
|
||||
@ -342,7 +55,6 @@
|
||||
body {
|
||||
@apply antialiased;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
background: var(--c-bg);
|
||||
color: var(--c-content);
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@ -366,9 +78,13 @@
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: var(--c-muted);
|
||||
background-color: transparent;
|
||||
border-radius: 3px;
|
||||
}
|
||||
*:hover > ::-webkit-scrollbar-thumb,
|
||||
*:hover::-webkit-scrollbar-thumb {
|
||||
background-color: var(--c-muted);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--c-emphasis);
|
||||
}
|
||||
|
||||
32
src/utils/color.ts
Normal file
32
src/utils/color.ts
Normal file
@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 将 hex 颜色值转换为 rgba 字符串
|
||||
*/
|
||||
export function hexToRgba(hex: string, alpha: number): string {
|
||||
const rgbMatch = hex.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
|
||||
if (rgbMatch) {
|
||||
return `rgba(${rgbMatch[1]}, ${rgbMatch[2]}, ${rgbMatch[3]}, ${alpha})`;
|
||||
}
|
||||
let h = hex.replace('#', '');
|
||||
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
|
||||
const r = parseInt(h.substring(0, 2), 16);
|
||||
const g = parseInt(h.substring(2, 4), 16);
|
||||
const b = parseInt(h.substring(4, 6), 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将颜色值(hex 或 rgba)转换为 hex 格式(供 input[type=color] 使用)
|
||||
*/
|
||||
export function toHex(color: string): string {
|
||||
if (color.startsWith('#')) {
|
||||
return color.length === 4
|
||||
? '#' + color[1] + color[1] + color[2] + color[2] + color[3] + color[3]
|
||||
: color;
|
||||
}
|
||||
const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
|
||||
if (!m) return '#000000';
|
||||
const r = parseInt(m[1]).toString(16).padStart(2, '0');
|
||||
const g = parseInt(m[2]).toString(16).padStart(2, '0');
|
||||
const b = parseInt(m[3]).toString(16).padStart(2, '0');
|
||||
return `#${r}${g}${b}`;
|
||||
}
|
||||
@ -5,6 +5,8 @@ export interface Song {
|
||||
al: { id?: number; picUrl: string; name?: string };
|
||||
dt?: number;
|
||||
localPath?: string;
|
||||
alg?: string;
|
||||
br?: number;
|
||||
}
|
||||
|
||||
export function normalizeSong(song: any): Song {
|
||||
@ -13,7 +15,9 @@ export function normalizeSong(song: any): Song {
|
||||
picUrl: song.al?.picUrl || song.album?.picUrl || '',
|
||||
name: song.al?.name || song.album?.name,
|
||||
};
|
||||
const ar = (song.ar && song.ar.length > 0) ? song.ar : (song.artists || []);
|
||||
const rawAr = (song.ar && song.ar.length > 0) ? song.ar : (song.artists || []);
|
||||
// 过滤掉 id 和 name 同时不存在的歌手(下线艺人等)
|
||||
const ar = rawAr.filter((a: any) => a.name);
|
||||
let dt = song.dt || song.duration || 0;
|
||||
if (dt < 100 || dt > 7200000) dt = 0;
|
||||
return {
|
||||
@ -23,6 +27,8 @@ export function normalizeSong(song: any): Song {
|
||||
al,
|
||||
dt,
|
||||
localPath: song.localPath,
|
||||
alg: song.alg || undefined,
|
||||
br: song.br || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@ -34,7 +40,21 @@ export function getCoverUrl(song: Song | null, sizeParam = ''): string {
|
||||
return raw + sizeParam;
|
||||
}
|
||||
|
||||
export function getArtistDisplay(song: Song): string {
|
||||
if (!song.ar || song.ar.length === 0) return '未知歌手';
|
||||
const names = song.ar
|
||||
.filter(a => a.id != null && a.name)
|
||||
.map(a => a.name);
|
||||
return names.length > 0 ? names.join(' / ') : '未知歌手';
|
||||
}
|
||||
|
||||
export function getAlbumDisplay(song: Song): string {
|
||||
if (!song.al?.id || !song.al?.name) return '未知专辑';
|
||||
return song.al.name;
|
||||
}
|
||||
|
||||
const colorCache = new Map<string, string>();
|
||||
const MAX_COLOR_CACHE = 200;
|
||||
|
||||
export function extractDominantColor(imageUrl: string): Promise<string> {
|
||||
if (colorCache.has(imageUrl)) {
|
||||
@ -67,6 +87,10 @@ export function extractDominantColor(imageUrl: string): Promise<string> {
|
||||
b = Math.round(b / count);
|
||||
|
||||
const color = `rgb(${r}, ${g}, ${b})`;
|
||||
if (colorCache.size >= MAX_COLOR_CACHE) {
|
||||
const firstKey = colorCache.keys().next().value;
|
||||
if (firstKey !== undefined) colorCache.delete(firstKey);
|
||||
}
|
||||
colorCache.set(imageUrl, color);
|
||||
resolve(color);
|
||||
} catch {
|
||||
|
||||
@ -62,23 +62,18 @@
|
||||
</div>
|
||||
|
||||
<!-- 歌曲列表 -->
|
||||
<div v-else-if="songs.length" 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>
|
||||
<VirtualSongList
|
||||
v-else-if="songs.length"
|
||||
:songs="songs"
|
||||
:current-song-id="player.currentSong?.id"
|
||||
show-index
|
||||
show-like
|
||||
show-download
|
||||
show-menu
|
||||
show-duration
|
||||
show-playing-overlay
|
||||
@song-click="(_s, i) => player.playFromList(songs, i)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -90,7 +85,7 @@ import { usePlayerStore } from '../stores/player';
|
||||
import { normalizeSong, type Song } from '../utils/song';
|
||||
import { formatDate } from '../utils/format';
|
||||
import { pageCacheGet, pageCacheSet } from '../composables/usePageCache';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
import VirtualSongList from '../components/VirtualSongList.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import IconPlay from '~icons/lucide/play';
|
||||
|
||||
|
||||
@ -114,23 +114,18 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="songs.length" 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>
|
||||
<VirtualSongList
|
||||
v-else-if="songs.length"
|
||||
:songs="songs"
|
||||
:current-song-id="player.currentSong?.id"
|
||||
show-index
|
||||
show-like
|
||||
show-download
|
||||
show-menu
|
||||
show-duration
|
||||
show-playing-overlay
|
||||
@song-click="(_s, i) => player.playFromList(songs, i)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 专辑列表 -->
|
||||
@ -171,7 +166,7 @@ import { usePlayerStore } from '../stores/player';
|
||||
import { formatPlayCount, formatDate } from '../utils/format';
|
||||
import { normalizeSong, type Song } from '../utils/song';
|
||||
import { pageCacheGet, pageCacheSet } from '../composables/usePageCache';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
import VirtualSongList from '../components/VirtualSongList.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import IconPlay from '~icons/lucide/play';
|
||||
import IconMusic from '~icons/lucide/music';
|
||||
|
||||
@ -23,30 +23,25 @@
|
||||
<p class="text-content-2 text-sm">加载失败</p>
|
||||
<button @click="loadData(true)" class="px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition">重试</button>
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<SongListItem
|
||||
v-for="(song, index) in songs"
|
||||
:key="song.id"
|
||||
:song="song"
|
||||
:index="index"
|
||||
:is-current="isCurrentSong(song.id)"
|
||||
show-index
|
||||
show-like
|
||||
show-download
|
||||
show-menu
|
||||
show-duration
|
||||
show-playing-overlay
|
||||
:container-class="isCurrentSong(song.id) ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
|
||||
@click="player.playFromList(songs, index)"
|
||||
/>
|
||||
</div>
|
||||
<VirtualSongList
|
||||
v-else
|
||||
:songs="songs"
|
||||
:current-song-id="player.currentSong?.id"
|
||||
show-index
|
||||
show-like
|
||||
show-download
|
||||
show-menu
|
||||
show-duration
|
||||
show-playing-overlay
|
||||
@song-click="(_s, i) => player.playFromList(songs, i)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onActivated, watch } from 'vue';
|
||||
import { MusicApi } from '../api';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
import VirtualSongList from '../components/VirtualSongList.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { pageCacheGet, pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
|
||||
@ -59,10 +54,6 @@ const songs = ref<Song[]>([]);
|
||||
const loading = ref(true);
|
||||
const loadError = ref(false);
|
||||
|
||||
function isCurrentSong(songId: number): boolean {
|
||||
return player.currentSong?.id === songId;
|
||||
}
|
||||
|
||||
async function loadData(force = false) {
|
||||
if (!force) {
|
||||
const cached = pageCacheGet('dailySongs');
|
||||
|
||||
@ -28,30 +28,25 @@
|
||||
<button @click="loadData(true)" class="px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition">重试</button>
|
||||
</div>
|
||||
<div v-else-if="songs.length === 0" class="text-content-2">暂无喜欢的音乐</div>
|
||||
<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>
|
||||
<VirtualSongList
|
||||
v-else
|
||||
:songs="songs"
|
||||
:current-song-id="player.currentSong?.id"
|
||||
show-index
|
||||
show-like
|
||||
show-download
|
||||
show-menu
|
||||
show-duration
|
||||
show-playing-overlay
|
||||
@song-click="(_s, i) => player.playFromList(songs, i)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onActivated, watch } from 'vue';
|
||||
import { MusicApi } from '../api';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
import VirtualSongList from '../components/VirtualSongList.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { useUserStore } from '../stores/user';
|
||||
|
||||
@ -4,19 +4,12 @@
|
||||
<h1 class="text-2xl font-bold">本地音乐</h1>
|
||||
<span v-if="songs.length" class="text-xs text-content-3">{{ songs.length }} 首</span>
|
||||
<template #actions>
|
||||
<button
|
||||
@click="refresh"
|
||||
class="px-3 py-1 bg-muted hover:bg-emphasis rounded-full text-xs transition"
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
<button
|
||||
@click="showFolderModal = true"
|
||||
class="text-content-3 hover:text-content transition p-1 rounded hover:bg-muted"
|
||||
title="文件夹管理"
|
||||
>
|
||||
<IconEllipsis class="w-5 h-5 fill-current" />
|
||||
<button @click="cycleSort" class="px-3 py-1 bg-muted hover:bg-emphasis rounded-full text-xs transition-all flex items-center justify-center gap-1 whitespace-nowrap">
|
||||
<IconArrowUpDown class="w-3 h-3" />
|
||||
{{ sortLabel }}
|
||||
</button>
|
||||
<button @click="refresh" class="px-3 py-1 bg-muted hover:bg-emphasis rounded-full text-xs transition">刷新</button>
|
||||
<button @click="showFolderModal = true" class="px-3 py-1 bg-muted hover:bg-emphasis rounded-full text-xs transition">扫描目录</button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
@ -29,27 +22,42 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="settings.localMusicPaths.length === 0" class="text-content-3 py-4">
|
||||
<div v-else-if="settings.localMusicFolders.length === 0" class="text-content-3 py-4">
|
||||
请先添加要扫描的文件夹
|
||||
</div>
|
||||
<div v-else-if="settings.enabledMusicPaths.length === 0" class="text-content-3 py-4">
|
||||
请至少启用一个扫描文件夹
|
||||
</div>
|
||||
<div v-else-if="songs.length === 0" class="text-content-3">
|
||||
当前文件夹下没有音乐文件,支持 mp3、flac、wav、ogg、aac、m4a 格式
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<SongListItem
|
||||
v-for="(song, index) in normalizedSongs"
|
||||
v-for="(song, index) in sortedSongs"
|
||||
:key="song.id + '-' + index"
|
||||
:song="song"
|
||||
:song="sortedNormalized[index]"
|
||||
:index="index"
|
||||
:is-current="player.currentSong?.id === song.id"
|
||||
show-index
|
||||
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(normalizedSongs, index)"
|
||||
@click="player.playFromList(sortedNormalized, index)"
|
||||
>
|
||||
<template #actions>
|
||||
<span class="text-xs text-content-3 flex-shrink-0">{{ formatFileSize(songs[index].fileSize) }}</span>
|
||||
<span class="text-xs text-content-3 flex-shrink-0">{{ formatFileSize(song.fileSize) }}</span>
|
||||
<div class="relative flex-shrink-0" :ref="(el: any) => menuRefs[song.id] = el">
|
||||
<button @click.stop="toggleMenu(song.id)" class="text-content-3 hover:text-content transition p-1 rounded-md hover:bg-subtle">
|
||||
<IconEllipsis class="w-4 h-4 fill-current" />
|
||||
</button>
|
||||
<div v-if="openMenuId === song.id"
|
||||
class="absolute right-0 top-full mt-1 bg-surface border border-line rounded-xl shadow-xl z-50 py-1 min-w-[140px]">
|
||||
<button @click.stop="openFolder(song.path)" class="w-full flex items-center gap-2 px-3 py-2 text-sm text-content-2 hover:bg-subtle hover:text-content transition whitespace-nowrap">
|
||||
<IconFolderOpen class="w-3.5 h-3.5" />
|
||||
打开所在文件夹
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SongListItem>
|
||||
</div>
|
||||
@ -59,35 +67,28 @@
|
||||
<div v-if="showFolderModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showFolderModal = false">
|
||||
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-[420px] p-6 select-auto">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold">扫描文件夹</h2>
|
||||
<h2 class="text-lg font-semibold">扫描目录</h2>
|
||||
<button @click="showFolderModal = false" class="text-content-3 hover:text-content transition">
|
||||
<IconX class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="settings.localMusicPaths.length === 0" class="text-sm text-content-3 py-4 text-center">
|
||||
<div v-if="settings.localMusicFolders.length === 0" class="text-sm text-content-3 py-4 text-center">
|
||||
未添加任何文件夹
|
||||
</div>
|
||||
<div v-else class="space-y-1.5 max-h-60 overflow-y-auto mb-4">
|
||||
<div
|
||||
v-for="p in settings.localMusicPaths"
|
||||
:key="p"
|
||||
class="flex items-center gap-2 px-3 py-2 bg-subtle rounded-lg group"
|
||||
>
|
||||
<div v-for="folder in settings.localMusicFolders" :key="folder.path" class="flex items-center gap-2 px-3 py-2 bg-subtle rounded-lg group">
|
||||
<button @click="settings.toggleLocalMusicFolder(folder.path)" class="flex-shrink-0" :title="folder.enabled ? '点击禁用' : '点击启用'">
|
||||
<IconCheckSquare v-if="folder.enabled" class="w-4 h-4 text-accent-text" />
|
||||
<IconSquare v-else class="w-4 h-4 text-content-4" />
|
||||
</button>
|
||||
<IconFolder class="w-4 h-4 text-content-3 flex-shrink-0" />
|
||||
<span class="text-sm text-content-2 truncate flex-1" :title="p">{{ p }}</span>
|
||||
<button
|
||||
@click="settings.removeLocalMusicPath(p)"
|
||||
class="text-content-4 hover:text-danger transition opacity-0 group-hover:opacity-100"
|
||||
title="移除"
|
||||
>
|
||||
<span class="text-sm truncate flex-1" :class="folder.enabled ? 'text-content-2' : 'text-content-4 line-through'" :title="folder.path">{{ folder.path }}</span>
|
||||
<button @click="settings.removeLocalMusicPath(folder.path)" class="text-content-4 hover:text-danger transition opacity-0 group-hover:opacity-100" title="移除">
|
||||
<IconX class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="addFolder"
|
||||
class="w-full py-2.5 rounded-lg bg-accent/15 text-accent-text hover:bg-accent/25 text-sm font-medium transition"
|
||||
>
|
||||
<button @click="addFolder" class="w-full py-2.5 rounded-lg bg-accent/15 text-accent-text hover:bg-accent/25 text-sm font-medium transition">
|
||||
添加文件夹
|
||||
</button>
|
||||
</div>
|
||||
@ -97,18 +98,23 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onActivated, watch } from 'vue';
|
||||
import { DownloadApi } from '../api';
|
||||
import { ref, computed, onMounted, onActivated, watch, onBeforeUnmount } from 'vue';
|
||||
import { AppApi, DownloadApi } from '../api';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { useSettingsStore } from '../stores/settings';
|
||||
import { pageCacheSet, pageCacheIsStale } from '../composables/usePageCache';
|
||||
import { formatFileSize, localSongToSong, fetchMissingCovers, type LocalSong } from '../composables/useLocalMusic';
|
||||
import { showToast } from '../composables/useToast';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import IconEllipsis from '~icons/lucide/ellipsis';
|
||||
import IconFolder from '~icons/lucide/folder';
|
||||
import IconFolderOpen from '~icons/lucide/folder-open';
|
||||
import IconX from '~icons/lucide/x';
|
||||
import IconArrowUpDown from '~icons/lucide/arrow-up-down';
|
||||
import IconCheckSquare from '~icons/lucide/check-square';
|
||||
import IconSquare from '~icons/lucide/square';
|
||||
import IconEllipsis from '~icons/lucide/ellipsis';
|
||||
|
||||
defineOptions({ name: 'LocalMusicView' });
|
||||
|
||||
@ -119,7 +125,59 @@ const songs = ref<LocalSong[]>([]);
|
||||
const loading = ref(true);
|
||||
const showFolderModal = ref(false);
|
||||
|
||||
const normalizedSongs = computed(() => songs.value.map(localSongToSong));
|
||||
// 排序:点击循环切换
|
||||
type SortKey = 'default' | 'name' | 'size';
|
||||
const SORT_CYCLE: SortKey[] = ['default', 'name', 'size'];
|
||||
const SORT_LABELS: Record<SortKey, string> = { default: '默认', name: '名称', size: '大小' };
|
||||
const sortBy = ref<SortKey>('default');
|
||||
|
||||
const sortLabel = computed(() => SORT_LABELS[sortBy.value]);
|
||||
|
||||
function cycleSort() {
|
||||
const idx = SORT_CYCLE.indexOf(sortBy.value);
|
||||
sortBy.value = SORT_CYCLE[(idx + 1) % SORT_CYCLE.length];
|
||||
}
|
||||
|
||||
const sortedSongs = computed(() => {
|
||||
const list = [...songs.value];
|
||||
if (sortBy.value === 'name') {
|
||||
list.sort((a, b) => a.name.localeCompare(b.name, 'zh-Hans-CN'));
|
||||
} else if (sortBy.value === 'size') {
|
||||
list.sort((a, b) => b.fileSize - a.fileSize);
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
const sortedNormalized = computed(() => sortedSongs.value.map(localSongToSong));
|
||||
|
||||
// 三点菜单
|
||||
const openMenuId = ref<number | null>(null);
|
||||
const menuRefs: Record<number, HTMLElement | null> = {};
|
||||
|
||||
function toggleMenu(id: number) {
|
||||
openMenuId.value = openMenuId.value === id ? null : id;
|
||||
}
|
||||
|
||||
async function openFolder(path: string) {
|
||||
openMenuId.value = null;
|
||||
try {
|
||||
await AppApi.showItemInFolder(path);
|
||||
} catch (e: any) {
|
||||
showToast(e.toString(), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
if (openMenuId.value !== null) {
|
||||
const el = menuRefs[openMenuId.value];
|
||||
if (el && !el.contains(e.target as Node)) {
|
||||
openMenuId.value = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('click', onClickOutside));
|
||||
onBeforeUnmount(() => document.removeEventListener('click', onClickOutside));
|
||||
|
||||
async function addFolder() {
|
||||
const selected = await open({
|
||||
@ -133,14 +191,15 @@ async function addFolder() {
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (settings.localMusicPaths.length === 0) {
|
||||
const paths = settings.enabledMusicPaths;
|
||||
if (paths.length === 0) {
|
||||
songs.value = [];
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const list = await DownloadApi.scanLocalFolders(settings.localMusicPaths);
|
||||
const list = await DownloadApi.scanLocalFolders(paths);
|
||||
songs.value = list;
|
||||
pageCacheSet('localMusic', list);
|
||||
fetchMissingCovers(songs.value);
|
||||
@ -157,7 +216,7 @@ onActivated(() => {
|
||||
if (pageCacheIsStale('localMusic')) refresh();
|
||||
});
|
||||
|
||||
watch(() => settings.localMusicPaths, () => { refresh(); }, { deep: true });
|
||||
watch(() => settings.enabledMusicPaths, () => { refresh(); }, { deep: true });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@ -100,23 +100,18 @@
|
||||
</div>
|
||||
|
||||
<!-- 歌曲列表 -->
|
||||
<div v-else-if="songs.length" 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>
|
||||
<VirtualSongList
|
||||
v-else-if="songs.length"
|
||||
:songs="songs"
|
||||
:current-song-id="player.currentSong?.id"
|
||||
show-index
|
||||
show-like
|
||||
show-download
|
||||
show-menu
|
||||
show-duration
|
||||
show-playing-overlay
|
||||
@song-click="(_s, i) => player.playFromList(songs, i)"
|
||||
/>
|
||||
|
||||
<div v-else-if="!songsLoading && !loadError" class="text-content-2">暂无歌曲</div>
|
||||
|
||||
@ -136,7 +131,7 @@ import { showToast } from '../composables/useToast';
|
||||
import { formatPlayCount } from '../utils/format';
|
||||
import { normalizeSong, type Song } from '../utils/song';
|
||||
import { pageCacheGet, pageCacheSet } from '../composables/usePageCache';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
import VirtualSongList from '../components/VirtualSongList.vue';
|
||||
import CommentSection from '../components/CommentSection.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import IconPlay from '~icons/lucide/play';
|
||||
|
||||
@ -4,29 +4,24 @@
|
||||
<h1 class="text-2xl font-bold">最近播放</h1>
|
||||
</PageHeader>
|
||||
<div v-if="player.recentLocal.length === 0" class="text-content-3">还没有播放记录,去听首歌吧</div>
|
||||
<div v-else class="space-y-2">
|
||||
<SongListItem
|
||||
v-for="(song, index) in player.recentLocal"
|
||||
:key="song.id"
|
||||
:song="song"
|
||||
:index="index"
|
||||
:is-current="player.currentSong?.id === song.id"
|
||||
show-index
|
||||
show-like
|
||||
show-download
|
||||
show-menu
|
||||
show-duration
|
||||
show-playing-overlay
|
||||
:container-class="player.currentSong?.id === song.id ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
|
||||
@click="player.playFromList(player.recentLocal, index)"
|
||||
/>
|
||||
</div>
|
||||
<VirtualSongList
|
||||
v-else
|
||||
:songs="player.recentLocal"
|
||||
:current-song-id="player.currentSong?.id"
|
||||
show-index
|
||||
show-like
|
||||
show-download
|
||||
show-menu
|
||||
show-duration
|
||||
show-playing-overlay
|
||||
@song-click="(_s, i) => player.playFromList(player.recentLocal, i)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
import VirtualSongList from '../components/VirtualSongList.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
|
||||
const player = usePlayerStore();
|
||||
|
||||
@ -26,37 +26,84 @@
|
||||
</section>
|
||||
|
||||
<section class="mb-8">
|
||||
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">外观</h2>
|
||||
<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>
|
||||
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">皮肤</h2>
|
||||
<div class="space-y-4">
|
||||
<!-- 明暗切换 -->
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="settings.setSkin(toSkinId(currentThemeColor, 'dark'))"
|
||||
class="flex items-center gap-2 px-4 py-2 rounded-xl transition-all border-2"
|
||||
:class="!settings.isPreset || currentAppearance !== 'dark' ? 'border-transparent bg-subtle hover:bg-muted' : 'border-accent/40 bg-accent/10'"
|
||||
>
|
||||
<IconMoon class="w-4 h-4" :class="currentAppearance === 'dark' && settings.isPreset ? 'text-accent-text' : 'text-content-3'" />
|
||||
<span class="text-sm" :class="currentAppearance === 'dark' && settings.isPreset ? 'text-content font-medium' : 'text-content-3'">深色</span>
|
||||
</button>
|
||||
<button
|
||||
@click="settings.setSkin(toSkinId(currentThemeColor, 'light'))"
|
||||
class="flex items-center gap-2 px-4 py-2 rounded-xl transition-all border-2"
|
||||
:class="!settings.isPreset || currentAppearance !== 'light' ? 'border-transparent bg-subtle hover:bg-muted' : 'border-accent/40 bg-accent/10'"
|
||||
>
|
||||
<IconSun class="w-4 h-4" :class="currentAppearance === 'light' && settings.isPreset ? 'text-accent-text' : 'text-content-3'" />
|
||||
<span class="text-sm" :class="currentAppearance === 'light' && settings.isPreset ? 'text-content font-medium' : 'text-content-3'">浅色</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 主题色选择 -->
|
||||
<div class="grid grid-cols-7 gap-2">
|
||||
<div
|
||||
v-for="tc in themeColorOptions"
|
||||
:key="tc.id"
|
||||
@click="settings.setSkin(toSkinId(tc.id, currentAppearance))"
|
||||
class="flex flex-col items-center gap-1.5 p-2 rounded-xl transition-all border-2 cursor-pointer"
|
||||
:class="currentThemeColor === tc.id && settings.isPreset ? '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: tc.color }"></div>
|
||||
<span class="text-[11px]" :class="currentThemeColor === tc.id && settings.isPreset ? 'text-content font-medium' : 'text-content-3'">{{ tc.name }}</span>
|
||||
</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="space-y-2">
|
||||
<p class="text-xs text-content-3">自定义</p>
|
||||
<div class="grid grid-cols-5 gap-2">
|
||||
<div
|
||||
v-for="s in settings.customSkins"
|
||||
:key="s.id"
|
||||
@click="settings.setSkin(s.id)"
|
||||
class="flex flex-col items-center gap-1.5 p-2 rounded-xl transition-all border-2 cursor-pointer relative group"
|
||||
:class="settings.skin === s.id ? '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 class="w-8 h-8 rounded-full shadow-md relative overflow-hidden" :style="{ backgroundColor: s.preview }">
|
||||
<div v-if="s.wallpaper && skinWallpaperDataUrls[s.wallpaper]" class="absolute inset-0 bg-cover bg-center opacity-40" :style="{ backgroundImage: `url(${skinWallpaperDataUrls[s.wallpaper]})` }"></div>
|
||||
</div>
|
||||
<span class="text-[11px] truncate w-full text-center" :class="settings.skin === s.id ? 'text-content font-medium' : 'text-content-3'">{{ s.name }}</span>
|
||||
<!-- 编辑按钮 -->
|
||||
<button
|
||||
@click.stop="openSkinEditor(s.id)"
|
||||
class="absolute -top-1 -right-1 w-4 h-4 flex items-center justify-center rounded-full bg-accent/60 text-white opacity-0 group-hover:opacity-100 transition"
|
||||
title="编辑"
|
||||
>
|
||||
<svg class="w-2 h-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
</button>
|
||||
<!-- 删除按钮 -->
|
||||
<button
|
||||
@click.stop="handleDeleteCustomSkin(s.id)"
|
||||
class="absolute -top-1 -left-1 w-4 h-4 flex items-center justify-center rounded-full bg-danger/80 text-white opacity-0 group-hover:opacity-100 transition"
|
||||
title="删除"
|
||||
>
|
||||
<IconX style="font-size: 8px" />
|
||||
</button>
|
||||
</div>
|
||||
<!-- 创建自定义皮肤(永远排最后) -->
|
||||
<div
|
||||
@click="openSkinEditor()"
|
||||
class="flex flex-col items-center justify-center gap-1.5 p-2 rounded-xl transition-all border-2 border-dashed border-line cursor-pointer hover:border-accent/40 hover:bg-accent/5"
|
||||
>
|
||||
<div class="w-8 h-8 rounded-full flex items-center justify-center bg-subtle">
|
||||
<IconPalette class="w-4 h-4 text-content-3" />
|
||||
</div>
|
||||
<span class="text-[11px] text-content-3">自定义</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -201,7 +248,7 @@
|
||||
</div>
|
||||
</section>
|
||||
<Transition name="fade">
|
||||
<div v-if="showResetConfirm" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showResetConfirm = false">
|
||||
<div v-if="showResetConfirm" class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showResetConfirm = false">
|
||||
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-80 p-6 select-auto">
|
||||
<h2 class="text-lg font-semibold text-content mb-1">确认重置</h2>
|
||||
<p class="text-sm text-content-2 mb-5">所有设置将恢复为默认值,此操作不可撤销。</p>
|
||||
@ -220,7 +267,7 @@
|
||||
</Transition>
|
||||
|
||||
<Transition name="fade">
|
||||
<div v-if="showChangelogModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showChangelogModal = false">
|
||||
<div v-if="showChangelogModal" class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showChangelogModal = false">
|
||||
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-[480px] max-h-[80vh] flex flex-col select-auto">
|
||||
<div class="p-6 pb-4">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
@ -249,15 +296,366 @@
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- 皮肤编辑器弹窗(Teleport 到 body 避免 z-index 问题) -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="showSkinEditor" class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showSkinEditor = false">
|
||||
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-[960px] max-h-[90vh] flex flex-col select-auto">
|
||||
<!-- 顶栏 -->
|
||||
<div class="flex items-center justify-between p-5 border-b border-line-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-lg font-semibold">{{ editingSkinId ? '编辑皮肤' : '创建自定义皮肤' }}</h2>
|
||||
<input v-model="editorName" class="px-3 py-1.5 bg-subtle border border-line rounded-lg text-sm text-content focus:border-accent focus:outline-none transition w-40" placeholder="皮肤名称" />
|
||||
</div>
|
||||
<button @click="showSkinEditor = false" class="text-content-3 hover:text-content transition">
|
||||
<IconX class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<!-- 左侧:实时预览 -->
|
||||
<div class="w-[420px] flex-shrink-0 p-5 border-r border-line-2 flex flex-col gap-4">
|
||||
<p class="text-xs text-content-3 font-medium">实时预览</p>
|
||||
<!-- 横向桌面比例预览 -->
|
||||
<div class="rounded-xl overflow-hidden border border-line relative" style="aspect-ratio: 16/10;" :style="{ backgroundColor: getEditorColor('bg') }">
|
||||
<!-- 壁纸层 -->
|
||||
<div v-if="editorWallpaper && editorWallpaperDataUrl" class="absolute inset-0 bg-cover bg-center" :style="{ backgroundImage: `url(${editorWallpaperDataUrl})`, filter: `blur(${editorWallpaperBlur}px)`, opacity: editorWallpaperOpacity }"></div>
|
||||
<!-- 无壁纸时的提示 -->
|
||||
<div v-if="!editorWallpaper" class="absolute inset-0 flex items-center justify-center">
|
||||
<span class="text-[10px] opacity-20" :style="{ color: getEditorColor('content3') }">纯色背景</span>
|
||||
</div>
|
||||
<!-- 模拟内容 -->
|
||||
<div class="relative z-[1] flex flex-col h-full">
|
||||
<!-- 模拟 TitleBar -->
|
||||
<div class="h-5 flex items-center justify-end px-2 flex-shrink-0" :style="{ backgroundColor: `${getEditorColor('surface')}cc` }">
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-red-500"></div>
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-yellow-500 ml-1"></div>
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-green-500 ml-1"></div>
|
||||
</div>
|
||||
<div class="flex flex-1 min-h-0">
|
||||
<!-- 模拟 Sidebar (w-56 比例) -->
|
||||
<div class="w-[30%] flex-shrink-0 flex flex-col p-1.5 gap-0.5" :style="{ backgroundColor: `${getEditorColor('surface')}cc`, borderRight: `1px solid ${getEditorColor('line')}` }">
|
||||
<div class="flex items-center gap-1 px-1.5 py-1 rounded" :style="{ backgroundColor: getEditorColor('muted') }">
|
||||
<div class="w-1.5 h-1.5 rounded-sm" :style="{ backgroundColor: getEditorColor('accent') }"></div>
|
||||
<div class="h-1 rounded-full w-6" :style="{ backgroundColor: getEditorColor('content') }"></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 px-1.5 py-1 rounded">
|
||||
<div class="w-1.5 h-1.5 rounded-sm" :style="{ backgroundColor: getEditorColor('content3') }"></div>
|
||||
<div class="h-1 rounded-full w-5" :style="{ backgroundColor: getEditorColor('content2') }"></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 px-1.5 py-1 rounded">
|
||||
<div class="w-1.5 h-1.5 rounded-sm" :style="{ backgroundColor: getEditorColor('content3') }"></div>
|
||||
<div class="h-1 rounded-full w-4" :style="{ backgroundColor: getEditorColor('content2') }"></div>
|
||||
</div>
|
||||
<div class="mt-1 pt-1" :style="{ borderTop: `1px solid ${getEditorColor('line2')}` }">
|
||||
<div class="h-0.5 rounded-full w-4 mb-0.5" :style="{ backgroundColor: getEditorColor('content4') }"></div>
|
||||
<div class="flex items-center gap-1 px-1.5 py-1 rounded">
|
||||
<div class="w-1.5 h-1.5 rounded-sm" :style="{ backgroundColor: getEditorColor('content3') }"></div>
|
||||
<div class="h-1 rounded-full w-7" :style="{ backgroundColor: getEditorColor('content2') }"></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 px-1.5 py-1 rounded">
|
||||
<div class="w-1.5 h-1.5 rounded-sm" :style="{ backgroundColor: getEditorColor('content3') }"></div>
|
||||
<div class="h-1 rounded-full w-6" :style="{ backgroundColor: getEditorColor('content2') }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 底部设置+头像 -->
|
||||
<div class="mt-auto flex items-center gap-1 px-1.5 py-1">
|
||||
<div class="w-3 h-3 rounded-full" :style="{ backgroundColor: getEditorColor('subtle') }"></div>
|
||||
<div class="h-1 rounded-full w-5" :style="{ backgroundColor: getEditorColor('content3') }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 模拟主内容 -->
|
||||
<div class="flex-1 p-2 flex flex-col gap-1.5 overflow-hidden" :style="editorWallpaper ? { backgroundColor: `${getEditorColor('bg')}cc` } : {}">
|
||||
<div class="h-2 rounded-full w-14" :style="{ backgroundColor: getEditorColor('content') }"></div>
|
||||
<div class="h-1 rounded-full w-20" :style="{ backgroundColor: getEditorColor('content3') }"></div>
|
||||
<!-- 模拟歌曲行 -->
|
||||
<div class="mt-1 flex items-center gap-1 px-1 py-0.5 rounded" :style="{ backgroundColor: `${getEditorColor('surface')}99` }">
|
||||
<div class="w-2 text-right flex-shrink-0"><div class="h-0.5 rounded-full w-1.5 ml-auto" :style="{ backgroundColor: getEditorColor('content4') }"></div></div>
|
||||
<div class="w-4 h-4 rounded flex-shrink-0" :style="{ backgroundColor: getEditorColor('subtle') }"></div>
|
||||
<div class="flex-1 flex flex-col gap-0.5 min-w-0">
|
||||
<div class="h-0.5 rounded-full w-12" :style="{ backgroundColor: getEditorColor('content') }"></div>
|
||||
<div class="h-0.5 rounded-full w-8" :style="{ backgroundColor: getEditorColor('content3') }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 px-1 py-0.5 rounded">
|
||||
<div class="w-2 text-right flex-shrink-0"><div class="h-0.5 rounded-full w-1.5 ml-auto" :style="{ backgroundColor: getEditorColor('content4') }"></div></div>
|
||||
<div class="w-4 h-4 rounded flex-shrink-0" :style="{ backgroundColor: getEditorColor('subtle') }"></div>
|
||||
<div class="flex-1 flex flex-col gap-0.5 min-w-0">
|
||||
<div class="h-0.5 rounded-full w-10" :style="{ backgroundColor: getEditorColor('content2') }"></div>
|
||||
<div class="h-0.5 rounded-full w-14" :style="{ backgroundColor: getEditorColor('content3') }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 选中行(均衡器动画) -->
|
||||
<div class="flex items-center gap-1 px-1 py-0.5 rounded" :style="{ backgroundColor: getEditorColor('accentDim') }">
|
||||
<div class="w-2 flex items-center justify-end flex-shrink-0 gap-[1px]">
|
||||
<div class="w-[1px] rounded-full" :style="{ backgroundColor: getEditorColor('accentText'), height: '3px' }"></div>
|
||||
<div class="w-[1px] rounded-full" :style="{ backgroundColor: getEditorColor('accentText'), height: '5px' }"></div>
|
||||
<div class="w-[1px] rounded-full" :style="{ backgroundColor: getEditorColor('accentText'), height: '2px' }"></div>
|
||||
</div>
|
||||
<div class="w-4 h-4 rounded flex-shrink-0" :style="{ backgroundColor: getEditorColor('subtle') }"></div>
|
||||
<div class="flex-1 flex flex-col gap-0.5 min-w-0">
|
||||
<div class="h-0.5 rounded-full w-11" :style="{ backgroundColor: getEditorColor('accentText') }"></div>
|
||||
<div class="h-0.5 rounded-full w-8" :style="{ backgroundColor: getEditorColor('content3') }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 模拟按钮 -->
|
||||
<div class="flex gap-1 mt-0.5">
|
||||
<div class="px-2 py-0.5 rounded text-[6px] font-medium text-white" :style="{ backgroundColor: getEditorColor('accent') }">播放全部</div>
|
||||
<div class="px-2 py-0.5 rounded text-[6px]" :style="{ backgroundColor: getEditorColor('muted'), color: getEditorColor('content2') }">收藏</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 模拟 PlayerBar -->
|
||||
<div class="flex-shrink-0 flex flex-col" :style="{ backgroundColor: `${getEditorColor('surface')}f2` }">
|
||||
<!-- 进度条 -->
|
||||
<div class="h-0.5 w-full" :style="{ backgroundColor: getEditorColor('muted') }">
|
||||
<div class="h-full w-1/3" :style="{ backgroundColor: getEditorColor('accent') }"></div>
|
||||
</div>
|
||||
<div class="flex items-center px-2 h-6 gap-1.5">
|
||||
<!-- 封面+歌名 -->
|
||||
<div class="flex items-center gap-1 w-[30%] min-w-0">
|
||||
<div class="w-4 h-4 rounded flex-shrink-0" :style="{ backgroundColor: getEditorColor('subtle') }"></div>
|
||||
<div class="flex-1 flex flex-col gap-0.5 min-w-0">
|
||||
<div class="h-0.5 rounded-full w-10" :style="{ backgroundColor: getEditorColor('content') }"></div>
|
||||
<div class="h-0.5 rounded-full w-6" :style="{ backgroundColor: getEditorColor('content3') }"></div>
|
||||
</div>
|
||||
<svg class="w-1.5 h-1.5 flex-shrink-0" :style="{ color: getEditorColor('content3') }" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
|
||||
</div>
|
||||
<!-- 播放控制 -->
|
||||
<div class="flex-1 flex items-center justify-center gap-2">
|
||||
<svg class="w-2 h-2" :style="{ color: getEditorColor('content2') }" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polygon points="19 20 9 12 19 4" fill="currentColor"/><line x1="5" y1="4" x2="5" y2="20"/></svg>
|
||||
<div class="w-4 h-4 rounded-full flex items-center justify-center" :style="{ backgroundColor: getEditorColor('muted'), border: `1px solid ${getEditorColor('emphasis')}` }">
|
||||
<svg class="w-2 h-2" :style="{ color: getEditorColor('content') }" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21"/></svg>
|
||||
</div>
|
||||
<svg class="w-2 h-2" :style="{ color: getEditorColor('content2') }" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polygon points="5 4 15 12 5 20" fill="currentColor"/><line x1="19" y1="4" x2="19" y2="20"/></svg>
|
||||
</div>
|
||||
<!-- 右侧 -->
|
||||
<div class="w-[30%] flex items-center justify-end gap-1">
|
||||
<svg class="w-1.5 h-1.5" :style="{ color: getEditorColor('content3') }" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19" fill="currentColor"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/></svg>
|
||||
<div class="w-4 h-0.5 rounded-full" :style="{ backgroundColor: getEditorColor('muted') }">
|
||||
<div class="h-full w-2/3 rounded-full" :style="{ backgroundColor: getEditorColor('accent') }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 基础风格快选 -->
|
||||
<div>
|
||||
<p class="text-xs text-content-3 mb-2">基于预设风格</p>
|
||||
<div class="flex gap-1.5 flex-wrap">
|
||||
<div
|
||||
v-for="s in presetSkins"
|
||||
:key="s.id"
|
||||
@click="editorBaseSkin = s.id; onBaseSkinChange()"
|
||||
class="w-6 h-6 rounded-full cursor-pointer border-2 transition-all"
|
||||
:class="editorBaseSkin === s.id ? 'border-white scale-125' : 'border-transparent hover:scale-110'"
|
||||
:style="{ backgroundColor: s.preview }"
|
||||
:title="s.name"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:控制面板 -->
|
||||
<div class="flex-1 overflow-y-auto p-5 space-y-5">
|
||||
<!-- 背景与壁纸 -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-1 h-4 rounded-full" :style="{ backgroundColor: getEditorColor('accent') }"></div>
|
||||
<p class="text-sm font-medium">背景与壁纸</p>
|
||||
</div>
|
||||
<div class="pl-3 space-y-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="color-swatch"><input type="color" :value="toHex(getEditorColor('bg'))" @input="setEditorColor('bg', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<div>
|
||||
<p class="text-sm">背景色</p>
|
||||
<p class="text-[11px] text-content-3">整个页面的底色,壁纸会覆盖在上面</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="pickEditorWallpaper" class="flex items-center gap-2 px-3 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition">
|
||||
<IconImage class="w-4 h-4" />
|
||||
{{ editorWallpaper ? '更换图片' : '选择壁纸图片' }}
|
||||
</button>
|
||||
<button v-if="editorWallpaper" @click="editorWallpaper = ''" class="px-3 py-2 bg-subtle hover:bg-danger/10 rounded-lg text-sm text-content-2 hover:text-danger transition">移除</button>
|
||||
</div>
|
||||
<template v-if="editorWallpaper">
|
||||
<div>
|
||||
<div class="flex justify-between mb-1">
|
||||
<span class="text-xs text-content-3">模糊</span>
|
||||
<span class="text-xs text-content-4">{{ editorWallpaperBlur }}px</span>
|
||||
</div>
|
||||
<input type="range" min="0" max="30" step="1" v-model.number="editorWallpaperBlur" class="w-full h-1.5 bg-muted rounded-full appearance-none cursor-pointer accent-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex justify-between mb-1">
|
||||
<span class="text-xs text-content-3">透明度</span>
|
||||
<span class="text-xs text-content-4">{{ Math.round(editorWallpaperOpacity * 100) }}%</span>
|
||||
</div>
|
||||
<input type="range" min="0" max="100" step="5" :value="Math.round(editorWallpaperOpacity * 100)" @input="editorWallpaperOpacity = Number(($event.target as HTMLInputElement).value) / 100" class="w-full h-1.5 bg-muted rounded-full appearance-none cursor-pointer accent-accent" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主题色 -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-1 h-4 rounded-full" :style="{ backgroundColor: getEditorColor('accent') }"></div>
|
||||
<p class="text-sm font-medium">主题色</p>
|
||||
</div>
|
||||
<p class="text-[11px] text-content-3 pl-3">按钮、链接、高亮、播放图标等使用这个颜色</p>
|
||||
<div class="pl-3 space-y-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="color-swatch"><input type="color" :value="toHex(getEditorColor('accent'))" @input="setEditorColor('accent', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<div>
|
||||
<p class="text-sm">主题色</p>
|
||||
<p class="text-[11px] text-content-3">按钮、进度条、选中状态</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="color-swatch color-swatch-sm"><input type="color" :value="toHex(getEditorColor('accentDim'))" @input="setEditorColor('accentDim', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<div>
|
||||
<p class="text-sm text-content-2">主题色淡</p>
|
||||
<p class="text-[11px] text-content-3">选中项的背景、淡色高亮</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文字颜色 -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-1 h-4 rounded-full" :style="{ backgroundColor: getEditorColor('content') }"></div>
|
||||
<p class="text-sm font-medium">文字</p>
|
||||
</div>
|
||||
<div class="pl-3 space-y-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="color-swatch"><input type="color" :value="toHex(getEditorColor('content'))" @input="setEditorColor('content', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<div>
|
||||
<p class="text-sm">主要文字</p>
|
||||
<p class="text-[11px] text-content-3">标题、歌曲名等最重要的文字</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="color-swatch color-swatch-sm"><input type="color" :value="toHex(getEditorColor('content2'))" @input="setEditorColor('content2', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<div>
|
||||
<p class="text-sm text-content-2">次要文字</p>
|
||||
<p class="text-[11px] text-content-3">歌手名、专辑名</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="color-swatch color-swatch-sm"><input type="color" :value="toHex(getEditorColor('content3'))" @input="setEditorColor('content3', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<div>
|
||||
<p class="text-sm text-content-2">辅助文字</p>
|
||||
<p class="text-[11px] text-content-3">描述、时间、播放量等</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表面与卡片 -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-1 h-4 rounded-full" :style="{ backgroundColor: getEditorColor('surface') }"></div>
|
||||
<p class="text-sm font-medium">表面与卡片</p>
|
||||
</div>
|
||||
<p class="text-[11px] text-content-3 pl-3">侧栏、底栏、弹窗、歌曲卡片的背景色</p>
|
||||
<div class="pl-3 space-y-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="color-swatch"><input type="color" :value="toHex(getEditorColor('surface'))" @input="setEditorColor('surface', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<div>
|
||||
<p class="text-sm">卡片背景</p>
|
||||
<p class="text-[11px] text-content-3">弹窗、侧栏、底栏的主色</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="color-swatch color-swatch-sm"><input type="color" :value="toHex(getEditorColor('line'))" @input="setEditorColor('line', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<div>
|
||||
<p class="text-sm text-content-2">分割线</p>
|
||||
<p class="text-[11px] text-content-3">卡片边框、区域之间的分隔</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 更多细节(折叠) -->
|
||||
<div>
|
||||
<button @click="showAdvancedEditor = !showAdvancedEditor" class="flex items-center gap-1.5 text-xs text-content-3 hover:text-content-2 transition">
|
||||
<svg class="w-3 h-3 transition-transform" :class="showAdvancedEditor ? 'rotate-90' : ''" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
更多细节调整
|
||||
</button>
|
||||
<div v-if="showAdvancedEditor" class="mt-3 space-y-4 pl-1">
|
||||
<div>
|
||||
<p class="text-[11px] text-content-3 mb-1.5">悬停与交互</p>
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="color-swatch color-swatch-xs"><input type="color" :value="toHex(getEditorColor('subtle'))" @input="setEditorColor('subtle', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<span class="text-xs text-content-2">微弱背景</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="color-swatch color-swatch-xs"><input type="color" :value="toHex(getEditorColor('muted'))" @input="setEditorColor('muted', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<span class="text-xs text-content-2">悬停背景</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="color-swatch color-swatch-xs"><input type="color" :value="toHex(getEditorColor('emphasis'))" @input="setEditorColor('emphasis', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<span class="text-xs text-content-2">强调背景</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[11px] text-content-3 mb-1.5">主题色变体</p>
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="color-swatch color-swatch-xs"><input type="color" :value="toHex(getEditorColor('accentHover'))" @input="setEditorColor('accentHover', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<span class="text-xs text-content-2">按钮悬停</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="color-swatch color-swatch-xs"><input type="color" :value="toHex(getEditorColor('accentText'))" @input="setEditorColor('accentText', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<span class="text-xs text-content-2">主题文字</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[11px] text-content-3 mb-1.5">功能色</p>
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="color-swatch color-swatch-xs"><input type="color" :value="toHex(getEditorColor('danger'))" @input="setEditorColor('danger', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<span class="text-xs text-content-2">危险/错误</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="color-swatch color-swatch-xs"><input type="color" :value="toHex(getEditorColor('warning'))" @input="setEditorColor('warning', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<span class="text-xs text-content-2">警告</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底栏 -->
|
||||
<div class="p-4 border-t border-line flex gap-3">
|
||||
<button @click="showSkinEditor = false" class="flex-1 py-2.5 rounded-lg bg-muted hover:bg-emphasis text-sm text-content-2 transition">取消</button>
|
||||
<button @click="handleSaveSkin" :disabled="!editorName.trim()" class="flex-1 py-2.5 rounded-lg bg-accent hover:bg-accent-hover text-white text-sm font-medium transition disabled:opacity-50">{{ editingSkinId ? '保存修改' : '创建皮肤' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useSettingsStore, qualityLabels, closeActionLabels, defaultShortcuts, themeLabels, themeColors, appearanceLabels, type CloseAction } from '../stores/settings';
|
||||
import { ref, computed, onMounted, onBeforeUnmount, reactive, watch } from 'vue';
|
||||
import { useSettingsStore, qualityLabels, closeActionLabels, defaultShortcuts, type CloseAction } from '../stores/settings';
|
||||
import { presetSkins, getPresetSkin, type SkinColors } from '../skins';
|
||||
import { toHex } from '../utils/color';
|
||||
import { useToast } from '../composables/useToast';
|
||||
import { useUpdater } from '../composables/useUpdater';
|
||||
import { DeviceApi, DownloadApi } from '../api';
|
||||
import { DeviceApi, DownloadApi, AppApi } from '../api';
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
@ -266,13 +664,224 @@ import PageHeader from '../components/PageHeader.vue';
|
||||
import IconX from '~icons/lucide/x';
|
||||
import IconFileText from '~icons/lucide/file-text';
|
||||
import IconLoader2 from '~icons/lucide/loader-2';
|
||||
import IconPalette from '~icons/lucide/palette';
|
||||
import IconSun from '~icons/lucide/sun';
|
||||
import IconMoon from '~icons/lucide/moon';
|
||||
import IconImage from '~icons/lucide/image';
|
||||
|
||||
const settings = useSettingsStore();
|
||||
const { showToast } = useToast();
|
||||
const updater = useUpdater();
|
||||
|
||||
// 主题色选项(7色,不分深浅)
|
||||
const themeColorOptions = [
|
||||
{ id: 'blue', name: '蓝', color: '#3b82f6' },
|
||||
{ id: 'green', name: '翠', color: '#22c55e' },
|
||||
{ id: 'rose', name: '红', color: '#f43f5e' },
|
||||
{ id: 'violet', name: '紫', color: '#8b5cf6' },
|
||||
{ id: 'orange', name: '橙', color: '#f97316' },
|
||||
{ id: 'cyan', name: '青', color: '#06b6d4' },
|
||||
{ id: 'pink', name: '粉', color: '#ec4899' },
|
||||
];
|
||||
|
||||
// 从当前 skin id 解析出 appearance 和 themeColor
|
||||
const currentAppearance = computed(() => {
|
||||
if (settings.skin.startsWith('light')) return 'light';
|
||||
return 'dark';
|
||||
});
|
||||
|
||||
const currentThemeColor = computed(() => {
|
||||
const id = settings.skin;
|
||||
if (id.startsWith('dark-')) return id.slice(5);
|
||||
if (id.startsWith('light-')) return id.slice(6);
|
||||
return 'blue'; // 自定义皮肤默认蓝
|
||||
});
|
||||
|
||||
function toSkinId(color: string, appearance: 'dark' | 'light'): string {
|
||||
return `${appearance}-${color}`;
|
||||
}
|
||||
|
||||
// 壁纸路径转可访问 URL(通过 Rust 命令读取本地图片转 base64 data URL)
|
||||
const wallpaperCache = new Map<string, string>();
|
||||
const MAX_WALLPAPER_CACHE = 10;
|
||||
async function wallpaperSrc(path: string): Promise<string> {
|
||||
if (!path) return '';
|
||||
if (wallpaperCache.has(path)) return wallpaperCache.get(path)!;
|
||||
try {
|
||||
const dataUrl = await AppApi.readImageAsDataUrl(path);
|
||||
if (wallpaperCache.size >= MAX_WALLPAPER_CACHE) {
|
||||
const firstKey = wallpaperCache.keys().next().value;
|
||||
if (firstKey !== undefined) wallpaperCache.delete(firstKey);
|
||||
}
|
||||
wallpaperCache.set(path, dataUrl);
|
||||
return dataUrl;
|
||||
} catch (e) {
|
||||
console.error('加载壁纸预览失败:', e);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
// 用于模板中同步绑定壁纸预览的响应式数据
|
||||
const editorWallpaperDataUrl = ref('');
|
||||
const skinWallpaperDataUrls = ref<Record<string, string>>({});
|
||||
|
||||
async function loadEditorWallpaper() {
|
||||
if (!editorWallpaper.value) {
|
||||
editorWallpaperDataUrl.value = '';
|
||||
return;
|
||||
}
|
||||
editorWallpaperDataUrl.value = await wallpaperSrc(editorWallpaper.value);
|
||||
}
|
||||
|
||||
async function loadSkinWallpaperPreviews() {
|
||||
for (const s of settings.customSkins) {
|
||||
if (s.wallpaper && !skinWallpaperDataUrls.value[s.wallpaper]) {
|
||||
const url = await wallpaperSrc(s.wallpaper);
|
||||
if (url) skinWallpaperDataUrls.value[s.wallpaper] = url;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 皮肤编辑器
|
||||
const showSkinEditor = ref(false);
|
||||
const showAdvancedEditor = ref(false);
|
||||
const editorName = ref('');
|
||||
const editorBaseSkin = ref('dark-blue');
|
||||
const editorColors = reactive<Partial<SkinColors>>({});
|
||||
const editorWallpaper = ref('');
|
||||
const editorWallpaperBlur = ref(10);
|
||||
const editorWallpaperOpacity = ref(0.3);
|
||||
/** 正在编辑的已有皮肤 id,为空则表示创建新皮肤 */
|
||||
const editingSkinId = ref<string | null>(null);
|
||||
|
||||
function openSkinEditor(skinId?: string) {
|
||||
if (skinId) {
|
||||
// 编辑已有自定义皮肤
|
||||
const existing = settings.customSkins.find(s => s.id === skinId);
|
||||
if (!existing) return;
|
||||
editingSkinId.value = skinId;
|
||||
editorName.value = existing.name;
|
||||
editorBaseSkin.value = 'dark-blue';
|
||||
// 将已有颜色完整填入 editorColors
|
||||
Object.keys(editorColors).forEach(k => delete editorColors[k as keyof SkinColors]);
|
||||
for (const [key, value] of Object.entries(existing.colors)) {
|
||||
(editorColors as any)[key] = value;
|
||||
}
|
||||
editorWallpaper.value = existing.wallpaper || '';
|
||||
editorWallpaperBlur.value = existing.wallpaperBlur ?? 10;
|
||||
editorWallpaperOpacity.value = existing.wallpaperOpacity ?? 0.3;
|
||||
} else {
|
||||
// 创建新皮肤:基于当前皮肤或默认
|
||||
editingSkinId.value = null;
|
||||
editorName.value = '';
|
||||
const baseSkinId = settings.isPreset ? settings.skin : 'dark-blue';
|
||||
editorBaseSkin.value = baseSkinId;
|
||||
// 将基础皮肤颜色完整填入 editorColors
|
||||
Object.keys(editorColors).forEach(k => delete editorColors[k as keyof SkinColors]);
|
||||
const base = getPresetSkin(baseSkinId);
|
||||
if (base) {
|
||||
for (const [key, value] of Object.entries(base.colors)) {
|
||||
(editorColors as any)[key] = value;
|
||||
}
|
||||
}
|
||||
editorWallpaper.value = '';
|
||||
editorWallpaperBlur.value = 10;
|
||||
editorWallpaperOpacity.value = 0.3;
|
||||
}
|
||||
showSkinEditor.value = true;
|
||||
loadEditorWallpaper();
|
||||
loadSkinWallpaperPreviews();
|
||||
}
|
||||
|
||||
function onBaseSkinChange() {
|
||||
// 切换基础风格时,将该风格的完整颜色填入 editorColors
|
||||
Object.keys(editorColors).forEach(k => delete editorColors[k as keyof SkinColors]);
|
||||
const base = getPresetSkin(editorBaseSkin.value);
|
||||
if (base) {
|
||||
for (const [key, value] of Object.entries(base.colors)) {
|
||||
(editorColors as any)[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getEditorColor(key: keyof SkinColors): string {
|
||||
return editorColors[key] || '#000000';
|
||||
}
|
||||
|
||||
function setEditorColor(key: keyof SkinColors, value: string) {
|
||||
editorColors[key] = value;
|
||||
}
|
||||
|
||||
function handleSaveSkin() {
|
||||
if (!editorName.value.trim()) return;
|
||||
// 确保颜色完整:缺失字段从基础皮肤补齐
|
||||
const base = getPresetSkin(editorBaseSkin.value);
|
||||
const baseColors = base ? base.colors : getPresetSkin('dark-blue')!.colors;
|
||||
const colors = { ...baseColors } as SkinColors;
|
||||
for (const key of Object.keys(editorColors) as (keyof SkinColors)[]) {
|
||||
if (editorColors[key]) {
|
||||
colors[key] = editorColors[key]!;
|
||||
}
|
||||
}
|
||||
|
||||
if (editingSkinId.value) {
|
||||
settings.updateCustomSkin(editingSkinId.value, {
|
||||
name: editorName.value.trim(),
|
||||
preview: colors.accent,
|
||||
colors,
|
||||
wallpaper: editorWallpaper.value,
|
||||
wallpaperBlur: editorWallpaperBlur.value,
|
||||
wallpaperOpacity: editorWallpaperOpacity.value,
|
||||
});
|
||||
showSkinEditor.value = false;
|
||||
showToast('皮肤已更新', 'success');
|
||||
} else {
|
||||
const id = `custom-${Date.now()}`;
|
||||
settings.addCustomSkin({
|
||||
id,
|
||||
name: editorName.value.trim(),
|
||||
preview: colors.accent,
|
||||
colors,
|
||||
wallpaper: editorWallpaper.value,
|
||||
wallpaperBlur: editorWallpaperBlur.value,
|
||||
wallpaperOpacity: editorWallpaperOpacity.value,
|
||||
});
|
||||
showSkinEditor.value = false;
|
||||
showToast('自定义皮肤已创建', 'success');
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteCustomSkin(id: string) {
|
||||
settings.removeCustomSkin(id);
|
||||
showToast('已删除自定义皮肤', 'success');
|
||||
}
|
||||
|
||||
async function pickEditorWallpaper() {
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
title: '选择壁纸图片',
|
||||
filters: [{
|
||||
name: '图片',
|
||||
extensions: ['png', 'jpg', 'jpeg', 'webp', 'bmp', 'gif'],
|
||||
}],
|
||||
});
|
||||
if (selected) {
|
||||
editorWallpaper.value = selected;
|
||||
wallpaperCache.delete(selected);
|
||||
loadEditorWallpaper();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 监听编辑器壁纸变化
|
||||
watch(editorWallpaper, () => {
|
||||
loadEditorWallpaper();
|
||||
});
|
||||
|
||||
// 监听自定义皮肤列表变化,加载壁纸预览
|
||||
watch(() => settings.customSkins, () => {
|
||||
loadSkinWallpaperPreviews();
|
||||
}, { deep: true });
|
||||
|
||||
const devices = ref<string[]>([]);
|
||||
const deviceOptions = computed(() => {
|
||||
const options: Record<string, string> = { '': '跟随系统默认' };
|
||||
@ -450,3 +1059,50 @@ onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', onRecordingKeydown, true);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 颜色选择器:让 input[type=color] 填满外层 label,消除内部小方块 */
|
||||
.color-swatch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-line);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.color-swatch-sm {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.color-swatch-xs {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.color-swatch input[type="color"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: calc(100% + 8px);
|
||||
height: calc(100% + 8px);
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
}
|
||||
.color-swatch input[type="color"]::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
.color-swatch input[type="color"]::-webkit-color-swatch {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
.color-swatch input[type="color"]::-moz-color-swatch {
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user