opt export subtitle

Signed-off-by: dom <githubaccount56556@proton.me>
This commit is contained in:
dom
2026-07-11 13:52:54 +08:00
parent 82116480cb
commit bcd8bc7366
3 changed files with 141 additions and 67 deletions

View File

@@ -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<String?> vttSubtitles(String subtitleUrl) async {
static Future<String?> vttSubtitles(
String subtitleUrl, {
SubtitleFormat format = .vtt,
}) async {
final res = await Request().get("https:$subtitleUrl");
if (res.data?['body'] case List list) {
return compute<List, String>(processList, list);
switch (format) {
case .json:
throw UnimplementedError();
case .vtt:
return compute<List, String>(SubtitleUtils.json2Vtt, list);
case .srt:
return compute<List, String>(SubtitleUtils.json2Srt, list);
}
}
return null;
}

View File

@@ -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<HeaderControl>
if (!mounted) return;
String sub = buffer.toString();
sub = await compute<List, String>(
VideoHttp.processList,
SubtitleUtils.json2Vtt,
jsonDecode(sub)['body'],
);
if (!mounted) return;
@@ -1138,35 +1139,61 @@ class HeaderControlState extends State<HeaderControl>
);
}
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<SubtitleFormat>(
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<HeaderControl>
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<Uint8List>(
url.http2https,
options: Options(
responseType: ResponseType.bytes,
responseType: .bytes,
headers: Constants.baseHeaders,
extra: {'account': const NoAccount()},
),
@@ -1207,13 +1240,14 @@ class HeaderControlState extends State<HeaderControl>
),
);
}
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<HeaderControl>
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<HeaderControl>
);
}
}
enum _SubtitleFormat { json, vtt }

View File

@@ -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();
}
}