feat(player): add PWA-compatible stream casting (#33)
Add a browser-native casting service with Live and Watch player controls for Remote Playback and AirPlay-compatible devices. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
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;
|
||||
});
|
||||
Reference in New Issue
Block a user