mirror of
https://github.com/immich-app/immich
synced 2025-10-17 18:19:27 +00:00
* 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
159 lines
4.7 KiB
TypeScript
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 };
|