feat: send live emote

Signed-off-by: bggRGjQaUbCoE <githubaccount56556@proton.me>
This commit is contained in:
bggRGjQaUbCoE
2025-04-13 13:46:11 +08:00
parent 634bae915a
commit 671b6e1ef7
21 changed files with 908 additions and 526 deletions

View File

@@ -757,4 +757,7 @@ class Api {
static const String replyTop = '/x/v2/reply/top';
static const String getCoin = '${HttpString.accountBaseUrl}/site/getCoin';
static const String getLiveEmoticons =
'${HttpString.liveBaseUrl}/xlive/web-ucenter/v2/emoticon/GetEmoticons';
}

View File

@@ -2,6 +2,8 @@ import 'package:PiliPlus/common/constants.dart';
import 'package:PiliPlus/http/loading_state.dart';
import 'package:PiliPlus/models/live/danmu_info.dart';
import 'package:PiliPlus/models/live/follow.dart';
import 'package:PiliPlus/models/live/live_emoticons/data.dart';
import 'package:PiliPlus/models/live/live_emoticons/datum.dart';
import 'package:dio/dio.dart';
import '../models/live/item.dart';
import '../models/live/room_info.dart';
@@ -24,35 +26,34 @@ class LiveHttp {
}
}
static Future sendLiveMsg({
roomId,
msg,
}) async {
static Future sendLiveMsg({roomId, msg, dmType, emoticonOptions}) async {
dynamic csrf = await Request.getCsrf();
var res = await Request().post(
Api.sendLiveMsg,
data: {
data: FormData.fromMap({
'bubble': 0,
'msg': msg,
'color': 16777215,
'mode': 1,
'room_type': 0,
'jumpfrom': 71000,
'reply_mid': 0,
'reply_attr': 0,
'replay_dmid': '',
'statistics': Constants.statistics,
'reply_type': 0,
'reply_uname': '',
if (dmType != null) 'dm_type': dmType,
if (emoticonOptions != null)
'emoticonOptions': emoticonOptions
else ...{
'room_type': 0,
'jumpfrom': 0,
'reply_mid': 0,
'reply_attr': 0,
'replay_dmid': '',
'statistics': Constants.statistics,
'reply_type': 0,
'reply_uname': '',
},
'fontsize': 25,
'rnd': DateTime.now().millisecondsSinceEpoch ~/ 1000,
'roomid': roomId,
'csrf': csrf,
'csrf_token': csrf,
},
options: Options(
contentType: Headers.formUrlEncodedContentType,
),
}),
);
if (res.data['code'] == 0) {
return {
@@ -146,4 +147,21 @@ class LiveHttp {
};
}
}
static Future<LoadingState<List<LiveEmoteDatum>?>> getLiveEmoticons(
{required int roomId}) async {
var res = await Request().get(
Api.getLiveEmoticons,
queryParameters: {
'platform': 'pc',
'room_id': roomId,
},
);
if (res.data['code'] == 0) {
return LoadingState.success(
LiveEmoteData.fromJson(res.data['data']).data);
} else {
return LoadingState.error(res.data['message']);
}
}
}

View File

@@ -0,0 +1,23 @@
import 'datum.dart';
class LiveEmoteData {
int? fansBrand;
List<LiveEmoteDatum>? data;
dynamic purchaseUrl;
LiveEmoteData({this.fansBrand, this.data, this.purchaseUrl});
factory LiveEmoteData.fromJson(Map<String, dynamic> json) => LiveEmoteData(
fansBrand: json['fans_brand'] as int?,
data: (json['data'] as List<dynamic>?)
?.map((e) => LiveEmoteDatum.fromJson(e as Map<String, dynamic>))
.toList(),
purchaseUrl: json['purchase_url'] as dynamic,
);
Map<String, dynamic> toJson() => {
'fans_brand': fansBrand,
'data': data?.map((e) => e.toJson()).toList(),
'purchase_url': purchaseUrl,
};
}

View File

@@ -0,0 +1,71 @@
import 'emoticon.dart';
import 'top_show.dart';
import 'top_show_recent.dart';
class LiveEmoteDatum {
List<LiveEmoticon>? emoticons;
int? pkgId;
String? pkgName;
int? pkgType;
String? pkgDescript;
int? pkgPerm;
int? unlockIdentity;
int? unlockNeedGift;
String? currentCover;
List<dynamic>? recentlyUsedEmoticons;
TopShow? topShow;
TopShowRecent? topShowRecent;
LiveEmoteDatum({
this.emoticons,
this.pkgId,
this.pkgName,
this.pkgType,
this.pkgDescript,
this.pkgPerm,
this.unlockIdentity,
this.unlockNeedGift,
this.currentCover,
this.recentlyUsedEmoticons,
this.topShow,
this.topShowRecent,
});
factory LiveEmoteDatum.fromJson(Map<String, dynamic> json) => LiveEmoteDatum(
emoticons: (json['emoticons'] as List<dynamic>?)
?.map((e) => LiveEmoticon.fromJson(e as Map<String, dynamic>))
.toList(),
pkgId: json['pkg_id'] as int?,
pkgName: json['pkg_name'] as String?,
pkgType: json['pkg_type'] as int?,
pkgDescript: json['pkg_descript'] as String?,
pkgPerm: json['pkg_perm'] as int?,
unlockIdentity: json['unlock_identity'] as int?,
unlockNeedGift: json['unlock_need_gift'] as int?,
currentCover: json['current_cover'] as String?,
recentlyUsedEmoticons:
json['recently_used_emoticons'] as List<dynamic>?,
topShow: json['top_show'] == null
? null
: TopShow.fromJson(json['top_show'] as Map<String, dynamic>),
topShowRecent: json['top_show_recent'] == null
? null
: TopShowRecent.fromJson(
json['top_show_recent'] as Map<String, dynamic>),
);
Map<String, dynamic> toJson() => {
'emoticons': emoticons?.map((e) => e.toJson()).toList(),
'pkg_id': pkgId,
'pkg_name': pkgName,
'pkg_type': pkgType,
'pkg_descript': pkgDescript,
'pkg_perm': pkgPerm,
'unlock_identity': unlockIdentity,
'unlock_need_gift': unlockNeedGift,
'current_cover': currentCover,
'recently_used_emoticons': recentlyUsedEmoticons,
'top_show': topShow?.toJson(),
'top_show_recent': topShowRecent?.toJson(),
};
}

View File

@@ -0,0 +1,83 @@
class LiveEmoticon {
String? emoji;
String? descript;
String? url;
int? isDynamic;
int? inPlayerArea;
int? width;
int? height;
int? identity;
int? unlockNeedGift;
int? perm;
int? unlockNeedLevel;
int? emoticonValueType;
int? bulgeDisplay;
String? unlockShowText;
String? unlockShowColor;
String? emoticonUnique;
String? unlockShowImage;
int? emoticonId;
LiveEmoticon({
this.emoji,
this.descript,
this.url,
this.isDynamic,
this.inPlayerArea,
this.width,
this.height,
this.identity,
this.unlockNeedGift,
this.perm,
this.unlockNeedLevel,
this.emoticonValueType,
this.bulgeDisplay,
this.unlockShowText,
this.unlockShowColor,
this.emoticonUnique,
this.unlockShowImage,
this.emoticonId,
});
factory LiveEmoticon.fromJson(Map<String, dynamic> json) => LiveEmoticon(
emoji: json['emoji'] as String?,
descript: json['descript'] as String?,
url: json['url'] as String?,
isDynamic: json['is_dynamic'] as int?,
inPlayerArea: json['in_player_area'] as int?,
width: json['width'] as int? ?? 0,
height: json['height'] as int? ?? 0,
identity: json['identity'] as int?,
unlockNeedGift: json['unlock_need_gift'] as int?,
perm: json['perm'] as int?,
unlockNeedLevel: json['unlock_need_level'] as int?,
emoticonValueType: json['emoticon_value_type'] as int?,
bulgeDisplay: json['bulge_display'] as int?,
unlockShowText: json['unlock_show_text'] as String?,
unlockShowColor: json['unlock_show_color'] as String?,
emoticonUnique: json['emoticon_unique'] as String?,
unlockShowImage: json['unlock_show_image'] as String?,
emoticonId: json['emoticon_id'] as int?,
);
Map<String, dynamic> toJson() => {
'emoji': emoji,
'descript': descript,
'url': url,
'is_dynamic': isDynamic,
'in_player_area': inPlayerArea,
'width': width,
'height': height,
'identity': identity,
'unlock_need_gift': unlockNeedGift,
'perm': perm,
'unlock_need_level': unlockNeedLevel,
'emoticon_value_type': emoticonValueType,
'bulge_display': bulgeDisplay,
'unlock_show_text': unlockShowText,
'unlock_show_color': unlockShowColor,
'emoticon_unique': emoticonUnique,
'unlock_show_image': unlockShowImage,
'emoticon_id': emoticonId,
};
}

View File

@@ -0,0 +1,26 @@
import 'data.dart';
class LiveEmoticons {
int? code;
String? message;
int? ttl;
LiveEmoteData? data;
LiveEmoticons({this.code, this.message, this.ttl, this.data});
factory LiveEmoticons.fromJson(Map<String, dynamic> json) => LiveEmoticons(
code: json['code'] as int?,
message: json['message'] as String?,
ttl: json['ttl'] as int?,
data: json['data'] == null
? null
: LiveEmoteData.fromJson(json['data'] as Map<String, dynamic>),
);
Map<String, dynamic> toJson() => {
'code': code,
'message': message,
'ttl': ttl,
'data': data?.toJson(),
};
}

View File

@@ -0,0 +1,16 @@
class TopLeft {
String? image;
String? text;
TopLeft({this.image, this.text});
factory TopLeft.fromJson(Map<String, dynamic> json) => TopLeft(
image: json['image'] as String?,
text: json['text'] as String?,
);
Map<String, dynamic> toJson() => {
'image': image,
'text': text,
};
}

View File

@@ -0,0 +1,16 @@
class TopRight {
String? image;
String? text;
TopRight({this.image, this.text});
factory TopRight.fromJson(Map<String, dynamic> json) => TopRight(
image: json['image'] as String?,
text: json['text'] as String?,
);
Map<String, dynamic> toJson() => {
'image': image,
'text': text,
};
}

View File

@@ -0,0 +1,23 @@
import 'top_left.dart';
import 'top_right.dart';
class TopShow {
TopLeft? topLeft;
TopRight? topRight;
TopShow({this.topLeft, this.topRight});
factory TopShow.fromJson(Map<String, dynamic> json) => TopShow(
topLeft: json['top_left'] == null
? null
: TopLeft.fromJson(json['top_left'] as Map<String, dynamic>),
topRight: json['top_right'] == null
? null
: TopRight.fromJson(json['top_right'] as Map<String, dynamic>),
);
Map<String, dynamic> toJson() => {
'top_left': topLeft?.toJson(),
'top_right': topRight?.toJson(),
};
}

View File

@@ -0,0 +1,23 @@
import 'top_left.dart';
import 'top_right.dart';
class TopShowRecent {
TopLeft? topLeft;
TopRight? topRight;
TopShowRecent({this.topLeft, this.topRight});
factory TopShowRecent.fromJson(Map<String, dynamic> json) => TopShowRecent(
topLeft: json['top_left'] == null
? null
: TopLeft.fromJson(json['top_left'] as Map<String, dynamic>),
topRight: json['top_right'] == null
? null
: TopRight.fromJson(json['top_right'] as Map<String, dynamic>),
);
Map<String, dynamic> toJson() => {
'top_left': topLeft?.toJson(),
'top_right': topRight?.toJson(),
};
}

View File

@@ -7,6 +7,7 @@ import 'package:PiliPlus/common/widgets/icon_button.dart';
import 'package:PiliPlus/common/widgets/interactiveviewer_gallery/interactiveviewer_gallery.dart'
show SourceModel, SourceType;
import 'package:PiliPlus/http/msg.dart';
import 'package:PiliPlus/models/live/live_emoticons/emoticon.dart';
import 'package:PiliPlus/utils/extension.dart';
import 'package:chat_bottom_container/chat_bottom_container.dart';
import 'package:dio/dio.dart';
@@ -197,18 +198,29 @@ abstract class CommonPublishPageState<T extends CommonPublishPage>
Future onCustomPublish({required String message, List? pictures});
void onChooseEmote(Packages package, Emote emote) {
void onChooseEmote(emote) {
enablePublish.value = true;
final int cursorPosition = editController.selection.baseOffset;
final String currentText = editController.text;
final String newText = currentText.substring(0, cursorPosition) +
emote.text! +
currentText.substring(cursorPosition);
editController.value = TextEditingValue(
text: newText,
selection:
TextSelection.collapsed(offset: cursorPosition + emote.text!.length),
);
if (emote is Emote) {
final String newText = currentText.substring(0, cursorPosition) +
emote.text! +
currentText.substring(cursorPosition);
editController.value = TextEditingValue(
text: newText,
selection: TextSelection.collapsed(
offset: cursorPosition + emote.text!.length),
);
} else if (emote is LiveEmoticon) {
final String newText = currentText.substring(0, cursorPosition) +
emote.emoji! +
currentText.substring(cursorPosition);
editController.value = TextEditingValue(
text: newText,
selection: TextSelection.collapsed(
offset: cursorPosition + emote.emoji!.length),
);
}
widget.onSave?.call(editController.text);
}

View File

@@ -8,7 +8,7 @@ import 'controller.dart';
import 'package:PiliPlus/common/widgets/scroll_physics.dart';
class EmotePanel extends StatefulWidget {
final Function onChoose;
final ValueChanged<Emote> onChoose;
const EmotePanel({super.key, required this.onChoose});
@override
@@ -60,7 +60,7 @@ class _EmotePanelState extends State<EmotePanel>
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () {
widget.onChoose(e, e.emote![index]);
widget.onChoose(e.emote![index]);
},
child: Padding(
padding: const EdgeInsets.all(6),

View File

@@ -0,0 +1,41 @@
import 'package:PiliPlus/http/live.dart';
import 'package:PiliPlus/http/loading_state.dart';
import 'package:PiliPlus/models/live/live_emoticons/datum.dart';
import 'package:PiliPlus/pages/common/common_list_controller.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class LiveEmotePanelController
extends CommonListController<List<LiveEmoteDatum>?, LiveEmoteDatum>
with GetTickerProviderStateMixin {
LiveEmotePanelController(this.roomId);
final int roomId;
TabController? tabController;
@override
void onInit() {
super.onInit();
queryData();
}
@override
bool customHandleResponse(
bool isRefresh, Success<List<LiveEmoteDatum>?> response) {
if (response.response?.isNotEmpty == true) {
tabController =
TabController(length: response.response!.length, vsync: this);
}
loadingState.value = response;
return true;
}
@override
Future<LoadingState<List<LiveEmoteDatum>?>> customGetData() =>
LiveHttp.getLiveEmoticons(roomId: roomId);
@override
void onClose() {
tabController?.dispose();
super.onClose();
}
}

View File

@@ -0,0 +1,4 @@
library emote;
export 'controller.dart';
export 'view.dart';

View File

@@ -0,0 +1,148 @@
import 'dart:math';
import 'package:PiliPlus/common/widgets/loading_widget.dart';
import 'package:PiliPlus/http/loading_state.dart';
import 'package:PiliPlus/models/live/live_emoticons/datum.dart';
import 'package:PiliPlus/models/live/live_emoticons/emoticon.dart';
import 'package:PiliPlus/utils/extension.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../common/widgets/network_img_layer.dart';
import 'controller.dart';
import 'package:PiliPlus/common/widgets/scroll_physics.dart';
class LiveEmotePanel extends StatefulWidget {
final int roomId;
final ValueChanged<LiveEmoticon> onChoose;
final ValueChanged<LiveEmoticon> onSendEmoticonUnique;
const LiveEmotePanel({
super.key,
required this.roomId,
required this.onChoose,
required this.onSendEmoticonUnique,
});
@override
State<LiveEmotePanel> createState() => _LiveEmotePanelState();
}
class _LiveEmotePanelState extends State<LiveEmotePanel>
with AutomaticKeepAliveClientMixin {
late final LiveEmotePanelController _emotePanelController = Get.put(
LiveEmotePanelController(widget.roomId),
tag: widget.roomId.toString(),
);
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context);
return Obx(() => _buildBody(_emotePanelController.loadingState.value));
}
Widget _buildBody(LoadingState<List<LiveEmoteDatum>?> loadingState) {
return switch (loadingState) {
Loading() => loadingWidget,
Success() => loadingState.response?.isNotEmpty == true
? Column(
children: [
Expanded(
child: tabBarView(
controller: _emotePanelController.tabController,
children: loadingState.response!.map(
(item) {
if (item.emoticons.isNullOrEmpty) {
return const SizedBox.shrink();
}
double widthFac =
max(1, item.emoticons!.first.width! / 80);
double heightFac =
max(1, item.emoticons!.first.height! / 80);
return Padding(
padding: const EdgeInsets.fromLTRB(12, 6, 12, 0),
child: GridView.builder(
gridDelegate:
SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: widthFac * 40,
mainAxisExtent: heightFac * 40,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: item.emoticons!.length,
itemBuilder: (context, index) {
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () {
if (item.pkgType == 3) {
widget.onChoose(item.emoticons![index]);
} else {
widget.onSendEmoticonUnique(
item.emoticons![index],
);
}
},
child: Padding(
padding: const EdgeInsets.all(6),
child: NetworkImgLayer(
src: item.emoticons![index].url!,
width: widthFac * 38,
height: heightFac * 38,
type: 'emote',
quality: item.pkgType == 3 ? null : 80,
),
),
),
);
},
),
);
},
).toList(),
),
),
Divider(
height: 1,
color: Theme.of(context).dividerColor.withOpacity(0.1),
),
TabBar(
controller: _emotePanelController.tabController,
padding: const EdgeInsets.only(right: 60),
dividerColor: Colors.transparent,
dividerHeight: 0,
isScrollable: true,
tabs: loadingState.response!
.map(
(item) => Padding(
padding: const EdgeInsets.all(8),
child: NetworkImgLayer(
width: 24,
height: 24,
type: 'emote',
src: item.currentCover,
),
),
)
.toList(),
),
SizedBox(height: MediaQuery.of(context).padding.bottom),
],
)
: _errorWidget,
Error() => _errorWidget,
LoadingState() => throw UnimplementedError(),
};
}
Widget get _errorWidget => Center(
child: IconButton(
onPressed: () {
_emotePanelController.onReload();
},
icon: Icon(Icons.refresh),
),
);
}

