Merge branch 'feature-liveDanmaku'

This commit is contained in:
guozhigq
2024-08-26 00:16:37 +08:00
13 changed files with 1140 additions and 39 deletions

View File

@ -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 = [];

View File

@ -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),

View File

@ -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();
}
}

View File

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