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}
+
+
+

+
+
+ {#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 @@