feat: 优化界面;IPC-mpv控制;预加载脚本

This commit is contained in:
lqtmcstudio
2026-01-21 15:39:22 +08:00
parent ebfe430746
commit 39d16a65e5
11 changed files with 933 additions and 325 deletions

View File

@@ -1,7 +1,13 @@
import { ipcMain, BrowserWindow, app, Menu } from "electron"; var __defProp = Object.defineProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
import { 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 from "node:path"; import path from "node:path";
import os from "node:os";
import net from "node:net";
import { spawn } from "node:child_process";
createRequire(import.meta.url); createRequire(import.meta.url);
const __dirname$1 = path.dirname(fileURLToPath(import.meta.url)); const __dirname$1 = path.dirname(fileURLToPath(import.meta.url));
process.env.APP_ROOT = path.join(__dirname$1, ".."); process.env.APP_ROOT = path.join(__dirname$1, "..");
@@ -10,24 +16,145 @@ const MAIN_DIST = path.join(process.env.APP_ROOT, "dist-electron");
const RENDERER_DIST = path.join(process.env.APP_ROOT, "dist"); const RENDERER_DIST = path.join(process.env.APP_ROOT, "dist");
process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path.join(process.env.APP_ROOT, "public") : RENDERER_DIST; process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path.join(process.env.APP_ROOT, "public") : RENDERER_DIST;
let win; let win;
class MpvController {
// 用于处理 JSON 数据粘包
constructor() {
__publicField(this, "process", null);
__publicField(this, "socket", null);
__publicField(this, "socketPath");
__publicField(this, "buffer", "");
const socketName = `mpv-socket-${Date.now()}`;
this.socketPath = process.platform === "win32" ? `\\\\.\\pipe\\${socketName}` : path.join(os.tmpdir(), socketName);
}
start(binaryPath) {
console.log(`[MPV] Starting from: ${binaryPath}`);
console.log(`[MPV] IPC Socket: ${this.socketPath}`);
this.process = spawn(binaryPath, [
"--idle",
// 空闲时不退出
"--no-video",
// 纯音频模式
"--keep-open=yes",
// 播放结束不退出
`--input-ipc-server=${this.socketPath}`
// 指定 IPC 监听地址
]);
this.process.on("error", (err) => {
console.error("[MPV] Process Error:", err);
});
this.process.on("exit", (code) => {
var _a;
console.log(`[MPV] Process exited with code ${code}`);
(_a = this.socket) == null ? void 0 : _a.destroy();
});
this.connectSocket();
}
connectSocket(retries = 10) {
setTimeout(() => {
const socket = net.createConnection(this.socketPath);
socket.on("connect", () => {
console.log("[MPV] IPC Connected!");
this.socket = socket;
this.setupObservers();
});
socket.on("data", (data) => this.handleData(data));
socket.on("error", (err) => {
if (retries > 0) {
this.connectSocket(retries - 1);
} else {
console.error("[MPV] Failed to connect to IPC socket:", err);
}
});
}, 500);
}
// 处理接收到的数据 (解决 TCP 数据包粘连或截断问题)
handleData(data) {
this.buffer += data.toString();
const lines = this.buffer.split("\n");
this.buffer = lines.pop() || "";
lines.forEach((line) => {
if (!line.trim()) return;
try {
const message = JSON.parse(line);
console.log(message);
this.handleMessage(message);
} catch (e) {
console.error("[MPV] JSON Parse Error:", e);
}
});
}
// 处理解析后的 JSON 消息
handleMessage(msg) {
if (!win) return;
if (msg.event === "property-change") {
switch (msg.name) {
case "time-pos":
win.webContents.send("mpv-time-update", msg.data);
break;
case "duration":
win.webContents.send("mpv-duration", msg.data);
break;
case "pause":
win.webContents.send("mpv-play-state", !msg.data);
break;
}
}
if (msg.event === "end-file") {
win.webContents.send("mpv-ended");
win.webContents.send("mpv-play-state", false);
}
}
// 初始化监听属性
setupObservers() {
this.send(["observe_property", 1, "time-pos"]);
this.send(["observe_property", 2, "duration"]);
this.send(["observe_property", 3, "pause"]);
}
// 发送命令给 MPV
send(command) {
if (!this.socket || this.socket.destroyed) return;
const payload = JSON.stringify({ command });
this.socket.write(payload + "\n");
}
// === 业务方法 ===
load(url, autoPlay) {
if (autoPlay) {
this.send(["set_property", "pause", false]);
this.send(["loadfile", url, "replace"]);
} else {
this.send(["set_property", "pause", true]);
this.send(["loadfile", url, "replace"]);
}
}
play() {
this.send(["set_property", "pause", false]);
}
pause() {
this.send(["set_property", "pause", true]);
}
seek(time) {
this.send(["seek", time, "absolute"]);
}
setVolume(volume) {
this.send(["set_property", "volume", volume]);
}
}
const mpv = new MpvController();
const mpvExecutablePath = app.isPackaged ? path.join(process.resourcesPath, "core", "mpv.exe") : path.join(process.env.APP_ROOT, "core", "mpv.exe");
function createWindow() { function createWindow() {
win = new BrowserWindow({ win = new BrowserWindow({
// 🔧 禁用原生标题栏(无边框)
frame: false, frame: false,
// 设置窗口最小大小
minWidth: 950, minWidth: 950,
minHeight: 700, minHeight: 700,
// 设置初始窗口大小
width: 1e3, width: 1e3,
height: 800, height: 800,
icon: path.join(process.env.VITE_PUBLIC, "electron-vite.svg"), icon: path.join(process.env.VITE_PUBLIC, "electron-vite.svg"),
webPreferences: { webPreferences: {
preload: path.join(__dirname$1, "preload.mjs") preload: path.join(__dirname$1, "preload.mjs"),
nodeIntegration: false,
contextIsolation: true
} }
}); });
if (process.env.NODE_ENV === "development") {
win.webContents.openDevTools({ mode: "right" });
}
win.webContents.on("did-finish-load", () => { win.webContents.on("did-finish-load", () => {
win == null ? void 0 : win.webContents.send("main-process-message", (/* @__PURE__ */ new Date()).toLocaleString()); win == null ? void 0 : win.webContents.send("main-process-message", (/* @__PURE__ */ new Date()).toLocaleString());
}); });
@@ -38,22 +165,17 @@ function createWindow() {
} }
} }
ipcMain.on("window-minimize", (event) => { ipcMain.on("window-minimize", (event) => {
const win2 = BrowserWindow.fromWebContents(event.sender); var _a;
win2 == null ? void 0 : win2.minimize(); return (_a = BrowserWindow.fromWebContents(event.sender)) == null ? void 0 : _a.minimize();
});
ipcMain.on("window-maximize", () => {
if (win == null ? void 0 : win.isMaximized()) {
win.unmaximize();
} else {
win == null ? void 0 : win.maximize();
}
});
ipcMain.on("window-close", () => {
win == null ? void 0 : win.close();
});
ipcMain.handle("window-is-maximized", () => {
return (win == null ? void 0 : win.isMaximized()) || false;
}); });
ipcMain.on("window-maximize", () => (win == null ? void 0 : win.isMaximized()) ? win.unmaximize() : win == null ? void 0 : win.maximize());
ipcMain.on("window-close", () => win == null ? void 0 : win.close());
ipcMain.handle("window-is-maximized", () => (win == null ? void 0 : win.isMaximized()) || false);
ipcMain.on("mpv-load", (_, url, autoPlay = true) => mpv.load(url, autoPlay));
ipcMain.on("mpv-play", () => mpv.play());
ipcMain.on("mpv-pause", () => mpv.pause());
ipcMain.on("mpv-seek", (_, time) => mpv.seek(time));
ipcMain.on("mpv-volume", (_, volume) => mpv.setVolume(volume));
app.on("window-all-closed", () => { app.on("window-all-closed", () => {
if (process.platform !== "darwin") { if (process.platform !== "darwin") {
app.quit(); app.quit();
@@ -67,6 +189,7 @@ app.on("activate", () => {
}); });
app.whenReady().then(() => { app.whenReady().then(() => {
Menu.setApplicationMenu(null); Menu.setApplicationMenu(null);
mpv.start(mpvExecutablePath);
createWindow(); createWindow();
}); });
export { export {

View File

@@ -1,8 +1,20 @@
"use strict"; "use strict";
const electron = require("electron"); const electron = require("electron");
electron.contextBridge.exposeInMainWorld("electronAPI", { electron.contextBridge.exposeInMainWorld("electronAPI", {
// 窗口控制
minimizeWindow: () => electron.ipcRenderer.send("window-minimize"), minimizeWindow: () => electron.ipcRenderer.send("window-minimize"),
maximizeWindow: () => electron.ipcRenderer.send("window-maximize"), maximizeWindow: () => electron.ipcRenderer.send("window-maximize"),
closeWindow: () => electron.ipcRenderer.send("window-close"), closeWindow: () => electron.ipcRenderer.send("window-close"),
isMaximized: () => electron.ipcRenderer.invoke("window-is-maximized") isMaximized: () => electron.ipcRenderer.invoke("window-is-maximized"),
// MPV 控制 (Renderer -> Main)
mpvLoad: (url, autoPlay = true) => electron.ipcRenderer.send("mpv-load", url, autoPlay),
mpvPlay: () => electron.ipcRenderer.send("mpv-play"),
mpvPause: () => electron.ipcRenderer.send("mpv-pause"),
mpvSeek: (time) => electron.ipcRenderer.send("mpv-seek", time),
mpvSetVolume: (volume) => electron.ipcRenderer.send("mpv-volume", volume),
// MPV 事件 (Main -> Renderer)
onMpvTimeUpdate: (callback) => electron.ipcRenderer.on("mpv-time-update", (_, time) => callback(time)),
onMpvDuration: (callback) => electron.ipcRenderer.on("mpv-duration", (_, duration) => callback(duration)),
onMpvPlayState: (callback) => electron.ipcRenderer.on("mpv-play-state", (_, isPlaying) => callback(isPlaying)),
onMpvEnded: (callback) => electron.ipcRenderer.on("mpv-ended", () => callback())
}); });

View File

@@ -2,22 +2,15 @@ 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 os from 'node:os'
import net from 'node:net'
import { spawn, type ChildProcess } from 'node:child_process'
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))
// The built directory structure
//
// ├─┬─┬ dist
// │ │ └── index.html
// │ │
// │ ├─┬ dist-electron
// │ │ ├── main.js
// │ │ └── preload.mjs
// │
process.env.APP_ROOT = path.join(__dirname, '..') process.env.APP_ROOT = path.join(__dirname, '..')
// 🚧 Use ['ENV_NAME'] avoid vite:define plugin - Vite@2.x
export const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL'] export const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL']
export const MAIN_DIST = path.join(process.env.APP_ROOT, 'dist-electron') export const MAIN_DIST = path.join(process.env.APP_ROOT, 'dist-electron')
export const RENDERER_DIST = path.join(process.env.APP_ROOT, 'dist') export const RENDERER_DIST = path.join(process.env.APP_ROOT, 'dist')
@@ -26,76 +19,219 @@ process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path.join(process.env.APP_ROOT,
let win: BrowserWindow | null let win: BrowserWindow | null
function createWindow() {
win = new BrowserWindow({
// 🔧 禁用原生标题栏(无边框)
frame: false,
// 设置窗口最小大小
minWidth: 950,
minHeight: 700,
// 设置初始窗口大小
width: 1000,
height: 800,
icon: path.join(process.env.VITE_PUBLIC, 'electron-vite.svg'), class MpvController {
webPreferences: { private process: ChildProcess | null = null;
preload: path.join(__dirname, 'preload.mjs'), private socket: net.Socket | null = null;
}, private socketPath: string;
private buffer: string = '';
})
if (process.env.NODE_ENV === 'development') {
win.webContents.openDevTools({ mode: 'right' }); // 可选:'undocked', 'bottom', 'right'
}
// Test active push message to Renderer-process.
win.webContents.on('did-finish-load', () => {
win?.webContents.send('main-process-message', new Date().toLocaleString())
})
if (VITE_DEV_SERVER_URL) { constructor() {
win.loadURL(VITE_DEV_SERVER_URL) const socketName = `mpv-socket-${Date.now()}`;
} else { this.socketPath = process.platform === 'win32'
win.loadFile(path.join(RENDERER_DIST, 'index.html')) ? `\\\\.\\pipe\\${socketName}`
} : path.join(os.tmpdir(), socketName);
}
start(binaryPath: string) {
console.log(`[MPV] Starting from: ${binaryPath}`);
console.log(`[MPV] IPC Socket: ${this.socketPath}`);
// 启动 MPV 进程
this.process = spawn(binaryPath, [
'--idle', // 空闲时不退出
'--no-video', // 纯音频模式
'--keep-open=yes', // 播放结束不退出
`--input-ipc-server=${this.socketPath}`
]);
this.process.on('error', (err) => {
console.error('[MPV] Process Error:', err);
});
this.process.on('exit', (code) => {
console.log(`[MPV] Process exited with code ${code}`);
this.socket?.destroy();
});
this.connectSocket();
}
private connectSocket(retries = 10) {
setTimeout(() => {
const socket = net.createConnection(this.socketPath);
socket.on('connect', () => {
console.log('[MPV] IPC Connected!');
this.socket = socket;
this.setupObservers();
});
socket.on('data', (data) => this.handleData(data));
socket.on('error', (err) => {
if (retries > 0) {
// MPV 还没准备好,继续重试
this.connectSocket(retries - 1);
} else {
console.error('[MPV] Failed to connect to IPC socket:', err);
}
});
}, 500); // 500ms 重试间隔
}
// 处理接收到的数据
private handleData(data: Buffer) {
this.buffer += data.toString();
// MPV 的消息以 \n 分隔
const lines = this.buffer.split('\n');
// 最后一个元素可能是不完整的行,留到下一次处理
this.buffer = lines.pop() || '';
lines.forEach(line => {
if (!line.trim()) return;
try {
const message = JSON.parse(line);
console.log(message);
this.handleMessage(message);
} catch (e) {
console.error('[MPV] JSON Parse Error:', e);
}
});
}
// 处理解析后的 JSON 消息
private handleMessage(msg: any) {
if (!win) return;
// 处理属性变更事件
if (msg.event === 'property-change') {
switch (msg.name) {
case 'time-pos':
win.webContents.send('mpv-time-update', msg.data);
break;
case 'duration':
win.webContents.send('mpv-duration', msg.data);
break;
case 'pause':
win.webContents.send('mpv-play-state', !msg.data); // data=true 意味着暂停
break;
}
}
// 处理播放结束事件
if (msg.event === 'end-file') {
win.webContents.send('mpv-ended');
win.webContents.send('mpv-play-state', false);
}
}
// 初始化监听属性
private setupObservers() {
this.send(['observe_property', 1, 'time-pos']);
this.send(['observe_property', 2, 'duration']);
this.send(['observe_property', 3, 'pause']);
}
// 发送命令给 MPV
send(command: any[]) {
if (!this.socket || this.socket.destroyed) return;
const payload = JSON.stringify({ command });
this.socket.write(payload + '\n');
}
// === 业务方法 ===
load(url: string, autoPlay: boolean) {
if (autoPlay) {
// 确保取消暂停 (防止之前是暂停状态)
this.send(['set_property', 'pause', false]);
// 加载文件
this.send(['loadfile', url, 'replace']);
} else {
// 先设置为暂停 (这样后续加载的文件会继承这个暂停状态)
this.send(['set_property', 'pause', true]);
// 加载文件
this.send(['loadfile', url, 'replace']);
}
}
play() { this.send(['set_property', 'pause', false]); }
pause() { this.send(['set_property', 'pause', true]); }
seek(time: number) { this.send(['seek', time, 'absolute']); }
setVolume(volume: number) { this.send(['set_property', 'volume', volume]); }
} }
ipcMain.on('window-minimize', (event) => { const mpv = new MpvController();
const win = BrowserWindow.fromWebContents(event.sender)
win?.minimize()
})
ipcMain.on('window-maximize', () => { // === Electron 窗口逻辑 ===
if (win?.isMaximized()) {
win.unmaximize() const mpvExecutablePath = app.isPackaged
? path.join(process.resourcesPath, 'core', 'mpv.exe')
: path.join(process.env.APP_ROOT, 'core', 'mpv.exe');
function createWindow() {
win = new BrowserWindow({
frame: false,
minWidth: 950,
minHeight: 700,
width: 1000,
height: 800,
icon: path.join(process.env.VITE_PUBLIC, 'electron-vite.svg'),
webPreferences: {
preload: path.join(__dirname, 'preload.mjs'),
nodeIntegration: false,
contextIsolation: true,
},
})
win.webContents.on('did-finish-load', () => {
win?.webContents.send('main-process-message', new Date().toLocaleString())
})
if (VITE_DEV_SERVER_URL) {
win.loadURL(VITE_DEV_SERVER_URL)
} else { } else {
win?.maximize() win.loadFile(path.join(RENDERER_DIST, 'index.html'))
}
}
// === IPC 监听 ===
ipcMain.on('window-minimize', (event) => BrowserWindow.fromWebContents(event.sender)?.minimize())
ipcMain.on('window-maximize', () => win?.isMaximized() ? win.unmaximize() : win?.maximize())
ipcMain.on('window-close', () => win?.close())
ipcMain.handle('window-is-maximized', () => win?.isMaximized() || false)
// MPV 控制指令
ipcMain.on('mpv-load', (_, url, autoPlay = true) => mpv.load(url, autoPlay))
ipcMain.on('mpv-play', () => mpv.play())
ipcMain.on('mpv-pause', () => mpv.pause())
ipcMain.on('mpv-seek', (_, time) => mpv.seek(time))
ipcMain.on('mpv-volume', (_, volume) => mpv.setVolume(volume))
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
win = null
} }
}) })
ipcMain.on('window-close', () => {
win?.close()
})
ipcMain.handle('window-is-maximized', () => {
return win?.isMaximized() || false
})
// Quit when all windows are closed, except on macOS.
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
win = null
}
})
app.on('activate', () => { app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) { if (BrowserWindow.getAllWindows().length === 0) {
createWindow() createWindow()
} }
}) })
app.whenReady().then(() => { app.whenReady().then(() => {
Menu.setApplicationMenu(null) Menu.setApplicationMenu(null)
// 启动 MPV
createWindow() mpv.start(mpvExecutablePath);
createWindow()
}) })

