|
|
|
|
@@ -39,15 +39,17 @@ class BlogPostMediaContent extends StatefulWidget {
|
|
|
|
|
class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
|
|
|
|
|
static const _counterDisplayDuration = Duration(seconds: 1);
|
|
|
|
|
|
|
|
|
|
/// Landscape / video slide aspect (width / height).
|
|
|
|
|
/// Fallback while the real photo size is unknown, and for video-only.
|
|
|
|
|
static const _horizontalAspectRatio = 16 / 9;
|
|
|
|
|
|
|
|
|
|
/// Portrait slide aspect (width / height).
|
|
|
|
|
static const _verticalAspectRatio = 3 / 4;
|
|
|
|
|
|
|
|
|
|
bool _isCounterVisible = false;
|
|
|
|
|
Timer? _counterHideTimer;
|
|
|
|
|
|
|
|
|
|
final Map<String, double> _aspectByUrl = {};
|
|
|
|
|
final Map<String, ImageStream> _aspectStreams = {};
|
|
|
|
|
final Map<String, ImageStreamListener> _aspectListeners = {};
|
|
|
|
|
|
|
|
|
|
bool get _hasVideo =>
|
|
|
|
|
widget.videoEmbedUrl != null && widget.videoEmbedUrl!.trim().isNotEmpty;
|
|
|
|
|
|
|
|
|
|
@@ -82,7 +84,6 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
|
|
|
|
|
|
|
|
|
|
bool get _shouldShowCounter => !_isCurrentSlideVideo || _isCounterVisible;
|
|
|
|
|
|
|
|
|
|
/// Height is fixed for the whole post from the first gallery image (web-like).
|
|
|
|
|
String get _postOrientation {
|
|
|
|
|
if (_gallery.isNotEmpty) {
|
|
|
|
|
return _gallery.first.orientation;
|
|
|
|
|
@@ -90,23 +91,89 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
|
|
|
|
|
return 'horizontal';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
double _heightForAspect(double aspectRatio, double width) {
|
|
|
|
|
if (aspectRatio <= 0) {
|
|
|
|
|
return width / _horizontalAspectRatio;
|
|
|
|
|
}
|
|
|
|
|
return width / aspectRatio;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
double _heightForOrientation(String orientation, double width) {
|
|
|
|
|
final aspectRatio = orientation == 'vertical'
|
|
|
|
|
? _verticalAspectRatio
|
|
|
|
|
: _horizontalAspectRatio;
|
|
|
|
|
final maxHeight = MediaQuery.sizeOf(context).height * 0.7;
|
|
|
|
|
return (width / aspectRatio).clamp(180.0, maxHeight);
|
|
|
|
|
return _heightForAspect(aspectRatio, width);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Window height follows the current photo's real size, not a fake 16:9 / 3:4 box.
|
|
|
|
|
double _mediaHeight(double width) {
|
|
|
|
|
final gallery = _gallery;
|
|
|
|
|
if (gallery.isEmpty) {
|
|
|
|
|
return _heightForOrientation(_postOrientation, width);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final index = widget.currentGalleryIndex;
|
|
|
|
|
final image = index < gallery.length ? gallery[index] : gallery.first;
|
|
|
|
|
final aspect = _aspectByUrl[image.url];
|
|
|
|
|
if (aspect != null && aspect > 0) {
|
|
|
|
|
return _heightForAspect(aspect, width);
|
|
|
|
|
}
|
|
|
|
|
return _heightForOrientation(image.orientation, width);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _syncAspectListeners() {
|
|
|
|
|
final urls = _gallery.map((image) => image.url).toSet();
|
|
|
|
|
for (final url in _aspectStreams.keys.toList()) {
|
|
|
|
|
if (urls.contains(url)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
final stream = _aspectStreams.remove(url);
|
|
|
|
|
final listener = _aspectListeners.remove(url);
|
|
|
|
|
if (stream != null && listener != null) {
|
|
|
|
|
stream.removeListener(listener);
|
|
|
|
|
}
|
|
|
|
|
_aspectByUrl.remove(url);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (final url in urls) {
|
|
|
|
|
if (_aspectStreams.containsKey(url)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
final stream = CachedNetworkImageProvider(
|
|
|
|
|
url,
|
|
|
|
|
).resolve(ImageConfiguration.empty);
|
|
|
|
|
final listener = ImageStreamListener((info, _) {
|
|
|
|
|
final aspect = info.image.width / info.image.height;
|
|
|
|
|
if (!mounted || aspect <= 0 || _aspectByUrl[url] == aspect) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setState(() => _aspectByUrl[url] = aspect);
|
|
|
|
|
}, onError: (_, _) {});
|
|
|
|
|
stream.addListener(listener);
|
|
|
|
|
_aspectStreams[url] = stream;
|
|
|
|
|
_aspectListeners[url] = listener;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void initState() {
|
|
|
|
|
super.initState();
|
|
|
|
|
_syncAspectListeners();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void dispose() {
|
|
|
|
|
_counterHideTimer?.cancel();
|
|
|
|
|
for (final url in _aspectStreams.keys) {
|
|
|
|
|
_aspectStreams[url]!.removeListener(_aspectListeners[url]!);
|
|
|
|
|
}
|
|
|
|
|
super.dispose();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void didUpdateWidget(covariant BlogPostMediaContent oldWidget) {
|
|
|
|
|
super.didUpdateWidget(oldWidget);
|
|
|
|
|
_syncAspectListeners();
|
|
|
|
|
if (oldWidget.currentGalleryIndex != widget.currentGalleryIndex) {
|
|
|
|
|
_scheduleCounterHideOnVideo();
|
|
|
|
|
}
|
|
|
|
|
@@ -138,16 +205,26 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _imageSlide(String itemUrl, int itemIndex, double height) {
|
|
|
|
|
Widget _photo(String url, {required double width, double? height}) {
|
|
|
|
|
return CachedNetworkImage(
|
|
|
|
|
fit: BoxFit.fitWidth,
|
|
|
|
|
alignment: Alignment.center,
|
|
|
|
|
width: width,
|
|
|
|
|
height: height,
|
|
|
|
|
imageUrl: url,
|
|
|
|
|
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _imageSlide(
|
|
|
|
|
String itemUrl,
|
|
|
|
|
int itemIndex, {
|
|
|
|
|
required double width,
|
|
|
|
|
required double height,
|
|
|
|
|
}) {
|
|
|
|
|
return GestureDetector(
|
|
|
|
|
onTap: () => widget.onOpenGallery(_galleryUrls, itemIndex),
|
|
|
|
|
child: CachedNetworkImage(
|
|
|
|
|
fit: BoxFit.cover,
|
|
|
|
|
width: MediaQuery.sizeOf(context).width,
|
|
|
|
|
height: height,
|
|
|
|
|
imageUrl: itemUrl,
|
|
|
|
|
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
|
|
|
|
),
|
|
|
|
|
child: _photo(itemUrl, width: width, height: height),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -175,6 +252,8 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
|
|
|
|
|
viewportFraction: 1,
|
|
|
|
|
height: height,
|
|
|
|
|
enableInfiniteScroll: false,
|
|
|
|
|
disableCenter: true,
|
|
|
|
|
padEnds: false,
|
|
|
|
|
onPageChanged: (index, _) => widget.onGalleryChanged(index),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
@@ -200,29 +279,51 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
return LayoutBuilder(
|
|
|
|
|
builder: (context, constraints) {
|
|
|
|
|
final width = constraints.maxWidth.isFinite
|
|
|
|
|
? constraints.maxWidth
|
|
|
|
|
: MediaQuery.sizeOf(context).width;
|
|
|
|
|
return _buildMedia(width);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _buildMedia(double width) {
|
|
|
|
|
final gallery = _gallery;
|
|
|
|
|
final width = MediaQuery.sizeOf(context).width;
|
|
|
|
|
final mediaHeight = _heightForOrientation(_postOrientation, width);
|
|
|
|
|
final mediaHeight = _mediaHeight(width);
|
|
|
|
|
|
|
|
|
|
if (gallery.length > 1 || (gallery.length == 1 && _hasVideo)) {
|
|
|
|
|
final itemCount = _slideCount;
|
|
|
|
|
return _carousel(
|
|
|
|
|
itemCount: itemCount,
|
|
|
|
|
height: mediaHeight,
|
|
|
|
|
itemBuilder: (context, itemIndex) {
|
|
|
|
|
if (itemIndex < gallery.length) {
|
|
|
|
|
return _imageSlide(gallery[itemIndex].url, itemIndex, mediaHeight);
|
|
|
|
|
}
|
|
|
|
|
if (_hasVideo) {
|
|
|
|
|
return _videoSlide();
|
|
|
|
|
}
|
|
|
|
|
return const SizedBox.shrink();
|
|
|
|
|
},
|
|
|
|
|
return AnimatedSize(
|
|
|
|
|
duration: const Duration(milliseconds: 200),
|
|
|
|
|
curve: Curves.easeInOut,
|
|
|
|
|
child: _carousel(
|
|
|
|
|
itemCount: itemCount,
|
|
|
|
|
height: mediaHeight,
|
|
|
|
|
itemBuilder: (context, itemIndex) {
|
|
|
|
|
if (itemIndex < gallery.length) {
|
|
|
|
|
return _imageSlide(
|
|
|
|
|
gallery[itemIndex].url,
|
|
|
|
|
itemIndex,
|
|
|
|
|
width: width,
|
|
|
|
|
height: mediaHeight,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (_hasVideo) {
|
|
|
|
|
return _videoSlide();
|
|
|
|
|
}
|
|
|
|
|
return const SizedBox.shrink();
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (gallery.length == 1) {
|
|
|
|
|
return _imageSlide(gallery.first.url, 0, mediaHeight);
|
|
|
|
|
return GestureDetector(
|
|
|
|
|
onTap: () => widget.onOpenGallery(_galleryUrls, 0),
|
|
|
|
|
child: _photo(gallery.first.url, width: width),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (_hasVideo) {
|
|
|
|
|
@@ -230,12 +331,7 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (widget.imageUrl.isNotEmpty) {
|
|
|
|
|
final image = CachedNetworkImage(
|
|
|
|
|
fit: BoxFit.fitWidth,
|
|
|
|
|
width: width,
|
|
|
|
|
imageUrl: widget.imageUrl,
|
|
|
|
|
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
|
|
|
|
);
|
|
|
|
|
final image = _photo(widget.imageUrl, width: width);
|
|
|
|
|
return GestureDetector(
|
|
|
|
|
onTap: () => widget.onOpenGallery([widget.imageUrl], 0),
|
|
|
|
|
child: widget.heroTag == null
|
|
|
|
|
|