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>({}); @@ -118,6 +121,36 @@ class GalleryViewerPage extends HookConsumerWidget { const [], ); + useEffect(() { + final asset = loadAsset(currentIndex.value); + + if (asset.isRemote) { + ref.read(castProvider.notifier).loadMedia(asset, false); + } else { + if (isCasting) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) { + ref.read(castProvider.notifier).stop(); + context.scaffoldMessenger.showSnackBar( + SnackBar( + duration: const Duration(seconds: 1), + content: Text( + "local_asset_cast_failed".tr(), + style: context.textTheme.bodyLarge?.copyWith( + color: context.primaryColor, + ), + ), + ), + ); + } + }); + } + } + return null; + }, [ + ref.watch(castProvider).isCasting, + ]); + void showInfo() { final asset = ref.read(currentAssetProvider); if (asset == null) { @@ -356,6 +389,30 @@ class GalleryViewerPage extends HookConsumerWidget { Timer(const Duration(milliseconds: 400), () { precacheNextImage(next); }); + + context.scaffoldMessenger.hideCurrentSnackBar(); + + // send image to casting if the server has it + if (newAsset.isRemote) { + ref.read(castProvider.notifier).loadMedia(newAsset, false); + } else { + context.scaffoldMessenger.clearSnackBars(); + + if (isCasting) { + ref.read(castProvider.notifier).stop(); + context.scaffoldMessenger.showSnackBar( + SnackBar( + duration: const Duration(seconds: 2), + content: Text( + "local_asset_cast_failed".tr(), + style: context.textTheme.bodyLarge?.copyWith( + color: context.primaryColor, + ), + ), + ), + ); + } + } }, builder: buildAsset, ), diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index 957a119f66..8afa6ab4e3 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -13,6 +13,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/video_player_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/services/api.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/asset.service.dart'; @@ -60,6 +61,8 @@ class NativeVideoViewerPage extends HookConsumerWidget { final log = Logger('NativeVideoViewerPage'); + final isCasting = ref.watch(castProvider.select((c) => c.isCasting)); + Future createSource() async { if (!context.mounted) { return null; @@ -391,7 +394,7 @@ class NativeVideoViewerPage extends HookConsumerWidget { // This remains under the video to avoid flickering // For motion videos, this is the image portion of the asset Center(key: ValueKey(asset.id), child: image), - if (aspectRatio.value != null) + if (aspectRatio.value != null && !isCasting) Visibility.maintain( key: ValueKey(asset), visible: isVisible.value, diff --git a/mobile/lib/providers/cast.provider.dart b/mobile/lib/providers/cast.provider.dart new file mode 100644 index 0000000000..c80789d2e0 --- /dev/null +++ b/mobile/lib/providers/cast.provider.dart @@ -0,0 +1,93 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/interfaces/cast_destination_service.interface.dart'; +import 'package:immich_mobile/models/cast/cast_manager_state.dart'; +import 'package:immich_mobile/services/gcast.service.dart'; + +final castProvider = StateNotifierProvider( + (ref) => CastNotifier(ref.watch(gCastServiceProvider)), +); + +class CastNotifier extends StateNotifier { + // more cast providers can be added here (ie Fcast) + final ICastDestinationService _gCastService; + + List<(String, CastDestinationType, dynamic)> discovered = List.empty(); + + CastNotifier(this._gCastService) + : super( + const CastManagerState( + isCasting: false, + currentTime: Duration.zero, + duration: Duration.zero, + receiverName: '', + castState: CastState.idle, + ), + ) { + _gCastService.onConnectionState = _onConnectionState; + _gCastService.onCurrentTime = _onCurrentTime; + _gCastService.onDuration = _onDuration; + _gCastService.onReceiverName = _onReceiverName; + _gCastService.onCastState = _onCastState; + } + + void _onConnectionState(bool isCasting) { + state = state.copyWith(isCasting: isCasting); + } + + void _onCurrentTime(Duration currentTime) { + state = state.copyWith(currentTime: currentTime); + } + + void _onDuration(Duration duration) { + state = state.copyWith(duration: duration); + } + + void _onReceiverName(String receiverName) { + state = state.copyWith(receiverName: receiverName); + } + + void _onCastState(CastState castState) { + state = state.copyWith(castState: castState); + } + + void loadMedia(Asset asset, bool reload) { + _gCastService.loadMedia(asset, reload); + } + + Future connect(CastDestinationType type, dynamic device) async { + switch (type) { + case CastDestinationType.googleCast: + await _gCastService.connect(device); + break; + } + } + + Future> getDevices() async { + if (discovered.isEmpty) { + discovered = await _gCastService.getDevices(); + } + + return discovered; + } + + void play() { + _gCastService.play(); + } + + void pause() { + _gCastService.pause(); + } + + void seekTo(Duration position) { + _gCastService.seekTo(position); + } + + void stop() { + _gCastService.stop(); + } + + Future disconnect() async { + await _gCastService.disconnect(); + } +} diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart index 45442c2d61..f82df4b774 100644 --- a/mobile/lib/repositories/asset_api.repository.dart +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -72,4 +72,12 @@ class AssetApiRepository extends ApiRepository implements IAssetApiRepository { return AssetVisibility.archive; } } + + @override + Future getAssetMIMEType(String assetId) async { + final response = await checkNull(_api.getAssetInfo(assetId)); + + // we need to get the MIME of the thumbnail once that gets added to the API + return response.originalMimeType; + } } diff --git a/mobile/lib/repositories/gcast.repository.dart b/mobile/lib/repositories/gcast.repository.dart new file mode 100644 index 0000000000..11c149ab37 --- /dev/null +++ b/mobile/lib/repositories/gcast.repository.dart @@ -0,0 +1,75 @@ +import 'package:cast/device.dart'; +import 'package:cast/session.dart'; +import 'package:cast/session_manager.dart'; +import 'package:cast/discovery_service.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +final gCastRepositoryProvider = Provider((_) { + return GCastRepository(); +}); + +class GCastRepository { + CastSession? _castSession; + + void Function(CastSessionState)? onCastStatus; + void Function(Map)? onCastMessage; + + Map? _receiverStatus; + + GCastRepository(); + + Future connect(CastDevice device) async { + _castSession = await CastSessionManager().startSession(device); + + _castSession?.stateStream.listen((state) { + onCastStatus?.call(state); + }); + + _castSession?.messageStream.listen((message) { + onCastMessage?.call(message); + if (message['type'] == 'RECEIVER_STATUS') { + _receiverStatus = message; + } + }); + + // open the default receiver + sendMessage(CastSession.kNamespaceReceiver, { + 'type': 'LAUNCH', + 'appId': 'CC1AD845', + }); + } + + Future disconnect() async { + final sessionID = getSessionId(); + + sendMessage(CastSession.kNamespaceReceiver, { + 'type': "STOP", + "sessionId": sessionID, + }); + + // wait 500ms to ensure the stop command is processed + await Future.delayed(const Duration(milliseconds: 500)); + + await _castSession?.close(); + } + + String? getSessionId() { + if (_receiverStatus == null) { + return null; + } + return _receiverStatus!['status']['applications'][0]['sessionId']; + } + + void sendMessage(String namespace, Map message) { + if (_castSession == null) { + throw Exception("Cast session is not established"); + } + + _castSession!.sendMessage(namespace, message); + } + + Future> listDestinations() async { + return await CastDiscoveryService() + .search(timeout: const Duration(seconds: 3)); + } +} diff --git a/mobile/lib/repositories/sessions_api.repository.dart b/mobile/lib/repositories/sessions_api.repository.dart new file mode 100644 index 0000000000..e36331eb79 --- /dev/null +++ b/mobile/lib/repositories/sessions_api.repository.dart @@ -0,0 +1,47 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/interfaces/sessions_api.interface.dart'; +import 'package:immich_mobile/models/sessions/session_create_response.model.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/repositories/api.repository.dart'; +import 'package:openapi/api.dart'; + +final sessionsAPIRepositoryProvider = Provider( + (ref) => SessionsAPIRepository( + ref.watch(apiServiceProvider).sessionsApi, + ), +); + +class SessionsAPIRepository extends ApiRepository + implements ISessionAPIRepository { + final SessionsApi _api; + + SessionsAPIRepository(this._api); + + @override + Future createSession( + String deviceType, + String deviceOS, { + int? duration, + }) async { + final dto = await checkNull( + _api.createSession( + SessionCreateDto( + deviceType: deviceType, + deviceOS: deviceOS, + duration: duration, + ), + ), + ); + + return SessionCreateResponse( + id: dto.id, + current: dto.current, + deviceType: deviceType, + deviceOS: deviceOS, + expiresAt: dto.expiresAt, + createdAt: dto.createdAt, + updatedAt: dto.updatedAt, + token: dto.token, + ); + } +} diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index 24bdccc04d..fe007a2aab 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -34,6 +34,7 @@ class ApiService implements Authentication { late StacksApi stacksApi; late ViewApi viewApi; late MemoriesApi memoriesApi; + late SessionsApi sessionsApi; ApiService() { // The below line ensures that the api clients are initialized when the service is instantiated @@ -72,6 +73,7 @@ class ApiService implements Authentication { stacksApi = StacksApi(_apiClient); viewApi = ViewApi(_apiClient); memoriesApi = MemoriesApi(_apiClient); + sessionsApi = SessionsApi(_apiClient); } Future _setUserAgentHeader() async { diff --git a/mobile/lib/services/gcast.service.dart b/mobile/lib/services/gcast.service.dart new file mode 100644 index 0000000000..60c94c712c --- /dev/null +++ b/mobile/lib/services/gcast.service.dart @@ -0,0 +1,295 @@ +import 'dart:async'; + +import 'package:cast/session.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/interfaces/cast_destination_service.interface.dart'; +import 'package:immich_mobile/models/cast/cast_manager_state.dart'; +import 'package:immich_mobile/models/sessions/session_create_response.model.dart'; +import 'package:immich_mobile/repositories/asset_api.repository.dart'; +import 'package:immich_mobile/repositories/gcast.repository.dart'; +import 'package:immich_mobile/repositories/sessions_api.repository.dart'; +import 'package:immich_mobile/utils/image_url_builder.dart'; +// ignore: import_rule_openapi, we are only using the AssetMediaSize enum +import 'package:openapi/api.dart'; + +final gCastServiceProvider = Provider( + (ref) => GCastService( + ref.watch(gCastRepositoryProvider), + ref.watch(sessionsAPIRepositoryProvider), + ref.watch(assetApiRepositoryProvider), + ), +); + +class GCastService implements ICastDestinationService { + final GCastRepository _gCastRepository; + final SessionsAPIRepository _sessionsApiService; + final AssetApiRepository _assetApiRepository; + + SessionCreateResponse? sessionKey; + String? currentAssetId; + bool isConnected = false; + int? _sessionId; + Timer? _mediaStatusPollingTimer; + + @override + void Function(bool)? onConnectionState; + @override + void Function(Duration)? onCurrentTime; + @override + void Function(Duration)? onDuration; + @override + void Function(String)? onReceiverName; + @override + void Function(CastState)? onCastState; + + GCastService( + this._gCastRepository, + this._sessionsApiService, + this._assetApiRepository, + ) { + _gCastRepository.onCastStatus = _onCastStatusCallback; + _gCastRepository.onCastMessage = _onCastMessageCallback; + } + + void _onCastStatusCallback(CastSessionState state) { + if (state == CastSessionState.connected) { + onConnectionState?.call(true); + isConnected = true; + } else if (state == CastSessionState.closed) { + onConnectionState?.call(false); + isConnected = false; + onReceiverName?.call(""); + currentAssetId = null; + } + } + + void _onCastMessageCallback(Map message) { + switch (message['type']) { + case "MEDIA_STATUS": + _handleMediaStatus(message); + break; + } + } + + void _handleMediaStatus(Map message) { + final statusList = + (message['status'] as List).whereType>().toList(); + + if (statusList.isEmpty) { + return; + } + + final status = statusList[0]; + switch (status['playerState']) { + case "PLAYING": + onCastState?.call(CastState.playing); + break; + case "PAUSED": + onCastState?.call(CastState.paused); + break; + case "BUFFERING": + onCastState?.call(CastState.buffering); + break; + case "IDLE": + onCastState?.call(CastState.idle); + + // stop polling for media status if the video finished playing + if (status["idleReason"] == "FINISHED") { + _mediaStatusPollingTimer?.cancel(); + } + + break; + } + + if (status["media"] != null && status["media"]["duration"] != null) { + final duration = Duration( + milliseconds: (status["media"]["duration"] * 1000 ?? 0).toInt(), + ); + onDuration?.call(duration); + } + + if (status["mediaSessionId"] != null) { + _sessionId = status["mediaSessionId"]; + } + + if (status["currentTime"] != null) { + final currentTime = + Duration(milliseconds: (status["currentTime"] * 1000 ?? 0).toInt()); + onCurrentTime?.call(currentTime); + } + } + + @override + Future connect(dynamic device) async { + await _gCastRepository.connect(device); + + onReceiverName?.call(device.extras["fn"] ?? "Google Cast"); + } + + @override + CastDestinationType getType() { + return CastDestinationType.googleCast; + } + + @override + Future initialize() async { + // there is nothing blocking us from using Google Cast that we can check for + return true; + } + + @override + Future disconnect() async { + onReceiverName?.call(""); + currentAssetId = null; + await _gCastRepository.disconnect(); + } + + bool isSessionValid() { + // check if we already have a session token + // we should always have a expiration date + if (sessionKey == null || sessionKey?.expiresAt == null) { + return false; + } + + final tokenExpiration = DateTime.parse(sessionKey!.expiresAt!); + + // we want to make sure we have at least 10 seconds remaining in the session + // this is to account for network latency and other delays when sending the request + final bufferedExpiration = + tokenExpiration.subtract(const Duration(seconds: 10)); + + return bufferedExpiration.isAfter(DateTime.now()); + } + + @override + void loadMedia(Asset asset, bool reload) async { + if (!isConnected) { + return; + } else if (asset.remoteId == null) { + return; + } else if (asset.remoteId == currentAssetId && !reload) { + return; + } + + // create a session key + if (!isSessionValid()) { + sessionKey = await _sessionsApiService.createSession( + "Cast", + "Google Cast", + duration: const Duration(minutes: 15).inSeconds, + ); + } + + final unauthenticatedUrl = asset.isVideo + ? getPlaybackUrlForRemoteId( + asset.remoteId!, + ) + : getThumbnailUrlForRemoteId( + asset.remoteId!, + type: AssetMediaSize.fullsize, + ); + + final authenticatedURL = + "$unauthenticatedUrl&sessionKey=${sessionKey?.token}"; + + // get image mime type + final mimeType = + await _assetApiRepository.getAssetMIMEType(asset.remoteId!); + + if (mimeType == null) { + return; + } + + _gCastRepository.sendMessage(CastSession.kNamespaceMedia, { + "type": "LOAD", + "media": { + "contentId": authenticatedURL, + "streamType": "BUFFERED", + "contentType": mimeType, + "contentUrl": authenticatedURL, + }, + "autoplay": true, + }); + + currentAssetId = asset.remoteId; + + // we need to poll for media status since the cast device does not + // send a message when the media is loaded for whatever reason + // only do this on videos + _mediaStatusPollingTimer?.cancel(); + + if (asset.isVideo) { + _mediaStatusPollingTimer = + Timer.periodic(const Duration(milliseconds: 500), (timer) { + if (isConnected) { + _gCastRepository.sendMessage(CastSession.kNamespaceMedia, { + "type": "GET_STATUS", + "mediaSessionId": _sessionId, + }); + } else { + timer.cancel(); + } + }); + } + } + + @override + void play() { + _gCastRepository.sendMessage(CastSession.kNamespaceMedia, { + "type": "PLAY", + "mediaSessionId": _sessionId, + }); + } + + @override + void pause() { + _gCastRepository.sendMessage(CastSession.kNamespaceMedia, { + "type": "PAUSE", + "mediaSessionId": _sessionId, + }); + } + + @override + void seekTo(Duration position) { + _gCastRepository.sendMessage(CastSession.kNamespaceMedia, { + "type": "SEEK", + "mediaSessionId": _sessionId, + "currentTime": position.inSeconds, + }); + } + + @override + void stop() { + _gCastRepository.sendMessage(CastSession.kNamespaceMedia, { + "type": "STOP", + "mediaSessionId": _sessionId, + }); + _mediaStatusPollingTimer?.cancel(); + + currentAssetId = null; + } + + // 0x01 is display capability bitmask + bool isDisplay(int ca) => (ca & 0x01) != 0; + + @override + Future> getDevices() async { + final dests = await _gCastRepository.listDestinations(); + + return dests + .map( + (device) => ( + device.extras["fn"] ?? "Google Cast", + CastDestinationType.googleCast, + device + ), + ) + .where((device) { + final caString = device.$3.extras["ca"]; + final caNumber = int.tryParse(caString ?? "0") ?? 0; + + return isDisplay(caNumber); + }).toList(growable: false); + } +} diff --git a/mobile/lib/utils/image_url_builder.dart b/mobile/lib/utils/image_url_builder.dart index d063b3aa91..50218eaffd 100644 --- a/mobile/lib/utils/image_url_builder.dart +++ b/mobile/lib/utils/image_url_builder.dart @@ -73,6 +73,10 @@ String getThumbnailUrlForRemoteId( return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=${type.value}'; } +String getPlaybackUrlForRemoteId(final String id) { + return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/video/playback?'; +} + String getFaceThumbnailUrl(final String personId) { return '${Store.get(StoreKey.serverEndpoint)}/people/$personId/thumbnail'; } diff --git a/mobile/lib/widgets/asset_viewer/cast_dialog.dart b/mobile/lib/widgets/asset_viewer/cast_dialog.dart new file mode 100644 index 0000000000..9043ea4bea --- /dev/null +++ b/mobile/lib/widgets/asset_viewer/cast_dialog.dart @@ -0,0 +1,160 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/models/cast/cast_manager_state.dart'; +import 'package:immich_mobile/providers/cast.provider.dart'; + +class CastDialog extends ConsumerWidget { + const CastDialog({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final castManager = ref.watch(castProvider); + + bool isCurrentDevice(String deviceName) { + return castManager.receiverName == deviceName && castManager.isCasting; + } + + bool isDeviceConnecting(String deviceName) { + return castManager.receiverName == deviceName && !castManager.isCasting; + } + + return AlertDialog( + title: const Text( + "cast", + style: TextStyle(fontWeight: FontWeight.bold), + ).tr(), + content: SizedBox( + width: 250, + height: 250, + child: FutureBuilder>( + future: ref.read(castProvider.notifier).getDevices(), + builder: (context, snapshot) { + if (snapshot.hasError) { + return Text( + 'Error: ${snapshot.error.toString()}', + ); + } else if (!snapshot.hasData) { + return const SizedBox( + height: 48, + child: Center(child: CircularProgressIndicator()), + ); + } + + if (snapshot.data!.isEmpty) { + return const Text( + 'no_cast_devices_found', + ).tr(); + } + + final devices = snapshot.data!; + final connected = + devices.where((d) => isCurrentDevice(d.$1)).toList(); + final others = + devices.where((d) => !isCurrentDevice(d.$1)).toList(); + + final List sectionedList = []; + + if (connected.isNotEmpty) { + sectionedList.add("connected_device"); + sectionedList.addAll(connected); + } + + if (others.isNotEmpty) { + sectionedList.add("discovered_devices"); + sectionedList.addAll(others); + } + + return ListView.builder( + shrinkWrap: true, + itemCount: sectionedList.length, + itemBuilder: (context, index) { + final item = sectionedList[index]; + + if (item is String) { + // It's a section header + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Text( + item, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ).tr(), + ); + } else { + final (deviceName, type, deviceObj) = + item as (String, CastDestinationType, dynamic); + + return ListTile( + title: Text( + deviceName, + style: TextStyle( + color: isCurrentDevice(deviceName) + ? context.colorScheme.primary + : null, + ), + ), + leading: Icon( + type == CastDestinationType.googleCast + ? Icons.cast + : Icons.cast_connected, + color: isCurrentDevice(deviceName) + ? context.colorScheme.primary + : null, + ), + trailing: isCurrentDevice(deviceName) + ? Icon(Icons.check, color: context.colorScheme.primary) + : isDeviceConnecting(deviceName) + ? const CircularProgressIndicator() + : null, + onTap: () async { + if (isDeviceConnecting(deviceName)) { + return; + } + + if (castManager.isCasting) { + await ref.read(castProvider.notifier).disconnect(); + } + + if (!isCurrentDevice(deviceName)) { + ref + .read(castProvider.notifier) + .connect(type, deviceObj); + } + }, + ); + } + }, + ); + }, + ), + ), + actions: [ + if (castManager.isCasting) + TextButton( + onPressed: () => ref.read(castProvider.notifier).disconnect(), + child: Text( + "stop_casting", + style: TextStyle( + color: context.colorScheme.secondary, + fontWeight: FontWeight.bold, + ), + ).tr(), + ), + TextButton( + onPressed: () => context.pop(), + child: Text( + "close", + style: TextStyle( + color: context.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ).tr(), + ), + ], + ); + } +} diff --git a/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart b/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart index d759b0d80b..d64e507170 100644 --- a/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart +++ b/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/models/cast/cast_manager_state.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_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/utils/hooks/timer_hook.dart'; import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart'; import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart'; @@ -25,6 +27,8 @@ class CustomVideoPlayerControls extends HookConsumerWidget { final VideoPlaybackState state = ref.watch(videoPlaybackValueProvider.select((value) => value.state)); + final cast = ref.watch(castProvider); + // A timer to hide the controls final hideTimer = useTimer( hideTimerDuration, @@ -42,7 +46,8 @@ class CustomVideoPlayerControls extends HookConsumerWidget { } }, ); - final showBuffering = state == VideoPlaybackState.buffering; + final showBuffering = + state == VideoPlaybackState.buffering && !cast.isCasting; /// Shows the controls and starts the timer to hide them void showControlsAndStartHideTimer() { @@ -59,6 +64,23 @@ class CustomVideoPlayerControls extends HookConsumerWidget { /// Toggles between playing and pausing depending on the state of the video void togglePlay() { showControlsAndStartHideTimer(); + + if (cast.isCasting) { + if (cast.castState == CastState.playing) { + ref.read(castProvider.notifier).pause(); + } else if (cast.castState == CastState.paused) { + ref.read(castProvider.notifier).play(); + } else if (cast.castState == CastState.idle) { + // resend the play command since its finished + final asset = ref.read(currentAssetProvider); + if (asset == null) { + return; + } + ref.read(castProvider.notifier).loadMedia(asset, true); + } + return; + } + if (state == VideoPlaybackState.playing) { ref.read(videoPlayerControlsProvider.notifier).pause(); } else if (state == VideoPlaybackState.completed) { @@ -89,7 +111,8 @@ class CustomVideoPlayerControls extends HookConsumerWidget { backgroundColor: Colors.black54, iconColor: Colors.white, isFinished: state == VideoPlaybackState.completed, - isPlaying: state == VideoPlaybackState.playing, + isPlaying: state == VideoPlaybackState.playing || + (cast.isCasting && cast.castState == CastState.playing), show: assetIsVideo && showControls, onPressed: togglePlay, ), diff --git a/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart b/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart index 64cb1c619f..a868aff617 100644 --- a/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart +++ b/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart @@ -1,12 +1,16 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/activity_statistics.provider.dart'; import 'package:immich_mobile/providers/album/current_album.provider.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; +import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/tab.provider.dart'; +import 'package:immich_mobile/providers/websocket.provider.dart'; +import 'package:immich_mobile/widgets/asset_viewer/cast_dialog.dart'; import 'package:immich_mobile/widgets/asset_viewer/motion_photo_button.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; @@ -44,6 +48,10 @@ class TopControlAppBar extends HookConsumerWidget { const double iconSize = 22.0; final a = ref.watch(assetWatcher(asset)).value ?? asset; final album = ref.watch(currentAlbumProvider); + final isCasting = ref.watch(castProvider.select((c) => c.isCasting)); + final websocketConnected = + ref.watch(websocketProvider.select((c) => c.isConnected)); + final comments = album != null && album.remoteId != null && asset.remoteId != null @@ -169,6 +177,22 @@ class TopControlAppBar extends HookConsumerWidget { ); } + Widget buildCastButton() { + return IconButton( + onPressed: () { + showDialog( + context: context, + builder: (context) => const CastDialog(), + ); + }, + icon: Icon( + isCasting ? Icons.cast_connected_rounded : Icons.cast_rounded, + size: 20.0, + color: isCasting ? context.primaryColor : Colors.grey[200], + ), + ); + } + bool isInHomePage = ref.read(tabProvider.notifier).state == TabEnum.home; bool? isInTrash = ref.read(currentAssetProvider)?.isTrashed; @@ -193,6 +217,8 @@ class TopControlAppBar extends HookConsumerWidget { !asset.isTrashed && !isInLockedView) buildAddToAlbumButton(), + if (isCasting || (asset.isRemote && websocketConnected)) + buildCastButton(), if (asset.isTrashed) buildRestoreButton(), if (album != null && album.shared && !isInLockedView) buildActivitiesButton(), diff --git a/mobile/lib/widgets/asset_viewer/video_position.dart b/mobile/lib/widgets/asset_viewer/video_position.dart index 4d0e7aa17f..0e90669fe3 100644 --- a/mobile/lib/widgets/asset_viewer/video_position.dart +++ b/mobile/lib/widgets/asset_viewer/video_position.dart @@ -6,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/colors.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_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/widgets/asset_viewer/formatted_duration.dart'; class VideoPosition extends HookConsumerWidget { @@ -13,9 +14,16 @@ class VideoPosition extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final (position, duration) = ref.watch( - videoPlaybackValueProvider.select((v) => (v.position, v.duration)), - ); + final isCasting = ref.watch(castProvider).isCasting; + + final (position, duration) = isCasting + ? ref.watch( + castProvider.select((c) => (c.currentTime, c.duration)), + ) + : ref.watch( + videoPlaybackValueProvider.select((v) => (v.position, v.duration)), + ); + final wasPlaying = useRef(true); return duration == Duration.zero ? const _VideoPositionPlaceholder() @@ -57,15 +65,22 @@ class VideoPosition extends HookConsumerWidget { } }, onChanged: (value) { - final inSeconds = - (duration * (value / 100.0)).inSeconds; - final position = inSeconds.toDouble(); + final seekToDuration = (duration * (value / 100.0)); + + if (isCasting) { + ref + .read(castProvider.notifier) + .seekTo(seekToDuration); + return; + } + ref .read(videoPlayerControlsProvider.notifier) - .position = position; + .position = seekToDuration.inSeconds.toDouble(); + // This immediately updates the slider position without waiting for the video to update ref.read(videoPlaybackValueProvider.notifier).position = - Duration(seconds: inSeconds); + seekToDuration; }, ), ), diff --git a/mobile/lib/widgets/common/immich_app_bar.dart b/mobile/lib/widgets/common/immich_app_bar.dart index 09f81b9e1a..812de58416 100644 --- a/mobile/lib/widgets/common/immich_app_bar.dart +++ b/mobile/lib/widgets/common/immich_app_bar.dart @@ -8,9 +8,11 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart'; import 'package:immich_mobile/models/server_info/server_info.model.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; +import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/widgets/asset_viewer/cast_dialog.dart'; import 'package:immich_mobile/widgets/common/app_bar_dialog/app_bar_dialog.dart'; import 'package:immich_mobile/widgets/common/user_circle_avatar.dart'; @@ -31,6 +33,7 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget { final user = ref.watch(currentUserProvider); final isDarkTheme = context.isDarkTheme; const widgetSize = 30.0; + final isCasting = ref.watch(castProvider.select((c) => c.isCasting)); buildProfileIndicator() { return InkWell( @@ -184,6 +187,21 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget { icon: const Icon(Icons.science_rounded), onPressed: () => context.pushRoute(const FeatInDevRoute()), ), + if (isCasting) + Padding( + padding: const EdgeInsets.only(right: 12), + child: IconButton( + onPressed: () { + showDialog( + context: context, + builder: (context) => const CastDialog(), + ); + }, + icon: Icon( + isCasting ? Icons.cast_connected_rounded : Icons.cast_rounded, + ), + ), + ), if (showUploadButton) Padding( padding: const EdgeInsets.only(right: 20), diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 5c54a2c349..fdff539085 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -81,6 +81,54 @@ packages: url: "https://pub.dev" source: hosted version: "9.2.0" + bonsoir: + dependency: transitive + description: + name: bonsoir + sha256: "2e2cf3be580deccad9a48dcaddddf90de092e74b7de2015ef58fb24e11d66496" + url: "https://pub.dev" + source: hosted + version: "5.1.11" + bonsoir_android: + dependency: transitive + description: + name: bonsoir_android + sha256: "9a65b6e50c5718c3f1a7ed6ff57ab9ed8ae990ff9c36d2b1ab3d1b90f28f7d1b" + url: "https://pub.dev" + source: hosted + version: "5.1.6" + bonsoir_darwin: + dependency: transitive + description: + name: bonsoir_darwin + sha256: "2d25c70f0d09260be1c2ab583b80dd89cbbfd59997579dadf789c5af00c7b2e4" + url: "https://pub.dev" + source: hosted + version: "5.1.3" + bonsoir_linux: + dependency: transitive + description: + name: bonsoir_linux + sha256: f2639aded6e15943a9822de98a663a1056f37cbfd0a74d72c9eaa941965945c2 + url: "https://pub.dev" + source: hosted + version: "5.1.3" + bonsoir_platform_interface: + dependency: transitive + description: + name: bonsoir_platform_interface + sha256: "08bb8b35d0198168b3bce87dbc718e4e510336cff1d97e43762e030c01636d45" + url: "https://pub.dev" + source: hosted + version: "5.1.3" + bonsoir_windows: + dependency: transitive + description: + name: bonsoir_windows + sha256: d4a0ca479d4f3679487a61f3174fb9fe1651e323c778b02dfa630490366be65d + url: "https://pub.dev" + source: hosted + version: "5.1.5" boolean_selector: dependency: transitive description: @@ -193,6 +241,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + cast: + dependency: "direct main" + description: + name: cast + sha256: de1856e1a31aa60a6fed627f827921f7ec6539c67c60d0c899e89646dcbe773e + url: "https://pub.dev" + source: hosted + version: "2.1.0" characters: dependency: transitive description: @@ -1404,6 +1460,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.3" + protobuf: + dependency: transitive + description: + name: protobuf + sha256: "68645b24e0716782e58948f8467fd42a880f255096a821f9e7d0ec625b00c84d" + url: "https://pub.dev" + source: hosted + version: "3.1.0" pub_semver: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 81249fdcfa..796a2df7b0 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -19,6 +19,7 @@ dependencies: background_downloader: ^9.2.0 cached_network_image: ^3.4.1 cancellation_token_http: ^2.1.0 + cast: ^2.1.0 collection: ^1.18.0 connectivity_plus: ^6.1.3 crop_image: ^1.0.16