import 'dart:async'; import 'dart:convert'; import 'package:PiliPlus/common/widgets/dialog/report.dart'; import 'package:PiliPlus/common/widgets/flutter/text_field/controller.dart'; import 'package:PiliPlus/http/live.dart'; import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/http/video.dart'; import 'package:PiliPlus/models/common/super_chat_type.dart'; import 'package:PiliPlus/models/common/video/live_quality.dart'; import 'package:PiliPlus/models/model_owner.dart'; import 'package:PiliPlus/models_new/live/live_danmaku/danmaku_msg.dart'; import 'package:PiliPlus/models_new/live/live_danmaku/live_emote.dart'; 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_superchat/item.dart'; import 'package:PiliPlus/pages/common/publish/publish_route.dart'; import 'package:PiliPlus/pages/danmaku/danmaku_model.dart'; import 'package:PiliPlus/pages/live_room/contribution_rank/view.dart'; import 'package:PiliPlus/pages/live_room/send_danmaku/view.dart'; import 'package:PiliPlus/pages/video/widgets/header_control.dart'; import 'package:PiliPlus/plugin/pl_player/controller.dart'; import 'package:PiliPlus/plugin/pl_player/models/data_source.dart'; import 'package:PiliPlus/plugin/pl_player/utils/danmaku_options.dart'; import 'package:PiliPlus/services/service_locator.dart'; import 'package:PiliPlus/tcp/live.dart'; import 'package:PiliPlus/utils/accounts.dart'; import 'package:PiliPlus/utils/danmaku_utils.dart'; import 'package:PiliPlus/utils/duration_utils.dart'; import 'package:PiliPlus/utils/extension/iterable_ext.dart'; import 'package:PiliPlus/utils/extension/size_ext.dart'; import 'package:PiliPlus/utils/num_utils.dart'; import 'package:PiliPlus/utils/platform_utils.dart'; import 'package:PiliPlus/utils/storage_pref.dart'; import 'package:PiliPlus/utils/utils.dart'; import 'package:PiliPlus/utils/video_utils.dart'; import 'package:canvas_danmaku/canvas_danmaku.dart'; import 'package:easy_debounce/easy_throttle.dart'; import 'package:flutter/material.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; class LiveRoomController extends GetxController { LiveRoomController(this.heroTag); final String heroTag; int roomId = Get.arguments; int? ruid; DanmakuController? danmakuController; PlPlayerController plPlayerController = PlPlayerController.getInstance( isLive: true, ); RxBool isLoaded = false.obs; Rx roomInfoH5 = Rx(null); Rx liveTime = Rx(null); Timer? liveTimeTimer; void startLiveTimer() { if (liveTime.value != null) { liveTimeTimer ??= Timer.periodic( const Duration(minutes: 5), (_) => liveTime.refresh(), ); } } void cancelLiveTimer() { liveTimeTimer?.cancel(); liveTimeTimer = null; } Widget get timeWidget => Obx(() { final liveTime = this.liveTime.value; String text = ''; if (liveTime != null) { final duration = DurationUtils.formatDurationBetween( liveTime * 1000, DateTime.now().millisecondsSinceEpoch, ); text += duration.isEmpty ? '刚刚开播' : '开播$duration'; } if (text.isEmpty) { return const SizedBox.shrink(); } return Text( text, style: const TextStyle( fontSize: 12, color: Colors.white, ), ); }); // dm LiveDmInfoData? dmInfo; List? savedDanmaku; RxList messages = [].obs; late final Rx fsSC = Rx(null); late final RxList superChatMsg = [].obs; RxBool disableAutoScroll = false.obs; bool autoScroll = true; LiveMessageStream? _msgStream; late final ScrollController scrollController; late final RxInt pageIndex = 0.obs; PageController? pageController; int? currentQn = PlatformUtils.isMobile ? null : Pref.liveQuality; RxString currentQnDesc = ''.obs; final RxBool isPortrait = false.obs; late List<({int code, String desc})> acceptQnList = []; late final bool isLogin; late final int mid; String? videoUrl; bool? isPlaying; late bool isFullScreen = false; final superChatType = Pref.superChatType; late final showSuperChat = superChatType != SuperChatType.disable; final headerKey = GlobalKey(); final RxString title = ''.obs; final RxnString onlineCount = RxnString(); Widget get onlineWidget => GestureDetector( onTap: _showRank, child: Obx(() { if (onlineCount.value case final onlineCount?) { return Text( '高能观众($onlineCount)', style: const TextStyle( fontSize: 12, color: Colors.white, ), ); } return const SizedBox.shrink(); }), ); void _showRank() { if (ruid case final ruid?) { final heightFactor = PlatformUtils.isMobile && !Get.mediaQuery.size.isPortrait ? 1.0 : 0.7; showModalBottomSheet( context: Get.context!, useSafeArea: true, clipBehavior: .hardEdge, isScrollControlled: true, constraints: const BoxConstraints(maxWidth: 450), builder: (context) => FractionallySizedBox( widthFactor: 1.0, heightFactor: heightFactor, child: ContributionRankPanel(ruid: ruid, roomId: roomId), ), ); } } final RxnString watchedShow = RxnString(); Widget get watchedWidget => Obx(() { if (watchedShow.value case final watchedShow?) { return Text( watchedShow, style: const TextStyle( fontSize: 12, color: Colors.white, ), ); } return const SizedBox.shrink(); }); @override void onInit() { super.onInit(); scrollController = ScrollController()..addListener(listener); final account = Accounts.main; isLogin = account.isLogin; mid = account.mid; queryLiveUrl(autoFullScreenFlag: true); queryLiveInfoH5(); if (Accounts.heartbeat.isLogin && !Pref.historyPause) { VideoHttp.roomEntryAction(roomId: roomId); } if (showSuperChat) { pageController = PageController(); } } Future? playerInit({ bool autoplay = true, bool autoFullScreenFlag = false, }) { if (videoUrl == null) { return null; } return plPlayerController.setDataSource( NetworkSource(videoSource: videoUrl!, audioSource: null), isLive: true, autoplay: autoplay, isVertical: isPortrait.value, autoFullScreenFlag: autoFullScreenFlag, ); } Future queryLiveUrl({bool autoFullScreenFlag = false}) async { currentQn ??= await Utils.isWiFi ? Pref.liveQuality : Pref.liveQualityCellular; final res = await LiveHttp.liveRoomInfo( roomId: roomId, qn: currentQn, onlyAudio: plPlayerController.onlyPlayAudio.value, ); if (res case Success(:final response)) { if (response.liveStatus != 1) { _showDialog('当前直播间未开播'); return; } if (response.playurlInfo?.playurl == null) { _showDialog('无法获取播放地址'); return; } ruid = response.uid; if (response.roomId != null) { roomId = response.roomId!; } liveTime.value = response.liveTime; startLiveTimer(); isPortrait.value = response.isPortrait ?? false; List codec = response.playurlInfo!.playurl!.stream!.first.format!.first.codec!; CodecItem item = codec.first; // 以服务端返回的码率为准 currentQn = item.currentQn!; acceptQnList = item.acceptQn!.map((e) { return ( code: e, desc: LiveQuality.fromCode(e)?.desc ?? e.toString(), ); }).toList(); currentQnDesc.value = LiveQuality.fromCode(currentQn)?.desc ?? currentQn.toString(); videoUrl = VideoUtils.getLiveCdnUrl(item); await playerInit(autoFullScreenFlag: autoFullScreenFlag); isLoaded.value = true; } else { _showDialog(res.toString()); } } Future queryLiveInfoH5() async { final res = await LiveHttp.liveRoomInfoH5(roomId: roomId); if (res case Success(:final response)) { roomInfoH5.value = response; title.value = response.roomInfo?.title ?? ''; watchedShow.value = response.watchedShow?.textLarge; videoPlayerServiceHandler?.onVideoDetailChange(response, roomId, heroTag); } else { res.toast(); } } void _showDialog(String title) { showDialog( context: Get.context!, builder: (_) => AlertDialog( title: Text(title), actions: [ TextButton( onPressed: Get.back, child: Text( '关闭', style: TextStyle(color: Get.theme.colorScheme.outline), ), ), TextButton( onPressed: () { if (plPlayerController.isDesktopPip) { plPlayerController.exitDesktopPip(); } Get ..back() ..back(); }, child: const Text('退出'), ), ], ), ); } void scrollToBottom([_]) { EasyThrottle.throttle( 'liveDm', const Duration(milliseconds: 500), () => WidgetsBinding.instance.addPostFrameCallback( _scrollToBottom, ), ); } void _scrollToBottom([_]) { if (scrollController.hasClients) { scrollController.animateTo( scrollController.position.maxScrollExtent, duration: const Duration(milliseconds: 500), curve: Curves.linearToEaseOut, ); } } void jumpToBottom() { if (scrollController.hasClients) { scrollController.jumpTo(scrollController.position.maxScrollExtent); } } void closeLiveMsg() { _msgStream?.close(); _msgStream = null; } @pragma('vm:notify-debugger-on-exception') Future prefetch() async { final res = await LiveHttp.liveRoomDmPrefetch(roomId: roomId); if (res case Success(:final response)) { if (response != null && response.isNotEmpty) { messages.addAll(response); scrollToBottom(); } } } Future getSuperChatMsg() async { final res = await LiveHttp.superChatMsg(roomId); if (res.dataOrNull?.list case final list?) { superChatMsg.addAll(list); } } void clearSC() { superChatMsg.removeWhere((e) => e.expired); } void startLiveMsg() { if (messages.isEmpty) { prefetch(); if (showSuperChat) { getSuperChatMsg(); } } if (_msgStream != null) { return; } if (dmInfo != null) { initDm(dmInfo!); return; } LiveHttp.liveRoomGetDanmakuToken(roomId: roomId).then((res) { if (res case Success(:final response)) { initDm(dmInfo = response); } }); } void listener() { final userScrollDirection = scrollController.position.userScrollDirection; if (userScrollDirection == .forward) { disableAutoScroll.value = true; } else if (userScrollDirection == .reverse) { final pos = scrollController.position; if (pos.maxScrollExtent - pos.pixels <= 100) { disableAutoScroll.value = false; } } } @override void onClose() { closeLiveMsg(); cancelLikeTimer(); cancelLiveTimer(); savedDanmaku?.clear(); savedDanmaku = null; messages.clear(); if (showSuperChat) { superChatMsg.clear(); fsSC.value = null; } scrollController ..removeListener(listener) ..dispose(); pageController?.dispose(); danmakuController = null; super.onClose(); } // 修改画质 Future? changeQn(int qn) { if (currentQn == qn) { return null; } currentQn = qn; currentQnDesc.value = LiveQuality.fromCode(currentQn)?.desc ?? currentQn.toString(); return queryLiveUrl(); } void initDm(LiveDmInfoData info) { if (info.hostList.isNullOrEmpty) { return; } _msgStream = LiveMessageStream( streamToken: info.token!, roomId: roomId, uid: Accounts.heartbeat.mid, servers: info.hostList! .map((host) => 'wss://${host.host}:${host.wssPort}/sub') .toList(), ) ..addEventListener(_danmakuListener) ..init(); } void addDm(dynamic msg, [DanmakuContentItem? item]) { if (plPlayerController.showDanmaku) { if (item != null) { danmakuController?.addDanmaku(item); } if (autoScroll && !disableAutoScroll.value) { messages.add(msg); scrollToBottom(); return; } } messages.addOnly(msg); } @pragma('vm:notify-debugger-on-exception') void _danmakuListener(dynamic obj) { try { // logger.i(' 原始弹幕消息 ======> ${jsonEncode(obj)}'); switch (obj['cmd']) { case 'DANMU_MSG': final info = obj['info']; final first = info[0]; final content = first[15]; final Map extra = jsonDecode(content['extra']); final user = content['user']; // final midHash = first[7]; final uid = user['uid']; final name = user['base']['name']; final msg = info[1]; BaseEmote? uemote; if (first[13] case Map map) { uemote = BaseEmote.fromJson(map); } final checkInfo = info[9]; final liveExtra = LiveDanmaku( id: extra['id_str'], mid: uid, dmType: extra['dm_type'], ts: checkInfo['ts'], ct: checkInfo['ct'], ); Owner? reply; final replyMid = extra['reply_mid']; if (replyMid != null && replyMid != 0) { reply = Owner( mid: replyMid, name: extra['reply_uname'], ); } addDm( DanmakuMsg( name: name, text: msg, emots: (extra['emots'] as Map?)?.map( (k, v) => MapEntry(k, BaseEmote.fromJson(v)), ), uemote: uemote, extra: liveExtra, reply: reply, ), DanmakuContentItem( msg, color: DanmakuOptions.blockColorful ? Colors.white : DmUtils.decimalToColor(extra['color']), type: DmUtils.getPosition(extra['mode']), // extra['send_from_me'] is invalid selfSend: isLogin && uid == mid, extra: liveExtra, ), ); break; case 'SUPER_CHAT_MESSAGE' when showSuperChat: final item = SuperChatItem.fromJson(obj['data']); superChatMsg.insert(0, item); if (isFullScreen || plPlayerController.isDesktopPip) { fsSC.value = item.copyWith( endTime: DateTime.now().millisecondsSinceEpoch ~/ 1000 + 10, ); } addDm(item); break; case 'SUPER_CHAT_MESSAGE_DELETE' when showSuperChat: if (obj['roomid'] == roomId) { final ids = obj['data']?['ids'] as List?; if (ids != null && ids.isNotEmpty) { if (superChatType == .valid) { superChatMsg.removeWhere((e) => ids.contains(e.id)); } else { bool? refresh; for (final id in ids) { if (superChatMsg.firstWhereOrNull((e) => e.id == id) case final item?) { item.deleted = true; refresh ??= true; } } if (refresh ?? false) { superChatMsg.refresh(); } } } } case 'WATCHED_CHANGE': watchedShow.value = obj['data']['text_large']; break; case 'ONLINE_RANK_COUNT': onlineCount.value = NumUtils.numFormat(obj['data']['count']); break; case 'ROOM_CHANGE': title.value = obj['data']['title']; break; } } catch (_) {} } final RxInt likeClickTime = 0.obs; Timer? likeClickTimer; void cancelLikeTimer() { likeClickTimer?.cancel(); likeClickTimer = null; } void onLikeTapDown([_]) { cancelLikeTimer(); likeClickTime.value++; } void onLikeTapUp([_]) { likeClickTimer ??= Timer( const Duration(milliseconds: 800), onLike, ); } Future onLike() async { if (!isLogin) { likeClickTime.value = 0; return; } final res = await LiveHttp.liveLikeReport( clickTime: likeClickTime.value, roomId: roomId, uid: mid, anchorId: roomInfoH5.value?.roomInfo?.uid, ); if (res.isSuccess) { SmartDialog.showToast('点赞成功'); } else { res.toast(); } likeClickTime.value = 0; } void onSendDanmaku([bool fromEmote = false]) { if (!isLogin) { SmartDialog.showToast('账号未登录'); return; } Get.key.currentState!.push( PublishRoute( pageBuilder: (context, animation, secondaryAnimation) { return LiveSendDmPanel( fromEmote: fromEmote, liveRoomController: this, items: savedDanmaku, autofocus: !fromEmote, onSave: (msg) { if (msg.isEmpty) { savedDanmaku?.clear(); savedDanmaku = null; } else { savedDanmaku = msg.toList(); } }, ); }, transitionDuration: fromEmote ? const Duration(milliseconds: 400) : const Duration(milliseconds: 500), ), ); } void reportSC(SuperChatItem item) { if (!isLogin) { SmartDialog.showToast('账号未登录'); return; } autoWrapReportDialog( Get.context!, ban: false, ReportOptions.liveDanmakuReport, (reasonType, reasonDesc, banUid) { return LiveHttp.superChatReport( id: item.id, roomId: roomId, uid: item.uid, msg: item.message, reason: ReportOptions.liveDanmakuReport['']![reasonType]!, ts: item.ts, token: item.token, ); }, ); } }