diff --git a/lib/common/widgets/icon_button.dart b/lib/common/widgets/icon_button.dart new file mode 100644 index 000000000..69f4c7d56 --- /dev/null +++ b/lib/common/widgets/icon_button.dart @@ -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, + ), + ), + ); +} diff --git a/lib/pages/video/detail/controller.dart b/lib/pages/video/detail/controller.dart index d1438e4d3..57be34184 100644 --- a/lib/pages/video/detail/controller.dart +++ b/lib/pages/video/detail/controller.dart @@ -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 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(); + final childKey = GlobalKey(); + 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? 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? list; + + void onBlock(BuildContext context) { + PersistentBottomSheetController? ctr; + list ??= []; + 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 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 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( + 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( + 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, + ); + }, + ); } diff --git a/lib/pages/video/detail/view.dart b/lib/pages/video/detail/view.dart index 81acca320..04c89104c 100644 --- a/lib/pages/video/detail/view.dart +++ b/lib/pages/video/detail/view.dart @@ -85,8 +85,6 @@ class _VideoDetailPageState extends State // StreamSubscription? _bufferedListener; bool get isFullScreen => plPlayerController?.isFullScreen.value ?? false; - final scaffoldKey = GlobalKey(); - @override void initState() { super.initState(); @@ -520,7 +518,7 @@ class _VideoDetailPageState extends State ), Expanded( child: Scaffold( - key: scaffoldKey, + key: videoDetailController.childKey, resizeToAvoidBottomInset: false, body: Column( children: [ @@ -578,7 +576,7 @@ class _VideoDetailPageState extends State ), Expanded( child: Scaffold( - key: scaffoldKey, + key: videoDetailController.childKey, resizeToAvoidBottomInset: false, body: Column( children: [ @@ -630,7 +628,7 @@ class _VideoDetailPageState extends State ), Expanded( child: Scaffold( - key: scaffoldKey, + key: videoDetailController.childKey, resizeToAvoidBottomInset: false, body: Column( children: [ @@ -685,7 +683,7 @@ class _VideoDetailPageState extends State Expanded( child: Expanded( child: Scaffold( - key: scaffoldKey, + key: videoDetailController.childKey, resizeToAvoidBottomInset: false, body: Column( children: [ @@ -785,7 +783,7 @@ class _VideoDetailPageState extends State 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 // 展示二级回复 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 // 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 context: context, scaffoldState: isFullScreen ? videoDetailController.scaffoldKey.currentState - : scaffoldKey.currentState, + : videoDetailController.childKey.currentState, ).buildShowBottomSheet(); } } diff --git a/lib/pages/video/detail/widgets/header_control.dart b/lib/pages/video/detail/widgets/header_control.dart index 0ad037e18..763e78a49 100644 --- a/lib/pages/video/detail/widgets/header_control.dart +++ b/lib/pages/video/detail/widgets/header_control.dart @@ -65,7 +65,6 @@ class _HeaderControlState extends State { RxString now = ''.obs; Timer? clock; late String defaultCDNService; - bool get isFullScreen => widget.controller!.isFullScreen.value; @override @@ -1450,20 +1449,37 @@ class _HeaderControlState extends State { // ), // 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, ), diff --git a/lib/utils/storage.dart b/lib/utils/storage.dart index d375ab311..cab6087cc 100644 --- a/lib/utils/storage.dart +++ b/lib/utils/storage.dart @@ -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',