[READ ONLY MIRROR] Open Source TikTok alternative built on AT Protocol github.com/sprksocial/client
flutter atproto video dart
10
fork

Configure Feed

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

agá éle ésse (#80)

authored by

Jean Carlo Polo and committed by
GitHub
3c455118 f188fc08

+415 -677
-4
lib/src/core/di/service_locator.dart
··· 10 10 import 'package:sparksocial/src/core/network/atproto/data/repositories/sprk_repository_impl.dart'; 11 11 import 'package:sparksocial/src/core/network/messages/data/repository/messages_repository.dart'; 12 12 import 'package:sparksocial/src/core/network/messages/data/repository/messages_repository_impl.dart'; 13 - import 'package:sparksocial/src/core/storage/cache/cache_manager_impl.dart'; 14 13 import 'package:sparksocial/src/core/storage/cache/download_manager_interface.dart'; 15 14 import 'package:sparksocial/src/core/storage/cache/sql_cache_interface.dart'; 16 15 import 'package:sparksocial/src/core/storage/preferences/settings_repository.dart'; ··· 43 42 final sqlCache = SQLCacheImpl(); 44 43 await sqlCache.database; 45 44 sl.registerSingleton<SQLCacheInterface>(sqlCache); 46 - 47 - // Register cache manager 48 - sl.registerSingleton<CacheManagerInterface>(CacheManagerImpl.instance); 49 45 50 46 final downloadManager = DownloadManagerImpl(); 51 47 sl.registerSingleton<DownloadManagerInterface>(downloadManager);
+9 -9
lib/src/core/network/atproto/data/models/feed_models.dart
··· 251 251 } 252 252 253 253 /// Resolves AT Protocol blob URLs to HTTP URLs for display 254 - String _resolveAtUriToHttpUrl(AtUri atUri, {bool isFullsize = false}) { 255 - final uriString = atUri.toString(); 254 + String _resolveAtUriToHttpUrl(Uri uri, {bool isFullsize = false}) { 255 + final uriString = uri.toString(); 256 256 257 257 // If it's already an HTTP URL, return as is 258 258 if (uriString.startsWith('http://') || uriString.startsWith('https://')) { ··· 366 366 @JsonSerializable(explicitToJson: true) 367 367 const factory EmbedView.video({ 368 368 required String cid, 369 - @AtUriConverter() required AtUri playlist, 370 - @AtUriConverter() required AtUri thumbnail, 369 + @AtUriConverter() required Uri playlist, 370 + @AtUriConverter() required Uri thumbnail, 371 371 String? alt, 372 372 }) = EmbedViewVideo; 373 373 ··· 380 380 @JsonSerializable(explicitToJson: true) 381 381 const factory EmbedView.bskyVideo({ 382 382 required String cid, 383 - @AtUriConverter() required AtUri playlist, 384 - @AtUriConverter() required AtUri thumbnail, 383 + @AtUriConverter() required Uri playlist, 384 + @AtUriConverter() required Uri thumbnail, 385 385 String? alt, 386 386 }) = EmbedViewBskyVideo; 387 387 ··· 510 510 /// Link feature for URLs 511 511 @FreezedUnionValue('#link') 512 512 @JsonSerializable(explicitToJson: true) 513 - const factory FacetFeature.link({@AtUriConverter() required AtUri uri}) = LinkFeature; 513 + const factory FacetFeature.link({@AtUriConverter() required Uri uri}) = LinkFeature; 514 514 515 515 /// Tag feature for hashtags 516 516 @FreezedUnionValue('#tag') ··· 558 558 class ViewImage with _$ViewImage { 559 559 @JsonSerializable(explicitToJson: true) 560 560 const factory ViewImage({ 561 - @AtUriConverter() required AtUri thumb, 562 - @AtUriConverter() required AtUri fullsize, 561 + @AtUriConverter() required Uri thumb, 562 + @AtUriConverter() required Uri fullsize, 563 563 String? alt, 564 564 // aspectRatio: {width: int, height: int} 565 565 }) = _ViewImage;
+33 -4
lib/src/core/network/atproto/data/repositories/feed_repository_impl.dart
··· 526 526 ); 527 527 528 528 final serviceToken = serviceTokenRes.data.token; 529 - final response = await http.post( 529 + var response = await http.post( 530 530 Uri.parse('${AppConfig.videoServiceUrl}/xrpc/so.sprk.video.uploadVideo'), 531 531 headers: {'Authorization': 'Bearer $serviceToken', 'Content-Type': _getContentType(cleanVideoPath)}, 532 532 body: videoBytes, ··· 537 537 } 538 538 539 539 // Parse the response 540 - final responseData = jsonDecode(response.body); 541 - //{'jobStatus': {'blob': blob}} = responseData; this is how it should work in the lexicon 542 - return Blob.fromJson(responseData['blobRef'] as Map<String, dynamic>); 540 + dynamic responseData = jsonDecode(response.body); 541 + _logger.d('Video upload response: $responseData'); 542 + while (responseData['jobStatus']?['state'] == 'JOB_STATE_PROCESSING') { 543 + _logger.d('Video upload in progress, status: ${responseData['jobStatus']?['state']}'); 544 + await Future.delayed(const Duration(seconds: 2)); 545 + response = await http.get( 546 + Uri.parse('${AppConfig.videoServiceUrl}/xrpc/so.sprk.video.getJobStatus').replace( 547 + queryParameters: { 548 + 'jobId': responseData['jobStatus']?['jobId'], 549 + }, 550 + ), 551 + headers: {'Authorization': 'Bearer $serviceToken', 'Content-Type': _getContentType(cleanVideoPath)}, 552 + ); 553 + if (response.statusCode != 200) { 554 + throw Exception('Failed to check video upload status: ${response.statusCode} ${response.body}'); 555 + } else { 556 + responseData = jsonDecode(response.body); 557 + _logger.d('Video upload status response: $responseData'); 558 + } 559 + } 560 + if (responseData['jobStatus']?['state'] == 'JOB_STATE_FAILED') { 561 + throw Exception('Video upload failed: ${responseData['jobStatus']?['status']}'); 562 + } 563 + Map<String, dynamic> blob; 564 + if (responseData case {'jobStatus': {'blob': final blobData}}) { 565 + blob = blobData as Map<String, dynamic>; 566 + } else if (responseData case {'blobRef': final blobRef}) { 567 + blob = blobRef as Map<String, dynamic>; 568 + } else { 569 + throw Exception('Unexpected response format: $responseData'); 570 + } 571 + return Blob.fromJson(blob); 543 572 }); 544 573 } 545 574
-196
lib/src/core/storage/cache/cache_manager_impl.dart
··· 1 - import 'dart:io'; 2 - import 'dart:typed_data'; 3 - 4 - import 'package:atproto/core.dart'; 5 - import 'package:flutter_cache_manager/flutter_cache_manager.dart'; 6 - import 'package:get_it/get_it.dart'; 7 - import 'package:path_provider/path_provider.dart'; 8 - import 'package:sparksocial/src/core/network/atproto/data/repositories/sprk_repository.dart'; 9 - import 'package:sparksocial/src/core/storage/cache/cache_manager_interface.dart'; 10 - import 'package:sparksocial/src/core/utils/logging/logging.dart'; 11 - 12 - /// Manages temporary cache files for the application 13 - class CacheManagerImpl implements CacheManagerInterface { 14 - /// Private constructor 15 - CacheManagerImpl._() { 16 - cacheManager = CacheManager( 17 - Config( 18 - 'sparksocial', 19 - maxNrOfCacheObjects: 100, 20 - ), 21 - ); 22 - _logger = GetIt.instance<LogService>().getLogger('CacheManager'); 23 - } 24 - 25 - /// Singleton instance 26 - static final CacheManagerImpl _instance = CacheManagerImpl._(); 27 - 28 - /// Default cache manager for most files 29 - late final CacheManager cacheManager; 30 - 31 - /// Logger for debugging 32 - late final SparkLogger _logger; 33 - 34 - /// Get the singleton instance 35 - static CacheManagerImpl get instance => _instance; 36 - 37 - /// Check if a URL is an AT Protocol blob URL 38 - bool _isAtProtocolBlobUrl(String url) { 39 - return url.startsWith('at://') && url.contains('/blob/'); 40 - } 41 - 42 - /// Extract DID and CID from AT Protocol blob URL 43 - ({String did, String cid})? _parseAtProtocolBlobUrl(String url) { 44 - // Format: at://did:plc:xxx/blob/xxx 45 - final match = RegExp(r'^at://([^/]+)/blob/(.+)$').firstMatch(url); 46 - if (match != null) { 47 - return (did: match.group(1)!, cid: match.group(2)!); 48 - } 49 - return null; 50 - } 51 - 52 - /// Download blob from AT Protocol API 53 - Future<Uint8List> _downloadAtProtocolBlob(String did, String cid) async { 54 - try { 55 - final sprkRepository = GetIt.instance<SprkRepository>(); 56 - final authRepository = sprkRepository.authRepository; 57 - 58 - if (authRepository.atproto == null) { 59 - throw Exception('AT Protocol client not initialized'); 60 - } 61 - 62 - _logger.d('Downloading AT Protocol blob: did=$did, cid=$cid'); 63 - 64 - final response = await authRepository.atproto!.get( 65 - NSID.parse('com.atproto.sync.getBlob'), 66 - parameters: {'did': did, 'cid': cid}, 67 - headers: {'Accept': '*/*'}, 68 - ); 69 - 70 - if (response.status != HttpStatus.ok) { 71 - throw Exception('Failed to download blob: ${response.status}'); 72 - } 73 - 74 - final bytes = response.data as Uint8List; 75 - _logger.d('Successfully downloaded AT Protocol blob: ${bytes.length} bytes'); 76 - return bytes; 77 - } catch (e) { 78 - _logger.e('Error downloading AT Protocol blob: $e'); 79 - rethrow; 80 - } 81 - } 82 - 83 - /// Get a cached file or download it if not available 84 - @override 85 - Future<File> getFile(String url) async { 86 - if (_isAtProtocolBlobUrl(url)) { 87 - return _getAtProtocolBlobFile(url); 88 - } 89 - 90 - final fileInfo = await cacheManager.getFileFromCache(url); 91 - if (fileInfo != null) { 92 - return fileInfo.file; 93 - } 94 - 95 - // File not in cache, download it 96 - final file = await cacheManager.getSingleFile(url); 97 - return file; 98 - } 99 - 100 - /// Get AT Protocol blob file from cache or download 101 - Future<File> _getAtProtocolBlobFile(String url) async { 102 - final fileInfo = await cacheManager.getFileFromCache(url); 103 - if (fileInfo != null) { 104 - return fileInfo.file; 105 - } 106 - 107 - // Parse the AT Protocol URL 108 - final parsed = _parseAtProtocolBlobUrl(url); 109 - if (parsed == null) { 110 - throw Exception('Invalid AT Protocol blob URL: $url'); 111 - } 112 - 113 - // Download the blob 114 - final bytes = await _downloadAtProtocolBlob(parsed.did, parsed.cid); 115 - 116 - // Store in cache 117 - await cacheManager.putFile(url, bytes, maxAge: const Duration(days: 7)); 118 - 119 - // Return the cached file 120 - final cachedFileInfo = await cacheManager.getFileFromCache(url); 121 - if (cachedFileInfo == null) { 122 - throw Exception('Failed to cache AT Protocol blob'); 123 - } 124 - 125 - return cachedFileInfo.file; 126 - } 127 - 128 - /// Get a cached file 129 - /// Returns null if not found 130 - @override 131 - Future<File?> getCachedFile(String url) async { 132 - return (await cacheManager.getFileFromCache(url))?.file; 133 - } 134 - 135 - /// Store a file in the cache with the given key 136 - @override 137 - Future<void> putFile(String url, Uint8List fileBytes) async { 138 - await cacheManager.putFile( 139 - url, 140 - fileBytes, 141 - maxAge: const Duration(days: 7), // Cache for 7 days 142 - ); 143 - } 144 - 145 - /// Remove a specific file from cache 146 - @override 147 - Future<void> removeFile(String url) async { 148 - await cacheManager.removeFile(url); 149 - } 150 - 151 - /// Calculate the total size of the cache in bytes 152 - @override 153 - Future<int> getCacheSize() async { 154 - final cacheDir = await getTemporaryDirectory(); 155 - return _calculateDirSize(cacheDir); 156 - } 157 - 158 - /// Clear all cached files 159 - @override 160 - Future<void> clearCache() async { 161 - await cacheManager.emptyCache(); 162 - 163 - // Also clear the temp directory 164 - final tempDir = await getTemporaryDirectory(); 165 - if (tempDir.existsSync()) { 166 - tempDir.listSync().forEach((entity) { 167 - if (entity is File) { 168 - try { 169 - entity.deleteSync(); 170 - } catch (_) {} 171 - } else if (entity is Directory) { 172 - try { 173 - entity.deleteSync(recursive: true); 174 - } catch (_) {} 175 - } 176 - }); 177 - } 178 - } 179 - 180 - /// Helper method to calculate directory size 181 - Future<int> _calculateDirSize(Directory dir) async { 182 - var totalSize = 0; 183 - try { 184 - if (dir.existsSync()) { 185 - dir.listSync(recursive: true, followLinks: false).forEach((FileSystemEntity entity) { 186 - if (entity is File) { 187 - totalSize += entity.lengthSync(); 188 - } 189 - }); 190 - } 191 - } catch (e) { 192 - // Ignore errors 193 - } 194 - return totalSize; 195 - } 196 - }
-24
lib/src/core/storage/cache/cache_manager_interface.dart
··· 1 - import 'dart:io'; 2 - import 'dart:typed_data'; 3 - 4 - /// Interface defining cache management operations 5 - abstract class CacheManagerInterface { 6 - /// Get a cached file or download it if not available 7 - Future<File> getFile(String url); 8 - 9 - /// Get a cached file 10 - /// Returns null if not found 11 - Future<File?> getCachedFile(String url); 12 - 13 - /// Store a file in the cache with the given key 14 - Future<void> putFile(String url, Uint8List fileBytes); 15 - 16 - /// Remove a specific file from cache 17 - Future<void> removeFile(String url); 18 - 19 - /// Calculate the total size of the cache in bytes 20 - Future<int> getCacheSize(); 21 - 22 - /// Clear all cached files 23 - Future<void> clearCache(); 24 - }
+77 -66
lib/src/core/storage/cache/download_manager_impl.dart
··· 1 + import 'package:better_player_plus/better_player_plus.dart'; 1 2 import 'package:cached_network_image/cached_network_image.dart'; 2 3 import 'package:collection/collection.dart'; 3 4 import 'package:get_it/get_it.dart'; ··· 6 7 import 'package:sparksocial/src/core/storage/cache/download_manager_interface.dart'; 7 8 import 'package:sparksocial/src/core/storage/cache/sql_cache_interface.dart'; 8 9 import 'package:sparksocial/src/core/storage/preferences/settings_repository.dart'; 9 - import 'package:sparksocial/src/core/storage/storage.dart'; 10 10 import 'package:sparksocial/src/core/utils/logging/logging.dart'; 11 11 import 'package:sparksocial/src/features/feed/providers/feed_state.dart'; 12 12 ··· 14 14 DownloadManagerImpl() : _pool = Pool(FeedState.poolSize) { 15 15 _sqlCache = GetIt.instance<SQLCacheInterface>(); 16 16 _logger = GetIt.instance<LogService>().getLogger('DownloadManager'); 17 - _cacheManager = GetIt.instance<CacheManagerInterface>(); 18 17 } 19 18 20 19 Future<void> init() async { ··· 26 25 27 26 late final SQLCacheInterface _sqlCache; 28 27 late final SparkLogger _logger; 29 - late final CacheManagerInterface _cacheManager; 30 28 late final Pool _pool; 31 29 final PriorityQueue<DownloadTask> _tasks = PriorityQueue<DownloadTask>((a, b) => a.priority.compareTo(b.priority)); 32 30 33 31 late Feed _activeFeed; 34 32 bool _isProcessing = false; 33 + 34 + static final controller = BetterPlayerController( 35 + const BetterPlayerConfiguration(), 36 + ); // static controller for caching (for some reason the method for precaching is not static) 35 37 36 38 @override 37 39 void setActiveFeed(Feed feed) { ··· 83 85 // that would make _processQueue sequential for task submission to pool. 84 86 // Instead, we launch it and let the pool handle concurrency. 85 87 88 + task.status = DownloadTaskStatus.submitted; 86 89 await _pool 87 90 .withResource(() => _executeTask(task)) 88 91 .then((_) { ··· 102 105 _tasks.remove(task); // Ensure removal on unhandled pool error 103 106 }); 104 107 _logger.d('Task ${task.uri} submitted to pool for execution.'); 105 - task.status = DownloadTaskStatus.submitted; 106 108 } 107 109 if (task.status != DownloadTaskStatus.completed && task.status != DownloadTaskStatus.failed) { 108 110 newTasks.add(task); ··· 128 130 return tasks.any((task) => task.status == DownloadTaskStatus.pending && task.feed == _activeFeed); 129 131 } 130 132 131 - bool _isSparkPost(PostView post) { 132 - return post.uri.toString().contains('so.sprk'); 133 - } 134 - 135 133 Future<void> _executeTask(DownloadTask task) async { 136 134 _logger.d('Executing task: ${task.uri} for feed ${task.feed.identifier} with priority ${task.priority}'); 137 135 if (_activeFeed != task.feed && task.priority > activeFeedPriority && _areTherePendingActiveFeedTasks()) { ··· 144 142 145 143 task.status = DownloadTaskStatus.active; 146 144 147 - // Skip media caching for Bluesky posts (they use HLS streaming) 148 - if (_isSparkPost(task.post)) { 149 - _logger.d('Caching media for Spark post: ${task.uri}'); 150 - // Actual caching work - start downloading the embed 151 - switch (task.post.embed) { 152 - case EmbedViewVideo(): 153 - var cachedFile = await _cacheManager.getCachedFile(task.post.videoUrl); 154 - if (cachedFile != null) { 155 - _logger.d('Video file already cached: ${task.post.videoUrl}'); 156 - break; 145 + _logger.d('Caching media for post: ${task.uri}'); 146 + // Actual caching work - start downloading the embed 147 + switch (task.post.embed) { 148 + case EmbedViewVideo(): 149 + await DownloadManagerImpl.controller.preCache( 150 + BetterPlayerDataSource( 151 + BetterPlayerDataSourceType.network, 152 + task.post.videoUrl, 153 + cacheConfiguration: BetterPlayerCacheConfiguration( 154 + useCache: true, 155 + preCacheSize: 10 * 1024 * 1024, // 10 MB 156 + maxCacheSize: 500 * 1024 * 1024, // 500 MB 157 + key: task.post.videoUrl, 158 + ), 159 + ), 160 + ); 161 + _logger.d('Video file successfully cached: ${task.post.videoUrl}'); 162 + case EmbedViewImage(): 163 + for (final url in task.post.imageUrls) { 164 + // Download the image and verify it's cached 165 + final fileInfo = await CachedNetworkImageProvider.defaultCacheManager.downloadFile(url, key: url); 166 + if (fileInfo.statusCode != 200) { 167 + _logger.w('Image file was not properly cached after download: $url'); 157 168 } 158 - // Download the video and ensure it's cached 159 - await _cacheManager.getFile(task.post.videoUrl); 160 - // Verify the file is actually cached before proceeding 161 - cachedFile = await _cacheManager.getCachedFile(task.post.videoUrl); 162 - if (cachedFile == null) { 163 - throw Exception('Video file was not properly cached after download: ${task.post.videoUrl}'); 164 - } 165 - _logger.d('Video file successfully cached: ${task.post.videoUrl}'); 166 - case EmbedViewImage(): 167 - for (final url in task.post.imageUrls) { 168 - // Download the image and verify it's cached 169 - final fileInfo = await CachedNetworkImageProvider.defaultCacheManager.downloadFile(url, key: url); 170 - if (fileInfo.statusCode != 200) { 171 - _logger.w('Image file was not properly cached after download: $url'); 169 + } 170 + case EmbedViewBskyRecordWithMedia(:final media): 171 + // Handle nested media in record with media embeds 172 + switch (media) { 173 + case EmbedViewVideo() || EmbedViewBskyVideo(): 174 + await DownloadManagerImpl.controller.preCache( 175 + BetterPlayerDataSource( 176 + BetterPlayerDataSourceType.network, 177 + task.post.videoUrl, 178 + videoFormat: BetterPlayerVideoFormat.hls, 179 + videoExtension: 'm3u8', 180 + cacheConfiguration: BetterPlayerCacheConfiguration( 181 + useCache: true, 182 + preCacheSize: 10 * 1024 * 1024, // 10 MB 183 + maxCacheSize: 500 * 1024 * 1024, // 500 MB 184 + key: task.post.videoUrl, 185 + ), 186 + ), 187 + ); 188 + _logger.d('Video file successfully cached: ${task.post.videoUrl}'); 189 + case EmbedViewImage() || EmbedViewBskyImages(): 190 + for (final url in task.post.imageUrls) { 191 + // Download the image and verify it's cached 192 + final fileInfo = await CachedNetworkImageProvider.defaultCacheManager.downloadFile(url, key: url); 193 + if (fileInfo.statusCode != 200) { 194 + _logger.w('Image file was not properly cached after download: $url'); 195 + } 172 196 } 173 - } 174 - case EmbedViewBskyRecordWithMedia(:final media): 175 - // Handle nested media in record with media embeds 176 - switch (media) { 177 - case EmbedViewVideo() || EmbedViewBskyVideo(): 178 - var cachedFile = await _cacheManager.getCachedFile(task.post.videoUrl); 179 - if (cachedFile != null) { 180 - _logger.d('Video file already cached: ${task.post.videoUrl}'); 181 - break; 182 - } 183 - // Download the video and ensure it's cached 184 - await _cacheManager.getFile(task.post.videoUrl); 185 - // Verify the file is actually cached before proceeding 186 - cachedFile = await _cacheManager.getCachedFile(task.post.videoUrl); 187 - if (cachedFile == null) { 188 - throw Exception('Video file was not properly cached after download: ${task.post.videoUrl}'); 189 - } 190 - _logger.d('Video file successfully cached: ${task.post.videoUrl}'); 191 - case EmbedViewImage() || EmbedViewBskyImages(): 192 - for (final url in task.post.imageUrls) { 193 - // Download the image and verify it's cached 194 - final fileInfo = await CachedNetworkImageProvider.defaultCacheManager.downloadFile(url, key: url); 195 - if (fileInfo.statusCode != 200) { 196 - _logger.w('Image file was not properly cached after download: $url'); 197 - } 198 - } 199 - case _: 200 - throw Exception('Unsupported media type: ${media.runtimeType}'); 201 - } 202 - case _: 203 - throw Exception('Unsupported media type: ${task.post.embed.runtimeType}'); 204 - } 205 - } else { 206 - _logger.d('Skipping media caching for Bluesky post: ${task.uri} (HLS streaming not supported)'); 197 + case _: 198 + throw Exception('Unsupported media type: ${media.runtimeType}'); 199 + } 200 + case EmbedViewBskyVideo(:final thumbnail): 201 + await DownloadManagerImpl.controller.preCache( 202 + BetterPlayerDataSource( 203 + BetterPlayerDataSourceType.network, 204 + videoFormat: BetterPlayerVideoFormat.hls, 205 + videoExtension: 'm3u8', 206 + task.post.videoUrl, 207 + placeholder: CachedNetworkImage(imageUrl: thumbnail.toString()), 208 + cacheConfiguration: BetterPlayerCacheConfiguration( 209 + useCache: true, 210 + preCacheSize: 10 * 1024 * 1024, // 10 MB 211 + maxCacheSize: 500 * 1024 * 1024, // 500 MB 212 + key: task.post.videoUrl, 213 + ), 214 + ), 215 + ); 216 + case _: 217 + throw Exception('Unsupported media type: ${task.post.embed.runtimeType}'); 207 218 } 208 219 209 220 // Always store the post data in the database (regardless of whether media was cached)
+8 -23
lib/src/core/storage/cache/sql_cache_impl.dart
··· 3 3 4 4 import 'package:atproto/atproto.dart'; 5 5 import 'package:atproto_core/atproto_core.dart'; 6 + import 'package:better_player_plus/better_player_plus.dart'; 6 7 import 'package:get_it/get_it.dart'; 7 8 import 'package:path/path.dart'; 8 9 import 'package:sparksocial/src/core/network/atproto/data/models/models.dart'; 9 10 import 'package:sparksocial/src/core/storage/cache/sql_cache_interface.dart'; 10 - import 'package:sparksocial/src/core/storage/storage.dart'; 11 11 import 'package:sparksocial/src/core/utils/logging/logging.dart'; 12 12 import 'package:sqflite/sqflite.dart'; 13 13 ··· 246 246 .map((e) => Label.fromJson(e as Map<String, dynamic>)) 247 247 .toList() 248 248 : null, 249 - viewer: map[_columnViewer] != null ? Viewer.fromJson(jsonDecode(map[_columnViewer] as String) as Map<String, dynamic>) : null, 250 - embed: map[_columnEmbed] != null ? EmbedView.fromJson(jsonDecode(map[_columnEmbed] as String) as Map<String, dynamic>) : null, 249 + viewer: map[_columnViewer] != null 250 + ? Viewer.fromJson(jsonDecode(map[_columnViewer] as String) as Map<String, dynamic>) 251 + : null, 252 + embed: map[_columnEmbed] != null 253 + ? EmbedView.fromJson(jsonDecode(map[_columnEmbed] as String) as Map<String, dynamic>) 254 + : null, 251 255 ); 252 256 } 253 257 ··· 469 473 @override 470 474 Future<int> evictLeastRecentlyAccessed({required int postsToKeep}) async { 471 475 final db = await database; 472 - final cacheManager = GetIt.instance<CacheManagerInterface>(); 473 476 final countResult = await db.rawQuery('SELECT COUNT(*) FROM $_tablePosts'); 474 477 final currentSize = Sqflite.firstIntValue(countResult) ?? 0; 475 478 var deletedCount = 0; ··· 485 488 ); 486 489 487 490 if (toDeleteMaps.isNotEmpty) { 488 - for (final map in toDeleteMaps) { 489 - final uri = map[_columnUri] as String; 490 - await cacheManager.removeFile(uri); 491 - } 492 - 493 491 final urisToDelete = toDeleteMaps.map((map) => map[_columnUri] as String).toList(); 494 492 final placeholders = List.generate(urisToDelete.length, (index) => '?').join(','); 495 493 deletedCount = await db.delete(_tablePosts, where: '$_columnUri IN ($placeholders)', whereArgs: urisToDelete); ··· 505 503 final db = await database; 506 504 final cutoffTimestamp = DateTime.now().subtract(maxAge).millisecondsSinceEpoch; 507 505 508 - final cacheManager = GetIt.instance<CacheManagerInterface>(); 509 - final List<Map<String, dynamic>> toDeleteMaps = await db.query( 510 - _tablePosts, 511 - columns: [_columnUri], 512 - where: '$_columnLastAccessed < ?', 513 - whereArgs: [cutoffTimestamp], 514 - ); 515 - for (final map in toDeleteMaps) { 516 - final uri = map[_columnUri] as String; 517 - await cacheManager.removeFile(uri); 518 - } 519 - 520 506 return db.delete(_tablePosts, where: '$_columnLastAccessed < ?', whereArgs: [cutoffTimestamp]); 521 507 } 522 508 ··· 524 510 @override 525 511 Future<void> clearAllData() async { 526 512 final db = await database; 527 - final cacheManager = GetIt.instance<CacheManagerInterface>(); 528 513 await db.transaction((txn) async { 529 514 await txn.delete(_tableFeedPostAssociations); 530 515 await txn.delete(_tablePosts); 531 516 await txn.delete(_tableFeeds); 532 517 }); 533 - await cacheManager.clearCache(); 518 + await BetterPlayerController(const BetterPlayerConfiguration()).clearCache(); 534 519 } 535 520 536 521 /// Closes the database. Not typically needed for a singleton service
-1
lib/src/core/storage/storage.dart
··· 1 - export 'cache/cache_manager_interface.dart'; 2 1 export 'cache/download_manager_impl.dart'; 3 2 export 'cache/sql_cache_impl.dart'; 4 3 export 'preferences/local_storage_interface.dart';
+9 -15
lib/src/features/feed/providers/feed_provider.dart
··· 10 10 import 'package:sparksocial/src/core/network/atproto/data/models/labeler_models.dart'; 11 11 import 'package:sparksocial/src/core/network/atproto/data/repositories/feed_repository.dart'; 12 12 import 'package:sparksocial/src/core/network/atproto/data/repositories/sprk_repository.dart'; 13 - import 'package:sparksocial/src/core/storage/cache/cache_manager_interface.dart'; 14 13 import 'package:sparksocial/src/core/storage/cache/download_manager_interface.dart'; 15 14 import 'package:sparksocial/src/core/storage/cache/sql_cache_interface.dart'; 16 15 import 'package:sparksocial/src/core/storage/preferences/settings_repository.dart'; ··· 34 33 late final SparkLogger _logger; 35 34 late final DownloadManagerInterface _downloadManager; 36 35 late final SettingsRepository _settingsRepository; 37 - late final CacheManagerInterface _cacheManager; 38 36 39 37 // Add a flag to track if this notifier has been built before 40 38 bool _hasBeenBuilt = false; ··· 50 48 _settingsRepository = GetIt.instance<SettingsRepository>(); 51 49 _sqlCache = GetIt.instance<SQLCacheInterface>(); 52 50 _downloadManager = GetIt.instance<DownloadManagerInterface>(); 53 - _cacheManager = GetIt.instance<CacheManagerInterface>(); 54 51 _logger = GetIt.instance<LogService>().getLogger('FeedNotifier ${feed.identifier}'); 55 52 } else { 56 53 _logger.d('Build called again for ${feed.identifier}, hasBeenBuilt: $_hasBeenBuilt'); ··· 471 468 472 469 // Ensure media files are cached; if missing, enqueue a download task. 473 470 if (post.embed is EmbedViewVideo) { 474 - final cachedFile = await _cacheManager.getCachedFile(post.videoUrl); 475 - if (cachedFile == null) { 476 - _downloadManager.submitTask( 477 - DownloadTask( 478 - uri: post.uri, 479 - post: post, 480 - feed: _feed, 481 - onComplete: (task) => _logger.d('Re-cached media for \\${task.uri}'), 482 - onError: (task, e, s) => _logger.e('Failed to re-cache media for \\${task.uri}: \\$e'), 483 - ), 484 - ); 485 - } 471 + _downloadManager.submitTask( 472 + DownloadTask( 473 + uri: post.uri, 474 + post: post, 475 + feed: _feed, 476 + onComplete: (task) => _logger.d('Re-cached media for \\${task.uri}'), 477 + onError: (task, e, s) => _logger.e('Failed to re-cache media for \\${task.uri}: \\$e'), 478 + ), 479 + ); 486 480 } 487 481 } 488 482
+1
lib/src/features/feed/ui/pages/feed_page.dart
··· 111 111 scrollDirection: Axis.vertical, 112 112 restorationId: widget.feed.identifier, 113 113 physics: shouldBeActive ? const PageScrollPhysics() : const NeverScrollableScrollPhysics(), 114 + allowImplicitScrolling: true, 114 115 onPageChanged: (index) { 115 116 // Only handle page changes when active 116 117 if (shouldBeActive) {
+4 -4
lib/src/features/feed/ui/pages/standalone_post_page.dart
··· 152 152 key: _videoPlayerKey, 153 153 videoUrl: postData.videoUrl, 154 154 // For standalone, we don't need feed and index 155 - isSparkPost: true, 155 + thumbnail: postData.thumbnailUrl, 156 156 ), 157 157 EmbedViewBskyVideo() => PostVideoPlayer( 158 158 key: _videoPlayerKey, 159 159 videoUrl: postData.videoUrl, 160 - isSparkPost: false, 160 + thumbnail: postData.thumbnailUrl, 161 161 ), 162 162 EmbedViewImage() || EmbedViewBskyImages() => ImageCarousel(imageUrls: postData.imageUrls), 163 163 EmbedViewBskyRecordWithMedia(:final media) => switch (media) { 164 164 EmbedViewVideo() => PostVideoPlayer( 165 165 key: _videoPlayerKey, 166 166 videoUrl: postData.videoUrl, 167 - isSparkPost: true, 167 + thumbnail: postData.thumbnailUrl, 168 168 ), 169 169 EmbedViewBskyVideo() => PostVideoPlayer( 170 170 key: _videoPlayerKey, 171 171 videoUrl: postData.videoUrl, 172 - isSparkPost: false, 172 + thumbnail: postData.thumbnailUrl, 173 173 ), 174 174 EmbedViewImage() || EmbedViewBskyImages() => ImageCarousel(imageUrls: postData.imageUrls), 175 175 _ => const SizedBox.shrink(),
+24 -21
lib/src/features/feed/ui/widgets/post/feed_post_widget.dart
··· 200 200 child: GestureDetector( 201 201 onDoubleTap: () => _handleDoubleTapLike(postData), 202 202 child: Stack( 203 + alignment: Alignment.center, 203 204 children: [ 204 205 // Main content 205 - switch (postData.embed) { 206 - EmbedViewVideo() => PostVideoPlayer( 207 - key: _videoPlayerKey, 208 - videoUrl: postData.videoUrl, 209 - feed: widget.feed, 210 - index: widget.index, 211 - isSparkPost: true, 212 - ), 213 - EmbedViewBskyVideo() => PostVideoPlayer( 214 - key: _videoPlayerKey, 215 - videoUrl: postData.videoUrl, 216 - feed: widget.feed, 217 - index: widget.index, 218 - isSparkPost: false, 219 - ), 220 - EmbedViewImage() || EmbedViewBskyImages() => ImageCarousel(imageUrls: postData.imageUrls), 221 - EmbedViewBskyRecordWithMedia(:final media) => switch (media) { 206 + Positioned.fill( 207 + child: switch (postData.embed) { 222 208 EmbedViewVideo() => PostVideoPlayer( 223 209 key: _videoPlayerKey, 224 210 videoUrl: postData.videoUrl, 225 211 feed: widget.feed, 226 212 index: widget.index, 227 - isSparkPost: true, 213 + thumbnail: postData.thumbnailUrl, 228 214 ), 229 215 EmbedViewBskyVideo() => PostVideoPlayer( 230 216 key: _videoPlayerKey, 231 217 videoUrl: postData.videoUrl, 232 218 feed: widget.feed, 233 219 index: widget.index, 234 - isSparkPost: false, 220 + thumbnail: postData.thumbnailUrl, 235 221 ), 236 222 EmbedViewImage() || EmbedViewBskyImages() => ImageCarousel(imageUrls: postData.imageUrls), 223 + EmbedViewBskyRecordWithMedia(:final media) => switch (media) { 224 + EmbedViewVideo() => PostVideoPlayer( 225 + key: _videoPlayerKey, 226 + videoUrl: postData.videoUrl, 227 + feed: widget.feed, 228 + index: widget.index, 229 + thumbnail: postData.thumbnailUrl, 230 + ), 231 + EmbedViewBskyVideo() => PostVideoPlayer( 232 + key: _videoPlayerKey, 233 + videoUrl: postData.videoUrl, 234 + feed: widget.feed, 235 + index: widget.index, 236 + thumbnail: postData.thumbnailUrl, 237 + ), 238 + EmbedViewImage() || EmbedViewBskyImages() => ImageCarousel(imageUrls: postData.imageUrls), 239 + _ => const DecoratedBox(decoration: BoxDecoration(color: AppColors.black)), 240 + }, 237 241 _ => const DecoratedBox(decoration: BoxDecoration(color: AppColors.black)), 238 242 }, 239 - _ => const DecoratedBox(decoration: BoxDecoration(color: AppColors.black)), 240 - }, 243 + ), 241 244 242 245 // Side action bar 243 246 Positioned(bottom: 4, right: 4, child: sideActionBar),
-43
lib/src/features/feed/ui/widgets/videos/time_display.dart
··· 1 - import 'package:flutter/material.dart'; 2 - import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 3 - 4 - class TimeDisplay extends StatelessWidget { 5 - const TimeDisplay({required this.position, required this.duration, super.key}); 6 - final Duration position; 7 - final Duration duration; 8 - 9 - String _formatDuration(Duration duration) { 10 - final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); 11 - final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); 12 - return '$minutes:$seconds'; 13 - } 14 - 15 - @override 16 - Widget build(BuildContext context) { 17 - return Center( 18 - child: Container( 19 - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), 20 - decoration: BoxDecoration( 21 - color: AppColors.black.withAlpha(180), 22 - borderRadius: BorderRadius.circular(12), 23 - boxShadow: [ 24 - BoxShadow( 25 - color: AppColors.black.withAlpha(100), 26 - blurRadius: 8, 27 - offset: const Offset(0, 2), 28 - ), 29 - ], 30 - ), 31 - child: Text( 32 - '${_formatDuration(position)} / ${_formatDuration(duration)}', 33 - style: const TextStyle( 34 - color: AppColors.white, 35 - fontWeight: FontWeight.bold, 36 - fontSize: 16, 37 - letterSpacing: 0.5, 38 - ), 39 - ), 40 - ), 41 - ); 42 - } 43 - }
+157 -236
lib/src/features/feed/ui/widgets/videos/video_player.dart
··· 1 1 import 'dart:async'; 2 2 3 + import 'package:better_player_plus/better_player_plus.dart'; 3 4 import 'package:flutter/material.dart'; 5 + import 'package:flutter_animated_progress_bar/flutter_animated_progress_bar.dart'; 4 6 import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 7 import 'package:get_it/get_it.dart'; 6 - import 'package:smooth_video_progress/smooth_video_progress.dart'; 7 8 import 'package:sparksocial/src/core/network/atproto/data/models/feed_models.dart'; 8 - import 'package:sparksocial/src/core/storage/storage.dart'; 9 9 import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 10 + import 'package:sparksocial/src/core/utils/logging/logging.dart'; 10 11 import 'package:sparksocial/src/features/feed/providers/feed_provider.dart'; 11 - import 'package:sparksocial/src/features/feed/ui/widgets/videos/slider.dart'; 12 - import 'package:sparksocial/src/features/feed/ui/widgets/videos/time_display.dart'; 13 12 import 'package:sparksocial/src/features/home/providers/navigation_provider.dart'; 14 - import 'package:video_player/video_player.dart'; 15 13 16 14 class PostVideoPlayer extends ConsumerStatefulWidget { 17 - const PostVideoPlayer({required this.videoUrl, required this.isSparkPost, super.key, this.feed, this.index}); 15 + const PostVideoPlayer({required this.videoUrl, required this.thumbnail, super.key, this.feed, this.index}); 18 16 19 17 final String videoUrl; 18 + final String thumbnail; 20 19 final Feed? feed; 21 20 final int? index; 22 - final bool isSparkPost; 23 21 24 22 @override 25 23 ConsumerState<PostVideoPlayer> createState() => PostVideoPlayerState(); 26 24 } 27 25 28 26 class PostVideoPlayerState extends ConsumerState<PostVideoPlayer> with TickerProviderStateMixin { 29 - bool isPlaying = false; 30 - late VideoPlayerController videoController; 31 - bool isInitialized = false; 27 + BetterPlayerController? videoController; 28 + late final ProgressBarController _progressController; 32 29 bool _userInteracted = false; // Track if user manually played/paused 33 - bool shouldCacheAgain = false; 34 - bool _cacheRequested = false; // Track if cache request has been made 35 - bool _isSeeking = false; 36 30 37 31 late AnimationController _bounceController; 38 32 late Animation<double> _bounceAnimation; ··· 41 35 int? _lastNavigationIndex; 42 36 int? _lastFeedIndex; 43 37 44 - // Expose the video controller publicly 45 - VideoPlayerController? get controller => isInitialized ? videoController : null; 46 - 47 - // Add public method to pause video 48 - void pauseVideo() { 49 - if (isInitialized && videoController.value.isPlaying) { 50 - videoController.pause(); 51 - setState(() { 52 - _userInteracted = true; // Mark as user interaction to prevent auto-resume 53 - }); 54 - } 55 - } 38 + bool get isPlaying => videoController?.isPlaying() ?? false; 39 + bool get isInitialized => videoController?.isVideoInitialized() ?? false; 56 40 57 41 @override 58 42 void initState() { 59 43 super.initState(); 60 - _bounceController = AnimationController(duration: const Duration(milliseconds: 300), vsync: this); 44 + _bounceController = AnimationController( 45 + duration: const Duration(milliseconds: 300), 46 + vsync: this, 47 + ); 61 48 _bounceAnimation = Tween<double>( 62 49 begin: 1, 63 50 end: 1.3, 64 51 ).animate(CurvedAnimation(parent: _bounceController, curve: Curves.elasticOut)); 52 + _progressController = ProgressBarController( 53 + vsync: this, 54 + waitingDuration: const Duration(milliseconds: 100), 55 + barAnimationDuration: const Duration(milliseconds: 200), 56 + ); 65 57 initVideoPlayer(); 58 + GetIt.I<LogService>().getLogger('PostVideoPlayer').i('Initialized PostVideoPlayer with video URL: ${widget.videoUrl}'); 59 + } 60 + 61 + void pauseVideo() { 62 + if (videoController?.isPlaying() ?? false) { 63 + videoController?.pause(); 64 + setState(() { 65 + _userInteracted = true; // Mark as user interaction to prevent auto-resume 66 + }); 67 + } 66 68 } 67 69 68 70 @override 69 71 void dispose() { 70 72 _bounceController.dispose(); 71 - if (isInitialized) { 72 - videoController.removeListener(_videoListener); 73 - videoController.dispose(); 74 - } 73 + videoController?.dispose(); 74 + _progressController.dispose(); 75 75 super.dispose(); 76 76 } 77 77 78 - void _videoListener() { 79 - if (mounted && videoController.value.isInitialized) { 80 - final nowPlaying = videoController.value.isPlaying; 81 - if (nowPlaying != isPlaying) { 82 - setState(() { 83 - isPlaying = nowPlaying; 84 - }); 78 + void _videoListener(BetterPlayerEvent event) { 79 + if (mounted) { 80 + final paused = event.betterPlayerEventType == BetterPlayerEventType.pause; 81 + 82 + if (paused) { 83 + _bounceController.reset(); 84 + _bounceController.forward(); 85 + } 85 86 86 - if (!nowPlaying) { 87 - _bounceController.reset(); 88 - _bounceController.forward(); 89 - } 87 + final playing = event.betterPlayerEventType == BetterPlayerEventType.play; 88 + if (playing) { 89 + _bounceController.stop(); 90 + _bounceController.value = 1.0; // Reset bounce animation when playing 90 91 } 91 92 } 92 93 } 93 94 94 95 Future<void> initVideoPlayer() async { 95 96 try { 96 - final cacheManager = GetIt.I<CacheManagerInterface>(); 97 - 98 - // Check if this is a Bluesky post (non-Spark) - always use network streaming 99 - if (!widget.isSparkPost) { 100 - // For Bluesky posts, always use network streaming (HLS support) 101 - videoController = VideoPlayerController.networkUrl(Uri.parse(widget.videoUrl)); 102 - } else { 103 - // For Spark posts, use caching as before 104 - final file = await cacheManager.getCachedFile(widget.videoUrl); 105 - if (!mounted) return; 106 - 107 - if (file == null) { 108 - // For AT Protocol blob URLs, force caching first for Spark videos 109 - if (widget.videoUrl.startsWith('at://')) { 110 - try { 111 - final cachedFile = await cacheManager.getFile(widget.videoUrl); 112 - videoController = VideoPlayerController.file( 113 - cachedFile, 114 - videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true), 115 - ); 116 - } catch (e) { 117 - // If AT Protocol blob download fails, we can't fall back to network URL 118 - // because AT URIs are not HTTP URLs 119 - if (!mounted) return; 120 - setState(() { 121 - isInitialized = false; // Mark as failed to initialize 122 - }); 123 - return; 124 - } 125 - } else { 126 - // For HTTP URLs, use network player and cache in background 127 - videoController = VideoPlayerController.networkUrl(Uri.parse(widget.videoUrl)); 128 - shouldCacheAgain = true; 129 - } 130 - } else { 131 - videoController = VideoPlayerController.file(file, videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true)); 132 - } 133 - } 134 - 135 - await videoController.initialize(); 136 - videoController.setLooping(true); 137 - videoController.addListener(_videoListener); 97 + final dataSource = BetterPlayerDataSource( 98 + BetterPlayerDataSourceType.network, 99 + widget.videoUrl, 100 + // don't use placeholder because then the video straight up never loads 101 + videoFormat: BetterPlayerVideoFormat.hls, 102 + videoExtension: 'm3u8', 103 + bufferingConfiguration: BetterPlayerBufferingConfiguration( 104 + minBufferMs: const Duration(seconds: 10).inMilliseconds, 105 + maxBufferMs: const Duration(seconds: 60).inMilliseconds, 106 + ), 107 + cacheConfiguration: BetterPlayerCacheConfiguration( 108 + useCache: true, 109 + preCacheSize: 20 * 1024 * 1024, // 20 MB 110 + maxCacheSize: 1024 * 1024 * 1024, // 1 GB 111 + key: widget.videoUrl, 112 + ), 113 + ); 114 + final videoControllerTemp = BetterPlayerController( 115 + const BetterPlayerConfiguration( 116 + controlsConfiguration: BetterPlayerControlsConfiguration(showControls: false), 117 + looping: true, 118 + fit: BoxFit.contain, 119 + expandToFill: false, 120 + allowedScreenSleep: false, 121 + ), 122 + ); 123 + await videoControllerTemp.setupDataSource(dataSource); 124 + videoControllerTemp.addEventsListener(_videoListener); 138 125 if (!mounted) return; 139 126 setState(() { 140 - isInitialized = true; 141 - isPlaying = videoController.value.isPlaying; 127 + videoController = videoControllerTemp; 142 128 }); 143 129 } catch (e) { 144 130 if (!mounted) return; 145 - setState(() { 146 - isInitialized = false; // Mark as failed to initialize 147 - }); 148 131 } 149 132 } 150 133 151 134 void _handleAutoPlayPause(bool shouldPlay) { 152 - if (!isInitialized || _userInteracted) return; 135 + if (_userInteracted) return; 153 136 154 - if (shouldPlay && !videoController.value.isPlaying) { 155 - videoController.play(); 156 - } else if (!shouldPlay && videoController.value.isPlaying) { 157 - videoController.pause(); 137 + if (shouldPlay && !isPlaying) { 138 + videoController?.play(); 139 + } else if (!shouldPlay && isPlaying) { 140 + videoController?.pause(); 158 141 } 159 142 } 160 143 ··· 162 145 if (!isInitialized) return; 163 146 164 147 // Always pause when not on feeds tab, regardless of user interaction 165 - if (!isOnFeedsTab && videoController.value.isPlaying) { 166 - videoController.pause(); 148 + if (!isOnFeedsTab && isPlaying) { 149 + videoController?.pause(); 167 150 } 168 151 } 169 152 170 - void _onSeekStart(double value) { 171 - if (!isInitialized) return; 172 - _isSeeking = true; 173 - videoController.pause(); 174 - videoController.setVolume(0); 175 - } 176 - 177 - void _onSeekChanged(double value) { 178 - if (!isInitialized) return; 179 - videoController.seekTo(Duration(milliseconds: value.toInt())); 180 - if (videoController.value.isPlaying) { 181 - videoController.pause(); 182 - } 183 - } 184 - 185 - void _onSeekEnd(double value) { 186 - if (!isInitialized) return; 187 - _isSeeking = false; 188 - videoController.setVolume(1); 189 - videoController.play(); 190 - } 191 - 192 153 @override 193 154 Widget build(BuildContext context) { 194 155 if (!isInitialized) { 195 - // Show loading indicator - error handling is done in initVideoPlayer 196 156 return const Center(child: CircularProgressIndicator()); 197 157 } 198 158 ··· 212 172 } 213 173 }); 214 174 } 175 + WidgetsBinding.instance.addPostFrameCallback((_) { 176 + // Handle feed index changes only when they actually change 177 + if (feedState != null && _lastFeedIndex != feedState.index) { 178 + _lastFeedIndex = feedState.index; 179 + WidgetsBinding.instance.addPostFrameCallback((_) { 180 + if (mounted && !_userInteracted) { 181 + final shouldPlay = feedState.index == widget.index && isOnFeedsTab; 182 + _handleAutoPlayPause(shouldPlay); 183 + } 184 + }); 185 + } else if (widget.feed == null && widget.index == null) { 186 + _handleAutoPlayPause(true); 187 + } 188 + }); 215 189 216 - // Handle feed index changes only when they actually change 217 - if (feedState != null && _lastFeedIndex != feedState.index) { 218 - _lastFeedIndex = feedState.index; 219 - WidgetsBinding.instance.addPostFrameCallback((_) { 220 - if (mounted && !_userInteracted) { 221 - final shouldPlay = feedState.index == widget.index && isOnFeedsTab; 222 - _handleAutoPlayPause(shouldPlay); 190 + return GestureDetector( 191 + onTap: () { 192 + _userInteracted = true; // User manually interacted 193 + if (isPlaying) { 194 + videoController?.pause(); 195 + } else { 196 + videoController?.play(); 223 197 } 224 - }); 225 - } else if (widget.feed == null && widget.index == null) { 226 - _handleAutoPlayPause(true); 227 - } 228 - 229 - if (shouldCacheAgain && !_cacheRequested && widget.feed != null && widget.index != null && widget.isSparkPost) { 230 - _cacheRequested = true; // Set flag immediate to prevent multiple requests 231 - // Delay the provider modification until after the build is complete 232 - WidgetsBinding.instance.addPostFrameCallback((_) { 233 - final notifier = ref.read(feedNotifierProvider(widget.feed!).notifier); 234 - final state = ref.read(feedNotifierProvider(widget.feed!)); 235 - if (widget.index! < state.loadedPosts.length) { 236 - notifier.store([state.loadedPosts[widget.index!]]); 237 - } 238 - }); 239 - } 240 - 241 - return Stack( 242 - children: [ 243 - GestureDetector( 244 - onTap: () { 245 - _userInteracted = true; // User manually interacted 246 - if (videoController.value.isPlaying) { 247 - videoController.pause(); 248 - } else { 249 - videoController.play(); 250 - } 251 - }, 252 - child: Stack( 253 - children: [ 254 - SizedBox.expand( 255 - child: FittedBox( 256 - child: SizedBox( 257 - width: videoController.value.size.width, 258 - height: videoController.value.size.height, 259 - child: VideoPlayer(videoController), 198 + }, 199 + child: Stack( 200 + alignment: Alignment.center, 201 + children: [ 202 + Positioned.fill(child: BetterPlayer(controller: videoController!)), 203 + Center( 204 + child: AnimatedBuilder( 205 + animation: _bounceAnimation, 206 + builder: (context, child) { 207 + return Transform.scale( 208 + scale: isPlaying ? 1.0 : _bounceAnimation.value, 209 + child: Icon( 210 + isPlaying ? Icons.pause : Icons.play_arrow, 211 + size: 50, 212 + color: isPlaying ? Colors.transparent : AppColors.white, 260 213 ), 261 - ), 262 - ), 263 - Center( 264 - child: AnimatedBuilder( 265 - animation: _bounceAnimation, 266 - builder: (context, child) { 267 - return Transform.scale( 268 - scale: isPlaying ? 1.0 : _bounceAnimation.value, 269 - child: Icon( 270 - isPlaying ? Icons.pause : Icons.play_arrow, 271 - size: 50, 272 - color: isPlaying ? Colors.transparent : AppColors.white, 273 - ), 274 - ); 275 - }, 276 - ), 277 - ), 278 - ], 214 + ); 215 + }, 216 + ), 279 217 ), 280 - ), 281 - // Gradient overlay at the bottom to improve text readability 282 - Positioned( 283 - left: 0, 284 - right: 0, 285 - bottom: 0, 286 - child: IgnorePointer( 287 - child: Container( 288 - height: 120, 289 - decoration: BoxDecoration( 290 - gradient: LinearGradient( 291 - begin: Alignment.bottomCenter, 292 - end: Alignment.topCenter, 293 - colors: [Colors.black87.withAlpha(100), Colors.transparent], 218 + Positioned( 219 + left: 0, 220 + right: 0, 221 + bottom: 0, 222 + child: IgnorePointer( 223 + child: Container( 224 + height: 120, 225 + decoration: BoxDecoration( 226 + gradient: LinearGradient( 227 + begin: Alignment.bottomCenter, 228 + end: Alignment.topCenter, 229 + colors: [Colors.black87.withAlpha(100), Colors.transparent], 230 + ), 294 231 ), 295 232 ), 296 233 ), 297 234 ), 298 - ), 299 - Positioned( 300 - bottom: 2, 301 - left: 0, 302 - right: 0, 303 - child: SmoothVideoProgress( 304 - controller: videoController, 305 - builder: (context, position, duration, child) { 306 - return Column( 307 - children: [ 308 - if (_isSeeking) 309 - Padding( 310 - padding: const EdgeInsets.only(bottom: 32), 311 - child: TimeDisplay( 312 - position: Duration(milliseconds: videoController.value.position.inMilliseconds), 313 - duration: videoController.value.duration, 314 - ), 315 - ), 316 - SizedBox( 317 - height: 150, 318 - child: SliderTheme( 319 - data: SliderTheme.of(context).copyWith( 320 - trackHeight: 3, 321 - activeTrackColor: AppColors.primary, 322 - inactiveTrackColor: AppColors.white.withAlpha(64), 323 - thumbShape: SliderComponentShape.noThumb, 324 - overlayShape: SliderComponentShape.noThumb, 325 - trackShape: const BottomAlignedSliderTrackShape(), 326 - ), 327 - child: Slider( 328 - value: position.inMilliseconds.toDouble(), 329 - max: duration.inMilliseconds.toDouble(), 330 - onChanged: _onSeekChanged, 331 - onChangeStart: _onSeekStart, 332 - onChangeEnd: _onSeekEnd, 333 - ), 334 - ), 335 - ), 336 - ], 337 - ); 338 - }, 235 + Positioned( 236 + bottom: 0, 237 + left: 0, 238 + right: 0, 239 + child: FutureBuilder( 240 + future: videoController?.videoPlayerController?.position, 241 + builder: (context, snapshot) { 242 + final position = snapshot.data; 243 + return ProgressBar( 244 + controller: _progressController, 245 + progress: position ?? Duration.zero, 246 + total: videoController?.videoPlayerController?.value.duration ?? Duration.zero, 247 + thumbGlowRadius: 0, 248 + collapsedThumbRadius: 0, 249 + collapsedProgressBarColor: AppColors.primary, 250 + expandedBarHeight: 16, 251 + 252 + onSeek: (duration) { 253 + if (videoController?.videoPlayerController != null) { 254 + videoController?.videoPlayerController?.seekTo(duration); 255 + } 256 + }, 257 + ); 258 + }, 259 + ), 339 260 ), 340 - ), 341 - ], 261 + ], 262 + ), 342 263 ); 343 264 } 344 265 }
+5 -12
lib/src/features/profile/ui/widgets/profile_feed_post_widget.dart
··· 147 147 child: FutureBuilder<PostView?>( 148 148 future: _loadPostWithFallback(), 149 149 builder: (context, snapshot) { 150 - if (snapshot.connectionState == ConnectionState.waiting) { 151 - return const ColoredBox( 152 - color: AppColors.black, 153 - child: Center(child: CircularProgressIndicator(color: AppColors.white)), 154 - ); 155 - } 156 - 157 - if (snapshot.hasError || !snapshot.hasData) { 150 + if (!snapshot.hasData) { 158 151 return const ColoredBox( 159 152 color: AppColors.black, 160 153 child: Center( ··· 185 178 children: [ 186 179 // Main content 187 180 switch (post.embed) { 188 - EmbedViewVideo() => PostVideoPlayer(videoUrl: post.videoUrl, isSparkPost: true), 189 - EmbedViewBskyVideo() => PostVideoPlayer(videoUrl: post.videoUrl, isSparkPost: false), 181 + EmbedViewVideo() => PostVideoPlayer(videoUrl: post.videoUrl, thumbnail: post.thumbnailUrl), 182 + EmbedViewBskyVideo() => PostVideoPlayer(videoUrl: post.videoUrl, thumbnail: post.thumbnailUrl), 190 183 EmbedViewImage() || EmbedViewBskyImages() => ImageCarousel(imageUrls: post.imageUrls), 191 184 EmbedViewBskyRecordWithMedia(:final media) => switch (media) { 192 - EmbedViewVideo() => PostVideoPlayer(videoUrl: post.videoUrl, isSparkPost: true), 193 - EmbedViewBskyVideo() => PostVideoPlayer(videoUrl: post.videoUrl, isSparkPost: false), 185 + EmbedViewVideo() => PostVideoPlayer(videoUrl: post.videoUrl, thumbnail: post.thumbnailUrl), 186 + EmbedViewBskyVideo() => PostVideoPlayer(videoUrl: post.videoUrl, thumbnail: post.thumbnailUrl), 194 187 EmbedViewImage() || EmbedViewBskyImages() => ImageCarousel(imageUrls: post.imageUrls), 195 188 _ => const DecoratedBox(decoration: BoxDecoration(color: AppColors.black)), 196 189 },
+86 -17
pubspec.lock
··· 129 129 url: "https://pub.dev" 130 130 source: hosted 131 131 version: "1.0.1" 132 + better_player_plus: 133 + dependency: "direct main" 134 + description: 135 + name: better_player_plus 136 + sha256: fc8804f837b450b1b614d5e624a9312894d3089c9194330542ab04a4afa82127 137 + url: "https://pub.dev" 138 + source: hosted 139 + version: "1.0.8" 132 140 bluesky: 133 141 dependency: "direct main" 134 142 description: ··· 425 433 url: "https://pub.dev" 426 434 source: hosted 427 435 version: "3.1.0" 436 + dbus: 437 + dependency: transitive 438 + description: 439 + name: dbus 440 + sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" 441 + url: "https://pub.dev" 442 + source: hosted 443 + version: "0.7.11" 428 444 fake_async: 429 445 dependency: transitive 430 446 description: ··· 502 518 description: flutter 503 519 source: sdk 504 520 version: "0.0.0" 521 + flutter_animated_progress_bar: 522 + dependency: "direct main" 523 + description: 524 + name: flutter_animated_progress_bar 525 + sha256: "805175fab1a1fb7d13f9107321353c0f297b83fb67e05e77ab96cf555e30a62e" 526 + url: "https://pub.dev" 527 + source: hosted 528 + version: "1.0.5" 505 529 flutter_cache_manager: 506 530 dependency: "direct main" 507 531 description: ··· 518 542 url: "https://pub.dev" 519 543 source: hosted 520 544 version: "5.2.1" 521 - flutter_hooks: 522 - dependency: transitive 523 - description: 524 - name: flutter_hooks 525 - sha256: "6a126f703b89499818d73305e4ce1e3de33b4ae1c5512e3b8eab4b986f46774c" 526 - url: "https://pub.dev" 527 - source: hosted 528 - version: "0.18.6" 529 545 flutter_launcher_icons: 530 546 dependency: "direct dev" 531 547 description: ··· 542 558 url: "https://pub.dev" 543 559 source: hosted 544 560 version: "6.0.0" 561 + flutter_localizations: 562 + dependency: transitive 563 + description: flutter 564 + source: sdk 565 + version: "0.0.0" 545 566 flutter_plugin_android_lifecycle: 546 567 dependency: transitive 547 568 description: ··· 624 645 description: flutter 625 646 source: sdk 626 647 version: "0.0.0" 648 + flutter_widget_from_html_core: 649 + dependency: transitive 650 + description: 651 + name: flutter_widget_from_html_core 652 + sha256: b1048fd119a14762e2361bd057da608148a895477846d6149109b2151d2f7abf 653 + url: "https://pub.dev" 654 + source: hosted 655 + version: "0.15.2" 627 656 freezed: 628 657 dependency: "direct main" 629 658 description: ··· 824 853 url: "https://pub.dev" 825 854 source: hosted 826 855 version: "1.53.0" 856 + intl: 857 + dependency: transitive 858 + description: 859 + name: intl 860 + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" 861 + url: "https://pub.dev" 862 + source: hosted 863 + version: "0.20.2" 827 864 io: 828 865 dependency: transitive 829 866 description: ··· 984 1021 url: "https://pub.dev" 985 1022 source: hosted 986 1023 version: "2.2.0" 1024 + package_info_plus: 1025 + dependency: transitive 1026 + description: 1027 + name: package_info_plus 1028 + sha256: "7976bfe4c583170d6cdc7077e3237560b364149fcd268b5f53d95a991963b191" 1029 + url: "https://pub.dev" 1030 + source: hosted 1031 + version: "8.3.0" 1032 + package_info_plus_platform_interface: 1033 + dependency: transitive 1034 + description: 1035 + name: package_info_plus_platform_interface 1036 + sha256: "6c935fb612dff8e3cc9632c2b301720c77450a126114126ffaafe28d2e87956c" 1037 + url: "https://pub.dev" 1038 + source: hosted 1039 + version: "3.2.0" 987 1040 path: 988 1041 dependency: "direct main" 989 1042 description: ··· 1245 1298 description: flutter 1246 1299 source: sdk 1247 1300 version: "0.0.0" 1248 - smooth_video_progress: 1249 - dependency: "direct main" 1250 - description: 1251 - name: smooth_video_progress 1252 - sha256: a47efa055d4f0d7634ffa7c43197d71a30c98f31fffe4bbe1a949c4b851c68db 1253 - url: "https://pub.dev" 1254 - source: hosted 1255 - version: "0.0.4" 1256 1301 source_gen: 1257 1302 dependency: transitive 1258 1303 description: ··· 1566 1611 source: hosted 1567 1612 version: "9.0.0" 1568 1613 video_player: 1569 - dependency: "direct main" 1614 + dependency: transitive 1570 1615 description: 1571 1616 name: video_player 1572 1617 sha256: "0d55b1f1a31e5ad4c4967bfaa8ade0240b07d20ee4af1dfef5f531056512961a" ··· 1605 1650 url: "https://pub.dev" 1606 1651 source: hosted 1607 1652 version: "2.3.4" 1653 + visibility_detector: 1654 + dependency: transitive 1655 + description: 1656 + name: visibility_detector 1657 + sha256: dd5cc11e13494f432d15939c3aa8ae76844c42b723398643ce9addb88a5ed420 1658 + url: "https://pub.dev" 1659 + source: hosted 1660 + version: "0.4.0+2" 1608 1661 vm_service: 1609 1662 dependency: transitive 1610 1663 description: ··· 1613 1666 url: "https://pub.dev" 1614 1667 source: hosted 1615 1668 version: "15.0.0" 1669 + wakelock_plus: 1670 + dependency: transitive 1671 + description: 1672 + name: wakelock_plus 1673 + sha256: a474e314c3e8fb5adef1f9ae2d247e57467ad557fa7483a2b895bc1b421c5678 1674 + url: "https://pub.dev" 1675 + source: hosted 1676 + version: "1.3.2" 1677 + wakelock_plus_platform_interface: 1678 + dependency: transitive 1679 + description: 1680 + name: wakelock_plus_platform_interface 1681 + sha256: e10444072e50dbc4999d7316fd303f7ea53d31c824aa5eb05d7ccbdd98985207 1682 + url: "https://pub.dev" 1683 + source: hosted 1684 + version: "1.2.3" 1616 1685 watcher: 1617 1686 dependency: transitive 1618 1687 description:
+2 -2
pubspec.yaml
··· 11 11 flutter: 12 12 sdk: flutter 13 13 flutter_dotenv: ^5.1.0 14 - video_player: ^2.10.0 15 14 cached_network_image: ^3.3.1 16 15 camera: ^0.11.1 17 16 path_provider: ^2.1.2 ··· 41 40 pool: ^1.5.0 42 41 collection: ^1.19.1 43 42 carousel_slider: ^5.0.0 44 - smooth_video_progress: ^0.0.4 45 43 imgly_editor: ^1.53.0 46 44 imgly_camera: ^1.53.0 47 45 web_socket_channel: ^3.0.3 48 46 any_link_preview: ^3.0.3 47 + better_player_plus: ^1.0.8 48 + flutter_animated_progress_bar: ^1.0.5 49 49 50 50 dev_dependencies: 51 51 flutter_test: