From a1a031a9191b7d35bdebf4cded68cba62b2c12a1 Mon Sep 17 00:00:00 2001 From: Roscoe Rubin-Rottenberg Date: Mon, 8 Jun 2026 18:10:00 -0700 Subject: [PATCH] fix: thumbnails full screen from the start --- .../data/models/feed_video_aspect_ratio.dart | 62 +++++ .../atproto/data/models/record_models.dart | 72 +++++- .../data/models/record_write_adapters.dart | 28 ++- .../data/repositories/feed_repository.dart | 2 + .../repositories/feed_repository_impl.dart | 3 +- .../feed/ui/pages/standalone_post_page.dart | 2 + .../ui/widgets/post/post_media_viewer.dart | 2 + .../feed_video_better_player_layout.dart | 46 ++++ .../feed/ui/widgets/videos/video_frame.dart | 211 ++++++++++++++++++ .../feed/ui/widgets/videos/video_player.dart | 157 ++++++++----- .../providers/video_upload_provider.dart | 11 +- .../posting/ui/pages/video_review_page.dart | 25 ++- .../models/record_write_adapters_test.dart | 35 ++- .../sprk_feed_response_fixtures_test.dart | 76 ++++++- .../ui/widgets/videos/video_frame_test.dart | 62 +++++ 15 files changed, 713 insertions(+), 81 deletions(-) create mode 100644 lib/src/core/network/atproto/data/models/feed_video_aspect_ratio.dart create mode 100644 lib/src/features/feed/ui/widgets/videos/feed_video_better_player_layout.dart create mode 100644 lib/src/features/feed/ui/widgets/videos/video_frame.dart create mode 100644 test/src/features/feed/ui/widgets/videos/video_frame_test.dart diff --git a/lib/src/core/network/atproto/data/models/feed_video_aspect_ratio.dart b/lib/src/core/network/atproto/data/models/feed_video_aspect_ratio.dart new file mode 100644 index 00000000..e543483d --- /dev/null +++ b/lib/src/core/network/atproto/data/models/feed_video_aspect_ratio.dart @@ -0,0 +1,62 @@ +import 'package:sprk_poptart/so/sprk/feed/defs.dart' as sprk_feed_defs; +import 'package:sprk_poptart/so/sprk/media/defs/aspect_ratio.dart' + as sprk_media_defs; + +import 'package:spark/src/core/network/atproto/data/models/feed_models.dart'; +import 'package:spark/src/core/network/atproto/data/models/record_models.dart' + as local; + +extension PostVideoAspectRatio on PostView { + double? get videoAspectRatio { + final mediaAspectRatio = switch (displayMedia) { + sprk_feed_defs.UPostViewMediaMediaVideoView(:final data) => + _aspectRatioDataValue(data.aspectRatio), + sprk_feed_defs.UPostViewMediaUnknown(:final data) + when data[r'$type'] == 'so.sprk.media.video#view' => + _aspectRatioFromJson(data['aspectRatio']), + _ => null, + }; + + return mediaAspectRatio ?? _recordVideoAspectRatio; + } + + double? get _recordVideoAspectRatio { + return switch (localRecord?.media) { + local.MediaVideo(:final aspectRatio) => aspectRatio?.value, + _ => null, + }; + } +} + +double? _aspectRatioDataValue(sprk_media_defs.AspectRatio? aspectRatio) { + if (aspectRatio == null) return null; + return _aspectRatioValue(aspectRatio.width, aspectRatio.height); +} + +double? _aspectRatioFromJson(Object? value) { + return switch (value) { + {'width': final Object? width, 'height': final Object? height} => + _aspectRatioValue(width, height), + _ => null, + }; +} + +double? _aspectRatioValue(Object? width, Object? height) { + final widthValue = switch (width) { + final num value => value.toDouble(), + _ => null, + }; + final heightValue = switch (height) { + final num value => value.toDouble(), + _ => null, + }; + + if (widthValue == null || + heightValue == null || + widthValue <= 0 || + heightValue <= 0) { + return null; + } + + return widthValue / heightValue; +} diff --git a/lib/src/core/network/atproto/data/models/record_models.dart b/lib/src/core/network/atproto/data/models/record_models.dart index e5e791fc..c8dd4e26 100644 --- a/lib/src/core/network/atproto/data/models/record_models.dart +++ b/lib/src/core/network/atproto/data/models/record_models.dart @@ -109,7 +109,11 @@ sealed class Media with _$Media { // Spark media types (new schema) @FreezedUnionValue('so.sprk.media.video') - const factory Media.video({required Blob video, String? alt}) = MediaVideo; + const factory Media.video({ + required Blob video, + String? alt, + MediaAspectRatio? aspectRatio, + }) = MediaVideo; @FreezedUnionValue('so.sprk.media.image') const factory Media.image({required Blob image, String? alt}) = MediaImage; @@ -143,6 +147,72 @@ sealed class Media with _$Media { factory Media.fromJson(Map json) => _$MediaFromJson(json); } +final class MediaAspectRatio { + const MediaAspectRatio({required this.width, required this.height}); + + factory MediaAspectRatio.fromJson(Map json) { + return MediaAspectRatio( + width: (json['width'] as num).toInt(), + height: (json['height'] as num).toInt(), + ); + } + + static MediaAspectRatio? fromDimensions({ + required num? width, + required num? height, + }) { + if (width == null || height == null || width <= 0 || height <= 0) { + return null; + } + + final normalizedWidth = width.round(); + final normalizedHeight = height.round(); + if (normalizedWidth <= 0 || normalizedHeight <= 0) return null; + + final divisor = _greatestCommonDivisor(normalizedWidth, normalizedHeight); + return MediaAspectRatio( + width: normalizedWidth ~/ divisor, + height: normalizedHeight ~/ divisor, + ); + } + + final int width; + final int height; + + double? get value { + if (width <= 0 || height <= 0) return null; + return width / height; + } + + Map toJson() => {'width': width, 'height': height}; + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is MediaAspectRatio && + runtimeType == other.runtimeType && + width == other.width && + height == other.height; + } + + @override + int get hashCode => Object.hash(width, height); + + @override + String toString() => 'MediaAspectRatio(width: $width, height: $height)'; + + static int _greatestCommonDivisor(int a, int b) { + var x = a.abs(); + var y = b.abs(); + while (y != 0) { + final next = x % y; + x = y; + y = next; + } + return x == 0 ? 1 : x; + } +} + @freezed abstract class EmbedExternal with _$EmbedExternal { const factory EmbedExternal({ diff --git a/lib/src/core/network/atproto/data/models/record_write_adapters.dart b/lib/src/core/network/atproto/data/models/record_write_adapters.dart index fb14127c..668fc741 100644 --- a/lib/src/core/network/atproto/data/models/record_write_adapters.dart +++ b/lib/src/core/network/atproto/data/models/record_write_adapters.dart @@ -10,6 +10,8 @@ import 'package:sprk_poptart/so/sprk/embed/defs.dart' as sprk_embed_defs; import 'package:sprk_poptart/so/sprk/media/image.dart' as sprk_image; import 'package:sprk_poptart/so/sprk/media/images.dart' as sprk_images; +import 'package:sprk_poptart/so/sprk/media/defs/aspect_ratio.dart' + as sprk_media_defs; import 'package:sprk_poptart/so/sprk/media/video.dart' as sprk_video; import 'package:sprk_poptart/so/sprk/richtext/facet.dart' as sprk_facet; import 'package:sprk_poptart/so/sprk/story/post.dart' as sprk_story; @@ -79,9 +81,13 @@ List? _sprkFacets(List facets) { sprk_post.UFeedPostMedia _sprkPostMedia(local.Media media) { return switch (media) { - local.MediaVideo(:final video, :final alt) => + local.MediaVideo(:final video, :final alt, :final aspectRatio) => sprk_post.UFeedPostMedia.mediaVideo( - data: sprk_video.MediaVideo(video: video, alt: alt), + data: sprk_video.MediaVideo( + video: video, + alt: alt, + aspectRatio: _sprkAspectRatio(aspectRatio), + ), ), local.MediaImages(:final images) => sprk_post.UFeedPostMedia.mediaImages( data: sprk_images.MediaImages( @@ -114,9 +120,13 @@ sprk_story.UStoryPostMedia _sprkStoryMedia(local.Media media) { sprk_story.UStoryPostMedia.mediaImage( data: sprk_image.MediaImage(image: image, alt: alt ?? ''), ), - local.MediaVideo(:final video, :final alt) => + local.MediaVideo(:final video, :final alt, :final aspectRatio) => sprk_story.UStoryPostMedia.mediaVideo( - data: sprk_video.MediaVideo(video: video, alt: alt), + data: sprk_video.MediaVideo( + video: video, + alt: alt, + aspectRatio: _sprkAspectRatio(aspectRatio), + ), ), _ => sprk_story.UStoryPostMedia.unknown(data: media.toJson()), }; @@ -126,6 +136,16 @@ sprk_image.MediaImage _sprkMediaImage(local.Image image) { return sprk_image.MediaImage(image: image.image, alt: image.alt ?? ''); } +sprk_media_defs.AspectRatio? _sprkAspectRatio( + local.MediaAspectRatio? aspectRatio, +) { + if (aspectRatio == null) return null; + return sprk_media_defs.AspectRatio( + width: aspectRatio.width, + height: aspectRatio.height, + ); +} + sprk_post.UFeedPostLabels? _sprkPostLabels(List? labels) { if (labels == null || labels.isEmpty) return null; return sprk_post.UFeedPostLabels.selfLabels(data: SelfLabels(values: labels)); diff --git a/lib/src/core/network/atproto/data/repositories/feed_repository.dart b/lib/src/core/network/atproto/data/repositories/feed_repository.dart index a5a99571..12bf03b3 100644 --- a/lib/src/core/network/atproto/data/repositories/feed_repository.dart +++ b/lib/src/core/network/atproto/data/repositories/feed_repository.dart @@ -182,6 +182,7 @@ abstract class FeedRepository { /// [langs] The languages of the video /// [selfLabels] The self labels of the video /// [facets] Optional list of facets for text formatting (mentions, links, etc.) + /// [aspectRatio] Optional media aspect ratio hint for feed rendering. Future postVideo( Blob blob, { String text = '', @@ -190,6 +191,7 @@ abstract class FeedRepository { List? langs, List? selfLabels, List facets = const [], + MediaAspectRatio? aspectRatio, }); /// Get the thread for a post diff --git a/lib/src/core/network/atproto/data/repositories/feed_repository_impl.dart b/lib/src/core/network/atproto/data/repositories/feed_repository_impl.dart index ac25f25c..7854db67 100644 --- a/lib/src/core/network/atproto/data/repositories/feed_repository_impl.dart +++ b/lib/src/core/network/atproto/data/repositories/feed_repository_impl.dart @@ -1675,12 +1675,13 @@ class FeedRepositoryImpl implements FeedRepository { List? langs, List? selfLabels, List facets = const [], + MediaAspectRatio? aspectRatio, }) async { _logger.d('Posting video with description: $text'); final record = PostRecord( caption: CaptionRef(text: text, facets: facets), - media: Media.video(video: blob, alt: alt), + media: Media.video(video: blob, alt: alt, aspectRatio: aspectRatio), createdAt: DateTime.now().toUtc(), langs: langs, selfLabels: selfLabels, diff --git a/lib/src/features/feed/ui/pages/standalone_post_page.dart b/lib/src/features/feed/ui/pages/standalone_post_page.dart index 3a133c83..e73adf72 100644 --- a/lib/src/features/feed/ui/pages/standalone_post_page.dart +++ b/lib/src/features/feed/ui/pages/standalone_post_page.dart @@ -9,6 +9,7 @@ import 'package:spark/src/core/design_system/components/atoms/buttons/app_overla import 'package:spark/src/core/design_system/tokens/constants.dart'; import 'package:spark/src/core/l10n/app_localizations.dart'; import 'package:spark/src/core/network/atproto/data/models/feed_models.dart'; +import 'package:spark/src/core/network/atproto/data/models/feed_video_aspect_ratio.dart'; import 'package:spark/src/core/network/atproto/data/repositories/sprk_repository.dart'; import 'package:spark/src/core/routing/app_router.dart'; import 'package:spark/src/core/design_system/tokens/colors.dart'; @@ -389,6 +390,7 @@ class _StandalonePostPageState extends ConsumerState { key: _videoPlayerKey, videoUrl: postData.videoUrl, thumbnail: postData.thumbnailUrl, + videoAspectRatio: postData.videoAspectRatio, ) : postData.imageUrls.isNotEmpty ? ImageCarousel(imageUrls: postData.imageUrls) diff --git a/lib/src/features/feed/ui/widgets/post/post_media_viewer.dart b/lib/src/features/feed/ui/widgets/post/post_media_viewer.dart index 771f358f..330a1d03 100644 --- a/lib/src/features/feed/ui/widgets/post/post_media_viewer.dart +++ b/lib/src/features/feed/ui/widgets/post/post_media_viewer.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:spark/src/core/design_system/tokens/colors.dart'; import 'package:spark/src/core/network/atproto/data/models/feed_models.dart'; +import 'package:spark/src/core/network/atproto/data/models/feed_video_aspect_ratio.dart'; import 'package:spark/src/core/pro_video_editor/models/sound_audio_track.dart'; import 'package:spark/src/features/feed/ui/widgets/images/image_carousel.dart'; import 'package:spark/src/features/feed/ui/widgets/post/static_media_sound_player.dart'; @@ -56,6 +57,7 @@ class PostMediaViewerState extends State { key: _videoPlayerKey, videoUrl: post.videoUrl, thumbnail: post.thumbnailUrl, + videoAspectRatio: post.videoAspectRatio, feed: widget.feed, index: widget.index, profileFeedUri: widget.profileFeedUri, diff --git a/lib/src/features/feed/ui/widgets/videos/feed_video_better_player_layout.dart b/lib/src/features/feed/ui/widgets/videos/feed_video_better_player_layout.dart new file mode 100644 index 00000000..1e7d78af --- /dev/null +++ b/lib/src/features/feed/ui/widgets/videos/feed_video_better_player_layout.dart @@ -0,0 +1,46 @@ +import 'dart:async'; + +import 'package:better_player_plus/better_player_plus.dart'; +// ignore: implementation_imports +import 'package:better_player_plus/src/enum/aspect_enum.dart'; +import 'package:flutter/material.dart'; + +BetterPlayerConfiguration feedVideoBetterPlayerConfiguration({ + required double? aspectRatio, + required BoxFit fit, +}) { + return BetterPlayerConfiguration( + controlsConfiguration: const BetterPlayerControlsConfiguration( + showControls: false, + ), + aspectRatio: aspectRatio, + aspectRatioIOS: _iosAspectRatioForFit(fit), + looping: true, + fit: fit, + expandToFill: false, + allowedScreenSleep: false, + ); +} + +void applyFeedVideoBetterPlayerLayout( + BetterPlayerController controller, { + required double? aspectRatio, + required BoxFit fit, +}) { + if (aspectRatio != null) { + controller.setOverriddenAspectRatio(aspectRatio); + } + controller.setOverriddenFit(fit); + + final update = controller.updateAspectRatioIOS(_iosAspectRatioForFit(fit)); + if (update != null) { + unawaited(update); + } +} + +AspectRatioTypeIOS _iosAspectRatioForFit(BoxFit fit) { + return switch (fit) { + BoxFit.cover => AspectRatioTypeIOS.fill, + _ => AspectRatioTypeIOS.aspect, + }; +} diff --git a/lib/src/features/feed/ui/widgets/videos/video_frame.dart b/lib/src/features/feed/ui/widgets/videos/video_frame.dart new file mode 100644 index 00000000..99693b33 --- /dev/null +++ b/lib/src/features/feed/ui/widgets/videos/video_frame.dart @@ -0,0 +1,211 @@ +import 'package:flutter/material.dart'; +import 'package:spark/src/core/design_system/tokens/colors.dart'; + +const double _fullScreenVideoMinAspectRatio = 0.5; +const double _fullScreenVideoMaxAspectRatio = 0.7; + +BoxFit feedVideoFitForAspectRatio(double? aspectRatio) { + final shouldFillScreen = + aspectRatio != null && + aspectRatio > _fullScreenVideoMinAspectRatio && + aspectRatio < _fullScreenVideoMaxAspectRatio; + return shouldFillScreen ? BoxFit.cover : BoxFit.contain; +} + +BoxFit feedVideoThumbnailFitForAspectRatio(double? aspectRatio) { + return aspectRatio == null ? BoxFit.contain : BoxFit.cover; +} + +double? feedVideoAspectRatioFromSize(Size? size) { + if (size == null || size.width <= 0 || size.height <= 0) return null; + return size.width / size.height; +} + +Size? feedVideoFrameSize({Size? videoSize, double? aspectRatio}) { + if (videoSize != null && videoSize.width > 0 && videoSize.height > 0) { + return videoSize; + } + if (aspectRatio == null || aspectRatio <= 0) return null; + return Size(aspectRatio, 1); +} + +class FeedVideoFrame extends StatelessWidget { + const FeedVideoFrame({ + required this.fit, + required this.frameSize, + required this.child, + super.key, + }); + + final BoxFit fit; + final Size? frameSize; + final Widget child; + + @override + Widget build(BuildContext context) { + final size = frameSize; + if (size == null) return child; + + return FittedBox( + fit: fit, + child: SizedBox(width: size.width, height: size.height, child: child), + ); + } +} + +class FeedVideoThumbnailFrame extends StatefulWidget { + const FeedVideoThumbnailFrame({ + required this.thumbnail, + super.key, + this.videoAspectRatio, + }); + + final String thumbnail; + final double? videoAspectRatio; + + @override + State createState() => + _FeedVideoThumbnailFrameState(); +} + +class _FeedVideoThumbnailFrameState extends State { + double? _thumbnailImageAspectRatio; + ImageStream? _thumbnailImageStream; + ImageStreamListener? _thumbnailImageListener; + String? _thumbnailImageAspectRatioUrl; + int _thumbnailImageRequestId = 0; + + double? get _metadataVideoAspectRatio => + widget.videoAspectRatio != null && widget.videoAspectRatio! > 0 + ? widget.videoAspectRatio + : null; + + double? get _aspectRatio => + _metadataVideoAspectRatio ?? _thumbnailImageAspectRatio; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _resolveThumbnailImageAspectRatio(); + } + + @override + void didUpdateWidget(FeedVideoThumbnailFrame oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.thumbnail != widget.thumbnail || + oldWidget.videoAspectRatio != widget.videoAspectRatio) { + _resetThumbnailImageAspectRatio(); + _resolveThumbnailImageAspectRatio(); + } + } + + @override + void dispose() { + _cancelThumbnailImageRequest(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final aspectRatio = _aspectRatio; + final frameSize = feedVideoFrameSize(aspectRatio: aspectRatio); + final thumbnail = widget.thumbnail.isNotEmpty + ? Image.network( + widget.thumbnail, + fit: feedVideoThumbnailFitForAspectRatio(aspectRatio), + width: double.infinity, + height: double.infinity, + ) + : const DecoratedBox(decoration: BoxDecoration(color: AppColors.black)); + + return FeedVideoFrame( + fit: feedVideoFitForAspectRatio(aspectRatio), + frameSize: frameSize, + child: thumbnail, + ); + } + + void _resetThumbnailImageAspectRatio() { + _cancelThumbnailImageRequest(); + _thumbnailImageAspectRatio = null; + _thumbnailImageAspectRatioUrl = null; + } + + void _cancelThumbnailImageRequest() { + _thumbnailImageRequestId++; + _removeCurrentThumbnailImageListener(); + } + + void _removeCurrentThumbnailImageListener() { + final stream = _thumbnailImageStream; + final listener = _thumbnailImageListener; + if (stream != null && listener != null) { + stream.removeListener(listener); + } + _thumbnailImageStream = null; + _thumbnailImageListener = null; + } + + void _removeThumbnailImageListener( + ImageStream stream, + ImageStreamListener listener, + ) { + stream.removeListener(listener); + if (identical(_thumbnailImageStream, stream) && + identical(_thumbnailImageListener, listener)) { + _thumbnailImageStream = null; + _thumbnailImageListener = null; + } + } + + void _resolveThumbnailImageAspectRatio() { + if (_metadataVideoAspectRatio != null || widget.thumbnail.isEmpty) { + _cancelThumbnailImageRequest(); + return; + } + if (_thumbnailImageAspectRatioUrl == widget.thumbnail && + (_thumbnailImageAspectRatio != null || _thumbnailImageStream != null)) { + return; + } + + _cancelThumbnailImageRequest(); + _thumbnailImageAspectRatio = null; + _thumbnailImageAspectRatioUrl = widget.thumbnail; + final requestId = _thumbnailImageRequestId; + final requestUrl = widget.thumbnail; + + final image = NetworkImage(widget.thumbnail); + final stream = image.resolve(createLocalImageConfiguration(context)); + late final ImageStreamListener listener; + listener = ImageStreamListener( + (imageInfo, synchronousCall) { + final width = imageInfo.image.width; + final height = imageInfo.image.height; + if (width <= 0 || height <= 0) return; + + final aspectRatio = width / height; + _removeThumbnailImageListener(stream, listener); + if (!mounted || + _thumbnailImageRequestId != requestId || + _thumbnailImageAspectRatioUrl != requestUrl || + widget.thumbnail != requestUrl) { + return; + } + + if (synchronousCall) { + _thumbnailImageAspectRatio = aspectRatio; + } else { + setState(() { + _thumbnailImageAspectRatio = aspectRatio; + }); + } + }, + onError: (_, _) { + _removeThumbnailImageListener(stream, listener); + }, + ); + _thumbnailImageStream = stream; + _thumbnailImageListener = listener; + stream.addListener(listener); + } +} diff --git a/lib/src/features/feed/ui/widgets/videos/video_player.dart b/lib/src/features/feed/ui/widgets/videos/video_player.dart index 453cfaf3..78bd458a 100644 --- a/lib/src/features/feed/ui/widgets/videos/video_player.dart +++ b/lib/src/features/feed/ui/widgets/videos/video_player.dart @@ -6,10 +6,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:get_it/get_it.dart'; import 'package:spark/src/core/design_system/components/atoms/icons.dart'; import 'package:spark/src/core/network/atproto/data/models/feed_models.dart'; -import 'package:spark/src/core/design_system/tokens/colors.dart'; import 'package:spark/src/core/utils/logging/logging.dart'; import 'package:spark/src/features/feed/providers/feed_provider.dart'; import 'package:spark/src/features/feed/providers/feed_state.dart'; +import 'package:spark/src/features/feed/ui/widgets/videos/feed_video_better_player_layout.dart'; +import 'package:spark/src/features/feed/ui/widgets/videos/video_frame.dart'; import 'package:spark/src/features/feed/ui/widgets/videos/video_progress_bar.dart'; import 'package:spark/src/features/home/providers/feed_settings_visibility_provider.dart'; import 'package:spark/src/features/home/providers/navigation_provider.dart'; @@ -20,6 +21,7 @@ class PostVideoPlayer extends ConsumerStatefulWidget { required this.videoUrl, required this.thumbnail, super.key, + this.videoAspectRatio, this.feed, this.index, this.profileFeedUri, @@ -28,6 +30,7 @@ class PostVideoPlayer extends ConsumerStatefulWidget { final String videoUrl; final String thumbnail; + final double? videoAspectRatio; final Feed? feed; final int? index; @@ -56,9 +59,24 @@ class PostVideoPlayerState extends ConsumerState int? _lastFeedIndex; bool? _lastFeedSettingsVisible; bool _wasPlayingWhenMenuOpened = false; + Size? _playerVideoSize; + double? _lastAppliedPlayerAspectRatio; + BoxFit? _lastAppliedPlayerFit; bool get isPlaying => videoController?.isPlaying() ?? false; bool get isInitialized => videoController?.isVideoInitialized() ?? false; + double? get _knownVideoAspectRatio => + (widget.videoAspectRatio != null && widget.videoAspectRatio! > 0) + ? widget.videoAspectRatio! + : null; + double? get _resolvedVideoAspectRatio => + feedVideoAspectRatioFromSize(_playerVideoSize) ?? _knownVideoAspectRatio; + BoxFit get _resolvedVideoFit => + feedVideoFitForAspectRatio(_resolvedVideoAspectRatio); + Size? get _resolvedVideoFrameSize => feedVideoFrameSize( + videoSize: _playerVideoSize, + aspectRatio: _resolvedVideoAspectRatio, + ); @override void initState() { @@ -88,6 +106,7 @@ class PostVideoPlayerState extends ConsumerState @override void dispose() { _bounceController.dispose(); + videoController?.videoPlayerController?.removeListener(_videoValueListener); videoController?.dispose(); super.dispose(); } @@ -98,6 +117,9 @@ class PostVideoPlayerState extends ConsumerState if (oldWidget.videoUrl != widget.videoUrl) { _showThumbnailOverlay = true; } + if (oldWidget.videoAspectRatio != widget.videoAspectRatio) { + _syncPlayerLayout(); + } } void _hideThumbnailOverlay() { @@ -156,22 +178,30 @@ class PostVideoPlayerState extends ConsumerState ), ); final videoControllerTemp = BetterPlayerController( - const BetterPlayerConfiguration( - controlsConfiguration: BetterPlayerControlsConfiguration( - showControls: false, - ), - looping: true, - fit: BoxFit.contain, - expandToFill: false, - allowedScreenSleep: false, + feedVideoBetterPlayerConfiguration( + aspectRatio: _knownVideoAspectRatio, + fit: feedVideoFitForAspectRatio(_knownVideoAspectRatio), ), ); await videoControllerTemp.setupDataSource(dataSource); videoControllerTemp.addEventsListener(_videoListener); - if (!mounted) return; + if (!mounted) { + videoControllerTemp.dispose(); + return; + } + final playerVideoSize = + videoControllerTemp.videoPlayerController?.value.size; + _syncPlayerLayout( + controller: videoControllerTemp, + videoSize: playerVideoSize, + ); setState(() { videoController = videoControllerTemp; + _playerVideoSize = playerVideoSize; }); + videoControllerTemp.videoPlayerController?.addListener( + _videoValueListener, + ); } catch (e) { if (!mounted) return; } @@ -198,6 +228,43 @@ class PostVideoPlayerState extends ConsumerState } } + void _videoValueListener() { + if (!mounted) return; + final controller = videoController; + if (controller == null) return; + final videoSize = controller.videoPlayerController?.value.size; + _syncPlayerLayout(controller: controller, videoSize: videoSize); + + if (_playerVideoSize == videoSize) return; + setState(() { + _playerVideoSize = videoSize; + }); + } + + void _syncPlayerLayout({ + BetterPlayerController? controller, + Size? videoSize, + }) { + final effectiveController = controller ?? videoController; + if (effectiveController == null) return; + + final aspectRatio = + feedVideoAspectRatioFromSize(videoSize ?? _playerVideoSize) ?? + _knownVideoAspectRatio; + final fit = feedVideoFitForAspectRatio(aspectRatio); + + if (_lastAppliedPlayerAspectRatio != aspectRatio || + _lastAppliedPlayerFit != fit) { + applyFeedVideoBetterPlayerLayout( + effectiveController, + aspectRatio: aspectRatio, + fit: fit, + ); + _lastAppliedPlayerAspectRatio = aspectRatio; + _lastAppliedPlayerFit = fit; + } + } + void _handleNavigationVisibility( bool isOnFeedsTab, { required bool shouldPlay, @@ -256,16 +323,10 @@ class PostVideoPlayerState extends ConsumerState Widget build(BuildContext context) { if (!isInitialized) { // Show thumbnail while video is initializing - return widget.thumbnail.isNotEmpty - ? Image.network( - widget.thumbnail, - fit: BoxFit.contain, - width: double.infinity, - height: double.infinity, - ) - : const DecoratedBox( - decoration: BoxDecoration(color: AppColors.black), - ); + return FeedVideoThumbnailFrame( + thumbnail: widget.thumbnail, + videoAspectRatio: _knownVideoAspectRatio, + ); } final navigationState = ref.watch(navigationProvider); @@ -360,50 +421,28 @@ class PostVideoPlayerState extends ConsumerState } }); - final videoAspectRatio = - videoController?.videoPlayerController?.value.aspectRatio; - final videoSize = videoController?.videoPlayerController?.value.size; - - final shouldFillScreen = - videoAspectRatio != null && - videoAspectRatio > 0.5 && - videoAspectRatio < 0.7; - final fitMode = shouldFillScreen ? BoxFit.cover : BoxFit.contain; - - final thumbnailOverlay = widget.thumbnail.isNotEmpty - ? Positioned.fill( - child: IgnorePointer( - child: Image.network( - widget.thumbnail, - fit: BoxFit.contain, - width: double.infinity, - height: double.infinity, - ), - ), - ) - : const Positioned.fill( - child: IgnorePointer( - child: DecoratedBox( - decoration: BoxDecoration(color: AppColors.black), - ), - ), - ); + final resolvedAspectRatio = _resolvedVideoAspectRatio; + final fitMode = _resolvedVideoFit; + final frameSize = _resolvedVideoFrameSize; + + final thumbnailOverlay = Positioned.fill( + child: IgnorePointer( + child: FeedVideoThumbnailFrame( + thumbnail: widget.thumbnail, + videoAspectRatio: resolvedAspectRatio, + ), + ), + ); return Stack( alignment: Alignment.center, children: [ Positioned.fill( - child: - videoSize != null && videoSize.width > 0 && videoSize.height > 0 - ? FittedBox( - fit: fitMode, - child: SizedBox( - width: videoSize.width, - height: videoSize.height, - child: BetterPlayer(controller: videoController!), - ), - ) - : BetterPlayer(controller: videoController!), + child: FeedVideoFrame( + fit: fitMode, + frameSize: frameSize, + child: BetterPlayer(controller: videoController!), + ), ), if (_showThumbnailOverlay) thumbnailOverlay, Positioned.fill( diff --git a/lib/src/features/posting/providers/video_upload_provider.dart b/lib/src/features/posting/providers/video_upload_provider.dart index 7b02a01e..1825240b 100644 --- a/lib/src/features/posting/providers/video_upload_provider.dart +++ b/lib/src/features/posting/providers/video_upload_provider.dart @@ -48,6 +48,7 @@ Future postVideo( String description = '', String altText = '', String? videoPath, + MediaAspectRatio? aspectRatio, bool crosspostToBsky = false, RepoStrongRef? soundRef, List facets = const [], @@ -59,6 +60,7 @@ Future postVideo( blob: blob, description: description, altText: altText, + aspectRatio: aspectRatio, crosspostToBsky: crosspostToBsky, soundRef: soundRef, facets: facets, @@ -73,6 +75,7 @@ Future postProcessedVideo({ required VideoUploadResult uploadResult, String description = '', String altText = '', + MediaAspectRatio? aspectRatio, bool crosspostToBsky = false, bool storyMode = false, RepoStrongRef? soundRef, @@ -111,7 +114,7 @@ Future postProcessedVideo({ try { final storyRepository = GetIt.I(); final res = await storyRepository.postStory( - Media.video(video: videoBlob), + Media.video(video: videoBlob, aspectRatio: aspectRatio), soundRef: effectiveSoundRef, embeds: storyEmbeds, ); @@ -128,6 +131,7 @@ Future postProcessedVideo({ blob: videoBlob, description: description, altText: altText, + aspectRatio: aspectRatio, crosspostToBsky: crosspostToBsky, soundRef: effectiveSoundRef, facets: facets, @@ -151,6 +155,7 @@ Future processAndPostVideo( required String videoPath, String description = '', String altText = '', + MediaAspectRatio? aspectRatio, bool crosspostToBsky = false, bool storyMode = false, RepoStrongRef? soundRef, @@ -172,6 +177,7 @@ Future processAndPostVideo( uploadResult: uploadResult, description: description, altText: altText, + aspectRatio: aspectRatio, crosspostToBsky: crosspostToBsky, storyMode: storyMode, soundRef: soundRef, @@ -185,6 +191,7 @@ Future _postVideoRecord({ required Blob blob, required String description, required String altText, + required MediaAspectRatio? aspectRatio, required bool crosspostToBsky, required RepoStrongRef? soundRef, required List facets, @@ -199,7 +206,7 @@ Future _postVideoRecord({ text: description.isNotEmpty ? description : '', facets: facets, ), - media: Media.video(video: blob, alt: altText), + media: Media.video(video: blob, alt: altText, aspectRatio: aspectRatio), createdAt: DateTime.now().toUtc(), sound: soundRef, ); diff --git a/lib/src/features/posting/ui/pages/video_review_page.dart b/lib/src/features/posting/ui/pages/video_review_page.dart index 385da643..274dc21b 100644 --- a/lib/src/features/posting/ui/pages/video_review_page.dart +++ b/lib/src/features/posting/ui/pages/video_review_page.dart @@ -271,6 +271,7 @@ class _VideoReviewPageState extends ConsumerState { uploadResult: uploadResult, description: description, altText: _videoAltText, + aspectRatio: _videoAspectRatio, storyMode: widget.storyMode, soundRef: widget.soundRef, crosspostToBsky: !widget.storyMode && _crosspostToBsky, @@ -325,13 +326,29 @@ class _VideoReviewPageState extends ConsumerState { ..showSnackBar(SnackBar(content: Text(message))); } + MediaAspectRatio? get _videoAspectRatio { + final player = _player; + if (player == null) return null; + + final size = player.value.size; + return MediaAspectRatio.fromDimensions( + width: size.width, + height: size.height, + ); + } + + double get _previewAspectRatio { + final metadataAspectRatio = _videoAspectRatio?.value; + if (metadataAspectRatio != null) return metadataAspectRatio; + + final rawAspectRatio = _player?.value.aspectRatio; + return rawAspectRatio != null && rawAspectRatio > 0 ? rawAspectRatio : 1.0; + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); - final rawAspectRatio = _player?.value.aspectRatio; - final ar = rawAspectRatio != null && rawAspectRatio > 0 - ? rawAspectRatio - : 1.0; + final ar = _previewAspectRatio; final textLength = _descriptionController.text.runes.length; final isOverLimit = textLength > AppConstants.postDescriptionMaxChars; final uploadStatusLabel = _uploadStatusLabel(l10n); diff --git a/test/src/core/network/atproto/data/models/record_write_adapters_test.dart b/test/src/core/network/atproto/data/models/record_write_adapters_test.dart index 771cab30..d2ae77c9 100644 --- a/test/src/core/network/atproto/data/models/record_write_adapters_test.dart +++ b/test/src/core/network/atproto/data/models/record_write_adapters_test.dart @@ -7,6 +7,8 @@ import 'package:spark/src/core/network/atproto/data/models/models.dart'; import 'package:spark/src/core/network/atproto/data/models/record_write_adapters.dart'; void main() { + const videoAspectRatio = MediaAspectRatio(width: 9, height: 16); + Blob blob(String mimeType) { return Blob.fromJson({ r'$type': 'blob', @@ -16,11 +18,32 @@ void main() { }); } + group('MediaAspectRatio', () { + test('normalizes video dimensions', () { + expect( + MediaAspectRatio.fromDimensions(width: 1080, height: 1920), + videoAspectRatio, + ); + }); + + test('rejects missing or invalid dimensions', () { + expect(MediaAspectRatio.fromDimensions(width: 0, height: 1920), isNull); + expect( + MediaAspectRatio.fromDimensions(width: null, height: 1920), + isNull, + ); + }); + }); + group('sprkPostRecordFromLocal', () { test('writes video posts with generated Spark media and label unions', () { final record = PostRecord( caption: const CaptionRef(text: 'hello #spark'), - media: Media.video(video: blob('video/mp4'), alt: 'a clip'), + media: Media.video( + video: blob('video/mp4'), + alt: 'a clip', + aspectRatio: videoAspectRatio, + ), createdAt: DateTime.parse('2026-05-15T12:00:00.000Z'), selfLabels: const [SelfLabel(val: 'porn')], tags: const ['spark'], @@ -36,6 +59,8 @@ void main() { expect(json['caption'][r'$type'], 'so.sprk.feed.post#captionRef'); expect(json['media'][r'$type'], 'so.sprk.media.video'); expect(json['media']['alt'], 'a clip'); + expect(json['media']['aspectRatio']['width'], 9); + expect(json['media']['aspectRatio']['height'], 16); expect(json['labels'][r'$type'], 'com.atproto.label.defs#selfLabels'); expect(json['labels']['values'], [ {r'$type': 'com.atproto.label.defs#selfLabel', 'val': 'porn'}, @@ -124,7 +149,11 @@ void main() { group('sprkStoryRecordFromLocal', () { test('writes story media, labels, sound, and mention embeds', () { final json = sprkStoryRecordFromLocal( - media: Media.video(video: blob('video/mp4'), alt: 'story clip'), + media: Media.video( + video: blob('video/mp4'), + alt: 'story clip', + aspectRatio: videoAspectRatio, + ), createdAt: DateTime.parse('2026-05-15T12:00:00.000Z'), labels: const [SelfLabel(val: 'nudity')], sound: RepoStrongRef( @@ -147,6 +176,8 @@ void main() { expect(json[r'$type'], 'so.sprk.story.post'); expect(json['media'][r'$type'], 'so.sprk.media.video'); expect(json['media']['alt'], 'story clip'); + expect(json['media']['aspectRatio']['width'], 9); + expect(json['media']['aspectRatio']['height'], 16); expect(json['labels'][r'$type'], 'com.atproto.label.defs#selfLabels'); expect(json['sound']['cid'], 'sound-cid'); expect(json['tags'], isNull); diff --git a/test/src/core/network/atproto/data/models/sprk_feed_response_fixtures_test.dart b/test/src/core/network/atproto/data/models/sprk_feed_response_fixtures_test.dart index 5929baf4..9126fe14 100644 --- a/test/src/core/network/atproto/data/models/sprk_feed_response_fixtures_test.dart +++ b/test/src/core/network/atproto/data/models/sprk_feed_response_fixtures_test.dart @@ -1,5 +1,4 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:spark/src/core/network/atproto/data/models/feed_models.dart'; import 'package:sprk_poptart/so/sprk/feed/get_feed/output.dart' as sprk_get_feed; import 'package:sprk_poptart/so/sprk/feed/get_posts/output.dart' @@ -9,6 +8,9 @@ import 'package:sprk_poptart/so/sprk/feed/get_timeline/output.dart' import 'package:sprk_poptart/so/sprk/feed/search_posts/output.dart' as sprk_search_posts; +import 'package:spark/src/core/network/atproto/data/models/feed_models.dart'; +import 'package:spark/src/core/network/atproto/data/models/feed_video_aspect_ratio.dart'; + void main() { Map authorJson() => { 'did': 'did:plc:author', @@ -24,7 +26,31 @@ void main() { 'alt': 'fixture image', }; - Map postViewJson() => { + Map videoBlobJson() => { + r'$type': 'blob', + 'ref': {r'$link': 'bafkreigh2akiscaildc2'}, + 'mimeType': 'video/mp4', + 'size': 42, + }; + + Map videoViewJson() => { + r'$type': 'so.sprk.media.video#view', + 'cid': 'video-cid', + 'playlist': 'https://cdn.example.com/video.m3u8', + 'thumbnail': 'https://cdn.example.com/video-thumb.jpg', + 'aspectRatio': {'width': 9, 'height': 16}, + }; + + Map videoRecordMediaJson() => { + r'$type': 'so.sprk.media.video', + 'video': videoBlobJson(), + 'aspectRatio': {'width': 9, 'height': 16}, + }; + + Map postViewJson({ + Map? media, + Map? recordMedia, + }) => { r'$type': 'so.sprk.feed.defs#postView', 'uri': 'at://did:plc:author/so.sprk.feed.post/1', 'cid': 'post-cid', @@ -35,21 +61,27 @@ void main() { r'$type': 'so.sprk.feed.post#captionRef', 'text': 'spark fixture', }, + if (recordMedia != null) 'media': recordMedia, 'createdAt': '2026-05-15T12:00:00.000Z', }, - 'media': { - r'$type': 'so.sprk.media.images#view', - 'images': [imageViewJson()], - }, + 'media': + media ?? + { + r'$type': 'so.sprk.media.images#view', + 'images': [imageViewJson()], + }, 'replyCount': 1, 'repostCount': 2, 'likeCount': 3, 'indexedAt': '2026-05-15T12:00:01.000Z', }; - Map feedViewPostJson() => { + Map feedViewPostJson({ + Map? media, + Map? recordMedia, + }) => { r'$type': 'so.sprk.feed.defs#feedViewPost', - 'post': postViewJson(), + 'post': postViewJson(media: media, recordMedia: recordMedia), 'feedContext': 'context-token', }; @@ -80,6 +112,34 @@ void main() { ); }); + test('getFeed exposes video aspect ratio for thumbnail fitting', () { + final output = sprk_get_feed.FeedGetFeedOutput.fromJson({ + 'cursor': 'next-feed-cursor', + 'feed': [feedViewPostJson(media: videoViewJson())], + }); + + final post = output.feed.single.localPost; + + expect(post.videoUrl, 'https://cdn.example.com/video.m3u8'); + expect(post.thumbnailUrl, 'https://cdn.example.com/video-thumb.jpg'); + expect(post.videoAspectRatio, closeTo(9 / 16, 0.0001)); + }); + + test('getFeed falls back to record video aspect ratio', () { + final media = videoViewJson()..remove('aspectRatio'); + final output = sprk_get_feed.FeedGetFeedOutput.fromJson({ + 'cursor': 'next-feed-cursor', + 'feed': [ + feedViewPostJson(media: media, recordMedia: videoRecordMediaJson()), + ], + }); + + expect( + output.feed.single.localPost.videoAspectRatio, + closeTo(9 / 16, 0.0001), + ); + }); + test('getPosts parses into local post views', () { final output = sprk_get_posts.FeedGetPostsOutput.fromJson({ 'posts': [postViewJson()], diff --git a/test/src/features/feed/ui/widgets/videos/video_frame_test.dart b/test/src/features/feed/ui/widgets/videos/video_frame_test.dart new file mode 100644 index 00000000..0bfa2f42 --- /dev/null +++ b/test/src/features/feed/ui/widgets/videos/video_frame_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spark/src/features/feed/ui/widgets/videos/video_frame.dart'; + +void main() { + group('feedVideoFitForAspectRatio', () { + test('covers vertical full-screen video ratios', () { + expect(feedVideoFitForAspectRatio(9 / 16), BoxFit.cover); + }); + + test('contains horizontal and unknown video ratios', () { + expect(feedVideoFitForAspectRatio(16 / 9), BoxFit.contain); + expect(feedVideoFitForAspectRatio(null), BoxFit.contain); + }); + }); + + group('feedVideoThumbnailFitForAspectRatio', () { + test( + 'shows unknown-ratio thumbnails immediately with old contain behavior', + () { + expect(feedVideoThumbnailFitForAspectRatio(null), BoxFit.contain); + }, + ); + + test('covers thumbnails once a frame ratio is known', () { + expect(feedVideoThumbnailFitForAspectRatio(9 / 16), BoxFit.cover); + expect(feedVideoThumbnailFitForAspectRatio(16 / 9), BoxFit.cover); + }); + }); + + group('feedVideoFrameSize', () { + test('derives aspect ratio only from valid player sizes', () { + expect(feedVideoAspectRatioFromSize(const Size(1920, 1080)), 16 / 9); + expect(feedVideoAspectRatioFromSize(Size.zero), isNull); + expect(feedVideoAspectRatioFromSize(const Size(1920, 0)), isNull); + expect(feedVideoAspectRatioFromSize(null), isNull); + }); + + test('uses player-reported video size before aspect ratio fallback', () { + expect( + feedVideoFrameSize( + videoSize: const Size(1920, 1080), + aspectRatio: 9 / 16, + ), + const Size(1920, 1080), + ); + }); + + test('falls back to an aspect-ratio frame when no size is available', () { + expect(feedVideoFrameSize(aspectRatio: 9 / 16), const Size(9 / 16, 1)); + }); + + test('stays unknown for missing or invalid dimensions', () { + expect(feedVideoFrameSize(), isNull); + expect(feedVideoFrameSize(aspectRatio: 0), isNull); + expect( + feedVideoFrameSize(videoSize: Size.zero, aspectRatio: null), + isNull, + ); + }); + }); +} -- 2.51.2