immich/web/src/lib/managers/cast-manager.svelte.ts
Brandon Wees 86db0aafe5
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
2025-05-20 16:08:23 -05:00

159 lines
4.7 KiB
TypeScript

import { GCastDestination } from '$lib/utils/cast/gcast-destination.svelte';
import { createSession, type SessionCreateResponseDto } from '@immich/sdk';
import { DateTime, Duration } from 'luxon';
// follows chrome.cast.media.PlayerState
export enum CastState {
IDLE = 'IDLE',
PLAYING = 'PLAYING',
PAUSED = 'PAUSED',
BUFFERING = 'BUFFERING',
}
export enum CastDestinationType {
GCAST = 'GCAST',
}
export interface ICastDestination {
initialize(): Promise<boolean>; // returns if the cast destination can be used
type: CastDestinationType; // type of cast destination
isAvailable: boolean; // can we use the cast destination
isConnected: boolean; // is the cast destination actively sharing
currentTime: number | null; // current seek time the player is at
duration: number | null; // duration of media
receiverName: string | null; // name of the cast destination
castState: CastState; // current state of the cast destination
loadMedia(mediaUrl: string, sessionKey: string, reload: boolean): Promise<void>; // load media to the cast destination
// remote player controls
play(): void;
pause(): void;
seekTo(time: number): void;
disconnect(): void;
}
class CastManager {
private castDestinations = $state<ICastDestination[]>([]);
private current = $derived<ICastDestination | null>(this.monitorConnectedDestination());
availableDestinations = $state<ICastDestination[]>([]);
initialized = $state(false);
isCasting = $derived<boolean>(this.current?.isConnected ?? false);
receiverName = $derived<string | null>(this.current?.receiverName ?? null);
castState = $derived<CastState | null>(this.current?.castState ?? null);
currentTime = $derived<number | null>(this.current?.currentTime ?? null);
duration = $derived<number | null>(this.current?.duration ?? null);
private sessionKey: SessionCreateResponseDto | null = null;
constructor() {
// load each cast destination
this.castDestinations = [
new GCastDestination(),
// Add other cast destinations here (ie FCast)
];
}
async initialize() {
// this goes first to prevent multiple calls to initialize
if (this.initialized) {
return;
}
this.initialized = true;
// try to initialize each cast destination
for (const castDestination of this.castDestinations) {
const destAvailable = await castDestination.initialize();
if (destAvailable) {
this.availableDestinations.push(castDestination);
}
}
}
// monitor all cast destinations for changes
// we want to make sure only one session is active at a time
private monitorConnectedDestination(): ICastDestination | null {
// check if we have a connected destination
const connectedDest = this.castDestinations.find((dest) => dest.isConnected);
return connectedDest || null;
}
private isTokenValid() {
// check if we already have a session token
// we should always have a expiration date
if (!this.sessionKey || !this.sessionKey.expiresAt) {
return false;
}
const tokenExpiration = DateTime.fromISO(this.sessionKey.expiresAt);
// we want to make sure we have at least 10 seconds remaining in the session
// this is to account for network latency and other delays when sending the request
const bufferedExpiration = tokenExpiration.minus({ seconds: 10 });
return bufferedExpiration > DateTime.now();
}
private async refreshSessionToken() {
// get session token to authenticate the media url
// check and make sure we have at least 10 seconds remaining in the session
// before we send the media request, refresh the session if needed
if (!this.isTokenValid()) {
this.sessionKey = await createSession({
sessionCreateDto: {
duration: Duration.fromObject({ minutes: 15 }).as('seconds'),
deviceOS: 'Google Cast',
deviceType: 'Cast',
},
});
}
}
async loadMedia(mediaUrl: string, reload: boolean = false) {
if (!this.current) {
throw new Error('No active cast destination');
}
await this.refreshSessionToken();
if (!this.sessionKey) {
throw new Error('No session key available');
}
await this.current.loadMedia(mediaUrl, this.sessionKey.token, reload);
}
play() {
this.current?.play();
}
pause() {
this.current?.pause();
}
seekTo(time: number) {
this.current?.seekTo(time);
}
disconnect() {
this.current?.disconnect();
}
}
// Persist castManager across Svelte HMRs
let castManager: CastManager;
if (import.meta.hot && import.meta.hot.data) {
if (!import.meta.hot.data.castManager) {
import.meta.hot.data.castManager = new CastManager();
}
castManager = import.meta.hot.data.castManager;
} else {
castManager = new CastManager();
}
export { castManager };