From 8a68aae24895530e4a702f094f36036850a40110 Mon Sep 17 00:00:00 2001 From: Bretton Date: Sat, 8 Nov 2025 23:53:42 -0800 Subject: [PATCH] feat: add Streamable video support infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/services/streamable_service.dart | 151 ++++++++++++++++++++ lib/widgets/fullscreen_video_player.dart | 172 +++++++++++++++++++++++ lib/widgets/minimal_video_controls.dart | 144 +++++++++++++++++++ 3 files changed, 467 insertions(+) create mode 100644 lib/services/streamable_service.dart create mode 100644 lib/widgets/fullscreen_video_player.dart create mode 100644 lib/widgets/minimal_video_controls.dart diff --git a/lib/services/streamable_service.dart b/lib/services/streamable_service.dart new file mode 100644 index 0000000..f7bdcd5 --- /dev/null +++ b/lib/services/streamable_service.dart @@ -0,0 +1,151 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; + +/// Service for interacting with Streamable API +/// +/// Fetches video data from Streamable to get direct MP4 URLs +/// for in-app video playback. +/// +/// Implements caching to reduce API calls for recently accessed videos. +class StreamableService { + StreamableService({Dio? dio}) : _dio = dio ?? _sharedDio; + + // Singleton Dio instance for efficient connection reuse + static final Dio _sharedDio = Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 10), + sendTimeout: const Duration(seconds: 10), + ), + ); + + final Dio _dio; + + // Cache for video URLs (shortcode -> {url, timestamp}) + // Short-lived cache (5 min) to reduce API calls + final Map _urlCache = {}; + static const Duration _cacheDuration = Duration(minutes: 5); + + /// Extracts the Streamable shortcode from a URL + /// + /// Examples: + /// - https://streamable.com/7kpdft -> 7kpdft + /// - https://streamable.com/e/abc123 -> abc123 + /// - streamable.com/abc123 -> abc123 + static String? extractShortcode(String url) { + try { + // Handle URLs without scheme + var urlToParse = url; + if (!url.contains('://')) { + urlToParse = 'https://$url'; + } + + final uri = Uri.parse(urlToParse); + final path = uri.path; + + // Get the last non-empty path segment (handles /e/ prefix and other cases) + final segments = path.split('/').where((s) => s.isNotEmpty).toList(); + if (segments.isEmpty) { + return null; + } + + final shortcode = segments.last; + + if (shortcode.isEmpty) { + return null; + } + + return shortcode; + } on FormatException catch (e) { + if (kDebugMode) { + debugPrint('Error extracting Streamable shortcode: $e'); + } + return null; + } + } + + /// Fetches the MP4 video URL for a Streamable video + /// + /// Returns the direct MP4 URL or null if the video cannot be fetched. + /// Uses a 5-minute cache to reduce API calls for repeated access. + Future getVideoUrl(String streamableUrl) async { + try { + final shortcode = extractShortcode(streamableUrl); + if (shortcode == null) { + if (kDebugMode) { + debugPrint('Failed to extract shortcode from: $streamableUrl'); + } + return null; + } + + // Check cache first + final cached = _urlCache[shortcode]; + if (cached != null) { + final age = DateTime.now().difference(cached.cachedAt); + if (age < _cacheDuration) { + if (kDebugMode) { + debugPrint('Using cached URL for shortcode: $shortcode'); + } + return cached.url; + } + // Cache expired, remove it + _urlCache.remove(shortcode); + } + + // Fetch video data from Streamable API + final response = await _dio.get>( + 'https://api.streamable.com/videos/$shortcode', + ); + + if (response.statusCode == 200 && response.data != null) { + final data = response.data!; + + // Extract MP4 URL from response + // Response structure: { "files": { "mp4": { "url": "//..." } } } + final files = data['files'] as Map?; + if (files == null) { + if (kDebugMode) { + debugPrint('No files found in Streamable response'); + } + return null; + } + + final mp4 = files['mp4'] as Map?; + if (mp4 == null) { + if (kDebugMode) { + debugPrint('No MP4 file found in Streamable response'); + } + return null; + } + + var videoUrl = mp4['url'] as String?; + if (videoUrl == null) { + if (kDebugMode) { + debugPrint('No URL found in MP4 data'); + } + return null; + } + + // Prepend https: if URL is protocol-relative + if (videoUrl.startsWith('//')) { + videoUrl = 'https:$videoUrl'; + } + + // Cache the URL for future requests + _urlCache[shortcode] = (url: videoUrl, cachedAt: DateTime.now()); + + return videoUrl; + } + + if (kDebugMode) { + debugPrint('Failed to fetch Streamable video: ${response.statusCode}'); + } + return null; + } on DioException catch (e) { + if (kDebugMode) { + debugPrint('Error fetching Streamable video URL: $e'); + } + return null; + } + } +} diff --git a/lib/widgets/fullscreen_video_player.dart b/lib/widgets/fullscreen_video_player.dart new file mode 100644 index 0000000..d9dd89a --- /dev/null +++ b/lib/widgets/fullscreen_video_player.dart @@ -0,0 +1,172 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:video_player/video_player.dart'; + +import '../constants/app_colors.dart'; +import 'minimal_video_controls.dart'; + +/// Fullscreen video player with swipe-to-dismiss gesture +/// +/// Displays the video player in fullscreen with a black background. +/// Supports vertical swipe-down gesture to dismiss (like Instagram/TikTok). +class FullscreenVideoPlayer extends StatefulWidget { + const FullscreenVideoPlayer({required this.videoUrl, super.key}); + + final String videoUrl; + + @override + State createState() => _FullscreenVideoPlayerState(); +} + +class _FullscreenVideoPlayerState extends State + with WidgetsBindingObserver { + double _dragOffsetX = 0; + double _dragOffsetY = 0; + bool _isDragging = false; + VideoPlayerController? _videoController; + bool _isInitializing = true; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _initializePlayer(); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _videoController?.dispose(); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + // Pause video when app goes to background + if (state == AppLifecycleState.paused || + state == AppLifecycleState.inactive) { + _videoController?.pause(); + } + } + + Future _initializePlayer() async { + try { + _videoController = VideoPlayerController.networkUrl( + Uri.parse(widget.videoUrl), + ); + + await _videoController!.initialize(); + await _videoController!.play(); + + if (mounted) { + setState(() { + _isInitializing = false; + }); + } + } on Exception catch (e) { + if (kDebugMode) { + debugPrint('Error initializing video: $e'); + } + if (mounted) { + setState(() { + _isInitializing = false; + }); + } + } + } + + void _onPanUpdate(DragUpdateDetails details) { + setState(() { + _isDragging = true; + // Track both horizontal and vertical movement + _dragOffsetX += details.delta.dx; + _dragOffsetY += details.delta.dy; + }); + } + + void _onPanEnd(DragEndDetails details) { + // If dragged more than 100 pixels vertically, dismiss + if (_dragOffsetY.abs() > 100) { + Navigator.of(context).pop(); + } else { + // Otherwise, animate back to original position + setState(() { + _dragOffsetX = 0.0; + _dragOffsetY = 0.0; + _isDragging = false; + }); + } + } + + void _togglePlayPause() { + if (_videoController == null || !_videoController!.value.isInitialized) { + return; + } + + setState(() { + if (_videoController!.value.isPlaying) { + _videoController!.pause(); + } else { + _videoController!.play(); + } + }); + } + + @override + Widget build(BuildContext context) { + // Calculate opacity based on drag offset (fade out as user drags) + final opacity = (1.0 - (_dragOffsetY.abs() / 300)).clamp(0.0, 1.0); + + return Scaffold( + backgroundColor: Colors.black.withValues(alpha: opacity), + body: GestureDetector( + onPanUpdate: _onPanUpdate, + onPanEnd: _onPanEnd, + onTap: _togglePlayPause, + child: Stack( + children: [ + // Video player - fills entire screen and moves with drag + AnimatedContainer( + duration: + _isDragging + ? Duration.zero + : const Duration(milliseconds: 200), + curve: Curves.easeOut, + transform: Matrix4.translationValues( + _dragOffsetX, + _dragOffsetY, + 0, + ), + child: SizedBox.expand( + child: + _isInitializing || _videoController == null + ? const Center( + child: CircularProgressIndicator( + color: AppColors.loadingIndicator, + ), + ) + : Center( + child: AspectRatio( + aspectRatio: _videoController!.value.aspectRatio, + child: VideoPlayer(_videoController!), + ), + ), + ), + ), + // Minimal controls at bottom (scrubber only) + if (_videoController != null && + _videoController!.value.isInitialized) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: SafeArea( + child: MinimalVideoControls(controller: _videoController!), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/minimal_video_controls.dart b/lib/widgets/minimal_video_controls.dart new file mode 100644 index 0000000..507d1e5 --- /dev/null +++ b/lib/widgets/minimal_video_controls.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart'; +import 'package:video_player/video_player.dart'; + +import '../constants/app_colors.dart'; + +/// Minimal video controls showing only a scrubber/progress bar +/// +/// Always visible at the bottom of the video, positioned above +/// the Android navigation bar using SafeArea. +class MinimalVideoControls extends StatefulWidget { + const MinimalVideoControls({ + required this.controller, + super.key, + }); + + final VideoPlayerController controller; + + @override + State createState() => _MinimalVideoControlsState(); +} + +class _MinimalVideoControlsState extends State { + double _sliderValue = 0; + bool _isUserDragging = false; + + @override + void initState() { + super.initState(); + widget.controller.addListener(_updateSlider); + } + + @override + void dispose() { + widget.controller.removeListener(_updateSlider); + super.dispose(); + } + + void _updateSlider() { + if (!_isUserDragging && mounted) { + final position = + widget.controller.value.position.inMilliseconds.toDouble(); + final duration = + widget.controller.value.duration.inMilliseconds.toDouble(); + + if (duration > 0) { + setState(() { + _sliderValue = position / duration; + }); + } + } + } + + void _onSliderChanged(double value) { + setState(() { + _sliderValue = value; + }); + } + + void _onSliderChangeStart(double value) { + _isUserDragging = true; + } + + void _onSliderChangeEnd(double value) { + _isUserDragging = false; + final duration = widget.controller.value.duration; + final position = duration * value; + widget.controller.seekTo(position); + } + + String _formatDuration(Duration duration) { + final minutes = duration.inMinutes; + final seconds = duration.inSeconds % 60; + return '${minutes.toString().padLeft(1, '0')}:' + '${seconds.toString().padLeft(2, '0')}'; + } + + @override + Widget build(BuildContext context) { + final position = widget.controller.value.position; + final duration = widget.controller.value.duration; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + Colors.black.withValues(alpha: 0.7), + Colors.black.withValues(alpha: 0), + ], + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Scrubber slider + SliderTheme( + data: SliderThemeData( + trackHeight: 3, + thumbShape: + const RoundSliderThumbShape(enabledThumbRadius: 6), + overlayShape: + const RoundSliderOverlayShape(overlayRadius: 12), + activeTrackColor: AppColors.primary, + inactiveTrackColor: Colors.white.withValues(alpha: 0.3), + thumbColor: AppColors.primary, + overlayColor: AppColors.primary.withValues(alpha: 0.3), + ), + child: Slider( + value: _sliderValue.clamp(0, 1.0), + onChanged: _onSliderChanged, + onChangeStart: _onSliderChangeStart, + onChangeEnd: _onSliderChangeEnd, + ), + ), + // Time labels + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _formatDuration(position), + style: const TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + Text( + _formatDuration(duration), + style: TextStyle( + color: Colors.white.withValues(alpha: 0.7), + fontSize: 12, + ), + ), + ], + ), + ), + ], + ), + ); + } +} -- 2.51.2