View File

@@ -1,8 +1,29 @@
import { contextBridge, ipcRenderer } from 'electron' import { contextBridge, ipcRenderer } from 'electron'
contextBridge.exposeInMainWorld('electronAPI', { contextBridge.exposeInMainWorld('electronAPI', {
// 窗口控制
minimizeWindow: () => ipcRenderer.send('window-minimize'), minimizeWindow: () => ipcRenderer.send('window-minimize'),
maximizeWindow: () => ipcRenderer.send('window-maximize'), maximizeWindow: () => ipcRenderer.send('window-maximize'),
closeWindow: () => ipcRenderer.send('window-close'), closeWindow: () => ipcRenderer.send('window-close'),
isMaximized: () => ipcRenderer.invoke('window-is-maximized') isMaximized: () => ipcRenderer.invoke('window-is-maximized'),
// MPV 控制 (Renderer -> Main)
mpvLoad: (url: string,autoPlay: boolean = true) => ipcRenderer.send('mpv-load', url, autoPlay),
mpvPlay: () => ipcRenderer.send('mpv-play'),
mpvPause: () => ipcRenderer.send('mpv-pause'),
mpvSeek: (time: number) => ipcRenderer.send('mpv-seek', time),
mpvSetVolume: (volume: number) => ipcRenderer.send('mpv-volume', volume),
// MPV 事件 (Main -> Renderer)
onMpvTimeUpdate: (callback: (time: number) => void) =>
ipcRenderer.on('mpv-time-update', (_, time) => callback(time)),
onMpvDuration: (callback: (duration: number) => void) =>
ipcRenderer.on('mpv-duration', (_, duration) => callback(duration)),
onMpvPlayState: (callback: (isPlaying: boolean) => void) =>
ipcRenderer.on('mpv-play-state', (_, isPlaying) => callback(isPlaying)),
onMpvEnded: (callback: () => void) =>
ipcRenderer.on('mpv-ended', () => callback()),
}) })

