'use strict'; const util = require('node:util'); const Bonjour = require('bonjour-service').Bonjour; const CastClient = require('castv2-client').Client; const DefaultMediaReceiver = require('castv2-client').DefaultMediaReceiver; /** * Progress logging, off unless asked for: * * NODE_DEBUG=cast-chromecast npm start * * The receiver reports its state on a timer, so `player status: PLAYING` * repeats for as long as a film runs and buries everything else in the * terminal. Node's own `debuglog` is used rather than a flag of our own: it * costs nothing when disabled — the message is never even formatted — and it * is the knob a Node developer already expects to reach for. * * Failures do not come through here. A connection that dies silently is a * connection nobody can diagnose, so those stay on `console.error`. */ const debug = util.debuglog('cast-chromecast'); const SCAN_DURATION_MS = 6000; // The one track the receiver is ever told about. A side-loaded track cannot // have its cues replaced in place, so changing subtitles means loading again // with a new URL under this same id rather than adding a second track. const TEXT_TRACK_ID = 1; // White on nothing is unreadable over a bright scene, and the receiver's // default style is exactly that. An outline costs no bandwidth. const TEXT_TRACK_STYLE = Object.freeze({ backgroundColor: '#00000000', foregroundColor: '#FFFFFFFF', edgeType: 'OUTLINE', edgeColor: '#000000FF', fontScale: 1.0, fontFamily: 'SANS_SERIF', }); /** * What to hand `player.load()` for a stream, with or without subtitles. * * `streamType: 'LIVE'` because the relay has no beginning to seek back to — * the film's own timeline lives on this side, and a seek restarts the relay. */ function mediaFor(mediaUrl, subtitle) { const media = { contentId: mediaUrl, contentType: 'video/mp4', streamType: 'LIVE', }; if (subtitle && subtitle.url) { media.tracks = [{ trackId: TEXT_TRACK_ID, type: 'TEXT', trackContentId: subtitle.url, trackContentType: 'text/vtt', subtype: 'SUBTITLES', name: subtitle.label || 'Subtitles', language: subtitle.language || 'und', }]; media.textTrackStyle = TEXT_TRACK_STYLE; } return media; } function loadOptionsFor(subtitle) { return { autoplay: true, activeTrackIds: subtitle && subtitle.url ? [TEXT_TRACK_ID] : [], }; } class CastChromecast { constructor() { this._bonjour = null; this._browser = null; this._devices = new Map(); this._client = null; this._player = null; this._connectedDevice = null; } async discover() { this._devices.clear(); if (this._browser) { this._browser.stop(); this._browser = null; } if (!this._bonjour) { this._bonjour = new Bonjour(); } debug('[cast-chromecast] scanning for devices...'); return new Promise((resolve) => { this._browser = this._bonjour.find({ type: 'googlecast' }, (service) => { const id = service.txt?.id || service.name; const name = service.txt?.fn || service.name; const host = service.addresses?.find((a) => /^\d+\.\d+\.\d+\.\d+$/.test(a)) || (service.referer && service.referer.address); const port = service.port || 8009; if (host && id) { this._devices.set(id, { id, name, host, port }); debug(`[cast-chromecast] discovered: "${name}" at ${host}:${port}`); } }); setTimeout(() => { if (this._browser) { this._browser.stop(); this._browser = null; } const devices = Array.from(this._devices.values()); debug(`[cast-chromecast] scan complete: ${devices.length} device(s)`); resolve(devices); }, SCAN_DURATION_MS); }); } async connect(deviceId, mediaUrl, subtitle) { const device = this._devices.get(deviceId); if (!device) throw new Error(`Unknown device: ${deviceId}`); await this.disconnect(); debug(`[cast-chromecast] connecting to "${device.name}" (${device.host}:${device.port})`); const client = new CastClient(); await new Promise((resolve, reject) => { client.on('error', (err) => { // Not `debug`: this fires when the socket to the receiver dies, and it // is the only trace of why the cast stopped. console.error(`[cast-chromecast] client error: ${err.message}`); this._cleanup(); }); client.connect(device.host, () => resolve()); setTimeout(() => reject(new Error('Connection timeout')), 10000); }); this._client = client; this._connectedDevice = device; const player = await new Promise((resolve, reject) => { client.launch(DefaultMediaReceiver, (err, p) => { if (err) return reject(err); resolve(p); }); }); this._player = player; player.on('status', (status) => { debug(`[cast-chromecast] player status: ${status.playerState}`); }); debug(`[cast-chromecast] loading with ${subtitle?.url ? 'subtitles' : 'no subtitles'}`); const status = await new Promise((resolve, reject) => { player.load(mediaFor(mediaUrl, subtitle), loadOptionsFor(subtitle), (err, s) => { if (err) return reject(err); resolve(s); }); }); debug(`[cast-chromecast] loaded on "${device.name}", state: ${status.playerState}`); return { deviceName: device.name, playerState: status.playerState }; } async reload(mediaUrl, subtitle) { if (!this._player) throw new Error('Not connected'); debug(`[cast-chromecast] reloading stream on "${this._connectedDevice?.name}"` + ` with ${subtitle?.url ? 'subtitles' : 'no subtitles'}`); const status = await new Promise((resolve, reject) => { this._player.load(mediaFor(mediaUrl, subtitle), loadOptionsFor(subtitle), (err, s) => { if (err) return reject(err); resolve(s); }); }); debug(`[cast-chromecast] reloaded, state: ${status.playerState}`); return { playerState: status.playerState }; } async disconnect() { if (this._player) { try { await new Promise((resolve) => { this._player.stop(() => resolve()); }); } catch { /* already stopped */ } } this._cleanup(); } getStatus() { return { connected: this._client !== null, deviceName: this._connectedDevice?.name || null, }; } _cleanup() { if (this._client) { try { this._client.close(); } catch { /* ignore */ } } this._client = null; this._player = null; this._connectedDevice = null; } } module.exports = CastChromecast; module.exports.mediaFor = mediaFor; module.exports.loadOptionsFor = loadOptionsFor;