import "dart:async"; import "dart:io"; import "dart:typed_data"; import "dart:ui" as ui; import "package:flutter/material.dart"; import "package:flutter/rendering.dart"; import "package:path/path.dart" as path; import "package:path_provider/path_provider.dart"; import "package:quotegen_client/services/quote_api_v2.dart"; import "package:video_player/video_player.dart"; class RenderProvider extends ChangeNotifier { Uint8List? postPreviewImage; String? postPreviewContentType; int renderProgress = 0; String renderStatus = "idle"; String renderStage = "idle"; VideoPlayerController? videoController; bool videoLoadFailed = false; StreamSubscription? _sseSubscription; bool _disposed = false; void clearRender() { if (postPreviewImage == null && renderStatus == "idle") return; videoController?.dispose(); videoController = null; postPreviewImage = null; postPreviewContentType = null; renderStatus = "idle"; renderProgress = 0; renderStage = "idle"; videoLoadFailed = false; if (!_disposed) notifyListeners(); } Future renderOutput(String? sessionId, bool isVideoInForm, {GlobalKey? previewKey}) async { if (!_disposed) { renderProgress = 0; renderStatus = "pending"; renderStage = "idle"; notifyListeners(); } if (isVideoInForm) { // video still goes through the server, so no session means theres genuinely nothing we can do if (sessionId == null) { print("Cant render video, session isnt ready (or never got created)"); if (!_disposed) { renderStatus = "failed"; notifyListeners(); } return; } print("Starting video render for session: $sessionId"); if (!_disposed) { renderStatus = "rendering"; renderStage = "rendering"; notifyListeners(); } // cancel any existing sub before starting new one await _sseSubscription?.cancel(); _sseSubscription = QuoteGeneratorApiV2.watchRenderProgress(sessionId).listen( (progress) { print("Render progress: ${progress.status} - ${progress.stage} - ${progress.progress}%"); if (_disposed) return; renderProgress = progress.progress; renderStatus = progress.status; renderStage = progress.stage ?? "idle"; notifyListeners(); if (progress.isComplete) { print("Render complete, fetching media..."); fetchRenderedMedia(sessionId); } else if (progress.isFailed) { print("Render failed: ${progress.error}"); if (!_disposed) { renderStatus = "failed"; notifyListeners(); } } }, onError: (error) { print("Error watching render progress: $error"); }, ); try { await QuoteGeneratorApiV2.generateMedia(sessionId); } catch (e) { print("Error starting render: $e"); } return; } // image render - grab it straight off the live preview widget, dont need the server for this bit anymore try { if (previewKey == null) { throw Exception("no preview key given, cant capture anything"); } // renderStatus flipped to pending up top already, which swaps the ui back to the live // tweet card - just gotta wait for that frame to actually paint before we grab it await WidgetsBinding.instance.endOfFrame; final bytes = await _capturePreview(previewKey); if (_disposed) return; videoController?.dispose(); videoController = null; postPreviewImage = bytes; postPreviewContentType = "image/png"; videoLoadFailed = false; renderStatus = "completed"; renderProgress = 100; renderStage = "completed"; notifyListeners(); } catch (e) { print("Error rendering: $e"); if (!_disposed) { renderStatus = "failed"; notifyListeners(); } } } // rasterizes a repaint boundary to png, scaled to a fixed resolution regardless of its onscreen size Future _capturePreview(GlobalKey key, {double targetSize = 1080}) async { final renderObj = key.currentContext?.findRenderObject(); if (renderObj is! RenderRepaintBoundary) { throw Exception("preview widget isnt mounted, nothing to capture"); } final logicalSize = renderObj.size; final pixelRatio = logicalSize.shortestSide == 0 ? 1.0 : targetSize / logicalSize.shortestSide; final image = await renderObj.toImage(pixelRatio: pixelRatio); final byteData = await image.toByteData(format: ui.ImageByteFormat.png); image.dispose(); if (byteData == null) { throw Exception("failed to encode captured png"); } return byteData.buffer.asUint8List(); } Future fetchRenderedMedia(String sessionId) async { try { MediaResponse media = await QuoteGeneratorApiV2.generateMedia(sessionId); if (_disposed) return; videoController?.dispose(); videoController = null; postPreviewImage = media.bytes; postPreviewContentType = media.contentType; videoLoadFailed = false; renderStatus = "completed"; renderProgress = 100; renderStage = "completed"; notifyListeners(); if (media.isVideo) { await initializeVideoPlayer(media.bytes); } } catch (e) { print("Error fetching rendered media: $e"); if (!_disposed) { renderStatus = "failed"; notifyListeners(); } } } Future initializeVideoPlayer(Uint8List videoBytes) async { try { videoLoadFailed = false; final tempDir = await getTemporaryDirectory(); final videoPath = path.join(tempDir.path, "preview_video_${DateTime.now().millisecondsSinceEpoch}.mp4"); final file = File(videoPath); await file.writeAsBytes(videoBytes); print("Video saved to: $videoPath (${videoBytes.length} bytes)"); videoController = VideoPlayerController.file(file); videoController!.addListener(() { if (!_disposed) notifyListeners(); }); await videoController!.initialize(); videoController!.setLooping(true); videoController!.play(); if (!_disposed) { videoLoadFailed = false; notifyListeners(); } } catch (e) { print("Error initializing video player: $e"); if (!_disposed) { videoLoadFailed = true; notifyListeners(); } } } @override void dispose() { _disposed = true; _sseSubscription?.cancel(); videoController?.dispose(); super.dispose(); } }