260 lines
10 KiB
JavaScript
260 lines
10 KiB
JavaScript
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;
|
|
|
|
const launchApp = (metadata && metadata.appId) ? metadata.appId : DefaultMediaReceiver;
|
|
activeClient.launch(launchApp, (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');
|