View File

@@ -4,7 +4,6 @@ import 'package:PiliPlus/http/video.dart';
import 'package:PiliPlus/models/live/danmu_info.dart';
import 'package:PiliPlus/models/live/quality.dart';
import 'package:PiliPlus/pages/mine/controller.dart';
import 'package:PiliPlus/pages/video/detail/widgets/send_danmaku_panel.dart';
import 'package:PiliPlus/services/service_locator.dart';
import 'package:PiliPlus/tcp/live.dart';
import 'package:PiliPlus/utils/danmaku.dart';
@@ -13,13 +12,11 @@ import 'package:canvas_danmaku/canvas_danmaku.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
import 'package:PiliPlus/http/constants.dart';
import 'package:PiliPlus/http/live.dart';
import 'package:PiliPlus/models/live/room_info.dart';
import 'package:PiliPlus/plugin/pl_player/index.dart';
import 'package:get/get_navigation/src/dialog/dialog_route.dart';
import '../../models/live/room_info_h5.dart';
import '../../utils/video_utils.dart';
@@ -248,41 +245,4 @@ class LiveRoomController extends GetxController {
.description;
await queryLiveInfo();
}
void onSendDanmaku() {
if (!isLogin) {
SmartDialog.showToast('未登录');
return;
}
Navigator.of(Get.context!).push(
GetDialogRoute(
pageBuilder: (buildContext, animation, secondaryAnimation) {
return SendDanmakuPanel(
roomId: roomId,
initialValue: savedDanmaku,
onSave: (danmaku) => savedDanmaku = danmaku,
callback: (danmakuModel) {
savedDanmaku = null;
plPlayerController.danmakuController?.addDanmaku(danmakuModel);
},
darkVideoPage: false,
);
},
transitionDuration: const Duration(milliseconds: 500),
transitionBuilder: (context, animation, secondaryAnimation, child) {
const begin = Offset(0.0, 1.0);
const end = Offset.zero;
const curve = Curves.linear;
var tween =
Tween(begin: begin, end: end).chain(CurveTween(curve: curve));
return SlideTransition(
position: animation.drive(tween),
child: child,
);
},
),
);
}
}

