From 9533b609067d8e371d41e865406a9a1844356f09 Mon Sep 17 00:00:00 2001 From: Abraham Date: Tue, 13 Jan 2026 15:08:10 -0800 Subject: [PATCH] =?UTF-8?q?video=20thumbnails=20extractor=20a=C3=B1adido?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...deo_thumbnail.dart => video_thumbnail.txt} | 0 lib/pages/videos/gestor_videos_page.dart | 41 +++-- .../widgets/video_thumbnail_widget.dart | 143 ++++++++++++++++++ lib/providers/videos_provider.dart | 39 ++++- pubspec.lock | 20 ++- pubspec.yaml | 2 + 6 files changed, 225 insertions(+), 20 deletions(-) rename assets/referencia/{video_thumbnail.dart => video_thumbnail.txt} (100%) create mode 100644 lib/pages/videos/widgets/video_thumbnail_widget.dart diff --git a/assets/referencia/video_thumbnail.dart b/assets/referencia/video_thumbnail.txt similarity index 100% rename from assets/referencia/video_thumbnail.dart rename to assets/referencia/video_thumbnail.txt diff --git a/lib/pages/videos/gestor_videos_page.dart b/lib/pages/videos/gestor_videos_page.dart index 00e5799..afe628b 100644 --- a/lib/pages/videos/gestor_videos_page.dart +++ b/lib/pages/videos/gestor_videos_page.dart @@ -11,6 +11,7 @@ import 'package:nethive_neo/pages/videos/widgets/video_player_dialog.dart'; import 'package:nethive_neo/pages/videos/widgets/gestor_videos_widgets/empty_state_widget.dart'; import 'package:nethive_neo/pages/videos/widgets/gestor_videos_widgets/edit_video_dialog.dart'; import 'package:nethive_neo/pages/videos/widgets/gestor_videos_widgets/delete_video_dialog.dart'; +import 'package:nethive_neo/pages/videos/widgets/video_thumbnail_widget.dart'; import 'package:gap/gap.dart'; class GestorVideosPage extends StatefulWidget { @@ -360,7 +361,12 @@ class _GestorVideosPageState extends State { errorBuilder: (context, error, stackTrace) => _buildThumbnailPlaceholder(), ) - : _buildThumbnailPlaceholder(), + : (video.fileUrl != null && video.fileUrl!.isNotEmpty) + ? VideoThumbnailWidget( + videoUrl: video.fileUrl!, + fit: BoxFit.cover, + ) + : _buildThumbnailPlaceholder(), // Overlay con icono de play Positioned.fill( child: Container( @@ -782,21 +788,26 @@ class _GestorVideosPageState extends State { ), ), ) - : Container( - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - AppTheme.of(context).tertiaryBackground, - AppTheme.of(context).primaryBackground, - ], + : (video.fileUrl != null && video.fileUrl!.isNotEmpty) + ? VideoThumbnailWidget( + videoUrl: video.fileUrl!, + fit: BoxFit.cover, + ) + : Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + AppTheme.of(context).tertiaryBackground, + AppTheme.of(context).primaryBackground, + ], + ), + ), + child: Icon( + Icons.video_library_rounded, + size: 80, + color: AppTheme.of(context).tertiaryText, + ), ), - ), - child: Icon( - Icons.video_library_rounded, - size: 80, - color: AppTheme.of(context).tertiaryText, - ), - ), ), // Overlay con gradiente Positioned.fill( diff --git a/lib/pages/videos/widgets/video_thumbnail_widget.dart b/lib/pages/videos/widgets/video_thumbnail_widget.dart new file mode 100644 index 0000000..2dc502e --- /dev/null +++ b/lib/pages/videos/widgets/video_thumbnail_widget.dart @@ -0,0 +1,143 @@ +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:get_thumbnail_video/index.dart'; +import 'package:get_thumbnail_video/video_thumbnail.dart'; +import 'package:nethive_neo/theme/theme.dart'; + +/// Widget que genera un thumbnail automático desde una URL de video. +/// Se usa como fallback cuando no hay poster/portada subida por el usuario. +/// Usa get_thumbnail_video para compatibilidad con Flutter Web y mejor rendimiento. +class VideoThumbnailWidget extends StatefulWidget { + final String videoUrl; + final double? width; + final double? height; + final BoxFit fit; + + const VideoThumbnailWidget({ + Key? key, + required this.videoUrl, + this.width, + this.height, + this.fit = BoxFit.cover, + }) : super(key: key); + + @override + State createState() => _VideoThumbnailWidgetState(); +} + +class _VideoThumbnailWidgetState extends State { + Uint8List? _thumbnailBytes; + bool _isLoading = true; + bool _hasError = false; + + @override + void initState() { + super.initState(); + _generateThumbnail(); + } + + @override + void didUpdateWidget(VideoThumbnailWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.videoUrl != widget.videoUrl) { + _generateThumbnail(); + } + } + + Future _generateThumbnail() async { + if (!mounted) return; + + setState(() { + _isLoading = true; + _hasError = false; + }); + + try { + final thumbnail = await VideoThumbnail.thumbnailData( + video: widget.videoUrl, + imageFormat: ImageFormat.JPEG, + maxWidth: 320, + quality: 75, + ); + + if (!mounted) return; + + if (thumbnail != null && thumbnail.isNotEmpty) { + setState(() { + _thumbnailBytes = thumbnail; + _isLoading = false; + }); + } else { + setState(() { + _hasError = true; + _isLoading = false; + }); + } + } catch (e) { + if (!mounted) return; + setState(() { + _hasError = true; + _isLoading = false; + }); + debugPrint('Error generando thumbnail: $e'); + } + } + + @override + Widget build(BuildContext context) { + // Mostrar loading mientras genera thumbnail + if (_isLoading) { + return Container( + width: widget.width, + height: widget.height, + color: AppTheme.of(context).tertiaryBackground, + child: Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppTheme.of(context).primaryColor.withOpacity(0.5), + ), + ), + ), + ); + } + + // Mostrar placeholder si hay error + if (_hasError || _thumbnailBytes == null) { + return Container( + width: widget.width, + height: widget.height, + color: AppTheme.of(context).tertiaryBackground, + child: Center( + child: Icon( + Icons.video_library_rounded, + size: 28, + color: AppTheme.of(context).tertiaryText, + ), + ), + ); + } + + // Mostrar thumbnail generado (imagen estática, no video) + return Image.memory( + _thumbnailBytes!, + width: widget.width, + height: widget.height, + fit: widget.fit, + errorBuilder: (context, error, stackTrace) => Container( + width: widget.width, + height: widget.height, + color: AppTheme.of(context).tertiaryBackground, + child: Center( + child: Icon( + Icons.video_library_rounded, + size: 28, + color: AppTheme.of(context).tertiaryText, + ), + ), + ), + ); + } +} diff --git a/lib/providers/videos_provider.dart b/lib/providers/videos_provider.dart index 5e97e7c..96d9cd7 100644 --- a/lib/providers/videos_provider.dart +++ b/lib/providers/videos_provider.dart @@ -37,6 +37,36 @@ class VideosProvider extends ChangeNotifier { String? posterStoragePath; String posterFileExtension = ''; Uint8List? webPosterBytes; + // ========== HELPERS ========== + /// Sanitize file name to avoid issues with special characters in URLs + /// Removes/replaces: brackets [], parentheses (), spaces, special chars + String _sanitizeFileName(String fileName) { + // Get extension + final ext = p.extension(fileName); + final nameWithoutExt = p.basenameWithoutExtension(fileName); + + // Replace special characters and spaces + String sanitized = nameWithoutExt + .replaceAll(RegExp(r'[\[\]\(\){}]'), '') // Remove brackets/parentheses + .replaceAll(RegExp(r'[^a-zA-Z0-9_-]'), + '_') // Replace other special chars with underscore + .replaceAll( + RegExp(r'_+'), '_') // Replace multiple underscores with single + .replaceAll( + RegExp(r'^_|_$'), ''); // Remove leading/trailing underscores + + // If name is empty after sanitization, use generic name + if (sanitized.isEmpty) { + sanitized = 'video_${DateTime.now().millisecondsSinceEpoch}'; + } + + // Limit length to avoid excessively long names (max 100 chars + extension) + if (sanitized.length > 100) { + sanitized = sanitized.substring(0, 100); + } + + return '$sanitized$ext'; + } // ========== LOADING STATE ========== bool isLoading = false; @@ -246,7 +276,8 @@ class VideosProvider extends ChangeNotifier { // 1. Upload video to storage final timestamp = DateTime.now().millisecondsSinceEpoch; - final fileName = '${timestamp}_$videoName'; + final sanitizedName = _sanitizeFileName(videoName!); + final fileName = '${timestamp}_$sanitizedName'; videoStoragePath = 'videos/$fileName'; await supabaseML.storage.from('energymedia').uploadBinary( @@ -323,7 +354,8 @@ class VideosProvider extends ChangeNotifier { try { final timestamp = DateTime.now().millisecondsSinceEpoch; - final fileName = '${timestamp}_$posterName'; + final sanitizedName = _sanitizeFileName(posterName!); + final fileName = '${timestamp}_$sanitizedName'; posterStoragePath = 'imagenes/$fileName'; // Solo subir al storage, NO crear registro en media_files @@ -484,7 +516,8 @@ class VideosProvider extends ChangeNotifier { // Upload new poster to storage final timestamp = DateTime.now().millisecondsSinceEpoch; - final fileName = '${timestamp}_$posterName'; + final sanitizedName = _sanitizeFileName(posterName); + final fileName = '${timestamp}_$sanitizedName'; final posterPath = 'imagenes/$fileName'; await supabaseML.storage.from('energymedia').uploadBinary( diff --git a/pubspec.lock b/pubspec.lock index c87e7be..edea86b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -157,10 +157,10 @@ packages: dependency: transitive description: name: cross_file - sha256: "2f9d2cbccb76127ba28528cb3ae2c2326a122446a83de5a056aaa3880d3882c5" + sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608" url: "https://pub.dev" source: hosted - version: "0.3.3+7" + version: "0.3.5+1" crypto: dependency: transitive description: @@ -557,6 +557,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.1" + get_thumbnail_video: + dependency: "direct main" + description: + name: get_thumbnail_video + sha256: ff61495b42051765d2a9e93bd14dac7ede5853033837bde71c27575a192c53fc + url: "https://pub.dev" + source: hosted + version: "0.7.3" go_router: dependency: "direct main" description: @@ -1426,6 +1434,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.0" + video_thumbnail: + dependency: "direct main" + description: + name: video_thumbnail + sha256: "181a0c205b353918954a881f53a3441476b9e301641688a581e0c13f00dc588b" + url: "https://pub.dev" + source: hosted + version: "0.5.6" vm_service: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index cbf501a..a61652a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -77,6 +77,8 @@ dependencies: chewie: ^1.8.0 shimmer: ^3.0.0 fl_chart: 0.69.0 + video_thumbnail: ^0.5.3 + get_thumbnail_video: ^0.7.3 dev_dependencies: flutter_test: