diff --git a/i18n/en.json b/i18n/en.json
index 9d6c85e54d..1be84c5e7a 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -649,6 +649,7 @@
"confirm_password": "Confirm password",
"confirm_tag_face": "Do you want to tag this face as {name}?",
"confirm_tag_face_unnamed": "Do you want to tag this face?",
+ "connected_device": "Connected device",
"connected_to": "Connected to",
"contain": "Contain",
"context": "Context",
@@ -749,6 +750,7 @@
"disallow_edits": "Disallow edits",
"discord": "Discord",
"discover": "Discover",
+ "discovered_devices": "Discovered devices",
"dismiss_all_errors": "Dismiss all errors",
"dismiss_error": "Dismiss error",
"display_options": "Display options",
@@ -1133,6 +1135,7 @@
"list": "List",
"loading": "Loading",
"loading_search_results_failed": "Loading search results failed",
+ "local_asset_cast_failed": "Unable to cast an asset that is not uploaded to the server",
"local_network": "Local network",
"local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network",
"location_permission": "Location permission",
@@ -1273,6 +1276,7 @@
"no_archived_assets_message": "Archive photos and videos to hide them from your Photos view",
"no_assets_message": "CLICK TO UPLOAD YOUR FIRST PHOTO",
"no_assets_to_show": "No assets to show",
+ "no_cast_devices_found": "No cast devices found",
"no_duplicates_found": "No duplicates were found.",
"no_exif_info_available": "No exif info available",
"no_explore_results_message": "Upload more photos to explore your collection.",
@@ -1769,6 +1773,7 @@
"start_date": "Start date",
"state": "State",
"status": "Status",
+ "stop_casting": "Stop casting",
"stop_motion_photo": "Stop Motion Photo",
"stop_photo_sharing": "Stop sharing your photos?",
"stop_photo_sharing_description": "{partner} will no longer be able to access your photos.",
diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock
index 537cdba8d8..c5880221a3 100644
--- a/mobile/ios/Podfile.lock
+++ b/mobile/ios/Podfile.lock
@@ -1,6 +1,9 @@
PODS:
- background_downloader (0.0.1):
- Flutter
+ - bonsoir_darwin (0.0.1):
+ - Flutter
+ - FlutterMacOS
- connectivity_plus (0.0.1):
- Flutter
- device_info_plus (0.0.1):
@@ -129,6 +132,7 @@ PODS:
DEPENDENCIES:
- background_downloader (from `.symlinks/plugins/background_downloader/ios`)
+ - bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`)
@@ -173,6 +177,8 @@ SPEC REPOS:
EXTERNAL SOURCES:
background_downloader:
:path: ".symlinks/plugins/background_downloader/ios"
+ bonsoir_darwin:
+ :path: ".symlinks/plugins/bonsoir_darwin/darwin"
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
device_info_plus:
@@ -235,44 +241,45 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/wakelock_plus/ios"
SPEC CHECKSUMS:
- background_downloader: 50e91d979067b82081aba359d7d916b3ba5fadad
- connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
- device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
+ background_downloader: a05c77d32a0d70615b9c04577aa203535fc924ff
+ bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842
+ connectivity_plus: 2a701ffec2c0ae28a48cf7540e279787e77c447d
+ device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
- file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
+ file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
- flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100
- flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
- flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
- flutter_udid: f7c3884e6ec2951efe4f9de082257fc77c4d15e9
- flutter_web_auth_2: 5c8d9dcd7848b5a9efb086d24e7a9adcae979c80
- fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1
- geolocator_apple: 1560c3c875af2a412242c7a923e15d0d401966ff
- image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a
- integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
- isar_flutter_libs: bc909e72c3d756c2759f14c8776c13b5b0556e26
- local_auth_darwin: 553ce4f9b16d3fdfeafce9cf042e7c9f77c1c391
+ flutter_local_notifications: 4cde75091f6327eb8517fa068a0a5950212d2086
+ flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29
+ flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
+ flutter_udid: b2417673f287ee62817a1de3d1643f47b9f508ab
+ flutter_web_auth_2: 06d500582775790a0d4c323222fcb6d7990f9603
+ fluttertoast: 21eecd6935e7064cc1fcb733a4c5a428f3f24f0f
+ geolocator_apple: 9bcea1918ff7f0062d98345d238ae12718acfbc1
+ image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
+ integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
+ isar_flutter_libs: fdf730ca925d05687f36d7f1d355e482529ed097
+ local_auth_darwin: 66e40372f1c29f383a314c738c7446e2f7fdadc3
MapLibre: 0ebfa9329d313cec8bf0a5ba5a336a1dc903785e
- maplibre_gl: eab61cca6e1cfa9187249bacd3f08b51e8cd8ae9
- native_video_player: b65c58951ede2f93d103a25366bdebca95081265
- network_info_plus: cf61925ab5205dce05a4f0895989afdb6aade5fc
- package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
- path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
- permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
- photo_manager: d2fbcc0f2d82458700ee6256a15018210a81d413
+ maplibre_gl: be7b98f1c3ed75bf77f321eec04df359d0ff6f62
+ native_video_player: d12af78a1a4a8cf09775a5177d5b392def6fd23c
+ network_info_plus: 6613d9d7cdeb0e6f366ed4dbe4b3c51c52d567a9
+ package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
+ path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
+ permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
+ photo_manager: ff695c7a1dd5bc379974953a2b5c0a293f7c4c8a
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
SDWebImage: f84b0feeb08d2d11e6a9b843cb06d75ebf5b8868
- share_handler_ios: e2244e990f826b2c8eaa291ac3831569438ba0fb
+ share_handler_ios: 6dd3a4ac5ca0d955274aec712ba0ecdcaf583e7c
share_handler_ios_models: fc638c9b4330dc7f082586c92aee9dfa0b87b871
- share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
- shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
- sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
+ share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
+ shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
+ sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
sqlite3: fc1400008a9b3525f5914ed715a5d1af0b8f4983
- sqlite3_flutter_libs: f8fc13346870e73fe35ebf6dbb997fbcd156b241
+ sqlite3_flutter_libs: cc304edcb8e1d8c595d1b08c7aeb46a47691d9db
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
- url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
- wakelock_plus: 04623e3f525556020ebd4034310f20fe7fda8b49
+ url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
+ wakelock_plus: 373cfe59b235a6dd5837d0fb88791d2f13a90d56
PODFILE CHECKSUM: 7ce312f2beab01395db96f6969d90a447279cf45
diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist
index 38a1573dbd..c59b4c4295 100644
--- a/mobile/ios/Runner/Info.plist
+++ b/mobile/ios/Runner/Info.plist
@@ -113,6 +113,11 @@
NSAllowsArbitraryLoads
+ NSBonjourServices
+
+ _googlecast._tcp
+ _CC1AD845._googlecast._tcp
+
NSCameraUsageDescription
We need to access the camera to let you take beautiful video using this app
NSLocationAlwaysAndWhenInUseUsageDescription
@@ -164,4 +169,4 @@
NSFaceIDUsageDescription
We need to use FaceID to allow access to your locked folder
-
\ No newline at end of file
+
diff --git a/mobile/lib/interfaces/asset_api.interface.dart b/mobile/lib/interfaces/asset_api.interface.dart
index a17e607d83..71ee993a6b 100644
--- a/mobile/lib/interfaces/asset_api.interface.dart
+++ b/mobile/lib/interfaces/asset_api.interface.dart
@@ -21,4 +21,6 @@ abstract interface class IAssetApiRepository {
List list,
AssetVisibilityEnum visibility,
);
+
+ Future getAssetMIMEType(String id);
}
diff --git a/mobile/lib/interfaces/cast_destination_service.interface.dart b/mobile/lib/interfaces/cast_destination_service.interface.dart
new file mode 100644
index 0000000000..add8ad7c51
--- /dev/null
+++ b/mobile/lib/interfaces/cast_destination_service.interface.dart
@@ -0,0 +1,27 @@
+import 'package:immich_mobile/entities/asset.entity.dart';
+import 'package:immich_mobile/models/cast/cast_manager_state.dart';
+
+abstract interface class ICastDestinationService {
+ Future initialize();
+ CastDestinationType getType();
+
+ void Function(bool)? onConnectionState;
+
+ void Function(Duration)? onCurrentTime;
+ void Function(Duration)? onDuration;
+
+ void Function(String)? onReceiverName;
+ void Function(CastState)? onCastState;
+
+ Future connect(dynamic device);
+
+ void loadMedia(Asset asset, bool reload);
+
+ void play();
+ void pause();
+ void seekTo(Duration position);
+ void stop();
+ Future disconnect();
+
+ Future> getDevices();
+}
diff --git a/mobile/lib/interfaces/sessions_api.interface.dart b/mobile/lib/interfaces/sessions_api.interface.dart
new file mode 100644
index 0000000000..4b90b77829
--- /dev/null
+++ b/mobile/lib/interfaces/sessions_api.interface.dart
@@ -0,0 +1,9 @@
+import 'package:immich_mobile/models/sessions/session_create_response.model.dart';
+
+abstract interface class ISessionAPIRepository {
+ Future createSession(
+ String deviceName,
+ String deviceOS, {
+ int? duration,
+ });
+}
diff --git a/mobile/lib/models/cast/cast_manager_state.dart b/mobile/lib/models/cast/cast_manager_state.dart
new file mode 100644
index 0000000000..703ceb4c47
--- /dev/null
+++ b/mobile/lib/models/cast/cast_manager_state.dart
@@ -0,0 +1,88 @@
+import 'dart:convert';
+
+enum CastDestinationType { googleCast }
+
+enum CastState { idle, playing, paused, buffering }
+
+class CastManagerState {
+ final bool isCasting;
+ final String receiverName;
+ final CastState castState;
+ final Duration currentTime;
+ final Duration duration;
+
+ const CastManagerState({
+ required this.isCasting,
+ required this.receiverName,
+ required this.castState,
+ required this.currentTime,
+ required this.duration,
+ });
+
+ CastManagerState copyWith({
+ bool? isCasting,
+ String? receiverName,
+ CastState? castState,
+ Duration? currentTime,
+ Duration? duration,
+ }) {
+ return CastManagerState(
+ isCasting: isCasting ?? this.isCasting,
+ receiverName: receiverName ?? this.receiverName,
+ castState: castState ?? this.castState,
+ currentTime: currentTime ?? this.currentTime,
+ duration: duration ?? this.duration,
+ );
+ }
+
+ Map toMap() {
+ final result = {};
+
+ result.addAll({'isCasting': isCasting});
+ result.addAll({'receiverName': receiverName});
+ result.addAll({'castState': castState});
+ result.addAll({'currentTime': currentTime.inSeconds});
+ result.addAll({'duration': duration.inSeconds});
+
+ return result;
+ }
+
+ factory CastManagerState.fromMap(Map map) {
+ return CastManagerState(
+ isCasting: map['isCasting'] ?? false,
+ receiverName: map['receiverName'] ?? '',
+ castState: map['castState'] ?? CastState.idle,
+ currentTime: Duration(seconds: map['currentTime']?.toInt() ?? 0),
+ duration: Duration(seconds: map['duration']?.toInt() ?? 0),
+ );
+ }
+
+ String toJson() => json.encode(toMap());
+
+ factory CastManagerState.fromJson(String source) =>
+ CastManagerState.fromMap(json.decode(source));
+
+ @override
+ String toString() =>
+ 'CastManagerState(isCasting: $isCasting, receiverName: $receiverName, castState: $castState, currentTime: $currentTime, duration: $duration)';
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(this, other)) return true;
+
+ return other is CastManagerState &&
+ other.isCasting == isCasting &&
+ other.receiverName == receiverName &&
+ other.castState == castState &&
+ other.currentTime == currentTime &&
+ other.duration == duration;
+ }
+
+ @override
+ int get hashCode =>
+ isCasting.hashCode ^
+ receiverName.hashCode ^
+ castState.hashCode ^
+ currentTime.hashCode ^
+ duration.hashCode;
+}
diff --git a/mobile/lib/models/sessions/session_create_response.model.dart b/mobile/lib/models/sessions/session_create_response.model.dart
new file mode 100644
index 0000000000..66b4c6c071
--- /dev/null
+++ b/mobile/lib/models/sessions/session_create_response.model.dart
@@ -0,0 +1,26 @@
+class SessionCreateResponse {
+ final String createdAt;
+ final bool current;
+ final String deviceOS;
+ final String deviceType;
+ final String? expiresAt;
+ final String id;
+ final String token;
+ final String updatedAt;
+
+ const SessionCreateResponse({
+ required this.createdAt,
+ required this.current,
+ required this.deviceOS,
+ required this.deviceType,
+ this.expiresAt,
+ required this.id,
+ required this.token,
+ required this.updatedAt,
+ });
+
+ @override
+ String toString() {
+ return 'SessionCreateResponse[createdAt=$createdAt, current=$current, deviceOS=$deviceOS, deviceType=$deviceType, expiresAt=$expiresAt, id=$id, token=$token, updatedAt=$updatedAt]';
+ }
+}
diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart
index bdde338cb3..09dac353fb 100644
--- a/mobile/lib/pages/common/gallery_viewer.page.dart
+++ b/mobile/lib/pages/common/gallery_viewer.page.dart
@@ -4,6 +4,7 @@ import 'dart:math';
import 'dart:ui' as ui;
import 'package:auto_route/auto_route.dart';
+import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
@@ -20,6 +21,7 @@ import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
+import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
@@ -62,6 +64,7 @@ class GalleryViewerPage extends HookConsumerWidget {
final currentIndex = useValueNotifier(initialIndex);
final loadAsset = renderList.loadAsset;
final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider);
+ final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
final videoPlayerKeys = useRef