57
package-lock.json generated
View File

@@ -21,6 +21,7 @@
"element-plus": "^2.13.0", "element-plus": "^2.13.0",
"jss": "^10.10.0", "jss": "^10.10.0",
"jss-preset-default": "^10.10.0", "jss-preset-default": "^10.10.0",
"node-mpv": "^1.5.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"tdesign-vue-next": "^1.17.7", "tdesign-vue-next": "^1.17.7",
"vue": "^3.4.21", "vue": "^3.4.21",
@@ -2669,6 +2670,12 @@
"dev": true, "dev": true,
"license": "Python-2.0" "license": "Python-2.0"
}, },
"node_modules/asap": {
"version": "2.0.6",
"resolved": "https://registry.npmmirror.com/asap/-/asap-2.0.6.tgz",
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
"license": "MIT"
},
"node_modules/assert-plus": { "node_modules/assert-plus": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmmirror.com/assert-plus/-/assert-plus-1.0.0.tgz", "resolved": "https://registry.npmmirror.com/assert-plus/-/assert-plus-1.0.0.tgz",
@@ -2837,6 +2844,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/browser-fingerprint": {
"version": "0.0.1",
"resolved": "https://registry.npmmirror.com/browser-fingerprint/-/browser-fingerprint-0.0.1.tgz",
"integrity": "sha512-b8SXP7yOlzLUJXF8WUvIjmbJzkJC0X6OHe7J9a/SHqEBC7a9Eglag6AANSTJz82h5U582kuxm/5TPudnD68EPA==",
"license": "MIT"
},
"node_modules/buffer": { "node_modules/buffer": {
"version": "5.7.1", "version": "5.7.1",
"resolved": "https://registry.npmmirror.com/buffer/-/buffer-5.7.1.tgz", "resolved": "https://registry.npmmirror.com/buffer/-/buffer-5.7.1.tgz",
@@ -3304,6 +3317,13 @@
"url": "https://github.com/sponsors/mesqueeb" "url": "https://github.com/sponsors/mesqueeb"
} }
}, },
"node_modules/core-js": {
"version": "1.2.7",
"resolved": "https://registry.npmmirror.com/core-js/-/core-js-1.2.7.tgz",
"integrity": "sha512-ZiPp9pZlgxpWRu0M+YWbm6+aQ84XEfH1JRXvfOc/fILWI0VKhLC2LX13X1NYq4fULzLMq7Hfh43CSo2/aIaUPA==",
"deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.",
"license": "MIT"
},
"node_modules/core-util-is": { "node_modules/core-util-is": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.2.tgz", "resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.2.tgz",
@@ -3382,6 +3402,18 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/cuid": {
"version": "1.3.8",
"resolved": "https://registry.npmmirror.com/cuid/-/cuid-1.3.8.tgz",
"integrity": "sha512-MoL67ZZuBetDMxzrZtO+Iq1ATajFACQCP52QRinBgd3yTjYdv54mJO8ibUrh06fojKCoX5P2i7KkEatm4VTIOQ==",
"deprecated": "Cuid and other k-sortable and non-cryptographic ids (Ulid, ObjectId, KSUID, all UUIDs) are all insecure. Use @paralleldrive/cuid2 instead.",
"license": "MIT",
"dependencies": {
"browser-fingerprint": "0.0.1",
"core-js": "^1.1.1",
"node-fingerprint": "0.0.2"
}
},
"node_modules/dayjs": { "node_modules/dayjs": {
"version": "1.11.19", "version": "1.11.19",
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.19.tgz", "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.19.tgz",
@@ -5471,6 +5503,22 @@
"license": "MIT", "license": "MIT",
"optional": true "optional": true
}, },
"node_modules/node-fingerprint": {
"version": "0.0.2",
"resolved": "https://registry.npmmirror.com/node-fingerprint/-/node-fingerprint-0.0.2.tgz",
"integrity": "sha512-vPFfTD5EBJieQ4SI3v61fWxlV1kav3m9Dbejd6CjWhOJn8s+XMxpOOosCNAyIrUQ/jJOlPndfrZ0lSw4+RgwcA==",
"license": "MIT"
},
"node_modules/node-mpv": {
"version": "1.5.0",
"resolved": "https://registry.npmmirror.com/node-mpv/-/node-mpv-1.5.0.tgz",
"integrity": "sha512-kvLo+PcWHZ/Sg7t9XeFDi5KJrNOL9XJOEljCEh5wBNOHiE6Wa/txwIsYWKmNaIFuncbEhgjnoOavE4T5YBNV8Q==",
"dependencies": {
"cuid": "^1.3.8",
"lodash": ">= 4.0.0",
"promise": "^7.1.1"
}
},
"node_modules/normalize-path": { "node_modules/normalize-path": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz", "resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -5717,6 +5765,15 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/promise": {
"version": "7.3.1",
"resolved": "https://registry.npmmirror.com/promise/-/promise-7.3.1.tgz",
"integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==",
"license": "MIT",
"dependencies": {
"asap": "~2.0.3"
}
},
"node_modules/promise-retry": { "node_modules/promise-retry": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmmirror.com/promise-retry/-/promise-retry-2.0.1.tgz", "resolved": "https://registry.npmmirror.com/promise-retry/-/promise-retry-2.0.1.tgz",

View File

@@ -22,6 +22,7 @@
"element-plus": "^2.13.0", "element-plus": "^2.13.0",
"jss": "^10.10.0", "jss": "^10.10.0",
"jss-preset-default": "^10.10.0", "jss-preset-default": "^10.10.0",
"node-mpv": "^1.5.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"tdesign-vue-next": "^1.17.7", "tdesign-vue-next": "^1.17.7",
"vue": "^3.4.21", "vue": "^3.4.21",

View File

@@ -56,6 +56,9 @@ const store = usePlayerStore();
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
// Register IPC
store.init()
// 跟踪路由历史,判断是否可以返回和前进 // 跟踪路由历史,判断是否可以返回和前进
const canGoBack = ref(true); // 返回按钮始终可用,让浏览器处理 const canGoBack = ref(true); // 返回按钮始终可用,让浏览器处理
const canGoForward = ref(false); const canGoForward = ref(false);

View File

@@ -1,21 +1,49 @@
<template> <template>
<div class="player-bar" :style="gradientStyle"> <div class="player-bar" :style="dynamicBackground">
<div class="glass-overlay"></div> <div class="noise-overlay"></div>
<div class="glass-surface"></div>
<!-- 新增顶部进度条使用专辑图提取的最深色 --> <div
<div class="top-progress"> class="progress-container"
<div class="progress-fill" :style="{ width: store.progressPercentage + '%', backgroundColor: store.progressColor }"></div> @mousemove="handleHoverProgress"
@mouseleave="isHoveringProgress = false"
@click="handleSeek"
>
<div class="progress-track">
<div
class="progress-fill"
:style="{
width: store.progressPercentage + '%',
backgroundColor: store.themeColors.secondary
}"
>
<div class="progress-glow"></div>
</div>
</div>
</div> </div>
<div class="bar-content"> <div class="bar-content">
<div class="side-container left"> <div class="side-container left">
<div class="track-info"> <div class="track-info">
<div class="album-cover"> <div class="album-cover">
<img :src="store.currentSong.cover" @load="store.extractColors" alt="Album Pic"/> <img
:src="store.currentSong.cover"
crossorigin="anonymous"
@load="handleImageLoad"
alt="Cover"
/>
</div> </div>
<div class="meta"> <div class="meta">
<div class="song-title">{{ store.currentSong.title }}</div> <transition name="fade-slide" mode="out-in">
<div class="artist-name">{{ store.currentSong.artist }}</div> <div :key="store.currentSong.title" class="song-title">
{{ store.currentSong.title }}
</div>
</transition>
<transition name="fade-slide" mode="out-in">
<div :key="store.currentSong.artist" class="artist-name">
{{ store.currentSong.artist }}
</div>
</transition>
</div> </div>
</div> </div>
</div> </div>
@@ -23,254 +51,438 @@
<div class="center-container"> <div class="center-container">
<div class="controls-wrapper"> <div class="controls-wrapper">
<button class="ctrl-btn sm"> <button class="ctrl-btn sm">
<Icon icon="lucide:skip-back" width="22" /> <Icon icon="lucide:skip-back" width="24" />
</button> </button>
<button class="ctrl-btn lg play-btn" @click="store.togglePlay">
<Icon :icon="store.isPlaying ? 'lucide:pause' : 'lucide:play'" width="28" fill="currentColor" /> <button
class="ctrl-btn play-btn"
:class="{ playing: store.isPlaying }"
@click="store.togglePlay"
>
<div class="play-btn-bg"></div>
<Icon
:icon="store.isPlaying ? 'lucide:pause' : 'lucide:play'"
width="32"
fill="currentColor"
class="play-icon"
/>
</button> </button>
<button class="ctrl-btn sm"> <button class="ctrl-btn sm">
<Icon icon="lucide:skip-forward" width="22" /> <Icon icon="lucide:skip-forward" width="24" />
</button> </button>
</div> </div>
</div> </div>
<div class="side-container right"> <div class="side-container right">
<div class="extra-controls"> <div class="extra-controls">
<!-- 美化后的时间显示放在右侧最左侧 --> <span class="time-display">
<div class="time-display"> {{ formatTime(store.currentTime) }} / {{ formatTime(store.currentSong.duration) }}
{{ formatTime(store.currentTime) }}&nbsp;/&nbsp;{{ formatTime(store.currentSong.duration) }} </span>
<div class="volume-box">
<Icon v-if="store.volume === 0" icon="lucide:volume-x" width="18" />
<Icon v-else-if="store.volume < 50" icon="lucide:volume-1" width="18" />
<Icon v-else icon="lucide:volume-2" width="18" />
<el-slider
v-model="store.volume"
size="small"
class="apple-slider"
:show-tooltip="false"
/>
</div> </div>
<Icon icon="lucide:repeat" width="20" class="icon-btn" /> <button
<div class="volume-box"> class="icon-btn"
<Icon icon="lucide:volume-2" width="20" /> :class="{ active: store.showPlaylist }"
<el-slider v-model="store.volume" size="small" class="custom-slider" /> @click="store.showPlaylist = !store.showPlaylist"
</div> >
<Icon icon="lucide:list-music" width="20" <Icon icon="lucide:list-music" width="20" />
class="icon-btn" </button>
:class="{ active: store.showPlaylist }"
@click="store.showPlaylist = !store.showPlaylist" />
</div> </div>
</div> </div>
</div> </div>
<!-- 保留底部细进度条鼠标悬停可变粗 -->
<div class="bottom-progress">
<div class="progress-fill" :style="{ width: store.progressPercentage + '%' }"></div>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed, ref } from 'vue';
import { usePlayerStore } from '../../stores/playerStore'; import { usePlayerStore } from '../../stores/playerStore';
import { Icon } from '@iconify/vue'; import { Icon } from '@iconify/vue';
const store = usePlayerStore(); const store = usePlayerStore();
const isHoveringProgress = ref(false);
const gradientStyle = computed(() => { // 动态背景样式
const dynamicBackground = computed(() => {
const { primary, secondary } = store.themeColors;
// 使用 radial-gradient 模拟 Apple Music 的光斑效果
return { return {
background: `linear-gradient(135deg, ${store.themeColors.primary}, ${store.themeColors.secondary})` background: `
radial-gradient(circle at 0% 0%, ${primary} 0%, transparent 60%),
radial-gradient(circle at 100% 100%, ${secondary} 0%, transparent 60%),
linear-gradient(135deg, rgba(20,20,20,0.8) 0%, rgba(30,30,30,0.9) 100%)
`,
backgroundColor: '#1a1a1a' // 兜底色
}; };
}); });
// 处理图片加载并取色
function handleImageLoad(e: Event) {
const img = e.target as HTMLImageElement;
// 调用优化后的取色函数 (建议将此函数移至 Store 或 Utils这里演示直接调用)
extractColorsBetter(img);
}
// 占位函数:进度条交互
function handleHoverProgress(e: MouseEvent) { isHoveringProgress.value = true; }
function handleSeek(e: MouseEvent) { /* 调用 store.seek */ }
function formatTime(val: number) { function formatTime(val: number) {
if (!val || isNaN(val)) return '0:00'; if (!val || isNaN(val)) return '0:00';
const m = Math.floor(val / 60); const m = Math.floor(val / 60);
const s = Math.floor(val % 60); const s = Math.floor(val % 60);
return `${m}:${s < 10 ? '0' + s : s}`; return `${m}:${s < 10 ? '0' + s : s}`;
} }
/**
* 优化版取色逻辑 (可放入 Store)
* 逻辑:转 HSL优先取高饱和度(S)和适中亮度(L)的颜色
*/
function extractColorsBetter(img: HTMLImageElement) {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return;
const scale = 0.2; // 降低分辨率以提高性能
canvas.width = Math.floor(img.width * scale);
canvas.height = Math.floor(img.height * scale);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
// 简单的 HSL 转换工具
const rgbToHsl = (r: number, g: number, b: number) => {
r /= 255; g /= 255; b /= 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
let h = 0, s, l = (max + min) / 2;
if (max === min) { h = s = 0; } else {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
case g: h = (b - r) / d + 2; break;
case b: h = (r - g) / d + 4; break;
}
h /= 6;
}
return [h, s, l];
};
let bestPrimary = { r: 0, g: 0, b: 0, score: -Infinity };
let bestSecondary = { r: 0, g: 0, b: 0, score: -Infinity };
// 步长设为 16 加速遍历
for (let i = 0; i < imageData.length; i += 16) {
const r = imageData[i], g = imageData[i + 1], b = imageData[i + 2];
const [h, s, l] = rgbToHsl(r, g, b);
// 过滤太黑、太白、太灰的颜色
if (l < 0.1 || l > 0.9 || s < 0.2) continue;
// 评分算法:饱和度越高分越高,亮度适中分越高
// Apple Music 偏好鲜艳的颜色
const score = s * 10 - Math.abs(l - 0.5) * 5;
if (score > bestPrimary.score) {
// 当前第一名降级为第二名
bestSecondary = { ...bestPrimary };
bestPrimary = { r, g, b, score };
} else if (score > bestSecondary.score) {
bestSecondary = { r, g, b, score };
}
}
// 如果找不到颜色(比如全黑白封面),给默认值
if (bestPrimary.score === -Infinity) {
store.themeColors = { primary: '#555', secondary: '#333' };
return;
}
store.themeColors = {
primary: `rgb(${bestPrimary.r}, ${bestPrimary.g}, ${bestPrimary.b})`,
// 如果第二颜色太弱,稍微调亮主色作为副色
secondary: bestSecondary.score > -Infinity
? `rgb(${bestSecondary.r}, ${bestSecondary.g}, ${bestSecondary.b})`
: `rgba(${bestPrimary.r}, ${bestPrimary.g}, ${bestPrimary.b}, 0.5)`
};
// 进度条颜色取主色的反白或高亮
store.progressColor = `rgba(255,255,255,0.9)`;
}
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.player-bar { // 引入字体 (推荐在全局 CSS 引入 SF Pro 或 Inter)
height: 80px; // 稍微增高以容纳布局 $font-stack: "SF Pro Display", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
width: 100%;
position: relative;
overflow: hidden;
transition: background 0.5s ease; // 颜色切换动画
.glass-overlay { .player-bar {
position: relative;
height: 84px; // 稍微加高,显得大气
width: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
transition: background 1s ease; // 颜色切换要非常平滑
font-family: $font-stack;
user-select: none;
// 玻璃表面层
.glass-surface {
position: absolute; position: absolute;
inset: 0; inset: 0;
background: rgba(255, 255, 255, 0.15); // 稍微提亮 // 关键iOS 风格的毛玻璃 + 饱和度提升,让背景色透出来更鲜艳
backdrop-filter: blur(20px); // 毛玻璃 backdrop-filter: blur(50px) saturate(180%);
-webkit-backdrop-filter: blur(50px) saturate(180%);
background: rgba(30, 30, 30, 0.45);
border-top: 1px solid rgba(255, 255, 255, 0.12);
z-index: 1; z-index: 1;
border-top: 1px solid rgba(255,255,255,0.1); }
// 噪点层
.noise-overlay {
position: absolute;
inset: 0;
opacity: 0.04;
z-index: 2;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
pointer-events: none;
} }
.bar-content { .bar-content {
position: relative; position: relative;
z-index: 2; z-index: 10;
height: 100%; flex: 1;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 0 3%; // 使用百分比 padding 适应缩放 padding: 0 32px;
color: white; // 假设提取的颜色较深,文字用白色,反之需计算反色 color: rgba(255, 255, 255, 0.92);
} }
/* 左右容器,确保中间绝对居中 */ /* --- 顶部进度条 (Apple 风格:极细 -> 悬浮变粗) --- */
.progress-container {
position: absolute;
top: -1px; // 贴顶
left: 0;
width: 100%;
height: 4px; // 热区高度
z-index: 20;
cursor: pointer;
transition: height 0.2s ease;
.progress-track {
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.1);
}
.progress-fill {
position: relative;
height: 100%;
// 默认颜色,会被 inline-style 覆盖
background: white;
transition: width 0.1s linear;
// 进度条末端的辉光
.progress-glow {
position: absolute;
right: -4px;
top: 50%;
transform: translateY(-50%);
width: 8px;
height: 8px;
background: inherit;
border-radius: 50%;
box-shadow: 0 0 10px rgba(255, 255, 255, 0.8);
opacity: 0; // 默认隐藏
transition: opacity 0.2s;
}
}
&:hover {
height: 6px; // 悬浮变粗
.progress-fill .progress-glow { opacity: 1; }
}
}
/* --- 左侧:信息 --- */
.side-container { .side-container {
flex: 1; flex: 1;
display: flex; display: flex;
align-items: center; align-items: center;
min-width: 0; // 防止 flex item 溢出 min-width: 0;
&.right { justify-content: flex-end; }
&.right {
justify-content: flex-end;
}
} }
/* 左侧信息 */
.track-info { .track-info {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 16px;
.album-cover { .album-cover {
width: 56px; width: 52px;
height: 56px; height: 52px;
border-radius: 8px; border-radius: 6px; // Apple Music 圆角较小
overflow: hidden; overflow: hidden;
box-shadow: 0 4px 12px rgba(0,0,0,0.2); box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3); // 更深邃的阴影
flex-shrink: 0; background: #333;
img { width: 100%; height: 100%; object-fit: cover; } img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.5s; }
&:hover img { transform: scale(1.05); } // 细微交互
} }
.meta { .meta {
overflow: hidden; display: flex;
white-space: nowrap; flex-direction: column;
.song-title { font-size: 1.1rem; font-weight: 600; text-overflow: ellipsis; overflow: hidden; } justify-content: center;
.artist-name { font-size: 0.9rem; opacity: 0.8; margin-top: 4px; } gap: 2px;
.song-title {
font-size: 15px;
font-weight: 600;
letter-spacing: -0.2px;
// 长文字渐变消失遮罩
mask-image: linear-gradient(90deg, #000 85%, transparent 100%);
}
.artist-name {
font-size: 13px;
color: rgba(255, 255, 255, 0.6);
font-weight: 400;
}
} }
} }
/* 绝对居中容器 */ /* --- 中间:控制 --- */
.center-container { .center-container {
position: absolute; position: absolute;
left: 50%; left: 50%;
top: 50%; top: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
width: 200px; // 限制宽度
.controls-wrapper { .controls-wrapper {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 24px; gap: 32px; // 更宽的间距
} }
} }
/* 按钮样式 */
.ctrl-btn { .ctrl-btn {
background: transparent; background: none;
border: none; border: none;
color: rgba(255,255,255,0.9);
cursor: pointer; cursor: pointer;
padding: 0; color: rgba(255, 255, 255, 0.7);
transition: transform 0.2s, color 0.2s; transition: all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94);
display: flex;
align-items: center;
justify-content: center;
&:hover { color: #fff; transform: scale(1.1); } &:hover { color: white; transform: scale(1.05); }
&:active { transform: scale(0.95); } &:active { transform: scale(0.95); }
&.play-btn { &.play-btn {
position: relative;
width: 48px; width: 48px;
height: 48px; height: 48px;
background: rgba(255,255,255,0.2); color: white; // 播放图标始终高亮
border-radius: 50%;
display: flex; // 播放按钮背景 (毛玻璃圆)
align-items: center; .play-btn-bg {
justify-content: center; position: absolute;
&:hover { background: rgba(255,255,255,0.3); } inset: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10px);
transition: background 0.2s;
z-index: -1;
}
&:hover .play-btn-bg { background: rgba(255, 255, 255, 0.25); }
&.playing .play-icon { transform: scale(0.9); } // 细微视觉调整
} }
} }
/* 右侧图标 */ /* --- 右侧:功能 --- */
.extra-controls { .extra-controls {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 20px; gap: 24px;
.time-display { .time-display {
margin-right: auto; font-variant-numeric: tabular-nums; // 数字等宽,防止跳动
padding-right: 20px; // 与图标保持距离 font-size: 12px;
} color: rgba(255, 255, 255, 0.5);
letter-spacing: 0.5px;
.icon-btn {
cursor: pointer;
opacity: 0.8;
transition: opacity 0.2s;
&:hover { opacity: 1; }
&.active { color: #ffeb3b; opacity: 1; }
} }
.volume-box { .volume-box {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 10px;
width: 100px; width: 120px;
} color: rgba(255, 255, 255, 0.7);
}
/* 底部微型进度条 */
.bottom-progress {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 3px;
background: rgba(255,255,255,0.1);
z-index: 3;
cursor: pointer;
.progress-fill {
height: 100%;
background: rgba(255,255,255,0.6);
transition: width 0.1s linear;
} }
&:hover { .icon-btn {
height: 6px; background: none;
.progress-fill { background: #fff; } border: none;
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
transition: color 0.2s;
&.active, &:hover { color: white; }
} }
} }
/* 顶部进度条 */
.top-progress {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 3px;
background: rgba(0, 0, 0, 0.2);
z-index: 4;
.progress-fill {
height: 100%;
transition: width 0.2s ease;
background-color: white; // fallback
}
}
.time-display {
font-family: 'SF Mono', 'Roboto Mono', Menlo, monospace;
font-size: 12.5px;
font-weight: 500;
opacity: 0.9;
letter-spacing: 0.5px;
min-width: 100px;
text-align: left;
}
} }
/* 覆盖 Element Slider 样式以适配深色背景 */ /* --- Apple 风格 Slider --- */
:deep(.custom-slider) { :deep(.apple-slider) {
--el-slider-main-bg-color: rgba(255,255,255,0.8); --el-slider-height: 4px;
--el-slider-runway-bg-color: rgba(255,255,255,0.2); --el-slider-button-size: 12px;
.el-slider__bar { background-color: white; } --el-slider-main-bg-color: rgba(255, 255, 255, 0.9);
.el-slider__button { border-color: white; width: 12px; height: 12px; } --el-slider-runway-bg-color: rgba(255, 255, 255, 0.15);
.el-slider__bar {
border-radius: 2px;
}
.el-slider__button {
background: white;
border: none;
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
transition: transform 0.1s;
opacity: 0; // 默认隐藏滑块圆点
}
// 悬浮时显示滑块圆点
&:hover .el-slider__button {
opacity: 1;
transform: scale(1.2);
}
} }
</style>
/* --- Vue 动画 --- */
.fade-slide-enter-active,
.fade-slide-leave-active {
transition: all 0.3s ease;
}
.fade-slide-enter-from {
opacity: 0;
transform: translateY(5px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateY(-5px);
}
</style>

View File

@@ -16,7 +16,6 @@ const router = createRouter({
routes: [ routes: [
{ path: '/', component: HomeView }, { path: '/', component: HomeView },
{ path: '/playlist/:id', component: PlaylistDetailView }, { path: '/playlist/:id', component: PlaylistDetailView },
// 可扩展其他路由
] ]
}) })

