Merge branch 'feature-liveDanmaku'
This commit is contained in:
@ -558,4 +558,11 @@ class Api {
|
||||
/// 系统通知标记已读
|
||||
static const String systemMarkRead =
|
||||
'${HttpString.messageBaseUrl}/x/sys-msg/update_cursor';
|
||||
|
||||
/// 直播间弹幕信息
|
||||
static const String getDanmuInfo =
|
||||
'${HttpString.liveBaseUrl}/xlive/web-room/v1/index/getDanmuInfo';
|
||||
|
||||
/// 直播间发送弹幕
|
||||
static const String sendLiveMsg = '${HttpString.liveBaseUrl}/msg/send';
|
||||
}
|
||||
|
@ -29,6 +29,7 @@ class Request {
|
||||
late String systemProxyPort;
|
||||
static final RegExp spmPrefixExp =
|
||||
RegExp(r'<meta name="spm_prefix" content="([^"]+?)">');
|
||||
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;
|
||||
}
|
||||
|
||||
|
@ -65,4 +65,56 @@ 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'],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 发送弹幕
|
||||
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'],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
101
lib/models/live/message.dart
Normal file
101
lib/models/live/message.dart
Normal file
@ -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<String, dynamic>? 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')}";
|
||||
}
|
||||
}
|
@ -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<int, List<DanmakuElem>> dmSegMap = {};
|
||||
// 已请求的段落标记
|
||||
List<bool> requestedSeg = [];
|
||||
|
@ -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<PlDanmaku> {
|
||||
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<PlDanmaku> {
|
||||
..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<PlDanmaku> {
|
||||
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<PlDanmaku> {
|
||||
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),
|
||||
|
@ -1,10 +1,19 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
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';
|
||||
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 +33,20 @@ class LiveRoomController extends GetxController {
|
||||
int? tempCurrentQn;
|
||||
late List<Map<String, dynamic>> acceptQnList;
|
||||
RxString currentQnDesc = ''.obs;
|
||||
Box userInfoCache = GStrorage.userInfo;
|
||||
int userId = 0;
|
||||
PlSocket? plSocket;
|
||||
List<String> danmuHostList = [];
|
||||
String token = '';
|
||||
// 弹幕消息列表
|
||||
RxList<LiveMessageModel> messageList = <LiveMessageModel>[].obs;
|
||||
DanmakuController? danmakuController;
|
||||
// 输入控制器
|
||||
TextEditingController inputController = TextEditingController();
|
||||
// 加入直播间提示
|
||||
RxMap<String, String> joinRoomTip = {'userName': '', 'message': ''}.obs;
|
||||
// 直播间弹幕开关 默认打开
|
||||
RxBool danmakuSwitch = true.obs;
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
@ -43,6 +66,14 @@ 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());
|
||||
danmakuSwitch.listen((p0) {
|
||||
plPlayerController.isOpenDanmu.value = p0;
|
||||
});
|
||||
}
|
||||
|
||||
playerInit(source) async {
|
||||
@ -61,6 +92,7 @@ class LiveRoomController extends GetxController {
|
||||
enableHA: true,
|
||||
autoplay: true,
|
||||
);
|
||||
plPlayerController.isOpenDanmu.value = danmakuSwitch.value;
|
||||
}
|
||||
|
||||
Future queryLiveInfo() async {
|
||||
@ -127,4 +159,126 @@ 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<String>((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<LiveMessageModel>? liveMsg =
|
||||
LiveUtils.decodeMessage(message);
|
||||
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();
|
||||
|
||||
// 添加到 messageList
|
||||
messageList.addAll(chatMessages);
|
||||
|
||||
// 将 chatMessages 转换为 danmakuItems 列表
|
||||
List<DanmakuItem> danmakuItems = chatMessages.map<DanmakuItem>((e) {
|
||||
return DanmakuItem(
|
||||
e.message ?? '',
|
||||
color: Color.fromARGB(
|
||||
255,
|
||||
e.color.r,
|
||||
e.color.g,
|
||||
e.color.b,
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
|
||||
// 添加到 danmakuController
|
||||
if (danmakuSwitch.value) {
|
||||
danmakuController?.addItems(danmakuItems);
|
||||
}
|
||||
}
|
||||
},
|
||||
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);
|
||||
}
|
||||
|
||||
// 发送弹幕
|
||||
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();
|
||||
super.onClose();
|
||||
}
|
||||
}
|
||||
|
@ -1,9 +1,13 @@
|
||||
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/pages/danmaku/index.dart';
|
||||
import 'package:pilipala/plugin/pl_player/index.dart';
|
||||
|
||||
import 'controller.dart';
|
||||
@ -16,15 +20,20 @@ class LiveRoomPage extends StatefulWidget {
|
||||
State<LiveRoomPage> createState() => _LiveRoomPageState();
|
||||
}
|
||||
|
||||
class _LiveRoomPageState extends State<LiveRoomPage> {
|
||||
class _LiveRoomPageState extends State<LiveRoomPage>
|
||||
with TickerProviderStateMixin {
|
||||
final LiveRoomController _liveRoomController = Get.put(LiveRoomController());
|
||||
PlPlayerController? plPlayerController;
|
||||
late PlPlayerController plPlayerController;
|
||||
late Future? _futureBuilder;
|
||||
late Future? _futureBuilderFuture;
|
||||
|
||||
bool isShowCover = true;
|
||||
bool isPlay = true;
|
||||
Floating? floating;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
late AnimationController fabAnimationCtr;
|
||||
bool _shouldAutoScroll = true;
|
||||
final int roomId = int.parse(Get.parameters['roomid']!);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@ -34,6 +43,13 @@ class _LiveRoomPageState extends State<LiveRoomPage> {
|
||||
}
|
||||
videoSourceInit();
|
||||
_futureBuilderFuture = _liveRoomController.queryLiveInfo();
|
||||
// 监听滚动事件
|
||||
_scrollController.addListener(_onScroll);
|
||||
fabAnimationCtr = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
value: 0.0,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> videoSourceInit() async {
|
||||
@ -41,12 +57,52 @@ class _LiveRoomPageState extends State<LiveRoomPage> {
|
||||
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();
|
||||
plPlayerController.dispose();
|
||||
if (floating != null) {
|
||||
floating!.dispose();
|
||||
}
|
||||
_scrollController.dispose();
|
||||
fabAnimationCtr.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@ -56,8 +112,9 @@ class _LiveRoomPageState extends State<LiveRoomPage> {
|
||||
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,
|
||||
@ -68,6 +125,14 @@ class _LiveRoomPageState extends State<LiveRoomPage> {
|
||||
});
|
||||
},
|
||||
),
|
||||
danmuWidget: PlDanmaku(
|
||||
cid: roomId,
|
||||
playerController: plPlayerController,
|
||||
type: 'live',
|
||||
createdController: (e) {
|
||||
_liveRoomController.danmakuController = e;
|
||||
},
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return const SizedBox();
|
||||
@ -80,20 +145,6 @@ class _LiveRoomPageState extends State<LiveRoomPage> {
|
||||
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 +157,7 @@ class _LiveRoomPageState extends State<LiveRoomPage> {
|
||||
.roomInfoH5.value.roomInfo?.appBackground !=
|
||||
null
|
||||
? Opacity(
|
||||
opacity: 0.8,
|
||||
opacity: 0.6,
|
||||
child: NetworkImgLayer(
|
||||
width: Get.width,
|
||||
height: Get.height,
|
||||
@ -116,10 +167,19 @@ class _LiveRoomPageState extends State<LiveRoomPage> {
|
||||
'',
|
||||
),
|
||||
)
|
||||
: 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(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AppBar(
|
||||
centerTitle: false,
|
||||
@ -179,10 +239,10 @@ class _LiveRoomPageState extends State<LiveRoomPage> {
|
||||
),
|
||||
),
|
||||
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) {
|
||||
@ -198,8 +258,160 @@ class _LiveRoomPageState extends State<LiveRoomPage> {
|
||||
child: videoPlayerPanel,
|
||||
),
|
||||
),
|
||||
// 显示消息的列表
|
||||
buildMessageListUI(
|
||||
context,
|
||||
_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<double> 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(
|
||||
left: 14,
|
||||
right: 14,
|
||||
top: 4,
|
||||
bottom: MediaQuery.of(context).padding.bottom + 20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.withOpacity(0.1),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 34,
|
||||
height: 34,
|
||||
child: Obx(
|
||||
() => IconButton(
|
||||
style: ButtonStyle(
|
||||
padding: MaterialStateProperty.all(EdgeInsets.zero),
|
||||
backgroundColor: MaterialStateProperty.resolveWith(
|
||||
(Set<MaterialState> 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,
|
||||
style:
|
||||
const TextStyle(color: Colors.white, fontSize: 13),
|
||||
decoration: InputDecoration(
|
||||
hintText: '发送弹幕',
|
||||
hintStyle: TextStyle(
|
||||
color: Colors.white.withOpacity(0.6),
|
||||
),
|
||||
border: InputBorder.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// 定位 快速滑动到底部
|
||||
Positioned(
|
||||
right: 20,
|
||||
bottom: MediaQuery.of(context).padding.bottom + 80,
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0, 4),
|
||||
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 +426,138 @@ class _LiveRoomPageState extends State<LiveRoomPage> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.black.withOpacity(0.5),
|
||||
Colors.black,
|
||||
],
|
||||
stops: const [0.01, 0.05, 0.2],
|
||||
).createShader(bounds);
|
||||
},
|
||||
blendMode: BlendMode.dstIn,
|
||||
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');
|
||||
},
|
||||
),
|
||||
TextSpan(
|
||||
children: [
|
||||
...buildMessageTextSpan(context, liveMsgItem)
|
||||
],
|
||||
// text: liveMsgItem.message,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<InlineSpan> buildMessageTextSpan(
|
||||
BuildContext context,
|
||||
LiveMessageModel liveMsgItem,
|
||||
) {
|
||||
final List<InlineSpan> inlineSpanList = [];
|
||||
|
||||
// 是否包含表情包
|
||||
if (liveMsgItem.emots == null) {
|
||||
// 没有表情包的消息
|
||||
inlineSpanList.add(
|
||||
TextSpan(text: liveMsgItem.message ?? ''),
|
||||
);
|
||||
} else {
|
||||
// 有表情包的消息 使用正则匹配 表情包用图片渲染
|
||||
final List<String> 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),
|
||||
);
|
||||
return nonMatch;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return inlineSpanList;
|
||||
}
|
||||
|
107
lib/plugin/pl_socket/index.dart
Normal file
107
lib/plugin/pl_socket/index.dart
Normal file
@ -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<String, dynamic>? headers;
|
||||
|
||||
PlSocket({
|
||||
required this.url,
|
||||
required this.heartTime,
|
||||
this.onReadyCb,
|
||||
this.onCloseCb,
|
||||
this.onErrorCb,
|
||||
this.onMessageCb,
|
||||
this.headers,
|
||||
});
|
||||
|
||||
WebSocketChannel? channel;
|
||||
StreamSubscription<dynamic>? 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);
|
||||
}
|
||||
}
|
||||
}
|
117
lib/utils/binary_writer.dart
Normal file
117
lib/utils/binary_writer.dart
Normal file
@ -0,0 +1,117 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
class BinaryWriter {
|
||||
List<int> buffer;
|
||||
int position = 0;
|
||||
BinaryWriter(this.buffer);
|
||||
int get length => buffer.length;
|
||||
|
||||
void writeBytes(List<int> 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;
|
||||
}
|
||||
}
|
196
lib/utils/live.dart
Normal file
196
lib/utils/live.dart
Normal file
@ -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<int> 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<LiveMessageModel>? decodeMessage(List<int> 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<LiveMessageModel> 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<int?>(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<String, dynamic> 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<T>(dynamic value) {
|
||||
if (value is T) {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static int readInt(List<int> buffer, int start, int len) {
|
||||
var data = _getByteData(buffer, start, len);
|
||||
return _readIntFromByteData(data, len);
|
||||
}
|
||||
|
||||
static ByteData _getByteData(List<int> 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');
|
||||
}
|
||||
}
|
||||
}
|
14
pubspec.lock
14
pubspec.lock
@ -129,6 +129,14 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "4.0.4"
|
||||
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:
|
||||
@ -1672,13 +1680,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:
|
||||
|
@ -147,6 +147,8 @@ dependencies:
|
||||
# 二维码
|
||||
qr_flutter: ^4.1.0
|
||||
bottom_sheet: ^4.0.4
|
||||
web_socket_channel: ^2.4.5
|
||||
brotli: ^0.6.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
Reference in New Issue
Block a user