feat: live dm block

Signed-off-by: bggRGjQaUbCoE <githubaccount56556@proton.me>
This commit is contained in:
bggRGjQaUbCoE
2025-06-19 18:46:01 +08:00
parent dcb893ed07
commit 0bc0c36f14
14 changed files with 760 additions and 19 deletions

View File

@@ -11,7 +11,7 @@ class CustomSliverPersistentHeaderDelegate
final double _minExtent;
final double _maxExtent;
final Widget child;
final Color bgColor;
final Color? bgColor;
@override
Widget build(
@@ -19,18 +19,20 @@ class CustomSliverPersistentHeaderDelegate
//创建child子组件
//shrinkOffsetchild偏移值minExtent~maxExtent
//overlapsContentSliverPersistentHeader覆盖其他子组件返回true否则返回false
return DecoratedBox(
decoration: BoxDecoration(
color: bgColor,
boxShadow: [
BoxShadow(
color: bgColor,
offset: const Offset(0, -2),
),
],
),
child: child,
);
return bgColor != null
? DecoratedBox(
decoration: BoxDecoration(
color: bgColor,
boxShadow: [
BoxShadow(
color: bgColor!,
offset: const Offset(0, -2),
),
],
),
child: child,
)
: child;
}
//SliverPersistentHeader最大高度

View File

@@ -896,4 +896,19 @@ class Api {
static const String dynPic = '/x/polymer/web-dynamic/v1/detail/pic';
static const String msgLikeDetail = '/x/msgfeed/like_detail';
static const String getLiveInfoByUser =
'${HttpString.liveBaseUrl}/xlive/web-room/v1/index/getInfoByUser';
static const String liveSetSilent =
'${HttpString.liveBaseUrl}/liveact/user_silent';
static const String addShieldKeyword =
'${HttpString.liveBaseUrl}/xlive/web-ucenter/v1/banned/AddShieldKeyword';
static const String delShieldKeyword =
'${HttpString.liveBaseUrl}/xlive/web-ucenter/v1/banned/DelShieldKeyword';
static const String liveShieldUser =
'${HttpString.liveBaseUrl}/liveact/shield_user';
}

View File

@@ -5,6 +5,8 @@ import 'package:PiliPlus/http/loading_state.dart';
import 'package:PiliPlus/models/common/live_search_type.dart';
import 'package:PiliPlus/models_new/live/live_area_list/area_item.dart';
import 'package:PiliPlus/models_new/live/live_area_list/area_list.dart';
import 'package:PiliPlus/models_new/live/live_dm_block/data.dart';
import 'package:PiliPlus/models_new/live/live_dm_block/shield_info.dart';
import 'package:PiliPlus/models_new/live/live_dm_info/data.dart';
import 'package:PiliPlus/models_new/live/live_emote/data.dart';
import 'package:PiliPlus/models_new/live/live_emote/datum.dart';
@@ -453,4 +455,108 @@ class LiveHttp {
return Error(res.data['message']);
}
}
static Future<LoadingState<ShieldInfo?>> getLiveInfoByUser(
dynamic roomId) async {
var res = await Request().get(
Api.getLiveInfoByUser,
queryParameters: await WbiSign.makSign({
'room_id': roomId,
'from': 0,
'not_mock_enter_effect': 1,
'web_location': 444.8,
}),
);
if (res.data['code'] == 0) {
return Success(LiveDmBlockData.fromJson(res.data['data']).shieldInfo);
} else {
return Error(res.data['message']);
}
}
static Future liveSetSilent({
required String type,
required int level,
}) async {
final csrf = Accounts.main.csrf;
var res = await Request().post(
Api.liveSetSilent,
data: {
'type': type,
'level': level,
'csrf': csrf,
'csrf_token': csrf,
},
options: Options(contentType: Headers.formUrlEncodedContentType),
);
if (res.data['code'] == 0) {
return {'status': true};
} else {
return {'status': false, 'msg': res.data['message']};
}
}
static Future addShieldKeyword({
required String keyword,
}) async {
final csrf = Accounts.main.csrf;
var res = await Request().post(
Api.addShieldKeyword,
data: {
'keyword': keyword,
'csrf': csrf,
'csrf_token': csrf,
},
options: Options(contentType: Headers.formUrlEncodedContentType),
);
if (res.data['code'] == 0) {
return {'status': true};
} else {
return {'status': false, 'msg': res.data['message']};
}
}
static Future delShieldKeyword({
required String keyword,
}) async {
final csrf = Accounts.main.csrf;
var res = await Request().post(
Api.delShieldKeyword,
data: {
'keyword': keyword,
'csrf': csrf,
'csrf_token': csrf,
},
options: Options(contentType: Headers.formUrlEncodedContentType),
);
if (res.data['code'] == 0) {
return {'status': true};
} else {
return {'status': false, 'msg': res.data['message']};
}
}
static Future liveShieldUser({
required dynamic uid,
required dynamic roomid,
required int type,
}) async {
final csrf = Accounts.main.csrf;
var res = await Request().post(
Api.liveShieldUser,
data: {
'uid': uid,
'roomid': roomid,
'type': type,
'csrf': csrf,
'csrf_token': csrf,
},
options: Options(contentType: Headers.formUrlEncodedContentType),
);
if (res.data['code'] == 0) {
return {'status': true, 'data': res.data['data']};
} else {
return {'status': false, 'msg': res.data['message']};
}
}
}

