const { Client, DefaultMediaReceiver } = require('castv2-client'); const readline = require('readline'); const rl = readline.createInterface({ input: process.stdin, terminal: false }); let activeClient = null; let activePlayer = null; function isNotAllowedError(err) { if (!err) return false; const msg = (err.message || String(err)).toUpperCase(); return msg.includes('NOT_ALLOWED') || msg.includes('NOT ALLOWED'); } function stopSessions(client, sessions, cb) { if (!client || !sessions || sessions.length === 0) return cb(); const remaining = sessions.slice(); const stopNext = () => { const session = remaining.shift(); if (!session) return cb(); try { client.stop(session, (err) => { if (err) { log(`Stop session failed (${session.appId || 'unknown app'}): ${err.message || String(err)}`); } else { log(`Stopped session (${session.appId || 'unknown app'})`); } // Continue regardless; best-effort. stopNext(); }); } catch (err) { // Some devices/library versions may throw synchronously; just log and continue. log(`Stop session threw (${session.appId || 'unknown app'}): ${err.message || String(err)}`); stopNext(); } }; stopNext(); } function log(msg) { console.log(JSON.stringify({ type: 'log', message: msg })); } function error(msg) { console.error(JSON.stringify({ type: 'error', message: msg })); } rl.on('line', (line) => { try { const data = JSON.parse(line); const { command, args } = data; switch (command) { case 'play': play(args.ip, args.url, args.metadata); break; case 'stop': stop(); break; case 'volume': setVolume(args.level); break; default: error(`Unknown command: ${command}`); } } catch (e) { error(`Failed to parse line: ${e.message}`); } }); function play(ip, url, metadata) { if (activeClient) { try { activeClient.removeAllListeners(); } catch (e) { } try { activeClient.close(); } catch (e) { } } activeClient = new Client(); // Increase max listeners for this client instance to avoid Node warnings try { if (typeof activeClient.setMaxListeners === 'function') activeClient.setMaxListeners(50); } catch (e) {} activeClient._playMetadata = metadata || {}; activeClient.connect(ip, () => { log(`Connected to ${ip}`); // First, check if DefaultMediaReceiver is already running activeClient.getSessions((err, sessions) => { if (err) return error(`GetSessions error: ${err.message}`); // Log sessions for debugging (appId/sessionId if available) try { const sessInfo = sessions.map(s => ({ appId: s.appId, sessionId: s.sessionId, displayName: s.displayName })); log(`Sessions: ${JSON.stringify(sessInfo)}`); } catch (e) { log('Sessions: (unable to stringify)'); } // DefaultMediaReceiver App ID is CC1AD845 const session = sessions.find(s => s.appId === 'CC1AD845'); if (session) { log('Session already running, joining...'); activeClient.join(session, DefaultMediaReceiver, (err, player) => { if (err) { log('Join failed, attempting launch...'); log(`Join error: ${err && err.message ? err.message : String(err)}`); // Join can fail if the session is stale; stop it and retry launch. stopSessions(activeClient, [session], () => launchPlayer(url, activeClient._playMetadata, /*didStopFirst*/ true)); } else { // Clean up previous player listeners before replacing try { if (activePlayer && typeof activePlayer.removeAllListeners === 'function') activePlayer.removeAllListeners(); } catch (e) {} activePlayer = player; try { if (typeof activePlayer.setMaxListeners === 'function') activePlayer.setMaxListeners(50); } catch (e) {} loadMedia(url, activeClient._playMetadata); } }); } else { // Backdrop or other non-media session present: skip stopping to avoid platform sender crash, just launch. if (sessions.length > 0) { log('Non-media session detected; skipping stop and launching DefaultMediaReceiver...'); } launchPlayer(url, activeClient._playMetadata, /*didStopFirst*/ false); } }); }); activeClient.on('error', (err) => { error(`Client error: ${err.message}`); try { activeClient.close(); } catch (e) { } activeClient = null; activePlayer = null; }); } function launchPlayer(url, metadata, didStopFirst) { if (!activeClient) return; activeClient.launch(DefaultMediaReceiver, (err, player) => { if (err) { const details = `Launch error: ${err && err.message ? err.message : String(err)}${err && err.code ? ` (code: ${err.code})` : ''}`; // If launch fails with NOT_ALLOWED, the device may be busy with another app/session. // Best-effort: stop existing sessions once, then retry launch. if (!didStopFirst && isNotAllowedError(err)) { log('Launch NOT_ALLOWED; attempting to stop existing sessions and retry...'); activeClient.getSessions((sessErr, sessions) => { if (sessErr) { error(`${details} | GetSessions error: ${sessErr.message || String(sessErr)}`); return; } stopSessions(activeClient, sessions, () => { activeClient.launch(DefaultMediaReceiver, (retryErr, retryPlayer) => { if (retryErr) { const retryDetails = `Launch retry error: ${retryErr && retryErr.message ? retryErr.message : String(retryErr)}${retryErr && retryErr.code ? ` (code: ${retryErr.code})` : ''}`; error(retryDetails); try { error(`Launch retry error full: ${JSON.stringify(retryErr)}`); } catch (e) { /* ignore */ } return; } try { if (activePlayer && typeof activePlayer.removeAllListeners === 'function') activePlayer.removeAllListeners(); } catch (e) {} activePlayer = retryPlayer; try { if (typeof activePlayer.setMaxListeners === 'function') activePlayer.setMaxListeners(50); } catch (e) {} loadMedia(url, metadata); }); }); }); return; } error(details); try { error(`Launch error full: ${JSON.stringify(err)}`); } catch (e) { /* ignore */ } return; } try { if (activePlayer && typeof activePlayer.removeAllListeners === 'function') activePlayer.removeAllListeners(); } catch (e) {} activePlayer = player; try { if (typeof activePlayer.setMaxListeners === 'function') activePlayer.setMaxListeners(50); } catch (e) {} loadMedia(url, metadata); }); } function loadMedia(url, metadata) { if (!activePlayer) return; const meta = metadata || {}; // Build a richer metadata payload. Many receivers only honor specific // fields; we set both Music metadata and generic hints via `customData`. const media = { contentId: url, contentType: 'audio/mpeg', streamType: 'LIVE', metadata: { // Use MusicTrack metadata (common on audio receivers) but include // a subtitle field in case receivers surface it. metadataType: 3, // MusicTrackMediaMetadata title: meta.title || 'Radio Station', albumName: 'Radio Player', artist: meta.artist || meta.subtitle || meta.station || '', subtitle: meta.subtitle || '', images: (meta.image ? [ { url: meta.image }, // also include a large hint for receivers that prefer big artwork { url: meta.image, width: 1920, height: 1080 } ] : []) }, // Many receivers ignore `customData`, but some Styled receivers will // use it. Include background and theming hints here. customData: { appName: meta.appName || 'Radio Player', backgroundImage: meta.backgroundImage || meta.image || undefined, backgroundGradient: meta.bgGradient || '#6a0dad', themeHint: { primary: '#6a0dad', accent: '#b36cf3' } } }; // Ensure we don't accumulate 'status' listeners across loads try { if (activePlayer && typeof activePlayer.removeAllListeners === 'function') activePlayer.removeAllListeners('status'); } catch (e) {} activePlayer.load(media, { autoplay: true }, (err, status) => { if (err) return error(`Load error: ${err.message}`); log('Media loaded, playing...'); }); activePlayer.on('status', (status) => { // Optional: track status }); } function stop() { if (activePlayer) { try { activePlayer.stop(); } catch (e) { } try { if (typeof activePlayer.removeAllListeners === 'function') activePlayer.removeAllListeners(); } catch (e) {} log('Stopped playback'); } if (activeClient) { try { if (typeof activeClient.removeAllListeners === 'function') activeClient.removeAllListeners(); } catch (e) {} try { activeClient.close(); } catch (e) { } activeClient = null; activePlayer = null; } } function setVolume(level) { if (activeClient && activePlayer) { activeClient.setVolume({ level }, (err, status) => { if (err) return error(`Volume error: ${err.message}`); log(`Volume set to ${level}`); }); } else { log('Volume command ignored: Player not initialized'); } } log('Sidecar initialized and waiting for commands');