feat: sponsorblock: post segments (#9)

Signed-off-by: bggRGjQaUbCoE <githubaccount56556@proton.me>
This commit is contained in:
bggRGjQaUbCoE
2024-11-25 14:35:26 +08:00
parent c0879ee169
commit f25eb7be82
5 changed files with 476 additions and 38 deletions

View File

@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
Widget iconButton({
required BuildContext context,
String? tooltip,
required IconData icon,
required VoidCallback? onPressed,
double size = 36,
}) {
return SizedBox(
width: size,
height: size,
child: IconButton(
tooltip: tooltip,
onPressed: onPressed,
icon: Icon(
icon,
size: size / 2,
color: Theme.of(context).colorScheme.onSecondaryContainer,
),
style: IconButton.styleFrom(
padding: EdgeInsets.all(0),
backgroundColor: Theme.of(context).colorScheme.secondaryContainer,
),
),
);
}

View File

@@ -1,5 +1,8 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:PiliPalaX/common/constants.dart';
import 'package:PiliPalaX/common/widgets/icon_button.dart';
import 'package:PiliPalaX/common/widgets/pair.dart';
import 'package:PiliPalaX/common/widgets/segment_progress_bar.dart';
import 'package:PiliPalaX/http/danmaku.dart';
@@ -7,6 +10,7 @@ import 'package:PiliPalaX/http/init.dart';
import 'package:dio/dio.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:hive/hive.dart';
@@ -115,6 +119,19 @@ class SegmentModel {
bool hasSkipped;
}
class PostSegmentModel {
PostSegmentModel({
required this.segment,
required this.category,
required this.actionType,
});
Pair<int, int> segment;
SegmentType category;
ActionType actionType;
}
enum ActionType { skip, mute, full, poi }
class VideoDetailController extends GetxController
with GetSingleTickerProviderStateMixin {
/// 路由传参
@@ -159,6 +176,8 @@ class VideoDetailController extends GetxController
RxInt oid = 0.obs;
final scaffoldKey = GlobalKey<ScaffoldState>();
final childKey = GlobalKey<ScaffoldState>();
RxString bgCover = ''.obs;
PlPlayerController plPlayerController = PlPlayerController.getInstance()
..setCurrBrightness(-1.0);
@@ -183,7 +202,7 @@ class VideoDetailController extends GetxController
late String cacheSecondDecode;
late int cacheAudioQa;
late final bool _enableSponsorBlock;
late final bool enableSponsorBlock;
PlayerStatus? playerStatus;
StreamSubscription<Duration>? positionSubscription;
@@ -244,9 +263,9 @@ class VideoDetailController extends GetxController
cacheAudioQa = setting.get(SettingBoxKey.defaultAudioQa,
defaultValue: AudioQuality.hiRes.code);
oid.value = IdUtils.bv2av(Get.parameters['bvid']!);
_enableSponsorBlock =
enableSponsorBlock =
setting.get(SettingBoxKey.enableSponsorBlock, defaultValue: false);
if (_enableSponsorBlock) {
if (enableSponsorBlock) {
_blockLimit = GStorage.blockLimit;
_blockSettings = GStorage.blockSettings;
_blockColor = GStorage.blockColor;
@@ -263,14 +282,17 @@ class VideoDetailController extends GetxController
_blockColor?[segment.index] ?? segment.color;
Future _vote(String uuid, int type) async {
Request().post(
Request()
.post(
'${GStorage.blockServer}/api/voteOnSponsorTime',
queryParameters: {
'UUID': uuid,
'userID': GStorage.blockUserID,
'type': type,
},
).then((res) {
options: _options,
)
.then((res) {
SmartDialog.showToast(res.statusCode == 200 ? '投票成功' : '投票失败');
});
}
@@ -289,14 +311,17 @@ class VideoDetailController extends GetxController
dense: true,
onTap: () {
Get.back();
Request().post(
Request()
.post(
'${GStorage.blockServer}/api/voteOnSponsorTime',
queryParameters: {
'UUID': segment.UUID,
'userID': GStorage.blockUserID,
'category': item.name,
},
).then((res) {
options: _options,
)
.then((res) {
SmartDialog.showToast(
'类别更改${res.statusCode == 200 ? '成功' : '失败'}');
});
@@ -387,7 +412,7 @@ class VideoDetailController extends GetxController
);
}
void showSponsorBlock(BuildContext context) {
void showSBDetail(BuildContext context) {
showDialog(
context: context,
builder: (_) => AlertDialog(
@@ -502,14 +527,7 @@ class VideoDetailController extends GetxController
);
}
Future _sponsorBlock() async {
dynamic result = await Request().get(
'${GStorage.blockServer}/api/skipSegments',
data: {
'videoID': bvid,
'cid': cid.value,
},
options: Options(
Options get _options => Options(
headers: {
'env': '',
'app-key': '',
@@ -519,7 +537,16 @@ class VideoDetailController extends GetxController
HttpHeaders.cookieHeader:
'buvid3= ; SESSDATA= ; bili_jct= ; DedeUserID= ; DedeUserID__ckMd5= ; sid= ',
},
),
);
Future _sponsorBlock() async {
dynamic result = await Request().get(
'${GStorage.blockServer}/api/skipSegments',
data: {
'videoID': bvid,
'cid': cid.value,
},
options: _options,
);
if (result.data is List && result.data.isNotEmpty) {
try {
@@ -608,6 +635,7 @@ class VideoDetailController extends GetxController
Request().post(
'${GStorage.blockServer}/api/viewedVideoSponsorTime',
queryParameters: {'UUID': item.UUID},
options: _options,
);
}
} catch (e) {
@@ -832,7 +860,7 @@ class VideoDetailController extends GetxController
var result = await VideoHttp.videoUrl(cid: cid.value, bvid: bvid);
if (result['status']) {
data = result['data'];
if (_enableSponsorBlock) {
if (enableSponsorBlock) {
await _sponsorBlock();
}
if (data.acceptDesc!.isNotEmpty && data.acceptDesc!.contains('试看')) {
@@ -974,4 +1002,371 @@ class VideoDetailController extends GetxController
}
return result;
}
List<PostSegmentModel>? list;
void onBlock(BuildContext context) {
PersistentBottomSheetController? ctr;
list ??= <PostSegmentModel>[];
if (list!.isEmpty) {
list!.add(
PostSegmentModel(
segment: Pair(first: 0, second: 0),
category: SegmentType.sponsor,
actionType: ActionType.skip,
),
);
}
ctr = plPlayerController.isFullScreen.value
? scaffoldKey.currentState?.showBottomSheet(
enableDrag: false,
(context) => _postPanel(ctr?.close),
)
: childKey.currentState?.showBottomSheet(
enableDrag: false,
(context) => _postPanel(ctr?.close),
);
}
Widget _postPanel(onClose) => StatefulBuilder(
builder: (context, setState) {
List<Widget> segmentWidget({
required int index,
required bool isFirst,
}) {
String value = Utils.timeFormat(isFirst
? list![index].segment.first
: list![index].segment.second);
return [
Text(
'${isFirst ? '开始' : '结束'}: $value',
),
const SizedBox(width: 5),
iconButton(
context: context,
size: 26,
tooltip: '使用当前位置',
icon: Icons.my_location,
onPressed: () {
setState(() {
if (isFirst) {
list![index].segment.first =
plPlayerController.positionSeconds.value;
} else {
list![index].segment.second =
plPlayerController.positionSeconds.value;
}
});
},
),
const SizedBox(width: 5),
iconButton(
context: context,
size: 26,
tooltip: '编辑',
icon: Icons.edit,
onPressed: () {
showDialog(
context: context,
builder: (_) {
String initV = value;
return AlertDialog(
content: TextFormField(
initialValue: value,
autofocus: true,
onChanged: (value) {
initV = value;
},
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp(r'[\d:]+'),
),
],
),
actions: [
TextButton(
onPressed: Get.back,
child: Text(
'取消',
style: TextStyle(
color: Theme.of(context).colorScheme.outline),
),
),
TextButton(
onPressed: () => Get.back(result: initV),
child: Text('确定'),
),
],
);
},
).then((res) {
if (res != null) {
try {
List<int> split = (res as String)
.split(':')
.toList()
.reversed
.toList()
.map((e) => int.parse(e))
.toList();
int duration = 0;
for (int i = 0; i < split.length; i++) {
duration += split[i] * pow(60, i).toInt();
}
if (duration <= (data.timeLength ?? 0) / 1000) {
setState(() {
if (isFirst) {
list![index].segment.first = duration;
} else {
list![index].segment.second = duration;
}
});
}
} catch (e) {
debugPrint(e.toString());
}
}
});
},
),
];
}
return Scaffold(
resizeToAvoidBottomInset: true,
appBar: AppBar(
automaticallyImplyLeading: false,
titleSpacing: 16,
title: const Text('提交片段'),
actions: [
iconButton(
context: context,
tooltip: '添加片段',
onPressed: () {
setState(() {
list?.insert(
0,
PostSegmentModel(
segment: Pair(first: 0, second: 0),
category: SegmentType.sponsor,
actionType: ActionType.skip,
),
);
});
},
icon: Icons.add,
),
const SizedBox(width: 10),
iconButton(
context: context,
tooltip: '关闭',
onPressed: onClose,
icon: Icons.close,
),
const SizedBox(width: 16),
],
),
body: list?.isNotEmpty == true
? Stack(
children: [
SingleChildScrollView(
child: Column(
children: [
...List.generate(
list!.length,
(index) => Container(
margin: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 5,
),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.onInverseSurface,
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
...segmentWidget(
isFirst: true,
index: index,
),
const SizedBox(width: 16),
...segmentWidget(
isFirst: false,
index: index,
),
const Spacer(),
iconButton(
context: context,
size: 26,
icon: Icons.clear,
onPressed: () {
setState(() {
list!.removeAt(index);
});
},
),
],
),
const SizedBox(height: 8),
Row(
children: [
const Text('分类: '),
PopupMenuButton(
initialValue: list![index].category,
onSelected: (item) async {
setState(() {
list![index].category = item;
});
},
itemBuilder: (context) => SegmentType
.values
.map((item) =>
PopupMenuItem<SegmentType>(
value: item,
child: Text(item.title),
))
.toList(),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
list![index].category.title,
style: TextStyle(
fontSize: 14,
color: Theme.of(context)
.colorScheme
.primary,
),
),
Icon(
size: 20,
Icons.keyboard_arrow_right,
color: Theme.of(context)
.colorScheme
.primary,
)
],
),
),
const SizedBox(width: 16),
const Text('ActionType: '),
PopupMenuButton(
initialValue: list![index].actionType,
onSelected: (item) async {
setState(() {
list![index].actionType = item;
});
},
itemBuilder: (context) => ActionType
.values
.map((item) =>
PopupMenuItem<ActionType>(
value: item,
child: Text(item.name),
))
.toList(),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
list![index].actionType.name,
style: TextStyle(
fontSize: 14,
color: Theme.of(context)
.colorScheme
.primary,
),
),
Icon(
size: 20,
Icons.keyboard_arrow_right,
color: Theme.of(context)
.colorScheme
.primary,
)
],
),
),
],
)
],
),
),
),
SizedBox(
height: 88 + MediaQuery.paddingOf(context).bottom,
),
],
),
),
Positioned(
right: 16,
bottom: 16 + MediaQuery.paddingOf(context).bottom,
child: FloatingActionButton(
tooltip: '提交',
onPressed: () {
Request()
.post(
'${GStorage.blockServer}/api/skipSegments',
queryParameters: {
'videoID': bvid,
'cid': cid.value,
'userID': GStorage.blockUserID,
'userAgent': Constants.userAgent,
'videoDuration': (data.timeLength ?? 0 / 1000),
},
data: {
'segments': list!
.map(
(item) => {
'segment': [
item.segment.first,
item.segment.second,
],
'category': item.category.name,
'actionType': item.actionType.name,
},
)
.toList(),
},
options: _options,
)
.then(
(res) {
if (res.statusCode == 200) {
Get.back();
SmartDialog.showToast('提交成功');
list?.clear();
} else {
SmartDialog.showToast(
'提交失败: ${{
400: '参数错误',
403: '被自动审核机制拒绝',
429: '重复提交太快',
409: '重复提交'
}[res.statusCode]}',
);
}
},
);
},
child: Icon(Icons.check),
),
)
],
)
: null,
);
},
);
}