View File

@@ -0,0 +1,247 @@
import 'dart:async';
import 'package:PiliPlus/http/live.dart';
import 'package:PiliPlus/pages/common/common_publish_page.dart';
import 'package:PiliPlus/pages/live_emote/controller.dart';
import 'package:PiliPlus/pages/live_emote/view.dart';
import 'package:PiliPlus/pages/live_room/controller.dart';
import 'package:PiliPlus/pages/video/detail/reply_new/toolbar_icon_button.dart';
import 'package:canvas_danmaku/models/danmaku_content_item.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart' hide MultipartFile;
class LiveSendDmPanel extends CommonPublishPage {
final bool fromEmote;
final LiveRoomController liveRoomController;
const LiveSendDmPanel({
super.key,
super.initialValue,
super.onSave,
this.fromEmote = false,
required this.liveRoomController,
});
@override
State<LiveSendDmPanel> createState() => _ReplyPageState();
}
class _ReplyPageState extends CommonPublishPageState<LiveSendDmPanel> {
LiveRoomController get liveRoomController => widget.liveRoomController;
@override
void initState() {
super.initState();
if (widget.fromEmote) {
selectKeyboard.value = false;
updatePanelType(PanelType.emoji);
}
}
@override
void dispose() {
Get.delete<LiveEmotePanelController>(
tag: liveRoomController.roomId.toString());
super.dispose();
}
@override
Widget build(BuildContext context) {
return MediaQuery.removePadding(
removeTop: true,
context: context,
child: GestureDetector(
onTap: Get.back,
child: LayoutBuilder(
builder: (context, constraints) {
bool isH = constraints.maxWidth > constraints.maxHeight;
late double padding = constraints.maxWidth * 0.12;
return Padding(
padding: EdgeInsets.symmetric(horizontal: isH ? padding : 0),
child: Scaffold(
resizeToAvoidBottomInset: false,
backgroundColor: Colors.transparent,
body: GestureDetector(
onTap: () {},
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
buildInputView(),
buildPanelContainer(
Theme.of(context).colorScheme.surface),
],
),
),
),
);
},
),
),
);
}
@override
Widget? customPanel(double height) => SizedBox(
height: height,
child: LiveEmotePanel(
onChoose: onChooseEmote,
roomId: liveRoomController.roomId,
onSendEmoticonUnique: (emote) {
onCustomPublish(
message: emote.emoticonUnique!,
dmType: 1,
emoticonOptions: '[object Object]',
);
},
),
);
Widget buildInputView() {
return Container(
clipBehavior: Clip.hardEdge,
margin: EdgeInsets.only(top: MediaQuery.of(context).padding.top),
decoration: BoxDecoration(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
color: Theme.of(context).colorScheme.surface,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding:
const EdgeInsets.only(top: 12, right: 15, left: 15, bottom: 10),
child: Form(
autovalidateMode: AutovalidateMode.onUserInteraction,
child: Listener(
onPointerUp: (event) {
if (readOnly.value) {
updatePanelType(PanelType.keyboard);
selectKeyboard.value = true;
}
},
child: Obx(
() => TextField(
controller: editController,
minLines: 1,
maxLines: 2,
autofocus: false,
readOnly: readOnly.value,
onChanged: (value) {
bool isEmpty = value.trim().isEmpty;
if (!isEmpty && !enablePublish.value) {
enablePublish.value = true;
} else if (isEmpty && enablePublish.value) {
enablePublish.value = false;
}
liveRoomController.savedDanmaku = value;
},
focusNode: focusNode,
decoration: InputDecoration(
hintText: "输入弹幕内容",
border: InputBorder.none,
hintStyle: TextStyle(fontSize: 14)),
style: Theme.of(context).textTheme.bodyLarge,
inputFormatters: [LengthLimitingTextInputFormatter(20)],
),
),
),
),
),
Divider(
height: 1,
color: Theme.of(context).dividerColor.withOpacity(0.1),
),
Container(
height: 52,
padding: const EdgeInsets.only(left: 12, right: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Obx(
() => ToolbarIconButton(
tooltip: '输入',
onPressed: () {
if (!selectKeyboard.value) {
selectKeyboard.value = true;
updatePanelType(PanelType.keyboard);
}
},
icon: const Icon(Icons.keyboard, size: 22),
selected: selectKeyboard.value,
),
),
const SizedBox(width: 10),
Obx(
() => ToolbarIconButton(
tooltip: '表情',
onPressed: () {
if (selectKeyboard.value) {
selectKeyboard.value = false;
updatePanelType(PanelType.emoji);
}
},
icon: const Icon(Icons.emoji_emotions, size: 22),
selected: !selectKeyboard.value,
),
),
const Spacer(),
Obx(
() => FilledButton.tonal(
onPressed: enablePublish.value ? onPublish : null,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 20, vertical: 10),
visualDensity: const VisualDensity(
horizontal: -2,
vertical: -2,
),
),
child: const Text('发送'),
),
),
],
),
),
],
),
);
}
@override
Future onCustomPublish({
required String message,
List? pictures,
int? dmType,
emoticonOptions,
}) async {
if (!liveRoomController.isLogin) {
SmartDialog.showToast('未登录');
return;
}
final res = await LiveHttp.sendLiveMsg(
roomId: liveRoomController.roomId,
msg: message,
dmType: dmType,
emoticonOptions: emoticonOptions,
);
if (res['status']) {
Get.back();
liveRoomController.savedDanmaku = null;
SmartDialog.showToast('发送成功');
liveRoomController.plPlayerController.danmakuController?.addDanmaku(
DanmakuContentItem(
message,
type: DanmakuItemType.scroll,
selfSend: true,
),
);
} else {
SmartDialog.showToast(res['msg']);
}
}
}

