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
JavaScript
;
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