重构播放列表为右侧弹出式

播放列表可以定位正在播放的歌曲位置
添加歌词翻译
新增快捷键 播放/暂停
重构主题设置,支持多种主题

修复评论playerbar查看点击后一直默认评论打开抽屉页面问题
This commit is contained in:
2026-05-22 00:54:09 +08:00
parent cf21c96eaf
commit 970fb15f5a
9 changed files with 415 additions and 115 deletions

View File

@ -184,6 +184,12 @@
:class="roamTab === 'comment' ? 'bg-white/15 text-white font-medium' : 'text-white/50 hover:text-white/80'"> :class="roamTab === 'comment' ? 'bg-white/15 text-white font-medium' : 'text-white/50 hover:text-white/80'">
评论 评论
</button> </button>
<button v-if="hasTranslation" @click="toggleTranslation"
class="ml-auto px-2.5 py-1 rounded-full text-xs transition flex items-center gap-1"
:class="showTranslation ? 'bg-white/15 text-white font-medium' : 'text-white/40 hover:text-white/70'">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 8l6 6"/><path d="M4 14l6-6 2-3"/><path d="M2 5h12"/><path d="M7 2h1"/><path d="M22 22l-5-10-5 10"/><path d="M14 18h6"/></svg>
</button>
</div> </div>
<div v-show="roamTab === 'lyric'" ref="lyricScrollContainer" class="flex-1 min-h-0 overflow-y-auto custom-scroll px-4"> <div v-show="roamTab === 'lyric'" ref="lyricScrollContainer" class="flex-1 min-h-0 overflow-y-auto custom-scroll px-4">
<div v-if="lyrics.length > 0" class="w-full max-w-lg mx-auto text-center" <div v-if="lyrics.length > 0" class="w-full max-w-lg mx-auto text-center"
@ -198,6 +204,7 @@
@mouseleave="roamLyricHovering = false" @mouseleave="roamLyricHovering = false"
> >
{{ line.text }} {{ line.text }}
<span v-if="showTranslation && line.translation" class="block text-sm opacity-60 mt-1">{{ line.translation }}</span>
</p> </p>
</div> </div>
<div v-else class="text-content-3 text-center mt-8">暂无歌词</div> <div v-else class="text-content-3 text-center mt-8">暂无歌词</div>
@ -305,7 +312,7 @@ function doSearch() {
if (q) router.push({ path: '/discover', query: { q } }); if (q) router.push({ path: '/discover', query: { q } });
} }
const { lyrics, currentLyricIdx } = useLyric(); const { lyrics, currentLyricIdx, hasTranslation, showTranslation, toggleTranslation } = useLyric();
const lyricScrollContainer = ref<HTMLElement | null>(null); const lyricScrollContainer = ref<HTMLElement | null>(null);
const roamLyricHovering = ref(false); const roamLyricHovering = ref(false);
const roamLyricPadPx = ref(0); const roamLyricPadPx = ref(0);
@ -372,8 +379,8 @@ function getRoamLyricClass(idx: number): string {
return 'roam-lyric-active text-accent-text font-semibold text-xl'; return 'roam-lyric-active text-accent-text font-semibold text-xl';
} }
if (diff === 1) return 'text-content/70 text-lg'; if (diff === 1) return 'text-content/70 text-lg';
if (diff === 2) return 'text-content-2/50 text-base'; if (diff === 2) return 'text-content-2/50 text-[1rem]';
return 'text-content-3/35 text-base'; return 'text-content-3/35 text-[1rem]';
} }
function seekToRoamLyric(time: number) { function seekToRoamLyric(time: number) {
@ -506,6 +513,7 @@ onMounted(() => {
async function registerGlobalShortcuts() { async function registerGlobalShortcuts() {
const globalActions: Record<string, () => void> = { const globalActions: Record<string, () => void> = {
globalPlayPause: () => player.toggle(),
globalPrev: () => player.prev(), globalPrev: () => player.prev(),
globalNext: () => player.next(), globalNext: () => player.next(),
globalVolUp: () => player.adjustVolume(5), globalVolUp: () => player.adjustVolume(5),
@ -551,6 +559,7 @@ onMounted(() => {
} }
const localActions: Record<string, () => void> = { const localActions: Record<string, () => void> = {
playPause: () => player.toggle(),
prev: () => player.prev(), prev: () => player.prev(),
next: () => player.next(), next: () => player.next(),
volUp: () => player.adjustVolume(5), volUp: () => player.adjustVolume(5),

View File

@ -104,41 +104,99 @@
</div> </div>
</div> </div>
<Transition name="slide-up"> <Teleport to="body">
<div v-if="showQueuePanel" <Transition name="queue-fade">
class="border-t border-line bg-surface/95 backdrop-blur p-4 max-h-64 overflow-y-auto"> <div v-if="showQueuePanel" class="fixed inset-0 z-[55] bg-black/40 backdrop-blur-[2px]" @click="showQueuePanel = false"></div>
<div class="flex justify-between items-center mb-3"> </Transition>
<h3 class="text-sm font-semibold">播放列表 ({{ player.queue.length }})</h3> <Transition name="queue-slide">
<button @click="player.clearQueue()" class="text-xs text-danger hover:text-danger transition">清空</button> <div v-if="showQueuePanel"
</div> class="fixed right-0 top-0 bottom-0 z-[56] w-[340px] bg-base/95 backdrop-blur border-l border-line flex flex-col shadow-2xl shadow-black/40">
<div class="space-y-1">
<div v-for="(song, idx) in player.queue" :key="song.id + '-' + idx" @click="playFromQueue(idx)" :class="[ <div class="px-5 pt-5 pb-3">
'flex items-center gap-3 p-2 rounded-lg cursor-pointer transition', <div class="flex items-center justify-between">
idx === player.currentIndex ? 'bg-accent-dim text-content' : 'hover:bg-subtle text-content-2', <div>
]"> <h3 class="text-[1rem] font-semibold text-content">播放列表</h3>
<span class="text-xs w-6 text-center">{{ idx + 1 }}</span> <p class="text-xs text-content-3 mt-0.5">{{ player.queue.length }} 首歌曲</p>
<div class="flex-1 min-w-0"> </div>
<p class="text-xs font-medium truncate">{{ song.name }}</p> <div class="flex items-center gap-1">
<p class="text-xs text-content-3 truncate"> <button @click="player.clearQueue()"
<template v-for="(a, i) in song.ar || []" :key="a.id || i"> class="px-2.5 py-1 text-xs text-content-3 hover:text-danger hover:bg-danger-dim rounded-lg transition">
<span v-if="i > 0" class="text-content-3">/</span> 清空
<span class="hover:text-accent-text cursor-pointer transition" @click.stop="a.id && router.push({ name: 'artist', params: { id: a.id } })">{{ a.name }}</span> </button>
</template> <button @click="showQueuePanel = false"
</p> class="w-7 h-7 flex items-center justify-center rounded-lg text-content-3 hover:text-content hover:bg-subtle transition">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
</div> </div>
<button @click.stop="player.removeFromQueue(idx)" </div>
class="text-content-3 hover:text-danger transition text-sm">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> <div class="h-px mx-5 bg-line"></div>
<div ref="queueListEl" class="flex-1 overflow-y-auto px-3 py-2 relative">
<div v-if="player.queue.length === 0" class="flex flex-col items-center justify-center h-full text-content-4 gap-3">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="opacity-40">
<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>
</svg>
<p class="text-sm">播放列表为空</p>
<p class="text-xs text-content-4">去发现好听的音乐吧</p>
</div>
<div v-for="(song, idx) in player.queue" :key="song.id + '-' + idx" :id="'queue-item-' + idx"
@click="playFromQueue(idx)" :class="[
'flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer transition-all duration-200 group',
idx === player.currentIndex
? 'bg-muted'
: 'hover:bg-subtle',
]">
<div class="w-9 h-9 rounded-md overflow-hidden flex-shrink-0 relative">
<img v-if="song.al?.picUrl" :src="song.al.picUrl + '?param=80y80'" class="w-full h-full object-cover" loading="lazy" />
<div v-else class="w-full h-full bg-muted flex items-center justify-center">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-content-4"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
</div>
<div v-if="idx === player.currentIndex"
class="absolute inset-0 bg-black/30 flex items-center justify-center">
<div class="flex items-end gap-[2px] h-3">
<span class="eq-bar-sm w-[2px] bg-white rounded-full" style="animation-delay: 0s"></span>
<span class="eq-bar-sm w-[2px] bg-white rounded-full" style="animation-delay: 0.12s"></span>
<span class="eq-bar-sm w-[2px] bg-white rounded-full" style="animation-delay: 0.24s"></span>
</div>
</div>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm truncate" :class="idx === player.currentIndex ? 'text-accent-text font-medium' : 'text-content-2'">
{{ song.name }}
</p>
<p class="text-xs text-content-3 truncate mt-0.5">
<template v-for="(a, i) in song.ar || []" :key="a.id || i">
<span v-if="i > 0" class="text-content-4">/</span>
<span>{{ a.name }}</span>
</template>
</p>
</div>
<button @click.stop="player.removeFromQueue(idx)"
class="w-6 h-6 flex items-center justify-center rounded-md text-content-4 hover:text-danger hover:bg-danger-dim transition opacity-0 group-hover:opacity-100 flex-shrink-0">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="h-2"></div>
<button v-if="player.currentIndex >= 0 && player.queue.length > 0" v-show="!currentSongVisible"
@click="scrollToCurrent"
class="sticky bottom-3 float-right mr-1 w-9 h-9 flex items-center justify-center rounded-full bg-surface/90 backdrop-blur shadow-lg shadow-black/30 text-content-3 hover:text-accent-text hover:bg-accent-dim/50 transition-all duration-300"
title="定位到正在播放">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><line x1="12" y1="6" x2="12" y2="2"/><line x1="12" y1="22" x2="12" y2="18"/></svg>
</button> </button>
</div> </div>
</div> </div>
</div> </Transition>
</Transition> </Teleport>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onBeforeUnmount, onMounted } from 'vue'; import { ref, computed, watch, onBeforeUnmount, onMounted, nextTick } from 'vue';
import { usePlayerStore, PlayMode } from '../stores/player'; import { usePlayerStore, PlayMode } from '../stores/player';
import { useDownload } from '../composables/useDownload'; import { useDownload } from '../composables/useDownload';
import { formatTime } from '../utils/format'; import { formatTime } from '../utils/format';
@ -150,6 +208,8 @@ const router = useRouter();
const player = usePlayerStore(); const player = usePlayerStore();
const download = useDownload(); const download = useDownload();
const showQueuePanel = ref(false); const showQueuePanel = ref(false);
const queueListEl = ref<HTMLElement | null>(null);
const currentSongVisible = ref(true);
const progressBar = ref<HTMLElement | null>(null); const progressBar = ref<HTMLElement | null>(null);
const isSeeking = ref(false); const isSeeking = ref(false);
const previewTime = ref(0); const previewTime = ref(0);
@ -241,6 +301,50 @@ function playFromQueue(index: number) {
player.playCurrent(); player.playCurrent();
} }
function scrollToCurrent() {
const el = document.getElementById('queue-item-' + player.currentIndex);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
let currentSongObserver: IntersectionObserver | null = null;
function setupCurrentSongObserver() {
if (currentSongObserver) {
currentSongObserver.disconnect();
currentSongObserver = null;
}
nextTick(() => {
const el = document.getElementById('queue-item-' + player.currentIndex);
if (!el || !queueListEl.value) return;
currentSongObserver = new IntersectionObserver(
([entry]) => { currentSongVisible.value = entry.isIntersecting; },
{ root: queueListEl.value, threshold: 0.5 }
);
currentSongObserver.observe(el);
});
}
watch(() => [showQueuePanel.value, player.currentIndex, player.queue.length], () => {
if (showQueuePanel.value) {
setupCurrentSongObserver();
} else {
if (currentSongObserver) {
currentSongObserver.disconnect();
currentSongObserver = null;
}
currentSongVisible.value = true;
}
});
onBeforeUnmount(() => {
if (currentSongObserver) {
currentSongObserver.disconnect();
currentSongObserver = null;
}
});
async function handleVolumeChange(e: Event) { async function handleVolumeChange(e: Event) {
const target = e.target as HTMLInputElement; const target = e.target as HTMLInputElement;
const val = parseInt(target.value, 10); const val = parseInt(target.value, 10);
@ -255,26 +359,6 @@ const volumeBarBg = computed(() => {
</script> </script>
<style scoped> <style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.slide-up-enter-active,
.slide-up-leave-active {
transition: all 0.2s ease;
}
.slide-up-enter-from,
.slide-up-leave-to {
opacity: 0;
transform: translateY(10px);
}
.vol-slider { .vol-slider {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
@ -311,4 +395,31 @@ const volumeBarBg = computed(() => {
.vol-slider::-webkit-slider-thumb:hover { .vol-slider::-webkit-slider-thumb:hover {
transform: scale(1.2); transform: scale(1.2);
} }
.queue-fade-enter-active,
.queue-fade-leave-active {
transition: opacity 0.2s ease;
}
.queue-fade-enter-from,
.queue-fade-leave-to {
opacity: 0;
}
.queue-slide-enter-active,
.queue-slide-leave-active {
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.queue-slide-enter-from,
.queue-slide-leave-to {
transform: translateX(100%);
}
@keyframes eq-bounce-sm {
0%, 100% { height: 2px; }
50% { height: 10px; }
}
.eq-bar-sm {
animation: eq-bounce-sm 0.6s ease-in-out infinite;
}
</style> </style>

View File

@ -1,6 +1,6 @@
<template> <template>
<Transition name="fade"> <Transition name="fade">
<div v-if="visible" class="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="handleIgnore"> <div v-if="visible" class="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="!downloading && handleIgnore()">
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-[440px] max-h-[80vh] flex flex-col select-auto"> <div class="bg-surface border border-line rounded-2xl shadow-2xl w-[440px] max-h-[80vh] flex flex-col select-auto">
<div class="p-6 pb-4"> <div class="p-6 pb-4">
<div class="flex items-center gap-3 mb-1"> <div class="flex items-center gap-3 mb-1">

View File

@ -1,6 +1,6 @@
import { ref, watch } from 'vue'; import { ref, watch } from 'vue';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { parseLrc, getCurrentLyricIndex, LyricLine } from '../utils/lyric'; import { parseLrc, mergeTranslation, getCurrentLyricIndex, LyricLine } from '../utils/lyric';
import { usePlayerStore } from '../stores/player'; import { usePlayerStore } from '../stores/player';
export function useLyric() { export function useLyric() {
@ -8,21 +8,33 @@ export function useLyric() {
const lyrics = ref<LyricLine[]>([]); const lyrics = ref<LyricLine[]>([]);
const currentLyricIdx = ref(-1); const currentLyricIdx = ref(-1);
const showTranslation = ref(true);
const hasTranslation = ref(false);
watch(() => player.currentSong, async (song) => { watch(() => player.currentSong, async (song) => {
if (!song) { if (!song) {
lyrics.value = []; lyrics.value = [];
currentLyricIdx.value = -1; currentLyricIdx.value = -1;
hasTranslation.value = false;
return; return;
} }
try { try {
const jsonStr: string = await invoke('get_lyric', { id: song.id }); const jsonStr: string = await invoke('get_lyric', { id: song.id });
const data = JSON.parse(jsonStr); const data = JSON.parse(jsonStr);
const lrc = data?.lrc?.lyric || ''; const lrc = data?.lrc?.lyric || '';
lyrics.value = lrc ? parseLrc(lrc) : []; const tLrc = data?.tlyric?.lyric || '';
let parsed = lrc ? parseLrc(lrc) : [];
if (tLrc && parsed.length > 0) {
parsed = mergeTranslation(parsed, tLrc);
hasTranslation.value = parsed.some(l => l.translation);
} else {
hasTranslation.value = false;
}
lyrics.value = parsed;
currentLyricIdx.value = -1; currentLyricIdx.value = -1;
} catch { } catch {
lyrics.value = []; lyrics.value = [];
hasTranslation.value = false;
} }
}, { immediate: true }); }, { immediate: true });
@ -34,8 +46,15 @@ export function useLyric() {
} }
}); });
function toggleTranslation() {
showTranslation.value = !showTranslation.value;
}
return { return {
lyrics, lyrics,
currentLyricIdx, currentLyricIdx,
hasTranslation,
showTranslation,
toggleTranslation,
}; };
} }

View File

@ -1,5 +1,5 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { ref , watch } from 'vue'; import { ref, watch, nextTick } from 'vue';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { normalizeSong } from '../utils/song'; import { normalizeSong } from '../utils/song';
import { useSettingsStore } from './settings'; import { useSettingsStore } from './settings';
@ -422,6 +422,7 @@ export const usePlayerStore = defineStore('player', () => {
function openRoamDrawer(tab: 'lyric' | 'comment' = 'lyric') { function openRoamDrawer(tab: 'lyric' | 'comment' = 'lyric') {
roamInitialTab.value = tab; roamInitialTab.value = tab;
showRoamDrawer.value = true; showRoamDrawer.value = true;
nextTick(() => { roamInitialTab.value = 'lyric'; });
} }
function openCommentForSong(songId: number) { function openCommentForSong(songId: number) {

View File

@ -2,9 +2,29 @@ import { defineStore } from 'pinia';
import { ref, watch } from 'vue'; import { ref, watch } from 'vue';
export type AudioQuality = 'standard' | 'higher' | 'exhigh' | 'lossless' | 'hires'; export type AudioQuality = 'standard' | 'higher' | 'exhigh' | 'lossless' | 'hires';
export type ThemeMode = 'dark' | 'light'; export type ThemeName = 'green' | 'rose' | 'blue' | 'violet' | 'orange' | 'cyan' | 'pink';
export type CloseAction = 'ask' | 'minimize' | 'exit'; export type CloseAction = 'ask' | 'minimize' | 'exit';
export const themeLabels: Record<ThemeName, string> = {
green: '翠绿',
rose: '玫红',
blue: '天蓝',
violet: '紫罗兰',
orange: '橙色',
cyan: '青色',
pink: '粉色',
};
export const themeColors: Record<ThemeName, string> = {
green: '#22c55e',
rose: '#f43f5e',
blue: '#3b82f6',
violet: '#8b5cf6',
orange: '#f97316',
cyan: '#06b6d4',
pink: '#ec4899',
};
export const qualityLabels: Record<AudioQuality, string> = { export const qualityLabels: Record<AudioQuality, string> = {
standard: '标准', standard: '标准',
higher: '较高', higher: '较高',
@ -25,10 +45,12 @@ export interface ShortcutBinding {
} }
export const defaultShortcuts: Record<string, ShortcutBinding> = { export const defaultShortcuts: Record<string, ShortcutBinding> = {
playPause: { key: 'Control+KeyP', label: '播放/暂停' },
prev: { key: 'Control+ArrowLeft', label: '上一首' }, prev: { key: 'Control+ArrowLeft', label: '上一首' },
next: { key: 'Control+ArrowRight', label: '下一首' }, next: { key: 'Control+ArrowRight', label: '下一首' },
volUp: { key: 'Control+ArrowUp', label: '音量增加' }, volUp: { key: 'Control+ArrowUp', label: '音量增加' },
volDown: { key: 'Control+ArrowDown', label: '音量减小' }, volDown: { key: 'Control+ArrowDown', label: '音量减小' },
globalPlayPause: { key: 'Alt+Control+KeyP', label: '播放/暂停(全局)' },
globalPrev: { key: 'Alt+Control+ArrowLeft', label: '上一首(全局)' }, globalPrev: { key: 'Alt+Control+ArrowLeft', label: '上一首(全局)' },
globalNext: { key: 'Alt+Control+ArrowRight', label: '下一首(全局)' }, globalNext: { key: 'Alt+Control+ArrowRight', label: '下一首(全局)' },
globalVolUp: { key: 'Alt+Control+ArrowUp', label: '音量增加(全局)' }, globalVolUp: { key: 'Alt+Control+ArrowUp', label: '音量增加(全局)' },
@ -38,7 +60,7 @@ export const defaultShortcuts: Record<string, ShortcutBinding> = {
interface SettingsData { interface SettingsData {
audioQuality: AudioQuality; audioQuality: AudioQuality;
downloadPath: string; downloadPath: string;
theme: ThemeMode; theme: ThemeName;
closeAction: CloseAction; closeAction: CloseAction;
shortcuts: Record<string, ShortcutBinding>; shortcuts: Record<string, ShortcutBinding>;
outputDevice: string | null; outputDevice: string | null;
@ -49,10 +71,12 @@ function loadSettings(): SettingsData {
const raw = localStorage.getItem('app_settings'); const raw = localStorage.getItem('app_settings');
if (raw) { if (raw) {
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);
const theme = parsed.theme || parsed.accentColor || 'green';
const validThemes: ThemeName[] = ['green', 'rose', 'blue', 'violet', 'orange', 'cyan', 'pink'];
return { return {
audioQuality: parsed.audioQuality || 'standard', audioQuality: parsed.audioQuality || 'standard',
downloadPath: parsed.downloadPath || '', downloadPath: parsed.downloadPath || '',
theme: parsed.theme || 'dark', theme: validThemes.includes(theme) ? theme : 'green',
closeAction: parsed.closeAction || 'ask', closeAction: parsed.closeAction || 'ask',
shortcuts: { ...defaultShortcuts, ...(parsed.shortcuts || {}) }, shortcuts: { ...defaultShortcuts, ...(parsed.shortcuts || {}) },
outputDevice: parsed.outputDevice || null, outputDevice: parsed.outputDevice || null,
@ -62,7 +86,7 @@ function loadSettings(): SettingsData {
return { return {
audioQuality: 'standard', audioQuality: 'standard',
downloadPath: '', downloadPath: '',
theme: 'dark', theme: 'green',
closeAction: 'ask', closeAction: 'ask',
shortcuts: { ...defaultShortcuts }, shortcuts: { ...defaultShortcuts },
outputDevice: null, outputDevice: null,
@ -74,7 +98,7 @@ export const useSettingsStore = defineStore('settings', () => {
const audioQuality = ref<AudioQuality>(saved.audioQuality); const audioQuality = ref<AudioQuality>(saved.audioQuality);
const downloadPath = ref<string>(saved.downloadPath); const downloadPath = ref<string>(saved.downloadPath);
const theme = ref<ThemeMode>(saved.theme); const theme = ref<ThemeName>(saved.theme);
const closeAction = ref<CloseAction>(saved.closeAction || 'ask'); const closeAction = ref<CloseAction>(saved.closeAction || 'ask');
const shortcuts = ref<Record<string, ShortcutBinding>>(saved.shortcuts); const shortcuts = ref<Record<string, ShortcutBinding>>(saved.shortcuts);
const outputDevice = ref<string | null>(saved.outputDevice); const outputDevice = ref<string | null>(saved.outputDevice);
@ -87,7 +111,7 @@ export const useSettingsStore = defineStore('settings', () => {
downloadPath.value = p; downloadPath.value = p;
} }
function setTheme(t: ThemeMode) { function setTheme(t: ThemeName) {
theme.value = t; theme.value = t;
} }
@ -110,7 +134,7 @@ export const useSettingsStore = defineStore('settings', () => {
function resetAll() { function resetAll() {
audioQuality.value = 'standard'; audioQuality.value = 'standard';
downloadPath.value = ''; downloadPath.value = '';
theme.value = 'dark'; theme.value = 'green';
closeAction.value = 'ask'; closeAction.value = 'ask';
shortcuts.value = { ...defaultShortcuts }; shortcuts.value = { ...defaultShortcuts };
outputDevice.value = null; outputDevice.value = null;

View File

@ -23,18 +23,19 @@
} }
@layer base { @layer base {
:root { :root,
--c-bg: #030712; [data-theme="green"] {
--c-surface: #111827; --c-bg: #020c06;
--c-subtle: rgba(255, 255, 255, 0.05); --c-surface: #0a1a10;
--c-muted: rgba(255, 255, 255, 0.10); --c-subtle: rgba(34, 197, 94, 0.06);
--c-emphasis: rgba(255, 255, 255, 0.18); --c-muted: rgba(34, 197, 94, 0.10);
--c-emphasis: rgba(34, 197, 94, 0.18);
--c-content: #ffffff; --c-content: #ffffff;
--c-content-2: #9ca3af; --c-content-2: #9ca3af;
--c-content-3: #6b7280; --c-content-3: #6b7280;
--c-content-4: #4b5563; --c-content-4: #4b5563;
--c-line: rgba(255, 255, 255, 0.10); --c-line: rgba(255, 255, 255, 0.08);
--c-line-2: rgba(255, 255, 255, 0.05); --c-line-2: rgba(255, 255, 255, 0.04);
--c-accent: #22c55e; --c-accent: #22c55e;
--c-accent-hover: #16a34a; --c-accent-hover: #16a34a;
--c-accent-text: #4ade80; --c-accent-text: #4ade80;
@ -45,26 +46,136 @@
--c-info: #3b82f6; --c-info: #3b82f6;
} }
[data-theme="light"] { [data-theme="rose"] {
--c-bg: #f3f4f6; --c-bg: #0c0206;
--c-surface: #ffffff; --c-surface: #1a0a10;
--c-subtle: rgba(0, 0, 0, 0.04); --c-subtle: rgba(244, 63, 94, 0.06);
--c-muted: rgba(0, 0, 0, 0.08); --c-muted: rgba(244, 63, 94, 0.10);
--c-emphasis: rgba(0, 0, 0, 0.12); --c-emphasis: rgba(244, 63, 94, 0.18);
--c-content: #111827; --c-content: #ffffff;
--c-content-2: #4b5563; --c-content-2: #9ca3af;
--c-content-3: #6b7280; --c-content-3: #6b7280;
--c-content-4: #9ca3af; --c-content-4: #4b5563;
--c-line: rgba(0, 0, 0, 0.10); --c-line: rgba(255, 255, 255, 0.08);
--c-line-2: rgba(0, 0, 0, 0.05); --c-line-2: rgba(255, 255, 255, 0.04);
--c-accent: #16a34a; --c-accent: #f43f5e;
--c-accent-hover: #15803d; --c-accent-hover: #e11d48;
--c-accent-text: #16a34a; --c-accent-text: #fb7185;
--c-accent-dim: rgba(22, 163, 74, 0.15); --c-accent-dim: rgba(244, 63, 94, 0.20);
--c-danger: #dc2626; --c-danger: #ef4444;
--c-danger-dim: rgba(220, 38, 38, 0.15); --c-danger-dim: rgba(239, 68, 68, 0.20);
--c-warning: #ca8a04; --c-warning: #eab308;
--c-info: #2563eb; --c-info: #3b82f6;
}
[data-theme="blue"] {
--c-bg: #02060c;
--c-surface: #0a101a;
--c-subtle: rgba(59, 130, 246, 0.06);
--c-muted: rgba(59, 130, 246, 0.10);
--c-emphasis: rgba(59, 130, 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: #3b82f6;
--c-accent-hover: #2563eb;
--c-accent-text: #60a5fa;
--c-accent-dim: rgba(59, 130, 246, 0.20);
--c-danger: #ef4444;
--c-danger-dim: rgba(239, 68, 68, 0.20);
--c-warning: #eab308;
--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;
} }
html { html {
@ -78,6 +189,7 @@
@apply antialiased; @apply antialiased;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background: var(--c-bg); background: var(--c-bg);
color: var(--c-content);
position: fixed; position: fixed;
inset: 0; inset: 0;
overflow: hidden; overflow: hidden;

View File

@ -1,6 +1,7 @@
export interface LyricLine { export interface LyricLine {
time: number; // 秒 time: number;
text: string; text: string;
translation?: string;
} }
export function parseLrc(lrcStr: string): LyricLine[] { export function parseLrc(lrcStr: string): LyricLine[] {
@ -20,11 +21,37 @@ export function parseLrc(lrcStr: string): LyricLine[] {
} }
} }
} }
// 按时长排序
result.sort((a, b) => a.time - b.time); result.sort((a, b) => a.time - b.time);
return result; return result;
} }
export function mergeTranslation(lyrics: LyricLine[], tLrcStr: string): LyricLine[] {
if (!tLrcStr) return lyrics;
const tLines = parseLrc(tLrcStr);
if (tLines.length === 0) return lyrics;
const tMap = new Map<number, string>();
for (const t of tLines) {
const key = Math.round(t.time * 100);
tMap.set(key, t.text);
}
return lyrics.map(line => {
const key = Math.round(line.time * 100);
const translation = tMap.get(key);
if (translation) {
return { ...line, translation };
}
for (let offset = -3; offset <= 3; offset++) {
const t = tMap.get(key + offset);
if (t) {
return { ...line, translation: t };
}
}
return line;
});
}
export function getCurrentLyricIndex(lyrics: LyricLine[], currentTime: number): number { export function getCurrentLyricIndex(lyrics: LyricLine[], currentTime: number): number {
let index = -1; let index = -1;
for (let i = 0; i < lyrics.length; i++) { for (let i = 0; i < lyrics.length; i++) {
@ -35,4 +62,4 @@ export function getCurrentLyricIndex(lyrics: LyricLine[], currentTime: number):
} }
} }
return index; return index;
} }

View File

@ -28,23 +28,19 @@
<section class="mb-8"> <section class="mb-8">
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">外观</h2> <h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">外观</h2>
<div class="space-y-5"> <div>
<div class="flex items-center justify-between"> <p class="text-sm font-medium mb-3">主题色</p>
<div> <div class="grid grid-cols-4 gap-3">
<p class="text-sm font-medium">主题</p> <button
<p class="text-xs text-content-3 mt-0.5">切换应用主题</p> v-for="(color, key) in themeColors"
</div> :key="key"
<div class="flex bg-subtle rounded-lg p-0.5"> @click="settings.setTheme(key)"
<button class="flex flex-col items-center gap-2 p-3 rounded-xl transition-all border-2"
v-for="t in themeOptions" :class="settings.theme === key ? 'border-white/30 bg-white/5 scale-[1.02]' : 'border-transparent bg-subtle hover:bg-muted'"
:key="t.value" >
@click="settings.setTheme(t.value)" <div class="w-8 h-8 rounded-full shadow-md" :style="{ backgroundColor: color }"></div>
class="px-3 py-1.5 rounded-md text-sm transition" <span class="text-xs" :class="settings.theme === key ? 'text-content font-medium' : 'text-content-3'">{{ themeLabels[key] }}</span>
:class="settings.theme === t.value ? 'bg-muted text-content' : 'text-content-3 hover:text-content-2'" </button>
>
{{ t.label }}
</button>
</div>
</div> </div>
</div> </div>
</section> </section>
@ -241,7 +237,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'; import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useSettingsStore, qualityLabels, closeActionLabels, defaultShortcuts, type CloseAction } from '../stores/settings'; import { useSettingsStore, qualityLabels, closeActionLabels, defaultShortcuts, themeLabels, themeColors, type CloseAction } from '../stores/settings';
import { useToast } from '../composables/useToast'; import { useToast } from '../composables/useToast';
import { useUpdater } from '../composables/useUpdater'; import { useUpdater } from '../composables/useUpdater';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
@ -317,11 +313,6 @@ function clearDownloadPath() {
showToast('已重置为默认路径', 'success'); showToast('已重置为默认路径', 'success');
} }
const themeOptions = [
{ label: '深色', value: 'dark' as const },
{ label: '浅色', value: 'light' as const },
];
async function handleCheckUpdate() { async function handleCheckUpdate() {
const result = await updater.checkForUpdate(false); const result = await updater.checkForUpdate(false);
if (!result) { if (!result) {
@ -415,6 +406,12 @@ function onRecordingKeydown(e: KeyboardEvent) {
} }
if (parts.length > 0 && !ignoredKeys.includes(e.key)) { if (parts.length > 0 && !ignoredKeys.includes(e.key)) {
const hasModifier = parts.includes('Control') || parts.includes('Alt') || parts.includes('Shift');
if (!hasModifier) {
showToast('快捷键必须包含 Ctrl、Alt 或 Shift', 'error');
recordingId.value = null;
return;
}
const combo = parts.join('+'); const combo = parts.join('+');
settings.setShortcut(recordingId.value, combo); settings.setShortcut(recordingId.value, combo);
recordingId.value = null; recordingId.value = null;