View File

@@ -0,0 +1 @@
enum LiveDmSilentType { level, rank, verify }

View File

@@ -0,0 +1,16 @@
import 'package:PiliPlus/models_new/live/live_dm_block/shield_info.dart';
class LiveDmBlockData {
ShieldInfo? shieldInfo;
LiveDmBlockData({
this.shieldInfo,
});
factory LiveDmBlockData.fromJson(Map<String, dynamic> json) =>
LiveDmBlockData(
shieldInfo: json['shield_info'] == null
? null
: ShieldInfo.fromJson(json['shield_info'] as Map<String, dynamic>),
);
}

View File

@@ -0,0 +1,31 @@
import 'package:PiliPlus/models_new/live/live_dm_block/shield_rules.dart';
import 'package:PiliPlus/models_new/live/live_dm_block/shield_user_list.dart';
class ShieldInfo {
List<ShieldUserList>? shieldUserList;
List<String>? keywordList;
ShieldRules? shieldRules;
bool? isBlock;
int? blockExpired;
ShieldInfo({
this.shieldUserList,
this.keywordList,
this.shieldRules,
this.isBlock,
this.blockExpired,
});
factory ShieldInfo.fromJson(Map<String, dynamic> json) => ShieldInfo(
shieldUserList: (json['shield_user_list'] as List<dynamic>?)
?.map((e) => ShieldUserList.fromJson(e as Map<String, dynamic>))
.toList(),
keywordList: (json['keyword_list'] as List?)?.cast(),
shieldRules: json['shield_rules'] == null
? null
: ShieldRules.fromJson(
json['shield_rules'] as Map<String, dynamic>),
isBlock: json['is_block'] as bool?,
blockExpired: json['block_expired'] as int?,
);
}

View File

@@ -0,0 +1,13 @@
class ShieldRules {
int rank;
int verify;
int level;
ShieldRules({this.rank = 0, this.verify = 0, this.level = 0});
factory ShieldRules.fromJson(Map<String, dynamic> json) => ShieldRules(
rank: json['rank'] as int? ?? 0,
verify: json['verify'] as int? ?? 0,
level: json['level'] as int? ?? 0,
);
}

View File

