chore(mobile): update casting to new asset viewer (#19994)

* update casting to new asset viewer

* handle websocket

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
Brandon Wees 2025-07-17 12:08:32 -05:00 committed by GitHub
parent 055b930066
commit 03ff425664
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 140 additions and 23 deletions

View File

@ -125,7 +125,7 @@ class GalleryViewerPage extends HookConsumerWidget {
final asset = loadAsset(currentIndex.value); final asset = loadAsset(currentIndex.value);
if (asset.isRemote) { if (asset.isRemote) {
ref.read(castProvider.notifier).loadMedia(asset, false); ref.read(castProvider.notifier).loadMediaOld(asset, false);
} else { } else {
if (isCasting) { if (isCasting) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@ -394,7 +394,7 @@ class GalleryViewerPage extends HookConsumerWidget {
// send image to casting if the server has it // send image to casting if the server has it
if (newAsset.isRemote) { if (newAsset.isRemote) {
ref.read(castProvider.notifier).loadMedia(newAsset, false); ref.read(castProvider.notifier).loadMediaOld(newAsset, false);
} else { } else {
context.scaffoldMessenger.clearSnackBars(); context.scaffoldMessenger.clearSnackBars();

View File

@ -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/search/search_input_focus.provider.dart';
import 'package:immich_mobile/providers/tab.provider.dart'; import 'package:immich_mobile/providers/tab.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.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/routing/router.dart';
import 'package:immich_mobile/utils/migration.dart'; import 'package:immich_mobile/utils/migration.dart';
@ -24,8 +25,8 @@ class _TabShellPageState extends ConsumerState<TabShellPage> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(websocketProvider.notifier).connect();
runNewSync(ref, full: true); runNewSync(ref, full: true);
}); });
} }

View File

@ -6,6 +6,7 @@ class BaseActionButton extends StatelessWidget {
super.key, super.key,
required this.label, required this.label,
required this.iconData, required this.iconData,
this.iconColor,
this.onPressed, this.onPressed,
this.onLongPressed, this.onLongPressed,
this.maxWidth = 90.0, this.maxWidth = 90.0,
@ -15,6 +16,7 @@ class BaseActionButton extends StatelessWidget {
final String label; final String label;
final IconData iconData; final IconData iconData;
final Color? iconColor;
final double maxWidth; final double maxWidth;
final double? minWidth; final double? minWidth;
final bool menuItem; final bool menuItem;
@ -27,7 +29,8 @@ class BaseActionButton extends StatelessWidget {
minWidth ?? (context.isMobile ? context.width / 4.5 : 75.0); minWidth ?? (context.isMobile ? context.width / 4.5 : 75.0);
final iconTheme = IconTheme.of(context); final iconTheme = IconTheme.of(context);
final iconSize = iconTheme.size ?? 24.0; 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; final textColor = context.themeData.textTheme.labelLarge?.color;
if (menuItem) { if (menuItem) {

View File

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

View File

@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.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/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_controls_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_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/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
@ -184,6 +186,40 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
} }
}); });
_delayedOperations.add(timer); _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) { void _onPageBuild(PhotoViewControllerBase controller) {
@ -570,6 +606,19 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)); ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity));
ref.watch(isPlayingMotionVideoProvider); 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. // 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 // Issue: https://github.com/flutter/flutter/issues/109037
// TODO: Add a custom scrum builder once the fix lands on stable // TODO: Add a custom scrum builder once the fix lands on stable

View File

@ -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/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.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/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/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/action_buttons/unfavorite_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.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/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
const ViewerTopAppBar({super.key}); const ViewerTopAppBar({super.key});
@ -37,7 +40,17 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
opacity = 0; opacity = 0;
} }
final isCasting = ref.watch(
castProvider.select((c) => c.isCasting),
);
final websocketConnected =
ref.watch(websocketProvider.select((c) => c.isConnected));
final actions = <Widget>[ final actions = <Widget>[
if (isCasting || (asset.hasRemote && websocketConnected))
const CastActionButton(
menuItem: true,
),
if (asset.hasRemote && isOwner && !asset.isFavorite) if (asset.hasRemote && isOwner && !asset.isFavorite)
const FavoriteActionButton(source: ActionSource.viewer, menuItem: true), const FavoriteActionButton(source: ActionSource.viewer, menuItem: true),
if (asset.hasRemote && isOwner && asset.isFavorite) if (asset.hasRemote && isOwner && asset.isFavorite)

View File

@ -141,13 +141,15 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
state = AppLifeCycleEnum.paused; state = AppLifeCycleEnum.paused;
_wasPaused = true; _wasPaused = true;
if (!Store.isBetaTimelineEnabled && if (_ref.read(authProvider).isAuthenticated) {
_ref.read(authProvider).isAuthenticated) { if (!Store.isBetaTimelineEnabled) {
// Do not cancel backup if manual upload is in progress // Do not cancel backup if manual upload is in progress
if (_ref.read(backupProvider.notifier).backupProgress != if (_ref.read(backupProvider.notifier).backupProgress !=
BackUpProgressEnum.manualInProgress) { BackUpProgressEnum.manualInProgress) {
_ref.read(backupProvider.notifier).cancelBackup(); _ref.read(backupProvider.notifier).cancelBackup();
}
} }
_ref.read(websocketProvider.notifier).disconnect(); _ref.read(websocketProvider.notifier).disconnect();
} }

View File

@ -1,5 +1,6 @@
import 'package:hooks_riverpod/hooks_riverpod.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/entities/asset.entity.dart' as old_asset_entity;
import 'package:immich_mobile/models/cast/cast_manager_state.dart'; import 'package:immich_mobile/models/cast/cast_manager_state.dart';
import 'package:immich_mobile/services/gcast.service.dart'; import 'package:immich_mobile/services/gcast.service.dart';
@ -50,10 +51,29 @@ class CastNotifier extends StateNotifier<CastManagerState> {
state = state.copyWith(castState: castState); state = state.copyWith(castState: castState);
} }
void loadMedia(Asset asset, bool reload) { void loadMedia(RemoteAsset asset, bool reload) {
_gCastService.loadMedia(asset, 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<void> connect(CastDestinationType type, dynamic device) async { Future<void> connect(CastDestinationType type, dynamic device) async {
switch (type) { switch (type) {
case CastDestinationType.googleCast: case CastDestinationType.googleCast:

View File

@ -2,7 +2,7 @@ import 'dart:async';
import 'package:cast/session.dart'; import 'package:cast/session.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/cast/cast_manager_state.dart';
import 'package:immich_mobile/models/sessions/session_create_response.model.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/asset_api.repository.dart';
@ -156,12 +156,10 @@ class GCastService {
return bufferedExpiration.isAfter(DateTime.now()); return bufferedExpiration.isAfter(DateTime.now());
} }
void loadMedia(Asset asset, bool reload) async { void loadMedia(RemoteAsset asset, bool reload) async {
if (!isConnected) { if (!isConnected) {
return; return;
} else if (asset.remoteId == null) { } else if (asset.id == currentAssetId && !reload) {
return;
} else if (asset.remoteId == currentAssetId && !reload) {
return; return;
} }
@ -176,10 +174,10 @@ class GCastService {
final unauthenticatedUrl = asset.isVideo final unauthenticatedUrl = asset.isVideo
? getPlaybackUrlForRemoteId( ? getPlaybackUrlForRemoteId(
asset.remoteId!, asset.id,
) )
: getThumbnailUrlForRemoteId( : getThumbnailUrlForRemoteId(
asset.remoteId!, asset.id,
type: AssetMediaSize.fullsize, type: AssetMediaSize.fullsize,
); );
@ -187,8 +185,7 @@ class GCastService {
"$unauthenticatedUrl&sessionKey=${sessionKey?.token}"; "$unauthenticatedUrl&sessionKey=${sessionKey?.token}";
// get image mime type // get image mime type
final mimeType = final mimeType = await _assetApiRepository.getAssetMIMEType(asset.id);
await _assetApiRepository.getAssetMIMEType(asset.remoteId!);
if (mimeType == null) { if (mimeType == null) {
return; return;
@ -205,7 +202,7 @@ class GCastService {
"autoplay": true, "autoplay": true,
}); });
currentAssetId = asset.remoteId; currentAssetId = asset.id;
// we need to poll for media status since the cast device does not // we need to poll for media status since the cast device does not
// send a message when the media is loaded for whatever reason // send a message when the media is loaded for whatever reason

View File

@ -76,7 +76,7 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
if (asset == null) { if (asset == null) {
return; return;
} }
ref.read(castProvider.notifier).loadMedia(asset, true); ref.read(castProvider.notifier).loadMediaOld(asset, true);
} }
return; return;
} }