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: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 videosRows = []; // ========== DATA LISTS ========== List mediaFiles = []; List categories = []; List 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 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) .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 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) .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 loadCategories() async { try { final response = await supabaseML .from('media_categories') .select() .order('category_name'); categories = (response as List) .map((item) => MediaCategoryModel.fromMap(item)) .toList(); notifyListeners(); } catch (e) { print('Error en loadCategories: $e'); } } /// Build PlutoGrid rows from media files Future _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!) : '-'), '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 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 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 uploadVideo({ required String title, String? description, int? durationSeconds, List? 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 _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 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 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 updateVideoMetadata( int mediaFileId, Map 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 updateVideoTags(int mediaFileId, List 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? ?? {}; 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 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? ?? {}; 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 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? ?? {}; 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 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?; // 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 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? ?? {}; 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> 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 {}; } } // ========== SEARCH & FILTER ========== /// Search videos by title or description void searchVideos(String query) { if (query.isEmpty) { _buildPlutoRows(); 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!) : '-'), 'createdAt': PlutoCell( value: media.createdAt?.toString().split('.')[0] ?? '-'), 'tags': PlutoCell(value: media.tags.join(', ')), 'actions': PlutoCell(value: media), }, ), ); } notifyListeners(); } // ========== CLEANUP ========== @override void dispose() { busquedaVideoController.dispose(); tituloController.dispose(); descripcionController.dispose(); super.dispose(); } }