Files
Nodecast/public/js/services/serverCastDevicePicker.js
blomios 5ee07570e0 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>
2026-10-06 22:40:17 +02:00

182 lines
9.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(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 };
});