video thumbnails extractor añadido

This commit is contained in:
Abraham
2026-01-13 15:08:10 -08:00
parent 8612688aa3
commit 9533b60906
6 changed files with 225 additions and 20 deletions

View File

@@ -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/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/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/gestor_videos_widgets/delete_video_dialog.dart';
import 'package:nethive_neo/pages/videos/widgets/video_thumbnail_widget.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
class GestorVideosPage extends StatefulWidget { class GestorVideosPage extends StatefulWidget {
@@ -360,7 +361,12 @@ class _GestorVideosPageState extends State<GestorVideosPage> {
errorBuilder: (context, error, stackTrace) => errorBuilder: (context, error, stackTrace) =>
_buildThumbnailPlaceholder(), _buildThumbnailPlaceholder(),
) )
: _buildThumbnailPlaceholder(), : (video.fileUrl != null && video.fileUrl!.isNotEmpty)
? VideoThumbnailWidget(
videoUrl: video.fileUrl!,
fit: BoxFit.cover,
)
: _buildThumbnailPlaceholder(),
// Overlay con icono de play // Overlay con icono de play
Positioned.fill( Positioned.fill(
child: Container( child: Container(
@@ -782,21 +788,26 @@ class _GestorVideosPageState extends State<GestorVideosPage> {
), ),
), ),
) )
: Container( : (video.fileUrl != null && video.fileUrl!.isNotEmpty)
decoration: BoxDecoration( ? VideoThumbnailWidget(
gradient: LinearGradient( videoUrl: video.fileUrl!,
colors: [ fit: BoxFit.cover,
AppTheme.of(context).tertiaryBackground, )
AppTheme.of(context).primaryBackground, : 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 // Overlay con gradiente
Positioned.fill( Positioned.fill(

View File

@@ -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<VideoThumbnailWidget> createState() => _VideoThumbnailWidgetState();
}
class _VideoThumbnailWidgetState extends State<VideoThumbnailWidget> {
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<void> _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,
),
),
),
);
}
}

View File

@@ -37,6 +37,36 @@ class VideosProvider extends ChangeNotifier {
String? posterStoragePath; String? posterStoragePath;
String posterFileExtension = ''; String posterFileExtension = '';
Uint8List? webPosterBytes; 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 ========== // ========== LOADING STATE ==========
bool isLoading = false; bool isLoading = false;
@@ -246,7 +276,8 @@ class VideosProvider extends ChangeNotifier {
// 1. Upload video to storage // 1. Upload video to storage
final timestamp = DateTime.now().millisecondsSinceEpoch; final timestamp = DateTime.now().millisecondsSinceEpoch;
final fileName = '${timestamp}_$videoName'; final sanitizedName = _sanitizeFileName(videoName!);
final fileName = '${timestamp}_$sanitizedName';
videoStoragePath = 'videos/$fileName'; videoStoragePath = 'videos/$fileName';
await supabaseML.storage.from('energymedia').uploadBinary( await supabaseML.storage.from('energymedia').uploadBinary(
@@ -323,7 +354,8 @@ class VideosProvider extends ChangeNotifier {
try { try {
final timestamp = DateTime.now().millisecondsSinceEpoch; final timestamp = DateTime.now().millisecondsSinceEpoch;
final fileName = '${timestamp}_$posterName'; final sanitizedName = _sanitizeFileName(posterName!);
final fileName = '${timestamp}_$sanitizedName';
posterStoragePath = 'imagenes/$fileName'; posterStoragePath = 'imagenes/$fileName';
// Solo subir al storage, NO crear registro en media_files // Solo subir al storage, NO crear registro en media_files
@@ -484,7 +516,8 @@ class VideosProvider extends ChangeNotifier {
// Upload new poster to storage // Upload new poster to storage
final timestamp = DateTime.now().millisecondsSinceEpoch; final timestamp = DateTime.now().millisecondsSinceEpoch;
final fileName = '${timestamp}_$posterName'; final sanitizedName = _sanitizeFileName(posterName);
final fileName = '${timestamp}_$sanitizedName';
final posterPath = 'imagenes/$fileName'; final posterPath = 'imagenes/$fileName';
await supabaseML.storage.from('energymedia').uploadBinary( await supabaseML.storage.from('energymedia').uploadBinary(

View File

@@ -157,10 +157,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: cross_file name: cross_file
sha256: "2f9d2cbccb76127ba28528cb3ae2c2326a122446a83de5a056aaa3880d3882c5" sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.3.3+7" version: "0.3.5+1"
crypto: crypto:
dependency: transitive dependency: transitive
description: description:
@@ -557,6 +557,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.1" 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: go_router:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1426,6 +1434,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.0" 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: vm_service:
dependency: transitive dependency: transitive
description: description:

View File

@@ -77,6 +77,8 @@ dependencies:
chewie: ^1.8.0 chewie: ^1.8.0
shimmer: ^3.0.0 shimmer: ^3.0.0
fl_chart: 0.69.0 fl_chart: 0.69.0
video_thumbnail: ^0.5.3
get_thumbnail_video: ^0.7.3
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: