From f5d7dc6b6a4db02518bd881f4c3d3b4a6982995a Mon Sep 17 00:00:00 2001 From: bggRGjQaUbCoE Date: Tue, 28 Jan 2025 14:19:59 +0800 Subject: [PATCH] feat: live photo Signed-off-by: bggRGjQaUbCoE --- lib/common/widgets/article_content.dart | 5 +- lib/common/widgets/html_render.dart | 4 +- lib/common/widgets/imageview.dart | 30 ++- .../interactiveviewer_gallery.dart | 181 ++++++++++++------ lib/models/dynamics/result.dart | 2 + lib/pages/bangumi/introduction/view.dart | 10 +- lib/pages/common/common_publish_page.dart | 10 +- lib/pages/dynamics/widgets/content_panel.dart | 1 + lib/pages/dynamics/widgets/forward_panel.dart | 1 + lib/pages/dynamics/widgets/pic_panel.dart | 1 + .../member/new/widget/user_info_card.dart | 6 +- lib/pages/setting/widgets/model.dart | 8 + lib/utils/download.dart | 33 ++++ lib/utils/extension.dart | 4 +- lib/utils/storage.dart | 4 + 15 files changed, 227 insertions(+), 73 deletions(-) diff --git a/lib/common/widgets/article_content.dart b/lib/common/widgets/article_content.dart index aa24b744..45d28afa 100644 --- a/lib/common/widgets/article_content.dart +++ b/lib/common/widgets/article_content.dart @@ -1,3 +1,5 @@ +import 'package:PiliPlus/common/widgets/interactiveviewer_gallery/interactiveviewer_gallery.dart' + show SourceModel; import 'package:PiliPlus/common/widgets/network_img_layer.dart'; import 'package:PiliPlus/models/dynamics/article_content_model.dart'; import 'package:PiliPlus/utils/extension.dart'; @@ -68,7 +70,8 @@ Widget articleContent({ } else { context.imageView( initialPage: imgList.indexOf(item.pic!.pics!.first.url!), - imgList: imgList, + imgList: + imgList.map((url) => SourceModel(url: url)).toList(), ); } }, diff --git a/lib/common/widgets/html_render.dart b/lib/common/widgets/html_render.dart index fc555301..60aeffb4 100644 --- a/lib/common/widgets/html_render.dart +++ b/lib/common/widgets/html_render.dart @@ -1,3 +1,5 @@ +import 'package:PiliPlus/common/widgets/interactiveviewer_gallery/interactiveviewer_gallery.dart' + show SourceModel; import 'package:PiliPlus/utils/extension.dart'; import 'package:flutter/material.dart'; import 'package:flutter_html/flutter_html.dart'; @@ -54,7 +56,7 @@ Widget htmlRender({ callback([imgUrl], 0); } else { context.imageView( - imgList: [imgUrl], + imgList: [SourceModel(url: imgUrl)], ); } }, diff --git a/lib/common/widgets/imageview.dart b/lib/common/widgets/imageview.dart index 6857e700..be2e9a99 100644 --- a/lib/common/widgets/imageview.dart +++ b/lib/common/widgets/imageview.dart @@ -2,9 +2,12 @@ import 'dart:math'; import 'package:PiliPlus/common/constants.dart'; import 'package:PiliPlus/common/widgets/badge.dart'; +import 'package:PiliPlus/common/widgets/interactiveviewer_gallery/interactiveviewer_gallery.dart' + show SourceModel, SourceType; import 'package:PiliPlus/common/widgets/network_img_layer.dart'; import 'package:PiliPlus/common/widgets/nine_grid_view.dart'; import 'package:PiliPlus/utils/extension.dart'; +import 'package:PiliPlus/utils/storage.dart'; import 'package:flutter/material.dart'; class ImageModel { @@ -12,16 +15,20 @@ class ImageModel { required this.width, required this.height, required this.url, + this.liveUrl, }); dynamic width; dynamic height; String url; + String? liveUrl; bool? _isLongPic; + bool? _isLivePhoto; dynamic get safeWidth => width ?? 1; dynamic get safeHeight => height ?? 1; bool get isLongPic => _isLongPic ??= (safeHeight / safeWidth) > (22 / 9); + bool get isLivePhoto => _isLivePhoto ??= liveUrl?.isNotEmpty == true; } Widget imageview( @@ -83,6 +90,8 @@ Widget imageview( ); } + late final enableLivePhoto = GStorage.enableLivePhoto; + return NineGridView( type: NineGridType.weiBo, margin: const EdgeInsets.only(top: 6), @@ -102,7 +111,17 @@ Widget imageview( onViewImage?.call(); context.imageView( initialPage: index, - imgList: picArr.map((item) => item.url).toList(), + imgList: picArr + .map( + (item) => SourceModel( + sourceType: item.isLivePhoto && enableLivePhoto + ? SourceType.livePhoto + : SourceType.networkImage, + url: item.url, + liveUrl: item.liveUrl, + ), + ) + .toList(), onDismissed: onDismissed, ); } @@ -143,7 +162,14 @@ Widget imageview( }, ), ), - if (picArr[index].isLongPic) + if (picArr[index].liveUrl?.isNotEmpty == true) + const PBadge( + text: 'Live', + right: 8, + bottom: 8, + type: 'gray', + ) + else if (picArr[index].isLongPic) const PBadge( text: '长图', right: 8, diff --git a/lib/common/widgets/interactiveviewer_gallery/interactiveviewer_gallery.dart b/lib/common/widgets/interactiveviewer_gallery/interactiveviewer_gallery.dart index bf6cb61f..af84ad9a 100644 --- a/lib/common/widgets/interactiveviewer_gallery/interactiveviewer_gallery.dart +++ b/lib/common/widgets/interactiveviewer_gallery/interactiveviewer_gallery.dart @@ -10,6 +10,8 @@ import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:media_kit_video/media_kit_video.dart'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; import 'package:status_bar_control/status_bar_control.dart'; @@ -33,6 +35,20 @@ typedef IndexedFocusedWidgetBuilder = Widget Function( typedef IndexedTagStringBuilder = String Function(int index); +enum SourceType { fileImage, networkImage, livePhoto } + +class SourceModel { + final SourceType sourceType; + final String url; + final String? liveUrl; + + const SourceModel({ + this.sourceType = SourceType.networkImage, + required this.url, + this.liveUrl, + }); +} + class InteractiveviewerGallery extends StatefulWidget { const InteractiveviewerGallery({ super.key, @@ -45,17 +61,14 @@ class InteractiveviewerGallery extends StatefulWidget { this.onDismissed, this.setStatusBar, this.onClose, - this.isFile, }); - final bool? isFile; - final VoidCallback? onClose; final bool? setStatusBar; /// The sources to show. - final List sources; + final List sources; /// The index of the first source in [sources] to show. final int initIndex; @@ -92,7 +105,7 @@ class _InteractiveviewerGalleryState extends State late Offset _doubleTapLocalPosition; - int? currentIndex; + late final RxInt currentIndex = widget.initIndex.obs; late List _thumbList; late final int _quality = GStorage.previewQ; @@ -115,10 +128,13 @@ class _InteractiveviewerGalleryState extends State _animation?.value ?? Matrix4.identity(); }); - currentIndex = widget.initIndex; if (widget.setStatusBar != false) { setStatusBar(); } + + if (widget.sources[currentIndex.value].sourceType == SourceType.livePhoto) { + _onPlay(currentIndex.value); + } } setStatusBar() async { @@ -132,6 +148,7 @@ class _InteractiveviewerGalleryState extends State @override void dispose() async { + _player?.dispose(); _pageController?.dispose(); _animationController.removeListener(() {}); _animationController.dispose(); @@ -140,8 +157,8 @@ class _InteractiveviewerGalleryState extends State StatusBarControl.setHidden(false, animation: StatusBarAnimation.FADE); } } - if (widget.isFile != true) { - for (int index = 0; index < widget.sources.length; index++) { + for (int index = 0; index < widget.sources.length; index++) { + if (widget.sources[index].sourceType == SourceType.networkImage) { CachedNetworkImageProvider(_getActualUrl(index)).evict(); } } @@ -201,14 +218,22 @@ class _InteractiveviewerGalleryState extends State } } + void _onPlay(int index) { + _player ??= Player(); + _videoController ??= VideoController(_player!); + _player!.open(Media(widget.sources[index].liveUrl!)); + } + /// When the page view changed its page, the source will animate back into the /// original scale if it was scaled up. /// /// Additionally the swipe up / down to dismiss gets enabled. void _onPageChanged(int page) { - setState(() { - currentIndex = page; - }); + _player?.pause(); + currentIndex.value = page; + if (widget.sources[page].sourceType == SourceType.livePhoto) { + _onPlay(page); + } widget.onPageChanged?.call(page); if (_transformationController!.value != Matrix4.identity()) { // animate the reset for the transformation of the interactive viewer @@ -226,7 +251,7 @@ class _InteractiveviewerGalleryState extends State String _getActualUrl(int index) => _thumbList[index] && _quality != 100 ? '${widget.sources[index]}@${_quality}q.webp'.http2https - : widget.sources[index].http2https; + : widget.sources[index].url.http2https; void onClose() { if (widget.onClose != null) { @@ -237,6 +262,9 @@ class _InteractiveviewerGalleryState extends State } } + Player? _player; + VideoController? _videoController; + @override Widget build(BuildContext context) { return Stack( @@ -272,12 +300,15 @@ class _InteractiveviewerGalleryState extends State _doubleTapLocalPosition = details.localPosition; }, onDoubleTap: onDoubleTap, - onLongPress: widget.isFile == true ? null : onLongPress, + onLongPress: + widget.sources[index].sourceType == SourceType.fileImage + ? null + : onLongPress, child: widget.itemBuilder != null ? widget.itemBuilder!( context, index, - index == currentIndex, + index == currentIndex.value, _enablePageView, ) : _itemBuilder(index), @@ -321,47 +352,60 @@ class _InteractiveviewerGalleryState extends State if (widget.sources.length > 1) Align( alignment: Alignment.center, - child: Text( - "${currentIndex! + 1}/${widget.sources.length}", - style: const TextStyle(color: Colors.white), + child: Obx( + () => Text( + "${currentIndex.value + 1}/${widget.sources.length}", + style: const TextStyle(color: Colors.white), + ), ), ), - if (widget.isFile != true) + if (widget.sources[currentIndex.value].sourceType != + SourceType.fileImage) Align( alignment: Alignment.centerRight, child: PopupMenuButton( itemBuilder: (context) { return [ PopupMenuItem( - value: 0, - onTap: () => - onShareImg(widget.sources[currentIndex!]), + onTap: () => onShareImg( + widget.sources[currentIndex.value].url), child: const Text("分享图片"), ), PopupMenuItem( - value: 1, onTap: () { - Utils.copyText(widget.sources[currentIndex!]); + Utils.copyText( + widget.sources[currentIndex.value].url); }, child: const Text("复制链接"), ), PopupMenuItem( - value: 2, onTap: () { DownloadUtils.downloadImg( context, - [widget.sources[currentIndex!]], + [widget.sources[currentIndex.value].url], ); }, child: const Text("保存图片"), ), + if (widget.sources[currentIndex.value].sourceType == + SourceType.livePhoto) + PopupMenuItem( + onTap: () { + DownloadUtils.downloadVideo( + context, + widget.sources[currentIndex.value].liveUrl!, + ); + }, + child: const Text("保存 Live"), + ), if (widget.sources.length > 1) PopupMenuItem( - value: 3, onTap: () { DownloadUtils.downloadImg( context, - widget.sources, + widget.sources + .map((item) => item.url) + .toList(), ); }, child: const Text("保存全部图片"), @@ -396,34 +440,37 @@ class _InteractiveviewerGalleryState extends State Widget _itemBuilder(index) { return Center( child: Hero( - tag: widget.sources[index], - child: widget.isFile == true - ? Image( - filterQuality: FilterQuality.low, - image: FileImage(File(widget.sources[index])), - ) - : CachedNetworkImage( - fadeInDuration: const Duration(milliseconds: 0), - fadeOutDuration: const Duration(milliseconds: 0), - imageUrl: _getActualUrl(index), - // fit: BoxFit.contain, - progressIndicatorBuilder: (context, url, progress) { - return Center( - child: SizedBox( - width: 150.0, - child: LinearProgressIndicator( - value: progress.progress ?? 0), - ), - ); - }, - // errorListener: (value) { - // WidgetsBinding.instance.addPostFrameCallback((_) { - // setState(() { - // _thumbList[index] = false; - // }); - // }); - // }, - ), + tag: widget.sources[index].url, + child: switch (widget.sources[index].sourceType) { + SourceType.fileImage => Image( + filterQuality: FilterQuality.low, + image: FileImage(File(widget.sources[index].url)), + ), + SourceType.networkImage => CachedNetworkImage( + fadeInDuration: const Duration(milliseconds: 0), + fadeOutDuration: const Duration(milliseconds: 0), + imageUrl: _getActualUrl(index), + // fit: BoxFit.contain, + progressIndicatorBuilder: (context, url, progress) { + return Center( + child: SizedBox( + width: 150.0, + child: + LinearProgressIndicator(value: progress.progress ?? 0), + ), + ); + }, + // errorListener: (value) { + // WidgetsBinding.instance.addPostFrameCallback((_) { + // setState(() { + // _thumbList[index] = false; + // }); + // }); + // }, + ), + SourceType.livePhoto => + IgnorePointer(child: Video(controller: _videoController!)), + }, ), ); } @@ -487,7 +534,7 @@ class _InteractiveviewerGalleryState extends State children: [ ListTile( onTap: () { - onShareImg(widget.sources[currentIndex!]); + onShareImg(widget.sources[currentIndex.value].url); Get.back(); }, dense: true, @@ -496,7 +543,7 @@ class _InteractiveviewerGalleryState extends State ListTile( onTap: () { Get.back(); - Utils.copyText(widget.sources[currentIndex!]); + Utils.copyText(widget.sources[currentIndex.value].url); }, dense: true, title: const Text('复制链接', style: TextStyle(fontSize: 14)), @@ -506,19 +553,35 @@ class _InteractiveviewerGalleryState extends State Get.back(); DownloadUtils.downloadImg( context, - [widget.sources[currentIndex!]], + [widget.sources[currentIndex.value].url], ); }, dense: true, title: const Text('保存图片', style: TextStyle(fontSize: 14)), ), + if (widget.sources[currentIndex.value].sourceType == + SourceType.livePhoto) + ListTile( + onTap: () { + Get.back(); + DownloadUtils.downloadVideo( + context, + widget.sources[currentIndex.value].liveUrl!, + ); + }, + dense: true, + title: const Text( + '保存 Live', + style: TextStyle(fontSize: 14), + ), + ), if (widget.sources.length > 1) ListTile( onTap: () { Get.back(); DownloadUtils.downloadImg( context, - widget.sources, + widget.sources.map((item) => item.url).toList(), ); }, dense: true, diff --git a/lib/models/dynamics/result.dart b/lib/models/dynamics/result.dart index 796946d2..b578f72f 100644 --- a/lib/models/dynamics/result.dart +++ b/lib/models/dynamics/result.dart @@ -673,6 +673,7 @@ class OpusPicsModel { int? size; String? src; String? url; + String? liveUrl; OpusPicsModel.fromJson(Map json) { width = json['width']; @@ -680,6 +681,7 @@ class OpusPicsModel { size = json['size'] != null ? json['size'].toInt() : 0; src = json['src']; url = json['url']; + liveUrl = json['live_url']; } } diff --git a/lib/pages/bangumi/introduction/view.dart b/lib/pages/bangumi/introduction/view.dart index 44f749de..1a8a6316 100644 --- a/lib/pages/bangumi/introduction/view.dart +++ b/lib/pages/bangumi/introduction/view.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'package:PiliPlus/common/widgets/http_error.dart'; +import 'package:PiliPlus/common/widgets/interactiveviewer_gallery/interactiveviewer_gallery.dart' + show SourceModel; import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/utils/extension.dart'; import 'package:flutter/material.dart'; @@ -224,9 +226,11 @@ class _BangumiInfoState extends State videoDetailCtr.onViewImage(); context.imageView( imgList: [ - !widget.isLoading - ? widget.bangumiDetail!.cover! - : bangumiItem!.cover! + SourceModel( + url: !widget.isLoading + ? widget.bangumiDetail!.cover! + : bangumiItem!.cover!, + ) ], onDismissed: videoDetailCtr.onDismissed, ); diff --git a/lib/pages/common/common_publish_page.dart b/lib/pages/common/common_publish_page.dart index f12d7e44..8329d867 100644 --- a/lib/pages/common/common_publish_page.dart +++ b/lib/pages/common/common_publish_page.dart @@ -4,6 +4,8 @@ import 'dart:math'; import 'package:PiliPlus/common/constants.dart'; import 'package:PiliPlus/common/widgets/icon_button.dart'; +import 'package:PiliPlus/common/widgets/interactiveviewer_gallery/interactiveviewer_gallery.dart' + show SourceModel, SourceType; import 'package:PiliPlus/http/msg.dart'; import 'package:PiliPlus/utils/extension.dart'; import 'package:chat_bottom_container/chat_bottom_container.dart'; @@ -274,8 +276,12 @@ abstract class CommonPublishPageState onTap: () { controller.keepChatPanel(); context.imageView( - isFile: true, - imgList: pathList, + imgList: pathList + .map((path) => SourceModel( + url: path, + sourceType: SourceType.fileImage, + )) + .toList(), initialPage: index, ); }, diff --git a/lib/pages/dynamics/widgets/content_panel.dart b/lib/pages/dynamics/widgets/content_panel.dart index 2f1a3b44..624e6405 100644 --- a/lib/pages/dynamics/widgets/content_panel.dart +++ b/lib/pages/dynamics/widgets/content_panel.dart @@ -16,6 +16,7 @@ Widget content(context, item, source, callback) { width: item.width, height: item.height, url: item.url ?? '', + liveUrl: item.liveUrl, ), ) .toList(), diff --git a/lib/pages/dynamics/widgets/forward_panel.dart b/lib/pages/dynamics/widgets/forward_panel.dart index 41d1a561..b199c314 100644 --- a/lib/pages/dynamics/widgets/forward_panel.dart +++ b/lib/pages/dynamics/widgets/forward_panel.dart @@ -27,6 +27,7 @@ InlineSpan picsNodes(List pics, callback) { width: item.width, height: item.height, url: item.url ?? '', + liveUrl: item.liveUrl, ), ) .toList(), diff --git a/lib/pages/dynamics/widgets/pic_panel.dart b/lib/pages/dynamics/widgets/pic_panel.dart index 4c08e0e8..5015a2df 100644 --- a/lib/pages/dynamics/widgets/pic_panel.dart +++ b/lib/pages/dynamics/widgets/pic_panel.dart @@ -17,6 +17,7 @@ Widget picWidget(item, context, callback) { width: item.width, height: item.height, url: item.url ?? '', + liveUrl: item.liveUrl, ), ) .toList(), diff --git a/lib/pages/member/new/widget/user_info_card.dart b/lib/pages/member/new/widget/user_info_card.dart index 294de005..86bcbf6b 100644 --- a/lib/pages/member/new/widget/user_info_card.dart +++ b/lib/pages/member/new/widget/user_info_card.dart @@ -1,3 +1,5 @@ +import 'package:PiliPlus/common/widgets/interactiveviewer_gallery/interactiveviewer_gallery.dart' + show SourceModel; import 'package:PiliPlus/common/widgets/network_img_layer.dart'; import 'package:PiliPlus/models/dynamics/result.dart'; import 'package:PiliPlus/models/space/card.dart' as space; @@ -81,7 +83,7 @@ class UserInfoCard extends StatelessWidget { child: GestureDetector( onTap: () { context.imageView( - imgList: [imgUrl ?? 'bgTag'], + imgList: [SourceModel(url: imgUrl ?? 'bgTag')], ); }, child: CachedNetworkImage( @@ -458,7 +460,7 @@ class UserInfoCard extends StatelessWidget { child: GestureDetector( onTap: () { context.imageView( - imgList: [card.face ?? 'avatarTag'], + imgList: [SourceModel(url: card.face ?? 'avatarTag')], ); }, child: NetworkImgLayer( diff --git a/lib/pages/setting/widgets/model.dart b/lib/pages/setting/widgets/model.dart index 0249ac1c..9ee221bd 100644 --- a/lib/pages/setting/widgets/model.dart +++ b/lib/pages/setting/widgets/model.dart @@ -1914,6 +1914,14 @@ List get extraSettings => [ defaultVal: true, onChanged: (value) => ModuleAuthorModel.showDynDecorate = value, ), + SettingsModel( + settingsType: SettingsType.sw1tch, + title: '预览 Live Photo', + subtitle: '开启则以视频形式预览Live Photo,否则预览静态图片', + leading: Icon(Icons.image_outlined), + setKey: SettingBoxKey.enableLivePhoto, + defaultVal: true, + ), SettingsModel( settingsType: SettingsType.sw1tch, enableFeedback: true, diff --git a/lib/utils/download.dart b/lib/utils/download.dart index 0e2de3bb..14cbf99e 100644 --- a/lib/utils/download.dart +++ b/lib/utils/download.dart @@ -6,6 +6,7 @@ import 'package:device_info_plus/device_info_plus.dart'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:saver_gallery/saver_gallery.dart'; import 'dart:io'; @@ -85,6 +86,38 @@ class DownloadUtils { return await requestStoragePer(context); } + static Future downloadVideo(BuildContext context, String url) async { + try { + if (!await checkPermissionDependOnSdkInt(context)) { + return; + } + SmartDialog.showLoading(msg: '正在下载'); + String videoName = + "video_${DateTime.now().toString().replaceAll(' ', '_').replaceAll(':', '-').split('.').first}.${url.split('.').lastOrNull ?? 'mp4'}"; + String savePath = '${(await getTemporaryDirectory()).path}/$videoName'; + await Request.dio.download(url, savePath); + SmartDialog.showLoading(msg: '正在保存'); + final SaveResult result = await SaverGallery.saveFile( + filePath: savePath, + fileName: videoName, + androidRelativePath: "Pictures/PiliPlus", + skipIfExists: false, + ); + SmartDialog.dismiss(); + if (result.isSuccess) { + SmartDialog.showToast('「$videoName」已保存 '); + } else { + SmartDialog.showToast('保存失败,${result.errorMessage}'); + } + + return true; + } catch (err) { + SmartDialog.dismiss(); + SmartDialog.showToast(err.toString()); + return false; + } + } + static Future downloadImg( BuildContext context, List imgList, { diff --git a/lib/utils/extension.dart b/lib/utils/extension.dart index 93c3647f..24fd90cf 100644 --- a/lib/utils/extension.dart +++ b/lib/utils/extension.dart @@ -73,15 +73,13 @@ extension BuildContextExt on BuildContext { void imageView({ int? initialPage, - required List imgList, + required List imgList, ValueChanged? onDismissed, - bool? isFile, }) { Navigator.of(this).push( HeroDialogRoute( builder: (context) => InteractiveviewerGallery( sources: imgList, - isFile: isFile, initIndex: initialPage ?? 0, onPageChanged: (int pageIndex) {}, onDismissed: onDismissed, diff --git a/lib/utils/storage.dart b/lib/utils/storage.dart index b2fa2905..d7437f9a 100644 --- a/lib/utils/storage.dart +++ b/lib/utils/storage.dart @@ -354,6 +354,9 @@ class GStorage { static bool get showDynDecorate => GStorage.setting.get(SettingBoxKey.showDynDecorate, defaultValue: true); + static bool get enableLivePhoto => + GStorage.setting.get(SettingBoxKey.enableLivePhoto, defaultValue: true); + static List get dynamicDetailRatio => List.from(setting .get(SettingBoxKey.dynamicDetailRatio, defaultValue: [60.0, 40.0])); @@ -581,6 +584,7 @@ class SettingBoxKey { mainTabBarView = 'mainTabBarView', searchSuggestion = 'searchSuggestion', showDynDecorate = 'showDynDecorate', + enableLivePhoto = 'enableLivePhoto', // Sponsor Block enableSponsorBlock = 'enableSponsorBlock',