UNPKG

desktop-audio-proxy

Version:

A comprehensive audio streaming solution for Tauri and Electron apps that bypasses CORS and WebKit codec issues

264 lines (227 loc) 7.07 kB
import { AudioProxyOptions, StreamInfo, Environment } from './types'; // Type declarations for window objects declare global { interface Window { __TAURI__?: { tauri: { convertFileSrc: (_filePath: string) => string; invoke?: ( _command: string, _args?: Record<string, unknown> ) => Promise<unknown>; }; }; electronAPI?: unknown; } } interface ProcessVersions { electron?: string; [key: string]: string | undefined; } declare const process: | { versions?: ProcessVersions; } | undefined; export class AudioProxyClient { private options: Required<AudioProxyOptions>; private environment: Environment; constructor(options: AudioProxyOptions = {}) { 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(); } private detectEnvironment(): Environment { 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'; } public getEnvironment(): Environment { return this.environment; } public async isProxyAvailable(): Promise<boolean> { 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: unknown) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; console.warn( '[AudioProxyClient] Proxy server unavailable:', errorMessage ); return false; } } public async canPlayUrl(url: string): Promise<StreamInfo> { 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: 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: StreamInfo = { url, status: 0, headers: {}, canPlay: false, requiresProxy: true, }; console.log('[AudioProxyClient] Stream info:', streamInfo); return streamInfo; } public async getPlayableUrl(url: string): Promise<string> { 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; } private isLocalFile(url: string): boolean { return ( url.startsWith('/') || url.startsWith('./') || url.startsWith('../') || url.startsWith('file://') || url.startsWith('blob:') || url.startsWith('data:') || !!url.match(/^[a-zA-Z]:\\/) ); // Windows path } private handleLocalFile(url: string): string { // 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; } private delay(ms: number): Promise<void> { return new Promise(resolve => setTimeout(resolve, ms)); } } export function createAudioClient( options?: AudioProxyOptions ): AudioProxyClient { return new AudioProxyClient(options); }