forked from miao-moe/QZMusic_PC
feat:
- 搜索页UI - 插件系统支持搜索单曲接口 - 修复音量初始化和状态 - 搜索关键词匹配优化
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -22,5 +22,4 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
core/mpv.exe
|
||||
.gitignore
|
||||
|
||||
@@ -66,6 +66,7 @@ class QzpController extends EventEmitter {
|
||||
this.send(["observe_property", 3, "duration"]);
|
||||
this.send(["observe_property", 4, "idle-active"]);
|
||||
this.send(["observe_property", 5, "eof-reached"]);
|
||||
this.send(["set_property", "volume", 50]);
|
||||
});
|
||||
this.socket.on("data", (data) => {
|
||||
this.handleData(data);
|
||||
@@ -148,6 +149,27 @@ class PluginSystem {
|
||||
this.pluginId = pluginId;
|
||||
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() {
|
||||
try {
|
||||
const pluginPath = path.join(
|
||||
@@ -175,7 +197,17 @@ class PluginSystem {
|
||||
};
|
||||
}
|
||||
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) {
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -19,7 +19,8 @@ electron.contextBridge.exposeInMainWorld("electronAPI", {
|
||||
},
|
||||
// Plugin System
|
||||
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
|
||||
getCacheInfo: () => electron.ipcRenderer.invoke("cache:getInfo"),
|
||||
|
||||
@@ -92,6 +92,8 @@ ipcMain.handle(
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
|
||||
// Cache IPC Handlers
|
||||
ipcMain.handle('cache:getInfo', () => {
|
||||
const settings = loadSettings();
|
||||
|
||||
@@ -21,7 +21,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
|
||||
// Plugin System
|
||||
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
|
||||
|
||||
@@ -69,6 +69,7 @@ export class QzpController extends EventEmitter {
|
||||
this.send(['observe_property', 3, 'duration']);
|
||||
this.send(['observe_property', 4, 'idle-active']);
|
||||
this.send(['observe_property', 5, 'eof-reached']);
|
||||
this.send(['set_property', 'volume', 50])
|
||||
});
|
||||
|
||||
this.socket.on('data', (data) => {
|
||||
|
||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -27,6 +27,7 @@
|
||||
"tdesign-vue-next": "^1.17.7",
|
||||
"url": "^0.11.4",
|
||||
"vite-plugin-electron-renderer": "^0.14.6",
|
||||
"vite-plugin-wasm": "^3.5.0",
|
||||
"vue": "^3.4.21",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/vm-browserify/-/vm-browserify-1.1.2.tgz",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"tdesign-vue-next": "^1.17.7",
|
||||
"url": "^0.11.4",
|
||||
"vite-plugin-electron-renderer": "^0.14.6",
|
||||
"vite-plugin-wasm": "^3.5.0",
|
||||
"vue": "^3.4.21",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
|
||||
@@ -12,7 +12,10 @@ export interface UrlResponse {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -22,6 +25,28 @@ export class PluginSystem {
|
||||
this.pluginId = pluginId
|
||||
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() {
|
||||
try {
|
||||
const pluginPath = path.join(
|
||||
@@ -53,7 +78,21 @@ export class PluginSystem {
|
||||
}
|
||||
|
||||
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) {
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
type="text"
|
||||
placeholder="搜索音乐、歌手、专辑..."
|
||||
class="search-input"
|
||||
v-model="searchQuery"
|
||||
@keydown.enter="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -55,10 +57,19 @@ import { Icon } from '@iconify/vue';
|
||||
|
||||
const router = useRouter();
|
||||
const isMaximized = ref(false);
|
||||
const searchQuery = ref('');
|
||||
|
||||
const goBack = () => router.back();
|
||||
const goForward = () => router.forward();
|
||||
|
||||
const handleSearch = () => {
|
||||
if (!searchQuery.value.trim()) return;
|
||||
router.push({
|
||||
name: 'Search',
|
||||
query: { q: searchQuery.value }
|
||||
});
|
||||
};
|
||||
|
||||
// Settings
|
||||
const openSettings = inject<() => void>('openSettings', () => {});
|
||||
|
||||
|
||||
@@ -30,6 +30,11 @@ const router = createRouter({
|
||||
path: '/recent',
|
||||
name: 'Recent',
|
||||
component: () => import('./views/Playlist.vue')
|
||||
},
|
||||
{
|
||||
path: '/search',
|
||||
name: 'Search',
|
||||
component: () => import('./views/Search.vue')
|
||||
}
|
||||
]
|
||||
})
|
||||
@@ -72,5 +77,5 @@ const testSong2: Song = {
|
||||
|
||||
// Auto Play
|
||||
playerStore.playSong(testSong).then(() => {
|
||||
playerStore.setPlaylist([testSong,testSong2]);
|
||||
playerStore.setPlaylist([testSong, testSong2]);
|
||||
});
|
||||
@@ -24,7 +24,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
// State
|
||||
const isPlaying = ref(false);
|
||||
const currentSong = ref<Song | null>(null);
|
||||
const volume = ref(100);
|
||||
const volume = ref(50);
|
||||
const duration = ref(0);
|
||||
const currentTime = ref(0);
|
||||
|
||||
|
||||
1
src/renderer/types/electron.d.ts
vendored
1
src/renderer/types/electron.d.ts
vendored
@@ -15,6 +15,7 @@ export interface IElectronAPI {
|
||||
};
|
||||
plugin: {
|
||||
call: (pluginId: string, method: string, args: any[]) => Promise<any>;
|
||||
search: (pluginId: string, query: string, page: number, limit: number) => Promise<any>;
|
||||
};
|
||||
// Cache Control
|
||||
getCacheInfo: () => Promise<{ path: string; size: string; persistCache: boolean }>;
|
||||
|
||||
25
src/renderer/utils/songUtils.ts
Normal file
25
src/renderer/utils/songUtils.ts
Normal 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
|
||||
};
|
||||
}
|
||||
472
src/renderer/views/Search.vue
Normal file
472
src/renderer/views/Search.vue
Normal 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>
|
||||
@@ -3,6 +3,7 @@ import vueJsx from "@vitejs/plugin-vue-jsx";
|
||||
import path from 'node:path'
|
||||
import electron from 'vite-plugin-electron/simple'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import wasm from "vite-plugin-wasm";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
@@ -14,6 +15,7 @@ export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueJsx(),
|
||||
wasm(),
|
||||
electron({
|
||||
main: {
|
||||
// Shortcut of `build.lib.entry`.
|
||||
|
||||
Reference in New Issue
Block a user