From 0c0189bcef55b62b66e7b88f24e62fafa0647f98 Mon Sep 17 00:00:00 2001 From: chengww Date: Sun, 17 May 2026 19:48:43 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(local-music):=20=E6=B3=A8=E5=86=8C=20lo?= =?UTF-8?q?cal=20=E5=8D=8F=E8=AE=AE=E4=B8=BA=E7=89=B9=E6=9D=83=20scheme=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=9C=AC=E5=9C=B0=E9=9F=B3=E4=B9=90=20CORS?= =?UTF-8?q?=20=E6=8A=A5=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - main/index.ts 在 app.whenReady 之前调用 registerSchemesAsPrivileged, 把 local 标记为 standard/secure/supportFetchAPI/stream/corsEnabled/bypassCSP, 让 http 页面(dev server / 生产)可跨协议加载本地音频 - fileManager.ts 用 Electron 25 起推荐的 protocol.handle 替代已弃用的 registerFileProtocol,内部转发到 file:// 自动支持 Range / 流式播放 - 修复 macOS/Linux 上去掉前导斜杠后丢失绝对路径前缀的问题 --- src/main/index.ts | 19 ++++++++++++++++- src/main/modules/fileManager.ts | 37 +++++++++++++++++++-------------- 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 40190df..150ebc6 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,7 +1,24 @@ import { electronApp, optimizer } from '@electron-toolkit/utils'; -import { app, ipcMain, nativeImage, session } from 'electron'; +import { app, ipcMain, nativeImage, protocol, session } from 'electron'; import { join } from 'path'; +// 必须在 app.whenReady() 之前注册自定义协议为特权协议, +// 否则 http(s) 页面(dev server、生产环境的 file://)无法把 local:// 当成 +// 安全/可 fetch/可流式的资源加载,会触发 CORS 拦截或 net::ERR_UNKNOWN_URL_SCHEME +protocol.registerSchemesAsPrivileged([ + { + scheme: 'local', + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + stream: true, + bypassCSP: true, + corsEnabled: true + } + } +]); + import type { Language } from '../i18n/main'; import i18n from '../i18n/main'; import { loadLyricWindow } from './lyric'; diff --git a/src/main/modules/fileManager.ts b/src/main/modules/fileManager.ts index ce6e823..45ae3ef 100644 --- a/src/main/modules/fileManager.ts +++ b/src/main/modules/fileManager.ts @@ -1,7 +1,8 @@ -import { app, dialog, ipcMain, protocol, shell } from 'electron'; +import { app, dialog, ipcMain, net, protocol, shell } from 'electron'; import Store from 'electron-store'; import * as fs from 'fs'; import * as path from 'path'; +import { pathToFileURL } from 'url'; import { getStore } from './config'; @@ -28,31 +29,35 @@ function sanitizeFilename(filename: string): string { */ export function initializeFileManager() { // 注册本地文件协议 - protocol.registerFileProtocol('local', (request, callback) => { + // Electron 25+ 起 registerFileProtocol 已弃用,改用 protocol.handle,并配合 main/index.ts + // 中的 registerSchemesAsPrivileged,让 audio 元素能从 http(s) 页面跨协议加载本地文件 + protocol.handle('local', async (request) => { try { - const url = request.url; - // local://C:/Users/xxx.mp3 - let filePath = decodeURIComponent(url.replace('local:///', '')); + // local:/// + let filePath = decodeURIComponent(request.url.replace(/^local:\/\/\/?/, '')); - // 兼容 local:///C:/Users/xxx.mp3 这种情况 + // Windows: 协议解析后可能是 /C:/...,去掉前导斜杠 if (/^\/[a-zA-Z]:\//.test(filePath)) { filePath = filePath.slice(1); } - // 还原为系统路径格式 - filePath = path.normalize(filePath); - - // 检查文件是否存在 - if (!fs.existsSync(filePath)) { - console.error('File not found:', filePath); - callback({ error: -6 }); // net::ERR_FILE_NOT_FOUND - return; + // macOS/Linux 上去掉前导斜杠后会丢失绝对路径标识,这里补回 + if (process.platform !== 'win32' && !filePath.startsWith('/')) { + filePath = '/' + filePath; } - callback({ path: filePath }); + filePath = path.normalize(filePath); + + if (!fs.existsSync(filePath)) { + console.error('File not found:', filePath); + return new Response(null, { status: 404 }); + } + + // 用 net.fetch 走 file:// 加载,自动支持 Range / streaming,让媒体元素能正常 seek + return net.fetch(pathToFileURL(filePath).toString()); } catch (error) { console.error('Error handling local protocol:', error); - callback({ error: -2 }); // net::FAILED + return new Response(null, { status: 500 }); } }); From 51f1aaba55643c314a19dd2c3c9969908f8fe3d7 Mon Sep 17 00:00:00 2001 From: chengww Date: Sun, 17 May 2026 19:48:58 +0800 Subject: [PATCH 2/4] =?UTF-8?q?chore:=20=E5=BF=BD=E7=95=A5=20*.tsbuildinfo?= =?UTF-8?q?=20=E7=BC=96=E8=AF=91=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 1edb4af..9f812f4 100644 --- a/.gitignore +++ b/.gitignore @@ -46,5 +46,8 @@ AGENTS.md .auto-imports.d.ts .components.d.ts +# TypeScript 增量编译缓存 +*.tsbuildinfo + src/renderer/auto-imports.d.ts src/renderer/components.d.ts From c15a10c0983879731fc692638325f0c7fcf9d33b Mon Sep 17 00:00:00 2001 From: chengww Date: Sun, 17 May 2026 20:16:25 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(local-music):=20=E4=B8=BB=E8=BF=9B?= =?UTF-8?q?=E7=A8=8B=E5=AE=9E=E7=8E=B0=20Range/206=20=E8=AE=A9=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E9=9F=B3=E4=B9=90=20seek/=E5=BF=AB=E8=BF=9B=E6=81=A2?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 切到 protocol.handle 后,audio 元素发的 Range 请求会带 HTTP 头到达 主进程;net.fetch(file://) 既不识别 Range 也不带 Accept-Ranges,导致 浏览器认为 local:// 不支持随机访问,seek 时只能从头加载,表现为 快进失效、退出后恢复播放从 0 开始。 新增 buildLocalFileResponse: - 无 Range:200 + Accept-Ranges: bytes,让 audio 知道支持随机访问 - 有 Range:206 + Content-Range,用 fs.createReadStream 切片返回 - 非法 Range:416 + Content-Range: bytes */total 顺手把 fs.existsSync 换成 stat,一次拿文件大小且过滤掉目录。 --- src/main/modules/fileManager.ts | 49 +++++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/src/main/modules/fileManager.ts b/src/main/modules/fileManager.ts index 45ae3ef..b3da081 100644 --- a/src/main/modules/fileManager.ts +++ b/src/main/modules/fileManager.ts @@ -1,8 +1,8 @@ -import { app, dialog, ipcMain, net, protocol, shell } from 'electron'; +import { app, dialog, ipcMain, protocol, shell } from 'electron'; import Store from 'electron-store'; import * as fs from 'fs'; import * as path from 'path'; -import { pathToFileURL } from 'url'; +import { Readable } from 'stream'; import { getStore } from './config'; @@ -24,6 +24,45 @@ function sanitizeFilename(filename: string): string { .trim(); } +// Electron net.fetch(file://) 在当前版本不会回 206,audio seek 需要主进程自己处理 Range +function buildLocalFileResponse( + filePath: string, + total: number, + rangeHeader: string | null +): Response { + const range416 = () => + new Response(null, { status: 416, headers: { 'Content-Range': `bytes */${total}` } }); + + let start = 0; + let end = total - 1; + let partial = false; + + if (rangeHeader) { + const m = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim()); + if (!m || (!m[1] && !m[2])) return range416(); + if (m[1]) { + start = parseInt(m[1], 10); + if (m[2]) end = Math.min(parseInt(m[2], 10), end); + } else { + start = Math.max(0, total - parseInt(m[2], 10)); + } + if (start > end || start >= total) return range416(); + partial = true; + } + + return new Response( + Readable.toWeb(fs.createReadStream(filePath, { start, end })) as ReadableStream, + { + status: partial ? 206 : 200, + headers: { + 'Content-Length': String(end - start + 1), + 'Accept-Ranges': 'bytes', + ...(partial && { 'Content-Range': `bytes ${start}-${end}/${total}` }) + } + } + ); +} + /** * 初始化文件管理相关的IPC监听 */ @@ -48,13 +87,13 @@ export function initializeFileManager() { filePath = path.normalize(filePath); - if (!fs.existsSync(filePath)) { + const stat = await fs.promises.stat(filePath).catch(() => null); + if (!stat?.isFile()) { console.error('File not found:', filePath); return new Response(null, { status: 404 }); } - // 用 net.fetch 走 file:// 加载,自动支持 Range / streaming,让媒体元素能正常 seek - return net.fetch(pathToFileURL(filePath).toString()); + return buildLocalFileResponse(filePath, stat.size, request.headers.get('range')); } catch (error) { console.error('Error handling local protocol:', error); return new Response(null, { status: 500 }); From fa818a020f4fa52ca39d76ee8cc2eca3a075e562 Mon Sep 17 00:00:00 2001 From: chengww Date: Sun, 17 May 2026 20:46:47 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(player):=20=E5=8F=8C=E5=87=BB=E6=AD=8C?= =?UTF-8?q?=E6=9B=B2=E6=97=B6=E5=90=8C=E6=AD=A5=E8=AE=BE=E7=BD=AE=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E5=88=97=E8=A1=A8=E4=B8=8A=E4=B8=8B=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BaseSongItem 双击和右键菜单"播放"只调了 setPlay,未 emit 'play', 导致父组件 setPlayList 不会执行,上下一首切换失效。抽出统一入口 先 emit 再触发播放,与点击播放按钮行为对齐。 --- .../components/common/songItemCom/BaseSongItem.vue | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/renderer/components/common/songItemCom/BaseSongItem.vue b/src/renderer/components/common/songItemCom/BaseSongItem.vue index 9689c4b..f37f620 100644 --- a/src/renderer/components/common/songItemCom/BaseSongItem.vue +++ b/src/renderer/components/common/songItemCom/BaseSongItem.vue @@ -4,7 +4,7 @@ @contextmenu.prevent="handleContextMenu" @mouseenter="handleMouseEnter" @mouseleave="handleMouseLeave" - @dblclick.stop="playMusicEvent(item)" + @dblclick.stop="handlePlay(item)" > @@ -22,7 +22,7 @@ :is-dislike="isDislike" :can-remove="canRemove" @update:show="showDropdown = $event" - @play="playMusicEvent(item)" + @play="handlePlay(item)" @play-next="handlePlayNext" @download="downloadMusic(item)" @download-lyric="downloadLyric(item)" @@ -83,6 +83,12 @@ const imageLoad = async (event: Event) => { await handleImageLoad(target); }; +// 双击和右键菜单"播放"统一入口:先通知父组件设置播放列表上下文,再触发播放 +const handlePlay = (song: SongResult) => { + emits('play', song); + playMusicEvent(song); +}; + // 切换选择状态 const toggleSelect = () => { emits('select', props.item.id, !props.selected);