diff --git a/lib/common/widgets/video_card_h.dart b/lib/common/widgets/video_card_h.dart index 9fc7daad..c78643db 100644 --- a/lib/common/widgets/video_card_h.dart +++ b/lib/common/widgets/video_card_h.dart @@ -324,8 +324,9 @@ class VideoContent extends StatelessWidget { reSrc: 11, ); SmartDialog.dismiss(); - SmartDialog.showToast( - res['msg'] ?? '成功'); + SmartDialog.showToast(res['code'] == 0 + ? '成功' + : res['msg']); }, child: const Text('确认'), ) diff --git a/lib/common/widgets/video_card_v.dart b/lib/common/widgets/video_card_v.dart index 0bbd5377..04950d5b 100644 --- a/lib/common/widgets/video_card_v.dart +++ b/lib/common/widgets/video_card_v.dart @@ -158,12 +158,12 @@ class VideoCardV extends StatelessWidget { height: maxHeight, ), ), - if (videoItem.duration != null) + if (videoItem.duration > 0) if (crossAxisCount == 1) ...[ PBadge( bottom: 10, right: 10, - text: videoItem.duration, + text: Utils.timeFormat(videoItem.duration), ) ] else ...[ PBadge( @@ -171,7 +171,7 @@ class VideoCardV extends StatelessWidget { right: 7, size: 'small', type: 'gray', - text: videoItem.duration, + text: Utils.timeFormat(videoItem.duration), ) ], ], @@ -331,10 +331,8 @@ class VideoStat extends StatelessWidget { color: Theme.of(context).colorScheme.outline, ), children: [ - if (videoItem.stat.view != '-') - TextSpan(text: '${videoItem.stat.view}观看'), - if (videoItem.stat.danmu != '-') - TextSpan(text: ' • ${videoItem.stat.danmu}弹幕'), + TextSpan(text: '${Utils.numFormat(videoItem.stat.view)}观看'), + TextSpan(text: ' • ${Utils.numFormat(videoItem.stat.danmu)}弹幕'), ], ), ); diff --git a/lib/http/video.dart b/lib/http/video.dart index eaa131f3..d0faabaa 100644 --- a/lib/http/video.dart +++ b/lib/http/video.dart @@ -9,6 +9,7 @@ import '../models/user/fav_folder.dart'; import '../models/video/ai.dart'; import '../models/video/play/url.dart'; import '../models/video_detail_res.dart'; +import '../utils/recommend_filter.dart'; import '../utils/storage.dart'; import '../utils/wbi_sign.dart'; import 'api.dart'; @@ -46,8 +47,13 @@ class VideoHttp { setting.get(SettingBoxKey.blackMidsList, defaultValue: [-1]); for (var i in res.data['data']['item']) { //过滤掉live与ad,以及拉黑用户 - if (i['goto'] == 'av' && !blackMidsList.contains(i['owner']['mid'])) { - list.add(RecVideoItemModel.fromJson(i)); + if (i['goto'] == 'av' && + (i['owner'] != null && + !blackMidsList.contains(i['owner']['mid']))) { + RecVideoItemModel videoItem = RecVideoItemModel.fromJson(i); + if (!RecommendFilter.filter(videoItem)){ + list.add(videoItem); + } } } return {'status': true, 'data': list}; @@ -59,7 +65,9 @@ class VideoHttp { } } - static Future rcmdVideoListApp({int? ps, required int freshIdx}) async { + // 添加额外的loginState变量模拟未登录状态 + static Future rcmdVideoListApp( + {bool loginStatus = true, required int freshIdx}) async { try { var res = await Request().get( Api.recommendListApp, @@ -72,9 +80,11 @@ class VideoHttp { 'device_name': 'vivo', 'pull': freshIdx == 0 ? 'true' : 'false', 'appkey': Constants.appKey, - 'access_key': localCache - .get(LocalCacheKey.accessKey, defaultValue: {})['value'] ?? - '' + 'access_key': loginStatus + ? (localCache.get(LocalCacheKey.accessKey, + defaultValue: {})['value'] ?? + '') + : '' }, ); if (res.data['code'] == 0) { @@ -87,12 +97,15 @@ class VideoHttp { (!enableRcmdDynamic ? i['card_goto'] != 'picture' : true) && (i['args'] != null && !blackMidsList.contains(i['args']['up_mid']))) { - list.add(RecVideoItemAppModel.fromJson(i)); + RecVideoItemAppModel videoItem = RecVideoItemAppModel.fromJson(i); + if (!RecommendFilter.filter(videoItem)){ + list.add(videoItem); + } } } return {'status': true, 'data': list}; } else { - return {'status': false, 'data': [], 'msg': ''}; + return {'status': false, 'data': [], 'msg': res.data['message']}; } } catch (err) { return {'status': false, 'data': [], 'msg': err.toString()}; @@ -203,7 +216,10 @@ class VideoHttp { if (res.data['code'] == 0) { List list = []; for (var i in res.data['data']) { - list.add(HotVideoItemModel.fromJson(i)); + HotVideoItemModel videoItem = HotVideoItemModel.fromJson(i); + if (!RecommendFilter.filter(videoItem, relatedVideos: true)){ + list.add(videoItem); + } } return {'status': true, 'data': list}; } else { diff --git a/lib/main.dart b/lib/main.dart index bdf3f6b7..5c467722 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -21,9 +21,11 @@ import 'package:pilipala/utils/app_scheme.dart'; import 'package:pilipala/utils/data.dart'; import 'package:pilipala/utils/storage.dart'; import 'package:media_kit/media_kit.dart'; // Provides [Player], [Media], [Playlist] etc. +import 'package:pilipala/utils/recommend_filter.dart'; import 'package:catcher_2/catcher_2.dart'; import './services/loggeer.dart'; + void main() async { WidgetsFlutterBinding.ensureInitialized(); MediaKit.ensureInitialized(); @@ -34,6 +36,7 @@ void main() async { await setupServiceLocator(); Request(); await Request.setCookie(); + RecommendFilter(); // 异常捕获 logo记录 final Catcher2Options debugConfig = Catcher2Options( @@ -60,6 +63,7 @@ void main() async { }, ); + // 小白条、导航栏沉浸 SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle( diff --git a/lib/models/common/rcmd_type.dart b/lib/models/common/rcmd_type.dart index dbb64b15..2dfdad1c 100644 --- a/lib/models/common/rcmd_type.dart +++ b/lib/models/common/rcmd_type.dart @@ -1,7 +1,7 @@ // 首页推荐类型 -enum RcmdType { web, app } +enum RcmdType { web, app, notLogin } extension RcmdTypeExtension on RcmdType { - String get values => ['web', 'app'][index]; - String get labels => ['web端', 'app端'][index]; + String get values => ['web', 'app', 'notLogin'][index]; + String get labels => ['web端', 'app端', '游客模式'][index]; } diff --git a/lib/models/home/rcmd/result.dart b/lib/models/home/rcmd/result.dart index d5bc7d89..9363beb3 100644 --- a/lib/models/home/rcmd/result.dart +++ b/lib/models/home/rcmd/result.dart @@ -28,7 +28,7 @@ class RecVideoItemAppModel { int? cid; String? pic; RcmdStat? stat; - String? duration; + int? duration; String? title; int? isFollowed; RcmdOwner? owner; @@ -54,13 +54,27 @@ class RecVideoItemAppModel { cid = json['player_args'] != null ? json['player_args']['cid'] : -1; pic = json['cover']; stat = RcmdStat.fromJson(json); - duration = json['cover_right_text']; + // 改用player_args中的duration作为原始数据(秒数) + duration = json['player_args'] != null + ? json['player_args']['duration'] + : -1; + //duration = json['cover_right_text']; title = json['title']; - isFollowed = 0; owner = RcmdOwner.fromJson(json); rcmdReason = json['rcmd_reason_style'] != null ? RcmdReason.fromJson(json['rcmd_reason_style']) : null; + // 由于app端api并不会直接返回与owner的关注状态 + // 所以借用推荐原因是否为“已关注”、“新关注”等判别关注状态,从而与web端接口等效 + isFollowed = rcmdReason != null && + rcmdReason!.content != null && + rcmdReason!.content!.contains('关注') + ? 1 + : 0; + // 如果是,就无需再显示推荐原因,交由view统一处理即可 + if (isFollowed == 1) { + rcmdReason = null; + } goto = json['goto']; param = int.parse(json['param']); uri = json['uri']; diff --git a/lib/models/model_rec_video_item.dart b/lib/models/model_rec_video_item.dart index bd42fd82..1503f192 100644 --- a/lib/models/model_rec_video_item.dart +++ b/lib/models/model_rec_video_item.dart @@ -1,5 +1,3 @@ -import 'package:pilipala/utils/utils.dart'; - import './model_owner.dart'; import 'package:hive/hive.dart'; @@ -38,7 +36,7 @@ class RecVideoItemModel { @HiveField(6) String? title = ''; @HiveField(7) - String? duration = ''; + int? duration = -1; @HiveField(8) int? pubdate = -1; @HiveField(9) @@ -58,7 +56,7 @@ class RecVideoItemModel { uri = json["uri"]; pic = json["pic"]; title = json["title"]; - duration = Utils.tampToSeektime(json["duration"]); + duration = json["duration"]; pubdate = json["pubdate"]; owner = Owner.fromJson(json["owner"]); stat = Stat.fromJson(json["stat"]); @@ -77,14 +75,15 @@ class Stat { this.danmu, }); @HiveField(0) - String? view; + int? view; @HiveField(1) int? like; @HiveField(2) int? danmu; Stat.fromJson(Map json) { - view = Utils.numFormat(json["view"]); + // 无需在model中转换以保留原始数据,在view层处理即可 + view = json["view"]; like = json["like"]; danmu = json['danmaku']; } diff --git a/lib/models/model_rec_video_item.g.dart b/lib/models/model_rec_video_item.g.dart index 1de6ab03..dc614354 100644 --- a/lib/models/model_rec_video_item.g.dart +++ b/lib/models/model_rec_video_item.g.dart @@ -24,7 +24,7 @@ class RecVideoItemModelAdapter extends TypeAdapter { uri: fields[4] as String?, pic: fields[5] as String?, title: fields[6] as String?, - duration: fields[7] as String?, + duration: fields[7] as int?, pubdate: fields[8] as int?, owner: fields[9] as Owner?, stat: fields[10] as Stat?, @@ -87,7 +87,7 @@ class StatAdapter extends TypeAdapter { for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), }; return Stat( - view: fields[0] as String?, + view: fields[0] as int?, like: fields[1] as int?, danmu: fields[2] as int?, ); diff --git a/lib/pages/rcmd/controller.dart b/lib/pages/rcmd/controller.dart index d6dab1b1..28ff055b 100644 --- a/lib/pages/rcmd/controller.dart +++ b/lib/pages/rcmd/controller.dart @@ -9,14 +9,15 @@ import 'package:pilipala/utils/storage.dart'; class RcmdController extends GetxController { final ScrollController scrollController = ScrollController(); int _currentPage = 0; - RxList appVideoList = [].obs; - RxList webVideoList = [].obs; + // RxList appVideoList = [].obs; + // RxList webVideoList = [].obs; bool isLoadingMore = true; OverlayEntry? popupDialog; Box setting = GStrorage.setting; RxInt crossAxisCount = 2.obs; late bool enableSaveLastData; late String defaultRcmdType = 'web'; + late RxList videoList; @override void onInit() { @@ -27,81 +28,58 @@ class RcmdController extends GetxController { setting.get(SettingBoxKey.enableSaveLastData, defaultValue: false); defaultRcmdType = setting.get(SettingBoxKey.defaultRcmdType, defaultValue: 'web'); + if (defaultRcmdType == 'web') { + videoList = [].obs; + } else { + videoList = [].obs; + } } // 获取推荐 Future queryRcmdFeed(type) async { - print(defaultRcmdType); - if (defaultRcmdType == 'app') { - return await queryRcmdFeedApp(type); - } - if (defaultRcmdType == 'web') { - return await queryRcmdFeedWeb(type); - } - } - - // 获取app端推荐 - Future queryRcmdFeedApp(type) async { if (isLoadingMore == false) { return; } if (type == 'onRefresh') { _currentPage = 0; } - var res = await VideoHttp.rcmdVideoListApp( - freshIdx: _currentPage, - ); + late final Map res; + switch (defaultRcmdType) { + case 'app': + case 'notLogin': + res = await VideoHttp.rcmdVideoListApp( + loginStatus: defaultRcmdType != 'notLogin', + freshIdx: _currentPage, + ); + break; + default: //'web' + res = await VideoHttp.rcmdVideoList( + freshIdx: _currentPage, + ps: 20, + ); + } if (res['status']) { if (type == 'init') { - if (appVideoList.isNotEmpty) { - appVideoList.addAll(res['data']); + if (videoList.isNotEmpty) { + videoList.addAll(res['data']); } else { - appVideoList.value = res['data']; + videoList.value = res['data']; } } else if (type == 'onRefresh') { if (enableSaveLastData) { - appVideoList.insertAll(0, res['data']); + videoList.insertAll(0, res['data']); } else { - appVideoList.value = res['data']; + videoList.value = res['data']; } } else if (type == 'onLoad') { - appVideoList.addAll(res['data']); + videoList.addAll(res['data']); } _currentPage += 1; - } - isLoadingMore = false; - return res; - } - - // 获取web端推荐 - Future queryRcmdFeedWeb(type) async { - if (isLoadingMore == false) { - return; - } - if (type == 'onRefresh') { - _currentPage = 0; - } - var res = await VideoHttp.rcmdVideoList( - ps: 20, - freshIdx: _currentPage, - ); - if (res['status']) { - if (type == 'init') { - if (webVideoList.isNotEmpty) { - webVideoList.addAll(res['data']); - } else { - webVideoList.value = res['data']; - } - } else if (type == 'onRefresh') { - if (enableSaveLastData) { - webVideoList.insertAll(0, res['data']); - } else { - webVideoList.value = res['data']; - } - } else if (type == 'onLoad') { - webVideoList.addAll(res['data']); + // 若videoList数量太小,可能会影响翻页,此时再次请求 + // 为避免请求到的数据太少时还在反复请求,要求本次返回数据大于1条才触发 + if (res['data'].length > 1 && videoList.length < 10) { + queryRcmdFeed('onLoad'); } - _currentPage += 1; } isLoadingMore = false; return res; @@ -118,7 +96,7 @@ class RcmdController extends GetxController { queryRcmdFeed('onLoad'); } - // 返回顶部并刷新 + // 返回顶部 void animateToTop() async { if (scrollController.offset >= MediaQuery.of(Get.context!).size.height * 5) { diff --git a/lib/pages/rcmd/view.dart b/lib/pages/rcmd/view.dart index bc3c4a00..42b66364 100644 --- a/lib/pages/rcmd/view.dart +++ b/lib/pages/rcmd/view.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:io'; import 'package:easy_debounce/easy_throttle.dart'; import 'package:flutter/material.dart'; @@ -97,24 +96,18 @@ class _RcmdPageState extends State if (snapshot.connectionState == ConnectionState.done) { Map data = snapshot.data as Map; if (data['status']) { - return Platform.isAndroid || Platform.isIOS - ? Obx( - () => contentGrid( - _rcmdController, - _rcmdController.defaultRcmdType == 'web' - ? _rcmdController.webVideoList - : _rcmdController.appVideoList), - ) - : SliverLayoutBuilder( - builder: (context, boxConstraints) { - return Obx( - () => contentGrid( - _rcmdController, - _rcmdController.defaultRcmdType == 'web' - ? _rcmdController.webVideoList - : _rcmdController.appVideoList), - ); - }); + return Obx( + () { + if (_rcmdController.isLoadingMore && + _rcmdController.videoList.isEmpty) { + return contentGrid(_rcmdController, []); + } else { + // 显示视频列表 + return contentGrid( + _rcmdController, _rcmdController.videoList); + } + }, + ); } else { return HttpError( errMsg: data['msg'], @@ -127,20 +120,12 @@ class _RcmdPageState extends State ); } } else { - // 缓存数据 - // if (_rcmdController.videoList.isNotEmpty) { - // return contentGrid( - // _rcmdController, _rcmdController.videoList); - // } - // // 骨架屏 - // else { return contentGrid(_rcmdController, []); - // } } }, ), ), - LoadingMore(ctr: _rcmdController) + LoadingMore(ctr: _rcmdController), ], ), ), diff --git a/lib/pages/setting/extra_setting.dart b/lib/pages/setting/extra_setting.dart index b4275815..b32a06f5 100644 --- a/lib/pages/setting/extra_setting.dart +++ b/lib/pages/setting/extra_setting.dart @@ -1,9 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:hive/hive.dart'; -import 'package:pilipala/http/member.dart'; import 'package:pilipala/models/common/dynamics_type.dart'; -import 'package:pilipala/models/common/rcmd_type.dart'; import 'package:pilipala/models/common/reply_sort_type.dart'; import 'package:pilipala/pages/setting/widgets/select_dialog.dart'; import 'package:pilipala/utils/storage.dart'; @@ -20,23 +18,16 @@ class ExtraSetting extends StatefulWidget { class _ExtraSettingState extends State { Box setting = GStrorage.setting; static Box localCache = GStrorage.localCache; - late dynamic defaultRcmdType; late dynamic defaultReplySort; late dynamic defaultDynamicType; late dynamic enableSystemProxy; late String defaultSystemProxyHost; late String defaultSystemProxyPort; - Box userInfoCache = GStrorage.userInfo; - var userInfo; bool userLogin = false; - var accessKeyInfo; @override void initState() { super.initState(); - // 首页默认推荐类型 - defaultRcmdType = - setting.get(SettingBoxKey.defaultRcmdType, defaultValue: 'web'); // 默认优先显示最新评论 defaultReplySort = setting.get(SettingBoxKey.replySortType, defaultValue: 0); @@ -49,9 +40,6 @@ class _ExtraSettingState extends State { localCache.get(LocalCacheKey.systemProxyHost, defaultValue: ''); defaultSystemProxyPort = localCache.get(LocalCacheKey.systemProxyPort, defaultValue: ''); - userInfo = userInfoCache.get('userInfoCache'); - userLogin = userInfo != null; - accessKeyInfo = localCache.get(LocalCacheKey.accessKey, defaultValue: null); } // 设置代理 @@ -159,12 +147,6 @@ class _ExtraSettingState extends State { setKey: SettingBoxKey.enableSearchWord, defaultVal: true, ), - const SetSwitchItem( - title: '推荐动态', - subTitle: '是否在推荐内容中展示动态', - setKey: SettingBoxKey.enableRcmdDynamic, - defaultVal: true, - ), const SetSwitchItem( title: '快速收藏', subTitle: '点按收藏至默认,长按选择文件夹', @@ -177,50 +159,6 @@ class _ExtraSettingState extends State { setKey: SettingBoxKey.enableWordRe, defaultVal: false, ), - const SetSwitchItem( - title: '首页推荐刷新', - subTitle: '下拉刷新时保留上次内容', - setKey: SettingBoxKey.enableSaveLastData, - defaultVal: false, - ), - ListTile( - dense: false, - title: Text('首页推荐类型', style: titleStyle), - subtitle: Text( - '当前使用「$defaultRcmdType端」推荐', - style: subTitleStyle, - ), - onTap: () async { - String? result = await showDialog( - context: context, - builder: (context) { - return SelectDialog( - title: '推荐类型', - value: defaultRcmdType, - values: RcmdType.values.map((e) { - return {'title': e.labels, 'value': e.values}; - }).toList(), - ); - }, - ); - if (result != null) { - if (result == 'app') { - // app端推荐需要access_key - if (accessKeyInfo == null) { - if (!userLogin) { - SmartDialog.showToast('请先登录'); - return; - } - await MemberHttp.cookieToKey(); - } - } - defaultRcmdType = result; - setting.put(SettingBoxKey.defaultRcmdType, result); - SmartDialog.showToast('下次启动时生效'); - setState(() {}); - } - }, - ), const SetSwitchItem( title: '启用ai总结', subTitle: '视频详情页开启ai总结', diff --git a/lib/pages/setting/recommend_setting.dart b/lib/pages/setting/recommend_setting.dart new file mode 100644 index 00000000..ab8ec063 --- /dev/null +++ b/lib/pages/setting/recommend_setting.dart @@ -0,0 +1,260 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:hive/hive.dart'; +import 'package:pilipala/http/member.dart'; +import 'package:pilipala/models/common/rcmd_type.dart'; +import 'package:pilipala/pages/setting/widgets/select_dialog.dart'; +import 'package:pilipala/utils/recommend_filter.dart'; +import 'package:pilipala/utils/storage.dart'; + +import 'widgets/switch_item.dart'; + +class RecommendSetting extends StatefulWidget { + const RecommendSetting({super.key}); + + @override + State createState() => _RecommendSettingState(); +} + +class _RecommendSettingState extends State { + Box setting = GStrorage.setting; + static Box localCache = GStrorage.localCache; + late dynamic defaultRcmdType; + Box userInfoCache = GStrorage.userInfo; + late dynamic userInfo; + bool userLogin = false; + late dynamic accessKeyInfo; + // late int filterUnfollowedRatio; + late int minDurationForRcmd; + late int minLikeRatioForRecommend; + + @override + void initState() { + super.initState(); + // 首页默认推荐类型 + defaultRcmdType = + setting.get(SettingBoxKey.defaultRcmdType, defaultValue: 'web'); + userInfo = userInfoCache.get('userInfoCache'); + userLogin = userInfo != null; + accessKeyInfo = localCache.get(LocalCacheKey.accessKey, defaultValue: null); + // filterUnfollowedRatio = setting + // .get(SettingBoxKey.filterUnfollowedRatio, defaultValue: 0); + minDurationForRcmd = + setting.get(SettingBoxKey.minDurationForRcmd, defaultValue: 0); + minLikeRatioForRecommend = + setting.get(SettingBoxKey.minLikeRatioForRecommend, defaultValue: 0); + } + + @override + Widget build(BuildContext context) { + TextStyle titleStyle = Theme.of(context).textTheme.titleMedium!; + TextStyle subTitleStyle = Theme.of(context) + .textTheme + .labelMedium! + .copyWith(color: Theme.of(context).colorScheme.outline); + return Scaffold( + appBar: AppBar( + centerTitle: false, + titleSpacing: 0, + title: Text( + '推荐设置', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + body: ListView( + children: [ + ListTile( + dense: false, + title: Text('首页推荐类型', style: titleStyle), + subtitle: Text( + '当前使用「$defaultRcmdType端」推荐¹', + style: subTitleStyle, + ), + onTap: () async { + String? result = await showDialog( + context: context, + builder: (context) { + return SelectDialog( + title: '推荐类型', + value: defaultRcmdType, + values: RcmdType.values.map((e) { + return {'title': e.labels, 'value': e.values}; + }).toList(), + ); + }, + ); + if (result != null) { + if (result == 'app') { + // app端推荐需要access_key + if (accessKeyInfo == null) { + if (!userLogin) { + SmartDialog.showToast('请先登录'); + return; + } + // 显示一个确认框,告知用户可能会导致账号被风控 + SmartDialog.show( + animationType: SmartAnimationType.centerFade_otherSlide, + builder: (context) { + return AlertDialog( + title: const Text('提示'), + content: const Text( + '使用app端推荐需获取access_key,有小概率触发风控导致账号退出(在官方版本app重新登录即可解除),是否继续?'), + actions: [ + TextButton( + onPressed: () { + result = null; + SmartDialog.dismiss(); + }, + child: const Text('取消'), + ), + TextButton( + onPressed: () async { + SmartDialog.dismiss(); + await MemberHttp.cookieToKey(); + }, + child: const Text('确定'), + ), + ], + ); + }); + } + } + if (result != null) { + defaultRcmdType = result; + setting.put(SettingBoxKey.defaultRcmdType, result); + SmartDialog.showToast('下次启动时生效'); + setState(() {}); + } + } + }, + ), + const SetSwitchItem( + title: '推荐动态', + subTitle: '是否在推荐内容中展示动态(仅app端)', + setKey: SettingBoxKey.enableRcmdDynamic, + defaultVal: true, + ), + const SetSwitchItem( + title: '首页推荐刷新', + subTitle: '下拉刷新时保留上次内容', + setKey: SettingBoxKey.enableSaveLastData, + defaultVal: false, + ), + // 分割线 + const Divider(height: 1), + ListTile( + dense: false, + title: Text('点赞率过滤', style: titleStyle), + subtitle: Text( + '过滤掉点赞数/播放量「小于$minLikeRatioForRecommend%」的推荐视频(仅web端)', + style: subTitleStyle, + ), + onTap: () async { + int? result = await showDialog( + context: context, + builder: (context) { + return SelectDialog( + title: '选择点赞率(0即不过滤)', + value: minLikeRatioForRecommend, + values: [0, 1, 2, 3, 4].map((e) { + return {'title': '$e %', 'value': e}; + }).toList()); + }, + ); + if (result != null) { + minLikeRatioForRecommend = result; + setting.put(SettingBoxKey.minLikeRatioForRecommend, result); + RecommendFilter.update(); + setState(() {}); + } + }, + ), + ListTile( + dense: false, + title: Text('视频时长过滤', style: titleStyle), + subtitle: Text( + '过滤掉时长「小于$minDurationForRcmd秒」的推荐视频', + style: subTitleStyle, + ), + onTap: () async { + int? result = await showDialog( + context: context, + builder: (context) { + return SelectDialog( + title: '选择时长(0即不过滤)', + value: minDurationForRcmd, + values: [0, 30, 60, 90, 120].map((e) { + return {'title': '$e 秒', 'value': e}; + }).toList()); + }, + ); + if (result != null) { + minDurationForRcmd = result; + setting.put(SettingBoxKey.minDurationForRcmd, result); + RecommendFilter.update(); + setState(() {}); + } + }, + ), + SetSwitchItem( + title: '已关注Up豁免推荐过滤', + subTitle: '推荐中已关注用户发布的内容不会被过滤', + setKey: SettingBoxKey.exemptFilterForFollowed, + defaultVal: true, + callFn: (_) => {RecommendFilter.update}, + ), + // ListTile( + // dense: false, + // title: Text('按比例过滤未关注Up', style: titleStyle), + // subtitle: Text( + // '滤除推荐中占比「$filterUnfollowedRatio%」的未关注用户发布的内容', + // style: subTitleStyle, + // ), + // onTap: () async { + // int? result = await showDialog( + // context: context, + // builder: (context) { + // return SelectDialog( + // title: '选择滤除比例(0即不过滤)', + // value: filterUnfollowedRatio, + // values: [0, 16, 32, 48, 64].map((e) { + // return {'title': '$e %', 'value': e}; + // }).toList()); + // }, + // ); + // if (result != null) { + // filterUnfollowedRatio = result; + // setting.put( + // SettingBoxKey.filterUnfollowedRatio, result); + // RecommendFilter.update(); + // setState(() {}); + // } + // }, + // ), + SetSwitchItem( + title: '过滤器也应用于相关视频', + subTitle: '视频详情页的相关视频也进行过滤²', + setKey: SettingBoxKey.applyFilterToRelatedVideos, + defaultVal: true, + callFn: (_) => {RecommendFilter.update}, + ), + ListTile( + dense: true, + subtitle: Text( + '¹ 若默认web端推荐不太符合预期,可尝试切换至app端。\n' + '¹ 选择“模拟未登录(notLogin)”,将以空的key请求推荐接口,但播放页仍会携带用户信息,保证账号能正常记录进度、点赞投币等。\n\n' + '² 由于接口未提供关注信息,无法豁免相关视频中的已关注Up。\n\n' + '* 其它(如热门视频、手动搜索、链接跳转等)均不受过滤器影响。\n' + '* 设定较严苛的条件可导致推荐项数锐减或多次请求,请酌情选择。\n' + '* 后续可能会增加更多过滤条件,敬请期待。', + style: Theme.of(context) + .textTheme + .labelSmall! + .copyWith(color: Theme.of(context).colorScheme.outline.withOpacity(0.7)), + ), + ) + ], + ), + ); + } +} diff --git a/lib/pages/setting/view.dart b/lib/pages/setting/view.dart index 677a4546..19cdedaf 100644 --- a/lib/pages/setting/view.dart +++ b/lib/pages/setting/view.dart @@ -24,6 +24,11 @@ class SettingPage extends StatelessWidget { dense: false, title: const Text('隐私设置'), ), + ListTile( + onTap: () => Get.toNamed('/recommendSetting'), + dense: false, + title: const Text('推荐设置'), + ), ListTile( onTap: () => Get.toNamed('/playSetting'), dense: false, diff --git a/lib/router/app_pages.dart b/lib/router/app_pages.dart index 23172d3c..ff506630 100644 --- a/lib/router/app_pages.dart +++ b/lib/router/app_pages.dart @@ -39,6 +39,7 @@ import '../pages/setting/pages/display_mode.dart'; import '../pages/setting/pages/font_size_select.dart'; import '../pages/setting/pages/home_tabbar_set.dart'; import '../pages/setting/pages/play_speed_set.dart'; +import '../pages/setting/recommend_setting.dart'; import '../pages/setting/play_setting.dart'; import '../pages/setting/privacy_setting.dart'; import '../pages/setting/style_setting.dart'; @@ -102,7 +103,8 @@ class Routes { // 二级回复 CustomGetPage( name: '/replyReply', page: () => const VideoReplyReplyPanel()), - + // 推荐设置 + CustomGetPage(name: '/recommendSetting', page: () => const RecommendSetting()), // 播放设置 CustomGetPage(name: '/playSetting', page: () => const PlaySetting()), // 外观设置 diff --git a/lib/utils/recommend_filter.dart b/lib/utils/recommend_filter.dart new file mode 100644 index 00000000..113e2261 --- /dev/null +++ b/lib/utils/recommend_filter.dart @@ -0,0 +1,52 @@ +import 'dart:math'; + +import 'storage.dart'; + +class RecommendFilter { + // static late int filterUnfollowedRatio; + static late int minDurationForRcmd; + static late int minLikeRatioForRecommend; + static late bool exemptFilterForFollowed; + static late bool applyFilterToRelatedVideos; + RecommendFilter() { + update(); + } + + static void update() { + var setting = GStrorage.setting; + // filterUnfollowedRatio = + // setting.get(SettingBoxKey.filterUnfollowedRatio, defaultValue: 0); + minDurationForRcmd = + setting.get(SettingBoxKey.minDurationForRcmd, defaultValue: 0); + minLikeRatioForRecommend = + setting.get(SettingBoxKey.minLikeRatioForRecommend, defaultValue: 0); + exemptFilterForFollowed = + setting.get(SettingBoxKey.exemptFilterForFollowed, defaultValue: true); + applyFilterToRelatedVideos = setting + .get(SettingBoxKey.applyFilterToRelatedVideos, defaultValue: true); + } + + static bool filter(dynamic videoItem, {bool relatedVideos = false}) { + if (relatedVideos && !applyFilterToRelatedVideos) { + return false; + } + //由于相关视频中没有已关注标签,只能视为非关注视频 + if (!relatedVideos && + videoItem.isFollowed == 1 && + exemptFilterForFollowed) { + return false; + } + if (videoItem.duration > 0 && videoItem.duration < minDurationForRcmd) { + return true; + } + if (videoItem.stat.view is int && + videoItem.stat.view > -1 && + videoItem.stat.like is int && + videoItem.stat.like > -1 && + videoItem.stat.like * 100 < + minLikeRatioForRecommend * videoItem.stat.view) { + return true; + } + return false; + } +} diff --git a/lib/utils/storage.dart b/lib/utils/storage.dart index 89dae3e7..1b0b0c37 100644 --- a/lib/utils/storage.dart +++ b/lib/utils/storage.dart @@ -105,17 +105,24 @@ class SettingBoxKey { /// 隐私 blackMidsList = 'blackMidsList', + /// 推荐 + enableRcmdDynamic = 'enableRcmdDynamic', + defaultRcmdType = 'defaultRcmdType', + enableSaveLastData = 'enableSaveLastData', + minDurationForRcmd = 'minDurationForRcmd', + minLikeRatioForRecommend = 'minLikeRatioForRecommend', + exemptFilterForFollowed = 'exemptFilterForFollowed', + //filterUnfollowedRatio = 'filterUnfollowedRatio', + applyFilterToRelatedVideos = 'applyFilterToRelatedVideos', + /// 其他 autoUpdate = 'autoUpdate', - defaultRcmdType = 'defaultRcmdType', replySortType = 'replySortType', defaultDynamicType = 'defaultDynamicType', enableHotKey = 'enableHotKey', enableQuickFav = 'enableQuickFav', enableWordRe = 'enableWordRe', enableSearchWord = 'enableSearchWord', - enableRcmdDynamic = 'enableRcmdDynamic', - enableSaveLastData = 'enableSaveLastData', enableSystemProxy = 'enableSystemProxy', enableAi = 'enableAi'; diff --git a/lib/utils/utils.dart b/lib/utils/utils.dart index e17ed5ef..64d20aa3 100644 --- a/lib/utils/utils.dart +++ b/lib/utils/utils.dart @@ -9,7 +9,6 @@ import 'package:crypto/crypto.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/material.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; -import 'package:get/get_utils/get_utils.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:path_provider/path_provider.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -28,10 +27,16 @@ class Utils { return tempPath; } - static String numFormat(int number) { + static String numFormat(dynamic number) { + if (number == null){ + return '0'; + } + if (number is String) { + return number; + } final String res = (number / 10000).toString(); if (int.parse(res.split('.')[0]) >= 1) { - return '${(number / 10000).toPrecision(1)}万'; + return '${(number / 10000).toStringAsFixed(1)}万'; } else { return number.toString(); }