feat(cast): add server-managed Google Cast playback (#34)

Add server-side Google Cast discovery and sessions, signed cast media URLs, browser device picker integration, and deployment documentation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
blomios
2026-10-06 22:40:17 +02:00
parent e6e481bf0e
commit 5ee07570e0
15 changed files with 1394 additions and 9 deletions

View File

@ -0,0 +1,181 @@
(function (root, factory) {
const exports = factory();
if (typeof module === 'object' && module.exports) module.exports = exports;
if (root) root.ServerCastDevicePicker = exports.ServerCastDevicePicker;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
class ServerCastDevicePicker {
constructor(options = {}) {
this.document = options.document || (typeof document !== 'undefined' ? document : null);
this.api = options.api;
this.nativeCastService = options.nativeCastService || null;
this.mediaUrlProvider = options.mediaUrlProvider || (() => '');
this.onState = options.onState || (() => {});
this.devices = [];
this.query = '';
this.ensureDialog();
this.attachListeners();
}
ensureDialog() {
if (!this.document) return;
this.dialog = this.document.getElementById('cast-device-dialog');
if (!this.dialog) {
this.dialog = this.document.createElement('dialog');
this.dialog.id = 'cast-device-dialog';
this.dialog.className = 'cast-device-dialog';
this.dialog.innerHTML = this.dialogContent();
this.document.body.appendChild(this.dialog);
}
this.list = this.document.getElementById('cast-device-list');
this.search = this.document.getElementById('cast-device-search');
this.message = this.document.getElementById('cast-device-message');
this.nativeButton = this.document.getElementById('cast-browser-picker');
}
dialogContent() {
return `<div class="cast-dialog-header"><h2>Play on a device</h2>
<button type="button" class="btn btn-ghost" data-cast-action="close" aria-label="Close device picker">×</button></div>
<p class="setting-hint">Choose a device connected to the same network as Nodecast.</p>
<input id="cast-device-search" class="form-input" type="search" placeholder="Search devices…" aria-label="Search devices">
<div id="cast-device-message" class="cast-device-message" role="status" aria-live="polite"></div>
<div id="cast-device-list" class="cast-device-list"></div>
<div class="cast-dialog-actions">
<button type="button" class="btn btn-secondary" data-cast-action="refresh">Refresh devices</button>
<button type="button" class="btn btn-secondary" id="cast-browser-picker">Use browser device picker</button>
</div>`;
}
attachListeners() {
this.dialog?.querySelector('[data-cast-action="close"]')?.addEventListener('click', () => this.close());
this.dialog?.querySelector('[data-cast-action="refresh"]')?.addEventListener('click', () => void this.loadDevices(true));
this.search?.addEventListener('input', event => {
this.query = event.target.value.toLowerCase().trim();
this.renderDevices();
});
this.nativeButton?.addEventListener('click', async () => {
try {
await this.nativeCastService?.prompt?.();
this.setMessage('Browser device picker opened.');
} catch (error) {
this.setMessage('The browser device picker could not be opened.', 'error');
}
});
this.dialog?.addEventListener('cancel', event => {
event.preventDefault();
this.close();
});
}
async open() {
if (!this.dialog) return;
if (typeof this.dialog.showModal === 'function') this.dialog.showModal();
else {
this.dialog.hidden = false;
this.dialog.setAttribute('open', '');
}
this.updateNativeAction();
await this.loadDevices(false);
this.search?.focus();
}
close() {
if (typeof this.dialog?.close === 'function') this.dialog.close();
else {
this.dialog?.removeAttribute('open');
if (this.dialog) this.dialog.hidden = true;
}
}
updateNativeAction() {
if (!this.nativeButton) return;
const state = this.nativeCastService?.state;
const supported = Boolean(state && (state.type !== 'unsupported' || state.available));
this.nativeButton.hidden = !supported;
}
async loadDevices(refresh) {
this.setMessage(refresh ? 'Refreshing devices…' : 'Looking for devices…');
try {
const result = refresh ? await this.api.refreshDevices() : await this.api.getDevices();
this.devices = Array.isArray(result) ? result : (result?.devices || []);
this.renderDevices();
this.setMessage(this.devices.length ? '' : 'No server-managed devices found.');
} catch (error) {
this.devices = [];
this.renderDevices();
const message = error?.message || 'Unable to discover devices.';
this.setMessage(/base url|localhost|lan|nodecast/i.test(message)
? `Nodecast is not reachable by devices: ${message}`
: message, 'error');
}
}
renderDevices() {
if (!this.list) return;
const devices = this.devices.filter(device =>
!this.query || `${device.name || ''} ${device.type || device.kind || ''}`.toLowerCase().includes(this.query)
);
this.list.innerHTML = devices.map(device => {
const type = device.type || device.kind || 'device';
const receiverRequired = type === 'fire_tv' && device.status === 'receiver_required';
const connectable = !receiverRequired && device.ready !== false && ['google_cast', 'android_tv_cast'].includes(type);
const status = receiverRequired
? 'Receiver required'
: connectable ? 'Ready' : (device.status || 'Unavailable');
const explanation = receiverRequired
? 'Install and configure a compatible receiver app on this Fire TV before connecting.'
: '';
return `<button type="button" class="cast-device-item" data-device-id="${this.escape(device.id)}" ${connectable ? '' : 'disabled'}>
<span class="cast-device-name">${this.escape(device.name || 'Unnamed device')}</span>
<span class="cast-device-status">${this.escape(status)}</span>
${explanation ? `<span class="cast-device-explanation">${this.escape(explanation)}</span>` : ''}
</button>`;
}).join('') || '<p class="hint">No devices match your search.</p>';
this.list.querySelectorAll('[data-device-id]:not(:disabled)').forEach(button => {
button.addEventListener('click', () => void this.connect(button.dataset.deviceId));
});
}
async connect(deviceId) {
const mediaPath = this.mediaUrlProvider();
if (!this.isAllowedMediaPath(mediaPath)) {
this.setMessage('This stream is not ready for server casting yet. Continue playback here or use the browser device picker.', 'error');
return;
}
this.setMessage('Connecting…');
this.onState({ status: 'connecting', deviceId });
try {
const session = await this.api.startSession({ deviceId, mediaPath });
this.setMessage('Playing on the selected device.');
this.onState({ status: 'connected', deviceId, session });
this.close();
} catch (error) {
const message = error?.message || 'Unable to start device playback.';
this.setMessage(/base url|localhost|lan|nodecast/i.test(message)
? `Nodecast is not reachable by this device: ${message}`
: message, 'error');
this.onState({ status: 'error', deviceId, error: message });
}
}
isAllowedMediaPath(value) {
if (typeof value !== 'string' || value.includes('?')) return false;
return /^\/api\/transcode\/[A-Za-z0-9_-]{1,128}\/stream\.m3u8$/.test(value)
|| /^\/api\/proxy\/xtream\/\d+\/stream\/[A-Za-z0-9._-]{1,128}\/(?:live|movie|series)$/.test(value);
}
setMessage(message, state = '') {
if (this.message) {
this.message.textContent = message;
this.message.dataset.state = state;
}
}
escape(value) {
return String(value ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#039;');
}
}
return { ServerCastDevicePicker };
});