mirror of
https://github.com/HChaZZY/PiliPlus.git
synced 2025-12-06 09:13:48 +08:00
opt: sponsor block
This commit is contained in:
@@ -139,12 +139,13 @@ class _ExtraSettingState extends State<ExtraSetting> {
|
||||
),
|
||||
body: ListView(
|
||||
children: [
|
||||
const SetSwitchItem(
|
||||
SetSwitchItem(
|
||||
title: 'Sponsor Block',
|
||||
subTitle: '跳过赞助商广告',
|
||||
subTitle: '点击配置',
|
||||
leading: Icon(Icons.block),
|
||||
setKey: SettingBoxKey.enableSponsorBlock,
|
||||
defaultVal: false,
|
||||
onTap: () => Get.toNamed('/sponsorBlock'),
|
||||
),
|
||||
Obx(
|
||||
() => ListTile(
|
||||
|
||||
160
lib/pages/setting/sponsor_block_page.dart
Normal file
160
lib/pages/setting/sponsor_block_page.dart
Normal file
@@ -0,0 +1,160 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:PiliPalaX/common/widgets/pair.dart';
|
||||
import 'package:PiliPalaX/pages/video/detail/controller.dart'
|
||||
show SegmentType, SegmentTypeExt, SkipType, SkipTypeExt;
|
||||
import 'package:PiliPalaX/utils/storage.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
class SponsorBlockPage extends StatefulWidget {
|
||||
const SponsorBlockPage({super.key});
|
||||
|
||||
@override
|
||||
State<SponsorBlockPage> createState() => _SponsorBlockPageState();
|
||||
}
|
||||
|
||||
class _SponsorBlockPageState extends State<SponsorBlockPage> {
|
||||
late double _blockLimit;
|
||||
late List<Pair<SegmentType, SkipType>> _blockSettings;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_blockLimit = GStorage.blockLimit;
|
||||
_blockSettings = GStorage.blockSettings;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
centerTitle: false,
|
||||
titleSpacing: 0,
|
||||
title: Text(
|
||||
'Sponsor Block',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
body: ListView.separated(
|
||||
itemCount: _blockSettings.length + 1,
|
||||
itemBuilder: (_, index) => index == 0
|
||||
? ListTile(
|
||||
onTap: () {
|
||||
final textController =
|
||||
TextEditingController(text: _blockLimit.toString());
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Block Limit'),
|
||||
content: TextFormField(
|
||||
keyboardType:
|
||||
TextInputType.numberWithOptions(decimal: true),
|
||||
controller: textController,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(suffixText: 's'),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: Get.back,
|
||||
child: Text(
|
||||
'取消',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Get.back();
|
||||
_blockLimit = max(0.0,
|
||||
double.tryParse(textController.text) ?? 0.0);
|
||||
await GStorage.setting
|
||||
.put(SettingBoxKey.blockLimit, _blockLimit);
|
||||
setState(() {});
|
||||
},
|
||||
child: Text('确定'),
|
||||
)
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
leading: Icon(Icons.av_timer),
|
||||
title: const Text('Block Limit'),
|
||||
trailing: Text(
|
||||
'${_blockLimit}s',
|
||||
style: TextStyle(fontSize: 13),
|
||||
),
|
||||
)
|
||||
: ListTile(
|
||||
leading: Container(
|
||||
height: 24,
|
||||
width: 24,
|
||||
alignment: Alignment.center,
|
||||
child: Container(
|
||||
height: 10,
|
||||
width: 10,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: _blockSettings[index - 1].first.color,
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
_blockSettings[index - 1].first.name,
|
||||
style: _blockSettings[index - 1].second == SkipType.disable
|
||||
? TextStyle(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
trailing: PopupMenuButton(
|
||||
initialValue: _blockSettings[index - 1].second,
|
||||
onSelected: (item) async {
|
||||
_blockSettings[index - 1].second = item;
|
||||
await GStorage.setting.put(
|
||||
SettingBoxKey.blockSettings,
|
||||
_blockSettings
|
||||
.map((item) => item.second.index)
|
||||
.toList());
|
||||
setState(() {});
|
||||
},
|
||||
itemBuilder: (context) => SkipType.values
|
||||
.map((item) => PopupMenuItem<SkipType>(
|
||||
value: item,
|
||||
child: Text(item.title),
|
||||
))
|
||||
.toList(),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_blockSettings[index - 1].second.title,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: _blockSettings[index - 1].second ==
|
||||
SkipType.disable
|
||||
? Theme.of(context).colorScheme.error
|
||||
: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
size: 20,
|
||||
Icons.keyboard_arrow_right,
|
||||
color:
|
||||
_blockSettings[index - 1].second == SkipType.disable
|
||||
? Theme.of(context).colorScheme.error
|
||||
: Theme.of(context).colorScheme.primary,
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
separatorBuilder: (_, index) => Divider(height: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ class SetSwitchItem extends StatefulWidget {
|
||||
final Function? callFn;
|
||||
final bool? needReboot;
|
||||
final Widget? leading;
|
||||
final GestureTapCallback? onTap;
|
||||
|
||||
const SetSwitchItem({
|
||||
this.title,
|
||||
@@ -21,6 +22,7 @@ class SetSwitchItem extends StatefulWidget {
|
||||
this.callFn,
|
||||
this.needReboot,
|
||||
this.leading,
|
||||
this.onTap,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@@ -56,14 +58,19 @@ class _SetSwitchItemState extends State<SetSwitchItem> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
TextStyle titleStyle = Theme.of(context).textTheme.titleMedium!;
|
||||
TextStyle titleStyle = Theme.of(context).textTheme.titleMedium!.copyWith(
|
||||
color: widget.onTap != null && !val
|
||||
? Theme.of(context).colorScheme.outline
|
||||
: null);
|
||||
TextStyle subTitleStyle = Theme.of(context)
|
||||
.textTheme
|
||||
.labelMedium!
|
||||
.copyWith(color: Theme.of(context).colorScheme.outline);
|
||||
return ListTile(
|
||||
enabled: widget.onTap != null ? val : true,
|
||||
enableFeedback: true,
|
||||
onTap: () => switchChange(null),
|
||||
onTap: () =>
|
||||
widget.onTap != null ? widget.onTap?.call() : switchChange(null),
|
||||
title: Text(widget.title!, style: titleStyle),
|
||||
subtitle: widget.subTitle != null
|
||||
? Text(widget.subTitle!, style: subTitleStyle)
|
||||
@@ -73,9 +80,9 @@ class _SetSwitchItemState extends State<SetSwitchItem> {
|
||||
alignment: Alignment.centerRight, // 缩放Switch的大小后保持右侧对齐, 避免右侧空隙过大
|
||||
scale: 0.8,
|
||||
child: Switch(
|
||||
thumbIcon: MaterialStateProperty.resolveWith<Icon?>(
|
||||
(Set<MaterialState> states) {
|
||||
if (states.isNotEmpty && states.first == MaterialState.selected) {
|
||||
thumbIcon:
|
||||
WidgetStateProperty.resolveWith<Icon?>((Set<WidgetState> states) {
|
||||
if (states.isNotEmpty && states.first == WidgetState.selected) {
|
||||
return const Icon(Icons.done);
|
||||
}
|
||||
return null; // All other states will use the default thumbIcon.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:PiliPalaX/common/widgets/pair.dart';
|
||||
import 'package:PiliPalaX/common/widgets/segment_progress_bar.dart';
|
||||
import 'package:PiliPalaX/http/danmaku.dart';
|
||||
import 'package:PiliPalaX/http/init.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
@@ -22,6 +24,55 @@ import 'package:ns_danmaku/models/danmaku_item.dart';
|
||||
import '../../../utils/id_utils.dart';
|
||||
import 'widgets/header_control.dart';
|
||||
|
||||
enum SegmentType {
|
||||
sponsor,
|
||||
selfpromo,
|
||||
interaction,
|
||||
intro,
|
||||
outro,
|
||||
preview,
|
||||
music_offtopic,
|
||||
poi_highlight,
|
||||
chapter,
|
||||
filler,
|
||||
exclusive_access
|
||||
}
|
||||
|
||||
extension SegmentTypeExt on SegmentType {
|
||||
Color get color => [
|
||||
Colors.amber,
|
||||
Colors.blue,
|
||||
Colors.red,
|
||||
Colors.indigo,
|
||||
Colors.pink,
|
||||
Colors.purple,
|
||||
Colors.lightGreen,
|
||||
Colors.teal,
|
||||
Colors.cyan,
|
||||
Colors.yellow,
|
||||
Colors.orange
|
||||
][index];
|
||||
}
|
||||
|
||||
enum SkipType { alwaysSkip, skipOnce, showOnly, disable }
|
||||
|
||||
extension SkipTypeExt on SkipType {
|
||||
String get title => ['总是跳过', '跳过一次', '仅显示', '禁用'][index];
|
||||
}
|
||||
|
||||
class SegmentModel {
|
||||
SegmentModel({
|
||||
required this.segmentType,
|
||||
required this.segment,
|
||||
required this.skipType,
|
||||
required this.hasSkipped,
|
||||
});
|
||||
SegmentType segmentType;
|
||||
Pair<int, int> segment;
|
||||
SkipType skipType;
|
||||
bool hasSkipped;
|
||||
}
|
||||
|
||||
class VideoDetailController extends GetxController
|
||||
with GetSingleTickerProviderStateMixin {
|
||||
/// 路由传参
|
||||
@@ -90,8 +141,8 @@ class VideoDetailController extends GetxController
|
||||
late String cacheSecondDecode;
|
||||
late int cacheAudioQa;
|
||||
|
||||
late final bool _enableSponsorBlock;
|
||||
PlayerStatus? playerStatus;
|
||||
|
||||
StreamSubscription<Duration>? positionSubscription;
|
||||
|
||||
@override
|
||||
@@ -151,12 +202,19 @@ class VideoDetailController extends GetxController
|
||||
cacheAudioQa = setting.get(SettingBoxKey.defaultAudioQa,
|
||||
defaultValue: AudioQuality.hiRes.code);
|
||||
oid.value = IdUtils.bv2av(Get.parameters['bvid']!);
|
||||
if (setting.get(SettingBoxKey.enableSponsorBlock, defaultValue: false)) {
|
||||
_sponsorBlock();
|
||||
_enableSponsorBlock =
|
||||
setting.get(SettingBoxKey.enableSponsorBlock, defaultValue: false);
|
||||
if (_enableSponsorBlock) {
|
||||
_blockLimit = GStorage.blockLimit;
|
||||
_blockSettings = GStorage.blockSettings;
|
||||
}
|
||||
}
|
||||
|
||||
List? _segmentList;
|
||||
int? _lastPos;
|
||||
double? _blockLimit;
|
||||
List<Pair<SegmentType, SkipType>>? _blockSettings;
|
||||
List<SegmentModel>? _segmentList;
|
||||
List<Segment>? _segmentProgressList;
|
||||
|
||||
Future _sponsorBlock() async {
|
||||
dynamic result = await Request().get(
|
||||
@@ -175,30 +233,94 @@ class VideoDetailController extends GetxController
|
||||
),
|
||||
);
|
||||
if (result.data is List && result.data.isNotEmpty) {
|
||||
_segmentList = (result.data as List)
|
||||
.where((item) => item['category'] == 'sponsor')
|
||||
.toList()
|
||||
.map((item) => item['segment'])
|
||||
.toList();
|
||||
try {
|
||||
List<String> list =
|
||||
SegmentType.values.map((item) => item.name).toList();
|
||||
List<String> enableList = _blockSettings!
|
||||
.where((item) => item.second != SkipType.disable)
|
||||
.toList()
|
||||
.map((item) => item.first.name)
|
||||
.toList();
|
||||
_segmentList = (result.data as List)
|
||||
.where((item) =>
|
||||
enableList.contains(item['category']) &&
|
||||
item['segment'][1] > 0 &&
|
||||
item['segment'][1] >= item['segment'][0])
|
||||
.map(
|
||||
(item) {
|
||||
SegmentType segmentType =
|
||||
SegmentType.values[list.indexOf(item['category'])];
|
||||
SkipType skipType = _blockSettings![segmentType.index].second;
|
||||
if (skipType != SkipType.showOnly) {
|
||||
if (item['segment'][1] == item['segment'][0] ||
|
||||
item['segment'][1] - item['segment'][0] < _blockLimit) {
|
||||
skipType = SkipType.showOnly;
|
||||
}
|
||||
}
|
||||
return SegmentModel(
|
||||
segmentType: segmentType,
|
||||
segment: Pair(
|
||||
first: _convert(item['segment'][0]),
|
||||
second: _convert(item['segment'][1]),
|
||||
),
|
||||
skipType: skipType,
|
||||
hasSkipped: false,
|
||||
);
|
||||
},
|
||||
).toList();
|
||||
_segmentProgressList = _segmentList?.map((item) {
|
||||
double start = (item.segment.first / ((data.timeLength ?? 0) / 1000))
|
||||
.clamp(0.0, 1.0);
|
||||
double end = (item.segment.second / ((data.timeLength ?? 0) / 1000))
|
||||
.clamp(0.0, 1.0);
|
||||
return Segment(
|
||||
start,
|
||||
start == end ? (end + 0.01).clamp(0.0, 1.0) : end,
|
||||
item.segmentType.color,
|
||||
);
|
||||
}).toList();
|
||||
} catch (e) {
|
||||
print(e.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int _convert(value) {
|
||||
return value is double
|
||||
? value.round()
|
||||
: value is int
|
||||
? value
|
||||
: -1;
|
||||
}
|
||||
|
||||
void _initSkip() {
|
||||
if (_segmentList != null && _segmentList!.isNotEmpty) {
|
||||
positionSubscription = plPlayerController
|
||||
.videoPlayerController?.stream.position
|
||||
.listen((position) async {
|
||||
for (List item in _segmentList!) {
|
||||
// debugPrint(
|
||||
// '${position.inSeconds},,${(item.first as double).round()}');
|
||||
if ((item.first as double).round() == position.inSeconds) {
|
||||
try {
|
||||
await plPlayerController
|
||||
.seekTo(Duration(seconds: (item[1] as double).round()));
|
||||
SmartDialog.showToast('已跳过赞助商广告');
|
||||
} catch (e) {
|
||||
debugPrint('failed to skip: $e');
|
||||
SmartDialog.showToast('广告跳过失败');
|
||||
int currentPos = position.inSeconds;
|
||||
if (currentPos != _lastPos) {
|
||||
_lastPos = currentPos;
|
||||
for (SegmentModel item in _segmentList!) {
|
||||
// debugPrint(
|
||||
// '${position.inSeconds},,${item.segment.first},,${item.segment.second},,${item.skipType.name},,${item.hasSkipped}');
|
||||
if (item.segment.first == position.inSeconds) {
|
||||
if (item.skipType == SkipType.alwaysSkip ||
|
||||
(item.skipType == SkipType.skipOnce && !item.hasSkipped)) {
|
||||
try {
|
||||
plPlayerController.danmakuController?.clear();
|
||||
await plPlayerController.videoPlayerController
|
||||
?.seek(Duration(seconds: item.segment.second));
|
||||
// await plPlayerController
|
||||
// .seekTo(Duration(seconds: item.segment.second));
|
||||
SmartDialog.showToast('已跳过${item.segmentType.name}');
|
||||
item.hasSkipped = true;
|
||||
} catch (e) {
|
||||
debugPrint('failed to skip: $e');
|
||||
SmartDialog.showToast('${item.segmentType.name}跳过失败');
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -384,6 +506,7 @@ class VideoDetailController extends GetxController
|
||||
'referer': HttpString.baseUrl
|
||||
},
|
||||
),
|
||||
segmentList: _segmentProgressList,
|
||||
// 硬解
|
||||
enableHA: enableHA.value,
|
||||
hwdec: hwdec.value,
|
||||
@@ -414,6 +537,9 @@ class VideoDetailController extends GetxController
|
||||
var result = await VideoHttp.videoUrl(cid: cid.value, bvid: bvid);
|
||||
if (result['status']) {
|
||||
data = result['data'];
|
||||
if (_enableSponsorBlock) {
|
||||
await _sponsorBlock();
|
||||
}
|
||||
if (data.acceptDesc!.isNotEmpty && data.acceptDesc!.contains('试看')) {
|
||||
SmartDialog.showToast(
|
||||
'该视频为专属视频,仅提供试看',
|
||||
|
||||
Reference in New Issue
Block a user