diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json index bc45fe7..8b51059 100644 --- a/assets/translations/en-US.json +++ b/assets/translations/en-US.json @@ -291,6 +291,7 @@ "attachmentInsertLink": "Insert Link", "attachmentSetAsPostThumbnail": "Set as post thumbnail", "attachmentUnsetAsPostThumbnail": "Unset as post thumbnail", + "attachmentCompressVideo": "Re-encode video", "attachmentSetThumbnail": "Set thumbnail", "attachmentCopyRandomId": "Copy RID", "attachmentUpload": "Upload", @@ -513,5 +514,11 @@ "postCategoryLiterature": "Literature", "postCategoryFunny": "Funny", "postCategoryUncategorized": "Uncategorized", - "waitingForUpload": "Waiting for upload" + "waitingForUpload": "Waiting for upload", + "attachmentCompressQuality": "Compress quality", + "attachmentCompressQualityHighest": "Highest", + "attachmentCompressQualityDefault": "Default", + "attachmentCompressQualityMedium": "Medium", + "attachmentCompressQualityLow": "Low", + "attachmentCompressQualityHint": "Solar Network doesn't prevent you from uploading large files, high resolution, high bitrate videos. But for your network conditions, we suggest you choose a suitable compression quality." } diff --git a/assets/translations/zh-CN.json b/assets/translations/zh-CN.json index 11efe3b..d3c62e0 100644 --- a/assets/translations/zh-CN.json +++ b/assets/translations/zh-CN.json @@ -289,6 +289,7 @@ "attachmentInsertLink": "插入连接", "attachmentSetAsPostThumbnail": "设置为帖子缩略图", "attachmentUnsetAsPostThumbnail": "取消设置为帖子缩略图", + "attachmentCompressVideo": "重新编码视频", "attachmentSetThumbnail": "设置缩略图", "attachmentCopyRandomId": "复制访问 ID", "attachmentUpload": "上传", @@ -511,5 +512,11 @@ "postCategoryLiterature": "文学", "postCategoryFunny": "搞笑", "postCategoryUncategorized": "未分类", - "waitingForUpload": "等待上传" + "waitingForUpload": "等待上传", + "attachmentCompressQuality": "压缩质量", + "attachmentCompressQualityHighest": "最高", + "attachmentCompressQualityDefault": "默认", + "attachmentCompressQualityMedium": "中等", + "attachmentCompressQualityLow": "低", + "attachmentCompressQualityHint": "Solar Network 并没有阻止你上传大文件、高分辨率、高码率的视频,但是为了你的网络情况观众考虑,我们建议你选择一个合适的压缩质量。" } diff --git a/assets/translations/zh-HK.json b/assets/translations/zh-HK.json index b96e2b3..9b23ec6 100644 --- a/assets/translations/zh-HK.json +++ b/assets/translations/zh-HK.json @@ -289,6 +289,7 @@ "attachmentInsertLink": "插入連接", "attachmentSetAsPostThumbnail": "設置為帖子縮略圖", "attachmentUnsetAsPostThumbnail": "取消設置為帖子縮略圖", + "attachmentCompressVideo": "重新編碼視頻", "attachmentSetThumbnail": "設置縮略圖", "attachmentCopyRandomId": "複製訪問 ID", "attachmentUpload": "上傳", @@ -511,5 +512,11 @@ "postCategoryLiterature": "文學", "postCategoryFunny": "搞笑", "postCategoryUncategorized": "未分類", - "waitingForUpload": "等待上傳" + "waitingForUpload": "等待上傳", + "attachmentCompressQuality": "壓縮質量", + "attachmentCompressQualityHighest": "最高", + "attachmentCompressQualityDefault": "默認", + "attachmentCompressQualityMedium": "中等", + "attachmentCompressQualityLow": "低", + "attachmentCompressQualityHint": "Solar Network 並沒有阻止你上傳大文件、高分辨率、高碼率的視頻,但是為了你的網絡情況觀眾考慮,我們建議你選擇一個合適的壓縮質量。" } diff --git a/assets/translations/zh-TW.json b/assets/translations/zh-TW.json index 8377725..b040503 100644 --- a/assets/translations/zh-TW.json +++ b/assets/translations/zh-TW.json @@ -289,6 +289,7 @@ "attachmentInsertLink": "插入連接", "attachmentSetAsPostThumbnail": "設置為帖子縮略圖", "attachmentUnsetAsPostThumbnail": "取消設置為帖子縮略圖", + "attachmentCompressVideo": "重新編碼視頻", "attachmentSetThumbnail": "設置縮略圖", "attachmentCopyRandomId": "複製訪問 ID", "attachmentUpload": "上傳", @@ -511,5 +512,11 @@ "postCategoryLiterature": "文學", "postCategoryFunny": "搞笑", "postCategoryUncategorized": "未分類", - "waitingForUpload": "等待上傳" + "waitingForUpload": "等待上傳", + "attachmentCompressQuality": "壓縮質量", + "attachmentCompressQualityHighest": "最高", + "attachmentCompressQualityDefault": "默認", + "attachmentCompressQualityMedium": "中等", + "attachmentCompressQualityLow": "低", + "attachmentCompressQualityHint": "Solar Network 並沒有阻止你上傳大文件、高分辨率、高碼率的視頻,但是為了你的網絡情況觀眾考慮,我們建議你選擇一個合適的壓縮質量。" } diff --git a/ios/Podfile.lock b/ios/Podfile.lock index b4d56d6..9d4ed1b 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -217,6 +217,8 @@ PODS: - SwiftyGif (5.4.5) - url_launcher_ios (0.0.1): - Flutter + - video_compress (0.3.0): + - Flutter - volume_controller (0.0.1): - Flutter - wakelock_plus (0.0.1): @@ -259,6 +261,7 @@ DEPENDENCIES: - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + - video_compress (from `.symlinks/plugins/video_compress/ios`) - volume_controller (from `.symlinks/plugins/volume_controller/ios`) - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) - workmanager (from `.symlinks/plugins/workmanager/ios`) @@ -348,6 +351,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/sqflite_darwin/darwin" url_launcher_ios: :path: ".symlinks/plugins/url_launcher_ios/ios" + video_compress: + :path: ".symlinks/plugins/video_compress/ios" volume_controller: :path: ".symlinks/plugins/volume_controller/ios" wakelock_plus: @@ -405,6 +410,7 @@ SPEC CHECKSUMS: sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe + video_compress: fce97e4fb1dfd88175aa07d2ffc8a2f297f87fbe volume_controller: 531ddf792994285c9b17f9d8a7e4dcdd29b3eae9 wakelock_plus: 373cfe59b235a6dd5837d0fb88791d2f13a90d56 WebRTC-SDK: 79942c006ea64f6fb48d7da8a4786dfc820bc1db diff --git a/lib/widgets/attachment/attachment_item.dart b/lib/widgets/attachment/attachment_item.dart index 7f973ab..9f387ff 100644 --- a/lib/widgets/attachment/attachment_item.dart +++ b/lib/widgets/attachment/attachment_item.dart @@ -215,7 +215,7 @@ class _AttachmentItemContentVideoState extends State<_AttachmentItemContentVideo behavior: HitTestBehavior.opaque, child: Stack( children: [ - if (widget.data.thumbnail != null) + if (widget.data.thumbnail?.isNotEmpty ?? false) AutoResizeUniversalImage( sn.getAttachmentUrl(widget.data.thumbnail!), fit: BoxFit.cover, diff --git a/lib/widgets/attachment/pending_attachment_compress.dart b/lib/widgets/attachment/pending_attachment_compress.dart new file mode 100644 index 0000000..6568922 --- /dev/null +++ b/lib/widgets/attachment/pending_attachment_compress.dart @@ -0,0 +1,163 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:cross_file/cross_file.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:styled_widget/styled_widget.dart'; +import 'package:surface/controllers/post_write_controller.dart'; +import 'package:surface/widgets/dialog.dart'; +import 'package:video_compress/video_compress.dart'; + +class PendingVideoCompressDialog extends StatefulWidget { + final PostWriteMedia media; + + const PendingVideoCompressDialog({super.key, required this.media}); + + @override + State createState() => _PendingVideoCompressDialogState(); +} + +class _PendingVideoCompressDialogState extends State { + VideoQuality _quality = VideoQuality.DefaultQuality; + + bool _isBusy = false; + double? _progress; + MediaInfo? _mediaInfo; + + Subscription? _progressSubscription; + + Future _startCompress() async { + _mediaInfo = await VideoCompress.compressVideo( + widget.media.file!.path, + quality: _quality, + deleteOrigin: false, + frameRate: switch (_quality) { + VideoQuality.HighestQuality => 60, + VideoQuality.DefaultQuality => 60, + _ => 30, + }, + ); + if (_mediaInfo == null) return; + setState(() => _isBusy = true); + if (!mounted || _mediaInfo == null) return; + Navigator.pop(context, PostWriteMedia.fromFile(XFile(_mediaInfo!.path!))); + } + + @override + void initState() { + super.initState(); + _progressSubscription = VideoCompress.compressProgress$.subscribe((event) { + log('[Compress] Progress: $event'); + setState(() { + _progress = event / 100; + _isBusy = event < 100; + }); + }); + } + + @override + void dispose() { + _progressSubscription?.unsubscribe(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text('attachmentCompressVideo').tr(), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FutureBuilder( + future: widget.media.file?.length(), + builder: (context, snapshot) { + if (!snapshot.hasData) return const SizedBox.shrink(); + return Text( + snapshot.data!.formatBytes(), + style: GoogleFonts.robotoMono(fontSize: 13), + ); + }, + ), + Text('attachmentCompressQuality').tr(), + const Gap(8), + Card( + child: Column( + children: [ + RadioListTile( + title: Text('attachmentCompressQualityHighest').tr(), + value: VideoQuality.HighestQuality, + groupValue: _quality, + selected: _quality == VideoQuality.HighestQuality, + onChanged: (val) { + if (val != null) { + setState(() => _quality = val); + } + }, + ), + RadioListTile( + title: Text('attachmentCompressQualityDefault').tr(), + value: VideoQuality.DefaultQuality, + groupValue: _quality, + onChanged: (val) { + if (val != null) { + setState(() => _quality = val); + } + }, + ), + RadioListTile( + title: Text('attachmentCompressQualityMedium').tr(), + value: VideoQuality.MediumQuality, + groupValue: _quality, + onChanged: (val) { + if (val != null) { + setState(() => _quality = val); + } + }, + ), + RadioListTile( + title: Text('attachmentCompressQualityLow').tr(), + value: VideoQuality.LowQuality, + groupValue: _quality, + onChanged: (val) { + if (val != null) { + setState(() => _quality = val); + } + }, + ), + ], + ), + ), + const Gap(8), + Text('attachmentCompressQualityHint', style: Theme.of(context).textTheme.bodySmall!).tr(), + if (_isBusy) + TweenAnimationBuilder( + tween: Tween(begin: 0, end: _progress ?? 0), + duration: Duration(milliseconds: 100), + builder: (context, value, _) => LinearProgressIndicator( + value: value, + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + ).padding(top: 16), + ], + ), + actions: [ + TextButton( + onPressed: _isBusy + ? null + : () { + Navigator.pop(context); + }, + child: Text('dialogDismiss').tr(), + ), + TextButton( + onPressed: _isBusy ? null : _startCompress, + child: Text('dialogConfirm').tr(), + ), + ], + ); + } +} diff --git a/lib/widgets/post/post_media_pending_list.dart b/lib/widgets/post/post_media_pending_list.dart index 6dda66b..10f7d0e 100644 --- a/lib/widgets/post/post_media_pending_list.dart +++ b/lib/widgets/post/post_media_pending_list.dart @@ -24,6 +24,8 @@ import 'package:surface/widgets/context_menu.dart'; import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/universal_image.dart'; +import '../attachment/pending_attachment_compress.dart'; + class PostMediaPendingList extends StatelessWidget { final PostWriteMedia? thumbnail; final List attachments; @@ -118,9 +120,28 @@ class PostMediaPendingList extends StatelessWidget { } } + Future _compressVideo(BuildContext context, int idx) async { + final result = await showDialog( + context: context, + builder: (context) => PendingVideoCompressDialog(media: attachments[idx]), + ); + if (result == null) return; + + onUpdate!(idx, result); + } + ContextMenu _createContextMenu(BuildContext context, int idx, PostWriteMedia media) { + final canCompressVideo = !kIsWeb && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS); return ContextMenu( entries: [ + if (media.attachment == null && media.type == SnMediaType.video && canCompressVideo) + MenuItem( + label: 'attachmentCompressVideo'.tr(), + icon: Symbols.compress, + onSelected: () { + _compressVideo(context, idx); + }, + ), if (media.attachment != null && media.type == SnMediaType.video) MenuItem( label: 'attachmentSetThumbnail'.tr(), @@ -306,22 +327,22 @@ class _PostMediaPendingItem extends StatelessWidget { ), ), SnMediaType.audio => Container( - color: Theme.of(context).colorScheme.surfaceContainer, - child: Stack( - fit: StackFit.expand, - children: [ - if (media.attachment?.thumbnail != null) - AutoResizeUniversalImage(sn.getAttachmentUrl(media.attachment!.thumbnail!)), - const Icon(Symbols.audio_file, color: Colors.white, shadows: [ - Shadow( - offset: Offset(1, 1), - blurRadius: 8.0, - color: Color.fromARGB(255, 0, 0, 0), - ), - ]), - ], + color: Theme.of(context).colorScheme.surfaceContainer, + child: Stack( + fit: StackFit.expand, + children: [ + if (media.attachment?.thumbnail != null) + AutoResizeUniversalImage(sn.getAttachmentUrl(media.attachment!.thumbnail!)), + const Icon(Symbols.audio_file, color: Colors.white, shadows: [ + Shadow( + offset: Offset(1, 1), + blurRadius: 8.0, + color: Color.fromARGB(255, 0, 0, 0), + ), + ]), + ], + ), ), - ), _ => Container( color: Theme.of(context).colorScheme.surfaceContainer, child: const Icon(Symbols.docs).center(),