View File

@@ -85,8 +85,6 @@ class _VideoDetailPageState extends State<VideoDetailPage>
// StreamSubscription<Duration>? _bufferedListener;
bool get isFullScreen => plPlayerController?.isFullScreen.value ?? false;
final scaffoldKey = GlobalKey<ScaffoldState>();
@override
void initState() {
super.initState();
@@ -520,7 +518,7 @@ class _VideoDetailPageState extends State<VideoDetailPage>
),
Expanded(
child: Scaffold(
key: scaffoldKey,
key: videoDetailController.childKey,
resizeToAvoidBottomInset: false,
body: Column(
children: [
@@ -578,7 +576,7 @@ class _VideoDetailPageState extends State<VideoDetailPage>
),
Expanded(
child: Scaffold(
key: scaffoldKey,
key: videoDetailController.childKey,
resizeToAvoidBottomInset: false,
body: Column(
children: [
@@ -630,7 +628,7 @@ class _VideoDetailPageState extends State<VideoDetailPage>
),
Expanded(
child: Scaffold(
key: scaffoldKey,
key: videoDetailController.childKey,
resizeToAvoidBottomInset: false,
body: Column(
children: [
@@ -685,7 +683,7 @@ class _VideoDetailPageState extends State<VideoDetailPage>
Expanded(
child: Expanded(
child: Scaffold(
key: scaffoldKey,
key: videoDetailController.childKey,
resizeToAvoidBottomInset: false,
body: Column(
children: [
@@ -785,7 +783,7 @@ class _VideoDetailPageState extends State<VideoDetailPage>
height: context.height -
(removeSafeArea ? 0 : MediaQuery.of(context).padding.top),
child: Scaffold(
key: scaffoldKey,
key: videoDetailController.childKey,
resizeToAvoidBottomInset: false,
body: Column(
children: [
@@ -1289,7 +1287,7 @@ class _VideoDetailPageState extends State<VideoDetailPage>
// 展示二级回复
void replyReply(replyItem, id) {
scaffoldKey.currentState?.showBottomSheet(
videoDetailController.childKey.currentState?.showBottomSheet(
(context) => VideoReplyReplyPanel(
id: id,
// rcount: replyItem.rcount,
@@ -1304,14 +1302,14 @@ class _VideoDetailPageState extends State<VideoDetailPage>
// ai总结
showAiBottomSheet() {
scaffoldKey.currentState?.showBottomSheet(
videoDetailController.childKey.currentState?.showBottomSheet(
enableDrag: true,
(context) => AiDetail(modelResult: videoIntroController.modelResult),
);
}
showIntroDetail(videoDetail, videoTags) {
scaffoldKey.currentState?.showBottomSheet(
videoDetailController.childKey.currentState?.showBottomSheet(
enableDrag: true,
(context) => videoDetail is BangumiInfoModel
? bangumi.IntroDetail(
@@ -1339,7 +1337,7 @@ class _VideoDetailPageState extends State<VideoDetailPage>
context: context,
scaffoldState: isFullScreen
? videoDetailController.scaffoldKey.currentState
: scaffoldKey.currentState,
: videoDetailController.childKey.currentState,
).buildShowBottomSheet();
}
}

View File

@@ -65,7 +65,6 @@ class _HeaderControlState extends State<HeaderControl> {
RxString now = ''.obs;
Timer? clock;
late String defaultCDNService;
bool get isFullScreen => widget.controller!.isFullScreen.value;
@override
@@ -1450,20 +1449,37 @@ class _HeaderControlState extends State<HeaderControl> {
// ),
// fuc: () => _.screenshot(),
// ),
if (widget.videoDetailCtr?.enableSponsorBlock == true)
SizedBox(
width: 42,
height: 34,
child: IconButton(
tooltip: '提交片段',
style: ButtonStyle(
padding: WidgetStateProperty.all(EdgeInsets.zero),
),
onPressed: () => widget.videoDetailCtr?.onBlock(context),
icon: const Icon(
Icons.block,
size: 19,
color: Colors.white,
),
),
),
Obx(
() => widget.videoDetailCtr?.segmentList.isNotEmpty == true
? SizedBox(
width: 42,
height: 34,
child: IconButton(
tooltip: 'SponsorBlock',
tooltip: '片段信息',
style: ButtonStyle(
padding: WidgetStateProperty.all(EdgeInsets.zero),
),
onPressed: () =>
widget.videoDetailCtr?.showSponsorBlock(context),
icon: const Icon(
Icons.block,
widget.videoDetailCtr?.showSBDetail(context),
icon: Icon(
MdiIcons.advertisements,
size: 19,
color: Colors.white,
),

View File

@@ -255,6 +255,12 @@ class SettingBoxKey {
enableAi = 'enableAi',
disableLikeMsg = 'disableLikeMsg',
defaultHomePage = 'defaultHomePage',
previewQuality = 'previewQuality',
checkDynamic = 'checkDynamic',
dynamicPeriod = 'dynamicPeriod',
schemeVariant = 'schemeVariant',
// Sponsor Block
enableSponsorBlock = 'enableSponsorBlock',
blockSettings = 'blockSettings',
blockLimit = 'blockLimit',
@@ -263,10 +269,6 @@ class SettingBoxKey {
blockToast = 'blockToast',
blockServer = 'blockServer',
blockTrack = 'blockTrack',
previewQuality = 'previewQuality',
checkDynamic = 'checkDynamic',
dynamicPeriod = 'dynamicPeriod',
schemeVariant = 'schemeVariant',
// 弹幕相关设置 权重(云屏蔽) 屏蔽类型 显示区域 透明度 字体大小 弹幕时间 描边粗细 字体粗细
danmakuWeight = 'danmakuWeight',