/* * This file is part of PiliPlus * * PiliPlus is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * PiliPlus is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with PiliPlus. If not, see . */ import 'dart:io' show File, Platform; import 'dart:ui' as ui; import 'package:PiliPlus/common/widgets/flutter/page/page_view.dart'; import 'package:PiliPlus/common/widgets/gesture/image_horizontal_drag_gesture_recognizer.dart'; import 'package:PiliPlus/common/widgets/gesture/image_tap_gesture_recognizer.dart'; import 'package:PiliPlus/common/widgets/image_viewer/image.dart'; import 'package:PiliPlus/common/widgets/image_viewer/loading_indicator.dart'; import 'package:PiliPlus/common/widgets/image_viewer/viewer.dart'; import 'package:PiliPlus/common/widgets/scroll_physics.dart'; import 'package:PiliPlus/models/common/image_preview_type.dart'; import 'package:PiliPlus/utils/extension/string_ext.dart'; import 'package:PiliPlus/utils/image_utils.dart'; import 'package:PiliPlus/utils/page_utils.dart'; import 'package:PiliPlus/utils/platform_utils.dart'; import 'package:PiliPlus/utils/storage_pref.dart'; import 'package:PiliPlus/utils/utils.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:easy_debounce/easy_throttle.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart' hide Image, PageView; import 'package:flutter/services.dart' show HapticFeedback; import 'package:get/get.dart'; import 'package:media_kit/media_kit.dart'; import 'package:media_kit_video/media_kit_video.dart'; /// /// created by dom on 2026/02/14 /// class GalleryViewer extends StatefulWidget { const GalleryViewer({ super.key, this.minScale = 1.0, this.maxScale = 8.0, required this.quality, required this.sources, this.initIndex = 1, }); final double minScale; final double maxScale; final int quality; final List sources; final int initIndex; @override State createState() => _GalleryViewerState(); } class _GalleryViewerState extends State with SingleTickerProviderStateMixin { late Size _containerSize; late final int _quality; late final RxInt _currIndex; late final List _keys; Player? _player; Player get _effectivePlayer => _player ??= Player(); VideoController? _videoController; VideoController get _effectiveVideoController => _videoController ??= VideoController(_effectivePlayer); late final PageController _pageController; late final ImageTapGestureRecognizer _tapGestureRecognizer; late final ImageHorizontalDragGestureRecognizer _horizontalDragGestureRecognizer; late final LongPressGestureRecognizer _longPressGestureRecognizer; final Rx _matrix = Rx(Matrix4.identity()); late final AnimationController _animateController; late final Animation _opacityAnimation; double dx = 0, dy = 0; Offset _offset = Offset.zero; bool _dragging = false; bool get _isActive => _dragging || _animateController.isAnimating; String _getActualUrl(String url) { return _quality != 100 ? ImageUtils.thumbnailUrl(url, _quality) : url.http2https; } @override void initState() { super.initState(); _quality = Pref.previewQ; _currIndex = widget.initIndex.obs; _playIfNeeded(widget.initIndex); _keys = List.generate(widget.sources.length, (_) => GlobalKey()); _pageController = PageController(initialPage: widget.initIndex); final gestureSettings = MediaQuery.maybeGestureSettingsOf(Get.context!); _tapGestureRecognizer = ImageTapGestureRecognizer() ..onTap = _onTap ..gestureSettings = gestureSettings; if (PlatformUtils.isDesktop) { _tapGestureRecognizer.onSecondaryTapUp = _showDesktopMenu; } _horizontalDragGestureRecognizer = ImageHorizontalDragGestureRecognizer() ..gestureSettings = gestureSettings; _longPressGestureRecognizer = LongPressGestureRecognizer() ..onLongPress = _onLongPress ..gestureSettings = gestureSettings; _animateController = AnimationController( duration: const Duration(milliseconds: 300), vsync: this, ); _opacityAnimation = _animateController.drive( DecorationTween( begin: const BoxDecoration(color: Colors.black), end: const BoxDecoration(color: Colors.transparent), ), ); _animateController.addListener(_updateTransformation); } void _updateTransformation() { final val = _animateController.value; final scale = ui.lerpDouble(1.0, 0.25, val)!; // Matrix4.identity() // ..translateByDouble(size.width / 2, size.height / 2, 0, 1) // ..translateByDouble(size.width * val * dx, size.height * val * dy, 0, 1) // ..scaleByDouble(scale, scale, 1, 1) // ..translateByDouble(-size.width / 2, -size.height / 2, 0, 1); final tmp = (1.0 - scale) / 2.0; _matrix.value = Matrix4.diagonal3Values(scale, scale, scale) ..setTranslationRaw( _containerSize.width * (val * dx + tmp), _containerSize.height * (val * dy + tmp), 0, ); } void _updateMoveAnimation() { dy = _offset.dy.sign; if (dy == 0) { dx = 0; } else { dx = _offset.dx / _offset.dy.abs(); } } bool isAnimating() => _animateController.value != 0; void _onDragStart(ScaleStartDetails details) { _dragging = true; if (_animateController.isAnimating) { _animateController.stop(); } else { _offset = Offset.zero; _animateController.value = 0.0; } _updateMoveAnimation(); } void _onDragUpdate(ScaleUpdateDetails details) { if (!_isActive || _animateController.isAnimating) { return; } _offset += details.focalPointDelta; _updateMoveAnimation(); if (!_animateController.isAnimating) { _animateController.value = _offset.dy.abs() / _containerSize.height; } } void _onDragEnd(ScaleEndDetails details) { if (!_isActive || _animateController.isAnimating) { return; } _dragging = false; if (_animateController.isCompleted) { return; } if (!_animateController.isDismissed) { if (_animateController.value > 0.2) { Get.back(); } else { _animateController.reverse(); } } } @override void dispose() { _player?.dispose(); _player = null; _videoController = null; _pageController.dispose(); _animateController ..removeListener(_updateTransformation) ..dispose(); _tapGestureRecognizer.dispose(); _longPressGestureRecognizer.dispose(); _currIndex.close(); _matrix.close(); if (widget.quality != _quality) { for (final item in widget.sources) { if (item.sourceType == SourceType.networkImage) { CachedNetworkImageProvider(_getActualUrl(item.url)).evict(); } } } super.dispose(); } void _onPointerDown(PointerDownEvent event) { _tapGestureRecognizer.addPointer(event); _longPressGestureRecognizer.addPointer(event); } @override Widget build(BuildContext context) { return Listener( behavior: .opaque, onPointerDown: _onPointerDown, child: DecoratedBoxTransition( decoration: _opacityAnimation, child: Stack( clipBehavior: .none, children: [ LayoutBuilder( builder: (context, constraints) { _containerSize = constraints.biggest; return Obx( () => Transform( transform: _matrix.value, child: PageView.builder( controller: _pageController, onPageChanged: _onPageChanged, physics: const CustomTabBarViewScrollPhysics( parent: AlwaysScrollableScrollPhysics(), ), itemCount: widget.sources.length, itemBuilder: _itemBuilder, horizontalDragGestureRecognizer: () => _horizontalDragGestureRecognizer, ), ), ); }, ), _buildIndicator, ], ), ), ); } Widget get _buildIndicator => Positioned( bottom: 0, left: 0, right: 0, child: IgnorePointer( child: Container( padding: MediaQuery.viewPaddingOf(context) + const EdgeInsets.fromLTRB(12, 8, 20, 8), decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Colors.transparent, Colors.black.withValues(alpha: 0.3), ], ), ), alignment: Alignment.center, child: Obx( () => Text( "${_currIndex.value + 1}/${widget.sources.length}", style: const TextStyle(color: Colors.white), ), ), ), ), ); void _playIfNeeded(int index) { final item = widget.sources[index]; if (item.sourceType == .livePhoto) { _effectivePlayer.open(Media(item.liveUrl!)); } } void _onPageChanged(int index) { _player?.pause(); _playIfNeeded(index); _currIndex.value = index; } late final ValueChanged? _onChangePage = widget.sources.length == 1 ? null : (int offset) { final currPage = _pageController.page?.round() ?? 0; final nextPage = (currPage + offset).clamp( 0, widget.sources.length - 1, ); if (nextPage != currPage) { _pageController.animateToPage( nextPage, duration: const Duration(milliseconds: 200), curve: Curves.ease, ); } }; Widget _itemBuilder(BuildContext context, int index) { final item = widget.sources[index]; return Hero( tag: item.url, child: switch (item.sourceType) { .fileImage => Image.file( key: _keys[index], File(item.url), filterQuality: .low, minScale: widget.minScale, maxScale: widget.maxScale, containerSize: _containerSize, isAnimating: isAnimating, onDragStart: _onDragStart, onDragUpdate: _onDragUpdate, onDragEnd: _onDragEnd, tapGestureRecognizer: _tapGestureRecognizer, horizontalDragGestureRecognizer: _horizontalDragGestureRecognizer, onChangePage: _onChangePage, ), .networkImage => Image( key: _keys[index], image: CachedNetworkImageProvider(_getActualUrl(item.url)), minScale: widget.minScale, maxScale: widget.maxScale, containerSize: _containerSize, tapGestureRecognizer: _tapGestureRecognizer, horizontalDragGestureRecognizer: _horizontalDragGestureRecognizer, onChangePage: _onChangePage, frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { if (wasSynchronouslyLoaded) { return child; } if (frame == null) { if (widget.quality == _quality) { return const SizedBox.expand(); } else { return Image( image: CachedNetworkImageProvider( ImageUtils.thumbnailUrl(item.url, widget.quality), ), minScale: widget.minScale, maxScale: widget.maxScale, containerSize: _containerSize, isAnimating: isAnimating, onDragStart: null, onDragUpdate: null, onDragEnd: null, tapGestureRecognizer: _tapGestureRecognizer, horizontalDragGestureRecognizer: _horizontalDragGestureRecognizer, onChangePage: _onChangePage, ); // final isLongPic = item.isLongPic; // return CachedNetworkImage( // fadeInDuration: Duration.zero, // fadeOutDuration: Duration.zero, // // fit: isLongPic ? .fitWidth : null, // // alignment: isLongPic ? .topCenter : .center, // imageUrl: ImageUtils.thumbnailUrl(item.url, widget.quality), // placeholder: (_, _) => const SizedBox.expand(), // ); } } return child; }, loadingBuilder: loadingBuilder, isAnimating: isAnimating, onDragStart: _onDragStart, onDragUpdate: _onDragUpdate, onDragEnd: _onDragEnd, ), .livePhoto => Obx( key: _keys[index], () => _currIndex.value == index ? Viewer( minScale: widget.minScale, maxScale: widget.maxScale, containerSize: _containerSize, childSize: _containerSize, isAnimating: isAnimating, onDragStart: _onDragStart, onDragUpdate: _onDragUpdate, onDragEnd: _onDragEnd, tapGestureRecognizer: _tapGestureRecognizer, horizontalDragGestureRecognizer: _horizontalDragGestureRecognizer, onChangePage: _onChangePage, child: AbsorbPointer( child: Video( controller: _effectiveVideoController, fill: Colors.transparent, ), ), ) : const SizedBox.shrink(), ), }, ); } void _onTap() { EasyThrottle.throttle( 'VIEWER_TAP', const Duration(milliseconds: 555), Get.back, ); } void _onLongPress() { final item = widget.sources[_currIndex.value]; if (item.sourceType == .fileImage) return; HapticFeedback.mediumImpact(); showDialog( context: context, builder: (context) => AlertDialog( clipBehavior: Clip.hardEdge, contentPadding: const EdgeInsets.symmetric(vertical: 12), content: Column( mainAxisSize: MainAxisSize.min, children: [ if (PlatformUtils.isMobile) ListTile( onTap: () { Get.back(); ImageUtils.onShareImg(item.url); }, dense: true, title: const Text('分享', style: TextStyle(fontSize: 14)), ), ListTile( onTap: () { Get.back(); Utils.copyText(item.url); }, dense: true, title: const Text('复制链接', style: TextStyle(fontSize: 14)), ), ListTile( onTap: () { Get.back(); ImageUtils.downloadImg([item.url]); }, dense: true, title: const Text('保存图片', style: TextStyle(fontSize: 14)), ), if (PlatformUtils.isDesktop) ListTile( onTap: () { Get.back(); PageUtils.launchURL(item.url); }, dense: true, title: const Text('网页打开', style: TextStyle(fontSize: 14)), ) else if (widget.sources.length > 1) ListTile( onTap: () { Get.back(); ImageUtils.downloadImg( widget.sources.map((item) => item.url).toList(), ); }, dense: true, title: const Text('保存全部图片', style: TextStyle(fontSize: 14)), ), if (item.sourceType == SourceType.livePhoto) ListTile( onTap: () { Get.back(); ImageUtils.downloadLivePhoto( url: item.url, liveUrl: item.liveUrl!, width: item.width!, height: item.height!, ); }, dense: true, title: Text( '保存${Platform.isIOS ? ' Live Photo' : '视频'}', style: const TextStyle(fontSize: 14), ), ), ], ), ), ); } void _showDesktopMenu(TapUpDetails details) { final item = widget.sources[_currIndex.value]; if (item.sourceType == .fileImage) return; showMenu( context: context, position: PageUtils.menuPosition(details.globalPosition), items: [ PopupMenuItem( height: 42, onTap: () => Utils.copyText(item.url), child: const Text('复制链接', style: TextStyle(fontSize: 14)), ), PopupMenuItem( height: 42, onTap: () => ImageUtils.downloadImg([item.url]), child: const Text('保存图片', style: TextStyle(fontSize: 14)), ), PopupMenuItem( height: 42, onTap: () => PageUtils.launchURL(item.url), child: const Text('网页打开', style: TextStyle(fontSize: 14)), ), if (item.sourceType == SourceType.livePhoto) PopupMenuItem( height: 42, onTap: () => ImageUtils.downloadLivePhoto( url: item.url, liveUrl: item.liveUrl!, width: item.width!, height: item.height!, ), child: const Text('保存视频', style: TextStyle(fontSize: 14)), ), ], ); } Widget loadingBuilder( BuildContext context, Widget child, ImageChunkEvent? loadingProgress, ) { if (loadingProgress != null) { if (loadingProgress.cumulativeBytesLoaded != loadingProgress.expectedTotalBytes && loadingProgress.expectedTotalBytes != null) { return Stack( fit: .expand, alignment: .center, clipBehavior: .none, children: [ child, Center( child: LoadingIndicator( size: 39.4, progress: loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes!, ), ), ], ); } } return child; } }