import "dart:async"; import "dart:convert"; import "dart:typed_data"; import "package:flutter/foundation.dart"; import "package:http/http.dart" as http; class QuoteEngagement { final int? likes; final int? retweets; final int? replies; final int? views; QuoteEngagement({ this.likes, this.retweets, this.replies, this.views, }); Map toJson() { return { if (likes != null) "likes": likes, if (retweets != null) "retweets": retweets, if (replies != null) "replies": replies, if (views != null) "views": views, }; } factory QuoteEngagement.fromJson(Map json) { return QuoteEngagement( likes: json["likes"], retweets: json["retweets"], replies: json["replies"], views: json["views"], ); } } class QuoteSessionRequest { final String? displayName; final String? username; final dynamic avatarUrl; final String? text; final dynamic imageUrl; final int? timestamp; final bool? verified; final QuoteEngagement? engagement; final bool clearAvatarUrl; final bool clearImageUrl; final bool clearEngagement; QuoteSessionRequest({ this.displayName, this.username, this.avatarUrl, this.text, this.imageUrl, this.timestamp, this.verified, this.engagement, this.clearAvatarUrl = false, this.clearImageUrl = false, this.clearEngagement = false, }); // convert img/video bytes to base64 data uri String? _encodeImage(dynamic image) { if (image == null) return null; if (image is String) return image; if (image is Uint8List) { final base64String = base64Encode(image); // detect mime type from magic bytes String mimeType = "image/png"; // default if (image.length >= 4) { // MP4: starts with ftyp box if (image.length >= 12 && image[4] == 0x66 && image[5] == 0x74 && image[6] == 0x79 && image[7] == 0x70) { mimeType = "video/mp4"; } // WebM: starts with 0x1A45DFA3 else if (image[0] == 0x1A && image[1] == 0x45 && image[2] == 0xDF && image[3] == 0xA3) { mimeType = "video/webm"; } // GIF: starts with GIF87a or GIF89a else if (image[0] == 0x47 && image[1] == 0x49 && image[2] == 0x46) { mimeType = "image/gif"; } // PNG: starts with 0x89504E47 else if (image[0] == 0x89 && image[1] == 0x50 && image[2] == 0x4E && image[3] == 0x47) { mimeType = "image/png"; } // JPEG: starts with 0xFFD8FF else if (image[0] == 0xFF && image[1] == 0xD8 && image[2] == 0xFF) { mimeType = "image/jpeg"; } } return "data:$mimeType;base64,$base64String"; } return null; } Map toJson() { final map = {}; if (displayName != null) map["displayName"] = displayName; if (username != null) map["username"] = username; if (clearAvatarUrl) { map["avatarUrl"] = null; } else { final encodedAvatar = _encodeImage(avatarUrl); if (encodedAvatar != null) map["avatarUrl"] = encodedAvatar; } if (text != null) map["text"] = text; if (clearImageUrl) { map["imageUrl"] = null; } else { final encodedImage = _encodeImage(imageUrl); if (encodedImage != null) map["imageUrl"] = encodedImage; } if (timestamp != null) map["timestamp"] = timestamp; if (verified != null) map["verified"] = verified; if (clearEngagement) { map["engagement"] = null; } else if (engagement != null) { map["engagement"] = engagement!.toJson(); } return map; } } class QuoteSession { final String id; final String? displayName; final String? username; final String? avatarUrl; final String? text; final String? imageUrl; final int? timestamp; final bool? verified; final QuoteEngagement? engagement; final String? renderStatus; final int? renderProgress; final int? renderPid; final String? renderError; final int createdAt; final int updatedAt; QuoteSession({ required this.id, this.displayName, this.username, this.avatarUrl, this.text, this.imageUrl, this.timestamp, this.verified, this.engagement, this.renderStatus, this.renderProgress, this.renderPid, this.renderError, required this.createdAt, required this.updatedAt, }); factory QuoteSession.fromJson(Map json) { return QuoteSession( id: json["id"], displayName: json["displayName"], username: json["username"], avatarUrl: json["avatarUrl"], text: json["text"], imageUrl: json["imageUrl"], timestamp: json["timestamp"], verified: json["verified"], engagement: json["engagement"] != null ? QuoteEngagement.fromJson(json["engagement"]) : null, renderStatus: json["renderStatus"], renderProgress: json["renderProgress"], renderPid: json["renderPid"], renderError: json["renderError"], createdAt: json["createdAt"], updatedAt: json["updatedAt"], ); } // helper to check if render is complete bool get isRenderComplete => renderStatus == "completed"; // helper to check if render failed bool get isRenderFailed => renderStatus == "failed"; // helper to check if render is in progress bool get isRendering => renderStatus == "rendering" || renderStatus == "pending"; } class QuoteSnapshot { final String token; final String url; final String sessionId; final int createdAt; final int expiresAt; QuoteSnapshot({ required this.token, required this.url, required this.sessionId, required this.createdAt, required this.expiresAt, }); factory QuoteSnapshot.fromJson(Map json) { return QuoteSnapshot( token: json["token"], url: json["url"], sessionId: json["sessionId"], createdAt: json["createdAt"], expiresAt: json["expiresAt"], ); } } class RenderProgress { final String status; final String sessionId; final int progress; final String? stage; final String? error; RenderProgress({ required this.status, required this.sessionId, required this.progress, this.stage, this.error, }); factory RenderProgress.fromJson(Map json) { return RenderProgress( status: json["status"], sessionId: json["sessionId"], progress: json["progress"], stage: json["stage"], error: json["error"], ); } bool get isComplete => status == "completed"; bool get isFailed => status == "failed"; bool get isRendering => status == "rendering" || status == "pending"; // helper to get stage display text String get stageText { switch (stage) { case "rendering": return "Rendering video..."; case "encoding": return "Encoding to H.264..."; case "completed": return "Complete!"; default: return "Processing..."; } } } class MediaResponse { final Uint8List bytes; final String contentType; final String? sessionId; final bool? cacheHit; MediaResponse({ required this.bytes, required this.contentType, this.sessionId, this.cacheHit, }); bool get isVideo => contentType.startsWith("video/"); bool get isImage => contentType.startsWith("image/"); } class QuoteGeneratorApiV2 { // static const String _baseUrl = "https://quotes.imbenji.net"; static final String _baseUrl = kDebugMode && defaultTargetPlatform == TargetPlatform.iOS ? "http://localhost:3000" : "https://quotes.imbenji.net"; // create new session static Future createSession(QuoteSessionRequest request) async { final url = Uri.parse("$_baseUrl/v2/quote"); final response = await http.post( url, headers: { "Content-Type": "application/json", "User-Agent": "QuoteGen-Flutter/1.0", }, body: jsonEncode(request.toJson()), ); if (response.statusCode == 201) { final data = jsonDecode(response.body); return QuoteSession.fromJson(data); } else { throw Exception("Failed to create session: ${response.statusCode}"); } } // get session state static Future getSession(String sessionId) async { final url = Uri.parse("$_baseUrl/v2/quote/$sessionId"); final response = await http.get( url, headers: {"User-Agent": "QuoteGen-Flutter/1.0"}, ); if (response.statusCode == 200) { final data = jsonDecode(response.body); return QuoteSession.fromJson(data); } else { throw Exception("Failed to get session: ${response.statusCode}"); } } // update session fields (only send whats changed) static Future updateSession( String sessionId, QuoteSessionRequest updates, ) async { final url = Uri.parse("$_baseUrl/v2/quote/$sessionId"); final response = await http.patch( url, headers: { "Content-Type": "application/json", "User-Agent": "QuoteGen-Flutter/1.0", }, body: jsonEncode(updates.toJson()), ); if (response.statusCode == 200) { final data = jsonDecode(response.body); return QuoteSession.fromJson(data); } else { throw Exception("Failed to update session: ${response.statusCode}"); } } // render the session as image or video (returns full response info) static Future generateMedia(String sessionId) async { final url = Uri.parse("$_baseUrl/v2/quote/$sessionId/image"); final response = await http.get( url, headers: {"User-Agent": "QuoteGen-Flutter/1.0"}, ); if (response.statusCode == 200) { return MediaResponse( bytes: response.bodyBytes, contentType: response.headers["content-type"] ?? "image/png", sessionId: response.headers["x-session-id"], cacheHit: response.headers["x-cache"] == "HIT", ); } else { throw Exception("Failed to generate media: ${response.statusCode}"); } } // render the session as image (legacy method, returns just bytes) static Future generateImage(String sessionId) async { final media = await generateMedia(sessionId); return media.bytes; } // delete session static Future deleteSession(String sessionId) async { final url = Uri.parse("$_baseUrl/v2/quote/$sessionId"); final response = await http.delete( url, headers: {"User-Agent": "QuoteGen-Flutter/1.0"}, ); if (response.statusCode != 204) { throw Exception("Failed to delete session: ${response.statusCode}"); } } // create persistant snapshot link static Future createSnapshotLink(String sessionId) async { final url = Uri.parse("$_baseUrl/v2/quote/$sessionId/snapshot-link"); final response = await http.post( url, headers: {"User-Agent": "QuoteGen-Flutter/1.0"}, ); if (response.statusCode == 201) { final data = jsonDecode(response.body); return QuoteSnapshot.fromJson(data); } else { throw Exception("Failed to create snapshot link: ${response.statusCode}"); } } // get snapshot media by token (returns full response info) static Future getSnapshotMedia(String token) async { final url = Uri.parse("$_baseUrl/v2/snapshot/$token"); final response = await http.get( url, headers: {"User-Agent": "QuoteGen-Flutter/1.0"}, ); if (response.statusCode == 200) { return MediaResponse( bytes: response.bodyBytes, contentType: response.headers["content-type"] ?? "image/png", sessionId: response.headers["x-snapshot-token"], cacheHit: response.headers["x-cache"] == "HIT", ); } else { throw Exception("Failed to get snapshot: ${response.statusCode}"); } } // get snapshot image by token (legacy method, returns just bytes) static Future getSnapshot(String token) async { final media = await getSnapshotMedia(token); return media.bytes; } // stream render progress via SSE static Stream watchRenderProgress(String sessionId) async* { final url = Uri.parse("$_baseUrl/v2/quote/$sessionId/render-info"); print("Connecting to SSE: $url"); final request = http.Request("GET", url); request.headers["User-Agent"] = "QuoteGen-Flutter/1.0"; request.headers["Accept"] = "text/event-stream"; request.headers["Cache-Control"] = "no-cache"; final client = http.Client(); try { final response = await client.send(request); print("SSE response status: ${response.statusCode}"); if (response.statusCode != 200) { throw Exception("Failed to watch render progress: ${response.statusCode}"); } // buffer for incomplete SSE messages String buffer = ""; await for (final chunk in response.stream.transform(utf8.decoder)) { buffer += chunk; print("SSE chunk received: $chunk"); // split by double newlines (SSE message boundry) final messages = buffer.split("\n\n"); // last part might be incomplete, keep it in buffer buffer = messages.last; // process complete messages for (int i = 0; i < messages.length - 1; i++) { final message = messages[i].trim(); if (message.isEmpty) continue; // parse SSE data field (format: "data: {...}") if (message.startsWith("data:")) { final jsonString = message.substring(5).trim(); print("SSE data: $jsonString"); try { final data = jsonDecode(jsonString); final progress = RenderProgress.fromJson(data); print("Parsed progress: status=${progress.status}, stage=${progress.stage}, progress=${progress.progress}%"); yield progress; // close stream when render is done if (progress.isComplete || progress.isFailed) { print("Render finished, closing SSE stream"); client.close(); return; } } catch (e) { print("Error parsing SSE JSON: $e"); // ignore malformed JSON continue; } } } } } catch (e) { print("SSE stream error: $e"); rethrow; } finally { client.close(); print("SSE connection closed"); } } // ping the backend to see if its actually up, used to hide server dependant features when its not static Future checkHealth() async { try { final url = Uri.parse("$_baseUrl/health"); final response = await http.get( url, headers: {"User-Agent": "QuoteGen-Flutter/1.0"}, ).timeout(const Duration(seconds: 5)); if (response.statusCode == 200) { final data = jsonDecode(response.body); return data["status"] == "ok"; } return false; } catch (e) { print("Health check failed: $e"); return false; } } // helper to get current timestamp in seconds static int getCurrentTimestamp() { return DateTime.now().millisecondsSinceEpoch ~/ 1000; } }