feat: 添加插件系统,修复TypeScript错误

This commit is contained in:
QZMusic
2026-06-04 14:23:39 +00:00
parent 98da20cab4
commit e92d6cec96
14 changed files with 231 additions and 151 deletions

View File

@@ -1,6 +1,6 @@
# QZMusic Web # QZMusic Web
QZMusic 网页版,基于 Vue 3 + TypeScript + Vite 构建的音乐播放器。 QZMusic 网页版,基于 Vue 3 + TypeScript + Vite 构建的音乐播放器,支持插件系统获取音乐资源
## 功能特性 ## 功能特性
@@ -10,6 +10,7 @@ QZMusic 网页版,基于 Vue 3 + TypeScript + Vite 构建的音乐播放器。
- 🎚️ 音量控制 - 🎚️ 音量控制
- 📊 音频可视化(基于 Web Audio API - 📊 音频可视化(基于 Web Audio API
- 🔍 搜索功能 - 🔍 搜索功能
- 🔌 **插件系统** - 支持通过插件获取音乐资源
- 🌐 默认端口10096 - 🌐 默认端口10096
## 快速开始 ## 快速开始

View File

@@ -16,15 +16,14 @@ const showSettings = ref(false);
provide('openSettings', () => { showSettings.value = true; }); provide('openSettings', () => { showSettings.value = true; });
// Apply saved theme on app startup // Apply saved theme on app startup
onMounted(async () => { onMounted(() => {
if (window.electronAPI?.settings) { const savedTheme = localStorage.getItem('qz-theme') || 'dark';
const settings = await window.electronAPI.settings.getAll(); const savedAccentColor = localStorage.getItem('qz-accent-color') || '#ec4141';
document.documentElement.setAttribute('data-theme', settings.theme); document.documentElement.setAttribute('data-theme', savedTheme);
document.documentElement.style.setProperty('--color-accent', settings.accentColor); document.documentElement.style.setProperty('--color-accent', savedAccentColor);
}
}); });
</script> </script>
<style> <style>
@import "styles/main.css"; @import "styles/main.css";
</style> </style>

View File

@@ -13,9 +13,7 @@
<a @click.stop="onAlbumClicked && onAlbumClicked()">{{ album }}</a> <a @click.stop="onAlbumClicked && onAlbumClicked()">{{ album }}</a>
</TextMarquee> </TextMarquee>
</div> </div>
<!-- MenuButton placeholder or implementation -->
<div class="menu-button-placeholder" @click="onMenuButtonClicked"> <div class="menu-button-placeholder" @click="onMenuButtonClicked">
<!-- Icon could go here -->
</div> </div>
</div> </div>
</template> </template>
@@ -23,7 +21,7 @@
<script setup lang="ts"> <script setup lang="ts">
import TextMarquee from './TextMarquee.vue'; import TextMarquee from './TextMarquee.vue';
const props = defineProps<{ defineProps<{
name?: string; name?: string;
artists?: string[]; artists?: string[];
album?: string; album?: string;
@@ -46,7 +44,6 @@ const props = defineProps<{
flex: 1; flex: 1;
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
/* Fixed line-height to prevent layout shifting */
line-height: 1.25em; line-height: 1.25em;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -91,7 +88,6 @@ const props = defineProps<{
opacity: 0.5; opacity: 0.5;
} }
/* Add separators between artists */
.artists :deep(span::after) { .artists :deep(span::after) {
content: ", "; content: ", ";
} }

View File

@@ -15,7 +15,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onUnmounted } from 'vue'; import { ref, onUnmounted } from 'vue';
const props = defineProps<{ defineProps<{
className?: string; className?: string;
}>(); }>();

View File

@@ -1,10 +1,10 @@
// src/renderer/main.ts
import { createApp } from 'vue' import { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import { createRouter, createWebHashHistory } from 'vue-router' import { createRouter, createWebHashHistory } from 'vue-router'
import App from './App.vue' import App from './App.vue'
import 'tdesign-vue-next/es/style/index.css' import 'tdesign-vue-next/es/style/index.css'
import { initPlugins } from './plugins/init'
const pinia = createPinia() const pinia = createPinia()
const router = createRouter({ const router = createRouter({
@@ -41,4 +41,7 @@ const router = createRouter({
const app = createApp(App) const app = createApp(App)
app.use(pinia) app.use(pinia)
app.use(router) app.use(router)
app.mount('#app')
initPlugins().then(() => {
app.mount('#app')
})

View File

@@ -0,0 +1,51 @@
import type { PluginModule, PluginSearchResult } from '../../types/plugin';
const mockSongs = [
{ id: '1', name: '晴天', artist: '周杰伦', albumName: '叶惠美', duration: '04:29', picUrl: 'https://picsum.photos/200/200?random=1' },
{ id: '2', name: '夜曲', artist: '周杰伦', albumName: '十一月的萧邦', duration: '04:23', picUrl: 'https://picsum.photos/200/200?random=2' },
{ id: '3', name: '稻香', artist: '周杰伦', albumName: '魔杰座', duration: '03:43', picUrl: 'https://picsum.photos/200/200?random=3' },
{ id: '4', name: '七里香', artist: '周杰伦', albumName: '七里香', duration: '04:59', picUrl: 'https://picsum.photos/200/200?random=4' },
{ id: '5', name: '告白气球', artist: '周杰伦', albumName: '周杰伦的床边故事', duration: '03:35', picUrl: 'https://picsum.photos/200/200?random=5' },
{ id: '6', name: '成都', artist: '赵雷', albumName: '无法长大', duration: '05:28', picUrl: 'https://picsum.photos/200/200?random=6' },
{ id: '7', name: '理想', artist: '赵雷', albumName: '无法长大', duration: '04:26', picUrl: 'https://picsum.photos/200/200?random=7' },
{ id: '8', name: '南方姑娘', artist: '赵雷', albumName: '赵小雷', duration: '05:34', picUrl: 'https://picsum.photos/200/200?random=8' },
{ id: '9', name: '平凡之路', artist: '朴树', albumName: '猎户星座', duration: '04:46', picUrl: 'https://picsum.photos/200/200?random=9' },
{ id: '10', name: '那些花儿', artist: '朴树', albumName: '我去2000年', duration: '04:57', picUrl: 'https://picsum.photos/200/200?random=10' },
];
const searchSongs = (query: string): typeof mockSongs => {
const q = query.toLowerCase();
return mockSongs.filter(song =>
song.name.toLowerCase().includes(q) ||
song.artist.toLowerCase().includes(q) ||
song.albumName.toLowerCase().includes(q)
);
};
export const defaultPlugin: PluginModule = {
info: {
id: 'default',
name: '默认音源',
description: '内置音乐搜索插件',
version: '1.0.0',
author: 'QZMusic',
},
async search(query: string, page: number, limit: number): Promise<PluginSearchResult> {
const results = searchSongs(query);
const start = (page - 1) * limit;
const end = start + limit;
return {
list: results.slice(start, end),
total: results.length,
songCount: results.length,
};
},
async getSongUrl(_songId: string): Promise<string> {
return 'http://commondatastorage.googleapis.com/codeskulptor-demos/riceracer_assets/music/win.ogg';
},
async getLyric(_songId: string): Promise<string> {
return `[00:00.00]歌曲歌词
[00:05.00]暂无歌词数据
[00:10.00]---`;
},
};

3
src/plugins/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export { pluginManager } from './pluginManager';
export { defaultPlugin } from './impl/defaultPlugin';
export * from '../types/plugin';

10
src/plugins/init.ts Normal file
View File

@@ -0,0 +1,10 @@
import { pluginManager, defaultPlugin } from './index';
export const initPlugins = async (): Promise<void> => {
await pluginManager.register(async () => defaultPlugin);
const savedPlugin = sessionStorage.getItem('qz-active-plugin');
if (savedPlugin) {
pluginManager.setActivePlugin(savedPlugin);
}
};

View File

@@ -0,0 +1,73 @@
import type { PluginInfo, PluginModule, PluginSearchResult } from '../types/plugin';
class PluginManager {
private plugins: Map<string, PluginModule> = new Map();
private activePluginId: string = '';
async register(loader: () => Promise<PluginModule>): Promise<void> {
try {
const module = await loader();
this.plugins.set(module.info.id, module);
if (!this.activePluginId) {
this.activePluginId = module.info.id;
}
} catch (error) {
console.error('Failed to load plugin:', error);
}
}
getAll(): PluginInfo[] {
return Array.from(this.plugins.values()).map(p => p.info);
}
get(id: string): PluginModule | undefined {
return this.plugins.get(id);
}
getActivePlugin(): PluginModule | undefined {
return this.plugins.get(this.activePluginId);
}
setActivePlugin(id: string): boolean {
if (this.plugins.has(id)) {
this.activePluginId = id;
sessionStorage.setItem('qz-active-plugin', id);
return true;
}
return false;
}
getActivePluginId(): string {
return this.activePluginId;
}
async search(query: string, page: number, limit: number): Promise<PluginSearchResult> {
const plugin = this.getActivePlugin();
if (!plugin) {
return { list: [], total: 0 };
}
return plugin.search(query, page, limit);
}
async getSongUrl(songId: string): Promise<string> {
const plugin = this.getActivePlugin();
if (!plugin || !plugin.getSongUrl) {
throw new Error('No active plugin or plugin does not support getSongUrl');
}
return plugin.getSongUrl(songId);
}
async getLyric(songId: string): Promise<string> {
const plugin = this.getActivePlugin();
if (!plugin || !plugin.getLyric) {
throw new Error('No active plugin or plugin does not support getLyric');
}
return plugin.getLyric(songId);
}
hasPlugins(): boolean {
return this.plugins.size > 0;
}
}
export const pluginManager = new PluginManager();

View File

@@ -2,7 +2,6 @@ import { defineStore } from 'pinia';
import { ref, shallowRef, watch } from 'vue'; import { ref, shallowRef, watch } from 'vue';
import { MessagePlugin } from 'tdesign-vue-next'; import { MessagePlugin } from 'tdesign-vue-next';
import type { Song } from '../types/song'; import type { Song } from '../types/song';
import { parseLyric } from '../utils/lyricUtil'
export enum PlayMode { export enum PlayMode {
List = 'list', List = 'list',

2
src/types/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from './song';
export * from './plugin';

30
src/types/plugin.ts Normal file
View File

@@ -0,0 +1,30 @@
export interface PluginInfo {
id: string;
name: string;
description?: string;
version?: string;
author?: string;
icon?: string;
}
export interface PluginSearchResult {
list: any[];
total?: number;
songCount?: number;
}
export interface PluginAPI {
search: (pluginId: string, query: string, page: number, limit: number) => Promise<PluginSearchResult>;
getSongUrl: (pluginId: string, songId: string) => Promise<string>;
getLyric: (pluginId: string, songId: string) => Promise<string>;
getAll: () => Promise<PluginInfo[]>;
}
export type PluginLoader = () => Promise<PluginModule>;
export interface PluginModule {
info: PluginInfo;
search: (query: string, page: number, limit: number) => Promise<PluginSearchResult>;
getSongUrl?: (songId: string) => Promise<string>;
getLyric?: (songId: string) => Promise<string>;
}

View File

@@ -1,7 +1,6 @@
<template> <template>
<div class="view-container playlist-view"> <div class="view-container playlist-view">
<div class="content-wrapper"> <div class="content-wrapper">
<!-- 动态头部设计 -->
<div class="playlist-header"> <div class="playlist-header">
<div class="header-bg" :class="themeClass"> <div class="header-bg" :class="themeClass">
<div class="flow-circle c1"></div> <div class="flow-circle c1"></div>
@@ -45,7 +44,6 @@
</div> </div>
</div> </div>
<!-- 歌曲列表 -->
<div class="song-list-container"> <div class="song-list-container">
<div class="list-header"> <div class="list-header">
<div class="col-index">#</div> <div class="col-index">#</div>
@@ -61,10 +59,10 @@
<div class="cover-gradient" :class="themeClass"></div> <div class="cover-gradient" :class="themeClass"></div>
</div> </div>
<div class="song-info"> <div class="song-info">
<h4 class="song-title">{{ song.title }}</h4> <h4 class="song-title">{{ song.name }}</h4>
<p class="song-artist">{{ song.artist }}</p> <p class="song-artist">{{ song.artist }}</p>
</div> </div>
<div class="song-album">{{ song.album }}</div> <div class="song-album">{{ song.albumName }}</div>
<div class="song-duration">{{ song.duration }}</div> <div class="song-duration">{{ song.duration }}</div>
</div> </div>
</div> </div>
@@ -78,15 +76,14 @@ import { computed, ref } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { Icon } from '@iconify/vue'; import { Icon } from '@iconify/vue';
import { usePlayerStore } from '../stores/player'; import { usePlayerStore } from '../stores/player';
import type { Song } from '../types/song';
const route = useRoute(); const route = useRoute();
const playerStore = usePlayerStore(); const playerStore = usePlayerStore();
// 判断页面类型
const isLiked = computed(() => route.path.includes('liked')); const isLiked = computed(() => route.path.includes('liked'));
const isRecent = computed(() => route.path.includes('recent')); const isRecent = computed(() => route.path.includes('recent'));
// 动态数据
const title = computed(() => { const title = computed(() => {
if (isLiked.value) return '我喜欢的音乐'; if (isLiked.value) return '我喜欢的音乐';
if (isRecent.value) return '最近播放'; if (isRecent.value) return '最近播放';
@@ -111,9 +108,8 @@ const coverGradient = computed(() => {
return 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'; return 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)';
}); });
// Mock Playlist Data const songs = ref<Song[]>(Array.from({ length: 15 }, (_, i) => ({
const songs = ref(Array.from({ length: 15 }, (_, i) => ({ id: String(i + 1),
id: i + 1,
name: `Song Title ${i + 1}`, name: `Song Title ${i + 1}`,
artist: `Artist Name ${i + 1}`, artist: `Artist Name ${i + 1}`,
albumName: `Album Mock`, albumName: `Album Mock`,
@@ -149,7 +145,6 @@ const handlePlaySong = (index: number) => {
margin: 0 auto; margin: 0 auto;
} }
/* Header Styles */
.playlist-header { .playlist-header {
position: relative; position: relative;
height: 240px; height: 240px;
@@ -174,7 +169,6 @@ const handlePlaySong = (index: number) => {
z-index: 1; z-index: 1;
} }
/* Default Dark Dynamic */
.header-bg { .header-bg {
background-image: linear-gradient(-45deg, #1e1e1e, #2a2a2a, #3a1c1c, #1a1a1a); background-image: linear-gradient(-45deg, #1e1e1e, #2a2a2a, #3a1c1c, #1a1a1a);
} }
@@ -204,7 +198,7 @@ const handlePlaySong = (index: number) => {
.c1 { .c1 {
width: 300px; width: 300px;
height: 300px; height: 300px;
background: #ec4141; /* Default/Liked Color */ background: #ec4141;
top: -50px; top: -50px;
left: -50px; left: -50px;
animation-delay: 0s; animation-delay: 0s;
@@ -219,7 +213,6 @@ const handlePlaySong = (index: number) => {
animation-delay: -5s; animation-delay: -5s;
} }
/* Recent Theme Colors */
.theme-recent .c1 { background: #a18cd1; } .theme-recent .c1 { background: #a18cd1; }
.theme-recent .c2 { background: #fbc2eb; } .theme-recent .c2 { background: #fbc2eb; }
@@ -322,7 +315,7 @@ const handlePlaySong = (index: number) => {
} }
.play-all-btn { .play-all-btn {
background: #ec4141; /* Default */ background: #ec4141;
color: white; color: white;
border: none; border: none;
padding: 10px 24px; padding: 10px 24px;
@@ -364,9 +357,6 @@ const handlePlaySong = (index: number) => {
border-color: white; border-color: white;
} }
/* List Styles */
.list-header { .list-header {
display: flex; display: flex;
padding: 0 16px; padding: 0 16px;
@@ -380,8 +370,6 @@ const handlePlaySong = (index: number) => {
.col-album { width: 200px; } .col-album { width: 200px; }
.col-time { width: 60px; text-align: right; } .col-time { width: 60px; text-align: right; }
.song-item { .song-item {
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;
@@ -424,7 +412,6 @@ const handlePlaySong = (index: number) => {
background: linear-gradient(45deg, #667eea, #764ba2); background: linear-gradient(45deg, #667eea, #764ba2);
} }
/* Different gradients for list items too, maybe? */
.cover-gradient.theme-liked { .cover-gradient.theme-liked {
background: linear-gradient(45deg, #ff9a9e, #fecfef); background: linear-gradient(45deg, #ff9a9e, #fecfef);
} }

View File

@@ -21,7 +21,6 @@
</div> </div>
</div> </div>
<!-- Plugin Selector -->
<div class="plugin-select-container"> <div class="plugin-select-container">
<button class="select-trigger" @click.stop="toggleDropdown" :title="'当前源: ' + activePluginName"> <button class="select-trigger" @click.stop="toggleDropdown" :title="'当前源: ' + activePluginName">
<span>{{ activePluginName }}</span> <span>{{ activePluginName }}</span>
@@ -86,7 +85,6 @@
</div> </div>
</div> </div>
<!-- Pagination -->
<div class="pagination"> <div class="pagination">
<button class="pagination-btn" :disabled="currentPage <= 1" @click="changePage(currentPage - 1)"> <button class="pagination-btn" :disabled="currentPage <= 1" @click="changePage(currentPage - 1)">
<Icon icon="lucide:chevron-left" /> <Icon icon="lucide:chevron-left" />
@@ -129,13 +127,12 @@ import { ref, watch, computed, onMounted, onBeforeUnmount } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { Icon } from '@iconify/vue'; import { Icon } from '@iconify/vue';
import { usePlayerStore } from '../stores/player'; import { usePlayerStore } from '../stores/player';
import { transformSearchSong } from '../utils/songUtils'; import { pluginManager } from '../plugins/index';
import type { Song } from '../types/song'; import type { PluginInfo, Song } from '../types';
const route = useRoute(); const route = useRoute();
const playerStore = usePlayerStore(); const playerStore = usePlayerStore();
// --- State ---
const query = computed(() => route.query.q as string || ''); const query = computed(() => route.query.q as string || '');
const currentPage = ref(1); const currentPage = ref(1);
const showSettings = ref(false); const showSettings = ref(false);
@@ -147,9 +144,8 @@ const loading = ref(false);
const error = ref(false); const error = ref(false);
const songs = ref<Song[]>([]); const songs = ref<Song[]>([]);
// Plugin Selector State const plugins = ref<PluginInfo[]>([]);
const plugins = ref<any[]>([]); const activePlugin = ref<string>('');
const activePlugin = ref<string>(''); // Plugin ID
const isDropdownOpen = ref(false); const isDropdownOpen = ref(false);
const activePluginName = computed(() => { const activePluginName = computed(() => {
@@ -161,17 +157,16 @@ const toggleDropdown = () => {
isDropdownOpen.value = !isDropdownOpen.value; isDropdownOpen.value = !isDropdownOpen.value;
}; };
const selectPlugin = (plugin: any) => { const selectPlugin = (plugin: PluginInfo) => {
if (activePlugin.value !== plugin.id) { if (activePlugin.value !== plugin.id) {
activePlugin.value = plugin.id; activePlugin.value = plugin.id;
sessionStorage.setItem('qz-active-plugin', plugin.id); pluginManager.setActivePlugin(plugin.id);
isDropdownOpen.value = false; isDropdownOpen.value = false;
} else { } else {
isDropdownOpen.value = false; isDropdownOpen.value = false;
} }
}; };
// Close dropdown on click outside
const closeDropdown = (e: MouseEvent) => { const closeDropdown = (e: MouseEvent) => {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
if (!target.closest('.plugin-select-container')) { if (!target.closest('.plugin-select-container')) {
@@ -179,7 +174,6 @@ const closeDropdown = (e: MouseEvent) => {
} }
}; };
// --- Computed ---
const totalPages = computed(() => { const totalPages = computed(() => {
const t = Number(total.value) || 0; const t = Number(total.value) || 0;
const l = Number(limit.value) || 30; const l = Number(limit.value) || 30;
@@ -208,26 +202,14 @@ const visiblePages = computed(() => {
return pages; return pages;
}); });
// --- Methods --- const loadPlugins = () => {
const loadPlugins = async () => { plugins.value = pluginManager.getAll();
try { const saved = sessionStorage.getItem('qz-active-plugin');
if (window.electronAPI?.plugin?.getAll) { if (saved && plugins.value.find(p => p.id === saved)) {
const all = await window.electronAPI.plugin.getAll(); activePlugin.value = saved;
// Filter valid plugins basically (have id and name) } else if (plugins.value.length > 0) {
plugins.value = all.filter((p: any) => p.id && p.name); activePlugin.value = plugins.value[0].id;
pluginManager.setActivePlugin(activePlugin.value);
// Restore selection or default
const saved = sessionStorage.getItem('qz-active-plugin');
if (saved && plugins.value.find(p => p.id === saved)) {
activePlugin.value = saved;
} else if (plugins.value.length > 0) {
// Default to 'wy' if present, else first
const wy = plugins.value.find(p => p.id === 'wy');
activePlugin.value = wy ? 'wy' : plugins.value[0].id;
}
}
} catch (e) {
console.error("Failed to load plugins", e);
} }
}; };
@@ -239,10 +221,20 @@ const fetchData = async () => {
songs.value = []; songs.value = [];
try { try {
const result = await window.electronAPI.plugin.search(activePlugin.value, query.value, currentPage.value, limit.value); const result = await pluginManager.search(query.value, currentPage.value, limit.value);
if (result && result.list) { if (result && result.list) {
songs.value = result.list.map((item: any) => transformSearchSong(item)); songs.value = result.list.map((item: any) => ({
id: String(item.id),
name: item.name,
artist: item.artist,
albumName: item.albumName,
duration: item.duration,
url: item.url || '',
picUrl: item.picUrl || '',
source: activePlugin.value,
type: 'Remote' as const
}));
total.value = result.songCount || result.total || 0; total.value = result.songCount || result.total || 0;
} else { } else {
total.value = 0; total.value = 0;
@@ -290,12 +282,10 @@ const highlight = (text: string) => {
return text.replace(regex, match => `<span class="highlight">${match}</span>`); return text.replace(regex, match => `<span class="highlight">${match}</span>`);
}; };
// --- Watchers & Lifecycle ---
watch(query, () => { watch(query, () => {
currentPage.value = 1; currentPage.value = 1;
// ensure plugins loaded before fetching? usually mounted happens first
if (activePlugin.value) fetchData(); if (activePlugin.value) fetchData();
}, { immediate: false }); // Wait for mount init }, { immediate: false });
watch(limit, (newLimit) => { watch(limit, (newLimit) => {
localStorage.setItem('qz-search-limit', newLimit.toString()); localStorage.setItem('qz-search-limit', newLimit.toString());
@@ -310,10 +300,9 @@ watch(activePlugin, (newVal, oldVal) => {
} }
}); });
onMounted(async () => { onMounted(() => {
document.addEventListener('click', closeDropdown); document.addEventListener('click', closeDropdown);
await loadPlugins(); loadPlugins();
// After plugins loaded, if we have a query, fetch data
if (query.value && activePlugin.value) { if (query.value && activePlugin.value) {
fetchData(); fetchData();
} }
@@ -324,7 +313,6 @@ onBeforeUnmount(() => {
}); });
</script> </script>
<style scoped> <style scoped>
.view-container { .view-container {
width: 100%; width: 100%;
@@ -334,10 +322,8 @@ onBeforeUnmount(() => {
.content-wrapper { .content-wrapper {
box-sizing: border-box; box-sizing: border-box;
padding: 20px 30px; /* Reduced vertical padding, kept horizontal for spacing but flexible */ padding: 20px 30px;
width: 100%; width: 100%;
/* Removed max-width to allow full width usage as requested */
/* margin: 0 auto; */
} }
.search-header { .search-header {
@@ -401,7 +387,6 @@ onBeforeUnmount(() => {
color: var(--color-accent); color: var(--color-accent);
} }
/* Settings Panel */
.settings-panel { .settings-panel {
background: var(--color-bg-primary); background: var(--color-bg-primary);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
@@ -411,7 +396,7 @@ onBeforeUnmount(() => {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
position: relative; /* Context */ position: relative;
} }
.limit-setting { .limit-setting {
@@ -420,10 +405,8 @@ onBeforeUnmount(() => {
gap: 16px; gap: 16px;
} }
/* Plugin Selector */
.plugin-select-container { .plugin-select-container {
position: relative; position: relative;
/* ensure it stays on top when options open? No, options use absolute */
z-index: 10; z-index: 10;
} }
@@ -499,7 +482,6 @@ onBeforeUnmount(() => {
font-size: 14px; font-size: 14px;
} }
/* Fade util */
.fade-enter-active, .fade-enter-active,
.fade-leave-active { .fade-leave-active {
transition: opacity 0.2s ease; transition: opacity 0.2s ease;
@@ -510,8 +492,6 @@ onBeforeUnmount(() => {
opacity: 0; opacity: 0;
} }
.setting-label { .setting-label {
font-size: 14px; font-size: 14px;
color: var(--color-text-secondary); color: var(--color-text-secondary);
@@ -580,10 +560,8 @@ onBeforeUnmount(() => {
border-width: 0; border-width: 0;
} }
/* List Grid Layout */
.list-header, .song-item { .list-header, .song-item {
display: grid; display: grid;
/* Define columns: Index | Title (40%) | Album (30%) | Time */
grid-template-columns: 50px 4fr 3fr 60px; grid-template-columns: 50px 4fr 3fr 60px;
gap: 16px; gap: 16px;
align-items: center; align-items: center;
@@ -595,12 +573,10 @@ onBeforeUnmount(() => {
margin-bottom: 8px; margin-bottom: 8px;
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: 13px; font-size: 13px;
/* Header doesn't need hover background usually, but padding ensures alignment */
} }
/* Override existing mixins/styles if necessary */
.col-index, .col-title, .col-album, .col-time { .col-index, .col-title, .col-album, .col-time {
width: auto; /* Let grid handle width */ width: auto;
} }
.col-index { text-align: center; } .col-index { text-align: center; }
.col-time { text-align: right; } .col-time { text-align: right; }
@@ -611,7 +587,7 @@ onBeforeUnmount(() => {
transition: background-color 0.2s; transition: background-color 0.2s;
cursor: pointer; cursor: pointer;
color: var(--color-text-secondary); color: var(--color-text-secondary);
width: 100%; /* Fill width */ width: 100%;
} }
.song-item:hover { .song-item:hover {
@@ -626,63 +602,17 @@ onBeforeUnmount(() => {
} }
.song-cover { .song-cover {
/* Cover is not in the top grid definition, wait. display: flex;
The previous request structure had a cover inside.
The Grid should account for cover?
Previous structure: Index | Cover | Info (Title+Artist) | Album | Time
Let's adjust Grid columns to: Index | Cover+Info | Album | Time?
Or Index | Info (with Cover flow) | Album | Time.
Let's stick to the structure:
.song-item > .song-index
.song-item > .song-cover (This was separate div in previous template)
.song-item > .song-info
.song-item > .song-album
.song-item > .song-duration
So we have 5 direct children in .song-item?
Checking template:
1. .song-index
2. .song-cover
3. .song-info
4. .song-album
5. .song-duration
Yes, 5 columns.
Updated Grid: Index(50px) Cover(40px) Info(4fr) Album(3fr) Time(60px)
*/
display: flex; /* Reset for grid child if needed */
} }
/* Refined Grid for 5 columns */
.list-header, .song-item { .list-header, .song-item {
grid-template-columns: 50px 40px 4fr 3fr 60px; grid-template-columns: 50px 40px 4fr 3fr 60px;
} }
/* Header has 4 children (Index, Title, Album, Time).
We need to make Title span 2 columns (Cover + Info area) or Insert a spacer?
Title header usually aligns with Title text.
Let's make Header Grid: 50px (Spacer 40px) Title ...
Actually, usually Cover doesn't have a header.
Let's align "Title" header to start of Info.
List Header children:
div.col-index
div.col-title
div.col-album
div.col-time
We need to adjust .col-title to span the Cover+Info space? Or just Info space?
If we want "Title" label to align with Song Title text, we should skip the Cover column.
*/
.list-header { .list-header {
/* 50px | 40px spacer | 4fr | 3fr | 60px */
grid-template-columns: 50px 40px 4fr 3fr 60px; grid-template-columns: 50px 40px 4fr 3fr 60px;
} }
/* We need a phantom element or nth-child hacking for header?
Easiest is to add a spacer div in template or just use grid-column on col-title?
If we say .col-title { grid-column: 3 / 4; } ?
*/
.list-header .col-title { .list-header .col-title {
grid-column: 3; grid-column: 3;
} }
@@ -693,7 +623,6 @@ onBeforeUnmount(() => {
grid-column: 5; grid-column: 5;
} }
.song-cover { .song-cover {
width: 40px; width: 40px;
height: 40px; height: 40px;
@@ -713,7 +642,7 @@ onBeforeUnmount(() => {
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
min-width: 0; min-width: 0;
padding-left: 10px; /* Small gap between cover and text */ padding-left: 10px;
} }
.song-title { .song-title {
@@ -746,18 +675,16 @@ onBeforeUnmount(() => {
font-size: 13px; font-size: 13px;
} }
/* Highlight */
:deep(.highlight) { :deep(.highlight) {
color: var(--color-accent); color: var(--color-accent);
} }
/* Pagination */
.pagination { .pagination {
display: flex; display: flex;
justify-content: flex-start; /* Alignment Change: Left Align */ justify-content: flex-start;
align-items: center; align-items: center;
margin-top: 32px; margin-top: 32px;
gap: 8px; /* Closer gap */ gap: 8px;
} }
.pagination-btn { .pagination-btn {
@@ -783,7 +710,7 @@ onBeforeUnmount(() => {
.pagination-btn.active { .pagination-btn.active {
background: var(--color-accent); background: var(--color-accent);
color: #fff; /* Ensure readable text on accent */ color: #fff;
border-color: var(--color-accent); border-color: var(--color-accent);
font-weight: bold; font-weight: bold;
} }
@@ -794,7 +721,6 @@ onBeforeUnmount(() => {
border-color: transparent; border-color: transparent;
} }
/* States */
.loading-state, .empty-state, .error-state { .loading-state, .empty-state, .error-state {
padding: 60px 0; padding: 60px 0;
display: flex; display: flex;
@@ -837,7 +763,7 @@ onBeforeUnmount(() => {
margin-top: 8px; margin-top: 8px;
padding: 8px 24px; padding: 8px 24px;
background: var(--color-accent); background: var(--color-accent);
color: white; /* Ensure text is readable on accent color */ color: white;
border-radius: var(--radius-full); border-radius: var(--radius-full);
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;