From 44e4ddb57b6fefd93c6a6de0462b8417e191c686 Mon Sep 17 00:00:00 2001 From: omnessssssssss Date: Sun, 7 Sep 2025 23:18:10 -0400 Subject: [PATCH 1/6] feat: implement webtoon-style viewer for tall images - Add fitWidthForTallImages parameter to BooruImage widget - Always use BoxFit.fitWidth when fitWidthForTallImages flag is enabled - Remove aspect ratio constraints for tall images to allow proper fit-width - Detect very tall images (aspect ratio > 2.0) in post details - Use SingleChildScrollView for tall images instead of InteractiveViewer - Add proper status bar padding for full-screen tall image viewing - Preserve tap-to-toggle overlay functionality for tall images - Allow natural vertical scrolling instead of pan-to-info gesture - Update OriginalImagePage and PostDetailsImage to use new fit mode This change allows tall images to fit the screen width and be scrollable vertically, providing a webtoon-style viewing experience for long images. --- lib/core/images/booru_image.dart | 42 ++-- .../src/widgets/post_details_image.dart | 5 +- .../src/widgets/post_details_item.dart | 204 ++++++++++++------ .../post/src/pages/original_image_page.dart | 4 +- 4 files changed, 172 insertions(+), 83 deletions(-) diff --git a/lib/core/images/booru_image.dart b/lib/core/images/booru_image.dart index e39cb52f13..9c57e181ff 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,25 +143,34 @@ 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(); + + // Determine if we should use fit width for tall images + final imgHeight = imageHeight; + final imgWidth = imageWidth; + final shouldFitWidthForTallImage = fitWidthForTallImages; + final fit = this.fit ?? - // 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 - ? BoxFit.cover - : BoxFit.contain); + // Always use fit width when flag is enabled + (shouldFitWidthForTallImage + ? 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 + ? BoxFit.cover + : BoxFit.contain); final borderRadius = this.borderRadius ?? _defaultRadius; return imageUrl.isNotEmpty @@ -166,7 +180,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/widgets/post_details_image.dart b/lib/core/posts/details/src/widgets/post_details_image.dart index 4c3d04c871..57efd1b9ed 100644 --- a/lib/core/posts/details/src/widgets/post_details_image.dart +++ b/lib/core/posts/details/src/widgets/post_details_image.dart @@ -117,10 +117,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: true, 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 06a2cc8538..85a8b6b620 100644 --- a/lib/core/posts/details/src/widgets/post_details_item.dart +++ b/lib/core/posts/details/src/widgets/post_details_item.dart @@ -14,6 +14,7 @@ import '../../../../configs/config/types.dart'; import '../../../../configs/gesture/gesture.dart'; import '../../../../settings/providers.dart'; import '../../../../settings/settings.dart'; +import '../../../../settings/src/types/types.dart'; import '../../../../videos/play_pause_button.dart'; import '../../../../videos/providers.dart'; import '../../../../videos/sound_control_button.dart'; @@ -117,75 +118,146 @@ class PostDetailsItem extends ConsumerWidget { post, ) : null, - child: Stack( - alignment: Alignment.center, - children: [ - ValueListenableBuilder( - valueListenable: isInitPageListenable, - builder: (_, 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 - : null, - controller: pageViewController, - ); - }, - ), - 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(), - ), - ], + child: _buildPostContent( + context, + ref, + post, + authConfig, + imageUrlBuilder, + imageCacheManager, + isInitPageListenable, + initialThumbnailUrl, + pageViewController, + detailsController, + videoPlayerEngine, + onItemTap, ), ), ), ); } + + Widget _buildPostContent( + BuildContext context, + WidgetRef ref, + T post, + BooruConfigAuth authConfig, + String Function(T post)? imageUrlBuilder, + ImageCacheManager? imageCacheManager, + ValueListenable isInitPageListenable, + String? initialThumbnailUrl, + PostDetailsPageViewController pageViewController, + PostDetailsController detailsController, + VideoPlayerEngine videoPlayerEngine, + VoidCallback onItemTap, + ) { + // Check if this is a very tall image that should use webtoon viewer + final isVeryTallImage = !post.isVideo && + post.height != null && + post.width != null && + post.width! > 0 && + (post.height! / post.width!) > 2.0; // Lower threshold for more aggressive webtoon mode + + if (isVeryTallImage) { + // For very tall images, use scrollable view instead of InteractiveViewer + return GestureDetector( + onTap: onItemTap, // Still allow tap to show/hide overlay + child: SingleChildScrollView( + child: Padding( + padding: EdgeInsets.only( + top: MediaQuery.of(context).padding.top, // Status bar height + ), + child: ValueListenableBuilder( + valueListenable: isInitPageListenable, + builder: (_, isInitPage, _) { + return PostMedia( + post: post, + config: authConfig, + imageUrlBuilder: imageUrlBuilder, + imageCacheManager: imageCacheManager, + thumbnailUrlBuilder: + isInitPage && initialThumbnailUrl != null + ? (Post _) => initialThumbnailUrl + : null, + controller: pageViewController, + ); + }, + ), + ), + ), + ); + } + + // Normal (non-tall) image layout + return Stack( + alignment: Alignment.center, + children: [ + ValueListenableBuilder( + valueListenable: isInitPageListenable, + builder: (_, 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 + : null, + controller: pageViewController, + ); + }, + ), + if (post.isVideo) + Align( + alignment: Alignment.bottomRight, + child: ValueListenableBuilder( + valueListenable: pageViewController.sheetState, + builder: (_, state, _) => 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(), + ), + ), + ], + ); + } } 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..c810bcd99b 100644 --- a/lib/core/posts/post/src/pages/original_image_page.dart +++ b/lib/core/posts/post/src/pages/original_image_page.dart @@ -254,7 +254,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: true, placeholderWidget: ValueListenableBuilder( valueListenable: _controller.progress, builder: (context, progress, child) { From 5c7c495308c42fcaae6ad49639037773d3fa271b Mon Sep 17 00:00:00 2001 From: omnessssssssss Date: Mon, 8 Sep 2025 12:39:30 -0400 Subject: [PATCH 2/6] Remove unused variables and fix linter warnings --- lib/core/images/booru_image.dart | 33 ++++++++--------- .../src/widgets/post_details_item.dart | 36 ++++++++----------- 2 files changed, 29 insertions(+), 40 deletions(-) diff --git a/lib/core/images/booru_image.dart b/lib/core/images/booru_image.dart index 9c57e181ff..ce4e174a98 100644 --- a/lib/core/images/booru_image.dart +++ b/lib/core/images/booru_image.dart @@ -143,34 +143,31 @@ class BooruRawImage extends StatelessWidget { ); return NullableAspectRatio( - aspectRatio: forceCover || fit == BoxFit.contain || fitWidthForTallImages ? 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(); - - // Determine if we should use fit width for tall images - final imgHeight = imageHeight; - final imgWidth = imageWidth; - final shouldFitWidthForTallImage = fitWidthForTallImages; - + final fit = this.fit ?? // Always use fit width when flag is enabled - (shouldFitWidthForTallImage + (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 - ? BoxFit.cover - : BoxFit.contain); + _shouldForceFill( + constraints.biggest, + imageWidth, + imageHeight, + ) + ? BoxFit.fill + // Cover is for the standard grid that crops the image to fit the aspect ratio + : forceCover + ? BoxFit.cover + : BoxFit.contain); final borderRadius = this.borderRadius ?? _defaultRadius; return imageUrl.isNotEmpty 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 85a8b6b620..1963b20143 100644 --- a/lib/core/posts/details/src/widgets/post_details_item.dart +++ b/lib/core/posts/details/src/widgets/post_details_item.dart @@ -137,7 +137,7 @@ class PostDetailsItem extends ConsumerWidget { ); } - Widget _buildPostContent( + Widget _buildPostContent( BuildContext context, WidgetRef ref, T post, @@ -152,11 +152,7 @@ class PostDetailsItem extends ConsumerWidget { VoidCallback onItemTap, ) { // Check if this is a very tall image that should use webtoon viewer - final isVeryTallImage = !post.isVideo && - post.height != null && - post.width != null && - post.width! > 0 && - (post.height! / post.width!) > 2.0; // Lower threshold for more aggressive webtoon mode + final isVeryTallImage = !post.isVideo && (post.height / post.width) > 2.0; if (isVeryTallImage) { // For very tall images, use scrollable view instead of InteractiveViewer @@ -175,9 +171,8 @@ class PostDetailsItem extends ConsumerWidget { config: authConfig, imageUrlBuilder: imageUrlBuilder, imageCacheManager: imageCacheManager, - thumbnailUrlBuilder: - isInitPage && initialThumbnailUrl != null - ? (Post _) => initialThumbnailUrl + thumbnailUrlBuilder: isInitPage && initialThumbnailUrl != null + ? (_) => initialThumbnailUrl : null, controller: pageViewController, ); @@ -201,11 +196,8 @@ class PostDetailsItem extends ConsumerWidget { 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 + thumbnailUrlBuilder: isInitPage && initialThumbnailUrl != null + ? (_) => initialThumbnailUrl : null, controller: pageViewController, ); @@ -216,7 +208,8 @@ class PostDetailsItem extends ConsumerWidget { alignment: Alignment.bottomRight, child: ValueListenableBuilder( valueListenable: pageViewController.sheetState, - builder: (_, state, _) => state.isExpanded && !context.isLargeScreen + builder: (_, state, _) => + state.isExpanded && !context.isLargeScreen ? Padding( padding: const EdgeInsets.all(8), child: Row( @@ -243,13 +236,12 @@ class PostDetailsItem extends ConsumerWidget { }, ), VideoSoundScope( - builder: (context, soundOn) => - SoundControlButton( - padding: const EdgeInsets.all(8), - soundOn: soundOn, - onSoundChanged: (value) => - ref.setGlobalVideoSound(value), - ), + builder: (context, soundOn) => SoundControlButton( + padding: const EdgeInsets.all(8), + soundOn: soundOn, + onSoundChanged: (value) => + ref.setGlobalVideoSound(value), + ), ), ], ), From 4315cb93c0aa94a55cbd6c9a5fcebff41f5a02ab Mon Sep 17 00:00:00 2001 From: omnessssssssss Date: Mon, 8 Sep 2025 22:35:18 -0400 Subject: [PATCH 3/6] refactor: inline post content method and fix generic type issues - Remove _buildPostContent method and inline content as requested in PR review - Fix PostDetailsImage generic type parameter to properly handle T extends Post - Fix state class to maintain generic type information --- .../src/widgets/post_details_image.dart | 4 +- .../src/widgets/post_details_item.dart | 232 ++++++++---------- .../posts/details/src/widgets/post_media.dart | 2 +- 3 files changed, 105 insertions(+), 133 deletions(-) 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 57efd1b9ed..f5df3299f6 100644 --- a/lib/core/posts/details/src/widgets/post_details_image.dart +++ b/lib/core/posts/details/src/widgets/post_details_image.dart @@ -33,10 +33,10 @@ class PostDetailsImage extends ConsumerStatefulWidget { final T post; @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 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 1963b20143..59368d5b58 100644 --- a/lib/core/posts/details/src/widgets/post_details_item.dart +++ b/lib/core/posts/details/src/widgets/post_details_item.dart @@ -118,138 +118,110 @@ class PostDetailsItem extends ConsumerWidget { post, ) : null, - child: _buildPostContent( - context, - ref, - post, - authConfig, - imageUrlBuilder, - imageCacheManager, - isInitPageListenable, - initialThumbnailUrl, - pageViewController, - detailsController, - videoPlayerEngine, - onItemTap, - ), - ), - ), - ); - } - - Widget _buildPostContent( - BuildContext context, - WidgetRef ref, - T post, - BooruConfigAuth authConfig, - String Function(T post)? imageUrlBuilder, - ImageCacheManager? imageCacheManager, - ValueListenable isInitPageListenable, - String? initialThumbnailUrl, - PostDetailsPageViewController pageViewController, - PostDetailsController detailsController, - VideoPlayerEngine videoPlayerEngine, - VoidCallback onItemTap, - ) { - // Check if this is a very tall image that should use webtoon viewer - final isVeryTallImage = !post.isVideo && (post.height / post.width) > 2.0; - - if (isVeryTallImage) { - // For very tall images, use scrollable view instead of InteractiveViewer - return GestureDetector( - onTap: onItemTap, // Still allow tap to show/hide overlay - child: SingleChildScrollView( - child: Padding( - padding: EdgeInsets.only( - top: MediaQuery.of(context).padding.top, // Status bar height - ), - child: ValueListenableBuilder( - valueListenable: isInitPageListenable, - builder: (_, isInitPage, _) { - return PostMedia( - post: post, - config: authConfig, - imageUrlBuilder: imageUrlBuilder, - imageCacheManager: imageCacheManager, - thumbnailUrlBuilder: isInitPage && initialThumbnailUrl != null - ? (_) => initialThumbnailUrl - : null, - controller: pageViewController, - ); - }, - ), - ), - ), - ); - } - - // Normal (non-tall) image layout - return Stack( - alignment: Alignment.center, - children: [ - ValueListenableBuilder( - valueListenable: isInitPageListenable, - builder: (_, 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 - ? (_) => initialThumbnailUrl - : null, - controller: pageViewController, + child: () { + // Check if this is a very tall image that should use webtoon viewer + final isVeryTallImage = !post.isVideo && (post.height / post.width) > 2.0; + + if (isVeryTallImage) { + // For very tall images, use scrollable view instead of InteractiveViewer + return GestureDetector( + onTap: onItemTap, // Still allow tap to show/hide overlay + child: SingleChildScrollView( + child: Padding( + padding: EdgeInsets.only( + top: MediaQuery.of(context).padding.top, // Status bar height + ), + child: ValueListenableBuilder( + valueListenable: isInitPageListenable, + builder: (_, isInitPage, _) { + return PostMedia( + post: post, + config: authConfig, + imageUrlBuilder: imageUrlBuilder, + imageCacheManager: imageCacheManager, + thumbnailUrlBuilder: isInitPage && initialThumbnailUrl != null + ? (_) => initialThumbnailUrl + : null, + controller: pageViewController, + ); + }, + ), + ), + ), + ); + } + + // Normal (non-tall) image layout + return Stack( + alignment: Alignment.center, + children: [ + ValueListenableBuilder( + valueListenable: isInitPageListenable, + builder: (_, 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 + ? (_) => initialThumbnailUrl + : null, + controller: pageViewController, + ); + }, + ), + if (post.isVideo) + Align( + alignment: Alignment.bottomRight, + child: ValueListenableBuilder( + valueListenable: pageViewController.sheetState, + builder: (_, state, _) => + 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 (post.isVideo) - Align( - alignment: Alignment.bottomRight, - child: ValueListenableBuilder( - valueListenable: pageViewController.sheetState, - builder: (_, state, _) => - 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(), - ), - ), - ], + ), ); } } diff --git a/lib/core/posts/details/src/widgets/post_media.dart b/lib/core/posts/details/src/widgets/post_media.dart index a7ab55885c..5f404323e2 100644 --- a/lib/core/posts/details/src/widgets/post_media.dart +++ b/lib/core/posts/details/src/widgets/post_media.dart @@ -108,7 +108,7 @@ class PostMedia extends ConsumerWidget { ), ], ) - : PostDetailsImage( + : PostDetailsImage( heroTag: heroTag, imageUrlBuilder: imageUrlBuilder, thumbnailUrlBuilder: thumbnailUrlBuilder, From c1fa3ae78dc9c5facfe05fcddff06e2c129841c1 Mon Sep 17 00:00:00 2001 From: omnessssssssss Date: Mon, 8 Sep 2025 22:42:46 -0400 Subject: [PATCH 4/6] fix: restore swipe-up for post info on tall images when UI is visible - Add swipe-up gesture detection for tall images when overlay UI is showing - Disable scrolling when UI is visible to allow swipe gesture to work - Enable normal scrolling when UI is hidden for viewing tall images --- .../src/widgets/post_details_item.dart | 60 ++++++++++++------- 1 file changed, 37 insertions(+), 23 deletions(-) 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 59368d5b58..a4446cd9ee 100644 --- a/lib/core/posts/details/src/widgets/post_details_item.dart +++ b/lib/core/posts/details/src/widgets/post_details_item.dart @@ -124,30 +124,44 @@ class PostDetailsItem extends ConsumerWidget { if (isVeryTallImage) { // For very tall images, use scrollable view instead of InteractiveViewer - return GestureDetector( - onTap: onItemTap, // Still allow tap to show/hide overlay - child: SingleChildScrollView( - child: Padding( - padding: EdgeInsets.only( - top: MediaQuery.of(context).padding.top, // Status bar height + return ValueListenableBuilder( + valueListenable: pageViewController.overlay, + builder: (context, overlayVisible, child) { + return GestureDetector( + onTap: onItemTap, // Still allow tap to show/hide overlay + onVerticalDragUpdate: overlayVisible ? (details) { + // When UI is visible and user drags up, expand the sheet + if (details.primaryDelta! < -10) { + pageViewController.expandToSnapPoint(); + } + } : null, + child: SingleChildScrollView( + physics: overlayVisible + ? const NeverScrollableScrollPhysics() // Disable scroll when UI is visible to allow swipe-up + : const ClampingScrollPhysics(), // Enable scroll when UI is hidden + child: Padding( + padding: EdgeInsets.only( + top: MediaQuery.of(context).padding.top, // Status bar height + ), + child: ValueListenableBuilder( + valueListenable: isInitPageListenable, + builder: (_, isInitPage, _) { + return PostMedia( + post: post, + config: authConfig, + imageUrlBuilder: imageUrlBuilder, + imageCacheManager: imageCacheManager, + thumbnailUrlBuilder: isInitPage && initialThumbnailUrl != null + ? (_) => initialThumbnailUrl + : null, + controller: pageViewController, + ); + }, + ), + ), ), - child: ValueListenableBuilder( - valueListenable: isInitPageListenable, - builder: (_, isInitPage, _) { - return PostMedia( - post: post, - config: authConfig, - imageUrlBuilder: imageUrlBuilder, - imageCacheManager: imageCacheManager, - thumbnailUrlBuilder: isInitPage && initialThumbnailUrl != null - ? (_) => initialThumbnailUrl - : null, - controller: pageViewController, - ); - }, - ), - ), - ), + ); + }, ); } From 918b2fdbb0cb0ae6eaae8ba4caea0348947b6cc9 Mon Sep 17 00:00:00 2001 From: omnessssssssss Date: Wed, 17 Sep 2025 00:05:25 -0400 Subject: [PATCH 5/6] feat: add tall media viewer with scrollable support for tall images Implements intelligent tall media detection and specialized scrolling behavior for very tall images/comics. Adds settings-driven classification system to determine when content should use scrollable viewer versus standard interactive viewer. (Trying Codex instead of Claude this time :P) --- .../src/utils/tall_media_classifier.dart | 137 ++++++ .../src/widgets/post_details_image.dart | 10 +- .../src/widgets/post_details_item.dart | 140 +++--- .../posts/details/src/widgets/post_media.dart | 3 + .../src/widgets/tall_media_scroller.dart | 420 ++++++++++++++++++ .../post_details_page_view_controller.dart | 36 +- .../post/src/pages/original_image_page.dart | 26 +- lib/core/settings/src/types/settings.dart | 136 +++++- .../details/tall_media_classifier_test.dart | 69 +++ 9 files changed, 897 insertions(+), 80 deletions(-) create mode 100644 lib/core/posts/details/src/utils/tall_media_classifier.dart create mode 100644 lib/core/posts/details/src/widgets/tall_media_scroller.dart create mode 100644 test/core/posts/details/tall_media_classifier_test.dart 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..cdbe0090e3 --- /dev/null +++ b/lib/core/posts/details/src/utils/tall_media_classifier.dart @@ -0,0 +1,137 @@ +// 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; +} + +class TallMediaClassifier { + const TallMediaClassifier({ + required this.settings, + required this.viewportSize, + }); + + final TallMediaSettings settings; + final Size viewportSize; + + TallMediaDisposition classify({ + required double width, + required double height, + required bool isVideo, + }) { + if (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); + final rawScrollExtent = projectedHeight - viewportSize.height; + final hasScroll = rawScrollExtent > settings.minScrollExtentPx; + + final meetsAspect = aspectRatio >= settings.aspectRatioThreshold; + final meetsHeight = height >= settings.minHeightPx; + final meetsViewport = viewportRatio >= settings.minViewportHeightRatio; + final meetsPixels = pixelCount >= settings.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) { + 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 f5df3299f6..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 @@ -121,7 +125,7 @@ class _PostDetailsImageState extends ConsumerState extends ConsumerWidget { const PostDetailsItem({ @@ -118,84 +123,88 @@ class PostDetailsItem extends ConsumerWidget { ) : null, child: () { - // Check if this is a very tall image that should use webtoon viewer - final isVeryTallImage = !post.isVideo && (post.height / post.width) > 2.0; + 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 = + TallMediaClassifier( + settings: tallSettings, + viewportSize: viewportSize, + ).classify( + width: post.width, + height: post.height, + isVideo: post.isVideo, + ); + final useTallScroller = + disposition.isTall && disposition.hasScrollableExtent; - if (isVeryTallImage) { - // For very tall images, use scrollable view instead of InteractiveViewer + Widget buildPostMedia() { return ValueListenableBuilder( - valueListenable: pageViewController.overlay, - builder: (context, overlayVisible, child) { - return GestureDetector( - onTap: onItemTap, // Still allow tap to show/hide overlay - onVerticalDragUpdate: overlayVisible ? (details) { - // When UI is visible and user drags up, expand the sheet - if (details.primaryDelta! < -10) { - pageViewController.expandToSnapPoint(); - } - } : null, - child: SingleChildScrollView( - physics: overlayVisible - ? const NeverScrollableScrollPhysics() // Disable scroll when UI is visible to allow swipe-up - : const ClampingScrollPhysics(), // Enable scroll when UI is hidden - child: Padding( - padding: EdgeInsets.only( - top: MediaQuery.of(context).padding.top, // Status bar height - ), - child: ValueListenableBuilder( - valueListenable: isInitPageListenable, - builder: (_, isInitPage, _) { - return PostMedia( - post: post, - config: authConfig, - imageUrlBuilder: imageUrlBuilder, - imageCacheManager: imageCacheManager, - thumbnailUrlBuilder: isInitPage && initialThumbnailUrl != null - ? (_) => initialThumbnailUrl - : null, - controller: pageViewController, - ); - }, - ), - ), - ), + valueListenable: isInitPageListenable, + builder: (context, isInitPage, _) { + return PostMedia( + post: post, + config: authConfig, + imageUrlBuilder: imageUrlBuilder, + imageCacheManager: imageCacheManager, + thumbnailUrlBuilder: + isInitPage && initialThumbnailUrl != null + ? (_) => initialThumbnailUrl + : null, + controller: pageViewController, + fitWidthForTallImages: disposition.shouldFitToWidth, ); }, ); } - // Normal (non-tall) image layout + 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: [ - ValueListenableBuilder( - valueListenable: isInitPageListenable, - builder: (_, 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 - ? (_) => initialThumbnailUrl - : null, - controller: pageViewController, - ); - }, - ), + buildPostMedia(), if (post.isVideo) Align( alignment: Alignment.bottomRight, child: ValueListenableBuilder( valueListenable: pageViewController.sheetState, - builder: (_, state, _) => + builder: (context, state, _) => state.isExpanded && !context.isLargeScreen ? Padding( padding: const EdgeInsets.all(8), child: Row( children: [ - // duplicate codes, maybe refactor later PlayPauseButton( isPlaying: detailsController.isVideoPlaying, onPlayingChanged: (value) { @@ -211,18 +220,17 @@ class PostDetailsItem extends ConsumerWidget { post.isWebm, videoPlayerEngine.isDefault, ); - } else { - // do nothing } }, ), VideoSoundScope( - builder: (context, soundOn) => SoundControlButton( - padding: const EdgeInsets.all(8), - soundOn: soundOn, - onSoundChanged: (value) => - ref.setGlobalVideoSound(value), - ), + builder: (context, soundOn) => + SoundControlButton( + padding: const EdgeInsets.all(8), + soundOn: soundOn, + onSoundChanged: (value) => + ref.setGlobalVideoSound(value), + ), ), ], ), diff --git a/lib/core/posts/details/src/widgets/post_media.dart b/lib/core/posts/details/src/widgets/post_media.dart index e53a11d4fa..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); @@ -115,6 +117,7 @@ class PostMedia extends ConsumerWidget { 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..cb6df67ed9 --- /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 > _settings.scrollLockDistanceThreshold) { + _ensureSwipeLock(); + } + + if (!_hasTriggeredLock && _gestureStartTimestamp != null) { + final elapsed = event.timeStamp - _gestureStartTimestamp!; + final elapsedMs = max(elapsed.inMilliseconds, 1); + final velocity = (dragDistance / elapsedMs) * 1000; + if (velocity > _settings.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 >= _settings.navigationDistanceThreshold; + final meetsVelocity = + velocity.abs() >= _settings.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 * _settings.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 c810bcd99b..9fa1429584 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,28 @@ 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 = + TallMediaClassifier( + settings: tallSettings, + viewportSize: viewportSize, + ).classify( + width: contentSize.width, + height: contentSize.height, + isVideo: false, + ); + return BooruImage( config: ref.watchConfigAuth, imageUrl: widget.imageUrl, @@ -256,7 +280,7 @@ class __ImageViewerState extends ConsumerState<_ImageViewer> { imageWidth: widget.contentSize?.width, forceFill: false, forceCover: false, // Never force cover when we want fit width - fitWidthForTallImages: true, + 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..9894358b94 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,121 @@ class ImageViewerSettings extends Equatable { } } +class TallMediaSettings extends Equatable { + const TallMediaSettings({ + required this.aspectRatioThreshold, + required this.minHeightPx, + required this.minViewportHeightRatio, + required this.minPixelCount, + required this.navigationVelocityThreshold, + required this.navigationDistanceThreshold, + required this.edgeActivationRatio, + required this.scrollLockVelocityThreshold, + required this.scrollLockDistanceThreshold, + required this.minScrollExtentPx, + }); + + const TallMediaSettings.defaults() + : aspectRatioThreshold = 2.15, + minHeightPx = 1800, + minViewportHeightRatio = 1.35, + minPixelCount = 2000000, + navigationVelocityThreshold = 2200, + navigationDistanceThreshold = 240, + edgeActivationRatio = 0.12, + scrollLockVelocityThreshold = 180, + scrollLockDistanceThreshold = 28, + minScrollExtentPx = 48; + + TallMediaSettings.fromJson(Map json) + : aspectRatioThreshold = + (json['aspectRatioThreshold'] as num?)?.toDouble() ?? 2.15, + minHeightPx = (json['minHeightPx'] as num?)?.toDouble() ?? 1800, + minViewportHeightRatio = + (json['minViewportHeightRatio'] as num?)?.toDouble() ?? 1.35, + minPixelCount = (json['minPixelCount'] as num?)?.toDouble() ?? 2000000, + navigationVelocityThreshold = + (json['navigationVelocityThreshold'] as num?)?.toDouble() ?? 2200, + navigationDistanceThreshold = + (json['navigationDistanceThreshold'] as num?)?.toDouble() ?? 240, + edgeActivationRatio = + (json['edgeActivationRatio'] as num?)?.toDouble() ?? 0.12, + scrollLockVelocityThreshold = + (json['scrollLockVelocityThreshold'] as num?)?.toDouble() ?? 180, + scrollLockDistanceThreshold = + (json['scrollLockDistanceThreshold'] as num?)?.toDouble() ?? 28, + minScrollExtentPx = (json['minScrollExtentPx'] as num?)?.toDouble() ?? 48; + + final double aspectRatioThreshold; + final double minHeightPx; + final double minViewportHeightRatio; + final double minPixelCount; + final double navigationVelocityThreshold; + final double navigationDistanceThreshold; + final double edgeActivationRatio; + final double scrollLockVelocityThreshold; + final double scrollLockDistanceThreshold; + final double minScrollExtentPx; + + TallMediaSettings copyWith({ + double? aspectRatioThreshold, + double? minHeightPx, + double? minViewportHeightRatio, + double? minPixelCount, + double? navigationVelocityThreshold, + double? navigationDistanceThreshold, + double? edgeActivationRatio, + double? scrollLockVelocityThreshold, + double? scrollLockDistanceThreshold, + double? minScrollExtentPx, + }) { + return TallMediaSettings( + aspectRatioThreshold: aspectRatioThreshold ?? this.aspectRatioThreshold, + minHeightPx: minHeightPx ?? this.minHeightPx, + minViewportHeightRatio: + minViewportHeightRatio ?? this.minViewportHeightRatio, + minPixelCount: minPixelCount ?? this.minPixelCount, + navigationVelocityThreshold: + navigationVelocityThreshold ?? this.navigationVelocityThreshold, + navigationDistanceThreshold: + navigationDistanceThreshold ?? this.navigationDistanceThreshold, + edgeActivationRatio: edgeActivationRatio ?? this.edgeActivationRatio, + scrollLockVelocityThreshold: + scrollLockVelocityThreshold ?? this.scrollLockVelocityThreshold, + scrollLockDistanceThreshold: + scrollLockDistanceThreshold ?? this.scrollLockDistanceThreshold, + minScrollExtentPx: minScrollExtentPx ?? this.minScrollExtentPx, + ); + } + + Map toJson() => { + 'aspectRatioThreshold': aspectRatioThreshold, + 'minHeightPx': minHeightPx, + 'minViewportHeightRatio': minViewportHeightRatio, + 'minPixelCount': minPixelCount, + 'navigationVelocityThreshold': navigationVelocityThreshold, + 'navigationDistanceThreshold': navigationDistanceThreshold, + 'edgeActivationRatio': edgeActivationRatio, + 'scrollLockVelocityThreshold': scrollLockVelocityThreshold, + 'scrollLockDistanceThreshold': scrollLockDistanceThreshold, + 'minScrollExtentPx': minScrollExtentPx, + }; + + @override + List get props => [ + aspectRatioThreshold, + minHeightPx, + minViewportHeightRatio, + minPixelCount, + navigationVelocityThreshold, + navigationDistanceThreshold, + edgeActivationRatio, + scrollLockVelocityThreshold, + scrollLockDistanceThreshold, + minScrollExtentPx, + ]; +} + 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..7e4f5e0f85 --- /dev/null +++ b/test/core/posts/details/tall_media_classifier_test.dart @@ -0,0 +1,69 @@ +// 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(); + + TallMediaClassifier classifier(Size viewport) => + TallMediaClassifier(settings: settings, viewportSize: viewport); + + test('marks extremely tall images as tall', () { + final disposition = classifier(const Size(1080, 1920)).classify( + 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 = classifier(const Size(1080, 1920)).classify( + 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 = classifier(const Size(720, 1280)).classify( + width: 420, + height: 1600, + isVideo: false, + ); + + expect(disposition.isTall, isFalse); + }); + + test('ignores tall originals when scaled height fits viewport', () { + final disposition = classifier(const Size(1080, 1920)).classify( + width: 4200, + height: 2800, + isVideo: false, + ); + + expect(disposition.isTall, isFalse); + }); + + test('videos are never classified as tall still images', () { + final disposition = classifier(const Size(1080, 1920)).classify( + width: 1080, + height: 3600, + isVideo: true, + ); + + expect(disposition.isTall, isFalse); + }); +} From 572e391820d819bc5ce7dfdd20375849bbb95eb3 Mon Sep 17 00:00:00 2001 From: omnessssssssss Date: Wed, 17 Sep 2025 21:38:54 -0400 Subject: [PATCH 6/6] refactor(tall-media): simplify settings and convert classifier to function - Reduce TallMediaSettings from 10 complex parameters to single enabled toggle - Convert TallMediaClassifier class to classifyTallMedia function - Move threshold values to static constants for consistent behavior - Add feature disable capability via enabled setting - Update all usage sites and tests for new function-based API --- .../src/utils/tall_media_classifier.dart | 134 ++++++++---------- .../src/widgets/post_details_item.dart | 16 +-- .../src/widgets/tall_media_scroller.dart | 10 +- .../post/src/pages/original_image_page.dart | 16 +-- lib/core/settings/src/types/settings.dart | 116 +++------------ .../details/tall_media_classifier_test.dart | 38 +++-- 6 files changed, 132 insertions(+), 198 deletions(-) diff --git a/lib/core/posts/details/src/utils/tall_media_classifier.dart b/lib/core/posts/details/src/utils/tall_media_classifier.dart index cdbe0090e3..40fedde77a 100644 --- a/lib/core/posts/details/src/utils/tall_media_classifier.dart +++ b/lib/core/posts/details/src/utils/tall_media_classifier.dart @@ -52,86 +52,78 @@ class TallMediaDisposition { bool get hasScrollableExtent => scrollExtent > 0; } -class TallMediaClassifier { - const TallMediaClassifier({ - required this.settings, - required this.viewportSize, - }); - - final TallMediaSettings settings; - final Size viewportSize; - - TallMediaDisposition classify({ - required double width, - required double height, - required bool isVideo, - }) { - if (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; +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; - final pixelCount = width * height; - final projectedHeight = _projectedHeight(width, height); - final rawScrollExtent = projectedHeight - viewportSize.height; - final hasScroll = rawScrollExtent > settings.minScrollExtentPx; - - final meetsAspect = aspectRatio >= settings.aspectRatioThreshold; - final meetsHeight = height >= settings.minHeightPx; - final meetsViewport = viewportRatio >= settings.minViewportHeightRatio; - final meetsPixels = pixelCount >= settings.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( + 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, - scrollExtent: scrollExtent > 0 ? scrollExtent : 0, ); } - double _projectedHeight(double width, double height) { - final viewportWidth = viewportSize.width; - if (width <= 0 || height <= 0 || viewportWidth <= 0) { - return height; - } + final scrollExtent = rawScrollExtent > 0 ? rawScrollExtent : 0.0; + + return TallMediaDisposition.tall( + aspectRatio: aspectRatio, + viewportRatio: viewportRatio, + pixelCount: pixelCount, + scrollExtent: scrollExtent > 0 ? scrollExtent : 0, + ); +} - final scale = viewportWidth / width; - final clampedScale = scale < 1 ? scale : 1.0; - return height * clampedScale; +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_item.dart b/lib/core/posts/details/src/widgets/post_details_item.dart index 8915fdbd66..fa05573547 100644 --- a/lib/core/posts/details/src/widgets/post_details_item.dart +++ b/lib/core/posts/details/src/widgets/post_details_item.dart @@ -141,15 +141,13 @@ class PostDetailsItem extends ConsumerWidget { ), ); - final disposition = - TallMediaClassifier( - settings: tallSettings, - viewportSize: viewportSize, - ).classify( - width: post.width, - height: post.height, - isVideo: post.isVideo, - ); + final disposition = classifyTallMedia( + settings: tallSettings, + viewportSize: viewportSize, + width: post.width, + height: post.height, + isVideo: post.isVideo, + ); final useTallScroller = disposition.isTall && disposition.hasScrollableExtent; diff --git a/lib/core/posts/details/src/widgets/tall_media_scroller.dart b/lib/core/posts/details/src/widgets/tall_media_scroller.dart index cb6df67ed9..f95f6e01e1 100644 --- a/lib/core/posts/details/src/widgets/tall_media_scroller.dart +++ b/lib/core/posts/details/src/widgets/tall_media_scroller.dart @@ -139,7 +139,7 @@ class _TallMediaScrollerState extends State { // Lock swipe if user drags beyond threshold even when detection is late final dragDistance = (event.position.dy - _gestureStartPosition!.dy).abs(); if (!_hasTriggeredLock && - dragDistance > _settings.scrollLockDistanceThreshold) { + dragDistance > TallMediaSettings.scrollLockDistanceThreshold) { _ensureSwipeLock(); } @@ -147,7 +147,7 @@ class _TallMediaScrollerState extends State { final elapsed = event.timeStamp - _gestureStartTimestamp!; final elapsedMs = max(elapsed.inMilliseconds, 1); final velocity = (dragDistance / elapsedMs) * 1000; - if (velocity > _settings.scrollLockVelocityThreshold) { + if (velocity > TallMediaSettings.scrollLockVelocityThreshold) { _ensureSwipeLock(); } } @@ -236,9 +236,9 @@ class _TallMediaScrollerState extends State { final velocity = (deltaY / timeMs) * 1000; final distance = deltaY.abs(); - final meetsDistance = distance >= _settings.navigationDistanceThreshold; + final meetsDistance = distance >= TallMediaSettings.navigationDistanceThreshold; final meetsVelocity = - velocity.abs() >= _settings.navigationVelocityThreshold; + velocity.abs() >= TallMediaSettings.navigationVelocityThreshold; final isSwipeDown = deltaY > 0; @@ -281,7 +281,7 @@ class _TallMediaScrollerState extends State { double _edgeActivationExtent(ScrollPosition position) { return max( _kEdgeSlack, - position.viewportDimension * _settings.edgeActivationRatio, + position.viewportDimension * TallMediaSettings.edgeActivationRatio, ); } 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 9fa1429584..9335825a96 100644 --- a/lib/core/posts/post/src/pages/original_image_page.dart +++ b/lib/core/posts/post/src/pages/original_image_page.dart @@ -260,15 +260,13 @@ class __ImageViewerState extends ConsumerState<_ImageViewer> { final viewportSize = Size(mediaSize.width, viewportHeight); final contentSize = widget.contentSize ?? Size.zero; - final disposition = - TallMediaClassifier( - settings: tallSettings, - viewportSize: viewportSize, - ).classify( - width: contentSize.width, - height: contentSize.height, - isVideo: false, - ); + final disposition = classifyTallMedia( + settings: tallSettings, + viewportSize: viewportSize, + width: contentSize.width, + height: contentSize.height, + isVideo: false, + ); return BooruImage( config: ref.watchConfigAuth, diff --git a/lib/core/settings/src/types/settings.dart b/lib/core/settings/src/types/settings.dart index 9894358b94..2b0ae482df 100644 --- a/lib/core/settings/src/types/settings.dart +++ b/lib/core/settings/src/types/settings.dart @@ -569,117 +569,41 @@ class ImageViewerSettings extends Equatable { class TallMediaSettings extends Equatable { const TallMediaSettings({ - required this.aspectRatioThreshold, - required this.minHeightPx, - required this.minViewportHeightRatio, - required this.minPixelCount, - required this.navigationVelocityThreshold, - required this.navigationDistanceThreshold, - required this.edgeActivationRatio, - required this.scrollLockVelocityThreshold, - required this.scrollLockDistanceThreshold, - required this.minScrollExtentPx, + required this.enabled, }); - const TallMediaSettings.defaults() - : aspectRatioThreshold = 2.15, - minHeightPx = 1800, - minViewportHeightRatio = 1.35, - minPixelCount = 2000000, - navigationVelocityThreshold = 2200, - navigationDistanceThreshold = 240, - edgeActivationRatio = 0.12, - scrollLockVelocityThreshold = 180, - scrollLockDistanceThreshold = 28, - minScrollExtentPx = 48; + const TallMediaSettings.defaults() : enabled = true; TallMediaSettings.fromJson(Map json) - : aspectRatioThreshold = - (json['aspectRatioThreshold'] as num?)?.toDouble() ?? 2.15, - minHeightPx = (json['minHeightPx'] as num?)?.toDouble() ?? 1800, - minViewportHeightRatio = - (json['minViewportHeightRatio'] as num?)?.toDouble() ?? 1.35, - minPixelCount = (json['minPixelCount'] as num?)?.toDouble() ?? 2000000, - navigationVelocityThreshold = - (json['navigationVelocityThreshold'] as num?)?.toDouble() ?? 2200, - navigationDistanceThreshold = - (json['navigationDistanceThreshold'] as num?)?.toDouble() ?? 240, - edgeActivationRatio = - (json['edgeActivationRatio'] as num?)?.toDouble() ?? 0.12, - scrollLockVelocityThreshold = - (json['scrollLockVelocityThreshold'] as num?)?.toDouble() ?? 180, - scrollLockDistanceThreshold = - (json['scrollLockDistanceThreshold'] as num?)?.toDouble() ?? 28, - minScrollExtentPx = (json['minScrollExtentPx'] as num?)?.toDouble() ?? 48; - - final double aspectRatioThreshold; - final double minHeightPx; - final double minViewportHeightRatio; - final double minPixelCount; - final double navigationVelocityThreshold; - final double navigationDistanceThreshold; - final double edgeActivationRatio; - final double scrollLockVelocityThreshold; - final double scrollLockDistanceThreshold; - final double minScrollExtentPx; + : enabled = json['enabled'] ?? true; + + final bool enabled; TallMediaSettings copyWith({ - double? aspectRatioThreshold, - double? minHeightPx, - double? minViewportHeightRatio, - double? minPixelCount, - double? navigationVelocityThreshold, - double? navigationDistanceThreshold, - double? edgeActivationRatio, - double? scrollLockVelocityThreshold, - double? scrollLockDistanceThreshold, - double? minScrollExtentPx, + bool? enabled, }) { return TallMediaSettings( - aspectRatioThreshold: aspectRatioThreshold ?? this.aspectRatioThreshold, - minHeightPx: minHeightPx ?? this.minHeightPx, - minViewportHeightRatio: - minViewportHeightRatio ?? this.minViewportHeightRatio, - minPixelCount: minPixelCount ?? this.minPixelCount, - navigationVelocityThreshold: - navigationVelocityThreshold ?? this.navigationVelocityThreshold, - navigationDistanceThreshold: - navigationDistanceThreshold ?? this.navigationDistanceThreshold, - edgeActivationRatio: edgeActivationRatio ?? this.edgeActivationRatio, - scrollLockVelocityThreshold: - scrollLockVelocityThreshold ?? this.scrollLockVelocityThreshold, - scrollLockDistanceThreshold: - scrollLockDistanceThreshold ?? this.scrollLockDistanceThreshold, - minScrollExtentPx: minScrollExtentPx ?? this.minScrollExtentPx, + enabled: enabled ?? this.enabled, ); } Map toJson() => { - 'aspectRatioThreshold': aspectRatioThreshold, - 'minHeightPx': minHeightPx, - 'minViewportHeightRatio': minViewportHeightRatio, - 'minPixelCount': minPixelCount, - 'navigationVelocityThreshold': navigationVelocityThreshold, - 'navigationDistanceThreshold': navigationDistanceThreshold, - 'edgeActivationRatio': edgeActivationRatio, - 'scrollLockVelocityThreshold': scrollLockVelocityThreshold, - 'scrollLockDistanceThreshold': scrollLockDistanceThreshold, - 'minScrollExtentPx': minScrollExtentPx, + 'enabled': enabled, }; @override - List get props => [ - aspectRatioThreshold, - minHeightPx, - minViewportHeightRatio, - minPixelCount, - navigationVelocityThreshold, - navigationDistanceThreshold, - edgeActivationRatio, - scrollLockVelocityThreshold, - scrollLockDistanceThreshold, - minScrollExtentPx, - ]; + 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 { diff --git a/test/core/posts/details/tall_media_classifier_test.dart b/test/core/posts/details/tall_media_classifier_test.dart index 7e4f5e0f85..bd3eb11dd4 100644 --- a/test/core/posts/details/tall_media_classifier_test.dart +++ b/test/core/posts/details/tall_media_classifier_test.dart @@ -11,11 +11,10 @@ import 'package:boorusama/core/settings/src/types/settings.dart'; void main() { const settings = TallMediaSettings.defaults(); - TallMediaClassifier classifier(Size viewport) => - TallMediaClassifier(settings: settings, viewportSize: viewport); - test('marks extremely tall images as tall', () { - final disposition = classifier(const Size(1080, 1920)).classify( + final disposition = classifyTallMedia( + settings: settings, + viewportSize: const Size(1080, 1920), width: 1200, height: 4200, isVideo: false, @@ -27,7 +26,9 @@ void main() { }); test('does not mark marginal aspect ratios as tall', () { - final disposition = classifier(const Size(1080, 1920)).classify( + final disposition = classifyTallMedia( + settings: settings, + viewportSize: const Size(1080, 1920), width: 900, height: 1500, isVideo: false, @@ -38,7 +39,9 @@ void main() { }); test('requires sufficient pixel density to be considered tall', () { - final disposition = classifier(const Size(720, 1280)).classify( + final disposition = classifyTallMedia( + settings: settings, + viewportSize: const Size(720, 1280), width: 420, height: 1600, isVideo: false, @@ -48,7 +51,9 @@ void main() { }); test('ignores tall originals when scaled height fits viewport', () { - final disposition = classifier(const Size(1080, 1920)).classify( + final disposition = classifyTallMedia( + settings: settings, + viewportSize: const Size(1080, 1920), width: 4200, height: 2800, isVideo: false, @@ -58,7 +63,9 @@ void main() { }); test('videos are never classified as tall still images', () { - final disposition = classifier(const Size(1080, 1920)).classify( + final disposition = classifyTallMedia( + settings: settings, + viewportSize: const Size(1080, 1920), width: 1080, height: 3600, isVideo: true, @@ -66,4 +73,19 @@ void main() { 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); + }); }