diff --git a/i18n/en.json b/i18n/en.json index 686844e59f..36b8501cb4 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1778,6 +1778,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/lib/interfaces/cast_destination_service.interface.dart b/mobile/lib/interfaces/cast_destination_service.interface.dart index 4481eb597e..89cc9e6d38 100644 --- a/mobile/lib/interfaces/cast_destination_service.interface.dart +++ b/mobile/lib/interfaces/cast_destination_service.interface.dart @@ -1,3 +1,4 @@ +import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/models/cast_manager_state.dart'; abstract interface class ICastDestinationService { @@ -14,7 +15,7 @@ abstract interface class ICastDestinationService { void Function(String)? onReceiverName; void Function(CastState)? onCastState; - void loadMedia(String url, String sessionKey, bool reload); + void loadMedia(Asset asset, bool reload); void play(); void pause(); diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index 420b699730..7d1ea71622 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -20,6 +20,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'; @@ -355,6 +356,11 @@ class GalleryViewerPage extends HookConsumerWidget { Timer(const Duration(milliseconds: 400), () { precacheNextImage(next); }); + + // send image to casting if the server has it + if (newAsset.isRemote) { + ref.read(castProvider.notifier).loadMedia(newAsset, false); + } }, builder: buildAsset, ), diff --git a/mobile/lib/providers/cast.provider.dart b/mobile/lib/providers/cast.provider.dart index c71a942221..e829f84ce8 100644 --- a/mobile/lib/providers/cast.provider.dart +++ b/mobile/lib/providers/cast.provider.dart @@ -1,6 +1,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/models/cast_manager_state.dart'; import 'package:immich_mobile/services/gcast.service.dart'; +import 'package:openapi/api.dart'; final castProvider = StateNotifierProvider( (ref) => CastNotifier(ref.watch(gCastServiceProvider)), @@ -46,8 +48,8 @@ class CastNotifier extends StateNotifier { state = state.copyWith(castState: castState); } - void loadMedia(String url, String sessionKey, bool reload) { - _gCastService.loadMedia(url, sessionKey, reload); + void loadMedia(Asset asset, bool reload) { + _gCastService.loadMedia(asset, reload); } Future connect(CastDestinationType type, dynamic device) async { @@ -59,16 +61,7 @@ class CastNotifier extends StateNotifier { } Future> getDevices() async { - // return _gCastService.getDevices(); - // delay for 2 seconds to simulate loading - await Future.delayed(const Duration(seconds: 2)); - - return Future>.value([ - ('Google Cast', CastDestinationType.googleCast, null), - ('Apple TV', CastDestinationType.googleCast, null), - ('Roku', CastDestinationType.googleCast, null), - ('Fire TV', CastDestinationType.googleCast, null), - ]); + return _gCastService.getDevices(); } void play() { diff --git a/mobile/lib/repositories/gcast.repository.dart b/mobile/lib/repositories/gcast.repository.dart index bd677c73aa..3f38cb3e32 100644 --- a/mobile/lib/repositories/gcast.repository.dart +++ b/mobile/lib/repositories/gcast.repository.dart @@ -29,7 +29,7 @@ class GCastRepository { }); // open the default receiver - sendMessage({ + sendMessage(CastSession.kNamespaceReceiver, { 'type': 'LAUNCH', 'appId': 'CC1AD845', }); @@ -41,12 +41,12 @@ class GCastRepository { await _castSession?.close(); } - void sendMessage(Map message) { + void sendMessage(String namespace, Map message) { if (_castSession == null) { throw Exception("Cast session is not established"); } - _castSession!.sendMessage(CastSession.kNamespaceReceiver, message); + _castSession!.sendMessage(namespace, message); } Future> listDestinations() async { diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index 92b077ef59..87eb348cf1 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -33,6 +33,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 @@ -70,6 +71,7 @@ class ApiService implements Authentication { stacksApi = StacksApi(_apiClient); viewApi = ViewApi(_apiClient); memoriesApi = MemoriesApi(_apiClient); + sessionsApi = SessionsApi(_apiClient); } Future resolveAndSetEndpoint(String serverUrl) async { diff --git a/mobile/lib/services/gcast.service.dart b/mobile/lib/services/gcast.service.dart index 6db1ce0e42..4cb7441219 100644 --- a/mobile/lib/services/gcast.service.dart +++ b/mobile/lib/services/gcast.service.dart @@ -2,17 +2,30 @@ import 'package:cast/device.dart'; import 'package:cast/session.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/interfaces/cast_destination_service.interface.dart'; import 'package:immich_mobile/models/cast_manager_state.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/repositories/gcast.repository.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/utils/url_helper.dart'; +import 'package:openapi/api.dart' as api; +import 'package:uuid/uuid.dart'; -final gCastServiceProvider = - Provider((ref) => GCastService(ref.watch(gCastRepositoryProvider))); +final gCastServiceProvider = Provider( + (ref) => GCastService( + ref.watch(gCastRepositoryProvider), ref.watch(apiServiceProvider)), +); class GCastService implements ICastDestinationService { final GCastRepository _gCastRepository; + final ApiService _apiService; + + api.SessionCreateResponseDto? sessionKey; + String? currentAssetId; + bool isConnected = false; @override void Function(bool)? onConnectionState; @@ -25,7 +38,7 @@ class GCastService implements ICastDestinationService { @override void Function(CastState)? onCastState; - GCastService(this._gCastRepository) { + GCastService(this._gCastRepository, this._apiService) { _gCastRepository.onCastStatus = _onCastStatusCallback; _gCastRepository.onCastMessage = _onCastMessageCallback; } @@ -33,8 +46,10 @@ class GCastService implements ICastDestinationService { void _onCastStatusCallback(CastSessionState state) { if (state == CastSessionState.connected) { onConnectionState?.call(true); + isConnected = true; } else if (state == CastSessionState.closed) { onConnectionState?.call(false); + isConnected = false; } } @@ -50,6 +65,8 @@ class GCastService implements ICastDestinationService { Future connect(CastDevice device) async { await _gCastRepository.connect(device); + + onReceiverName?.call(device.extras["fn"] ?? "Google Cast"); } @override @@ -68,6 +85,8 @@ class GCastService implements ICastDestinationService { @override void disconnect() { _gCastRepository.disconnect(); + + onReceiverName?.call(""); } @override @@ -79,8 +98,58 @@ class GCastService implements ICastDestinationService { } @override - void loadMedia(String url, String sessionKey, bool reload) { - // TODO: implement loadMedia + void loadMedia(Asset asset, bool reload) async { + print("Casting media: ${asset.remoteId}"); + + if (!isConnected) { + return; + } else if (asset.remoteId == null) { + return; + } else if (asset.remoteId == currentAssetId && !reload) { + return; + } + + // create a session key + sessionKey ??= await _apiService.sessionsApi.createSession( + api.SessionCreateDto( + deviceOS: "Google Cast", + deviceType: "Google Cast", + duration: const Duration(minutes: 15).inSeconds, + ), + ); + + final unauthenticatedUrl = asset.isVideo + ? getOriginalUrlForRemoteId( + asset.remoteId!, + ) + : getThumbnailUrlForRemoteId( + asset.remoteId!, + type: api.AssetMediaSize.thumbnail, + ); + + final authenticatedURL = + "$unauthenticatedUrl&sessionKey=${sessionKey?.token}"; + + // get image mime type + final info = await _apiService.assetsApi.getAssetInfo(asset.remoteId!); + final mimeType = info?.originalMimeType; + + if (mimeType == null) { + return; + } + + _gCastRepository.sendMessage(CastSession.kNamespaceMedia, { + "type": "LOAD", + "media": { + "contentId": authenticatedURL, + "streamType": "LIVE", + "contentType": mimeType, + "contentUrl": authenticatedURL, + }, + "autoplay": true, + }); + + print("Sending message: $authenticatedURL"); } @override @@ -103,7 +172,13 @@ class GCastService implements ICastDestinationService { final dests = await _gCastRepository.listDestinations(); return dests - .map((device) => (device.name, CastDestinationType.googleCast, device)) + .map( + (device) => ( + device.extras["fn"] ?? "Google Cast", + CastDestinationType.googleCast, + device + ), + ) .toList(growable: false); } } diff --git a/mobile/lib/widgets/asset_viewer/cast_dialog.dart b/mobile/lib/widgets/asset_viewer/cast_dialog.dart index 12505c7e15..451995a09e 100644 --- a/mobile/lib/widgets/asset_viewer/cast_dialog.dart +++ b/mobile/lib/widgets/asset_viewer/cast_dialog.dart @@ -10,6 +10,8 @@ class CastDialog extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final castManager = ref.watch(castProvider); + return AlertDialog( title: const Text( "cast", @@ -21,8 +23,6 @@ class CastDialog extends ConsumerWidget { child: FutureBuilder>( future: ref.read(castProvider.notifier).getDevices(), builder: (context, snapshot) { - final cast = ref.read(castProvider.notifier); - if (snapshot.hasError) { return Text( 'Error: ${snapshot.error.toString()}', @@ -50,14 +50,25 @@ class CastDialog extends ConsumerWidget { final deviceObj = found.$3; return ListTile( - title: Text(deviceName), + title: Text( + deviceName, + style: TextStyle( + color: castManager.receiverName == deviceName + ? context.colorScheme.primary + : null, + ), + ), leading: Icon( type == CastDestinationType.googleCast ? Icons.cast : Icons.cast_connected, ), + trailing: castManager.isCasting && + castManager.receiverName == deviceName + ? Icon(Icons.check, color: context.colorScheme.primary) + : null, onTap: () { - cast.connect(type, deviceObj); + ref.read(castProvider.notifier).connect(type, deviceObj); }, ); }, @@ -66,12 +77,23 @@ class CastDialog extends ConsumerWidget { ), ), 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.tertiary, + color: context.colorScheme.primary, fontWeight: FontWeight.bold, ), ).tr(), 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 2d9a1fd1ea..4b841fad96 100644 --- a/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart +++ b/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart @@ -6,6 +6,7 @@ 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/widgets/asset_viewer/cast_dialog.dart'; import 'package:immich_mobile/widgets/asset_viewer/motion_photo_button.dart'; @@ -45,6 +46,7 @@ class TopControlAppBar extends HookConsumerWidget { const double iconSize = 22.0; final a = ref.watch(assetWatcher(asset)).value ?? asset; final album = ref.watch(currentAlbumProvider); + final castManager = ref.watch(castProvider); final comments = album != null && album.remoteId != null && asset.remoteId != null @@ -174,10 +176,14 @@ class TopControlAppBar extends HookConsumerWidget { return IconButton( onPressed: () { showDialog( - context: context, builder: (context) => const CastDialog()); + context: context, + builder: (context) => const CastDialog(), + ); }, icon: Icon( - Icons.cast, + castManager.isCasting + ? Icons.cast_connected_rounded + : Icons.cast_rounded, size: 20.0, color: Colors.grey[200], ),