diff --git a/docs/docs/features/casting.md b/docs/docs/features/casting.md index cc25e24da..bca85cb28 100644 --- a/docs/docs/features/casting.md +++ b/docs/docs/features/casting.md @@ -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` + + + ## Limitations To use casting with Immich, there are a few prerequisites: diff --git a/docs/docs/features/img/gcast-enable.webp b/docs/docs/features/img/gcast-enable.webp new file mode 100644 index 000000000..f128b82e2 Binary files /dev/null and b/docs/docs/features/img/gcast-enable.webp differ diff --git a/i18n/en.json b/i18n/en.json index 56e38cf81..603eb58b3 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -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", diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 24faee28a..b8bcbcebc 100644 Binary files a/mobile/openapi/README.md and b/mobile/openapi/README.md differ diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index d3a342db6..ca33a5dea 100644 Binary files a/mobile/openapi/lib/api.dart and b/mobile/openapi/lib/api.dart differ diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index cc01fd2c0..105965532 100644 Binary files a/mobile/openapi/lib/api_client.dart and b/mobile/openapi/lib/api_client.dart differ diff --git a/mobile/openapi/lib/model/cast_response.dart b/mobile/openapi/lib/model/cast_response.dart new file mode 100644 index 000000000..d49f1ad3d Binary files /dev/null and b/mobile/openapi/lib/model/cast_response.dart differ diff --git a/mobile/openapi/lib/model/cast_update.dart b/mobile/openapi/lib/model/cast_update.dart new file mode 100644 index 000000000..870763913 Binary files /dev/null and b/mobile/openapi/lib/model/cast_update.dart differ diff --git a/mobile/openapi/lib/model/user_preferences_response_dto.dart b/mobile/openapi/lib/model/user_preferences_response_dto.dart index 215e691cb..c729e0d80 100644 Binary files a/mobile/openapi/lib/model/user_preferences_response_dto.dart and b/mobile/openapi/lib/model/user_preferences_response_dto.dart differ diff --git a/mobile/openapi/lib/model/user_preferences_update_dto.dart b/mobile/openapi/lib/model/user_preferences_update_dto.dart index 3e420df11..73e3cac9f 100644 Binary files a/mobile/openapi/lib/model/user_preferences_update_dto.dart and b/mobile/openapi/lib/model/user_preferences_update_dto.dart differ diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 98382a382..6aca3c349 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -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" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 0ce6f417b..050fabca9 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -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; diff --git a/server/src/dtos/user-preferences.dto.ts b/server/src/dtos/user-preferences.dto.ts index a9d32523a..43e15689b 100644 --- a/server/src/dtos/user-preferences.dto.ts +++ b/server/src/dtos/user-preferences.dto.ts @@ -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 => { diff --git a/server/src/types.ts b/server/src/types.ts index d166a94e8..9d5ba46e1 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -502,6 +502,9 @@ export interface UserPreferences { showSupportBadge: boolean; hideBuyButtonUntil: string; }; + cast: { + gCastEnabled: boolean; + }; } export interface UserMetadata extends Record> { diff --git a/server/src/utils/preferences.ts b/server/src/utils/preferences.ts index a013c0b74..009dabce5 100644 --- a/server/src/utils/preferences.ts +++ b/server/src/utils/preferences.ts @@ -42,6 +42,9 @@ const getDefaultPreferences = (): UserPreferences => { showSupportBadge: true, hideBuyButtonUntil: new Date(2022, 1, 12).toISOString(), }, + cast: { + gCastEnabled: false, + }, }; }; diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.spec.ts b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.spec.ts index f77fbc7f2..f6a46143b 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.spec.ts +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.spec.ts @@ -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(); }); diff --git a/web/src/lib/components/user-settings-page/feature-settings.svelte b/web/src/lib/components/user-settings-page/feature-settings.svelte index d331e4043..b7db2c92a 100644 --- a/web/src/lib/components/user-settings-page/feature-settings.svelte +++ b/web/src/lib/components/user-settings-page/feature-settings.svelte @@ -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} + +
+ +
+
+
diff --git a/web/src/lib/utils/cast/gcast-destination.svelte.ts b/web/src/lib/utils/cast/gcast-destination.svelte.ts index fcfb8c382..f101c504f 100644 --- a/web/src/lib/utils/cast/gcast-destination.svelte.ts +++ b/web/src/lib/utils/cast/gcast-destination.svelte.ts @@ -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 { + 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 diff --git a/web/src/test-data/factories/preferences-factory.ts b/web/src/test-data/factories/preferences-factory.ts new file mode 100644 index 000000000..d531bc1a9 --- /dev/null +++ b/web/src/test-data/factories/preferences-factory.ts @@ -0,0 +1,43 @@ +import type { UserPreferencesResponseDto } from '@immich/sdk'; +import { Sync } from 'factory.ts'; + +export const preferencesFactory = Sync.makeFactory({ + 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, + }, +});