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:
181
public/js/services/serverCastDevicePicker.js
Normal file
181
public/js/services/serverCastDevicePicker.js
Normal 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, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
}
|
||||
|
||||
return { ServerCastDevicePicker };
|
||||
});
|
||||
Reference in New Issue
Block a user