@@ -0,0 +1,13 @@
class ShieldUserList {
int? uid;
String? uname;
ShieldUserList({this.uid, this.uname});
factory ShieldUserList.fromJson(Map<String, dynamic> json) {
return ShieldUserList(
uid: json['uid'] as int?,
uname: json['uname'] as String?,
);
}
}

View File

@@ -225,12 +225,15 @@ Widget module(
if (item.modules.moduleDynamic!.major!.common!.cover
?.isNotEmpty ==
true)
CachedNetworkImage(
width: 45,
height: 45,
fit: BoxFit.cover,
imageUrl: item.modules.moduleDynamic!.major!.common!.cover!
.http2https,
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(6)),
child: CachedNetworkImage(
width: 45,
height: 45,
fit: BoxFit.cover,
imageUrl: item.modules.moduleDynamic!.major!.common!
.cover!.http2https,
),
),
Expanded(
child: Column(

View File

@@ -0,0 +1,151 @@
import 'package:PiliPlus/http/live.dart';
import 'package:PiliPlus/models/common/live_dm_silent_type.dart';
import 'package:PiliPlus/models_new/live/live_dm_block/shield_info.dart';
import 'package:PiliPlus/models_new/live/live_dm_block/shield_rules.dart';
import 'package:PiliPlus/models_new/live/live_dm_block/shield_user_list.dart';
import 'package:flutter/material.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
class LiveDmBlockController extends GetxController
with GetSingleTickerProviderStateMixin {
final roomId = Get.parameters['roomId'];
@override
void onInit() {
super.onInit();
queryData();
}
late final TabController tabController =
TabController(length: 2, vsync: this);
int? oldLevel;
final RxInt level = 0.obs;
final RxInt rank = 0.obs;
final RxInt verify = 0.obs;
final RxBool isEnable = false.obs;
final RxList<String> keywordList = <String>[].obs;
final RxList<ShieldUserList> shieldUserList = <ShieldUserList>[].obs;
void updateValue() {
isEnable.value = level.value != 0 || rank.value != 0 || verify.value != 0;
}
Future<void> queryData() async {
var res = await LiveHttp.getLiveInfoByUser(roomId);
if (res.isSuccess) {
ShieldInfo? data = res.data;
ShieldRules? shieldRules = data?.shieldRules;
level.value = shieldRules?.level ?? 0;
rank.value = shieldRules?.rank ?? 0;
verify.value = shieldRules?.verify ?? 0;
updateValue();
if (data?.keywordList != null) {
keywordList.addAll(data!.keywordList!);
}
if (data?.shieldUserList != null) {
shieldUserList.addAll(data!.shieldUserList!);
}
} else {
res.toast();
}
}
Future<bool> setSilent(LiveDmSilentType type, int level,
{VoidCallback? onError}) async {
var res = await LiveHttp.liveSetSilent(type: type.name, level: level);
if (res['status']) {
switch (type) {
case LiveDmSilentType.level:
this.level.value = level;
case LiveDmSilentType.rank:
rank.value = level;
case LiveDmSilentType.verify:
verify.value = level;
}
updateValue();
return true;
} else {
onError?.call();
SmartDialog.showToast(res['msg']);
return false;
}
}
Future<void> setEnable(bool enable) async {
if (enable == isEnable.value) {
return;
}
final futures = enable
? [
setSilent(LiveDmSilentType.rank, 1),
setSilent(LiveDmSilentType.verify, 1),
]
: [
for (var e in LiveDmSilentType.values) setSilent(e, 0),
];
var res = await Future.wait(futures);
if (enable) {
if (res.any((e) => e)) {
isEnable.value = true;
}
} else {
if (res.every((e) => e)) {
isEnable.value = false;
}
}
}
Future<void> addShieldKeyword(bool isKeyword, String value) async {
if (isKeyword) {
var res = await LiveHttp.addShieldKeyword(keyword: value);
if (res['status']) {
keywordList.insert(0, value);
} else {
SmartDialog.showToast(res['msg']);
}
} else {
var res =
await LiveHttp.liveShieldUser(uid: value, roomid: roomId, type: 1);
if (res['status']) {
shieldUserList.insert(
0,
ShieldUserList(
uid: res['data']['uid'],
uname: res['data']['uname'],
),
);
} else {
SmartDialog.showToast(res['msg']);
}
}
}
Future<void> onRemove(int index, dynamic item) async {
if (item is ShieldUserList) {
var res =
await LiveHttp.liveShieldUser(uid: item.uid, roomid: roomId, type: 0);
if (res['status']) {
shieldUserList.removeAt(index);
} else {
SmartDialog.showToast(res['msg']);
}
} else {
var res = await LiveHttp.delShieldKeyword(keyword: item);
if (res['status']) {
keywordList.removeAt(index);
} else {
SmartDialog.showToast(res['msg']);
}
}
}
@override
void onClose() {
tabController.dispose();
super.onClose();
}
}

View File

@@ -0,0 +1,356 @@
import 'package:PiliPlus/common/widgets/custom_sliver_persistent_header_delegate.dart';
import 'package:PiliPlus/common/widgets/dialog/dialog.dart';
import 'package:PiliPlus/common/widgets/keep_alive_wrapper.dart';
import 'package:PiliPlus/common/widgets/loading_widget/loading_widget.dart';
import 'package:PiliPlus/common/widgets/scroll_physics.dart';
import 'package:PiliPlus/models/common/live_dm_silent_type.dart';
import 'package:PiliPlus/models_new/live/live_dm_block/shield_user_list.dart';
import 'package:PiliPlus/pages/live_dm_block/controller.dart';
import 'package:PiliPlus/pages/search/widgets/search_text.dart';
import 'package:PiliPlus/utils/utils.dart';
import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
class LiveDmBlockPage extends StatefulWidget {
const LiveDmBlockPage({super.key});
@override
State<LiveDmBlockPage> createState() => _LiveDmBlockPageState();
}
class _LiveDmBlockPageState extends State<LiveDmBlockPage> {
final _controller =
Get.put(LiveDmBlockController(), tag: Utils.generateRandomString(8));
late bool isPortrait;
@override
Widget build(BuildContext context) {
isPortrait = context.orientation == Orientation.portrait;
final theme = Theme.of(context);
Widget tabBar = TabBar(
controller: _controller.tabController,
tabs: const [Tab(text: '关键词'), Tab(text: '用户')],
);
Widget view = tabBarView(
controller: _controller.tabController,
children: [
KeepAliveWrapper(
builder: (context) =>
Obx(() => _buildKeyword(_controller.keywordList)),
),
KeepAliveWrapper(
builder: (context) =>
Obx(() => _buildKeyword(_controller.shieldUserList)),
),
],
);
Widget title = Padding(
padding: EdgeInsets.only(
top: isPortrait ? 18 : 0, left: isPortrait ? 0 : 12, bottom: 12),
child: const Text(
'关键词屏蔽',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold),
),
);
Widget left = Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'全局屏蔽',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold),
),
..._buildHeader(theme),
if (isPortrait) title,
],
),
);
return Scaffold(
resizeToAvoidBottomInset: false,
appBar: AppBar(
title: const Text('弹幕屏蔽'),
),
body: SafeArea(
bottom: false,
child: Stack(
clipBehavior: Clip.none,
children: [
isPortrait
? ExtendedNestedScrollView(
onlyOneScrollInBody: true,
headerSliverBuilder: (context, innerBoxIsScrolled) {
return [
SliverToBoxAdapter(child: left),
SliverOverlapAbsorber(
handle: ExtendedNestedScrollView
.sliverOverlapAbsorberHandleFor(context),
sliver: SliverPersistentHeader(
pinned: true,
delegate: CustomSliverPersistentHeaderDelegate(
extent: 48,
child: tabBar,
bgColor: null,
),
),
),
];
},
body: LayoutBuilder(
builder: (context, _) {
return Padding(
padding: EdgeInsets.only(
top: ExtendedNestedScrollView
.sliverOverlapAbsorberHandleFor(context)
.layoutExtent ??
0,
),
child: view,
);
},
),
)
: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: left),
VerticalDivider(
width: 1,
color: theme.colorScheme.outline.withValues(alpha: 0.1),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isPortrait) title,
tabBar,
Expanded(child: view)
],
),
),
],
),
Positioned(
right: 16,
bottom: 16 + MediaQuery.paddingOf(context).bottom,
child: FloatingActionButton(
tooltip: '添加',
onPressed: _addShieldKeyword,
child: const Icon(Icons.add),
),
),
],
),
),
);
}
Widget _buildKeyword(List list) {
if (list.isEmpty) {
return isPortrait ? errorWidget() : scrollErrorWidget();
}
return SingleChildScrollView(
padding: EdgeInsets.only(
top: 12,
left: 12,
right: 12,
bottom: MediaQuery.paddingOf(context).bottom + 80,
),
child: Wrap(
spacing: 12,
runSpacing: 12,
children: list.indexed.map(
(e) {
final item = e.$2;
return SearchText(
text: item is ShieldUserList ? item.uname! : item as String,
onTap: (value) => showConfirmDialog(
context: context,
title: '确定删除该规则?',
onConfirm: () => _controller.onRemove(e.$1, item),
),
);
},
).toList(),
),
);
}
List<Widget> _buildHeader(ThemeData theme) {
return [
const SizedBox(height: 6),
Obx(
() => Row(
spacing: 10,
children: [
Text('屏蔽${_controller.isEnable.value ? '' : ''}开启'),
Transform.scale(
scale: .8,
child: Switch(
value: _controller.isEnable.value,
onChanged: _controller.setEnable,
),
),
],
),
),
const SizedBox(height: 6),
Obx(
() {
return Row(
children: [
const Text('用户等级'),
Slider(
min: 0,
max: 60,
// ignore: deprecated_member_use
year2023: true,
inactiveColor: theme.colorScheme.onInverseSurface,
padding: const EdgeInsets.only(left: 20, right: 25),
value: _controller.level.value.toDouble(),
onChangeStart: (value) =>
_controller.oldLevel = _controller.level.value,
onChanged: (value) =>
_controller.level.value = value.round().clamp(0, 60),
onChangeEnd: (value) {
if (_controller.oldLevel != _controller.level.value) {
_controller.setSilent(
LiveDmSilentType.level,
_controller.level.value,
onError: () =>
_controller.level.value = _controller.oldLevel ?? 0,
);
}
},
),
Text('${_controller.level.value} 以下')
],
);
},
),
const SizedBox(height: 20),
Row(
spacing: 16,
children: [
Obx(() {
final isEnable = _controller.rank.value == 1;
return _headerBtn(
theme,
isEnable,
Icons.live_tv,
'非正式会员',
() => _controller.setSilent(
LiveDmSilentType.rank,
isEnable ? 0 : 1,
),
);
}),
Obx(() {
final isEnable = _controller.verify.value == 1;
return _headerBtn(
theme,
isEnable,
Icons.smartphone,
'未绑定手机用户',
() => _controller.setSilent(
LiveDmSilentType.verify,
isEnable ? 0 : 1,
),
);
}),
],
),
];
}
Widget _headerBtn(ThemeData theme, bool isEnable, IconData icon, String name,
VoidCallback onTap) {
final color =
isEnable ? theme.colorScheme.primary : theme.colorScheme.outline;
Widget top = Container(
width: 42,
height: 42,
alignment: Alignment.center,
decoration: isEnable
? BoxDecoration(
border: Border.all(color: color),
borderRadius: const BorderRadius.all(Radius.circular(4)))
: null,
child: Icon(icon, color: color),
);
if (isEnable) {
top = Stack(
clipBehavior: Clip.none,
children: [
top,
Positioned(
right: -6,
top: -6,
child: DecoratedBox(
decoration: BoxDecoration(
color: theme.colorScheme.error,
shape: BoxShape.circle,
),
child: Padding(
padding: const EdgeInsets.all(2),
child: Icon(
size: 14,
Icons.horizontal_rule,
color: theme.colorScheme.onError,
),
),
),
),
],
);
}
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Column(
spacing: 5,
children: [
top,
Text(
name,
style: TextStyle(color: color),
),
],
),
);
}
void _addShieldKeyword() {
bool isKeyword = _controller.tabController.index == 0;
String value = '';
showConfirmDialog(
context: context,
title: '${isKeyword ? '关键词' : '用户'}屏蔽',
content: TextFormField(
autofocus: true,
initialValue: value,
onChanged: (val) => value = val,
decoration: isKeyword ? null : const InputDecoration(hintText: 'UID'),
keyboardType: isKeyword ? null : TextInputType.number,
inputFormatters: isKeyword
? null
: [FilteringTextInputFormatter.allow(RegExp(r'\d+'))],
),
onConfirm: () {
if (value.isNotEmpty) {
_controller.addShieldKeyword(isKeyword, value);
}
},
);
}
}

