前言
最近在写electron,发现了一个问题,关于读取本地音频文件播放无法快进的问题
一、现象
在比较新版本的electron,使用 protocol.handle去自定义协议读取本地文件然后发送给前端文件地址
这种方式是可以让音频文件播放,但是有一个很大的问题就是无法跳转快进的功能
下面是electron GitHub的issues
issues
可以看到,这个问题23年就已经出现了,最新的回复是两周前
二、解决方式
如果不考虑快进的问题,那是不需要解决的,但是我相信没有哪个产品允许不能跳转功能的
所以,必须要解决这个问题;解决方式也比较简单,就是启动项目的时候同时启动一个服务,将本地文件通过http协议进行发送,就相当于是从服务端读取的音频文件
代码如下:
main.ts
javascript
import { app, shell, BrowserWindow, ipcMain, dialog } from 'electron'
import { join } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset'
import { startLocalServer, registerFile, getServerPort } from './local-audio-server'
function createWindow(): void {
// 创建主窗口
const mainWindow = new BrowserWindow({
width: 900,
height: 670,
show: false,
autoHideMenuBar: true,
...(process.platform === 'linux' ? { icon } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false
}
})
mainWindow.on('ready-to-show', () => {
mainWindow.show()
})
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
// 开发模式走 vite 的 dev server,生产模式加载打包后的 html
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
}
// 应用就绪后启动本地音频服务,并注册 IPC 接口
app.whenReady().then(async () => {
// 先把本地音频服务跑起来,拿到端口后再继续后续初始化
try {
const port = await startLocalServer()
console.log(`[local-audio-server] 已启动: http://127.0.0.1:${port}`)
} catch (err) {
console.error('[local-audio-server] 启动失败:', err)
}
// 设置应用 ID(Windows 任务栏归类用)
electronApp.setAppUserModelId('com.electron')
// 开发模式下注册快捷键工具
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
// 简易 IPC 测试通道
ipcMain.on('ping', () => console.log('pong'))
// 弹出文件选择框,仅允许常见音频格式
ipcMain.handle('selectFile', async () => {
return dialog.showOpenDialog({
properties: ['openFile'],
filters: [{ name: 'Audio', extensions: ['mp3', 'flac', 'wav', 'ogg', 'aac', 'm4a'] }]
})
})
// 把渲染进程传过来的本地路径注册到 HTTP 服务上,返回可播放的 URL
ipcMain.handle('registerAudioFile', (_event, filePath: string) => {
return registerFile(filePath)
})
// 暴露端口号,方便前端做调试/状态展示
ipcMain.handle('getAudioServerPort', () => getServerPort())
createWindow()
app.on('activate', function () {
// macOS 上点击 dock 图标且没有窗口时重新创建窗口
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
// 关闭所有窗口时退出应用(macOS 除外)
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
// 此处可继续追加其它主进程逻辑
local-audio-server.ts
javascript
// =====================================================================
// 本地音频 HTTP 服务
// ---------------------------------------------------------------------
// 思路:自定义协议(local-file://)在某些场景下会被 Chromium 的安全策略
// 拦截,也不利于第三方库使用。这里在主进程启动一个仅监听 127.0.0.1
// 的 HTTP 服务,对外暴露 `/play/<token>` 接口,由渲染进程通过
// <audio> 标签直接以普通的 http 请求方式播放本地文件。
//
// 模块内部维护 token <-> 绝对路径 的双向映射,token 由 crypto 随机
// 生成,避免本地路径直接出现在 URL 中。
// =====================================================================
import http from 'http'
import { createReadStream, statSync, existsSync } from 'fs'
import { extname } from 'path'
import { randomBytes } from 'crypto'
// token -> 绝对路径(实际暴露给 HTTP 的资源映射表)
const tokenToPath = new Map<string, string>()
// 绝对路径 -> token(避免同一文件多次注册时生成不同的 token)
const pathToToken = new Map<string, string>()
// 服务监听到的端口(由系统在启动时分配)
let serverPort = 0
// 服务实例的单例引用,避免重复启动
let serverInstance: http.Server | null = null
// 常见音频扩展名对应的 MIME 类型,确保浏览器能正确识别音频流
const MIME_MAP: Record<string, string> = {
'.mp3': 'audio/mpeg',
'.flac': 'audio/flac',
'.wav': 'audio/wav',
'.ogg': 'audio/ogg',
'.aac': 'audio/aac',
'.m4a': 'audio/mp4'
}
/**
* 根据文件后缀名返回 MIME 类型,找不到时使用通用的二进制流类型
*/
function getMimeType(filePath: string): string {
return MIME_MAP[extname(filePath).toLowerCase()] || 'application/octet-stream'
}
/**
* 处理一次 HTTP 请求:解析 token、读取文件并按需返回 Range 数据
*/
function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
try {
// 处理预检请求,简单放行(仅本地访问)
if (req.method === 'OPTIONS') {
res.writeHead(204, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': '*',
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS'
})
res.end()
return
}
const url = new URL(req.url || '/', `http://${req.headers.host}`)
// 仅匹配 /play/<token>,token 由 hex 字符串组成
const match = url.pathname.match(/^\/play\/([a-f0-9]+)$/)
if (!match) {
res.statusCode = 404
res.end('Not Found')
return
}
const token = match[1]
const filePath = tokenToPath.get(token)
if (!filePath || !existsSync(filePath)) {
res.statusCode = 404
res.end('File not found')
return
}
const stat = statSync(filePath)
const fileSize = stat.size
const mime = getMimeType(filePath)
const range = req.headers.range
if (range) {
// 解析 "Range: bytes=start-end",支持音频跳转/拖动
const parts = range.replace(/bytes=/, '').split('-')
const start = parseInt(parts[0], 10)
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1
// 防止非法范围导致 socket 异常
if (isNaN(start) || start >= fileSize || end >= fileSize) {
res.writeHead(416, {
'Content-Range': `bytes */${fileSize}`
})
res.end()
return
}
const chunkSize = end - start + 1
res.writeHead(206, {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunkSize,
'Content-Type': mime,
'Access-Control-Allow-Origin': '*'
})
createReadStream(filePath, { start, end }).pipe(res)
} else {
// 没有 Range 时返回整个文件
res.writeHead(200, {
'Content-Length': fileSize,
'Content-Type': mime,
'Accept-Ranges': 'bytes',
'Access-Control-Allow-Origin': '*'
})
createReadStream(filePath).pipe(res)
}
} catch (err) {
console.error('[local-audio-server] 请求处理失败:', err)
if (!res.headersSent) {
res.statusCode = 500
res.end('Internal Server Error')
}
}
}
/**
* 启动本地音频 HTTP 服务
* - 只监听 127.0.0.1,避免把本机文件暴露到局域网
* - 端口使用 0 让系统自动分配,避免与其他程序冲突
* - 支持 Range 请求,便于 <audio> 标签拖动进度条
* - 重复调用时直接返回已分配的端口(幂等)
*/
export function startLocalServer(): Promise<number> {
// 已启动则直接复用,避免端口重新分配
if (serverInstance && serverPort) {
return Promise.resolve(serverPort)
}
return new Promise((resolve, reject) => {
const server = http.createServer(handleRequest)
// 监听 127.0.0.1:0 让系统自动分配一个可用端口
server.listen(0, '127.0.0.1', () => {
const address = server.address()
if (address && typeof address === 'object') {
serverInstance = server
serverPort = address.port
resolve(serverPort)
} else {
reject(new Error('无法获取本地音频服务端口'))
}
})
server.on('error', reject)
})
}
/**
* 将本地文件路径注册到 token 表中,返回前端可直接播放的 HTTP URL。
* 同一路径多次注册时复用之前生成的 token。
*/
export function registerFile(filePath: string): string {
if (!serverPort) {
throw new Error('本地音频服务尚未启动,请先调用 startLocalServer()')
}
if (!filePath || !existsSync(filePath)) {
throw new Error(`文件不存在: ${filePath}`)
}
let token = pathToToken.get(filePath)
if (!token) {
token = randomBytes(16).toString('hex')
tokenToPath.set(token, filePath)
pathToToken.set(filePath, token)
}
return `http://127.0.0.1:${serverPort}/play/${token}`
}
/**
* 获取当前服务监听的端口,未启动时返回 0
*/
export function getServerPort(): number {
return serverPort
}
上面的代码时ai生成的,大家也可以自行处理
总结
以上就是我目前解决的方案,如果大家有更好的或者自定义协议本身也可以处理的(自定义协议ai写了n个版本都不行),欢迎评论