Skip to content
137 changes: 137 additions & 0 deletions lib/core/posts/details/src/utils/tall_media_classifier.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// Flutter imports:
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';

// Project imports:
import '../../../../settings/settings.dart';

class TallMediaDisposition {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Change the class name, it sounds strange. Maybe MediaViewportConfiguration

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;
}
}
10 changes: 7 additions & 3 deletions lib/core/posts/details/src/widgets/post_details_image.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class PostDetailsImage<T extends Post> extends ConsumerStatefulWidget {
super.key,
this.heroTag,
this.imageCacheManager,
this.fitWidthForTallImages = false,
});

final BooruConfigAuth config;
Expand All @@ -31,12 +32,15 @@ class PostDetailsImage<T extends Post> extends ConsumerStatefulWidget {
final String Function(T post)? thumbnailUrlBuilder;
final ImageCacheManager? imageCacheManager;
final T post;
final bool fitWidthForTallImages;

@override
ConsumerState<PostDetailsImage<T>> createState() => _PostDetailsImageState<T>();
ConsumerState<PostDetailsImage<T>> createState() =>
_PostDetailsImageState<T>();
}

class _PostDetailsImageState<T extends Post> extends ConsumerState<PostDetailsImage<T>> {
class _PostDetailsImageState<T extends Post>
extends ConsumerState<PostDetailsImage<T>> {
@override
Widget build(BuildContext context) {
final imageUrl = widget.imageUrlBuilder != null
Expand Down Expand Up @@ -121,7 +125,7 @@ class _PostDetailsImageState<T extends Post> extends ConsumerState<PostDetailsIm
imageHeight: post.height,
imageWidth: post.width,
forceFill: false,
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above?

fitWidthForTallImages: true,
fitWidthForTallImages: widget.fitWidthForTallImages,
borderRadius: BorderRadius.zero,
forceLoadPlaceholder: true,
imageCacheManager: widget.imageCacheManager,
Expand Down
140 changes: 74 additions & 66 deletions lib/core/posts/details/src/widgets/post_details_item.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
// Dart imports:
import 'dart:async';

// Flutter imports:
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
Expand All @@ -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<T extends Post> extends ConsumerWidget {
const PostDetailsItem({
Expand Down Expand Up @@ -118,84 +123,88 @@ class PostDetailsItem<T extends Post> 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<T>(
post: post,
config: authConfig,
imageUrlBuilder: imageUrlBuilder,
imageCacheManager: imageCacheManager,
thumbnailUrlBuilder: isInitPage && initialThumbnailUrl != null
? (_) => initialThumbnailUrl
: null,
controller: pageViewController,
);
},
),
),
),
valueListenable: isInitPageListenable,
builder: (context, isInitPage, _) {
return PostMedia<T>(
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<T>(
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) {
Expand All @@ -211,18 +220,17 @@ class PostDetailsItem<T extends Post> 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),
),
),
],
),
Expand Down
3 changes: 3 additions & 0 deletions lib/core/posts/details/src/widgets/post_media.dart
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class PostMedia<T extends Post> extends ConsumerWidget {
required this.controller,
required this.imageCacheManager,
super.key,
this.fitWidthForTallImages = false,
});

final T post;
Expand All @@ -40,6 +41,7 @@ class PostMedia<T extends Post> 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);
Expand Down Expand Up @@ -115,6 +117,7 @@ class PostMedia<T extends Post> extends ConsumerWidget {
imageCacheManager: imageCacheManager,
post: post,
config: config,
fitWidthForTallImages: fitWidthForTallImages,
);
}
}
Loading