mirror of
https://github.com/atdunbg/Nekosonic-Music.git
synced 2026-06-22 00:58:51 +08:00
Compare commits
2 Commits
987d34f58b
...
970fb15f5a
| Author | SHA1 | Date | |
|---|---|---|---|
| 970fb15f5a | |||
| cf21c96eaf |
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@ -4,7 +4,7 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "Nekosonic"
|
||||
version = "0.4.0"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"cpal",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "Nekosonic"
|
||||
version = "0.4.0"
|
||||
version = "0.4.1"
|
||||
description = "A Simple music app"
|
||||
authors = ["atdunbg"]
|
||||
edition = "2021"
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Nekosonic",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.1",
|
||||
"identifier": "com.atdunbg.Nekosonic",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
15
src/App.vue
15
src/App.vue
@ -184,6 +184,12 @@
|
||||
:class="roamTab === 'comment' ? 'bg-white/15 text-white font-medium' : 'text-white/50 hover:text-white/80'">
|
||||
评论
|
||||
</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 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"
|
||||
@ -198,6 +204,7 @@
|
||||
@mouseleave="roamLyricHovering = false"
|
||||
>
|
||||
{{ line.text }}
|
||||
<span v-if="showTranslation && line.translation" class="block text-sm opacity-60 mt-1">{{ line.translation }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="text-content-3 text-center mt-8">暂无歌词</div>
|
||||
@ -305,7 +312,7 @@ function doSearch() {
|
||||
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 roamLyricHovering = ref(false);
|
||||
const roamLyricPadPx = ref(0);
|
||||
@ -372,8 +379,8 @@ function getRoamLyricClass(idx: number): string {
|
||||
return 'roam-lyric-active text-accent-text font-semibold text-xl';
|
||||
}
|
||||
if (diff === 1) return 'text-content/70 text-lg';
|
||||
if (diff === 2) return 'text-content-2/50 text-base';
|
||||
return 'text-content-3/35 text-base';
|
||||
if (diff === 2) return 'text-content-2/50 text-[1rem]';
|
||||
return 'text-content-3/35 text-[1rem]';
|
||||
}
|
||||
|
||||
function seekToRoamLyric(time: number) {
|
||||
@ -506,6 +513,7 @@ onMounted(() => {
|
||||
|
||||
async function registerGlobalShortcuts() {
|
||||
const globalActions: Record<string, () => void> = {
|
||||
globalPlayPause: () => player.toggle(),
|
||||
globalPrev: () => player.prev(),
|
||||
globalNext: () => player.next(),
|
||||
globalVolUp: () => player.adjustVolume(5),
|
||||
@ -551,6 +559,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
const localActions: Record<string, () => void> = {
|
||||
playPause: () => player.toggle(),
|
||||
prev: () => player.prev(),
|
||||
next: () => player.next(),
|
||||
volUp: () => player.adjustVolume(5),
|
||||
|
||||
@ -104,41 +104,99 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition name="slide-up">
|
||||
<div v-if="showQueuePanel"
|
||||
class="border-t border-line bg-surface/95 backdrop-blur p-4 max-h-64 overflow-y-auto">
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<h3 class="text-sm font-semibold">播放列表 ({{ player.queue.length }})</h3>
|
||||
<button @click="player.clearQueue()" class="text-xs text-danger hover:text-danger transition">清空</button>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div v-for="(song, idx) in player.queue" :key="song.id + '-' + idx" @click="playFromQueue(idx)" :class="[
|
||||
'flex items-center gap-3 p-2 rounded-lg cursor-pointer transition',
|
||||
idx === player.currentIndex ? 'bg-accent-dim text-content' : 'hover:bg-subtle text-content-2',
|
||||
]">
|
||||
<span class="text-xs w-6 text-center">{{ idx + 1 }}</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs font-medium truncate">{{ song.name }}</p>
|
||||
<p class="text-xs text-content-3 truncate">
|
||||
<template v-for="(a, i) in song.ar || []" :key="a.id || i">
|
||||
<span v-if="i > 0" class="text-content-3">/</span>
|
||||
<span class="hover:text-accent-text cursor-pointer transition" @click.stop="a.id && router.push({ name: 'artist', params: { id: a.id } })">{{ a.name }}</span>
|
||||
</template>
|
||||
</p>
|
||||
<Teleport to="body">
|
||||
<Transition name="queue-fade">
|
||||
<div v-if="showQueuePanel" class="fixed inset-0 z-[55] bg-black/40 backdrop-blur-[2px]" @click="showQueuePanel = false"></div>
|
||||
</Transition>
|
||||
<Transition name="queue-slide">
|
||||
<div v-if="showQueuePanel"
|
||||
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="px-5 pt-5 pb-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-[1rem] font-semibold text-content">播放列表</h3>
|
||||
<p class="text-xs text-content-3 mt-0.5">{{ player.queue.length }} 首歌曲</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="player.clearQueue()"
|
||||
class="px-2.5 py-1 text-xs text-content-3 hover:text-danger hover:bg-danger-dim rounded-lg transition">
|
||||
清空
|
||||
</button>
|
||||
<button @click="showQueuePanel = false"
|
||||
class="w-7 h-7 flex items-center justify-center rounded-lg text-content-3 hover:text-content hover:bg-subtle transition">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button @click.stop="player.removeFromQueue(idx)"
|
||||
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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 { useDownload } from '../composables/useDownload';
|
||||
import { formatTime } from '../utils/format';
|
||||
@ -150,6 +208,8 @@ const router = useRouter();
|
||||
const player = usePlayerStore();
|
||||
const download = useDownload();
|
||||
const showQueuePanel = ref(false);
|
||||
const queueListEl = ref<HTMLElement | null>(null);
|
||||
const currentSongVisible = ref(true);
|
||||
const progressBar = ref<HTMLElement | null>(null);
|
||||
const isSeeking = ref(false);
|
||||
const previewTime = ref(0);
|
||||
@ -241,6 +301,50 @@ function playFromQueue(index: number) {
|
||||
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) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const val = parseInt(target.value, 10);
|
||||
@ -255,26 +359,6 @@ const volumeBarBg = computed(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.slide-up-enter-from,
|
||||
.slide-up-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
.vol-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
@ -311,4 +395,31 @@ const volumeBarBg = computed(() => {
|
||||
.vol-slider::-webkit-slider-thumb:hover {
|
||||
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>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<Transition name="fade">
|
||||
<div v-if="visible" class="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="handleIgnore">
|
||||
<div 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="p-6 pb-4">
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { ref, watch } from 'vue';
|
||||
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';
|
||||
|
||||
export function useLyric() {
|
||||
@ -8,21 +8,33 @@ export function useLyric() {
|
||||
|
||||
const lyrics = ref<LyricLine[]>([]);
|
||||
const currentLyricIdx = ref(-1);
|
||||
const showTranslation = ref(true);
|
||||
const hasTranslation = ref(false);
|
||||
|
||||
watch(() => player.currentSong, async (song) => {
|
||||
if (!song) {
|
||||
lyrics.value = [];
|
||||
currentLyricIdx.value = -1;
|
||||
hasTranslation.value = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const jsonStr: string = await invoke('get_lyric', { id: song.id });
|
||||
const data = JSON.parse(jsonStr);
|
||||
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;
|
||||
} catch {
|
||||
lyrics.value = [];
|
||||
hasTranslation.value = false;
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
@ -34,8 +46,15 @@ export function useLyric() {
|
||||
}
|
||||
});
|
||||
|
||||
function toggleTranslation() {
|
||||
showTranslation.value = !showTranslation.value;
|
||||
}
|
||||
|
||||
return {
|
||||
lyrics,
|
||||
currentLyricIdx,
|
||||
hasTranslation,
|
||||
showTranslation,
|
||||
toggleTranslation,
|
||||
};
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref , watch } from 'vue';
|
||||
import { ref, watch, nextTick } from 'vue';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { normalizeSong } from '../utils/song';
|
||||
import { useSettingsStore } from './settings';
|
||||
@ -422,6 +422,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
function openRoamDrawer(tab: 'lyric' | 'comment' = 'lyric') {
|
||||
roamInitialTab.value = tab;
|
||||
showRoamDrawer.value = true;
|
||||
nextTick(() => { roamInitialTab.value = 'lyric'; });
|
||||
}
|
||||
|
||||
function openCommentForSong(songId: number) {
|
||||
|
||||
@ -2,9 +2,29 @@ import { defineStore } from 'pinia';
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
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 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> = {
|
||||
standard: '标准',
|
||||
higher: '较高',
|
||||
@ -25,10 +45,12 @@ export interface ShortcutBinding {
|
||||
}
|
||||
|
||||
export const defaultShortcuts: Record<string, ShortcutBinding> = {
|
||||
playPause: { key: 'Control+KeyP', label: '播放/暂停' },
|
||||
prev: { key: 'Control+ArrowLeft', label: '上一首' },
|
||||
next: { key: 'Control+ArrowRight', label: '下一首' },
|
||||
volUp: { key: 'Control+ArrowUp', label: '音量增加' },
|
||||
volDown: { key: 'Control+ArrowDown', label: '音量减小' },
|
||||
globalPlayPause: { key: 'Alt+Control+KeyP', label: '播放/暂停(全局)' },
|
||||
globalPrev: { key: 'Alt+Control+ArrowLeft', label: '上一首(全局)' },
|
||||
globalNext: { key: 'Alt+Control+ArrowRight', label: '下一首(全局)' },
|
||||
globalVolUp: { key: 'Alt+Control+ArrowUp', label: '音量增加(全局)' },
|
||||
@ -38,7 +60,7 @@ export const defaultShortcuts: Record<string, ShortcutBinding> = {
|
||||
interface SettingsData {
|
||||
audioQuality: AudioQuality;
|
||||
downloadPath: string;
|
||||
theme: ThemeMode;
|
||||
theme: ThemeName;
|
||||
closeAction: CloseAction;
|
||||
shortcuts: Record<string, ShortcutBinding>;
|
||||
outputDevice: string | null;
|
||||
@ -49,10 +71,12 @@ function loadSettings(): SettingsData {
|
||||
const raw = localStorage.getItem('app_settings');
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
const theme = parsed.theme || parsed.accentColor || 'green';
|
||||
const validThemes: ThemeName[] = ['green', 'rose', 'blue', 'violet', 'orange', 'cyan', 'pink'];
|
||||
return {
|
||||
audioQuality: parsed.audioQuality || 'standard',
|
||||
downloadPath: parsed.downloadPath || '',
|
||||
theme: parsed.theme || 'dark',
|
||||
theme: validThemes.includes(theme) ? theme : 'green',
|
||||
closeAction: parsed.closeAction || 'ask',
|
||||
shortcuts: { ...defaultShortcuts, ...(parsed.shortcuts || {}) },
|
||||
outputDevice: parsed.outputDevice || null,
|
||||
@ -62,7 +86,7 @@ function loadSettings(): SettingsData {
|
||||
return {
|
||||
audioQuality: 'standard',
|
||||
downloadPath: '',
|
||||
theme: 'dark',
|
||||
theme: 'green',
|
||||
closeAction: 'ask',
|
||||
shortcuts: { ...defaultShortcuts },
|
||||
outputDevice: null,
|
||||
@ -74,7 +98,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
|
||||
const audioQuality = ref<AudioQuality>(saved.audioQuality);
|
||||
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 shortcuts = ref<Record<string, ShortcutBinding>>(saved.shortcuts);
|
||||
const outputDevice = ref<string | null>(saved.outputDevice);
|
||||
@ -87,7 +111,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
downloadPath.value = p;
|
||||
}
|
||||
|
||||
function setTheme(t: ThemeMode) {
|
||||
function setTheme(t: ThemeName) {
|
||||
theme.value = t;
|
||||
}
|
||||
|
||||
@ -110,7 +134,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
function resetAll() {
|
||||
audioQuality.value = 'standard';
|
||||
downloadPath.value = '';
|
||||
theme.value = 'dark';
|
||||
theme.value = 'green';
|
||||
closeAction.value = 'ask';
|
||||
shortcuts.value = { ...defaultShortcuts };
|
||||
outputDevice.value = null;
|
||||
|
||||
166
src/style.css
166
src/style.css
@ -23,18 +23,19 @@
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--c-bg: #030712;
|
||||
--c-surface: #111827;
|
||||
--c-subtle: rgba(255, 255, 255, 0.05);
|
||||
--c-muted: rgba(255, 255, 255, 0.10);
|
||||
--c-emphasis: rgba(255, 255, 255, 0.18);
|
||||
: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.10);
|
||||
--c-line-2: rgba(255, 255, 255, 0.05);
|
||||
--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;
|
||||
@ -45,26 +46,136 @@
|
||||
--c-info: #3b82f6;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--c-bg: #f3f4f6;
|
||||
--c-surface: #ffffff;
|
||||
--c-subtle: rgba(0, 0, 0, 0.04);
|
||||
--c-muted: rgba(0, 0, 0, 0.08);
|
||||
--c-emphasis: rgba(0, 0, 0, 0.12);
|
||||
--c-content: #111827;
|
||||
--c-content-2: #4b5563;
|
||||
[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: #9ca3af;
|
||||
--c-line: rgba(0, 0, 0, 0.10);
|
||||
--c-line-2: rgba(0, 0, 0, 0.05);
|
||||
--c-accent: #16a34a;
|
||||
--c-accent-hover: #15803d;
|
||||
--c-accent-text: #16a34a;
|
||||
--c-accent-dim: rgba(22, 163, 74, 0.15);
|
||||
--c-danger: #dc2626;
|
||||
--c-danger-dim: rgba(220, 38, 38, 0.15);
|
||||
--c-warning: #ca8a04;
|
||||
--c-info: #2563eb;
|
||||
--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"] {
|
||||
--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 {
|
||||
@ -78,6 +189,7 @@
|
||||
@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;
|
||||
overflow: hidden;
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
export interface LyricLine {
|
||||
time: number; // 秒
|
||||
time: number;
|
||||
text: string;
|
||||
translation?: string;
|
||||
}
|
||||
|
||||
export function parseLrc(lrcStr: string): LyricLine[] {
|
||||
@ -20,11 +21,37 @@ export function parseLrc(lrcStr: string): LyricLine[] {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 按时长排序
|
||||
result.sort((a, b) => a.time - b.time);
|
||||
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 {
|
||||
let index = -1;
|
||||
for (let i = 0; i < lyrics.length; i++) {
|
||||
|
||||
@ -28,23 +28,19 @@
|
||||
|
||||
<section class="mb-8">
|
||||
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">外观</h2>
|
||||
<div class="space-y-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium">主题</p>
|
||||
<p class="text-xs text-content-3 mt-0.5">切换应用主题</p>
|
||||
</div>
|
||||
<div class="flex bg-subtle rounded-lg p-0.5">
|
||||
<button
|
||||
v-for="t in themeOptions"
|
||||
:key="t.value"
|
||||
@click="settings.setTheme(t.value)"
|
||||
class="px-3 py-1.5 rounded-md text-sm transition"
|
||||
:class="settings.theme === t.value ? 'bg-muted text-content' : 'text-content-3 hover:text-content-2'"
|
||||
>
|
||||
{{ t.label }}
|
||||
</button>
|
||||
</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-white/30 bg-white/5 scale-[1.02]' : 'border-transparent bg-subtle hover:bg-muted'"
|
||||
>
|
||||
<div class="w-8 h-8 rounded-full shadow-md" :style="{ backgroundColor: color }"></div>
|
||||
<span class="text-xs" :class="settings.theme === key ? 'text-content font-medium' : 'text-content-3'">{{ themeLabels[key] }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@ -241,7 +237,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
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 { useUpdater } from '../composables/useUpdater';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
@ -317,11 +313,6 @@ function clearDownloadPath() {
|
||||
showToast('已重置为默认路径', 'success');
|
||||
}
|
||||
|
||||
const themeOptions = [
|
||||
{ label: '深色', value: 'dark' as const },
|
||||
{ label: '浅色', value: 'light' as const },
|
||||
];
|
||||
|
||||
async function handleCheckUpdate() {
|
||||
const result = await updater.checkForUpdate(false);
|
||||
if (!result) {
|
||||
@ -415,6 +406,12 @@ function onRecordingKeydown(e: KeyboardEvent) {
|
||||
}
|
||||
|
||||
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('+');
|
||||
settings.setShortcut(recordingId.value, combo);
|
||||
recordingId.value = null;
|
||||
|
||||
Reference in New Issue
Block a user