Add v2 quote API with video support and Flutter tweet template widget
- Implement Express routes for creating, updating, retrieving, and deleting quote sessions - Add video detection and rendering pipeline using Playwright and FFmpeg - Add caching utilities for images and videos - Provide helpers for formatting counts, timestamps, and normalizing usernames - Add snapshot creation and retrieval endpoints - Implement SSE endpoint for render progress updates - Add Flutter widget for rendering tweet templates with image/video support
This commit is contained in:
402
lib/tweet_template_widget.dart
Normal file
402
lib/tweet_template_widget.dart
Normal file
@@ -0,0 +1,402 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
class TweetTemplateWidget extends StatefulWidget {
|
||||
final Uint8List? avatarBytes;
|
||||
final String displayName;
|
||||
final bool isVerified;
|
||||
final String username;
|
||||
final String text;
|
||||
final Uint8List? imageBytes;
|
||||
final Uint8List? videoBytes;
|
||||
final DateTime timestamp;
|
||||
final Map<String, int>? engagement; // {replies, retweets, likes, views}
|
||||
|
||||
const TweetTemplateWidget({
|
||||
Key? key,
|
||||
this.avatarBytes,
|
||||
required this.displayName,
|
||||
this.isVerified = false,
|
||||
required this.username,
|
||||
required this.text,
|
||||
this.imageBytes,
|
||||
this.videoBytes,
|
||||
required this.timestamp,
|
||||
this.engagement,
|
||||
}) : assert(imageBytes == null || videoBytes == null,
|
||||
'Cannot have both imageBytes and videoBytes set at the same time'),
|
||||
super(key: key);
|
||||
|
||||
@override
|
||||
State<TweetTemplateWidget> createState() => _TweetTemplateWidgetState();
|
||||
}
|
||||
|
||||
class _TweetTemplateWidgetState extends State<TweetTemplateWidget> {
|
||||
VideoPlayerController? _videoController;
|
||||
late String _normalizedUsername;
|
||||
File? _tempVideoFile;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_normalizedUsername = _normalizeUsername(widget.username);
|
||||
_initializeMedia();
|
||||
}
|
||||
|
||||
String _normalizeUsername(String username) {
|
||||
final trimmed = username.trim();
|
||||
|
||||
// if empty or just "@", return default
|
||||
if (trimmed.isEmpty || trimmed == '@') return '@anonymous';
|
||||
|
||||
// add @ if it doesnt start with it
|
||||
return trimmed.startsWith('@') ? trimmed : '@$trimmed';
|
||||
}
|
||||
|
||||
Future<void> _initializeMedia() async {
|
||||
if (widget.videoBytes == null) return;
|
||||
|
||||
// write video bytes to a temp file
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final tempPath = '${tempDir.path}/temp_video_${DateTime.now().millisecondsSinceEpoch}.mp4';
|
||||
_tempVideoFile = File(tempPath);
|
||||
await _tempVideoFile!.writeAsBytes(widget.videoBytes!);
|
||||
|
||||
_videoController = VideoPlayerController.file(_tempVideoFile!)
|
||||
..initialize().then((_) {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
_videoController?.play();
|
||||
_videoController?.setLooping(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_videoController?.dispose();
|
||||
_tempVideoFile?.delete().catchError((_) {});
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
color: Colors.black,
|
||||
child: Center(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final size = constraints.biggest.shortestSide - 20;
|
||||
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
color: Colors.black,
|
||||
child: Center(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.contain,
|
||||
child: SizedBox(
|
||||
width: 450,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
color: Colors.black,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// header with avatar and user info
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Avatar
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
margin: const EdgeInsets.only(right: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color.fromRGBO(51, 54, 57, 1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: widget.avatarBytes != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Image.memory(
|
||||
widget.avatarBytes!,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
// user info section
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
widget.displayName,
|
||||
style: const TextStyle(
|
||||
fontFamily: '-apple-system',
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color.fromRGBO(231, 233, 234, 1),
|
||||
height: 1.33,
|
||||
),
|
||||
),
|
||||
if (widget.isVerified) ...[
|
||||
const SizedBox(width: 4),
|
||||
SvgPicture.string(
|
||||
_verifiedBadgeSvg,
|
||||
width: 18,
|
||||
height: 18,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
Text(
|
||||
_normalizedUsername,
|
||||
style: const TextStyle(
|
||||
fontFamily: '-apple-system',
|
||||
fontSize: 15,
|
||||
color: Color.fromRGBO(113, 118, 123, 1),
|
||||
height: 1.33,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
|
||||
const SizedBox(height: 12),
|
||||
// tweet text
|
||||
Text(
|
||||
widget.text,
|
||||
style: const TextStyle(
|
||||
fontFamily: '-apple-system',
|
||||
fontSize: 15,
|
||||
color: Color.fromRGBO(231, 233, 234, 1),
|
||||
height: 1.33,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// media content (image or vdeo)
|
||||
if (widget.imageBytes != null) ...[
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: const Color.fromRGBO(47, 51, 54, 1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Image.memory(
|
||||
widget.imageBytes!,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
|
||||
if (widget.videoBytes != null) ...[
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: const Color.fromRGBO(47, 51, 54, 1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: (_videoController?.value.isInitialized ?? false)
|
||||
? AspectRatio(
|
||||
aspectRatio: _videoController!.value.aspectRatio,
|
||||
child: VideoPlayer(_videoController!),
|
||||
)
|
||||
: Container(
|
||||
height: 200,
|
||||
color: const Color.fromRGBO(51, 54, 57, 1),
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: Color.fromRGBO(29, 155, 240, 1),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
|
||||
// timestmp and source
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
style: const TextStyle(
|
||||
fontFamily: '-apple-system',
|
||||
fontSize: 15,
|
||||
color: Color.fromRGBO(113, 118, 123, 1),
|
||||
),
|
||||
children: [
|
||||
TextSpan(text: _formatTimestamp(widget.timestamp)),
|
||||
const TextSpan(text: ' · '),
|
||||
const TextSpan(
|
||||
text: 'via tweetforge.imbenji.net',
|
||||
style: TextStyle(
|
||||
color: Color.fromRGBO(29, 155, 240, 1),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// engagement metrics if provided
|
||||
if (widget.engagement != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Color.fromRGBO(47, 51, 54, 1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
if (widget.engagement!['replies'] != null)
|
||||
_buildEngagementItem(
|
||||
_replyIcon,
|
||||
widget.engagement!['replies']!,
|
||||
),
|
||||
if (widget.engagement!['retweets'] != null)
|
||||
_buildEngagementItem(
|
||||
_retweetIcon,
|
||||
widget.engagement!['retweets']!,
|
||||
),
|
||||
if (widget.engagement!['likes'] != null)
|
||||
_buildEngagementItem(
|
||||
_likeIcon,
|
||||
widget.engagement!['likes']!,
|
||||
),
|
||||
if (widget.engagement!['views'] != null)
|
||||
_buildEngagementItem(
|
||||
_viewsIcon,
|
||||
widget.engagement!['views']!,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEngagementItem(String iconSvg, int count) {
|
||||
return Row(
|
||||
children: [
|
||||
SvgPicture.string(
|
||||
iconSvg,
|
||||
width: 18.75,
|
||||
height: 18.75,
|
||||
colorFilter: const ColorFilter.mode(
|
||||
Color.fromRGBO(113, 118, 123, 1),
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_formatCount(count),
|
||||
style: const TextStyle(
|
||||
fontFamily: '-apple-system',
|
||||
fontSize: 13,
|
||||
color: Color.fromRGBO(113, 118, 123, 1),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _formatCount(int count) {
|
||||
if (count >= 1000000000) {
|
||||
final formatted = (count / 1000000000).toStringAsFixed(1);
|
||||
return formatted.replaceAll(RegExp(r'\.0$'), '') + 'B';
|
||||
}
|
||||
if (count >= 1000000) {
|
||||
final formatted = (count / 1000000).toStringAsFixed(1);
|
||||
return formatted.replaceAll(RegExp(r'\.0$'), '') + 'M';
|
||||
}
|
||||
if (count >= 1000) {
|
||||
final formatted = (count / 1000).toStringAsFixed(1);
|
||||
return formatted.replaceAll(RegExp(r'\.0$'), '') + 'K';
|
||||
}
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
String _formatTimestamp(DateTime date) {
|
||||
final hours = date.hour;
|
||||
final minutes = date.minute.toString().padLeft(2, '0');
|
||||
final ampm = hours >= 12 ? 'PM' : 'AM';
|
||||
final hour12 = hours % 12 == 0 ? 12 : hours % 12;
|
||||
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
final month = months[date.month - 1];
|
||||
final day = date.day;
|
||||
final year = date.year;
|
||||
|
||||
return '$hour12:$minutes $ampm · $month $day, $year';
|
||||
}
|
||||
|
||||
// verified badge svg path
|
||||
static const String _verifiedBadgeSvg = '''
|
||||
<svg viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#1D9BF0" d="M20.396 11c-.018-.646-.215-1.275-.57-1.816-.354-.54-.852-.972-1.438-1.246.223-.607.27-1.264.14-1.897-.131-.634-.437-1.218-.882-1.687-.47-.445-1.053-.75-1.687-.882-.633-.13-1.29-.083-1.897.14-.273-.587-.704-1.086-1.245-1.44S11.647 1.62 11 1.604c-.646.017-1.273.213-1.813.568s-.969.854-1.24 1.44c-.608-.223-1.267-.272-1.902-.14-.635.13-1.22.436-1.69.882-.445.47-.749 1.055-.878 1.688-.13.633-.08 1.29.144 1.896-.587.274-1.087.705-1.443 1.245-.356.54-.555 1.17-.574 1.817.02.647.218 1.276.574 1.817.356.54.856.972 1.443 1.245-.224.606-.274 1.263-.144 1.896.13.634.433 1.218.877 1.688.47.443 1.054.747 1.687.878.633.132 1.29.084 1.897-.136.274.586.705 1.084 1.246 1.439.54.354 1.17.551 1.816.569.647-.016 1.276-.213 1.817-.567s.972-.854 1.245-1.44c.604.239 1.266.296 1.903.164.636-.132 1.22-.447 1.68-.907.46-.46.776-1.044.908-1.681s.075-1.299-.165-1.903c.586-.274 1.084-.705 1.439-1.246.354-.54.551-1.17.569-1.816zM9.662 14.85l-3.429-3.428 1.293-1.302 2.072 2.072 4.4-4.794 1.347 1.246z"/>
|
||||
</svg>
|
||||
''';
|
||||
|
||||
// engagment icons (simplified versions)
|
||||
static const String _replyIcon = '''
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.751 10c0-4.42 3.584-8 8.005-8h4.366c4.49 0 8.129 3.64 8.129 8.13 0 2.96-1.607 5.68-4.196 7.11l-8.054 4.46v-3.69h-.067c-4.49.1-8.183-3.51-8.183-8.01zm8.005-6c-3.317 0-6.005 2.69-6.005 6 0 3.37 2.77 6.08 6.138 6.01l.351-.01h1.761v2.3l5.087-2.81c1.951-1.08 3.163-3.13 3.163-5.36 0-3.39-2.744-6.13-6.129-6.13H9.756z"/>
|
||||
</svg>
|
||||
''';
|
||||
|
||||
static const String _retweetIcon = '''
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4.5 3.88l4.432 4.14-1.364 1.46L5.5 7.55V16c0 1.1.896 2 2 2H13v2H7.5c-2.209 0-4-1.79-4-4V7.55L1.432 9.48.068 8.02 4.5 3.88zM16.5 6H11V4h5.5c2.209 0 4 1.79 4 4v8.45l2.068-1.93 1.364 1.46-4.432 4.14-4.432-4.14 1.364-1.46 2.068 1.93V8c0-1.1-.896-2-2-2z"/>
|
||||
</svg>
|
||||
''';
|
||||
|
||||
static const String _likeIcon = '''
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16.697 5.5c-1.222-.06-2.679.51-3.89 2.16l-.805 1.09-.806-1.09C9.984 6.01 8.526 5.44 7.304 5.5c-1.243.07-2.349.78-2.91 1.91-.552 1.12-.633 2.78.479 4.82 1.074 1.97 3.257 4.27 7.129 6.61 3.87-2.34 6.052-4.64 7.126-6.61 1.111-2.04 1.03-3.7.477-4.82-.561-1.13-1.666-1.84-2.908-1.91zm4.187 7.69c-1.351 2.48-4.001 5.12-8.379 7.67l-.503.3-.504-.3c-4.379-2.55-7.029-5.19-8.382-7.67-1.36-2.5-1.41-4.86-.514-6.67.887-1.79 2.647-2.91 4.601-3.01 1.651-.09 3.368.56 4.798 2.01 1.429-1.45 3.146-2.1 4.796-2.01 1.954.1 3.714 1.22 4.601 3.01.896 1.81.846 4.17-.514 6.67z"/>
|
||||
</svg>
|
||||
''';
|
||||
|
||||
static const String _viewsIcon = '''
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.75 21V3h2v18h-2zM18 21V8.5h2V21h-2zM4 21l.004-10h2L6 21H4zm9.248 0v-7h2v7h-2z"/>
|
||||
</svg>
|
||||
''';
|
||||
}
|
||||
Reference in New Issue
Block a user