diff --git a/lib/components/audio_player.dart b/lib/components/audio_player.dart index 940fa0f8..2abf37f5 100644 --- a/lib/components/audio_player.dart +++ b/lib/components/audio_player.dart @@ -11,6 +11,7 @@ import 'package:universal_html/prefer_universal/html.dart' as html; import 'dialogs/simple_dialogs.dart'; import '../utils/ui_fake.dart' if (dart.library.html) 'dart:ui' as ui; import 'matrix.dart'; +import '../utils/event_extension.dart'; class AudioPlayer extends StatefulWidget { final Color color; @@ -67,8 +68,8 @@ class _AudioPlayerState extends State { Future _downloadAction() async { if (status != AudioPlayerStatus.NOT_DOWNLOADED) return; setState(() => status = AudioPlayerStatus.DOWNLOADING); - final matrixFile = await SimpleDialogs(context) - .tryRequestWithErrorToast(widget.event.downloadAndDecryptAttachment()); + final matrixFile = await SimpleDialogs(context).tryRequestWithErrorToast( + widget.event.downloadAndDecryptAttachmentCached()); setState(() { audioFile = matrixFile.bytes; status = AudioPlayerStatus.DOWNLOADED; diff --git a/lib/components/image_bubble.dart b/lib/components/image_bubble.dart index c3925619..fe426828 100644 --- a/lib/components/image_bubble.dart +++ b/lib/components/image_bubble.dart @@ -5,6 +5,9 @@ import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_blurhash/flutter_blurhash.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +import '../utils/event_extension.dart'; class ImageBubble extends StatefulWidget { final Event event; @@ -14,6 +17,7 @@ class ImageBubble extends StatefulWidget { final Color backgroundColor; final double radius; final bool thumbnailOnly; + final void Function() onLoaded; const ImageBubble( this.event, { @@ -23,6 +27,7 @@ class ImageBubble extends StatefulWidget { this.fit = BoxFit.cover, this.radius = 10.0, this.thumbnailOnly = true, + this.onLoaded, Key key, }) : super(key: key); @@ -31,132 +36,230 @@ class ImageBubble extends StatefulWidget { } class _ImageBubbleState extends State { - bool get isUnencrypted => widget.event.content['url'] is String; + String thumbnailUrl; + String attachmentUrl; + MatrixFile _file; + MatrixFile _thumbnail; + bool _requestedThumbnailOnFailure = false; - static final Map _matrixFileMap = {}; - MatrixFile get _file => _matrixFileMap[widget.event.eventId]; - set _file(MatrixFile file) { - if (file != null) { - _matrixFileMap[widget.event.eventId] = file; - } - } + bool get isSvg => + widget.event.attachmentMimetype.split('+').first == 'image/svg'; + bool get isThumbnailSvg => + widget.event.thumbnailMimetype.split('+').first == 'image/svg'; - static final Map _matrixThumbnailMap = {}; - MatrixFile get _thumbnail => _matrixThumbnailMap[widget.event.eventId]; - set _thumbnail(MatrixFile thumbnail) { - if (thumbnail != null) { - _matrixThumbnailMap[widget.event.eventId] = thumbnail; - } - } + MatrixFile get _displayFile => _file ?? _thumbnail; + String get displayUrl => widget.thumbnailOnly ? thumbnailUrl : attachmentUrl; dynamic _error; - bool _requestedFile = false; - Future _getFile() async { - _requestedFile = true; - if (widget.thumbnailOnly) return null; - if (_file != null) return _file; - return widget.event.downloadAndDecryptAttachment(); + Future _requestFile({bool getThumbnail = false}) async { + try { + final res = await widget.event + .downloadAndDecryptAttachmentCached(getThumbnail: getThumbnail); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (getThumbnail) { + if (mounted) { + setState(() => _thumbnail = res); + } + } else { + if (widget.onLoaded != null) { + widget.onLoaded(); + } + if (mounted) { + setState(() => _file = res); + } + } + }); + } catch (err) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() => _error = err); + } + }); + } } - bool _requestedThumbnail = false; - Future _getThumbnail() async { - _requestedThumbnail = true; - if (isUnencrypted) return null; - if (_thumbnail != null) return _thumbnail; - return widget.event - .downloadAndDecryptAttachment(getThumbnail: widget.event.hasThumbnail); + @override + void initState() { + thumbnailUrl = widget.event.getAttachmentUrl(getThumbnail: true); + attachmentUrl = widget.event.getAttachmentUrl(); + if (thumbnailUrl == null) { + _requestFile(getThumbnail: true); + } + if (!widget.thumbnailOnly && attachmentUrl == null) { + _requestFile(); + } else { + // if the full attachment is cached, we might as well fetch it anyways. + // no need to stick with thumbnail only, since we don't do any networking + widget.event.isAttachmentCached().then((cached) { + if (cached) { + _requestFile(); + } + }); + } + super.initState(); + } + + Widget getErrorWidget() { + return Center( + child: Text( + _error.toString(), + ), + ); + } + + Widget getPlaceholderWidget() { + Widget blurhash; + if (widget.event.infoMap['xyz.amorgan.blurhash'] is String) { + final ratio = + widget.event.infoMap['w'] is int && widget.event.infoMap['h'] is int + ? widget.event.infoMap['w'] / widget.event.infoMap['h'] + : 1.0; + var width = 32; + var height = 32; + if (ratio > 1.0) { + height = (width / ratio).round(); + } else { + width = (height * ratio).round(); + } + blurhash = BlurHash( + hash: widget.event.infoMap['xyz.amorgan.blurhash'], + decodingWidth: width, + decodingHeight: height, + imageFit: widget.fit, + ); + } + return Stack( + children: [ + if (blurhash != null) blurhash, + Center( + child: CircularProgressIndicator(), + ), + ], + ); + } + + Widget getMemoryWidget() { + final isOriginal = _file != null || + widget.event.attachmentOrThumbnailMxcUrl(getThumbnail: true) == + widget.event.attachmentMxcUrl; + final key = isOriginal + ? widget.event.attachmentMxcUrl + : widget.event.thumbnailMxcUrl; + if (isOriginal ? isSvg : isThumbnailSvg) { + return SvgPicture.memory( + _displayFile.bytes, + key: ValueKey(key), + fit: widget.fit, + ); + } else { + return Image.memory( + _displayFile.bytes, + key: ValueKey(key), + fit: widget.fit, + ); + } + } + + Widget getNetworkWidget() { + if (displayUrl == attachmentUrl && + (_requestedThumbnailOnFailure ? isSvg : isThumbnailSvg)) { + return SvgPicture.network( + displayUrl, + key: ValueKey(displayUrl), + placeholderBuilder: (context) => getPlaceholderWidget(), + fit: widget.fit, + ); + } else { + return CachedNetworkImage( + // as we change the url on-error we need a key so that the widget actually updates + key: ValueKey(displayUrl), + imageUrl: displayUrl, + placeholder: (context, url) { + if (!widget.thumbnailOnly && + displayUrl != thumbnailUrl && + displayUrl == attachmentUrl) { + // we have to display the thumbnail while loading + return CachedNetworkImage( + key: ValueKey(thumbnailUrl), + imageUrl: thumbnailUrl, + placeholder: (c, u) => getPlaceholderWidget(), + fit: widget.fit, + ); + } + return getPlaceholderWidget(); + }, + errorWidget: (context, url, error) { + // we can re-request the thumbnail + if (!_requestedThumbnailOnFailure) { + _requestedThumbnailOnFailure = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + setState(() { + thumbnailUrl = widget.event.getAttachmentUrl( + getThumbnail: true, useThumbnailMxcUrl: true); + attachmentUrl = + widget.event.getAttachmentUrl(useThumbnailMxcUrl: true); + }); + }); + } + return getPlaceholderWidget(); + }, + fit: widget.fit, + ); + } } @override Widget build(BuildContext context) { + Widget content; + String key; + if (_error != null) { + content = getErrorWidget(); + key = 'error'; + } else if (_displayFile != null) { + content = getMemoryWidget(); + key = 'memory-' + (content.key as ValueKey).value; + } else if (displayUrl != null) { + content = getNetworkWidget(); + key = 'network-' + (content.key as ValueKey).value; + } else { + content = getPlaceholderWidget(); + key = 'placeholder'; + } return ClipRRect( borderRadius: BorderRadius.circular(widget.radius), - child: Container( - height: widget.maxSize ? 300 : null, - width: widget.maxSize ? 400 : null, - child: Builder( - builder: (BuildContext context) { - if (_error != null) { - return Center( - child: Text( - _error.toString(), - ), - ); - } - if (_thumbnail == null && !_requestedThumbnail && !isUnencrypted) { - _getThumbnail().then((MatrixFile thumbnail) { - if (mounted) { - setState(() => _thumbnail = thumbnail); + child: InkWell( + onTap: () { + if (!widget.tapToView) return; + Navigator.of(context).push( + AppRoute( + ImageView(widget.event, onLoaded: () { + // If the original file didn't load yet, we want to do that now. + // This is so that the original file displays after going on the image viewer, + // waiting for it to load, and then hitting back. This ensures that we always + // display the best image available, with requiring as little network as possible + if (_file == null) { + widget.event.isAttachmentCached().then((cached) { + if (cached) { + _requestFile(); + } + }); } - }, onError: (error, stacktrace) { - if (mounted) { - setState(() => _error = error); - } - }); - } - if (_file == null && !widget.thumbnailOnly && !_requestedFile) { - _getFile().then((MatrixFile file) { - if (mounted) { - setState(() => _file = file); - } - }, onError: (error, stacktrace) { - if (mounted) { - setState(() => _error = error); - } - }); - } - final display = _file ?? _thumbnail; - - final generatePlaceholderWidget = () => Stack( - children: [ - if (widget.event.content['info'] is Map && - widget.event.content['info']['xyz.amorgan.blurhash'] - is String) - BlurHash( - hash: widget.event.content['info'] - ['xyz.amorgan.blurhash']), - Center( - child: CircularProgressIndicator(), - ), - ], - ); - - Widget renderWidget; - if (display != null) { - renderWidget = Image.memory( - display.bytes, - fit: widget.fit, - ); - } else if (isUnencrypted) { - final src = Uri.parse(widget.event.content['url']).getThumbnail( - widget.event.room.client, - width: 800, - height: 800, - method: ThumbnailMethod.scale); - renderWidget = CachedNetworkImage( - imageUrl: src, - placeholder: (context, url) => generatePlaceholderWidget(), - fit: widget.fit, - ); - } else { - renderWidget = generatePlaceholderWidget(); - } - return InkWell( - onTap: () { - if (!widget.tapToView) return; - Navigator.of(context).push( - AppRoute( - ImageView(widget.event), - ), - ); - }, - child: Hero( - tag: widget.event.eventId, - child: renderWidget, - ), - ); - }, + }), + ), + ); + }, + child: Hero( + tag: widget.event.eventId, + child: AnimatedSwitcher( + duration: Duration(milliseconds: 1000), + child: Container( + key: ValueKey(key), + height: widget.maxSize ? 300 : null, + width: widget.maxSize ? 400 : null, + child: content, + ), + ), ), ), ); diff --git a/lib/utils/event_extension.dart b/lib/utils/event_extension.dart index b7a60b35..afd34868 100644 --- a/lib/utils/event_extension.dart +++ b/lib/utils/event_extension.dart @@ -2,6 +2,7 @@ import 'package:famedlysdk/famedlysdk.dart'; import 'package:fluffychat/components/dialogs/simple_dialogs.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'matrix_file_extension.dart'; import 'app_route.dart'; import '../views/image_view.dart'; @@ -19,7 +20,7 @@ extension LocalizedBody on Event { } final MatrixFile matrixFile = await SimpleDialogs(context).tryRequestWithLoadingDialog( - downloadAndDecryptAttachment(), + downloadAndDecryptAttachmentCached(), ); matrixFile.open(); } @@ -39,17 +40,18 @@ extension LocalizedBody on Event { } } + bool get isAttachmentSmallEnough => + infoMap['size'] is int && + infoMap['size'] < room.client.database.maxFileSize; + bool get isThumbnailSmallEnough => + thumbnailInfoMap['size'] is int && + thumbnailInfoMap['size'] < room.client.database.maxFileSize; + bool get showThumbnail => [MessageTypes.Image, MessageTypes.Sticker].contains(messageType) && (kIsWeb || - (content['info'] is Map && - content['info']['size'] is int && - content['info']['size'] < room.client.database.maxFileSize) || - (hasThumbnail && - content['info']['thumbnail_info'] is Map && - content['info']['thumbnail_info']['size'] is int && - content['info']['thumbnail_info']['size'] < - room.client.database.maxFileSize) || + isAttachmentSmallEnough || + isThumbnailSmallEnough || (content['url'] is String)); String get sizeString { @@ -73,4 +75,36 @@ extension LocalizedBody on Event { return null; } } + + static final _downloadAndDecryptFutures = >{}; + + Future isAttachmentCached({bool getThumbnail = false}) async { + final mxcUrl = attachmentOrThumbnailMxcUrl(getThumbnail: getThumbnail); + // check if we have it in-memory + if (_downloadAndDecryptFutures.containsKey(mxcUrl)) { + return true; + } + // check if it is stored + if (await isAttachmentInLocalStore(getThumbnail: getThumbnail)) { + return true; + } + // check if the url is cached + final url = Uri.parse(mxcUrl).getDownloadLink(room.client); + final file = await DefaultCacheManager().getFileFromCache(url); + return file != null; + } + + Future downloadAndDecryptAttachmentCached( + {bool getThumbnail = false}) async { + final mxcUrl = attachmentOrThumbnailMxcUrl(getThumbnail: getThumbnail); + _downloadAndDecryptFutures[mxcUrl] ??= downloadAndDecryptAttachment( + getThumbnail: getThumbnail, + downloadCallback: (String url) async { + final file = await DefaultCacheManager().getSingleFile(url); + return await file.readAsBytes(); + }, + ); + final res = await _downloadAndDecryptFutures[mxcUrl]; + return res; + } } diff --git a/lib/views/image_view.dart b/lib/views/image_view.dart index 72d2fbfb..50398a64 100644 --- a/lib/views/image_view.dart +++ b/lib/views/image_view.dart @@ -7,8 +7,9 @@ import '../utils/event_extension.dart'; class ImageView extends StatelessWidget { final Event event; + final void Function() onLoaded; - const ImageView(this.event, {Key key}) : super(key: key); + const ImageView(this.event, {Key key, this.onLoaded}) : super(key: key); void _forwardAction(BuildContext context) async { Matrix.of(context).shareContent = event.content; @@ -47,6 +48,7 @@ class ImageView extends StatelessWidget { child: ImageBubble( event, tapToView: false, + onLoaded: onLoaded, fit: BoxFit.contain, backgroundColor: Colors.black, maxSize: false, diff --git a/pubspec.lock b/pubspec.lock index 95a75c32..81d2c44e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -203,7 +203,7 @@ packages: name: encrypt url: "https://pub.dartlang.org" source: hosted - version: "4.0.3" + version: "4.0.2" fake_async: dependency: transitive description: @@ -215,8 +215,8 @@ packages: dependency: "direct main" description: path: "." - ref: c8d5bbfd144fd4ed36ebb12abc83e7676b9e45b0 - resolved-ref: c8d5bbfd144fd4ed36ebb12abc83e7676b9e45b0 + ref: b1709ca8c3409f58ed711cff3fbe329e9b3c3a9c + resolved-ref: b1709ca8c3409f58ed711cff3fbe329e9b3c3a9c url: "https://gitlab.com/famedly/famedlysdk.git" source: git version: "0.0.1" @@ -317,7 +317,7 @@ packages: source: hosted version: "0.5.0" flutter_cache_manager: - dependency: transitive + dependency: "direct main" description: name: flutter_cache_manager url: "https://pub.dartlang.org" @@ -405,6 +405,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.1" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + url: "https://pub.dartlang.org" + source: hosted + version: "0.19.1" flutter_test: dependency: "direct dev" description: flutter @@ -676,6 +683,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.8.0-nullsafety.1" + path_drawing: + dependency: transitive + description: + name: path_drawing + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.1+1" + path_parsing: + dependency: transitive + description: + name: path_parsing + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.4" path_provider: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 47dd9969..c812cf1a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,7 +23,7 @@ dependencies: famedlysdk: git: url: https://gitlab.com/famedly/famedlysdk.git - ref: c8d5bbfd144fd4ed36ebb12abc83e7676b9e45b0 + ref: b1709ca8c3409f58ed711cff3fbe329e9b3c3a9c localstorage: ^3.0.3+6 file_picker_cross: 4.2.2 @@ -71,6 +71,8 @@ dependencies: sentry: ">=3.0.0 <4.0.0" scroll_to_index: ^1.0.6 swipe_to_action: ^0.1.0 + flutter_svg: ^0.19.1 + flutter_cache_manager: ^2.0.0 dev_dependencies: flutter_test: