UNPKG

desktop-audio-proxy

Version:

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

735 lines (728 loc) 27.8 kB
'use strict'; var vue = require('vue'); 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)); } } // 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; } } } /** * Vue composable for managing audio proxy client with automatic URL processing */ function useAudioProxy(url, options) { const audioUrl = vue.ref(null); const isLoading = vue.ref(false); const error = vue.ref(null); const streamInfo = vue.ref(null); // Create reactive URL ref if needed const urlRef = vue.ref(url); const client = new AudioProxyClient(options); const processUrl = async (inputUrl) => { isLoading.value = true; error.value = null; audioUrl.value = null; streamInfo.value = null; try { // Get stream info first const info = await client.canPlayUrl(inputUrl); streamInfo.value = info; // Get playable URL const playableUrl = await client.getPlayableUrl(inputUrl); audioUrl.value = playableUrl; } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Unknown error'; error.value = errorMessage; } finally { isLoading.value = false; } }; const retry = () => { if (urlRef.value) { processUrl(urlRef.value); } }; // Watch for URL changes vue.watch(urlRef, (newUrl) => { if (newUrl) { processUrl(newUrl); } else { audioUrl.value = null; streamInfo.value = null; error.value = null; isLoading.value = false; } }, { immediate: true }); return { audioUrl: readonly(audioUrl), isLoading: readonly(isLoading), error: readonly(error), streamInfo: readonly(streamInfo), retry, client, }; } /** * Vue composable for accessing audio capabilities and system information */ function useAudioCapabilities() { const capabilities = vue.ref(null); const devices = vue.ref(null); const systemSettings = vue.ref(null); const isLoading = vue.ref(true); const error = vue.ref(null); const client = new AudioProxyClient(); const refresh = async () => { isLoading.value = true; error.value = null; try { const environment = client.getEnvironment(); let service = null; if (environment === 'tauri') { service = new TauriAudioService(); } else if (environment === 'electron') { service = new ElectronAudioService(); } if (service) { // Get codec capabilities const codecInfo = await service.checkSystemCodecs(); capabilities.value = { ...codecInfo, environment, }; // Get audio devices const deviceInfo = await service.getAudioDevices(); if (deviceInfo) { devices.value = deviceInfo; } // Get system settings (Electron only) if (environment === 'electron' && 'getSystemAudioSettings' in service) { const settings = await service.getSystemAudioSettings(); if (settings) { systemSettings.value = settings; } } } else { // Basic web environment capabilities const audio = new Audio(); const formats = ['MP3', 'OGG', 'WAV', 'AAC', 'FLAC', 'WEBM', 'M4A']; const supportedFormats = formats.filter(format => { const mimeTypes = { MP3: 'audio/mpeg', OGG: 'audio/ogg', WAV: 'audio/wav', AAC: 'audio/aac', FLAC: 'audio/flac', WEBM: 'audio/webm', M4A: 'audio/mp4', }; return (audio.canPlayType(mimeTypes[format]) !== ''); }); capabilities.value = { supportedFormats, missingCodecs: formats.filter(f => !supportedFormats.includes(f)), capabilities: {}, environment, }; } } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Unknown error'; error.value = errorMessage; } finally { isLoading.value = false; } }; vue.onMounted(() => { refresh(); }); return { capabilities: readonly(capabilities), devices: readonly(devices), systemSettings: readonly(systemSettings), isLoading: readonly(isLoading), error: readonly(error), refresh, }; } /** * Vue composable for checking proxy server availability */ function useProxyStatus(options) { const isAvailable = vue.ref(null); const isChecking = vue.ref(false); const error = vue.ref(null); const proxyUrl = vue.ref(''); const client = new AudioProxyClient(options); const refresh = async () => { isChecking.value = true; error.value = null; try { const available = await client.isProxyAvailable(); isAvailable.value = available; proxyUrl.value = client.options?.proxyUrl || 'http://localhost:3002'; } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Unknown error'; error.value = errorMessage; isAvailable.value = false; } finally { isChecking.value = false; } }; vue.onMounted(() => { refresh(); }); return { isAvailable: readonly(isAvailable), isChecking: readonly(isChecking), error: readonly(error), proxyUrl: readonly(proxyUrl), refresh, }; } /** * Vue composable for audio metadata extraction (Tauri/Electron only) */ function useAudioMetadata(filePath) { const metadata = vue.ref(null); const isLoading = vue.ref(false); const error = vue.ref(null); const filePathRef = vue.ref(filePath); const client = new AudioProxyClient(); const getMetadata = async (path) => { isLoading.value = true; error.value = null; metadata.value = null; try { const environment = client.getEnvironment(); let service = null; if (environment === 'tauri') { service = new TauriAudioService(); } else if (environment === 'electron') { service = new ElectronAudioService(); } if (service) { const result = await service.getAudioMetadata(path); metadata.value = result; } else { error.value = 'Audio metadata extraction is only available in Tauri or Electron environments'; } } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Unknown error'; error.value = errorMessage; } finally { isLoading.value = false; } }; vue.watch(filePathRef, (newPath) => { if (newPath) { getMetadata(newPath); } else { metadata.value = null; error.value = null; isLoading.value = false; } }, { immediate: true }); return { metadata: readonly(metadata), isLoading: readonly(isLoading), error: readonly(error), }; } /** * Helper function to create readonly refs */ function readonly(ref) { return vue.computed(() => ref.value); } function createAudioProxy(globalOptions = {}) { return { install(app) { const client = new AudioProxyClient(globalOptions.defaultOptions); app.config.globalProperties.$audioProxy = client; app.provide('audioProxy', client); app.provide('audioProxyOptions', globalOptions.defaultOptions || {}); }, }; } /** * Injection key for dependency injection */ const audioProxyInjectionKey = Symbol('audioProxy'); /** * Composable to inject the global audio proxy client */ function useGlobalAudioProxy() { const client = vue.inject(audioProxyInjectionKey); if (!client) { throw new Error('AudioProxy plugin must be installed to use useGlobalAudioProxy'); } return client; } exports.audioProxyInjectionKey = audioProxyInjectionKey; exports.createAudioProxy = createAudioProxy; exports.useAudioCapabilities = useAudioCapabilities; exports.useAudioMetadata = useAudioMetadata; exports.useAudioProxy = useAudioProxy; exports.useGlobalAudioProxy = useGlobalAudioProxy; exports.useProxyStatus = useProxyStatus; //# sourceMappingURL=vue.cjs.map