feat: 初步添加直播弹幕支持

pick from cf87904fc6

opt: live danmaku style
This commit is contained in:
foxtoy
2024-08-29 18:00:42 +08:00
committed by bggRGjQaUbCoE
parent a119050944
commit 08e7e7b6e8
9 changed files with 510 additions and 3 deletions

View File

@@ -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';

View File

@@ -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'],
};
}
}
}

View File

@@ -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<HostInfo> 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<HostInfo> 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}';
}
}

View File

@@ -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<LiveRoomPage> {
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']!)))
],
),
],

View File

@@ -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<LiveRoomChat> createState() => _LiveRoomChatState();
}
class _LiveRoomChatState extends State<LiveRoomChat> {
String dm = "Danmaku Area";
final List<Widget> _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();

View File

@@ -200,8 +200,8 @@ class CustomGetPage extends GetPage<dynamic> {
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,

270
lib/tcp/live.dart Normal file
View File

@@ -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<int> _int32ToBytes(int value) {
final bytes = ByteData(4);
bytes.setInt32(0, value, Endian.big);
return bytes.buffer.asUint8List();
}
List<int> _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<T> {
PackageHeader header;
T body;
Uint8List marshal();
AbstractPackage({required this.header, required this.body});
}
//认证包
class AuthPackage extends AbstractPackage<Message> {
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<dynamic> {
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<void Function(dynamic obj)> 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<int> 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<int> 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;
}
}