mirror of
https://github.com/lqtmcstudio/QZMusic_PC.git
synced 2026-06-20 23:35:06 +08:00
feat: 实现功能&优化
- 底部播放栏 - MediaSession - 插件系统&存储位置 - URL缓存机制 - 整理项目结构
This commit is contained in:
@@ -1,14 +1,16 @@
|
|||||||
var __defProp = Object.defineProperty;
|
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 __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);
|
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 { createRequire } from "node:module";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import path$1 from "node:path";
|
import path$1 from "node:path";
|
||||||
|
import fs$1 from "node:fs";
|
||||||
import { spawn } from "child_process";
|
import { spawn } from "child_process";
|
||||||
import { Socket } from "net";
|
import { Socket } from "net";
|
||||||
import { EventEmitter } from "events";
|
import { EventEmitter } from "events";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import fs from "fs";
|
||||||
class MpvController extends EventEmitter {
|
class MpvController extends EventEmitter {
|
||||||
constructor(ipcPath) {
|
constructor(ipcPath) {
|
||||||
super();
|
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);
|
createRequire(import.meta.url);
|
||||||
const __dirname$1 = path$1.dirname(fileURLToPath(import.meta.url));
|
const __dirname$1 = path$1.dirname(fileURLToPath(import.meta.url));
|
||||||
process.env.APP_ROOT = path$1.join(__dirname$1, "..");
|
process.env.APP_ROOT = path$1.join(__dirname$1, "..");
|
||||||
@@ -173,6 +219,7 @@ function createWindow() {
|
|||||||
} else {
|
} else {
|
||||||
win.loadFile(path$1.join(RENDERER_DIST, "index.html"));
|
win.loadFile(path$1.join(RENDERER_DIST, "index.html"));
|
||||||
}
|
}
|
||||||
|
win.webContents.openDevTools();
|
||||||
registerZoomShortcuts(win);
|
registerZoomShortcuts(win);
|
||||||
}
|
}
|
||||||
ipcMain.on("window-minimize", (event) => {
|
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-pause", () => mpv == null ? void 0 : mpv.pause());
|
||||||
ipcMain.handle("mpv-toggle-pause", () => mpv == null ? void 0 : mpv.togglePause());
|
ipcMain.handle("mpv-toggle-pause", () => mpv == null ? void 0 : mpv.togglePause());
|
||||||
ipcMain.handle("mpv-stop", () => mpv == null ? void 0 : mpv.stop());
|
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("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", () => {
|
app.on("window-all-closed", () => {
|
||||||
if (process.platform !== "darwin") {
|
if (process.platform !== "darwin") {
|
||||||
app.quit();
|
app.quit();
|
||||||
@@ -231,6 +291,29 @@ app.on("activate", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
app.whenReady().then(() => {
|
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);
|
Menu.setApplicationMenu(null);
|
||||||
createWindow();
|
createWindow();
|
||||||
mpv = new MpvController();
|
mpv = new MpvController();
|
||||||
|
|||||||
@@ -16,5 +16,9 @@ electron.contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
setVolume: (vol) => electron.ipcRenderer.invoke("mpv-set-volume", vol),
|
setVolume: (vol) => electron.ipcRenderer.invoke("mpv-set-volume", vol),
|
||||||
seek: (time) => electron.ipcRenderer.invoke("mpv-seek", time),
|
seek: (time) => electron.ipcRenderer.invoke("mpv-seek", time),
|
||||||
onEvent: (callback) => electron.ipcRenderer.on("mpv-event", callback)
|
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 { createRequire } from 'node:module'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
|
import fs from 'node:fs'
|
||||||
import { MpvController } from './mpvController'
|
import { MpvController } from './mpvController'
|
||||||
|
import { PluginSystem } from '../src/main/pluginSystem.ts'
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const require = createRequire(import.meta.url)
|
const require = createRequire(import.meta.url)
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
@@ -45,7 +46,7 @@ function createWindow() {
|
|||||||
} else {
|
} else {
|
||||||
win.loadFile(path.join(RENDERER_DIST, 'index.html'))
|
win.loadFile(path.join(RENDERER_DIST, 'index.html'))
|
||||||
}
|
}
|
||||||
|
win.webContents.openDevTools();
|
||||||
registerZoomShortcuts(win)
|
registerZoomShortcuts(win)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,9 +70,25 @@ ipcMain.handle('mpv-play', () => mpv?.play())
|
|||||||
ipcMain.handle('mpv-pause', () => mpv?.pause())
|
ipcMain.handle('mpv-pause', () => mpv?.pause())
|
||||||
ipcMain.handle('mpv-toggle-pause', () => mpv?.togglePause())
|
ipcMain.handle('mpv-toggle-pause', () => mpv?.togglePause())
|
||||||
ipcMain.handle('mpv-stop', () => mpv?.stop())
|
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))
|
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', () => {
|
app.on('window-all-closed', () => {
|
||||||
if (process.platform !== 'darwin') {
|
if (process.platform !== 'darwin') {
|
||||||
@@ -115,6 +132,34 @@ app.on('activate', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
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)
|
Menu.setApplicationMenu(null)
|
||||||
createWindow()
|
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),
|
setVolume: (vol: number) => ipcRenderer.invoke('mpv-set-volume', vol),
|
||||||
seek: (time: number) => ipcRenderer.invoke('mpv-seek', time),
|
seek: (time: number) => ipcRenderer.invoke('mpv-seek', time),
|
||||||
onEvent: (callback: (event: any, data: any) => void) => ipcRenderer.on('mpv-event', callback)
|
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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/renderer/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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 { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
@@ -50,13 +50,13 @@ const playerStore = usePlayerStore()
|
|||||||
playerStore.playMode = PlayMode.Single;
|
playerStore.playMode = PlayMode.Single;
|
||||||
|
|
||||||
const testSong: Song = {
|
const testSong: Song = {
|
||||||
id: '9999',
|
id: '3337983421',
|
||||||
name: 'Test FLAC',
|
name: '不死身ごっこ (feat. 初音ミク)',
|
||||||
artist: 'Netease',
|
artist: 'ピノキオピー、初音ミク',
|
||||||
picUrl: 'http://p1.music.126.net/btYBbFLd5mf9w0lDpfNs6w==/109951171506809884.jpg?param=130y130',
|
picUrl: 'http://p2.music.126.net/7O6FcCraxldhFGz4CSPVlw==/109951172567380787.jpg?imageView=&thumbnail=371y371&type=webp&rotate=360&tostatic=0',
|
||||||
url: 'http://m801.music.126.net/20260202213928/189743bba596f8fd999bc44fd51d11fd/jdymusic/obj/wo3DlMOGwrbDjj7DisKw/61393856655/24be/6a68/77e4/fb898a5378682427bf7a4fb55640e610.flac',
|
url: 'http://m701.music.126.net/20260203120731/1480f3bdda9795d5e7cc25af304b6cae/jdymusic/obj/wo3DlMOGwrbDjj7DisKw/77635439540/89c6/162f/e373/62ded4a27758346e53f717f46ba2802c.flac',
|
||||||
duration: '03:30',
|
duration: '02:31',
|
||||||
source: 'netease',
|
source: 'wy',
|
||||||
type: 'Remote'
|
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>;
|
setVolume: (vol: number) => Promise<void>;
|
||||||
seek: (time: number) => Promise<void>;
|
seek: (time: number) => Promise<void>;
|
||||||
onEvent: (callback: (event: any, data: any) => void) => void;
|
onEvent: (callback: (event: any, data: any) => void) => void;
|
||||||
}
|
};
|
||||||
|
plugin: {
|
||||||
|
call: (pluginId: string, method: string, args: any[]) => Promise<any>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
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