diff --git a/lib/http/video.dart b/lib/http/video.dart index 9c349e780..73acbf3a8 100644 --- a/lib/http/video.dart +++ b/lib/http/video.dart @@ -828,7 +828,13 @@ class VideoHttp { } } - static Future playInfo({String? aid, String? bvid, required int cid}) async { + static Future playInfo({ + String? aid, + String? bvid, + required int cid, + dynamic seasonId, + dynamic epId, + }) async { assert(aid != null || bvid != null); var res = await Request().get( Api.playInfo, @@ -836,6 +842,8 @@ class VideoHttp { 'aid': ?aid, 'bvid': ?bvid, 'cid': cid, + 'season_id': ?seasonId, + 'ep_id': ?epId, }), ); if (res.data['code'] == 0) { @@ -848,32 +856,31 @@ 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) => + '${item?['sid'] ?? 0}\n${_subtitleTimecode(item['from'])} --> ${_subtitleTimecode(item['to'])}\n${item['content'].trim()}', + ), + '\n\n', + ); + return sb.toString(); + } + static Future vttSubtitles(String subtitleUrl) async { - 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"; - } - - String processList(List list) { - final sb = StringBuffer('WEBVTT\n\n') - ..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:$subtitleUrl"); - if (res.data?['body'] case List list) { return compute(processList, list); } diff --git a/lib/models_new/video/video_play_info/subtitle.dart b/lib/models_new/video/video_play_info/subtitle.dart index 789d0f607..797adb963 100644 --- a/lib/models_new/video/video_play_info/subtitle.dart +++ b/lib/models_new/video/video_play_info/subtitle.dart @@ -5,6 +5,11 @@ class Subtitle { String? subtitleUrlV2; bool isAi = false; + Subtitle({ + required this.lan, + this.lanDoc, + }); + Subtitle.fromJson(Map json) { lan = json["lan"]; isAi = json["type"] == 1; diff --git a/lib/pages/video/controller.dart b/lib/pages/video/controller.dart index cb9725fbf..0df6cb8c2 100644 --- a/lib/pages/video/controller.dart +++ b/lib/pages/video/controller.dart @@ -1477,7 +1477,7 @@ class VideoDetailController extends GetxController } RxList subtitles = RxList(); - late final Map _vttSubtitles = {}; + late final Map vttSubtitles = {}; late final RxInt vttSubtitlesIndex = (-1).obs; late final RxBool showVP = true.obs; late final RxList viewPointList = [].obs; @@ -1493,17 +1493,18 @@ class VideoDetailController extends GetxController } void setSub(String subtitle) { + final sub = subtitles[index - 1]; plPlayerController.videoPlayerController?.setSubtitleTrack( SubtitleTrack.data( subtitle, - title: subtitles[index - 1].lanDoc, - language: subtitles[index - 1].lan, + title: sub.lanDoc, + language: sub.lan, ), ); vttSubtitlesIndex.value = index; } - String? subtitle = _vttSubtitles[index - 1]; + String? subtitle = vttSubtitles[index - 1]; if (subtitle != null) { setSub(subtitle); } else { @@ -1511,7 +1512,7 @@ class VideoDetailController extends GetxController subtitles[index - 1].subtitleUrl!, ); if (result != null) { - _vttSubtitles[index - 1] = result; + vttSubtitles[index - 1] = result; setSub(result); } } @@ -1548,12 +1549,17 @@ class VideoDetailController extends GetxController late bool continuePlayingPart = Pref.continuePlayingPart; Future _queryPlayInfo() async { - _vttSubtitles.clear(); + vttSubtitles.clear(); vttSubtitlesIndex.value = 0; if (plPlayerController.showViewPoints) { viewPointList.clear(); } - var res = await VideoHttp.playInfo(bvid: bvid, cid: cid.value); + var res = await VideoHttp.playInfo( + bvid: bvid, + cid: cid.value, + seasonId: seasonId, + epId: epId, + ); if (res['status']) { PlayInfoData playInfo = res['data']; // interactive video @@ -1699,6 +1705,11 @@ class VideoDetailController extends GetxController // danmaku savedDanmaku = null; + // subtitle + subtitles.clear(); + vttSubtitlesIndex.value = -1; + vttSubtitles.clear(); + if (!isFileSource) { // language languages.value = null; @@ -1709,11 +1720,6 @@ class VideoDetailController extends GetxController dmTrend.value = null; } - // subtitle - subtitles.clear(); - vttSubtitlesIndex.value = -1; - _vttSubtitles.clear(); - // view point if (plPlayerController.showViewPoints) { viewPointList.clear(); diff --git a/lib/pages/video/widgets/header_control.dart b/lib/pages/video/widgets/header_control.dart index 1750ca47c..96abe5533 100644 --- a/lib/pages/video/widgets/header_control.dart +++ b/lib/pages/video/widgets/header_control.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'dart:math'; @@ -12,12 +13,14 @@ import 'package:PiliPlus/http/danmaku.dart'; import 'package:PiliPlus/http/danmaku_block.dart'; import 'package:PiliPlus/http/init.dart'; import 'package:PiliPlus/http/live.dart'; +import 'package:PiliPlus/http/video.dart'; import 'package:PiliPlus/models/common/super_resolution_type.dart'; import 'package:PiliPlus/models/common/video/audio_quality.dart'; import 'package:PiliPlus/models/common/video/cdn_type.dart'; import 'package:PiliPlus/models/common/video/video_decode_type.dart'; import 'package:PiliPlus/models/common/video/video_quality.dart'; import 'package:PiliPlus/models/video/play/url.dart'; +import 'package:PiliPlus/models_new/video/video_play_info/subtitle.dart'; import 'package:PiliPlus/pages/common/common_intro_controller.dart'; import 'package:PiliPlus/pages/danmaku/dnamaku_model.dart'; import 'package:PiliPlus/pages/setting/widgets/select_dialog.dart'; @@ -43,6 +46,7 @@ import 'package:PiliPlus/utils/utils.dart'; import 'package:PiliPlus/utils/video_utils.dart'; import 'package:canvas_danmaku/canvas_danmaku.dart'; import 'package:dio/dio.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:floating/floating.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -592,17 +596,66 @@ class HeaderControlState extends State { leading: const Icon(CustomIcons.dm_settings, size: 20), title: const Text('弹幕设置', style: titleStyle), ), - if (!videoDetailCtr.isFileSource) - ListTile( - dense: true, - onTap: () { - Get.back(); - showSetSubtitle(); - }, - leading: const Icon(Icons.subtitles_outlined, size: 20), - title: const Text('字幕设置', style: titleStyle), - ), - if (videoDetailCtr.subtitles.isNotEmpty) + ListTile( + dense: true, + onTap: () { + Get.back(); + showSetSubtitle(); + }, + leading: const Icon(Icons.subtitles_outlined, size: 20), + title: const Text('字幕设置', style: titleStyle), + ), + ListTile( + dense: true, + onTap: () async { + Get.back(); + try { + final FilePickerResult? file = await FilePicker.platform + .pickFiles(); + if (file != null) { + final first = file.files.first; + final path = first.path; + if (path != null) { + final file = File(path); + final stream = file.openRead().transform( + utf8.decoder, + ); + final buffer = StringBuffer(); + await for (final chunk in stream) { + if (!mounted) return; + buffer.write(chunk); + } + if (!mounted) return; + String sub = buffer.toString(); + final name = first.name; + if (name.endsWith('.json')) { + sub = await compute( + VideoHttp.processList, + jsonDecode(sub)['body'], + ); + if (!mounted) return; + } + final length = videoDetailCtr.subtitles.length; + videoDetailCtr + ..subtitles.add( + Subtitle( + lan: '', + lanDoc: name.split('.').firstOrNull ?? name, + ), + ) + ..vttSubtitles[length] = sub + ..setSubtitle(length + 1); + } + } + } catch (e) { + SmartDialog.showToast('加载失败: $e'); + } + }, + leading: const Icon(Icons.file_open_outlined, size: 20), + title: const Text('加载字幕', style: titleStyle), + ), + if (!videoDetailCtr.isFileSource && + videoDetailCtr.subtitles.isNotEmpty) ListTile( dense: true, onTap: () { @@ -1065,9 +1118,11 @@ class HeaderControlState extends State { dense: true, onTap: () async { Get.back(); + final url = item.subtitleUrl; + if (url == null || url.isEmpty) return; try { final res = await Request.dio.get( - item.subtitleUrl!.http2https, + url.http2https, options: Options( responseType: ResponseType.bytes, headers: Constants.baseHeaders, diff --git a/lib/plugin/pl_player/view.dart b/lib/plugin/pl_player/view.dart index ad38b8fc7..3cdaded12 100644 --- a/lib/plugin/pl_player/view.dart +++ b/lib/plugin/pl_player/view.dart @@ -666,20 +666,30 @@ class _PLVideoPlayerState extends State return [ PopupMenuItem( value: 0, + height: 35, onTap: () => videoDetailController.setSubtitle(0), child: const Text( "关闭字幕", - style: TextStyle(color: Colors.white), + style: TextStyle( + color: Colors.white, + fontSize: 13, + ), ), ), ...videoDetailController.subtitles.indexed.map((e) { return PopupMenuItem( value: e.$1 + 1, + height: 35, onTap: () => videoDetailController.setSubtitle(e.$1 + 1), child: Text( "${e.$2.lanDoc}", - style: const TextStyle(color: Colors.white), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 13, + ), ), ); }), @@ -870,10 +880,8 @@ class _PLVideoPlayerState extends State BottomControlType.viewPoints, if (isNotFileSource || anySeason) BottomControlType.episode, if (flag) BottomControlType.fit, - if (isNotFileSource) ...[ - BottomControlType.aiTranslate, - BottomControlType.subtitle, - ], + if (isNotFileSource) BottomControlType.aiTranslate, + BottomControlType.subtitle, BottomControlType.speed, if (isNotFileSource && flag) BottomControlType.qa, if (!plPlayerController.isDesktopPip) BottomControlType.fullscreen,