From 222515a2b54354690ea7f8b64bec093b0c732266 Mon Sep 17 00:00:00 2001 From: gitea Date: Thu, 13 Nov 2025 21:29:13 +0100 Subject: [PATCH] mp4 improved with preview --- lib/ui/add_recipe/add_recipe_screen.dart | 112 +++++++++++++++++++- lib/ui/favourites/favourites_screen.dart | 112 ++++++++++++++++---- lib/ui/recipes/recipes_screen.dart | 125 ++++++++++++++++++----- 3 files changed, 295 insertions(+), 54 deletions(-) diff --git a/lib/ui/add_recipe/add_recipe_screen.dart b/lib/ui/add_recipe/add_recipe_screen.dart index 544477a..279c646 100644 --- a/lib/ui/add_recipe/add_recipe_screen.dart +++ b/lib/ui/add_recipe/add_recipe_screen.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:video_player/video_player.dart'; import '../../core/service_locator.dart'; import '../../core/logger.dart'; import '../../data/recipes/recipe_service.dart'; @@ -1470,9 +1471,9 @@ class _AddRecipeScreenState extends State { ), child: ClipRRect( borderRadius: BorderRadius.circular(12), - child: isVideo - ? _buildVideoThumbnailForTile(mediaUrl) - : _buildImagePreview(mediaUrl), + child: isVideo + ? _VideoThumbnailPreview(videoUrl: mediaUrl) + : _buildImagePreview(mediaUrl), ), ), ); @@ -1603,7 +1604,7 @@ class _AddRecipeScreenState extends State { ) : null, child: media.isVideo - ? _buildVideoThumbnailForTile(media.url) + ? _VideoThumbnailPreview(videoUrl: media.url) : _buildImagePreviewForTile(media.url), ), ); @@ -1776,3 +1777,106 @@ class _AddRecipeScreenState extends State { ); } } + + +/// Widget that displays a video thumbnail preview with a few seconds of playback. +class _VideoThumbnailPreview extends StatefulWidget { + final String videoUrl; + + const _VideoThumbnailPreview({ + required this.videoUrl, + }); + + @override + State<_VideoThumbnailPreview> createState() => _VideoThumbnailPreviewState(); +} + +class _VideoThumbnailPreviewState extends State<_VideoThumbnailPreview> { + VideoPlayerController? _controller; + bool _isInitialized = false; + bool _hasError = false; + + @override + void initState() { + super.initState(); + _initializeVideo(); + } + + Future _initializeVideo() async { + try { + _controller = VideoPlayerController.networkUrl(Uri.parse(widget.videoUrl)); + await _controller!.initialize(); + + if (mounted) { + setState(() { + _isInitialized = true; + }); + + // Play continuously in a loop + _controller!.setLooping(true); + _controller!.play(); + } + } catch (e) { + Logger.warning('Failed to initialize video thumbnail: $e'); + if (mounted) { + setState(() { + _hasError = true; + }); + } + } + } + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_hasError || !_isInitialized || _controller == null) { + return Stack( + fit: StackFit.expand, + children: [ + Container( + color: Colors.black87, + child: const Center( + child: Icon( + Icons.play_circle_filled, + size: 40, + color: Colors.white70, + ), + ), + ), + ], + ); + } + + return Stack( + fit: StackFit.expand, + children: [ + AspectRatio( + aspectRatio: _controller!.value.aspectRatio, + child: VideoPlayer(_controller!), + ), + // Play icon overlay + Positioned( + top: 4, + right: 4, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.6), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.play_circle_filled, + size: 24, + color: Colors.white, + ), + ), + ), + ], + ); + } +} diff --git a/lib/ui/favourites/favourites_screen.dart b/lib/ui/favourites/favourites_screen.dart index 84d26ee..9feab0c 100644 --- a/lib/ui/favourites/favourites_screen.dart +++ b/lib/ui/favourites/favourites_screen.dart @@ -1,5 +1,6 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:video_player/video_player.dart'; import '../../core/service_locator.dart'; import '../../core/logger.dart'; import '../../data/recipes/recipe_service.dart'; @@ -834,37 +835,104 @@ class _RecipeCard extends StatelessWidget { } Widget _buildVideoThumbnail(String videoUrl) { + return _VideoThumbnailPreview(videoUrl: videoUrl); + } +} + +/// Widget that displays a video thumbnail preview with a few seconds of playback. +class _VideoThumbnailPreview extends StatefulWidget { + final String videoUrl; + + const _VideoThumbnailPreview({ + required this.videoUrl, + }); + + @override + State<_VideoThumbnailPreview> createState() => _VideoThumbnailPreviewState(); +} + +class _VideoThumbnailPreviewState extends State<_VideoThumbnailPreview> { + VideoPlayerController? _controller; + bool _isInitialized = false; + bool _hasError = false; + + @override + void initState() { + super.initState(); + _initializeVideo(); + } + + Future _initializeVideo() async { + try { + _controller = VideoPlayerController.networkUrl(Uri.parse(widget.videoUrl)); + await _controller!.initialize(); + + if (mounted) { + setState(() { + _isInitialized = true; + }); + + // Play continuously in a loop + _controller!.setLooping(true); + _controller!.play(); + } + } catch (e) { + Logger.warning('Failed to initialize video thumbnail: $e'); + if (mounted) { + setState(() { + _hasError = true; + }); + } + } + } + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_hasError || !_isInitialized || _controller == null) { + return Stack( + fit: StackFit.expand, + children: [ + Container( + color: Colors.black87, + child: const Center( + child: Icon( + Icons.play_circle_filled, + size: 40, + color: Colors.white70, + ), + ), + ), + ], + ); + } + return Stack( fit: StackFit.expand, children: [ - Container( - color: Colors.black87, - child: const Center( - child: Icon( - Icons.play_circle_filled, - size: 40, - color: Colors.white70, - ), - ), + AspectRatio( + aspectRatio: _controller!.value.aspectRatio, + child: VideoPlayer(_controller!), ), + // Play icon overlay Positioned( - bottom: 4, - left: 4, + top: 4, right: 4, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + padding: const EdgeInsets.all(4), decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.7), - borderRadius: BorderRadius.circular(4), + color: Colors.black.withValues(alpha: 0.6), + shape: BoxShape.circle, ), - child: const Text( - 'MP4', - style: TextStyle( - color: Colors.white, - fontSize: 10, - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, + child: const Icon( + Icons.play_circle_filled, + size: 24, + color: Colors.white, ), ), ), diff --git a/lib/ui/recipes/recipes_screen.dart b/lib/ui/recipes/recipes_screen.dart index 9eea666..e9f364f 100644 --- a/lib/ui/recipes/recipes_screen.dart +++ b/lib/ui/recipes/recipes_screen.dart @@ -1,5 +1,6 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:video_player/video_player.dart'; import '../../core/service_locator.dart'; import '../../core/logger.dart'; import '../../data/recipes/recipe_service.dart'; @@ -1053,47 +1054,115 @@ class _RecipeCard extends StatelessWidget { } Widget _buildVideoThumbnail(String videoUrl) { + return _VideoThumbnailPreview(videoUrl: videoUrl); + } + + Color _getRatingColor(int rating) { + if (rating >= 4) return Colors.green; + if (rating >= 2) return Colors.orange; + return Colors.red; + } +} + + +/// Widget that displays a video thumbnail preview with a few seconds of playback. +class _VideoThumbnailPreview extends StatefulWidget { + final String videoUrl; + + const _VideoThumbnailPreview({ + required this.videoUrl, + }); + + @override + State<_VideoThumbnailPreview> createState() => _VideoThumbnailPreviewState(); +} + +class _VideoThumbnailPreviewState extends State<_VideoThumbnailPreview> { + VideoPlayerController? _controller; + bool _isInitialized = false; + bool _hasError = false; + + @override + void initState() { + super.initState(); + _initializeVideo(); + } + + Future _initializeVideo() async { + try { + _controller = VideoPlayerController.networkUrl(Uri.parse(widget.videoUrl)); + await _controller!.initialize(); + + if (mounted) { + setState(() { + _isInitialized = true; + }); + + // Play continuously in a loop + _controller!.setLooping(true); + _controller!.play(); + } + } catch (e) { + Logger.warning('Failed to initialize video thumbnail: $e'); + if (mounted) { + setState(() { + _hasError = true; + }); + } + } + } + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_hasError || !_isInitialized || _controller == null) { + return Stack( + fit: StackFit.expand, + children: [ + Container( + color: Colors.black87, + child: const Center( + child: Icon( + Icons.play_circle_filled, + size: 40, + color: Colors.white70, + ), + ), + ), + ], + ); + } + return Stack( fit: StackFit.expand, children: [ - Container( - color: Colors.black87, - child: const Center( - child: Icon( - Icons.play_circle_filled, - size: 40, - color: Colors.white70, - ), - ), + AspectRatio( + aspectRatio: _controller!.value.aspectRatio, + child: VideoPlayer(_controller!), ), + // Play icon overlay Positioned( - bottom: 4, - left: 4, + top: 4, right: 4, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + padding: const EdgeInsets.all(4), decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.7), - borderRadius: BorderRadius.circular(4), + color: Colors.black.withValues(alpha: 0.6), + shape: BoxShape.circle, ), - child: const Text( - 'MP4', - style: TextStyle( - color: Colors.white, - fontSize: 10, - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, + child: const Icon( + Icons.play_circle_filled, + size: 24, + color: Colors.white, ), ), ), ], ); } - - Color _getRatingColor(int rating) { - if (rating >= 4) return Colors.green; - if (rating >= 2) return Colors.orange; - return Colors.red; - } }