mirror of
https://github.com/HChaZZY/PiliPlus.git
synced 2025-12-06 09:13:48 +08:00
@@ -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';
|
||||
|
||||
@@ -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'],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
75
lib/models/live/danmu_info.dart
Normal file
75
lib/models/live/danmu_info.dart
Normal 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}';
|
||||
}
|
||||
}
|
||||
@@ -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']!)))
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
121
lib/pages/live_room/widgets/chat.dart
Normal file
121
lib/pages/live_room/widgets/chat.dart
Normal 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();
|
||||
@@ -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
270
lib/tcp/live.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user