diff --git a/lib/community/pages/create_post_page.dart b/lib/community/pages/create_post_page.dart index 963514d77..ede3476c5 100644 --- a/lib/community/pages/create_post_page.dart +++ b/lib/community/pages/create_post_page.dart @@ -18,6 +18,8 @@ import 'package:markdown_editor/markdown_editor.dart'; import 'package:thunder/account/models/account.dart'; import 'package:thunder/account/models/draft.dart'; import 'package:thunder/community/bloc/image_bloc.dart'; +import 'package:thunder/core/enums/media_type.dart'; +import 'package:thunder/core/models/media.dart'; import 'package:thunder/post/widgets/post_action_bottom_sheet.dart'; import 'package:thunder/core/auth/bloc/auth_bloc.dart'; import 'package:thunder/core/auth/helpers/fetch_account.dart'; @@ -32,7 +34,7 @@ import 'package:thunder/shared/cross_posts.dart'; import 'package:thunder/shared/full_name_widgets.dart'; import 'package:thunder/shared/input_dialogs.dart'; import 'package:thunder/shared/language_selector.dart'; -import 'package:thunder/shared/link_preview_card.dart'; +import 'package:thunder/shared/media/media_view.dart'; import 'package:thunder/shared/snackbar.dart'; import 'package:thunder/user/utils/restore_user.dart'; import 'package:thunder/user/widgets/user_selector.dart'; @@ -538,23 +540,22 @@ class _CreatePostPageState extends State { SizedBox(height: url.isNotEmpty ? 10 : 5), Visibility( visible: url.isNotEmpty, - child: LinkPreviewCard( - hideNsfw: false, - scrapeMissingPreviews: false, - originURL: url, - mediaURL: isImageUrl(url) - ? url - : customThumbnail?.isNotEmpty == true && isImageUrl(customThumbnail!) - ? customThumbnail - : null, - mediaHeight: null, - mediaWidth: null, + child: MediaView( showFullHeightImages: false, edgeToEdgeImages: false, viewMode: ViewMode.comfortable, - postId: null, markPostReadOnMediaView: false, isUserLoggedIn: true, + media: Media( + originalUrl: url, + mediaUrl: isImageUrl(url) + ? url + : customThumbnail?.isNotEmpty == true && isImageUrl(customThumbnail!) + ? customThumbnail + : null, + nsfw: isNSFW, + mediaType: MediaType.link, + ), ), ), if (crossPosts.isNotEmpty && widget.postView == null) const SizedBox(height: 6), diff --git a/lib/community/widgets/post_card_view_comfortable.dart b/lib/community/widgets/post_card_view_comfortable.dart index ac9a8b601..cc271c23b 100644 --- a/lib/community/widgets/post_card_view_comfortable.dart +++ b/lib/community/widgets/post_card_view_comfortable.dart @@ -90,8 +90,7 @@ class PostCardViewComfortable extends StatelessWidget { final Color? readColor = indicateRead && postViewMedia.postView.read ? theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.45) : theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.90); Widget mediaView = MediaView( - scrapeMissingPreviews: state.scrapeMissingPreviews, - postViewMedia: postViewMedia, + media: postViewMedia.media.first, showFullHeightImages: showFullHeightImages, hideNsfwPreviews: hideNsfwPreviews, hideThumbnails: hideThumbnails, diff --git a/lib/community/widgets/post_card_view_compact.dart b/lib/community/widgets/post_card_view_compact.dart index bc136ee39..5fc7552c4 100644 --- a/lib/community/widgets/post_card_view_compact.dart +++ b/lib/community/widgets/post_card_view_compact.dart @@ -238,8 +238,7 @@ class ThumbnailPreview extends StatelessWidget { Padding( padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 4), child: MediaView( - scrapeMissingPreviews: state.scrapeMissingPreviews, - postViewMedia: postViewMedia, + media: postViewMedia.media.first, showFullHeightImages: false, hideNsfwPreviews: hideNsfwPreviews, markPostReadOnMediaView: markPostReadOnMediaView, diff --git a/lib/core/models/media.dart b/lib/core/models/media.dart index edbdd3951..aff6ab32e 100644 --- a/lib/core/models/media.dart +++ b/lib/core/models/media.dart @@ -9,6 +9,7 @@ class Media { this.originalUrl, this.width, this.height, + this.nsfw = false, required this.mediaType, }); @@ -27,6 +28,9 @@ class Media { /// The height of the media source double? height; + /// Indicates whether the media is NSFW + bool nsfw; + /// Indicates the type of media it holds MediaType mediaType; diff --git a/lib/post/utils/post.dart b/lib/post/utils/post.dart index 3d07a3fa9..5ccbece59 100644 --- a/lib/post/utils/post.dart +++ b/lib/post/utils/post.dart @@ -357,7 +357,13 @@ Future parsePostView(PostView postView, bool fetchImageDimensions mediaType = MediaType.text; } - Media media = Media(mediaType: mediaType, originalUrl: url); + Media media = Media(mediaType: mediaType, originalUrl: url, nsfw: postView.post.nsfw); + + if (media.mediaType == MediaType.text) { + media.altText = postView.post.body; + } else if (media.mediaType == MediaType.image) { + media.altText = postView.post.altText; + } // Determine the thumbnail url if (thumbnailUrl != null && thumbnailUrl.isNotEmpty) { @@ -382,34 +388,32 @@ Future parsePostView(PostView postView, bool fetchImageDimensions media.mediaUrl = videoUrl; } - if (fetchImageDimensions && media.thumbnailUrl != null) { - Size result = Size(MediaQuery.of(GlobalContext.context).size.width, 200); - - bool useImageMetadata = LemmyClient.instance.supportsFeature(LemmyFeature.imageDimension); + Size result = Size(MediaQuery.of(GlobalContext.context).size.width, 200); + bool useImageMetadata = LemmyClient.instance.supportsFeature(LemmyFeature.imageDimension); - // Finally, check to see if we need to fetch the image dimensions for the thumbnail url - if (useImageMetadata && postView.imageDetails != null) { - debugPrint('Using image metadata for ${media.thumbnailUrl ?? media.mediaUrl}'); - result = Size(postView.imageDetails!.width.toDouble(), postView.imageDetails!.height.toDouble()); - } else { - // If the instance does not contain image metadata, we'll do some additional checks - try { - SharedPreferences prefs = (await UserPreferences.instance).sharedPreferences; - int imageDimensionTimeout = prefs.getInt(LocalSettings.imageDimensionTimeout.name) ?? 2; + // Check to see if there is available image metadata + if (useImageMetadata && postView.imageDetails != null) { + debugPrint('Using image metadata for ${media.thumbnailUrl ?? media.mediaUrl}'); + result = Size(postView.imageDetails!.width.toDouble(), postView.imageDetails!.height.toDouble()); + } - result = await retrieveImageDimensions(imageUrl: media.thumbnailUrl ?? media.mediaUrl).timeout(Duration(seconds: imageDimensionTimeout)); - } catch (e) { - debugPrint('${media.thumbnailUrl ?? media.originalUrl} - $e: Falling back to default image size'); - } + if (fetchImageDimensions && media.thumbnailUrl != null) { + // If the instance does not contain image metadata, we'll do some additional checks + try { + SharedPreferences prefs = (await UserPreferences.instance).sharedPreferences; + int imageDimensionTimeout = prefs.getInt(LocalSettings.imageDimensionTimeout.name) ?? 2; + + result = await retrieveImageDimensions(imageUrl: media.thumbnailUrl ?? media.mediaUrl).timeout(Duration(seconds: imageDimensionTimeout)); + } catch (e) { + debugPrint('${media.thumbnailUrl ?? media.originalUrl} - $e: Falling back to default image size'); } + } - Size scaledSize = MediaExtension.getScaledMediaSize(width: result.width, height: result.height, offset: edgeToEdgeImages ? 0 : 24, tabletMode: tabletMode); + Size scaledSize = MediaExtension.getScaledMediaSize(width: result.width, height: result.height, offset: edgeToEdgeImages ? 0 : 24, tabletMode: tabletMode); - media.width = scaledSize.width; - media.height = scaledSize.height; - } + media.width = scaledSize.width; + media.height = scaledSize.height; - media.altText = postView.post.altText; mediaList.add(media); return PostViewMedia(postView: postView, media: mediaList); diff --git a/lib/post/widgets/post_view.dart b/lib/post/widgets/post_view.dart index 102db41c6..077d98dd8 100644 --- a/lib/post/widgets/post_view.dart +++ b/lib/post/widgets/post_view.dart @@ -180,13 +180,13 @@ class _PostSubviewState extends State with SingleTickerProviderStat ], ), ), - if (thunderState.postBodyViewType != PostBodyViewType.condensed) + if (thunderState.postBodyViewType != PostBodyViewType.condensed && postViewMedia.media.first.mediaType != MediaType.text) Expandable( controller: expandableController, collapsed: Container(), expanded: MediaView( - scrapeMissingPreviews: scrapeMissingPreviews, - postViewMedia: widget.postViewMedia, + viewMode: ViewMode.comfortable, + media: postViewMedia.media.first, showFullHeightImages: true, allowUnconstrainedImageHeight: true, hideNsfwPreviews: hideNsfwPreviews, @@ -388,8 +388,7 @@ class _PostSubviewState extends State with SingleTickerProviderStat vertical: 4, ), child: MediaView( - scrapeMissingPreviews: thunderState.scrapeMissingPreviews, - postViewMedia: postViewMedia, + media: postViewMedia.media.first, showFullHeightImages: false, hideNsfwPreviews: hideNsfwPreviews, markPostReadOnMediaView: markPostReadOnMediaView, diff --git a/lib/shared/image/image_preview.dart b/lib/shared/image/image_preview.dart index 76da5a337..3747ffcac 100644 --- a/lib/shared/image/image_preview.dart +++ b/lib/shared/image/image_preview.dart @@ -111,11 +111,15 @@ class _ImagePreviewState extends State with SingleTickerProviderSt }, ); - return ImageFiltered( - enabled: widget.blur == true, - imageFilter: ImageFilter.blur(sigmaX: 30, sigmaY: 30), - child: image, - ); + if (widget.blur == true) { + return ImageFiltered( + enabled: true, + imageFilter: ImageFilter.blur(sigmaX: 30, sigmaY: 30), + child: image, + ); + } + + return image; } IconData _getErrorIcon(MediaType? mediaType) { diff --git a/lib/shared/link_preview_card.dart b/lib/shared/link_preview_card.dart deleted file mode 100644 index d43cc58e2..000000000 --- a/lib/shared/link_preview_card.dart +++ /dev/null @@ -1,277 +0,0 @@ -import 'dart:ui'; - -import 'package:flex_color_scheme/flex_color_scheme.dart'; -import 'package:flutter/material.dart'; - -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:link_preview_generator/link_preview_generator.dart'; -import 'package:thunder/feed/bloc/feed_bloc.dart'; -import 'package:thunder/post/enums/post_action.dart'; -import 'package:thunder/shared/link_information.dart'; - -import 'package:thunder/utils/links.dart'; -import 'package:thunder/core/enums/view_mode.dart'; -import 'package:thunder/shared/image_preview.dart'; - -class LinkPreviewCard extends StatelessWidget { - const LinkPreviewCard({ - super.key, - this.originURL, - this.mediaURL, - this.mediaHeight, - this.mediaWidth, - this.scrapeMissingPreviews = false, - this.showFullHeightImages = false, - this.edgeToEdgeImages = false, - this.viewMode = ViewMode.comfortable, - this.postId, - required this.hideNsfw, - required this.isUserLoggedIn, - required this.markPostReadOnMediaView, - this.read, - }); - - final int? postId; - - final String? originURL; - final String? mediaURL; - - final double? mediaHeight; - final double? mediaWidth; - - final bool scrapeMissingPreviews; - final bool showFullHeightImages; - - final bool edgeToEdgeImages; - - final bool markPostReadOnMediaView; - final bool isUserLoggedIn; - - final bool hideNsfw; - - final ViewMode viewMode; - - final bool? read; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - if ((mediaURL != null || originURL != null) && viewMode == ViewMode.comfortable) { - return Semantics( - label: originURL ?? mediaURL, - child: Container( - clipBehavior: Clip.hardEdge, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular((edgeToEdgeImages ? 0 : 12)), - ), - child: Stack( - alignment: Alignment.bottomRight, - fit: StackFit.passthrough, - children: [ - if (mediaURL != null) ...[ - hideNsfw - ? ImageFiltered( - imageFilter: ImageFilter.blur(sigmaX: 30, sigmaY: 30), - child: ImagePreview( - read: read, - url: mediaURL ?? originURL!, - height: showFullHeightImages ? mediaHeight : ViewMode.comfortable.height, - width: mediaWidth ?? MediaQuery.of(context).size.width - (edgeToEdgeImages ? 0 : 24), - edgeToEdgeImages: edgeToEdgeImages, - isExpandable: false, - ), - ) - : ImagePreview( - read: read, - url: mediaURL ?? originURL!, - height: showFullHeightImages ? mediaHeight : ViewMode.comfortable.height, - width: mediaWidth ?? MediaQuery.of(context).size.width - (edgeToEdgeImages ? 0 : 24), - edgeToEdgeImages: edgeToEdgeImages, - isExpandable: false, - ) - ] else if (scrapeMissingPreviews) - SizedBox( - height: ViewMode.comfortable.height, - // This is used for external links when Lemmy does not provide a preview thumbnail - // and when the user has enabled external scraping. - // This is only used in comfortable mode. - child: hideNsfw - ? ImageFiltered( - imageFilter: ImageFilter.blur(sigmaX: 30, sigmaY: 30), - child: LinkPreviewGenerator( - opacity: read == true ? 0.55 : 1, - link: originURL!, - showBody: false, - showTitle: false, - cacheDuration: Duration.zero, - )) - : LinkPreviewGenerator( - opacity: read == true ? 0.55 : 1, - link: originURL!, - showBody: false, - showTitle: false, - cacheDuration: Duration.zero, - ), - ), - if (hideNsfw) - Container( - alignment: Alignment.center, - padding: const EdgeInsets.fromLTRB(20, 20, 20, 50), - child: const Column( - children: [ - Icon(Icons.warning_rounded, size: 55), - Text( - "NSFW - Tap to reveal", - textScaler: TextScaler.linear(1.5), - ), - ], - ), - ), - LinkInformation( - viewMode: viewMode, - originURL: originURL, - showEdgeToEdgeImages: edgeToEdgeImages, - ), - Positioned.fill( - child: Material( - color: Colors.transparent, - child: InkWell( - splashColor: theme.colorScheme.primary.withValues(alpha: 0.4), - onTap: () => triggerOnTap(context), - onLongPress: originURL != null ? () => handleLinkLongPress(context, originURL!, originURL) : null, - borderRadius: BorderRadius.circular((edgeToEdgeImages ? 0 : 12)), - ), - ), - ), - ], - ), - ), - ); - } else if ((mediaURL != null || originURL != null) && viewMode == ViewMode.compact) { - return Container( - clipBehavior: Clip.hardEdge, - decoration: BoxDecoration(borderRadius: BorderRadius.circular(12)), - child: Stack( - alignment: Alignment.center, - fit: StackFit.passthrough, - children: [ - mediaURL != null - ? hideNsfw - ? ImageFiltered( - imageFilter: ImageFilter.blur(sigmaX: 30, sigmaY: 30), - child: ImagePreview( - read: read, - url: mediaURL!, - height: ViewMode.compact.height, - width: ViewMode.compact.height, - isExpandable: false, - ), - ) - : ImagePreview( - read: read, - url: mediaURL!, - height: ViewMode.compact.height, - width: ViewMode.compact.height, - isExpandable: false, - ) - : scrapeMissingPreviews - ? SizedBox( - height: ViewMode.compact.height, - width: ViewMode.compact.height, - // This is used for external links when Lemmy does not provide a preview thumbnail - // and when the user has enabled external scraping. - // This is only used in compact mode. - child: hideNsfw - ? ImageFiltered( - imageFilter: ImageFilter.blur(sigmaX: 30, sigmaY: 30), - child: LinkPreviewGenerator( - opacity: read == true ? 0.55 : 1, - link: originURL!, - showBody: false, - showTitle: false, - cacheDuration: Duration.zero, - )) - : LinkPreviewGenerator( - opacity: read == true ? 0.55 : 1, - link: originURL!, - showBody: false, - showTitle: false, - cacheDuration: Duration.zero, - ), - ) - // This is used for link previews when no thumbnail comes from Lemmy - // and the user has disabled scraping. This is only in compact mode. - : Container( - height: ViewMode.compact.height, - width: ViewMode.compact.height, - color: theme.cardColor.darken(5), - child: Icon( - hideNsfw ? null : Icons.language, - color: theme.colorScheme.onSecondaryContainer.withValues(alpha: read == true ? 0.55 : 1.0), - ), - ), - if (hideNsfw) - Container( - alignment: Alignment.center, - padding: const EdgeInsets.all(20), - child: const Column( - children: [ - Icon(Icons.warning_rounded, size: 30), - ], - ), - ), - Positioned.fill( - child: Material( - color: Colors.transparent, - child: InkWell( - splashColor: theme.colorScheme.primary.withValues(alpha: 0.4), - onTap: () => triggerOnTap(context), - onLongPress: originURL != null ? () => handleLinkLongPress(context, originURL!, originURL) : null, - ), - ), - ), - ], - ), - ); - } else { - var inkWell = InkWell( - onTap: () => triggerOnTap(context), - child: Container( - clipBehavior: Clip.hardEdge, - decoration: BoxDecoration(borderRadius: BorderRadius.circular(12)), - child: const Stack( - alignment: Alignment.center, - fit: StackFit.passthrough, - ), - ), - ); - if (edgeToEdgeImages) { - return Padding( - padding: const EdgeInsets.only(top: 4.0, bottom: 8.0, left: 12.0, right: 12.0), - child: inkWell, - ); - } else { - return Padding( - padding: const EdgeInsets.only(top: 4.0, bottom: 8.0), - child: inkWell, - ); - } - } - } - - void triggerOnTap(BuildContext context) async { - if (isUserLoggedIn && markPostReadOnMediaView) { - // Mark post as read when on the feed page - try { - FeedBloc feedBloc = BlocProvider.of(context); - feedBloc.add(FeedItemActionedEvent(postAction: PostAction.read, postId: postId, value: true)); - } catch (e) { - // Ignore exception - } - } - if (originURL != null) { - handleLink(context, url: originURL!); - } - } -} diff --git a/lib/shared/media/media_view.dart b/lib/shared/media/media_view.dart index af90ecc29..e061869ec 100644 --- a/lib/shared/media/media_view.dart +++ b/lib/shared/media/media_view.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:thunder/core/models/media.dart'; import 'package:thunder/shared/image/image_preview.dart'; import 'package:thunder/shared/link_information.dart'; import 'package:thunder/shared/media/media_view_text.dart'; @@ -12,19 +13,21 @@ import 'package:thunder/shared/image_viewer.dart'; import 'package:thunder/core/enums/view_mode.dart'; import 'package:thunder/core/enums/media_type.dart'; import 'package:thunder/post/enums/post_action.dart'; -import 'package:thunder/shared/link_preview_card.dart'; import 'package:thunder/thunder/bloc/thunder_bloc.dart'; -import 'package:thunder/core/models/post_view_media.dart'; +import 'package:thunder/utils/links.dart'; import 'package:thunder/utils/media/video.dart'; class MediaView extends StatefulWidget { - /// The post containing the media information - final PostViewMedia postViewMedia; + /// The media information + final Media media; + + /// The associated post ID for the media + final int? postId; /// Whether to show the full height for images final bool showFullHeightImages; - /// When enabled, the image height will be unconstrained. This is only applicable when [showFullHeightImages] is enabled. + /// When enabled, the image height will be unconstrained. final bool allowUnconstrainedImageHeight; /// Whether to blur NSFW images @@ -42,21 +45,19 @@ class MediaView extends StatefulWidget { /// Whether the user is logged in final bool isUserLoggedIn; - /// Whether to scrape missing previews for thumbnails - final bool? scrapeMissingPreviews; - /// The view mode of the media final ViewMode viewMode; /// The function to navigate to the post - final void Function({PostViewMedia? postViewMedia})? navigateToPost; + final void Function()? navigateToPost; /// Whether the post has been read final bool? read; const MediaView({ super.key, - required this.postViewMedia, + required this.media, + this.postId, this.showFullHeightImages = true, this.allowUnconstrainedImageHeight = false, this.edgeToEdgeImages = false, @@ -65,7 +66,6 @@ class MediaView extends StatefulWidget { this.markPostReadOnMediaView = false, this.isUserLoggedIn = false, this.viewMode = ViewMode.comfortable, - this.scrapeMissingPreviews, this.navigateToPost, this.read, }); @@ -75,14 +75,16 @@ class MediaView extends StatefulWidget { } class _MediaViewState extends State with TickerProviderStateMixin { - // Overlay used for image peeking + // An overlay entry to display the image overlay for hold to peek OverlayEntry? _overlayEntry; + + // An animation controller to animate the image overlay late final AnimationController _overlayAnimationController; @override void initState() { - _overlayAnimationController = AnimationController(duration: const Duration(milliseconds: 100), vsync: this); super.initState(); + _overlayAnimationController = AnimationController(duration: const Duration(milliseconds: 100), vsync: this); } @override @@ -96,8 +98,7 @@ class _MediaViewState extends State with TickerProviderStateMixin { if (widget.isUserLoggedIn && widget.markPostReadOnMediaView) { try { // Mark post as read when on the feed page - int postId = widget.postViewMedia.postView.post.id; - context.read().add(FeedItemActionedEvent(postAction: PostAction.read, postId: postId, value: true)); + context.read().add(FeedItemActionedEvent(postAction: PostAction.read, postId: widget.postId, value: true)); } catch (e) { // Do nothing otherwise } @@ -112,10 +113,10 @@ class _MediaViewState extends State with TickerProviderStateMixin { }, pageBuilder: (BuildContext context, Animation animation, Animation secondaryAnimation) { return ImageViewer( - url: widget.postViewMedia.media.first.imageUrl, - postId: widget.postViewMedia.postView.post.id, + url: widget.media.imageUrl, + postId: widget.postId, navigateToPost: widget.navigateToPost, - altText: widget.postViewMedia.media.first.altText, + altText: widget.media.altText, ); }, ), @@ -125,37 +126,53 @@ class _MediaViewState extends State with TickerProviderStateMixin { double getMinHeight() { if (!widget.showFullHeightImages) return ViewMode.comfortable.height; - if (widget.postViewMedia.media.first.height != null) { - if (MediaQuery.of(context).size.height < widget.postViewMedia.media.first.height!) return MediaQuery.of(context).size.height; - return widget.postViewMedia.media.first.height!; + if (widget.media.height != null) { + if (MediaQuery.of(context).size.height < widget.media.height!) return MediaQuery.of(context).size.height; + return widget.media.height!; } return ViewMode.comfortable.height; } double getMaxHeight() { + if (widget.allowUnconstrainedImageHeight) return MediaQuery.of(context).size.height; if (!widget.showFullHeightImages) return ViewMode.comfortable.height; - if (widget.postViewMedia.media.first.height != null) { - if (MediaQuery.of(context).size.height < widget.postViewMedia.media.first.height!) return MediaQuery.of(context).size.height; - return widget.postViewMedia.media.first.height!; + if (widget.media.height != null) { + if (MediaQuery.of(context).size.height < widget.media.height!) return MediaQuery.of(context).size.height; + return widget.media.height!; } - if (widget.allowUnconstrainedImageHeight) return MediaQuery.of(context).size.height; - return ViewMode.comfortable.height; } - /// Creates an image preview - Widget buildMediaImage() { + @override + Widget build(BuildContext context) { + // If hiding thumbnails is enabled or if the media has no image URL (e.g., text or links with no images), we should display a link preview instead + // This only applies for [ViewMode.comfortable] + if (widget.viewMode == ViewMode.comfortable && (widget.hideThumbnails || widget.media.imageUrl == null)) { + return LinkInformation( + viewMode: widget.viewMode, + originURL: widget.media.originalUrl, + mediaType: widget.media.mediaType, + onTap: widget.media.mediaType == MediaType.image ? showImage : null, + showEdgeToEdgeImages: widget.edgeToEdgeImages, + ); + } + + if (widget.viewMode == ViewMode.compact && widget.media.mediaType == MediaType.text) { + return MediaViewText( + text: widget.media.altText, + read: widget.read, + ); + } + + // At this point, all other media types should contain images, so we display the image as well as any additional information final theme = Theme.of(context); final l10n = AppLocalizations.of(context)!; - final state = context.read().state; - // TODO: If this site has a content warning, we don't need to blur previews. - // (This can be implemented once the web UI does the same.) - final blurNSFWPreviews = widget.hideNsfwPreviews && widget.postViewMedia.postView.post.nsfw; + final blurNSFWPreviews = widget.hideNsfwPreviews && widget.media.nsfw; double? width; double? height; @@ -167,7 +184,101 @@ class _MediaViewState extends State with TickerProviderStateMixin { break; case ViewMode.comfortable: width = (state.tabletMode ? (MediaQuery.of(context).size.width / 2) - 24.0 : MediaQuery.of(context).size.width) - (widget.edgeToEdgeImages ? 0 : 24); - height = widget.showFullHeightImages ? widget.postViewMedia.media.first.height : null; + height = (widget.showFullHeightImages && !widget.allowUnconstrainedImageHeight) ? widget.media.height : null; + } + + Widget? child; + + // For links, add inkwell to handle links. For [ViewMode.comfortable], add link information below the image + if (widget.media.mediaType == MediaType.link) { + child = InkWell( + splashColor: theme.colorScheme.primary.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular((widget.edgeToEdgeImages ? 0 : 12)), + onTap: () => handleLink(context, url: widget.media.originalUrl!), + onLongPress: () => handleLinkLongPress(context, widget.media.originalUrl!, widget.media.originalUrl), + child: widget.viewMode == ViewMode.comfortable + ? SizedBox( + height: 70.0, + child: Align( + alignment: Alignment.bottomCenter, + child: LinkInformation( + viewMode: widget.viewMode, + mediaType: widget.media.mediaType, + originURL: widget.media.originalUrl ?? '', + showEdgeToEdgeImages: widget.edgeToEdgeImages, + ), + ), + ) + : SizedBox(), + ); + } + + // For images, add hold to peek gesture + if (widget.media.mediaType == MediaType.image) { + child = InkWell( + splashColor: theme.colorScheme.primary.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular((widget.edgeToEdgeImages ? 0 : 12)), + onTap: showImage, + child: GestureDetector( + onLongPressStart: (_) { + _overlayEntry = OverlayEntry( + builder: (context) { + return FadeTransition( + opacity: _overlayAnimationController, + child: ImageViewer( + url: widget.media.thumbnailUrl ?? widget.media.mediaUrl, + postId: widget.postId, + navigateToPost: widget.navigateToPost, + isPeek: true, + ), + ); + }, + ); + Overlay.of(context).insert(_overlayEntry!); + _overlayAnimationController.forward(); + }, + onLongPressEnd: (_) async { + await _overlayAnimationController.reverse(); + _overlayEntry?.remove(); + _overlayEntry = null; + }, + ), + ); + } + + // For videos, add a play icon and tap gesture to play the video + if (widget.media.mediaType == MediaType.video) { + child = InkWell( + splashColor: theme.colorScheme.primary.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular((widget.edgeToEdgeImages ? 0 : 12)), + onTap: () { + if (widget.isUserLoggedIn && widget.markPostReadOnMediaView) { + FeedBloc feedBloc = BlocProvider.of(context); + feedBloc.add(FeedItemActionedEvent(postAction: PostAction.read, postId: widget.postId, value: true)); + } + + showVideoPlayer(context, url: widget.media.mediaUrl ?? widget.media.originalUrl, postId: widget.postId); + }, + child: widget.viewMode == ViewMode.comfortable + ? Column( + children: [ + Expanded(child: Icon(Icons.play_arrow_rounded, size: 55)), + SizedBox( + height: 70.0, + child: Align( + alignment: Alignment.bottomCenter, + child: LinkInformation( + viewMode: widget.viewMode, + mediaType: widget.media.mediaType, + originURL: widget.media.originalUrl ?? '', + showEdgeToEdgeImages: widget.edgeToEdgeImages, + ), + ), + ), + ], + ) + : SizedBox(), + ); } return Stack( @@ -200,11 +311,11 @@ class _MediaViewState extends State with TickerProviderStateMixin { alignment: Alignment.center, children: [ ImagePreview( - url: widget.postViewMedia.media.first.thumbnailUrl ?? widget.postViewMedia.media.first.originalUrl!, + url: widget.media.thumbnailUrl ?? widget.media.imageUrl ?? widget.media.originalUrl!, width: width, height: height, fit: widget.viewMode == ViewMode.compact ? BoxFit.cover : BoxFit.fitWidth, - mediaType: widget.postViewMedia.media.first.mediaType, + mediaType: widget.media.mediaType, viewed: widget.read, blur: blurNSFWPreviews, ), @@ -213,205 +324,23 @@ class _MediaViewState extends State with TickerProviderStateMixin { crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.warning_rounded, size: widget.viewMode != ViewMode.compact ? 55 : 30), + widget.media.mediaType == MediaType.image + ? Icon(Icons.warning_rounded, size: widget.viewMode != ViewMode.compact ? 55 : 30) + : Icon(widget.viewMode != ViewMode.compact ? Icons.play_arrow_rounded : Icons.warning_rounded, size: widget.viewMode != ViewMode.compact ? 55 : 30), if (widget.viewMode != ViewMode.compact) Text(l10n.nsfwWarning, textScaler: const TextScaler.linear(1.5)), ], ), + if (child != null) + Positioned.fill( + child: Material( + color: Colors.transparent, + child: child, + ), + ), ], ), ), - Positioned.fill( - child: Material( - color: Colors.transparent, - child: InkWell( - splashColor: theme.colorScheme.primary.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular((widget.edgeToEdgeImages ? 0 : 12)), - onTap: showImage, - child: GestureDetector( - onLongPressStart: (_) { - _overlayEntry = OverlayEntry( - builder: (context) { - return FadeTransition( - opacity: _overlayAnimationController, - child: ImageViewer( - url: widget.postViewMedia.media.first.thumbnailUrl ?? widget.postViewMedia.media.first.mediaUrl, - postId: widget.postViewMedia.postView.post.id, - navigateToPost: widget.navigateToPost, - isPeek: true, - ), - ); - }, - ); - Overlay.of(context).insert(_overlayEntry!); - _overlayAnimationController.forward(); - }, - onLongPressEnd: (_) async { - await _overlayAnimationController.reverse(); - _overlayEntry?.remove(); - _overlayEntry = null; - }, - ), - ), - ), - ), ], ); } - - /// Creates an video preview. This displays a thumbnail of the video, and tapping it will open the video in a fullscreen player - Widget buildMediaVideo() { - final theme = Theme.of(context); - final l10n = AppLocalizations.of(context)!; - final state = context.read().state; - - // TODO: If this site has a content warning, we don't need to blur previews. - // (This can be implemented once the web UI does the same.) - final blurNSFWPreviews = widget.hideNsfwPreviews && widget.postViewMedia.postView.post.nsfw; - - double? width; - double? height; - - switch (widget.viewMode) { - case ViewMode.compact: - width = null; // Setting this to null will use the image's width. This will allow the image to not be stretched or squished. - height = ViewMode.compact.height; - break; - case ViewMode.comfortable: - width = (state.tabletMode ? (MediaQuery.of(context).size.width / 2) - 24.0 : MediaQuery.of(context).size.width) - (widget.edgeToEdgeImages ? 0 : 24); - height = widget.showFullHeightImages ? widget.postViewMedia.media.first.height : null; - } - - return InkWell( - splashColor: theme.colorScheme.primary.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular((widget.edgeToEdgeImages ? 0 : 12)), - onTap: () { - if (widget.isUserLoggedIn && widget.markPostReadOnMediaView && widget.postViewMedia.postView.read == false) { - FeedBloc feedBloc = BlocProvider.of(context); - feedBloc.add(FeedItemActionedEvent(postAction: PostAction.read, postId: widget.postViewMedia.postView.post.id, value: true)); - } - - showVideoPlayer(context, url: widget.postViewMedia.media.first.mediaUrl ?? widget.postViewMedia.media.first.originalUrl, postId: widget.postViewMedia.postView.post.id); - }, - child: Container( - clipBehavior: Clip.hardEdge, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular((widget.edgeToEdgeImages ? 0 : 12)), - color: getBackgroundColor(context), - ), - constraints: BoxConstraints( - maxHeight: switch (widget.viewMode) { - ViewMode.compact => ViewMode.compact.height, - ViewMode.comfortable => getMaxHeight(), - }, - minHeight: switch (widget.viewMode) { - ViewMode.compact => ViewMode.compact.height, - ViewMode.comfortable => getMinHeight(), - }, - maxWidth: switch (widget.viewMode) { - ViewMode.compact => ViewMode.compact.height, - ViewMode.comfortable => widget.edgeToEdgeImages ? double.infinity : MediaQuery.of(context).size.width, - }, - minWidth: switch (widget.viewMode) { - ViewMode.compact => ViewMode.compact.height, - ViewMode.comfortable => widget.edgeToEdgeImages ? double.infinity : MediaQuery.of(context).size.width, - }), - child: Stack( - fit: widget.allowUnconstrainedImageHeight ? StackFit.loose : StackFit.expand, - alignment: Alignment.bottomLeft, - children: [ - if (!widget.postViewMedia.postView.post.nsfw && widget.postViewMedia.media.first.thumbnailUrl?.isNotEmpty != true) - Icon( - Icons.video_camera_back_outlined, - color: theme.colorScheme.onSecondaryContainer.withValues(alpha: widget.read == true ? 0.55 : 1.0), - ), - if (widget.postViewMedia.media.first.thumbnailUrl != null) - ImagePreview( - url: widget.postViewMedia.media.first.thumbnailUrl ?? widget.postViewMedia.media.first.originalUrl!, - width: width, - height: height, - fit: widget.viewMode == ViewMode.compact ? BoxFit.cover : BoxFit.fitWidth, - mediaType: widget.postViewMedia.media.first.mediaType, - viewed: widget.read, - blur: blurNSFWPreviews, - ), - if (widget.postViewMedia.postView.post.nsfw) - Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(widget.viewMode != ViewMode.compact ? Icons.play_arrow_rounded : Icons.warning_rounded, size: widget.viewMode != ViewMode.compact ? 55 : 30), - if (widget.viewMode != ViewMode.compact) Text(l10n.nsfwWarning, textScaler: const TextScaler.linear(1.5)), - ], - ) - else if (widget.viewMode == ViewMode.comfortable) - SizedBox( - height: 70.0, - child: Align( - alignment: Alignment.bottomCenter, - child: LinkInformation( - viewMode: widget.viewMode, - mediaType: widget.postViewMedia.media.first.mediaType, - originURL: widget.postViewMedia.media.first.originalUrl ?? '', - showEdgeToEdgeImages: widget.edgeToEdgeImages, - ), - ), - ), - ], - ), - ), - ); - } - - @override - Widget build(BuildContext context) { - if (widget.hideThumbnails) { - return LinkInformation( - viewMode: widget.viewMode, - originURL: widget.postViewMedia.media.first.originalUrl, - mediaType: widget.postViewMedia.media.first.mediaType, - onTap: widget.postViewMedia.media.first.mediaType == MediaType.image ? showImage : null, - showEdgeToEdgeImages: widget.edgeToEdgeImages, - ); - } - switch (widget.postViewMedia.media.firstOrNull?.mediaType) { - case MediaType.image: - return buildMediaImage(); - case MediaType.video: - if (widget.viewMode == ViewMode.comfortable && widget.postViewMedia.media.first.thumbnailUrl == null) { - return LinkInformation( - viewMode: widget.viewMode, - mediaType: widget.postViewMedia.media.first.mediaType, - originURL: widget.postViewMedia.media.first.originalUrl ?? '', - showEdgeToEdgeImages: widget.edgeToEdgeImages, - ); - } - - return buildMediaVideo(); - case MediaType.link: - return LinkPreviewCard( - hideNsfw: widget.hideNsfwPreviews && widget.postViewMedia.postView.post.nsfw, - scrapeMissingPreviews: widget.scrapeMissingPreviews!, - originURL: widget.postViewMedia.media.first.originalUrl, - mediaURL: widget.postViewMedia.media.first.thumbnailUrl ?? widget.postViewMedia.postView.post.thumbnailUrl, - mediaHeight: widget.postViewMedia.media.first.height, - mediaWidth: widget.postViewMedia.media.first.width, - showFullHeightImages: widget.viewMode == ViewMode.comfortable ? widget.showFullHeightImages : false, - edgeToEdgeImages: widget.viewMode == ViewMode.comfortable ? widget.edgeToEdgeImages : false, - viewMode: widget.viewMode, - postId: widget.postViewMedia.postView.post.id, - markPostReadOnMediaView: widget.markPostReadOnMediaView, - isUserLoggedIn: widget.isUserLoggedIn, - read: widget.read, - ); - case MediaType.text: - if (widget.viewMode == ViewMode.comfortable) return Container(); - - return MediaViewText( - text: widget.postViewMedia.postView.post.body, - read: widget.read, - ); - default: - return Container(); - } - } }