View File

@@ -2,7 +2,7 @@ import 'dart:async';
import 'dart:io';
import 'dart:ui';
import 'package:PiliPlus/http/live.dart';
import 'package:PiliPlus/pages/live_room/send_dm_panel.dart';
import 'package:PiliPlus/pages/live_room/widgets/chat.dart';
import 'package:PiliPlus/pages/live_room/widgets/header_control.dart';
import 'package:PiliPlus/services/service_locator.dart';
@@ -13,7 +13,6 @@ import 'package:canvas_danmaku/canvas_danmaku.dart';
import 'package:floating/floating.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
import 'package:PiliPlus/common/widgets/network_img_layer.dart';
import 'package:PiliPlus/plugin/pl_player/index.dart';
@@ -44,8 +43,6 @@ class _LiveRoomPageState extends State<LiveRoomPage>
bool isPlay = true;
Floating? floating;
late final _node = FocusNode();
late final _ctr = TextEditingController();
StreamSubscription? _listener;
int latestAddedPosition = -1;
@@ -128,10 +125,8 @@ class _LiveRoomPageState extends State<LiveRoomPage>
PlPlayerController.setPlayCallBack(null);
_liveRoomController.msgStream?.close();
// floating?.dispose();
_node.dispose();
plPlayerController.removeStatusLister(playerListener);
plPlayerController.dispose();
_ctr.dispose();
super.dispose();
}
@@ -157,66 +152,60 @@ class _LiveRoomPageState extends State<LiveRoomPage>
plPlayerController.triggerFullScreen(status: false);
}
},
child: Listener(
onPointerDown: (_) {
_node.unfocus();
},
child: FutureBuilder(
key: videoPlayerKey,
future: _futureBuilderFuture,
builder: (BuildContext context, AsyncSnapshot snapshot) {
if (snapshot.hasData && snapshot.data['status']) {
return PLVideoPlayer(
key: playerKey,
fill: fill,
alignment: alignment,
child: FutureBuilder(
key: videoPlayerKey,
future: _futureBuilderFuture,
builder: (BuildContext context, AsyncSnapshot snapshot) {
if (snapshot.hasData && snapshot.data['status']) {
return PLVideoPlayer(
key: playerKey,
fill: fill,
alignment: alignment,
plPlayerController: plPlayerController,
headerControl: LiveHeaderControl(
plPlayerController: plPlayerController,
headerControl: LiveHeaderControl(
plPlayerController: plPlayerController,
floating: floating,
onSendDanmaku: _liveRoomController.onSendDanmaku,
),
bottomControl: BottomControl(
plPlayerController: plPlayerController,
liveRoomCtr: _liveRoomController,
onRefresh: () {
_futureBuilderFuture = _liveRoomController.queryLiveInfo();
},
),
danmuWidget: Obx(
() => AnimatedOpacity(
opacity: plPlayerController.isOpenDanmu.value ? 1 : 0,
duration: const Duration(milliseconds: 100),
child: DanmakuScreen(
createdController: (DanmakuController e) {
plPlayerController.danmakuController =
_liveRoomController.controller = e;
},
option: DanmakuOption(
fontSize: _getFontSize(isFullScreen),
fontWeight: plPlayerController.fontWeight,
area: plPlayerController.showArea,
opacity: plPlayerController.opacity,
hideTop: plPlayerController.blockTypes.contains(5),
hideScroll: plPlayerController.blockTypes.contains(2),
hideBottom: plPlayerController.blockTypes.contains(4),
duration: plPlayerController.danmakuDuration /
plPlayerController.playbackSpeed,
staticDuration:
plPlayerController.danmakuStaticDuration /
plPlayerController.playbackSpeed,
strokeWidth: plPlayerController.strokeWidth,
lineHeight: plPlayerController.danmakuLineHeight,
),
floating: floating,
onSendDanmaku: onSendDanmaku,
),
bottomControl: BottomControl(
plPlayerController: plPlayerController,
liveRoomCtr: _liveRoomController,
onRefresh: () {
_futureBuilderFuture = _liveRoomController.queryLiveInfo();
},
),
danmuWidget: Obx(
() => AnimatedOpacity(
opacity: plPlayerController.isOpenDanmu.value ? 1 : 0,
duration: const Duration(milliseconds: 100),
child: DanmakuScreen(
createdController: (DanmakuController e) {
plPlayerController.danmakuController =
_liveRoomController.controller = e;
},
option: DanmakuOption(
fontSize: _getFontSize(isFullScreen),
fontWeight: plPlayerController.fontWeight,
area: plPlayerController.showArea,
opacity: plPlayerController.opacity,
hideTop: plPlayerController.blockTypes.contains(5),
hideScroll: plPlayerController.blockTypes.contains(2),
hideBottom: plPlayerController.blockTypes.contains(4),
duration: plPlayerController.danmakuDuration /
plPlayerController.playbackSpeed,
staticDuration: plPlayerController.danmakuStaticDuration /
plPlayerController.playbackSpeed,
strokeWidth: plPlayerController.strokeWidth,
lineHeight: plPlayerController.danmakuLineHeight,
),
),
),
);
} else {
return const SizedBox();
}
},
),
),
);
} else {
return const SizedBox();
}
},
),
);
}
@@ -361,7 +350,7 @@ class _LiveRoomPageState extends State<LiveRoomPage>
);
}
Color get _color => Color(0xFFEEEEEE);
final Color _color = Color(0xFFEEEEEE);
PreferredSizeWidget get _buildAppBar => AppBar(
backgroundColor: Colors.transparent,
@@ -381,7 +370,6 @@ class _LiveRoomPageState extends State<LiveRoomPage>
children: [
GestureDetector(
onTap: () {
_node.unfocus();
dynamic uid =
_liveRoomController.roomInfoH5.value.roomInfo?.uid;
Get.toNamed(
@@ -481,15 +469,12 @@ class _LiveRoomPageState extends State<LiveRoomPage>
),
),
Expanded(
child: Scaffold(
backgroundColor: Colors.transparent,
body: SafeArea(
left: false,
top: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: _buildBottomWidget,
),
child: SafeArea(
left: false,
top: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: _buildBottomWidget,
),
),
),
@@ -517,18 +502,13 @@ class _LiveRoomPageState extends State<LiveRoomPage>
_buildInputWidget,
];
Widget _buildChatWidget([bool? isPP]) => Listener(
onPointerDown: (_) {
_node.unfocus();
},
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: LiveRoomChat(
key: chatKey,
isPP: isPP,
roomId: _roomId,
liveRoomController: _liveRoomController,
),
Widget _buildChatWidget([bool? isPP]) => Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: LiveRoomChat(
key: chatKey,
isPP: isPP,
roomId: _roomId,
liveRoomController: _liveRoomController,
),
);
@@ -568,60 +548,43 @@ class _LiveRoomPageState extends State<LiveRoomPage>
),
),
Expanded(
child: TextField(
focusNode: _node,
controller: _ctr,
textInputAction: TextInputAction.send,
cursorColor: _color,
style: TextStyle(color: _color),
onSubmitted: (value) {
if (value.isNotEmpty) {
_onSendMsg(value);
}
},
decoration: InputDecoration(
border: InputBorder.none,
hintText: '发送弹幕',
hintStyle: TextStyle(
color: Colors.white.withOpacity(0.6),
),
child: GestureDetector(
onTap: onSendDanmaku,
child: Text(
'发送弹幕',
style: TextStyle(color: _color),
),
),
),
IconButton(
onPressed: () {
if (_ctr.text.isNotEmpty) {
_onSendMsg(_ctr.text);
}
onSendDanmaku(true);
},
icon: Icon(Icons.send, color: _color),
icon: Icon(Icons.emoji_emotions_outlined, color: _color),
),
],
),
);
void _onSendMsg(msg) async {
if (!_liveRoomController.isLogin) {
SmartDialog.showToast('未登录');
return;
}
dynamic res = await LiveHttp.sendLiveMsg(
roomId: _liveRoomController.roomId, msg: msg);
if (res['status']) {
if (mounted) {
FocusScope.of(context).unfocus();
}
SmartDialog.showToast('发送成功');
plPlayerController.danmakuController?.addDanmaku(
DanmakuContentItem(
_ctr.text,
type: DanmakuItemType.scroll,
selfSend: true,
),
);
_ctr.clear();
} else {
SmartDialog.showToast(res['msg']);
}
void onSendDanmaku([bool fromEmote = false]) {
Get.generalDialog(
pageBuilder: (context, animation, secondaryAnimation) {
return LiveSendDmPanel(
fromEmote: fromEmote,
liveRoomController: _liveRoomController,
initialValue: _liveRoomController.savedDanmaku,
onSave: (msg) => _liveRoomController.savedDanmaku = msg,
);
},
transitionDuration: const Duration(milliseconds: 500),
transitionBuilder: (context, animation, secondaryAnimation, child) {
var tween = Tween(begin: Offset(0.0, 1.0), end: Offset.zero)
.chain(CurveTween(curve: Curves.linear));
return SlideTransition(
position: animation.drive(tween),
child: child,
);
},
);
}
}

