mirror of
https://github.com/bggRGjQaUbCoE/PiliPlus.git
synced 2026-05-30 23:58:13 +08:00
599
lib/common/widgets/image_viewer/gallery_viewer.dart
Normal file
599
lib/common/widgets/image_viewer/gallery_viewer.dart
Normal file
@@ -0,0 +1,599 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<SourceModel> sources;
|
||||
final int initIndex;
|
||||
|
||||
@override
|
||||
State<GalleryViewer> createState() => _GalleryViewerState();
|
||||
}
|
||||
|
||||
class _GalleryViewerState extends State<GalleryViewer>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late Size _containerSize;
|
||||
late final int _quality;
|
||||
late final RxInt _currIndex;
|
||||
late final List<GlobalKey> _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<Matrix4> _matrix = Rx(Matrix4.identity());
|
||||
late final AnimationController _animateController;
|
||||
late final Animation<Decoration> _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<ImageHorizontalDragGestureRecognizer>.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<int>? _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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user