audio block

Signed-off-by: dom <githubaccount56556@proton.me>
This commit is contained in:
dom
2026-02-08 21:01:38 +08:00
parent 0c65605ac0
commit 0cb07aef1c
10 changed files with 687 additions and 556 deletions

View File

@@ -12,6 +12,7 @@ import 'package:PiliPlus/models_new/sponsor_block/user_info.dart';
import 'package:PiliPlus/utils/storage_pref.dart';
import 'package:PiliPlus/utils/utils.dart';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart' show kDebugMode;
/// https://github.com/hanydd/BilibiliSponsorBlock/wiki/API
abstract final class SponsorBlock {
@@ -19,10 +20,12 @@ abstract final class SponsorBlock {
static final options = Options(
followRedirects: true,
// https://github.com/hanydd/BilibiliSponsorBlock/wiki/API#1-%E5%85%AC%E7%94%A8%E5%8F%82%E6%95%B0
headers: {
'origin': Constants.appName,
'x-ext-version': BuildConfig.versionName,
},
headers: kDebugMode
? null
: {
'origin': Constants.appName,
'x-ext-version': BuildConfig.versionName,
},
validateStatus: (status) => true,
);
@@ -142,7 +145,9 @@ abstract final class SponsorBlock {
'videoID': bvid,
'cid': cid.toString(),
'userID': Pref.blockUserID,
'userAgent': '${Constants.appName}/${BuildConfig.versionName}',
'userAgent': kDebugMode
? Constants.userAgent
: '${Constants.appName}/${BuildConfig.versionName}',
'videoDuration': videoDuration,
'segments': segments
.map(

View File

@@ -19,6 +19,7 @@ import 'package:PiliPlus/pages/common/common_intro_controller.dart'
show FavMixin;
import 'package:PiliPlus/pages/dynamics_repost/view.dart';
import 'package:PiliPlus/pages/main_reply/view.dart';
import 'package:PiliPlus/pages/sponsor_block/block_mixin.dart';
import 'package:PiliPlus/pages/video/controller.dart';
import 'package:PiliPlus/pages/video/introduction/ugc/widgets/triple_mixin.dart';
import 'package:PiliPlus/pages/video/pay_coins/view.dart';
@@ -43,17 +44,24 @@ import 'package:get/get.dart';
import 'package:media_kit/media_kit.dart';
class AudioController extends GetxController
with GetTickerProviderStateMixin, TripleMixin, FavMixin {
with
GetTickerProviderStateMixin,
TripleMixin,
FavMixin,
BlockConfigMixin,
BlockMixin {
late Int64 id;
late Int64 oid;
late List<Int64> subId;
late int itemType;
Int64? extraId;
late final PlaylistSource from;
late final isVideo = itemType == 1;
@override
late final bool isUgc = itemType == 1;
final Rx<DetailItem?> audioItem = Rx<DetailItem?>(null);
@override
Player? player;
late int cacheAudioQa;
@@ -109,6 +117,7 @@ class AudioController extends GetxController
final String? audioUrl = args['audioUrl'];
final hasAudioUrl = audioUrl != null;
if (hasAudioUrl) {
_querySponsorBlock();
_onOpenMedia(
audioUrl,
ua: UaType.pc.ua,
@@ -130,6 +139,16 @@ class AudioController extends GetxController
vsync: this,
duration: const Duration(milliseconds: 200),
);
if (shutdownTimerService.isActive) {
shutdownTimerService
..onPause = onPause
..isPlaying = isPlaying;
}
}
bool isPlaying() {
return player?.state.playing ?? false;
}
Future<void>? onPlay() {
@@ -203,7 +222,19 @@ class AudioController extends GetxController
}
}
@pragma('vm:notify-debugger-on-exception')
void _querySponsorBlock() {
if (isUgc && enableSponsorBlock) {
try {
final bvid = IdUtils.av2bv(oid.toInt());
final cid = subId.first.toInt();
querySponsorBlock(bvid: bvid, cid: cid);
} catch (_) {}
}
}
Future<bool> _queryPlayUrl() async {
_querySponsorBlock();
final res = await AudioGrpc.audioPlayUrl(
itemType: itemType,
oid: oid,
@@ -475,12 +506,12 @@ class AudioController extends GetxController
void showReply() {
MainReplyPage.toMainReplyPage(
oid: oid.toInt(),
replyType: isVideo ? 1 : 14,
replyType: isUgc ? 1 : 14,
);
}
void actionShareVideo(BuildContext context) {
final audioUrl = isVideo
final audioUrl = isUgc
? '${HttpString.baseUrl}/video/${IdUtils.av2bv(oid.toInt())}'
: '${HttpString.baseUrl}/audio/au$oid';
showDialog(
@@ -552,7 +583,7 @@ class AudioController extends GetxController
useSafeArea: true,
builder: (context) => RepostPanel(
rid: oid.toInt(),
dynType: isVideo ? 8 : 256,
dynType: isUgc ? 8 : 256,
pic: arc.cover,
title: arc.title,
uname: owner.name,
@@ -561,7 +592,7 @@ class AudioController extends GetxController
}
},
),
if (isVideo)
if (isUgc)
ListTile(
dense: true,
title: const Text(
@@ -679,7 +710,7 @@ class AudioController extends GetxController
}
@override
(Object, int) get getFavRidType => (oid, isVideo ? 2 : 12);
(Object, int) get getFavRidType => (oid, isUgc ? 2 : 12);
@override
void updateFavCount(int count) {
@@ -716,6 +747,25 @@ class AudioController extends GetxController
}
}
@override
BlockConfigMixin get blockConfig => this;
@override
int get currPosInMilliseconds => position.value.inMilliseconds;
@override
Future<void>? seekTo(Duration duration, {required bool isSeek}) =>
onSeek(duration);
@override
int? get timeLength => duration.value.inMilliseconds;
@override
bool get autoPlay => true;
@override
bool get preInitPlayer => true;
@override
void onClose() {
shutdownTimerService

View File

@@ -6,6 +6,7 @@ import 'package:PiliPlus/common/widgets/flutter/refresh_indicator.dart';
import 'package:PiliPlus/common/widgets/gesture/tap_gesture_recognizer.dart';
import 'package:PiliPlus/common/widgets/image/network_img_layer.dart';
import 'package:PiliPlus/common/widgets/progress_bar/audio_video_progress_bar.dart';
import 'package:PiliPlus/common/widgets/progress_bar/segment_progress_bar.dart';
import 'package:PiliPlus/grpc/bilibili/app/listener/v1.pb.dart';
import 'package:PiliPlus/models/common/image_preview_type.dart';
import 'package:PiliPlus/models/common/image_type.dart';
@@ -29,6 +30,7 @@ import 'package:PiliPlus/utils/utils.dart';
import 'package:flutter/material.dart' hide DraggableScrollableSheet;
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:get/get.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
class AudioPage extends StatefulWidget {
const AudioPage({super.key});
@@ -87,6 +89,16 @@ class _AudioPageState extends State<AudioPage> {
resizeToAvoidBottomInset: false,
appBar: AppBar(
actions: [
if (_controller.isUgc && _controller.enableSponsorBlock)
Obx(() {
if (_controller.segmentProgressList.isNotEmpty) {
return IconButton(
onPressed: _controller.showSBDetail,
icon: const Icon(MdiIcons.advertisements, size: 22),
);
}
return const SizedBox.shrink();
}),
Builder(
builder: (context) {
return PopupMenuButton<ListOrder>(
@@ -107,14 +119,14 @@ class _AudioPageState extends State<AudioPage> {
tooltip: '定时关闭',
onPressed: () => shutdownTimerService
..onPause ??= _controller.onPause
..isPlaying ??= (() => _controller.player?.state.playing ?? false)
..isPlaying ??= _controller.isPlaying
..showScheduleExitDialog(
context,
isFullScreen: false,
),
icon: const Icon(Icons.schedule, size: 22),
),
if (_controller.isVideo)
if (_controller.isUgc)
IconButton(
tooltip: '更多',
onPressed: _showMore,
@@ -754,7 +766,7 @@ class _AudioPageState extends State<AudioPage> {
final baseBarColor = colorScheme.brightness.isDark
? const Color(0x33FFFFFF)
: const Color(0x33999999);
return Obx(
Widget child = Obx(
() => ProgressBar(
progress: _controller.position.value,
total: _controller.duration.value,
@@ -770,6 +782,30 @@ class _AudioPageState extends State<AudioPage> {
onSeek: _onSeek,
),
);
if (_controller.isUgc && _controller.enableSponsorBlock) {
child = Stack(
children: [
child,
Positioned(
left: 0,
right: 0,
bottom: 3.5,
child: Obx(
() {
if (_controller.segmentProgressList.isNotEmpty) {
return SegmentProgressBar(
height: 5,
segments: _controller.segmentProgressList,
);
}
return const SizedBox();
},
),
),
],
);
}
return child;
}
Widget _buildDuration(ColorScheme colorScheme) {
@@ -883,10 +919,8 @@ class _AudioPageState extends State<AudioPage> {
const SizedBox(height: 12),
SelectableText(
audioItem.arc.title,
style: const TextStyle(
height: 1.7,
fontSize: 16,
),
style: const TextStyle(height: 1.7, fontSize: 16),
scrollPhysics: const NeverScrollableScrollPhysics(),
),
const SizedBox(height: 12),
if (audioItem.owner.hasName()) ...[

View File

@@ -0,0 +1,525 @@
import 'dart:async' show StreamSubscription, Timer;
import 'package:PiliPlus/common/widgets/pair.dart';
import 'package:PiliPlus/common/widgets/progress_bar/segment_progress_bar.dart';
import 'package:PiliPlus/http/loading_state.dart';
import 'package:PiliPlus/http/sponsor_block.dart';
import 'package:PiliPlus/models/common/sponsor_block/segment_model.dart';
import 'package:PiliPlus/models/common/sponsor_block/segment_type.dart';
import 'package:PiliPlus/models/common/sponsor_block/skip_type.dart';
import 'package:PiliPlus/models_new/sponsor_block/segment_item.dart';
import 'package:PiliPlus/utils/duration_utils.dart';
import 'package:PiliPlus/utils/storage_pref.dart';
import 'package:easy_debounce/easy_throttle.dart';
import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:flutter/material.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import 'package:media_kit/media_kit.dart';
mixin BlockConfigMixin {
late final pgcSkipType = Pref.pgcSkipType;
late final enablePgcSkip = pgcSkipType != SkipType.disable;
late final bool enableSponsorBlock = Pref.enableSponsorBlock;
late final bool enableBlock = enableSponsorBlock || enablePgcSkip;
late final double blockLimit = Pref.blockLimit;
late final blockSettings = Pref.blockSettings;
late final List<Color> blockColor = Pref.blockColor;
late final Set<String> enableList = blockSettings
.where((item) => item.second != SkipType.disable)
.map((item) => item.first.name)
.toSet();
}
mixin BlockMixin on GetxController {
int? _lastBlockPos;
BlockConfigMixin get blockConfig;
StreamSubscription<Duration>? _blockListener;
StreamSubscription<Duration>? get blockListener => _blockListener;
Color _getBlockColor(SegmentType segment) =>
blockConfig.blockColor[segment.index];
late final List<SegmentModel> _segmentList = <SegmentModel>[];
late final RxList<Segment> segmentProgressList = <Segment>[].obs;
Timer? _skipTimer;
late final listKey = GlobalKey<AnimatedListState>();
late final List listData = [];
RxString? get videoLabel => null;
Player? get player;
bool get autoPlay;
int? get timeLength;
bool get preInitPlayer;
int get currPosInMilliseconds;
bool get isFullScreen => false;
bool get isUgc;
late final isBlock = isUgc || !blockConfig.enablePgcSkip;
Future<void> querySponsorBlock({
required String bvid,
required int cid,
}) async {
resetBlock();
final result = await SponsorBlock.getSkipSegments(bvid: bvid, cid: cid);
switch (result) {
case Success<List<SegmentItemModel>>(:final response):
handleSBData(response);
case Error(:final code) when code != 404:
if (kDebugMode) {
result.toast();
}
default:
}
}
void initSkip() {
if (isClosed) return;
if (_segmentList.isNotEmpty) {
_blockListener?.cancel();
_blockListener = player?.stream.position.listen((position) {
int currentPos = position.inSeconds;
if (currentPos != _lastBlockPos) {
_lastBlockPos = currentPos;
final msPos = currentPos * 1000;
for (SegmentModel item in _segmentList) {
// if (kDebugMode) {
// debugPrint(
// '${position.inSeconds},,${item.segment.first},,${item.segment.second},,${item.skipType.name},,${item.hasSkipped}');
// }
if (msPos <= item.segment.first &&
item.segment.first <= msPos + 1000) {
switch (item.skipType) {
case SkipType.alwaysSkip:
onSkip(item, isSeek: false);
break;
case SkipType.skipOnce:
if (!item.hasSkipped) {
item.hasSkipped = true;
onSkip(item, isSeek: false);
}
break;
case SkipType.skipManually:
onAddItem(item);
break;
default:
break;
}
break;
}
}
}
});
}
}
Future<void> handleSBData(List<SegmentItemModel> list) async {
if (list.isNotEmpty) {
try {
Future? future;
final duration = list.first.videoDuration ?? timeLength!;
// segmentList
_segmentList.addAll(
list
.where(
(item) =>
blockConfig.enableList.contains(item.category) &&
item.segment[1] >= item.segment[0],
)
.map(
(item) {
final segmentType = SegmentType.values.byName(item.category);
if (item.segment[0] == 0 && item.segment[1] == 0) {
videoLabel?.value +=
'${videoLabel!.value.isNotEmpty ? '/' : ''}${segmentType.title}';
}
SkipType skipType;
if (isBlock) {
skipType =
blockConfig.blockSettings[segmentType.index].second;
if (skipType != SkipType.showOnly) {
if (item.segment[1] == item.segment[0] ||
item.segment[1] - item.segment[0] <
blockConfig.blockLimit) {
skipType = SkipType.showOnly;
}
}
} else {
skipType = blockConfig.pgcSkipType;
}
final segmentModel = SegmentModel(
UUID: item.uuid,
segmentType: segmentType,
segment: Pair(
first: item.segment[0],
second: item.segment[1],
),
skipType: skipType,
);
if (_blockListener == null && autoPlay && player != null) {
final currPos = currPosInMilliseconds;
if (currPos >= segmentModel.segment.first &&
currPos < segmentModel.segment.second) {
_lastBlockPos = currPos;
switch (segmentModel.skipType) {
case SkipType.alwaysSkip:
case SkipType.skipOnce:
segmentModel.hasSkipped = true;
if (player!.state.playing) {
future = onSkip(
segmentModel,
);
} else {
player!.stream.playing.firstWhere((e) {
if (e) {
future = onSkip(segmentModel);
return true;
}
return false;
});
}
break;
case SkipType.skipManually:
onAddItem(segmentModel);
break;
default:
break;
}
}
}
return segmentModel;
},
),
);
// _segmentProgressList
segmentProgressList.addAll(
_segmentList.map((e) {
double start = (e.segment.first / duration).clamp(0.0, 1.0);
double end = (e.segment.second / duration).clamp(0.0, 1.0);
return Segment(
start: start,
end: end,
color: _getBlockColor(e.segmentType),
);
}),
);
if (_blockListener == null && (autoPlay || preInitPlayer)) {
await future;
initSkip();
}
} catch (e) {
if (kDebugMode) debugPrint('failed to parse sponsorblock: $e');
}
}
}
void onAddItem(dynamic item) {
if (listData.contains(item)) return;
listData.insert(0, item);
listKey.currentState?.insertItem(0);
_skipTimer ??= Timer.periodic(const Duration(seconds: 4), (_) {
if (listData.isNotEmpty) {
onRemoveItem(listData.length - 1, listData.last);
}
});
}
void onRemoveItem(int index, item) {
EasyThrottle.throttle(
'onRemoveItem',
const Duration(milliseconds: 500),
() {
try {
listData.removeAt(index);
if (listData.isEmpty) {
_stopSkipTimer();
}
listKey.currentState?.removeItem(
index,
(context, animation) => buildItem(item, animation),
);
} catch (_) {}
},
);
}
Widget buildItem(dynamic item, Animation<double> animation) =>
throw UnimplementedError();
void _stopSkipTimer() {
if (_skipTimer != null) {
_skipTimer!.cancel();
_skipTimer = null;
}
}
Future<void>? seekTo(Duration duration, {required bool isSeek});
Future<void> onSkip(
SegmentModel item, {
bool isSkip = true,
bool isSeek = true,
}) async {
try {
await seekTo(
Duration(milliseconds: item.segment.second),
isSeek: isSeek,
);
if (isSkip) {
if (autoPlay && Pref.blockToast) {
_showBlockToast('已跳过${item.segmentType.shortTitle}片段');
}
if (isBlock && Pref.blockTrack) {
SponsorBlock.viewedVideoSponsorTime(item.UUID);
}
} else {
_showBlockToast('已跳至${item.segmentType.shortTitle}');
}
} catch (e) {
if (kDebugMode) debugPrint('failed to skip: $e');
if (isSkip) {
_showBlockToast('${item.segmentType.shortTitle}片段跳过失败');
} else {
_showBlockToast('跳转失败');
}
}
}
void _showBlockToast(String msg) {
SmartDialog.showToast(
msg,
alignment: isFullScreen ? const Alignment(0, 0.7) : null,
);
}
void _showVoteDialog(SegmentModel segment) {
showDialog(
context: Get.context!,
builder: (context) => AlertDialog(
clipBehavior: Clip.hardEdge,
contentPadding: const EdgeInsets.fromLTRB(0, 10, 0, 10),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
dense: true,
title: const Text('赞成票', style: TextStyle(fontSize: 14)),
onTap: () {
Get.back();
_doVote(segment.UUID, 1);
},
),
ListTile(
dense: true,
title: const Text('反对票', style: TextStyle(fontSize: 14)),
onTap: () {
Get.back();
_doVote(segment.UUID, 0);
},
),
ListTile(
dense: true,
title: const Text('更改类别', style: TextStyle(fontSize: 14)),
onTap: () {
Get.back();
_showCategoryDialog(segment);
},
),
],
),
),
),
);
}
void _doVote(String uuid, int type) => SponsorBlock.voteOnSponsorTime(
uuid: uuid,
type: type,
).then((i) => SmartDialog.showToast(i.isSuccess ? '投票成功' : '投票失败: $i'));
void _showCategoryDialog(SegmentModel segment) {
showDialog(
context: Get.context!,
builder: (context) => AlertDialog(
clipBehavior: Clip.hardEdge,
contentPadding: const EdgeInsets.fromLTRB(0, 10, 0, 10),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: SegmentType.values
.map(
(item) => ListTile(
dense: true,
onTap: () {
Get.back();
SponsorBlock.voteOnSponsorTime(
uuid: segment.UUID,
category: item,
).then((i) {
SmartDialog.showToast(
'类别更改${i.isSuccess ? '成功' : '失败: $i'}',
);
});
},
title: Text.rich(
TextSpan(
children: [
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Container(
height: 10,
width: 10,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _getBlockColor(item),
),
),
style: const TextStyle(fontSize: 14, height: 1),
),
TextSpan(
text: ' ${item.title}',
style: const TextStyle(fontSize: 14, height: 1),
),
],
),
),
),
)
.toList(),
),
),
),
);
}
void showSBDetail() {
showDialog(
context: Get.context!,
builder: (context) => AlertDialog(
clipBehavior: Clip.hardEdge,
contentPadding: const EdgeInsets.fromLTRB(0, 10, 0, 10),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: _segmentList
.map(
(item) => ListTile(
onTap: () {
Get.back();
if (isBlock) {
_showVoteDialog(item);
}
},
dense: true,
title: Text.rich(
TextSpan(
children: [
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Container(
height: 10,
width: 10,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _getBlockColor(item.segmentType),
),
),
style: const TextStyle(fontSize: 14, height: 1),
),
TextSpan(
text: ' ${item.segmentType.title}',
style: const TextStyle(fontSize: 14, height: 1),
),
],
),
),
contentPadding: const EdgeInsets.only(left: 16, right: 8),
subtitle: Text(
'${DurationUtils.formatDuration(item.segment.first / 1000)}${DurationUtils.formatDuration(item.segment.second / 1000)}',
style: const TextStyle(fontSize: 13),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
item.skipType.label,
style: const TextStyle(fontSize: 13),
),
if (item.segment.second != 0)
SizedBox(
width: 36,
height: 36,
child: IconButton(
tooltip: item.skipType == SkipType.showOnly
? '跳至此片段'
: '跳过此片段',
onPressed: () {
Get.back();
onSkip(
item,
isSkip: item.skipType != SkipType.showOnly,
isSeek: false,
);
},
style: IconButton.styleFrom(
padding: EdgeInsets.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
icon: Icon(
item.skipType == SkipType.showOnly
? Icons.my_location
: MdiIcons.debugStepOver,
size: 18,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.7),
),
),
)
else
const SizedBox(width: 10),
],
),
),
)
.toList(),
),
),
),
);
}
void cancelBlockListener() {
if (_blockListener != null) {
_blockListener!.cancel();
_blockListener = null;
}
}
void resetBlock() {
cancelBlockListener();
_lastBlockPos = null;
videoLabel?.value = '';
_segmentList.clear();
segmentProgressList.clear();
}
@override
void onClose() {
_stopSkipTimer();
if (blockConfig.enableBlock) {
resetBlock();
}
super.onClose();
}
}

View File

@@ -11,7 +11,6 @@ import 'package:PiliPlus/http/constants.dart';
import 'package:PiliPlus/http/fav.dart';
import 'package:PiliPlus/http/init.dart';
import 'package:PiliPlus/http/loading_state.dart';
import 'package:PiliPlus/http/sponsor_block.dart';
import 'package:PiliPlus/http/ua_type.dart';
import 'package:PiliPlus/http/user.dart';
import 'package:PiliPlus/http/video.dart';
@@ -21,7 +20,6 @@ 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_model.dart';
import 'package:PiliPlus/models/common/sponsor_block/segment_type.dart';
import 'package:PiliPlus/models/common/sponsor_block/skip_type.dart';
import 'package:PiliPlus/models/common/video/audio_quality.dart';
import 'package:PiliPlus/models/common/video/source_type.dart';
import 'package:PiliPlus/models/common/video/subtitle_pref_type.dart';
@@ -32,7 +30,6 @@ import 'package:PiliPlus/models/video/play/url.dart';
import 'package:PiliPlus/models_new/download/bili_download_entry_info.dart';
import 'package:PiliPlus/models_new/media_list/media_list.dart';
import 'package:PiliPlus/models_new/pgc/pgc_info_model/result.dart';
import 'package:PiliPlus/models_new/sponsor_block/segment_item.dart';
import 'package:PiliPlus/models_new/video/video_detail/data.dart';
import 'package:PiliPlus/models_new/video/video_detail/episode.dart' as ugc;
import 'package:PiliPlus/models_new/video/video_detail/page.dart';
@@ -42,6 +39,7 @@ import 'package:PiliPlus/models_new/video/video_stein_edgeinfo/data.dart';
import 'package:PiliPlus/pages/audio/view.dart';
import 'package:PiliPlus/pages/common/publish/publish_route.dart';
import 'package:PiliPlus/pages/search/widgets/search_text.dart';
import 'package:PiliPlus/pages/sponsor_block/block_mixin.dart';
import 'package:PiliPlus/pages/video/download_panel/view.dart';
import 'package:PiliPlus/pages/video/introduction/pgc/controller.dart';
import 'package:PiliPlus/pages/video/introduction/ugc/controller.dart';
@@ -56,7 +54,6 @@ import 'package:PiliPlus/plugin/pl_player/models/heart_beat_type.dart';
import 'package:PiliPlus/plugin/pl_player/models/play_status.dart';
import 'package:PiliPlus/services/download/download_service.dart';
import 'package:PiliPlus/utils/accounts.dart';
import 'package:PiliPlus/utils/duration_utils.dart';
import 'package:PiliPlus/utils/extension/context_ext.dart';
import 'package:PiliPlus/utils/extension/iterable_ext.dart';
import 'package:PiliPlus/utils/extension/num_ext.dart';
@@ -67,7 +64,6 @@ import 'package:PiliPlus/utils/storage.dart';
import 'package:PiliPlus/utils/storage_pref.dart';
import 'package:PiliPlus/utils/utils.dart';
import 'package:PiliPlus/utils/video_utils.dart';
import 'package:easy_debounce/easy_throttle.dart';
import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart';
import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:flutter/material.dart';
@@ -75,11 +71,10 @@ import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:flutter_volume_controller/flutter_volume_controller.dart';
import 'package:get/get.dart';
import 'package:hive/hive.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import 'package:media_kit/media_kit.dart';
class VideoDetailController extends GetxController
with GetTickerProviderStateMixin {
with GetTickerProviderStateMixin, BlockMixin {
/// 路由传参
late final Map args;
late String bvid;
@@ -93,6 +88,7 @@ class VideoDetailController extends GetxController
// 视频类型 默认投稿视频
late final VideoType videoType;
@override
late final isUgc = videoType == VideoType.ugc;
VideoType? _actualVideoType;
@@ -118,7 +114,7 @@ class VideoDetailController extends GetxController
late VideoDecodeFormatType currentDecodeFormats;
// 是否开始自动播放 存在多p的情况下第二p需要为true
final RxBool autoPlay = Pref.autoPlayEnable.obs;
final RxBool _autoPlay = Pref.autoPlayEnable.obs;
final videoPlayerKey = GlobalKey();
final childKey = GlobalKey<ScaffoldState>();
@@ -165,7 +161,6 @@ class VideoDetailController extends GetxController
late final RxInt seasonIndex = 0.obs;
PlayerStatus? playerStatus;
StreamSubscription<Duration>? positionSubscription;
late final scrollKey = GlobalKey<ExtendedNestedScrollViewState>();
late final RxBool isVertical = false.obs;
@@ -487,459 +482,32 @@ class VideoDetailController extends GetxController
bool get showVideoSheet =>
(!horizontalScreen && !isPortrait) || plPlayerController.isDesktopPip;
late final _isBlock = isUgc || !plPlayerController.enablePgcSkip;
int? _lastPos;
late final List<PostSegmentModel> postList = [];
late final List<SegmentModel> segmentList = <SegmentModel>[];
late final RxList<Segment> segmentProgressList = <Segment>[].obs;
@override
late final RxString videoLabel = ''.obs;
@override
int? get timeLength => data.timeLength;
@override
BlockConfigMixin get blockConfig => plPlayerController;
@override
Player? get player => plPlayerController.videoPlayerController;
@override
bool get isFullScreen => plPlayerController.isFullScreen.value;
@override
bool get autoPlay => _autoPlay.value;
set autoPlay(bool value) => _autoPlay.value = value;
@override
bool get preInitPlayer => plPlayerController.preInitPlayer;
@override
int get currPosInMilliseconds =>
defaultST?.inMilliseconds ??
plPlayerController.position.value.inMilliseconds;
@override
Future<void> seekTo(Duration duration, {required bool isSeek}) =>
plPlayerController.seekTo(duration, isSeek: isSeek);
Color _getColor(SegmentType segment) =>
plPlayerController.blockColor[segment.index];
late RxString videoLabel = ''.obs;
Timer? skipTimer;
late final listKey = GlobalKey<AnimatedListState>();
late final List listData = [];
void _vote(String uuid, int type) {
SponsorBlock.voteOnSponsorTime(
uuid: uuid,
type: type,
).then((i) => SmartDialog.showToast(i.isSuccess ? '投票成功' : '投票失败: $i'));
}
void _showCategoryDialog(BuildContext context, SegmentModel segment) {
showDialog(
context: context,
builder: (context) => AlertDialog(
clipBehavior: Clip.hardEdge,
contentPadding: const EdgeInsets.fromLTRB(0, 10, 0, 10),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: SegmentType.values
.map(
(item) => ListTile(
dense: true,
onTap: () {
Get.back();
SponsorBlock.voteOnSponsorTime(
uuid: segment.UUID,
category: item,
).then((i) {
SmartDialog.showToast(
'类别更改${i.isSuccess ? '成功' : '失败: $i'}',
);
});
},
title: Text.rich(
TextSpan(
children: [
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Container(
height: 10,
width: 10,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _getColor(item),
),
),
style: const TextStyle(fontSize: 14, height: 1),
),
TextSpan(
text: ' ${item.title}',
style: const TextStyle(fontSize: 14, height: 1),
),
],
),
),
),
)
.toList(),
),
),
),
);
}
void _showVoteDialog(BuildContext context, SegmentModel segment) {
showDialog(
context: context,
builder: (context) => AlertDialog(
clipBehavior: Clip.hardEdge,
contentPadding: const EdgeInsets.fromLTRB(0, 10, 0, 10),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
dense: true,
title: const Text(
'赞成票',
style: TextStyle(fontSize: 14),
),
onTap: () {
Get.back();
_vote(segment.UUID, 1);
},
),
ListTile(
dense: true,
title: const Text(
'反对票',
style: TextStyle(fontSize: 14),
),
onTap: () {
Get.back();
_vote(segment.UUID, 0);
},
),
ListTile(
dense: true,
title: const Text(
'更改类别',
style: TextStyle(fontSize: 14),
),
onTap: () {
Get.back();
_showCategoryDialog(context, segment);
},
),
],
),
),
),
);
}
void showSBDetail(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
clipBehavior: Clip.hardEdge,
contentPadding: const EdgeInsets.fromLTRB(0, 10, 0, 10),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: segmentList
.map(
(item) => ListTile(
onTap: () {
Get.back();
if (_isBlock) {
_showVoteDialog(context, item);
}
},
dense: true,
title: Text.rich(
TextSpan(
children: [
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Container(
height: 10,
width: 10,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _getColor(item.segmentType),
),
),
style: const TextStyle(fontSize: 14, height: 1),
),
TextSpan(
text: ' ${item.segmentType.title}',
style: const TextStyle(fontSize: 14, height: 1),
),
],
),
),
contentPadding: const EdgeInsets.only(left: 16, right: 8),
subtitle: Text(
'${DurationUtils.formatDuration(item.segment.first / 1000)}${DurationUtils.formatDuration(item.segment.second / 1000)}',
style: const TextStyle(fontSize: 13),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
item.skipType.label,
style: const TextStyle(fontSize: 13),
),
if (item.segment.second != 0)
SizedBox(
width: 36,
height: 36,
child: IconButton(
tooltip: item.skipType == SkipType.showOnly
? '跳至此片段'
: '跳过此片段',
onPressed: () {
Get.back();
onSkip(
item,
isSkip: item.skipType != SkipType.showOnly,
isSeek: false,
);
},
style: IconButton.styleFrom(
padding: EdgeInsets.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
icon: Icon(
item.skipType == SkipType.showOnly
? Icons.my_location
: MdiIcons.debugStepOver,
size: 18,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.7),
),
),
)
else
const SizedBox(width: 10),
],
),
),
)
.toList(),
),
),
),
);
}
void _showBlockToast(String msg) {
SmartDialog.showToast(
msg,
alignment: plPlayerController.isFullScreen.value
? const Alignment(0, 0.7)
: null,
);
}
Future<void> _querySponsorBlock() async {
positionSubscription?.cancel();
positionSubscription = null;
videoLabel.value = '';
segmentList.clear();
segmentProgressList.clear();
final result = await SponsorBlock.getSkipSegments(
bvid: bvid,
cid: cid.value,
);
switch (result) {
case Success<List<SegmentItemModel>>(:final response):
handleSBData(response);
case Error(:final code) when code != 404:
if (kDebugMode) {
result.toast();
}
default:
}
}
Future<void> handleSBData(List<SegmentItemModel> list) async {
if (list.isNotEmpty) {
try {
Future? future;
final duration = list.first.videoDuration ?? data.timeLength!;
// segmentList
segmentList.addAll(
list
.where(
(item) =>
plPlayerController.enableList.contains(item.category) &&
item.segment[1] >= item.segment[0],
)
.map(
(item) {
final segmentType = SegmentType.values.byName(item.category);
if (item.segment[0] == 0 && item.segment[1] == 0) {
videoLabel.value +=
'${videoLabel.value.isNotEmpty ? '/' : ''}${segmentType.title}';
}
SkipType skipType;
if (_isBlock) {
skipType = plPlayerController
.blockSettings[segmentType.index]
.second;
if (skipType != SkipType.showOnly) {
if (item.segment[1] == item.segment[0] ||
item.segment[1] - item.segment[0] <
plPlayerController.blockLimit) {
skipType = SkipType.showOnly;
}
}
} else {
skipType = Pref.pgcSkipType;
}
final segmentModel = SegmentModel(
UUID: item.uuid,
segmentType: segmentType,
segment: Pair(
first: item.segment[0],
second: item.segment[1],
),
skipType: skipType,
);
if (positionSubscription == null &&
autoPlay.value &&
plPlayerController.videoPlayerController != null) {
final currPost =
defaultST?.inMilliseconds ??
plPlayerController.position.value.inMilliseconds;
if (currPost >= segmentModel.segment.first &&
currPost < segmentModel.segment.second) {
_lastPos = currPost;
switch (segmentModel.skipType) {
case SkipType.alwaysSkip:
case SkipType.skipOnce:
segmentModel.hasSkipped = true;
final videoPlayerController =
plPlayerController.videoPlayerController!;
if (videoPlayerController.state.playing) {
future = onSkip(
segmentModel,
);
} else {
videoPlayerController.stream.playing.firstWhere((
e,
) {
if (e) {
future = onSkip(
segmentModel,
);
return true;
}
return false;
});
}
break;
case SkipType.skipManually:
onAddItem(segmentModel);
break;
default:
break;
}
}
}
return segmentModel;
},
),
);
// _segmentProgressList
segmentProgressList.addAll(
segmentList.map((e) {
double start = (e.segment.first / duration).clamp(0.0, 1.0);
double end = (e.segment.second / duration).clamp(0.0, 1.0);
return Segment(
start: start,
end: end,
color: _getColor(e.segmentType),
);
}),
);
if (positionSubscription == null &&
(autoPlay.value || plPlayerController.preInitPlayer)) {
await future;
initSkip();
}
} catch (e) {
if (kDebugMode) debugPrint('failed to parse sponsorblock: $e');
}
}
}
void initSkip() {
if (isClosed) return;
if (segmentList.isNotEmpty) {
positionSubscription?.cancel();
positionSubscription = plPlayerController
.videoPlayerController
?.stream
.position
.listen((position) {
int currentPos = position.inSeconds;
if (currentPos != _lastPos) {
_lastPos = currentPos;
final msPos = currentPos * 1000;
for (SegmentModel item in segmentList) {
// if (kDebugMode) {
// debugPrint(
// '${position.inSeconds},,${item.segment.first},,${item.segment.second},,${item.skipType.name},,${item.hasSkipped}');
// }
if (msPos <= item.segment.first &&
item.segment.first <= msPos + 1000) {
switch (item.skipType) {
case SkipType.alwaysSkip:
onSkip(item, isSeek: false);
break;
case SkipType.skipOnce:
if (!item.hasSkipped) {
item.hasSkipped = true;
onSkip(item, isSeek: false);
}
break;
case SkipType.skipManually:
onAddItem(item);
break;
default:
break;
}
break;
}
}
}
});
}
}
void onAddItem(dynamic item) {
if (listData.contains(item)) return;
listData.insert(0, item);
listKey.currentState?.insertItem(0);
skipTimer ??= Timer.periodic(const Duration(seconds: 4), (_) {
if (listData.isNotEmpty) {
onRemoveItem(listData.length - 1, listData.last);
}
});
}
void cancelSkipTimer() {
skipTimer?.cancel();
skipTimer = null;
}
void onRemoveItem(int index, item) {
EasyThrottle.throttle(
'onRemoveItem',
const Duration(milliseconds: 500),
() {
try {
listData.removeAt(index);
if (listData.isEmpty) {
cancelSkipTimer();
}
listKey.currentState?.removeItem(
index,
(context, animation) => buildItem(item, animation),
);
} catch (_) {}
},
);
}
@override
Widget buildItem(dynamic item, Animation<double> animation) {
final theme = Get.theme;
return Align(
@@ -995,36 +563,6 @@ class VideoDetailController extends GetxController
);
}
Future<void> onSkip(
SegmentModel item, {
bool isSkip = true,
bool isSeek = true,
}) async {
try {
await plPlayerController.seekTo(
Duration(milliseconds: item.segment.second),
isSeek: isSeek,
);
if (isSkip) {
if (autoPlay.value && Pref.blockToast) {
_showBlockToast('已跳过${item.segmentType.shortTitle}片段');
}
if (_isBlock && Pref.blockTrack) {
SponsorBlock.viewedVideoSponsorTime(item.UUID);
}
} else {
_showBlockToast('已跳至${item.segmentType.shortTitle}');
}
} catch (e) {
if (kDebugMode) debugPrint('failed to skip: $e');
if (isSkip) {
_showBlockToast('${item.segmentType.shortTitle}片段跳过失败');
} else {
_showBlockToast('跳转失败');
}
}
}
({int mode, int fontSize, Color color})? dmConfig;
String? savedDanmaku;
@@ -1035,7 +573,7 @@ class VideoDetailController extends GetxController
return;
}
final isPlaying =
autoPlay.value && plPlayerController.playerStatus.isPlaying;
_autoPlay.value && plPlayerController.playerStatus.isPlaying;
if (isPlaying) {
await plPlayerController.pause();
}
@@ -1095,7 +633,7 @@ class VideoDetailController extends GetxController
void updatePlayer() {
final currentVideoQa = this.currentVideoQa.value;
if (currentVideoQa == null) return;
autoPlay.value = true;
_autoPlay.value = true;
playedTime = plPlayerController.position.value;
plPlayerController
..removeListeners()
@@ -1122,7 +660,7 @@ class VideoDetailController extends GetxController
}
Future<void>? _initPlayerIfNeeded() {
if (autoPlay.value ||
if (_autoPlay.value ||
(plPlayerController.preInitPlayer && !plPlayerController.processing) &&
(isFileSource
? true
@@ -1167,7 +705,7 @@ class VideoDetailController extends GetxController
aid: aid,
bvid: bvid,
cid: cid.value,
autoplay: autoplay ?? autoPlay.value,
autoplay: autoplay ?? _autoPlay.value,
epid: isUgc ? null : epId,
seasonId: isUgc ? null : seasonId,
pgcType: isUgc ? null : pgcType,
@@ -1233,8 +771,8 @@ class VideoDetailController extends GetxController
return;
}
isQuerying = true;
if (plPlayerController.enableSponsorBlock && _isBlock && !fromReset) {
_querySponsorBlock();
if (plPlayerController.enableSponsorBlock && isBlock && !fromReset) {
querySponsorBlock(bvid: bvid, cid: cid.value);
}
if (plPlayerController.cacheVideoQa == null) {
final isWiFi = await Utils.isWiFi;
@@ -1279,8 +817,7 @@ class VideoDetailController extends GetxController
if (!isUgc && !fromReset && plPlayerController.enablePgcSkip) {
if (data.clipInfoList case final clipInfoList?) {
positionSubscription?.cancel();
positionSubscription = null;
resetBlock();
handleSBData(clipInfoList);
}
}
@@ -1313,7 +850,7 @@ class VideoDetailController extends GetxController
}
if (data.dash == null) {
SmartDialog.showToast('视频资源不存在');
autoPlay.value = false;
_autoPlay.value = false;
videoState.value = const Error('视频资源不存在');
if (plPlayerController.isFullScreen.value) {
plPlayerController.toggleFullScreen(false);
@@ -1408,7 +945,7 @@ class VideoDetailController extends GetxController
}
await _initPlayerIfNeeded();
} else {
autoPlay.value = false;
_autoPlay.value = false;
videoState.value = result..toast();
if (plPlayerController.isFullScreen.value) {
plPlayerController.toggleFullScreen(false);
@@ -1417,6 +954,7 @@ class VideoDetailController extends GetxController
isQuerying = false;
}
late final List<PostSegmentModel> postList = <PostSegmentModel>[];
void onBlock(BuildContext context) {
if (postList.isEmpty) {
postList.add(
@@ -1656,9 +1194,6 @@ class VideoDetailController extends GetxController
@override
void onClose() {
cancelSkipTimer();
positionSubscription?.cancel();
positionSubscription = null;
cid.close();
if (isFileSource) {
cacheLocalProgress();
@@ -1713,13 +1248,8 @@ class VideoDetailController extends GetxController
}
// sponsor block
if (plPlayerController.enableBlock) {
_lastPos = null;
positionSubscription?.cancel();
positionSubscription = null;
videoLabel.value = '';
segmentList.clear();
segmentProgressList.clear();
if (blockConfig.enableBlock) {
resetBlock();
}
// interactive video
@@ -1842,7 +1372,7 @@ class VideoDetailController extends GetxController
oid: aid,
subId: [cid.value],
from: from,
heroTag: autoPlay.value ? heroTag : null,
heroTag: _autoPlay.value ? heroTag : null,
start: playedTime,
audioUrl: audioUrl,
extraId: extraId,

View File

@@ -316,7 +316,7 @@ class _PostPanelState extends State<PostPanel>
SmartDialog.showToast('提交成功');
list.clear();
videoDetailController.handleSBData(response);
if (videoDetailController.positionSubscription == null) {
if (videoDetailController.blockListener == null) {
videoDetailController.initSkip();
}
} else {

View File

@@ -163,7 +163,7 @@ class _VideoDetailPageVState extends State<VideoDetailPageV>
// 获取视频资源,初始化播放器
Future<void> videoSourceInit() async {
videoDetailController.queryVideoUrl();
if (videoDetailController.autoPlay.value) {
if (videoDetailController.autoPlay) {
plPlayerController = videoDetailController.plPlayerController;
plPlayerController!
..addStatusLister(playerListener)
@@ -310,7 +310,7 @@ class _VideoDetailPageVState extends State<VideoDetailPageV>
}
}
plPlayerController = videoDetailController.plPlayerController;
videoDetailController.autoPlay.value = true;
videoDetailController.autoPlay = true;
if (videoDetailController.plPlayerController.preInitPlayer) {
await plPlayerController!.play();
} else {
@@ -385,7 +385,7 @@ class _VideoDetailPageVState extends State<VideoDetailPageV>
ScreenBrightnessPlatform.instance.resetApplicationScreenBrightness();
}
videoDetailController.positionSubscription?.cancel();
videoDetailController.cancelBlockListener();
introController.cancelTimer();
@@ -447,7 +447,7 @@ class _VideoDetailPageVState extends State<VideoDetailPageV>
}
() async {
if (videoDetailController.autoPlay.value) {
if (videoDetailController.autoPlay) {
await videoDetailController.playerInit(
autoplay: videoDetailController.playerStatus?.isPlaying ?? false,
);
@@ -547,7 +547,7 @@ class _VideoDetailPageVState extends State<VideoDetailPageV>
if (!isPortrait &&
!isFullScreen &&
plPlayerController != null &&
videoDetailController.autoPlay.value) {
videoDetailController.autoPlay) {
WidgetsBinding.instance.addPostFrameCallback((_) {
plPlayerController!.triggerFullScreen(
status: true,
@@ -1199,7 +1199,7 @@ class _VideoDetailPageVState extends State<VideoDetailPageV>
);
Widget get manualPlayerWidget => Obx(() {
if (!videoDetailController.autoPlay.value) {
if (!videoDetailController.autoPlay) {
return Stack(
clipBehavior: Clip.none,
children: [
@@ -1354,7 +1354,7 @@ class _VideoDetailPageVState extends State<VideoDetailPageV>
child: Obx(
() =>
videoDetailController.videoState.value is! Success ||
!videoDetailController.autoPlay.value ||
!videoDetailController.autoPlay ||
plPlayerController?.videoController == null
? const SizedBox.shrink()
: PLVideoPlayer(
@@ -1415,7 +1415,7 @@ class _VideoDetailPageVState extends State<VideoDetailPageV>
introController: introController,
onSendDanmaku: videoDetailController.showShootDanmakuSheet,
canPlay: () {
if (videoDetailController.autoPlay.value) {
if (videoDetailController.autoPlay) {
return true;
}
handlePlay();
@@ -1594,7 +1594,7 @@ class _VideoDetailPageVState extends State<VideoDetailPageV>
if (isShowing) plPlayer(width: width, height: height),
Obx(() {
if (!videoDetailController.autoPlay.value) {
if (!videoDetailController.autoPlay) {
return Positioned.fill(
child: GestureDetector(
onTap: handlePlay,

View File

@@ -1944,8 +1944,7 @@ class HeaderControlState extends State<HeaderControl>
child: IconButton(
tooltip: '片段信息',
style: btnStyle,
onPressed: () =>
videoDetailCtr.showSBDetail(context),
onPressed: videoDetailCtr.showSBDetail,
icon: const Icon(
MdiIcons.advertisements,
size: 19,

View File

@@ -11,7 +11,6 @@ import 'package:PiliPlus/http/ua_type.dart';
import 'package:PiliPlus/http/video.dart';
import 'package:PiliPlus/models/common/account_type.dart';
import 'package:PiliPlus/models/common/audio_normalization.dart';
import 'package:PiliPlus/models/common/sponsor_block/skip_type.dart';
import 'package:PiliPlus/models/common/super_resolution_type.dart';
import 'package:PiliPlus/models/common/video/video_type.dart';
import 'package:PiliPlus/models/user/danmaku_rule.dart';
@@ -19,6 +18,7 @@ import 'package:PiliPlus/models/video/play/url.dart';
import 'package:PiliPlus/models_new/video/video_shot/data.dart';
import 'package:PiliPlus/pages/danmaku/danmaku_model.dart';
import 'package:PiliPlus/pages/mine/controller.dart';
import 'package:PiliPlus/pages/sponsor_block/block_mixin.dart';
import 'package:PiliPlus/plugin/pl_player/models/data_source.dart';
import 'package:PiliPlus/plugin/pl_player/models/data_status.dart';
import 'package:PiliPlus/plugin/pl_player/models/double_tap_type.dart';
@@ -62,7 +62,7 @@ import 'package:path/path.dart' as path;
import 'package:wakelock_plus/wakelock_plus.dart';
import 'package:window_manager/window_manager.dart';
class PlPlayerController {
class PlPlayerController with BlockConfigMixin {
Player? _videoPlayerController;
VideoController? _videoController;
@@ -358,19 +358,6 @@ class PlPlayerController {
late double subtitleStrokeWidth = Pref.subtitleStrokeWidth;
late int subtitleFontWeight = Pref.subtitleFontWeight;
late final pgcSkipType = Pref.pgcSkipType;
late final enablePgcSkip = Pref.pgcSkipType != SkipType.disable;
// sponsor block
late final bool enableSponsorBlock = Pref.enableSponsorBlock;
late final bool enableBlock = enableSponsorBlock || enablePgcSkip;
late final double blockLimit = Pref.blockLimit;
late final blockSettings = Pref.blockSettings;
late final List<Color> blockColor = Pref.blockColor;
late final Set<String> enableList = blockSettings
.where((item) => item.second != SkipType.disable)
.map((item) => item.first.name)
.toSet();
// settings
late final showFSActionItem = Pref.showFSActionItem;
late final enableShrinkVideoSize = Pref.enableShrinkVideoSize;

View File

@@ -33,6 +33,7 @@ class ShutdownTimerService {
ValueGetter<bool>? isPlaying;
Timer? _shutdownTimer;
bool get isActive => _shutdownTimer?.isActive ?? false;
int _durationInMinutes = 0;
_ShutdownType _shutdownType = .pause;