feat: add gallery feature with database integration and rendering capabilities
Build Android App / build (push) Failing after 2m19s

This commit is contained in:
ImBenji
2026-08-25 11:46:33 +01:00
parent b66b73a3c6
commit 8c2212b563
17 changed files with 2902 additions and 885 deletions
+240
View File
@@ -0,0 +1,240 @@
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<void> 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<Uint8List> _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<void> 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<void> 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();
}
}