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

@ -65,6 +65,17 @@ const API = {
estimateByUrl: (url, type) => API.request('POST', '/sources/estimate', { url, type }), // Estimate by URL (before creation)
},
cast: {
getDevices: () => API.request('GET', '/cast/devices'),
devices: () => API.request('GET', '/cast/devices'),
refreshDevices: () => API.request('POST', '/cast/devices/refresh'),
startSession: (payload) => API.request('POST', '/cast/sessions', payload),
createSession: (payload) => API.request('POST', '/cast/sessions', payload),
getSessions: () => API.request('GET', '/cast/sessions'),
getSession: (id) => API.request('GET', `/cast/sessions/${encodeURIComponent(id)}`),
stopSession: (id) => API.request('DELETE', `/cast/sessions/${encodeURIComponent(id)}`)
},
// Channels (hidden items)
channels: {
getHidden: (sourceId = null) => API.request('GET', `/channels/hidden${sourceId ? `?sourceId=${sourceId}` : ''}`),

View File

@ -457,10 +457,23 @@ class VideoPlayer {
return;
}
this.castService = new CastService(this.video);
const serverAvailable = typeof ServerCastDevicePicker !== 'undefined'
&& typeof API !== 'undefined'
&& Boolean(API.cast);
this.serverCastPicker = serverAvailable ? new ServerCastDevicePicker({
document,
api: API.cast,
nativeCastService: this.castService,
mediaUrlProvider: () => this.getServerCastMediaPath(),
onState: state => {
if (status && state.status === 'connected') status.textContent = 'Playing on the selected device.';
else if (status && state.status === 'error') status.textContent = state.error || 'Could not connect to a playback device.';
}
}) : null;
this.castService.subscribe(state => {
const unsupported = state.type === 'unsupported';
button.hidden = unsupported;
button.disabled = !state.available || state.status === 'connecting';
button.hidden = unsupported && !serverAvailable;
button.disabled = serverAvailable ? false : (!state.available || state.status === 'connecting');
button.setAttribute('aria-pressed', String(state.status === 'connected'));
if (status) status.textContent = unsupported ? 'Not supported on this browser'
: state.status === 'connected' ? 'Playing on a device'
@ -470,7 +483,8 @@ class VideoPlayer {
button.addEventListener('click', async event => {
event.stopPropagation();
try {
await this.castService.prompt();
if (this.serverCastPicker) await this.serverCastPicker.open();
else await this.castService.prompt();
} catch (error) {
if (status) status.textContent = 'Could not connect to a playback device.';
if (error?.name !== 'NotAllowedError' && error?.name !== 'AbortError') {
@ -481,6 +495,20 @@ class VideoPlayer {
void this.castService.watchAvailability();
}
getServerCastMediaPath() {
const value = String(this.currentUrl || '');
if (!value) return null;
let path = value;
try {
const url = new URL(value, window.location.origin);
if (url.origin !== window.location.origin || url.search) return null;
path = url.pathname;
} catch { return null; }
const castable = /^\/api\/transcode\/[A-Za-z0-9_-]{1,128}\/stream\.m3u8$/.test(path)
|| /^\/api\/proxy\/xtream\/\d+\/stream\/[A-Za-z0-9._-]{1,128}\/(?:live|movie|series)$/.test(path);
return castable ? path : null;
}
/**
* Add the live quality control to the existing player overflow menu.
*/

View File

@ -251,10 +251,23 @@ class WatchPage {
return;
}
this.castService = new CastService(this.video);
const serverAvailable = typeof ServerCastDevicePicker !== 'undefined'
&& typeof API !== 'undefined'
&& Boolean(API.cast);
this.serverCastPicker = serverAvailable ? new ServerCastDevicePicker({
document,
api: API.cast,
nativeCastService: this.castService,
mediaUrlProvider: () => this.getServerCastMediaPath(),
onState: state => {
if (status && state.status === 'connected') status.textContent = 'Playing on the selected device.';
else if (status && state.status === 'error') status.textContent = state.error || 'Could not connect to a playback device.';
}
}) : null;
this.castService.subscribe(state => {
const unsupported = state.type === 'unsupported';
button.hidden = unsupported;
button.disabled = !state.available || state.status === 'connecting';
button.hidden = unsupported && !serverAvailable;
button.disabled = serverAvailable ? false : (!state.available || state.status === 'connecting');
button.setAttribute('aria-pressed', String(state.status === 'connected'));
if (status) status.textContent = unsupported ? 'Not supported on this browser'
: state.status === 'connected' ? 'Playing on a device'
@ -264,7 +277,8 @@ class WatchPage {
button.addEventListener('click', async event => {
event.stopPropagation();
try {
await this.castService.prompt();
if (this.serverCastPicker) await this.serverCastPicker.open();
else await this.castService.prompt();
} catch (error) {
if (status) status.textContent = 'Could not connect to a playback device.';
if (error?.name !== 'NotAllowedError' && error?.name !== 'AbortError') {
@ -275,6 +289,20 @@ class WatchPage {
void this.castService.watchAvailability();
}
getServerCastMediaPath() {
const value = String(this.currentUrl || '');
if (!value) return null;
let path = value;
try {
const url = new URL(value, window.location.origin);
if (url.origin !== window.location.origin || url.search) return null;
path = url.pathname;
} catch { return null; }
const castable = /^\/api\/transcode\/[A-Za-z0-9_-]{1,128}\/stream\.m3u8$/.test(path)
|| /^\/api\/proxy\/xtream\/\d+\/stream\/[A-Za-z0-9._-]{1,128}\/(?:live|movie|series)$/.test(path);
return castable ? path : null;
}
/**
* Main entry point - play content
* @param {Object} content - Movie or episode info

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