diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index 6fdbecced1..539406365a 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -125,7 +125,7 @@ class GalleryViewerPage extends HookConsumerWidget { final asset = loadAsset(currentIndex.value); if (asset.isRemote) { - ref.read(castProvider.notifier).loadMedia(asset, false); + ref.read(castProvider.notifier).loadMediaOld(asset, false); } else { if (isCasting) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -394,7 +394,7 @@ class GalleryViewerPage extends HookConsumerWidget { // send image to casting if the server has it if (newAsset.isRemote) { - ref.read(castProvider.notifier).loadMedia(newAsset, false); + ref.read(castProvider.notifier).loadMediaOld(newAsset, false); } else { context.scaffoldMessenger.clearSnackBars(); diff --git a/mobile/lib/pages/common/tab_shell.page.dart b/mobile/lib/pages/common/tab_shell.page.dart index c8ca7ea061..007dc4c9d6 100644 --- a/mobile/lib/pages/common/tab_shell.page.dart +++ b/mobile/lib/pages/common/tab_shell.page.dart @@ -9,6 +9,7 @@ import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/search/search_input_focus.provider.dart'; import 'package:immich_mobile/providers/tab.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; +import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/migration.dart'; @@ -24,8 +25,8 @@ class _TabShellPageState extends ConsumerState { @override void initState() { super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(websocketProvider.notifier).connect(); runNewSync(ref, full: true); }); } diff --git a/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart index 94e3610a57..2ad285326c 100644 --- a/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart @@ -6,6 +6,7 @@ class BaseActionButton extends StatelessWidget { super.key, required this.label, required this.iconData, + this.iconColor, this.onPressed, this.onLongPressed, this.maxWidth = 90.0, @@ -15,6 +16,7 @@ class BaseActionButton extends StatelessWidget { final String label; final IconData iconData; + final Color? iconColor; final double maxWidth; final double? minWidth; final bool menuItem; @@ -27,7 +29,8 @@ class BaseActionButton extends StatelessWidget { minWidth ?? (context.isMobile ? context.width / 4.5 : 75.0); final iconTheme = IconTheme.of(context); final iconSize = iconTheme.size ?? 24.0; - final iconColor = iconTheme.color ?? context.themeData.iconTheme.color; + final iconColor = + this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color; final textColor = context.themeData.textTheme.labelLarge?.color; if (menuItem) { diff --git a/mobile/lib/presentation/widgets/action_buttons/cast_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/cast_action_button.widget.dart new file mode 100644 index 0000000000..2900d55834 --- /dev/null +++ b/mobile/lib/presentation/widgets/action_buttons/cast_action_button.widget.dart @@ -0,0 +1,32 @@ +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/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; +import 'package:immich_mobile/providers/cast.provider.dart'; +import 'package:immich_mobile/widgets/asset_viewer/cast_dialog.dart'; + +class CastActionButton extends ConsumerWidget { + const CastActionButton({super.key, this.menuItem = true}); + + final bool menuItem; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isCasting = ref.watch(castProvider.select((c) => c.isCasting)); + + return BaseActionButton( + iconData: isCasting ? Icons.cast_connected_rounded : Icons.cast_rounded, + iconColor: + isCasting ? context.primaryColor : null, // null = default color + label: "cast".t(context: context), + onPressed: () { + showDialog( + context: context, + builder: (context) => const CastDialog(), + ); + }, + menuItem: menuItem, + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index dfc0023685..1c0f28413a 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; @@ -18,6 +19,7 @@ import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.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/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; @@ -184,6 +186,40 @@ class _AssetViewerState extends ConsumerState { } }); _delayedOperations.add(timer); + + _handleCasting(asset); + } + + void _handleCasting(BaseAsset asset) { + if (!ref.read(castProvider).isCasting) return; + + // hide any casting snackbars if they exist + context.scaffoldMessenger.hideCurrentSnackBar(); + + // send image to casting if the server has it + if (asset.hasRemote) { + final remoteAsset = asset as RemoteAsset; + + ref.read(castProvider.notifier).loadMedia(remoteAsset, false); + } else { + // casting cannot show local assets + context.scaffoldMessenger.clearSnackBars(); + + if (ref.read(castProvider).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, + ), + ), + ), + ); + } + } } void _onPageBuild(PhotoViewControllerBase controller) { @@ -570,6 +606,19 @@ class _AssetViewerState extends ConsumerState { ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)); ref.watch(isPlayingMotionVideoProvider); + // Listen for casting changes and send initial asset to the cast provider + ref.listen(castProvider.select((value) => value.isCasting), + (_, isCasting) async { + if (!isCasting) return; + + final asset = ref.read(currentAssetNotifier); + if (asset == null) return; + + WidgetsBinding.instance.addPostFrameCallback((_) { + _handleCasting(asset); + }); + }); + // Currently it is not possible to scroll the asset when the bottom sheet is open all the way. // Issue: https://github.com/flutter/flutter/issues/109037 // TODO: Add a custom scrum builder once the fix lands on stable diff --git a/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart index 0f3d46f673..c85c4390ae 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart @@ -5,12 +5,15 @@ import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/providers/websocket.provider.dart'; class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { const ViewerTopAppBar({super.key}); @@ -37,7 +40,17 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { opacity = 0; } + final isCasting = ref.watch( + castProvider.select((c) => c.isCasting), + ); + final websocketConnected = + ref.watch(websocketProvider.select((c) => c.isConnected)); + final actions = [ + if (isCasting || (asset.hasRemote && websocketConnected)) + const CastActionButton( + menuItem: true, + ), if (asset.hasRemote && isOwner && !asset.isFavorite) const FavoriteActionButton(source: ActionSource.viewer, menuItem: true), if (asset.hasRemote && isOwner && asset.isFavorite) diff --git a/mobile/lib/providers/app_life_cycle.provider.dart b/mobile/lib/providers/app_life_cycle.provider.dart index b4b7b0d406..997058d763 100644 --- a/mobile/lib/providers/app_life_cycle.provider.dart +++ b/mobile/lib/providers/app_life_cycle.provider.dart @@ -141,13 +141,15 @@ class AppLifeCycleNotifier extends StateNotifier { state = AppLifeCycleEnum.paused; _wasPaused = true; - if (!Store.isBetaTimelineEnabled && - _ref.read(authProvider).isAuthenticated) { - // Do not cancel backup if manual upload is in progress - if (_ref.read(backupProvider.notifier).backupProgress != - BackUpProgressEnum.manualInProgress) { - _ref.read(backupProvider.notifier).cancelBackup(); + if (_ref.read(authProvider).isAuthenticated) { + if (!Store.isBetaTimelineEnabled) { + // Do not cancel backup if manual upload is in progress + if (_ref.read(backupProvider.notifier).backupProgress != + BackUpProgressEnum.manualInProgress) { + _ref.read(backupProvider.notifier).cancelBackup(); + } } + _ref.read(websocketProvider.notifier).disconnect(); } diff --git a/mobile/lib/providers/cast.provider.dart b/mobile/lib/providers/cast.provider.dart index f70bdad9dc..11cdcd54c5 100644 --- a/mobile/lib/providers/cast.provider.dart +++ b/mobile/lib/providers/cast.provider.dart @@ -1,5 +1,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/entities/asset.entity.dart' as old_asset_entity; import 'package:immich_mobile/models/cast/cast_manager_state.dart'; import 'package:immich_mobile/services/gcast.service.dart'; @@ -50,10 +51,29 @@ class CastNotifier extends StateNotifier { state = state.copyWith(castState: castState); } - void loadMedia(Asset asset, bool reload) { + void loadMedia(RemoteAsset asset, bool reload) { _gCastService.loadMedia(asset, reload); } + // TODO: remove this when we migrate to new timeline + void loadMediaOld(old_asset_entity.Asset asset, bool reload) { + final remoteAsset = RemoteAsset( + id: asset.remoteId.toString(), + name: asset.name, + ownerId: asset.ownerId.toString(), + checksum: asset.checksum, + type: asset.type == old_asset_entity.AssetType.image + ? AssetType.image + : asset.type == old_asset_entity.AssetType.video + ? AssetType.video + : AssetType.other, + createdAt: asset.fileCreatedAt, + updatedAt: asset.updatedAt, + ); + + _gCastService.loadMedia(remoteAsset, reload); + } + Future connect(CastDestinationType type, dynamic device) async { switch (type) { case CastDestinationType.googleCast: diff --git a/mobile/lib/services/gcast.service.dart b/mobile/lib/services/gcast.service.dart index 5a8c27b0db..6d6646fe50 100644 --- a/mobile/lib/services/gcast.service.dart +++ b/mobile/lib/services/gcast.service.dart @@ -2,7 +2,7 @@ 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/domain/models/asset/base_asset.model.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'; @@ -156,12 +156,10 @@ class GCastService { return bufferedExpiration.isAfter(DateTime.now()); } - void loadMedia(Asset asset, bool reload) async { + void loadMedia(RemoteAsset asset, bool reload) async { if (!isConnected) { return; - } else if (asset.remoteId == null) { - return; - } else if (asset.remoteId == currentAssetId && !reload) { + } else if (asset.id == currentAssetId && !reload) { return; } @@ -176,10 +174,10 @@ class GCastService { final unauthenticatedUrl = asset.isVideo ? getPlaybackUrlForRemoteId( - asset.remoteId!, + asset.id, ) : getThumbnailUrlForRemoteId( - asset.remoteId!, + asset.id, type: AssetMediaSize.fullsize, ); @@ -187,8 +185,7 @@ class GCastService { "$unauthenticatedUrl&sessionKey=${sessionKey?.token}"; // get image mime type - final mimeType = - await _assetApiRepository.getAssetMIMEType(asset.remoteId!); + final mimeType = await _assetApiRepository.getAssetMIMEType(asset.id); if (mimeType == null) { return; @@ -205,7 +202,7 @@ class GCastService { "autoplay": true, }); - currentAssetId = asset.remoteId; + currentAssetId = asset.id; // we need to poll for media status since the cast device does not // send a message when the media is loaded for whatever reason 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 d64e507170..18565c8332 100644 --- a/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart +++ b/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart @@ -76,7 +76,7 @@ class CustomVideoPlayerControls extends HookConsumerWidget { if (asset == null) { return; } - ref.read(castProvider.notifier).loadMedia(asset, true); + ref.read(castProvider.notifier).loadMediaOld(asset, true); } return; }