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 @@