Files
energymedia_content_manager/lib/providers/videos_provider.dart

901 lines
27 KiB
Dart

import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:pluto_grid/pluto_grid.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:video_player/video_player.dart';
import 'package:path/path.dart' as p;
import 'package:energy_media/helpers/globals.dart';
import 'package:energy_media/models/media/media_models.dart';
class VideosProvider extends ChangeNotifier {
// ========== ORGANIZATION CONSTANT ==========
static const int organizationId = 17;
// ========== STATE MANAGEMENT ==========
PlutoGridStateManager? stateManager;
List<PlutoRow> videosRows = [];
int gridRebuildKey = 0; // Key for forcing PlutoGrid rebuild
// ========== DATA LISTS ==========
List<MediaFileModel> mediaFiles = [];
List<MediaCategoryModel> categories = [];
List<MediaWithPosterModel> mediaWithPosters = [];
// ========== CONTROLLERS ==========
final busquedaVideoController = TextEditingController();
final tituloController = TextEditingController();
final descripcionController = TextEditingController();
// ========== VIDEO/IMAGE UPLOAD STATE ==========
String? videoName;
String? videoUrl;
String? videoStoragePath;
String videoFileExtension = '';
Uint8List? webVideoBytes;
String? posterName;
String? posterUrl;
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;
String? errorMessage;
// ========== CONSTRUCTOR ==========
VideosProvider() {
loadMediaFiles();
loadCategories();
}
// ========== LOAD METHODS ==========
/// Load all media files with organization filter
Future<void> loadMediaFiles() async {
try {
isLoading = true;
errorMessage = null;
notifyListeners();
final response = await supabaseML
.from('media_files')
.select()
.eq('organization_fk', organizationId)
.order('created_at_timestamp', ascending: false);
mediaFiles = (response as List<dynamic>)
.map((item) => MediaFileModel.fromMap(item))
.toList();
await _buildPlutoRows();
isLoading = false;
notifyListeners();
} catch (e) {
errorMessage = 'Error cargando videos: $e';
isLoading = false;
notifyListeners();
print('Error en loadMediaFiles: $e');
}
}
/// Load media files with posters using view
Future<void> loadMediaWithPosters() async {
try {
isLoading = true;
notifyListeners();
final response = await supabaseML
.from('vw_media_files_with_posters')
.select()
.eq('organization_fk', organizationId)
.order('media_created_at', ascending: false);
mediaWithPosters = (response as List<dynamic>)
.map((item) => MediaWithPosterModel.fromMap(item))
.toList();
isLoading = false;
notifyListeners();
} catch (e) {
errorMessage = 'Error cargando videos con posters: $e';
isLoading = false;
notifyListeners();
print('Error en loadMediaWithPosters: $e');
}
}
/// Load all categories
Future<void> loadCategories() async {
try {
final response = await supabaseML
.from('media_categories')
.select()
.order('category_name');
categories = (response as List<dynamic>)
.map((item) => MediaCategoryModel.fromMap(item))
.toList();
notifyListeners();
} catch (e) {
print('Error en loadCategories: $e');
}
}
/// Build PlutoGrid rows from media files
Future<void> _buildPlutoRows() async {
videosRows.clear();
for (var media in mediaFiles) {
videosRows.add(
PlutoRow(
cells: {
'video': PlutoCell(value: media), // Objeto completo para renderers
'thumbnail': PlutoCell(value: media.fileUrl),
'title': PlutoCell(value: media.title ?? media.fileName),
'file_description': PlutoCell(value: media.fileDescription),
'reproducciones': PlutoCell(value: media.reproducciones),
'duration': PlutoCell(
value: media.seconds != null
? _formatDuration(media.seconds!)
: '-'),
'file_size': PlutoCell(
value: media.fileSizeBytes != null
? _formatFileSize(media.fileSizeBytes!)
: '-'),
'createdAt': PlutoCell(
value: media.createdAt?.toString().split('.')[0] ?? '-'),
'tags': PlutoCell(value: media.tags.join(', ')),
'actions': PlutoCell(value: media),
},
),
);
}
}
/// Format duration in seconds to human readable
String _formatDuration(int seconds) {
final duration = Duration(seconds: seconds);
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
final secs = duration.inSeconds.remainder(60);
if (hours > 0) {
return '${hours}h ${minutes}m';
} else if (minutes > 0) {
return '${minutes}m ${secs}s';
} else {
return '${secs}s';
}
}
/// Format file size to human readable
String _formatFileSize(int? bytes) {
if (bytes == null) return '-';
if (bytes < 1024) return '$bytes B';
if (bytes < 1048576) return '${(bytes / 1024).toStringAsFixed(1)} KB';
if (bytes < 1073741824) return '${(bytes / 1048576).toStringAsFixed(1)} MB';
return '${(bytes / 1073741824).toStringAsFixed(1)} GB';
}
// ========== VIDEO UPLOAD ==========
/// Select video file from device
Future<bool> selectVideo() async {
try {
final ImagePicker picker = ImagePicker();
final XFile? pickedVideo = await picker.pickVideo(
source: ImageSource.gallery,
);
if (pickedVideo == null) return false;
videoName = pickedVideo.name;
videoFileExtension = p.extension(pickedVideo.name);
webVideoBytes = await pickedVideo.readAsBytes();
// Remove extension from name for title
final nameWithoutExt = videoName!.replaceAll(videoFileExtension, '');
tituloController.text = nameWithoutExt;
notifyListeners();
return true;
} catch (e) {
errorMessage = 'Error seleccionando video: $e';
notifyListeners();
return false;
}
}
/// Select poster/thumbnail image
Future<bool> selectPoster() async {
try {
final ImagePicker picker = ImagePicker();
final XFile? pickedImage = await picker.pickImage(
source: ImageSource.gallery,
);
if (pickedImage == null) return false;
posterName = pickedImage.name;
posterFileExtension = p.extension(pickedImage.name);
webPosterBytes = await pickedImage.readAsBytes();
notifyListeners();
return true;
} catch (e) {
errorMessage = 'Error seleccionando poster: $e';
notifyListeners();
return false;
}
}
/// Upload video to Supabase Storage and create record
Future<bool> uploadVideo({
required String title,
String? description,
int? durationSeconds,
List<String>? tags,
}) async {
if (webVideoBytes == null || videoName == null) {
errorMessage = 'No hay video seleccionado';
notifyListeners();
return false;
}
try {
isLoading = true;
notifyListeners();
// 1. Upload video to storage
final timestamp = DateTime.now().millisecondsSinceEpoch;
final sanitizedName = _sanitizeFileName(videoName!);
final fileName = '${timestamp}_$sanitizedName';
videoStoragePath = 'videos/$fileName';
await supabaseML.storage.from('energymedia').uploadBinary(
videoStoragePath!,
webVideoBytes!,
fileOptions: const FileOptions(
cacheControl: '3600',
upsert: false,
),
);
// 2. Get public URL
videoUrl = supabaseML.storage
.from('energymedia')
.getPublicUrl(videoStoragePath!);
// 3. Upload poster if exists (solo storage, no DB)
String? posterUrlUploaded;
if (webPosterBytes != null && posterName != null) {
posterUrlUploaded = await _uploadPoster();
}
// 4. Create media_files record (UN SOLO REGISTRO con poster en metadata_json)
final metadataJson = {
'uploaded_at': DateTime.now().toIso8601String(),
'reproducciones': 0,
'original_file_name': videoName,
'duration_seconds': durationSeconds,
'file_size_bytes': webVideoBytes!.length, // Peso del video
if (posterUrlUploaded != null) 'poster_url': posterUrlUploaded,
if (posterUrlUploaded != null) 'poster_file_name': posterName,
if (tags != null && tags.isNotEmpty) 'tags': tags,
};
await supabaseML.from('media_files').insert({
'file_name': fileName,
'title': title,
'file_description': description,
'file_type': 'video',
'mime_type': _getMimeType(videoFileExtension),
'file_extension': videoFileExtension,
'file_size_bytes': webVideoBytes!.length,
'file_url': videoUrl,
'storage_path': videoStoragePath,
'organization_fk': organizationId,
'metadata_json': metadataJson,
'seconds': durationSeconds,
'is_public_file': true,
'uploaded_by_user_id': currentUser?.id,
});
// Clean up
_clearUploadState();
// Reload data
await loadMediaFiles();
isLoading = false;
notifyListeners();
return true;
} catch (e) {
errorMessage = 'Error subiendo video: $e';
isLoading = false;
notifyListeners();
print('Error en uploadVideo: $e');
return false;
}
}
/// Upload poster image to storage only (NO database record)
/// Returns the public URL of the uploaded poster
Future<String?> _uploadPoster() async {
if (webPosterBytes == null || posterName == null) return null;
try {
final timestamp = DateTime.now().millisecondsSinceEpoch;
final sanitizedName = _sanitizeFileName(posterName!);
final fileName = '${timestamp}_$sanitizedName';
posterStoragePath = 'imagenes/$fileName';
// Solo subir al storage, NO crear registro en media_files
await supabaseML.storage.from('energymedia').uploadBinary(
posterStoragePath!,
webPosterBytes!,
fileOptions: const FileOptions(
cacheControl: '3600',
upsert: false,
),
);
// Obtener URL pública del poster
posterUrl = supabaseML.storage
.from('energymedia')
.getPublicUrl(posterStoragePath!);
return posterUrl; // Retornar solo la URL, no el ID
} catch (e) {
print('Error en _uploadPoster: $e');
return null;
}
}
/// Get MIME type from file extension
String _getMimeType(String extension) {
final ext = extension.toLowerCase().replaceAll('.', '');
switch (ext) {
case 'mp4':
return 'video/mp4';
case 'webm':
return 'video/webm';
case 'mov':
return 'video/quicktime';
case 'avi':
return 'video/x-msvideo';
case 'jpg':
case 'jpeg':
return 'image/jpeg';
case 'png':
return 'image/png';
case 'gif':
return 'image/gif';
default:
return 'application/octet-stream';
}
}
/// Clear upload state
void _clearUploadState() {
videoName = null;
videoUrl = null;
videoStoragePath = null;
videoFileExtension = '';
webVideoBytes = null;
posterName = null;
posterUrl = null;
posterStoragePath = null;
posterFileExtension = '';
webPosterBytes = null;
tituloController.clear();
descripcionController.clear();
}
// ========== UPDATE METHODS ==========
/// Update video title
Future<bool> updateVideoTitle(int mediaFileId, String title) async {
try {
await supabaseML
.from('media_files')
.update({'title': title})
.eq('media_file_id', mediaFileId)
.eq('organization_fk', organizationId);
await loadMediaFiles();
return true;
} catch (e) {
errorMessage = 'Error actualizando título: $e';
notifyListeners();
print('Error en updateVideoTitle: $e');
return false;
}
}
/// Update video description
Future<bool> updateVideoDescription(
int mediaFileId, String description) async {
try {
await supabaseML
.from('media_files')
.update({'file_description': description})
.eq('media_file_id', mediaFileId)
.eq('organization_fk', organizationId);
await loadMediaFiles();
return true;
} catch (e) {
errorMessage = 'Error actualizando descripción: $e';
notifyListeners();
print('Error en updateVideoDescription: $e');
return false;
}
}
/// Update video metadata
Future<bool> updateVideoMetadata(
int mediaFileId,
Map<String, dynamic> metadata,
) async {
try {
await supabaseML
.from('media_files')
.update({'metadata_json': metadata})
.eq('media_file_id', mediaFileId)
.eq('organization_fk', organizationId);
await loadMediaFiles();
return true;
} catch (e) {
errorMessage = 'Error actualizando metadata: $e';
notifyListeners();
print('Error en updateVideoMetadata: $e');
return false;
}
}
/// Update video tags
Future<bool> updateVideoTags(int mediaFileId, List<String> tags) async {
try {
// Get current metadata
final response = await supabaseML
.from('media_files')
.select('metadata_json')
.eq('media_file_id', mediaFileId)
.eq('organization_fk', organizationId)
.single();
final metadata = response['metadata_json'] as Map<String, dynamic>? ?? {};
metadata['tags'] = tags;
await updateVideoMetadata(mediaFileId, metadata);
return true;
} catch (e) {
errorMessage = 'Error actualizando tags: $e';
notifyListeners();
print('Error en updateVideoTags: $e');
return false;
}
}
/// Update video poster
Future<bool> updateVideoPoster(
int mediaFileId, Uint8List posterBytes, String posterName) async {
try {
isLoading = true;
notifyListeners();
// Upload new poster to storage
final timestamp = DateTime.now().millisecondsSinceEpoch;
final sanitizedName = _sanitizeFileName(posterName);
final fileName = '${timestamp}_$sanitizedName';
final posterPath = 'imagenes/$fileName';
await supabaseML.storage.from('energymedia').uploadBinary(
posterPath,
posterBytes,
fileOptions: const FileOptions(
cacheControl: '3600',
upsert: false,
),
);
// Get public URL
final newPosterUrl =
supabaseML.storage.from('energymedia').getPublicUrl(posterPath);
// Get current metadata and old poster URL
final response = await supabaseML
.from('media_files')
.select('metadata_json')
.eq('media_file_id', mediaFileId)
.eq('organization_fk', organizationId)
.single();
final metadata = response['metadata_json'] as Map<String, dynamic>? ?? {};
final oldPosterUrl = metadata['poster_url'] as String?;
// Delete old poster from storage if exists
if (oldPosterUrl != null && oldPosterUrl.isNotEmpty) {
try {
final uri = Uri.parse(oldPosterUrl);
final pathSegments = uri.pathSegments;
final bucketIndex = pathSegments.indexOf('energymedia');
if (bucketIndex != -1 && bucketIndex < pathSegments.length - 1) {
final oldPosterPath =
pathSegments.sublist(bucketIndex + 1).join('/');
await supabaseML.storage
.from('energymedia')
.remove([oldPosterPath]);
}
} catch (e) {
print('Error eliminando poster antiguo: $e');
}
}
// Update metadata with new poster info
metadata['poster_url'] = newPosterUrl;
metadata['poster_file_name'] = posterName;
await updateVideoMetadata(mediaFileId, metadata);
isLoading = false;
notifyListeners();
return true;
} catch (e) {
errorMessage = 'Error actualizando poster: $e';
isLoading = false;
notifyListeners();
print('Error en updateVideoPoster: $e');
return false;
}
}
/// Delete video poster only (not the video itself)
Future<bool> deletePoster(int mediaFileId) async {
try {
isLoading = true;
notifyListeners();
// Get current metadata
final response = await supabaseML
.from('media_files')
.select('metadata_json')
.eq('media_file_id', mediaFileId)
.eq('organization_fk', organizationId)
.single();
final metadata = response['metadata_json'] as Map<String, dynamic>? ?? {};
final posterUrl = metadata['poster_url'] as String?;
// Delete poster from storage if exists
if (posterUrl != null && posterUrl.isNotEmpty) {
try {
final uri = Uri.parse(posterUrl);
final pathSegments = uri.pathSegments;
final bucketIndex = pathSegments.indexOf('energymedia');
if (bucketIndex != -1 && bucketIndex < pathSegments.length - 1) {
final posterPath = pathSegments.sublist(bucketIndex + 1).join('/');
await supabaseML.storage.from('energymedia').remove([posterPath]);
}
} catch (e) {
print('Error eliminando poster del storage: $e');
}
}
// Remove poster references from metadata
metadata.remove('poster_url');
metadata.remove('poster_file_name');
// Update metadata
await updateVideoMetadata(mediaFileId, metadata);
isLoading = false;
notifyListeners();
return true;
} catch (e) {
errorMessage = 'Error eliminando portada: $e';
isLoading = false;
notifyListeners();
print('Error en deletePoster: $e');
return false;
}
}
// ========== DELETE METHODS ==========
/// Delete video and its storage files
Future<bool> deleteVideo(int mediaFileId) async {
try {
isLoading = true;
notifyListeners();
// Get video info
final response = await supabaseML
.from('media_files')
.select()
.eq('media_file_id', mediaFileId)
.eq('organization_fk', organizationId)
.single();
final storagePath = response['storage_path'] as String?;
final metadataJson = response['metadata_json'] as Map<String, dynamic>?;
// Delete video from storage if path exists
if (storagePath != null) {
await supabaseML.storage.from('energymedia').remove([storagePath]);
}
// Delete poster from storage if exists in metadata_json
if (metadataJson != null && metadataJson['poster_url'] != null) {
final posterUrl = metadataJson['poster_url'] as String;
// Extraer el path del storage desde la URL
// URL format: https://xxx.supabase.co/storage/v1/object/public/energymedia/imagenes/filename.png
final uri = Uri.parse(posterUrl);
final pathSegments = uri.pathSegments;
// Encontrar el índice después de 'energymedia' y construir el path
final bucketIndex = pathSegments.indexOf('energymedia');
if (bucketIndex != -1 && bucketIndex < pathSegments.length - 1) {
final posterPath = pathSegments.sublist(bucketIndex + 1).join('/');
await supabaseML.storage.from('energymedia').remove([posterPath]);
}
}
// Delete database record
await supabaseML
.from('media_files')
.delete()
.eq('media_file_id', mediaFileId)
.eq('organization_fk', organizationId);
await loadMediaFiles();
isLoading = false;
notifyListeners();
return true;
} catch (e) {
errorMessage = 'Error eliminando video: $e';
isLoading = false;
notifyListeners();
print('Error en deleteVideo: $e');
return false;
}
}
// ========== ANALYTICS METHODS ==========
/// Increment view count
Future<bool> incrementReproduccion(int mediaFileId) async {
try {
// Get current metadata
final response = await supabaseML
.from('media_files')
.select('metadata_json')
.eq('media_file_id', mediaFileId)
.eq('organization_fk', organizationId)
.single();
final metadata = response['metadata_json'] as Map<String, dynamic>? ?? {};
final currentCount = metadata['reproducciones'] ?? 0;
metadata['reproducciones'] = currentCount + 1;
metadata['last_viewed_at'] = DateTime.now().toIso8601String();
await updateVideoMetadata(mediaFileId, metadata);
return true;
} catch (e) {
print('Error en incrementReproduccion: $e');
return false;
}
}
/// Get dashboard statistics
Future<Map<String, dynamic>> getDashboardStats() async {
try {
// Total videos
final totalVideos = mediaFiles.length;
// Total reproducciones
int totalReproducciones = 0;
for (var media in mediaFiles) {
totalReproducciones += media.reproducciones;
}
// Most viewed video
MediaFileModel? mostViewed;
if (mediaFiles.isNotEmpty) {
mostViewed = mediaFiles.reduce((curr, next) =>
curr.reproducciones > next.reproducciones ? curr : next);
}
return {
'total_videos': totalVideos,
'total_reproducciones': totalReproducciones,
'most_viewed_video': mostViewed?.toMap(),
};
} catch (e) {
print('Error en getDashboardStats: $e');
return {};
}
}
/// Update missing video durations (batch process)
Future<Map<String, dynamic>> updateMissingDurations(
Function(int current, int total) onProgress,
) async {
try {
// Obtener videos sin duración
final videosWithoutDuration = mediaFiles
.where((video) => video.seconds == null && video.fileUrl != null)
.toList();
if (videosWithoutDuration.isEmpty) {
return {'success': true, 'updated': 0, 'failed': 0};
}
int updated = 0;
int failed = 0;
for (int i = 0; i < videosWithoutDuration.length; i++) {
final video = videosWithoutDuration[i];
onProgress(i + 1, videosWithoutDuration.length);
VideoPlayerController? controller;
try {
// Inicializar VideoPlayerController para obtener duración
controller = VideoPlayerController.network(video.fileUrl!);
await controller.initialize();
final durationSeconds = controller.value.duration.inSeconds;
if (durationSeconds > 0) {
// Obtener metadata actual
final response = await supabaseML
.from('media_files')
.select('metadata_json')
.eq('media_file_id', video.mediaFileId)
.eq('organization_fk', organizationId)
.single();
final metadata =
response['metadata_json'] as Map<String, dynamic>? ?? {};
// Actualizar metadata con duración
metadata['duration_seconds'] = durationSeconds;
// Actualizar tanto seconds como metadata_json
await supabaseML
.from('media_files')
.update({
'seconds': durationSeconds,
'metadata_json': metadata,
})
.eq('media_file_id', video.mediaFileId)
.eq('organization_fk', organizationId);
updated++;
print('✅ Video ${video.mediaFileId}: $durationSeconds segundos');
} else {
failed++;
print('⚠️ Video ${video.mediaFileId}: duración inválida');
}
} catch (e) {
print('❌ Error procesando video ${video.mediaFileId}: $e');
failed++;
} finally {
// Limpiar recursos del controller
controller?.dispose();
}
}
// Recargar datos
await loadMediaFiles();
return {
'success': true,
'updated': updated,
'failed': failed,
'total': videosWithoutDuration.length,
};
} catch (e) {
print('Error en updateMissingDurations: $e');
return {'success': false, 'error': e.toString()};
}
}
// ========== SEARCH & FILTER ==========
/// Search videos by title or description
void searchVideos(String query) {
if (query.isEmpty) {
_buildPlutoRows();
gridRebuildKey++;
notifyListeners();
return;
}
videosRows.clear();
final filteredMedia = mediaFiles.where((media) {
final title = (media.title ?? media.fileName).toLowerCase();
final description = (media.fileDescription ?? '').toLowerCase();
final searchQuery = query.toLowerCase();
return title.contains(searchQuery) || description.contains(searchQuery);
}).toList();
for (var media in filteredMedia) {
videosRows.add(
PlutoRow(
cells: {
'video': PlutoCell(value: media),
'thumbnail': PlutoCell(value: media.fileUrl),
'title': PlutoCell(value: media.title ?? media.fileName),
'file_description': PlutoCell(value: media.fileDescription),
'reproducciones': PlutoCell(value: media.reproducciones),
'duration': PlutoCell(
value: media.seconds != null
? _formatDuration(media.seconds!)
: '-'),
'file_size': PlutoCell(
value: media.fileSizeBytes != null
? _formatFileSize(media.fileSizeBytes!)
: '-'),
'createdAt': PlutoCell(
value: media.createdAt?.toString().split('.')[0] ?? '-'),
'tags': PlutoCell(value: media.tags.join(', ')),
'actions': PlutoCell(value: media),
},
),
);
}
gridRebuildKey++;
notifyListeners();
}
// ========== CLEANUP ==========
@override
void dispose() {
busquedaVideoController.dispose();
tituloController.dispose();
descripcionController.dispose();
super.dispose();
}
}