Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(web): chromecast support #13966

Draft
wants to merge 6 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions docs/docs/features/casting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# 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.

## Casting

When you use Chrome and a chromecast is found on your local network, there will be a Cast button in the top navigation bar and in the asset viewer. Click the button and Chrome will pop up a menu to select the device to cast to. Now, browse Immich normally and your photos and videos should show up on the casted device!

When you are finished casting, click the cast menu again and press the stop button.

## Network requirements

Your Immich server must be reachable by the Chromecast, not just your browser. If you are away from your home network you therefore can't cast your vacation photo to your friend's TV if only your computer is connected to a VPN.

Your Immich server address must be resolvable by the Google DNS servers `8.8.8.8` and `8.8.4.4`, so you can't use an internal domain name on your internal DNS server.

You also need a real TLS certificate such as from LetsEncrypt. Self-signed certificates are not supported.

## Limitations

At this stage there are some important limitations to be aware of.

### Video playback

Depending on the device you are casting to, you need to consider the video codecs, resolutions, and framerates are supported please see [this list](https://developers.google.com/cast/docs/media#video_codecs). For instance, Chromecast generation 1 and 2 only support playback of 1080p/30 videos while 3rd gen supports 1080p/60 but only when using H.264.

Immich does not support on-the-fly transcoding, so you'll need to preselect the transcoded video format in the transcoding settings. For instance, if you will be casting to a 3rd gen Chromecast, all videos in your Immich instance must be transcoded to H.264 with at most 1080p at 60 fps.

In the Video Transcoding settings, set the `Transcode Policy` to `Videos higher than target resolution or not in an accepted format`. Then select your target resolution. We currently don't have a way to set the target framerate, so your high refresh rate videos will have a hard time playing on older devices

### Other limitations

- No preloading which means navigating between assets is slow
- No support for slideshows
- No video playback controls like pause, resume, and seek
- Your Immich server must be accessible from the Chromecast device

## Troubleshooting

First, ensure you are using Chrome since other browsers will never be supported by Google.

Next, check the network requirements above.

If you have issues playing video, check the video playback limitations above.
83 changes: 83 additions & 0 deletions web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"tslib": "^2.6.2",
"typescript": "^5.5.0",
"vite": "^5.1.4",
"vite-tsconfig-paths": "^5.1.0",
"vitest": "^2.0.5"
},
"type": "module",
Expand All @@ -72,6 +73,7 @@
"@photo-sphere-viewer/core": "^5.7.1",
"@photo-sphere-viewer/equirectangular-video-adapter": "^5.7.2",
"@photo-sphere-viewer/video-plugin": "^5.7.2",
"@types/chromecast-caf-sender": "^1.0.10",
"@zoom-image/svelte": "^0.3.0",
"dom-to-image": "^2.6.0",
"handlebars": "^4.7.8",
Expand Down
8 changes: 8 additions & 0 deletions web/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,11 @@ input:focus-visible {
scrollbar-gutter: stable both-edges;
}
}

google-cast-launcher {
float: right;
width: 40px;
height: 30px;

--disconnected-color: white;
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
} from '@mdi/js';
import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
import { t } from 'svelte-i18n';
import CastButton from '$lib/components/cast/cast-button.svelte';

export let asset: AssetResponseDto;
export let album: AlbumResponseDto | null = null;
Expand Down Expand Up @@ -84,6 +85,7 @@
class="flex w-[calc(100%-3rem)] justify-end gap-2 overflow-hidden text-white"
data-testid="asset-viewer-navbar-actions"
>
<CastButton />
{#if !asset.isTrashed && $user}
<ShareAction {asset} />
{/if}
Expand Down
22 changes: 22 additions & 0 deletions web/src/lib/components/asset-viewer/photo-viewer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,28 @@ 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(() => {
Expand Down
26 changes: 26 additions & 0 deletions web/src/lib/components/asset-viewer/photo-viewer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import { NotificationType, notificationController } from '../shared-components/notification/notification';
import { handleError } from '$lib/utils/handle-error';
import CastPlayer from '$lib/utils/cast-player';
import { get } from 'svelte/store';

export let asset: AssetResponseDto;
export let preloadAssets: AssetResponseDto[] | undefined = undefined;
Expand All @@ -38,6 +40,10 @@
let forceUseOriginal: boolean = false;
let loader: HTMLImageElement;

let castPlayer = CastPlayer.getInstance();

let castState = get(castPlayer.castState);

$: isWebCompatible = isWebCompatibleImage(asset);
$: useOriginalByDefault = isWebCompatible && $alwaysLoadOriginalFile;
$: useOriginalImage = useOriginalByDefault || forceUseOriginal;
Expand All @@ -47,6 +53,7 @@

$: preload(useOriginalImage, preloadAssets);
$: imageLoaderUrl = getAssetUrl(asset.id, useOriginalImage, asset.checksum);
$: void cast(imageLoaderUrl);

photoZoomState.set({
currentRotation: 0,
Expand All @@ -57,6 +64,25 @@
});
$zoomed = false;

onMount(() => {
castPlayer.castState.subscribe((value) => {
if (castState !== value) {
void cast(assetFileUrl);
}
castState = value;
});
});

const cast = async (url: string) => {
if (!url) {
return;
} else if (castState !== 'CONNECTED') {
return;
}
const fullUrl = new URL(url, window.location.href);
await castPlayer.loadMedia(fullUrl.href);
};

onDestroy(() => {
$boundingBoxesArray = [];
});
Expand Down
Loading
Loading