electron播放本地音乐的问题

前言

最近在写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个版本都不行),欢迎评论

相关推荐
艾伦野鸽ggg1 小时前
CSS布局与动效知识梳理
前端·css
介一安全1 小时前
【漏洞学习】聊天机器人安全漏洞实战:服务器端JavaScript注入探秘
javascript·安全性测试·聊天机器人
ljt27249606611 小时前
Vue笔记(二)--组件的属性和方法
前端·vue.js·笔记
Boop_wu1 小时前
[前端] CSS 常用样式(聊天界面 / 网页布局专用)
前端·css·css3
声声codeGrandMaster1 小时前
React框架的基础代码使用
前端·react.js·前端框架
叫我少年1 小时前
Vue 3 集成 Vue Router:从基础配置到项目实践
前端·路由
Highcharts.js1 小时前
Highcharts React 5.0 正式版:支持 ES 模块化、组件更精简、开发体验全面升级
前端·javascript·react.js·elasticsearch·前端框架·highcharts
大江东去浪淘尽千古风流人物1 小时前
【X-Restormer++】全天候图像恢复赛冠军方案:三项创新解析及对VIO/SLAM前端的工程价值
前端
LaughingZhu1 小时前
Claude Code 时代的写作:为什么 HTML 正在取代 Markdown
前端·人工智能·html