View File

@@ -4,6 +4,7 @@ import 'package:PiliPlus/plugin/pl_player/widgets/common_btn.dart';
import 'package:PiliPlus/plugin/pl_player/widgets/play_pause_btn.dart';
import 'package:PiliPlus/utils/storage.dart';
import 'package:flutter/material.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
class BottomControl extends StatelessWidget {
@@ -46,11 +47,40 @@ class BottomControl extends StatelessWidget {
onTap: onRefresh,
),
const Spacer(),
SizedBox(
width: 35,
height: 35,
child: IconButton(
tooltip: '弹幕屏蔽',
style: ButtonStyle(
padding: WidgetStateProperty.all(EdgeInsets.zero),
),
onPressed: () {
if (liveRoomCtr.accountService.isLogin.value) {
Get.toNamed(
'/liveDmBlockPage',
parameters: {
'roomId': liveRoomCtr.roomId.toString(),
},
);
} else {
SmartDialog.showToast('账号未登录');
}
},
icon: const Icon(
size: 18,
Icons.block,
color: Colors.white,
),
),
),
const SizedBox(width: 10),
Obx(
() => SizedBox(
width: 35,
height: 35,
child: IconButton(
tooltip: '弹幕开关',
style: ButtonStyle(
padding: WidgetStateProperty.all(EdgeInsets.zero),
),

View File

@@ -147,6 +147,7 @@ class LiveHeaderControl extends StatelessWidget {
width: 35,
height: 35,
child: IconButton(
tooltip: '定时关闭',
style: ButtonStyle(
padding: WidgetStateProperty.all(EdgeInsets.zero),
),

View File

@@ -20,6 +20,7 @@ import 'package:PiliPlus/pages/home/view.dart';
import 'package:PiliPlus/pages/hot/view.dart';
import 'package:PiliPlus/pages/later/view.dart';
import 'package:PiliPlus/pages/later_search/view.dart';
import 'package:PiliPlus/pages/live_dm_block/view.dart';
import 'package:PiliPlus/pages/live_room/view.dart';
import 'package:PiliPlus/pages/login/view.dart';
import 'package:PiliPlus/pages/main/view.dart';
@@ -183,6 +184,8 @@ class Routes {
CustomGetPage(name: '/dynTopicRcmd', page: () => const DynTopicRcmdPage()),
CustomGetPage(name: '/matchInfo', page: () => const MatchInfoPage()),
CustomGetPage(name: '/msgLikeDetail', page: () => const LikeDetailPage()),
CustomGetPage(
name: '/liveDmBlockPage', page: () => const LiveDmBlockPage()),
];
}