forked from miao-moe/QZMusic_PC
feat: 实现功能&播放器内核&实现页面
- AMLL MeshGradient背景 - 全屏播放页初始化 - 纯C音频播放器 - FFmpeg解码 - 编译FFmpeg静态库 - wasapi shared - IPC通信 - FFTW实时频谱计算 - 低频响度实时计算 - PCM缓存 - 数据缓存&解码缓存 - 弃用mpv,改用qzplayer
This commit is contained in:
BIN
core/libfftw3f-3.dll
Normal file
BIN
core/libfftw3f-3.dll
Normal file
Binary file not shown.
BIN
core/qzplayer.exe
Normal file
BIN
core/qzplayer.exe
Normal file
Binary file not shown.
@@ -11,9 +11,9 @@ import { Socket } from "net";
|
|||||||
import { EventEmitter } from "events";
|
import { EventEmitter } from "events";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import http from "http";
|
import http from "http";
|
||||||
import { Readable, PassThrough } from "stream";
|
import { Readable } from "stream";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
class MpvController extends EventEmitter {
|
class QzpController extends EventEmitter {
|
||||||
constructor(ipcPath) {
|
constructor(ipcPath) {
|
||||||
super();
|
super();
|
||||||
__publicField(this, "process", null);
|
__publicField(this, "process", null);
|
||||||
@@ -24,38 +24,28 @@ class MpvController extends EventEmitter {
|
|||||||
}
|
}
|
||||||
getIpcPath() {
|
getIpcPath() {
|
||||||
if (process.platform === "win32") {
|
if (process.platform === "win32") {
|
||||||
return "\\\\.\\pipe\\qzmusic_mpv_socket";
|
return "\\\\.\\pipe\\qzplayer";
|
||||||
}
|
}
|
||||||
return "/tmp/qzmusic_mpv_socket";
|
return "/tmp/qzmusic_mpv_socket";
|
||||||
}
|
}
|
||||||
getMpvPath() {
|
getCorePath() {
|
||||||
const appRoot = process.env.APP_ROOT || process.cwd();
|
const appRoot = process.env.APP_ROOT || process.cwd();
|
||||||
if (process.platform === "win32") {
|
if (process.platform === "win32") {
|
||||||
return path.join(appRoot, "core", "mpv.exe");
|
return path.join(appRoot, "core", "qzplayer.exe");
|
||||||
}
|
}
|
||||||
return "mpv";
|
return "qzplayer";
|
||||||
}
|
}
|
||||||
start() {
|
start() {
|
||||||
const mpvPath = this.getMpvPath();
|
const playerPath = this.getCorePath();
|
||||||
console.log("Starting MPV from:", mpvPath);
|
console.log("Starting QZPlayer from:", playerPath);
|
||||||
this.process = spawn(mpvPath, [
|
this.process = spawn(playerPath);
|
||||||
"--idle",
|
|
||||||
"--force-window=no",
|
|
||||||
"--no-media-controls",
|
|
||||||
`--input-ipc-server=${this.ipcPath}`,
|
|
||||||
"--no-terminal",
|
|
||||||
// Network cache for smooth playback (disk caching is handled by proxy)
|
|
||||||
"--cache=yes",
|
|
||||||
"--demuxer-max-bytes=50MiB",
|
|
||||||
"--demuxer-readahead-secs=30"
|
|
||||||
]);
|
|
||||||
this.process.on("error", (err) => {
|
this.process.on("error", (err) => {
|
||||||
console.error("Failed to start MPV:", err);
|
console.error("Failed to start QZPlayer:", err);
|
||||||
this.emit("error", err);
|
this.emit("error", err);
|
||||||
});
|
});
|
||||||
this.process.on("exit", (code, signal) => {
|
this.process.on("exit", (code, signal) => {
|
||||||
var _a;
|
var _a;
|
||||||
console.log(`MPV exited with code ${code} and signal ${signal}`);
|
console.log(`QZPlayer exited with code ${code} and signal ${signal}`);
|
||||||
this.emit("exit", { code, signal });
|
this.emit("exit", { code, signal });
|
||||||
(_a = this.socket) == null ? void 0 : _a.destroy();
|
(_a = this.socket) == null ? void 0 : _a.destroy();
|
||||||
});
|
});
|
||||||
@@ -63,13 +53,13 @@ class MpvController extends EventEmitter {
|
|||||||
}
|
}
|
||||||
tryConnect(retries = 10) {
|
tryConnect(retries = 10) {
|
||||||
if (retries <= 0) {
|
if (retries <= 0) {
|
||||||
console.error("Could not connect to MPV socket after multiple attempts.");
|
console.error("Could not connect to QZPlayer socket after multiple attempts.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.socket = new Socket();
|
this.socket = new Socket();
|
||||||
this.socket.on("connect", () => {
|
this.socket.on("connect", () => {
|
||||||
console.log("Connected to MPV IPC socket");
|
console.log("Connected to QZPlayer IPC socket");
|
||||||
this.emit("ready");
|
this.emit("ready");
|
||||||
this.send(["observe_property", 1, "pause"]);
|
this.send(["observe_property", 1, "pause"]);
|
||||||
this.send(["observe_property", 2, "time-pos"]);
|
this.send(["observe_property", 2, "time-pos"]);
|
||||||
@@ -103,17 +93,17 @@ class MpvController extends EventEmitter {
|
|||||||
this.emit("event", json);
|
this.emit("event", json);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to parse MPV message:", msg);
|
console.error("Failed to parse QZPlayer message:", msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async send(command) {
|
async send(command) {
|
||||||
if (!this.socket || this.socket.destroyed) {
|
if (!this.socket || this.socket.destroyed) {
|
||||||
console.warn("MPV socket not connected");
|
console.warn("QZPlayer socket not connected");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const payload = JSON.stringify({ command });
|
const payload = JSON.stringify({ command });
|
||||||
console.log("[MPV TX]", payload);
|
console.log("[QZPlayer TX]", payload);
|
||||||
this.socket.write(payload + "\n");
|
this.socket.write(payload + "\n");
|
||||||
}
|
}
|
||||||
// Convenience methods
|
// Convenience methods
|
||||||
@@ -140,7 +130,7 @@ class MpvController extends EventEmitter {
|
|||||||
}
|
}
|
||||||
destroy() {
|
destroy() {
|
||||||
if (this.process) {
|
if (this.process) {
|
||||||
console.log("Killing MPV process...");
|
console.log("Killing QZPlayer process...");
|
||||||
this.process.kill();
|
this.process.kill();
|
||||||
this.process = null;
|
this.process = null;
|
||||||
}
|
}
|
||||||
@@ -380,7 +370,13 @@ function serveFromPartialCache(req, res, filePath, task) {
|
|||||||
const range = parseRangeHeader(req.headers.range, task.totalSize);
|
const range = parseRangeHeader(req.headers.range, task.totalSize);
|
||||||
if (!range) return false;
|
if (!range) return false;
|
||||||
const { start, end } = range;
|
const { start, end } = range;
|
||||||
if (end >= task.currentSize) {
|
let actualSize;
|
||||||
|
try {
|
||||||
|
actualSize = fs.statSync(tempPath).size;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (end >= actualSize) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const chunksize = end - start + 1;
|
const chunksize = end - start + 1;
|
||||||
@@ -392,6 +388,9 @@ function serveFromPartialCache(req, res, filePath, task) {
|
|||||||
});
|
});
|
||||||
const file = fs.createReadStream(tempPath, { start, end });
|
const file = fs.createReadStream(tempPath, { start, end });
|
||||||
file.pipe(res);
|
file.pipe(res);
|
||||||
|
file.on("error", (err) => {
|
||||||
|
console.error("[Proxy] Error reading partial cache:", err);
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[Proxy] Error serving from partial cache:", e);
|
console.error("[Proxy] Error serving from partial cache:", e);
|
||||||
@@ -400,7 +399,8 @@ function serveFromPartialCache(req, res, filePath, task) {
|
|||||||
}
|
}
|
||||||
function startBackgroundDownload(targetUrl, cacheFilePath, cacheKey) {
|
function startBackgroundDownload(targetUrl, cacheFilePath, cacheKey) {
|
||||||
const existingTask = downloadTasks.get(cacheFilePath);
|
const existingTask = downloadTasks.get(cacheFilePath);
|
||||||
if (existingTask) {
|
if (existingTask && existingTask.promise) {
|
||||||
|
console.log(`[Proxy] Reusing existing download task for ${cacheFilePath}`);
|
||||||
return existingTask;
|
return existingTask;
|
||||||
}
|
}
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
@@ -409,7 +409,8 @@ function startBackgroundDownload(targetUrl, cacheFilePath, cacheKey) {
|
|||||||
totalSize: 0,
|
totalSize: 0,
|
||||||
contentType: "audio/mpeg",
|
contentType: "audio/mpeg",
|
||||||
abortController,
|
abortController,
|
||||||
promise: null
|
promise: null,
|
||||||
|
waiters: []
|
||||||
};
|
};
|
||||||
downloadTasks.set(cacheFilePath, task);
|
downloadTasks.set(cacheFilePath, task);
|
||||||
task.promise = (async () => {
|
task.promise = (async () => {
|
||||||
@@ -448,6 +449,11 @@ function startBackgroundDownload(targetUrl, cacheFilePath, cacheKey) {
|
|||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
nodeStream.on("data", (chunk) => {
|
nodeStream.on("data", (chunk) => {
|
||||||
task.currentSize += chunk.length;
|
task.currentSize += chunk.length;
|
||||||
|
notifyWaiters(task);
|
||||||
|
if (task.currentSize % Math.floor(contentLength / 10) < chunk.length) {
|
||||||
|
const percent = Math.floor(task.currentSize / contentLength * 100);
|
||||||
|
console.log(`[Proxy] Download progress: ${percent}% (${task.currentSize}/${contentLength})`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
nodeStream.pipe(fileStream);
|
nodeStream.pipe(fileStream);
|
||||||
fileStream.on("finish", () => {
|
fileStream.on("finish", () => {
|
||||||
@@ -504,12 +510,29 @@ function startBackgroundDownload(targetUrl, cacheFilePath, cacheKey) {
|
|||||||
fs.unlinkSync(tempPath);
|
fs.unlinkSync(tempPath);
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
|
for (const waiter of task.waiters) {
|
||||||
|
waiter.reject(new Error("Download failed"));
|
||||||
|
}
|
||||||
|
task.waiters = [];
|
||||||
} finally {
|
} finally {
|
||||||
downloadTasks.delete(cacheFilePath);
|
downloadTasks.delete(cacheFilePath);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
|
function notifyWaiters(task) {
|
||||||
|
const resolvedWaiters = [];
|
||||||
|
for (let i = 0; i < task.waiters.length; i++) {
|
||||||
|
const waiter = task.waiters[i];
|
||||||
|
if (task.currentSize > waiter.end) {
|
||||||
|
waiter.resolve();
|
||||||
|
resolvedWaiters.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let i = resolvedWaiters.length - 1; i >= 0; i--) {
|
||||||
|
task.waiters.splice(resolvedWaiters[i], 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
async function proxyAndCache(req, res, targetUrl, cacheFilePath, cacheKey) {
|
async function proxyAndCache(req, res, targetUrl, cacheFilePath, cacheKey) {
|
||||||
const requestedRange = req.headers.range;
|
const requestedRange = req.headers.range;
|
||||||
const isSeekRequest = requestedRange && !requestedRange.startsWith("bytes=0-");
|
const isSeekRequest = requestedRange && !requestedRange.startsWith("bytes=0-");
|
||||||
@@ -519,14 +542,21 @@ async function proxyAndCache(req, res, targetUrl, cacheFilePath, cacheKey) {
|
|||||||
if (requestedRange && task.totalSize > 0) {
|
if (requestedRange && task.totalSize > 0) {
|
||||||
const range = parseRangeHeader(requestedRange, task.totalSize);
|
const range = parseRangeHeader(requestedRange, task.totalSize);
|
||||||
if (range) {
|
if (range) {
|
||||||
const safeEnd = Math.floor(task.currentSize * 0.95);
|
const tempPath = getTempPath(cacheFilePath);
|
||||||
if (range.end < safeEnd && range.start < safeEnd) {
|
let actualSize = 0;
|
||||||
console.log(`[Proxy] Serving range ${range.start}-${range.end} from partial cache (downloaded: ${task.currentSize})`);
|
try {
|
||||||
|
if (fs.existsSync(tempPath)) {
|
||||||
|
actualSize = fs.statSync(tempPath).size;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
if (range.end < actualSize) {
|
||||||
|
console.log(`[Proxy] Serving range ${range.start}-${range.end} from partial cache (downloaded: ${actualSize})`);
|
||||||
if (serveFromPartialCache(req, res, cacheFilePath, task)) {
|
if (serveFromPartialCache(req, res, cacheFilePath, task)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log(`[Proxy] Range ${range.start}-${range.end} not cached yet (downloaded: ${task.currentSize}), proxying directly`);
|
console.log(`[Proxy] Range ${range.start}-${range.end} not cached yet (downloaded: ${actualSize}), proxying directly`);
|
||||||
await proxyRangeDirect(req, res, targetUrl, task.totalSize, task.contentType);
|
await proxyRangeDirect(req, res, targetUrl, task.totalSize, task.contentType);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -544,10 +574,13 @@ async function proxyAndCache(req, res, targetUrl, cacheFilePath, cacheKey) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
const totalSize = parseInt(headResponse.headers.get("content-length") || "0", 10);
|
const totalSize = parseInt(headResponse.headers.get("content-length") || "0", 10);
|
||||||
const contentType2 = headResponse.headers.get("content-type") || "audio/mpeg";
|
const contentType = headResponse.headers.get("content-type") || "audio/mpeg";
|
||||||
await proxyRangeDirect(req, res, targetUrl, totalSize, contentType2);
|
await proxyRangeDirect(req, res, targetUrl, totalSize, contentType);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
await streamAndCache(req, res, targetUrl, cacheFilePath);
|
||||||
|
}
|
||||||
|
async function streamAndCache(req, res, targetUrl, cacheFilePath, cacheKey) {
|
||||||
const headers = {
|
const headers = {
|
||||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||||
};
|
};
|
||||||
@@ -581,12 +614,13 @@ async function proxyAndCache(req, res, targetUrl, cacheFilePath, cacheKey) {
|
|||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
const fileStream = fs.createWriteStream(tempPath);
|
const fileStream = fs.createWriteStream(tempPath);
|
||||||
task = {
|
const task = {
|
||||||
currentSize: 0,
|
currentSize: 0,
|
||||||
totalSize: contentLength,
|
totalSize: contentLength,
|
||||||
contentType,
|
contentType,
|
||||||
abortController: null,
|
abortController: null,
|
||||||
promise: null
|
promise: null,
|
||||||
|
waiters: []
|
||||||
};
|
};
|
||||||
downloadTasks.set(cacheFilePath, task);
|
downloadTasks.set(cacheFilePath, task);
|
||||||
console.log(`[Proxy] Starting stream download: ${cacheFilePath} (${contentLength} bytes)`);
|
console.log(`[Proxy] Starting stream download: ${cacheFilePath} (${contentLength} bytes)`);
|
||||||
@@ -601,18 +635,42 @@ async function proxyAndCache(req, res, targetUrl, cacheFilePath, cacheKey) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nodeStream = Readable.fromWeb(response.body);
|
const nodeStream = Readable.fromWeb(response.body);
|
||||||
const passThrough = new PassThrough();
|
let clientConnected = true;
|
||||||
passThrough.on("data", (chunk) => {
|
res.on("close", () => {
|
||||||
if (task) {
|
clientConnected = false;
|
||||||
|
console.log("[Proxy] Client disconnected");
|
||||||
|
});
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
nodeStream.on("data", (chunk) => {
|
||||||
task.currentSize += chunk.length;
|
task.currentSize += chunk.length;
|
||||||
|
fileStream.write(chunk);
|
||||||
|
if (clientConnected && !res.writableEnded) {
|
||||||
|
res.write(chunk);
|
||||||
|
}
|
||||||
|
notifyWaiters(task);
|
||||||
|
});
|
||||||
|
nodeStream.on("end", () => {
|
||||||
|
fileStream.end();
|
||||||
|
if (clientConnected && !res.writableEnded) {
|
||||||
|
res.end();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
passThrough.pipe(res);
|
nodeStream.on("error", (err) => {
|
||||||
nodeStream.pipe(passThrough);
|
console.error("[Proxy] Stream error:", err);
|
||||||
nodeStream.pipe(fileStream);
|
fileStream.destroy();
|
||||||
const cleanup = (success, error) => {
|
if (clientConnected && !res.headersSent) {
|
||||||
|
res.writeHead(502);
|
||||||
|
res.end("Proxy stream error");
|
||||||
|
}
|
||||||
|
downloadTasks.delete(cacheFilePath);
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(tempPath);
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
fileStream.on("finish", () => {
|
||||||
downloadTasks.delete(cacheFilePath);
|
downloadTasks.delete(cacheFilePath);
|
||||||
if (success && fs.existsSync(tempPath)) {
|
|
||||||
try {
|
try {
|
||||||
const stat = fs.statSync(tempPath);
|
const stat = fs.statSync(tempPath);
|
||||||
if (stat.size === contentLength) {
|
if (stat.size === contentLength) {
|
||||||
@@ -623,7 +681,7 @@ async function proxyAndCache(req, res, targetUrl, cacheFilePath, cacheKey) {
|
|||||||
complete: true,
|
complete: true,
|
||||||
createdAt: Date.now()
|
createdAt: Date.now()
|
||||||
});
|
});
|
||||||
console.log(`[Proxy] Cache complete: ${cacheFilePath}`);
|
console.log(`[Proxy] Cache complete: ${cacheFilePath} (${contentLength} bytes)`);
|
||||||
} else {
|
} else {
|
||||||
console.warn(`[Proxy] Incomplete download: ${stat.size}/${contentLength}`);
|
console.warn(`[Proxy] Incomplete download: ${stat.size}/${contentLength}`);
|
||||||
try {
|
try {
|
||||||
@@ -638,23 +696,17 @@ async function proxyAndCache(req, res, targetUrl, cacheFilePath, cacheKey) {
|
|||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (!success) {
|
resolve();
|
||||||
console.error("[Proxy] Download failed:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fileStream.on("finish", () => cleanup(true));
|
|
||||||
fileStream.on("error", (err) => cleanup(false, err));
|
|
||||||
nodeStream.on("error", (err) => {
|
|
||||||
cleanup(false, err);
|
|
||||||
if (!res.headersSent) {
|
|
||||||
res.writeHead(502);
|
|
||||||
res.end("Proxy stream error");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
res.on("close", () => {
|
fileStream.on("error", (err) => {
|
||||||
if (!res.writableEnded) {
|
console.error("[Proxy] File write error:", err);
|
||||||
console.log("[Proxy] Client disconnected, download continues in background");
|
downloadTasks.delete(cacheFilePath);
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(tempPath);
|
||||||
|
} catch {
|
||||||
}
|
}
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let persistCacheEnabled = true;
|
let persistCacheEnabled = true;
|
||||||
@@ -904,7 +956,7 @@ const MAIN_DIST = path$1.join(process.env.APP_ROOT, "dist-electron");
|
|||||||
const RENDERER_DIST = path$1.join(process.env.APP_ROOT, "dist");
|
const RENDERER_DIST = path$1.join(process.env.APP_ROOT, "dist");
|
||||||
process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path$1.join(process.env.APP_ROOT, "public") : RENDERER_DIST;
|
process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path$1.join(process.env.APP_ROOT, "public") : RENDERER_DIST;
|
||||||
let win;
|
let win;
|
||||||
let mpv;
|
let qzplayer;
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
win = new BrowserWindow({
|
win = new BrowserWindow({
|
||||||
frame: false,
|
frame: false,
|
||||||
@@ -937,18 +989,18 @@ ipcMain.on("window-minimize", (event) => {
|
|||||||
ipcMain.on("window-maximize", () => (win == null ? void 0 : win.isMaximized()) ? win.unmaximize() : win == null ? void 0 : win.maximize());
|
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.on("window-close", () => win == null ? void 0 : win.close());
|
||||||
ipcMain.handle("window-is-maximized", () => (win == null ? void 0 : win.isMaximized()) || false);
|
ipcMain.handle("window-is-maximized", () => (win == null ? void 0 : win.isMaximized()) || false);
|
||||||
ipcMain.handle("mpv-command", async (_, command) => {
|
ipcMain.handle("qzplayer-command", async (_, command) => {
|
||||||
if (mpv) {
|
if (qzplayer) {
|
||||||
mpv.send(command);
|
qzplayer.send(command);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
ipcMain.handle("mpv-load", (_, url) => mpv == null ? void 0 : mpv.load(url));
|
ipcMain.handle("qzplayer-load", (_, url) => qzplayer == null ? void 0 : qzplayer.load(url));
|
||||||
ipcMain.handle("mpv-play", () => mpv == null ? void 0 : mpv.play());
|
ipcMain.handle("qzplayer-play", () => qzplayer == null ? void 0 : qzplayer.play());
|
||||||
ipcMain.handle("mpv-pause", () => mpv == null ? void 0 : mpv.pause());
|
ipcMain.handle("qzplayer-pause", () => qzplayer == null ? void 0 : qzplayer.pause());
|
||||||
ipcMain.handle("mpv-toggle-pause", () => mpv == null ? void 0 : mpv.togglePause());
|
ipcMain.handle("qzplayer-toggle-pause", () => qzplayer == null ? void 0 : qzplayer.togglePause());
|
||||||
ipcMain.handle("mpv-stop", () => mpv == null ? void 0 : mpv.stop());
|
ipcMain.handle("qzplayer-stop", () => qzplayer == null ? void 0 : qzplayer.stop());
|
||||||
ipcMain.handle("mpv-set-volume", (_, vol) => mpv == null ? void 0 : mpv.setVolume(vol));
|
ipcMain.handle("qzplayer-set-volume", (_, vol) => qzplayer == null ? void 0 : qzplayer.setVolume(vol));
|
||||||
ipcMain.handle("mpv-seek", (_, time) => mpv == null ? void 0 : mpv.seek(time));
|
ipcMain.handle("qzplayer-seek", (_, time) => qzplayer == null ? void 0 : qzplayer.seek(time));
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
"plugin:call",
|
"plugin:call",
|
||||||
async (_evenv, pluginId, method, args) => {
|
async (_evenv, pluginId, method, args) => {
|
||||||
@@ -1007,8 +1059,8 @@ app.on("window-all-closed", () => {
|
|||||||
});
|
});
|
||||||
app.on("will-quit", () => {
|
app.on("will-quit", () => {
|
||||||
cleanupCache();
|
cleanupCache();
|
||||||
if (mpv) {
|
if (qzplayer) {
|
||||||
mpv.destroy();
|
qzplayer.destroy();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
function registerZoomShortcuts(win2) {
|
function registerZoomShortcuts(win2) {
|
||||||
@@ -1063,11 +1115,11 @@ module.exports = {
|
|||||||
Menu.setApplicationMenu(null);
|
Menu.setApplicationMenu(null);
|
||||||
createWindow();
|
createWindow();
|
||||||
startProxyServer();
|
startProxyServer();
|
||||||
mpv = new MpvController();
|
qzplayer = new QzpController();
|
||||||
mpv.start();
|
qzplayer.start();
|
||||||
mpv.on("event", (data) => {
|
qzplayer.on("event", (data) => {
|
||||||
if (win && !win.isDestroyed()) {
|
if (win && !win.isDestroyed()) {
|
||||||
win.webContents.send("mpv-event", data);
|
win.webContents.send("qzplayer-event", data);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,16 +6,16 @@ electron.contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
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 Control
|
// qzplayer Control
|
||||||
mpv: {
|
qzplayer: {
|
||||||
load: (url) => electron.ipcRenderer.invoke("mpv-load", url),
|
load: (url) => electron.ipcRenderer.invoke("qzplayer-load", url),
|
||||||
play: () => electron.ipcRenderer.invoke("mpv-play"),
|
play: () => electron.ipcRenderer.invoke("qzplayer-play"),
|
||||||
pause: () => electron.ipcRenderer.invoke("mpv-pause"),
|
pause: () => electron.ipcRenderer.invoke("qzplayer-pause"),
|
||||||
togglePause: () => electron.ipcRenderer.invoke("mpv-toggle-pause"),
|
togglePause: () => electron.ipcRenderer.invoke("qzplayer-toggle-pause"),
|
||||||
stop: () => electron.ipcRenderer.invoke("mpv-stop"),
|
stop: () => electron.ipcRenderer.invoke("qzplayer-stop"),
|
||||||
setVolume: (vol) => electron.ipcRenderer.invoke("mpv-set-volume", vol),
|
setVolume: (vol) => electron.ipcRenderer.invoke("qzplayer-set-volume", vol),
|
||||||
seek: (time) => electron.ipcRenderer.invoke("mpv-seek", time),
|
seek: (time) => electron.ipcRenderer.invoke("qzplayer-seek", time),
|
||||||
onEvent: (callback) => electron.ipcRenderer.on("mpv-event", callback)
|
onEvent: (callback) => electron.ipcRenderer.on("qzplayer-event", callback)
|
||||||
},
|
},
|
||||||
// Plugin System
|
// Plugin System
|
||||||
plugin: {
|
plugin: {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ 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 fs from 'node:fs'
|
||||||
import { MpvController } from './mpvController'
|
import { QzpController } from './qzpController.ts'
|
||||||
import { startProxyServer, cleanupCache, getCacheDir, getCacheSize, setPersistCache, clearCacheNow } from './proxyServer'
|
import { startProxyServer, cleanupCache, getCacheDir, getCacheSize, setPersistCache, clearCacheNow } from './proxyServer'
|
||||||
import { PluginSystem } from '../src/main/pluginSystem.ts'
|
import { PluginSystem } from '../src/main/pluginSystem.ts'
|
||||||
import { loadSettings, saveSettings, getSetting, AppSettings } from './settingsStore'
|
import { loadSettings, saveSettings, getSetting, AppSettings } from './settingsStore'
|
||||||
@@ -20,7 +20,7 @@ export 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: BrowserWindow | null
|
let win: BrowserWindow | null
|
||||||
let mpv: MpvController | null
|
let qzplayer: QzpController | null
|
||||||
|
|
||||||
// === Electron 窗口逻辑 ===
|
// === Electron 窗口逻辑 ===
|
||||||
|
|
||||||
@@ -59,21 +59,21 @@ ipcMain.on('window-maximize', () => win?.isMaximized() ? win.unmaximize() : win?
|
|||||||
ipcMain.on('window-close', () => win?.close())
|
ipcMain.on('window-close', () => win?.close())
|
||||||
ipcMain.handle('window-is-maximized', () => win?.isMaximized() || false)
|
ipcMain.handle('window-is-maximized', () => win?.isMaximized() || false)
|
||||||
|
|
||||||
// --- MPV IPC Handlers ---
|
// --- qzplayer IPC Handlers ---
|
||||||
ipcMain.handle('mpv-command', async (_, command: any[]) => {
|
ipcMain.handle('qzplayer-command', async (_, command: any[]) => {
|
||||||
if (mpv) {
|
if (qzplayer) {
|
||||||
mpv.send(command)
|
qzplayer.send(command)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Quick Helpers
|
// Quick Helpers
|
||||||
ipcMain.handle('mpv-load', (_, url) => mpv?.load(url))
|
ipcMain.handle('qzplayer-load', (_, url) => qzplayer?.load(url))
|
||||||
ipcMain.handle('mpv-play', () => mpv?.play())
|
ipcMain.handle('qzplayer-play', () => qzplayer?.play())
|
||||||
ipcMain.handle('mpv-pause', () => mpv?.pause())
|
ipcMain.handle('qzplayer-pause', () => qzplayer?.pause())
|
||||||
ipcMain.handle('mpv-toggle-pause', () => mpv?.togglePause())
|
ipcMain.handle('qzplayer-toggle-pause', () => qzplayer?.togglePause())
|
||||||
ipcMain.handle('mpv-stop', () => mpv?.stop())
|
ipcMain.handle('qzplayer-stop', () => qzplayer?.stop())
|
||||||
ipcMain.handle('mpv-set-volume', (_, vol) => mpv?.setVolume(vol))
|
ipcMain.handle('qzplayer-set-volume', (_, vol) => qzplayer?.setVolume(vol))
|
||||||
ipcMain.handle('mpv-seek', (_, time) => mpv?.seek(time))
|
ipcMain.handle('qzplayer-seek', (_, time) => qzplayer?.seek(time))
|
||||||
|
|
||||||
// PluginSystem
|
// PluginSystem
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
@@ -150,8 +150,8 @@ app.on('window-all-closed', () => {
|
|||||||
|
|
||||||
app.on('will-quit', () => {
|
app.on('will-quit', () => {
|
||||||
cleanupCache()
|
cleanupCache()
|
||||||
if (mpv) {
|
if (qzplayer) {
|
||||||
mpv.destroy()
|
qzplayer.destroy()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -219,14 +219,14 @@ module.exports = {
|
|||||||
// Start Proxy Server
|
// Start Proxy Server
|
||||||
startProxyServer()
|
startProxyServer()
|
||||||
|
|
||||||
// Start MPV
|
// Start qzplayer
|
||||||
mpv = new MpvController()
|
qzplayer = new QzpController()
|
||||||
mpv.start()
|
qzplayer.start()
|
||||||
|
|
||||||
mpv.on('event', (data) => {
|
qzplayer.on('event', (data) => {
|
||||||
// Forward MPV events to Render Process
|
// Forward qzplayer events to Render Process
|
||||||
if (win && !win.isDestroyed()) {
|
if (win && !win.isDestroyed()) {
|
||||||
win.webContents.send('mpv-event', data)
|
win.webContents.send('qzplayer-event', data)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,16 +7,16 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
closeWindow: () => ipcRenderer.send('window-close'),
|
closeWindow: () => ipcRenderer.send('window-close'),
|
||||||
isMaximized: () => ipcRenderer.invoke('window-is-maximized'),
|
isMaximized: () => ipcRenderer.invoke('window-is-maximized'),
|
||||||
|
|
||||||
// MPV Control
|
// qzplayer Control
|
||||||
mpv: {
|
qzplayer: {
|
||||||
load: (url: string) => ipcRenderer.invoke('mpv-load', url),
|
load: (url: string) => ipcRenderer.invoke('qzplayer-load', url),
|
||||||
play: () => ipcRenderer.invoke('mpv-play'),
|
play: () => ipcRenderer.invoke('qzplayer-play'),
|
||||||
pause: () => ipcRenderer.invoke('mpv-pause'),
|
pause: () => ipcRenderer.invoke('qzplayer-pause'),
|
||||||
togglePause: () => ipcRenderer.invoke('mpv-toggle-pause'),
|
togglePause: () => ipcRenderer.invoke('qzplayer-toggle-pause'),
|
||||||
stop: () => ipcRenderer.invoke('mpv-stop'),
|
stop: () => ipcRenderer.invoke('qzplayer-stop'),
|
||||||
setVolume: (vol: number) => ipcRenderer.invoke('mpv-set-volume', vol),
|
setVolume: (vol: number) => ipcRenderer.invoke('qzplayer-set-volume', vol),
|
||||||
seek: (time: number) => ipcRenderer.invoke('mpv-seek', time),
|
seek: (time: number) => ipcRenderer.invoke('qzplayer-seek', time),
|
||||||
onEvent: (callback: (event: any, data: any) => void) => ipcRenderer.on('mpv-event', callback)
|
onEvent: (callback: (event: any, data: any) => void) => ipcRenderer.on('qzplayer-event', callback)
|
||||||
},
|
},
|
||||||
|
|
||||||
// Plugin System
|
// Plugin System
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import http from 'http';
|
import http from 'http';
|
||||||
import { Readable, PassThrough } from 'stream';
|
import { Readable } from 'stream';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { app } from 'electron';
|
import { app } from 'electron';
|
||||||
@@ -42,6 +42,8 @@ interface DownloadTask {
|
|||||||
contentType: string;
|
contentType: string;
|
||||||
abortController: AbortController | null;
|
abortController: AbortController | null;
|
||||||
promise: Promise<void> | null;
|
promise: Promise<void> | null;
|
||||||
|
// 新增:用于通知等待者的回调列表
|
||||||
|
waiters: Array<{ start: number; end: number; resolve: () => void; reject: (err: Error) => void }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Memory cache for resolved URLs
|
// Memory cache for resolved URLs
|
||||||
@@ -212,7 +214,6 @@ async function proxyRangeDirect(
|
|||||||
throw { statusCode: response.status, statusText: response.statusText };
|
throw { statusCode: response.status, statusText: response.statusText };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forward response headers
|
|
||||||
const responseContentType = response.headers.get('content-type') || contentType || 'audio/mpeg';
|
const responseContentType = response.headers.get('content-type') || contentType || 'audio/mpeg';
|
||||||
const contentLength = response.headers.get('content-length');
|
const contentLength = response.headers.get('content-length');
|
||||||
const contentRange = response.headers.get('content-range');
|
const contentRange = response.headers.get('content-range');
|
||||||
@@ -269,9 +270,18 @@ function serveFromPartialCache(
|
|||||||
|
|
||||||
const { start, end } = range;
|
const { start, end } = range;
|
||||||
|
|
||||||
// 检查请求的范围是否已下载
|
// 检查请求的范围是否已下载,使用实际文件大小而不是 task.currentSize
|
||||||
if (end >= task.currentSize) {
|
// 因为文件写入可能有延迟
|
||||||
return false; // 还没下载到这里
|
let actualSize: number;
|
||||||
|
try {
|
||||||
|
actualSize = fs.statSync(tempPath).size;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保请求的范围完全在已下载的部分内
|
||||||
|
if (end >= actualSize) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const chunksize = (end - start) + 1;
|
const chunksize = (end - start) + 1;
|
||||||
@@ -286,6 +296,10 @@ function serveFromPartialCache(
|
|||||||
const file = fs.createReadStream(tempPath, { start, end });
|
const file = fs.createReadStream(tempPath, { start, end });
|
||||||
file.pipe(res);
|
file.pipe(res);
|
||||||
|
|
||||||
|
file.on('error', (err) => {
|
||||||
|
console.error('[Proxy] Error reading partial cache:', err);
|
||||||
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[Proxy] Error serving from partial cache:', e);
|
console.error('[Proxy] Error serving from partial cache:', e);
|
||||||
@@ -295,6 +309,7 @@ function serveFromPartialCache(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 启动后台下载任务(不阻塞当前请求)
|
* 启动后台下载任务(不阻塞当前请求)
|
||||||
|
* 修复版本:确保下载任务独立运行,不受客户端连接影响
|
||||||
*/
|
*/
|
||||||
function startBackgroundDownload(
|
function startBackgroundDownload(
|
||||||
targetUrl: string,
|
targetUrl: string,
|
||||||
@@ -302,7 +317,8 @@ function startBackgroundDownload(
|
|||||||
cacheKey: string
|
cacheKey: string
|
||||||
): DownloadTask {
|
): DownloadTask {
|
||||||
const existingTask = downloadTasks.get(cacheFilePath);
|
const existingTask = downloadTasks.get(cacheFilePath);
|
||||||
if (existingTask) {
|
if (existingTask && existingTask.promise) {
|
||||||
|
console.log(`[Proxy] Reusing existing download task for ${cacheFilePath}`);
|
||||||
return existingTask;
|
return existingTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,6 +329,7 @@ function startBackgroundDownload(
|
|||||||
contentType: 'audio/mpeg',
|
contentType: 'audio/mpeg',
|
||||||
abortController,
|
abortController,
|
||||||
promise: null,
|
promise: null,
|
||||||
|
waiters: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
downloadTasks.set(cacheFilePath, task);
|
downloadTasks.set(cacheFilePath, task);
|
||||||
@@ -365,12 +382,20 @@ function startBackgroundDownload(
|
|||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
nodeStream.on('data', (chunk: Buffer) => {
|
nodeStream.on('data', (chunk: Buffer) => {
|
||||||
task.currentSize += chunk.length;
|
task.currentSize += chunk.length;
|
||||||
|
|
||||||
|
// 通知等待下载进度的请求
|
||||||
|
notifyWaiters(task);
|
||||||
|
|
||||||
|
// 每 10% 输出一次进度
|
||||||
|
if (task.currentSize % Math.floor(contentLength / 10) < chunk.length) {
|
||||||
|
const percent = Math.floor((task.currentSize / contentLength) * 100);
|
||||||
|
console.log(`[Proxy] Download progress: ${percent}% (${task.currentSize}/${contentLength})`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
nodeStream.pipe(fileStream);
|
nodeStream.pipe(fileStream);
|
||||||
|
|
||||||
fileStream.on('finish', () => {
|
fileStream.on('finish', () => {
|
||||||
// 验证并移动文件
|
|
||||||
try {
|
try {
|
||||||
const stat = fs.statSync(tempPath);
|
const stat = fs.statSync(tempPath);
|
||||||
if (stat.size === contentLength) {
|
if (stat.size === contentLength) {
|
||||||
@@ -412,6 +437,12 @@ function startBackgroundDownload(
|
|||||||
console.error('[Proxy] Background download failed:', e);
|
console.error('[Proxy] Background download failed:', e);
|
||||||
}
|
}
|
||||||
try { fs.unlinkSync(tempPath); } catch { }
|
try { fs.unlinkSync(tempPath); } catch { }
|
||||||
|
|
||||||
|
// 通知所有等待者下载失败
|
||||||
|
for (const waiter of task.waiters) {
|
||||||
|
waiter.reject(new Error('Download failed'));
|
||||||
|
}
|
||||||
|
task.waiters = [];
|
||||||
} finally {
|
} finally {
|
||||||
downloadTasks.delete(cacheFilePath);
|
downloadTasks.delete(cacheFilePath);
|
||||||
}
|
}
|
||||||
@@ -420,8 +451,30 @@ function startBackgroundDownload(
|
|||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通知等待下载进度的请求
|
||||||
|
*/
|
||||||
|
function notifyWaiters(task: DownloadTask) {
|
||||||
|
const resolvedWaiters: number[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < task.waiters.length; i++) {
|
||||||
|
const waiter = task.waiters[i];
|
||||||
|
// 当下载进度超过请求的结束位置时,通知等待者
|
||||||
|
if (task.currentSize > waiter.end) {
|
||||||
|
waiter.resolve();
|
||||||
|
resolvedWaiters.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除已解决的等待者
|
||||||
|
for (let i = resolvedWaiters.length - 1; i >= 0; i--) {
|
||||||
|
task.waiters.splice(resolvedWaiters[i], 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 主代理函数 - 智能处理缓存和 Range 请求
|
* 主代理函数 - 智能处理缓存和 Range 请求
|
||||||
|
* 修复版本:正确处理流的复制,确保下载和响应同时进行
|
||||||
*/
|
*/
|
||||||
async function proxyAndCache(
|
async function proxyAndCache(
|
||||||
req: http.IncomingMessage,
|
req: http.IncomingMessage,
|
||||||
@@ -439,24 +492,29 @@ async function proxyAndCache(
|
|||||||
if (task) {
|
if (task) {
|
||||||
console.log(`[Proxy] Download in progress: ${task.currentSize}/${task.totalSize}`);
|
console.log(`[Proxy] Download in progress: ${task.currentSize}/${task.totalSize}`);
|
||||||
|
|
||||||
// 尝试从部分缓存中读取
|
|
||||||
if (requestedRange && task.totalSize > 0) {
|
if (requestedRange && task.totalSize > 0) {
|
||||||
const range = parseRangeHeader(requestedRange, task.totalSize);
|
const range = parseRangeHeader(requestedRange, task.totalSize);
|
||||||
|
|
||||||
if (range) {
|
if (range) {
|
||||||
// 检查请求的数据是否已下载
|
// 获取临时文件的实际大小
|
||||||
// 给一些缓冲区间(已下载部分的 95%)
|
const tempPath = getTempPath(cacheFilePath);
|
||||||
const safeEnd = Math.floor(task.currentSize * 0.95);
|
let actualSize = 0;
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(tempPath)) {
|
||||||
|
actualSize = fs.statSync(tempPath).size;
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
|
||||||
if (range.end < safeEnd && range.start < safeEnd) {
|
// 如果请求的范围已经下载完成,从缓存读取
|
||||||
console.log(`[Proxy] Serving range ${range.start}-${range.end} from partial cache (downloaded: ${task.currentSize})`);
|
if (range.end < actualSize) {
|
||||||
|
console.log(`[Proxy] Serving range ${range.start}-${range.end} from partial cache (downloaded: ${actualSize})`);
|
||||||
if (serveFromPartialCache(req, res, cacheFilePath, task)) {
|
if (serveFromPartialCache(req, res, cacheFilePath, task)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 请求的数据还没下载到,直接代理这个 Range
|
// 请求的数据还没下载到,直接代理
|
||||||
console.log(`[Proxy] Range ${range.start}-${range.end} not cached yet (downloaded: ${task.currentSize}), proxying directly`);
|
console.log(`[Proxy] Range ${range.start}-${range.end} not cached yet (downloaded: ${actualSize}), proxying directly`);
|
||||||
await proxyRangeDirect(req, res, targetUrl, task.totalSize, task.contentType);
|
await proxyRangeDirect(req, res, targetUrl, task.totalSize, task.contentType);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -492,8 +550,21 @@ async function proxyAndCache(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从头开始的请求:启动主下载任务并流式返回
|
// 从头开始的请求:下载并同时流式返回
|
||||||
|
await streamAndCache(req, res, targetUrl, cacheFilePath, cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增:边下载边返回给客户端,同时写入缓存
|
||||||
|
* 使用 PassThrough 流正确复制数据
|
||||||
|
*/
|
||||||
|
async function streamAndCache(
|
||||||
|
req: http.IncomingMessage,
|
||||||
|
res: http.ServerResponse,
|
||||||
|
targetUrl: string,
|
||||||
|
cacheFilePath: string,
|
||||||
|
cacheKey: string
|
||||||
|
): Promise<void> {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||||
};
|
};
|
||||||
@@ -527,7 +598,6 @@ async function proxyAndCache(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置下载任务
|
|
||||||
const tempPath = getTempPath(cacheFilePath);
|
const tempPath = getTempPath(cacheFilePath);
|
||||||
|
|
||||||
// 清理旧文件
|
// 清理旧文件
|
||||||
@@ -538,12 +608,14 @@ async function proxyAndCache(
|
|||||||
|
|
||||||
const fileStream = fs.createWriteStream(tempPath);
|
const fileStream = fs.createWriteStream(tempPath);
|
||||||
|
|
||||||
task = {
|
// 创建下载任务用于跟踪进度
|
||||||
|
const task: DownloadTask = {
|
||||||
currentSize: 0,
|
currentSize: 0,
|
||||||
totalSize: contentLength,
|
totalSize: contentLength,
|
||||||
contentType: contentType,
|
contentType: contentType,
|
||||||
abortController: null,
|
abortController: null,
|
||||||
promise: null,
|
promise: null,
|
||||||
|
waiters: [],
|
||||||
};
|
};
|
||||||
downloadTasks.set(cacheFilePath, task);
|
downloadTasks.set(cacheFilePath, task);
|
||||||
|
|
||||||
@@ -564,27 +636,63 @@ async function proxyAndCache(
|
|||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const nodeStream = Readable.fromWeb(response.body);
|
const nodeStream = Readable.fromWeb(response.body);
|
||||||
const passThrough = new PassThrough();
|
|
||||||
|
|
||||||
// 跟踪下载进度
|
// 关键修复:使用正确的方式将流复制到多个目标
|
||||||
passThrough.on('data', (chunk: Buffer) => {
|
// 手动读取数据并写入多个目标
|
||||||
if (task) {
|
let clientConnected = true;
|
||||||
|
let downloadComplete = false;
|
||||||
|
|
||||||
|
res.on('close', () => {
|
||||||
|
clientConnected = false;
|
||||||
|
console.log('[Proxy] Client disconnected');
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
nodeStream.on('data', (chunk: Buffer) => {
|
||||||
task.currentSize += chunk.length;
|
task.currentSize += chunk.length;
|
||||||
|
|
||||||
|
// 写入文件(始终进行)
|
||||||
|
fileStream.write(chunk);
|
||||||
|
|
||||||
|
// 写入响应(如果客户端还连接着)
|
||||||
|
if (clientConnected && !res.writableEnded) {
|
||||||
|
res.write(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通知等待者
|
||||||
|
notifyWaiters(task);
|
||||||
|
});
|
||||||
|
|
||||||
|
nodeStream.on('end', () => {
|
||||||
|
downloadComplete = true;
|
||||||
|
|
||||||
|
// 结束文件流
|
||||||
|
fileStream.end();
|
||||||
|
|
||||||
|
// 结束响应(如果客户端还连接着)
|
||||||
|
if (clientConnected && !res.writableEnded) {
|
||||||
|
res.end();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 流到客户端
|
nodeStream.on('error', (err: Error) => {
|
||||||
passThrough.pipe(res);
|
console.error('[Proxy] Stream error:', err);
|
||||||
|
fileStream.destroy();
|
||||||
|
|
||||||
// 流到文件
|
if (clientConnected && !res.headersSent) {
|
||||||
nodeStream.pipe(passThrough);
|
res.writeHead(502);
|
||||||
nodeStream.pipe(fileStream);
|
res.end('Proxy stream error');
|
||||||
|
}
|
||||||
|
|
||||||
// 处理完成
|
downloadTasks.delete(cacheFilePath);
|
||||||
const cleanup = (success: boolean, error?: Error) => {
|
try { fs.unlinkSync(tempPath); } catch { }
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
fileStream.on('finish', () => {
|
||||||
downloadTasks.delete(cacheFilePath);
|
downloadTasks.delete(cacheFilePath);
|
||||||
|
|
||||||
if (success && fs.existsSync(tempPath)) {
|
// 验证并移动文件
|
||||||
try {
|
try {
|
||||||
const stat = fs.statSync(tempPath);
|
const stat = fs.statSync(tempPath);
|
||||||
if (stat.size === contentLength) {
|
if (stat.size === contentLength) {
|
||||||
@@ -595,7 +703,7 @@ async function proxyAndCache(
|
|||||||
complete: true,
|
complete: true,
|
||||||
createdAt: Date.now()
|
createdAt: Date.now()
|
||||||
});
|
});
|
||||||
console.log(`[Proxy] Cache complete: ${cacheFilePath}`);
|
console.log(`[Proxy] Cache complete: ${cacheFilePath} (${contentLength} bytes)`);
|
||||||
} else {
|
} else {
|
||||||
console.warn(`[Proxy] Incomplete download: ${stat.size}/${contentLength}`);
|
console.warn(`[Proxy] Incomplete download: ${stat.size}/${contentLength}`);
|
||||||
try { fs.unlinkSync(tempPath); } catch { }
|
try { fs.unlinkSync(tempPath); } catch { }
|
||||||
@@ -604,27 +712,16 @@ async function proxyAndCache(
|
|||||||
console.error('[Proxy] Failed to finalize cache:', e);
|
console.error('[Proxy] Failed to finalize cache:', e);
|
||||||
try { fs.unlinkSync(tempPath); } catch { }
|
try { fs.unlinkSync(tempPath); } catch { }
|
||||||
}
|
}
|
||||||
} else if (!success) {
|
|
||||||
console.error('[Proxy] Download failed:', error);
|
|
||||||
// 不删除临时文件,让后续请求可以继续
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fileStream.on('finish', () => cleanup(true));
|
resolve();
|
||||||
fileStream.on('error', (err) => cleanup(false, err));
|
|
||||||
nodeStream.on('error', (err: Error) => {
|
|
||||||
cleanup(false, err);
|
|
||||||
if (!res.headersSent) {
|
|
||||||
res.writeHead(502);
|
|
||||||
res.end('Proxy stream error');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 客户端断开时不终止下载
|
fileStream.on('error', (err) => {
|
||||||
res.on('close', () => {
|
console.error('[Proxy] File write error:', err);
|
||||||
if (!res.writableEnded) {
|
downloadTasks.delete(cacheFilePath);
|
||||||
console.log('[Proxy] Client disconnected, download continues in background');
|
try { fs.unlinkSync(tempPath); } catch { }
|
||||||
}
|
reject(err);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -749,7 +846,7 @@ export function startProxyServer(persistCache: boolean = true) {
|
|||||||
console.log(`[Proxy] Persist cache: ${persistCache}`);
|
console.log(`[Proxy] Persist cache: ${persistCache}`);
|
||||||
|
|
||||||
// 定期清理临时文件
|
// 定期清理临时文件
|
||||||
setInterval(cleanupTempFiles, 30 * 60 * 1000); // 每 30 分钟
|
setInterval(cleanupTempFiles, 30 * 60 * 1000);
|
||||||
|
|
||||||
const server = http.createServer(async (req, res) => {
|
const server = http.createServer(async (req, res) => {
|
||||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
@@ -838,11 +935,8 @@ export function startProxyServer(persistCache: boolean = true) {
|
|||||||
|
|
||||||
if (currentAttempt < maxAttempts) {
|
if (currentAttempt < maxAttempts) {
|
||||||
console.log('[Proxy] Error occurred, refreshing URL from plugin...');
|
console.log('[Proxy] Error occurred, refreshing URL from plugin...');
|
||||||
|
|
||||||
// 清除 URL 缓存
|
|
||||||
urlCache.delete(cacheKey);
|
urlCache.delete(cacheKey);
|
||||||
|
|
||||||
// 获取新 URL
|
|
||||||
const newUrl = await fetchFreshUrl(source, id, quality);
|
const newUrl = await fetchFreshUrl(source, id, quality);
|
||||||
if (newUrl) {
|
if (newUrl) {
|
||||||
playUrl = newUrl;
|
playUrl = newUrl;
|
||||||
@@ -852,7 +946,6 @@ export function startProxyServer(persistCache: boolean = true) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送错误响应
|
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.writeHead(lastError?.statusCode || 502);
|
res.writeHead(lastError?.statusCode || 502);
|
||||||
res.end('Proxy Error');
|
res.end('Proxy Error');
|
||||||
@@ -872,7 +965,7 @@ export function startProxyServer(persistCache: boolean = true) {
|
|||||||
|
|
||||||
server.listen(PORT, '127.0.0.1', () => {
|
server.listen(PORT, '127.0.0.1', () => {
|
||||||
ensureCacheDir();
|
ensureCacheDir();
|
||||||
cleanupTempFiles(); // 启动时清理
|
cleanupTempFiles();
|
||||||
console.log(`[Proxy] Server running at http://127.0.0.1:${PORT}/music`);
|
console.log(`[Proxy] Server running at http://127.0.0.1:${PORT}/music`);
|
||||||
console.log(`[Proxy] Cache dir: ${CACHE_DIR}`);
|
console.log(`[Proxy] Cache dir: ${CACHE_DIR}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
//@ts-ignore
|
||||||
import { spawn, ChildProcess } from 'child_process';
|
import { spawn, ChildProcess } 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';
|
||||||
|
|
||||||
export class MpvController extends EventEmitter {
|
export class QzpController extends EventEmitter {
|
||||||
private process: ChildProcess | null = null;
|
private process: ChildProcess | null = null;
|
||||||
private socket: Socket | null = null;
|
private socket: Socket | null = null;
|
||||||
private ipcPath: string;
|
private ipcPath: string;
|
||||||
@@ -16,45 +17,33 @@ export class MpvController extends EventEmitter {
|
|||||||
|
|
||||||
private getIpcPath(): string {
|
private getIpcPath(): string {
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
return '\\\\.\\pipe\\qzmusic_mpv_socket';
|
return '\\\\.\\pipe\\qzplayer';
|
||||||
}
|
}
|
||||||
return '/tmp/qzmusic_mpv_socket';
|
return '/tmp/qzmusic_mpv_socket';
|
||||||
}
|
}
|
||||||
|
|
||||||
private getMpvPath(): string {
|
private getCorePath(): string {
|
||||||
const appRoot = process.env.APP_ROOT || process.cwd();
|
const appRoot = process.env.APP_ROOT || process.cwd();
|
||||||
|
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
return path.join(appRoot, 'core', 'mpv.exe');
|
return path.join(appRoot, 'core', 'qzplayer.exe');
|
||||||
}
|
}
|
||||||
// Darwin (Mac) or Linux
|
return "qzplayer"
|
||||||
// For now, assuming global mpv or a specific path in future
|
|
||||||
return 'mpv';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
start() {
|
start() {
|
||||||
const mpvPath = this.getMpvPath();
|
const playerPath = this.getCorePath();
|
||||||
console.log('Starting MPV from:', mpvPath);
|
console.log('Starting QZPlayer from:', playerPath);
|
||||||
|
|
||||||
this.process = spawn(mpvPath, [
|
this.process = spawn(playerPath);
|
||||||
'--idle',
|
|
||||||
'--force-window=no',
|
|
||||||
'--no-media-controls',
|
|
||||||
`--input-ipc-server=${this.ipcPath}`,
|
|
||||||
'--no-terminal',
|
|
||||||
// Network cache for smooth playback (disk caching is handled by proxy)
|
|
||||||
'--cache=yes',
|
|
||||||
'--demuxer-max-bytes=50MiB',
|
|
||||||
'--demuxer-readahead-secs=30'
|
|
||||||
]);
|
|
||||||
|
|
||||||
this.process.on('error', (err) => {
|
this.process.on('error', (err) => {
|
||||||
console.error('Failed to start MPV:', err);
|
console.error('Failed to start QZPlayer:', err);
|
||||||
this.emit('error', err);
|
this.emit('error', err);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.process.on('exit', (code, signal) => {
|
this.process.on('exit', (code, signal) => {
|
||||||
console.log(`MPV exited with code ${code} and signal ${signal}`);
|
console.log(`QZPlayer exited with code ${code} and signal ${signal}`);
|
||||||
this.emit('exit', { code, signal });
|
this.emit('exit', { code, signal });
|
||||||
this.socket?.destroy();
|
this.socket?.destroy();
|
||||||
});
|
});
|
||||||
@@ -64,7 +53,7 @@ export class MpvController extends EventEmitter {
|
|||||||
|
|
||||||
private tryConnect(retries = 10) {
|
private tryConnect(retries = 10) {
|
||||||
if (retries <= 0) {
|
if (retries <= 0) {
|
||||||
console.error('Could not connect to MPV socket after multiple attempts.');
|
console.error('Could not connect to QZPlayer socket after multiple attempts.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +61,7 @@ export class MpvController extends EventEmitter {
|
|||||||
this.socket = new Socket();
|
this.socket = new Socket();
|
||||||
|
|
||||||
this.socket.on('connect', () => {
|
this.socket.on('connect', () => {
|
||||||
console.log('Connected to MPV IPC socket');
|
console.log('Connected to QZPlayer IPC socket');
|
||||||
this.emit('ready');
|
this.emit('ready');
|
||||||
|
|
||||||
this.send(['observe_property', 1, 'pause']);
|
this.send(['observe_property', 1, 'pause']);
|
||||||
@@ -98,7 +87,6 @@ export class MpvController extends EventEmitter {
|
|||||||
private handleData(data: Buffer) {
|
private handleData(data: Buffer) {
|
||||||
// Determine message boundaries by newline
|
// Determine message boundaries by newline
|
||||||
const raw = data.toString();
|
const raw = data.toString();
|
||||||
// console.log('[MPV RX RAW]', raw.trim()); // Very noisy if enabled
|
|
||||||
|
|
||||||
this.messageBuffer += raw;
|
this.messageBuffer += raw;
|
||||||
const messages = this.messageBuffer.split('\n');
|
const messages = this.messageBuffer.split('\n');
|
||||||
@@ -118,19 +106,19 @@ export class MpvController extends EventEmitter {
|
|||||||
this.emit('event', json);
|
this.emit('event', json);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to parse MPV message:', msg);
|
console.error('Failed to parse QZPlayer message:', msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async send(command: any[]) {
|
async send(command: any[]) {
|
||||||
if (!this.socket || this.socket.destroyed) {
|
if (!this.socket || this.socket.destroyed) {
|
||||||
console.warn('MPV socket not connected');
|
console.warn('QZPlayer socket not connected');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = JSON.stringify({ command });
|
const payload = JSON.stringify({ command });
|
||||||
console.log('[MPV TX]', payload); // User requested raw communication
|
console.log('[QZPlayer TX]', payload); // User requested raw communication
|
||||||
this.socket.write(payload + '\n');
|
this.socket.write(payload + '\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +153,7 @@ export class MpvController extends EventEmitter {
|
|||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
if (this.process) {
|
if (this.process) {
|
||||||
console.log('Killing MPV process...');
|
console.log('Killing QZPlayer process...');
|
||||||
this.process.kill();
|
this.process.kill();
|
||||||
this.process = null;
|
this.process = null;
|
||||||
}
|
}
|
||||||
2119
package-lock.json
generated
2119
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -19,11 +19,14 @@
|
|||||||
"@pixi/filter-bulge-pinch": "^5.1.1",
|
"@pixi/filter-bulge-pinch": "^5.1.1",
|
||||||
"@pixi/filter-color-matrix": "^7.4.3",
|
"@pixi/filter-color-matrix": "^7.4.3",
|
||||||
"@pixi/sprite": "^7.4.3",
|
"@pixi/sprite": "^7.4.3",
|
||||||
|
"@vitejs/plugin-vue-jsx": "^5.1.4",
|
||||||
"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",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"tdesign-vue-next": "^1.17.7",
|
"tdesign-vue-next": "^1.17.7",
|
||||||
|
"url": "^0.11.4",
|
||||||
|
"vite-plugin-electron-renderer": "^0.14.6",
|
||||||
"vue": "^3.4.21",
|
"vue": "^3.4.21",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
@@ -35,7 +38,7 @@
|
|||||||
"typescript": "^5.2.2",
|
"typescript": "^5.2.2",
|
||||||
"vite": "^5.1.6",
|
"vite": "^5.1.6",
|
||||||
"vite-plugin-electron": "^0.28.6",
|
"vite-plugin-electron": "^0.28.6",
|
||||||
"vite-plugin-electron-renderer": "^0.14.5",
|
"vite-plugin-node-polyfills": "^0.25.0",
|
||||||
"vue-tsc": "^2.0.26"
|
"vue-tsc": "^2.0.26"
|
||||||
},
|
},
|
||||||
"main": "dist-electron/main.js"
|
"main": "dist-electron/main.js"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<MainLayout />
|
<MainLayout />
|
||||||
|
<FullScreenPlayer />
|
||||||
<Settings v-if="showSettings" @close="showSettings = false" />
|
<Settings v-if="showSettings" @close="showSettings = false" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -7,6 +8,7 @@
|
|||||||
import { ref, provide, onMounted } from 'vue';
|
import { ref, provide, onMounted } from 'vue';
|
||||||
import MainLayout from './layout/MainLayout.vue';
|
import MainLayout from './layout/MainLayout.vue';
|
||||||
import Settings from './components/Settings.vue';
|
import Settings from './components/Settings.vue';
|
||||||
|
import FullScreenPlayer from './components/FullScreenPlayer.vue';
|
||||||
|
|
||||||
const showSettings = ref(false);
|
const showSettings = ref(false);
|
||||||
|
|
||||||
|
|||||||
382
src/renderer/components/FullScreenPlayer.vue
Normal file
382
src/renderer/components/FullScreenPlayer.vue
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fullscreen-player" :class="{ active: isPlayerFullScreen }">
|
||||||
|
<div class="player-content">
|
||||||
|
<div class="dismiss-area" @click="toggleFullScreen">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polyline points="6 9 12 15 18 9"></polyline>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="background-container">
|
||||||
|
<BackgroundRender
|
||||||
|
:album="currentSong?.picUrl"
|
||||||
|
:lowFreqVolume="loudness"
|
||||||
|
:hasLyric="true"
|
||||||
|
:playing="isPlaying"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="music-info">
|
||||||
|
<!-- Placeholder for song info, lyrics etc. -->
|
||||||
|
<h1>{{ currentSong?.name || 'No Music' }}</h1>
|
||||||
|
<p>{{ currentSong?.artist }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<canvas ref="canvasRef" class="spectrum-canvas"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, watch } from 'vue';
|
||||||
|
import { usePlayerStore } from '../stores/player';
|
||||||
|
import {
|
||||||
|
type AbstractBaseRenderer,
|
||||||
|
type BaseRenderer,
|
||||||
|
BackgroundRender as CoreBackgroundRender,
|
||||||
|
MeshGradientRenderer,
|
||||||
|
} from "@applemusic-like-lyrics/core";
|
||||||
|
import {
|
||||||
|
defineComponent,
|
||||||
|
onMounted,
|
||||||
|
onUnmounted,
|
||||||
|
type PropType,
|
||||||
|
type Ref,
|
||||||
|
ref,
|
||||||
|
type ShallowRef,
|
||||||
|
useTemplateRef,
|
||||||
|
watchEffect,
|
||||||
|
h,
|
||||||
|
} from "vue";
|
||||||
|
|
||||||
|
// --- BackgroundRender Component Definition (from User Request) ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 背景渲染组件的引用
|
||||||
|
*/
|
||||||
|
export interface BackgroundRenderRef {
|
||||||
|
/**
|
||||||
|
* 背景渲染实例引用
|
||||||
|
*/
|
||||||
|
bgRender?: Ref<AbstractBaseRenderer | undefined>;
|
||||||
|
/**
|
||||||
|
* 将背景渲染实例的元素包裹起来的 DIV 元素实例
|
||||||
|
*/
|
||||||
|
wrapperEl: Readonly<ShallowRef<HTMLDivElement | null>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const backgroundRenderProps = {
|
||||||
|
/**
|
||||||
|
* 设置背景专辑资源
|
||||||
|
*/
|
||||||
|
album: {
|
||||||
|
type: [String, Object] as PropType<
|
||||||
|
string | HTMLImageElement | HTMLVideoElement
|
||||||
|
>,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 设置专辑资源是否为视频
|
||||||
|
*/
|
||||||
|
albumIsVideo: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 设置当前背景动画帧率,如果为 `undefined` 则默认为 `30`
|
||||||
|
*/
|
||||||
|
fps: {
|
||||||
|
type: Number,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 设置当前播放状态,如果为 `undefined` 则默认为 `true`
|
||||||
|
*/
|
||||||
|
playing: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 设置当前动画流动速度,如果为 `undefined` 则默认为 `2`
|
||||||
|
*/
|
||||||
|
flowSpeed: {
|
||||||
|
type: Number,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 设置背景是否根据“是否有歌词”这个特征调整自身效果,例如有歌词时会变得更加活跃
|
||||||
|
*
|
||||||
|
* 部分渲染器会根据这个特征调整自身效果
|
||||||
|
*
|
||||||
|
* 如果不确定是否需要赋值或无法知晓是否包含歌词,请传入 true 或不做任何处理(默认值为 true)
|
||||||
|
*/
|
||||||
|
hasLyric: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 设置低频的音量大小,范围在 80hz-120hz 之间为宜,取值范围在 [0.0-1.0] 之间
|
||||||
|
*
|
||||||
|
* 部分渲染器会根据音量大小调整背景效果(例如根据鼓点跳动)
|
||||||
|
*
|
||||||
|
* 如果无法获取到类似的数据,请传入 undefined 或 1.0 作为默认值,或不做任何处理(默认值即 1.0)
|
||||||
|
*/
|
||||||
|
lowFreqVolume: {
|
||||||
|
type: Number,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 设置当前渲染缩放比例,如果为 `undefined` 则默认为 `0.5`
|
||||||
|
*/
|
||||||
|
renderScale: {
|
||||||
|
type: Number,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 设置渲染器,如果为 `undefined` 则默认为 `MeshGradientRenderer`
|
||||||
|
* 默认渲染器有可能会随着版本更新而更换
|
||||||
|
*/
|
||||||
|
renderer: {
|
||||||
|
type: Object as PropType<{ // Use constructor type
|
||||||
|
new (...args: ConstructorParameters<typeof BaseRenderer>): BaseRenderer;
|
||||||
|
}>,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const BackgroundRender = defineComponent({
|
||||||
|
name: "BackgroundRender",
|
||||||
|
props: backgroundRenderProps,
|
||||||
|
setup(props, { expose }) {
|
||||||
|
const wrapperRef = useTemplateRef<HTMLDivElement>("wrapper-ref");
|
||||||
|
const bgRenderRef = ref<AbstractBaseRenderer>();
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (wrapperRef.value) {
|
||||||
|
// @ts-ignore
|
||||||
|
bgRenderRef.value = CoreBackgroundRender.new(
|
||||||
|
props.renderer ?? MeshGradientRenderer,
|
||||||
|
);
|
||||||
|
const el = bgRenderRef.value.getElement();
|
||||||
|
el.style.width = "100%";
|
||||||
|
el.style.height = "100%";
|
||||||
|
wrapperRef.value.appendChild(el);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (bgRenderRef.value) {
|
||||||
|
bgRenderRef.value.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
if (props.album)
|
||||||
|
bgRenderRef.value?.setAlbum(props.album, props.albumIsVideo);
|
||||||
|
});
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
if (props.fps) bgRenderRef.value?.setFPS(props.fps);
|
||||||
|
});
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
if (props.playing) bgRenderRef.value?.pause();
|
||||||
|
else bgRenderRef.value?.resume();
|
||||||
|
});
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
if (props.flowSpeed) bgRenderRef.value?.setFlowSpeed(props.flowSpeed);
|
||||||
|
});
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
if (props.renderScale)
|
||||||
|
bgRenderRef.value?.setRenderScale(props.renderScale);
|
||||||
|
});
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
if (props.lowFreqVolume !== undefined)
|
||||||
|
bgRenderRef.value?.setLowFreqVolume(props.lowFreqVolume);
|
||||||
|
});
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
if (props.hasLyric !== undefined)
|
||||||
|
bgRenderRef.value?.setHasLyric(props.hasLyric ?? true);
|
||||||
|
});
|
||||||
|
|
||||||
|
expose<BackgroundRenderRef>({
|
||||||
|
bgRender: bgRenderRef,
|
||||||
|
wrapperEl: wrapperRef,
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => h("div", { style: "display: contents;", ref: "wrapper-ref" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// --- FullScreenPlayer Logic ---
|
||||||
|
|
||||||
|
const playerStore = usePlayerStore();
|
||||||
|
const isPlayerFullScreen = computed(() => playerStore.isPlayerFullScreen);
|
||||||
|
const currentSong = computed(() => playerStore.currentSong);
|
||||||
|
const isPlaying = computed(() => playerStore.isPlaying);
|
||||||
|
const loudness = computed(() => playerStore.loudness);
|
||||||
|
|
||||||
|
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||||
|
let animationId: number | null = null;
|
||||||
|
|
||||||
|
const drawSpectrum = () => {
|
||||||
|
if (!canvasRef.value) return;
|
||||||
|
const canvas = canvasRef.value;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
// Adjust canvas size to display resolution
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
canvas.width = rect.width * dpr;
|
||||||
|
canvas.height = rect.height * dpr;
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
|
||||||
|
const width = rect.width;
|
||||||
|
const height = rect.height;
|
||||||
|
const barCount = 32;
|
||||||
|
const barWidth = (width / barCount) * 0.5; // Gap = Bar Width
|
||||||
|
const gap = (width - (barCount * barWidth)) / (barCount + 1);
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
|
||||||
|
const data = playerStore.spectrum;
|
||||||
|
if (!data || data.length === 0) return;
|
||||||
|
|
||||||
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
|
||||||
|
|
||||||
|
for (let i = 0; i < Math.min(data.length, barCount); i++) {
|
||||||
|
const value = data[i];
|
||||||
|
const barHeight = value * height * 0.6; // Scale down a bit
|
||||||
|
const x = gap + i * (barWidth + gap);
|
||||||
|
const y = height - barHeight;
|
||||||
|
|
||||||
|
// Draw rounded bar
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(x, y, barWidth, barHeight, 4);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loop = () => {
|
||||||
|
if (isPlayerFullScreen.value) {
|
||||||
|
drawSpectrum();
|
||||||
|
animationId = requestAnimationFrame(loop);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(isPlayerFullScreen, (val) => {
|
||||||
|
if (val) {
|
||||||
|
loop();
|
||||||
|
} else {
|
||||||
|
if (animationId) cancelAnimationFrame(animationId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (isPlayerFullScreen.value) loop();
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (animationId) cancelAnimationFrame(animationId);
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleFullScreen = () => {
|
||||||
|
playerStore.toggleFullScreen();
|
||||||
|
};
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fullscreen-player {
|
||||||
|
position: fixed;
|
||||||
|
top: 100%; /* Initially hidden below screen */
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background-color: #000;
|
||||||
|
z-index: 9999;
|
||||||
|
transition: top 0.4s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullscreen-player.active {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-content {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.background-container {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.6; /* Dim background slightly */
|
||||||
|
}
|
||||||
|
|
||||||
|
.dismiss-area {
|
||||||
|
padding: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
transition: color 0.2s;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dismiss-area:hover {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-info {
|
||||||
|
margin-top: auto;
|
||||||
|
padding: 40px;
|
||||||
|
text-align: left;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-info h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-info p {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spectrum-canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 200px;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 15;
|
||||||
|
/* Optional: Fade out at top */
|
||||||
|
mask-image: linear-gradient(to top, black 0%, transparent 100%);
|
||||||
|
-webkit-mask-image: linear-gradient(to top, black 0%, transparent 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<transition name="slide-up">
|
<transition name="slide-up">
|
||||||
<div class="player-bar" v-if="hasSongs">
|
<div class="player-bar" v-if="hasSongs" @click="handleBarClick">
|
||||||
<!-- Left: Vinyl & Info -->
|
<!-- Left: Vinyl & Info -->
|
||||||
<div class="player-left">
|
<div class="player-left">
|
||||||
<div class="vinyl-wrapper" :class="{ 'playing': isPlaying }">
|
<div class="vinyl-wrapper" :class="{ 'playing': isPlaying }">
|
||||||
@@ -164,6 +164,20 @@ const toggleMute = () => {
|
|||||||
else playerStore.setVolume(50);
|
else playerStore.setVolume(50);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBarClick = (e: MouseEvent) => {
|
||||||
|
// Prevent triggering when clicking on controls/inputs
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (
|
||||||
|
target.closest('button') ||
|
||||||
|
target.closest('input') ||
|
||||||
|
target.closest('.icon-btn') ||
|
||||||
|
target.closest('.play-btn')
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
playerStore.toggleFullScreen();
|
||||||
|
};
|
||||||
|
|
||||||
// Utils
|
// Utils
|
||||||
const formatTime = (seconds: number) => {
|
const formatTime = (seconds: number) => {
|
||||||
if (!seconds || isNaN(seconds)) return '00:00';
|
if (!seconds || isNaN(seconds)) return '00:00';
|
||||||
|
|||||||
@@ -155,7 +155,7 @@
|
|||||||
<div class="app-logo">🎶</div>
|
<div class="app-logo">🎶</div>
|
||||||
<h3>QZ Music</h3>
|
<h3>QZ Music</h3>
|
||||||
<p class="version">版本 1.0.0</p>
|
<p class="version">版本 1.0.0</p>
|
||||||
<p class="copyright">© 2024 QZ Music Team</p>
|
<p class="copyright">©2026 QZ <DEVELOPERS></DEVELOPERS></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -96,4 +96,15 @@ body {
|
|||||||
.page-content::-webkit-scrollbar-thumb:hover {
|
.page-content::-webkit-scrollbar-thumb:hover {
|
||||||
background: var(--color-text-muted);
|
background: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Route Transition Utils */
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -50,12 +50,12 @@ const playerStore = usePlayerStore()
|
|||||||
playerStore.playMode = PlayMode.Single;
|
playerStore.playMode = PlayMode.Single;
|
||||||
|
|
||||||
const testSong: Song = {
|
const testSong: Song = {
|
||||||
id: '3337983421',
|
id: '2716424334',
|
||||||
name: '不死身ごっこ (feat. 初音ミク)',
|
name: 'T氏の話を信じるな (feat. 初音ミク & 重音テト)',
|
||||||
artist: 'ピノキオピー、初音ミク',
|
artist: 'ピノキオピー、初音ミク、重音テト',
|
||||||
picUrl: 'http://p2.music.126.net/7O6FcCraxldhFGz4CSPVlw==/109951172567380787.jpg?imageView=&thumbnail=371y371&type=webp&rotate=360&tostatic=0',
|
picUrl: 'http://p2.music.126.net/DmrEz0M4GwSeISIReCNNgw==/109951171319581237.jpg?param=130y130',
|
||||||
url: 'http://m701.music.126.net/20260203120731/1480f3bdda9795d5e7cc25af304b6cae/jdymusic/obj/wo3DlMOGwrbDjj7DisKw/77635439540/89c6/162f/e373/62ded4a27758346e53f717f46ba2802c.flac',
|
url: '',
|
||||||
duration: '02:31',
|
duration: '02:43',
|
||||||
source: 'wy',
|
source: 'wy',
|
||||||
type: 'Remote'
|
type: 'Remote'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
const duration = ref(0);
|
const duration = ref(0);
|
||||||
const currentTime = ref(0);
|
const currentTime = ref(0);
|
||||||
|
|
||||||
|
// Audio Visualization State
|
||||||
|
const loudness = ref(0);
|
||||||
|
const spectrum = ref<number[]>([]);
|
||||||
|
|
||||||
|
// UI State
|
||||||
|
const isPlayerFullScreen = ref(false);
|
||||||
|
|
||||||
// Playlist State
|
// Playlist State
|
||||||
const playlist = ref<Song[]>([]);
|
const playlist = ref<Song[]>([]);
|
||||||
const currentIndex = ref(-1);
|
const currentIndex = ref(-1);
|
||||||
@@ -96,7 +103,7 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
let playUrl = song.url;
|
let playUrl = song.url;
|
||||||
if (song.type === 'Remote' && song.source) {
|
if (song.type === 'Remote' && song.source) {
|
||||||
// Use Local Proxy
|
// Use Local Proxy
|
||||||
const quality = 'hires';
|
const quality = 'jymaster';
|
||||||
playUrl = `http://localhost:5266/music?source=${song.source}&id=${song.id}&quality=${quality}`;
|
playUrl = `http://localhost:5266/music?source=${song.source}&id=${song.id}&quality=${quality}`;
|
||||||
console.log('[Player] Using Proxy:', playUrl);
|
console.log('[Player] Using Proxy:', playUrl);
|
||||||
}
|
}
|
||||||
@@ -106,8 +113,8 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
// Reset retry flag for new playback attempt
|
// Reset retry flag for new playback attempt
|
||||||
hasRetriedWithFreshUrl.value = false;
|
hasRetriedWithFreshUrl.value = false;
|
||||||
try {
|
try {
|
||||||
await window.electronAPI.mpv.load(playUrl);
|
await window.electronAPI.qzplayer.load(playUrl);
|
||||||
await window.electronAPI.mpv.play();
|
await window.electronAPI.qzplayer.play();
|
||||||
isPlaying.value = true;
|
isPlaying.value = true;
|
||||||
song.url = playUrl;
|
song.url = playUrl;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -131,8 +138,8 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
artwork: song.picUrl ? [{ src: song.picUrl, sizes: '512x512', type: 'image/png' }] : []
|
artwork: song.picUrl ? [{ src: song.picUrl, sizes: '512x512', type: 'image/png' }] : []
|
||||||
});
|
});
|
||||||
|
|
||||||
navigator.mediaSession.setActionHandler('play', () => window.electronAPI.mpv.play());
|
navigator.mediaSession.setActionHandler('play', () => window.electronAPI.qzplayer.play());
|
||||||
navigator.mediaSession.setActionHandler('pause', () => window.electronAPI.mpv.pause());
|
navigator.mediaSession.setActionHandler('pause', () => window.electronAPI.qzplayer.pause());
|
||||||
navigator.mediaSession.setActionHandler('previoustrack', () => prev());
|
navigator.mediaSession.setActionHandler('previoustrack', () => prev());
|
||||||
navigator.mediaSession.setActionHandler('nexttrack', () => next(true));
|
navigator.mediaSession.setActionHandler('nexttrack', () => next(true));
|
||||||
|
|
||||||
@@ -170,7 +177,7 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePlayError = async () => {
|
const handlePlayError = async () => {
|
||||||
// Proxy handles refreshing internally, so we rely on MPV error/retry for now.
|
// Proxy handles refreshing internally, so we rely on qzplayer error/retry for now.
|
||||||
// Or we could implement a mechanism to tell proxy to invalidate cache if this fails repeatedly (future work).
|
// Or we could implement a mechanism to tell proxy to invalidate cache if this fails repeatedly (future work).
|
||||||
|
|
||||||
// Normal error handling
|
// Normal error handling
|
||||||
@@ -184,7 +191,7 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (playErrorCount.value >= MAX_RETRY_COUNT) {
|
if (playErrorCount.value >= MAX_RETRY_COUNT) {
|
||||||
window.electronAPI.mpv.pause();
|
window.electronAPI.qzplayer.pause();
|
||||||
isPlaying.value = false;
|
isPlaying.value = false;
|
||||||
MessagePlugin.error('连续多次播放失败,已停止播放');
|
MessagePlugin.error('连续多次播放失败,已停止播放');
|
||||||
playErrorCount.value = 0;
|
playErrorCount.value = 0;
|
||||||
@@ -197,16 +204,18 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
|
|
||||||
// Listeners
|
// Listeners
|
||||||
if (window.electronAPI) {
|
if (window.electronAPI) {
|
||||||
window.electronAPI.mpv.onEvent((_event, data) => {
|
window.electronAPI.qzplayer.onEvent((_event, data) => {
|
||||||
if (data.event === 'property-change') {
|
if (data.event === 'property-change') {
|
||||||
if (data.name === 'pause') {
|
if (data.name === 'pause') {
|
||||||
const isPaused = data.data;
|
const isPaused = data.data;
|
||||||
isPlaying.value = !isPaused;
|
isPlaying.value = !isPaused;
|
||||||
// 核心:MPV 暂停 -> 同步暂停 Dummy -> 浏览器更新 SMTC 状态
|
// 核心:qzplayer 暂停 -> 同步暂停 Dummy -> 浏览器更新 SMTC 状态
|
||||||
syncDummyAudioState(!isPaused);
|
syncDummyAudioState(!isPaused);
|
||||||
}
|
}
|
||||||
if (data.name === 'time-pos') currentTime.value = data.data;
|
if (data.name === 'time-pos') currentTime.value = data.data;
|
||||||
if (data.name === 'duration') duration.value = data.data;
|
if (data.name === 'duration') duration.value = data.data;
|
||||||
|
if (data.name === 'loudness') loudness.value = data.data;
|
||||||
|
if (data.name === 'spectrum') spectrum.value = data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.event === 'end-file') {
|
if (data.event === 'end-file') {
|
||||||
@@ -221,16 +230,16 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const togglePlay = async () => {
|
const togglePlay = async () => {
|
||||||
await window.electronAPI.mpv.togglePause();
|
await window.electronAPI.qzplayer.togglePause();
|
||||||
};
|
};
|
||||||
|
|
||||||
const setVolume = async (vol: number) => {
|
const setVolume = async (vol: number) => {
|
||||||
volume.value = vol;
|
volume.value = vol;
|
||||||
await window.electronAPI.mpv.setVolume(vol);
|
await window.electronAPI.qzplayer.setVolume(vol);
|
||||||
};
|
};
|
||||||
|
|
||||||
const seek = async (time: number) => {
|
const seek = async (time: number) => {
|
||||||
await window.electronAPI.mpv.seek(time);
|
await window.electronAPI.qzplayer.seek(time);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleMode = () => {
|
const toggleMode = () => {
|
||||||
@@ -239,6 +248,10 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
else playMode.value = PlayMode.List;
|
else playMode.value = PlayMode.List;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleFullScreen = () => {
|
||||||
|
isPlayerFullScreen.value = !isPlayerFullScreen.value;
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isPlaying,
|
isPlaying,
|
||||||
currentSong,
|
currentSong,
|
||||||
@@ -247,6 +260,9 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
currentTime,
|
currentTime,
|
||||||
playlist,
|
playlist,
|
||||||
playMode,
|
playMode,
|
||||||
|
loudness,
|
||||||
|
spectrum,
|
||||||
|
isPlayerFullScreen,
|
||||||
setPlaylist,
|
setPlaylist,
|
||||||
playSong,
|
playSong,
|
||||||
next,
|
next,
|
||||||
@@ -254,6 +270,7 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
togglePlay,
|
togglePlay,
|
||||||
setVolume,
|
setVolume,
|
||||||
seek,
|
seek,
|
||||||
toggleMode
|
toggleMode,
|
||||||
|
toggleFullScreen
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
2
src/renderer/types/electron.d.ts
vendored
2
src/renderer/types/electron.d.ts
vendored
@@ -3,7 +3,7 @@ export interface IElectronAPI {
|
|||||||
maximizeWindow: () => void;
|
maximizeWindow: () => void;
|
||||||
closeWindow: () => void;
|
closeWindow: () => void;
|
||||||
isMaximized: () => Promise<boolean>;
|
isMaximized: () => Promise<boolean>;
|
||||||
mpv: {
|
qzplayer: {
|
||||||
load: (url: string) => Promise<void>;
|
load: (url: string) => Promise<void>;
|
||||||
play: () => Promise<void>;
|
play: () => Promise<void>;
|
||||||
pause: () => Promise<void>;
|
pause: () => Promise<void>;
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|
||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
|
import vueJsx from "@vitejs/plugin-vue-jsx";
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import electron from 'vite-plugin-electron/simple'
|
import electron from 'vite-plugin-electron/simple'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
url: 'url',
|
||||||
|
},
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
vue(),
|
vue(),
|
||||||
|
vueJsx(),
|
||||||
electron({
|
electron({
|
||||||
main: {
|
main: {
|
||||||
// Shortcut of `build.lib.entry`.
|
// Shortcut of `build.lib.entry`.
|
||||||
@@ -26,4 +33,7 @@ export default defineConfig({
|
|||||||
: {},
|
: {},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user