feat(web): make google cast opt in (#18514)

* add setting switch

this isnt bound to anything yet

* make google casting opt-in

* doc updates

* lint docs

* remove unneeded translation items

* update mobile openai defs

* fix failing test

we need to mock user prefs since CastButton uses it
This commit is contained in:
Brandon Wees 2025-05-28 15:57:36 -05:00 committed by GitHub
parent b054e9dc2c
commit 78224961d1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 141 additions and 1 deletions

View File

@ -2,6 +2,14 @@
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.
## Enable Google Cast Support
Google Cast support is disabled by default. The web UI uses Google-provided scripts and must retreive them from Google servers when the page loads. This is a privacy concern for some and is thus opt-in.
You can enable Google Cast support through `Account Settings > Features > Cast > Google Cast`
<img src={require('./img/gcast-enable.webp').default} width="70%" title='Enable Google Cast Support' />
## Limitations
To use casting with Immich, there are a few prerequisites:

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@ -604,6 +604,7 @@
"cannot_undo_this_action": "You cannot undo this action!",
"cannot_update_the_description": "Cannot update the description",
"cast": "Cast",
"cast_description": "Configure available cast destinations",
"change_date": "Change date",
"change_description": "Change description",
"change_display_order": "Change display order",
@ -1027,6 +1028,8 @@
"folders": "Folders",
"folders_feature_description": "Browsing the folder view for the photos and videos on the file system",
"forward": "Forward",
"gcast_enabled": "Google Cast",
"gcast_enabled_description": "This feature loads external resources from Google in order to work.",
"general": "General",
"get_help": "Get Help",
"get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network",

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/lib/model/cast_update.dart generated Normal file

Binary file not shown.

View File

@ -9556,6 +9556,26 @@
],
"type": "string"
},
"CastResponse": {
"properties": {
"gCastEnabled": {
"default": false,
"type": "boolean"
}
},
"required": [
"gCastEnabled"
],
"type": "object"
},
"CastUpdate": {
"properties": {
"gCastEnabled": {
"type": "boolean"
}
},
"type": "object"
},
"ChangePasswordDto": {
"properties": {
"newPassword": {
@ -14806,6 +14826,9 @@
},
"UserPreferencesResponseDto": {
"properties": {
"cast": {
"$ref": "#/components/schemas/CastResponse"
},
"download": {
"$ref": "#/components/schemas/DownloadResponse"
},
@ -14835,6 +14858,7 @@
}
},
"required": [
"cast",
"download",
"emailNotifications",
"folders",
@ -14852,6 +14876,9 @@
"avatar": {
"$ref": "#/components/schemas/AvatarUpdate"
},
"cast": {
"$ref": "#/components/schemas/CastUpdate"
},
"download": {
"$ref": "#/components/schemas/DownloadUpdate"
},

View File

@ -128,6 +128,9 @@ export type UserAdminUpdateDto = {
shouldChangePassword?: boolean;
storageLabel?: string | null;
};
export type CastResponse = {
gCastEnabled: boolean;
};
export type DownloadResponse = {
archiveSize: number;
includeEmbeddedVideos: boolean;
@ -164,6 +167,7 @@ export type TagsResponse = {
sidebarWeb: boolean;
};
export type UserPreferencesResponseDto = {
cast: CastResponse;
download: DownloadResponse;
emailNotifications: EmailNotificationsResponse;
folders: FoldersResponse;
@ -177,6 +181,9 @@ export type UserPreferencesResponseDto = {
export type AvatarUpdate = {
color?: UserAvatarColor;
};
export type CastUpdate = {
gCastEnabled?: boolean;
};
export type DownloadUpdate = {
archiveSize?: number;
includeEmbeddedVideos?: boolean;
@ -214,6 +221,7 @@ export type TagsUpdate = {
};
export type UserPreferencesUpdateDto = {
avatar?: AvatarUpdate;
cast?: CastUpdate;
download?: DownloadUpdate;
emailNotifications?: EmailNotificationsUpdate;
folders?: FoldersUpdate;

View File

@ -85,6 +85,11 @@ class PurchaseUpdate {
hideBuyButtonUntil?: string;
}
class CastUpdate {
@ValidateBoolean({ optional: true })
gCastEnabled?: boolean;
}
export class UserPreferencesUpdateDto {
@Optional()
@ValidateNested()
@ -135,6 +140,11 @@ export class UserPreferencesUpdateDto {
@ValidateNested()
@Type(() => PurchaseUpdate)
purchase?: PurchaseUpdate;
@Optional()
@ValidateNested()
@Type(() => CastUpdate)
cast?: CastUpdate;
}
class RatingsResponse {
@ -183,6 +193,10 @@ class PurchaseResponse {
hideBuyButtonUntil!: string;
}
class CastResponse {
gCastEnabled: boolean = false;
}
export class UserPreferencesResponseDto implements UserPreferences {
folders!: FoldersResponse;
memories!: MemoriesResponse;
@ -193,6 +207,7 @@ export class UserPreferencesResponseDto implements UserPreferences {
emailNotifications!: EmailNotificationsResponse;
download!: DownloadResponse;
purchase!: PurchaseResponse;
cast!: CastResponse;
}
export const mapPreferences = (preferences: UserPreferences): UserPreferencesResponseDto => {

View File

@ -502,6 +502,9 @@ export interface UserPreferences {
showSupportBadge: boolean;
hideBuyButtonUntil: string;
};
cast: {
gCastEnabled: boolean;
};
}
export interface UserMetadata extends Record<UserMetadataKey, Record<string, any>> {

View File

@ -42,6 +42,9 @@ const getDefaultPreferences = (): UserPreferences => {
showSupportBadge: true,
hideBuyButtonUntil: new Date(2022, 1, 12).toISOString(),
},
cast: {
gCastEnabled: false,
},
};
};

View File

@ -1,5 +1,6 @@
import { resetSavedUser, user as userStore } from '$lib/stores/user.store';
import { preferences as preferencesStore, resetSavedUser, user as userStore } from '$lib/stores/user.store';
import { assetFactory } from '@test-data/factories/asset-factory';
import { preferencesFactory } from '@test-data/factories/preferences-factory';
import { userAdminFactory } from '@test-data/factories/user-factory';
import '@testing-library/jest-dom';
import { render } from '@testing-library/svelte';
@ -42,6 +43,9 @@ describe('AssetViewerNavBar component', () => {
});
it('shows back button', () => {
const prefs = preferencesFactory.build({ cast: { gCastEnabled: false } });
preferencesStore.set(prefs);
const asset = assetFactory.build({ isTrashed: false });
const { getByTitle } = render(AssetViewerNavBar, { asset, ...additionalProps });
expect(getByTitle('go_back')).toBeInTheDocument();
@ -53,6 +57,10 @@ describe('AssetViewerNavBar component', () => {
const user = userAdminFactory.build({ id: ownerId });
const asset = assetFactory.build({ ownerId, isTrashed: false });
userStore.set(user);
const prefs = preferencesFactory.build({ cast: { gCastEnabled: false } });
preferencesStore.set(prefs);
const { getByTitle } = render(AssetViewerNavBar, { asset, ...additionalProps });
expect(getByTitle('delete')).toBeInTheDocument();
});

View File

@ -34,6 +34,9 @@
let tagsEnabled = $state($preferences?.tags?.enabled ?? false);
let tagsSidebar = $state($preferences?.tags?.sidebarWeb ?? false);
// Cast
let gCastEnabled = $state($preferences?.cast?.gCastEnabled ?? false);
const handleSave = async () => {
try {
const data = await updateMyPreferences({
@ -44,6 +47,7 @@
ratings: { enabled: ratingsEnabled },
sharedLinks: { enabled: sharedLinksEnabled, sidebarWeb: sharedLinkSidebar },
tags: { enabled: tagsEnabled, sidebarWeb: tagsSidebar },
cast: { gCastEnabled },
},
});
@ -138,6 +142,16 @@
{/if}
</SettingAccordion>
<SettingAccordion key="cast" title={$t('cast')} subtitle={$t('cast_description')}>
<div class="ms-4 mt-6">
<SettingSwitch
title={$t('gcast_enabled')}
subtitle={$t('gcast_enabled_description')}
bind:checked={gCastEnabled}
/>
</div>
</SettingAccordion>
<div class="flex justify-end">
<Button shape="round" type="submit" size="small" onclick={() => handleSave()}>{$t('save')}</Button>
</div>

View File

@ -1,6 +1,8 @@
import { CastDestinationType, CastState, type ICastDestination } from '$lib/managers/cast-manager.svelte';
import { preferences } from '$lib/stores/user.store';
import 'chromecast-caf-sender';
import { Duration } from 'luxon';
import { get } from 'svelte/store';
const FRAMEWORK_LINK = 'https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1';
@ -24,6 +26,12 @@ export class GCastDestination implements ICastDestination {
private currentUrl: string | null = null;
async initialize(): Promise<boolean> {
const preferencesStore = get(preferences);
if (!preferencesStore.cast.gCastEnabled) {
this.isAvailable = false;
return false;
}
// 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

View File

@ -0,0 +1,43 @@
import type { UserPreferencesResponseDto } from '@immich/sdk';
import { Sync } from 'factory.ts';
export const preferencesFactory = Sync.makeFactory<UserPreferencesResponseDto>({
cast: {
gCastEnabled: false,
},
download: {
archiveSize: 0,
includeEmbeddedVideos: false,
},
emailNotifications: {
albumInvite: false,
albumUpdate: false,
enabled: false,
},
folders: {
enabled: false,
sidebarWeb: false,
},
memories: {
enabled: false,
},
people: {
enabled: false,
sidebarWeb: false,
},
purchase: {
hideBuyButtonUntil: '',
showSupportBadge: false,
},
ratings: {
enabled: false,
},
sharedLinks: {
enabled: false,
sidebarWeb: false,
},
tags: {
enabled: false,
sidebarWeb: false,
},
});