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: download blob for videos

+220 -16
+187 -16
lib/features/feed/presentation/media/media_actions.dart
··· 29 29 try { 30 30 final granted = await _requestMediaPermission(MediaAssetType.image); 31 31 if (!granted) { 32 - messenger.showSnackBar(const SnackBar(content: Text('Photo access is required to save images.'))); 32 + _showPermissionDeniedSnackBar(messenger, label: 'images'); 33 33 return; 34 34 } 35 35 ··· 52 52 static Future<void> downloadVideo( 53 53 BuildContext context, 54 54 String playlistUrl, { 55 + String? preferredDownloadUrl, 55 56 String? suggestedName, 56 57 ValueChanged<double>? onProgress, 57 58 }) async { ··· 60 61 try { 61 62 final granted = await _requestMediaPermission(MediaAssetType.video); 62 63 if (!granted) { 63 - messenger.showSnackBar(const SnackBar(content: Text('Media access is required to save videos.'))); 64 + _showPermissionDeniedSnackBar(messenger, label: 'videos'); 64 65 return; 65 66 } 66 67 67 - final downloadUrl = await _resolveBestVideoDownloadUrl(playlistUrl); 68 - final filePath = await _downloadFile( 69 - downloadUrl, 70 - suggestedName: suggestedName, 71 - fallbackExtension: '.mp4', 72 - onProgress: onProgress, 68 + final candidateUrls = await _resolveVideoDownloadCandidates( 69 + playlistUrl, 70 + preferredDownloadUrl: preferredDownloadUrl, 73 71 ); 74 - await Gal.putVideo(filePath); 75 - await _deleteTempFile(filePath); 76 - messenger.showSnackBar(const SnackBar(content: Text('Video saved to your gallery.'))); 72 + 73 + if (candidateUrls.isEmpty) { 74 + throw StateError('No downloadable video URL was available for this post.'); 75 + } 76 + 77 + Object? lastError; 78 + for (final downloadUrl in candidateUrls) { 79 + String? filePath; 80 + try { 81 + filePath = await _downloadFile( 82 + downloadUrl, 83 + suggestedName: suggestedName, 84 + fallbackExtension: '.mp4', 85 + onProgress: onProgress, 86 + ); 87 + await Gal.putVideo(filePath); 88 + messenger.showSnackBar(const SnackBar(content: Text('Video saved to your gallery.'))); 89 + return; 90 + } catch (error, stackTrace) { 91 + lastError = error; 92 + log.w('Failed to save video using candidate URL: $downloadUrl', error: error, stackTrace: stackTrace); 93 + } finally { 94 + if (filePath != null) { 95 + await _deleteTempFile(filePath); 96 + } 97 + } 98 + } 99 + 100 + throw lastError ?? StateError('Video download failed.'); 77 101 } catch (error) { 78 102 messenger.showSnackBar(SnackBar(content: Text('Failed to save video: $error'))); 79 103 } finally { ··· 81 105 } 82 106 } 83 107 108 + static String? buildBlueskyBlobDownloadUrl({required String playlistUrl}) { 109 + final parsed = _extractDidAndCidFromPlaylistUrl(playlistUrl); 110 + if (parsed == null) { 111 + return null; 112 + } 113 + return Uri.https('bsky.social', '/xrpc/com.atproto.sync.getBlob', { 114 + 'did': parsed.did, 115 + 'cid': parsed.cid, 116 + }).toString(); 117 + } 118 + 84 119 static Future<String> _downloadFile( 85 120 String url, { 86 121 String? suggestedName, ··· 107 142 } 108 143 109 144 static Future<bool> _requestMediaPermission(MediaAssetType type) async { 145 + final hasAccess = await Gal.hasAccess(); 146 + if (hasAccess) { 147 + return true; 148 + } 149 + 150 + final grantedByGal = await Gal.requestAccess(); 151 + if (grantedByGal) { 152 + return true; 153 + } 154 + 110 155 if (Platform.isIOS) { 111 156 final status = await Permission.photosAddOnly.request(); 112 - return status.isGranted || status.isLimited; 157 + return status.isGranted; 113 158 } 114 159 115 160 if (Platform.isAndroid) { ··· 124 169 return true; 125 170 } 126 171 127 - static Future<String> _resolveBestVideoDownloadUrl(String playlistUrl) async { 172 + static Future<List<String>> _resolveVideoDownloadCandidates( 173 + String playlistUrl, { 174 + String? preferredDownloadUrl, 175 + }) async { 176 + final candidates = <String>{}; 177 + 178 + if (preferredDownloadUrl?.trim().isNotEmpty ?? false) { 179 + candidates.add(preferredDownloadUrl!.trim()); 180 + } 181 + 182 + final blobUrls = await _resolveBlobDownloadUrls(playlistUrl); 183 + for (final blobUrl in blobUrls) { 184 + candidates.add(blobUrl); 185 + } 186 + 187 + final resolvedFromManifest = await _resolveBestVideoDownloadUrl(playlistUrl); 188 + if (resolvedFromManifest != null) { 189 + candidates.add(resolvedFromManifest); 190 + } 191 + 192 + return candidates.toList(growable: false); 193 + } 194 + 195 + static Future<List<String>> _resolveBlobDownloadUrls(String playlistUrl) async { 196 + final parsed = _extractDidAndCidFromPlaylistUrl(playlistUrl); 197 + if (parsed == null) { 198 + return const []; 199 + } 200 + 201 + final urls = <String>[ 202 + Uri.https('bsky.social', '/xrpc/com.atproto.sync.getBlob', {'did': parsed.did, 'cid': parsed.cid}).toString(), 203 + ]; 204 + 205 + final pdsUrl = await _buildPdsBlobDownloadUrl(did: parsed.did, cid: parsed.cid); 206 + if (pdsUrl != null) { 207 + urls.add(pdsUrl); 208 + } 209 + 210 + return urls; 211 + } 212 + 213 + static Future<String?> _buildPdsBlobDownloadUrl({required String did, required String cid}) async { 214 + try { 215 + final response = await Dio().get<Map<String, dynamic>>( 216 + 'https://plc.directory/$did', 217 + options: Options(responseType: ResponseType.json), 218 + ); 219 + final body = response.data; 220 + if (body == null) { 221 + return null; 222 + } 223 + 224 + final services = body['service']; 225 + if (services is! List) { 226 + return null; 227 + } 228 + 229 + for (final entry in services) { 230 + if (entry is! Map) { 231 + continue; 232 + } 233 + final type = entry['type']; 234 + final endpoint = entry['serviceEndpoint']; 235 + if (type == 'AtprotoPersonalDataServer' && endpoint is String && endpoint.isNotEmpty) { 236 + final endpointUri = Uri.tryParse(endpoint); 237 + if (endpointUri == null || endpointUri.scheme.isEmpty || endpointUri.host.isEmpty) { 238 + continue; 239 + } 240 + return endpointUri 241 + .replace(path: '/xrpc/com.atproto.sync.getBlob', queryParameters: {'did': did, 'cid': cid}) 242 + .toString(); 243 + } 244 + } 245 + } catch (error, stackTrace) { 246 + log.w('Failed to resolve PDS endpoint from PLC directory', error: error, stackTrace: stackTrace); 247 + } 248 + 249 + return null; 250 + } 251 + 252 + static Future<String?> _resolveBestVideoDownloadUrl(String playlistUrl) async { 128 253 final dio = Dio(); 129 254 final playlistUri = Uri.parse(playlistUrl); 130 255 final manifest = await dio.get<String>(playlistUrl, options: Options(responseType: ResponseType.plain)); ··· 138 263 options: Options(responseType: ResponseType.plain), 139 264 ); 140 265 final mediaUri = _parseDirectMediaUri(variantUri, nestedManifest.data ?? ''); 141 - return (mediaUri ?? variantUri).toString(); 266 + return mediaUri?.toString(); 267 + } 268 + final extension = p.extension(variantUri.path).toLowerCase(); 269 + if (extension == '.mp4' || extension == '.mov' || extension == '.m4v') { 270 + return variantUri.toString(); 142 271 } 143 - return variantUri.toString(); 272 + return null; 144 273 } 145 274 146 275 final directMediaUri = _parseDirectMediaUri(playlistUri, body); 147 - return (directMediaUri ?? playlistUri).toString(); 276 + return directMediaUri?.toString(); 148 277 } 149 278 150 279 static Uri? _parseHighestBandwidthVariantUri(Uri baseUri, String manifestBody) { ··· 203 332 } 204 333 205 334 static bool _isPlaylistUri(Uri uri) => p.extension(uri.path).toLowerCase() == '.m3u8'; 335 + 336 + static ({String did, String cid})? _extractDidAndCidFromPlaylistUrl(String playlistUrl) { 337 + final uri = Uri.tryParse(playlistUrl); 338 + if (uri == null) { 339 + return null; 340 + } 341 + 342 + final segments = uri.pathSegments; 343 + final watchIndex = segments.indexOf('watch'); 344 + if (watchIndex != -1 && segments.length > watchIndex + 2) { 345 + final did = segments[watchIndex + 1]; 346 + final cid = segments[watchIndex + 2]; 347 + if (did.startsWith('did:') && cid.isNotEmpty) { 348 + return (did: did, cid: cid); 349 + } 350 + } 351 + 352 + final hlsIndex = segments.indexOf('hls'); 353 + if (hlsIndex != -1 && segments.length > hlsIndex + 2) { 354 + final did = segments[hlsIndex + 1]; 355 + final cid = segments[hlsIndex + 2]; 356 + if (did.startsWith('did:') && cid.isNotEmpty) { 357 + return (did: did, cid: cid); 358 + } 359 + } 360 + 361 + return null; 362 + } 363 + 364 + static void _showPermissionDeniedSnackBar(ScaffoldMessengerState messenger, {required String label}) { 365 + messenger.showSnackBar( 366 + SnackBar( 367 + content: Text('Photo access is required to save $label.'), 368 + action: SnackBarAction( 369 + label: 'Settings', 370 + onPressed: () { 371 + openAppSettings(); 372 + }, 373 + ), 374 + ), 375 + ); 376 + } 206 377 207 378 static String _normalizedFileName(String url, {String? suggestedName, String fallbackExtension = ''}) { 208 379 final uri = Uri.tryParse(url);
+2
lib/features/feed/presentation/media/video_player_route_args.dart
··· 1 1 class VideoPlayerRouteArgs { 2 2 const VideoPlayerRouteArgs({ 3 3 required this.playlistUrl, 4 + this.downloadUrl, 4 5 this.thumbnailUrl, 5 6 this.altText, 6 7 this.aspectRatio, ··· 8 9 }); 9 10 10 11 final String playlistUrl; 12 + final String? downloadUrl; 11 13 final String? thumbnailUrl; 12 14 final String? altText; 13 15 final double? aspectRatio;
+1
lib/features/feed/presentation/media/video_player_screen.dart
··· 224 224 await MediaActions.downloadVideo( 225 225 context, 226 226 widget.args.playlistUrl, 227 + preferredDownloadUrl: widget.args.downloadUrl, 227 228 suggestedName: 'lazurite-video.mp4', 228 229 onProgress: (value) { 229 230 if (!mounted) {
+2
lib/features/feed/presentation/widgets/post_embed_view.dart
··· 378 378 void _openVideoViewer(BuildContext context, EmbedVideoView video) { 379 379 final ratio = normalizeVideoAspectRatio(_rawAspectRatio(video)); 380 380 final isGif = video.presentation?.knownValue == KnownEmbedVideoViewPresentation.gif; 381 + final downloadUrl = MediaActions.buildBlueskyBlobDownloadUrl(playlistUrl: video.playlist); 381 382 GoRouter.maybeOf(context)?.push( 382 383 '/video', 383 384 extra: VideoPlayerRouteArgs( 384 385 playlistUrl: video.playlist, 386 + downloadUrl: downloadUrl, 385 387 thumbnailUrl: video.thumbnail, 386 388 altText: video.alt, 387 389 aspectRatio: ratio,
+28
test/features/feed/presentation/media/media_actions_test.dart
··· 1 + import 'package:flutter_test/flutter_test.dart'; 2 + import 'package:lazurite/features/feed/presentation/media/media_actions.dart'; 3 + 4 + void main() { 5 + group('MediaActions.buildBlueskyBlobDownloadUrl', () { 6 + test('builds blob URL from watch playlist URL', () { 7 + final result = MediaActions.buildBlueskyBlobDownloadUrl( 8 + playlistUrl: 'https://video.bsky.app/watch/did%3Aplc%3Aabc123/bafkreixyz987/playlist.m3u8', 9 + ); 10 + 11 + expect(result, 'https://bsky.social/xrpc/com.atproto.sync.getBlob?did=did%3Aplc%3Aabc123&cid=bafkreixyz987'); 12 + }); 13 + 14 + test('builds blob URL from hls playlist URL', () { 15 + final result = MediaActions.buildBlueskyBlobDownloadUrl( 16 + playlistUrl: 'https://video.cdn.bsky.app/hls/did%3Aplc%3Aabc123/bafkreixyz987/playlist.m3u8', 17 + ); 18 + 19 + expect(result, 'https://bsky.social/xrpc/com.atproto.sync.getBlob?did=did%3Aplc%3Aabc123&cid=bafkreixyz987'); 20 + }); 21 + 22 + test('returns null when URL does not contain did and cid segments', () { 23 + final result = MediaActions.buildBlueskyBlobDownloadUrl(playlistUrl: 'https://example.com/video/playlist.m3u8'); 24 + 25 + expect(result, isNull); 26 + }); 27 + }); 28 + }