View File

@@ -6,8 +6,8 @@ export const usePlayerStore = defineStore('player', () => {
id: 1, id: 1,
title: "Example Track", title: "Example Track",
artist: "AI Artist", artist: "AI Artist",
cover: "http://p2.music.126.net/W3VMsSEjTdvhz7h3a0oxTg==/17782401556325576.jpg?param=130y130", cover: "http://p2.music.126.net/h2vun-h_uGBYzGvQoLKiBw==/109951165966921437.jpg?param=130y130",
url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3", url: "https://m704.music.126.net/20260115210245/494fafc7ecc89da85365b1e6533cdb30/jdyyaac/obj/w5rDlsOJwrLDjj7CmsOj/32280537391/40ea/84dd/db94/451cfc92afa4a12926f40b1183eca3cd.m4a?vuutv=yqFO4JPFSDRDeqVLCjH3fPvuLTHnPPNLPMIBbHWfYTZOmqP5/RFh7UnxA2sG1X9+MLdjYsrkG5vUIUjV6t+y1pniceMN5lePyr33C0D1Aho=&authSecret=0000019bc1a9710615420a3283920006&cdntag=bWFyaz1vc193ZWIscXVhbGl0eV9leGhpZ2g",
duration: 0 duration: 0
}); });
@@ -18,91 +18,148 @@ export const usePlayerStore = defineStore('player', () => {
const showSettings = ref(false); const showSettings = ref(false);
const darkMode = ref(false); const darkMode = ref(false);
const themeColors = ref({ primary: '#6366f1', secondary: '#a855f7' }); const themeColors = ref({ primary: '#6366f1', secondary: '#a855f7' });
// 新增:用于顶部进度条的最深色
const progressColor = ref('#ffffff'); const progressColor = ref('#ffffff');
const audio = new Audio(currentSong.value.url); // 标记是否已初始化监听器,防止重复绑定
audio.volume = volume.value / 100; let isInitialized = false;
// 颜色提取函数(轻量版,不依赖外部库) // --- 初始化函数:在组件挂载时调用一次 ---
function init() {
if (isInitialized) return;
// 监听时间更新
window.electronAPI.onMpvTimeUpdate((time) => {
currentTime.value = time;
});
// 监听时长更新 (MPV 加载完元数据后会发送)
window.electronAPI.onMpvDuration((duration) => {
currentSong.value.duration = duration;
});
// 监听播放状态 (用于同步 MPV 内部状态和 UI)
window.electronAPI.onMpvPlayState((playing) => {
isPlaying.value = playing;
});
// 监听结束
window.electronAPI.onMpvEnded(() => {
isPlaying.value = false;
currentTime.value = 0;
// 这里可以添加自动播放下一首的逻辑
});
// 初始化音量
window.electronAPI.mpvSetVolume(volume.value);
// 加载初始歌曲
loadCurrentSong(false);
isInitialized = true;
}
function loadCurrentSong(autoPlay:boolean=true) {
if(currentSong.value.url) {
window.electronAPI.mpvLoad(currentSong.value.url,autoPlay);
}
}
function togglePlay() {
if (isPlaying.value) {
window.electronAPI.mpvPause();
} else {
window.electronAPI.mpvPlay();
}
isPlaying.value = !isPlaying.value;
}
function seek(time: number) {
window.electronAPI.mpvSeek(time);
currentTime.value = time; // 立即更新 UI 防止跳变
}
// 监听音量变化
watch(volume, (newVol) => {
window.electronAPI.mpvSetVolume(newVol);
});
// 监听歌曲 URL 变化 (切歌)
watch(() => currentSong.value.url, () => {
loadCurrentSong(true);
});
// 监听封面变化提取颜色 (保持原有逻辑不变)
watch(() => currentSong.value.cover, () => {
extractColors();
}, { immediate: true });
// --- 颜色提取逻辑 (保持不变) ---
function extractColors() { function extractColors() {
const img = new Image(); const img = new Image();
img.crossOrigin = 'Anonymous'; img.crossOrigin = 'Anonymous';
img.src = currentSong.value.cover + '?t=' + Date.now(); // 避免缓存 img.src = currentSong.value.cover + '?t=' + Date.now();
img.onload = () => { img.onload = () => {
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
if (!ctx) return; if (!ctx) return;
// 缩小图片以提高性能
const scale = 0.1; const scale = 0.1;
canvas.width = Math.floor(img.width * scale); canvas.width = Math.floor(img.width * scale);
canvas.height = Math.floor(img.height * scale); canvas.height = Math.floor(img.height * scale);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height); ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height).data; const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
// 收集所有像素颜色 const colors: { r: number; g: number; b: number; brightness: number }[] = [];
const colors: { r: number; g: number; b: number; brightness: number }[] = [];
for (let i = 0; i < imageData.length; i += 4) { for (let i = 0; i < imageData.length; i += 4) {
const r = imageData[i]; const r = imageData[i];
const g = imageData[i + 1]; const g = imageData[i + 1];
const b = imageData[i + 2]; const b = imageData[i + 2];
const brightness = (r + g + b) / 3; const brightness = (r + g + b) / 3;
colors.push({ r, g, b, brightness }); colors.push({ r, g, b, brightness });
} }
// 计算颜色频率(简单实现)
const colorFrequency: Record<string, number> = {}; const colorFrequency: Record<string, number> = {};
colors.forEach(color => { colors.forEach(color => {
// 将颜色量化为 16 位色,减少颜色数量
const key = `${Math.floor(color.r / 16)}${Math.floor(color.g / 16)}${Math.floor(color.b / 16)}`; const key = `${Math.floor(color.r / 16)}${Math.floor(color.g / 16)}${Math.floor(color.b / 16)}`;
colorFrequency[key] = (colorFrequency[key] || 0) + 1; colorFrequency[key] = (colorFrequency[key] || 0) + 1;
}); });
// 转换回 RGB 并按频率排序
const sortedColors = Object.entries(colorFrequency) const sortedColors = Object.entries(colorFrequency)
.map(([key, count]) => { .map(([key, count]) => {
const r = parseInt(key[0], 16) * 16; const r = parseInt(key[0], 16) * 16;
const g = parseInt(key[1], 16) * 16; const g = parseInt(key[1], 16) * 16;
const b = parseInt(key[2], 16) * 16; const b = parseInt(key[2], 16) * 16;
return { r, g, b, count }; return { r, g, b, count };
}) })
.sort((a, b) => b.count - a.count); .sort((a, b) => b.count - a.count);
// 提取主色调和辅助色调
if (sortedColors.length > 0) { if (sortedColors.length > 0) {
// 主色调:频率最高的颜色
const primary = sortedColors[0]; const primary = sortedColors[0];
const primaryColor = `rgb(${primary.r}, ${primary.g}, ${primary.b})`; const primaryColor = `rgb(${primary.r}, ${primary.g}, ${primary.b})`;
// 辅助色调:选择与主色调亮度差异较大的颜色
let secondary = sortedColors[1] || sortedColors[0]; let secondary = sortedColors[1] || sortedColors[0];
let maxBrightnessDiff = Math.abs(primary.brightness - secondary.brightness); let maxBrightnessDiff = Math.abs(primary.brightness - secondary.brightness);
// 在频率较高的颜色中寻找最合适的辅助色
for (let i = 1; i < Math.min(10, sortedColors.length); i++) { for (let i = 1; i < Math.min(10, sortedColors.length); i++) {
const currentBrightness = (sortedColors[i].r + sortedColors[i].g + sortedColors[i].b) / 3; const currentBrightness = (sortedColors[i].r + sortedColors[i].g + sortedColors[i].b) / 3;
const brightnessDiff = Math.abs(primary.brightness - currentBrightness); const brightnessDiff = Math.abs(primary.brightness - currentBrightness);
if (brightnessDiff > maxBrightnessDiff) { if (brightnessDiff > maxBrightnessDiff) {
maxBrightnessDiff = brightnessDiff; maxBrightnessDiff = brightnessDiff;
secondary = sortedColors[i]; secondary = sortedColors[i];
} }
} }
const secondaryColor = `rgb(${secondary.r}, ${secondary.g}, ${secondary.b})`; const secondaryColor = `rgb(${secondary.r}, ${secondary.g}, ${secondary.b})`;
// 更新主题颜色
themeColors.value = { themeColors.value = {
primary: primaryColor, primary: primaryColor,
secondary: secondaryColor secondary: secondaryColor
}; };
// 提取进度条颜色(使用主色调的深色版本)
const darkPrimary = { const darkPrimary = {
r: Math.floor(primary.r * 0.7), r: Math.floor(primary.r * 0.7),
g: Math.floor(primary.g * 0.7), g: Math.floor(primary.g * 0.7),
@@ -110,59 +167,22 @@ export const usePlayerStore = defineStore('player', () => {
}; };
progressColor.value = `rgb(${darkPrimary.r}, ${darkPrimary.g}, ${darkPrimary.b})`; progressColor.value = `rgb(${darkPrimary.r}, ${darkPrimary.g}, ${darkPrimary.b})`;
} else { } else {
// 默认颜色
themeColors.value = { primary: '#6366f1', secondary: '#a855f7' }; themeColors.value = { primary: '#6366f1', secondary: '#a855f7' };
progressColor.value = '#ffffff'; progressColor.value = '#ffffff';
} }
}; };
} }
// 监听歌曲切换时重新提取颜色
watch(() => currentSong.value.cover, () => {
extractColors();
console.log("!!")
}, { immediate: true });
audio.addEventListener('loadedmetadata', () => {
currentSong.value.duration = audio.duration;
});
audio.addEventListener('timeupdate', () => {
currentTime.value = audio.currentTime;
});
audio.addEventListener('ended', () => {
isPlaying.value = false;
currentTime.value = 0;
});
function togglePlay() {
if (isPlaying.value) {
audio.pause();
} else {
audio.play().catch(e => console.warn("播放失败,需用户交互:", e));
}
isPlaying.value = !isPlaying.value;
}
function seek(time: number) {
audio.currentTime = time;
}
function toggleSettings() { console.log("!!");showSettings.value = !showSettings.value; }
watch(volume, (newVol) => {
audio.volume = newVol / 100;
});
const progressPercentage = computed(() => const progressPercentage = computed(() =>
currentSong.value.duration ? (currentTime.value / currentSong.value.duration) * 100 : 0 currentSong.value.duration ? (currentTime.value / currentSong.value.duration) * 100 : 0
); );
return { return {
currentSong, isPlaying, currentTime, volume, showPlaylist, currentSong, isPlaying, currentTime, volume, showPlaylist,
themeColors, progressPercentage, progressColor, themeColors, progressPercentage, progressColor,
togglePlay, seek, extractColors, togglePlay, seek, extractColors,
showSettings, toggleSettings, showSettings,
darkMode darkMode,
init
}; };
}); });

24
src/types/electron.d.ts vendored Normal file
View File

@@ -0,0 +1,24 @@
export interface IElectronAPI {
minimizeWindow: () => void;
maximizeWindow: () => void;
closeWindow: () => void;
isMaximized: () => Promise<boolean>;
mpvLoad: (url: string,autoPlay?: boolean) => void;
mpvPlay: () => void;
mpvPause: () => void;
mpvResume: () => void;
mpvSeek: (time: number) => void;
mpvSetVolume: (volume: number) => void;
onMpvTimeUpdate: (callback: (time: number) => void) => void;
onMpvDuration: (callback: (duration: number) => void) => void;
onMpvPlayState: (callback: (isPlaying: boolean) => void) => void;
onMpvEnded: (callback: () => void) => void;
}
declare global {
interface Window {
electronAPI: IElectronAPI
}
}