From ee368d348d00148edf7c5557f6171b044bc3d353 Mon Sep 17 00:00:00 2001 From: guozhigq Date: Tue, 27 Feb 2024 22:50:02 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E5=AD=97=E5=B9=95=E5=B1=95?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/http/api.dart | 3 ++ lib/http/video.dart | 16 ++++++ lib/models/video/subTitile/content.dart | 20 +++++++ lib/models/video/subTitile/result.dart | 70 +++++++++++++++++++++++++ lib/pages/video/detail/controller.dart | 36 +++++++++++++ lib/pages/video/detail/view.dart | 5 +- lib/plugin/pl_player/controller.dart | 21 +++++++- lib/plugin/pl_player/view.dart | 37 ++++++++++++- 8 files changed, 204 insertions(+), 4 deletions(-) create mode 100644 lib/models/video/subTitile/content.dart create mode 100644 lib/models/video/subTitile/result.dart diff --git a/lib/http/api.dart b/lib/http/api.dart index 2e7584396..559c6fdb7 100644 --- a/lib/http/api.dart +++ b/lib/http/api.dart @@ -483,4 +483,7 @@ class Api { /// 激活buvid3 static const activateBuvidApi = '/x/internal/gaia-gateway/ExClimbWuzhi'; + + /// 获取字幕配置 + static const getSubtitleConfig = '/x/player/v2'; } diff --git a/lib/http/video.dart b/lib/http/video.dart index 30df62c3e..a852e74bc 100644 --- a/lib/http/video.dart +++ b/lib/http/video.dart @@ -8,6 +8,7 @@ import '../models/model_rec_video_item.dart'; import '../models/user/fav_folder.dart'; import '../models/video/ai.dart'; import '../models/video/play/url.dart'; +import '../models/video/subTitile/result.dart'; import '../models/video_detail_res.dart'; import '../utils/recommend_filter.dart'; import '../utils/storage.dart'; @@ -475,4 +476,19 @@ class VideoHttp { return {'status': false, 'data': []}; } } + + static Future getSubtitle({int? cid, String? bvid}) async { + var res = await Request().get(Api.getSubtitleConfig, data: { + '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']}; + } + } } diff --git a/lib/models/video/subTitile/content.dart b/lib/models/video/subTitile/content.dart new file mode 100644 index 000000000..b18098a49 --- /dev/null +++ b/lib/models/video/subTitile/content.dart @@ -0,0 +1,20 @@ +class SubTitileContentModel { + double? from; + double? to; + int? location; + String? content; + + SubTitileContentModel({ + this.from, + this.to, + this.location, + this.content, + }); + + SubTitileContentModel.fromJson(Map json) { + from = json['from']; + to = json['to']; + location = json['location']; + content = json['content']; + } +} diff --git a/lib/models/video/subTitile/result.dart b/lib/models/video/subTitile/result.dart new file mode 100644 index 000000000..389378fa4 --- /dev/null +++ b/lib/models/video/subTitile/result.dart @@ -0,0 +1,70 @@ +class SubTitlteModel { + SubTitlteModel({ + this.aid, + this.bvid, + this.cid, + this.loginMid, + this.loginMidHash, + this.isOwner, + this.name, + this.subtitles, + }); + + int? aid; + String? bvid; + int? cid; + int? loginMid; + String? loginMidHash; + bool? isOwner; + String? name; + List? subtitles; + + factory SubTitlteModel.fromJson(Map json) => SubTitlteModel( + aid: json["aid"], + bvid: json["bvid"], + cid: json["cid"], + loginMid: json["login_mid"], + loginMidHash: json["login_mid_hash"], + isOwner: json["is_owner"], + name: json["name"], + subtitles: json["subtitle"] != null + ? json["subtitle"]["subtitles"] + .map((x) => SubTitlteItemModel.fromJson(x)) + .toList() + : [], + ); +} + +class SubTitlteItemModel { + SubTitlteItemModel({ + this.id, + this.lan, + this.lanDoc, + this.isLock, + this.subtitleUrl, + this.type, + this.aiType, + this.aiStatus, + }); + + int? id; + String? lan; + String? lanDoc; + bool? isLock; + String? subtitleUrl; + int? type; + int? aiType; + int? aiStatus; + + factory SubTitlteItemModel.fromJson(Map json) => + SubTitlteItemModel( + id: json["id"], + lan: json["lan"], + lanDoc: json["lan_doc"], + isLock: json["is_lock"], + subtitleUrl: json["subtitle_url"], + type: json["type"], + aiType: json["ai_type"], + aiStatus: json["ai_status"], + ); +} diff --git a/lib/pages/video/detail/controller.dart b/lib/pages/video/detail/controller.dart index 7465c6f22..198da362d 100644 --- a/lib/pages/video/detail/controller.dart +++ b/lib/pages/video/detail/controller.dart @@ -19,6 +19,8 @@ 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 '../../../utils/id_utils.dart'; import 'widgets/header_control.dart'; @@ -91,6 +93,8 @@ class VideoDetailController extends GetxController late int cacheAudioQa; PersistentBottomSheetController? replyReplyBottomSheetCtr; + RxList subtitleContents = + [].obs; @override void onInit() { @@ -139,6 +143,7 @@ class VideoDetailController extends GetxController cacheAudioQa = setting.get(SettingBoxKey.defaultAudioQa, defaultValue: AudioQuality.hiRes.code); oid.value = IdUtils.bv2av(Get.parameters['bvid']!); + getSubtitle(); } showReplyReplyPanel() { @@ -381,4 +386,35 @@ class VideoDetailController extends GetxController ? replyReplyBottomSheetCtr!.close() : print('replyReplyBottomSheetCtr is null'); } + + // 获取字幕配置 + Future getSubtitle() async { + 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); + } + 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(); + } + + setSubtitleContent() { + plPlayerController.subtitleContent.value = ''; + if (subtitleContents.isNotEmpty) { + plPlayerController.subtitleContents = subtitleContents; + } + } } diff --git a/lib/pages/video/detail/view.dart b/lib/pages/video/detail/view.dart index febca105c..449c377e9 100644 --- a/lib/pages/video/detail/view.dart +++ b/lib/pages/video/detail/view.dart @@ -236,7 +236,10 @@ class _VideoDetailPageState extends State void didPopNext() async { if (plPlayerController != null && plPlayerController!.videoPlayerController != null) { - setState(() => isShowing = true); + setState(() { + videoDetailController.setSubtitleContent(); + isShowing = true; + }); } videoDetailController.isFirstTime = false; final bool autoplay = autoPlayEnable; diff --git a/lib/plugin/pl_player/controller.dart b/lib/plugin/pl_player/controller.dart index dfa580ab6..081b9ad90 100644 --- a/lib/plugin/pl_player/controller.dart +++ b/lib/plugin/pl_player/controller.dart @@ -21,6 +21,7 @@ import 'package:pilipala/utils/storage.dart'; 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 'package:wakelock_plus/wakelock_plus.dart'; Box videoStorage = GStrorage.video; @@ -231,6 +232,10 @@ class PlPlayerController { // 播放顺序相关 PlayRepeat playRepeat = PlayRepeat.pause; + RxList subtitleContents = + [].obs; + RxString subtitleContent = ''.obs; + void updateSliderPositionSecond() { int newSecond = _sliderPosition.value.inSeconds; if (sliderPositionSeconds.value != newSecond) { @@ -277,8 +282,7 @@ class PlPlayerController { danmakuDurationVal = localCache.get(LocalCacheKey.danmakuDuration, defaultValue: 4.0); // 描边粗细 - strokeWidth = - localCache.get(LocalCacheKey.strokeWidth, defaultValue: 1.5); + strokeWidth = localCache.get(LocalCacheKey.strokeWidth, defaultValue: 1.5); playRepeat = PlayRepeat.values.toList().firstWhere( (e) => e.value == @@ -566,6 +570,8 @@ class PlPlayerController { _sliderPosition.value = event; updateSliderPositionSecond(); } + querySubtitleContent( + videoPlayerController!.state.position.inSeconds.toDouble()); /// 触发回调事件 for (var element in _positionListeners) { @@ -1050,6 +1056,17 @@ class PlPlayerController { } } + void querySubtitleContent(double progress) { + if (subtitleContents.isNotEmpty) { + for (var content in subtitleContents) { + if (progress >= content.from! && progress <= content.to!) { + subtitleContent.value = content.content!; + return; + } + } + } + } + setPlayRepeat(PlayRepeat type) { playRepeat = type; videoStorage.put(VideoBoxKey.playRepeat, type.value); diff --git a/lib/plugin/pl_player/view.dart b/lib/plugin/pl_player/view.dart index d073945b7..e7a8ce732 100644 --- a/lib/plugin/pl_player/view.dart +++ b/lib/plugin/pl_player/view.dart @@ -16,7 +16,6 @@ import 'package:pilipala/plugin/pl_player/utils.dart'; import 'package:pilipala/utils/feed_back.dart'; import 'package:pilipala/utils/storage.dart'; import 'package:screen_brightness/screen_brightness.dart'; - import 'models/bottom_progress_behavior.dart'; import 'widgets/app_bar_ani.dart'; import 'widgets/backward_seek.dart'; @@ -428,6 +427,42 @@ 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, + ), + ), + ), + ), + ), + ), + ], + ) + : nil, + /// 手势 Positioned.fill( left: 16, From 2cd8ab7d27fcf9c6a686725827242d76091b3207 Mon Sep 17 00:00:00 2001 From: guozhigq Date: Sun, 24 Mar 2024 16:03:18 +0800 Subject: [PATCH 2/4] =?UTF-8?q?mod:=20=E8=A7=86=E9=A2=91=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E9=A1=B5=E7=AE=80=E4=BB=8B=E6=9F=A5=E7=9C=8B&=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E6=A0=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/video/detail/introduction/view.dart | 33 ++--- .../introduction/widgets/intro_detail.dart | 130 ++++-------------- .../detail/widgets/expandable_section.dart | 20 +-- 3 files changed, 50 insertions(+), 133 deletions(-) diff --git a/lib/pages/video/detail/introduction/view.dart b/lib/pages/video/detail/introduction/view.dart index 9c1b7db0d..831491f60 100644 --- a/lib/pages/video/detail/introduction/view.dart +++ b/lib/pages/video/detail/introduction/view.dart @@ -15,6 +15,7 @@ import 'package:pilipala/pages/video/detail/widgets/ai_detail.dart'; import 'package:pilipala/utils/feed_back.dart'; import 'package:pilipala/utils/storage.dart'; import 'package:pilipala/utils/utils.dart'; +import '../widgets/expandable_section.dart'; import 'widgets/action_item.dart'; import 'widgets/fav_panel.dart'; import 'widgets/intro_detail.dart'; @@ -137,6 +138,7 @@ class _VideoInfoState extends State with TickerProviderStateMixin { late String memberHeroTag; late bool enableAi; bool isProcessing = false; + RxBool isExpand = false.obs; void Function()? handleState(Future Function() action) { return isProcessing ? null @@ -212,13 +214,7 @@ class _VideoInfoState extends State with TickerProviderStateMixin { // 视频介绍 showIntroDetail() { feedBack(); - showBottomSheet( - context: context, - enableDrag: true, - builder: (BuildContext context) { - return IntroDetail(videoDetail: widget.videoDetail!); - }, - ); + isExpand.value = !(isExpand.value); } // 用户主页 @@ -330,6 +326,16 @@ class _VideoInfoState extends State with TickerProviderStateMixin { ], ), + /// 视频简介 + Obx( + () => ExpandedSection( + expand: isExpand.value, + begin: 0, + end: 1, + child: IntroDetail(videoDetail: widget.videoDetail!), + ), + ), + /// 点赞收藏转发 actionGrid(context, videoIntroController), // 合集 @@ -438,6 +444,7 @@ class _VideoInfoState extends State with TickerProviderStateMixin { margin: const EdgeInsets.only(top: 6, bottom: 4), height: constraints.maxWidth / 5 * 0.8, child: GridView.count( + physics: const NeverScrollableScrollPhysics(), primary: false, padding: EdgeInsets.zero, crossAxisCount: 5, @@ -451,12 +458,6 @@ class _VideoInfoState extends State with TickerProviderStateMixin { selectStatus: videoIntroController.hasLike.value, text: widget.videoDetail!.stat!.like!.toString()), ), - // ActionItem( - // icon: const Icon(FontAwesomeIcons.clock), - // onTap: () => videoIntroController.actionShareVideo(), - // selectStatus: false, - // loadingStatus: loadingStatus, - // text: '稍后再看'), Obx( () => ActionItem( icon: const Icon(FontAwesomeIcons.b), @@ -477,10 +478,10 @@ class _VideoInfoState extends State with TickerProviderStateMixin { ), ), ActionItem( - icon: const Icon(FontAwesomeIcons.comment), - onTap: () => videoDetailCtr.tabCtr.animateTo(1), + icon: const Icon(FontAwesomeIcons.clock), + onTap: () => videoIntroController.actionShareVideo(), selectStatus: false, - text: widget.videoDetail!.stat!.reply!.toString(), + text: '稍后看', ), ActionItem( icon: const Icon(FontAwesomeIcons.shareFromSquare), diff --git a/lib/pages/video/detail/introduction/widgets/intro_detail.dart b/lib/pages/video/detail/introduction/widgets/intro_detail.dart index c74e27ee1..1e9bb8424 100644 --- a/lib/pages/video/detail/introduction/widgets/intro_detail.dart +++ b/lib/pages/video/detail/introduction/widgets/intro_detail.dart @@ -1,16 +1,10 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; -import 'package:hive/hive.dart'; -import 'package:pilipala/common/widgets/stat/danmu.dart'; -import 'package:pilipala/common/widgets/stat/view.dart'; -import 'package:pilipala/utils/storage.dart'; import 'package:pilipala/utils/utils.dart'; -Box localCache = GStrorage.localCache; -late double sheetHeight; - class IntroDetail extends StatelessWidget { const IntroDetail({ super.key, @@ -20,105 +14,39 @@ class IntroDetail extends StatelessWidget { @override Widget build(BuildContext context) { - sheetHeight = localCache.get('sheetHeight'); - return Container( - color: Theme.of(context).colorScheme.background, - padding: EdgeInsets.only( - left: 14, - right: 14, - bottom: MediaQuery.of(context).padding.bottom + 20), - height: sheetHeight, + return SizedBox( + width: double.infinity, + child: SelectableRegion( + focusNode: FocusNode(), + selectionControls: MaterialTextSelectionControls(), child: Column( - children: [ - InkWell( - onTap: () => Get.back(), - child: Container( - height: 35, - padding: const EdgeInsets.only(bottom: 2), - child: Center( - child: Container( - width: 32, - height: 3, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary, - borderRadius: - const BorderRadius.all(Radius.circular(3))), - ), - ), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + GestureDetector( + onTap: () { + Clipboard.setData(ClipboardData(text: videoDetail!.bvid!)); + SmartDialog.showToast('已复制'); + }, + child: Text( + videoDetail!.bvid!, + style: TextStyle( + fontSize: 13, color: Theme.of(context).colorScheme.primary), ), ), - Expanded( - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - videoDetail!.title, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 6), - Row( - children: [ - StatView( - theme: 'gray', - view: videoDetail!.stat!.view, - size: 'medium', - ), - const SizedBox(width: 10), - StatDanMu( - theme: 'gray', - danmu: videoDetail!.stat!.danmaku, - size: 'medium', - ), - const SizedBox(width: 10), - Text( - Utils.dateFormat(videoDetail!.pubdate, - formatType: 'detail'), - style: TextStyle( - fontSize: 12, - color: Theme.of(context).colorScheme.outline, - ), - ), - ], - ), - const SizedBox(height: 20), - SizedBox( - width: double.infinity, - child: SelectableRegion( - focusNode: FocusNode(), - selectionControls: MaterialTextSelectionControls(), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - videoDetail!.bvid!, - style: const TextStyle(fontSize: 13), - ), - const SizedBox(height: 4), - Text.rich( - style: const TextStyle( - height: 1.4, - // fontSize: 13, - ), - TextSpan( - children: [ - buildContent(context, videoDetail!), - ], - ), - ), - ], - ), - ), - ), - ], - ), + const SizedBox(height: 4), + Text.rich( + style: const TextStyle(height: 1.4), + TextSpan( + children: [ + buildContent(context, videoDetail!), + ], ), - ) + ), ], - )); + ), + ), + ); } InlineSpan buildContent(BuildContext context, content) { diff --git a/lib/pages/video/detail/widgets/expandable_section.dart b/lib/pages/video/detail/widgets/expandable_section.dart index afa68cc9f..69e73e206 100644 --- a/lib/pages/video/detail/widgets/expandable_section.dart +++ b/lib/pages/video/detail/widgets/expandable_section.dart @@ -32,28 +32,14 @@ class _ExpandedSectionState extends State _runExpandCheck(); } - ///Setting up the animation - // void prepareAnimations() { - // expandController = AnimationController( - // vsync: this, duration: const Duration(milliseconds: 500)); - // animation = CurvedAnimation( - // parent: expandController, - // curve: Curves.fastOutSlowIn, - // ); - // } - void prepareAnimations() { expandController = AnimationController( vsync: this, duration: const Duration(milliseconds: 400)); Animation curve = CurvedAnimation( parent: expandController, - curve: Curves.fastOutSlowIn, + curve: Curves.linear, ); animation = Tween(begin: widget.begin, end: widget.end).animate(curve); - // animation = CurvedAnimation( - // parent: expandController, - // curve: Curves.fastOutSlowIn, - // ); } void _runExpandCheck() { @@ -67,7 +53,9 @@ class _ExpandedSectionState extends State @override void didUpdateWidget(ExpandedSection oldWidget) { super.didUpdateWidget(oldWidget); - _runExpandCheck(); + if (widget.expand != oldWidget.expand) { + _runExpandCheck(); + } } @override From 1f75a7e781dd452eae763bd2fc38fb409e97e973 Mon Sep 17 00:00:00 2001 From: guozhigq Date: Sun, 24 Mar 2024 16:17:02 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20appbar=E6=BB=91=E5=8A=A8=E8=B7=9D?= =?UTF-8?q?=E7=A6=BB=E7=A7=BB=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/video/detail/widgets/app_bar.dart | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/pages/video/detail/widgets/app_bar.dart b/lib/pages/video/detail/widgets/app_bar.dart index 17f4bec7d..efc0b593b 100644 --- a/lib/pages/video/detail/widgets/app_bar.dart +++ b/lib/pages/video/detail/widgets/app_bar.dart @@ -17,12 +17,16 @@ class ScrollAppBar extends StatelessWidget { Widget build(BuildContext context) { final double statusBarHeight = MediaQuery.of(context).padding.top; final videoHeight = MediaQuery.sizeOf(context).width * 9 / 16; + double scrollDistance = scrollVal; + if (scrollVal > videoHeight - kToolbarHeight) { + scrollDistance = videoHeight - kToolbarHeight; + } return Positioned( - top: -videoHeight + scrollVal + kToolbarHeight + 0.5, + top: -videoHeight + scrollDistance + kToolbarHeight + 0.5, left: 0, right: 0, child: Opacity( - opacity: scrollVal / (videoHeight - kToolbarHeight), + opacity: scrollDistance / (videoHeight - kToolbarHeight), child: Container( height: statusBarHeight + kToolbarHeight, color: Theme.of(context).colorScheme.background, From 955d8f54019ee6f0d97672fcc4f06a03776baddc Mon Sep 17 00:00:00 2001 From: guozhigq Date: Sun, 24 Mar 2024 23:25:45 +0800 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20=E7=AE=80=E5=8D=95=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=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"; + } +}