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: in app video player

+496 -106
+1
android/app/src/main/AndroidManifest.xml
··· 1 1 <manifest xmlns:android="http://schemas.android.com/apk/res/android"> 2 2 <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> 3 + <uses-permission android:name="android.permission.READ_MEDIA_VIDEO" /> 3 4 <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" /> 4 5 <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" /> 5 6 <application
+4 -1
docs/BUGS.md
··· 1 1 --- 2 - title: Bugs 2 + title: Bugs & Inconsistencies 3 3 updated: 2026-03-18 4 4 --- 5 + 6 + The create post FAB should be on profiles and auto-fill @handle of that user's profile 7 + like the official app does.
+7 -7
docs/tasks/phase-4.md
··· 29 29 30 30 ### Video Player 31 31 32 - - [ ] `VideoPlayerScreen` — `chewie` wrapping `VideoPlayerController.networkUrl` with HLS `playlist` URL 33 - - [ ] Video player uses embed `aspectRatio` when available, defaults to 16:9 34 - - [ ] Video thumbnail as placeholder until player initialises; controller disposed on screen pop 35 - - [ ] GIF-presentation mode — auto-play, loop, muted, controls hidden when `presentation` is `"gif"` 36 - - [ ] Download button in video player toolbar — parse `.m3u8` for highest-bandwidth variant URL, download MP4 via `dio` with progress, save via `gal` 37 - - [ ] Declare `NSPhotoLibraryAddUsageDescription` in `Info.plist` and storage permissions in `AndroidManifest.xml` 38 - - [ ] Replace `_launchExternal` calls for image/video embeds in `PostCard` with navigation to the new viewer screens 32 + - [x] `VideoPlayerScreen` — `chewie` wrapping `VideoPlayerController.networkUrl` with HLS `playlist` URL 33 + - [x] Video player uses embed `aspectRatio` when available, defaults to 16:9 34 + - [x] Video thumbnail as placeholder until player initialises; controller disposed on screen pop 35 + - [x] GIF-presentation mode — auto-play, loop, muted, controls hidden when `presentation` is `"gif"` 36 + - [x] Download button in video player toolbar — parse `.m3u8` for highest-bandwidth variant URL, download MP4 via `dio` with progress, save via `gal` 37 + - [x] Declare `NSPhotoLibraryAddUsageDescription` in `Info.plist` and storage permissions in `AndroidManifest.xml` 38 + - [x] Replace `_launchExternal` calls for image/video embeds in `PostCard` with navigation to the new viewer screens 39 39 40 40 ## M14 — Account Switching 41 41
+6
ios/Podfile
··· 39 39 post_install do |installer| 40 40 installer.pods_project.targets.each do |target| 41 41 flutter_additional_ios_build_settings(target) 42 + target.build_configurations.each do |config| 43 + config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [ 44 + '$(inherited)', 45 + 'PERMISSION_PHOTOS_ADD_ONLY=1', 46 + ] 47 + end 42 48 end 43 49 end
+11 -1
lib/core/router/app_router.dart
··· 15 15 import 'package:lazurite/features/devtools/presentation/dev_tools_screen.dart'; 16 16 import 'package:lazurite/features/feed/presentation/feed_management_screen.dart'; 17 17 import 'package:lazurite/features/feed/presentation/home_feed_screen.dart'; 18 - import 'package:lazurite/features/feed/presentation/media/image_viewer_route_args.dart'; 19 18 import 'package:lazurite/features/feed/presentation/media/image_viewer_screen.dart'; 19 + import 'package:lazurite/features/feed/presentation/media/image_viewer_route_args.dart'; 20 20 import 'package:lazurite/features/feed/presentation/post_thread_screen.dart'; 21 + import 'package:lazurite/features/feed/presentation/media/video_player_route_args.dart'; 22 + import 'package:lazurite/features/feed/presentation/media/video_player_screen.dart'; 21 23 import 'package:lazurite/features/logs/presentation/logs_screen.dart'; 22 24 import 'package:lazurite/features/notifications/bloc/notification_bloc.dart'; 23 25 import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; ··· 106 108 builder: (context, state) { 107 109 final args = state.extra as ImageViewerRouteArgs; 108 110 return ImageViewerScreen(args: args); 111 + }, 112 + ), 113 + GoRoute( 114 + path: '/video', 115 + parentNavigatorKey: _rootNavigatorKey, 116 + builder: (context, state) { 117 + final args = state.extra as VideoPlayerRouteArgs; 118 + return VideoPlayerScreen(args: args); 109 119 }, 110 120 ), 111 121 GoRoute(
-84
lib/features/feed/presentation/media/image_media_actions.dart
··· 1 - import 'dart:io'; 2 - 3 - import 'package:dio/dio.dart'; 4 - import 'package:flutter/material.dart'; 5 - import 'package:gal/gal.dart'; 6 - import 'package:path/path.dart' as p; 7 - import 'package:path_provider/path_provider.dart'; 8 - import 'package:permission_handler/permission_handler.dart'; 9 - import 'package:share_plus/share_plus.dart'; 10 - 11 - class ImageMediaActions { 12 - ImageMediaActions._(); 13 - 14 - static Future<void> shareImage(BuildContext context, String imageUrl) async { 15 - await Share.share(imageUrl); 16 - } 17 - 18 - static Future<void> downloadImage( 19 - BuildContext context, 20 - String imageUrl, { 21 - String? suggestedName, 22 - ValueChanged<double>? onProgress, 23 - }) async { 24 - final messenger = ScaffoldMessenger.of(context); 25 - 26 - try { 27 - final granted = await _requestPhotoPermission(); 28 - if (!granted) { 29 - messenger.showSnackBar(const SnackBar(content: Text('Photo access is required to save images.'))); 30 - return; 31 - } 32 - 33 - final tempDir = await getTemporaryDirectory(); 34 - final fileName = _normalizedFileName(imageUrl, suggestedName: suggestedName); 35 - final filePath = p.join(tempDir.path, fileName); 36 - final dio = Dio(); 37 - 38 - await dio.download( 39 - imageUrl, 40 - filePath, 41 - onReceiveProgress: (received, total) { 42 - if (total <= 0) { 43 - return; 44 - } 45 - onProgress?.call(received / total); 46 - }, 47 - ); 48 - 49 - await Gal.putImage(filePath); 50 - try { 51 - await File(filePath).delete(); 52 - } catch (_) {} 53 - messenger.showSnackBar(const SnackBar(content: Text('Image saved to your gallery.'))); 54 - } catch (error) { 55 - messenger.showSnackBar(SnackBar(content: Text('Failed to save image: $error'))); 56 - } finally { 57 - onProgress?.call(0); 58 - } 59 - } 60 - 61 - static Future<bool> _requestPhotoPermission() async { 62 - if (Platform.isIOS) { 63 - final status = await Permission.photosAddOnly.request(); 64 - return status.isGranted || status.isLimited; 65 - } 66 - 67 - if (Platform.isAndroid) { 68 - final statuses = await [Permission.photos, Permission.storage].request(); 69 - return statuses.values.any((status) => status.isGranted || status.isLimited); 70 - } 71 - 72 - return true; 73 - } 74 - 75 - static String _normalizedFileName(String imageUrl, {String? suggestedName}) { 76 - final uri = Uri.tryParse(imageUrl); 77 - final rawName = suggestedName ?? (uri?.pathSegments.isNotEmpty == true ? uri!.pathSegments.last : 'image.jpg'); 78 - final sanitizedName = rawName.split('?').first; 79 - if (p.extension(sanitizedName).isNotEmpty) { 80 - return sanitizedName; 81 - } 82 - return '$sanitizedName.jpg'; 83 - } 84 - }
+4 -4
lib/features/feed/presentation/media/image_viewer_screen.dart
··· 1 1 import 'package:flutter/material.dart'; 2 - import 'package:photo_view/photo_view_gallery.dart'; 3 2 import 'package:photo_view/photo_view.dart'; 4 - import 'package:lazurite/features/feed/presentation/media/image_media_actions.dart'; 5 3 import 'package:lazurite/features/feed/presentation/media/image_viewer_route_args.dart'; 4 + import 'package:lazurite/features/feed/presentation/media/media_actions.dart'; 5 + import 'package:photo_view/photo_view_gallery.dart'; 6 6 7 7 class ImageViewerScreen extends StatefulWidget { 8 8 const ImageViewerScreen({super.key, required this.args}); ··· 65 65 ), 66 66 IconButton( 67 67 tooltip: 'Share', 68 - onPressed: () => ImageMediaActions.shareImage(context, image.fullsizeUrl), 68 + onPressed: () => MediaActions.shareImage(context, image.fullsizeUrl), 69 69 icon: const Icon(Icons.share_outlined), 70 70 ), 71 71 ], ··· 177 177 }); 178 178 179 179 final image = widget.args.images[_currentIndex]; 180 - await ImageMediaActions.downloadImage( 180 + await MediaActions.downloadImage( 181 181 context, 182 182 image.fullsizeUrl, 183 183 onProgress: (value) {
+224
lib/features/feed/presentation/media/media_actions.dart
··· 1 + import 'dart:io'; 2 + 3 + import 'package:dio/dio.dart'; 4 + import 'package:flutter/material.dart'; 5 + import 'package:gal/gal.dart'; 6 + import 'package:lazurite/core/logging/app_logger.dart'; 7 + import 'package:path/path.dart' as p; 8 + import 'package:path_provider/path_provider.dart'; 9 + import 'package:permission_handler/permission_handler.dart'; 10 + import 'package:share_plus/share_plus.dart'; 11 + 12 + enum MediaAssetType { image, video } 13 + 14 + class MediaActions { 15 + MediaActions._(); 16 + 17 + static Future<void> shareImage(BuildContext context, String imageUrl) async { 18 + await Share.share(imageUrl); 19 + } 20 + 21 + static Future<void> downloadImage( 22 + BuildContext context, 23 + String imageUrl, { 24 + String? suggestedName, 25 + ValueChanged<double>? onProgress, 26 + }) async { 27 + final messenger = ScaffoldMessenger.of(context); 28 + 29 + try { 30 + final granted = await _requestMediaPermission(MediaAssetType.image); 31 + if (!granted) { 32 + messenger.showSnackBar(const SnackBar(content: Text('Photo access is required to save images.'))); 33 + return; 34 + } 35 + 36 + final filePath = await _downloadFile( 37 + imageUrl, 38 + suggestedName: suggestedName, 39 + fallbackExtension: '.jpg', 40 + onProgress: onProgress, 41 + ); 42 + await Gal.putImage(filePath); 43 + await _deleteTempFile(filePath); 44 + messenger.showSnackBar(const SnackBar(content: Text('Image saved to your gallery.'))); 45 + } catch (error) { 46 + messenger.showSnackBar(SnackBar(content: Text('Failed to save image: $error'))); 47 + } finally { 48 + onProgress?.call(0); 49 + } 50 + } 51 + 52 + static Future<void> downloadVideo( 53 + BuildContext context, 54 + String playlistUrl, { 55 + String? suggestedName, 56 + ValueChanged<double>? onProgress, 57 + }) async { 58 + final messenger = ScaffoldMessenger.of(context); 59 + 60 + try { 61 + final granted = await _requestMediaPermission(MediaAssetType.video); 62 + if (!granted) { 63 + messenger.showSnackBar(const SnackBar(content: Text('Media access is required to save videos.'))); 64 + return; 65 + } 66 + 67 + final downloadUrl = await _resolveBestVideoDownloadUrl(playlistUrl); 68 + final filePath = await _downloadFile( 69 + downloadUrl, 70 + suggestedName: suggestedName, 71 + fallbackExtension: '.mp4', 72 + onProgress: onProgress, 73 + ); 74 + await Gal.putVideo(filePath); 75 + await _deleteTempFile(filePath); 76 + messenger.showSnackBar(const SnackBar(content: Text('Video saved to your gallery.'))); 77 + } catch (error) { 78 + messenger.showSnackBar(SnackBar(content: Text('Failed to save video: $error'))); 79 + } finally { 80 + onProgress?.call(0); 81 + } 82 + } 83 + 84 + static Future<String> _downloadFile( 85 + String url, { 86 + String? suggestedName, 87 + String fallbackExtension = '', 88 + ValueChanged<double>? onProgress, 89 + }) async { 90 + final tempDir = await getTemporaryDirectory(); 91 + final fileName = _normalizedFileName(url, suggestedName: suggestedName, fallbackExtension: fallbackExtension); 92 + final filePath = p.join(tempDir.path, fileName); 93 + final dio = Dio(); 94 + 95 + await dio.download( 96 + url, 97 + filePath, 98 + onReceiveProgress: (received, total) { 99 + if (total <= 0) { 100 + return; 101 + } 102 + onProgress?.call(received / total); 103 + }, 104 + ); 105 + 106 + return filePath; 107 + } 108 + 109 + static Future<bool> _requestMediaPermission(MediaAssetType type) async { 110 + if (Platform.isIOS) { 111 + final status = await Permission.photosAddOnly.request(); 112 + return status.isGranted || status.isLimited; 113 + } 114 + 115 + if (Platform.isAndroid) { 116 + final requested = switch (type) { 117 + MediaAssetType.image => [Permission.photos, Permission.storage], 118 + MediaAssetType.video => [Permission.videos, Permission.storage], 119 + }; 120 + final statuses = await requested.request(); 121 + return statuses.values.any((status) => status.isGranted || status.isLimited); 122 + } 123 + 124 + return true; 125 + } 126 + 127 + static Future<String> _resolveBestVideoDownloadUrl(String playlistUrl) async { 128 + final dio = Dio(); 129 + final playlistUri = Uri.parse(playlistUrl); 130 + final manifest = await dio.get<String>(playlistUrl, options: Options(responseType: ResponseType.plain)); 131 + final body = manifest.data ?? ''; 132 + 133 + final variantUri = _parseHighestBandwidthVariantUri(playlistUri, body); 134 + if (variantUri != null) { 135 + if (_isPlaylistUri(variantUri)) { 136 + final nestedManifest = await dio.get<String>( 137 + variantUri.toString(), 138 + options: Options(responseType: ResponseType.plain), 139 + ); 140 + final mediaUri = _parseDirectMediaUri(variantUri, nestedManifest.data ?? ''); 141 + return (mediaUri ?? variantUri).toString(); 142 + } 143 + return variantUri.toString(); 144 + } 145 + 146 + final directMediaUri = _parseDirectMediaUri(playlistUri, body); 147 + return (directMediaUri ?? playlistUri).toString(); 148 + } 149 + 150 + static Uri? _parseHighestBandwidthVariantUri(Uri baseUri, String manifestBody) { 151 + final lines = manifestBody.split('\n').map((line) => line.trim()).where((line) => line.isNotEmpty).toList(); 152 + Uri? bestUri; 153 + var bestBandwidth = -1; 154 + 155 + for (var i = 0; i < lines.length; i++) { 156 + final line = lines[i]; 157 + if (!line.startsWith('#EXT-X-STREAM-INF:')) { 158 + continue; 159 + } 160 + 161 + final match = RegExp(r'BANDWIDTH=(\d+)').firstMatch(line); 162 + final bandwidth = int.tryParse(match?.group(1) ?? '') ?? 0; 163 + final nextUriLine = _nextUriLine(lines, i + 1); 164 + if (nextUriLine == null) { 165 + continue; 166 + } 167 + 168 + if (bandwidth >= bestBandwidth) { 169 + bestBandwidth = bandwidth; 170 + bestUri = baseUri.resolve(nextUriLine); 171 + } 172 + } 173 + 174 + return bestUri; 175 + } 176 + 177 + static Uri? _parseDirectMediaUri(Uri baseUri, String manifestBody) { 178 + final lines = manifestBody 179 + .split('\n') 180 + .map((line) => line.trim()) 181 + .where((line) => line.isNotEmpty && !line.startsWith('#')) 182 + .toList(); 183 + 184 + for (final line in lines) { 185 + final candidate = baseUri.resolve(line); 186 + final extension = p.extension(candidate.path).toLowerCase(); 187 + if (extension == '.mp4' || extension == '.mov' || extension == '.m4v') { 188 + return candidate; 189 + } 190 + } 191 + 192 + return null; 193 + } 194 + 195 + static String? _nextUriLine(List<String> lines, int startIndex) { 196 + for (var i = startIndex; i < lines.length; i++) { 197 + final line = lines[i]; 198 + if (!line.startsWith('#')) { 199 + return line; 200 + } 201 + } 202 + return null; 203 + } 204 + 205 + static bool _isPlaylistUri(Uri uri) => p.extension(uri.path).toLowerCase() == '.m3u8'; 206 + 207 + static String _normalizedFileName(String url, {String? suggestedName, String fallbackExtension = ''}) { 208 + final uri = Uri.tryParse(url); 209 + final rawName = suggestedName ?? (uri?.pathSegments.isNotEmpty == true ? uri!.pathSegments.last : 'download'); 210 + final sanitizedName = rawName.split('?').first; 211 + if (p.extension(sanitizedName).isNotEmpty) { 212 + return sanitizedName; 213 + } 214 + return '$sanitizedName$fallbackExtension'; 215 + } 216 + 217 + static Future<void> _deleteTempFile(String filePath) async { 218 + try { 219 + await File(filePath).delete(); 220 + } catch (e) { 221 + log.d('failed to delete temp file $filePath', error: e); 222 + } 223 + } 224 + }
+15
lib/features/feed/presentation/media/video_player_route_args.dart
··· 1 + class VideoPlayerRouteArgs { 2 + const VideoPlayerRouteArgs({ 3 + required this.playlistUrl, 4 + this.thumbnailUrl, 5 + this.altText, 6 + this.aspectRatio, 7 + this.isGif = false, 8 + }); 9 + 10 + final String playlistUrl; 11 + final String? thumbnailUrl; 12 + final String? altText; 13 + final double? aspectRatio; 14 + final bool isGif; 15 + }
+203
lib/features/feed/presentation/media/video_player_screen.dart
··· 1 + import 'package:chewie/chewie.dart'; 2 + import 'package:flutter/material.dart'; 3 + import 'package:lazurite/features/feed/presentation/media/media_actions.dart'; 4 + import 'package:lazurite/features/feed/presentation/media/video_player_route_args.dart'; 5 + import 'package:video_player/video_player.dart'; 6 + 7 + class VideoPlayerScreen extends StatefulWidget { 8 + const VideoPlayerScreen({super.key, required this.args}); 9 + 10 + final VideoPlayerRouteArgs args; 11 + 12 + @override 13 + State<VideoPlayerScreen> createState() => _VideoPlayerScreenState(); 14 + } 15 + 16 + class _VideoPlayerScreenState extends State<VideoPlayerScreen> { 17 + VideoPlayerController? _videoController; 18 + ChewieController? _chewieController; 19 + Object? _initializationError; 20 + bool _isInitializing = true; 21 + bool _isDownloading = false; 22 + double _downloadProgress = 0; 23 + 24 + double get _aspectRatio => widget.args.aspectRatio ?? 16 / 9; 25 + 26 + @override 27 + void initState() { 28 + super.initState(); 29 + _initializePlayer(); 30 + } 31 + 32 + @override 33 + void dispose() { 34 + _chewieController?.dispose(); 35 + _videoController?.dispose(); 36 + super.dispose(); 37 + } 38 + 39 + @override 40 + Widget build(BuildContext context) { 41 + final theme = Theme.of(context); 42 + final progressValue = _downloadProgress > 0 && _downloadProgress < 1 ? _downloadProgress : null; 43 + 44 + return Scaffold( 45 + backgroundColor: Colors.black, 46 + appBar: AppBar( 47 + backgroundColor: Colors.black, 48 + foregroundColor: Colors.white, 49 + title: const Text('Video'), 50 + actions: [ 51 + IconButton( 52 + tooltip: 'Download', 53 + onPressed: _isDownloading ? null : _downloadVideo, 54 + icon: _isDownloading 55 + ? SizedBox.square( 56 + dimension: 22, 57 + child: CircularProgressIndicator(value: progressValue, strokeWidth: 2.4, color: Colors.white), 58 + ) 59 + : const Icon(Icons.download_outlined), 60 + ), 61 + ], 62 + ), 63 + body: ListView( 64 + padding: const EdgeInsets.fromLTRB(0, 12, 0, 24), 65 + children: [ 66 + AspectRatio( 67 + aspectRatio: _aspectRatio, 68 + child: switch ((_isInitializing, _initializationError, _chewieController)) { 69 + (true, _, _) => _buildPlaceholder(showSpinner: true), 70 + (_, final Object error, _) => _buildErrorState(theme, error), 71 + (_, _, final ChewieController controller) => Chewie(controller: controller), 72 + _ => _buildPlaceholder(), 73 + }, 74 + ), 75 + if (widget.args.altText?.trim().isNotEmpty ?? false) 76 + Padding( 77 + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), 78 + child: Container( 79 + padding: const EdgeInsets.all(12), 80 + decoration: BoxDecoration( 81 + color: theme.colorScheme.surfaceContainerHigh, 82 + borderRadius: BorderRadius.circular(16), 83 + ), 84 + child: Text(widget.args.altText!, style: theme.textTheme.bodyMedium), 85 + ), 86 + ), 87 + ], 88 + ), 89 + ); 90 + } 91 + 92 + Widget _buildPlaceholder({bool showSpinner = false}) { 93 + return Stack( 94 + fit: StackFit.expand, 95 + alignment: Alignment.center, 96 + children: [ 97 + if (widget.args.thumbnailUrl != null) 98 + Image.network( 99 + widget.args.thumbnailUrl!, 100 + fit: BoxFit.cover, 101 + errorBuilder: (_, _, _) => const ColoredBox(color: Colors.black26), 102 + ) 103 + else 104 + const ColoredBox(color: Colors.black26), 105 + if (showSpinner) const Center(child: CircularProgressIndicator()), 106 + if (!showSpinner) 107 + Center( 108 + child: Container( 109 + width: 56, 110 + height: 56, 111 + decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.65), shape: BoxShape.circle), 112 + child: const Icon(Icons.play_arrow, color: Colors.white, size: 28), 113 + ), 114 + ), 115 + ], 116 + ); 117 + } 118 + 119 + Widget _buildErrorState(ThemeData theme, Object error) { 120 + return ColoredBox( 121 + color: theme.colorScheme.surfaceContainerHighest, 122 + child: Center( 123 + child: Padding( 124 + padding: const EdgeInsets.all(24), 125 + child: Text('Failed to load video.\n$error', textAlign: TextAlign.center, style: theme.textTheme.bodyMedium), 126 + ), 127 + ), 128 + ); 129 + } 130 + 131 + Future<void> _initializePlayer() async { 132 + try { 133 + final controller = VideoPlayerController.networkUrl(Uri.parse(widget.args.playlistUrl)); 134 + await controller.initialize(); 135 + 136 + if (widget.args.isGif) { 137 + await controller.setVolume(0); 138 + } 139 + 140 + final chewieController = ChewieController( 141 + videoPlayerController: controller, 142 + aspectRatio: _aspectRatio, 143 + autoInitialize: true, 144 + autoPlay: widget.args.isGif, 145 + looping: widget.args.isGif, 146 + showControls: !widget.args.isGif, 147 + allowMuting: !widget.args.isGif, 148 + allowPlaybackSpeedChanging: !widget.args.isGif, 149 + placeholder: _buildPlaceholder(), 150 + ); 151 + 152 + if (!mounted) { 153 + chewieController.dispose(); 154 + await controller.dispose(); 155 + return; 156 + } 157 + 158 + setState(() { 159 + _videoController = controller; 160 + _chewieController = chewieController; 161 + _isInitializing = false; 162 + }); 163 + } catch (error) { 164 + if (!mounted) { 165 + return; 166 + } 167 + setState(() { 168 + _initializationError = error; 169 + _isInitializing = false; 170 + }); 171 + } 172 + } 173 + 174 + Future<void> _downloadVideo() async { 175 + setState(() { 176 + _isDownloading = true; 177 + _downloadProgress = 0; 178 + }); 179 + 180 + await MediaActions.downloadVideo( 181 + context, 182 + widget.args.playlistUrl, 183 + suggestedName: 'lazurite-video.mp4', 184 + onProgress: (value) { 185 + if (!mounted) { 186 + return; 187 + } 188 + setState(() { 189 + _downloadProgress = value; 190 + }); 191 + }, 192 + ); 193 + 194 + if (!mounted) { 195 + return; 196 + } 197 + 198 + setState(() { 199 + _isDownloading = false; 200 + _downloadProgress = 0; 201 + }); 202 + } 203 + }
+21 -9
lib/features/feed/presentation/widgets/post_card.dart
··· 9 9 import 'package:flutter/material.dart'; 10 10 import 'package:go_router/go_router.dart'; 11 11 import 'package:intl/intl.dart'; 12 + import 'package:lazurite/features/feed/presentation/media/media_actions.dart'; 13 + import 'package:lazurite/features/feed/presentation/media/image_viewer_route_args.dart'; 14 + import 'package:lazurite/features/feed/presentation/media/video_player_route_args.dart'; 12 15 import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; 13 - import 'package:lazurite/features/feed/presentation/media/image_media_actions.dart'; 14 - import 'package:lazurite/features/feed/presentation/media/image_viewer_route_args.dart'; 15 16 import 'package:url_launcher/url_launcher.dart'; 16 17 17 18 class PostCard extends StatelessWidget { ··· 301 302 302 303 Widget _buildVideoEmbed(BuildContext context, EmbedVideoView video) { 303 304 return InkWell( 304 - onTap: () => _launchExternal(Uri.parse(video.playlist)), 305 + onTap: () => _openVideoViewer(context, video), 305 306 borderRadius: BorderRadius.circular(16), 306 307 child: ClipRRect( 307 308 borderRadius: BorderRadius.circular(16), ··· 559 560 560 561 switch (selected) { 561 562 case _ImageThumbnailAction.save: 562 - await ImageMediaActions.downloadImage( 563 - context, 564 - image.fullsize, 565 - suggestedName: _downloadFileName(image.fullsize), 566 - ); 563 + await MediaActions.downloadImage(context, image.fullsize, suggestedName: _downloadFileName(image.fullsize)); 567 564 case _ImageThumbnailAction.share: 568 - await ImageMediaActions.shareImage(context, image.fullsize); 565 + await MediaActions.shareImage(context, image.fullsize); 569 566 } 570 567 } 571 568 ··· 575 572 final uri = Uri.tryParse(url); 576 573 final segment = uri?.pathSegments.isNotEmpty == true ? uri!.pathSegments.last : 'image.jpg'; 577 574 return segment.isEmpty ? 'image.jpg' : segment; 575 + } 576 + 577 + void _openVideoViewer(BuildContext context, EmbedVideoView video) { 578 + final ratio = video.aspectRatio == null ? null : video.aspectRatio!.width / video.aspectRatio!.height; 579 + final isGif = video.presentation?.knownValue == KnownEmbedVideoViewPresentation.gif; 580 + GoRouter.maybeOf(context)?.push( 581 + '/video', 582 + extra: VideoPlayerRouteArgs( 583 + playlistUrl: video.playlist, 584 + thumbnailUrl: video.thumbnail, 585 + altText: video.alt, 586 + aspectRatio: ratio, 587 + isGif: isGif, 588 + ), 589 + ); 578 590 } 579 591 } 580 592