Files
PiliPlus/lib/pages/live_room/controller.dart
dom 9578f948b4 tweaks
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-23 13:47:20 +08:00

656 lines
19 KiB
Dart

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_medal_wall/uinfo_medal.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/global_data.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/foundation.dart' show kDebugMode;
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<DanmakuExtra>? danmakuController;
PlPlayerController plPlayerController = PlPlayerController.getInstance(
isLive: true,
);
RxBool isLoaded = false.obs;
Rx<RoomInfoH5Data?> roomInfoH5 = Rx<RoomInfoH5Data?>(null);
Rx<int?> liveTime = Rx<int?>(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<RichTextItem>? savedDanmaku;
RxList<dynamic> messages = <dynamic>[].obs;
late final Rx<SuperChatItem?> fsSC = Rx<SuperChatItem?>(null);
late final RxList<SuperChatItem> superChatMsg = <SuperChatItem>[].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<TimeBatteryMixin>();
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<void>? 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<void> 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<CodecItem> 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<void> 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<void> prefetch() async {
final res = await LiveHttp.liveRoomDmPrefetch(roomId: roomId);
if (res case Success(:final response)) {
if (response != null && response.isNotEmpty) {
messages.addAll(response);
scrollToBottom();
}
} else {
if (kDebugMode) {
Utils.reportError(res.toString());
}
}
}
Future<void> 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<void>? 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<DanmakuExtra>? 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<String, dynamic> 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<String, dynamic> 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<String, dynamic>?)?.map(
(k, v) => MapEntry(k, BaseEmote.fromJson(v)),
),
uemote: uemote,
extra: liveExtra,
reply: reply,
medalInfo: !GlobalData().showMedal || user['medal'] == null
? null
: UinfoMedal.fromJson(user['medal']),
),
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 (e, s) {
if (kDebugMode) {
Utils.reportError(e, s);
}
}
}
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<void> 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,
);
},
);
}
}