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:
@ -4610,6 +4610,88 @@ select.form-input optgroup:disabled {
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.cast-device-dialog {
|
||||
width: min(520px, calc(100vw - 32px));
|
||||
max-height: min(80vh, 680px);
|
||||
padding: var(--space-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text-primary);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.cast-device-dialog::backdrop {
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
}
|
||||
|
||||
.cast-dialog-header,
|
||||
.cast-dialog-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.cast-device-dialog .form-input {
|
||||
width: 100%;
|
||||
margin: var(--space-md) 0;
|
||||
}
|
||||
|
||||
.cast-device-message {
|
||||
min-height: 1.25rem;
|
||||
margin-bottom: var(--space-sm);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.cast-device-message[data-state="error"] {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.cast-device-list {
|
||||
display: grid;
|
||||
gap: var(--space-sm);
|
||||
max-height: 360px;
|
||||
margin-bottom: var(--space-md);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.cast-device-item {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-height: 56px;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cast-device-item:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.cast-device-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cast-device-status,
|
||||
.cast-device-explanation {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.cast-dialog-actions {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.overflow-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@ -1218,11 +1218,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog id="cast-device-dialog" class="cast-device-dialog">
|
||||
<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>
|
||||
</dialog>
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script src="/js/icons.js"></script>
|
||||
<script src="/js/api.js?v=6"></script>
|
||||
<script src="/js/api.js?v=7"></script>
|
||||
<script src="/js/services/castService.js?v=1"></script>
|
||||
<script src="/js/components/VideoPlayer.js?v=6"></script>
|
||||
<script src="/js/services/serverCastDevicePicker.js?v=1"></script>
|
||||
<script src="/js/components/VideoPlayer.js?v=7"></script>
|
||||
<script src="/js/services/channelLanguageDetection.js?v=1"></script>
|
||||
<script src="/js/services/channelLanguageFusion.js?v=1"></script>
|
||||
<script src="/js/services/layaStatusPresentation.js?v=6"></script>
|
||||
@ -1238,7 +1254,7 @@
|
||||
<script src="/js/pages/MoviesPage.js?v=4"></script>
|
||||
<script src="/js/pages/SeriesPage.js?v=1"></script>
|
||||
<script src="/js/pages/Settings.js?v=8"></script>
|
||||
<script src="/js/pages/WatchPage.js?v=2"></script>
|
||||
<script src="/js/pages/WatchPage.js?v=3"></script>
|
||||
<script src="/js/app.js?v=4"></script>
|
||||
<script>
|
||||
// Check for SSO token in URL
|
||||
|
||||
@ -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}` : ''}`),
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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
|
||||
|
||||
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