video thumbnails extractor añadido
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
143
lib/pages/videos/widgets/video_thumbnail_widget.dart
Normal file
143
lib/pages/videos/widgets/video_thumbnail_widget.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
20
pubspec.lock
20
pubspec.lock
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user