mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
feat(web): add support for casting (#18231)
* recreate #13966 * gcast button works * rewrote gcast-player to be GCastDestination and CastManager manages the interface between UI and casting destinations * remove unneeded imports * add "Connected to" translation * Remove css for cast launcher * fix tests * fix doc tests * fix the receiver application ID * remove casting app ID * remove cast button from nav bar It is now present at the following locations: - shared link album and single asset views - asset viewer (normal user) - album view (normal user) * part 1 of fixes from @danieldietzler code review * part 2 of code review changes from @danieldietzler and @jsram91 * cleanup documentation * onVideoStarted missing callback * add token expiry validation * cleanup logic and logging * small cleanup * rename to ICastDestination * cast button changes
This commit is contained in:
parent
12b7a079c1
commit
86db0aafe5
16 changed files with 708 additions and 36 deletions
234
web/src/lib/utils/cast/gcast-destination.svelte.ts
Normal file
234
web/src/lib/utils/cast/gcast-destination.svelte.ts
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import { CastDestinationType, CastState, type ICastDestination } from '$lib/managers/cast-manager.svelte';
|
||||
import 'chromecast-caf-sender';
|
||||
import { Duration } from 'luxon';
|
||||
|
||||
const FRAMEWORK_LINK = 'https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1';
|
||||
|
||||
enum SESSION_DISCOVERY_CAUSE {
|
||||
LOAD_MEDIA,
|
||||
ACTIVE_SESSION,
|
||||
}
|
||||
|
||||
export class GCastDestination implements ICastDestination {
|
||||
type = CastDestinationType.GCAST;
|
||||
isAvailable = $state<boolean>(false);
|
||||
isConnected = $state<boolean>(false);
|
||||
currentTime = $state<number | null>(null);
|
||||
duration = $state<number | null>(null);
|
||||
castState = $state<CastState>(CastState.IDLE);
|
||||
receiverName = $state<string | null>(null);
|
||||
|
||||
private remotePlayer: cast.framework.RemotePlayer | null = null;
|
||||
private session: chrome.cast.Session | null = null;
|
||||
private currentMedia: chrome.cast.media.Media | null = null;
|
||||
private currentUrl: string | null = null;
|
||||
|
||||
async initialize(): Promise<boolean> {
|
||||
// this is a really messy way since google does a pseudo-callbak
|
||||
// in the form of a global window event. We will give Chrome 3 seconds to respond
|
||||
// or we will mark the destination as unavailable
|
||||
|
||||
const callbackPromise: Promise<boolean> = new Promise((resolve) => {
|
||||
// check if the cast framework is already loaded
|
||||
if (this.isAvailable) {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
|
||||
window['__onGCastApiAvailable'] = (isAvailable: boolean) => {
|
||||
resolve(isAvailable);
|
||||
};
|
||||
|
||||
if (!document.querySelector(`script[src="${FRAMEWORK_LINK}"]`)) {
|
||||
const script = document.createElement('script');
|
||||
script.src = FRAMEWORK_LINK;
|
||||
document.body.append(script);
|
||||
}
|
||||
});
|
||||
|
||||
const timeoutPromise: Promise<boolean> = new Promise((resolve) => {
|
||||
setTimeout(
|
||||
() => {
|
||||
resolve(false);
|
||||
},
|
||||
Duration.fromObject({ seconds: 3 }).toMillis(),
|
||||
);
|
||||
});
|
||||
|
||||
this.isAvailable = await Promise.race([callbackPromise, timeoutPromise]);
|
||||
|
||||
if (!this.isAvailable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const castContext = cast.framework.CastContext.getInstance();
|
||||
this.remotePlayer = new cast.framework.RemotePlayer();
|
||||
|
||||
castContext.setOptions({
|
||||
receiverApplicationId: chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID,
|
||||
autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED,
|
||||
});
|
||||
|
||||
castContext.addEventListener(cast.framework.CastContextEventType.SESSION_STATE_CHANGED, (event) =>
|
||||
this.onSessionStateChanged(event),
|
||||
);
|
||||
|
||||
castContext.addEventListener(cast.framework.CastContextEventType.CAST_STATE_CHANGED, (event) =>
|
||||
this.onCastStateChanged(event),
|
||||
);
|
||||
|
||||
const remotePlayerController = new cast.framework.RemotePlayerController(this.remotePlayer);
|
||||
remotePlayerController.addEventListener(cast.framework.RemotePlayerEventType.ANY_CHANGE, (event) =>
|
||||
this.onRemotePlayerChange(event),
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async loadMedia(mediaUrl: string, sessionKey: string, reload: boolean = false): Promise<void> {
|
||||
if (!this.isAvailable || !this.isConnected || !this.session) {
|
||||
return;
|
||||
}
|
||||
|
||||
// already playing the same media
|
||||
if (this.currentUrl === mediaUrl && !reload) {
|
||||
return;
|
||||
}
|
||||
|
||||
// we need to send content type in the request
|
||||
// in the future we can swap this out for an API call to get image metadata
|
||||
const assetHead = await fetch(mediaUrl, { method: 'HEAD' });
|
||||
const contentType = assetHead.headers.get('content-type');
|
||||
|
||||
if (!contentType) {
|
||||
throw new Error('No content type found for media url');
|
||||
}
|
||||
|
||||
// build the authenticated media request and send it to the cast device
|
||||
const authenticatedUrl = `${mediaUrl}&sessionKey=${sessionKey}`;
|
||||
const mediaInfo = new chrome.cast.media.MediaInfo(authenticatedUrl, contentType);
|
||||
const request = new chrome.cast.media.LoadRequest(mediaInfo);
|
||||
const successCallback = this.onMediaDiscovered.bind(this, SESSION_DISCOVERY_CAUSE.LOAD_MEDIA);
|
||||
|
||||
this.currentUrl = mediaUrl;
|
||||
|
||||
return this.session.loadMedia(request, successCallback, this.onError.bind(this));
|
||||
}
|
||||
|
||||
///
|
||||
/// Remote Player Controls
|
||||
///
|
||||
|
||||
play(): void {
|
||||
if (!this.currentMedia) {
|
||||
return;
|
||||
}
|
||||
|
||||
const playRequest = new chrome.cast.media.PlayRequest();
|
||||
|
||||
this.currentMedia.play(playRequest, () => {}, this.onError.bind(this));
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
if (!this.currentMedia) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pauseRequest = new chrome.cast.media.PauseRequest();
|
||||
|
||||
this.currentMedia.pause(pauseRequest, () => {}, this.onError.bind(this));
|
||||
}
|
||||
|
||||
seekTo(time: number): void {
|
||||
const remotePlayer = new cast.framework.RemotePlayer();
|
||||
const remotePlayerController = new cast.framework.RemotePlayerController(remotePlayer);
|
||||
remotePlayer.currentTime = time;
|
||||
remotePlayerController.seek();
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.session?.leave(() => {
|
||||
this.session = null;
|
||||
this.castState = CastState.IDLE;
|
||||
this.isConnected = false;
|
||||
this.receiverName = null;
|
||||
}, this.onError.bind(this));
|
||||
}
|
||||
|
||||
///
|
||||
/// Google Cast Callbacks
|
||||
///
|
||||
private onSessionStateChanged(event: cast.framework.SessionStateEventData) {
|
||||
switch (event.sessionState) {
|
||||
case cast.framework.SessionState.SESSION_ENDED: {
|
||||
this.session = null;
|
||||
break;
|
||||
}
|
||||
case cast.framework.SessionState.SESSION_RESUMED:
|
||||
case cast.framework.SessionState.SESSION_STARTED: {
|
||||
this.session = event.session.getSessionObj();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onCastStateChanged(event: cast.framework.CastStateEventData) {
|
||||
this.isConnected = event.castState === cast.framework.CastState.CONNECTED;
|
||||
this.receiverName = this.session?.receiver.friendlyName ?? null;
|
||||
|
||||
if (event.castState === cast.framework.CastState.NOT_CONNECTED) {
|
||||
this.currentMedia = null;
|
||||
this.currentUrl = null;
|
||||
}
|
||||
}
|
||||
|
||||
private onRemotePlayerChange(event: cast.framework.RemotePlayerChangedEvent) {
|
||||
switch (event.field) {
|
||||
case 'isConnected': {
|
||||
this.isConnected = event.value;
|
||||
break;
|
||||
}
|
||||
case 'remotePlayer': {
|
||||
this.remotePlayer = event.value;
|
||||
break;
|
||||
}
|
||||
case 'duration': {
|
||||
this.duration = event.value;
|
||||
break;
|
||||
}
|
||||
case 'currentTime': {
|
||||
this.currentTime = event.value;
|
||||
break;
|
||||
}
|
||||
case 'playerState': {
|
||||
this.castState = event.value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onError(error: chrome.cast.Error) {
|
||||
console.error('Google Cast Error:', error);
|
||||
}
|
||||
|
||||
private onMediaDiscovered(cause: SESSION_DISCOVERY_CAUSE, currentMedia: chrome.cast.media.Media) {
|
||||
this.currentMedia = currentMedia;
|
||||
|
||||
if (cause === SESSION_DISCOVERY_CAUSE.LOAD_MEDIA) {
|
||||
this.castState = CastState.PLAYING;
|
||||
} else if (cause === SESSION_DISCOVERY_CAUSE.ACTIVE_SESSION) {
|
||||
// CastState and PlayerState are identical enums
|
||||
this.castState = currentMedia.playerState as unknown as CastState;
|
||||
}
|
||||
}
|
||||
|
||||
static async showCastDialog() {
|
||||
try {
|
||||
await cast.framework.CastContext.getInstance().requestSession();
|
||||
} catch {
|
||||
// the cast dialog throws an error if the user closes it
|
||||
// we don't care about this error
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue