From 5e6f082ade33223e30fc522ec6fb78f428a713a3 Mon Sep 17 00:00:00 2001 From: guozhigq Date: Sat, 27 Jul 2024 11:00:19 +0800 Subject: [PATCH 01/13] =?UTF-8?q?feat:=20=E8=AF=84=E8=AE=BA=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E4=B8=BA=E5=9B=BE=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../detail/reply/widgets/reply_item.dart | 28 +++- .../detail/reply/widgets/reply_save.dart | 140 ++++++++++++++++++ 2 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 lib/pages/video/detail/reply/widgets/reply_save.dart diff --git a/lib/pages/video/detail/reply/widgets/reply_item.dart b/lib/pages/video/detail/reply/widgets/reply_item.dart index 5b7b0659..9fc8b11e 100644 --- a/lib/pages/video/detail/reply/widgets/reply_item.dart +++ b/lib/pages/video/detail/reply/widgets/reply_item.dart @@ -14,11 +14,13 @@ import 'package:pilipala/pages/main/index.dart'; import 'package:pilipala/pages/video/detail/index.dart'; import 'package:pilipala/pages/video/detail/reply_new/index.dart'; import 'package:pilipala/plugin/pl_gallery/index.dart'; +import 'package:pilipala/plugin/pl_popup/index.dart'; import 'package:pilipala/utils/app_scheme.dart'; import 'package:pilipala/utils/feed_back.dart'; import 'package:pilipala/utils/storage.dart'; import 'package:pilipala/utils/url_utils.dart'; import 'package:pilipala/utils/utils.dart'; +import 'reply_save.dart'; import 'zan.dart'; Box setting = GStrorage.setting; @@ -58,7 +60,10 @@ class ReplyItem extends StatelessWidget { useRootNavigator: true, isScrollControlled: true, builder: (context) { - return MorePanel(item: replyItem); + return MorePanel( + item: replyItem, + mainFloor: true, + ); }, ); }, @@ -1004,7 +1009,12 @@ InlineSpan buildContent( class MorePanel extends StatelessWidget { final dynamic item; - const MorePanel({super.key, required this.item}); + final bool mainFloor; + const MorePanel({ + super.key, + required this.item, + this.mainFloor = false, + }); Future menuActionHandler(String type) async { String message = item.content.message ?? item.content; @@ -1026,6 +1036,13 @@ class MorePanel extends StatelessWidget { }, ); break; + case 'save': + Get.back(); + Navigator.push( + Get.context!, + PlPopupRoute(child: ReplySave(replyItem: item)), + ); + break; // case 'block': // SmartDialog.showToast('加入黑名单'); // break; @@ -1076,6 +1093,13 @@ class MorePanel extends StatelessWidget { leading: const Icon(Icons.copy_outlined, size: 19), title: Text('自由复制', style: textTheme.titleSmall), ), + if (mainFloor) + ListTile( + onTap: () async => await menuActionHandler('save'), + minLeadingWidth: 0, + leading: const Icon(Icons.save_alt_rounded, size: 19), + title: Text('本地保存', style: textTheme.titleSmall), + ), // ListTile( // onTap: () async => await menuActionHandler('block'), // minLeadingWidth: 0, diff --git a/lib/pages/video/detail/reply/widgets/reply_save.dart b/lib/pages/video/detail/reply/widgets/reply_save.dart new file mode 100644 index 00000000..c083806f --- /dev/null +++ b/lib/pages/video/detail/reply/widgets/reply_save.dart @@ -0,0 +1,140 @@ +import 'dart:math'; +import 'dart:typed_data'; +import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:get/get.dart'; +import 'package:pilipala/models/video/reply/item.dart'; +import 'package:pilipala/pages/video/detail/reply/widgets/reply_item.dart'; +import 'package:saver_gallery/saver_gallery.dart'; + +class ReplySave extends StatefulWidget { + final ReplyItemModel? replyItem; + const ReplySave({required this.replyItem, super.key}); + + @override + State createState() => _ReplySaveState(); +} + +class _ReplySaveState extends State { + final _boundaryKey = GlobalKey(); + + void _generatePicWidget() async { + SmartDialog.showLoading(msg: '保存中'); + try { + RenderRepaintBoundary boundary = _boundaryKey.currentContext! + .findRenderObject() as RenderRepaintBoundary; + var image = await boundary.toImage(pixelRatio: 3); + ByteData? byteData = await image.toByteData(format: ImageByteFormat.png); + Uint8List pngBytes = byteData!.buffer.asUint8List(); + String picName = + "plpl_reply_${DateTime.now().toString().replaceAll(RegExp(r'[- :]'), '').split('.').first}"; + final result = await SaverGallery.saveImage( + Uint8List.fromList(pngBytes), + name: '$picName.png', + androidRelativePath: "Pictures/PiliPala", + androidExistNotSave: false, + ); + if (result.isSuccess) { + SmartDialog.showToast('保存成功'); + } + } catch (err) { + print(err); + } finally { + SmartDialog.dismiss(); + } + } + + List _createWidgets(int count, Widget Function() builder) { + return List.generate(count, (_) => Expanded(child: builder())); + } + + List _createColumnWidgets() { + return _createWidgets(3, () => Row(children: _createRowWidgets())); + } + + List _createRowWidgets() { + return _createWidgets( + 4, + () => Center( + child: Transform.rotate( + angle: pi / 10, + child: const Text( + 'PiliPala', + style: TextStyle( + color: Color(0x08000000), + fontSize: 18, + fontWeight: FontWeight.bold, + decoration: TextDecoration.none, + ), + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Container( + width: Get.width, + height: Get.height, + margin: EdgeInsets.fromLTRB( + 0, + MediaQuery.of(context).padding.top + 4, + 0, + MediaQuery.of(context).padding.bottom + 4, + ), + color: Colors.transparent, + child: Column( + children: [ + Expanded( + child: Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + ), + child: Center( + child: SingleChildScrollView( + child: RepaintBoundary( + key: _boundaryKey, + child: IntrinsicHeight( + child: Stack( + children: [ + ReplyItem( + replyItem: widget.replyItem, + showReplyRow: false, + ), + Positioned.fill( + child: Column( + children: _createColumnWidgets(), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FilledButton( + onPressed: () => Get.back(), + child: const Text('取消'), + ), + const SizedBox(width: 40), + FilledButton( + onPressed: _generatePicWidget, + child: const Text('保存'), + ), + ], + ), + ], + ), + ); + } +} From 0bb9a68cc55cb65bbc1a5bf41c683a47f8d9827d Mon Sep 17 00:00:00 2001 From: guozhigq Date: Fri, 9 Aug 2024 18:39:16 +0800 Subject: [PATCH 02/13] =?UTF-8?q?mod:=20=E4=BF=9D=E5=AD=98=E5=B0=81?= =?UTF-8?q?=E9=9D=A2=E6=97=B6=E8=A7=86=E9=A2=91=E6=A0=87=E9=A2=98=E5=8F=AF?= =?UTF-8?q?=E9=80=89=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/utils/image_save.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/utils/image_save.dart b/lib/utils/image_save.dart index 0b77b7cc..99513ac8 100644 --- a/lib/utils/image_save.dart +++ b/lib/utils/image_save.dart @@ -57,7 +57,7 @@ Future imageSaveDialog(context, videoItem, closeFn) { child: Row( children: [ Expanded( - child: Text( + child: SelectableText( videoItem.title! as String, style: Theme.of(context).textTheme.titleSmall, ), From b188675faf2607e9e5d3eb5c60da0ae3863203fa Mon Sep 17 00:00:00 2001 From: guozhigq Date: Sun, 11 Aug 2024 18:43:21 +0800 Subject: [PATCH 03/13] opt --- .../detail/reply/widgets/reply_item.dart | 43 +++++-- .../detail/reply/widgets/reply_save.dart | 106 ++++++++++-------- 2 files changed, 88 insertions(+), 61 deletions(-) diff --git a/lib/pages/video/detail/reply/widgets/reply_item.dart b/lib/pages/video/detail/reply/widgets/reply_item.dart index 9fc8b11e..ae691f97 100644 --- a/lib/pages/video/detail/reply/widgets/reply_item.dart +++ b/lib/pages/video/detail/reply/widgets/reply_item.dart @@ -17,6 +17,7 @@ import 'package:pilipala/plugin/pl_gallery/index.dart'; import 'package:pilipala/plugin/pl_popup/index.dart'; import 'package:pilipala/utils/app_scheme.dart'; import 'package:pilipala/utils/feed_back.dart'; +import 'package:pilipala/utils/id_utils.dart'; import 'package:pilipala/utils/storage.dart'; import 'package:pilipala/utils/url_utils.dart'; import 'package:pilipala/utils/utils.dart'; @@ -33,6 +34,7 @@ class ReplyItem extends StatelessWidget { this.showReplyRow = true, this.replyReply, this.replyType, + this.replySave = false, super.key, }); final ReplyItemModel? replyItem; @@ -41,6 +43,7 @@ class ReplyItem extends StatelessWidget { final bool? showReplyRow; final Function? replyReply; final ReplyType? replyType; + final bool? replySave; @override Widget build(BuildContext context) { @@ -48,12 +51,18 @@ class ReplyItem extends StatelessWidget { child: InkWell( // 点击整个评论区 评论详情/回复 onTap: () { + if (replySave!) { + return; + } feedBack(); if (replyReply != null) { replyReply!(replyItem, null, replyItem!.replies!.isNotEmpty); } }, onLongPress: () { + if (replySave!) { + return; + } feedBack(); showModalBottomSheet( context: context, @@ -236,7 +245,7 @@ class ReplyItem extends StatelessWidget { ), ), // 操作区域 - bottonAction(context, replyItem!.replyControl), + bottonAction(context, replyItem!.replyControl, replySave), // 一楼的评论 if ((replyItem!.replyControl!.isShow! || replyItem!.replies!.isNotEmpty) && @@ -257,7 +266,7 @@ class ReplyItem extends StatelessWidget { } // 感谢、回复、复制 - Widget bottonAction(BuildContext context, replyControl) { + Widget bottonAction(BuildContext context, replyControl, replySave) { ColorScheme colorScheme = Theme.of(context).colorScheme; TextTheme textTheme = Theme.of(context).textTheme; return Row( @@ -290,16 +299,26 @@ class ReplyItem extends StatelessWidget { }); }, child: Row(children: [ - Icon(Icons.reply, - size: 18, color: colorScheme.outline.withOpacity(0.8)), - const SizedBox(width: 3), - Text( - '回复', - style: TextStyle( - fontSize: textTheme.labelMedium!.fontSize, - color: colorScheme.outline, + if (!replySave!) ...[ + Icon(Icons.reply, + size: 18, color: colorScheme.outline.withOpacity(0.8)), + const SizedBox(width: 3), + Text( + '回复', + style: TextStyle( + fontSize: textTheme.labelMedium!.fontSize, + color: colorScheme.outline, + ), + ) + ], + if (replySave!) + Text( + IdUtils.av2bv(replyItem!.oid!), + style: TextStyle( + fontSize: textTheme.labelMedium!.fontSize, + color: colorScheme.outline, + ), ), - ), ]), ), ), @@ -1093,7 +1112,7 @@ class MorePanel extends StatelessWidget { leading: const Icon(Icons.copy_outlined, size: 19), title: Text('自由复制', style: textTheme.titleSmall), ), - if (mainFloor) + if (mainFloor && item.content.pictures.isEmpty) ListTile( onTap: () async => await menuActionHandler('save'), minLeadingWidth: 0, diff --git a/lib/pages/video/detail/reply/widgets/reply_save.dart b/lib/pages/video/detail/reply/widgets/reply_save.dart index c083806f..9eed4923 100644 --- a/lib/pages/video/detail/reply/widgets/reply_save.dart +++ b/lib/pages/video/detail/reply/widgets/reply_save.dart @@ -76,64 +76,72 @@ class _ReplySaveState extends State { @override Widget build(BuildContext context) { - return Container( - width: Get.width, - height: Get.height, - margin: EdgeInsets.fromLTRB( - 0, - MediaQuery.of(context).padding.top + 4, - 0, - MediaQuery.of(context).padding.bottom + 4, - ), - color: Colors.transparent, - child: Column( - children: [ - Expanded( - child: Container( - clipBehavior: Clip.antiAlias, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - ), - child: Center( - child: SingleChildScrollView( - child: RepaintBoundary( - key: _boundaryKey, - child: IntrinsicHeight( - child: Stack( - children: [ - ReplyItem( - replyItem: widget.replyItem, - showReplyRow: false, + return SafeArea( + top: false, + bottom: false, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 4.0, sigmaY: 4.0), + child: Container( + width: Get.width, + height: Get.height, + padding: EdgeInsets.fromLTRB( + 0, + MediaQuery.of(context).padding.top + 4, + 0, + MediaQuery.of(context).padding.bottom + 4, + ), + color: Colors.black54, + child: Column( + children: [ + Expanded( + child: Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + ), + child: Center( + child: SingleChildScrollView( + child: RepaintBoundary( + key: _boundaryKey, + child: IntrinsicHeight( + child: Stack( + children: [ + ReplyItem( + replyItem: widget.replyItem, + showReplyRow: false, + replySave: true, + ), + Positioned.fill( + child: Column( + children: _createColumnWidgets(), + ), + ), + ], ), - Positioned.fill( - child: Column( - children: _createColumnWidgets(), - ), - ), - ], + ), ), ), ), ), ), - ), - ), - const SizedBox(height: 20), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - FilledButton( - onPressed: () => Get.back(), - child: const Text('取消'), - ), - const SizedBox(width: 40), - FilledButton( - onPressed: _generatePicWidget, - child: const Text('保存'), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FilledButton( + onPressed: () => Get.back(), + child: const Text('取消'), + ), + const SizedBox(width: 40), + FilledButton( + onPressed: _generatePicWidget, + child: const Text('保存'), + ), + ], ), ], ), - ], + ), ), ); } From ec30235421b13dfba26b3b7a7685d1402eff0709 Mon Sep 17 00:00:00 2001 From: guozhigq Date: Sun, 11 Aug 2024 23:16:33 +0800 Subject: [PATCH 04/13] =?UTF-8?q?opt:=20=E4=B8=80=E9=94=AE=E4=B8=89?= =?UTF-8?q?=E8=BF=9E=E5=8A=A8=E7=94=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/video/detail/introduction/view.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/pages/video/detail/introduction/view.dart b/lib/pages/video/detail/introduction/view.dart index 1e8d97f1..760976ae 100644 --- a/lib/pages/video/detail/introduction/view.dart +++ b/lib/pages/video/detail/introduction/view.dart @@ -613,6 +613,9 @@ class _VideoInfoState extends State with TickerProviderStateMixin { } _controller.reverse(); }, + onTapCancel: () { + _controller.reverse(); + }, borderRadius: StyleString.mdRadius, child: SizedBox( width: (Get.size.width - 24) / 5, From 1012b5d00907e05616833a4f71f53300913d31ed Mon Sep 17 00:00:00 2001 From: guozhigq Date: Sun, 18 Aug 2024 23:30:51 +0800 Subject: [PATCH 05/13] =?UTF-8?q?feat:=20=E7=9B=B4=E6=92=AD=E5=BC=B9?= =?UTF-8?q?=E5=B9=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/http/api.dart | 4 + lib/http/init.dart | 3 + lib/http/live.dart | 19 ++ lib/models/live/message.dart | 101 +++++++++++ lib/pages/live_room/controller.dart | 78 ++++++++ lib/pages/live_room/view.dart | 269 ++++++++++++++++++++++++++-- lib/plugin/pl_socket/index.dart | 107 +++++++++++ lib/utils/binary_writer.dart | 117 ++++++++++++ lib/utils/live.dart | 196 ++++++++++++++++++++ pubspec.lock | 14 +- pubspec.yaml | 2 + 11 files changed, 890 insertions(+), 20 deletions(-) create mode 100644 lib/models/live/message.dart create mode 100644 lib/plugin/pl_socket/index.dart create mode 100644 lib/utils/binary_writer.dart create mode 100644 lib/utils/live.dart diff --git a/lib/http/api.dart b/lib/http/api.dart index 31e5a38b..08a20382 100644 --- a/lib/http/api.dart +++ b/lib/http/api.dart @@ -554,4 +554,8 @@ class Api { /// 系统通知 static const String messageSystemAPi = '${HttpString.messageBaseUrl}/x/sys-msg/query_unified_notify'; + + /// 直播间弹幕信息 + static const String getDanmuInfo = + '${HttpString.liveBaseUrl}/xlive/web-room/v1/index/getDanmuInfo'; } diff --git a/lib/http/init.dart b/lib/http/init.dart index cb9d6f39..faa57dd5 100644 --- a/lib/http/init.dart +++ b/lib/http/init.dart @@ -29,6 +29,7 @@ class Request { late String systemProxyPort; static final RegExp spmPrefixExp = RegExp(r''); + static late String buvid; /// 设置cookie static setCookie() async { @@ -70,6 +71,8 @@ class Request { final String cookieString = cookie .map((Cookie cookie) => '${cookie.name}=${cookie.value}') .join('; '); + + buvid = cookie.firstWhere((e) => e.name == 'buvid3').value; dio.options.headers['cookie'] = cookieString; } diff --git a/lib/http/live.dart b/lib/http/live.dart index e624120e..a405fd58 100644 --- a/lib/http/live.dart +++ b/lib/http/live.dart @@ -65,4 +65,23 @@ class LiveHttp { }; } } + + // 获取弹幕信息 + static Future liveDanmakuInfo({roomId}) async { + var res = await Request().get(Api.getDanmuInfo, data: { + 'id': roomId, + }); + if (res.data['code'] == 0) { + return { + 'status': true, + 'data': res.data['data'], + }; + } else { + return { + 'status': false, + 'data': [], + 'msg': res.data['message'], + }; + } + } } diff --git a/lib/models/live/message.dart b/lib/models/live/message.dart new file mode 100644 index 00000000..cd0f4b75 --- /dev/null +++ b/lib/models/live/message.dart @@ -0,0 +1,101 @@ +class LiveMessageModel { + // 消息类型 + final LiveMessageType type; + + // 用户名 + final String userName; + + // 信息 + final String? message; + + // 数据 + final dynamic data; + + final String? face; + final int? uid; + final Map? emots; + + // 颜色 + final LiveMessageColor color; + + LiveMessageModel({ + required this.type, + required this.userName, + required this.message, + required this.color, + this.data, + this.face, + this.uid, + this.emots, + }); +} + +class LiveSuperChatMessage { + final String backgroundBottomColor; + final String backgroundColor; + final DateTime endTime; + final String face; + final String message; + final String price; + final DateTime startTime; + final String userName; + + LiveSuperChatMessage({ + required this.backgroundBottomColor, + required this.backgroundColor, + required this.endTime, + required this.face, + required this.message, + required this.price, + required this.startTime, + required this.userName, + }); +} + +enum LiveMessageType { + // 普通留言 + chat, + // 醒目留言 + superChat, + // + online, + // 加入 + join, + // 关注 + follow, +} + +class LiveMessageColor { + final int r, g, b; + LiveMessageColor(this.r, this.g, this.b); + static LiveMessageColor get white => LiveMessageColor(255, 255, 255); + static LiveMessageColor numberToColor(int intColor) { + var obj = intColor.toRadixString(16); + + LiveMessageColor color = LiveMessageColor.white; + if (obj.length == 4) { + obj = "00$obj"; + } + if (obj.length == 6) { + var R = int.parse(obj.substring(0, 2), radix: 16); + var G = int.parse(obj.substring(2, 4), radix: 16); + var B = int.parse(obj.substring(4, 6), radix: 16); + + color = LiveMessageColor(R, G, B); + } + if (obj.length == 8) { + var R = int.parse(obj.substring(2, 4), radix: 16); + var G = int.parse(obj.substring(4, 6), radix: 16); + var B = int.parse(obj.substring(6, 8), radix: 16); + //var A = int.parse(obj.substring(0, 2), radix: 16); + color = LiveMessageColor(R, G, B); + } + + return color; + } + + @override + String toString() { + return "#${r.toRadixString(16).padLeft(2, '0')}${g.toRadixString(16).padLeft(2, '0')}${b.toRadixString(16).padLeft(2, '0')}"; + } +} diff --git a/lib/pages/live_room/controller.dart b/lib/pages/live_room/controller.dart index 4e67fa2c..fa95ce63 100644 --- a/lib/pages/live_room/controller.dart +++ b/lib/pages/live_room/controller.dart @@ -1,10 +1,16 @@ +import 'dart:convert'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; +import 'package:hive/hive.dart'; import 'package:pilipala/http/constants.dart'; +import 'package:pilipala/http/init.dart'; import 'package:pilipala/http/live.dart'; +import 'package:pilipala/models/live/message.dart'; import 'package:pilipala/models/live/quality.dart'; import 'package:pilipala/models/live/room_info.dart'; import 'package:pilipala/plugin/pl_player/index.dart'; +import 'package:pilipala/plugin/pl_socket/index.dart'; +import 'package:pilipala/utils/live.dart'; import '../../models/live/room_info_h5.dart'; import '../../utils/storage.dart'; import '../../utils/video_utils.dart'; @@ -24,6 +30,13 @@ class LiveRoomController extends GetxController { int? tempCurrentQn; late List> acceptQnList; RxString currentQnDesc = ''.obs; + Box userInfoCache = GStrorage.userInfo; + int userId = 0; + PlSocket? plSocket; + List danmuHostList = []; + String token = ''; + // 弹幕消息列表 + RxList messageList = [].obs; @override void onInit() { @@ -43,6 +56,11 @@ class LiveRoomController extends GetxController { } // CDN优化 enableCDN = setting.get(SettingBoxKey.enableCDN, defaultValue: true); + final userInfo = userInfoCache.get('userInfoCache'); + if (userInfo != null && userInfo.mid != null) { + userId = userInfo.mid; + } + liveDanmakuInfo().then((value) => initSocket()); } playerInit(source) async { @@ -127,4 +145,64 @@ class LiveRoomController extends GetxController { .description; await queryLiveInfo(); } + + Future liveDanmakuInfo() async { + var res = await LiveHttp.liveDanmakuInfo(roomId: roomId); + if (res['status']) { + danmuHostList = (res["data"]["host_list"] as List) + .map((e) => '${e["host"]}:${e['wss_port']}') + .toList(); + token = res["data"]["token"]; + return res; + } + } + + // 建立socket + void initSocket() async { + final wsUrl = danmuHostList.isNotEmpty + ? danmuHostList.first + : "broadcastlv.chat.bilibili.com"; + plSocket = PlSocket( + url: 'wss://$wsUrl/sub', + heartTime: 30, + onReadyCb: () { + joinRoom(); + }, + onMessageCb: (message) { + final List? liveMsg = + LiveUtils.decodeMessage(message); + if (liveMsg != null) { + messageList.addAll(liveMsg + .where((msg) => msg.type == LiveMessageType.chat) + .toList()); + } + }, + onErrorCb: (e) { + print('error: $e'); + }, + ); + await plSocket?.connect(); + } + + void joinRoom() async { + var joinData = LiveUtils.encodeData( + json.encode({ + "uid": userId, + "roomid": roomId, + "protover": 3, + "buvid": Request.buvid, + "platform": "web", + "type": 2, + "key": token, + }), + 7, + ); + plSocket?.sendMessage(joinData); + } + + @override + void onClose() { + plSocket?.onClose(); + super.onClose(); + } } diff --git a/lib/pages/live_room/view.dart b/lib/pages/live_room/view.dart index 37981b1d..d9b316e9 100644 --- a/lib/pages/live_room/view.dart +++ b/lib/pages/live_room/view.dart @@ -1,9 +1,12 @@ import 'dart:io'; import 'package:floating/floating.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:get/get.dart'; import 'package:pilipala/common/widgets/network_img_layer.dart'; +import 'package:pilipala/models/live/message.dart'; import 'package:pilipala/plugin/pl_player/index.dart'; import 'controller.dart'; @@ -16,7 +19,8 @@ class LiveRoomPage extends StatefulWidget { State createState() => _LiveRoomPageState(); } -class _LiveRoomPageState extends State { +class _LiveRoomPageState extends State + with TickerProviderStateMixin { final LiveRoomController _liveRoomController = Get.put(LiveRoomController()); PlPlayerController? plPlayerController; late Future? _futureBuilder; @@ -25,6 +29,9 @@ class _LiveRoomPageState extends State { bool isShowCover = true; bool isPlay = true; Floating? floating; + final ScrollController _scrollController = ScrollController(); + late AnimationController fabAnimationCtr; + bool _shouldAutoScroll = true; @override void initState() { @@ -34,6 +41,13 @@ class _LiveRoomPageState extends State { } videoSourceInit(); _futureBuilderFuture = _liveRoomController.queryLiveInfo(); + // 监听滚动事件 + _scrollController.addListener(_onScroll); + fabAnimationCtr = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 300), + value: 0.0, + ); } Future videoSourceInit() async { @@ -41,12 +55,52 @@ class _LiveRoomPageState extends State { plPlayerController = _liveRoomController.plPlayerController; } + void _onScroll() { + // 反向时,展示按钮 + if (_scrollController.position.userScrollDirection == + ScrollDirection.forward) { + _shouldAutoScroll = false; + fabAnimationCtr.forward(); + } else { + _shouldAutoScroll = true; + fabAnimationCtr.reverse(); + } + } + + // 监听messageList的变化,自动滚动到底部 + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _liveRoomController.messageList.listen((_) { + if (_shouldAutoScroll) { + _scrollToBottom(); + } + }); + } + + void _scrollToBottom() { + if (_scrollController.hasClients) { + _scrollController + .animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ) + .then((value) { + _shouldAutoScroll = true; + // fabAnimationCtr.forward(); + }); + } + } + @override void dispose() { plPlayerController!.dispose(); if (floating != null) { floating!.dispose(); } + _scrollController.dispose(); + fabAnimationCtr.dispose(); super.dispose(); } @@ -80,20 +134,6 @@ class _LiveRoomPageState extends State { backgroundColor: Colors.black, body: Stack( children: [ - Positioned( - left: 0, - right: 0, - bottom: 0, - child: Opacity( - opacity: 0.8, - child: Image.asset( - 'assets/images/live/default_bg.webp', - fit: BoxFit.cover, - // width: Get.width, - // height: Get.height, - ), - ), - ), Obx( () => Positioned( left: 0, @@ -106,7 +146,7 @@ class _LiveRoomPageState extends State { .roomInfoH5.value.roomInfo?.appBackground != null ? Opacity( - opacity: 0.8, + opacity: 0.6, child: NetworkImgLayer( width: Get.width, height: Get.height, @@ -116,7 +156,15 @@ class _LiveRoomPageState extends State { '', ), ) - : const SizedBox(), + : Opacity( + opacity: 0.6, + child: Image.asset( + 'assets/images/live/default_bg.webp', + fit: BoxFit.cover, + // width: Get.width, + // height: Get.height, + ), + ), ), ), Column( @@ -198,8 +246,45 @@ class _LiveRoomPageState extends State { child: videoPlayerPanel, ), ), + const SizedBox(height: 20), + // 显示消息的列表 + buildMessageListUI( + context, + _liveRoomController, + _scrollController, + ), + // 底部安全距离 + SizedBox( + height: MediaQuery.of(context).padding.bottom + 20, + ) ], ), + // 定位 快速滑动到底部 + Positioned( + right: 20, + bottom: MediaQuery.of(context).padding.bottom + 20, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 2), + end: const Offset(0, 0), + ).animate(CurvedAnimation( + parent: fabAnimationCtr, + curve: Curves.easeInOut, + )), + child: ElevatedButton.icon( + onPressed: () { + _scrollToBottom(); + }, + icon: const Icon(Icons.keyboard_arrow_down), // 图标 + label: const Text('新消息'), // 文字 + style: ElevatedButton.styleFrom( + // primary: Colors.blue, // 按钮背景颜色 + // onPrimary: Colors.white, // 按钮文字颜色 + padding: const EdgeInsets.fromLTRB(14, 12, 20, 12), // 按钮内边距 + ), + ), + ), + ), ], ), ); @@ -214,3 +299,153 @@ class _LiveRoomPageState extends State { } } } + +Widget buildMessageListUI( + BuildContext context, + LiveRoomController liveRoomController, + ScrollController scrollController, +) { + return Expanded( + child: Obx( + () => MediaQuery.removePadding( + context: context, + removeTop: true, + removeBottom: true, + child: ShaderMask( + shaderCallback: (Rect bounds) { + return const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black, + Colors.black, + ], + stops: [0.0, 0.1, 1.0], + ).createShader(bounds); + }, + blendMode: BlendMode.dstIn, + child: ListView.builder( + controller: scrollController, + itemCount: liveRoomController.messageList.length, + itemBuilder: (context, index) { + final LiveMessageModel liveMsgItem = + liveRoomController.messageList[index]; + return Padding( + padding: EdgeInsets.only( + top: index == 0 ? 40.0 : 4.0, + bottom: 4.0, + left: 20.0, + right: 20.0, + ), + child: Text.rich( + TextSpan( + style: const TextStyle(color: Colors.white), + children: [ + TextSpan( + text: '${liveMsgItem.userName}: ', + style: TextStyle( + color: Colors.white.withOpacity(0.6), + ), + recognizer: TapGestureRecognizer() + ..onTap = () { + // 处理点击事件 + print('Text clicked'); + }, + ), + TextSpan( + children: [ + ...buildMessageTextSpan(context, liveMsgItem) + ], + // text: liveMsgItem.message, + ), + ], + ), + ), + ); + }, + ), + ), + ), + ), + ); +} + +List buildMessageTextSpan( + BuildContext context, + LiveMessageModel liveMsgItem, +) { + final List inlineSpanList = []; + + // 是否包含表情包 + if (liveMsgItem.emots == null) { + // 没有表情包的消息 + inlineSpanList.add( + TextSpan( + text: liveMsgItem.message ?? '', + style: const TextStyle( + shadows: [ + Shadow( + offset: Offset(2.0, 2.0), + blurRadius: 3.0, + color: Colors.black, + ), + Shadow( + offset: Offset(-1.0, -1.0), + blurRadius: 3.0, + color: Colors.black, + ), + ], + ), + ), + ); + } else { + // 有表情包的消息 使用正则匹配 表情包用图片渲染 + final List emotsKeys = liveMsgItem.emots!.keys.toList(); + final RegExp pattern = RegExp(emotsKeys.map(RegExp.escape).join('|')); + + liveMsgItem.message?.splitMapJoin( + pattern, + onMatch: (Match match) { + final emoteItem = liveMsgItem.emots![match.group(0)]; + if (emoteItem != null) { + inlineSpanList.add( + WidgetSpan( + child: NetworkImgLayer( + width: emoteItem['width'].toDouble(), + height: emoteItem['height'].toDouble(), + type: 'emote', + src: emoteItem['url'], + ), + ), + ); + } + return ''; + }, + onNonMatch: (String nonMatch) { + inlineSpanList.add( + TextSpan( + text: nonMatch, + style: const TextStyle( + shadows: [ + Shadow( + offset: Offset(2.0, 2.0), + blurRadius: 3.0, + color: Colors.black, + ), + Shadow( + offset: Offset(-1.0, -1.0), + blurRadius: 3.0, + color: Colors.black, + ), + ], + ), + ), + ); + return nonMatch; + }, + ); + } + + return inlineSpanList; +} diff --git a/lib/plugin/pl_socket/index.dart b/lib/plugin/pl_socket/index.dart new file mode 100644 index 00000000..1ad6af94 --- /dev/null +++ b/lib/plugin/pl_socket/index.dart @@ -0,0 +1,107 @@ +import 'dart:async'; + +import 'package:pilipala/utils/live.dart'; +import 'package:web_socket_channel/io.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +enum SocketStatus { + connected, + failed, + closed, +} + +class PlSocket { + SocketStatus status = SocketStatus.closed; + // 链接 + final String url; + // 心跳时间 + final int heartTime; + // 监听初始化完成 + final Function? onReadyCb; + // 监听关闭 + final Function? onCloseCb; + // 监听异常 + final Function? onErrorCb; + // 监听消息 + final Function? onMessageCb; + // 请求头 + final Map? headers; + + PlSocket({ + required this.url, + required this.heartTime, + this.onReadyCb, + this.onCloseCb, + this.onErrorCb, + this.onMessageCb, + this.headers, + }); + + WebSocketChannel? channel; + StreamSubscription? channelStreamSub; + + // 建立连接 + Future connect() async { + // 连接之前关闭上次连接 + onClose(); + try { + channel = IOWebSocketChannel.connect( + url, + connectTimeout: const Duration(seconds: 15), + headers: null, + ); + await channel?.ready; + onReady(); + } catch (err) { + connect(); + onError(err); + } + } + + // 初始化完成 + void onReady() { + status = SocketStatus.connected; + onReadyCb?.call(); + channelStreamSub = channel?.stream.listen((message) { + onMessageCb?.call(message); + }, onDone: () { + // 流被关闭 + print('结束了'); + }, onError: (err) { + onError(err); + }); + // 每30s发送心跳 + Timer.periodic(Duration(seconds: heartTime), (timer) { + if (status == SocketStatus.connected) { + sendMessage(LiveUtils.encodeData( + "", + 2, + )); + } else { + timer.cancel(); + } + }); + } + + // 连接关闭 + void onClose() { + status = SocketStatus.closed; + onCloseCb?.call(); + channelStreamSub?.cancel(); + channel?.sink.close(); + } + + // 连接异常 + void onError(err) { + onErrorCb?.call(err); + } + + // 接收消息 + void onMessage() {} + + void sendMessage(dynamic message) { + if (status == SocketStatus.connected) { + channel?.sink.add(message); + } + } +} diff --git a/lib/utils/binary_writer.dart b/lib/utils/binary_writer.dart new file mode 100644 index 00000000..929bc573 --- /dev/null +++ b/lib/utils/binary_writer.dart @@ -0,0 +1,117 @@ +import 'dart:typed_data'; + +class BinaryWriter { + List buffer; + int position = 0; + BinaryWriter(this.buffer); + int get length => buffer.length; + + void writeBytes(List list) { + buffer.addAll(list); + position += list.length; + } + + void writeInt(int value, int len, {Endian endian = Endian.big}) { + var bytes = _createByteData(len); + switch (len) { + case 1: + bytes.setUint8(0, value.toUnsigned(8)); + break; + case 2: + bytes.setInt16(0, value, endian); + break; + case 4: + bytes.setInt32(0, value, endian); + break; + case 8: + bytes.setInt64(0, value, endian); + break; + default: + throw ArgumentError('Invalid length for writeInt: $len'); + } + _addBytesToBuffer(bytes, len); + } + + void writeDouble(double value, int len, {Endian endian = Endian.big}) { + var bytes = _createByteData(len); + switch (len) { + case 4: + bytes.setFloat32(0, value, endian); + break; + case 8: + bytes.setFloat64(0, value, endian); + break; + default: + throw ArgumentError('Invalid length for writeDouble: $len'); + } + _addBytesToBuffer(bytes, len); + } + + ByteData _createByteData(int len) { + var b = Uint8List(len).buffer; + return ByteData.view(b); + } + + void _addBytesToBuffer(ByteData bytes, int len) { + buffer.addAll(bytes.buffer.asUint8List()); + position += len; + } +} + +class BinaryReader { + Uint8List buffer; + int position = 0; + BinaryReader(this.buffer); + int get length => buffer.length; + + int read() { + return buffer[position++]; + } + + int readInt(int len, {Endian endian = Endian.big}) { + var bytes = _getBytes(len); + var data = ByteData.view(bytes.buffer); + switch (len) { + case 1: + return data.getUint8(0); + case 2: + return data.getInt16(0, endian); + case 4: + return data.getInt32(0, endian); + case 8: + return data.getInt64(0, endian); + default: + throw ArgumentError('Invalid length for readInt: $len'); + } + } + + int readByte({Endian endian = Endian.big}) => readInt(1, endian: endian); + int readShort({Endian endian = Endian.big}) => readInt(2, endian: endian); + int readInt32({Endian endian = Endian.big}) => readInt(4, endian: endian); + int readLong({Endian endian = Endian.big}) => readInt(8, endian: endian); + + Uint8List readBytes(int len) { + var bytes = _getBytes(len); + return bytes; + } + + double readFloat(int len, {Endian endian = Endian.big}) { + var bytes = _getBytes(len); + var data = ByteData.view(bytes.buffer); + switch (len) { + case 4: + return data.getFloat32(0, endian); + case 8: + return data.getFloat64(0, endian); + default: + throw ArgumentError('Invalid length for readFloat: $len'); + } + } + + Uint8List _getBytes(int len) { + var bytes = + Uint8List.fromList(buffer.getRange(position, position + len).toList()); + position += len; + return bytes; + } +} diff --git a/lib/utils/live.dart b/lib/utils/live.dart new file mode 100644 index 00000000..dd56616e --- /dev/null +++ b/lib/utils/live.dart @@ -0,0 +1,196 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; +import 'package:brotli/brotli.dart'; +import 'package:pilipala/models/live/message.dart'; +import 'package:pilipala/utils/binary_writer.dart'; + +class LiveUtils { + static List encodeData(String msg, int action) { + var data = utf8.encode(msg); + //头部长度固定16 + var length = data.length + 16; + var buffer = Uint8List(length); + + var writer = BinaryWriter([]); + + //数据包长度 + writer.writeInt(buffer.length, 4); + //数据包头部长度,固定16 + writer.writeInt(16, 2); + + //协议版本,0=JSON,1=Int32,2=Buffer + writer.writeInt(0, 2); + + //操作类型 + writer.writeInt(action, 4); + + //数据包头部长度,固定1 + + writer.writeInt(1, 4); + + writer.writeBytes(data); + + return writer.buffer; + } + + static List? decodeMessage(List data) { + try { + //操作类型。3=心跳回应,内容为房间人气值;5=通知,弹幕、广播等全部信息;8=进房回应,空 + int operation = readInt(data, 8, 4); + //内容 + var body = data.skip(16).toList(); + if (operation == 3) { + var online = readInt(body, 0, 4); + final LiveMessageModel liveMsg = LiveMessageModel( + type: LiveMessageType.online, + userName: '', + message: '', + color: LiveMessageColor.white, + data: online, + ); + return [liveMsg]; + } else if (operation == 5) { + //协议版本。0为JSON,可以直接解析;1为房间人气值,Body为4位Int32;2为压缩过Buffer,需要解压再处理 + int protocolVersion = readInt(data, 6, 2); + if (protocolVersion == 2) { + body = zlib.decode(body); + } else if (protocolVersion == 3) { + body = brotli.decode(body); + } + + var text = utf8.decode(body, allowMalformed: true); + + var group = + text.split(RegExp(r"[\x00-\x1f]+", unicode: true, multiLine: true)); + List messages = []; + for (var item + in group.where((x) => x.length > 2 && x.startsWith('{'))) { + if (parseMessage(item) is LiveMessageModel) { + messages.add(parseMessage(item)!); + } + } + return messages; + } + } catch (e) { + print(e); + } + return null; + } + + static LiveMessageModel? parseMessage(String jsonMessage) { + try { + var obj = json.decode(jsonMessage); + var cmd = obj["cmd"].toString(); + if (cmd.contains("DANMU_MSG")) { + if (obj["info"] != null && obj["info"].length != 0) { + var message = obj["info"][1].toString(); + var color = asT(obj["info"][0][3]) ?? 0; + if (obj["info"][2] != null && obj["info"][2].length != 0) { + var extra = obj["info"][0][15]['extra']; + var user = obj["info"][0][15]['user']['base']; + Map extraMap = jsonDecode(extra); + final int userId = obj["info"][2][0]; + final LiveMessageModel liveMsg = LiveMessageModel( + type: LiveMessageType.chat, + userName: user['name'], + message: message, + color: color == 0 + ? LiveMessageColor.white + : LiveMessageColor.numberToColor(color), + face: user['face'], + uid: userId, + emots: extraMap['emots'], + ); + return liveMsg; + } + } + } else if (cmd == "SUPER_CHAT_MESSAGE") { + if (obj["data"] == null) { + return null; + } + final data = obj["data"]; + final userInfo = data["user_info"]; + final String backgroundBottomColor = + data["background_bottom_color"].toString(); + final String backgroundColor = data["background_color"].toString(); + final DateTime endTime = + DateTime.fromMillisecondsSinceEpoch(data["end_time"] * 1000); + final String face = "${userInfo["face"]}@200w.jpg"; + final String message = data["message"].toString(); + final String price = data["price"]; + final DateTime startTime = + DateTime.fromMillisecondsSinceEpoch(data["start_time"] * 1000); + final String userName = userInfo["uname"].toString(); + + final LiveMessageModel liveMsg = LiveMessageModel( + type: LiveMessageType.superChat, + userName: "SUPER_CHAT_MESSAGE", + message: "SUPER_CHAT_MESSAGE", + color: LiveMessageColor.white, + data: { + "backgroundBottomColor": backgroundBottomColor, + "backgroundColor": backgroundColor, + "endTime": endTime, + "face": face, + "message": message, + "price": price, + "startTime": startTime, + "userName": userName, + }, + ); + return liveMsg; + } else if (cmd == 'INTERACT_WORD') { + if (obj["data"] == null) { + return null; + } + final data = obj["data"]; + final String userName = data['uname']; + final int msgType = data['msg_type']; + final LiveMessageModel liveMsg = LiveMessageModel( + type: msgType == 1 ? LiveMessageType.join : LiveMessageType.follow, + userName: userName, + message: msgType == 1 ? '进入直播间' : '关注了主播', + color: LiveMessageColor.white, + ); + return liveMsg; + } + } catch (e) { + print(e); + } + return null; + } + + static T? asT(dynamic value) { + if (value is T) { + return value; + } + return null; + } + + static int readInt(List buffer, int start, int len) { + var data = _getByteData(buffer, start, len); + return _readIntFromByteData(data, len); + } + + static ByteData _getByteData(List buffer, int start, int len) { + var bytes = + Uint8List.fromList(buffer.getRange(start, start + len).toList()); + return ByteData.view(bytes.buffer); + } + + static int _readIntFromByteData(ByteData data, int len) { + switch (len) { + case 1: + return data.getUint8(0); + case 2: + return data.getInt16(0, Endian.big); + case 4: + return data.getInt32(0, Endian.big); + case 8: + return data.getInt64(0, Endian.big); + default: + throw ArgumentError('Invalid length: $len'); + } + } +} diff --git a/pubspec.lock b/pubspec.lock index a46127f9..2dfaa961 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -113,6 +113,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.1.1" + brotli: + dependency: "direct main" + description: + name: brotli + sha256: "7f891558ed779aab2bed874f0a36b8123f9ff3f19cf6efbee89e18ed294945ae" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.6.0" build: dependency: transitive description: @@ -1656,13 +1664,13 @@ packages: source: hosted version: "0.5.1" web_socket_channel: - dependency: transitive + dependency: "direct main" description: name: web_socket_channel - sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b + sha256: "58c6666b342a38816b2e7e50ed0f1e261959630becd4c879c4f26bfa14aa5a42" url: "https://pub.flutter-io.cn" source: hosted - version: "2.4.0" + version: "2.4.5" webview_cookie_manager: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index b5d45714..23c1426c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -146,6 +146,8 @@ dependencies: lottie: ^3.1.2 # 二维码 qr_flutter: ^4.1.0 + web_socket_channel: ^2.4.5 + brotli: ^0.6.0 dev_dependencies: flutter_test: From 869e49bec2369e85a3ade02768888a7620b90924 Mon Sep 17 00:00:00 2001 From: guozhigq Date: Wed, 21 Aug 2024 00:09:26 +0800 Subject: [PATCH 06/13] =?UTF-8?q?feat:=20=E7=9B=B4=E6=92=AD=E5=BC=B9?= =?UTF-8?q?=E5=B9=952?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/danmaku/controller.dart | 3 ++- lib/pages/danmaku/view.dart | 32 +++++++++++++++++------------ lib/pages/live_room/controller.dart | 28 ++++++++++++++++++++++--- lib/pages/live_room/view.dart | 15 ++++++++++++-- 4 files changed, 59 insertions(+), 19 deletions(-) diff --git a/lib/pages/danmaku/controller.dart b/lib/pages/danmaku/controller.dart index 52c423d7..ed259b96 100644 --- a/lib/pages/danmaku/controller.dart +++ b/lib/pages/danmaku/controller.dart @@ -2,8 +2,9 @@ import 'package:pilipala/http/danmaku.dart'; import 'package:pilipala/models/danmaku/dm.pb.dart'; class PlDanmakuController { - PlDanmakuController(this.cid); + PlDanmakuController(this.cid, this.type); final int cid; + final String type; Map> dmSegMap = {}; // 已请求的段落标记 List requestedSeg = []; diff --git a/lib/pages/danmaku/view.dart b/lib/pages/danmaku/view.dart index 109f0206..e669b881 100644 --- a/lib/pages/danmaku/view.dart +++ b/lib/pages/danmaku/view.dart @@ -12,11 +12,15 @@ import 'package:pilipala/utils/storage.dart'; class PlDanmaku extends StatefulWidget { final int cid; final PlPlayerController playerController; + final String type; + final Function(DanmakuController)? createdController; const PlDanmaku({ super.key, required this.cid, required this.playerController, + this.type = 'video', + this.createdController, }); @override @@ -43,9 +47,9 @@ class _PlDanmakuState extends State { super.initState(); enableShowDanmaku = setting.get(SettingBoxKey.enableShowDanmaku, defaultValue: false); - _plDanmakuController = PlDanmakuController(widget.cid); - if (mounted) { - playerController = widget.playerController; + _plDanmakuController = PlDanmakuController(widget.cid, widget.type); + playerController = widget.playerController; + if (mounted && widget.type == 'video') { if (enableShowDanmaku || playerController.isOpenDanmu.value) { _plDanmakuController.initiate( playerController.duration.value.inMilliseconds, @@ -55,13 +59,15 @@ class _PlDanmakuState extends State { ..addStatusLister(playerListener) ..addPositionListener(videoPositionListen); } - playerController.isOpenDanmu.listen((p0) { - if (p0 && !_plDanmakuController.initiated) { - _plDanmakuController.initiate( - playerController.duration.value.inMilliseconds, - playerController.position.value.inMilliseconds); - } - }); + if (widget.type == 'video') { + playerController.isOpenDanmu.listen((p0) { + if (p0 && !_plDanmakuController.initiated) { + _plDanmakuController.initiate( + playerController.duration.value.inMilliseconds, + playerController.position.value.inMilliseconds); + } + }); + } blockTypes = playerController.blockTypes; showArea = playerController.showArea; opacityVal = playerController.opacityVal; @@ -123,11 +129,12 @@ class _PlDanmakuState extends State { // double initDuration = box.maxWidth / 12; return Obx( () => AnimatedOpacity( - opacity: playerController.isOpenDanmu.value ? 1 : 0, + opacity: playerController.isOpenDanmu.value ? 1 : 1, duration: const Duration(milliseconds: 100), child: DanmakuView( createdController: (DanmakuController e) async { playerController.danmakuController = _controller = e; + widget.createdController?.call(e); }, option: DanmakuOption( fontSize: 15 * fontSizeVal, @@ -136,8 +143,7 @@ class _PlDanmakuState extends State { hideTop: blockTypes.contains(5), hideScroll: blockTypes.contains(2), hideBottom: blockTypes.contains(4), - duration: - danmakuDurationVal / playerController.playbackSpeed, + duration: danmakuDurationVal / playerController.playbackSpeed, strokeWidth: strokeWidth, // initDuration / // (danmakuSpeedVal * widget.playerController.playbackSpeed), diff --git a/lib/pages/live_room/controller.dart b/lib/pages/live_room/controller.dart index fa95ce63..6a3525e3 100644 --- a/lib/pages/live_room/controller.dart +++ b/lib/pages/live_room/controller.dart @@ -1,7 +1,9 @@ import 'dart:convert'; +import 'dart:ui'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; import 'package:hive/hive.dart'; +import 'package:ns_danmaku/ns_danmaku.dart'; import 'package:pilipala/http/constants.dart'; import 'package:pilipala/http/init.dart'; import 'package:pilipala/http/live.dart'; @@ -37,6 +39,7 @@ class LiveRoomController extends GetxController { String token = ''; // 弹幕消息列表 RxList messageList = [].obs; + DanmakuController? danmakuController; @override void onInit() { @@ -172,9 +175,28 @@ class LiveRoomController extends GetxController { final List? liveMsg = LiveUtils.decodeMessage(message); if (liveMsg != null) { - messageList.addAll(liveMsg - .where((msg) => msg.type == LiveMessageType.chat) - .toList()); + // 过滤出聊天消息 + var chatMessages = + liveMsg.where((msg) => msg.type == LiveMessageType.chat).toList(); + + // 添加到 messageList + messageList.addAll(chatMessages); + + // 将 chatMessages 转换为 danmakuItems 列表 + List danmakuItems = chatMessages.map((e) { + return DanmakuItem( + e.message ?? '', + color: Color.fromARGB( + 255, + e.color.r, + e.color.g, + e.color.b, + ), + ); + }).toList(); + + // 添加到 danmakuController + danmakuController?.addItems(danmakuItems); } }, onErrorCb: (e) { diff --git a/lib/pages/live_room/view.dart b/lib/pages/live_room/view.dart index d9b316e9..d1fc6e07 100644 --- a/lib/pages/live_room/view.dart +++ b/lib/pages/live_room/view.dart @@ -7,6 +7,7 @@ import 'package:flutter/rendering.dart'; import 'package:get/get.dart'; import 'package:pilipala/common/widgets/network_img_layer.dart'; import 'package:pilipala/models/live/message.dart'; +import 'package:pilipala/pages/danmaku/index.dart'; import 'package:pilipala/plugin/pl_player/index.dart'; import 'controller.dart'; @@ -22,7 +23,7 @@ class LiveRoomPage extends StatefulWidget { class _LiveRoomPageState extends State with TickerProviderStateMixin { final LiveRoomController _liveRoomController = Get.put(LiveRoomController()); - PlPlayerController? plPlayerController; + late PlPlayerController plPlayerController; late Future? _futureBuilder; late Future? _futureBuilderFuture; @@ -32,6 +33,7 @@ class _LiveRoomPageState extends State final ScrollController _scrollController = ScrollController(); late AnimationController fabAnimationCtr; bool _shouldAutoScroll = true; + final int roomId = int.parse(Get.parameters['roomid']!); @override void initState() { @@ -110,8 +112,9 @@ class _LiveRoomPageState extends State future: _futureBuilderFuture, builder: (BuildContext context, AsyncSnapshot snapshot) { if (snapshot.hasData && snapshot.data['status']) { + plPlayerController = _liveRoomController.plPlayerController; return PLVideoPlayer( - controller: plPlayerController!, + controller: plPlayerController, bottomControl: BottomControl( controller: plPlayerController, liveRoomCtr: _liveRoomController, @@ -122,6 +125,14 @@ class _LiveRoomPageState extends State }); }, ), + danmuWidget: PlDanmaku( + cid: roomId, + playerController: plPlayerController, + type: 'live', + createdController: (e) { + _liveRoomController.danmakuController = e; + }, + ), ); } else { return const SizedBox(); From a074a360f2f1370a554d9248aa72909b841b268c Mon Sep 17 00:00:00 2001 From: guozhigq Date: Wed, 21 Aug 2024 00:14:45 +0800 Subject: [PATCH 07/13] =?UTF-8?q?feat:=20=E7=9B=B4=E6=92=AD=E5=BC=B9?= =?UTF-8?q?=E5=B9=952?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/danmaku/controller.dart | 3 ++- lib/pages/danmaku/view.dart | 30 +++++++++++++++++------------ lib/pages/live_room/controller.dart | 28 ++++++++++++++++++++++++--- lib/pages/live_room/view.dart | 15 +++++++++++++-- 4 files changed, 58 insertions(+), 18 deletions(-) diff --git a/lib/pages/danmaku/controller.dart b/lib/pages/danmaku/controller.dart index 52c423d7..ed259b96 100644 --- a/lib/pages/danmaku/controller.dart +++ b/lib/pages/danmaku/controller.dart @@ -2,8 +2,9 @@ import 'package:pilipala/http/danmaku.dart'; import 'package:pilipala/models/danmaku/dm.pb.dart'; class PlDanmakuController { - PlDanmakuController(this.cid); + PlDanmakuController(this.cid, this.type); final int cid; + final String type; Map> dmSegMap = {}; // 已请求的段落标记 List requestedSeg = []; diff --git a/lib/pages/danmaku/view.dart b/lib/pages/danmaku/view.dart index 109f0206..3cf1ed8a 100644 --- a/lib/pages/danmaku/view.dart +++ b/lib/pages/danmaku/view.dart @@ -12,11 +12,15 @@ import 'package:pilipala/utils/storage.dart'; class PlDanmaku extends StatefulWidget { final int cid; final PlPlayerController playerController; + final String type; + final Function(DanmakuController)? createdController; const PlDanmaku({ super.key, required this.cid, required this.playerController, + this.type = 'video', + this.createdController, }); @override @@ -43,9 +47,9 @@ class _PlDanmakuState extends State { super.initState(); enableShowDanmaku = setting.get(SettingBoxKey.enableShowDanmaku, defaultValue: false); - _plDanmakuController = PlDanmakuController(widget.cid); - if (mounted) { - playerController = widget.playerController; + _plDanmakuController = PlDanmakuController(widget.cid, widget.type); + playerController = widget.playerController; + if (mounted && widget.type == 'video') { if (enableShowDanmaku || playerController.isOpenDanmu.value) { _plDanmakuController.initiate( playerController.duration.value.inMilliseconds, @@ -55,13 +59,15 @@ class _PlDanmakuState extends State { ..addStatusLister(playerListener) ..addPositionListener(videoPositionListen); } - playerController.isOpenDanmu.listen((p0) { - if (p0 && !_plDanmakuController.initiated) { - _plDanmakuController.initiate( - playerController.duration.value.inMilliseconds, - playerController.position.value.inMilliseconds); - } - }); + if (widget.type == 'video') { + playerController.isOpenDanmu.listen((p0) { + if (p0 && !_plDanmakuController.initiated) { + _plDanmakuController.initiate( + playerController.duration.value.inMilliseconds, + playerController.position.value.inMilliseconds); + } + }); + } blockTypes = playerController.blockTypes; showArea = playerController.showArea; opacityVal = playerController.opacityVal; @@ -128,6 +134,7 @@ class _PlDanmakuState extends State { child: DanmakuView( createdController: (DanmakuController e) async { playerController.danmakuController = _controller = e; + widget.createdController?.call(e); }, option: DanmakuOption( fontSize: 15 * fontSizeVal, @@ -136,8 +143,7 @@ class _PlDanmakuState extends State { hideTop: blockTypes.contains(5), hideScroll: blockTypes.contains(2), hideBottom: blockTypes.contains(4), - duration: - danmakuDurationVal / playerController.playbackSpeed, + duration: danmakuDurationVal / playerController.playbackSpeed, strokeWidth: strokeWidth, // initDuration / // (danmakuSpeedVal * widget.playerController.playbackSpeed), diff --git a/lib/pages/live_room/controller.dart b/lib/pages/live_room/controller.dart index fa95ce63..6a3525e3 100644 --- a/lib/pages/live_room/controller.dart +++ b/lib/pages/live_room/controller.dart @@ -1,7 +1,9 @@ import 'dart:convert'; +import 'dart:ui'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; import 'package:hive/hive.dart'; +import 'package:ns_danmaku/ns_danmaku.dart'; import 'package:pilipala/http/constants.dart'; import 'package:pilipala/http/init.dart'; import 'package:pilipala/http/live.dart'; @@ -37,6 +39,7 @@ class LiveRoomController extends GetxController { String token = ''; // 弹幕消息列表 RxList messageList = [].obs; + DanmakuController? danmakuController; @override void onInit() { @@ -172,9 +175,28 @@ class LiveRoomController extends GetxController { final List? liveMsg = LiveUtils.decodeMessage(message); if (liveMsg != null) { - messageList.addAll(liveMsg - .where((msg) => msg.type == LiveMessageType.chat) - .toList()); + // 过滤出聊天消息 + var chatMessages = + liveMsg.where((msg) => msg.type == LiveMessageType.chat).toList(); + + // 添加到 messageList + messageList.addAll(chatMessages); + + // 将 chatMessages 转换为 danmakuItems 列表 + List danmakuItems = chatMessages.map((e) { + return DanmakuItem( + e.message ?? '', + color: Color.fromARGB( + 255, + e.color.r, + e.color.g, + e.color.b, + ), + ); + }).toList(); + + // 添加到 danmakuController + danmakuController?.addItems(danmakuItems); } }, onErrorCb: (e) { diff --git a/lib/pages/live_room/view.dart b/lib/pages/live_room/view.dart index d9b316e9..d1fc6e07 100644 --- a/lib/pages/live_room/view.dart +++ b/lib/pages/live_room/view.dart @@ -7,6 +7,7 @@ import 'package:flutter/rendering.dart'; import 'package:get/get.dart'; import 'package:pilipala/common/widgets/network_img_layer.dart'; import 'package:pilipala/models/live/message.dart'; +import 'package:pilipala/pages/danmaku/index.dart'; import 'package:pilipala/plugin/pl_player/index.dart'; import 'controller.dart'; @@ -22,7 +23,7 @@ class LiveRoomPage extends StatefulWidget { class _LiveRoomPageState extends State with TickerProviderStateMixin { final LiveRoomController _liveRoomController = Get.put(LiveRoomController()); - PlPlayerController? plPlayerController; + late PlPlayerController plPlayerController; late Future? _futureBuilder; late Future? _futureBuilderFuture; @@ -32,6 +33,7 @@ class _LiveRoomPageState extends State final ScrollController _scrollController = ScrollController(); late AnimationController fabAnimationCtr; bool _shouldAutoScroll = true; + final int roomId = int.parse(Get.parameters['roomid']!); @override void initState() { @@ -110,8 +112,9 @@ class _LiveRoomPageState extends State future: _futureBuilderFuture, builder: (BuildContext context, AsyncSnapshot snapshot) { if (snapshot.hasData && snapshot.data['status']) { + plPlayerController = _liveRoomController.plPlayerController; return PLVideoPlayer( - controller: plPlayerController!, + controller: plPlayerController, bottomControl: BottomControl( controller: plPlayerController, liveRoomCtr: _liveRoomController, @@ -122,6 +125,14 @@ class _LiveRoomPageState extends State }); }, ), + danmuWidget: PlDanmaku( + cid: roomId, + playerController: plPlayerController, + type: 'live', + createdController: (e) { + _liveRoomController.danmakuController = e; + }, + ), ); } else { return const SizedBox(); From 44910b35d97fd29c7ad30fe60f497a50872f662c Mon Sep 17 00:00:00 2001 From: guozhigq Date: Thu, 22 Aug 2024 20:24:21 +0800 Subject: [PATCH 08/13] =?UTF-8?q?feat:=20=E7=9B=B4=E6=92=AD=E5=BC=B9?= =?UTF-8?q?=E5=B9=95=E5=8F=91=E9=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/http/api.dart | 3 + lib/http/live.dart | 33 ++++++ lib/pages/live_room/controller.dart | 21 +++- lib/pages/live_room/view.dart | 163 +++++++++++++++------------- 4 files changed, 145 insertions(+), 75 deletions(-) diff --git a/lib/http/api.dart b/lib/http/api.dart index 08a20382..65b74d2d 100644 --- a/lib/http/api.dart +++ b/lib/http/api.dart @@ -558,4 +558,7 @@ class Api { /// 直播间弹幕信息 static const String getDanmuInfo = '${HttpString.liveBaseUrl}/xlive/web-room/v1/index/getDanmuInfo'; + + /// 直播间发送弹幕 + static const String sendLiveMsg = '${HttpString.liveBaseUrl}/msg/send'; } diff --git a/lib/http/live.dart b/lib/http/live.dart index a405fd58..f6fc4ea4 100644 --- a/lib/http/live.dart +++ b/lib/http/live.dart @@ -84,4 +84,37 @@ class LiveHttp { }; } } + + // 发送弹幕 + static Future sendDanmaku({roomId, msg}) async { + var res = await Request().post(Api.sendLiveMsg, queryParameters: { + 'bubble': 0, + 'msg': msg, + 'color': 16777215, // 颜色 + 'mode': 1, // 模式 + 'room_type': 0, + 'jumpfrom': 71001, // 直播间来源 + 'reply_mid': 0, + 'reply_attr': 0, + 'replay_dmid': '', + 'statistics': {"appId": 100, "platform": 5}, + 'fontsize': 25, // 字体大小 + 'rnd': DateTime.now().millisecondsSinceEpoch ~/ 1000, // 时间戳 + 'roomid': roomId, + 'csrf': await Request.getCsrf(), + 'csrf_token': await Request.getCsrf(), + }); + if (res.data['code'] == 0) { + return { + 'status': true, + 'data': res.data['data'], + }; + } else { + return { + 'status': false, + 'data': [], + 'msg': res.data['message'], + }; + } + } } diff --git a/lib/pages/live_room/controller.dart b/lib/pages/live_room/controller.dart index 6a3525e3..e8db54fb 100644 --- a/lib/pages/live_room/controller.dart +++ b/lib/pages/live_room/controller.dart @@ -1,5 +1,5 @@ import 'dart:convert'; -import 'dart:ui'; +import 'package:flutter/material.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; import 'package:hive/hive.dart'; @@ -40,6 +40,8 @@ class LiveRoomController extends GetxController { // 弹幕消息列表 RxList messageList = [].obs; DanmakuController? danmakuController; + // 输入控制器 + TextEditingController inputController = TextEditingController(); @override void onInit() { @@ -222,6 +224,23 @@ class LiveRoomController extends GetxController { plSocket?.sendMessage(joinData); } + // 发送弹幕 + void sendMsg() async { + final msg = inputController.text; + if (msg.isEmpty) { + return; + } + final res = await LiveHttp.sendDanmaku( + roomId: roomId, + msg: msg, + ); + if (res['status']) { + inputController.clear(); + } else { + SmartDialog.showToast(res['msg']); + } + } + @override void onClose() { plSocket?.onClose(); diff --git a/lib/pages/live_room/view.dart b/lib/pages/live_room/view.dart index d1fc6e07..92ea8fd8 100644 --- a/lib/pages/live_room/view.dart +++ b/lib/pages/live_room/view.dart @@ -97,7 +97,7 @@ class _LiveRoomPageState extends State @override void dispose() { - plPlayerController!.dispose(); + plPlayerController.dispose(); if (floating != null) { floating!.dispose(); } @@ -238,10 +238,10 @@ class _LiveRoomPageState extends State ), ), PopScope( - canPop: plPlayerController?.isFullScreen.value != true, + canPop: plPlayerController.isFullScreen.value != true, onPopInvoked: (bool didPop) { - if (plPlayerController?.isFullScreen.value == true) { - plPlayerController!.triggerFullScreen(status: false); + if (plPlayerController.isFullScreen.value == true) { + plPlayerController.triggerFullScreen(status: false); } if (MediaQuery.of(context).orientation == Orientation.landscape) { @@ -257,17 +257,53 @@ class _LiveRoomPageState extends State child: videoPlayerPanel, ), ), - const SizedBox(height: 20), // 显示消息的列表 buildMessageListUI( context, _liveRoomController, _scrollController, ), - // 底部安全距离 - SizedBox( - height: MediaQuery.of(context).padding.bottom + 20, - ) + // 弹幕输入框 + Container( + padding: EdgeInsets.only( + left: 14, + right: 14, + top: 4, + bottom: MediaQuery.of(context).padding.bottom + 20), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.5), + border: Border( + top: BorderSide( + color: Colors.white.withOpacity(0.1), + ), + ), + ), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _liveRoomController.inputController, + style: + const TextStyle(color: Colors.white, fontSize: 13), + decoration: InputDecoration( + hintText: '发送弹幕', + hintStyle: TextStyle( + color: Colors.white.withOpacity(0.6), + ), + border: InputBorder.none, + ), + ), + ), + IconButton( + onPressed: () => _liveRoomController.sendMsg(), + icon: const Icon( + Icons.send, + color: Colors.white, + ), + ), + ], + ), + ), ], ), // 定位 快速滑动到底部 @@ -324,15 +360,15 @@ Widget buildMessageListUI( removeBottom: true, child: ShaderMask( shaderCallback: (Rect bounds) { - return const LinearGradient( + return LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Colors.transparent, - Colors.black, + Colors.black.withOpacity(0.5), Colors.black, ], - stops: [0.0, 0.1, 1.0], + stops: const [0.01, 0.05, 0.2], ).createShader(bounds); }, blendMode: BlendMode.dstIn, @@ -342,35 +378,46 @@ Widget buildMessageListUI( itemBuilder: (context, index) { final LiveMessageModel liveMsgItem = liveRoomController.messageList[index]; - return Padding( - padding: EdgeInsets.only( - top: index == 0 ? 40.0 : 4.0, - bottom: 4.0, - left: 20.0, - right: 20.0, - ), - child: Text.rich( - TextSpan( - style: const TextStyle(color: Colors.white), - children: [ - TextSpan( - text: '${liveMsgItem.userName}: ', - style: TextStyle( - color: Colors.white.withOpacity(0.6), + return Align( + alignment: Alignment.centerLeft, + child: Container( + decoration: BoxDecoration( + color: Colors.grey.withOpacity(0.1), + borderRadius: const BorderRadius.all(Radius.circular(20)), + ), + margin: EdgeInsets.only( + top: index == 0 ? 20.0 : 0.0, + bottom: 6.0, + left: 14.0, + right: 14.0, + ), + padding: const EdgeInsets.symmetric( + vertical: 3.0, + horizontal: 10.0, + ), + child: Text.rich( + TextSpan( + style: const TextStyle(color: Colors.white), + children: [ + TextSpan( + text: '${liveMsgItem.userName}: ', + style: TextStyle( + color: Colors.white.withOpacity(0.6), + ), + recognizer: TapGestureRecognizer() + ..onTap = () { + // 处理点击事件 + print('Text clicked'); + }, ), - recognizer: TapGestureRecognizer() - ..onTap = () { - // 处理点击事件 - print('Text clicked'); - }, - ), - TextSpan( - children: [ - ...buildMessageTextSpan(context, liveMsgItem) - ], - // text: liveMsgItem.message, - ), - ], + TextSpan( + children: [ + ...buildMessageTextSpan(context, liveMsgItem) + ], + // text: liveMsgItem.message, + ), + ], + ), ), ), ); @@ -392,23 +439,7 @@ List buildMessageTextSpan( if (liveMsgItem.emots == null) { // 没有表情包的消息 inlineSpanList.add( - TextSpan( - text: liveMsgItem.message ?? '', - style: const TextStyle( - shadows: [ - Shadow( - offset: Offset(2.0, 2.0), - blurRadius: 3.0, - color: Colors.black, - ), - Shadow( - offset: Offset(-1.0, -1.0), - blurRadius: 3.0, - color: Colors.black, - ), - ], - ), - ), + TextSpan(text: liveMsgItem.message ?? ''), ); } else { // 有表情包的消息 使用正则匹配 表情包用图片渲染 @@ -435,23 +466,7 @@ List buildMessageTextSpan( }, onNonMatch: (String nonMatch) { inlineSpanList.add( - TextSpan( - text: nonMatch, - style: const TextStyle( - shadows: [ - Shadow( - offset: Offset(2.0, 2.0), - blurRadius: 3.0, - color: Colors.black, - ), - Shadow( - offset: Offset(-1.0, -1.0), - blurRadius: 3.0, - color: Colors.black, - ), - ], - ), - ), + TextSpan(text: nonMatch), ); return nonMatch; }, From 844db213a56d480934aecf8b786ab5028d8e2cbc Mon Sep 17 00:00:00 2001 From: guozhigq Date: Thu, 22 Aug 2024 23:57:11 +0800 Subject: [PATCH 09/13] =?UTF-8?q?mod:=20=E8=BE=93=E5=85=A5=E6=A1=86?= =?UTF-8?q?=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/live_room/controller.dart | 27 ++++++++++++++- lib/pages/live_room/view.dart | 52 +++++++++++++++++++++++++++-- 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/lib/pages/live_room/controller.dart b/lib/pages/live_room/controller.dart index e8db54fb..a56a5459 100644 --- a/lib/pages/live_room/controller.dart +++ b/lib/pages/live_room/controller.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; @@ -42,6 +43,8 @@ class LiveRoomController extends GetxController { DanmakuController? danmakuController; // 输入控制器 TextEditingController inputController = TextEditingController(); + // 加入直播间提示 + RxMap joinRoomTip = {'userName': '', 'message': ''}.obs; @override void onInit() { @@ -176,7 +179,29 @@ class LiveRoomController extends GetxController { onMessageCb: (message) { final List? liveMsg = LiveUtils.decodeMessage(message); - if (liveMsg != null) { + if (liveMsg != null && liveMsg.isNotEmpty) { + if (liveMsg.first.type == LiveMessageType.online) { + print('当前直播间人气:${liveMsg.first.data}'); + } else if (liveMsg.first.type == LiveMessageType.join || + liveMsg.first.type == LiveMessageType.follow) { + // 每隔一秒依次liveMsg中的每一项赋给activeUserName + int index = 0; + Timer.periodic(const Duration(seconds: 2), (timer) { + if (index < liveMsg.length) { + if (liveMsg[index].type == LiveMessageType.join || + liveMsg[index].type == LiveMessageType.follow) { + joinRoomTip.value = { + 'userName': liveMsg[index].userName, + 'message': liveMsg[index].message!, + }; + } + index++; + } else { + timer.cancel(); + } + }); + return; + } // 过滤出聊天消息 var chatMessages = liveMsg.where((msg) => msg.type == LiveMessageType.chat).toList(); diff --git a/lib/pages/live_room/view.dart b/lib/pages/live_room/view.dart index 92ea8fd8..3f563ad6 100644 --- a/lib/pages/live_room/view.dart +++ b/lib/pages/live_room/view.dart @@ -179,6 +179,7 @@ class _LiveRoomPageState extends State ), ), Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ AppBar( centerTitle: false, @@ -263,6 +264,49 @@ class _LiveRoomPageState extends State _liveRoomController, _scrollController, ), + // Container( + // padding: const EdgeInsets.only( + // left: 14, right: 14, top: 4, bottom: 4), + // margin: const EdgeInsets.only( + // bottom: 6, + // left: 14, + // ), + // decoration: BoxDecoration( + // color: Colors.grey.withOpacity(0.1), + // borderRadius: const BorderRadius.all(Radius.circular(20)), + // ), + // child: Obx( + // () => AnimatedSwitcher( + // duration: const Duration(milliseconds: 300), + // transitionBuilder: + // (Widget child, Animation animation) { + // return FadeTransition(opacity: animation, child: child); + // }, + // child: Text.rich( + // key: + // ValueKey(_liveRoomController.joinRoomTip['userName']), + // TextSpan( + // style: const TextStyle(color: Colors.white), + // children: [ + // TextSpan( + // text: + // '${_liveRoomController.joinRoomTip['userName']} ', + // style: TextStyle( + // color: Colors.white.withOpacity(0.6), + // ), + // ), + // TextSpan( + // text: + // '${_liveRoomController.joinRoomTip['message']}', + // style: const TextStyle(color: Colors.white), + // ), + // ], + // ), + // ), + // ), + // ), + // ), + const SizedBox(height: 10), // 弹幕输入框 Container( padding: EdgeInsets.only( @@ -271,7 +315,8 @@ class _LiveRoomPageState extends State top: 4, bottom: MediaQuery.of(context).padding.bottom + 20), decoration: BoxDecoration( - color: Colors.black.withOpacity(0.5), + color: Colors.grey.withOpacity(0.1), + borderRadius: const BorderRadius.all(Radius.circular(20)), border: Border( top: BorderSide( color: Colors.white.withOpacity(0.1), @@ -280,6 +325,7 @@ class _LiveRoomPageState extends State ), child: Row( children: [ + const SizedBox(width: 4), Expanded( child: TextField( controller: _liveRoomController.inputController, @@ -309,10 +355,10 @@ class _LiveRoomPageState extends State // 定位 快速滑动到底部 Positioned( right: 20, - bottom: MediaQuery.of(context).padding.bottom + 20, + bottom: MediaQuery.of(context).padding.bottom + 80, child: SlideTransition( position: Tween( - begin: const Offset(0, 2), + begin: const Offset(0, 4), end: const Offset(0, 0), ).animate(CurvedAnimation( parent: fabAnimationCtr, From fc22834e4a66e4d3fd803c3ec0455f13203bfcde Mon Sep 17 00:00:00 2001 From: guozhigq Date: Fri, 23 Aug 2024 23:47:15 +0800 Subject: [PATCH 10/13] =?UTF-8?q?mod:=20=E7=9B=B4=E6=92=AD=E5=BC=B9?= =?UTF-8?q?=E5=B9=95=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/danmaku/view.dart | 2 +- lib/pages/live_room/controller.dart | 12 ++- lib/pages/live_room/view.dart | 146 ++++++++++++++++++---------- 3 files changed, 105 insertions(+), 55 deletions(-) diff --git a/lib/pages/danmaku/view.dart b/lib/pages/danmaku/view.dart index e669b881..3cf1ed8a 100644 --- a/lib/pages/danmaku/view.dart +++ b/lib/pages/danmaku/view.dart @@ -129,7 +129,7 @@ class _PlDanmakuState extends State { // double initDuration = box.maxWidth / 12; return Obx( () => AnimatedOpacity( - opacity: playerController.isOpenDanmu.value ? 1 : 1, + opacity: playerController.isOpenDanmu.value ? 1 : 0, duration: const Duration(milliseconds: 100), child: DanmakuView( createdController: (DanmakuController e) async { diff --git a/lib/pages/live_room/controller.dart b/lib/pages/live_room/controller.dart index a56a5459..99025dce 100644 --- a/lib/pages/live_room/controller.dart +++ b/lib/pages/live_room/controller.dart @@ -45,6 +45,8 @@ class LiveRoomController extends GetxController { TextEditingController inputController = TextEditingController(); // 加入直播间提示 RxMap joinRoomTip = {'userName': '', 'message': ''}.obs; + // 直播间弹幕开关 默认打开 + RxBool danmakuSwitch = true.obs; @override void onInit() { @@ -69,6 +71,9 @@ class LiveRoomController extends GetxController { userId = userInfo.mid; } liveDanmakuInfo().then((value) => initSocket()); + danmakuSwitch.listen((p0) { + plPlayerController.isOpenDanmu.value = p0; + }); } playerInit(source) async { @@ -87,6 +92,7 @@ class LiveRoomController extends GetxController { enableHA: true, autoplay: true, ); + plPlayerController.isOpenDanmu.value = danmakuSwitch.value; } Future queryLiveInfo() async { @@ -185,6 +191,7 @@ class LiveRoomController extends GetxController { } else if (liveMsg.first.type == LiveMessageType.join || liveMsg.first.type == LiveMessageType.follow) { // 每隔一秒依次liveMsg中的每一项赋给activeUserName + int index = 0; Timer.periodic(const Duration(seconds: 2), (timer) { if (index < liveMsg.length) { @@ -200,6 +207,7 @@ class LiveRoomController extends GetxController { timer.cancel(); } }); + return; } // 过滤出聊天消息 @@ -223,7 +231,9 @@ class LiveRoomController extends GetxController { }).toList(); // 添加到 danmakuController - danmakuController?.addItems(danmakuItems); + if (danmakuSwitch.value) { + danmakuController?.addItems(danmakuItems); + } } }, onErrorCb: (e) { diff --git a/lib/pages/live_room/view.dart b/lib/pages/live_room/view.dart index 3f563ad6..abbcce13 100644 --- a/lib/pages/live_room/view.dart +++ b/lib/pages/live_room/view.dart @@ -325,7 +325,33 @@ class _LiveRoomPageState extends State ), child: Row( children: [ - const SizedBox(width: 4), + SizedBox( + width: 34, + height: 34, + child: Obx( + () => IconButton( + style: ButtonStyle( + padding: MaterialStateProperty.all(EdgeInsets.zero), + backgroundColor: MaterialStateProperty.resolveWith( + (Set states) { + return Colors.grey.withOpacity(0.1); + }), + ), + onPressed: () { + _liveRoomController.danmakuSwitch.value = + !_liveRoomController.danmakuSwitch.value; + }, + icon: Icon( + _liveRoomController.danmakuSwitch.value + ? Icons.subtitles_outlined + : Icons.subtitles_off_outlined, + size: 19, + color: Colors.white, + ), + ), + ), + ), + const SizedBox(width: 8), Expanded( child: TextField( controller: _liveRoomController.inputController, @@ -340,11 +366,19 @@ class _LiveRoomPageState extends State ), ), ), - IconButton( - onPressed: () => _liveRoomController.sendMsg(), - icon: const Icon( - Icons.send, - color: Colors.white, + SizedBox( + width: 34, + height: 34, + child: IconButton( + style: ButtonStyle( + padding: MaterialStateProperty.all(EdgeInsets.zero), + ), + onPressed: () => _liveRoomController.sendMsg(), + icon: const Icon( + Icons.send, + color: Colors.white, + size: 20, + ), ), ), ], @@ -418,56 +452,62 @@ Widget buildMessageListUI( ).createShader(bounds); }, blendMode: BlendMode.dstIn, - child: ListView.builder( - controller: scrollController, - itemCount: liveRoomController.messageList.length, - itemBuilder: (context, index) { - final LiveMessageModel liveMsgItem = - liveRoomController.messageList[index]; - return Align( - alignment: Alignment.centerLeft, - child: Container( - decoration: BoxDecoration( - color: Colors.grey.withOpacity(0.1), - borderRadius: const BorderRadius.all(Radius.circular(20)), - ), - margin: EdgeInsets.only( - top: index == 0 ? 20.0 : 0.0, - bottom: 6.0, - left: 14.0, - right: 14.0, - ), - padding: const EdgeInsets.symmetric( - vertical: 3.0, - horizontal: 10.0, - ), - child: Text.rich( - TextSpan( - style: const TextStyle(color: Colors.white), - children: [ - TextSpan( - text: '${liveMsgItem.userName}: ', - style: TextStyle( - color: Colors.white.withOpacity(0.6), + child: GestureDetector( + onTap: () { + // 键盘失去焦点 + FocusScope.of(context).requestFocus(FocusNode()); + }, + child: ListView.builder( + controller: scrollController, + itemCount: liveRoomController.messageList.length, + itemBuilder: (context, index) { + final LiveMessageModel liveMsgItem = + liveRoomController.messageList[index]; + return Align( + alignment: Alignment.centerLeft, + child: Container( + decoration: BoxDecoration( + color: Colors.grey.withOpacity(0.1), + borderRadius: const BorderRadius.all(Radius.circular(20)), + ), + margin: EdgeInsets.only( + top: index == 0 ? 20.0 : 0.0, + bottom: 6.0, + left: 14.0, + right: 14.0, + ), + padding: const EdgeInsets.symmetric( + vertical: 3.0, + horizontal: 10.0, + ), + child: Text.rich( + TextSpan( + style: const TextStyle(color: Colors.white), + children: [ + TextSpan( + text: '${liveMsgItem.userName}: ', + style: TextStyle( + color: Colors.white.withOpacity(0.6), + ), + recognizer: TapGestureRecognizer() + ..onTap = () { + // 处理点击事件 + print('Text clicked'); + }, ), - recognizer: TapGestureRecognizer() - ..onTap = () { - // 处理点击事件 - print('Text clicked'); - }, - ), - TextSpan( - children: [ - ...buildMessageTextSpan(context, liveMsgItem) - ], - // text: liveMsgItem.message, - ), - ], + TextSpan( + children: [ + ...buildMessageTextSpan(context, liveMsgItem) + ], + // text: liveMsgItem.message, + ), + ], + ), ), ), - ), - ); - }, + ); + }, + ), ), ), ), From 15e1cb5d4763a3b63b4c44e861b1dd79df7e6c6e Mon Sep 17 00:00:00 2001 From: guozhigq Date: Sat, 24 Aug 2024 00:07:08 +0800 Subject: [PATCH 11/13] =?UTF-8?q?feat:=20=E7=A7=81=E4=BF=A1=E5=8F=AF?= =?UTF-8?q?=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../whisper_detail/widget/chat_item.dart | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/lib/pages/whisper_detail/widget/chat_item.dart b/lib/pages/whisper_detail/widget/chat_item.dart index 77e38073..94347aff 100644 --- a/lib/pages/whisper_detail/widget/chat_item.dart +++ b/lib/pages/whisper_detail/widget/chat_item.dart @@ -84,7 +84,7 @@ class ChatItem extends StatelessWidget { emojiMap[e['text']] = e['url']; } text.splitMapJoin( - RegExp(r"\[.+?\]"), + RegExp(r"\[[^\[\]]+\]"), onMatch: (Match match) { final String emojiKey = match[0]!; if (emojiMap.containsKey(emojiKey)) { @@ -95,6 +95,17 @@ class ChatItem extends StatelessWidget { src: emojiMap[emojiKey]!, ), )); + } else { + children.add( + TextSpan( + text: emojiKey, + style: TextStyle( + color: textColor(context), + letterSpacing: 0.6, + height: 1.5, + ), + ), + ); } return ''; }, @@ -109,13 +120,13 @@ class ChatItem extends StatelessWidget { return ''; }, ); - return RichText( - text: TextSpan( + return SelectableText.rich( + TextSpan( children: children, ), ); } else { - return Text( + return SelectableText( text, style: TextStyle( letterSpacing: 0.6, @@ -133,7 +144,7 @@ class ChatItem extends StatelessWidget { case MsgType.pic_card: return SystemNotice2(item: item); case MsgType.notify_text: - return Text( + return SelectableText( jsonDecode(content['content']) .map((m) => m['text'] as String) .join("\n"), @@ -530,7 +541,7 @@ class SystemNotice extends StatelessWidget { Divider( color: Theme.of(context).colorScheme.primary.withOpacity(0.05), ), - Text( + SelectableText( content['text'], ) ], From b96f35bae2f2f3e1458a0fdb66e1976fddc02f91 Mon Sep 17 00:00:00 2001 From: guozhigq Date: Sat, 24 Aug 2024 00:28:07 +0800 Subject: [PATCH 12/13] =?UTF-8?q?mod:=20=E7=A7=BB=E9=99=A4up=E4=B8=BB?= =?UTF-8?q?=E9=A1=B5=E5=88=86=E5=89=B2=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/member/view.dart | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lib/pages/member/view.dart b/lib/pages/member/view.dart index c721d638..7d2e4bed 100644 --- a/lib/pages/member/view.dart +++ b/lib/pages/member/view.dart @@ -168,7 +168,6 @@ class _MemberPageState extends State const Icon(Icons.arrow_forward_outlined, size: 19), ), ), - const Divider(height: 1, thickness: 0.1), /// 视频 Obx(() => ListTile( @@ -178,7 +177,6 @@ class _MemberPageState extends State trailing: const Icon(Icons.arrow_forward_outlined, size: 19), )), - const Divider(height: 1, thickness: 0.1), /// 他的收藏夹 Obx(() => ListTile( @@ -188,13 +186,11 @@ class _MemberPageState extends State trailing: const Icon(Icons.arrow_forward_outlined, size: 19), )), - const Divider(height: 1, thickness: 0.1), /// 专栏 Obx(() => ListTile( title: Text( '${_memberController.isOwner.value ? '我' : 'Ta'}的专栏'))), - const Divider(height: 1, thickness: 0.1), /// 合集 Obx(() => ListTile( From d85e9446e3d634ad5c1cb3eecec45301a10a73e8 Mon Sep 17 00:00:00 2001 From: guozhigq Date: Mon, 26 Aug 2024 00:12:35 +0800 Subject: [PATCH 13/13] opt: fav follow bottomSheet --- lib/pages/follow/widgets/follow_item.dart | 19 +++++-- .../video/detail/introduction/controller.dart | 19 +++++-- lib/pages/video/detail/introduction/view.dart | 49 ++++++++++--------- .../introduction/widgets/fav_panel.dart | 43 +++++++++------- .../introduction/widgets/group_panel.dart | 16 ++++-- pubspec.lock | 16 ++++++ pubspec.yaml | 1 + 7 files changed, 111 insertions(+), 52 deletions(-) diff --git a/lib/pages/follow/widgets/follow_item.dart b/lib/pages/follow/widgets/follow_item.dart index d21a89bc..3d393277 100644 --- a/lib/pages/follow/widgets/follow_item.dart +++ b/lib/pages/follow/widgets/follow_item.dart @@ -1,3 +1,4 @@ +import 'package:bottom_sheet/bottom_sheet.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:pilipala/common/widgets/network_img_layer.dart'; @@ -47,9 +48,21 @@ class FollowItem extends StatelessWidget { height: 34, child: TextButton( onPressed: () async { - await Get.bottomSheet( - GroupPanel(mid: item.mid!), - isScrollControlled: true, + await showFlexibleBottomSheet( + bottomSheetColor: Colors.transparent, + minHeight: 1, + initHeight: 1, + maxHeight: 1, + context: Get.context!, + builder: (BuildContext context, + ScrollController scrollController, double offset) { + return GroupPanel( + mid: item.mid!, + scrollController: scrollController, + ); + }, + anchors: [1], + isSafeArea: true, ); }, style: TextButton.styleFrom( diff --git a/lib/pages/video/detail/introduction/controller.dart b/lib/pages/video/detail/introduction/controller.dart index 50aac4cd..d564aba4 100644 --- a/lib/pages/video/detail/introduction/controller.dart +++ b/lib/pages/video/detail/introduction/controller.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:bottom_sheet/bottom_sheet.dart'; import 'package:flutter/material.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; @@ -523,9 +524,21 @@ class VideoIntroController extends GetxController { // 设置关注分组 void setFollowGroup() { - Get.bottomSheet( - GroupPanel(mid: videoDetail.value.owner!.mid!), - isScrollControlled: true, + showFlexibleBottomSheet( + bottomSheetColor: Colors.transparent, + minHeight: 0.6, + initHeight: 0.6, + maxHeight: 1, + context: Get.context!, + builder: (BuildContext context, ScrollController scrollController, + double offset) { + return GroupPanel( + mid: videoDetail.value.owner!.mid!, + scrollController: scrollController, + ); + }, + anchors: [0.6, 1], + isSafeArea: true, ); } diff --git a/lib/pages/video/detail/introduction/view.dart b/lib/pages/video/detail/introduction/view.dart index 760976ae..a89b4d28 100644 --- a/lib/pages/video/detail/introduction/view.dart +++ b/lib/pages/video/detail/introduction/view.dart @@ -1,3 +1,6 @@ +import 'dart:ffi'; + +import 'package:bottom_sheet/bottom_sheet.dart'; import 'package:easy_debounce/easy_throttle.dart'; import 'package:expandable/expandable.dart'; import 'package:flutter/services.dart'; @@ -215,37 +218,35 @@ class _VideoInfoState extends State with TickerProviderStateMixin { if (!videoIntroController.hasFav.value) { videoIntroController.actionFavVideo(type: 'default'); } else { - showModalBottomSheet( - context: context, - useRootNavigator: true, - isScrollControlled: true, - builder: (BuildContext context) { - return FavPanel(ctr: videoIntroController); - }, - ); + _showFavPanel(); } } else { - showModalBottomSheet( - context: context, - useRootNavigator: true, - isScrollControlled: true, - builder: (BuildContext context) { - return FavPanel(ctr: videoIntroController); - }, - ); + _showFavPanel(); } } else if (type != 'longPress') { - showModalBottomSheet( - context: context, - useRootNavigator: true, - isScrollControlled: true, - builder: (BuildContext context) { - return FavPanel(ctr: videoIntroController); - }, - ); + _showFavPanel(); } } + void _showFavPanel() { + showFlexibleBottomSheet( + bottomSheetColor: Colors.transparent, + minHeight: 0.6, + initHeight: 0.6, + maxHeight: 1, + context: context, + builder: (BuildContext context, ScrollController scrollController, + double offset) { + return FavPanel( + ctr: videoIntroController, + scrollController: scrollController, + ); + }, + anchors: [0.6, 1], + isSafeArea: true, + ); + } + // 视频介绍 showIntroDetail() { feedBack(); diff --git a/lib/pages/video/detail/introduction/widgets/fav_panel.dart b/lib/pages/video/detail/introduction/widgets/fav_panel.dart index 5ef78967..a4e98df7 100644 --- a/lib/pages/video/detail/introduction/widgets/fav_panel.dart +++ b/lib/pages/video/detail/introduction/widgets/fav_panel.dart @@ -6,8 +6,9 @@ import 'package:pilipala/utils/feed_back.dart'; import 'package:pilipala/utils/storage.dart'; class FavPanel extends StatefulWidget { - const FavPanel({super.key, this.ctr}); + const FavPanel({super.key, this.ctr, this.scrollController}); final dynamic ctr; + final ScrollController? scrollController; @override State createState() => _FavPanelState(); @@ -15,31 +16,39 @@ class FavPanel extends StatefulWidget { class _FavPanelState extends State { final Box localCache = GStrorage.localCache; - late double sheetHeight; late Future _futureBuilderFuture; @override void initState() { super.initState(); - sheetHeight = localCache.get('sheetHeight'); _futureBuilderFuture = widget.ctr!.queryVideoInFolder(); } @override Widget build(BuildContext context) { return Container( - height: sheetHeight, - color: Theme.of(context).colorScheme.surface, + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), child: Column( children: [ AppBar( centerTitle: false, elevation: 0, - leading: IconButton( - onPressed: () => Get.back(), - icon: const Icon(Icons.close_outlined)), - title: - Text('添加到收藏夹', style: Theme.of(context).textTheme.titleMedium), + automaticallyImplyLeading: false, + leadingWidth: 0, + title: Text( + '选择收藏夹', + style: Theme.of(context) + .textTheme + .titleMedium! + .copyWith(fontWeight: FontWeight.bold), + ), ), Expanded( child: Material( @@ -51,22 +60,22 @@ class _FavPanelState extends State { if (data['status']) { return Obx( () => ListView.builder( + controller: widget.scrollController, itemCount: widget.ctr!.favFolderData.value.list!.length, itemBuilder: (context, index) { + final item = + widget.ctr!.favFolderData.value.list![index]; return ListTile( - onTap: () => widget.ctr!.onChoose( - widget.ctr!.favFolderData.value.list![index] - .favState != - 1, - index), + onTap: () => widget.ctr! + .onChoose(item.favState != 1, index), dense: true, leading: const Icon(Icons.folder_outlined), minLeadingWidth: 0, title: Text(widget.ctr!.favFolderData.value .list![index].title!), subtitle: Text( - '${widget.ctr!.favFolderData.value.list![index].mediaCount}个内容', + '${item.mediaCount}个内容 ', ), trailing: Transform.scale( scale: 0.9, @@ -132,7 +141,7 @@ class _FavPanelState extends State { backgroundColor: Theme.of(context).colorScheme.primary, // 设置按钮背景色 ), - child: const Text('完成'), + child: const Text('确认选择'), ), ], ), diff --git a/lib/pages/video/detail/introduction/widgets/group_panel.dart b/lib/pages/video/detail/introduction/widgets/group_panel.dart index dcdaf9c5..4bb0980c 100644 --- a/lib/pages/video/detail/introduction/widgets/group_panel.dart +++ b/lib/pages/video/detail/introduction/widgets/group_panel.dart @@ -10,7 +10,8 @@ import 'package:pilipala/utils/storage.dart'; class GroupPanel extends StatefulWidget { final int? mid; - const GroupPanel({super.key, this.mid}); + final ScrollController scrollController; + const GroupPanel({super.key, this.mid, required this.scrollController}); @override State createState() => _GroupPanelState(); @@ -18,7 +19,6 @@ class GroupPanel extends StatefulWidget { class _GroupPanelState extends State { final Box localCache = GStrorage.localCache; - late double sheetHeight; late Future _futureBuilderFuture; late List tagsList; bool showDefault = true; @@ -26,7 +26,6 @@ class _GroupPanelState extends State { @override void initState() { super.initState(); - sheetHeight = localCache.get('sheetHeight'); _futureBuilderFuture = MemberHttp.followUpTags(); } @@ -56,8 +55,14 @@ class _GroupPanelState extends State { @override Widget build(BuildContext context) { return Container( - height: sheetHeight, - color: Theme.of(context).colorScheme.surface, + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), child: Column( children: [ AppBar( @@ -79,6 +84,7 @@ class _GroupPanelState extends State { if (data['status']) { tagsList = data['data']; return ListView.builder( + controller: widget.scrollController, itemCount: data['data'].length, itemBuilder: (context, index) { return ListTile( diff --git a/pubspec.lock b/pubspec.lock index a46127f9..a42df7bb 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -113,6 +113,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.1.1" + bottom_inset_observer: + dependency: transitive + description: + name: bottom_inset_observer + sha256: cbfb01e0e07cc4922052701786d5e607765a6f54e1844f41061abf8744519a7d + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.0" + bottom_sheet: + dependency: "direct main" + description: + name: bottom_sheet + sha256: efd28f52357d23e1c01eaeb45466b407f1e29318305bd6d10baf814fda18bd7e + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.0.4" build: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b5d45714..fb4cc2c0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -146,6 +146,7 @@ dependencies: lottie: ^3.1.2 # 二维码 qr_flutter: ^4.1.0 + bottom_sheet: ^4.0.4 dev_dependencies: flutter_test: