diff --git a/docs/docs/features/casting.md b/docs/docs/features/casting.md new file mode 100644 index 0000000000..cc25e24da7 --- /dev/null +++ b/docs/docs/features/casting.md @@ -0,0 +1,11 @@ +# Chromecast support + +Immich supports the Google's Cast protocol so that photos and videos can be cast to devices such as a Chromecast and a Nest Hub. This feature is considered experimental and has several important limitations listed below. Currently, this feature is only supported by the web client, support on Android and iOS is planned for the future. + +## Limitations + +To use casting with Immich, there are a few prerequisites: + +1. Your instance must be accessed via an HTTPS connection in order for the casting menu to show. +2. Your instance must be publicly accessible via HTTPS and a DNS record for the server must be accessible via Google's DNS servers (`8.8.8.8` and `8.8.4.4`) +3. Videos must be in a format that is compatible with Google Cast. For more info, check out [Google's documentation](https://developers.google.com/cast/docs/media) diff --git a/i18n/en.json b/i18n/en.json index fb7743f8e4..d813b7f335 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -604,6 +604,7 @@ "cannot_merge_people": "Cannot merge people", "cannot_undo_this_action": "You cannot undo this action!", "cannot_update_the_description": "Cannot update the description", + "cast": "Cast", "change_date": "Change date", "change_description": "Change description", "change_display_order": "Change display order", @@ -661,6 +662,7 @@ "confirm_keep_this_delete_others": "All other assets in the stack will be deleted except for this asset. Are you sure you want to continue?", "confirm_new_pin_code": "Confirm new PIN code", "confirm_password": "Confirm password", + "connected_to": "Connected to", "contain": "Contain", "context": "Context", "continue": "Continue", diff --git a/web/package-lock.json b/web/package-lock.json index bde201c176..a1b1440fd7 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -52,6 +52,7 @@ "@testing-library/jest-dom": "^6.4.2", "@testing-library/svelte": "^5.2.6", "@testing-library/user-event": "^14.5.2", + "@types/chromecast-caf-sender": "^1.0.11", "@types/dom-to-image": "^2.6.7", "@types/justified-layout": "^4.1.4", "@types/lodash-es": "^4.17.12", @@ -2747,6 +2748,27 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/chrome": { + "version": "0.0.322", + "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.322.tgz", + "integrity": "sha512-glbRm82TzLLJfi3ttlnn7HR9KIX5OYeTo9Xug0Hna03JvaqNipZT+P/q/O5kxOvUQqKUqmn8NAOrcRSG6BOQAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/filesystem": "*", + "@types/har-format": "*" + } + }, + "node_modules/@types/chromecast-caf-sender": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@types/chromecast-caf-sender/-/chromecast-caf-sender-1.0.11.tgz", + "integrity": "sha512-Pv3xvNYtxD/cTM/tKfuZRlLasvpxAm+CFni0GJd6Cp8XgiZS9g9tMZkR1uymsi5fIFv057SZKKAWVFFgy7fJtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chrome": "*" + } + }, "node_modules/@types/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", @@ -2767,6 +2789,23 @@ "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", "license": "MIT" }, + "node_modules/@types/filesystem": { + "version": "0.0.36", + "resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz", + "integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/filewriter": "*" + } + }, + "node_modules/@types/filewriter": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz", + "integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/geojson": { "version": "7946.0.16", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", @@ -2782,6 +2821,13 @@ "@types/geojson": "*" } }, + "node_modules/@types/har-format": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz", + "integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", diff --git a/web/package.json b/web/package.json index e61c3919ee..44aad72d42 100644 --- a/web/package.json +++ b/web/package.json @@ -69,6 +69,7 @@ "@testing-library/jest-dom": "^6.4.2", "@testing-library/svelte": "^5.2.6", "@testing-library/user-event": "^14.5.2", + "@types/chromecast-caf-sender": "^1.0.11", "@types/dom-to-image": "^2.6.7", "@types/justified-layout": "^4.1.4", "@types/lodash-es": "^4.17.12", diff --git a/web/src/lib/cast/cast-button.svelte b/web/src/lib/cast/cast-button.svelte new file mode 100644 index 0000000000..c6be1c11d7 --- /dev/null +++ b/web/src/lib/cast/cast-button.svelte @@ -0,0 +1,45 @@ + + +{#if castManager.availableDestinations.length > 0 && castManager.availableDestinations[0].type === CastDestinationType.GCAST} + {#if navBar} + void GCastDestination.showCastDialog()} + aria-label={$t('cast')} + /> + {:else} + + {/if} +{/if} diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte index 887c3a81e4..227fb999b8 100644 --- a/web/src/lib/components/album-page/album-viewer.svelte +++ b/web/src/lib/components/album-page/album-viewer.svelte @@ -21,6 +21,7 @@ import ImmichLogoSmallLink from '../shared-components/immich-logo-small-link.svelte'; import ThemeButton from '../shared-components/theme-button.svelte'; import AlbumSummary from './album-summary.svelte'; + import CastButton from '$lib/cast/cast-button.svelte'; interface Props { sharedLink: SharedLinkResponseDto; @@ -103,6 +104,8 @@ {/snippet} {#snippet trailing()} + + {#if sharedLink.allowUpload} import { goto } from '$app/navigation'; + import CastButton from '$lib/cast/cast-button.svelte'; import type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action'; import AddToAlbumAction from '$lib/components/asset-viewer/actions/add-to-album-action.svelte'; import ArchiveAction from '$lib/components/asset-viewer/actions/archive-action.svelte'; @@ -116,6 +117,8 @@ {/if}
+ + {#if !asset.isTrashed && $user && !isLocked} {/if} diff --git a/web/src/lib/components/asset-viewer/photo-viewer.spec.ts b/web/src/lib/components/asset-viewer/photo-viewer.spec.ts index d90fb89c23..9e9f8fae62 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.spec.ts +++ b/web/src/lib/components/asset-viewer/photo-viewer.spec.ts @@ -31,6 +31,29 @@ describe('PhotoViewer component', () => { beforeAll(() => { getAssetOriginalUrlSpy = vi.spyOn(utils, 'getAssetOriginalUrl'); getAssetThumbnailUrlSpy = vi.spyOn(utils, 'getAssetThumbnailUrl'); + + vi.stubGlobal('cast', { + framework: { + CastState: { + NO_DEVICES_AVAILABLE: 'NO_DEVICES_AVAILABLE', + }, + RemotePlayer: vi.fn().mockImplementation(() => ({})), + RemotePlayerEventType: { + ANY_CHANGE: 'anyChanged', + }, + RemotePlayerController: vi.fn().mockImplementation(() => ({ addEventListener: vi.fn() })), + CastContext: { + getInstance: vi.fn().mockImplementation(() => ({ setOptions: vi.fn(), addEventListener: vi.fn() })), + }, + CastContextEventType: { + SESSION_STATE_CHANGED: 'sessionstatechanged', + CAST_STATE_CHANGED: 'caststatechanged', + }, + }, + }); + vi.stubGlobal('chrome', { + cast: { media: { PlayerState: { IDLE: 'IDLE' } }, AutoJoinPolicy: { ORIGIN_SCOPED: 'origin_scoped' } }, + }); }); beforeEach(() => { diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index b00be3c2f3..bb817494de 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -23,6 +23,7 @@ import { fade } from 'svelte/transition'; import LoadingSpinner from '../shared-components/loading-spinner.svelte'; import { NotificationType, notificationController } from '../shared-components/notification/notification'; + import { castManager } from '$lib/managers/cast-manager.svelte'; interface Props { asset: AssetResponseDto; @@ -147,6 +148,27 @@ return AssetMediaSize.Preview; }); + $effect(() => { + if (assetFileUrl) { + // this can't be in an async context with $effect + void cast(assetFileUrl); + } + }); + + const cast = async (url: string) => { + if (!url || !castManager.isCasting) { + return; + } + const fullUrl = new URL(url, globalThis.location.href); + + try { + await castManager.loadMedia(fullUrl.href); + } catch (error) { + handleError(error, 'Unable to cast'); + return; + } + }; + const onload = () => { imageLoaded = true; assetFileUrl = imageLoaderUrl; diff --git a/web/src/lib/components/asset-viewer/video-native-viewer.svelte b/web/src/lib/components/asset-viewer/video-native-viewer.svelte index a8b0abe5eb..8205c8c353 100644 --- a/web/src/lib/components/asset-viewer/video-native-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-native-viewer.svelte @@ -1,6 +1,8 @@ + + + + {$t('connected_to')} {castManager.receiverName} + + +poster + +
+ {#if castManager.castState == CastState.BUFFERING} +
+ +
+ {:else} + handlePlayPauseButton()} + title={castManager.castState == CastState.PLAYING ? 'Pause' : 'Play'} + /> + {/if} + + +
diff --git a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte index f1a48c98b4..582270b1af 100644 --- a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte +++ b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte @@ -27,6 +27,7 @@ import ThemeButton from '../theme-button.svelte'; import UserAvatar from '../user-avatar.svelte'; import AccountInfoPanel from './account-info-panel.svelte'; + import CastButton from '$lib/cast/cast-button.svelte'; interface Props { showUploadButton?: boolean; @@ -162,6 +163,8 @@ {/if}
+ +
(shouldShowAccountInfoPanel = false), diff --git a/web/src/lib/managers/cast-manager.svelte.ts b/web/src/lib/managers/cast-manager.svelte.ts new file mode 100644 index 0000000000..227bd3faea --- /dev/null +++ b/web/src/lib/managers/cast-manager.svelte.ts @@ -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; // 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; // load media to the cast destination + + // remote player controls + play(): void; + pause(): void; + seekTo(time: number): void; + disconnect(): void; +} + +class CastManager { + private castDestinations = $state([]); + private current = $derived(this.monitorConnectedDestination()); + + availableDestinations = $state([]); + initialized = $state(false); + + isCasting = $derived(this.current?.isConnected ?? false); + receiverName = $derived(this.current?.receiverName ?? null); + castState = $derived(this.current?.castState ?? null); + currentTime = $derived(this.current?.currentTime ?? null); + duration = $derived(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 }; diff --git a/web/src/lib/utils/cast/gcast-destination.svelte.ts b/web/src/lib/utils/cast/gcast-destination.svelte.ts new file mode 100644 index 0000000000..fcfb8c382a --- /dev/null +++ b/web/src/lib/utils/cast/gcast-destination.svelte.ts @@ -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(false); + isConnected = $state(false); + currentTime = $state(null); + duration = $state(null); + castState = $state(CastState.IDLE); + receiverName = $state(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 { + // 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 = 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 = 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 { + 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; + } + } +} diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 7fcc70ae25..bd9186e3c0 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -1,6 +1,7 @@