View File

@@ -1,3 +0,0 @@
library video_reply_new;
export './view.dart';

View File

@@ -1,282 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
import 'package:PiliPlus/http/video.dart';
import 'package:PiliPlus/models/common/reply_type.dart';
import 'package:PiliPlus/models/video/reply/emote.dart';
import 'package:PiliPlus/models/video/reply/item.dart';
import 'package:PiliPlus/pages/emote/index.dart';
import 'package:PiliPlus/utils/feed_back.dart';
import 'package:get/get_rx/src/rx_workers/utils/debouncer.dart';
import 'toolbar_icon_button.dart';
@Deprecated('Use ReplyPage instead')
class VideoReplyNewDialog extends StatefulWidget {
final int? oid;
final int? root;
final int? parent;
final ReplyType? replyType;
final ReplyItemModel? replyItem;
const VideoReplyNewDialog({
super.key,
this.oid,
this.root,
this.parent,
this.replyType,
this.replyItem,
});
@override
State<VideoReplyNewDialog> createState() => _VideoReplyNewDialogState();
}
class _VideoReplyNewDialogState extends State<VideoReplyNewDialog>
with WidgetsBindingObserver {
final TextEditingController _replyContentController = TextEditingController();
final FocusNode replyContentFocusNode = FocusNode();
final GlobalKey _formKey = GlobalKey<FormState>();
late double emoteHeight = 0.0;
double keyboardHeight = 0.0; // 键盘高度
final _debouncer =
Debouncer(delay: const Duration(milliseconds: 200)); // 设置延迟时间
String toolbarType = 'input';
bool _enablePublish = false;
final _publishStream = StreamController<bool>();
@override
void initState() {
super.initState();
// 监听输入框聚焦
// replyContentFocusNode.addListener(_onFocus);
// 界面观察者 必须
WidgetsBinding.instance.addObserver(this);
// 自动聚焦
_autoFocus();
// 监听聚焦状态
_focusListener();
}
_autoFocus() async {
await Future.delayed(const Duration(milliseconds: 300));
if (mounted) {
FocusScope.of(context).requestFocus(replyContentFocusNode);
}
}
_focusListener() {
replyContentFocusNode.addListener(listener);
}
void listener() {
if (replyContentFocusNode.hasFocus) {
setState(() {
toolbarType = 'input';
});
}
}
Future submitReplyAdd() async {
feedBack();
String message = _replyContentController.text;
var result = await VideoHttp.replyAdd(
type: widget.replyType ?? ReplyType.video,
oid: widget.oid!,
root: widget.root!,
parent: widget.parent!,
message: widget.replyItem != null && widget.replyItem!.root != 0
? ' 回复 @${widget.replyItem!.member!.uname!} : $message'
: message,
);
if (result['status']) {
SmartDialog.showToast(result['data']['success_toast']);
Get.back(result: {
'data': ReplyItemModel.fromJson(result['data']['reply'], ''),
});
} else {
SmartDialog.showToast(result['msg']);
}
}
void onChooseEmote(Packages package, Emote emote) {
if (!_enablePublish) {
_enablePublish = true;
_publishStream.add(true);
}
final int cursorPosition = _replyContentController.selection.baseOffset;
final String currentText = _replyContentController.text;
final String newText = currentText.substring(0, cursorPosition) +
emote.text! +
currentText.substring(cursorPosition);
_replyContentController.value = TextEditingValue(
text: newText,
selection:
TextSelection.collapsed(offset: cursorPosition + emote.text!.length),
);
}
@override
void didChangeMetrics() {
super.didChangeMetrics();
if (!mounted) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
// 键盘高度
final viewInsets = EdgeInsets.fromViewPadding(
View.of(context).viewInsets, View.of(context).devicePixelRatio);
_debouncer(() {
if (!mounted) return;
if (keyboardHeight == 0 && emoteHeight == 0) {
emoteHeight = keyboardHeight =
keyboardHeight == 0.0 ? viewInsets.bottom : keyboardHeight;
if (emoteHeight < 200) emoteHeight = 200;
setState(() {});
}
});
});
}
@override
void dispose() {
_publishStream.close();
WidgetsBinding.instance.removeObserver(this);
_replyContentController.dispose();
replyContentFocusNode.removeListener(listener);
replyContentFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
double _keyboardHeight = EdgeInsets.fromViewPadding(
View.of(context).viewInsets, View.of(context).devicePixelRatio)
.bottom;
return Container(
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
color: Theme.of(context).colorScheme.surface,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding:
const EdgeInsets.only(top: 12, right: 15, left: 15, bottom: 10),
child: SingleChildScrollView(
child: Form(
key: _formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
child: TextField(
controller: _replyContentController,
minLines: 4,
maxLines: 8,
autofocus: false,
onChanged: (value) {
if (value.isNotEmpty && !_enablePublish) {
_enablePublish = true;
_publishStream.add(true);
} else if (value.isEmpty && _enablePublish) {
_enablePublish = false;
_publishStream.add(false);
}
},
focusNode: replyContentFocusNode,
decoration: const InputDecoration(
hintText: "输入回复内容",
border: InputBorder.none,
hintStyle: TextStyle(
fontSize: 14,
)),
style: Theme.of(context).textTheme.bodyLarge,
),
),
),
),
Divider(
height: 1,
color: Theme.of(context).dividerColor.withOpacity(0.1),
),
Container(
height: 52,
padding: const EdgeInsets.only(left: 12, right: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ToolbarIconButton(
tooltip: '输入',
onPressed: () {
if (toolbarType == 'emote') {
setState(() {
toolbarType = 'input';
});
}
FocusScope.of(context).requestFocus(replyContentFocusNode);
},
icon: const Icon(Icons.keyboard, size: 22),
// toolbarType: toolbarType,
selected: toolbarType == 'input',
),
const SizedBox(width: 20),
ToolbarIconButton(
tooltip: '表情',
onPressed: () {
if (toolbarType == 'input') {
setState(() {
toolbarType = 'emote';
});
}
FocusScope.of(context).unfocus();
},
icon: const Icon(Icons.emoji_emotions, size: 22),
// toolbarType: toolbarType,
selected: toolbarType == 'emote',
),
const Spacer(),
StreamBuilder(
initialData: false,
stream: _publishStream.stream.distinct(),
builder: (context, snapshot) => FilledButton.tonal(
onPressed: snapshot.data == true ? submitReplyAdd : null,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 20, vertical: 10),
visualDensity: const VisualDensity(
horizontal: -2,
vertical: -2,
),
),
child: const Text('发送'),
),
),
],
),
),
AnimatedSize(
curve: Curves.easeInOut,
duration: const Duration(milliseconds: 300),
child: SizedBox(
width: double.infinity,
height: toolbarType == 'input'
? (_keyboardHeight > keyboardHeight
? _keyboardHeight
: keyboardHeight)
: emoteHeight,
child: EmotePanel(onChoose: onChooseEmote),
),
),
if (toolbarType == 'input' && keyboardHeight == 0.0)
SizedBox(
width: double.infinity,
height: MediaQuery.of(context).padding.bottom,
)
],
),
);
}
}

