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 383 additions and 3 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",

View File

@ -321,6 +321,8 @@ Class | Method | HTTP request | Description
- [BulkIdsDto](doc//BulkIdsDto.md)
- [CLIPConfig](doc//CLIPConfig.md)
- [CQMode](doc//CQMode.md)
- [CastResponse](doc//CastResponse.md)
- [CastUpdate](doc//CastUpdate.md)
- [ChangePasswordDto](doc//ChangePasswordDto.md)
- [CheckExistingAssetsDto](doc//CheckExistingAssetsDto.md)
- [CheckExistingAssetsResponseDto](doc//CheckExistingAssetsResponseDto.md)

View File

@ -114,6 +114,8 @@ part 'model/bulk_id_response_dto.dart';
part 'model/bulk_ids_dto.dart';
part 'model/clip_config.dart';
part 'model/cq_mode.dart';
part 'model/cast_response.dart';
part 'model/cast_update.dart';
part 'model/change_password_dto.dart';
part 'model/check_existing_assets_dto.dart';
part 'model/check_existing_assets_response_dto.dart';

View File

@ -284,6 +284,10 @@ class ApiClient {
return CLIPConfig.fromJson(value);
case 'CQMode':
return CQModeTypeTransformer().decode(value);
case 'CastResponse':
return CastResponse.fromJson(value);
case 'CastUpdate':
return CastUpdate.fromJson(value);
case 'ChangePasswordDto':
return ChangePasswordDto.fromJson(value);
case 'CheckExistingAssetsDto':

View File

@ -0,0 +1,99 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class CastResponse {
/// Returns a new [CastResponse] instance.
CastResponse({
this.gCastEnabled = false,
});
bool gCastEnabled;
@override
bool operator ==(Object other) => identical(this, other) || other is CastResponse &&
other.gCastEnabled == gCastEnabled;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(gCastEnabled.hashCode);
@override
String toString() => 'CastResponse[gCastEnabled=$gCastEnabled]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'gCastEnabled'] = this.gCastEnabled;
return json;
}
/// Returns a new [CastResponse] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static CastResponse? fromJson(dynamic value) {
upgradeDto(value, "CastResponse");
if (value is Map) {
final json = value.cast<String, dynamic>();
return CastResponse(
gCastEnabled: mapValueOfType<bool>(json, r'gCastEnabled')!,
);
}
return null;
}
static List<CastResponse> listFromJson(dynamic json, {bool growable = false,}) {
final result = <CastResponse>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = CastResponse.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, CastResponse> mapFromJson(dynamic json) {
final map = <String, CastResponse>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = CastResponse.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of CastResponse-objects as value to a dart map
static Map<String, List<CastResponse>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<CastResponse>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = CastResponse.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'gCastEnabled',
};
}

108
mobile/openapi/lib/model/cast_update.dart generated Normal file
View File

@ -0,0 +1,108 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class CastUpdate {
/// Returns a new [CastUpdate] instance.
CastUpdate({
this.gCastEnabled,
});
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
bool? gCastEnabled;
@override
bool operator ==(Object other) => identical(this, other) || other is CastUpdate &&
other.gCastEnabled == gCastEnabled;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(gCastEnabled == null ? 0 : gCastEnabled!.hashCode);
@override
String toString() => 'CastUpdate[gCastEnabled=$gCastEnabled]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.gCastEnabled != null) {
json[r'gCastEnabled'] = this.gCastEnabled;
} else {
// json[r'gCastEnabled'] = null;
}
return json;
}
/// Returns a new [CastUpdate] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static CastUpdate? fromJson(dynamic value) {
upgradeDto(value, "CastUpdate");
if (value is Map) {
final json = value.cast<String, dynamic>();
return CastUpdate(
gCastEnabled: mapValueOfType<bool>(json, r'gCastEnabled'),
);
}
return null;
}
static List<CastUpdate> listFromJson(dynamic json, {bool growable = false,}) {
final result = <CastUpdate>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = CastUpdate.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, CastUpdate> mapFromJson(dynamic json) {
final map = <String, CastUpdate>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = CastUpdate.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of CastUpdate-objects as value to a dart map
static Map<String, List<CastUpdate>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<CastUpdate>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = CastUpdate.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
};
}

View File

@ -13,6 +13,7 @@ part of openapi.api;
class UserPreferencesResponseDto {
/// Returns a new [UserPreferencesResponseDto] instance.
UserPreferencesResponseDto({
required this.cast,
required this.download,
required this.emailNotifications,
required this.folders,
@ -24,6 +25,8 @@ class UserPreferencesResponseDto {
required this.tags,
});
CastResponse cast;
DownloadResponse download;
EmailNotificationsResponse emailNotifications;
@ -44,6 +47,7 @@ class UserPreferencesResponseDto {
@override
bool operator ==(Object other) => identical(this, other) || other is UserPreferencesResponseDto &&
other.cast == cast &&
other.download == download &&
other.emailNotifications == emailNotifications &&
other.folders == folders &&
@ -57,6 +61,7 @@ class UserPreferencesResponseDto {
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(cast.hashCode) +
(download.hashCode) +
(emailNotifications.hashCode) +
(folders.hashCode) +
@ -68,10 +73,11 @@ class UserPreferencesResponseDto {
(tags.hashCode);
@override
String toString() => 'UserPreferencesResponseDto[download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, sharedLinks=$sharedLinks, tags=$tags]';
String toString() => 'UserPreferencesResponseDto[cast=$cast, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, sharedLinks=$sharedLinks, tags=$tags]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'cast'] = this.cast;
json[r'download'] = this.download;
json[r'emailNotifications'] = this.emailNotifications;
json[r'folders'] = this.folders;
@ -93,6 +99,7 @@ class UserPreferencesResponseDto {
final json = value.cast<String, dynamic>();
return UserPreferencesResponseDto(
cast: CastResponse.fromJson(json[r'cast'])!,
download: DownloadResponse.fromJson(json[r'download'])!,
emailNotifications: EmailNotificationsResponse.fromJson(json[r'emailNotifications'])!,
folders: FoldersResponse.fromJson(json[r'folders'])!,
@ -149,6 +156,7 @@ class UserPreferencesResponseDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'cast',
'download',
'emailNotifications',
'folders',

View File

@ -14,6 +14,7 @@ class UserPreferencesUpdateDto {
/// Returns a new [UserPreferencesUpdateDto] instance.
UserPreferencesUpdateDto({
this.avatar,
this.cast,
this.download,
this.emailNotifications,
this.folders,
@ -33,6 +34,14 @@ class UserPreferencesUpdateDto {
///
AvatarUpdate? avatar;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
CastUpdate? cast;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
@ -108,6 +117,7 @@ class UserPreferencesUpdateDto {
@override
bool operator ==(Object other) => identical(this, other) || other is UserPreferencesUpdateDto &&
other.avatar == avatar &&
other.cast == cast &&
other.download == download &&
other.emailNotifications == emailNotifications &&
other.folders == folders &&
@ -122,6 +132,7 @@ class UserPreferencesUpdateDto {
int get hashCode =>
// ignore: unnecessary_parenthesis
(avatar == null ? 0 : avatar!.hashCode) +
(cast == null ? 0 : cast!.hashCode) +
(download == null ? 0 : download!.hashCode) +
(emailNotifications == null ? 0 : emailNotifications!.hashCode) +
(folders == null ? 0 : folders!.hashCode) +
@ -133,7 +144,7 @@ class UserPreferencesUpdateDto {
(tags == null ? 0 : tags!.hashCode);
@override
String toString() => 'UserPreferencesUpdateDto[avatar=$avatar, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, sharedLinks=$sharedLinks, tags=$tags]';
String toString() => 'UserPreferencesUpdateDto[avatar=$avatar, cast=$cast, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, sharedLinks=$sharedLinks, tags=$tags]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -142,6 +153,11 @@ class UserPreferencesUpdateDto {
} else {
// json[r'avatar'] = null;
}
if (this.cast != null) {
json[r'cast'] = this.cast;
} else {
// json[r'cast'] = null;
}
if (this.download != null) {
json[r'download'] = this.download;
} else {
@ -200,6 +216,7 @@ class UserPreferencesUpdateDto {
return UserPreferencesUpdateDto(
avatar: AvatarUpdate.fromJson(json[r'avatar']),
cast: CastUpdate.fromJson(json[r'cast']),
download: DownloadUpdate.fromJson(json[r'download']),
emailNotifications: EmailNotificationsUpdate.fromJson(json[r'emailNotifications']),
folders: FoldersUpdate.fromJson(json[r'folders']),

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,
},
});