From 82f9f48a8e1067471319dd40ae1a92618038446e Mon Sep 17 00:00:00 2001 From: My-Responsitories <107370289+My-Responsitories@users.noreply.github.com> Date: Sat, 29 Mar 2025 17:52:06 +0800 Subject: [PATCH] opt: select dialog & feat: select subtitle if muted (#564) * opt: select dialog * opt: subtitle * feat: select subtitle if muted --- lib/http/video.dart | 29 +- lib/models/live/quality.dart | 2 +- lib/models/video/play/quality.dart | 8 +- lib/models/video/play/subtitle.dart | 11 +- lib/pages/setting/pages/color_select.dart | 6 +- lib/pages/setting/widgets/model.dart | 125 ++++---- lib/pages/setting/widgets/select_dialog.dart | 283 +++++++++++------- lib/pages/video/detail/controller.dart | 32 +- .../video/detail/widgets/header_control.dart | 10 +- lib/utils/video_utils.dart | 14 +- 10 files changed, 284 insertions(+), 236 deletions(-) diff --git a/lib/http/video.dart b/lib/http/video.dart index 33f849c7..60bbe468 100644 --- a/lib/http/video.dart +++ b/lib/http/video.dart @@ -975,7 +975,7 @@ class VideoHttp { } } - static Future subtitlesJson( + static Future> subtitlesJson( {String? aid, String? bvid, required int cid}) async { assert(aid != null || bvid != null); var res = await Request().get( @@ -1016,22 +1016,25 @@ class VideoHttp { } } - static Future vttSubtitles(subtile) async { + static Future vttSubtitles(Map subtile) async { String subtitleTimecode(num seconds) { - int h = (seconds / 3600).floor(); - int m = ((seconds % 3600) / 60).floor(); - int s = (seconds % 60).floor(); - int ms = ((seconds * 1000) % 1000).floor(); - if (h == 0) { - return "${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}.${ms.toString().padLeft(3, '0')}"; - } - return "${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}.${ms.toString().padLeft(3, '0')}"; + int h = seconds ~/ 3600; + seconds %= 3600; + int m = seconds ~/ 60; + seconds %= 60; + String sms = seconds.toStringAsFixed(3).padLeft(6, '0'); + return h == 0 + ? "${m.toString().padLeft(2, '0')}:$sms" + : "${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}:$sms"; } String processList(List list) { - return list.fold('WEBVTT\n\n', (previous, item) { - return '$previous${item?['sid'] ?? 0}\n${subtitleTimecode(item['from'])} --> ${subtitleTimecode(item['to'])}\n${item['content'].trim()}\n\n'; - }); + final sb = StringBuffer('WEBVTT\n\n'); + sb.writeAll( + list.map((item) => + '${item?['sid'] ?? 0}\n${subtitleTimecode(item['from'])} --> ${subtitleTimecode(item['to'])}\n${item['content'].trim()}'), + '\n\n'); + return sb.toString(); } var res = await Request().get("https:${subtile['subtitle_url']}"); diff --git a/lib/models/live/quality.dart b/lib/models/live/quality.dart index 677d615b..07706857 100644 --- a/lib/models/live/quality.dart +++ b/lib/models/live/quality.dart @@ -39,5 +39,5 @@ extension VideoQualityDesc on LiveQuality { '高清', '流畅', ]; - get description => _descList[index]; + String get description => _descList[index]; } diff --git a/lib/models/video/play/quality.dart b/lib/models/video/play/quality.dart index e52c0c2c..34cb858d 100644 --- a/lib/models/video/play/quality.dart +++ b/lib/models/video/play/quality.dart @@ -56,7 +56,7 @@ extension VideoQualityDesc on VideoQuality { '杜比视界', '8K 超高清' ]; - get description => _descList[index]; + String get description => _descList[index]; } /// @@ -89,7 +89,7 @@ extension AudioQualityDesc on AudioQuality { '杜比全景声', 'Hi-Res无损', ]; - get description => _descList[index]; + String get description => _descList[index]; } enum VideoDecodeFormats { @@ -101,12 +101,12 @@ enum VideoDecodeFormats { extension VideoDecodeFormatsDesc on VideoDecodeFormats { static final List _descList = ['DVH1', 'AV1', 'HEVC', 'AVC']; - get description => _descList[index]; + String get description => _descList[index]; } extension VideoDecodeFormatsCode on VideoDecodeFormats { static final List _codeList = ['dvh1', 'av01', 'hev1', 'avc1']; - get code => _codeList[index]; + String get code => _codeList[index]; static VideoDecodeFormats? fromCode(String code) { final index = _codeList.indexOf(code); diff --git a/lib/models/video/play/subtitle.dart b/lib/models/video/play/subtitle.dart index 75243d06..e27057c1 100644 --- a/lib/models/video/play/subtitle.dart +++ b/lib/models/video/play/subtitle.dart @@ -1,16 +1,17 @@ -enum SubtitlePreference { off, on, withoutAi } +enum SubtitlePreference { off, on, withoutAi, auto } extension SubtitlePreferenceDesc on SubtitlePreference { static final List _descList = [ '默认不显示字幕', - '选择第一个可用字幕', - '跳过自动生成(ai)字幕,选择第一个可用字幕' + '优先选择非自动生成(ai)字幕', + '跳过自动生成(ai)字幕,选择第一个可用字幕', + '静音时等同第二项,非静音时等同第三项' ]; - get description => _descList[index]; + String get description => _descList[index]; } extension SubtitlePreferenceCode on SubtitlePreference { - static final List _codeList = ['off', 'on', 'withoutAi']; + static const List _codeList = ['off', 'on', 'withoutAi', 'auto']; String get code => _codeList[index]; static SubtitlePreference? fromCode(String code) { diff --git a/lib/pages/setting/pages/color_select.dart b/lib/pages/setting/pages/color_select.dart index 628ffd38..96297ff9 100644 --- a/lib/pages/setting/pages/color_select.dart +++ b/lib/pages/setting/pages/color_select.dart @@ -61,9 +61,9 @@ class _ColorSelectPageState extends State { return SelectDialog( title: '主题模式', value: ctr.themeType.value, - values: ThemeType.values.map((e) { - return {'title': e.description, 'value': e}; - }).toList()); + values: ThemeType.values + .map((e) => (e, e.description)) + .toList()); }, ); if (result != null) { diff --git a/lib/pages/setting/widgets/model.dart b/lib/pages/setting/widgets/model.dart index 310643a2..3aa21b86 100644 --- a/lib/pages/setting/widgets/model.dart +++ b/lib/pages/setting/widgets/model.dart @@ -280,7 +280,7 @@ List get styleSettings => [ title: '动态页UP主显示位置', value: GStorage.upPanelPosition, values: UpPanelPosition.values.map((e) { - return {'title': e.labels, 'value': e}; + return (e, e.labels); }).toList(), ); }, @@ -318,7 +318,7 @@ List get styleSettings => [ title: '动态未读标记', value: GStorage.dynamicBadgeType, values: DynamicBadgeMode.values.map((e) { - return {'title': e.description, 'value': e}; + return (e, e.description); }).toList(), ); }, @@ -350,7 +350,7 @@ List get styleSettings => [ title: '消息未读标记', value: GStorage.msgBadgeMode, values: DynamicBadgeMode.values.map((e) { - return {'title': e.description, 'value': e}; + return (e, e.description); }).toList(), ); }, @@ -516,7 +516,7 @@ List get styleSettings => [ value: GStorage.themeType, values: ThemeType.values.map( (e) { - return {'title': e.description, 'value': e}; + return (e, e.description); }, ).toList()); }, @@ -562,7 +562,7 @@ List get styleSettings => [ title: '首页启动页', value: GStorage.defaultHomePage, values: defaultNavigationBars.map((e) { - return {'title': e['label'], 'value': e['id']}; + return (e['id'] as int, e['label'] as String); }).toList(), ); }, @@ -763,12 +763,13 @@ List get playSettings => [ context: Get.context!, builder: (context) { return SelectDialog( - title: '字幕选择偏好', - value: GStorage.setting.get(SettingBoxKey.subtitlePreference, - defaultValue: SubtitlePreference.values.first.code), - values: SubtitlePreference.values.map((e) { - return {'title': e.description, 'value': e.code}; - }).toList()); + title: '字幕选择偏好', + value: GStorage.setting.get(SettingBoxKey.subtitlePreference, + defaultValue: SubtitlePreference.values.first.code), + values: SubtitlePreference.values + .map((e) => (e.code, e.description)) + .toList(), + ); }, ); if (result != null) { @@ -879,7 +880,7 @@ List get playSettings => [ title: '默认全屏方向', value: GStorage.defaultFullScreenMode, values: FullScreenMode.values.map((e) { - return {'title': e.description, 'value': e.code}; + return (e.code, e.description); }).toList()); }, ); @@ -903,7 +904,7 @@ List get playSettings => [ title: '底部进度条展示', value: GStorage.defaultBtmProgressBehavior, values: BtmProgressBehavior.values.map((e) { - return {'title': e.description, 'value': e.code}; + return (e.code, e.description); }).toList()); }, ); @@ -972,12 +973,7 @@ List get videoSettings => [ String? result = await showDialog( context: Get.context!, builder: (context) { - return SelectDialog( - title: 'CDN 设置', - value: GStorage.defaultCDNService, - values: CDNService.values.map((e) { - return {'title': e.description, 'value': e.code}; - }).toList()); + return CdnSelectDialog(); }, ); if (result != null) { @@ -1007,7 +1003,7 @@ List get videoSettings => [ title: '默认画质', leading: const Icon(Icons.video_settings_outlined), getSubtitle: () => - '当前画质:${VideoQualityCode.fromCode(GStorage.defaultVideoQa)!.description!}', + '当前画质:${VideoQualityCode.fromCode(GStorage.defaultVideoQa)!.description}', onTap: (setState) async { int? result = await showDialog( context: Get.context!, @@ -1015,9 +1011,9 @@ List get videoSettings => [ return SelectDialog( title: '默认画质', value: GStorage.defaultVideoQa, - values: VideoQuality.values.reversed.map((e) { - return {'title': e.description, 'value': e.code}; - }).toList(), + values: VideoQuality.values.reversed + .map((e) => (e.code, e.description)) + .toList(), ); }, ); @@ -1032,7 +1028,7 @@ List get videoSettings => [ title: '蜂窝网络画质', leading: const Icon(Icons.video_settings_outlined), getSubtitle: () => - '当前画质:${VideoQualityCode.fromCode(GStorage.defaultVideoQaCellular)!.description!}', + '当前画质:${VideoQualityCode.fromCode(GStorage.defaultVideoQaCellular)!.description}', onTap: (setState) async { int? result = await showDialog( context: Get.context!, @@ -1041,7 +1037,7 @@ List get videoSettings => [ title: '蜂窝网络画质', value: GStorage.defaultVideoQaCellular, values: VideoQuality.values.reversed.map((e) { - return {'title': e.description, 'value': e.code}; + return (e.code, e.description); }).toList(), ); }, @@ -1058,7 +1054,7 @@ List get videoSettings => [ title: '默认音质', leading: const Icon(Icons.music_video_outlined), getSubtitle: () => - '当前音质:${AudioQualityCode.fromCode(GStorage.defaultAudioQa)!.description!}', + '当前音质:${AudioQualityCode.fromCode(GStorage.defaultAudioQa)!.description}', onTap: (setState) async { int? result = await showDialog( context: Get.context!, @@ -1066,9 +1062,9 @@ List get videoSettings => [ return SelectDialog( title: '默认音质', value: GStorage.defaultAudioQa, - values: AudioQuality.values.reversed.map((e) { - return {'title': e.description, 'value': e.code}; - }).toList(), + values: AudioQuality.values.reversed + .map((e) => (e.code, e.description)) + .toList(), ); }, ); @@ -1083,7 +1079,7 @@ List get videoSettings => [ title: '蜂窝网络音质', leading: const Icon(Icons.music_video_outlined), getSubtitle: () => - '当前音质:${AudioQualityCode.fromCode(GStorage.defaultAudioQaCellular)!.description!}', + '当前音质:${AudioQualityCode.fromCode(GStorage.defaultAudioQaCellular)!.description}', onTap: (setState) async { int? result = await showDialog( context: Get.context!, @@ -1092,7 +1088,7 @@ List get videoSettings => [ title: '蜂窝网络音质', value: GStorage.defaultAudioQaCellular, values: AudioQuality.values.reversed.map((e) { - return {'title': e.description, 'value': e.code}; + return (e.code, e.description); }).toList(), ); }, @@ -1109,7 +1105,7 @@ List get videoSettings => [ title: '直播默认画质', leading: const Icon(Icons.video_settings_outlined), getSubtitle: () => - '当前画质:${LiveQualityCode.fromCode(GStorage.liveQuality)!.description!}', + '当前画质:${LiveQualityCode.fromCode(GStorage.liveQuality)!.description}', onTap: (setState) async { int? result = await showDialog( context: Get.context!, @@ -1117,9 +1113,9 @@ List get videoSettings => [ return SelectDialog( title: '直播默认画质', value: GStorage.liveQuality, - values: LiveQuality.values.map((e) { - return {'title': e.description, 'value': e.code}; - }).toList(), + values: LiveQuality.values + .map((e) => (e.code, e.description)) + .toList(), ); }, ); @@ -1134,7 +1130,7 @@ List get videoSettings => [ title: '蜂窝网络直播默认画质', leading: const Icon(Icons.video_settings_outlined), getSubtitle: () => - '当前画质:${LiveQualityCode.fromCode(GStorage.liveQualityCellular)!.description!}', + '当前画质:${LiveQualityCode.fromCode(GStorage.liveQualityCellular)!.description}', onTap: (setState) async { int? result = await showDialog( context: Get.context!, @@ -1143,7 +1139,7 @@ List get videoSettings => [ title: '直播默认画质', value: GStorage.liveQualityCellular, values: LiveQuality.values.map((e) { - return {'title': e.description, 'value': e.code}; + return (e.code, e.description); }).toList(), ); }, @@ -1160,7 +1156,7 @@ List get videoSettings => [ title: '首选解码格式', leading: const Icon(Icons.movie_creation_outlined), getSubtitle: () => - '首选解码格式:${VideoDecodeFormatsCode.fromCode(GStorage.defaultDecode)!.description!},请根据设备支持情况与需求调整', + '首选解码格式:${VideoDecodeFormatsCode.fromCode(GStorage.defaultDecode)!.description},请根据设备支持情况与需求调整', onTap: (setState) async { String? result = await showDialog( context: Get.context!, @@ -1168,9 +1164,9 @@ List get videoSettings => [ return SelectDialog( title: '默认解码格式', value: GStorage.defaultDecode, - values: VideoDecodeFormats.values.map((e) { - return {'title': e.description, 'value': e.code}; - }).toList()); + values: VideoDecodeFormats.values + .map((e) => (e.code, e.description)) + .toList()); }, ); if (result != null) { @@ -1183,7 +1179,7 @@ List get videoSettings => [ settingsType: SettingsType.normal, title: '次选解码格式', getSubtitle: () => - '非杜比视频次选:${VideoDecodeFormatsCode.fromCode(GStorage.secondDecode)!.description!},仍无则选择首个提供的解码格式', + '非杜比视频次选:${VideoDecodeFormatsCode.fromCode(GStorage.secondDecode)!.description},仍无则选择首个提供的解码格式', leading: const Icon(Icons.swap_horizontal_circle_outlined), onTap: (setState) async { String? result = await showDialog( @@ -1193,7 +1189,7 @@ List get videoSettings => [ title: '次选解码格式', value: GStorage.secondDecode, values: VideoDecodeFormats.values.map((e) { - return {'title': e.description, 'value': e.code}; + return (e.code, e.description); }).toList()); }, ); @@ -1245,7 +1241,7 @@ List get videoSettings => [ 'display-desync', 'desync' ].map((e) { - return {'title': e, 'value': e}; + return (e, e); }).toList()); }, ); @@ -1269,7 +1265,7 @@ List get videoSettings => [ value: GStorage.hardwareDecoding, values: ['auto', 'auto-copy', 'auto-safe', 'no', 'yes'].map((e) { - return {'title': e, 'value': e}; + return (e, e); }).toList()); }, ); @@ -1872,18 +1868,18 @@ List get extraSettings => [ return SelectDialog( title: '音量均衡', value: audioNormalization, - values: values.map((e) { - return { - 'title': switch (e) { - '0' => AudioNormalization.disable.title, - '1' => AudioNormalization.dynaudnorm.title, - '2' => AudioNormalization.loudnorm.title, - '3' => AudioNormalization.custom.title, - _ => e, - }, - 'value': e, - }; - }).toList()); + values: values + .map((e) => ( + switch (e) { + '0' => AudioNormalization.disable.title, + '1' => AudioNormalization.dynaudnorm.title, + '2' => AudioNormalization.loudnorm.title, + '3' => AudioNormalization.custom.title, + _ => e, + }, + e + )) + .toList()); }, ); if (result != null) { @@ -1943,7 +1939,7 @@ List get extraSettings => [ value: SuperResolutionType.values[GStorage.superResolutionType], values: SuperResolutionType.values.map((e) { - return {'title': e.title, 'value': e}; + return (e, e.title); }).toList()); }, ); @@ -2268,7 +2264,7 @@ List get extraSettings => [ title: '评论展示', value: GStorage.defaultReplySort, values: ReplySortType.values.map((e) { - return {'title': e.title, 'value': e.index}; + return (e.index, e.title); }).toList(), ); }, @@ -2294,7 +2290,7 @@ List get extraSettings => [ title: '动态展示', value: GStorage.defaultDynamicType, values: DynamicsType.values.sublist(0, 4).map((e) { - return {'title': e.labels, 'value': e.index}; + return (e.index, e.labels); }).toList()); }, ); @@ -2319,7 +2315,7 @@ List get extraSettings => [ title: '用户页默认展示TAB', value: GStorage.memberTab, values: MemberTabType.values.map((e) { - return {'title': e.title, 'value': e}; + return (e, e.title); }).toList()); }, ); @@ -2511,12 +2507,9 @@ SettingsModel _getVideoFilterSelectModel({ values: (values ..addIf(!values.contains(value), value) ..sort()) - .map((e) => { - 'title': suffix == null ? e.toString() : '$e $suffix', - 'value': e - }) + .map((e) => (e, suffix == null ? e.toString() : '$e $suffix')) .toList() - ..add({'title': '自定义', 'value': -1})); + ..add((-1, '自定义'))); }, ); if (result != null) { diff --git a/lib/pages/setting/widgets/select_dialog.dart b/lib/pages/setting/widgets/select_dialog.dart index 111f6ec3..dd15cfd9 100644 --- a/lib/pages/setting/widgets/select_dialog.dart +++ b/lib/pages/setting/widgets/select_dialog.dart @@ -1,137 +1,200 @@ +import 'dart:async'; + import 'package:PiliPlus/http/constants.dart'; import 'package:PiliPlus/http/video.dart'; import 'package:PiliPlus/models/video/play/CDN.dart'; import 'package:PiliPlus/models/video/play/url.dart'; -import 'package:PiliPlus/utils/extension.dart'; import 'package:PiliPlus/utils/storage.dart'; import 'package:PiliPlus/utils/video_utils.dart'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; -import 'package:get/get_utils/get_utils.dart'; -class SelectDialog extends StatefulWidget { - final T value; +class SelectDialog extends StatelessWidget { + final T? value; final String title; - final List values; + final List<(T, String)> values; + final Widget Function(BuildContext, int)? subtitleBuilder; + const SelectDialog({ super.key, - required this.value, + this.value, required this.values, required this.title, + this.subtitleBuilder, }); - @override - State> createState() => _SelectDialogState(); -} - -class _SelectDialogState extends State> { - late T _tempValue; - late List _cdnResList; - late final cdnSpeedTest = GStorage.cdnSpeedTest; - - @override - void initState() { - super.initState(); - _tempValue = widget.value; - if (widget.title == 'CDN 设置' && cdnSpeedTest) { - _cdnResList = List.generate(widget.values.length, (_) => null); - - WidgetsBinding.instance.addPostFrameCallback((_) async { - try { - dynamic result = await VideoHttp.videoUrl( - cid: 196018899, - bvid: 'BV1fK4y1t7hj', - ); - if (result['status']) { - VideoItem videoItem = result['data'].dash.video.first; - - for (CDNService item in CDNService.values) { - if (mounted.not) { - break; - } - String videoUrl = VideoUtils.getCdnUrl(videoItem, item.code); - Dio dio = Dio()..options.headers['referer'] = HttpString.baseUrl; - int maxSize = 8 * 1024 * 1024; - int downloaded = 0; - int start = DateTime.now().millisecondsSinceEpoch; - try { - await dio.get( - videoUrl, - onReceiveProgress: (int count, int total) { - downloaded += count; - int now = DateTime.now().millisecondsSinceEpoch; - if (now - start > 15 * 1000) { - dio.close(force: true); - } - if (downloaded >= maxSize) { - dio.close(force: true); - _cdnResList[item.index] = - (maxSize / (now - start) / 1000).toPrecision(2); - if (mounted) { - setState(() {}); - } - } - }, - ); - } catch (e) { - if (_cdnResList[item.index] == null) { - _cdnResList[item.index] = '测速失败'; - debugPrint('$e'); - if (mounted) { - setState(() {}); - } - } - } - } - } - } catch (e) { - debugPrint('failed to check: $e'); - } - }); - } - } - @override Widget build(BuildContext context) { return AlertDialog( clipBehavior: Clip.hardEdge, - title: Text(widget.title), + title: Text(title), contentPadding: const EdgeInsets.fromLTRB(0, 12, 0, 12), - content: StatefulBuilder(builder: (context, StateSetter setState) { - return SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: List.generate( - widget.values.length, - (index) => RadioListTile( - dense: true, - value: widget.values[index]['value'], - title: Text( - widget.values[index]['title'], - style: Theme.of(context).textTheme.titleMedium!, - ), - subtitle: widget.title == 'CDN 设置' && cdnSpeedTest - ? Text( - _cdnResList[index] is double - ? '${_cdnResList[index]} MB/s' - : _cdnResList[index] is String - ? _cdnResList[index] - : '---', - style: TextStyle(fontSize: 13), - ) - : null, - groupValue: _tempValue, - onChanged: (value) { - setState(() { - _tempValue = value as T; - }); - Navigator.pop(context, _tempValue); - }, + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: List.generate( + values.length, + (index) => RadioListTile( + dense: true, + value: values[index].$1, + title: Text( + values[index].$2, + style: Theme.of(context).textTheme.titleMedium!, ), + subtitle: subtitleBuilder?.call(context, index), + groupValue: value, + onChanged: Navigator.of(context).pop, ), ), - ); - }), + ), + ), + ); + } +} + +class CdnSelectDialog extends StatefulWidget { + final VideoItem? sample; + + const CdnSelectDialog({ + super.key, + this.sample, + }); + + @override + State createState() => _CdnSelectDialogState(); +} + +class _CdnSelectDialogState extends State { + late final List> _cdnResList; + late final CancelToken _cancelToken; + bool _cdnSpeedTest = false; + + @override + void initState() { + _cdnSpeedTest = GStorage.cdnSpeedTest; + if (_cdnSpeedTest) { + _startSpeedTest(); + _cdnResList = List.generate( + CDNService.values.length, (_) => ValueNotifier(null)); + _cancelToken = CancelToken(); + } + super.initState(); + } + + @override + void dispose() { + if (_cdnSpeedTest) { + _cancelToken.cancel(); + for (final notifier in _cdnResList) { + notifier.dispose(); + } + } + super.dispose(); + } + + Future _getSampleUrl() async { + final result = + await VideoHttp.videoUrl(cid: 196018899, bvid: 'BV1fK4y1t7hj'); + if (!result['status']) throw Exception('无法获取视频流'); + return result['data'].dash.video.first; + } + + Future _startSpeedTest() async { + try { + final videoItem = widget.sample ?? await _getSampleUrl(); + await _testAllCdnServices(videoItem); + } catch (e) { + debugPrint('CDN speed test failed: $e'); + } + } + + Future _testAllCdnServices(VideoItem videoItem) async { + for (final item in CDNService.values) { + if (!mounted) break; + await _testSingleCdn(item, videoItem); + } + } + + Future _testSingleCdn(CDNService item, VideoItem videoItem) async { + try { + final cdnUrl = VideoUtils.getCdnUrl(videoItem, item.code); + await _measureDownloadSpeed(cdnUrl, item.index); + } catch (e) { + _handleSpeedTestError(e, item.index); + } + } + + Future _measureDownloadSpeed(String url, int index) async { + const maxSize = 8 * 1024 * 1024; + int downloaded = 0; + final dio = Dio()..options.headers['referer'] = HttpString.baseUrl; + final start = DateTime.now().microsecondsSinceEpoch; + + await dio.get( + url, + cancelToken: _cancelToken, + onReceiveProgress: (count, total) { + if (!mounted) { + dio.close(force: true); + return; + } + final duration = DateTime.now().microsecondsSinceEpoch - start; + + downloaded += count; + + if (duration > 15000000) { + dio.close(force: true); + if (downloaded > 0) { + _updateSpeedResult(index, downloaded, duration); + downloaded = 0; + } else { + throw TimeoutException('测速超时'); + } + } else if (downloaded >= maxSize) { + dio.close(force: true); + _updateSpeedResult(index, downloaded, duration); + downloaded = 0; + } + }, + ); + } + + void _updateSpeedResult(int index, int downloaded, int duration) { + final speed = (downloaded / duration).toStringAsPrecision(3); + _cdnResList[index].value = '${speed}MB/s'; + } + + void _handleSpeedTestError(dynamic error, int index) { + if (_cdnResList[index].value != null) return; + + debugPrint('CDN speed test error: $error'); + if (!mounted) return; + var message = error.toString(); + if (message.length > 30) { + message = '${message.substring(0, 30)}...'; + } else if (message.isEmpty) { + message = '测速失败'; + } + _cdnResList[index].value = message; + } + + @override + Widget build(BuildContext context) { + return SelectDialog( + title: 'CDN 设置', + values: CDNService.values.map((i) => (i.code, i.description)).toList(), + value: GStorage.defaultCDNService, + subtitleBuilder: _cdnSpeedTest + ? (context, index) => ValueListenableBuilder( + valueListenable: _cdnResList[index], + builder: (context, value, _) { + return Text( + _cdnResList[index].value ?? '---', + style: const TextStyle(fontSize: 13), + ); + }, + ) + : null, ); } } diff --git a/lib/pages/video/detail/controller.dart b/lib/pages/video/detail/controller.dart index a57d0cd4..4bbcad38 100644 --- a/lib/pages/video/detail/controller.dart +++ b/lib/pages/video/detail/controller.dart @@ -14,7 +14,6 @@ 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/video/later.dart'; -import 'package:PiliPlus/models/video/play/subtitle.dart'; import 'package:PiliPlus/models/video_detail_res.dart'; import 'package:PiliPlus/pages/search/widgets/search_text.dart'; import 'package:PiliPlus/pages/video/detail/introduction/controller.dart'; @@ -29,6 +28,7 @@ import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart'; import 'package:floating/floating.dart'; import 'package:flutter/material.dart'; 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:PiliPlus/http/constants.dart'; import 'package:PiliPlus/http/video.dart'; @@ -1361,7 +1361,7 @@ class VideoDetailController extends GetxController } } - RxList subtitles = [].obs; + RxList subtitles = RxList(); late final Map _vttSubtitles = {}; late final RxInt vttSubtitlesIndex = (-1).obs; late bool showVP = true; @@ -1412,7 +1412,7 @@ class VideoDetailController extends GetxController steinEdgeInfo = null; try { dynamic res = await Request().get( - 'https://api.bilibili.com/x/stein/edgeinfo_v2', + '/x/stein/edgeinfo_v2', queryParameters: { 'bvid': bvid, 'graph_version': graphVersion, @@ -1432,7 +1432,7 @@ class VideoDetailController extends GetxController late bool continuePlayingPart = GStorage.continuePlayingPart; Future _querySubtitles() async { - Map res = await VideoHttp.subtitlesJson(bvid: bvid, cid: cid.value); + var res = await VideoHttp.subtitlesJson(bvid: bvid, cid: cid.value); // if (!res["status"]) { // SmartDialog.showToast('查询字幕错误,${res["msg"]}'); // } @@ -1492,25 +1492,21 @@ class VideoDetailController extends GetxController } if (res["subtitles"] is List && res["subtitles"].isNotEmpty) { - vttSubtitlesIndex.value = 0; + int idx = 0; subtitles.value = res["subtitles"]; - String preference = setting.get( - SettingBoxKey.subtitlePreference, - defaultValue: SubtitlePreference.values.first.code, - ); - if (preference == 'on') { - vttSubtitlesIndex.value = 1; - } else if (preference == 'withoutAi') { - for (int i = 0; i < subtitles.length; i++) { - if (subtitles[i]['lan']!.startsWith('ai')) { - continue; + String preference = GStorage.defaultSubtitlePreference; + if (preference != 'off') { + idx = subtitles.indexWhere((i) => !i['lan']!.startsWith('ai')) + 1; + if (idx == 0) { + if (preference == 'on' || + (preference == 'auto' && + (await FlutterVolumeController.getVolume() ?? 0) <= 0)) { + idx = 1; } - vttSubtitlesIndex.value = i + 1; - break; } } - setSubtitle(vttSubtitlesIndex.value); + setSubtitle(idx); } } } diff --git a/lib/pages/video/detail/widgets/header_control.dart b/lib/pages/video/detail/widgets/header_control.dart index 16e7fab7..cfa5f4e9 100644 --- a/lib/pages/video/detail/widgets/header_control.dart +++ b/lib/pages/video/detail/widgets/header_control.dart @@ -257,12 +257,8 @@ class HeaderControlState extends State { String? result = await showDialog( context: context, builder: (context) { - return SelectDialog( - title: 'CDN 设置', - value: defaultCDNService, - values: CDNService.values.map((e) { - return {'title': e.description, 'value': e.code}; - }).toList()); + return CdnSelectDialog( + sample: videoInfo.dash?.video?.first); }, ); if (result != null) { @@ -1059,7 +1055,7 @@ class HeaderControlState extends State { contentPadding: const EdgeInsets.only(left: 20, right: 20), title: Text(VideoDecodeFormatsCode.fromString(i)! - .description!), + .description), subtitle: Text( i!, style: subTitleStyle, diff --git a/lib/utils/video_utils.dart b/lib/utils/video_utils.dart index 291cd157..4224ae2b 100644 --- a/lib/utils/video_utils.dart +++ b/lib/utils/video_utils.dart @@ -45,20 +45,16 @@ class VideoUtils { String defaultCDNHost = CDNServiceCode.fromCode(defaultCDNService)!.host; debugPrint("defaultCDNHost:$defaultCDNHost"); if (videoUrl!.contains("szbdyd.com")) { - String hostname = - Uri.parse(videoUrl).queryParameters['xy_usource'] ?? defaultCDNHost; - videoUrl = - Uri.parse(videoUrl).replace(host: hostname, port: 443).toString(); - } else if (videoUrl.contains(".mcdn.bilivideo")) { + final uri = Uri.parse(videoUrl); + String hostname = uri.queryParameters['xy_usource'] ?? defaultCDNHost; + videoUrl = uri.replace(host: hostname, port: 443).toString(); + } else if (videoUrl.contains(".mcdn.bilivideo") || + videoUrl.contains("/upgcxcode/")) { videoUrl = Uri.parse(videoUrl) .replace(host: defaultCDNHost, port: 443) .toString(); // videoUrl = // 'https://proxy-tf-all-ws.bilivideo.com/?url=${Uri.encodeComponent(videoUrl)}'; - } else if (videoUrl.contains("/upgcxcode/")) { - videoUrl = Uri.parse(videoUrl) - .replace(host: defaultCDNHost, port: 443) - .toString(); } debugPrint("videoUrl:$videoUrl");