Merge branch 'feature/t33-pwa-stream-casting' into develop (#33)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -234,6 +234,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="watch-btn" id="player-cast" title="Play on a device" aria-label="Play on a device" aria-describedby="player-cast-status" hidden>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
|
||||
<path d="M1 18v3h3c0-1.66-1.34-3-3-3zm0-4v2c2.76 0 5 2.24 5 5h2c0-3.87-3.13-7-7-7zm0-4v2c4.97 0 9 4.03 9 9h2c0-6.08-4.92-11-11-11zm3-7c-1.1 0-2 .9-2 2v3h2V5h16v12h-6v2h6c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2H4z" />
|
||||
</svg>
|
||||
</button>
|
||||
<span id="player-cast-status" class="sr-only" role="status" aria-live="polite"></span>
|
||||
<button class="watch-btn" id="btn-pip" title="Picture-in-Picture">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
|
||||
<path
|
||||
@ -1019,6 +1025,12 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="watch-btn" id="watch-cast" title="Play on a device" aria-label="Play on a device" aria-describedby="watch-cast-status" hidden>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
|
||||
<path d="M1 18v3h3c0-1.66-1.34-3-3-3zm0-4v2c2.76 0 5 2.24 5 5h2c0-3.87-3.13-7-7-7zm0-4v2c4.97 0 9 4.03 9 9h2c0-6.08-4.92-11-11-11zm3-7c-1.1 0-2 .9-2 2v3h2V5h16v12h-6v2h6c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2H4z" />
|
||||
</svg>
|
||||
</button>
|
||||
<span id="watch-cast-status" class="sr-only" role="status" aria-live="polite"></span>
|
||||
<button class="watch-btn" id="watch-pip" title="Picture-in-Picture">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
|
||||
<path
|
||||
@ -1209,7 +1221,8 @@
|
||||
<!-- JavaScript -->
|
||||
<script src="/js/icons.js"></script>
|
||||
<script src="/js/api.js?v=6"></script>
|
||||
<script src="/js/components/VideoPlayer.js?v=5"></script>
|
||||
<script src="/js/services/castService.js?v=1"></script>
|
||||
<script src="/js/components/VideoPlayer.js?v=6"></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>
|
||||
@ -1225,7 +1238,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=1"></script>
|
||||
<script src="/js/pages/WatchPage.js?v=2"></script>
|
||||
<script src="/js/app.js?v=4"></script>
|
||||
<script>
|
||||
// Check for SSO token in URL
|
||||
|
||||
@ -36,6 +36,7 @@ class VideoPlayer {
|
||||
this.originalStreamUrl = null;
|
||||
this.sourceStreamInfo = null;
|
||||
this.currentLogicalChannel = null;
|
||||
this.castService = null;
|
||||
|
||||
// Settings - start with defaults, load from server async
|
||||
this.settings = this.getDefaultSettings();
|
||||
@ -363,6 +364,7 @@ class VideoPlayer {
|
||||
e.stopPropagation();
|
||||
this.togglePictureInPicture();
|
||||
});
|
||||
this.initCastControl();
|
||||
|
||||
// Overflow Menu
|
||||
const btnOverflow = document.getElementById('btn-overflow');
|
||||
@ -446,6 +448,39 @@ class VideoPlayer {
|
||||
updateVolumeUI();
|
||||
}
|
||||
|
||||
initCastControl() {
|
||||
const button = document.getElementById('player-cast');
|
||||
const status = document.getElementById('player-cast-status');
|
||||
if (!button || !this.video || typeof CastService === 'undefined') {
|
||||
if (button) button.hidden = true;
|
||||
if (status) status.textContent = 'Not supported on this browser';
|
||||
return;
|
||||
}
|
||||
this.castService = new CastService(this.video);
|
||||
this.castService.subscribe(state => {
|
||||
const unsupported = state.type === 'unsupported';
|
||||
button.hidden = unsupported;
|
||||
button.disabled = !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'
|
||||
: state.status === 'connecting' ? 'Opening device picker…'
|
||||
: state.available ? 'Playback device available' : 'No playback devices available';
|
||||
});
|
||||
button.addEventListener('click', async event => {
|
||||
event.stopPropagation();
|
||||
try {
|
||||
await this.castService.prompt();
|
||||
} catch (error) {
|
||||
if (status) status.textContent = 'Could not connect to a playback device.';
|
||||
if (error?.name !== 'NotAllowedError' && error?.name !== 'AbortError') {
|
||||
console.warn('[Player] Device playback picker failed:', error?.message || error);
|
||||
}
|
||||
}
|
||||
});
|
||||
void this.castService.watchAvailability();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the live quality control to the existing player overflow menu.
|
||||
*/
|
||||
|
||||
@ -35,6 +35,7 @@ class WatchPage {
|
||||
this.timeTotal = document.getElementById('watch-time-total');
|
||||
this.scrollHint = document.getElementById('watch-scroll-hint');
|
||||
this.loadingSpinner = document.getElementById('watch-loading');
|
||||
this.castService = null;
|
||||
|
||||
// Next episode
|
||||
this.nextEpisodePanel = document.getElementById('watch-next-episode');
|
||||
@ -153,6 +154,7 @@ class WatchPage {
|
||||
// Picture-in-Picture
|
||||
const pipBtn = document.getElementById('watch-pip');
|
||||
pipBtn?.addEventListener('click', () => this.togglePictureInPicture());
|
||||
this.initCastControl();
|
||||
|
||||
// Overflow Menu
|
||||
const overflowBtn = document.getElementById('watch-overflow');
|
||||
@ -240,6 +242,39 @@ class WatchPage {
|
||||
});
|
||||
}
|
||||
|
||||
initCastControl() {
|
||||
const button = document.getElementById('watch-cast');
|
||||
const status = document.getElementById('watch-cast-status');
|
||||
if (!button || !this.video || typeof CastService === 'undefined') {
|
||||
if (button) button.hidden = true;
|
||||
if (status) status.textContent = 'Not supported on this browser';
|
||||
return;
|
||||
}
|
||||
this.castService = new CastService(this.video);
|
||||
this.castService.subscribe(state => {
|
||||
const unsupported = state.type === 'unsupported';
|
||||
button.hidden = unsupported;
|
||||
button.disabled = !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'
|
||||
: state.status === 'connecting' ? 'Opening device picker…'
|
||||
: state.available ? 'Playback device available' : 'No playback devices available';
|
||||
});
|
||||
button.addEventListener('click', async event => {
|
||||
event.stopPropagation();
|
||||
try {
|
||||
await this.castService.prompt();
|
||||
} catch (error) {
|
||||
if (status) status.textContent = 'Could not connect to a playback device.';
|
||||
if (error?.name !== 'NotAllowedError' && error?.name !== 'AbortError') {
|
||||
console.warn('[Watch] Device playback picker failed:', error?.message || error);
|
||||
}
|
||||
}
|
||||
});
|
||||
void this.castService.watchAvailability();
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point - play content
|
||||
* @param {Object} content - Movie or episode info
|
||||
|
||||
108
public/js/services/castService.js
Normal file
108
public/js/services/castService.js
Normal file
@ -0,0 +1,108 @@
|
||||
(function (root, factory) {
|
||||
const CastService = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = CastService;
|
||||
if (root) root.CastService = CastService;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
class CastService {
|
||||
constructor(video) {
|
||||
this.video = video || null;
|
||||
this.listeners = new Set();
|
||||
this.availabilityWatchId = null;
|
||||
this.state = { status: 'unsupported', available: false, type: 'unsupported', error: '' };
|
||||
this.handleRemoteConnect = () => this.setState({ status: 'connected', available: true });
|
||||
this.handleRemoteDisconnect = () => this.setState({ status: 'available', available: true });
|
||||
this.handleAirPlayAvailability = event => this.setState({
|
||||
status: event?.availability === 'available' ? 'available' : 'unavailable',
|
||||
available: event?.availability === 'available',
|
||||
type: 'airplay'
|
||||
});
|
||||
this.handleAirPlayState = () => this.setState({
|
||||
status: this.video?.webkitCurrentPlaybackTargetIsWireless ? 'connected' : 'available',
|
||||
available: true,
|
||||
type: 'airplay'
|
||||
});
|
||||
this.attachStateEvents();
|
||||
}
|
||||
|
||||
attachStateEvents() {
|
||||
this.video?.remote?.addEventListener?.('connect', this.handleRemoteConnect);
|
||||
this.video?.remote?.addEventListener?.('disconnect', this.handleRemoteDisconnect);
|
||||
this.video?.addEventListener?.('webkitplaybacktargetavailabilitychanged', this.handleAirPlayAvailability);
|
||||
this.video?.addEventListener?.('webkitcurrentplaybacktargetiswirelesschanged', this.handleAirPlayState);
|
||||
}
|
||||
|
||||
setState(update) {
|
||||
this.state = { ...this.state, ...update };
|
||||
this.listeners.forEach(listener => listener({ ...this.state }));
|
||||
}
|
||||
|
||||
subscribe(listener) {
|
||||
if (typeof listener !== 'function') return () => {};
|
||||
this.listeners.add(listener);
|
||||
listener({ ...this.state });
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
async watchAvailability() {
|
||||
const remote = this.video?.remote;
|
||||
if (remote && typeof remote.watchAvailability === 'function') {
|
||||
this.setState({ status: 'unavailable', available: false, type: 'remote-playback', error: '' });
|
||||
try {
|
||||
this.availabilityWatchId = await remote.watchAvailability(available => {
|
||||
this.setState({
|
||||
status: available ? 'available' : 'unavailable',
|
||||
available: Boolean(available),
|
||||
type: 'remote-playback',
|
||||
error: ''
|
||||
});
|
||||
});
|
||||
return this.state;
|
||||
} catch (error) {
|
||||
if (typeof this.video?.webkitShowPlaybackTargetPicker !== 'function') {
|
||||
this.setState({ status: 'unavailable', available: false, type: 'remote-playback', error: error?.message || '' });
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (typeof this.video?.webkitShowPlaybackTargetPicker === 'function') {
|
||||
this.setState({ status: 'available', available: true, type: 'airplay', error: '' });
|
||||
return this.state;
|
||||
}
|
||||
this.setState({ status: 'unsupported', available: false, type: 'unsupported', error: '' });
|
||||
return this.state;
|
||||
}
|
||||
|
||||
async prompt() {
|
||||
if (this.state.type === 'remote-playback' && typeof this.video?.remote?.prompt === 'function') {
|
||||
this.setState({ status: 'connecting', error: '' });
|
||||
try {
|
||||
await this.video.remote.prompt();
|
||||
const connected = this.video.remote.state === 'connected';
|
||||
this.setState({ status: connected ? 'connected' : (this.state.available ? 'available' : 'unavailable') });
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.setState({ status: this.state.available ? 'available' : 'unavailable', error: error?.message || 'Unable to open device picker.' });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (this.state.type === 'airplay' && typeof this.video?.webkitShowPlaybackTargetPicker === 'function') {
|
||||
this.video.webkitShowPlaybackTargetPicker();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.availabilityWatchId !== null) {
|
||||
this.video?.remote?.cancelWatchAvailability?.(this.availabilityWatchId).catch?.(() => {});
|
||||
}
|
||||
this.video?.remote?.removeEventListener?.('connect', this.handleRemoteConnect);
|
||||
this.video?.remote?.removeEventListener?.('disconnect', this.handleRemoteDisconnect);
|
||||
this.video?.removeEventListener?.('webkitplaybacktargetavailabilitychanged', this.handleAirPlayAvailability);
|
||||
this.video?.removeEventListener?.('webkitcurrentplaybacktargetiswirelesschanged', this.handleAirPlayState);
|
||||
this.listeners.clear();
|
||||
}
|
||||
}
|
||||
|
||||
return CastService;
|
||||
});
|
||||
82
test/t33CastFeature.test.js
Normal file
82
test/t33CastFeature.test.js
Normal file
@ -0,0 +1,82 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const castServicePath = path.join(root, 'public', 'js', 'services', 'castService.js');
|
||||
|
||||
function loadCastService() {
|
||||
assert.ok(fs.existsSync(castServicePath), 'CastService module should exist at public/js/services/castService.js');
|
||||
return require(castServicePath);
|
||||
}
|
||||
|
||||
test('CastService reports Remote Playback availability and prompts through video.remote', async () => {
|
||||
const CastService = loadCastService();
|
||||
let availabilityCallback;
|
||||
let promptCalls = 0;
|
||||
const video = {
|
||||
remote: {
|
||||
watchAvailability: async callback => {
|
||||
availabilityCallback = callback;
|
||||
callback(true);
|
||||
return 7;
|
||||
},
|
||||
prompt: async () => { promptCalls += 1; }
|
||||
}
|
||||
};
|
||||
|
||||
const service = new CastService(video);
|
||||
await service.watchAvailability();
|
||||
assert.equal(service.state.available, true);
|
||||
assert.equal(service.state.type, 'remote-playback');
|
||||
availabilityCallback(false);
|
||||
assert.equal(service.state.available, false);
|
||||
|
||||
await service.prompt();
|
||||
assert.equal(promptCalls, 1);
|
||||
});
|
||||
|
||||
test('CastService uses AirPlay picker when webkitShowPlaybackTargetPicker is available', async () => {
|
||||
const CastService = loadCastService();
|
||||
let airplayCalls = 0;
|
||||
const video = {
|
||||
webkitShowPlaybackTargetPicker: () => { airplayCalls += 1; }
|
||||
};
|
||||
|
||||
const service = new CastService(video);
|
||||
await service.watchAvailability();
|
||||
assert.equal(service.state.available, true);
|
||||
assert.equal(service.state.type, 'airplay');
|
||||
|
||||
await service.prompt();
|
||||
assert.equal(airplayCalls, 1);
|
||||
});
|
||||
|
||||
test('CastService stays unsupported without native casting APIs', async () => {
|
||||
const CastService = loadCastService();
|
||||
const service = new CastService({});
|
||||
await assert.doesNotReject(() => service.watchAvailability());
|
||||
await assert.doesNotReject(() => service.prompt());
|
||||
assert.equal(service.state.available, false);
|
||||
assert.equal(service.state.type, 'unsupported');
|
||||
});
|
||||
|
||||
test('Live and Watch players expose cast controls and load CastService', () => {
|
||||
const html = fs.readFileSync(path.join(root, 'public', 'index.html'), 'utf8');
|
||||
assert.match(html, /id="player-cast"/, 'Live player should expose a cast button');
|
||||
assert.match(html, /id="watch-cast"/, 'Watch player should expose a cast button');
|
||||
assert.match(html, /\/js\/services\/castService\.js/, 'index.html should load CastService before player pages');
|
||||
});
|
||||
|
||||
test('PWA/service worker cache rules do not precache live stream endpoints', () => {
|
||||
const publicDir = path.join(root, 'public');
|
||||
const candidates = fs.readdirSync(publicDir)
|
||||
.filter(name => /(?:service-worker|sw|workbox).*\.js$/i.test(name))
|
||||
.map(name => path.join(publicDir, name));
|
||||
for (const file of candidates) {
|
||||
const text = fs.readFileSync(file, 'utf8');
|
||||
assert.doesNotMatch(text, /\/api\/(?:proxy|transcode|live)/, `${path.basename(file)} should not precache stream API routes`);
|
||||
assert.doesNotMatch(text, /\.m3u8|\.ts\b/, `${path.basename(file)} should not precache HLS playlists or segments`);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user