From 08e7e7b6e89d02bbadd7be6bca34b22aedf79be2 Mon Sep 17 00:00:00 2001 From: foxtoy Date: Thu, 29 Aug 2024 18:00:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=9D=E6=AD=A5=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E7=9B=B4=E6=92=AD=E5=BC=B9=E5=B9=95=E6=94=AF=E6=8C=81=20pick?= =?UTF-8?q?=20from=20https://github.com/bilibili10633/PiliPalaX/commit/cf8?= =?UTF-8?q?7904fc61de1fb696221295d25b72f91fdbe71?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit opt: live danmaku style --- lib/http/api.dart | 4 + lib/http/live.dart | 17 +- lib/models/live/danmu_info.dart | 75 +++++++ lib/pages/live_room/view.dart | 11 ++ lib/pages/live_room/widgets/chat.dart | 121 ++++++++++++ lib/router/app_pages.dart | 4 +- lib/tcp/live.dart | 270 ++++++++++++++++++++++++++ pubspec.lock | 8 + pubspec.yaml | 3 + 9 files changed, 510 insertions(+), 3 deletions(-) create mode 100644 lib/models/live/danmu_info.dart create mode 100644 lib/pages/live_room/widgets/chat.dart create mode 100644 lib/tcp/live.dart diff --git a/lib/http/api.dart b/lib/http/api.dart index 880e3277..1cddff09 100644 --- a/lib/http/api.dart +++ b/lib/http/api.dart @@ -287,6 +287,10 @@ class Api { static const String liveRoomInfoH5 = '${HttpString.liveBaseUrl}/xlive/web-room/v1/index/getH5InfoByRoom'; + //直播间弹幕密钥获取接口 + static const String liveRoomDmToken = + '${HttpString.liveBaseUrl}/xlive/web-room/v1/index/getDanmuInfo'; + // 用户信息 需要Wbi签名 // https://api.bilibili.com/x/space/wbi/acc/info?mid=503427686&token=&platform=web&web_location=1550101&w_rid=d709892496ce93e3d94d6d37c95bde91&wts=1689301482 static const String memberInfo = '/x/space/wbi/acc/info'; diff --git a/lib/http/live.dart b/lib/http/live.dart index c5bc0451..9d00f3d1 100644 --- a/lib/http/live.dart +++ b/lib/http/live.dart @@ -1,5 +1,5 @@ import 'package:PiliPalaX/http/loading_state.dart'; - +import 'package:PiliPalaX/models/live/danmu_info.dart'; import '../models/live/item.dart'; import '../models/live/room_info.dart'; import '../models/live/room_info_h5.dart'; @@ -65,4 +65,19 @@ class LiveHttp { }; } } + + static Future liveRoomGetDanmakuToken({roomId}) async { + var res = await Request().get(Api.liveRoomDmToken, data: { + 'id': roomId, + }); + if (res.data['code'] == 0) { + return {'status': true, 'data': LiveDanmakuInfo.fromJson(res.data)}; + } else { + return { + 'status': false, + 'data': [], + 'msg': res.data['message'], + }; + } + } } diff --git a/lib/models/live/danmu_info.dart b/lib/models/live/danmu_info.dart new file mode 100644 index 00000000..88401cc7 --- /dev/null +++ b/lib/models/live/danmu_info.dart @@ -0,0 +1,75 @@ +import 'dart:developer'; + +class LiveDanmakuInfo { + String message; + int ttl, code; + DanmakuInfoData data; + LiveDanmakuInfo( + {required this.code, + required this.message, + required this.ttl, + required this.data}); + + @override + String toString() { + return 'LiveDanmakuInfo{code: $code, message: $message, ttl: $ttl, data: $data}'; + } + + factory LiveDanmakuInfo.fromJson(dynamic json) { + List hostList = []; + for (var host in json['data']['host_list']) { + hostList.add(HostInfo( + host: host['host'], + port: host['port'], + wssPort: host['wss_port'], + wsPort: host['ws_port'])); + } + return LiveDanmakuInfo( + code: json['code'], + message: json['message'], + ttl: json['ttl'], + data: DanmakuInfoData( + group: json['data']['group'], + businessId: json['data']['business_id'], + ttl: json['data']['ttl'] ?? 0, + refreshRate: json['data']['refresh_rate'], + maxDelay: json['data']['max_delay'], + token: json['data']['token'], + hostList: hostList)); + } +} + +class DanmakuInfoData { + String group; + int businessId, ttl, refreshRate, maxDelay; + String token; + List hostList; + DanmakuInfoData( + {required this.group, + required this.businessId, + required this.ttl, + required this.refreshRate, + required this.maxDelay, + required this.token, + required this.hostList}); + + @override + String toString() { + return 'DanmakuInfoData{group: $group, businessId: $businessId, ttl: $ttl, refreshRate: $refreshRate, maxDelay: $maxDelay, token: $token, hostList: $hostList}'; + } +} + +class HostInfo { + String host; + int port, wssPort, wsPort; + HostInfo( + {required this.host, + required this.port, + required this.wssPort, + required this.wsPort}); + + @override + String toString() { + return 'HostInfo{host: $host, port: $port, wssPort: $wssPort, wsPort: $wsPort}'; + } +} diff --git a/lib/pages/live_room/view.dart b/lib/pages/live_room/view.dart index 084d948e..2ec2a589 100644 --- a/lib/pages/live_room/view.dart +++ b/lib/pages/live_room/view.dart @@ -1,11 +1,13 @@ import 'dart:io'; +import 'package:PiliPalaX/pages/live_room/widgets/chat.dart'; import 'package:floating/floating.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:PiliPalaX/common/widgets/network_img_layer.dart'; import 'package:PiliPalaX/plugin/pl_player/index.dart'; +import '../../utils/storage.dart'; import 'controller.dart'; import 'widgets/bottom_control.dart'; @@ -229,6 +231,15 @@ class _LiveRoomPageState extends State { child: videoPlayerPanel, ), ), + Container( + height: MediaQuery.of(context).orientation == + Orientation.landscape + ? Get.size.height + : Get.size.height - (Get.size.width * 9 / 16) - 100, + color: const Color(0x10000000), + width: Get.size.width, + child: LiveRoomChat( + roomId: int.parse(Get.parameters['roomid']!))) ], ), ], diff --git a/lib/pages/live_room/widgets/chat.dart b/lib/pages/live_room/widgets/chat.dart new file mode 100644 index 00000000..70475379 --- /dev/null +++ b/lib/pages/live_room/widgets/chat.dart @@ -0,0 +1,121 @@ +import 'dart:convert'; +import 'dart:developer'; + +import 'package:PiliPalaX/http/live.dart'; +import 'package:PiliPalaX/models/live/danmu_info.dart'; +import 'package:PiliPalaX/services/loggeer.dart'; +import 'package:PiliPalaX/tcp/live.dart'; +import 'package:flutter/material.dart'; + +import '../../../utils/storage.dart'; + +class LiveRoomChat extends StatefulWidget { + final int roomId; + const LiveRoomChat({super.key, required this.roomId}); + @override + State createState() => _LiveRoomChatState(); +} + +class _LiveRoomChatState extends State { + String dm = "Danmaku Area"; + final List _items = []; + late LiveMessageStream msgStream; + + @override + Widget build(BuildContext context) { + return ListView.builder( + itemCount: _items.length, + itemBuilder: (ctx, i) { + return _items[i]; + }, + ); //Center(child: Text(dm,style: const TextStyle(color: Colors.red),),); + } + + @override + void initState() { + LiveHttp.liveRoomGetDanmakuToken(roomId: widget.roomId).then((v) { + if (v['status']) { + LiveDanmakuInfo info = v['data']; + // logger.d("info => $info"); + msgStream = LiveMessageStream( + streamToken: info.data.token, + roomId: widget.roomId, + uid: GStorage.userInfo.get('userInfoCache').mid ?? 0, + host: info.data.hostList[0].host, + port: info.data.hostList[0].port); + msgStream.addEventListener((obj) { + if (obj['cmd'] == 'DANMU_MSG') { + // logger.i(' 原始弹幕消息 ======> ${jsonEncode(obj)}'); + setState(() { + var widget = Container( + decoration: const BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(100))), + child: ListTile( + dense: true, + leading: obj['info'][0][15]['user']['medal'] != null + ? IntrinsicWidth( + child: Container( + // width: 70, + height: 20, + padding: const EdgeInsets.symmetric( + horizontal: 5, + ), + color: Color((0xff000000 + + obj['info'][0][15]['user']['medal'] + ['color']) as int), + child: Center( + child: Text( + '${obj['info'][0][15]['user']['medal']['name']} ' + 'lv${obj['info'][0][15]['user']['medal']['level']}', + style: + const TextStyle(color: Color(0xffdddddd)), + ), + ), + ), + ) + : null, + style: ListTileStyle.list, + title: Text.rich( + TextSpan( + children: [ + TextSpan( + text: + '${obj['info'][0][15]['user']['base']['name']}: ', + style: const TextStyle( + color: Color(0xFFAAAAAA), + fontSize: 14, + ), + ), + TextSpan( + text: obj['info'][1], + style: const TextStyle( + color: Color(0xFFFFFFFF), + fontSize: 14, + ), + ), + ], + ), + ), + ), + ); + _items.add(widget); + if (_items.length >= 20) { + _items.removeAt(0); + } + }); + } + }); + msgStream.init(); + } + }); + super.initState(); + } + + @override + void dispose() { + msgStream.close(); + super.dispose(); + } +} + +final PiliLogger logger = getLogger(); diff --git a/lib/router/app_pages.dart b/lib/router/app_pages.dart index c83e2520..01621a76 100644 --- a/lib/router/app_pages.dart +++ b/lib/router/app_pages.dart @@ -200,8 +200,8 @@ class CustomGetPage extends GetPage { this.fullscreen, super.transitionDuration, }) : super( - curve: Curves.linear, - transition: Transition.native, + curve: Curves.easeInOut, + transition: Transition.rightToLeft, showCupertinoParallax: false, popGesture: false, fullscreenDialog: fullscreen != null && fullscreen, diff --git a/lib/tcp/live.dart b/lib/tcp/live.dart new file mode 100644 index 00000000..ed0c9d2e --- /dev/null +++ b/lib/tcp/live.dart @@ -0,0 +1,270 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:PiliPalaX/services/loggeer.dart'; +import 'package:brotli/brotli.dart'; + +class PackageHeader { + int totalSize; + int headerSize; + int protocolVer; + int operationCode; + int seq; + + @override + String toString() { + return 'PackageHeader{totalSize: $totalSize, headerSize: $headerSize, protocolVer: $protocolVer, operationCode: $operationCode, seq: $seq}'; + } + + PackageHeader({ + required this.totalSize, + required this.headerSize, + required this.protocolVer, + required this.operationCode, + required this.seq, + }); + + Uint8List toBytes() { + final buffer = BytesBuilder(); + buffer.add(_int32ToBytes(totalSize)); + buffer.add(_int16ToBytes(headerSize)); + buffer.add(_int16ToBytes(protocolVer)); + buffer.add(_int32ToBytes(operationCode)); + buffer.add(_int32ToBytes(seq)); + return buffer.toBytes(); + } + + List _int32ToBytes(int value) { + final bytes = ByteData(4); + bytes.setInt32(0, value, Endian.big); + return bytes.buffer.asUint8List(); + } + + List _int16ToBytes(int value) { + final bytes = ByteData(2); + bytes.setInt16(0, value, Endian.big); + return bytes.buffer.asUint8List(); + } + + static PackageHeader fromBytesData(Uint8List data) { + if (data.length < 10) { + throw Exception("数据不足以解析PackageHeader"); + } + final byteData = ByteData.sublistView(data); + + int totalSize = byteData.getUint32(0, Endian.big); + int headerSize = byteData.getUint16(4, Endian.big); + int protocolVer = byteData.getUint16(6, Endian.big); + int operationCode = byteData.getUint32(8, Endian.big); + int seq = byteData.getUint32(12, Endian.big); + + return PackageHeader( + totalSize: totalSize, + headerSize: headerSize, + protocolVer: protocolVer, + operationCode: operationCode, + seq: seq, + ); + } +} + +abstract class Message { + String toJsonStr(); + int getMessageSize(); +} + +class AuthMessage implements Message { + int roomid; + int uid; + int protover; + String platform; + int type; + String key; + + AuthMessage({ + required this.roomid, + required this.uid, + required this.protover, + required this.platform, + required this.type, + required this.key, + }); + + @override + String toJsonStr() { + final message = { + 'roomid': roomid, + 'uid': uid, + 'protover': protover, + 'platform': platform, + 'type': type, + 'key': key, + }; + return jsonEncode(message); + } + + @override + int getMessageSize() { + return utf8.encode(toJsonStr()).length; + } +} + +abstract class AbstractPackage { + PackageHeader header; + T body; + Uint8List marshal(); + AbstractPackage({required this.header, required this.body}); +} + +//认证包 +class AuthPackage extends AbstractPackage { + AuthPackage({required super.header, required super.body}); + + @override + Uint8List marshal() { + int size = body.getMessageSize(); + header.headerSize = 0x10; // 固定大小 + size += header.headerSize; + header.totalSize = size; + final buffer = BytesBuilder(); + buffer.add(header.toBytes()); + buffer.add(utf8.encode(body.toJsonStr())); + return buffer.toBytes(); + } +} + +//心跳包 +class HeartbeatPackage extends AbstractPackage { + HeartbeatPackage({required super.header, super.body}); + + @override + Uint8List marshal() { + final buffer = BytesBuilder(); + header.headerSize = 0x10; + header.totalSize = 0x10; + buffer.add(header.toBytes()); + return buffer.toBytes(); + } +} + +class LiveMessageStream { + String streamToken, host; + int roomId, uid, port; + List eventListeners = []; + LiveMessageStream( + {required this.streamToken, + required this.roomId, + required this.uid, + required this.host, + required this.port}); + late Socket socket; + bool heartBeat = true; + PiliLogger logger = getLogger(); + final String logTag = "LiveStreamService"; + + void init() async { + final authPackage = AuthPackage( + header: PackageHeader( + totalSize: 0, + headerSize: 0, + protocolVer: 1, + operationCode: 7, + seq: 1, + ), + body: AuthMessage( + roomid: roomId, + uid: uid, + protover: 3, + platform: 'web', + type: 2, + key: streamToken, + ), + ); + + final marshaledData = authPackage.marshal(); + logger.d(marshaledData); + + try { + socket = await Socket.connect(host, port); + logger.d('$logTag ===> TCP连接建立'); + socket.add(authPackage.marshal()); + logger.d('$logTag ===> 发送认证包'); + await for (var data in socket) { + PackageHeader header = PackageHeader.fromBytesData(data); + List decompressedData = []; + //心跳包回复不用处理 + if (header.operationCode == 3) continue; + if (header.operationCode == 8) { + _heartBeat(); + } + try { + switch (header.protocolVer) { + case 0: + case 1: + _processingData(data); + continue; + case 2: + decompressedData = ZLibDecoder().convert(data.sublist(0x10)); + break; + case 3: + decompressedData = + const BrotliDecoder().convert(data.sublist(0x10)); + //print('Body: ${utf8.decode()}'); + } + _processingData(decompressedData); + } catch (e) { + logger.w(e); + } + } + socket.close(); + } catch (e) { + logger.e('$logTag ===> TCP连接失败: $e'); + } + } + + void _processingData(List data) { + try { + var subHeader = PackageHeader.fromBytesData(Uint8List.fromList(data)); + var msgBody = + utf8.decode(data.sublist(subHeader.headerSize, subHeader.totalSize)); + for (var f in eventListeners) { + f(jsonDecode(msgBody)); + } + if (subHeader.totalSize < data.length) { + _processingData(data.sublist(subHeader.totalSize)); + } + } catch (e) { + logger.e('ParseHeader错误: $e'); + } + } + + void _heartBeat() async { + logger.i("$logTag 直播间信息流认证成功"); + int heartBeatCount = 1; + while (heartBeat) { + await Future.delayed(const Duration(seconds: 30)); + //发送心跳包 + var package = HeartbeatPackage( + header: PackageHeader( + totalSize: 0, + headerSize: 0, + protocolVer: 1, + operationCode: 2, + seq: heartBeatCount)); + try { + socket.add(package.marshal()); + } catch (_) {} + heartBeatCount++; + } + } + + void addEventListener(void Function(dynamic) func) { + eventListeners.add(func); + } + + void close() { + socket.close(); + heartBeat = false; + } +} diff --git a/pubspec.lock b/pubspec.lock index 3daffaac..fe810ead 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -151,6 +151,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + brotli: + dependency: "direct main" + description: + name: brotli + sha256: "7f891558ed779aab2bed874f0a36b8123f9ff3f19cf6efbee89e18ed294945ae" + url: "https://pub.dev" + source: hosted + version: "0.6.0" build: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 8e62ccaf..5648a207 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -167,6 +167,9 @@ dependencies: flutter_svg: ^2.0.10+1 image_cropper: ^8.0.2 + #解压直播消息 + brotli: ^0.6.0 + dependency_overrides: screen_brightness: ^2.0.0+2 path: 1.9.1