feat: 添加插件系统,修复TypeScript错误
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|||||||
13
src/App.vue
13
src/App.vue
@@ -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>
|
||||||
|
|||||||
@@ -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: ", ";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
|||||||
51
src/plugins/impl/defaultPlugin.ts
Normal file
51
src/plugins/impl/defaultPlugin.ts
Normal 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
3
src/plugins/index.ts
Normal 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
10
src/plugins/init.ts
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
73
src/plugins/pluginManager.ts
Normal file
73
src/plugins/pluginManager.ts
Normal 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();
|
||||||
@@ -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
2
src/types/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './song';
|
||||||
|
export * from './plugin';
|
||||||
30
src/types/plugin.ts
Normal file
30
src/types/plugin.ts
Normal 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>;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user