diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json index 1f40f6e..4f26e45 100644 --- a/assets/translations/en-US.json +++ b/assets/translations/en-US.json @@ -543,6 +543,7 @@ "attachmentSaved": "Saved to album", "attachmentSavedDesktop": "Saved to Downloads folder", "openInAlbum": "Open in album", + "openInBrowser": "Open in browser", "postAbuseReport": "Report Post", "postAbuseReportDescription": "Report posts that violate our user agreement and community guidelines to help us improve the content on Solar Network. Please describe how this post violates the relevant rules. Do not include any sensitive information. We will process your report within 24 hours.", "abuseReport": "Abuse Report", @@ -958,5 +959,11 @@ "attachmentReferenceLink": "Use external attachment", "fieldAttachmentReferenceLink": "Reference Link", "attachmentReferenceLinkDescription": "It will be used as the source file of the attachment. The link needs to allow cross-origin access.", - "fieldAttachmentMimetype": "Mimetype" + "fieldAttachmentMimetype": "Mimetype", + "postVideoLive": "Live Stream", + "postVideoLiveDescription": "This is a live video, you can embed the source site by yourself.", + "postVideoRendererWeb": "WebView Rendering", + "postVideoRendererWebDescription": "Use WebView to render the content", + "fieldPostVideoStreamUrl": "Live Stream URL", + "fieldPostVideoStreamUrlDescription": "Live stream URL, can be HLS or webpage; use webpage renderer if you want to embed the webpage." } diff --git a/assets/translations/zh-CN.json b/assets/translations/zh-CN.json index 582477f..871625d 100644 --- a/assets/translations/zh-CN.json +++ b/assets/translations/zh-CN.json @@ -541,6 +541,7 @@ "attachmentSaved": "已保存到相册", "attachmentSavedDesktop": "已保存到下载目录", "openInAlbum": "在相册中打开", + "openInBrowser": "在浏览器中打开", "postAbuseReport": "检举帖子", "postAbuseReportDescription": "检举不符合我们用户协议以及社区准则的帖子,来帮助我们更好的维护 Solar Network 上的内容。请在下面描述该帖子如何违反我么的相关规定。请勿填写任何敏感信息。我们将会在 24 小时内处理您的检举。", "abuseReport": "检举", @@ -955,5 +956,11 @@ "attachmentReferenceLink": "引用外部附件", "fieldAttachmentReferenceLink": "引用连接", "attachmentReferenceLinkDescription": "作为附件的源文件。需要链接允许跨域访问。", - "fieldAttachmentMimetype": "文件类型" + "fieldAttachmentMimetype": "文件类型", + "postVideoLive": "直播", + "postVideoLiveDescription": "这是一条直播影片,允许用户自行嵌入源站。", + "postVideoRendererWeb": "网页渲染器", + "postVideoRendererWebDescription": "使用 WebView 渲染内容。", + "fieldPostVideoStreamUrl": "直播流地址", + "fieldPostVideoStreamUrlDescription": "直播流地址,可以为 HLS,或者网页;使用网页嵌入请启用网页渲染器。" } diff --git a/lib/controllers/post_write_controller.dart b/lib/controllers/post_write_controller.dart index 020f0a3..50ce73d 100644 --- a/lib/controllers/post_write_controller.dart +++ b/lib/controllers/post_write_controller.dart @@ -219,6 +219,8 @@ class PostWriteController extends ChangeNotifier { List attachments = List.empty(growable: true); DateTime? publishedAt, publishedUntil; SnAttachment? videoAttachment; + String videoUrl = ''; + bool videoLive = false; SnPoll? poll; Future fetchRelatedPost( @@ -445,8 +447,9 @@ class PostWriteController extends ChangeNotifier { titleController.text = data['title'] ?? ''; descriptionController.text = data['description'] ?? ''; rewardController.text = data['reward']?.toString() ?? ''; - if (data['thumbnail'] != null) + if (data['thumbnail'] != null) { thumbnail = PostWriteMedia(SnAttachment.fromJson(data['thumbnail'])); + } attachments.addAll(data['attachments'] .map((ele) => PostWriteMedia(SnAttachment.fromJson(ele))) .cast()); @@ -455,10 +458,12 @@ class PostWriteController extends ChangeNotifier { visibility = data['visibility']; visibleUsers = List.from(data['visible_users_list'] ?? []); invisibleUsers = List.from(data['invisible_users_list'] ?? []); - if (data['published_at'] != null) + if (data['published_at'] != null) { publishedAt = DateTime.tryParse(data['published_at'])?.toLocal(); - if (data['published_until'] != null) + } + if (data['published_until'] != null) { publishedUntil = DateTime.tryParse(data['published_until'])?.toLocal(); + } replyingPost = data['reply_to'] != null ? SnPost.fromJson(data['reply_to']) : null; repostingPost = @@ -595,7 +600,8 @@ class PostWriteController extends ChangeNotifier { if (replyingPost != null) 'reply_to': replyingPost!.id, if (repostingPost != null) 'repost_to': repostingPost!.id, if (reward != null) 'reward': reward, - if (videoAttachment != null) 'video': videoAttachment!.rid, + if (videoAttachment != null || videoUrl.isNotEmpty) + 'video': videoUrl.isNotEmpty ? videoUrl : videoAttachment!.rid, if (poll != null) 'poll': poll!.id, if (realm != null) 'realm': realm!.id, 'is_draft': saveAsDraft, @@ -738,6 +744,15 @@ class PostWriteController extends ChangeNotifier { notifyListeners(); } + void setVideoUrl(String value) { + videoUrl = value; + } + + void setVideoLive(bool value) { + videoLive = value; + notifyListeners(); + } + void setPoll(SnPoll? value) { poll = value; notifyListeners(); diff --git a/lib/screens/post/post_editor.dart b/lib/screens/post/post_editor.dart index 0ca0417..2e39861 100644 --- a/lib/screens/post/post_editor.dart +++ b/lib/screens/post/post_editor.dart @@ -1105,7 +1105,7 @@ class _PostQuestionEditor extends StatelessWidget { } } -class _PostVideoEditor extends StatelessWidget { +class _PostVideoEditor extends StatefulWidget { final PostWriteController controller; final Function? onTapPublisher; final Function? onTapRealm; @@ -1113,7 +1113,16 @@ class _PostVideoEditor extends StatelessWidget { const _PostVideoEditor( {required this.controller, this.onTapPublisher, this.onTapRealm}); - void _selectVideo(BuildContext context) async { + @override + State<_PostVideoEditor> createState() => _PostVideoEditorState(); +} + +class _PostVideoEditorState extends State<_PostVideoEditor> { + String? _renderer; + + final TextEditingController _streamUrlController = TextEditingController(); + + void _selectVideo() async { final video = await showDialog( context: context, builder: (context) => AttachmentInputDialog( @@ -1124,7 +1133,25 @@ class _PostVideoEditor extends StatelessWidget { ); if (!context.mounted) return; if (video == null) return; - controller.setVideoAttachment(video); + widget.controller.setVideoAttachment(video); + } + + @override + void initState() { + _streamUrlController.addListener(() { + if (_streamUrlController.text.isEmpty) { + widget.controller.setVideoUrl(''); + } else { + widget.controller.setVideoUrl(_streamUrlController.text); + } + }); + super.initState(); + } + + @override + void dispose() { + _streamUrlController.dispose(); + super.dispose(); } @override @@ -1142,10 +1169,10 @@ class _PostVideoEditor extends StatelessWidget { borderRadius: const BorderRadius.all(Radius.circular(24)), child: GestureDetector( onTap: () { - onTapPublisher?.call(); + widget.onTapPublisher?.call(); }, child: AccountImage( - content: controller.publisher?.avatar, + content: widget.controller.publisher?.avatar, ), ), ), @@ -1155,10 +1182,10 @@ class _PostVideoEditor extends StatelessWidget { borderRadius: const BorderRadius.all(Radius.circular(24)), child: GestureDetector( onTap: () { - onTapRealm?.call(); + widget.onTapRealm?.call(); }, child: AccountImage( - content: controller.realm?.avatar, + content: widget.controller.realm?.avatar, fallbackWidget: const Icon(Symbols.globe, size: 20), radius: 14, ), @@ -1171,7 +1198,7 @@ class _PostVideoEditor extends StatelessWidget { children: [ const Gap(6), TextField( - controller: controller.titleController, + controller: widget.controller.titleController, decoration: InputDecoration.collapsed( hintText: 'fieldPostTitle'.tr(), border: InputBorder.none, @@ -1182,7 +1209,7 @@ class _PostVideoEditor extends StatelessWidget { ).padding(horizontal: 16), const Gap(8), TextField( - controller: controller.descriptionController, + controller: widget.controller.descriptionController, decoration: InputDecoration.collapsed( hintText: 'fieldPostDescription'.tr(), border: InputBorder.none, @@ -1194,57 +1221,89 @@ class _PostVideoEditor extends StatelessWidget { FocusManager.instance.primaryFocus?.unfocus(), ).padding(horizontal: 16), const Gap(12), - Container( - margin: const EdgeInsets.only(left: 16, right: 16), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - border: Border.all(color: Theme.of(context).dividerColor), - ), - child: InkWell( - borderRadius: BorderRadius.circular(16), - onTap: controller.videoAttachment == null - ? () => _selectVideo(context) - : () { - showModalBottomSheet( - context: context, - builder: (context) => - PendingAttachmentActionSheet( - media: PostWriteMedia( - controller.videoAttachment!, + if (widget.controller.videoLive) + TextField( + controller: _streamUrlController, + decoration: InputDecoration( + labelText: 'fieldPostVideoStreamUrl'.tr(), + helperText: 'fieldPostVideoStreamUrlDescription'.tr(), + border: OutlineInputBorder(), + isDense: true, + ), + onTapOutside: (_) => + FocusManager.instance.primaryFocus?.unfocus(), + ).padding(horizontal: 12) + else + Container( + margin: const EdgeInsets.only(left: 16, right: 16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Theme.of(context).dividerColor), + ), + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: widget.controller.videoAttachment == null + ? () => _selectVideo() + : () { + showModalBottomSheet( + context: context, + builder: (context) => + PendingAttachmentActionSheet( + media: PostWriteMedia( + widget.controller.videoAttachment!, + ), + ), + ).then((value) async { + if (value is PostWriteMedia) { + widget.controller + .setVideoAttachment(value.attachment); + } else if (value == false) { + widget.controller.setVideoAttachment(null); + } + }); + }, + child: AspectRatio( + aspectRatio: 16 / 9, + child: widget.controller.videoAttachment == null + ? Center( + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.add), + const Gap(4), + Text('postVideoUpload'.tr()), + ], + ), + ) + : ClipRRect( + borderRadius: BorderRadius.circular(16), + child: AttachmentItem( + data: widget.controller.videoAttachment!, + heroTag: const Uuid().v4(), ), ), - ).then((value) async { - if (value is PostWriteMedia) { - controller.setVideoAttachment(value.attachment); - } else if (value == false) { - controller.setVideoAttachment(null); - } - }); - }, - child: AspectRatio( - aspectRatio: 16 / 9, - child: controller.videoAttachment == null - ? Center( - child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.add), - const Gap(4), - Text('postVideoUpload'.tr()), - ], - ), - ) - : ClipRRect( - borderRadius: BorderRadius.circular(16), - child: AttachmentItem( - data: controller.videoAttachment!, - heroTag: const Uuid().v4(), - ), - ), + ), ), ), + const Gap(8), + CheckboxListTile( + secondary: const Icon(Symbols.live_tv), + title: Text('postVideoLive').tr(), + subtitle: Text('postVideoLiveDescription').tr(), + value: widget.controller.videoLive, + onChanged: (value) => + widget.controller.setVideoLive(value ?? false), + ), + CheckboxListTile( + secondary: const Icon(Symbols.web), + title: Text('postVideoRendererWeb').tr(), + subtitle: Text('postVideoRendererWebDescription').tr(), + value: _renderer == 'web', + onChanged: (value) => setState( + () => _renderer = (value ?? false) ? 'web' : null, + ), ), ], ), diff --git a/lib/widgets/post/post_item.dart b/lib/widgets/post/post_item.dart index 9031223..e045232 100644 --- a/lib/widgets/post/post_item.dart +++ b/lib/widgets/post/post_item.dart @@ -8,6 +8,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_animate/flutter_animate.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -41,6 +42,7 @@ import 'package:surface/widgets/post/post_poll.dart'; import 'package:surface/widgets/post/post_reaction.dart'; import 'package:surface/widgets/post/publisher_popover.dart'; import 'package:surface/widgets/universal_image.dart'; +import 'package:url_launcher/url_launcher_string.dart'; import 'package:xml/xml.dart'; class OpenablePostItem extends StatelessWidget { @@ -2321,24 +2323,48 @@ class _PostVideoPlayer extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(8)), - border: Border.all( - color: Theme.of(context).dividerColor, - width: 1, - ), - ), - child: AspectRatio( - aspectRatio: 16 / 9, - child: ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(8)), - child: AttachmentItem( - data: data.body['video'], - heroTag: 'post-video-${data.id}', + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(8)), + border: Border.all( + color: Theme.of(context).dividerColor, + width: 1, + ), + ), + child: AspectRatio( + aspectRatio: 16 / 9, + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: (data.body['video'] is String) + ? InAppWebView( + initialUrlRequest: URLRequest( + url: WebUri(data.body['video']), + ), + ) + : AttachmentItem( + data: data.body['video'], + heroTag: 'post-video-${data.id}', + ), + ), ), ), - ), + if (data.body['video'] is String) + InkWell( + child: Row( + children: [ + const Icon(Symbols.launch, size: 16), + const Gap(6), + Text('openInBrowser').tr(), + ], + ).opacity(0.8), + onTap: () { + launchUrlString(data.body['video']); + }, + ).padding(top: 4), + ], ); } }