mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: video layout normalization

+153 -33
+5 -5
ios/Podfile.lock
··· 143 143 - FlutterMacOS 144 144 - wakelock_plus (0.0.1): 145 145 - Flutter 146 - - workmanager (0.0.1): 146 + - workmanager_apple (0.0.1): 147 147 - Flutter 148 148 149 149 DEPENDENCIES: ··· 165 165 - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) 166 166 - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`) 167 167 - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) 168 - - workmanager (from `.symlinks/plugins/workmanager/ios`) 168 + - workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`) 169 169 170 170 SPEC REPOS: 171 171 trunk: ··· 220 220 :path: ".symlinks/plugins/video_player_avfoundation/darwin" 221 221 wakelock_plus: 222 222 :path: ".symlinks/plugins/wakelock_plus/ios" 223 - workmanager: 224 - :path: ".symlinks/plugins/workmanager/ios" 223 + workmanager_apple: 224 + :path: ".symlinks/plugins/workmanager_apple/ios" 225 225 226 226 SPEC CHECKSUMS: 227 227 connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd ··· 255 255 url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b 256 256 video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a 257 257 wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 258 - workmanager: 01be2de7f184bd15de93a1812936a2b7f42ef07e 258 + workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778 259 259 260 260 PODFILE CHECKSUM: eacb6976ee55d09dd7976888367ed4eee5f3a1bd 261 261
+26
lib/features/feed/presentation/media/video_layout.dart
··· 1 + import 'dart:ui'; 2 + 3 + const double kDefaultVideoAspectRatio = 16 / 9; 4 + 5 + double normalizeVideoAspectRatio(double? value, {double fallback = kDefaultVideoAspectRatio}) { 6 + if (value == null || !value.isFinite || value <= 0) { 7 + return fallback; 8 + } 9 + return value; 10 + } 11 + 12 + Size containedVideoSize({required Size availableSize, required double aspectRatio}) { 13 + final safeAspectRatio = normalizeVideoAspectRatio(aspectRatio); 14 + final safeWidth = availableSize.width.isFinite && availableSize.width > 0 ? availableSize.width : 0.0; 15 + final safeHeight = availableSize.height.isFinite && availableSize.height > 0 ? availableSize.height : 0.0; 16 + 17 + if (safeWidth == 0 || safeHeight == 0) { 18 + return Size.zero; 19 + } 20 + 21 + final availableRatio = safeWidth / safeHeight; 22 + if (availableRatio > safeAspectRatio) { 23 + return Size(safeHeight * safeAspectRatio, safeHeight); 24 + } 25 + return Size(safeWidth, safeWidth / safeAspectRatio); 26 + }
+67 -26
lib/features/feed/presentation/media/video_player_screen.dart
··· 1 + import 'dart:io'; 2 + 1 3 import 'package:chewie/chewie.dart'; 2 4 import 'package:cached_network_image/cached_network_image.dart'; 3 5 import 'package:flutter/material.dart'; 4 6 import 'package:lazurite/core/cache/lazurite_image_cache.dart'; 5 7 import 'package:lazurite/features/feed/presentation/media/media_actions.dart'; 8 + import 'package:lazurite/features/feed/presentation/media/video_layout.dart'; 6 9 import 'package:lazurite/features/feed/presentation/media/video_player_route_args.dart'; 7 10 import 'package:video_player/video_player.dart'; 8 11 ··· 23 26 bool _isDownloading = false; 24 27 double _downloadProgress = 0; 25 28 26 - double get _aspectRatio => widget.args.aspectRatio ?? 16 / 9; 29 + double get _aspectRatio => normalizeVideoAspectRatio(widget.args.aspectRatio); 27 30 28 31 @override 29 32 void initState() { ··· 42 45 Widget build(BuildContext context) { 43 46 final theme = Theme.of(context); 44 47 final progressValue = _downloadProgress > 0 && _downloadProgress < 1 ? _downloadProgress : null; 48 + final altText = widget.args.altText?.trim(); 49 + final hasAltText = altText?.isNotEmpty ?? false; 45 50 46 51 return Scaffold( 47 52 backgroundColor: Colors.black, ··· 62 67 ), 63 68 ], 64 69 ), 65 - body: ListView( 66 - padding: const EdgeInsets.fromLTRB(0, 12, 0, 24), 67 - children: [ 68 - AspectRatio( 69 - aspectRatio: _aspectRatio, 70 - child: switch ((_isInitializing, _initializationError, _chewieController)) { 71 - (true, _, _) => _buildPlaceholder(showSpinner: true), 72 - (_, final Object error, _) => _buildErrorState(theme, error), 73 - (_, _, final ChewieController controller) => Chewie(controller: controller), 74 - _ => _buildPlaceholder(), 75 - }, 76 - ), 77 - if (widget.args.altText?.trim().isNotEmpty ?? false) 78 - Padding( 79 - padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), 80 - child: Container( 81 - padding: const EdgeInsets.all(12), 82 - decoration: BoxDecoration( 83 - color: theme.colorScheme.surfaceContainerHigh, 84 - borderRadius: BorderRadius.circular(16), 70 + body: SafeArea( 71 + child: Padding( 72 + padding: const EdgeInsets.fromLTRB(0, 12, 0, 24), 73 + child: Column( 74 + children: [ 75 + Expanded( 76 + child: LayoutBuilder( 77 + builder: (context, constraints) { 78 + final videoSize = containedVideoSize( 79 + availableSize: Size(constraints.maxWidth, constraints.maxHeight), 80 + aspectRatio: _aspectRatio, 81 + ); 82 + return Center( 83 + child: SizedBox( 84 + width: videoSize.width, 85 + height: videoSize.height, 86 + child: switch ((_isInitializing, _initializationError, _chewieController)) { 87 + (true, _, _) => _buildPlaceholder(showSpinner: true), 88 + (_, final Object error, _) => _buildErrorState(theme, error), 89 + (_, _, final ChewieController controller) => Chewie(controller: controller), 90 + _ => _buildPlaceholder(), 91 + }, 92 + ), 93 + ); 94 + }, 85 95 ), 86 - child: Text(widget.args.altText!, style: theme.textTheme.bodyMedium), 87 96 ), 88 - ), 89 - ], 97 + if (hasAltText) 98 + Flexible( 99 + fit: FlexFit.loose, 100 + child: SingleChildScrollView( 101 + child: Padding( 102 + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), 103 + child: Container( 104 + padding: const EdgeInsets.all(12), 105 + decoration: BoxDecoration( 106 + color: theme.colorScheme.surfaceContainerHigh, 107 + borderRadius: BorderRadius.circular(16), 108 + ), 109 + child: Text(altText!, style: theme.textTheme.bodyMedium), 110 + ), 111 + ), 112 + ), 113 + ), 114 + ], 115 + ), 116 + ), 90 117 ), 91 118 ); 92 119 } ··· 133 160 134 161 Future<void> _initializePlayer() async { 135 162 try { 136 - final controller = VideoPlayerController.networkUrl(Uri.parse(widget.args.playlistUrl)); 163 + final sourceUri = Uri.parse(widget.args.playlistUrl); 164 + final controller = VideoPlayerController.networkUrl(sourceUri, formatHint: _inferVideoFormat(sourceUri)); 137 165 await controller.initialize(); 166 + await controller.setLooping(widget.args.isGif); 138 167 139 168 if (widget.args.isGif) { 140 169 await controller.setVolume(0); ··· 143 172 final chewieController = ChewieController( 144 173 videoPlayerController: controller, 145 174 aspectRatio: _aspectRatio, 146 - autoInitialize: true, 175 + autoInitialize: false, 147 176 autoPlay: widget.args.isGif, 148 177 looping: widget.args.isGif, 149 178 showControls: !widget.args.isGif, 150 179 allowMuting: !widget.args.isGif, 151 180 allowPlaybackSpeedChanging: !widget.args.isGif, 181 + progressIndicatorDelay: Platform.isAndroid ? const Duration(days: 1) : null, 152 182 placeholder: _buildPlaceholder(), 153 183 ); 154 184 ··· 172 202 _isInitializing = false; 173 203 }); 174 204 } 205 + } 206 + 207 + VideoFormat? _inferVideoFormat(Uri uri) { 208 + final path = uri.path.toLowerCase(); 209 + if (path.endsWith('.m3u8')) { 210 + return VideoFormat.hls; 211 + } 212 + if (path.endsWith('.mpd')) { 213 + return VideoFormat.dash; 214 + } 215 + return null; 175 216 } 176 217 177 218 Future<void> _downloadVideo() async {
+13 -2
lib/features/feed/presentation/widgets/post_embed_view.dart
··· 12 12 import 'package:lazurite/core/theme/theme_extensions.dart'; 13 13 import 'package:lazurite/features/feed/presentation/media/image_viewer_route_args.dart'; 14 14 import 'package:lazurite/features/feed/presentation/media/media_actions.dart'; 15 + import 'package:lazurite/features/feed/presentation/media/video_layout.dart'; 15 16 import 'package:lazurite/features/feed/presentation/media/video_player_route_args.dart'; 16 17 import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; 17 18 import 'package:lazurite/features/feed/presentation/widgets/post_text_styles.dart'; ··· 151 152 moderationService?.postUi(feedViewPost.post, bsky_moderation.ModerationBehaviorContext.contentMedia) ?? 152 153 const bsky_moderation.ModerationUI(); 153 154 155 + final aspectRatio = normalizeVideoAspectRatio(_rawAspectRatio(video)); 156 + 154 157 return ModeratedBlurOverlay( 155 158 ui: mediaUi, 156 159 borderRadius: BorderRadius.circular(12), ··· 160 163 alignment: Alignment.center, 161 164 children: [ 162 165 AspectRatio( 163 - aspectRatio: video.aspectRatio == null ? 16 / 9 : video.aspectRatio!.width / video.aspectRatio!.height, 166 + aspectRatio: aspectRatio, 164 167 child: video.thumbnail == null 165 168 ? ColoredBox(color: context.colorScheme.surfaceContainerHighest, child: const SizedBox.expand()) 166 169 : CachedNetworkImage( ··· 373 376 } 374 377 375 378 void _openVideoViewer(BuildContext context, EmbedVideoView video) { 376 - final ratio = video.aspectRatio == null ? null : video.aspectRatio!.width / video.aspectRatio!.height; 379 + final ratio = normalizeVideoAspectRatio(_rawAspectRatio(video)); 377 380 final isGif = video.presentation?.knownValue == KnownEmbedVideoViewPresentation.gif; 378 381 GoRouter.maybeOf(context)?.push( 379 382 '/video', ··· 385 388 isGif: isGif, 386 389 ), 387 390 ); 391 + } 392 + 393 + double? _rawAspectRatio(EmbedVideoView video) { 394 + final ratio = video.aspectRatio; 395 + if (ratio == null || ratio.height == 0) { 396 + return null; 397 + } 398 + return ratio.width / ratio.height; 388 399 } 389 400 390 401 String _imageHeroTag(String heroNamespace, int index) => 'post-image-$heroNamespace-$index';
+42
test/features/feed/presentation/media/video_layout_test.dart
··· 1 + import 'dart:ui'; 2 + 3 + import 'package:flutter_test/flutter_test.dart'; 4 + import 'package:lazurite/features/feed/presentation/media/video_layout.dart'; 5 + 6 + void main() { 7 + group('normalizeVideoAspectRatio', () { 8 + test('falls back for null and invalid values', () { 9 + expect(normalizeVideoAspectRatio(null), kDefaultVideoAspectRatio); 10 + expect(normalizeVideoAspectRatio(0), kDefaultVideoAspectRatio); 11 + expect(normalizeVideoAspectRatio(-1), kDefaultVideoAspectRatio); 12 + expect(normalizeVideoAspectRatio(double.infinity), kDefaultVideoAspectRatio); 13 + expect(normalizeVideoAspectRatio(double.nan), kDefaultVideoAspectRatio); 14 + }); 15 + 16 + test('returns valid values unchanged', () { 17 + expect(normalizeVideoAspectRatio(16 / 9), closeTo(16 / 9, 0.0001)); 18 + expect(normalizeVideoAspectRatio(9 / 16), closeTo(9 / 16, 0.0001)); 19 + }); 20 + }); 21 + 22 + group('containedVideoSize', () { 23 + test('fits landscape videos by width', () { 24 + final size = containedVideoSize(availableSize: const Size(360, 640), aspectRatio: 16 / 9); 25 + 26 + expect(size.width, closeTo(360, 0.001)); 27 + expect(size.height, closeTo(202.5, 0.001)); 28 + }); 29 + 30 + test('fits portrait videos by height', () { 31 + final size = containedVideoSize(availableSize: const Size(360, 640), aspectRatio: 9 / 16); 32 + 33 + expect(size.width, closeTo(360, 0.001)); 34 + expect(size.height, closeTo(640, 0.001)); 35 + }); 36 + 37 + test('returns zero size for non-positive constraints', () { 38 + expect(containedVideoSize(availableSize: const Size(0, 640), aspectRatio: 16 / 9), Size.zero); 39 + expect(containedVideoSize(availableSize: const Size(360, 0), aspectRatio: 16 / 9), Size.zero); 40 + }); 41 + }); 42 + }