- 搜索页UI
- 插件系统支持搜索单曲接口
- 修复音量初始化和状态
- 搜索关键词匹配优化
This commit is contained in:
lqtmcstudio
2026-02-05 23:44:51 +08:00
parent 719cacef11
commit 21b80c566b
16 changed files with 610 additions and 8 deletions

1
.gitignore vendored
View File

@@ -22,5 +22,4 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
core/mpv.exe
.gitignore .gitignore

View File

@@ -66,6 +66,7 @@ class QzpController extends EventEmitter {
this.send(["observe_property", 3, "duration"]); this.send(["observe_property", 3, "duration"]);
this.send(["observe_property", 4, "idle-active"]); this.send(["observe_property", 4, "idle-active"]);
this.send(["observe_property", 5, "eof-reached"]); this.send(["observe_property", 5, "eof-reached"]);
this.send(["set_property", "volume", 50]);
}); });
this.socket.on("data", (data) => { this.socket.on("data", (data) => {
this.handleData(data); this.handleData(data);
@@ -148,6 +149,27 @@ class PluginSystem {
this.pluginId = pluginId; this.pluginId = pluginId;
this.loadPlugin(); this.loadPlugin();
} }
async search(query, page, limit) {
var _a, _b;
if (!((_b = (_a = this.plugin) == null ? void 0 : _a.musicSearch) == null ? void 0 : _b.search)) {
return {
list: [],
total: 0,
allPage: 0,
error: "Search not implemented"
};
}
try {
return await this.plugin.musicSearch.search(query, page, limit);
} catch (e) {
return {
list: [],
total: 0,
allPage: 0,
error: e.message || "Plugin search error"
};
}
}
loadPlugin() { loadPlugin() {
try { try {
const pluginPath = path.join( const pluginPath = path.join(
@@ -175,7 +197,17 @@ class PluginSystem {
}; };
} }
try { try {
return await this.plugin.getUrl(id, quality); const url = await this.plugin.getUrl(id, quality);
if (typeof url !== "string" || !url.startsWith("http")) {
return {
success: false,
error: "Invalid URL scheme"
};
}
return {
success: true,
url
};
} catch (e) { } catch (e) {
return { return {
success: false, success: false,

View File

@@ -19,7 +19,8 @@ electron.contextBridge.exposeInMainWorld("electronAPI", {
}, },
// Plugin System // Plugin System
plugin: { plugin: {
call: (pluginId, method, args) => electron.ipcRenderer.invoke("plugin:call", pluginId, method, args) call: (pluginId, method, args) => electron.ipcRenderer.invoke("plugin:call", pluginId, method, args),
search: (pluginId, query, page, limit) => electron.ipcRenderer.invoke("plugin:call", pluginId, "search", [query, page, limit])
}, },
// Cache Control // Cache Control
getCacheInfo: () => electron.ipcRenderer.invoke("cache:getInfo"), getCacheInfo: () => electron.ipcRenderer.invoke("cache:getInfo"),

View File

@@ -92,6 +92,8 @@ ipcMain.handle(
} }
) )
// Cache IPC Handlers // Cache IPC Handlers
ipcMain.handle('cache:getInfo', () => { ipcMain.handle('cache:getInfo', () => {
const settings = loadSettings(); const settings = loadSettings();

View File

@@ -21,7 +21,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
// Plugin System // Plugin System
plugin: { plugin: {
call: (pluginId: string, method: string, args: any[]) => ipcRenderer.invoke('plugin:call', pluginId, method, args) call: (pluginId: string, method: string, args: any[]) => ipcRenderer.invoke('plugin:call', pluginId, method, args),
search: (pluginId: string, query: string, page: number, limit: number) => ipcRenderer.invoke('plugin:call', pluginId, 'search', [query, page, limit]),
}, },
// Cache Control // Cache Control

View File

@@ -69,6 +69,7 @@ export class QzpController extends EventEmitter {
this.send(['observe_property', 3, 'duration']); this.send(['observe_property', 3, 'duration']);
this.send(['observe_property', 4, 'idle-active']); this.send(['observe_property', 4, 'idle-active']);
this.send(['observe_property', 5, 'eof-reached']); this.send(['observe_property', 5, 'eof-reached']);
this.send(['set_property', 'volume', 50])
}); });
this.socket.on('data', (data) => { this.socket.on('data', (data) => {

10
package-lock.json generated
View File

@@ -27,6 +27,7 @@
"tdesign-vue-next": "^1.17.7", "tdesign-vue-next": "^1.17.7",
"url": "^0.11.4", "url": "^0.11.4",
"vite-plugin-electron-renderer": "^0.14.6", "vite-plugin-electron-renderer": "^0.14.6",
"vite-plugin-wasm": "^3.5.0",
"vue": "^3.4.21", "vue": "^3.4.21",
"vue-router": "^4.6.4" "vue-router": "^4.6.4"
}, },
@@ -8992,6 +8993,15 @@
"vite": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" "vite": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
} }
}, },
"node_modules/vite-plugin-wasm": {
"version": "3.5.0",
"resolved": "https://registry.npmmirror.com/vite-plugin-wasm/-/vite-plugin-wasm-3.5.0.tgz",
"integrity": "sha512-X5VWgCnqiQEGb+omhlBVsvTfxikKtoOgAzQ95+BZ8gQ+VfMHIjSHr0wyvXFQCa0eKQ0fKyaL0kWcEnYqBac4lQ==",
"license": "MIT",
"peerDependencies": {
"vite": "^2 || ^3 || ^4 || ^5 || ^6 || ^7"
}
},
"node_modules/vm-browserify": { "node_modules/vm-browserify": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmmirror.com/vm-browserify/-/vm-browserify-1.1.2.tgz", "resolved": "https://registry.npmmirror.com/vm-browserify/-/vm-browserify-1.1.2.tgz",

View File

@@ -28,6 +28,7 @@
"tdesign-vue-next": "^1.17.7", "tdesign-vue-next": "^1.17.7",
"url": "^0.11.4", "url": "^0.11.4",
"vite-plugin-electron-renderer": "^0.14.6", "vite-plugin-electron-renderer": "^0.14.6",
"vite-plugin-wasm": "^3.5.0",
"vue": "^3.4.21", "vue": "^3.4.21",
"vue-router": "^4.6.4" "vue-router": "^4.6.4"
}, },

View File

@@ -12,7 +12,10 @@ export interface UrlResponse {
} }
type PluginModule = { type PluginModule = {
getUrl?: (id: string, quality: string) => Promise<UrlResponse> | UrlResponse getUrl?: (id: string, quality: string) => Promise<string> | string,
musicSearch?: {
search: (query: string, page: number, limit: number) => Promise<any> | any
}
} }
export class PluginSystem { export class PluginSystem {
@@ -22,6 +25,28 @@ export class PluginSystem {
this.pluginId = pluginId this.pluginId = pluginId
this.loadPlugin() this.loadPlugin()
} }
async search(query: string, page: number, limit: number): Promise<any> {
if (!this.plugin?.musicSearch?.search) {
return {
list: [],
total: 0,
allPage: 0,
error: 'Search not implemented'
}
}
try {
return await this.plugin.musicSearch.search(query, page, limit)
} catch (e: any) {
return {
list: [],
total: 0,
allPage: 0,
error: e.message || 'Plugin search error'
}
}
}
private loadPlugin() { private loadPlugin() {
try { try {
const pluginPath = path.join( const pluginPath = path.join(
@@ -53,7 +78,21 @@ export class PluginSystem {
} }
try { try {
return await this.plugin.getUrl(id, quality) // New behavior: plugin returns raw url string or throws
const url = await this.plugin.getUrl(id, quality)
if (typeof url !== 'string' || !url.startsWith('http')) {
return {
success: false,
error: 'Invalid URL scheme'
}
}
return {
success: true,
url
}
} catch (e: any) { } catch (e: any) {
return { return {
success: false, success: false,

View File

@@ -17,6 +17,8 @@
type="text" type="text"
placeholder="搜索音乐、歌手、专辑..." placeholder="搜索音乐、歌手、专辑..."
class="search-input" class="search-input"
v-model="searchQuery"
@keydown.enter="handleSearch"
/> />
</div> </div>
</div> </div>
@@ -55,10 +57,19 @@ import { Icon } from '@iconify/vue';
const router = useRouter(); const router = useRouter();
const isMaximized = ref(false); const isMaximized = ref(false);
const searchQuery = ref('');
const goBack = () => router.back(); const goBack = () => router.back();
const goForward = () => router.forward(); const goForward = () => router.forward();
const handleSearch = () => {
if (!searchQuery.value.trim()) return;
router.push({
name: 'Search',
query: { q: searchQuery.value }
});
};
// Settings // Settings
const openSettings = inject<() => void>('openSettings', () => {}); const openSettings = inject<() => void>('openSettings', () => {});

View File

@@ -30,6 +30,11 @@ const router = createRouter({
path: '/recent', path: '/recent',
name: 'Recent', name: 'Recent',
component: () => import('./views/Playlist.vue') component: () => import('./views/Playlist.vue')
},
{
path: '/search',
name: 'Search',
component: () => import('./views/Search.vue')
} }
] ]
}) })
@@ -72,5 +77,5 @@ const testSong2: Song = {
// Auto Play // Auto Play
playerStore.playSong(testSong).then(() => { playerStore.playSong(testSong).then(() => {
playerStore.setPlaylist([testSong,testSong2]); playerStore.setPlaylist([testSong, testSong2]);
}); });

View File

@@ -24,7 +24,7 @@ export const usePlayerStore = defineStore('player', () => {
// State // State
const isPlaying = ref(false); const isPlaying = ref(false);
const currentSong = ref<Song | null>(null); const currentSong = ref<Song | null>(null);
const volume = ref(100); const volume = ref(50);
const duration = ref(0); const duration = ref(0);
const currentTime = ref(0); const currentTime = ref(0);

View File

@@ -15,6 +15,7 @@ export interface IElectronAPI {
}; };
plugin: { plugin: {
call: (pluginId: string, method: string, args: any[]) => Promise<any>; call: (pluginId: string, method: string, args: any[]) => Promise<any>;
search: (pluginId: string, query: string, page: number, limit: number) => Promise<any>;
}; };
// Cache Control // Cache Control
getCacheInfo: () => Promise<{ path: string; size: string; persistCache: boolean }>; getCacheInfo: () => Promise<{ path: string; size: string; persistCache: boolean }>;

View File

@@ -0,0 +1,25 @@
import type { Song } from '../types/song';
export function formatDuration(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}
export function transformSearchSong(raw: any): Song {
return {
id: String(raw.songmid),
name: raw.name,
artist: raw.singer,
picUrl: raw.img || raw.m_img || raw.s_img,
url: '', // Empty initially
duration: formatDuration(raw.interval ? Number(raw.interval) : 0),
source: raw.source,
albumId: raw.albumId ? String(raw.albumId) : null,
albumName: raw.albumName,
type: 'Remote',
quality: 'auto',
types: raw.types // Store raw types for quality selection later
};
}

View File

@@ -0,0 +1,472 @@
<template>
<div class="view-container search-view">
<div class="content-wrapper">
<div class="search-header">
<h1 class="search-title">搜索: "{{ query }}"</h1>
<span class="result-count">找到 {{ total }} 个结果</span>
</div>
<div class="song-list-container" v-if="!loading && songs.length > 0">
<div class="list-header">
<div class="col-index">#</div>
<div class="col-title">标题</div>
<div class="col-album">专辑</div>
<div class="col-time">时长</div>
</div>
<div class="song-list">
<div
class="song-item"
v-for="(song, i) in songs"
:key="song.id"
@click="handlePlaySong(i)"
>
<div class="song-index">{{ (currentPage - 1) * limit + i + 1 }}</div>
<div class="song-cover">
<img v-if="song.picUrl" :src="song.picUrl" loading="lazy" />
<div v-else class="cover-placeholder"></div>
</div>
<div class="song-info">
<h4 class="song-title" v-html="highlight(song.name)"></h4>
<p class="song-artist" v-html="highlight(song.artist)"></p>
</div>
<div class="song-album">{{ song.albumName || '-' }}</div>
<div class="song-duration">{{ song.duration }}</div>
</div>
</div>
<!-- Pagination -->
<div class="pagination">
<button class="pagination-btn" :disabled="currentPage <= 1" @click="changePage(currentPage - 1)">
<Icon icon="lucide:chevron-left" />
</button>
<button
v-for="page in visiblePages"
:key="page"
class="pagination-btn"
:class="{ active: page === currentPage }"
@click="changePage(page)"
>
{{ page }}
</button>
<button class="pagination-btn" :disabled="currentPage >= totalPages" @click="changePage(currentPage + 1)">
<Icon icon="lucide:chevron-right" />
</button>
</div>
</div>
<div class="loading-state" v-if="loading">
<Icon icon="lucide:loader-2" class="spin" />
<span>正在搜索...</span>
</div>
<div class="empty-state" v-if="!loading && songs.length === 0">
<span>未找到相关歌曲</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Icon } from '@iconify/vue';
import { usePlayerStore } from '../stores/player';
import { transformSearchSong } from '../utils/songUtils';
import type { Song } from '../types/song';
const route = useRoute();
const router = useRouter();
const playerStore = usePlayerStore();
const query = computed(() => route.query.q as string || '');
const currentPage = ref(1);
const limit = ref(30);
const total = ref(0);
const loading = ref(false);
const songs = ref<Song[]>([]);
const totalPages = computed(() => {
const t = Number(total.value) || 0;
const l = Number(limit.value) || 30;
return Math.ceil(t / l) || 1;
});
const visiblePages = computed(() => {
const current = currentPage.value;
// Ensure totalPages is strictly valid
const total = totalPages.value;
const delta = 2; // 2 on each side
let start = Math.max(1, current - delta);
let end = Math.min(total, current + delta);
if (current - delta < 1) {
end = Math.min(total, end + (1 - (current - delta)));
}
if (current + delta > total) {
start = Math.max(1, start - ((current + delta) - total));
}
const pages = [];
for (let i = start; i <= end; i++) {
pages.push(i);
}
return pages;
});
const fetchData = async () => {
if (!query.value) return;
loading.value = true;
songs.value = [];
try {
const result = await window.electronAPI.plugin.search('wy', query.value, currentPage.value, limit.value);
if (result && result.list) {
songs.value = result.list.map((item: any) => transformSearchSong(item));
// Prioritize songCount if available, otherwise fallback to total, but NEVER use just list length as total
total.value = result.songCount || result.total || 0;
} else {
total.value = 0;
}
} catch (e) {
console.error("Search failed", e);
} finally {
loading.value = false;
}
};
const changePage = (page: number) => {
currentPage.value = page;
fetchData();
};
const handlePlaySong = (index: number) => {
playerStore.setPlaylist(songs.value, index);
};
const getHighlightRegex = (q: string) => {
const trimmed = q.trim();
if (!trimmed) return null;
// Escape regex characters
const escapeRegExp = (string: string) => string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// If strict or short query, fallback to exact match or simple space split
// "超过2个字相似" -> If > 2 chars, try to match substrings
if (trimmed.length <= 2) {
return new RegExp(escapeRegExp(trimmed), 'gi');
}
// Generate all substrings of length >= 2
const substrings = new Set<string>();
substrings.add(trimmed); // Always include full query
for (let i = 0; i < trimmed.length; i++) {
for (let j = i + 2; j <= trimmed.length; j++) {
substrings.add(trimmed.slice(i, j));
}
}
// Sort by length match (longest first)
const sorted = Array.from(substrings).sort((a, b) => b.length - a.length);
const pattern = sorted.map(s => escapeRegExp(s)).join('|');
return new RegExp(pattern, 'gi');
};
const highlight = (text: string) => {
if (!query.value || !text) return text;
const regex = getHighlightRegex(query.value);
if (!regex) return text;
return text.replace(regex, match => `<span class="highlight">${match}</span>`);
};
watch(query, () => {
currentPage.value = 1;
fetchData();
}, { immediate: true });
</script>
<style scoped>
.view-container {
width: 100%;
height: 100%;
overflow-y: auto;
}
.content-wrapper {
padding: 20px 30px; /* Reduced vertical padding, kept horizontal for spacing but flexible */
width: 100%;
/* Removed max-width to allow full width usage as requested */
/* margin: 0 auto; */
}
.search-header {
margin-bottom: 24px;
text-align: left;
}
.search-title {
font-size: 24px;
font-weight: 700;
color: var(--color-text-primary);
margin-bottom: 8px;
position: relative;
padding-left: 16px;
}
.search-title::before {
content: "";
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 70%;
background-color: var(--color-accent);
border-radius: 4px;
}
.result-count {
font-size: 13px;
color: var(--color-text-muted);
}
/* List Grid Layout */
.list-header, .song-item {
display: grid;
/* Define columns: Index | Title (40%) | Album (30%) | Time */
grid-template-columns: 50px 4fr 3fr 60px;
gap: 16px;
align-items: center;
padding: 10px 16px;
text-align: left;
}
.list-header {
margin-bottom: 8px;
color: var(--color-text-muted);
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 {
width: auto; /* Let grid handle width */
}
.col-index { text-align: center; }
.col-time { text-align: right; }
.song-item {
border-radius: var(--radius-lg);
transition: background-color 0.2s;
cursor: pointer;
color: var(--color-text-secondary);
width: 100%; /* Fill width */
box-sizing: border-box; /* Include padding in width */
}
.song-item:hover {
background-color: var(--color-bg-tertiary);
color: var(--color-text-primary);
}
.song-index {
text-align: center;
font-size: 14px;
color: var(--color-text-muted);
}
.song-cover {
/* Cover is not in the top grid definition, wait.
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 {
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 {
/* 50px | 40px spacer | 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 {
grid-column: 3;
}
.list-header .col-album {
grid-column: 4;
}
.list-header .col-time {
grid-column: 5;
}
.song-cover {
width: 40px;
height: 40px;
border-radius: 4px;
overflow: hidden;
background: #333;
}
.song-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.song-info {
display: flex;
flex-direction: column;
justify-content: center;
min-width: 0;
padding-left: 10px; /* Small gap between cover and text */
}
.song-title {
font-size: 15px;
color: var(--color-text-primary);
margin-bottom: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.song-artist {
font-size: 12px;
color: var(--color-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.song-album {
font-size: 13px;
color: var(--color-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.song-duration {
text-align: right;
font-size: 13px;
}
/* Highlight */
:deep(.highlight) {
color: var(--color-accent);
}
/* Pagination */
.pagination {
display: flex;
justify-content: flex-start; /* Alignment Change: Left Align */
align-items: center;
margin-top: 32px;
gap: 8px; /* Closer gap */
}
.pagination-btn {
min-width: 32px;
height: 32px;
padding: 0 4px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
border: 1px solid var(--color-border);
color: var(--color-text-primary);
background: transparent;
transition: all 0.2s;
font-size: 14px;
cursor: pointer;
}
.pagination-btn:hover:not(:disabled) {
background: var(--color-bg-tertiary);
border-color: var(--color-text-muted);
}
.pagination-btn.active {
background: var(--color-accent);
color: #fff; /* Ensure readable text on accent */
border-color: var(--color-accent);
font-weight: bold;
}
.pagination-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
border-color: transparent;
}
/* States */
.loading-state, .empty-state {
padding: 60px 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
color: var(--color-text-muted);
}
.spin {
animation: spin 1s linear infinite;
font-size: 32px;
}
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
</style>

View File

@@ -3,6 +3,7 @@ import vueJsx from "@vitejs/plugin-vue-jsx";
import path from 'node:path' import path from 'node:path'
import electron from 'vite-plugin-electron/simple' import electron from 'vite-plugin-electron/simple'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import wasm from "vite-plugin-wasm";
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
@@ -14,6 +15,7 @@ export default defineConfig({
plugins: [ plugins: [
vue(), vue(),
vueJsx(), vueJsx(),
wasm(),
electron({ electron({
main: { main: {
// Shortcut of `build.lib.entry`. // Shortcut of `build.lib.entry`.