Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,7 @@ env-config.js

out

.pnpm-store/*
.pnpm-store/*

# Compiled native binary (built from native/avplayer-helper.swift via pnpm build:native:mac)
native/avplayer-helper
3 changes: 3 additions & 0 deletions electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ mac:
notarize: false
identity: "-"
icon: ./build/icon.icns
extraResources:
- from: native/avplayer-helper
to: avplayer-helper
target:
- target: dmg
arch:
Expand Down
153 changes: 153 additions & 0 deletions electron/main/core/avPlayerBridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { ChildProcess, spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { platform } from '@electron-toolkit/utils'
import { app, BrowserWindow, ipcMain } from 'electron'
import {
AvPlayerCommandPayload,
AvPlayerEventPayload,
IpcChannels,
} from '../../preload/types'
import {
registerProxyStream,
startAvPlayerProxy,
stopAvPlayerProxy,
unregisterProxyStream,
} from './avPlayerProxy'

let helperProcess: ChildProcess | null = null

function getHelperPath(): string {
const binaryName = 'avplayer-helper'
if (process.env.NODE_ENV === 'development') {
return join(app.getAppPath(), 'native', binaryName)
}
return join(process.resourcesPath, binaryName)
}

function sendToHelper(payload: object) {
if (!helperProcess?.stdin) {
console.warn(
'[AVPlayerBridge] sendToHelper: no helper process stdin',
payload,
)
return
}
helperProcess.stdin.write(JSON.stringify(payload) + '\n')
}

export async function initAvPlayerBridge(window: BrowserWindow) {
if (!platform.isMacOS) {
console.log('[AVPlayerBridge] skipping — not macOS')
return
}
if (helperProcess) {
console.warn(
'[AVPlayerBridge] initAvPlayerBridge called while already initialized — ignoring',
)
return
}

const helperPath = getHelperPath()
console.log(
'[AVPlayerBridge] helper path:',
helperPath,
'exists:',
existsSync(helperPath),
)

if (!existsSync(helperPath)) {
console.warn(
'[AVPlayerBridge] helper binary not found — run pnpm build:native:mac',
)
return
}

// Start local HTTP proxy — Electron's subprocess can't make direct outgoing
// HTTPS requests on macOS, but can connect to localhost. The proxy uses
// Electron's net module to fetch the actual stream and serves it locally.
const port = await startAvPlayerProxy()
console.log('[AVPlayerBridge] proxy started on port', port)

// Minimal environment — passing the full process.env leaks Electron/Chromium
// IPC socket paths and Mach port variables that interfere with AVFoundation's
// own XPC and networking channels inside the subprocess.
const env: NodeJS.ProcessEnv = {
HOME: process.env.HOME,
TMPDIR: process.env.TMPDIR,
USER: process.env.USER,
PATH: process.env.PATH || '/usr/bin:/bin:/usr/sbin:/sbin',
}

helperProcess = spawn(helperPath, [], {
stdio: ['pipe', 'pipe', 'pipe'],
env,
})
console.log('[AVPlayerBridge] helper spawned, pid:', helperProcess.pid)

let lineBuffer = ''
// stdout is non-null: we spawned with stdio: ['pipe', 'pipe', 'pipe']
helperProcess.stdout!.setEncoding('utf8')
helperProcess.stdout!.on('data', (chunk: string) => {
lineBuffer += chunk
const lines = lineBuffer.split('\n')
lineBuffer = lines.pop() ?? ''

for (const line of lines) {
if (!line.trim()) continue
try {
const event = JSON.parse(line) as AvPlayerEventPayload
if (!window.isDestroyed()) {
window.webContents.send(IpcChannels.AvPlayerEvent, event)
}
} catch {
console.warn('[AVPlayerBridge] malformed line from helper:', line)
}
}
})

helperProcess.stderr!.setEncoding('utf8')
helperProcess.stderr!.on('data', (chunk: string) => {
console.log('[avplayer-helper]', chunk.trim())
})

helperProcess.on('exit', (code, signal) => {
console.log(
'[AVPlayerBridge] helper exited — code:',
code,
'signal:',
signal,
)
helperProcess = null
})

helperProcess.on('error', (err) => {
console.error('[AVPlayerBridge] helper spawn error:', err)
})

ipcMain.on(
IpcChannels.AvPlayerCommand,
(_, payload: AvPlayerCommandPayload) => {
if (payload.type === 'load') {
// Replace the remote URL with a proxy localhost URL so the helper can
// reach the stream (direct HTTPS from the subprocess is blocked by macOS).
const proxyUrl = registerProxyStream(payload.id, payload.url)
sendToHelper({ ...payload, url: proxyUrl })
} else {
if (payload.type === 'destroy') unregisterProxyStream(payload.id)
sendToHelper(payload)
}
},
)

console.log('[AVPlayerBridge] ready')
}

export function destroyAvPlayerBridge() {
ipcMain.removeAllListeners(IpcChannels.AvPlayerCommand)
if (helperProcess) {
helperProcess.kill()
helperProcess = null
}
stopAvPlayerProxy()
}
111 changes: 111 additions & 0 deletions electron/main/core/avPlayerProxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import * as http from 'node:http'
import type { AddressInfo } from 'node:net'
import { net } from 'electron'

let server: http.Server | null = null
let proxyPort = 0

// id → original URL
const streams = new Map<string, string>()

// Hop-by-hop headers must not be forwarded between proxy hops. Forwarding
// transfer-encoding in particular confuses AVFoundation because Node has
// already decoded chunked upstream data before we re-send it.
const HOP_BY_HOP = new Set([
'transfer-encoding',
'connection',
'keep-alive',
'proxy-authenticate',
'proxy-authorization',
'te',
'trailer',
'upgrade',
])

function handleRequest(req: http.IncomingMessage, res: http.ServerResponse) {
// Strip query string — the ?t= cache-buster is only for AVFoundation's URL cache
const id = (req.url?.split('?')[0] ?? '').replace(/^\//, '')
const url = streams.get(id)

if (!url) {
res.writeHead(404)
res.end()
return
}

const headers: Record<string, string> = {}
if (req.headers.range) headers.Range = req.headers.range as string

const upstream = net.request({ url, method: 'GET', headers })

upstream.on('response', (upRes) => {
const forwardHeaders: Record<string, string | string[]> = {}
for (const [k, v] of Object.entries(upRes.headers)) {
if (v !== undefined && !HOP_BY_HOP.has(k.toLowerCase())) {
forwardHeaders[k] = v as string | string[]
}
}
// Tell AVFoundation the connection closes after this response.
// Without this, the keep-alive connection stays open and AVFoundation
// treats the stream as a live/infinite source, never firing
// AVPlayerItemDidPlayToEndTime.
forwardHeaders.connection = 'close'
res.writeHead(upRes.statusCode ?? 200, forwardHeaders)

let done = false
upRes.on('data', (chunk: Buffer) => res.write(chunk))
upRes.on('end', () => {
done = true
res.end()
})
upRes.on('error', (err) => {
// Ignore errors after the response completes — these are harmless abort
// signals from req.on('close') cleaning up an already-finished upstream.
if (!done) {
console.error('[AVPlayerProxy] upstream error:', err.message)
res.destroy()
}
})
})

upstream.on('error', (err) => {
console.error('[AVPlayerProxy] request error:', err.message)
if (!res.headersSent) res.writeHead(502)
res.end()
})

req.on('close', () => upstream.abort())
upstream.end()
}

export function startAvPlayerProxy(): Promise<number> {
if (server) return Promise.resolve(proxyPort)

return new Promise((resolve, reject) => {
server = http.createServer(handleRequest)
server.on('error', reject)
server.listen(0, '127.0.0.1', () => {
proxyPort = (server!.address() as AddressInfo).port
console.log('[AVPlayerProxy] listening on port', proxyPort)
resolve(proxyPort)
})
})
}

export function stopAvPlayerProxy() {
server?.close()
server = null
proxyPort = 0
streams.clear()
}

export function registerProxyStream(id: string, url: string): string {
streams.set(id, url)
// The ?t= cache-buster ensures AVFoundation doesn't reuse a cached response
// from a previous song that happened to use the same player id.
return `http://127.0.0.1:${proxyPort}/${id}?t=${Date.now()}`
}

export function unregisterProxyStream(id: string) {
streams.delete(id)
}
7 changes: 6 additions & 1 deletion electron/main/core/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@ import {
} from '../../preload/types'
import { isQuitting } from '../index'
import { tray, updateTray } from '../tray'
import { updateDockMenu } from './dockMenu'
import { destroyAvPlayerBridge, initAvPlayerBridge } from './avPlayerBridge'
import { colorsState } from './colors'
import {
clearDiscordRpcActivity,
RpcPayload,
setDiscordRpcActivity,
} from './discordRpc'
import { updateDockMenu } from './dockMenu'
import { playerState } from './playerState'
import { getAppSetting, ISettingPayload, saveAppSettings } from './settings'
import { setTaskbarButtons } from './taskbar'
Expand Down Expand Up @@ -104,6 +105,10 @@ export function setupIpcEvents(window: BrowserWindow | null) {
if (!window) return

resetIpcEvents()
destroyAvPlayerBridge()
initAvPlayerBridge(window).catch((err) =>
console.error('[AVPlayerBridge] init failed:', err),
)

ipcMain.on(IpcChannels.ToggleFullscreen, (_, isFullscreen: boolean) => {
window.setFullScreen(isFullscreen)
Expand Down
19 changes: 18 additions & 1 deletion electron/preload/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { electronAPI } from '@electron-toolkit/preload'
import { contextBridge, ipcRenderer } from 'electron'
import { IAonsokuAPI, IpcChannels, PlayerStateListenerActions } from './types'
import {
AvPlayerCommandPayload,
AvPlayerEventPayload,
IAonsokuAPI,
IpcChannels,
PlayerStateListenerActions,
} from './types'

// Custom APIs for renderer
const api: IAonsokuAPI = {
Expand Down Expand Up @@ -82,6 +88,17 @@ const api: IAonsokuAPI = {
onUpdateDownloaded: (callback) => {
ipcRenderer.on(IpcChannels.UpdateDownloaded, (_, info) => callback(info))
},
avPlayer: {
command: (payload: AvPlayerCommandPayload) => {
ipcRenderer.send(IpcChannels.AvPlayerCommand, payload)
},
onEvent: (callback: (event: AvPlayerEventPayload) => void) => {
const listener = (_: Electron.IpcRendererEvent, event: AvPlayerEventPayload) =>
callback(event)
ipcRenderer.on(IpcChannels.AvPlayerEvent, listener)
return () => ipcRenderer.removeListener(IpcChannels.AvPlayerEvent, listener)
},
},
}

// Use `contextBridge` APIs to expose Electron APIs to
Expand Down
32 changes: 32 additions & 0 deletions electron/preload/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,32 @@ import { RpcPayload } from '../main/core/discordRpc'
import { IDownloadPayload } from '../main/core/downloads'
import { ISettingPayload } from '../main/core/settings'

export type AvPlayerCommandPayload =
| { type: 'load'; id: string; url: string }
| { type: 'play'; id: string }
| { type: 'pause'; id: string }
| { type: 'seek'; id: string; seconds: number }
| { type: 'setVolume'; id: string; value: number }
| { type: 'setLoop'; id: string; loop: boolean }
| { type: 'setRate'; id: string; rate: number }
| { type: 'destroy'; id: string }
| { type: 'showAirPlay'; x: number; y: number; height: number }

export type AvPlayerEventPayload = {
id: string
type:
| 'play'
| 'pause'
| 'timeupdate'
| 'loadedmetadata'
| 'ended'
| 'loadstart'
| 'error'
time?: number
duration?: number
message?: string
}

export enum IpcChannels {
FullscreenStatus = 'fullscreen-status',
ToggleFullscreen = 'toggle-fullscreen',
Expand Down Expand Up @@ -35,6 +61,8 @@ export enum IpcChannels {
UpdateError = 'update-error',
DownloadProgress = 'download-progress',
UpdateDownloaded = 'update-downloaded',
AvPlayerCommand = 'avplayer-command',
AvPlayerEvent = 'avplayer-event',
}

export type OverlayColors = {
Expand Down Expand Up @@ -89,4 +117,8 @@ export interface IAonsokuAPI {
onUpdateError: (callback: (error: string) => void) => void
onDownloadProgress: (callback: (progress: ProgressInfo) => void) => void
onUpdateDownloaded: (callback: (info: UpdateDownloadedEvent) => void) => void
avPlayer: {
command: (payload: AvPlayerCommandPayload) => void
onEvent: (callback: (event: AvPlayerEventPayload) => void) => () => void
}
}
Loading