(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 `

Play on a device

Choose a device connected to the same network as Nodecast.

`; } 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 ``; }).join('') || '

No devices match your search.

'; 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, '''); } } return { ServerCastDevicePicker }; });