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 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..b3da081 100644 --- a/src/main/modules/fileManager.ts +++ b/src/main/modules/fileManager.ts @@ -2,6 +2,7 @@ import { app, dialog, ipcMain, protocol, shell } from 'electron'; import Store from 'electron-store'; import * as fs from 'fs'; import * as path from 'path'; +import { Readable } from 'stream'; import { getStore } from './config'; @@ -23,36 +24,79 @@ 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监听 */ 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); + + const stat = await fs.promises.stat(filePath).catch(() => null); + if (!stat?.isFile()) { + console.error('File not found:', filePath); + return new Response(null, { status: 404 }); + } + + return buildLocalFileResponse(filePath, stat.size, request.headers.get('range')); } catch (error) { console.error('Error handling local protocol:', error); - callback({ error: -2 }); // net::FAILED + return new Response(null, { status: 500 }); } }); 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);