From 955d8f54019ee6f0d97672fcc4f06a03776baddc Mon Sep 17 00:00:00 2001 From: guozhigq Date: Sun, 24 Mar 2024 23:25:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=AE=80=E5=8D=95=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E5=AD=97=E5=B9=95=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/http/video.dart | 27 +++++-- lib/models/common/subtitle_type.dart | 47 ++++++++++++ lib/models/video/subTitile/result.dart | 21 +++++- lib/pages/video/detail/controller.dart | 42 +++++++---- lib/pages/video/detail/view.dart | 1 + .../video/detail/widgets/header_control.dart | 75 +++++++++++++++++++ lib/plugin/pl_player/controller.dart | 66 ++++++++++++++-- lib/plugin/pl_player/view.dart | 69 +++++++++-------- lib/utils/subtitle.dart | 32 ++++++++ 9 files changed, 318 insertions(+), 62 deletions(-) create mode 100644 lib/models/common/subtitle_type.dart create mode 100644 lib/utils/subtitle.dart diff --git a/lib/http/video.dart b/lib/http/video.dart index 4ac886c73..d43656b28 100644 --- a/lib/http/video.dart +++ b/lib/http/video.dart @@ -12,6 +12,7 @@ import '../models/video/subTitile/result.dart'; import '../models/video_detail_res.dart'; import '../utils/recommend_filter.dart'; import '../utils/storage.dart'; +import '../utils/subtitle.dart'; import '../utils/wbi_sign.dart'; import 'api.dart'; import 'init.dart'; @@ -482,13 +483,17 @@ class VideoHttp { 'cid': cid, 'bvid': bvid, }); - if (res.data['code'] == 0) { - return { - 'status': true, - 'data': SubTitlteModel.fromJson(res.data['data']), - }; - } else { - return {'status': false, 'data': [], 'msg': res.data['msg']}; + try { + if (res.data['code'] == 0) { + return { + 'status': true, + 'data': SubTitlteModel.fromJson(res.data['data']), + }; + } else { + return {'status': false, 'data': [], 'msg': res.data['msg']}; + } + } catch (err) { + print(err); } } @@ -514,4 +519,12 @@ class VideoHttp { return {'status': false, 'data': [], 'msg': err}; } } + + // 获取字幕内容 + static Future> getSubtitleContent(url) async { + var res = await Request().get('https:$url'); + final String content = SubTitleUtils.convertToWebVTT(res.data['body']); + final List body = res.data['body']; + return {'content': content, 'body': body}; + } } diff --git a/lib/models/common/subtitle_type.dart b/lib/models/common/subtitle_type.dart new file mode 100644 index 000000000..117163517 --- /dev/null +++ b/lib/models/common/subtitle_type.dart @@ -0,0 +1,47 @@ +enum SubtitleType { + // 中文(中国) + zhCN, + // 中文(自动翻译) + aizh, + // 英语(自动生成) + aien, +} + +extension SubtitleTypeExtension on SubtitleType { + String get description { + switch (this) { + case SubtitleType.zhCN: + return '中文(中国)'; + case SubtitleType.aizh: + return '中文(自动翻译)'; + case SubtitleType.aien: + return '英语(自动生成)'; + } + } +} + +extension SubtitleIdExtension on SubtitleType { + String get id { + switch (this) { + case SubtitleType.zhCN: + return 'zh-CN'; + case SubtitleType.aizh: + return 'ai-zh'; + case SubtitleType.aien: + return 'ai-en'; + } + } +} + +extension SubtitleCodeExtension on SubtitleType { + int get code { + switch (this) { + case SubtitleType.zhCN: + return 1; + case SubtitleType.aizh: + return 2; + case SubtitleType.aien: + return 3; + } + } +} diff --git a/lib/models/video/subTitile/result.dart b/lib/models/video/subTitile/result.dart index 389378fa4..d3e32e558 100644 --- a/lib/models/video/subTitile/result.dart +++ b/lib/models/video/subTitile/result.dart @@ -1,3 +1,6 @@ +import 'package:get/get.dart'; +import '../../common/subtitle_type.dart'; + class SubTitlteModel { SubTitlteModel({ this.aid, @@ -45,6 +48,10 @@ class SubTitlteItemModel { this.type, this.aiType, this.aiStatus, + this.title, + this.code, + this.content, + this.body, }); int? id; @@ -55,16 +62,28 @@ class SubTitlteItemModel { int? type; int? aiType; int? aiStatus; + String? title; + int? code; + String? content; + List? body; factory SubTitlteItemModel.fromJson(Map json) => SubTitlteItemModel( id: json["id"], - lan: json["lan"], + lan: json["lan"].replaceAll('-', ''), lanDoc: json["lan_doc"], isLock: json["is_lock"], subtitleUrl: json["subtitle_url"], type: json["type"], aiType: json["ai_type"], aiStatus: json["ai_status"], + title: json["lan_doc"], + code: SubtitleType.values + .firstWhereOrNull( + (element) => element.id.toString() == json["lan"]) + ?.index ?? + -1, + content: '', + body: [], ); } diff --git a/lib/pages/video/detail/controller.dart b/lib/pages/video/detail/controller.dart index 5eda1e770..5c4ac14b5 100644 --- a/lib/pages/video/detail/controller.dart +++ b/lib/pages/video/detail/controller.dart @@ -20,7 +20,6 @@ import 'package:pilipala/utils/utils.dart'; import 'package:pilipala/utils/video_utils.dart'; import 'package:screen_brightness/screen_brightness.dart'; -import '../../../http/index.dart'; import '../../../models/video/subTitile/content.dart'; import '../../../http/danmaku.dart'; import '../../../utils/id_utils.dart'; @@ -98,6 +97,7 @@ class VideoDetailController extends GetxController RxList subtitleContents = [].obs; late bool enableRelatedVideo; + List subtitles = []; @override void onInit() { @@ -256,6 +256,8 @@ class VideoDetailController extends GetxController /// 开启自动全屏时,在player初始化完成后立即传入headerControl plPlayerController.headerControl = headerControl; + + plPlayerController.subtitles.value = subtitles; } // 视频链接 @@ -398,30 +400,38 @@ class VideoDetailController extends GetxController var result = await VideoHttp.getSubtitle(bvid: bvid, cid: cid.value); if (result['status']) { if (result['data'].subtitles.isNotEmpty) { - SmartDialog.showToast('字幕加载中...'); - var subtitle = result['data'].subtitles.first; - getSubtitleContent(subtitle.subtitleUrl); + subtitles = result['data'].subtitles; + if (subtitles.isNotEmpty) { + for (var i in subtitles) { + final Map res = await VideoHttp.getSubtitleContent( + i.subtitleUrl, + ); + i.content = res['content']; + i.body = res['body']; + } + } } return result['data']; - } else { - SmartDialog.showToast(result['msg'].toString()); } } // 获取字幕内容 - Future getSubtitleContent(String url) async { - var res = await Request().get('https:$url'); - subtitleContents.value = res.data['body'].map((e) { - return SubTitileContentModel.fromJson(e); - }).toList(); - setSubtitleContent(); - } + // Future getSubtitleContent(String url) async { + // var res = await Request().get('https:$url'); + // subtitleContents.value = res.data['body'].map((e) { + // return SubTitileContentModel.fromJson(e); + // }).toList(); + // setSubtitleContent(); + // } setSubtitleContent() { plPlayerController.subtitleContent.value = ''; - if (subtitleContents.isNotEmpty) { - plPlayerController.subtitleContents = subtitleContents; - } + plPlayerController.subtitles.value = subtitles; + } + + clearSubtitleContent() { + plPlayerController.subtitleContent.value = ''; + plPlayerController.subtitles.value = []; } /// 发送弹幕 diff --git a/lib/pages/video/detail/view.dart b/lib/pages/video/detail/view.dart index 088267631..6958a62d7 100644 --- a/lib/pages/video/detail/view.dart +++ b/lib/pages/video/detail/view.dart @@ -212,6 +212,7 @@ class _VideoDetailPageState extends State videoIntroController.isPaused = true; plPlayerController!.removeStatusLister(playerListener); plPlayerController!.pause(); + vdCtr.clearSubtitleContent(); } setState(() => isShowing = false); super.didPushNext(); diff --git a/lib/pages/video/detail/widgets/header_control.dart b/lib/pages/video/detail/widgets/header_control.dart index 858ca2df5..b0b7db179 100644 --- a/lib/pages/video/detail/widgets/header_control.dart +++ b/lib/pages/video/detail/widgets/header_control.dart @@ -344,6 +344,56 @@ class _HeaderControlState extends State { ); } + /// 选择字幕 + void showSubtitleDialog() async { + int tempThemeValue = widget.controller!.subTitleCode.value; + int len = widget.videoDetailCtr!.subtitles.length; + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('选择字幕'), + contentPadding: const EdgeInsets.fromLTRB(0, 12, 0, 18), + content: StatefulBuilder(builder: (context, StateSetter setState) { + return len == 0 + ? const SizedBox( + height: 60, + child: Center( + child: Text('没有字幕'), + ), + ) + : Column( + mainAxisSize: MainAxisSize.min, + children: [ + RadioListTile( + value: -1, + title: const Text('关闭弹幕'), + groupValue: tempThemeValue, + onChanged: (value) { + tempThemeValue = value!; + widget.controller?.toggleSubtitle(value); + Get.back(); + }, + ), + ...widget.videoDetailCtr!.subtitles + .map((e) => RadioListTile( + value: e.code, + title: Text(e.title), + groupValue: tempThemeValue, + onChanged: (value) { + tempThemeValue = value!; + widget.controller?.toggleSubtitle(value); + Get.back(); + }, + )) + .toList(), + ], + ); + }), + ); + }); + } + /// 选择倍速 void showSetSpeedSheet() { final double currentSpeed = widget.controller!.playbackSpeed; @@ -1115,6 +1165,31 @@ class _HeaderControlState extends State { ), SizedBox(width: buttonSpace), ], + + /// 字幕 + // SizedBox( + // width: 34, + // height: 34, + // child: IconButton( + // style: ButtonStyle( + // padding: MaterialStateProperty.all(EdgeInsets.zero), + // ), + // onPressed: () => showSubtitleDialog(), + // icon: const Icon( + // Icons.closed_caption_off, + // size: 22, + // ), + // ), + // ), + ComBtn( + icon: const Icon( + Icons.closed_caption_off, + size: 22, + color: Colors.white, + ), + fuc: () => showSubtitleDialog(), + ), + SizedBox(width: buttonSpace), Obx( () => SizedBox( width: 45, diff --git a/lib/plugin/pl_player/controller.dart b/lib/plugin/pl_player/controller.dart index 6f73f6aa7..b385fca8c 100644 --- a/lib/plugin/pl_player/controller.dart +++ b/lib/plugin/pl_player/controller.dart @@ -22,6 +22,7 @@ import 'package:screen_brightness/screen_brightness.dart'; import 'package:status_bar_control/status_bar_control.dart'; import 'package:universal_platform/universal_platform.dart'; import '../../models/video/subTitile/content.dart'; +import '../../models/video/subTitile/result.dart'; // import 'package:wakelock_plus/wakelock_plus.dart'; Box videoStorage = GStrorage.video; @@ -74,6 +75,8 @@ class PlPlayerController { final Rx _doubleSpeedStatus = false.obs; final Rx _controlsLock = false.obs; final Rx _isFullScreen = false.obs; + final Rx _subTitleOpen = false.obs; + final Rx _subTitleCode = (-1).obs; // 默认投稿视频格式 static Rx _videoType = 'archive'.obs; @@ -119,6 +122,7 @@ class PlPlayerController { PreferredSizeWidget? headerControl; PreferredSizeWidget? bottomControl; Widget? danmuWidget; + late RxList subtitles; /// 数据加载监听 Stream get onDataStatusChanged => dataStatus.status.stream; @@ -148,6 +152,11 @@ class PlPlayerController { Rx get mute => _mute; Stream get onMuteChanged => _mute.stream; + /// 字幕开启状态 + Rx get subTitleOpen => _subTitleOpen; + Rx get subTitleCode => _subTitleCode; + // Stream get onSubTitleOpenChanged => _subTitleOpen.stream; + /// [videoPlayerController] instace of Player Player? get videoPlayerController => _videoPlayerController; @@ -355,6 +364,8 @@ class PlPlayerController { bool enableHeart = true, // 是否首次加载 bool isFirstTime = true, + // 是否开启字幕 + bool enableSubTitle = false, }) async { try { _autoPlay = autoplay; @@ -369,7 +380,9 @@ class PlPlayerController { _cid = cid; _enableHeart = enableHeart; _isFirstTime = isFirstTime; - + _subTitleOpen.value = enableSubTitle; + subtitles = [].obs; + subtitleContent.value = ''; if (_videoPlayerController != null && _videoPlayerController!.state.playing) { await pause(notify: false); @@ -616,6 +629,10 @@ class PlPlayerController { const Duration(seconds: 1), () => videoPlayerServiceHandler.onPositionChange(event)); }), + + // onSubTitleOpenChanged.listen((bool event) { + // toggleSubtitle(event ? subTitleCode.value : -1); + // }) ], ); } @@ -1054,11 +1071,46 @@ class PlPlayerController { } } + /// 字幕 + void toggleSubtitle(int code) { + _subTitleOpen.value = code != -1; + _subTitleCode.value = code; + // if (code == -1) { + // // 关闭字幕 + // _subTitleOpen.value = false; + // _subTitleCode.value = code; + // _videoPlayerController?.setSubtitleTrack(SubtitleTrack.no()); + // return; + // } + // final SubTitlteItemModel? subtitle = subtitles?.firstWhereOrNull( + // (element) => element.code == code, + // ); + // _subTitleOpen.value = true; + // _subTitleCode.value = code; + // _videoPlayerController?.setSubtitleTrack( + // SubtitleTrack.data( + // subtitle!.content!, + // title: subtitle.title, + // language: subtitle.lan, + // ), + // ); + } + void querySubtitleContent(double progress) { - if (subtitleContents.isNotEmpty) { - for (var content in subtitleContents) { - if (progress >= content.from! && progress <= content.to!) { - subtitleContent.value = content.content!; + if (subTitleCode.value == -1) { + subtitleContent.value = ''; + return; + } + if (subtitles.isEmpty) { + return; + } + final SubTitlteItemModel? subtitle = subtitles.firstWhereOrNull( + (element) => element.code == subTitleCode.value, + ); + if (subtitle != null && subtitle.body!.isNotEmpty) { + for (var content in subtitle.body!) { + if (progress >= content['from']! && progress <= content['to']!) { + subtitleContent.value = content['content']!; return; } } @@ -1071,6 +1123,9 @@ class PlPlayerController { } Future dispose({String type = 'single'}) async { + print('dispose'); + print('dispose: ${playerCount.value}'); + // 每次减1,最后销毁 if (type == 'single' && playerCount.value > 1) { _playerCount.value -= 1; @@ -1080,6 +1135,7 @@ class PlPlayerController { } _playerCount.value = 0; try { + print('dispose dispose ---------'); _timer?.cancel(); _timerForVolume?.cancel(); _timerForGettingVolume?.cancel(); diff --git a/lib/plugin/pl_player/view.dart b/lib/plugin/pl_player/view.dart index aad686e30..be24b1050 100644 --- a/lib/plugin/pl_player/view.dart +++ b/lib/plugin/pl_player/view.dart @@ -580,41 +580,44 @@ class _PLVideoPlayerState extends State if (widget.danmuWidget != null) Positioned.fill(top: 4, child: widget.danmuWidget!), - widget.controller.subscriptions.isNotEmpty - ? Stack( - children: [ - Positioned( - left: 0, - right: 0, - bottom: 30, - child: Align( - alignment: Alignment.center, - child: Obx( - () => Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(4), - color: widget.controller.subtitleContent.value != '' - ? Colors.black.withOpacity(0.4) - : Colors.transparent, - ), - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 4, - ), - child: Text( - widget.controller.subtitleContent.value, - style: const TextStyle( - color: Colors.white, - fontSize: 12, - ), + /// 开启且有字幕时展示 + Stack( + children: [ + Positioned( + left: 0, + right: 0, + bottom: 30, + child: Align( + alignment: Alignment.center, + child: Obx( + () => Visibility( + visible: widget.controller.subTitleCode.value != -1, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: widget.controller.subtitleContent.value != '' + ? Colors.black.withOpacity(0.6) + : Colors.transparent, + ), + padding: widget.controller.subTitleCode.value != -1 + ? const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ) + : EdgeInsets.zero, + child: Text( + widget.controller.subtitleContent.value, + style: const TextStyle( + color: Colors.white, + fontSize: 12, ), ), - ), - ), - ), - ], - ) - : const SizedBox(), + )), + ), + ), + ), + ], + ), /// 手势 Positioned.fill( diff --git a/lib/utils/subtitle.dart b/lib/utils/subtitle.dart new file mode 100644 index 000000000..452be5426 --- /dev/null +++ b/lib/utils/subtitle.dart @@ -0,0 +1,32 @@ +class SubTitleUtils { + // 格式整理 + static String convertToWebVTT(List jsonData) { + String webVTTContent = 'WEBVTT FILE\n\n'; + + for (int i = 0; i < jsonData.length; i++) { + final item = jsonData[i]; + double from = item['from'] as double; + double to = item['to'] as double; + int sid = (item['sid'] ?? 0) as int; + String content = item['content'] as String; + + webVTTContent += '$sid\n'; + webVTTContent += '${formatTime(from)} --> ${formatTime(to)}\n'; + webVTTContent += '$content\n\n'; + } + + return webVTTContent; + } + + static String formatTime(num seconds) { + final String h = (seconds / 3600).floor().toString().padLeft(2, '0'); + final String m = (seconds % 3600 / 60).floor().toString().padLeft(2, '0'); + final String s = (seconds % 60).floor().toString().padLeft(2, '0'); + final String ms = + (seconds * 1000 % 1000).floor().toString().padLeft(3, '0'); + if (h == '00') { + return "$m:$s.$ms"; + } + return "$h:$m:$s.$ms"; + } +}