Main coves client

feat: add Streamable video support infrastructure

Add core services and widgets for Streamable video playback:

- StreamableService: API client for fetching video URLs
- Extracts shortcodes from URLs (handles /e/ format)
- 5-minute URL caching to reduce API calls
- Singleton Dio instance with 10s timeouts
- Handles protocol-relative URLs

- FullscreenVideoPlayer: Immersive video playback
- Swipe-to-dismiss gesture (vertical drag)
- Tap to play/pause
- Fade out background during swipe
- Lifecycle-aware (pauses on app background)

- MinimalVideoControls: Clean scrubber interface
- Progress bar with seek support
- Current time / total duration display
- Minimal, non-intrusive design

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

+467
+151
lib/services/streamable_service.dart
··· 1 + import 'package:dio/dio.dart'; 2 + import '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. 10 + class 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 + }
+172
lib/widgets/fullscreen_video_player.dart
··· 1 + import 'package:flutter/foundation.dart'; 2 + import 'package:flutter/material.dart'; 3 + import 'package:video_player/video_player.dart'; 4 + 5 + import '../constants/app_colors.dart'; 6 + import 'minimal_video_controls.dart'; 7 + 8 + /// Fullscreen video player with swipe-to-dismiss gesture 9 + /// 10 + /// Displays the video player in fullscreen with a black background. 11 + /// Supports vertical swipe-down gesture to dismiss (like Instagram/TikTok). 12 + class FullscreenVideoPlayer extends StatefulWidget { 13 + const FullscreenVideoPlayer({required this.videoUrl, super.key}); 14 + 15 + final String videoUrl; 16 + 17 + @override 18 + State<FullscreenVideoPlayer> createState() => _FullscreenVideoPlayerState(); 19 + } 20 + 21 + class _FullscreenVideoPlayerState extends State<FullscreenVideoPlayer> 22 + with WidgetsBindingObserver { 23 + double _dragOffsetX = 0; 24 + double _dragOffsetY = 0; 25 + bool _isDragging = false; 26 + VideoPlayerController? _videoController; 27 + bool _isInitializing = true; 28 + 29 + @override 30 + void initState() { 31 + super.initState(); 32 + WidgetsBinding.instance.addObserver(this); 33 + _initializePlayer(); 34 + } 35 + 36 + @override 37 + void dispose() { 38 + WidgetsBinding.instance.removeObserver(this); 39 + _videoController?.dispose(); 40 + super.dispose(); 41 + } 42 + 43 + @override 44 + void didChangeAppLifecycleState(AppLifecycleState state) { 45 + // Pause video when app goes to background 46 + if (state == AppLifecycleState.paused || 47 + state == AppLifecycleState.inactive) { 48 + _videoController?.pause(); 49 + } 50 + } 51 + 52 + Future<void> _initializePlayer() async { 53 + try { 54 + _videoController = VideoPlayerController.networkUrl( 55 + Uri.parse(widget.videoUrl), 56 + ); 57 + 58 + await _videoController!.initialize(); 59 + await _videoController!.play(); 60 + 61 + if (mounted) { 62 + setState(() { 63 + _isInitializing = false; 64 + }); 65 + } 66 + } on Exception catch (e) { 67 + if (kDebugMode) { 68 + debugPrint('Error initializing video: $e'); 69 + } 70 + if (mounted) { 71 + setState(() { 72 + _isInitializing = false; 73 + }); 74 + } 75 + } 76 + } 77 + 78 + void _onPanUpdate(DragUpdateDetails details) { 79 + setState(() { 80 + _isDragging = true; 81 + // Track both horizontal and vertical movement 82 + _dragOffsetX += details.delta.dx; 83 + _dragOffsetY += details.delta.dy; 84 + }); 85 + } 86 + 87 + void _onPanEnd(DragEndDetails details) { 88 + // If dragged more than 100 pixels vertically, dismiss 89 + if (_dragOffsetY.abs() > 100) { 90 + Navigator.of(context).pop(); 91 + } else { 92 + // Otherwise, animate back to original position 93 + setState(() { 94 + _dragOffsetX = 0.0; 95 + _dragOffsetY = 0.0; 96 + _isDragging = false; 97 + }); 98 + } 99 + } 100 + 101 + void _togglePlayPause() { 102 + if (_videoController == null || !_videoController!.value.isInitialized) { 103 + return; 104 + } 105 + 106 + setState(() { 107 + if (_videoController!.value.isPlaying) { 108 + _videoController!.pause(); 109 + } else { 110 + _videoController!.play(); 111 + } 112 + }); 113 + } 114 + 115 + @override 116 + Widget build(BuildContext context) { 117 + // Calculate opacity based on drag offset (fade out as user drags) 118 + final opacity = (1.0 - (_dragOffsetY.abs() / 300)).clamp(0.0, 1.0); 119 + 120 + return Scaffold( 121 + backgroundColor: Colors.black.withValues(alpha: opacity), 122 + body: GestureDetector( 123 + onPanUpdate: _onPanUpdate, 124 + onPanEnd: _onPanEnd, 125 + onTap: _togglePlayPause, 126 + child: Stack( 127 + children: [ 128 + // Video player - fills entire screen and moves with drag 129 + AnimatedContainer( 130 + duration: 131 + _isDragging 132 + ? Duration.zero 133 + : const Duration(milliseconds: 200), 134 + curve: Curves.easeOut, 135 + transform: Matrix4.translationValues( 136 + _dragOffsetX, 137 + _dragOffsetY, 138 + 0, 139 + ), 140 + child: SizedBox.expand( 141 + child: 142 + _isInitializing || _videoController == null 143 + ? const Center( 144 + child: CircularProgressIndicator( 145 + color: AppColors.loadingIndicator, 146 + ), 147 + ) 148 + : Center( 149 + child: AspectRatio( 150 + aspectRatio: _videoController!.value.aspectRatio, 151 + child: VideoPlayer(_videoController!), 152 + ), 153 + ), 154 + ), 155 + ), 156 + // Minimal controls at bottom (scrubber only) 157 + if (_videoController != null && 158 + _videoController!.value.isInitialized) 159 + Positioned( 160 + bottom: 0, 161 + left: 0, 162 + right: 0, 163 + child: SafeArea( 164 + child: MinimalVideoControls(controller: _videoController!), 165 + ), 166 + ), 167 + ], 168 + ), 169 + ), 170 + ); 171 + } 172 + }
+144
lib/widgets/minimal_video_controls.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:video_player/video_player.dart'; 3 + 4 + import '../constants/app_colors.dart'; 5 + 6 + /// Minimal video controls showing only a scrubber/progress bar 7 + /// 8 + /// Always visible at the bottom of the video, positioned above 9 + /// the Android navigation bar using SafeArea. 10 + class MinimalVideoControls extends StatefulWidget { 11 + const MinimalVideoControls({ 12 + required this.controller, 13 + super.key, 14 + }); 15 + 16 + final VideoPlayerController controller; 17 + 18 + @override 19 + State<MinimalVideoControls> createState() => _MinimalVideoControlsState(); 20 + } 21 + 22 + class _MinimalVideoControlsState extends State<MinimalVideoControls> { 23 + double _sliderValue = 0; 24 + bool _isUserDragging = false; 25 + 26 + @override 27 + void initState() { 28 + super.initState(); 29 + widget.controller.addListener(_updateSlider); 30 + } 31 + 32 + @override 33 + void dispose() { 34 + widget.controller.removeListener(_updateSlider); 35 + super.dispose(); 36 + } 37 + 38 + void _updateSlider() { 39 + if (!_isUserDragging && mounted) { 40 + final position = 41 + widget.controller.value.position.inMilliseconds.toDouble(); 42 + final duration = 43 + widget.controller.value.duration.inMilliseconds.toDouble(); 44 + 45 + if (duration > 0) { 46 + setState(() { 47 + _sliderValue = position / duration; 48 + }); 49 + } 50 + } 51 + } 52 + 53 + void _onSliderChanged(double value) { 54 + setState(() { 55 + _sliderValue = value; 56 + }); 57 + } 58 + 59 + void _onSliderChangeStart(double value) { 60 + _isUserDragging = true; 61 + } 62 + 63 + void _onSliderChangeEnd(double value) { 64 + _isUserDragging = false; 65 + final duration = widget.controller.value.duration; 66 + final position = duration * value; 67 + widget.controller.seekTo(position); 68 + } 69 + 70 + String _formatDuration(Duration duration) { 71 + final minutes = duration.inMinutes; 72 + final seconds = duration.inSeconds % 60; 73 + return '${minutes.toString().padLeft(1, '0')}:' 74 + '${seconds.toString().padLeft(2, '0')}'; 75 + } 76 + 77 + @override 78 + Widget build(BuildContext context) { 79 + final position = widget.controller.value.position; 80 + final duration = widget.controller.value.duration; 81 + 82 + return Container( 83 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), 84 + decoration: BoxDecoration( 85 + gradient: LinearGradient( 86 + begin: Alignment.bottomCenter, 87 + end: Alignment.topCenter, 88 + colors: [ 89 + Colors.black.withValues(alpha: 0.7), 90 + Colors.black.withValues(alpha: 0), 91 + ], 92 + ), 93 + ), 94 + child: Column( 95 + mainAxisSize: MainAxisSize.min, 96 + children: [ 97 + // Scrubber slider 98 + SliderTheme( 99 + data: SliderThemeData( 100 + trackHeight: 3, 101 + thumbShape: 102 + const RoundSliderThumbShape(enabledThumbRadius: 6), 103 + overlayShape: 104 + const RoundSliderOverlayShape(overlayRadius: 12), 105 + activeTrackColor: AppColors.primary, 106 + inactiveTrackColor: Colors.white.withValues(alpha: 0.3), 107 + thumbColor: AppColors.primary, 108 + overlayColor: AppColors.primary.withValues(alpha: 0.3), 109 + ), 110 + child: Slider( 111 + value: _sliderValue.clamp(0, 1.0), 112 + onChanged: _onSliderChanged, 113 + onChangeStart: _onSliderChangeStart, 114 + onChangeEnd: _onSliderChangeEnd, 115 + ), 116 + ), 117 + // Time labels 118 + Padding( 119 + padding: const EdgeInsets.symmetric(horizontal: 8), 120 + child: Row( 121 + mainAxisAlignment: MainAxisAlignment.spaceBetween, 122 + children: [ 123 + Text( 124 + _formatDuration(position), 125 + style: const TextStyle( 126 + color: Colors.white, 127 + fontSize: 12, 128 + ), 129 + ), 130 + Text( 131 + _formatDuration(duration), 132 + style: TextStyle( 133 + color: Colors.white.withValues(alpha: 0.7), 134 + fontSize: 12, 135 + ), 136 + ), 137 + ], 138 + ), 139 + ), 140 + ], 141 + ), 142 + ); 143 + } 144 + }