forked from miao-moe/QZMusic_PC
feat: 实现功能&优化
- 底部播放栏 - MediaSession - 插件系统&存储位置 - URL缓存机制 - 整理项目结构
This commit is contained in:
@@ -1,14 +1,16 @@
|
||||
var __defProp = Object.defineProperty;
|
||||
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
|
||||
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
|
||||
import { ipcMain, BrowserWindow, app, Menu } from "electron";
|
||||
import { app, ipcMain, BrowserWindow, Menu } from "electron";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path$1 from "node:path";
|
||||
import fs$1 from "node:fs";
|
||||
import { spawn } from "child_process";
|
||||
import { Socket } from "net";
|
||||
import { EventEmitter } from "events";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
class MpvController extends EventEmitter {
|
||||
constructor(ipcPath) {
|
||||
super();
|
||||
@@ -142,6 +144,50 @@ class MpvController extends EventEmitter {
|
||||
}
|
||||
}
|
||||
}
|
||||
const require$1 = createRequire(import.meta.url);
|
||||
class PluginSystem {
|
||||
constructor(pluginId) {
|
||||
__publicField(this, "pluginId");
|
||||
__publicField(this, "plugin", null);
|
||||
this.pluginId = pluginId;
|
||||
this.loadPlugin();
|
||||
}
|
||||
loadPlugin() {
|
||||
try {
|
||||
const pluginPath = path.join(
|
||||
app.getPath("userData"),
|
||||
"plugins",
|
||||
this.pluginId,
|
||||
"index.js"
|
||||
);
|
||||
if (!fs.existsSync(pluginPath)) {
|
||||
throw new Error(`Plugin ${this.pluginId} not found`);
|
||||
}
|
||||
delete require$1.cache[require$1.resolve(pluginPath)];
|
||||
this.plugin = require$1(pluginPath);
|
||||
} catch (e) {
|
||||
console.error(`[PluginSystem] load failed:`, e);
|
||||
this.plugin = null;
|
||||
}
|
||||
}
|
||||
async getUrl(id, quality) {
|
||||
var _a;
|
||||
if (!((_a = this.plugin) == null ? void 0 : _a.getUrl)) {
|
||||
return {
|
||||
success: false,
|
||||
error: "getUrl not implemented"
|
||||
};
|
||||
}
|
||||
try {
|
||||
return await this.plugin.getUrl(id, quality);
|
||||
} catch (e) {
|
||||
return {
|
||||
success: false,
|
||||
error: e.message || "plugin error"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
createRequire(import.meta.url);
|
||||
const __dirname$1 = path$1.dirname(fileURLToPath(import.meta.url));
|
||||
process.env.APP_ROOT = path$1.join(__dirname$1, "..");
|
||||
@@ -173,6 +219,7 @@ function createWindow() {
|
||||
} else {
|
||||
win.loadFile(path$1.join(RENDERER_DIST, "index.html"));
|
||||
}
|
||||
win.webContents.openDevTools();
|
||||
registerZoomShortcuts(win);
|
||||
}
|
||||
ipcMain.on("window-minimize", (event) => {
|
||||
@@ -192,8 +239,21 @@ ipcMain.handle("mpv-play", () => mpv == null ? void 0 : mpv.play());
|
||||
ipcMain.handle("mpv-pause", () => mpv == null ? void 0 : mpv.pause());
|
||||
ipcMain.handle("mpv-toggle-pause", () => mpv == null ? void 0 : mpv.togglePause());
|
||||
ipcMain.handle("mpv-stop", () => mpv == null ? void 0 : mpv.stop());
|
||||
ipcMain.handle("mpv-set-volume", (e, vol) => mpv == null ? void 0 : mpv.setVolume(vol));
|
||||
ipcMain.handle("mpv-set-volume", (_, vol) => mpv == null ? void 0 : mpv.setVolume(vol));
|
||||
ipcMain.handle("mpv-seek", (_, time) => mpv == null ? void 0 : mpv.seek(time));
|
||||
ipcMain.handle(
|
||||
"plugin:call",
|
||||
async (_evenv, pluginId, method, args) => {
|
||||
const plugin = new PluginSystem(pluginId);
|
||||
if (typeof plugin[method] !== "function") {
|
||||
return {
|
||||
success: false,
|
||||
error: `Method ${method} not found`
|
||||
};
|
||||
}
|
||||
return await plugin[method](...args);
|
||||
}
|
||||
);
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
@@ -231,6 +291,29 @@ app.on("activate", () => {
|
||||
}
|
||||
});
|
||||
app.whenReady().then(() => {
|
||||
const pluginsPath = path$1.join(app.getPath("userData"), "plugins");
|
||||
if (!fs$1.existsSync(pluginsPath)) {
|
||||
fs$1.mkdirSync(pluginsPath, { recursive: true });
|
||||
}
|
||||
const wyPluginPath = path$1.join(pluginsPath, "wy");
|
||||
const wyPluginIndex = path$1.join(wyPluginPath, "index.js");
|
||||
if (!fs$1.existsSync(wyPluginIndex)) {
|
||||
if (!fs$1.existsSync(wyPluginPath)) fs$1.mkdirSync(wyPluginPath, { recursive: true });
|
||||
fs$1.writeFileSync(wyPluginIndex, `
|
||||
module.exports = {
|
||||
async getUrl(id, quality) {
|
||||
const url = \`https://api.qz.shiqianjiang.cn/music/url?source=wy&songId=\${id}&quality=\${quality}&key=testkey\`;
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (e) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
}
|
||||
}
|
||||
`.trim());
|
||||
}
|
||||
Menu.setApplicationMenu(null);
|
||||
createWindow();
|
||||
mpv = new MpvController();
|
||||
|
||||
@@ -16,5 +16,9 @@ electron.contextBridge.exposeInMainWorld("electronAPI", {
|
||||
setVolume: (vol) => electron.ipcRenderer.invoke("mpv-set-volume", vol),
|
||||
seek: (time) => electron.ipcRenderer.invoke("mpv-seek", time),
|
||||
onEvent: (callback) => electron.ipcRenderer.on("mpv-event", callback)
|
||||
},
|
||||
// Plugin System
|
||||
plugin: {
|
||||
call: (pluginId, method, args) => electron.ipcRenderer.invoke("plugin:call", pluginId, method, args)
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,8 +2,9 @@ import { app, BrowserWindow, Menu, ipcMain } from 'electron'
|
||||
import { createRequire } from 'node:module'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs'
|
||||
import { MpvController } from './mpvController'
|
||||
|
||||
import { PluginSystem } from '../src/main/pluginSystem.ts'
|
||||
// @ts-ignore
|
||||
const require = createRequire(import.meta.url)
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
@@ -45,7 +46,7 @@ function createWindow() {
|
||||
} else {
|
||||
win.loadFile(path.join(RENDERER_DIST, 'index.html'))
|
||||
}
|
||||
|
||||
win.webContents.openDevTools();
|
||||
registerZoomShortcuts(win)
|
||||
}
|
||||
|
||||
@@ -69,9 +70,25 @@ ipcMain.handle('mpv-play', () => mpv?.play())
|
||||
ipcMain.handle('mpv-pause', () => mpv?.pause())
|
||||
ipcMain.handle('mpv-toggle-pause', () => mpv?.togglePause())
|
||||
ipcMain.handle('mpv-stop', () => mpv?.stop())
|
||||
ipcMain.handle('mpv-set-volume', (e, vol) => mpv?.setVolume(vol))
|
||||
ipcMain.handle('mpv-set-volume', (_, vol) => mpv?.setVolume(vol))
|
||||
ipcMain.handle('mpv-seek', (_, time) => mpv?.seek(time))
|
||||
|
||||
// PluginSystem
|
||||
ipcMain.handle(
|
||||
'plugin:call',
|
||||
async (_evenv, pluginId: string, method: string, args: any[]) => {
|
||||
const plugin = new PluginSystem(pluginId)
|
||||
|
||||
if (typeof (plugin as any)[method] !== 'function') {
|
||||
return {
|
||||
success: false,
|
||||
error: `Method ${method} not found`
|
||||
}
|
||||
}
|
||||
|
||||
return await (plugin as any)[method](...args)
|
||||
}
|
||||
)
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
@@ -115,6 +132,34 @@ app.on('activate', () => {
|
||||
})
|
||||
|
||||
app.whenReady().then(() => {
|
||||
// Ensure plugins directory exists
|
||||
const pluginsPath = path.join(app.getPath('userData'), 'plugins')
|
||||
if (!fs.existsSync(pluginsPath)) {
|
||||
fs.mkdirSync(pluginsPath, { recursive: true })
|
||||
}
|
||||
|
||||
// --- Ensure Sample 'wy' Plugin Exists ---
|
||||
const wyPluginPath = path.join(pluginsPath, 'wy')
|
||||
const wyPluginIndex = path.join(wyPluginPath, 'index.js')
|
||||
if (!fs.existsSync(wyPluginIndex)) {
|
||||
if (!fs.existsSync(wyPluginPath)) fs.mkdirSync(wyPluginPath, { recursive: true })
|
||||
fs.writeFileSync(wyPluginIndex, `
|
||||
module.exports = {
|
||||
async getUrl(id, quality) {
|
||||
const url = \`https://api.qz.shiqianjiang.cn/music/url?source=wy&songId=\${id}&quality=\${quality}&key=testkey\`;
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (e) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
}
|
||||
}
|
||||
`.trim())
|
||||
}
|
||||
// ----------------------------------------
|
||||
|
||||
Menu.setApplicationMenu(null)
|
||||
createWindow()
|
||||
|
||||
|
||||
173
electron/mpvController.ts
Normal file
173
electron/mpvController.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import { Socket } from 'net';
|
||||
import { EventEmitter } from 'events';
|
||||
import path from 'path';
|
||||
|
||||
export class MpvController extends EventEmitter {
|
||||
private process: ChildProcess | null = null;
|
||||
private socket: Socket | null = null;
|
||||
private ipcPath: string;
|
||||
private messageBuffer: string = '';
|
||||
|
||||
constructor(ipcPath?: string) {
|
||||
super();
|
||||
this.ipcPath = ipcPath || this.getIpcPath();
|
||||
}
|
||||
|
||||
private getIpcPath(): string {
|
||||
if (process.platform === 'win32') {
|
||||
return '\\\\.\\pipe\\qzmusic_mpv_socket';
|
||||
}
|
||||
return '/tmp/qzmusic_mpv_socket';
|
||||
}
|
||||
|
||||
private getMpvPath(): string {
|
||||
const appRoot = process.env.APP_ROOT || process.cwd();
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
return path.join(appRoot, 'core', 'mpv.exe');
|
||||
}
|
||||
// Darwin (Mac) or Linux
|
||||
// For now, assuming global mpv or a specific path in future
|
||||
return 'mpv';
|
||||
}
|
||||
|
||||
start() {
|
||||
const mpvPath = this.getMpvPath();
|
||||
console.log('Starting MPV from:', mpvPath);
|
||||
|
||||
this.process = spawn(mpvPath, [
|
||||
'--idle',
|
||||
'--force-window=no',
|
||||
'--no-media-controls',
|
||||
`--input-ipc-server=${this.ipcPath}`,
|
||||
'--no-terminal'
|
||||
]);
|
||||
|
||||
this.process.on('error', (err) => {
|
||||
console.error('Failed to start MPV:', err);
|
||||
this.emit('error', err);
|
||||
});
|
||||
|
||||
this.process.on('exit', (code, signal) => {
|
||||
console.log(`MPV exited with code ${code} and signal ${signal}`);
|
||||
this.emit('exit', { code, signal });
|
||||
this.socket?.destroy();
|
||||
});
|
||||
|
||||
this.tryConnect();
|
||||
}
|
||||
|
||||
private tryConnect(retries = 10) {
|
||||
if (retries <= 0) {
|
||||
console.error('Could not connect to MPV socket after multiple attempts.');
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
this.socket = new Socket();
|
||||
|
||||
this.socket.on('connect', () => {
|
||||
console.log('Connected to MPV IPC socket');
|
||||
this.emit('ready');
|
||||
|
||||
this.send(['observe_property', 1, 'pause']);
|
||||
this.send(['observe_property', 2, 'time-pos']);
|
||||
this.send(['observe_property', 3, 'duration']);
|
||||
this.send(['observe_property', 4, 'idle-active']);
|
||||
this.send(['observe_property', 5, 'eof-reached']);
|
||||
});
|
||||
|
||||
this.socket.on('data', (data) => {
|
||||
this.handleData(data);
|
||||
});
|
||||
|
||||
this.socket.on('error', (err) => {
|
||||
this.socket?.destroy();
|
||||
this.tryConnect(retries - 1);
|
||||
});
|
||||
|
||||
this.socket.connect(this.ipcPath);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
private handleData(data: Buffer) {
|
||||
// Determine message boundaries by newline
|
||||
const raw = data.toString();
|
||||
// console.log('[MPV RX RAW]', raw.trim()); // Very noisy if enabled
|
||||
|
||||
this.messageBuffer += raw;
|
||||
const messages = this.messageBuffer.split('\n');
|
||||
|
||||
// The last part might be an incomplete message, save it back to buffer
|
||||
this.messageBuffer = messages.pop() || '';
|
||||
|
||||
for (const msg of messages) {
|
||||
if (!msg.trim()) continue;
|
||||
console.log('[IPC]', msg); // User requested raw communication
|
||||
try {
|
||||
const json = JSON.parse(msg);
|
||||
this.emit('message', json);
|
||||
|
||||
// Handle specific events
|
||||
if (json.event) {
|
||||
this.emit('event', json);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse MPV message:', msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async send(command: any[]) {
|
||||
if (!this.socket || this.socket.destroyed) {
|
||||
console.warn('MPV socket not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({ command });
|
||||
console.log('[MPV TX]', payload); // User requested raw communication
|
||||
this.socket.write(payload + '\n');
|
||||
}
|
||||
|
||||
// Convenience methods
|
||||
async load(url: string) {
|
||||
return this.send(['loadfile', url]);
|
||||
}
|
||||
|
||||
async play() {
|
||||
return this.send(['set_property', 'pause', false]);
|
||||
}
|
||||
|
||||
async pause() {
|
||||
return this.send(['set_property', 'pause', true]);
|
||||
}
|
||||
|
||||
async togglePause() {
|
||||
return this.send(['cycle', 'pause']);
|
||||
}
|
||||
|
||||
async stop() {
|
||||
return this.send(['stop']);
|
||||
}
|
||||
|
||||
async setVolume(vol: number) {
|
||||
return this.send(['set_property', 'volume', vol]);
|
||||
}
|
||||
|
||||
async seek(seconds: number) {
|
||||
return this.send(['seek', seconds, 'absolute']);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.process) {
|
||||
console.log('Killing MPV process...');
|
||||
this.process.kill();
|
||||
this.process = null;
|
||||
}
|
||||
if (this.socket) {
|
||||
this.socket.destroy();
|
||||
this.socket = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,5 +17,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
setVolume: (vol: number) => ipcRenderer.invoke('mpv-set-volume', vol),
|
||||
seek: (time: number) => ipcRenderer.invoke('mpv-seek', time),
|
||||
onEvent: (callback: (event: any, data: any) => void) => ipcRenderer.on('mpv-event', callback)
|
||||
},
|
||||
|
||||
// Plugin System
|
||||
plugin: {
|
||||
call: (pluginId: string, method: string, args: any[]) => ipcRenderer.invoke('plugin:call', pluginId, method, args)
|
||||
}
|
||||
})
|
||||
@@ -8,6 +8,6 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<script type="module" src="/src/renderer/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
64
src/main/pluginSystem.ts
Normal file
64
src/main/pluginSystem.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { app } from 'electron'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
export interface UrlResponse {
|
||||
success: boolean
|
||||
url?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
type PluginModule = {
|
||||
getUrl?: (id: string, quality: string) => Promise<UrlResponse> | UrlResponse
|
||||
}
|
||||
|
||||
export class PluginSystem {
|
||||
private pluginId: string
|
||||
private plugin: PluginModule | null = null
|
||||
constructor(pluginId: string) {
|
||||
this.pluginId = pluginId
|
||||
this.loadPlugin()
|
||||
}
|
||||
private loadPlugin() {
|
||||
try {
|
||||
const pluginPath = path.join(
|
||||
app.getPath('userData'),
|
||||
'plugins',
|
||||
this.pluginId,
|
||||
'index.js'
|
||||
)
|
||||
|
||||
if (!fs.existsSync(pluginPath)) {
|
||||
throw new Error(`Plugin ${this.pluginId} not found`)
|
||||
}
|
||||
|
||||
delete require.cache[require.resolve(pluginPath)]
|
||||
|
||||
this.plugin = require(pluginPath)
|
||||
|
||||
} catch (e: any) {
|
||||
console.error(`[PluginSystem] load failed:`, e)
|
||||
this.plugin = null
|
||||
}
|
||||
}
|
||||
async getUrl(id: string, quality: string): Promise<UrlResponse> {
|
||||
if (!this.plugin?.getUrl) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'getUrl not implemented'
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.plugin.getUrl(id, quality)
|
||||
} catch (e: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: e.message || 'plugin error'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
444
src/renderer/components/PlayerBar.vue
Normal file
444
src/renderer/components/PlayerBar.vue
Normal file
@@ -0,0 +1,444 @@
|
||||
<template>
|
||||
<transition name="slide-up">
|
||||
<div class="player-bar" v-if="hasSongs">
|
||||
<!-- Left: Vinyl & Info -->
|
||||
<div class="player-left">
|
||||
<div class="vinyl-wrapper" :class="{ 'playing': isPlaying }">
|
||||
<img
|
||||
v-if="currentSong?.picUrl"
|
||||
:src="currentSong.picUrl"
|
||||
class="album-art"
|
||||
alt="Album Art"
|
||||
/>
|
||||
<div v-else class="album-placeholder"></div>
|
||||
<div class="vinyl-bg"></div>
|
||||
</div>
|
||||
|
||||
<div class="track-info">
|
||||
<div class="track-title-row">
|
||||
<span class="track-name">{{ currentSong?.name || '未知歌曲' }}</span>
|
||||
<span class="track-artist"> - {{ currentSong?.artist || '未知歌手' }}</span>
|
||||
</div>
|
||||
<div class="track-actions">
|
||||
<button class="icon-btn tiny" title="添加到歌单">
|
||||
<Icon icon="lucide:plus-circle" />
|
||||
</button>
|
||||
<button class="icon-btn tiny" title="下载">
|
||||
<Icon icon="lucide:download" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Center: Controls & Progress -->
|
||||
<div class="player-center">
|
||||
<div class="controls-row">
|
||||
<button class="icon-btn small" :class="{ active: isLiked }" @click="toggleLike" title="喜欢">
|
||||
<Icon :icon="isLiked ? 'lucide:heart' : 'lucide:heart'" :class="{ filled: isLiked }" />
|
||||
</button>
|
||||
|
||||
<button class="icon-btn" @click="prev" title="上一首">
|
||||
<Icon icon="lucide:skip-back" />
|
||||
</button>
|
||||
|
||||
<button class="play-btn" @click="togglePlay">
|
||||
<Icon :icon="isPlaying ? 'lucide:pause' : 'lucide:play'" class="play-icon" />
|
||||
</button>
|
||||
|
||||
<button class="icon-btn" @click="next" title="下一首">
|
||||
<Icon icon="lucide:skip-forward" />
|
||||
</button>
|
||||
|
||||
<button class="icon-btn small" @click="toggleMode" :title="modeTitle">
|
||||
<Icon :icon="modeIcon" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="progress-row">
|
||||
<span class="time-text">{{ formatTime(currentTime) }}</span>
|
||||
<div class="slider-container">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
:max="duration || 100"
|
||||
:value="currentTime"
|
||||
class="custom-slider"
|
||||
@input="onSeek"
|
||||
>
|
||||
<div class="slider-track" :style="{ width: progressPercent + '%' }"></div>
|
||||
</div>
|
||||
<span class="time-text">{{ formatTime(duration) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Volume & Playlist -->
|
||||
<div class="player-right">
|
||||
<div class="volume-control">
|
||||
<button class="icon-btn small" @click="toggleMute">
|
||||
<Icon :icon="volumeIcon" />
|
||||
</button>
|
||||
<div class="slider-container volume-slider">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
:value="volume"
|
||||
class="custom-slider"
|
||||
@input="onVolumeChange"
|
||||
>
|
||||
<div class="slider-track" :style="{ width: volume + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider">|</div>
|
||||
|
||||
<button class="icon-btn playlist-toggle" @click="togglePlaylist" title="播放列表">
|
||||
<Icon icon="lucide:list-music" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { usePlayerStore, PlayMode } from '../stores/player';
|
||||
import { storeToRefs } from 'pinia';
|
||||
|
||||
const playerStore = usePlayerStore();
|
||||
const { isPlaying, currentSong, currentTime, duration, volume, playMode, playlist } = storeToRefs(playerStore);
|
||||
|
||||
const hasSongs = computed(() => playlist.value.length > 0);
|
||||
const isLiked = ref(false); // Mock like state
|
||||
|
||||
// Icons
|
||||
const modeIcon = computed(() => {
|
||||
switch (playMode.value) {
|
||||
case PlayMode.Single: return 'lucide:repeat-1';
|
||||
case PlayMode.Random: return 'lucide:shuffle';
|
||||
default: return 'lucide:repeat';
|
||||
}
|
||||
});
|
||||
|
||||
const modeTitle = computed(() => {
|
||||
switch (playMode.value) {
|
||||
case PlayMode.Single: return '单曲循环';
|
||||
case PlayMode.Random: return '随机播放';
|
||||
default: return '列表循环';
|
||||
}
|
||||
});
|
||||
|
||||
const volumeIcon = computed(() => {
|
||||
if (volume.value === 0) return 'lucide:volume-x';
|
||||
if (volume.value < 50) return 'lucide:volume-1';
|
||||
return 'lucide:volume-2';
|
||||
});
|
||||
|
||||
// Progress
|
||||
const progressPercent = computed(() => {
|
||||
if (!duration.value) return 0;
|
||||
return (currentTime.value / duration.value) * 100;
|
||||
});
|
||||
|
||||
// Actions
|
||||
const togglePlay = () => playerStore.togglePlay();
|
||||
const next = () => playerStore.next();
|
||||
const prev = () => playerStore.prev();
|
||||
const toggleMode = () => playerStore.toggleMode();
|
||||
const toggleLike = () => { isLiked.value = !isLiked.value; };
|
||||
const togglePlaylist = () => { /* TODO: Toggle Playlist Drawer */ };
|
||||
|
||||
const onSeek = (e: Event) => {
|
||||
const val = Number((e.target as HTMLInputElement).value);
|
||||
playerStore.seek(val);
|
||||
};
|
||||
|
||||
const onVolumeChange = (e: Event) => {
|
||||
const val = Number((e.target as HTMLInputElement).value);
|
||||
playerStore.setVolume(val);
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
if (volume.value > 0) playerStore.setVolume(0);
|
||||
else playerStore.setVolume(50);
|
||||
};
|
||||
|
||||
// Utils
|
||||
const formatTime = (seconds: number) => {
|
||||
if (!seconds || isNaN(seconds)) return '00:00';
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.player-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 80px; /* Established height */
|
||||
background-color: rgba(24, 24, 24, 0.95); /* Semi-transparent dark bg */
|
||||
backdrop-filter: blur(20px);
|
||||
border-top: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 -4px 20px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active {
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-up-enter-from,
|
||||
.slide-up-leave-to {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* --- Left Section --- */
|
||||
.player-left {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vinyl-wrapper {
|
||||
position: relative;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* Vinyl Background Simulation or Image */
|
||||
background: radial-gradient(circle, #1a1a1a 30%, #333 31%, #111 32%, #181818 100%);
|
||||
box-shadow: 0 4px 10px rgba(0,0,0,0.5);
|
||||
animation: spin 10s linear infinite;
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
.vinyl-wrapper.playing {
|
||||
animation-play-state: running;
|
||||
}
|
||||
|
||||
/* Use the user specific path if available, or fallback to CSS vinyl look */
|
||||
.vinyl-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
background-image: url('@/assets/miniYinyl.png'); /* User requested path */
|
||||
background-size: cover;
|
||||
opacity: 1;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.album-art {
|
||||
width: 51px;
|
||||
height: 51px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.album-placeholder {
|
||||
width: 51px;
|
||||
height: 51px;
|
||||
border-radius: 50%;
|
||||
background: #333;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.track-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.track-title-row {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.track-name {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.track-artist {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.track-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* --- Center Section --- */
|
||||
.player-center {
|
||||
flex: 1.5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.controls-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.progress-row {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.time-text {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
width: 35px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* --- Right Section --- */
|
||||
.player-right {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.volume-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.volume-slider {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.divider {
|
||||
color: var(--color-border);
|
||||
font-size: 12px;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
/* --- Common UI Components --- */
|
||||
.icon-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.icon-btn.small { width: 32px; height: 32px; }
|
||||
.icon-btn.tiny { width: 24px; height: 24px; padding: 0; color: var(--color-text-muted); }
|
||||
|
||||
.icon-btn.active { color: var(--color-accent); }
|
||||
.filled { fill: currentColor; }
|
||||
|
||||
.play-btn {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.play-btn:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Custom Slider */
|
||||
.slider-container {
|
||||
position: relative;
|
||||
height: 4px;
|
||||
flex: 1;
|
||||
border-radius: 2px;
|
||||
background: var(--color-bg-tertiary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.custom-slider {
|
||||
appearance: none;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
top: 0;
|
||||
left: 0;
|
||||
margin: 0;
|
||||
z-index: 2;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.custom-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slider-track {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background: var(--color-accent);
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
||||
310
src/renderer/components/Sidebar.vue
Normal file
310
src/renderer/components/Sidebar.vue
Normal file
@@ -0,0 +1,310 @@
|
||||
<template>
|
||||
<aside class="sidebar">
|
||||
<!-- 顶部区域 -->
|
||||
<div class="sidebar-header">
|
||||
<div class="logo-area">
|
||||
<div class="logo-icon">🎶</div>
|
||||
<span class="app-name">QZ Music</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主导航 -->
|
||||
<div class="nav-section">
|
||||
<router-link to="/" class="nav-item" active-class="active">
|
||||
<Icon icon="lucide:home" class="nav-icon" />
|
||||
<span class="nav-text">推荐</span>
|
||||
</router-link>
|
||||
<router-link to="/local" class="nav-item" active-class="active">
|
||||
<Icon icon="lucide:hard-drive" class="nav-icon" />
|
||||
<span class="nav-text">本地音乐</span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- 我的音乐 -->
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="nav-section">
|
||||
<div class="section-title">我的音乐</div>
|
||||
<router-link to="/liked" class="nav-item" active-class="active">
|
||||
<Icon icon="lucide:heart" class="nav-icon" />
|
||||
<span class="nav-text">我喜欢的</span>
|
||||
</router-link>
|
||||
<router-link to="/recent" class="nav-item" active-class="active">
|
||||
<Icon icon="lucide:clock" class="nav-icon" />
|
||||
<span class="nav-text">最近播放</span>
|
||||
</router-link>
|
||||
<div class="nav-item">
|
||||
<Icon icon="lucide:download" class="nav-icon" />
|
||||
<span class="nav-text">下载管理</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 我的歌单 -->
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="nav-section">
|
||||
<div class="section-header" @click="togglePlaylists">
|
||||
<span class="section-title">我的歌单</span>
|
||||
<Icon
|
||||
icon="lucide:chevron-down"
|
||||
class="collapse-icon"
|
||||
:class="{ 'collapsed': !isPlaylistsOpen }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="playlists-list" v-show="isPlaylistsOpen">
|
||||
<div class="nav-item playlist-item">
|
||||
<div class="playlist-cover">
|
||||
<Icon icon="lucide:music" />
|
||||
</div>
|
||||
<span class="nav-text">驾驶模式</span>
|
||||
</div>
|
||||
<div class="nav-item playlist-item">
|
||||
<div class="playlist-cover">
|
||||
<Icon icon="lucide:music" />
|
||||
</div>
|
||||
<span class="nav-text">放松时光</span>
|
||||
</div>
|
||||
<div class="nav-item playlist-item">
|
||||
<div class="playlist-cover">
|
||||
<Icon icon="lucide:music" />
|
||||
</div>
|
||||
<span class="nav-text">工作专注</span>
|
||||
</div>
|
||||
<div class="nav-item create-playlist">
|
||||
<Icon icon="lucide:plus" class="nav-icon" />
|
||||
<span class="nav-text">新建歌单</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { Icon } from '@iconify/vue';
|
||||
|
||||
const isPlaylistsOpen = ref(true);
|
||||
|
||||
const togglePlaylists = () => {
|
||||
isPlaylistsOpen.value = !isPlaylistsOpen.value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
height: 100vh;
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-right: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px 12px;
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
.sidebar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.sidebar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sidebar::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border-light);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.sidebar::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 顶部Logo区域 */
|
||||
.sidebar-header {
|
||||
padding: 8px 8px 24px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.logo-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px;
|
||||
border-radius: var(--radius-lg);
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.logo-area:hover {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: linear-gradient(135deg, #ec4141, #ff6b6b);
|
||||
border-radius: var(--radius-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
/* 导航区域 */
|
||||
.nav-section {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
text-decoration: none;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background-color: var(--color-accent-soft);
|
||||
color: var(--color-accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-item.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 3px;
|
||||
height: 20px;
|
||||
background-color: var(--color-accent);
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
transition: transform var(--transition-base);
|
||||
}
|
||||
|
||||
.nav-item:hover .nav-icon {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.nav-text {
|
||||
font-size: var(--font-size-sm);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 分割线 */
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: linear-gradient(to right, transparent, var(--color-border), transparent);
|
||||
margin: 16px 8px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* 区域标题 */
|
||||
.section-title {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
transition: all var(--transition-base);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.section-header:hover {
|
||||
color: var(--color-text-primary);
|
||||
background-color: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.collapse-icon {
|
||||
transition: transform var(--transition-base);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.collapse-icon.collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
/* 歌单项 */
|
||||
.playlist-item {
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.playlist-cover {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 12px;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.playlist-item:hover .playlist-cover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
/* 新建歌单 */
|
||||
.create-playlist {
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 8px;
|
||||
border: 1px dashed var(--color-border-light);
|
||||
}
|
||||
|
||||
.create-playlist:hover {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
background-color: var(--color-accent-soft);
|
||||
}
|
||||
</style>
|
||||
319
src/renderer/components/TopBar.vue
Normal file
319
src/renderer/components/TopBar.vue
Normal file
@@ -0,0 +1,319 @@
|
||||
<template>
|
||||
<header class="topbar">
|
||||
<div class="left-controls">
|
||||
<div class="nav-group">
|
||||
<button class="nav-btn ripple-btn" @click="goBack" title="返回">
|
||||
<Icon icon="lucide:chevron-left" class="nav-icon" />
|
||||
</button>
|
||||
<button class="nav-btn ripple-btn" @click="goForward" title="前进">
|
||||
<Icon icon="lucide:chevron-right" class="nav-icon" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="search-wrapper">
|
||||
<div class="search-container">
|
||||
<Icon icon="lucide:search" class="search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索音乐、歌手、专辑..."
|
||||
class="search-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="right-controls">
|
||||
<div class="app-actions">
|
||||
<button class="action-btn ripple-btn" title="设置">
|
||||
<Icon icon="lucide:settings" class="action-icon" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="window-actions">
|
||||
<button class="win-btn minimize" @click="handleMinimize" title="最小化">
|
||||
<Icon icon="lucide:minus" class="win-icon" />
|
||||
</button>
|
||||
|
||||
<button class="win-btn maximize" @click="handleMaximize" :title="isMaximized ? '还原' : '最大化'">
|
||||
<Icon :icon="isMaximized ? 'lucide:copy' : 'lucide:square'" class="win-icon" style="transform: scale(0.8);" />
|
||||
</button>
|
||||
|
||||
<button class="win-btn close" @click="handleClose" title="关闭">
|
||||
<Icon icon="lucide:x" class="win-icon" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { Icon } from '@iconify/vue';
|
||||
|
||||
const router = useRouter();
|
||||
const isMaximized = ref(false);
|
||||
|
||||
const goBack = () => router.back();
|
||||
const goForward = () => router.forward();
|
||||
|
||||
// --- 窗口控制逻辑 ---
|
||||
const handleMinimize = () => window.electronAPI?.minimizeWindow();
|
||||
|
||||
const handleMaximize = async () => {
|
||||
window.electronAPI?.maximizeWindow();
|
||||
isMaximized.value = !isMaximized.value;
|
||||
checkMaximizedState();
|
||||
};
|
||||
|
||||
const handleClose = () => window.electronAPI?.closeWindow();
|
||||
|
||||
const checkMaximizedState = async () => {
|
||||
if (window.electronAPI) {
|
||||
setTimeout(async () => {
|
||||
isMaximized.value = await window.electronAPI!.isMaximized();
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
checkMaximizedState();
|
||||
window.addEventListener('resize', checkMaximizedState);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', checkMaximizedState);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 变量定义 */
|
||||
:root {
|
||||
--radius-soft: 10px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: 64px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px 0 24px;
|
||||
background-color: var(--color-bg-primary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
-webkit-app-region: drag;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* --- 左侧区域 --- */
|
||||
.left-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.nav-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* --- 统一功能按钮(Nav / Settings)--- */
|
||||
.nav-btn,
|
||||
.action-btn {
|
||||
-webkit-app-region: no-drag;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px; /* 圆角边框 */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
color: var(--color-text-secondary);
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
cursor: pointer;
|
||||
|
||||
position: relative;
|
||||
overflow: hidden; /* 关键:裁剪涟漪 */
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
color 0.2s ease,
|
||||
transform 0.12s ease;
|
||||
}
|
||||
|
||||
|
||||
.nav-btn:hover,
|
||||
.action-btn:hover {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
border-color: var(--color-text-muted);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* 图标 */
|
||||
.nav-icon,
|
||||
.action-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.ripple-btn::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.1); /* 涟漪颜色 */
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%) scale(0); /* 初始不可见 */
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 点击瞬间:迅速放大并显示 */
|
||||
.ripple-btn:active::after {
|
||||
transform: translate(-50%, -50%) scale(2.5);
|
||||
opacity: 1;
|
||||
transition: 0s;
|
||||
}
|
||||
|
||||
/* 松开后:慢慢淡出 */
|
||||
.ripple-btn:not(:active):after {
|
||||
transform: translate(-50%, -50%) scale(2.5);
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease-out;
|
||||
}
|
||||
|
||||
|
||||
/* --- 搜索框 --- */
|
||||
.search-wrapper {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
width: 260px;
|
||||
padding: 0 14px;
|
||||
background-color: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 20px;
|
||||
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.search-container:hover {
|
||||
border-color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.search-container:focus-within {
|
||||
width: 340px;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-bg-primary) 0%,
|
||||
var(--color-bg-tertiary) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
color: var(--color-text-muted);
|
||||
margin-right: 10px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.search-container:focus-within .search-icon {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
font-size: 15px;
|
||||
color: var(--color-text-primary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* --- 右侧区域布局调整 --- */
|
||||
.right-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px; /* 减少间距,让设置按钮更靠近右边 */
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.app-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background-color: var(--color-border);
|
||||
margin: 0 4px; /* 减少分割线左右的间距 */
|
||||
}
|
||||
|
||||
/* --- 窗口控制区 --- */
|
||||
.window-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.win-btn {
|
||||
-webkit-app-region: no-drag;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: transparent;
|
||||
color: var(--color-text-primary);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
}
|
||||
|
||||
.win-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.win-btn:not(.close):hover {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.win-btn:not(.close):active {
|
||||
background-color: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.win-btn.close:hover {
|
||||
background-color: #e81123;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.win-btn.close:active {
|
||||
background-color: #bf0f1d;
|
||||
}
|
||||
</style>
|
||||
99
src/renderer/layout/MainLayout.vue
Normal file
99
src/renderer/layout/MainLayout.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<div class="main-layout" :class="{ 'has-player': hasSongs }">
|
||||
<Sidebar class="layout-sidebar" />
|
||||
<main class="content-area">
|
||||
<TopBar />
|
||||
<div class="page-content">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</div>
|
||||
</main>
|
||||
<PlayerBar />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import Sidebar from '../components/Sidebar.vue';
|
||||
import TopBar from '../components/TopBar.vue';
|
||||
import PlayerBar from '../components/PlayerBar.vue';
|
||||
import { usePlayerStore } from '../stores/player.ts';
|
||||
|
||||
const playerStore = usePlayerStore();
|
||||
const hasSongs = computed(() => playerStore.playlist.length > 0);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* 关键修复:全局重置盒模型 */
|
||||
/* 这确保 width: 100% + padding 不会撑破容器 */
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 隐藏 body 的滚动条,防止整体页面出现原生滚动条 */
|
||||
body {
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.main-layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
background-color: var(--color-bg-primary);
|
||||
overflow: hidden; /* 确保整个应用不会出现双重滚动条 */
|
||||
}
|
||||
|
||||
/* Dynamic Spacing for PlayerBar */
|
||||
.layout-sidebar,
|
||||
.content-area {
|
||||
transition: padding-bottom 0.3s ease;
|
||||
}
|
||||
|
||||
.main-layout.has-player .layout-sidebar,
|
||||
.main-layout.has-player .content-area {
|
||||
padding-bottom: 80px; /* PlayerBar Height */
|
||||
}
|
||||
|
||||
.content-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
/* 关键修复:防止 flex 子项内容过宽撑开容器 */
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
flex: 1;
|
||||
overflow-y: auto; /* 只允许内容区域滚动 */
|
||||
padding: 0;
|
||||
background-color: var(--color-bg-primary);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 滚动条样式优化 */
|
||||
.page-content::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.page-content::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.page-content::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border-light);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.page-content::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,4 @@
|
||||
// src/main.ts
|
||||
// src/renderer/main.ts
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
@@ -50,13 +50,13 @@ const playerStore = usePlayerStore()
|
||||
playerStore.playMode = PlayMode.Single;
|
||||
|
||||
const testSong: Song = {
|
||||
id: '9999',
|
||||
name: 'Test FLAC',
|
||||
artist: 'Netease',
|
||||
picUrl: 'http://p1.music.126.net/btYBbFLd5mf9w0lDpfNs6w==/109951171506809884.jpg?param=130y130',
|
||||
url: 'http://m801.music.126.net/20260202213928/189743bba596f8fd999bc44fd51d11fd/jdymusic/obj/wo3DlMOGwrbDjj7DisKw/61393856655/24be/6a68/77e4/fb898a5378682427bf7a4fb55640e610.flac',
|
||||
duration: '03:30',
|
||||
source: 'netease',
|
||||
id: '3337983421',
|
||||
name: '不死身ごっこ (feat. 初音ミク)',
|
||||
artist: 'ピノキオピー、初音ミク',
|
||||
picUrl: 'http://p2.music.126.net/7O6FcCraxldhFGz4CSPVlw==/109951172567380787.jpg?imageView=&thumbnail=371y371&type=webp&rotate=360&tostatic=0',
|
||||
url: 'http://m701.music.126.net/20260203120731/1480f3bdda9795d5e7cc25af304b6cae/jdymusic/obj/wo3DlMOGwrbDjj7DisKw/77635439540/89c6/162f/e373/62ded4a27758346e53f717f46ba2802c.flac',
|
||||
duration: '02:31',
|
||||
source: 'wy',
|
||||
type: 'Remote'
|
||||
};
|
||||
|
||||
296
src/renderer/stores/player.ts
Normal file
296
src/renderer/stores/player.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import { MessagePlugin } from 'tdesign-vue-next';
|
||||
import type { Song } from '../types/song';
|
||||
|
||||
export enum PlayMode {
|
||||
List = 'list',
|
||||
Single = 'single',
|
||||
Random = 'random'
|
||||
}
|
||||
|
||||
// 静音 WAV Base64
|
||||
const SILENT_AUDIO_URL = 'data:audio/wav;base64,UklGRigAAABXQVZFZm10IBIAAAABAAEARKwAAIhYAQACABAAAABkYXRhAgAAAAEA';
|
||||
|
||||
const dummyAudio = new Audio();
|
||||
dummyAudio.src = SILENT_AUDIO_URL;
|
||||
dummyAudio.loop = true;
|
||||
dummyAudio.volume = 0.01;
|
||||
if (typeof document !== 'undefined') {
|
||||
document.body.appendChild(dummyAudio);
|
||||
}
|
||||
|
||||
export const usePlayerStore = defineStore('player', () => {
|
||||
// State
|
||||
const isPlaying = ref(false);
|
||||
const currentSong = ref<Song | null>(null);
|
||||
const volume = ref(100);
|
||||
const duration = ref(0);
|
||||
const currentTime = ref(0);
|
||||
|
||||
// Playlist State
|
||||
const playlist = ref<Song[]>([]);
|
||||
const currentIndex = ref(-1);
|
||||
const playMode = ref<PlayMode>(PlayMode.List);
|
||||
|
||||
// Error Handling
|
||||
const playErrorCount = ref(0);
|
||||
const MAX_RETRY_COUNT = 3;
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
const activateDummyAudio = async () => {
|
||||
if (dummyAudio.paused) {
|
||||
try {
|
||||
await dummyAudio.play().catch(e => console.warn('Dummy play failed:', e));
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
if ('mediaSession' in navigator) {
|
||||
navigator.mediaSession.playbackState = 'playing';
|
||||
}
|
||||
};
|
||||
|
||||
// 同步浏览器状态
|
||||
const syncDummyAudioState = (shouldPlay: boolean) => {
|
||||
if (shouldPlay) {
|
||||
if (dummyAudio.paused) {
|
||||
dummyAudio.play().catch(() => { });
|
||||
}
|
||||
if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'playing';
|
||||
} else {
|
||||
if (!dummyAudio.paused) {
|
||||
dummyAudio.pause();
|
||||
}
|
||||
if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'paused';
|
||||
}
|
||||
};
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
// Cache: key = `${source}:${id}:${quality}`
|
||||
const urlCache = new Map<string, string>();
|
||||
|
||||
const fetchUrl = async (song: Song, forceRefresh = false): Promise<string | null> => {
|
||||
if (!song.source || !song.id) return null;
|
||||
|
||||
const quality = 'hires'; // TODO: Make configurable
|
||||
const cacheKey = `${song.source}:${song.id}:${quality}`;
|
||||
|
||||
if (!forceRefresh && urlCache.has(cacheKey)) {
|
||||
console.log(`[Cache] Hit for ${song.name}`);
|
||||
return urlCache.get(cacheKey) || null;
|
||||
}
|
||||
|
||||
console.log(`[Plugin] Fetching URL for [${song.name}] from ${song.source}...`);
|
||||
try {
|
||||
const result = await window.electronAPI.plugin.call(song.source, 'getUrl', [song.id, quality]);
|
||||
if (result.success && result.url) {
|
||||
console.log('[Plugin] Success:', result.url);
|
||||
urlCache.set(cacheKey, result.url);
|
||||
return result.url;
|
||||
} else {
|
||||
console.error('[Plugin] Failed:', result.error);
|
||||
if (!forceRefresh) MessagePlugin.error(`无法获取播放链接: ${result.error || '未知错误'}`);
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Plugin] Error:', e);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const setPlaylist = async (list: any[], startIndex = 0) => {
|
||||
playlist.value = list;
|
||||
currentIndex.value = startIndex;
|
||||
if (list.length > 0 && startIndex >= 0 && startIndex < list.length) {
|
||||
await playSong(list[startIndex]);
|
||||
}
|
||||
};
|
||||
|
||||
const playSong = async (song: Song) => {
|
||||
if (!song) return;
|
||||
|
||||
currentSong.value = song;
|
||||
const foundIndex = playlist.value.findIndex(s => s.id === song.id);
|
||||
if (foundIndex !== -1) {
|
||||
currentIndex.value = foundIndex;
|
||||
}
|
||||
|
||||
await activateDummyAudio();
|
||||
updateMediaSession(song);
|
||||
|
||||
// 1. Get URL (Cache -> Network)
|
||||
let playUrl = song.url;
|
||||
if (song.type === 'Remote' && song.source) {
|
||||
const fetched = await fetchUrl(song);
|
||||
if (fetched) playUrl = fetched;
|
||||
}
|
||||
|
||||
if (playUrl) {
|
||||
console.log('Playing:', song.name);
|
||||
try {
|
||||
await window.electronAPI.mpv.load(playUrl);
|
||||
await window.electronAPI.mpv.play();
|
||||
isPlaying.value = true;
|
||||
// Update song object URL for UI reference if needed
|
||||
song.url = playUrl;
|
||||
} catch (e) {
|
||||
console.error("Play request failed:", e);
|
||||
|
||||
// Retry Logic: If Remote and failed, force refresh URL and retry
|
||||
if (song.type === 'Remote' && song.source) {
|
||||
console.warn("Playback failed. Retrying with fresh URL...");
|
||||
const freshUrl = await fetchUrl(song, true); // Force Refresh
|
||||
if (freshUrl) {
|
||||
try {
|
||||
await window.electronAPI.mpv.load(freshUrl);
|
||||
await window.electronAPI.mpv.play();
|
||||
isPlaying.value = true;
|
||||
song.url = freshUrl;
|
||||
return; // Success on retry
|
||||
} catch (retryError) {
|
||||
console.error("Retry playback failed:", retryError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handlePlayError();
|
||||
}
|
||||
} else {
|
||||
console.warn("Song has no URL");
|
||||
handlePlayError();
|
||||
}
|
||||
};
|
||||
|
||||
const updateMediaSession = (song: Song) => {
|
||||
if (!('mediaSession' in navigator)) return;
|
||||
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: song.name,
|
||||
artist: song.artist,
|
||||
album: song.albumName || '',
|
||||
artwork: song.picUrl ? [{ src: song.picUrl, sizes: '512x512', type: 'image/png' }] : []
|
||||
});
|
||||
|
||||
navigator.mediaSession.setActionHandler('play', () => window.electronAPI.mpv.play());
|
||||
navigator.mediaSession.setActionHandler('pause', () => window.electronAPI.mpv.pause());
|
||||
navigator.mediaSession.setActionHandler('previoustrack', () => prev());
|
||||
navigator.mediaSession.setActionHandler('nexttrack', () => next(true));
|
||||
|
||||
navigator.mediaSession.setActionHandler('seekto', (details) => {
|
||||
if (details.seekTime != null) {
|
||||
seek(details.seekTime);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const next = async (manual = true) => {
|
||||
if (playlist.value.length === 0) return;
|
||||
let nextIndex = currentIndex.value;
|
||||
if (playMode.value === PlayMode.Single && !manual) {
|
||||
nextIndex = currentIndex.value;
|
||||
} else if (playMode.value === PlayMode.Random) {
|
||||
nextIndex = Math.floor(Math.random() * playlist.value.length);
|
||||
} else {
|
||||
nextIndex = (currentIndex.value + 1) % playlist.value.length;
|
||||
}
|
||||
currentIndex.value = nextIndex;
|
||||
await playSong(playlist.value[nextIndex]);
|
||||
};
|
||||
|
||||
const prev = async () => {
|
||||
if (playlist.value.length === 0) return;
|
||||
let prevIndex = currentIndex.value;
|
||||
if (playMode.value === PlayMode.Random) {
|
||||
prevIndex = Math.floor(Math.random() * playlist.value.length);
|
||||
} else {
|
||||
prevIndex = (currentIndex.value - 1 + playlist.value.length) % playlist.value.length;
|
||||
}
|
||||
currentIndex.value = prevIndex;
|
||||
await playSong(playlist.value[prevIndex]);
|
||||
};
|
||||
|
||||
const handlePlayError = () => {
|
||||
playErrorCount.value++;
|
||||
if (playlist.value.length === 0) {
|
||||
isPlaying.value = false;
|
||||
playErrorCount.value = 0;
|
||||
syncDummyAudioState(false);
|
||||
return;
|
||||
}
|
||||
if (playErrorCount.value >= MAX_RETRY_COUNT) {
|
||||
window.electronAPI.mpv.pause();
|
||||
isPlaying.value = false;
|
||||
MessagePlugin.error('连续多次播放失败,已停止播放');
|
||||
playErrorCount.value = 0;
|
||||
syncDummyAudioState(false);
|
||||
} else {
|
||||
MessagePlugin.warning(`播放失败,尝试播放下一首 (${playErrorCount.value}/${MAX_RETRY_COUNT})`);
|
||||
setTimeout(() => next(false), 1000);
|
||||
}
|
||||
};
|
||||
|
||||
// Listeners
|
||||
if (window.electronAPI) {
|
||||
window.electronAPI.mpv.onEvent((_event, data) => {
|
||||
if (data.event === 'property-change') {
|
||||
if (data.name === 'pause') {
|
||||
const isPaused = data.data;
|
||||
isPlaying.value = !isPaused;
|
||||
// 核心:MPV 暂停 -> 同步暂停 Dummy -> 浏览器更新 SMTC 状态
|
||||
syncDummyAudioState(!isPaused);
|
||||
}
|
||||
if (data.name === 'time-pos') currentTime.value = data.data;
|
||||
if (data.name === 'duration') duration.value = data.data;
|
||||
}
|
||||
|
||||
if (data.event === 'end-file') {
|
||||
const reason = data.reason;
|
||||
if (reason === 'eof') {
|
||||
next(false);
|
||||
} else if (reason === 'error') {
|
||||
handlePlayError();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const togglePlay = async () => {
|
||||
await window.electronAPI.mpv.togglePause();
|
||||
};
|
||||
|
||||
const setVolume = async (vol: number) => {
|
||||
volume.value = vol;
|
||||
await window.electronAPI.mpv.setVolume(vol);
|
||||
};
|
||||
|
||||
const seek = async (time: number) => {
|
||||
await window.electronAPI.mpv.seek(time);
|
||||
};
|
||||
|
||||
const toggleMode = () => {
|
||||
if (playMode.value === PlayMode.List) playMode.value = PlayMode.Single;
|
||||
else if (playMode.value === PlayMode.Single) playMode.value = PlayMode.Random;
|
||||
else playMode.value = PlayMode.List;
|
||||
};
|
||||
|
||||
return {
|
||||
isPlaying,
|
||||
currentSong,
|
||||
volume,
|
||||
duration,
|
||||
currentTime,
|
||||
playlist,
|
||||
playMode,
|
||||
setPlaylist,
|
||||
playSong,
|
||||
next,
|
||||
prev,
|
||||
togglePlay,
|
||||
setVolume,
|
||||
seek,
|
||||
toggleMode
|
||||
};
|
||||
});
|
||||
60
src/renderer/styles/main.css
Normal file
60
src/renderer/styles/main.css
Normal file
@@ -0,0 +1,60 @@
|
||||
@import 'variables.css';
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-family-base);
|
||||
background-color: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
overflow: hidden;
|
||||
/* App-like feel */
|
||||
user-select: none;
|
||||
/* Prevent text selection generally in app */
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
input {
|
||||
border: none;
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
color: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Custom Scrollbar for 'Exquisite' look */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-bg-tertiary);
|
||||
border-radius: var(--radius-full);
|
||||
-electron-corner-smoothing: 65%;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-text-muted);
|
||||
}
|
||||
49
src/renderer/styles/variables.css
Normal file
49
src/renderer/styles/variables.css
Normal file
@@ -0,0 +1,49 @@
|
||||
:root {
|
||||
/* Colors - 网易云风格配色 */
|
||||
--color-bg-primary: #121212;
|
||||
--color-bg-secondary: #181818;
|
||||
--color-bg-tertiary: #282828;
|
||||
--color-bg-elevated: #2a2a2a;
|
||||
|
||||
--color-text-primary: #ffffff;
|
||||
--color-text-secondary: #b3b3b3;
|
||||
--color-text-muted: #737373;
|
||||
|
||||
--color-accent: #ec4141;
|
||||
--color-accent-hover: #ff5555;
|
||||
--color-accent-soft: rgba(236, 65, 65, 0.1);
|
||||
|
||||
--color-border: #2a2a2a;
|
||||
--color-border-light: #3a3a3a;
|
||||
|
||||
/* Shadows - 柔和阴影效果 */
|
||||
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.12);
|
||||
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.16);
|
||||
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.24);
|
||||
--shadow-elevated: 0 12px 48px rgba(0, 0, 0, 0.32);
|
||||
|
||||
/* Typography */
|
||||
--font-family-base: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
--font-size-xs: 0.75rem;
|
||||
--font-size-sm: 0.875rem;
|
||||
--font-size-base: 1rem;
|
||||
--font-size-lg: 1.125rem;
|
||||
--font-size-xl: 1.25rem;
|
||||
--font-size-2xl: 1.5rem;
|
||||
|
||||
/* Spacing & Radius - 网易云风格大圆角 */
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 20px;
|
||||
--radius-xl: 24px;
|
||||
--radius-2xl: 32px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
--sidebar-width: 240px;
|
||||
--topbar-height: 64px;
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 0.15s ease;
|
||||
--transition-base: 0.25s ease;
|
||||
--transition-slow: 0.35s ease;
|
||||
}
|
||||
@@ -12,7 +12,10 @@ export interface IElectronAPI {
|
||||
setVolume: (vol: number) => Promise<void>;
|
||||
seek: (time: number) => Promise<void>;
|
||||
onEvent: (callback: (event: any, data: any) => void) => void;
|
||||
}
|
||||
};
|
||||
plugin: {
|
||||
call: (pluginId: string, method: string, args: any[]) => Promise<any>;
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
24
src/renderer/types/song.ts
Normal file
24
src/renderer/types/song.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// Song Type Definition
|
||||
|
||||
export type SongType = 'Local' | 'Remote';
|
||||
|
||||
export interface SongQualityMap {
|
||||
[quality: string]: string; // e.g., "standard": "5.5M", "exhigh": "8.0M"
|
||||
}
|
||||
|
||||
export interface Song {
|
||||
id: string;
|
||||
hash?: string | null;
|
||||
picUrl: string;
|
||||
url: string;
|
||||
name: string;
|
||||
artist: string;
|
||||
duration: string;
|
||||
source: string;
|
||||
quality?: string; // default 'auto'
|
||||
albumId?: string | null;
|
||||
albumName?: string | null;
|
||||
artistIds?: string[] | null;
|
||||
type: SongType;
|
||||
types?: SongQualityMap;
|
||||
}
|
||||
423
src/renderer/views/Home.vue
Normal file
423
src/renderer/views/Home.vue
Normal file
@@ -0,0 +1,423 @@
|
||||
<template>
|
||||
<div class="view-container home-view">
|
||||
<div class="content-wrapper">
|
||||
<!-- 每日推荐横幅 -->
|
||||
<div class="daily-recommend">
|
||||
<div class="banner-content">
|
||||
<div class="date-badge">
|
||||
<div class="day">{{ currentDate.day }}</div>
|
||||
<div class="month">{{ currentDate.month }}月</div>
|
||||
</div>
|
||||
<div class="banner-info">
|
||||
<h2 class="banner-title">每日推荐</h2>
|
||||
<p class="banner-desc">根据你的音乐口味,为你精选30首歌曲</p>
|
||||
<button class="play-btn">
|
||||
<Icon icon="lucide:play" class="play-icon" />
|
||||
立即播放
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 推荐歌单 -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">推荐歌单</h3>
|
||||
<button class="more-btn">更多</button>
|
||||
</div>
|
||||
<div class="playlist-grid">
|
||||
<div class="playlist-card" v-for="i in 6" :key="i">
|
||||
<div class="playlist-cover">
|
||||
<div class="cover-gradient"></div>
|
||||
<div class="play-overlay">
|
||||
<Icon icon="lucide:play" class="overlay-icon" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="playlist-info">
|
||||
<h4 class="playlist-name">精选歌单 {{ i }}</h4>
|
||||
<p class="playlist-desc">30首歌曲</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 热门歌手 -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">热门歌手</h3>
|
||||
<button class="more-btn">更多</button>
|
||||
</div>
|
||||
<div class="artist-grid">
|
||||
<div class="artist-card" v-for="i in 8" :key="i">
|
||||
<div class="artist-avatar">
|
||||
<div class="avatar-gradient"></div>
|
||||
</div>
|
||||
<p class="artist-name">歌手 {{ i }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新歌速递 -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">新歌速递</h3>
|
||||
<button class="more-btn">播放全部</button>
|
||||
</div>
|
||||
<div class="song-list">
|
||||
<div class="song-item" v-for="i in 10" :key="i">
|
||||
<div class="song-index">{{ i }}</div>
|
||||
<div class="song-cover">
|
||||
<div class="cover-gradient"></div>
|
||||
</div>
|
||||
<div class="song-info">
|
||||
<h4 class="song-title">歌曲名称 {{ i }}</h4>
|
||||
<p class="song-artist">歌手名称</p>
|
||||
</div>
|
||||
<div class="song-duration">03:45</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { Icon } from '@iconify/vue';
|
||||
|
||||
const currentDate = computed(() => {
|
||||
const now = new Date();
|
||||
return {
|
||||
day: now.getDate(),
|
||||
month: now.getMonth() + 1
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home-view {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
padding: 24px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* 每日推荐横幅 */
|
||||
.daily-recommend {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.banner-content {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: var(--radius-2xl);
|
||||
-electron-corner-smoothing: 65%;
|
||||
padding: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.banner-content::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
right: -20%;
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 70%);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.date-badge {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 16px 20px;
|
||||
text-align: center;
|
||||
min-width: 100px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.day {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.month {
|
||||
font-size: var(--font-size-lg);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.banner-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.banner-title {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.banner-desc {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: var(--font-size-base);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: white;
|
||||
color: #667eea;
|
||||
padding: 12px 32px;
|
||||
border-radius: var(--radius-full);
|
||||
border: none;
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.play-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.play-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* 区域样式 */
|
||||
.section {
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.more-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border-light);
|
||||
color: var(--color-text-secondary);
|
||||
padding: 8px 20px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.more-btn:hover {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
background-color: var(--color-accent-soft);
|
||||
}
|
||||
|
||||
/* 歌单网格 */
|
||||
.playlist-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.playlist-card {
|
||||
cursor: pointer;
|
||||
transition: transform var(--transition-base);
|
||||
}
|
||||
|
||||
.playlist-card:hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.playlist-cover {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
margin-bottom: 12px;
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.cover-gradient {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
}
|
||||
|
||||
.play-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-base);
|
||||
}
|
||||
|
||||
.playlist-card:hover .play-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.overlay-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: white;
|
||||
transform: scale(0.8);
|
||||
transition: transform var(--transition-base);
|
||||
}
|
||||
|
||||
.playlist-card:hover .overlay-icon {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.playlist-info {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.playlist-name {
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.playlist-desc {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 歌手网格 */
|
||||
.artist-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.artist-card {
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: transform var(--transition-base);
|
||||
}
|
||||
|
||||
.artist-card:hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.artist-avatar {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
margin: 0 auto 12px;
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.avatar-gradient {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
}
|
||||
|
||||
.artist-name {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-primary);
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 歌曲列表 */
|
||||
.song-list {
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.song-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-lg);
|
||||
transition: all var(--transition-base);
|
||||
cursor: pointer;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.song-item:hover {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.song-index {
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.song-cover {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.song-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.song-title {
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.song-artist {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.song-duration {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
54
src/renderer/views/LocalMusic.vue
Normal file
54
src/renderer/views/LocalMusic.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div class="view-container local-view">
|
||||
<h1 class="view-title">Local Files</h1>
|
||||
<div class="empty-state">
|
||||
<div class="icon-box">
|
||||
<Icon icon="lucide:music" width="48" height="48" />
|
||||
</div>
|
||||
<p>No local files scanned yet.</p>
|
||||
<button class="action-btn">Scan Folder</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue';
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.view-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.icon-box {
|
||||
background-color: var(--color-bg-secondary);
|
||||
padding: 24px;
|
||||
border-radius: 50%;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
margin-top: 24px;
|
||||
background-color: var(--color-accent);
|
||||
color: var(--color-bg-primary);
|
||||
padding: 12px 24px;
|
||||
border-radius: var(--radius-full);
|
||||
font-weight: 600;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
</style>
|
||||
465
src/renderer/views/Playlist.vue
Normal file
465
src/renderer/views/Playlist.vue
Normal file
@@ -0,0 +1,465 @@
|
||||
<template>
|
||||
<div class="view-container playlist-view">
|
||||
<div class="content-wrapper">
|
||||
<!-- 动态头部设计 -->
|
||||
<div class="playlist-header">
|
||||
<div class="header-bg" :class="themeClass">
|
||||
<div class="flow-circle c1"></div>
|
||||
<div class="flow-circle c2"></div>
|
||||
</div>
|
||||
|
||||
<div class="header-content">
|
||||
<div class="cover-box" :style="{ background: coverGradient }">
|
||||
<div class="icon-wrapper">
|
||||
<Icon :icon="iconName" class="big-icon" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<div class="sub-title">PLAYLIST</div>
|
||||
<h1 class="title">{{ title }}</h1>
|
||||
<div class="meta-info">
|
||||
<div class="avatar-row">
|
||||
<div class="user-avatar">
|
||||
<Icon icon="lucide:user" />
|
||||
</div>
|
||||
<span class="user-name">User</span>
|
||||
</div>
|
||||
<span class="divider">•</span>
|
||||
<span class="count">{{ songCount }} 首歌曲</span>
|
||||
</div>
|
||||
|
||||
<div class="action-row">
|
||||
<button class="play-all-btn" :class="themeClass" @click="handlePlayAll">
|
||||
<Icon icon="lucide:play" class="btn-icon" />
|
||||
播放全部
|
||||
</button>
|
||||
<button class="action-btn">
|
||||
<Icon icon="lucide:download" />
|
||||
</button>
|
||||
<button class="action-btn">
|
||||
<Icon icon="lucide:share-2" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 歌曲列表 -->
|
||||
<div class="song-list-container">
|
||||
<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" @dblclick="handlePlaySong(i)">
|
||||
<div class="song-index">{{ i + 1 }}</div>
|
||||
<div class="song-cover">
|
||||
<div class="cover-gradient" :class="themeClass"></div>
|
||||
</div>
|
||||
<div class="song-info">
|
||||
<h4 class="song-title">{{ song.title }}</h4>
|
||||
<p class="song-artist">{{ song.artist }}</p>
|
||||
</div>
|
||||
<div class="song-album">{{ song.album }}</div>
|
||||
<div class="song-duration">{{ song.duration }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { usePlayerStore } from '../stores/player.ts';
|
||||
|
||||
const route = useRoute();
|
||||
const playerStore = usePlayerStore();
|
||||
|
||||
// 判断页面类型
|
||||
const isLiked = computed(() => route.path.includes('liked'));
|
||||
const isRecent = computed(() => route.path.includes('recent'));
|
||||
|
||||
// 动态数据
|
||||
const title = computed(() => {
|
||||
if (isLiked.value) return '我喜欢的音乐';
|
||||
if (isRecent.value) return '最近播放';
|
||||
return '歌单';
|
||||
});
|
||||
|
||||
const iconName = computed(() => {
|
||||
if (isLiked.value) return 'lucide:heart';
|
||||
if (isRecent.value) return 'lucide:clock';
|
||||
return 'lucide:music';
|
||||
});
|
||||
|
||||
const themeClass = computed(() => {
|
||||
if (isLiked.value) return 'theme-liked';
|
||||
if (isRecent.value) return 'theme-recent';
|
||||
return 'theme-default';
|
||||
});
|
||||
|
||||
const coverGradient = computed(() => {
|
||||
if (isLiked.value) return 'linear-gradient(135deg, #ff9a9e 0%, #fecfef 99%, #fecfef 100%)';
|
||||
if (isRecent.value) return 'linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%)';
|
||||
return 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)';
|
||||
});
|
||||
|
||||
// Mock Playlist Data
|
||||
const songs = ref(Array.from({ length: 15 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
title: `Song Title ${i + 1}`,
|
||||
artist: `Artist Name ${i + 1}`,
|
||||
album: `Album Mock`,
|
||||
duration: '03:30',
|
||||
url: 'http://commondatastorage.googleapis.com/codeskulptor-demos/riceracer_assets/music/win.ogg'
|
||||
})));
|
||||
|
||||
const songCount = computed(() => songs.value.length);
|
||||
|
||||
const handlePlayAll = () => {
|
||||
playerStore.setPlaylist(songs.value);
|
||||
};
|
||||
|
||||
const handlePlaySong = (index: number) => {
|
||||
playerStore.setPlaylist(songs.value, index);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.playlist-view {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
padding: 30px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Header Styles */
|
||||
.playlist-header {
|
||||
position: relative;
|
||||
height: 240px;
|
||||
border-radius: var(--radius-2xl);
|
||||
overflow: hidden;
|
||||
margin-bottom: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 40px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.header-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-size: 400% 400%;
|
||||
animation: gradientBG 15s ease infinite;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Default Dark Dynamic */
|
||||
.header-bg {
|
||||
background-image: linear-gradient(-45deg, #1e1e1e, #2a2a2a, #3a1c1c, #1a1a1a);
|
||||
}
|
||||
|
||||
.header-bg.theme-liked {
|
||||
background-image: linear-gradient(-45deg, #2a1a1a, #4a2c2c, #3a1c1c, #1a1a1a);
|
||||
}
|
||||
|
||||
.header-bg.theme-recent {
|
||||
background-image: linear-gradient(-45deg, #1a1a2a, #2c2c4a, #1c1c3a, #1a1a1a);
|
||||
}
|
||||
|
||||
@keyframes gradientBG {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
|
||||
.flow-circle {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(60px);
|
||||
opacity: 0.4;
|
||||
animation: float 10s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.c1 {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
background: #ec4141; /* Default/Liked Color */
|
||||
top: -50px;
|
||||
left: -50px;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.c2 {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: #4facfe;
|
||||
bottom: -100px;
|
||||
right: -50px;
|
||||
animation-delay: -5s;
|
||||
}
|
||||
|
||||
/* Recent Theme Colors */
|
||||
.theme-recent .c1 { background: #a18cd1; }
|
||||
.theme-recent .c2 { background: #fbc2eb; }
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0) scale(1); }
|
||||
50% { transform: translateY(20px) scale(1.1); }
|
||||
}
|
||||
|
||||
.header-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cover-box {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
border-radius: var(--radius-xl);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
background: rgba(255,255,255,0.2);
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.big-icon {
|
||||
color: #fff;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
flex: 1;
|
||||
color: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sub-title {
|
||||
font-size: 12px;
|
||||
letter-spacing: 2px;
|
||||
opacity: 0.8;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 42px;
|
||||
font-weight: 800;
|
||||
margin-bottom: 16px;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.meta-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
font-size: 14px;
|
||||
color: rgba(255,255,255,0.8);
|
||||
}
|
||||
|
||||
.avatar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: #555;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.divider {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.play-all-btn {
|
||||
background: #ec4141; /* Default */
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 24px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.play-all-btn.theme-recent {
|
||||
background: #a18cd1;
|
||||
}
|
||||
|
||||
.play-all-btn:hover {
|
||||
transform: scale(1.05);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
background: transparent;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: rgba(255,255,255,0.1);
|
||||
border-color: white;
|
||||
}
|
||||
|
||||
/* List Styles */
|
||||
.song-list-container {
|
||||
}
|
||||
|
||||
.list-header {
|
||||
display: flex;
|
||||
padding: 0 16px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.col-index { width: 40px; text-align: center; }
|
||||
.col-title { flex: 1; }
|
||||
.col-album { width: 200px; }
|
||||
.col-time { width: 60px; text-align: right; }
|
||||
|
||||
.song-list {
|
||||
}
|
||||
|
||||
.song-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 16px;
|
||||
border-radius: var(--radius-lg);
|
||||
transition: background-color 0.2s;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.song-item:hover {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.song-item:hover .song-index {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.song-index {
|
||||
width: 40px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.song-cover {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 6px;
|
||||
margin-right: 16px;
|
||||
background: #333;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cover-gradient {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(45deg, #667eea, #764ba2);
|
||||
}
|
||||
|
||||
/* Different gradients for list items too, maybe? */
|
||||
.cover-gradient.theme-liked {
|
||||
background: linear-gradient(45deg, #ff9a9e, #fecfef);
|
||||
}
|
||||
|
||||
.cover-gradient.theme-recent {
|
||||
background: linear-gradient(45deg, #a18cd1, #fbc2eb);
|
||||
}
|
||||
|
||||
.song-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.song-title {
|
||||
font-size: 15px;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.song-artist {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.song-album {
|
||||
width: 200px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.song-duration {
|
||||
width: 60px;
|
||||
text-align: right;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user