From 81da31c91b933bc3aec8eda41b887bdb1bcfc8d1 Mon Sep 17 00:00:00 2001 From: orz12 Date: Tue, 27 Feb 2024 13:53:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=AD=97=E5=B9=95?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=8C=E5=80=8D=E9=80=9F=E7=A7=BB=E8=87=B3?= =?UTF-8?q?=E5=BA=95=E9=83=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/http/api.dart | 4 + lib/http/video.dart | 110 +++++++++++++- lib/plugin/pl_player/controller.dart | 86 ++++++++++- .../pl_player/widgets/bottom_control.dart | 137 ++++++++++++++---- 4 files changed, 306 insertions(+), 31 deletions(-) diff --git a/lib/http/api.dart b/lib/http/api.dart index 02c797a51..ba2e4d740 100644 --- a/lib/http/api.dart +++ b/lib/http/api.dart @@ -13,6 +13,10 @@ class Api { // https://github.com/SocialSisterYi/bilibili-API-collect/blob/master/docs/video/videostream_url.md static const String videoUrl = '/x/player/wbi/playurl'; + // 字幕 + // aid, cid + static const String subtitleUrl = '/x/player/wbi/v2'; + // 视频详情 // 竖屏 https://api.bilibili.com/x/web-interface/view?aid=527403921 // https://api.bilibili.com/x/web-interface/view/detail 获取视频超详细信息(web端) diff --git a/lib/http/video.dart b/lib/http/video.dart index 733c0bdb7..4f5ec9f98 100644 --- a/lib/http/video.dart +++ b/lib/http/video.dart @@ -1,4 +1,5 @@ import 'dart:developer'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:hive/hive.dart'; import '../common/constants.dart'; import '../models/common/reply_type.dart'; @@ -155,7 +156,8 @@ class VideoHttp { } // 免登录查看1080p - if ((userInfoCache.get('userInfoCache') == null || MineController.anonymity) && + if ((userInfoCache.get('userInfoCache') == null || + MineController.anonymity) && setting.get(SettingBoxKey.p1080, defaultValue: true)) { data['try_look'] = 1; } @@ -476,4 +478,110 @@ class VideoHttp { return {'status': false, 'data': []}; } } + + static Future subtitlesJson( + {String? aid, String? bvid, required int cid}) async { + assert(aid != null || bvid != null); + var res = await Request().get(Api.subtitleUrl, data: { + if (aid != null) 'aid': aid, + if (bvid != null) 'bvid': bvid, + 'cid': cid, + }); + if (res.data['code'] == 0) { + dynamic data = res.data['data']; + List subtitlesJson = data['subtitle']['subtitles']; + /* + [ + { + "id": 1430455228267894300, + "lan": "ai-zh", + "lan_doc": "中文(自动生成)", + "is_lock": false, + "subtitle_url": "//aisubtitle.hdslb.com/bfs/ai_subtitle/prod/15508958271448462983dacf99a49f40ccdf91a4df8d925e2b58?auth_key=1708941835-aaa0e44844594386ad356795733983a2-0-89af73c6aad5a1fca43b02113fa9d485", + "type": 1, + "id_str": "1430455228267894272", + "ai_type": 0, + "ai_status": 2 + } + ] + */ + return { + 'status': true, + 'data': subtitlesJson, + }; + } else { + return {'status': false, 'data': [], 'msg': res.data['message']}; + } + } + + static Future vttSubtitles(List subtitlesJson) async { + if (subtitlesJson.isEmpty) { + return []; + } + List> subtitlesVtt = []; + + String subtitleTimecode(double seconds) { + int h = (seconds / 3600).floor(); + int m = ((seconds % 3600) / 60).floor(); + int s = (seconds % 60).floor(); + int ms = ((seconds * 1000) % 1000).floor(); + if (h == 0) { + return "${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}.${ms.toString().padLeft(3, '0')}"; + } + return "${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}.${ms.toString().padLeft(3, '0')}"; + } + + for (var i in subtitlesJson) { + var res = + await Request().get("https://${i['subtitle_url'].split('//')[1]}"); + /* + { + "font_size": 0.4, + "font_color": "#FFFFFF", + "background_alpha": 0.5, + "background_color": "#9C27B0", + "Stroke": "none", + "type": "AIsubtitle", + "lang": "zh", + "version": "v1.6.0.4", + "body": [ + { + "from": 0.5, + "to": 1.58, + "sid": 1, + "location": 2, + "content": "很多人可能不知道", + "music": 0.0 + }, + ……, + { + "from": 558.629, + "to": 560.22, + "sid": 280, + "location": 2, + "content": "我们下期再见", + "music": 0.0 + } + ] + } + */ + if (res.data != null) { + String vttData = "WEBVTT\n\n"; + for (var item in res.data['body']) { + vttData += "${item['sid']}\n"; + vttData += + "${subtitleTimecode(item['from'])} --> ${subtitleTimecode(item['to'])}\n"; + vttData += "${item['content']}\n\n"; + } + subtitlesVtt.add({ + 'language': i['lan'], + 'title': i['lan_doc'], + 'text': vttData, + }); + } else { + SmartDialog.showToast("字幕${i['lan_doc']}加载失败, ${res.data['message']}"); + } + } + return subtitlesVtt; + } } diff --git a/lib/plugin/pl_player/controller.dart b/lib/plugin/pl_player/controller.dart index 10eb53f16..7cd78defd 100644 --- a/lib/plugin/pl_player/controller.dart +++ b/lib/plugin/pl_player/controller.dart @@ -6,6 +6,7 @@ import 'dart:typed_data'; import 'package:easy_debounce/easy_throttle.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:flutter_volume_controller/flutter_volume_controller.dart'; import 'package:get/get.dart'; import 'package:hive/hive.dart'; @@ -95,6 +96,7 @@ class PlPlayerController { int _heartDuration = 0; bool _enableHeart = true; bool _isFirstTime = true; + final RxList> _vttSubtitles = >[].obs; Timer? _timer; Timer? _timerForSeek; @@ -147,7 +149,10 @@ class PlPlayerController { Rx get mute => _mute; Stream get onMuteChanged => _mute.stream; - /// [videoPlayerController] instace of Player + // 视频字幕 + RxList get vttSubtitles => _vttSubtitles; + + /// [videoPlayerController] instance of Player Player? get videoPlayerController => _videoPlayerController; /// [videoController] instace of Player @@ -372,6 +377,7 @@ class PlPlayerController { // 获取视频时长 00:00 _duration.value = duration ?? _videoPlayerController!.state.duration; updateDurationSecond(); + refreshSubtitles(); // 数据加载完成 dataStatus.status.value = DataStatus.loaded; @@ -1077,4 +1083,82 @@ class PlPlayerController { print(err); } } + + void refreshSubtitles() async { + _vttSubtitles.clear(); + Map res = await VideoHttp.subtitlesJson( + bvid: _bvid, cid: _cid); + if (!res["status"]) { + SmartDialog.showToast('查询字幕错误,${res["msg"]}'); + } + if (res["data"].length == 0) { + return; + } + _vttSubtitles.value = await VideoHttp.vttSubtitles(res["data"]); + if (_vttSubtitles.isEmpty) { + // SmartDialog.showToast('字幕均加载失败'); + return; + } + } + + /// 选择字幕 + void showSetSubtitleSheet() async { + showDialog( + context: Get.context!, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('选择字幕(测试)'), + content: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Wrap( + spacing: 8, + runSpacing: 2, + children: [ + FilledButton.tonal( + onPressed: () async { + await removeSubtitle(); + Get.back(); + }, + child: const Text("关闭字幕"), + ), + for (final Map i in _vttSubtitles) ...[ + FilledButton.tonal( + onPressed: () async { + await setSubtitle(i); + Get.back(); + }, + child: Text(i["title"]!), + ), + ] + ], + ); + }), + actions: [ + TextButton( + onPressed: () => Get.back(), + child: Text( + '取消', + style: TextStyle(color: Theme.of(context).colorScheme.outline), + ), + ), + ], + ); + }, + ); + } + + removeSubtitle() { + _videoPlayerController?.setSubtitleTrack(SubtitleTrack.no()); + } + + // 设定字幕轨道 + setSubtitle(Map s) { + _videoPlayerController?.setSubtitleTrack( + SubtitleTrack.data( + s['text']!, + title: s['title']!, + language: s['language']!, + ) + ); + } } diff --git a/lib/plugin/pl_player/widgets/bottom_control.dart b/lib/plugin/pl_player/widgets/bottom_control.dart index bbadfa727..380312a21 100644 --- a/lib/plugin/pl_player/widgets/bottom_control.dart +++ b/lib/plugin/pl_player/widgets/bottom_control.dart @@ -1,6 +1,5 @@ import 'package:audio_video_progress_bar/audio_video_progress_bar.dart'; import 'package:flutter/material.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:get/get.dart'; import 'package:nil/nil.dart'; import 'package:PiliPalaX/plugin/pl_player/index.dart'; @@ -103,26 +102,8 @@ class BottomControl extends StatelessWidget implements PreferredSizeWidget { ), ), const Spacer(), - // 倍速 - // Obx( - // () => SizedBox( - // width: 45, - // height: 34, - // child: TextButton( - // style: ButtonStyle( - // padding: MaterialStateProperty.all(EdgeInsets.zero), - // ), - // onPressed: () { - // _.togglePlaybackSpeed(); - // }, - // child: Text( - // '${_.playbackSpeed.toString()}X', - // style: textStyle, - // ), - // ), - // ), - // ), SizedBox( + width: 45, height: 30, child: TextButton( onPressed: () => _.toggleVideoFit(), @@ -137,17 +118,55 @@ class BottomControl extends StatelessWidget implements PreferredSizeWidget { ), ), ), - const SizedBox(width: 10), - // 全屏 Obx( - () => ComBtn( - icon: Icon( - _.isFullScreen.value - ? FontAwesomeIcons.compress - : FontAwesomeIcons.expand, - size: 15, - color: Colors.white, + () => _.vttSubtitles.isEmpty + ? const SizedBox( + width: 0, + ) + : SizedBox( + width: 45, + height: 30, + child: IconButton( + style: ButtonStyle( + padding: MaterialStateProperty.all(EdgeInsets.zero), + ), + onPressed: () => _.showSetSubtitleSheet(), + icon: const Icon( + Icons.closed_caption_off_outlined, + size: 19, + color: Colors.white, + ), + ), + ), + ), + SizedBox( + width: 45, + height: 30, + child: TextButton( + style: ButtonStyle( + padding: MaterialStateProperty.all(EdgeInsets.zero), + ), + onPressed: () => showSetSpeedSheet(), + child: Obx( + () => Text( + '${_.playbackSpeed}X', + style: const TextStyle(color: Colors.white, fontSize: 13), + ), ), + ), + ), + // 全屏 + SizedBox( + width: 45, + height: 30, + child: ComBtn( + icon: Obx(() => Icon( + _.isFullScreen.value + ? Icons.fullscreen_exit + : Icons.fullscreen, + size: 19, + color: Colors.white, + )), fuc: () => triggerFullScreen!(status: !_.isFullScreen.value), ), ), @@ -158,4 +177,64 @@ class BottomControl extends StatelessWidget implements PreferredSizeWidget { ), ); } + + /// 选择倍速 + void showSetSpeedSheet() { + final double currentSpeed = controller!.playbackSpeed; + List speedsList = controller!.speedsList; + showDialog( + context: Get.context!, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('播放速度'), + content: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Wrap( + spacing: 8, + runSpacing: 2, + children: [ + for (final double i in speedsList) ...[ + if (i == currentSpeed) ...[ + FilledButton( + onPressed: () async { + // setState(() => currentSpeed = i), + await controller!.setPlaybackSpeed(i); + Get.back(); + }, + child: Text(i.toString()), + ), + ] else ...[ + FilledButton.tonal( + onPressed: () async { + // setState(() => currentSpeed = i), + await controller!.setPlaybackSpeed(i); + Get.back(); + }, + child: Text(i.toString()), + ), + ] + ] + ], + ); + }), + actions: [ + TextButton( + onPressed: () => Get.back(), + child: Text( + '取消', + style: TextStyle(color: Theme.of(context).colorScheme.outline), + ), + ), + TextButton( + onPressed: () async { + await controller!.setDefaultSpeed(); + Get.back(); + }, + child: const Text('默认速度'), + ), + ], + ); + }, + ); + } }