562 lines
15 KiB
Dart
562 lines
15 KiB
Dart
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<String, dynamic> 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<String, dynamic> 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<String, dynamic> toJson() {
|
|
final map = <String, dynamic>{};
|
|
|
|
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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<QuoteSession> 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<QuoteSession> 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<QuoteSession> 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<MediaResponse> 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<Uint8List> generateImage(String sessionId) async {
|
|
final media = await generateMedia(sessionId);
|
|
return media.bytes;
|
|
}
|
|
|
|
|
|
// delete session
|
|
static Future<void> 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<QuoteSnapshot> 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<MediaResponse> 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<Uint8List> getSnapshot(String token) async {
|
|
final media = await getSnapshotMedia(token);
|
|
return media.bytes;
|
|
}
|
|
|
|
|
|
// stream render progress via SSE
|
|
static Stream<RenderProgress> 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<bool> 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;
|
|
}
|
|
}
|