View File

@@ -19,9 +19,6 @@ class SendDanmakuPanel extends CommonPublishPage {
final dynamic bvid;
final dynamic progress;
// live
final dynamic roomId;
final ValueChanged<DanmakuContentItem> callback;
final bool darkVideoPage;
@@ -36,7 +33,6 @@ class SendDanmakuPanel extends CommonPublishPage {
this.cid,
this.bvid,
this.progress,
this.roomId,
required this.callback,
required this.darkVideoPage,
this.dmConfig,
@@ -86,18 +82,18 @@ class _SendDanmakuPanelState extends CommonPublishPageState<SendDanmakuPanel> {
get _buildColorPanel => Expanded(
child: Obx(
() => LayoutBuilder(
() => Builder(
key: ValueKey(_color.value),
builder: (context, constraints) {
final int crossAxisCount = (constraints.maxWidth / 40).toInt();
builder: (context) {
final bool isCustomColor = _colorList.contains(_color.value).not;
final int length =
_colorList.length + (isCustomColor ? 1 : 0) + 1;
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 40,
mainAxisExtent: 40,
crossAxisSpacing: 4,
mainAxisSpacing: 4,
),
@@ -341,28 +337,27 @@ class _SendDanmakuPanelState extends CommonPublishPageState<SendDanmakuPanel> {
),
child: Row(
children: [
if (widget.roomId == null)
Obx(
() => iconButton(
context: context,
tooltip: '弹幕样式',
onPressed: () {
if (selectKeyboard.value) {
selectKeyboard.value = false;
updatePanelType(PanelType.emoji);
} else {
selectKeyboard.value = true;
updatePanelType(PanelType.keyboard);
}
},
bgColor: Colors.transparent,
iconSize: 24,
icon: Icons.text_format,
iconColor: selectKeyboard.value.not
? themeData.colorScheme.primary
: themeData.colorScheme.onSurfaceVariant,
),
Obx(
() => iconButton(
context: context,
tooltip: '弹幕样式',
onPressed: () {
if (selectKeyboard.value) {
selectKeyboard.value = false;
updatePanelType(PanelType.emoji);
} else {
selectKeyboard.value = true;
updatePanelType(PanelType.keyboard);
}
},
bgColor: Colors.transparent,
iconSize: 24,
icon: Icons.text_format,
iconColor: selectKeyboard.value.not
? themeData.colorScheme.primary
: themeData.colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: 12),
Expanded(
child: Form(
@@ -470,20 +465,15 @@ class _SendDanmakuPanelState extends CommonPublishPageState<SendDanmakuPanel> {
@override
Future onCustomPublish({required String message, List? pictures}) async {
SmartDialog.showLoading(msg: '发送中...');
final res = widget.roomId != null
? await LiveHttp.sendLiveMsg(
roomId: widget.roomId,
msg: editController.text,
)
: await DanmakuHttp.shootDanmaku(
oid: widget.cid,
bvid: widget.bvid,
progress: widget.progress,
msg: editController.text,
mode: _mode.value,
fontsize: _fontsize.value,
color: _color.value.value & 0xFFFFFF,
);
final res = await DanmakuHttp.shootDanmaku(
oid: widget.cid,
bvid: widget.bvid,
progress: widget.progress,
msg: editController.text,
mode: _mode.value,
fontsize: _fontsize.value,
color: _color.value.value & 0xFFFFFF,
);
SmartDialog.dismiss();
if (res['status']) {
Get.back();