diff --git a/README.md b/README.md index 8523472bf..2e8bf871d 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ ## feat +- [x] SuperChat - [x] 播放课堂视频 - [x] 发起投票 - [x] 发布动态/评论支持`富文本编辑`/`表情显示`/`@用户` diff --git a/lib/http/api.dart b/lib/http/api.dart index 34360cfdd..73b1b64fe 100644 --- a/lib/http/api.dart +++ b/lib/http/api.dart @@ -962,4 +962,7 @@ class Api { static const String spaceShop = '${HttpString.mallBaseUrl}/community-hub/small_shop/feed/tab/item'; + + static const String superChatMsg = + '${HttpString.liveBaseUrl}/av/v1/SuperChat/getMessageList'; } diff --git a/lib/http/live.dart b/lib/http/live.dart index 484e1aa05..c23556c59 100644 --- a/lib/http/live.dart +++ b/lib/http/live.dart @@ -18,6 +18,7 @@ import 'package:PiliPlus/models_new/live/live_room_info_h5/data.dart'; import 'package:PiliPlus/models_new/live/live_room_play_info/data.dart'; import 'package:PiliPlus/models_new/live/live_search/data.dart'; import 'package:PiliPlus/models_new/live/live_second_list/data.dart'; +import 'package:PiliPlus/models_new/live/live_superchat/data.dart'; import 'package:PiliPlus/utils/accounts.dart'; import 'package:PiliPlus/utils/app_sign.dart'; import 'package:PiliPlus/utils/wbi_sign.dart'; @@ -642,4 +643,24 @@ class LiveHttp { return {'status': false, 'msg': res.data['message']}; } } + + static Future> superChatMsg( + dynamic roomId, + ) async { + var res = await Request().get( + Api.superChatMsg, + queryParameters: { + 'room_id': roomId, + }, + ); + if (res.data['code'] == 0) { + try { + return Success(SuperChatData.fromJson(res.data['data'])); + } catch (e) { + return Error(e.toString()); + } + } else { + return Error(res.data['message']); + } + } } diff --git a/lib/models_new/live/live_superchat/data.dart b/lib/models_new/live/live_superchat/data.dart new file mode 100644 index 000000000..d291c7243 --- /dev/null +++ b/lib/models_new/live/live_superchat/data.dart @@ -0,0 +1,13 @@ +import 'package:PiliPlus/models_new/live/live_superchat/item.dart'; + +class SuperChatData { + List? list; + + SuperChatData({this.list}); + + factory SuperChatData.fromJson(Map json) => SuperChatData( + list: (json['list'] as List?) + ?.map((e) => SuperChatItem.fromJson(e as Map)) + .toList(), + ); +} diff --git a/lib/models_new/live/live_superchat/item.dart b/lib/models_new/live/live_superchat/item.dart new file mode 100644 index 000000000..e046cbd7d --- /dev/null +++ b/lib/models_new/live/live_superchat/item.dart @@ -0,0 +1,42 @@ +import 'package:PiliPlus/models_new/live/live_superchat/user_info.dart'; +import 'package:PiliPlus/utils/utils.dart'; + +class SuperChatItem { + dynamic id; + dynamic uid; + int? price; + String backgroundColor; + String backgroundBottomColor; + String backgroundPriceColor; + String messageFontColor; + int endTime; + String message; + UserInfo userInfo; + bool expired = false; + + SuperChatItem({ + this.id, + required this.uid, + this.price, + required this.backgroundColor, + required this.backgroundBottomColor, + required this.backgroundPriceColor, + required this.messageFontColor, + required this.endTime, + required this.message, + required this.userInfo, + }); + + factory SuperChatItem.fromJson(Map json) => SuperChatItem( + id: json['id'] ?? Utils.generateRandomString(8), + uid: json['uid'], + price: json['price'] as int?, + backgroundColor: json['background_color'] ?? '#EDF5FF', + backgroundBottomColor: json['background_bottom_color'] ?? '#2A60B2', + backgroundPriceColor: json['background_price_color'] ?? '#7497CD', + messageFontColor: json['message_font_color'] ?? '#FFFFFF', + endTime: json['end_time'], + message: json['message'], + userInfo: UserInfo.fromJson(json['user_info'] as Map), + ); +} diff --git a/lib/models_new/live/live_superchat/user_info.dart b/lib/models_new/live/live_superchat/user_info.dart new file mode 100644 index 000000000..19089f6e4 --- /dev/null +++ b/lib/models_new/live/live_superchat/user_info.dart @@ -0,0 +1,17 @@ +class UserInfo { + String face; + String uname; + String nameColor; + + UserInfo({ + required this.face, + required this.uname, + required this.nameColor, + }); + + factory UserInfo.fromJson(Map json) => UserInfo( + face: json['face'], + uname: json['uname'], + nameColor: json['name_color'] ?? '#666666', + ); +} diff --git a/lib/pages/dynamics_mention/view.dart b/lib/pages/dynamics_mention/view.dart index 7352a71af..9232e5c37 100644 --- a/lib/pages/dynamics_mention/view.dart +++ b/lib/pages/dynamics_mention/view.dart @@ -10,7 +10,7 @@ import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/models_new/dynamic/dyn_mention/group.dart'; import 'package:PiliPlus/pages/dynamics_mention/controller.dart'; import 'package:PiliPlus/pages/dynamics_mention/widgets/item.dart'; -import 'package:PiliPlus/pages/search/controller.dart' show SearchState; +import 'package:PiliPlus/pages/search/controller.dart' show DebounceStreamState; import 'package:PiliPlus/utils/context_ext.dart'; import 'package:PiliPlus/utils/extension.dart'; import 'package:flutter/material.dart'; @@ -58,7 +58,8 @@ class DynMentionPanel extends StatefulWidget { State createState() => _DynMentionPanelState(); } -class _DynMentionPanelState extends SearchState { +class _DynMentionPanelState + extends DebounceStreamState { final _controller = Get.put(DynMentionController()); @override Duration get duration => const Duration(milliseconds: 300); diff --git a/lib/pages/dynamics_select_topic/view.dart b/lib/pages/dynamics_select_topic/view.dart index 928db1c16..435e32209 100644 --- a/lib/pages/dynamics_select_topic/view.dart +++ b/lib/pages/dynamics_select_topic/view.dart @@ -8,7 +8,7 @@ import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/models_new/dynamic/dyn_topic_top/topic_item.dart'; import 'package:PiliPlus/pages/dynamics_select_topic/controller.dart'; import 'package:PiliPlus/pages/dynamics_select_topic/widgets/item.dart'; -import 'package:PiliPlus/pages/search/controller.dart' show SearchState; +import 'package:PiliPlus/pages/search/controller.dart' show DebounceStreamState; import 'package:PiliPlus/utils/context_ext.dart'; import 'package:PiliPlus/utils/extension.dart'; import 'package:flutter/material.dart'; @@ -56,7 +56,8 @@ class SelectTopicPanel extends StatefulWidget { State createState() => _SelectTopicPanelState(); } -class _SelectTopicPanelState extends SearchState { +class _SelectTopicPanelState + extends DebounceStreamState { final _controller = Get.put(SelectTopicController()); @override Duration get duration => const Duration(milliseconds: 300); diff --git a/lib/pages/live_room/controller.dart b/lib/pages/live_room/controller.dart index 48b51f785..cd94053eb 100644 --- a/lib/pages/live_room/controller.dart +++ b/lib/pages/live_room/controller.dart @@ -12,6 +12,7 @@ import 'package:PiliPlus/models_new/live/live_dm_info/data.dart'; import 'package:PiliPlus/models_new/live/live_room_info_h5/data.dart'; import 'package:PiliPlus/models_new/live/live_room_play_info/codec.dart'; import 'package:PiliPlus/models_new/live/live_room_play_info/data.dart'; +import 'package:PiliPlus/models_new/live/live_superchat/item.dart'; import 'package:PiliPlus/plugin/pl_player/controller.dart'; import 'package:PiliPlus/plugin/pl_player/models/data_source.dart'; import 'package:PiliPlus/services/service_locator.dart'; @@ -64,10 +65,14 @@ class LiveRoomController extends GetxController { LiveDmInfoData? dmInfo; List? savedDanmaku; RxList messages = [].obs; + late final Rx fsSC = Rx(null); + late final RxList superChatMsg = [].obs; RxBool disableAutoScroll = false.obs; LiveMessageStream? _msgStream; late final ScrollController scrollController = ScrollController() ..addListener(listener); + late final RxInt pageIndex = 0.obs; + PageController? pageController; int? currentQn; RxString currentQnDesc = ''.obs; @@ -81,6 +86,8 @@ class LiveRoomController extends GetxController { bool? isPlaying; late bool isFullScreen = false; + final showSuperChat = Pref.showSuperChat; + @override void onInit() { super.onInit(); @@ -92,6 +99,9 @@ class LiveRoomController extends GetxController { if (isLogin && !Pref.historyPause) { VideoHttp.roomEntryAction(roomId: roomId); } + if (showSuperChat) { + pageController = PageController(); + } } Future? playerInit({bool autoplay = true}) { @@ -205,27 +215,50 @@ class LiveRoomController extends GetxController { } } + void jumpToBottom() { + if (scrollController.hasClients) { + scrollController.jumpTo(scrollController.position.maxScrollExtent); + } + } + void closeLiveMsg() { _msgStream?.close(); _msgStream = null; } + Future prefetch() async { + final res = await LiveHttp.liveRoomDanmaPrefetch(roomId: roomId); + if (res['status']) { + if (res['data'] case List list) { + try { + messages.addAll( + list.cast>().map(DanmakuMsg.fromPrefetch), + ); + WidgetsBinding.instance.addPostFrameCallback(scrollToBottom); + } catch (e) { + if (kDebugMode) debugPrint(e.toString()); + } + } + } + } + + Future getSuperChatMsg() async { + final res = await LiveHttp.superChatMsg(roomId); + if (res.dataOrNull?.list case List list) { + superChatMsg.addAll(list); + } + } + + void clearSC() { + superChatMsg.removeWhere((e) => e.expired); + } + void startLiveMsg() { if (messages.isEmpty) { - LiveHttp.liveRoomDanmaPrefetch(roomId: roomId).then((v) { - if (v['status']) { - if (v['data'] case List list) { - try { - messages.addAll( - list.cast>().map(DanmakuMsg.fromPrefetch), - ); - WidgetsBinding.instance.addPostFrameCallback(scrollToBottom); - } catch (e) { - if (kDebugMode) debugPrint(e.toString()); - } - } - } - }); + prefetch(); + if (showSuperChat) { + getSuperChatMsg(); + } } if (_msgStream != null) { return; @@ -257,14 +290,20 @@ class LiveRoomController extends GetxController { @override void onClose() { + closeLiveMsg(); cancelLikeTimer(); cancelLiveTimer(); savedDanmaku?.clear(); savedDanmaku = null; - closeLiveMsg(); + messages.clear(); + if (showSuperChat) { + superChatMsg.clear(); + fsSC.value = null; + } scrollController ..removeListener(listener) ..dispose(); + pageController?.dispose(); super.onClose(); } @@ -294,49 +333,63 @@ class LiveRoomController extends GetxController { ) ..addEventListener((obj) { try { - if (obj['cmd'] == 'DANMU_MSG') { - // logger.i(' 原始弹幕消息 ======> ${jsonEncode(obj)}'); - final info = obj['info']; - final first = info[0]; - final content = first[15]; - final extra = jsonDecode(content['extra']); - final user = content['user']; - final uid = user['uid']; - messages.add( - DanmakuMsg() - ..name = user['base']['name'] - ..uid = uid - ..text = info[1] - ..emots = (extra['emots'] as Map?)?.map( - (k, v) => MapEntry(k, BaseEmote.fromJson(v)), - ) - ..uemote = first[13] is Map - ? BaseEmote.fromJson(first[13]) - : null, - ); - - if (plPlayerController.showDanmaku) { - plPlayerController.danmakuController?.addDanmaku( - DanmakuContentItem( - extra['content'], - color: DmUtils.decimalToColor(extra['color']), - type: DmUtils.getPosition(extra['mode']), - selfSend: isLogin && uid == mid, + // logger.i(' 原始弹幕消息 ======> ${jsonEncode(obj)}'); + switch (obj['cmd']) { + case 'DANMU_MSG': + final info = obj['info']; + final first = info[0]; + final content = first[15]; + final extra = jsonDecode(content['extra']); + final user = content['user']; + final uid = user['uid']; + BaseEmote? uemote; + if (first[13] case Map map) { + uemote = BaseEmote.fromJson(map); + } + messages.add( + DanmakuMsg( + name: user['base']['name'], + uid: uid, + text: info[1], + emots: (extra['emots'] as Map?)?.map( + (k, v) => MapEntry(k, BaseEmote.fromJson(v)), + ), + uemote: uemote, ), ); - if (!disableAutoScroll.value) { - EasyThrottle.throttle( - 'liveDm', - const Duration(milliseconds: 500), - () => WidgetsBinding.instance.addPostFrameCallback( - scrollToBottom, + + if (plPlayerController.showDanmaku) { + plPlayerController.danmakuController?.addDanmaku( + DanmakuContentItem( + extra['content'], + color: DmUtils.decimalToColor(extra['color']), + type: DmUtils.getPosition(extra['mode']), + selfSend: extra['send_from_me'] ?? false, ), ); + if (!disableAutoScroll.value) { + EasyThrottle.throttle( + 'liveDm', + const Duration(milliseconds: 500), + () => WidgetsBinding.instance.addPostFrameCallback( + scrollToBottom, + ), + ); + } } - } + break; + case 'SUPER_CHAT_MESSAGE' when (showSuperChat): + final item = SuperChatItem.fromJson(obj['data']); + superChatMsg.insert(0, item); + if (isFullScreen) { + fsSC.value = item; + } + break; } } catch (e) { - if (kDebugMode) debugPrint(e.toString()); + if (kDebugMode) { + debugPrint('$e,,$obj'); + } } }) ..init(); diff --git a/lib/pages/live_room/superchat/superchat_card.dart b/lib/pages/live_room/superchat/superchat_card.dart new file mode 100644 index 000000000..991fe11ef --- /dev/null +++ b/lib/pages/live_room/superchat/superchat_card.dart @@ -0,0 +1,149 @@ +import 'dart:async'; + +import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; +import 'package:PiliPlus/models/common/image_type.dart'; +import 'package:PiliPlus/models_new/live/live_superchat/item.dart'; +import 'package:PiliPlus/utils/utils.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class SuperChatCard extends StatefulWidget { + const SuperChatCard({ + super.key, + required this.item, + required this.onRemove, + }); + + final SuperChatItem item; + final VoidCallback onRemove; + + @override + State createState() => _SuperChatCardState(); +} + +class _SuperChatCardState extends State { + Timer? _timer; + RxInt _remains = 0.obs; + + @override + void initState() { + super.initState(); + if (widget.item.expired) { + _onRemove(); + return; + } + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final offset = widget.item.endTime - now; + if (offset > 0) { + _remains = offset.obs; + _timer = Timer.periodic(const Duration(seconds: 1), _callback); + } else { + _onRemove(); + } + } + + void _onRemove() { + widget + ..item.expired = true + ..onRemove(); + } + + void _callback(_) { + final remains = _remains.value; + if (remains > 0) { + _remains.value = remains - 1; + } else { + _cancelTimer(); + _onRemove(); + } + } + + void _cancelTimer() { + _timer?.cancel(); + _timer = null; + } + + @override + void dispose() { + _cancelTimer(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final item = widget.item; + final bottomColor = Utils.parseColor(item.backgroundBottomColor); + final border = BorderSide(color: bottomColor); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(8), + topRight: Radius.circular(8), + ), + color: Utils.parseColor(item.backgroundColor), + border: Border(top: border, left: border, right: border), + ), + padding: const EdgeInsets.all(8), + child: Row( + spacing: 12, + children: [ + GestureDetector( + onTap: () => Get.toNamed('/member?mid=${item.uid}'), + child: NetworkImgLayer( + src: item.userInfo.face, + width: 45, + height: 45, + type: ImageType.avatar, + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + item.userInfo.uname, + style: TextStyle( + color: Utils.parseColor(item.userInfo.nameColor), + ), + ), + Text( + "¥${item.price}", + style: TextStyle( + fontSize: 12, + color: Utils.parseColor(item.backgroundPriceColor), + ), + ), + ], + ), + ), + Obx( + () => Text( + _remains.toString(), + style: const TextStyle(fontSize: 14, color: Colors.grey), + ), + ), + ], + ), + ), + Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(8), + bottomRight: Radius.circular(8), + ), + color: bottomColor, + ), + padding: const EdgeInsets.all(8), + child: SelectableText( + item.message, + style: TextStyle(color: Utils.parseColor(item.messageFontColor)), + ), + ), + ], + ); + } +} diff --git a/lib/pages/live_room/superchat/superchat_panel.dart b/lib/pages/live_room/superchat/superchat_panel.dart new file mode 100644 index 000000000..b87ed8cad --- /dev/null +++ b/lib/pages/live_room/superchat/superchat_panel.dart @@ -0,0 +1,45 @@ +import 'package:PiliPlus/pages/live_room/controller.dart'; +import 'package:PiliPlus/pages/live_room/superchat/superchat_card.dart'; +import 'package:PiliPlus/pages/search/controller.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get_state_manager/get_state_manager.dart'; + +class SuperChatPanel extends StatefulWidget { + const SuperChatPanel({ + super.key, + required this.controller, + }); + + final LiveRoomController controller; + + @override + State createState() => _SuperChatPanelState(); +} + +class _SuperChatPanelState extends DebounceStreamState { + @override + Duration get duration => const Duration(milliseconds: 300); + + @override + Widget build(BuildContext context) { + return Obx( + () => ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 12), + physics: const ClampingScrollPhysics(), + itemCount: widget.controller.superChatMsg.length, + itemBuilder: (context, index) { + final item = widget.controller.superChatMsg[index]; + return SuperChatCard( + key: Key(item.id.toString()), + item: item, + onRemove: () => ctr?.add(true), + ); + }, + separatorBuilder: (_, _) => const SizedBox(height: 12), + ), + ); + } + + @override + void onValueChanged(value) => widget.controller.clearSC(); +} diff --git a/lib/pages/live_room/view.dart b/lib/pages/live_room/view.dart index 26f03542e..c1b5ab0a4 100644 --- a/lib/pages/live_room/view.dart +++ b/lib/pages/live_room/view.dart @@ -1,19 +1,24 @@ import 'dart:io'; import 'dart:ui'; +import 'package:PiliPlus/common/widgets/button/icon_button.dart'; import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; +import 'package:PiliPlus/common/widgets/keep_alive_wrapper.dart'; +import 'package:PiliPlus/common/widgets/scroll_physics.dart'; import 'package:PiliPlus/models/common/image_type.dart'; import 'package:PiliPlus/models_new/live/live_room_info_h5/data.dart'; +import 'package:PiliPlus/models_new/live/live_superchat/item.dart'; import 'package:PiliPlus/pages/live_room/controller.dart'; import 'package:PiliPlus/pages/live_room/send_danmaku/view.dart'; +import 'package:PiliPlus/pages/live_room/superchat/superchat_card.dart'; +import 'package:PiliPlus/pages/live_room/superchat/superchat_panel.dart'; import 'package:PiliPlus/pages/live_room/widgets/bottom_control.dart'; -import 'package:PiliPlus/pages/live_room/widgets/chat.dart'; +import 'package:PiliPlus/pages/live_room/widgets/chat_panel.dart'; import 'package:PiliPlus/pages/live_room/widgets/header_control.dart'; import 'package:PiliPlus/plugin/pl_player/controller.dart'; import 'package:PiliPlus/plugin/pl_player/models/play_status.dart'; import 'package:PiliPlus/plugin/pl_player/utils/fullscreen.dart'; import 'package:PiliPlus/plugin/pl_player/view.dart'; -import 'package:PiliPlus/plugin/pl_player/widgets/common_btn.dart'; import 'package:PiliPlus/services/service_locator.dart'; import 'package:PiliPlus/utils/duration_util.dart'; import 'package:PiliPlus/utils/extension.dart'; @@ -24,6 +29,7 @@ import 'package:PiliPlus/utils/utils.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:canvas_danmaku/canvas_danmaku.dart'; import 'package:floating/floating.dart'; +import 'package:flutter/foundation.dart' show kDebugMode; import 'package:flutter/material.dart'; import 'package:flutter/services.dart' show SystemUiOverlayStyle; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; @@ -44,8 +50,10 @@ class _LiveRoomPageState extends State late final PlPlayerController plPlayerController; bool get isFullScreen => plPlayerController.isFullScreen.value; - final GlobalKey chatKey = GlobalKey(); - final GlobalKey playerKey = GlobalKey(); + late final GlobalKey pageKey = GlobalKey(); + late final GlobalKey chatKey = GlobalKey(); + late final GlobalKey scKey = GlobalKey(); + late final GlobalKey playerKey = GlobalKey(); @override void initState() { @@ -193,7 +201,128 @@ class _LiveRoomPageState extends State Alignment? alignment, bool needDm = true, }) { + if (!isFullScreen) { + _liveRoomController.fsSC.value = null; + } _liveRoomController.isFullScreen = isFullScreen; + Widget player = Obx(() { + if (_liveRoomController.isLoaded.value) { + final roomInfoH5 = _liveRoomController.roomInfoH5.value; + return PLVideoPlayer( + key: playerKey, + maxWidth: width, + maxHeight: height, + fill: fill, + alignment: alignment, + plPlayerController: plPlayerController, + headerControl: LiveHeaderControl( + title: roomInfoH5?.roomInfo?.title, + upName: roomInfoH5?.anchorInfo?.baseInfo?.uname, + plPlayerController: plPlayerController, + onSendDanmaku: onSendDanmaku, + onPlayAudio: _liveRoomController.queryLiveUrl, + ), + bottomControl: BottomControl( + plPlayerController: plPlayerController, + liveRoomCtr: _liveRoomController, + onRefresh: _liveRoomController.queryLiveUrl, + ), + danmuWidget: !needDm + ? null + : LiveDanmaku( + liveRoomController: _liveRoomController, + plPlayerController: plPlayerController, + isFullScreen: isFullScreen, + isPipMode: isPipMode, + ), + ); + } + return const SizedBox.shrink(); + }); + if (isFullScreen && _liveRoomController.showSuperChat) { + player = Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill(child: player), + if (kDebugMode) ...[ + Positioned( + top: 50, + right: 0, + child: TextButton( + onPressed: () { + _liveRoomController.fsSC.value = SuperChatItem.fromJson({ + "id": Utils.generateRandomString(8), + "price": 66, + "end_time": + DateTime.now().millisecondsSinceEpoch ~/ 1000 + 5, + "message": Utils.generateRandomString(55), + "user_info": { + "face": "", + "uname": Utils.generateRandomString(8), + }, + }); + }, + child: const Text('add superchat'), + ), + ), + Positioned( + right: 0, + top: 90, + child: TextButton( + onPressed: () { + _liveRoomController.fsSC.value = null; + }, + child: const Text('remove superchat'), + ), + ), + ], + Positioned( + left: padding.left + 25, + bottom: 25, + child: Obx(() { + final item = _liveRoomController.fsSC.value; + if (item == null) { + return const SizedBox.shrink(); + } + try { + return SizedBox( + key: Key(item.id.toString()), + width: 255, + child: Stack( + clipBehavior: Clip.none, + children: [ + Padding( + padding: const EdgeInsets.only(right: 6, top: 6), + child: SuperChatCard( + item: item, + onRemove: () => _liveRoomController.fsSC.value = null, + ), + ), + Positioned( + right: 0, + top: 0, + child: iconButton( + size: 24, + iconSize: 14, + context: context, + bgColor: const Color(0xEEFFFFFF), + iconColor: Colors.black54, + icon: Icons.clear, + onPressed: () => + _liveRoomController.fsSC.value = null, + ), + ), + ], + ), + ); + } catch (_) { + return const SizedBox.shrink(); + } + }), + ), + ], + ); + } return PopScope( canPop: !isFullScreen, onPopInvokedWithResult: (bool didPop, Object? result) { @@ -201,40 +330,7 @@ class _LiveRoomPageState extends State plPlayerController.triggerFullScreen(status: false); } }, - child: Obx(() { - if (_liveRoomController.isLoaded.value) { - final roomInfoH5 = _liveRoomController.roomInfoH5.value; - return PLVideoPlayer( - key: playerKey, - maxWidth: width, - maxHeight: height, - fill: fill, - alignment: alignment, - plPlayerController: plPlayerController, - headerControl: LiveHeaderControl( - title: roomInfoH5?.roomInfo?.title, - upName: roomInfoH5?.anchorInfo?.baseInfo?.uname, - plPlayerController: plPlayerController, - onSendDanmaku: onSendDanmaku, - onPlayAudio: _liveRoomController.queryLiveUrl, - ), - bottomControl: BottomControl( - plPlayerController: plPlayerController, - liveRoomCtr: _liveRoomController, - onRefresh: _liveRoomController.queryLiveUrl, - ), - danmuWidget: !needDm - ? null - : LiveDanmaku( - liveRoomController: _liveRoomController, - plPlayerController: plPlayerController, - isFullScreen: isFullScreen, - isPipMode: isPipMode, - ), - ); - } - return const SizedBox.shrink(); - }), + child: player, ); } @@ -344,7 +440,7 @@ class _LiveRoomPageState extends State Widget get _buildPP { final isFullScreen = this.isFullScreen; - final bottomHeight = 80.0 + padding.bottom; + final bottomHeight = 70 + padding.bottom; final topPadding = padding.top + kToolbarHeight; final videoHeight = maxHeight - bottomHeight - topPadding; return Stack( @@ -562,7 +658,7 @@ class _LiveRoomPageState extends State Widget get _buildBodyH { final videoWidth = - clampDouble(maxHeight / maxWidth * 1.08, 0.58, 0.75) * maxWidth; + clampDouble(maxHeight / maxWidth * 1.08, 0.56, 0.7) * maxWidth; final videoHeight = maxHeight - padding.top; return Obx( () { @@ -619,148 +715,215 @@ class _LiveRoomPageState extends State ], ); - Widget _buildChatWidget([bool isPP = false]) => Padding( - padding: EdgeInsets.only(bottom: 16, top: !isPortrait ? 0 : 16), - child: LiveRoomChat( + Widget _buildChatWidget([bool isPP = false]) { + Widget chat() => LiveRoomChatPanel( key: chatKey, isPP: isPP, roomId: _liveRoomController.roomId, liveRoomController: _liveRoomController, - ), - ); + ); + return Padding( + padding: EdgeInsets.only(bottom: 12, top: !isPortrait ? 0 : 12), + child: _liveRoomController.showSuperChat + ? PageView( + key: pageKey, + controller: _liveRoomController.pageController, + physics: const CustomTabBarViewClampingScrollPhysics(), + onPageChanged: (value) => + _liveRoomController.pageIndex.value = value, + children: [ + KeepAliveWrapper(builder: (context) => chat()), + KeepAliveWrapper( + builder: (context) => SuperChatPanel( + key: scKey, + controller: _liveRoomController, + ), + ), + ], + ) + : chat(), + ); + } - Widget get _buildInputWidget => Container( - padding: EdgeInsets.only( - top: 5, - left: 10, - right: 10, - bottom: 15 + padding.bottom, - ), - height: 80 + padding.bottom, - decoration: const BoxDecoration( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(20), - topRight: Radius.circular(20), + Widget get _buildInputWidget { + final child = Container( + padding: EdgeInsets.only( + top: 5, + left: 10, + right: 10, + bottom: padding.bottom, ), - border: Border( - top: BorderSide(color: Color(0x1AFFFFFF)), + height: 70 + padding.bottom, + decoration: const BoxDecoration( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + ), + border: Border(top: BorderSide(color: Color(0x1AFFFFFF))), + color: Color(0x1AFFFFFF), ), - color: Color(0x1AFFFFFF), - ), - child: GestureDetector( - onTap: onSendDanmaku, - behavior: HitTestBehavior.opaque, - child: Padding( - padding: const EdgeInsets.only(top: 5, bottom: 10), - child: Row( - spacing: 6, - children: [ - Obx( - () { - final enableShowDanmaku = - plPlayerController.enableShowDanmaku.value; - return ComBtn( - onTap: () { - final newVal = !enableShowDanmaku; - plPlayerController.enableShowDanmaku.value = newVal; - if (!plPlayerController.tempPlayerConf) { - GStorage.setting.put( - SettingBoxKey.enableShowDanmaku, - newVal, - ); - } - }, - icon: enableShowDanmaku - ? const Icon( - size: 22, - Icons.subtitles_outlined, - color: Color(0xFFEEEEEE), - ) - : const Icon( - size: 22, - Icons.subtitles_off_outlined, - color: Color(0xFFEEEEEE), + child: GestureDetector( + onTap: onSendDanmaku, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.only(top: 5, bottom: 10), + child: Align( + alignment: Alignment.topCenter, + child: Row( + spacing: 6, + children: [ + Obx( + () { + final enableShowDanmaku = + plPlayerController.enableShowDanmaku.value; + return SizedBox( + width: 34, + height: 34, + child: IconButton( + style: IconButton.styleFrom( + padding: EdgeInsets.zero, ), - ); - }, - ), - const Expanded( - child: Text( - '发送弹幕', - style: TextStyle(color: Color(0xFFEEEEEE)), - ), - ), - Builder( - builder: (context) { - final theme = Theme.of(context).colorScheme; - return Material( - type: MaterialType.transparency, - child: Stack( - clipBehavior: Clip.none, - children: [ - InkWell( - overlayColor: overlayColor(theme), - customBorder: const CircleBorder(), - onTapDown: _liveRoomController.onLikeTapDown, - onTapUp: _liveRoomController.onLikeTapUp, - onTapCancel: _liveRoomController.onLikeTapUp, - child: const SizedBox.square( - dimension: 34, - child: Icon( - size: 22, - color: Color(0xFFEEEEEE), - Icons.thumb_up_off_alt, - ), - ), - ), - Positioned( - right: -12, - top: -12, - child: Obx(() { - final likeClickTime = - _liveRoomController.likeClickTime.value; - if (likeClickTime == 0) { - return const SizedBox.shrink(); + onPressed: () { + final newVal = !enableShowDanmaku; + plPlayerController.enableShowDanmaku.value = newVal; + if (!plPlayerController.tempPlayerConf) { + GStorage.setting.put( + SettingBoxKey.enableShowDanmaku, + newVal, + ); } - return AnimatedSwitcher( - duration: const Duration(milliseconds: 160), - transitionBuilder: (child, animation) { - return ScaleTransition( - scale: animation, - child: child, - ); - }, - child: Text( - key: ValueKey(likeClickTime), - 'x$likeClickTime', - style: TextStyle( - fontSize: 16, - color: theme.brightness.isDark - ? theme.primary - : theme.inversePrimary, + }, + icon: enableShowDanmaku + ? const Icon( + size: 22, + Icons.subtitles_outlined, + color: Color(0xFFEEEEEE), + ) + : const Icon( + size: 22, + Icons.subtitles_off_outlined, + color: Color(0xFFEEEEEE), + ), + ), + ); + }, + ), + const Expanded( + child: Text( + '发送弹幕', + style: TextStyle(color: Color(0xFFEEEEEE)), + ), + ), + Builder( + builder: (context) { + final theme = Theme.of(context).colorScheme; + return Material( + type: MaterialType.transparency, + child: Stack( + clipBehavior: Clip.none, + children: [ + InkWell( + overlayColor: overlayColor(theme), + customBorder: const CircleBorder(), + onTapDown: _liveRoomController.onLikeTapDown, + onTapUp: _liveRoomController.onLikeTapUp, + onTapCancel: _liveRoomController.onLikeTapUp, + child: const SizedBox.square( + dimension: 34, + child: Icon( + size: 22, + color: Color(0xFFEEEEEE), + Icons.thumb_up_off_alt, ), ), - ); - }), + ), + Positioned( + right: -12, + top: -12, + child: Obx(() { + final likeClickTime = + _liveRoomController.likeClickTime.value; + if (likeClickTime == 0) { + return const SizedBox.shrink(); + } + return AnimatedSwitcher( + duration: const Duration(milliseconds: 160), + transitionBuilder: (child, animation) { + return ScaleTransition( + scale: animation, + child: child, + ); + }, + child: Text( + key: ValueKey(likeClickTime), + 'x$likeClickTime', + style: TextStyle( + fontSize: 16, + color: theme.brightness.isDark + ? theme.primary + : theme.inversePrimary, + ), + ), + ); + }), + ), + ], ), - ], + ); + }, + ), + SizedBox( + width: 34, + height: 34, + child: IconButton( + style: IconButton.styleFrom(padding: EdgeInsets.zero), + onPressed: () => onSendDanmaku(true), + icon: const Icon( + size: 22, + color: Color(0xFFEEEEEE), + Icons.emoji_emotions_outlined, + ), ), - ); - }, + ), + ], ), - ComBtn( - onTap: () => onSendDanmaku(true), - icon: const Icon( - size: 22, - color: Color(0xFFEEEEEE), - Icons.emoji_emotions_outlined, - ), - ), - ], + ), ), ), - ), - ); + ); + if (_liveRoomController.showSuperChat) { + return Stack( + children: [ + Positioned( + left: 0, + top: 0, + right: 0, + child: Obx(() { + return ClipRect( + clipper: _BorderClipper( + _liveRoomController.pageIndex.value == 0, + ), + child: const DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + ), + border: Border( + top: BorderSide(color: Colors.white38), + ), + ), + child: SizedBox(width: double.infinity, height: 20), + ), + ); + }), + ), + child, + ], + ); + } + return child; + } WidgetStateProperty? overlayColor(ColorScheme theme) => WidgetStateProperty.resolveWith((Set states) { @@ -828,6 +991,27 @@ class _LiveRoomPageState extends State } } +class _BorderClipper extends CustomClipper { + _BorderClipper(this.isLeft); + + final bool isLeft; + + @override + Rect getClip(Size size) { + return Rect.fromLTWH( + isLeft ? 0 : size.width / 2, + 0, + size.width / 2, + size.height, + ); + } + + @override + bool shouldReclip(_BorderClipper oldClipper) { + return isLeft != oldClipper.isLeft; + } +} + class LiveDanmaku extends StatefulWidget { final LiveRoomController liveRoomController; final PlPlayerController plPlayerController; diff --git a/lib/pages/live_room/widgets/chat.dart b/lib/pages/live_room/widgets/chat_panel.dart similarity index 53% rename from lib/pages/live_room/widgets/chat.dart rename to lib/pages/live_room/widgets/chat_panel.dart index 9d8519ce9..cb6960902 100644 --- a/lib/pages/live_room/widgets/chat.dart +++ b/lib/pages/live_room/widgets/chat_panel.dart @@ -1,14 +1,16 @@ import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; import 'package:PiliPlus/models/common/image_type.dart'; import 'package:PiliPlus/models_new/live/live_danmaku/danmaku_msg.dart'; +import 'package:PiliPlus/models_new/live/live_superchat/item.dart'; import 'package:PiliPlus/pages/live_room/controller.dart'; +import 'package:PiliPlus/utils/utils.dart'; import 'package:flutter/foundation.dart' show kDebugMode; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; -class LiveRoomChat extends StatelessWidget { - const LiveRoomChat({ +class LiveRoomChatPanel extends StatelessWidget { + const LiveRoomChatPanel({ super.key, required this.roomId, required this.liveRoomController, @@ -33,24 +35,23 @@ class LiveRoomChat extends StatelessWidget { children: [ Obx( () => ListView.separated( - padding: EdgeInsets.zero, + padding: const EdgeInsets.symmetric(horizontal: 12), controller: liveRoomController.scrollController, - separatorBuilder: (context, index) => const SizedBox(height: 6), + separatorBuilder: (context, index) => const SizedBox(height: 8), itemCount: liveRoomController.messages.length, physics: const ClampingScrollPhysics(), itemBuilder: (context, index) { final item = liveRoomController.messages[index]; - return Container( + return Align( alignment: Alignment.centerLeft, - padding: const EdgeInsets.symmetric(horizontal: 16), child: Container( padding: const EdgeInsets.symmetric( horizontal: 10, - vertical: 5, + vertical: 4, ), decoration: BoxDecoration( color: bg, - borderRadius: const BorderRadius.all(Radius.circular(18)), + borderRadius: const BorderRadius.all(Radius.circular(14)), ), child: Text.rich( TextSpan( @@ -61,14 +62,11 @@ class LiveRoomChat extends StatelessWidget { color: nameColor, fontSize: 14, ), - recognizer: TapGestureRecognizer() - ..onTap = () { - try { - Get.toNamed('/member?mid=${item.uid}'); - } catch (err) { - if (kDebugMode) debugPrint(err.toString()); - } - }, + recognizer: item.uid == 0 + ? null + : (TapGestureRecognizer() + ..onTap = () => + Get.toNamed('/member?mid=${item.uid}')), ), _buildMsg(devicePixelRatio, item), ], @@ -79,12 +77,101 @@ class LiveRoomChat extends StatelessWidget { }, ), ), + if (kDebugMode && liveRoomController.showSuperChat) ...[ + Positioned( + top: 50, + right: 0, + child: TextButton( + onPressed: () { + liveRoomController.superChatMsg.insert( + 0, + SuperChatItem.fromJson({ + "id": Utils.generateRandomString(8), + "price": 66, + "end_time": + DateTime.now().millisecondsSinceEpoch ~/ 1000 + 5, + "message": "message message message message message", + "user_info": { + "face": "", + "uname": "UNAME", + }, + }), + ); + }, + child: const Text('add superchat'), + ), + ), + Positioned( + right: 0, + top: 90, + child: TextButton( + onPressed: () { + if (liveRoomController.superChatMsg.isNotEmpty) { + liveRoomController.superChatMsg.removeLast(); + } + }, + child: const Text('remove superchat'), + ), + ), + ], + if (liveRoomController.showSuperChat) + Positioned( + top: 12, + right: 12, + child: Obx(() { + final isEmpty = liveRoomController.superChatMsg.isEmpty; + return AnimatedOpacity( + opacity: isEmpty ? 0 : 1, + duration: const Duration(milliseconds: 120), + child: GestureDetector( + onTap: isEmpty + ? null + : () => liveRoomController.pageController?.animateToPage( + 1, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + ), + child: Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(8)), + color: const Color(0x2FFFFFFF), + border: Border.all(color: Colors.white24, width: 0.7), + ), + padding: const EdgeInsets.fromLTRB(10, 4, 4, 4), + child: Text.rich( + style: const TextStyle(color: Colors.white), + strutStyle: const StrutStyle(height: 1, leading: 0), + TextSpan( + children: [ + TextSpan( + text: + 'SC: ${liveRoomController.superChatMsg.length}', + ), + const WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Icon( + size: 18, + Icons.keyboard_arrow_right, + color: Colors.white, + ), + ), + ], + ), + ), + ), + ), + ); + }), + ), Obx( () => liveRoomController.disableAutoScroll.value ? Positioned( right: 12, bottom: 0, child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + visualDensity: VisualDensity.comfortable, + ), icon: const Icon( Icons.arrow_downward_rounded, size: 20, @@ -92,7 +179,7 @@ class LiveRoomChat extends StatelessWidget { label: const Text('回到底部'), onPressed: () => liveRoomController ..disableAutoScroll.value = false - ..scrollToBottom(), + ..jumpToBottom(), ), ) : const SizedBox.shrink(), diff --git a/lib/pages/member/widget/user_info_card.dart b/lib/pages/member/widget/user_info_card.dart index f249bc3e2..6346bcbd1 100644 --- a/lib/pages/member/widget/user_info_card.dart +++ b/lib/pages/member/widget/user_info_card.dart @@ -474,15 +474,13 @@ class UserInfoCard extends StatelessWidget { bool isLight, SpacePrInfo prInfo, ) { - final textColor = !isLight - ? Color(int.parse('FF${prInfo.textColorNight.substring(1)}', radix: 16)) - : Color(int.parse('FF${prInfo.textColor.substring(1)}', radix: 16)); + final textColor = Utils.parseColor( + isLight ? prInfo.textColor : prInfo.textColorNight, + ); Widget child = Container( margin: const EdgeInsets.only(top: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - color: !isLight - ? Color(int.parse('FF${prInfo.bgColorNight.substring(1)}', radix: 16)) - : Color(int.parse('FF${prInfo.bgColor.substring(1)}', radix: 16)), + color: Utils.parseColor(isLight ? prInfo.bgColor : prInfo.bgColorNight), child: Row( children: [ if (!isLight && prInfo.iconNight?.isNotEmpty == true) ...[ diff --git a/lib/pages/search/controller.dart b/lib/pages/search/controller.dart index c69807aca..101f66f9d 100644 --- a/lib/pages/search/controller.dart +++ b/lib/pages/search/controller.dart @@ -33,8 +33,8 @@ mixin DebounceStreamMixin { } } -abstract class SearchState extends State - with DebounceStreamMixin { +abstract class DebounceStreamState extends State + with DebounceStreamMixin { @override void dispose() { subDispose(); diff --git a/lib/pages/setting/models/play_settings.dart b/lib/pages/setting/models/play_settings.dart index 103a7b826..556258e1f 100644 --- a/lib/pages/setting/models/play_settings.dart +++ b/lib/pages/setting/models/play_settings.dart @@ -129,6 +129,13 @@ List get playSettings => [ } }, ), + const SettingsModel( + settingsType: SettingsType.sw1tch, + title: '显示 SuperChat', + leading: Icon(Icons.live_tv), + setKey: SettingBoxKey.showSuperChat, + defaultVal: true, + ), const SettingsModel( settingsType: SettingsType.sw1tch, title: '竖屏扩大展示', diff --git a/lib/pages/settings_search/view.dart b/lib/pages/settings_search/view.dart index 1684ad239..941d58eb6 100644 --- a/lib/pages/settings_search/view.dart +++ b/lib/pages/settings_search/view.dart @@ -1,6 +1,6 @@ import 'package:PiliPlus/common/widgets/loading_widget/http_error.dart'; import 'package:PiliPlus/common/widgets/view_sliver_safe_area.dart'; -import 'package:PiliPlus/pages/search/controller.dart' show SearchState; +import 'package:PiliPlus/pages/search/controller.dart' show DebounceStreamState; import 'package:PiliPlus/pages/setting/models/extra_settings.dart'; import 'package:PiliPlus/pages/setting/models/model.dart'; import 'package:PiliPlus/pages/setting/models/play_settings.dart'; @@ -22,7 +22,8 @@ class SettingsSearchPage extends StatefulWidget { State createState() => _SettingsSearchPageState(); } -class _SettingsSearchPageState extends SearchState { +class _SettingsSearchPageState + extends DebounceStreamState { final _textEditingController = TextEditingController(); final RxList _list = [].obs; late final _settings = [ @@ -91,19 +92,24 @@ class _SettingsSearchPageState extends SearchState { body: CustomScrollView( slivers: [ ViewSliverSafeArea( - sliver: Obx( - () => _list.isEmpty - ? const HttpError() - : SliverWaterfallFlow( - gridDelegate: - SliverWaterfallFlowDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: Grid.smallCardWidth * 2, - ), - delegate: SliverChildBuilderDelegate( - (_, index) => _list[index].widget, - childCount: _list.length, + sliver: MediaQuery.removeViewPadding( + context: context, + removeLeft: true, + removeRight: true, + child: Obx( + () => _list.isEmpty + ? const HttpError() + : SliverWaterfallFlow( + gridDelegate: + SliverWaterfallFlowDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: Grid.smallCardWidth * 2, + ), + delegate: SliverChildBuilderDelegate( + (_, index) => _list[index].widget, + childCount: _list.length, + ), ), - ), + ), ), ), ], diff --git a/lib/utils/storage_key.dart b/lib/utils/storage_key.dart index 3547d7447..5ae3b69b7 100644 --- a/lib/utils/storage_key.dart +++ b/lib/utils/storage_key.dart @@ -23,7 +23,8 @@ class SettingBoxKey { enableAutoBrightness = 'enableAutoBrightness', enableAutoEnter = 'enableAutoEnter', enableAutoExit = 'enableAutoExit', - enableOnlineTotal = 'enableOnlineTotal'; + enableOnlineTotal = 'enableOnlineTotal', + showSuperChat = 'showSuperChat'; static const String enableVerticalExpand = 'enableVerticalExpand', feedBackEnable = 'feedBackEnable', diff --git a/lib/utils/storage_pref.dart b/lib/utils/storage_pref.dart index 1355b997c..c7a5e7254 100644 --- a/lib/utils/storage_pref.dart +++ b/lib/utils/storage_pref.dart @@ -807,4 +807,7 @@ class Pref { static bool get showMemberShop => _setting.get(SettingBoxKey.showMemberShop, defaultValue: false); + + static bool get showSuperChat => + _setting.get(SettingBoxKey.showSuperChat, defaultValue: true); } diff --git a/lib/utils/utils.dart b/lib/utils/utils.dart index c8123cfa5..14c9eb83f 100644 --- a/lib/utils/utils.dart +++ b/lib/utils/utils.dart @@ -15,6 +15,9 @@ class Utils { static const channel = MethodChannel("PiliPlus"); + static Color parseColor(String color) => + Color(int.parse(color.replaceFirst('#', 'FF'), radix: 16)); + static int? _sdkInt; static Future get sdkInt async {