immich/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart
shenlong 835c35e940
chore: migrate mobile translations accessor (2) (#30668)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-08-11 14:41:12 +05:30

380 lines
13 KiB
Dart

import 'dart:async';
import 'dart:math' as math;
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/scroll_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_page.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_preloader.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/utils/system_ui.utils.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
@RoutePage()
class AssetViewerPage extends StatelessWidget {
final int initialIndex;
final TimelineService timelineService;
final int? heroOffset;
final RemoteAlbum? currentAlbum;
const AssetViewerPage({
super.key,
required this.initialIndex,
required this.timelineService,
this.heroOffset,
this.currentAlbum,
});
@override
Widget build(BuildContext context) {
// This is necessary to ensure that the timeline service is available
// since the Timeline and AssetViewer are on different routes / Widget subtrees.
return ProviderScope(
overrides: [
timelineServiceProvider.overrideWithValue(timelineService),
currentRemoteAlbumScopedProvider.overrideWithValue(currentAlbum),
],
child: AssetViewer(initialIndex: initialIndex, heroOffset: heroOffset),
);
}
}
class AssetViewer extends ConsumerStatefulWidget {
final int initialIndex;
final int? heroOffset;
const AssetViewer({super.key, required this.initialIndex, this.heroOffset});
@override
ConsumerState createState() => _AssetViewerState();
/// Sets the asset and thumbnail size before opening the viewer.
static void setAsset(WidgetRef ref, BaseAsset asset, {Size? thumbnailSize}) {
ref.read(assetViewerProvider.notifier).reset();
// Hide controls by default for videos
if (asset.isVideo) {
ref.read(assetViewerProvider.notifier).setControls(false);
}
ref.read(assetViewerProvider.notifier).setAsset(asset, thumbnailSize: thumbnailSize);
}
}
class _AssetViewerState extends ConsumerState<AssetViewer> {
static const _viewerOverlayStyle = SystemUiOverlayStyle(
statusBarIconBrightness: Brightness.light,
statusBarBrightness: Brightness.dark,
systemNavigationBarIconBrightness: Brightness.light,
);
late final _heroOffset = widget.heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0;
late final _pageController = PageController(initialPage: widget.initialIndex);
late final _preloader = AssetPreloader(timelineService: ref.read(timelineServiceProvider), mounted: () => mounted);
late int _currentPage = widget.initialIndex;
late int _totalAssets = ref.read(timelineServiceProvider).totalAssets;
StreamSubscription? _reloadSubscription;
KeepAliveLink? _stackChildrenKeepAlive;
void _onTapNavigate(int direction) {
final page = _pageController.page?.toInt();
if (page == null) {
return;
}
final target = page + direction;
final maxPage = _totalAssets - 1;
if (target >= 0 && target <= maxPage) {
_pageController.jumpToPage(target);
unawaited(_onAssetChanged(target));
}
}
@override
void initState() {
super.initState();
final asset = ref.read(assetViewerProvider).currentAsset;
assert(asset != null, "Current asset should not be null when opening the AssetViewer");
if (asset != null) {
_stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive();
}
_reloadSubscription = EventStream.shared.listen(_onEvent);
WidgetsBinding.instance.addPostFrameCallback(_onAssetInit);
final assetViewer = ref.read(assetViewerProvider);
unawaited(_setSystemUIMode(assetViewer.showingControls, assetViewer.showingDetails));
}
@override
void dispose() {
_pageController.dispose();
_preloader.dispose();
unawaited(_reloadSubscription?.cancel());
_stackChildrenKeepAlive?.close();
unawaited(restoreEdgeToEdge());
super.dispose();
}
// The normal onPageChange callback listens to OnScrollUpdate events, and will
// round the current page and update whenever that value changes. In practise,
// this means that the page will change when swiped half way, and may flip
// whilst dragging.
//
// Changing the page at the end of a scroll should be more robust, and allow
// the page to be dragged more than half way whilst keeping the current video
// playing, and preventing the video on the next page from becoming ready
// unnecessarily.
bool _onScrollEnd(ScrollEndNotification notification) {
if (notification.depth != 0) {
return false;
}
final page = _pageController.page?.round();
if (page != null && page != _currentPage) {
unawaited(_onAssetChanged(page));
}
return false;
}
void _onAssetInit(Duration timeStamp) {
_preloader.preload(
widget.initialIndex,
context.sizeData,
thumbnailSize: ref.read(assetViewerProvider).thumbnailSize,
);
_handleCasting();
}
Future<void> _onAssetChanged(int index) async {
_currentPage = index;
final asset = await ref.read(timelineServiceProvider).getAssetAsync(index);
if (asset == null) {
return;
}
// The viewer is closing; don't flip the current asset now. Flipping it swaps
// the grid tile hero keys mid pop and animates the close on two tiles (#23779).
if (!mounted || !(ModalRoute.of(context)?.isActive ?? true)) {
return;
}
ref.read(assetViewerProvider.notifier).setAsset(asset);
_preloader.preload(index, context.sizeData, thumbnailSize: ref.read(assetViewerProvider).thumbnailSize);
_handleCasting();
_stackChildrenKeepAlive?.close();
_stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive();
}
void _handleCasting() {
if (!ref.read(castProvider).isCasting) {
return;
}
final asset = ref.read(assetViewerProvider).currentAsset;
if (asset == null) {
return;
}
if (asset is RemoteAsset) {
context.scaffoldMessenger.hideCurrentSnackBar();
ref.read(castProvider.notifier).loadMedia(asset, false);
return;
}
context.scaffoldMessenger.clearSnackBars();
ref.read(castProvider.notifier).stop();
context.scaffoldMessenger.showSnackBar(
SnackBar(
duration: const Duration(seconds: 2),
content: Text(
context.t.local_asset_cast_failed,
style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor),
),
),
);
}
void _onEvent(Event event) {
switch (event) {
case TimelineReloadEvent():
_onTimelineReloadEvent();
case ViewerReloadAssetEvent():
_onViewerReloadEvent();
case final ViewerStackAssetDeletedEvent event:
unawaited(_onViewerStackAssetDeletedEvent(event));
default:
}
}
void _onViewerReloadEvent() {
if (_totalAssets <= 1) {
return;
}
final index = _pageController.page?.round() ?? 0;
final target = index >= _totalAssets - 1 ? index - 1 : index + 1;
unawaited(_pageController.animateToPage(target, duration: Durations.medium1, curve: Curves.easeInOut));
unawaited(_onAssetChanged(target));
}
Future<void> _onViewerStackAssetDeletedEvent(ViewerStackAssetDeletedEvent event) async {
final timelineAsset = ref.read(timelineServiceProvider).getAssetSafe(_currentPage);
if (timelineAsset == null) {
_onViewerReloadEvent();
return;
}
final stackProvider = stackChildrenNotifier(timelineAsset);
ref.invalidate(stackProvider);
final stack = await ref.read(stackProvider.future);
if (!mounted) {
return;
}
if (stack.isEmpty) {
_onViewerReloadEvent();
return;
}
final targetIndex = math.min(event.stackIndex, stack.length - 1);
ref.read(assetViewerProvider.notifier)
..setAsset(stack[targetIndex])
..setStackIndex(targetIndex);
}
void _onTimelineReloadEvent() {
final timelineService = ref.read(timelineServiceProvider);
final totalAssets = timelineService.totalAssets;
if (totalAssets == 0) {
unawaited(context.maybePop());
return;
}
final currentAsset = ref.read(assetViewerProvider).currentAsset;
final assetIndex = currentAsset != null ? timelineService.getIndex(currentAsset.heroTag) : null;
final index = (assetIndex ?? _currentPage).clamp(0, totalAssets - 1);
if (index != _currentPage) {
_pageController.jumpToPage(index);
unawaited(_onAssetChanged(index));
} else if (currentAsset is RemoteAsset && currentAsset.stackId != null && assetIndex == null) {
final timelineAsset = timelineService.getAssetSafe(index);
if (timelineAsset is! RemoteAsset || currentAsset.stackId != timelineAsset.stackId) {
unawaited(_onAssetChanged(index));
}
} else if (currentAsset != null && assetIndex == null) {
unawaited(_onAssetChanged(index));
}
if (_totalAssets != totalAssets) {
setState(() {
_totalAssets = totalAssets;
});
}
}
Future<void> _setSystemUIMode(bool controls, bool details) {
final immersive = !controls || (CurrentPlatform.isIOS && details);
return immersive ? SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky) : restoreEdgeToEdge();
}
@override
Widget build(BuildContext context) {
final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
final isZoomed = ref.watch(assetViewerProvider.select((s) => s.isZoomed));
final backgroundColor = showingDetails
? context.colorScheme.surface
: Colors.black.withValues(alpha: ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)));
// Listen for casting changes and send initial asset to the cast provider
ref.listen(castProvider.select((value) => value.isCasting), (_, isCasting) {
if (!isCasting) {
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
_handleCasting();
});
});
ref.listen(assetViewerProvider.select((value) => (value.showingControls, value.showingDetails)), (_, state) {
final (controls, details) = state;
unawaited(_setSystemUIMode(controls, details));
});
return AnnotatedRegion(
value: _viewerOverlayStyle,
child: Scaffold(
backgroundColor: backgroundColor,
resizeToAvoidBottomInset: false,
appBar: const ViewerTopAppBar(),
extendBody: true,
extendBodyBehindAppBar: true,
floatingActionButton: IgnorePointer(
ignoring: !showingControls,
child: AnimatedOpacity(
opacity: showingControls ? 1.0 : 0.0,
duration: Durations.short2,
child: const DownloadStatusFloatingButton(),
),
),
bottomNavigationBar: const ViewerBottomAppBar(),
body: Stack(
children: [
NotificationListener<ScrollEndNotification>(
onNotification: _onScrollEnd,
child: PhotoViewGestureDetectorScope(
axis: Axis.horizontal,
child: PageView.builder(
controller: _pageController,
physics: isZoomed
? const NeverScrollableScrollPhysics()
: CurrentPlatform.isIOS
? const FastScrollPhysics()
: const FastClampingScrollPhysics(),
itemCount: _totalAssets,
itemBuilder: (context, index) =>
AssetPage(index: index, heroOffset: _heroOffset, onTapNavigate: _onTapNavigate),
),
),
),
if (!CurrentPlatform.isIOS)
IgnorePointer(
child: AnimatedContainer(
duration: Durations.short2,
color: Colors.black.withValues(alpha: showingDetails ? 0.6 : 0.0),
height: context.padding.top,
),
),
],
),
),
);
}
}