diff --git a/lib/core/images/booru_image.dart b/lib/core/images/booru_image.dart index e39cb52f13..ce4e174a98 100644 --- a/lib/core/images/booru_image.dart +++ b/lib/core/images/booru_image.dart @@ -30,6 +30,7 @@ class BooruImage extends ConsumerWidget { this.imageHeight, this.forceCover = false, this.forceFill = false, + this.fitWidthForTallImages = false, this.forceLoadPlaceholder = false, this.placeholderWidget, this.controller, @@ -46,6 +47,7 @@ class BooruImage extends ConsumerWidget { final double? imageHeight; final bool forceCover; final bool forceFill; + final bool fitWidthForTallImages; final bool forceLoadPlaceholder; final Widget? placeholderWidget; final ExtendedImageController? controller; @@ -78,6 +80,7 @@ class BooruImage extends ConsumerWidget { imageHeight: imageHeight, forceCover: forceCover, forceFill: forceFill, + fitWidthForTallImages: fitWidthForTallImages, isLargeImage: imageQualitySettings != ImageQuality.low, forceLoadPlaceholder: forceLoadPlaceholder, headers: ref.watch(httpHeadersProvider(config)), @@ -102,6 +105,7 @@ class BooruRawImage extends StatelessWidget { this.imageHeight, this.forceCover = false, this.forceFill = false, + this.fitWidthForTallImages = false, this.headers = const {}, this.isLargeImage = false, this.forceLoadPlaceholder = false, @@ -122,6 +126,7 @@ class BooruRawImage extends StatelessWidget { final double? imageHeight; final bool forceCover; final bool forceFill; + final bool fitWidthForTallImages; final Map headers; final bool isLargeImage; final bool forceLoadPlaceholder; @@ -138,20 +143,26 @@ class BooruRawImage extends StatelessWidget { ); return NullableAspectRatio( - aspectRatio: forceCover || fit == BoxFit.contain ? null : aspectRatio, + aspectRatio: forceCover || fit == BoxFit.contain || fitWidthForTallImages + ? null + : aspectRatio, child: LayoutBuilder( builder: (context, constraints) { final width = constraints.maxWidth.roundToDouble(); final height = constraints.maxHeight.roundToDouble(); + final fit = this.fit ?? - // If the image is larger than the layout, just fill it to prevent distortion - (forceFill && - _shouldForceFill( - constraints.biggest, - imageWidth, - imageHeight, - ) + // Always use fit width when flag is enabled + (fitWidthForTallImages + ? BoxFit.fitWidth + // If the image is larger than the layout, just fill it to prevent distortion + : forceFill && + _shouldForceFill( + constraints.biggest, + imageWidth, + imageHeight, + ) ? BoxFit.fill // Cover is for the standard grid that crops the image to fit the aspect ratio : forceCover @@ -166,7 +177,7 @@ class BooruRawImage extends StatelessWidget { headers: headers, borderRadius: borderRadius, width: width, - height: height, + height: fitWidthForTallImages ? null : height, fit: fit, gaplessPlayback: gaplessPlayback, fetchStrategy: _fetchStrategy, diff --git a/lib/core/posts/details/src/utils/tall_media_classifier.dart b/lib/core/posts/details/src/utils/tall_media_classifier.dart new file mode 100644 index 0000000000..40fedde77a --- /dev/null +++ b/lib/core/posts/details/src/utils/tall_media_classifier.dart @@ -0,0 +1,129 @@ +// Flutter imports: +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +// Project imports: +import '../../../../settings/settings.dart'; + +class TallMediaDisposition { + const TallMediaDisposition._({ + required this.isTall, + required this.shouldFitToWidth, + required this.aspectRatio, + required this.viewportRatio, + required this.pixelCount, + required this.scrollExtent, + }); + + const TallMediaDisposition.standard({ + required double aspectRatio, + required double viewportRatio, + required double pixelCount, + }) : this._( + isTall: false, + shouldFitToWidth: false, + aspectRatio: aspectRatio, + viewportRatio: viewportRatio, + pixelCount: pixelCount, + scrollExtent: 0, + ); + + factory TallMediaDisposition.tall({ + required double aspectRatio, + required double viewportRatio, + required double pixelCount, + required double scrollExtent, + }) => TallMediaDisposition._( + isTall: true, + shouldFitToWidth: true, + aspectRatio: aspectRatio, + viewportRatio: viewportRatio, + pixelCount: pixelCount, + scrollExtent: scrollExtent, + ); + + final bool isTall; + final bool shouldFitToWidth; + final double aspectRatio; + final double viewportRatio; + final double pixelCount; + final double scrollExtent; + + bool get hasScrollableExtent => scrollExtent > 0; +} + +TallMediaDisposition classifyTallMedia({ + required TallMediaSettings settings, + required Size viewportSize, + required double width, + required double height, + required bool isVideo, +}) { + if (!settings.enabled || width <= 0 || height <= 0 || isVideo) { + final aspectRatio = width <= 0 || height <= 0 ? 0.0 : height / width; + final viewportRatio = viewportSize.height == 0 + ? 0.0 + : height / viewportSize.height; + return TallMediaDisposition.standard( + aspectRatio: aspectRatio, + viewportRatio: viewportRatio, + pixelCount: width * height, + ); + } + + final aspectRatio = height / width; + final viewportRatio = viewportSize.height == 0 + ? 0.0 + : height / viewportSize.height; + final pixelCount = width * height; + final projectedHeight = _projectedHeight(width, height, viewportSize); + final rawScrollExtent = projectedHeight - viewportSize.height; + final hasScroll = rawScrollExtent > TallMediaSettings.minScrollExtentPx; + + final meetsAspect = aspectRatio >= TallMediaSettings.aspectRatioThreshold; + final meetsHeight = height >= TallMediaSettings.minHeightPx; + final meetsViewport = viewportRatio >= TallMediaSettings.minViewportHeightRatio; + final meetsPixels = pixelCount >= TallMediaSettings.minPixelCount; + + final isTall = + hasScroll && + ((meetsAspect && meetsViewport) || + (meetsAspect && meetsHeight && meetsPixels) || + (meetsHeight && meetsViewport && meetsPixels)); + + if (kDebugMode) { + debugPrint( + 'TallMediaClassifier: aspect=$aspectRatio viewportRatio=$viewportRatio ' + 'pixels=$pixelCount projectedHeight=$projectedHeight scroll=$rawScrollExtent ' + '-> isTall=$isTall', + ); + } + + if (!isTall) { + return TallMediaDisposition.standard( + aspectRatio: aspectRatio, + viewportRatio: viewportRatio, + pixelCount: pixelCount, + ); + } + + final scrollExtent = rawScrollExtent > 0 ? rawScrollExtent : 0.0; + + return TallMediaDisposition.tall( + aspectRatio: aspectRatio, + viewportRatio: viewportRatio, + pixelCount: pixelCount, + scrollExtent: scrollExtent > 0 ? scrollExtent : 0, + ); +} + +double _projectedHeight(double width, double height, Size viewportSize) { + final viewportWidth = viewportSize.width; + if (width <= 0 || height <= 0 || viewportWidth <= 0) { + return height; + } + + final scale = viewportWidth / width; + final clampedScale = scale < 1 ? scale : 1.0; + return height * clampedScale; +} diff --git a/lib/core/posts/details/src/widgets/post_details_image.dart b/lib/core/posts/details/src/widgets/post_details_image.dart index 4c3d04c871..957a2fc89f 100644 --- a/lib/core/posts/details/src/widgets/post_details_image.dart +++ b/lib/core/posts/details/src/widgets/post_details_image.dart @@ -23,6 +23,7 @@ class PostDetailsImage extends ConsumerStatefulWidget { super.key, this.heroTag, this.imageCacheManager, + this.fitWidthForTallImages = false, }); final BooruConfigAuth config; @@ -31,12 +32,15 @@ class PostDetailsImage extends ConsumerStatefulWidget { final String Function(T post)? thumbnailUrlBuilder; final ImageCacheManager? imageCacheManager; final T post; + final bool fitWidthForTallImages; @override - ConsumerState createState() => _PostDetailsImageState(); + ConsumerState> createState() => + _PostDetailsImageState(); } -class _PostDetailsImageState extends ConsumerState { +class _PostDetailsImageState + extends ConsumerState> { @override Widget build(BuildContext context) { final imageUrl = widget.imageUrlBuilder != null @@ -117,10 +121,11 @@ class _PostDetailsImageState extends ConsumerState { imageUrl: imageUrl, placeholderUrl: placeholderImageUrl, aspectRatio: post.aspectRatio, - forceCover: post.aspectRatio != null, + forceCover: false, // Never force cover when we want fit width imageHeight: post.height, imageWidth: post.width, - forceFill: true, + forceFill: false, + fitWidthForTallImages: widget.fitWidthForTallImages, borderRadius: BorderRadius.zero, forceLoadPlaceholder: true, imageCacheManager: widget.imageCacheManager, diff --git a/lib/core/posts/details/src/widgets/post_details_item.dart b/lib/core/posts/details/src/widgets/post_details_item.dart index db059f75f5..fa05573547 100644 --- a/lib/core/posts/details/src/widgets/post_details_item.dart +++ b/lib/core/posts/details/src/widgets/post_details_item.dart @@ -1,3 +1,6 @@ +// Dart imports: +import 'dart:async'; + // Flutter imports: import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -23,6 +26,8 @@ import '../../../post/post.dart'; import '../../details.dart'; import 'post_details_controller.dart'; import 'post_media.dart'; +import '../utils/tall_media_classifier.dart'; +import 'tall_media_scroller.dart'; class PostDetailsItem extends ConsumerWidget { const PostDetailsItem({ @@ -117,73 +122,123 @@ class PostDetailsItem extends ConsumerWidget { post, ) : null, - child: Stack( - alignment: Alignment.center, - children: [ - ValueListenableBuilder( + child: () { + final mediaSize = MediaQuery.sizeOf(context); + final mediaPadding = MediaQuery.paddingOf(context); + final rawViewportHeight = mediaSize.height - mediaPadding.vertical; + final viewportHeight = rawViewportHeight > 0 + ? rawViewportHeight + : 0.0; + final viewportSize = Size(mediaSize.width, viewportHeight); + + final viewerSettings = ref.watch( + settingsProvider.select((value) => value.viewer), + ); + final tallSettings = viewerSettings.tallMedia; + final hapticsEnabled = ref.watch( + hapticFeedbackLevelProvider.select( + (value) => value.isReducedOrAbove, + ), + ); + + final disposition = classifyTallMedia( + settings: tallSettings, + viewportSize: viewportSize, + width: post.width, + height: post.height, + isVideo: post.isVideo, + ); + final useTallScroller = + disposition.isTall && disposition.hasScrollableExtent; + + Widget buildPostMedia() { + return ValueListenableBuilder( valueListenable: isInitPageListenable, - builder: (_, isInitPage, _) { + builder: (context, isInitPage, _) { return PostMedia( post: post, config: authConfig, imageUrlBuilder: imageUrlBuilder, imageCacheManager: imageCacheManager, - // This is used to make sure we have a thumbnail to show instead of a black placeholder thumbnailUrlBuilder: isInitPage && initialThumbnailUrl != null - // Need to specify the type here to avoid type inference error - // ignore: avoid_types_on_closure_parameters - ? (Post _) => initialThumbnailUrl + ? (_) => initialThumbnailUrl : null, controller: pageViewController, + fitWidthForTallImages: disposition.shouldFitToWidth, ); }, - ), - if (post.isVideo) - Align( - alignment: Alignment.bottomRight, - child: state.isExpanded && !context.isLargeScreen - ? Padding( - padding: const EdgeInsets.all(8), - child: Row( - children: [ - // duplicate codes, maybe refactor later - PlayPauseButton( - isPlaying: detailsController.isVideoPlaying, - onPlayingChanged: (value) { - if (value) { - detailsController.pauseVideo( - post.id, - post.isWebm, - videoPlayerEngine.isDefault, - ); - } else if (!value) { - detailsController.playVideo( - post.id, - post.isWebm, - videoPlayerEngine.isDefault, - ); - } else { - // do nothing - } - }, - ), - VideoSoundScope( - builder: (context, soundOn) => - SoundControlButton( - padding: const EdgeInsets.all(8), - soundOn: soundOn, - onSoundChanged: (value) => - ref.setGlobalVideoSound(value), - ), - ), - ], - ), - ) - : const SizedBox.shrink(), + ); + } + + if (useTallScroller) { + return TallMediaScroller( + pageViewController: pageViewController, + settings: tallSettings, + overlayListenable: pageViewController.overlay, + enableHaptics: hapticsEnabled, + isVerticalSwipeMode: pageViewController.useVerticalLayout, + onRequestExpandSheet: () { + unawaited(pageViewController.expandToSnapPoint()); + }, + child: Padding( + padding: EdgeInsets.only(top: mediaPadding.top), + child: buildPostMedia(), ), - ], - ), + ); + } + + return Stack( + alignment: Alignment.center, + children: [ + buildPostMedia(), + if (post.isVideo) + Align( + alignment: Alignment.bottomRight, + child: ValueListenableBuilder( + valueListenable: pageViewController.sheetState, + builder: (context, state, _) => + state.isExpanded && !context.isLargeScreen + ? Padding( + padding: const EdgeInsets.all(8), + child: Row( + children: [ + PlayPauseButton( + isPlaying: detailsController.isVideoPlaying, + onPlayingChanged: (value) { + if (value) { + detailsController.pauseVideo( + post.id, + post.isWebm, + videoPlayerEngine.isDefault, + ); + } else if (!value) { + detailsController.playVideo( + post.id, + post.isWebm, + videoPlayerEngine.isDefault, + ); + } + }, + ), + VideoSoundScope( + builder: (context, soundOn) => + SoundControlButton( + padding: const EdgeInsets.all(8), + soundOn: soundOn, + onSoundChanged: (value) => + ref.setGlobalVideoSound(value), + ), + ), + ], + ), + ) + : const SizedBox.shrink(), + ), + ), + ], + ); + }(), ), ), ); diff --git a/lib/core/posts/details/src/widgets/post_media.dart b/lib/core/posts/details/src/widgets/post_media.dart index 87af7f589b..11d7da943f 100644 --- a/lib/core/posts/details/src/widgets/post_media.dart +++ b/lib/core/posts/details/src/widgets/post_media.dart @@ -32,6 +32,7 @@ class PostMedia extends ConsumerWidget { required this.controller, required this.imageCacheManager, super.key, + this.fitWidthForTallImages = false, }); final T post; @@ -40,6 +41,7 @@ class PostMedia extends ConsumerWidget { final String Function(T post)? imageUrlBuilder; final String Function(T post)? thumbnailUrlBuilder; final ImageCacheManager? imageCacheManager; + final bool fitWidthForTallImages; void _openSettings(WidgetRef ref) { openImageViewerSettingsPage(ref); @@ -108,13 +110,14 @@ class PostMedia extends ConsumerWidget { ), ], ) - : PostDetailsImage( + : PostDetailsImage( heroTag: heroTag, imageUrlBuilder: imageUrlBuilder, thumbnailUrlBuilder: thumbnailUrlBuilder, imageCacheManager: imageCacheManager, post: post, config: config, + fitWidthForTallImages: fitWidthForTallImages, ); } } diff --git a/lib/core/posts/details/src/widgets/tall_media_scroller.dart b/lib/core/posts/details/src/widgets/tall_media_scroller.dart new file mode 100644 index 0000000000..f95f6e01e1 --- /dev/null +++ b/lib/core/posts/details/src/widgets/tall_media_scroller.dart @@ -0,0 +1,420 @@ +// Dart imports: +import 'dart:async'; +import 'dart:math'; +import 'dart:ui' show PointerDeviceKind; + +// Flutter imports: +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +// Project imports: +import '../../../../settings/settings.dart'; +import '../../../details_pageview/src/post_details_page_view_controller.dart'; + +class TallMediaScroller extends StatefulWidget { + const TallMediaScroller({ + required this.child, + required this.pageViewController, + required this.settings, + required this.overlayListenable, + required this.enableHaptics, + required this.isVerticalSwipeMode, + this.onRequestExpandSheet, + super.key, + }); + + final Widget child; + final PostDetailsPageViewController pageViewController; + final TallMediaSettings settings; + final ValueListenable overlayListenable; + final bool enableHaptics; + final bool isVerticalSwipeMode; + final VoidCallback? onRequestExpandSheet; + + @override + State createState() => _TallMediaScrollerState(); +} + +class _TallMediaScrollerState extends State { + static const _kEdgeSlack = 12.0; + static const _kHintVisibleDuration = Duration(milliseconds: 1800); + + late final ScrollController _scrollController = ScrollController(); + late final ValueNotifier _canScrollUp = ValueNotifier(false); + late final ValueNotifier _canScrollDown = ValueNotifier(false); + + Offset? _gestureStartPosition; + Duration? _gestureStartTimestamp; + bool _trackingGesture = false; + bool _hasTriggeredLock = false; + bool _requestedExpandDuringGesture = false; + + Timer? _hintTimer; + bool _showInitialHint = false; + + TallMediaSettings get _settings => widget.settings; + PostDetailsPageViewController get _controller => widget.pageViewController; + + @override + void initState() { + super.initState(); + _scrollController.addListener(_updateScrollIndicators); + widget.overlayListenable.addListener(_handleOverlayChanged); + + _showInitialHint = widget.isVerticalSwipeMode; + if (_showInitialHint) { + _hintTimer = Timer(_kHintVisibleDuration, () { + if (!mounted) return; + setState(() => _showInitialHint = false); + }); + } + } + + @override + void didUpdateWidget(covariant TallMediaScroller oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.overlayListenable != widget.overlayListenable) { + oldWidget.overlayListenable.removeListener(_handleOverlayChanged); + widget.overlayListenable.addListener(_handleOverlayChanged); + } + } + + @override + void dispose() { + _hintTimer?.cancel(); + widget.overlayListenable.removeListener(_handleOverlayChanged); + _scrollController + ..removeListener(_updateScrollIndicators) + ..dispose(); + _canScrollUp.dispose(); + _canScrollDown.dispose(); + super.dispose(); + } + + void _handleOverlayChanged() { + if (!widget.overlayListenable.value) return; + + // Reset gesture tracking when overlay becomes visible + _endGesture(cancelled: true); + } + + void _updateScrollIndicators() { + if (!_scrollController.hasClients) return; + final position = _scrollController.position; + final offset = position.pixels; + final maxExtent = position.maxScrollExtent; + final activationExtent = _edgeActivationExtent(position); + + final canScrollUp = offset > activationExtent; + final canScrollDown = offset < max(0.0, maxExtent - activationExtent); + + if (_canScrollUp.value != canScrollUp) { + _canScrollUp.value = canScrollUp; + } + + if (_canScrollDown.value != canScrollDown) { + _canScrollDown.value = canScrollDown; + } + } + + void _handlePointerDown(PointerDownEvent event) { + if (!widget.isVerticalSwipeMode) return; + if (event.kind != PointerDeviceKind.touch && + event.kind != PointerDeviceKind.stylus) { + return; + } + + _trackingGesture = true; + _gestureStartPosition = event.position; + _gestureStartTimestamp = event.timeStamp; + _ensureSwipeLock(); + _requestedExpandDuringGesture = false; + } + + void _handlePointerMove(PointerMoveEvent event) { + if (!_trackingGesture) return; + if (_gestureStartPosition == null) return; + + // Lock swipe if user drags beyond threshold even when detection is late + final dragDistance = (event.position.dy - _gestureStartPosition!.dy).abs(); + if (!_hasTriggeredLock && + dragDistance > TallMediaSettings.scrollLockDistanceThreshold) { + _ensureSwipeLock(); + } + + if (!_hasTriggeredLock && _gestureStartTimestamp != null) { + final elapsed = event.timeStamp - _gestureStartTimestamp!; + final elapsedMs = max(elapsed.inMilliseconds, 1); + final velocity = (dragDistance / elapsedMs) * 1000; + if (velocity > TallMediaSettings.scrollLockVelocityThreshold) { + _ensureSwipeLock(); + } + } + + if (widget.onRequestExpandSheet != null && + widget.overlayListenable.value && + !_requestedExpandDuringGesture) { + final delta = event.position.dy - _gestureStartPosition!.dy; + if (delta < -12) { + _requestedExpandDuringGesture = true; + widget.onRequestExpandSheet!.call(); + } + } + } + + void _handlePointerUp(PointerUpEvent event) { + if (!_trackingGesture) return; + final shouldNavigate = + widget.isVerticalSwipeMode && _shouldTriggerNavigation(event); + + if (shouldNavigate) { + _trackingGesture = false; + _requestedExpandDuringGesture = false; + _gestureStartPosition = null; + _gestureStartTimestamp = null; + _performNavigation(event.position.dy); + } else { + _endGesture(cancelled: false); + _releaseSwipeLock(); + } + } + + void _handlePointerCancel(PointerCancelEvent event) { + _endGesture(cancelled: true); + } + + void _ensureSwipeLock() { + if (_hasTriggeredLock) return; + _controller.setSwipeLock(SwipeLockReason.tallContent, true); + _hasTriggeredLock = true; + } + + void _releaseSwipeLock() { + if (!_hasTriggeredLock) return; + _controller.setSwipeLock(SwipeLockReason.tallContent, false); + _hasTriggeredLock = false; + } + + void _endGesture({required bool cancelled}) { + _trackingGesture = false; + if (cancelled) { + _releaseSwipeLock(); + } else { + // keep the lock until navigation decision is made in _handlePointerUp + if (!widget.isVerticalSwipeMode) { + _releaseSwipeLock(); + } + } + + _gestureStartPosition = null; + _gestureStartTimestamp = null; + _requestedExpandDuringGesture = false; + } + + bool _shouldTriggerNavigation(PointerUpEvent event) { + if (_gestureStartPosition == null || + _gestureStartTimestamp == null || + !_scrollController.hasClients) { + return false; + } + + final offset = _scrollController.offset; + final position = _scrollController.position; + final maxExtent = position.maxScrollExtent; + final activationExtent = _edgeActivationExtent(position); + final atTop = offset <= activationExtent; + final atBottom = offset >= max(0.0, maxExtent - activationExtent); + + if (!atTop && !atBottom) { + return false; + } + + final deltaY = event.position.dy - _gestureStartPosition!.dy; + final duration = event.timeStamp - _gestureStartTimestamp!; + final timeMs = max(duration.inMilliseconds, 1); + final velocity = (deltaY / timeMs) * 1000; + final distance = deltaY.abs(); + + final meetsDistance = distance >= TallMediaSettings.navigationDistanceThreshold; + final meetsVelocity = + velocity.abs() >= TallMediaSettings.navigationVelocityThreshold; + + final isSwipeDown = deltaY > 0; + + if (atTop && isSwipeDown && (meetsVelocity || meetsDistance)) { + return true; + } + + if (atBottom && !isSwipeDown && (meetsVelocity || meetsDistance)) { + return true; + } + + return false; + } + + void _performNavigation(double pointerY) { + _releaseSwipeLock(); + + final delta = pointerY - (_gestureStartPosition?.dy ?? pointerY); + final goingDown = delta > 0; + + if (goingDown) { + if (_controller.page > 0) { + _controller.previousPage(); + _maybeHaptic(); + } + } else { + if (_controller.page < _controller.totalPage - 1) { + _controller.nextPage(); + _maybeHaptic(); + } + } + } + + void _maybeHaptic() { + if (widget.enableHaptics) { + HapticFeedback.selectionClick(); + } + } + + double _edgeActivationExtent(ScrollPosition position) { + return max( + _kEdgeSlack, + position.viewportDimension * TallMediaSettings.edgeActivationRatio, + ); + } + + @override + Widget build(BuildContext context) { + return Listener( + onPointerDown: widget.isVerticalSwipeMode ? _handlePointerDown : null, + onPointerMove: widget.isVerticalSwipeMode ? _handlePointerMove : null, + onPointerUp: widget.isVerticalSwipeMode ? _handlePointerUp : null, + onPointerCancel: widget.isVerticalSwipeMode ? _handlePointerCancel : null, + child: ValueListenableBuilder( + valueListenable: widget.overlayListenable, + builder: (context, overlayVisible, _) { + final physics = overlayVisible + ? const NeverScrollableScrollPhysics() + : const ClampingScrollPhysics(); + + return NotificationListener( + onNotification: (notification) { + if (notification is ScrollUpdateNotification) { + _updateScrollIndicators(); + } + return false; + }, + child: Stack( + children: [ + SingleChildScrollView( + controller: _scrollController, + physics: physics, + child: widget.child, + ), + _TallScrollIndicators( + showInitialHint: _showInitialHint, + canScrollUp: _canScrollUp, + canScrollDown: _canScrollDown, + ), + ], + ), + ); + }, + ), + ); + } +} + +class _TallScrollIndicators extends StatelessWidget { + const _TallScrollIndicators({ + required this.showInitialHint, + required this.canScrollUp, + required this.canScrollDown, + }); + + final bool showInitialHint; + final ValueListenable canScrollUp; + final ValueListenable canScrollDown; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final hintColor = colorScheme.onSurface.withValues(alpha: 0.6); + + return IgnorePointer( + ignoring: true, + child: Column( + children: [ + ValueListenableBuilder( + valueListenable: canScrollUp, + builder: (context, value, _) => _IndicatorRibbon( + alignment: Alignment.topCenter, + visible: value, + color: hintColor, + showPulse: showInitialHint, + icon: Icons.keyboard_double_arrow_up_rounded, + ), + ), + const Spacer(), + ValueListenableBuilder( + valueListenable: canScrollDown, + builder: (context, value, _) => _IndicatorRibbon( + alignment: Alignment.bottomCenter, + visible: value, + color: hintColor, + showPulse: showInitialHint, + icon: Icons.keyboard_double_arrow_down_rounded, + ), + ), + ], + ), + ); + } +} + +class _IndicatorRibbon extends StatelessWidget { + const _IndicatorRibbon({ + required this.visible, + required this.alignment, + required this.color, + required this.icon, + required this.showPulse, + }); + + final bool visible; + final Alignment alignment; + final Color color; + final IconData icon; + final bool showPulse; + + @override + Widget build(BuildContext context) { + return AnimatedAlign( + duration: const Duration(milliseconds: 200), + alignment: alignment, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: visible || showPulse ? 1 : 0, + child: Container( + margin: const EdgeInsets.all(12), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: color.withValues(alpha: 0.24)), + ), + child: Icon( + icon, + size: 20, + color: color, + ), + ), + ), + ); + } +} diff --git a/lib/core/posts/details_pageview/src/post_details_page_view_controller.dart b/lib/core/posts/details_pageview/src/post_details_page_view_controller.dart index 63d1fa409d..f0e8fa03aa 100644 --- a/lib/core/posts/details_pageview/src/post_details_page_view_controller.dart +++ b/lib/core/posts/details_pageview/src/post_details_page_view_controller.dart @@ -14,6 +14,12 @@ import 'auto_slide_mixin.dart'; import 'constants.dart'; import 'post_details_page_view.dart'; +enum SwipeLockReason { + legacy, + zoom, + tallContent, +} + class PostDetailsPageViewController extends ChangeNotifier with AutomaticSlideMixin { PostDetailsPageViewController({ @@ -54,6 +60,7 @@ class PostDetailsPageViewController extends ChangeNotifier DraggableScrollableController(); final bool Function() checkIfLargeScreen; + final Set _swipeLocks = {}; int get page => currentPage.value; bool get isExpanded => sheetState.value.isExpanded; @@ -458,13 +465,30 @@ class PostDetailsPageViewController extends ChangeNotifier } void disableAllSwiping() { - swipe.value = false; - canPull.value = false; + setSwipeLock(SwipeLockReason.legacy, true); } void enableAllSwiping() { - swipe.value = true; - canPull.value = true; + setSwipeLock(SwipeLockReason.legacy, false); + } + + bool get isSwipeLocked => _swipeLocks.isNotEmpty; + + void setSwipeLock(SwipeLockReason reason, bool locked) { + final changed = locked + ? _swipeLocks.add(reason) + : _swipeLocks.remove(reason); + + if (!changed) return; + + final shouldDisable = _swipeLocks.isNotEmpty; + + if (swipe.value == !shouldDisable && canPull.value == !shouldDisable) { + return; + } + + swipe.value = !shouldDisable; + canPull.value = !shouldDisable; } void setDisplacement(double value) { @@ -512,12 +536,12 @@ class PostDetailsPageViewController extends ChangeNotifier if (!initialHideOverlay) { hideAllUI(); } - disableAllSwiping(); + setSwipeLock(SwipeLockReason.zoom, true); } else { if (!initialHideOverlay) { showAllUI(); } - enableAllSwiping(); + setSwipeLock(SwipeLockReason.zoom, false); } } diff --git a/lib/core/posts/post/src/pages/original_image_page.dart b/lib/core/posts/post/src/pages/original_image_page.dart index 912c01feb4..9335825a96 100644 --- a/lib/core/posts/post/src/pages/original_image_page.dart +++ b/lib/core/posts/post/src/pages/original_image_page.dart @@ -16,6 +16,8 @@ import '../../../../../foundation/mobile.dart'; import '../../../../../foundation/platform.dart'; import '../../../../configs/config/providers.dart'; import '../../../../images/booru_image.dart'; +import '../../../../settings/providers.dart'; +import '../../../details/src/utils/tall_media_classifier.dart'; import '../../../../widgets/widgets.dart'; import '../types/post.dart'; @@ -246,6 +248,26 @@ class __ImageViewerState extends ConsumerState<_ImageViewer> { @override Widget build(BuildContext context) { + final viewerSettings = ref.watch( + settingsProvider.select((value) => value.viewer), + ); + final tallSettings = viewerSettings.tallMedia; + + final mediaSize = MediaQuery.sizeOf(context); + final mediaPadding = MediaQuery.paddingOf(context); + final rawViewportHeight = mediaSize.height - mediaPadding.vertical; + final viewportHeight = rawViewportHeight > 0 ? rawViewportHeight : 0.0; + final viewportSize = Size(mediaSize.width, viewportHeight); + final contentSize = widget.contentSize ?? Size.zero; + + final disposition = classifyTallMedia( + settings: tallSettings, + viewportSize: viewportSize, + width: contentSize.width, + height: contentSize.height, + isVideo: false, + ); + return BooruImage( config: ref.watchConfigAuth, imageUrl: widget.imageUrl, @@ -254,7 +276,9 @@ class __ImageViewerState extends ConsumerState<_ImageViewer> { aspectRatio: widget.aspectRatio, imageHeight: widget.contentSize?.height, imageWidth: widget.contentSize?.width, - forceFill: true, + forceFill: false, + forceCover: false, // Never force cover when we want fit width + fitWidthForTallImages: disposition.shouldFitToWidth, placeholderWidget: ValueListenableBuilder( valueListenable: _controller.progress, builder: (context, progress, child) { diff --git a/lib/core/settings/src/types/settings.dart b/lib/core/settings/src/types/settings.dart index faa345f7b7..2b0ae482df 100644 --- a/lib/core/settings/src/types/settings.dart +++ b/lib/core/settings/src/types/settings.dart @@ -142,6 +142,7 @@ class Settings extends Equatable { slideshowTransitionType: SlideshowTransitionType.natural, videoAudioDefaultState: VideoAudioDefaultState.unspecified, videoPlayerEngine: VideoPlayerEngine.auto, + tallMedia: TallMediaSettings.defaults(), ), colors: null, safeMode: true, @@ -464,6 +465,7 @@ class ImageViewerSettings extends Equatable { required this.slideshowTransitionType, required this.videoAudioDefaultState, required this.videoPlayerEngine, + required this.tallMedia, }); ImageViewerSettings.fromJson(Map json) @@ -487,7 +489,10 @@ class ImageViewerSettings extends Equatable { : VideoAudioDefaultState.unspecified, videoPlayerEngine = json['videoPlayerEngine'] != null ? VideoPlayerEngine.values[json['videoPlayerEngine']] - : VideoPlayerEngine.auto; + : VideoPlayerEngine.auto, + tallMedia = json['tallMedia'] != null + ? TallMediaSettings.fromJson(json['tallMedia']) + : const TallMediaSettings.defaults(); final PostDetailsSwipeMode swipeMode; final PostDetailsOverlayInitialState postDetailsOverlayInitialState; @@ -496,6 +501,7 @@ class ImageViewerSettings extends Equatable { final SlideshowTransitionType slideshowTransitionType; final VideoAudioDefaultState videoAudioDefaultState; final VideoPlayerEngine videoPlayerEngine; + final TallMediaSettings tallMedia; ImageViewerSettings copyWith({ PostDetailsSwipeMode? swipeMode, @@ -505,15 +511,20 @@ class ImageViewerSettings extends Equatable { SlideshowTransitionType? slideshowTransitionType, VideoAudioDefaultState? videoAudioDefaultState, VideoPlayerEngine? videoPlayerEngine, + TallMediaSettings? tallMedia, }) { return ImageViewerSettings( swipeMode: swipeMode ?? this.swipeMode, - postDetailsOverlayInitialState: postDetailsOverlayInitialState ?? this.postDetailsOverlayInitialState, + postDetailsOverlayInitialState: + postDetailsOverlayInitialState ?? this.postDetailsOverlayInitialState, slideshowDirection: slideshowDirection ?? this.slideshowDirection, slideshowInterval: slideshowInterval ?? this.slideshowInterval, - slideshowTransitionType: slideshowTransitionType ?? this.slideshowTransitionType, - videoAudioDefaultState: videoAudioDefaultState ?? this.videoAudioDefaultState, + slideshowTransitionType: + slideshowTransitionType ?? this.slideshowTransitionType, + videoAudioDefaultState: + videoAudioDefaultState ?? this.videoAudioDefaultState, videoPlayerEngine: videoPlayerEngine ?? this.videoPlayerEngine, + tallMedia: tallMedia ?? this.tallMedia, ); } @@ -525,6 +536,7 @@ class ImageViewerSettings extends Equatable { 'slideshowTransitionType': slideshowTransitionType.index, 'videoAudioDefaultState': videoAudioDefaultState.index, 'videoPlayerEngine': videoPlayerEngine.index, + 'tallMedia': tallMedia.toJson(), }; @override @@ -536,6 +548,7 @@ class ImageViewerSettings extends Equatable { slideshowTransitionType, videoAudioDefaultState, videoPlayerEngine, + tallMedia, ]; bool get hidePostDetailsOverlay => @@ -554,6 +567,45 @@ class ImageViewerSettings extends Equatable { } } +class TallMediaSettings extends Equatable { + const TallMediaSettings({ + required this.enabled, + }); + + const TallMediaSettings.defaults() : enabled = true; + + TallMediaSettings.fromJson(Map json) + : enabled = json['enabled'] ?? true; + + final bool enabled; + + TallMediaSettings copyWith({ + bool? enabled, + }) { + return TallMediaSettings( + enabled: enabled ?? this.enabled, + ); + } + + Map toJson() => { + 'enabled': enabled, + }; + + @override + List get props => [enabled]; + + static const double aspectRatioThreshold = 2.15; + static const double minHeightPx = 1800; + static const double minViewportHeightRatio = 1.35; + static const double minPixelCount = 2000000; + static const double navigationVelocityThreshold = 2200; + static const double navigationDistanceThreshold = 240; + static const double edgeActivationRatio = 0.12; + static const double scrollLockVelocityThreshold = 180; + static const double scrollLockDistanceThreshold = 28; + static const double minScrollExtentPx = 48; +} + class ImageListingSettings extends Equatable { const ImageListingSettings({ required this.gridSize, diff --git a/test/core/posts/details/tall_media_classifier_test.dart b/test/core/posts/details/tall_media_classifier_test.dart new file mode 100644 index 0000000000..bd3eb11dd4 --- /dev/null +++ b/test/core/posts/details/tall_media_classifier_test.dart @@ -0,0 +1,91 @@ +// Flutter imports: +import 'package:flutter/widgets.dart'; + +// Package imports: +import 'package:flutter_test/flutter_test.dart'; + +// Project imports: +import 'package:boorusama/core/posts/details/src/utils/tall_media_classifier.dart'; +import 'package:boorusama/core/settings/src/types/settings.dart'; + +void main() { + const settings = TallMediaSettings.defaults(); + + test('marks extremely tall images as tall', () { + final disposition = classifyTallMedia( + settings: settings, + viewportSize: const Size(1080, 1920), + width: 1200, + height: 4200, + isVideo: false, + ); + + expect(disposition.isTall, isTrue); + expect(disposition.shouldFitToWidth, isTrue); + expect(disposition.hasScrollableExtent, isTrue); + }); + + test('does not mark marginal aspect ratios as tall', () { + final disposition = classifyTallMedia( + settings: settings, + viewportSize: const Size(1080, 1920), + width: 900, + height: 1500, + isVideo: false, + ); + + expect(disposition.isTall, isFalse); + expect(disposition.shouldFitToWidth, isFalse); + }); + + test('requires sufficient pixel density to be considered tall', () { + final disposition = classifyTallMedia( + settings: settings, + viewportSize: const Size(720, 1280), + width: 420, + height: 1600, + isVideo: false, + ); + + expect(disposition.isTall, isFalse); + }); + + test('ignores tall originals when scaled height fits viewport', () { + final disposition = classifyTallMedia( + settings: settings, + viewportSize: const Size(1080, 1920), + width: 4200, + height: 2800, + isVideo: false, + ); + + expect(disposition.isTall, isFalse); + }); + + test('videos are never classified as tall still images', () { + final disposition = classifyTallMedia( + settings: settings, + viewportSize: const Size(1080, 1920), + width: 1080, + height: 3600, + isVideo: true, + ); + + expect(disposition.isTall, isFalse); + }); + + test('disabled setting prevents tall classification', () { + const disabledSettings = TallMediaSettings(enabled: false); + + final disposition = classifyTallMedia( + settings: disabledSettings, + viewportSize: const Size(1080, 1920), + width: 1200, + height: 4200, + isVideo: false, + ); + + expect(disposition.isTall, isFalse); + expect(disposition.shouldFitToWidth, isFalse); + }); +}