1import 'package:dio/dio.dart'; 2import 'package:flutter/foundation.dart'; 3 4/// Service for interacting with Streamable API 5/// 6/// Fetches video data from Streamable to get direct MP4 URLs 7/// for in-app video playback. 8/// 9/// Implements caching to reduce API calls for recently accessed videos. 10class StreamableService { 11 StreamableService({Dio? dio}) : _dio = dio ?? _sharedDio; 12 13 // Singleton Dio instance for efficient connection reuse 14 static final Dio _sharedDio = Dio( 15 BaseOptions( 16 connectTimeout: const Duration(seconds: 10), 17 receiveTimeout: const Duration(seconds: 10), 18 sendTimeout: const Duration(seconds: 10), 19 ), 20 ); 21 22 final Dio _dio; 23 24 // Cache for video URLs (shortcode -> {url, timestamp}) 25 // Short-lived cache (5 min) to reduce API calls 26 final Map<String, ({String url, DateTime cachedAt})> _urlCache = {}; 27 static const Duration _cacheDuration = Duration(minutes: 5); 28 29 /// Extracts the Streamable shortcode from a URL 30 /// 31 /// Examples: 32 /// - https://streamable.com/7kpdft -> 7kpdft 33 /// - https://streamable.com/e/abc123 -> abc123 34 /// - streamable.com/abc123 -> abc123 35 static String? extractShortcode(String url) { 36 try { 37 // Handle URLs without scheme 38 var urlToParse = url; 39 if (!url.contains('://')) { 40 urlToParse = 'https://$url'; 41 } 42 43 final uri = Uri.parse(urlToParse); 44 final path = uri.path; 45 46 // Get the last non-empty path segment (handles /e/ prefix and other cases) 47 final segments = path.split('/').where((s) => s.isNotEmpty).toList(); 48 if (segments.isEmpty) { 49 return null; 50 } 51 52 final shortcode = segments.last; 53 54 if (shortcode.isEmpty) { 55 return null; 56 } 57 58 return shortcode; 59 } on FormatException catch (e) { 60 if (kDebugMode) { 61 debugPrint('Error extracting Streamable shortcode: $e'); 62 } 63 return null; 64 } 65 } 66 67 /// Fetches the MP4 video URL for a Streamable video 68 /// 69 /// Returns the direct MP4 URL or null if the video cannot be fetched. 70 /// Uses a 5-minute cache to reduce API calls for repeated access. 71 Future<String?> getVideoUrl(String streamableUrl) async { 72 try { 73 final shortcode = extractShortcode(streamableUrl); 74 if (shortcode == null) { 75 if (kDebugMode) { 76 debugPrint('Failed to extract shortcode from: $streamableUrl'); 77 } 78 return null; 79 } 80 81 // Check cache first 82 final cached = _urlCache[shortcode]; 83 if (cached != null) { 84 final age = DateTime.now().difference(cached.cachedAt); 85 if (age < _cacheDuration) { 86 if (kDebugMode) { 87 debugPrint('Using cached URL for shortcode: $shortcode'); 88 } 89 return cached.url; 90 } 91 // Cache expired, remove it 92 _urlCache.remove(shortcode); 93 } 94 95 // Fetch video data from Streamable API 96 final response = await _dio.get<Map<String, dynamic>>( 97 'https://api.streamable.com/videos/$shortcode', 98 ); 99 100 if (response.statusCode == 200 && response.data != null) { 101 final data = response.data!; 102 103 // Extract MP4 URL from response 104 // Response structure: { "files": { "mp4": { "url": "//..." } } } 105 final files = data['files'] as Map<String, dynamic>?; 106 if (files == null) { 107 if (kDebugMode) { 108 debugPrint('No files found in Streamable response'); 109 } 110 return null; 111 } 112 113 final mp4 = files['mp4'] as Map<String, dynamic>?; 114 if (mp4 == null) { 115 if (kDebugMode) { 116 debugPrint('No MP4 file found in Streamable response'); 117 } 118 return null; 119 } 120 121 var videoUrl = mp4['url'] as String?; 122 if (videoUrl == null) { 123 if (kDebugMode) { 124 debugPrint('No URL found in MP4 data'); 125 } 126 return null; 127 } 128 129 // Prepend https: if URL is protocol-relative 130 if (videoUrl.startsWith('//')) { 131 videoUrl = 'https:$videoUrl'; 132 } 133 134 // Cache the URL for future requests 135 _urlCache[shortcode] = (url: videoUrl, cachedAt: DateTime.now()); 136 137 return videoUrl; 138 } 139 140 if (kDebugMode) { 141 debugPrint('Failed to fetch Streamable video: ${response.statusCode}'); 142 } 143 return null; 144 } on DioException catch (e) { 145 if (kDebugMode) { 146 debugPrint('Error fetching Streamable video URL: $e'); 147 } 148 return null; 149 } 150 } 151}