import 'dart:async'; import 'dart:math'; import 'package:PiliPlus/common/widgets/button/icon_button.dart'; import 'package:PiliPlus/common/widgets/loading_widget/loading_widget.dart'; import 'package:PiliPlus/common/widgets/pair.dart'; import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/http/sponsor_block.dart'; import 'package:PiliPlus/models/common/sponsor_block/action_type.dart'; import 'package:PiliPlus/models/common/sponsor_block/post_segment_model.dart'; import 'package:PiliPlus/models/common/sponsor_block/segment_type.dart'; import 'package:PiliPlus/pages/common/slide/common_slide_page.dart'; import 'package:PiliPlus/pages/video/controller.dart'; import 'package:PiliPlus/pages/video/post_panel/popup_menu_text.dart'; import 'package:PiliPlus/plugin/pl_player/controller.dart'; import 'package:PiliPlus/utils/duration_utils.dart'; import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart'; import 'package:flutter/foundation.dart' show kDebugMode; import 'package:flutter/material.dart'; import 'package:flutter/services.dart' show FilteringTextInputFormatter; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; class PostPanel extends CommonSlidePage { const PostPanel({ super.key, super.enableSlide, required this.videoDetailController, required this.plPlayerController, }); final VideoDetailController videoDetailController; final PlPlayerController plPlayerController; @override State createState() => _PostPanelState(); static void updateSegment({ required bool isFirst, required PostSegmentModel item, required double value, }) { if (isFirst) { item.segment.first = value; } else { item.segment.second = value; } if (item.category == SegmentType.poi_highlight || item.actionType == ActionType.full) { item.segment.second = value; } } static Widget segmentWidget( ThemeData theme, { required PostSegmentModel item, required double Function() currentPos, // get real-time pos required double videoDuration, }) { Widget segment(bool isFirst) => Builder( builder: (context) { String value = DurationUtils.formatDuration( isFirst ? item.segment.first : item.segment.second, ); return Row( spacing: 5, mainAxisSize: MainAxisSize.min, children: [ Text( '${isFirst ? '开始' : '结束'}: $value', ), iconButton( context: context, size: 26, tooltip: '设为当前', icon: const Icon(Icons.my_location), onPressed: () { updateSegment( isFirst: isFirst, item: item, value: currentPos(), ); (context as Element).markNeedsBuild(); }, ), iconButton( context: context, size: 26, tooltip: isFirst ? '视频开头' : '视频结尾', icon: isFirst ? const Icon(Icons.first_page) : const Icon(Icons.last_page), onPressed: () { updateSegment( isFirst: isFirst, item: item, value: isFirst ? 0 : videoDuration, ); (context as Element).markNeedsBuild(); }, ), iconButton( context: context, size: 26, tooltip: '编辑', icon: const Icon(Icons.edit), onPressed: () async { String initV = value; final res = await showDialog( context: context, builder: (context) => 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.colorScheme.outline, ), ), ), TextButton( onPressed: () => Get.back(result: initV), child: const Text('确定'), ), ], ), ); if (res != null) { try { List split = res .split(':') .reversed .map(num.parse) .toList(); double duration = 0; for (int i = 0; i < split.length; i++) { duration += split[i] * pow(60, i); } if (duration <= videoDuration) { updateSegment( isFirst: isFirst, item: item, value: duration, ); (context as Element).markNeedsBuild(); } } catch (e) { if (kDebugMode) debugPrint(e.toString()); } } }, ), ], ); }, ); if (item.category != SegmentType.poi_highlight) { return Wrap( runSpacing: 8, spacing: 16, children: [segment(true), segment(false)], ); } return segment(true); } } class _PostPanelState extends State with SingleTickerProviderStateMixin, CommonSlideMixin { late final VideoDetailController videoDetailController = widget.videoDetailController; late final PlPlayerController plPlayerController = widget.plPlayerController; late final List list = videoDetailController.postList; late final double videoDuration = plPlayerController.duration.value.inMilliseconds / 1000; double currentPos() => plPlayerController.position.inMilliseconds / 1000; @override Widget buildPage(ThemeData theme) { return Scaffold( resizeToAvoidBottomInset: false, appBar: AppBar( primary: false, toolbarHeight: 45, automaticallyImplyLeading: false, titleSpacing: 16, title: const Text('提交片段'), actions: [ iconButton( size: 32, context: context, tooltip: '添加片段', onPressed: () { setState(() { list.insert( 0, PostSegmentModel( segment: Pair( first: 0, second: currentPos(), ), category: SegmentType.sponsor, actionType: ActionType.skip, ), ); }); }, icon: const Icon(Icons.add), ), const SizedBox(width: 10), iconButton( size: 32, context: context, tooltip: '关闭', onPressed: Get.back, icon: const Icon(Icons.close), ), const SizedBox(width: 16), ], ), body: enableSlide ? slideList(theme) : buildList(theme), ); } late Key _key; late bool _isNested; @override void didChangeDependencies() { super.didChangeDependencies(); final controller = PrimaryScrollController.of(context); _isNested = controller is ExtendedNestedScrollController; _key = ValueKey(controller.hashCode); } @override Widget buildList(ThemeData theme) { if (list.isEmpty) { return errorWidget(); } final bottom = MediaQuery.viewPaddingOf(context).bottom; Widget child = ListView.builder( key: _key, physics: const AlwaysScrollableScrollPhysics(), padding: EdgeInsets.only(bottom: 88 + bottom), itemCount: list.length, itemBuilder: (context, index) { return _buildItem(theme, index, list[index]); }, ); if (_isNested) { child = ExtendedVisibilityDetector( uniqueKey: const Key('post-panel'), child: child, ); } return Stack( clipBehavior: Clip.none, children: [ child, Positioned( right: kFloatingActionButtonMargin, bottom: kFloatingActionButtonMargin + bottom, child: FloatingActionButton( tooltip: '提交', onPressed: () => showDialog( context: context, builder: (context) => AlertDialog( title: const Text('确定无误再提交'), actions: [ TextButton( onPressed: Get.back, child: Text( '取消', style: TextStyle(color: theme.colorScheme.outline), ), ), TextButton( onPressed: _onPost, child: const Text('确定提交'), ), ], ), ), child: const Icon(Icons.check), ), ), ], ); } Future _onPost() async { Get.back(); final res = await SponsorBlock.postSkipSegments( bvid: videoDetailController.bvid, cid: videoDetailController.cid.value, videoDuration: videoDuration, segments: list, ); if (res case Success(:final response)) { Get.back(); SmartDialog.showToast('提交成功'); list.clear(); videoDetailController.handleSBData(response); if (videoDetailController.blockListener == null) { videoDetailController.initSkip(); } } else { SmartDialog.showToast('提交失败: $res'); } } Widget _buildItem(ThemeData theme, int index, PostSegmentModel item) { return Stack( clipBehavior: Clip.none, children: [ Container( width: double.infinity, margin: const EdgeInsets.symmetric( horizontal: 16, vertical: 5, ), padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: theme.colorScheme.onInverseSurface, borderRadius: const BorderRadius.all(Radius.circular(12)), ), child: Builder( builder: (context) => Column( spacing: 8, mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (item.actionType != ActionType.full) PostPanel.segmentWidget( theme, item: item, currentPos: currentPos, videoDuration: videoDuration, ), Wrap( runSpacing: 8, spacing: 16, children: [ PopupMenuText( title: '分类', value: () => item.category, onSelected: (e) { bool flag = false; if (item.category == SegmentType.exclusive_access || item.category == SegmentType.poi_highlight) { flag = true; } item.category = e; List constraintList = e.toActionType; if (!constraintList.contains(item.actionType)) { item.actionType = constraintList.first; flag = true; } switch (e) { case SegmentType.poi_highlight: PostPanel.updateSegment( isFirst: false, item: item, value: item.segment.first, ); break; case SegmentType.exclusive_access: PostPanel.updateSegment( isFirst: true, item: item, value: 0, ); break; default: } if (flag) { (context as Element).markNeedsBuild(); } return flag; }, itemBuilder: (context) => SegmentType.values .map( (e) => PopupMenuItem(value: e, child: Text(e.title)), ) .toList(), getSelectTitle: (category) => category.title, ), PopupMenuText( title: '行为类别', value: () => item.actionType, onSelected: (e) { bool flag = false; if (item.actionType == ActionType.full) { flag = true; } item.actionType = e; if (e == ActionType.full) { flag = true; PostPanel.updateSegment( isFirst: true, item: item, value: 0, ); } if (flag) { (context as Element).markNeedsBuild(); } return flag; }, itemBuilder: (context) => ActionType.values .map( (e) => PopupMenuItem( enabled: item.category.toActionType.contains(e), value: e, child: Text(e.title), ), ) .toList(), getSelectTitle: (i) => i.title, ), ], ), ], ), ), ), Positioned( top: 0, right: 4, child: iconButton( context: context, size: 26, tooltip: '移除', icon: const Icon(Icons.clear), onPressed: () { setState(() { list.removeAt(index); }); }, ), ), Positioned( top: 0, left: 4, child: iconButton( context: context, size: 26, tooltip: '预览', icon: const Icon(Icons.preview_outlined), onPressed: () async { final player = plPlayerController.videoPlayerController; if (player != null) { final start = (item.segment.first * 1000).round(); final seek = max(0, start - 2000); await player.seek(Duration(milliseconds: seek)); if (!player.state.playing) { await player.play(); } Future seekTo() => player.seek( Duration(milliseconds: (item.segment.second * 1000).round()), ); if (start > seek) { final posSub = player.stream.position.listen( null, cancelOnError: true, ); final timer = Timer( const Duration(seconds: 10), posSub.cancel, ); final duration = Duration(milliseconds: start); posSub.onData((pos) { if (pos >= duration) { seekTo(); timer.cancel(); posSub.cancel(); } }); } else { seekTo(); } } }, ), ), ], ); } }