desktop-audio-proxy
Version:
A comprehensive audio streaming solution for Tauri and Electron apps that bypasses CORS and WebKit codec issues
464 lines (459 loc) • 19 kB
JavaScript
'use strict';
class AudioProxyClient {
constructor(options = {}) {
this.options = {
proxyUrl: options.proxyUrl || 'http://localhost:3002',
autoDetect: options.autoDetect ?? true,
fallbackToOriginal: options.fallbackToOriginal ?? true,
retryAttempts: options.retryAttempts || 3,
retryDelay: options.retryDelay || 1000,
proxyConfig: options.proxyConfig || {},
};
this.environment = this.detectEnvironment();
}
detectEnvironment() {
if (typeof window === 'undefined') {
return 'unknown';
}
if (window.__TAURI__) {
return 'tauri';
}
if (window.electronAPI ||
(typeof process !== 'undefined' &&
process?.versions &&
process.versions.electron)) {
return 'electron';
}
return 'web';
}
getEnvironment() {
return this.environment;
}
async isProxyAvailable() {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
const response = await fetch(`${this.options.proxyUrl}/health`, {
signal: controller.signal,
method: 'GET',
cache: 'no-cache',
});
clearTimeout(timeoutId);
if (response.ok) {
const data = await response.json();
console.log('[AudioProxyClient] Proxy server available:', data);
return true;
}
return false;
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.warn('[AudioProxyClient] Proxy server unavailable:', errorMessage);
return false;
}
}
async canPlayUrl(url) {
console.log('[AudioProxyClient] Processing URL:', url);
// Check if it's a local file
if (this.isLocalFile(url)) {
console.log('[AudioProxyClient] Using local file handler');
return {
url,
status: 200,
headers: {},
canPlay: true,
requiresProxy: false,
};
}
// Check if proxy is available
const proxyAvailable = await this.isProxyAvailable();
if (proxyAvailable) {
try {
const infoUrl = `${this.options.proxyUrl}/info?url=${encodeURIComponent(url)}`;
const response = await fetch(infoUrl);
if (response.ok) {
const data = await response.json();
const streamInfo = {
url: data.url,
status: data.status,
headers: data.headers || {},
canPlay: true,
requiresProxy: true,
contentType: data.contentType,
contentLength: data.contentLength,
acceptRanges: data.acceptRanges,
lastModified: data.lastModified,
};
console.log('[AudioProxyClient] Stream info:', streamInfo);
return streamInfo;
}
}
catch (error) {
console.warn('[AudioProxyClient] Failed to get stream info via proxy:', error);
}
}
// Fallback: assume it needs proxy
const streamInfo = {
url,
status: 0,
headers: {},
canPlay: false,
requiresProxy: true,
};
console.log('[AudioProxyClient] Stream info:', streamInfo);
return streamInfo;
}
async getPlayableUrl(url) {
console.log('[AudioProxyClient] Processing URL:', url);
// Handle local files
if (this.isLocalFile(url)) {
console.log('[AudioProxyClient] Using local file handler');
return this.handleLocalFile(url);
}
// Check stream info
const streamInfo = await this.canPlayUrl(url);
if (streamInfo.requiresProxy) {
console.log('[AudioProxyClient] Proxy required, checking availability...');
// Try proxy with retries
for (let attempt = 1; attempt <= this.options.retryAttempts; attempt++) {
const proxyAvailable = await this.isProxyAvailable();
if (proxyAvailable) {
console.log('[AudioProxyClient] Generated proxy URL:', `${this.options.proxyUrl}/proxy?url=${encodeURIComponent(url)}`);
return `${this.options.proxyUrl}/proxy?url=${encodeURIComponent(url)}`;
}
if (attempt < this.options.retryAttempts) {
console.log(`[AudioProxyClient] Proxy not available on attempt ${attempt}`);
await this.delay(this.options.retryDelay);
}
}
// Proxy failed, fallback if enabled
if (this.options.fallbackToOriginal) {
console.log('[AudioProxyClient] Falling back to original URL (may have CORS issues)');
return url;
}
else {
throw new Error('Proxy server unavailable and fallback disabled');
}
}
return url;
}
isLocalFile(url) {
return (url.startsWith('/') ||
url.startsWith('./') ||
url.startsWith('../') ||
url.startsWith('file://') ||
url.startsWith('blob:') ||
url.startsWith('data:') ||
!!url.match(/^[a-zA-Z]:\\/)); // Windows path
}
handleLocalFile(url) {
// Handle data: and blob: URLs directly - no conversion needed
if (url.startsWith('data:') || url.startsWith('blob:')) {
return url;
}
// In Tauri, use convertFileSrc for file:// URLs
if (this.environment === 'tauri' && window.__TAURI__) {
try {
const { convertFileSrc } = window.__TAURI__.tauri;
if (url.startsWith('file://') ||
url.startsWith('/') ||
url.match(/^[a-zA-Z]:\\/)) {
return convertFileSrc(url);
}
}
catch (error) {
console.warn('[AudioProxyClient] Failed to convert file source with Tauri:', error);
// Fallback to original URL if conversion fails
}
}
// For other environments or fallback, return as-is
return url;
}
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
function createAudioClient(options) {
return new AudioProxyClient(options);
}
// Type declarations for Tauri API that extends client.ts declarations
class TauriAudioService {
constructor(options = {}) {
// Use audioOptions if provided, otherwise use the options directly
const clientOptions = options.audioOptions || options;
this.audioClient = new AudioProxyClient(clientOptions);
}
async getStreamableUrl(url) {
return await this.audioClient.getPlayableUrl(url);
}
async canPlayStream(url) {
return await this.audioClient.canPlayUrl(url);
}
getEnvironment() {
return this.audioClient.getEnvironment();
}
async isProxyAvailable() {
return await this.audioClient.isProxyAvailable();
}
async checkSystemCodecs() {
const audio = new Audio();
const formats = [
{ name: 'MP3', mime: 'audio/mpeg', codecs: ['mp3'] },
{ name: 'OGG', mime: 'audio/ogg', codecs: ['vorbis', 'opus'] },
{ name: 'WAV', mime: 'audio/wav', codecs: ['pcm'] },
{ name: 'AAC', mime: 'audio/aac', codecs: ['mp4a.40.2'] },
{ name: 'FLAC', mime: 'audio/flac', codecs: ['flac'] },
{ name: 'WEBM', mime: 'audio/webm', codecs: ['vorbis', 'opus'] },
{ name: 'M4A', mime: 'audio/mp4', codecs: ['mp4a.40.2'] },
];
const supportedFormats = [];
const missingCodecs = [];
const capabilities = {};
for (const format of formats) {
let bestSupport = '';
let isSupported = false;
// Test basic MIME type
const basicSupport = audio.canPlayType(format.mime);
capabilities[format.name + '_basic'] = basicSupport;
if (basicSupport === 'probably' || basicSupport === 'maybe') {
bestSupport = basicSupport;
isSupported = true;
}
// Test with codecs
for (const codec of format.codecs) {
const codecSupport = audio.canPlayType(`${format.mime}; codecs="${codec}"`);
capabilities[format.name + '_' + codec] = codecSupport;
if (codecSupport === 'probably') {
bestSupport = 'probably';
isSupported = true;
}
else if (codecSupport === 'maybe' && bestSupport !== 'probably') {
bestSupport = 'maybe';
isSupported = true;
}
}
capabilities[format.name] = bestSupport;
if (isSupported) {
supportedFormats.push(format.name);
}
else {
missingCodecs.push(format.name);
}
}
// Check for additional Tauri-specific audio capabilities if available
if (this.getEnvironment() === 'tauri' && window.__TAURI__?.tauri?.invoke) {
try {
const { invoke } = window.__TAURI__.tauri;
// Try to get system audio info from Tauri backend
const systemAudioInfo = await invoke('get_system_audio_info').catch(() => null);
if (systemAudioInfo && typeof systemAudioInfo === 'object') {
capabilities['tauri_system_info'] = JSON.stringify(systemAudioInfo);
// Enhanced format support based on system capabilities
const audioInfo = systemAudioInfo;
if (audioInfo.supportedFormats) {
audioInfo.supportedFormats.forEach((format) => {
if (!supportedFormats.includes(format)) {
supportedFormats.push(format);
// Remove from missing codecs if it was there
const missingIndex = missingCodecs.indexOf(format);
if (missingIndex > -1) {
missingCodecs.splice(missingIndex, 1);
}
}
});
}
}
}
catch (error) {
console.warn('[TauriAudioService] Could not access Tauri backend for codec detection:', error);
}
}
return { supportedFormats, missingCodecs, capabilities };
}
// Tauri-specific method to get audio file metadata
async getAudioMetadata(filePath) {
if (this.getEnvironment() !== 'tauri' || !window.__TAURI__?.tauri?.invoke) {
return null;
}
try {
const { invoke } = window.__TAURI__.tauri;
const result = await invoke('get_audio_metadata', { path: filePath });
return result;
}
catch (error) {
console.warn('[TauriAudioService] Failed to get audio metadata:', error);
return null;
}
}
// Tauri-specific method to enumerate audio devices
async getAudioDevices() {
if (this.getEnvironment() !== 'tauri' || !window.__TAURI__?.tauri?.invoke) {
return null;
}
try {
const { invoke } = window.__TAURI__.tauri;
const result = await invoke('get_audio_devices');
return result;
}
catch (error) {
console.warn('[TauriAudioService] Failed to get audio devices:', error);
return null;
}
}
}
// Extend the existing Window interface from client.ts - don't redeclare electronAPI
class ElectronAudioService {
constructor(options = {}) {
// Use audioOptions if provided, otherwise use the options directly
const clientOptions = options.audioOptions || options;
this.audioClient = new AudioProxyClient(clientOptions);
}
async getStreamableUrl(url) {
return await this.audioClient.getPlayableUrl(url);
}
async canPlayStream(url) {
return await this.audioClient.canPlayUrl(url);
}
getEnvironment() {
return this.audioClient.getEnvironment();
}
async isProxyAvailable() {
return await this.audioClient.isProxyAvailable();
}
async checkSystemCodecs() {
const audio = new Audio();
const formats = [
{ name: 'MP3', mime: 'audio/mpeg', codecs: ['mp3'] },
{ name: 'OGG', mime: 'audio/ogg', codecs: ['vorbis', 'opus'] },
{ name: 'WAV', mime: 'audio/wav', codecs: ['pcm'] },
{ name: 'AAC', mime: 'audio/aac', codecs: ['mp4a.40.2'] },
{ name: 'FLAC', mime: 'audio/flac', codecs: ['flac'] },
{ name: 'WEBM', mime: 'audio/webm', codecs: ['vorbis', 'opus'] },
{ name: 'M4A', mime: 'audio/mp4', codecs: ['mp4a.40.2'] },
];
const supportedFormats = [];
const missingCodecs = [];
const capabilities = {};
for (const format of formats) {
let bestSupport = '';
let isSupported = false;
// Test basic MIME type
const basicSupport = audio.canPlayType(format.mime);
capabilities[format.name + '_basic'] = basicSupport;
if (basicSupport === 'probably' || basicSupport === 'maybe') {
bestSupport = basicSupport;
isSupported = true;
}
// Test with codecs
for (const codec of format.codecs) {
const codecSupport = audio.canPlayType(`${format.mime}; codecs="${codec}"`);
capabilities[format.name + '_' + codec] = codecSupport;
if (codecSupport === 'probably') {
bestSupport = 'probably';
isSupported = true;
}
else if (codecSupport === 'maybe' && bestSupport !== 'probably') {
bestSupport = 'maybe';
isSupported = true;
}
}
capabilities[format.name] = bestSupport;
if (isSupported) {
supportedFormats.push(format.name);
}
else {
missingCodecs.push(format.name);
}
}
const result = { supportedFormats, missingCodecs, capabilities };
// Add Electron version info if available
if (this.getEnvironment() === 'electron') {
try {
if (typeof process !== 'undefined' && process.versions) {
result.electronVersion = process.versions.electron;
result.chromiumVersion = process.versions.chrome;
}
// Integrate with Electron main process for system codec detection
const electronAPI = window.electronAPI;
if (electronAPI?.getSystemAudioInfo) {
try {
const systemAudioInfo = await electronAPI.getSystemAudioInfo();
if (systemAudioInfo) {
capabilities['electron_system_info'] =
JSON.stringify(systemAudioInfo);
// Enhanced format support based on system capabilities
if (systemAudioInfo.supportedFormats) {
systemAudioInfo.supportedFormats.forEach((format) => {
if (!supportedFormats.includes(format)) {
supportedFormats.push(format);
// Remove from missing codecs if it was there
const missingIndex = missingCodecs.indexOf(format);
if (missingIndex > -1) {
missingCodecs.splice(missingIndex, 1);
}
}
});
}
}
}
catch (error) {
console.warn('[ElectronAudioService] Failed to get system audio info via IPC:', error);
}
}
}
catch (error) {
console.warn('[ElectronAudioService] Could not access Electron version info:', error);
}
}
return result;
}
// Electron-specific method to get audio file metadata via main process
async getAudioMetadata(filePath) {
const electronAPI = window.electronAPI;
if (this.getEnvironment() !== 'electron' ||
!electronAPI?.getAudioMetadata) {
return null;
}
try {
return await electronAPI.getAudioMetadata(filePath);
}
catch (error) {
console.warn('[ElectronAudioService] Failed to get audio metadata:', error);
return null;
}
}
// Electron-specific method to enumerate audio devices via main process
async getAudioDevices() {
const electronAPI = window.electronAPI;
if (this.getEnvironment() !== 'electron' || !electronAPI?.getAudioDevices) {
return null;
}
try {
return await electronAPI.getAudioDevices();
}
catch (error) {
console.warn('[ElectronAudioService] Failed to get audio devices:', error);
return null;
}
}
// Electron-specific method to get system audio settings
async getSystemAudioSettings() {
const electronAPI = window.electronAPI;
if (this.getEnvironment() !== 'electron' ||
!electronAPI?.getSystemAudioSettings) {
return null;
}
try {
return await electronAPI.getSystemAudioSettings();
}
catch (error) {
console.warn('[ElectronAudioService] Failed to get system audio settings:', error);
return null;
}
}
}
exports.AudioProxyClient = AudioProxyClient;
exports.ElectronAudioService = ElectronAudioService;
exports.TauriAudioService = TauriAudioService;
exports.createAudioClient = createAudioClient;
//# sourceMappingURL=browser.cjs.map