mirror of
https://github.com/immich-app/immich
synced 2025-11-07 17:27:20 +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
159
web/src/lib/managers/cast-manager.svelte.ts
Normal file
159
web/src/lib/managers/cast-manager.svelte.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
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 };
|
||||
Loading…
Add table
Add a link
Reference in a new issue