diff --git a/lib/http/video.dart b/lib/http/video.dart index a30f6621e..82978c739 100644 --- a/lib/http/video.dart +++ b/lib/http/video.dart @@ -37,6 +37,7 @@ import 'package:PiliPlus/utils/recommend_filter.dart'; import 'package:PiliPlus/utils/request_utils.dart'; import 'package:PiliPlus/utils/storage.dart'; import 'package:PiliPlus/utils/storage_pref.dart'; +import 'package:PiliPlus/utils/subtitle_utils.dart'; import 'package:PiliPlus/utils/utils.dart'; import 'package:PiliPlus/utils/wbi_sign.dart'; import 'package:dio/dio.dart'; @@ -834,33 +835,20 @@ abstract final class VideoHttp { } } - static String _subtitleTimecode(num seconds) { - 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"; - } - - static String processList(List list) { - final sb = StringBuffer('WEBVTT\n\n') - ..writeAll( - list.map( - (item) => - '${_subtitleTimecode(item['from'])} --> ${_subtitleTimecode(item['to'])}\n${item['content'].trim()}', - ), - '\n\n', - ); - return sb.toString(); - } - - static Future vttSubtitles(String subtitleUrl) async { + static Future vttSubtitles( + String subtitleUrl, { + SubtitleFormat format = .vtt, + }) async { final res = await Request().get("https:$subtitleUrl"); if (res.data?['body'] case List list) { - return compute(processList, list); + switch (format) { + case .json: + throw UnimplementedError(); + case .vtt: + return compute(SubtitleUtils.json2Vtt, list); + case .srt: + return compute(SubtitleUtils.json2Srt, list); + } } return null; } diff --git a/lib/pages/video/widgets/header_control.dart b/lib/pages/video/widgets/header_control.dart index 0585f3ed5..bc9b78dea 100644 --- a/lib/pages/video/widgets/header_control.dart +++ b/lib/pages/video/widgets/header_control.dart @@ -53,6 +53,7 @@ import 'package:PiliPlus/utils/storage.dart'; import 'package:PiliPlus/utils/storage_key.dart'; import 'package:PiliPlus/utils/storage_pref.dart'; import 'package:PiliPlus/utils/storage_utils.dart'; +import 'package:PiliPlus/utils/subtitle_utils.dart'; import 'package:PiliPlus/utils/utils.dart'; import 'package:PiliPlus/utils/video_utils.dart'; import 'package:battery_plus/battery_plus.dart'; @@ -705,7 +706,7 @@ class HeaderControlState extends State if (!mounted) return; String sub = buffer.toString(); sub = await compute( - VideoHttp.processList, + SubtitleUtils.json2Vtt, jsonDecode(sub)['body'], ); if (!mounted) return; @@ -1138,35 +1139,61 @@ class HeaderControlState extends State ); } - Future<_SubtitleFormat?> _showFormatDialog() { - return showDialog<_SubtitleFormat>( - context: context, - builder: (context) => SimpleDialog( - title: const Text('选择格式'), - children: [ - DialogOption( - onPressed: () => Get.back(result: _SubtitleFormat.json), - child: const Text('JSON'), - ), - DialogOption( - onPressed: () => Get.back(result: _SubtitleFormat.vtt), - child: const Text('WEBVTT'), - ), - ], - ), - ); - } - void onExportSubtitle() { showDialog( context: context, builder: (context) { + SubtitleFormat format = .vtt; final subtitles = videoDetailCtr.subtitles; + final secondary = ColorScheme.of(context).secondary; return SimpleDialog( - clipBehavior: Clip.hardEdge, + clipBehavior: .hardEdge, contentPadding: const .only(bottom: 12), titlePadding: const .fromLTRB(20, 20, 20, 12), - title: const Text('保存字幕'), + title: Row( + children: [ + const Expanded(child: Text('保存字幕')), + const Text('格式: ', style: TextStyle(fontSize: 14)), + Builder( + builder: (context) => PopupMenuButton( + tooltip: '', + initialValue: format, + onSelected: (value) { + format = value; + (context as Element).markNeedsBuild(); + }, + itemBuilder: (_) => SubtitleFormat.values + .map( + (e) => PopupMenuItem( + value: e, + height: 35, + child: Text(e.label), + ), + ) + .toList(), + child: Padding( + padding: const .symmetric(horizontal: 2, vertical: 5), + child: Text.rich( + style: .new(fontSize: 14, color: secondary), + TextSpan( + children: [ + TextSpan(text: format.label), + WidgetSpan( + alignment: .middle, + child: Icon( + size: 14, + MdiIcons.unfoldMoreHorizontal, + color: secondary, + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), children: List.generate(subtitles.length, (i) { final item = subtitles[i]; return DialogOption( @@ -1174,27 +1201,33 @@ class HeaderControlState extends State Get.back(); final url = item.subtitleUrl; if (url == null || url.isEmpty) return; - final format = await _showFormatDialog(); - if (format == null) return; try { final Uint8List bytes; switch (format) { - case .vtt: - var subtitle = videoDetailCtr.vttSubtitles[i]; + case .vtt || .srt: + var subtitle = format == .vtt + ? videoDetailCtr.vttSubtitles[i]?.id + : null; if (subtitle == null) { final res = await VideoHttp.vttSubtitles( item.subtitleUrl!, + format: format, ); if (res == null) return; - subtitle = (isData: true, id: res); - videoDetailCtr.vttSubtitles[i] = subtitle; + subtitle = res; + if (format == .vtt) { + videoDetailCtr.vttSubtitles[i] = ( + isData: true, + id: res, + ); + } } - bytes = utf8.encode(subtitle.id); + bytes = utf8.encode(subtitle); case .json: final res = await Request.dio.get( url.http2https, options: Options( - responseType: ResponseType.bytes, + responseType: .bytes, headers: Constants.baseHeaders, extra: {'account': const NoAccount()}, ), @@ -1207,13 +1240,14 @@ class HeaderControlState extends State ), ); } - String name = - '${introController.videoDetail.value.title}-${videoDetailCtr.bvid}-${videoDetailCtr.cid.value}-${item.lanDoc}.${format.name}'; + final videoDetail = introController.videoDetail.value; + final name = + '${videoDetail.title}-${videoDetail.owner?.name}(${videoDetail.owner?.mid})-${videoDetailCtr.bvid}-${videoDetailCtr.cid.value}-${item.lanDoc}.${format.name}' + .replaceAll( + Platform.isWindows ? RegExp(r'[<>:/\\|?*"]') : '/', + '_', + ); // Reserved characters may not be used in file names. See: https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-file#naming-conventions - name = name.replaceAll( - Platform.isWindows ? RegExp(r'[<>:/\\|?*"]') : '/', - '_', - ); StorageUtils.saveBytes2File( name: name, bytes: bytes, @@ -1224,10 +1258,7 @@ class HeaderControlState extends State SmartDialog.showToast(e.toString()); } }, - child: Text( - item.lanDoc ?? item.lan, - style: const TextStyle(fontSize: 14), - ), + child: Text(item.lanDoc ?? item.lan), ); }), ); @@ -2083,5 +2114,3 @@ class HeaderControlState extends State ); } } - -enum _SubtitleFormat { json, vtt } diff --git a/lib/utils/subtitle_utils.dart b/lib/utils/subtitle_utils.dart new file mode 100644 index 000000000..9a59fc6e9 --- /dev/null +++ b/lib/utils/subtitle_utils.dart @@ -0,0 +1,57 @@ +import 'package:PiliPlus/models/common/enum_with_label.dart'; +import 'package:collection/collection.dart' show IterableExtension; + +enum SubtitleFormat implements EnumWithLabel { + json('JSON'), + vtt('WEBVTT'), + srt('SRT'); + + @override + final String label; + const SubtitleFormat(this.label); +} + +abstract final class SubtitleUtils { + static String _vttTimecode(num seconds) { + final int h = seconds ~/ 3600; + seconds %= 3600; + final int m = seconds ~/ 60; + seconds %= 60; + final String sms = seconds.toStringAsFixed(3).padLeft(6, '0'); + return "${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}:$sms"; + } + + static String json2Vtt(List list) { + final sb = StringBuffer('WEBVTT\n\n') + ..writeAll( + list.map( + (item) => + '${_vttTimecode(item['from'])} --> ${_vttTimecode(item['to'])}\n${item['content'].trim()}', + ), + '\n\n', + ); + return sb.toString(); + } + + static String _srtTimecode(num seconds) { + final int h = seconds ~/ 3600; + seconds %= 3600; + final int m = seconds ~/ 60; + seconds %= 60; + final int s = seconds.toInt(); + final int ms = ((seconds - s) * 1000).round(); + return '${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')},${ms.toString().padLeft(3, '0')}'; + } + + static String json2Srt(List list) { + final sb = StringBuffer() + ..writeAll( + list.mapIndexed( + (i, e) => + '${i + 1}\n${_srtTimecode(e['from'])} --> ${_srtTimecode(e['to'])}\n${e['content'].trim()}', + ), + '\n\n', + ); + return sb.toString(); + } +}