feat: 简单实现字幕功能

This commit is contained in:
guozhigq
2024-03-24 23:25:45 +08:00
parent 1f75a7e781
commit 955d8f5401
9 changed files with 318 additions and 62 deletions

View File

@ -12,6 +12,7 @@ import '../models/video/subTitile/result.dart';
import '../models/video_detail_res.dart'; import '../models/video_detail_res.dart';
import '../utils/recommend_filter.dart'; import '../utils/recommend_filter.dart';
import '../utils/storage.dart'; import '../utils/storage.dart';
import '../utils/subtitle.dart';
import '../utils/wbi_sign.dart'; import '../utils/wbi_sign.dart';
import 'api.dart'; import 'api.dart';
import 'init.dart'; import 'init.dart';
@ -482,13 +483,17 @@ class VideoHttp {
'cid': cid, 'cid': cid,
'bvid': bvid, 'bvid': bvid,
}); });
if (res.data['code'] == 0) { try {
return { if (res.data['code'] == 0) {
'status': true, return {
'data': SubTitlteModel.fromJson(res.data['data']), 'status': true,
}; 'data': SubTitlteModel.fromJson(res.data['data']),
} else { };
return {'status': false, 'data': [], 'msg': res.data['msg']}; } 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}; return {'status': false, 'data': [], 'msg': err};
} }
} }
// 获取字幕内容
static Future<Map<String, dynamic>> 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};
}
} }

View File

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

View File

@ -1,3 +1,6 @@
import 'package:get/get.dart';
import '../../common/subtitle_type.dart';
class SubTitlteModel { class SubTitlteModel {
SubTitlteModel({ SubTitlteModel({
this.aid, this.aid,
@ -45,6 +48,10 @@ class SubTitlteItemModel {
this.type, this.type,
this.aiType, this.aiType,
this.aiStatus, this.aiStatus,
this.title,
this.code,
this.content,
this.body,
}); });
int? id; int? id;
@ -55,16 +62,28 @@ class SubTitlteItemModel {
int? type; int? type;
int? aiType; int? aiType;
int? aiStatus; int? aiStatus;
String? title;
int? code;
String? content;
List? body;
factory SubTitlteItemModel.fromJson(Map<String, dynamic> json) => factory SubTitlteItemModel.fromJson(Map<String, dynamic> json) =>
SubTitlteItemModel( SubTitlteItemModel(
id: json["id"], id: json["id"],
lan: json["lan"], lan: json["lan"].replaceAll('-', ''),
lanDoc: json["lan_doc"], lanDoc: json["lan_doc"],
isLock: json["is_lock"], isLock: json["is_lock"],
subtitleUrl: json["subtitle_url"], subtitleUrl: json["subtitle_url"],
type: json["type"], type: json["type"],
aiType: json["ai_type"], aiType: json["ai_type"],
aiStatus: json["ai_status"], aiStatus: json["ai_status"],
title: json["lan_doc"],
code: SubtitleType.values
.firstWhereOrNull(
(element) => element.id.toString() == json["lan"])
?.index ??
-1,
content: '',
body: [],
); );
} }

View File

@ -20,7 +20,6 @@ import 'package:pilipala/utils/utils.dart';
import 'package:pilipala/utils/video_utils.dart'; import 'package:pilipala/utils/video_utils.dart';
import 'package:screen_brightness/screen_brightness.dart'; import 'package:screen_brightness/screen_brightness.dart';
import '../../../http/index.dart';
import '../../../models/video/subTitile/content.dart'; import '../../../models/video/subTitile/content.dart';
import '../../../http/danmaku.dart'; import '../../../http/danmaku.dart';
import '../../../utils/id_utils.dart'; import '../../../utils/id_utils.dart';
@ -98,6 +97,7 @@ class VideoDetailController extends GetxController
RxList<SubTitileContentModel> subtitleContents = RxList<SubTitileContentModel> subtitleContents =
<SubTitileContentModel>[].obs; <SubTitileContentModel>[].obs;
late bool enableRelatedVideo; late bool enableRelatedVideo;
List subtitles = [];
@override @override
void onInit() { void onInit() {
@ -256,6 +256,8 @@ class VideoDetailController extends GetxController
/// 开启自动全屏时在player初始化完成后立即传入headerControl /// 开启自动全屏时在player初始化完成后立即传入headerControl
plPlayerController.headerControl = 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); var result = await VideoHttp.getSubtitle(bvid: bvid, cid: cid.value);
if (result['status']) { if (result['status']) {
if (result['data'].subtitles.isNotEmpty) { if (result['data'].subtitles.isNotEmpty) {
SmartDialog.showToast('字幕加载中...'); subtitles = result['data'].subtitles;
var subtitle = result['data'].subtitles.first; if (subtitles.isNotEmpty) {
getSubtitleContent(subtitle.subtitleUrl); for (var i in subtitles) {
final Map<String, dynamic> res = await VideoHttp.getSubtitleContent(
i.subtitleUrl,
);
i.content = res['content'];
i.body = res['body'];
}
}
} }
return result['data']; return result['data'];
} else {
SmartDialog.showToast(result['msg'].toString());
} }
} }
// 获取字幕内容 // 获取字幕内容
Future getSubtitleContent(String url) async { // Future getSubtitleContent(String url) async {
var res = await Request().get('https:$url'); // var res = await Request().get('https:$url');
subtitleContents.value = res.data['body'].map<SubTitileContentModel>((e) { // subtitleContents.value = res.data['body'].map<SubTitileContentModel>((e) {
return SubTitileContentModel.fromJson(e); // return SubTitileContentModel.fromJson(e);
}).toList(); // }).toList();
setSubtitleContent(); // setSubtitleContent();
} // }
setSubtitleContent() { setSubtitleContent() {
plPlayerController.subtitleContent.value = ''; plPlayerController.subtitleContent.value = '';
if (subtitleContents.isNotEmpty) { plPlayerController.subtitles.value = subtitles;
plPlayerController.subtitleContents = subtitleContents; }
}
clearSubtitleContent() {
plPlayerController.subtitleContent.value = '';
plPlayerController.subtitles.value = [];
} }
/// 发送弹幕 /// 发送弹幕

View File

@ -212,6 +212,7 @@ class _VideoDetailPageState extends State<VideoDetailPage>
videoIntroController.isPaused = true; videoIntroController.isPaused = true;
plPlayerController!.removeStatusLister(playerListener); plPlayerController!.removeStatusLister(playerListener);
plPlayerController!.pause(); plPlayerController!.pause();
vdCtr.clearSubtitleContent();
} }
setState(() => isShowing = false); setState(() => isShowing = false);
super.didPushNext(); super.didPushNext();

View File

@ -344,6 +344,56 @@ class _HeaderControlState extends State<HeaderControl> {
); );
} }
/// 选择字幕
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() { void showSetSpeedSheet() {
final double currentSpeed = widget.controller!.playbackSpeed; final double currentSpeed = widget.controller!.playbackSpeed;
@ -1115,6 +1165,31 @@ class _HeaderControlState extends State<HeaderControl> {
), ),
SizedBox(width: buttonSpace), 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( Obx(
() => SizedBox( () => SizedBox(
width: 45, width: 45,

View File

@ -22,6 +22,7 @@ import 'package:screen_brightness/screen_brightness.dart';
import 'package:status_bar_control/status_bar_control.dart'; import 'package:status_bar_control/status_bar_control.dart';
import 'package:universal_platform/universal_platform.dart'; import 'package:universal_platform/universal_platform.dart';
import '../../models/video/subTitile/content.dart'; import '../../models/video/subTitile/content.dart';
import '../../models/video/subTitile/result.dart';
// import 'package:wakelock_plus/wakelock_plus.dart'; // import 'package:wakelock_plus/wakelock_plus.dart';
Box videoStorage = GStrorage.video; Box videoStorage = GStrorage.video;
@ -74,6 +75,8 @@ class PlPlayerController {
final Rx<bool> _doubleSpeedStatus = false.obs; final Rx<bool> _doubleSpeedStatus = false.obs;
final Rx<bool> _controlsLock = false.obs; final Rx<bool> _controlsLock = false.obs;
final Rx<bool> _isFullScreen = false.obs; final Rx<bool> _isFullScreen = false.obs;
final Rx<bool> _subTitleOpen = false.obs;
final Rx<int> _subTitleCode = (-1).obs;
// 默认投稿视频格式 // 默认投稿视频格式
static Rx<String> _videoType = 'archive'.obs; static Rx<String> _videoType = 'archive'.obs;
@ -119,6 +122,7 @@ class PlPlayerController {
PreferredSizeWidget? headerControl; PreferredSizeWidget? headerControl;
PreferredSizeWidget? bottomControl; PreferredSizeWidget? bottomControl;
Widget? danmuWidget; Widget? danmuWidget;
late RxList subtitles;
/// 数据加载监听 /// 数据加载监听
Stream<DataStatus> get onDataStatusChanged => dataStatus.status.stream; Stream<DataStatus> get onDataStatusChanged => dataStatus.status.stream;
@ -148,6 +152,11 @@ class PlPlayerController {
Rx<bool> get mute => _mute; Rx<bool> get mute => _mute;
Stream<bool> get onMuteChanged => _mute.stream; Stream<bool> get onMuteChanged => _mute.stream;
/// 字幕开启状态
Rx<bool> get subTitleOpen => _subTitleOpen;
Rx<int> get subTitleCode => _subTitleCode;
// Stream<bool> get onSubTitleOpenChanged => _subTitleOpen.stream;
/// [videoPlayerController] instace of Player /// [videoPlayerController] instace of Player
Player? get videoPlayerController => _videoPlayerController; Player? get videoPlayerController => _videoPlayerController;
@ -355,6 +364,8 @@ class PlPlayerController {
bool enableHeart = true, bool enableHeart = true,
// 是否首次加载 // 是否首次加载
bool isFirstTime = true, bool isFirstTime = true,
// 是否开启字幕
bool enableSubTitle = false,
}) async { }) async {
try { try {
_autoPlay = autoplay; _autoPlay = autoplay;
@ -369,7 +380,9 @@ class PlPlayerController {
_cid = cid; _cid = cid;
_enableHeart = enableHeart; _enableHeart = enableHeart;
_isFirstTime = isFirstTime; _isFirstTime = isFirstTime;
_subTitleOpen.value = enableSubTitle;
subtitles = [].obs;
subtitleContent.value = '';
if (_videoPlayerController != null && if (_videoPlayerController != null &&
_videoPlayerController!.state.playing) { _videoPlayerController!.state.playing) {
await pause(notify: false); await pause(notify: false);
@ -616,6 +629,10 @@ class PlPlayerController {
const Duration(seconds: 1), const Duration(seconds: 1),
() => videoPlayerServiceHandler.onPositionChange(event)); () => 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) { void querySubtitleContent(double progress) {
if (subtitleContents.isNotEmpty) { if (subTitleCode.value == -1) {
for (var content in subtitleContents) { subtitleContent.value = '';
if (progress >= content.from! && progress <= content.to!) { return;
subtitleContent.value = content.content!; }
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; return;
} }
} }
@ -1071,6 +1123,9 @@ class PlPlayerController {
} }
Future<void> dispose({String type = 'single'}) async { Future<void> dispose({String type = 'single'}) async {
print('dispose');
print('dispose: ${playerCount.value}');
// 每次减1最后销毁 // 每次减1最后销毁
if (type == 'single' && playerCount.value > 1) { if (type == 'single' && playerCount.value > 1) {
_playerCount.value -= 1; _playerCount.value -= 1;
@ -1080,6 +1135,7 @@ class PlPlayerController {
} }
_playerCount.value = 0; _playerCount.value = 0;
try { try {
print('dispose dispose ---------');
_timer?.cancel(); _timer?.cancel();
_timerForVolume?.cancel(); _timerForVolume?.cancel();
_timerForGettingVolume?.cancel(); _timerForGettingVolume?.cancel();

View File

@ -580,41 +580,44 @@ class _PLVideoPlayerState extends State<PLVideoPlayer>
if (widget.danmuWidget != null) if (widget.danmuWidget != null)
Positioned.fill(top: 4, child: widget.danmuWidget!), Positioned.fill(top: 4, child: widget.danmuWidget!),
widget.controller.subscriptions.isNotEmpty /// 开启且有字幕时展示
? Stack( Stack(
children: [ children: [
Positioned( Positioned(
left: 0, left: 0,
right: 0, right: 0,
bottom: 30, bottom: 30,
child: Align( child: Align(
alignment: Alignment.center, alignment: Alignment.center,
child: Obx( child: Obx(
() => Container( () => Visibility(
decoration: BoxDecoration( visible: widget.controller.subTitleCode.value != -1,
borderRadius: BorderRadius.circular(4), child: Container(
color: widget.controller.subtitleContent.value != '' decoration: BoxDecoration(
? Colors.black.withOpacity(0.4) borderRadius: BorderRadius.circular(4),
: Colors.transparent, color: widget.controller.subtitleContent.value != ''
), ? Colors.black.withOpacity(0.6)
padding: const EdgeInsets.symmetric( : Colors.transparent,
horizontal: 10, ),
vertical: 4, padding: widget.controller.subTitleCode.value != -1
), ? const EdgeInsets.symmetric(
child: Text( horizontal: 10,
widget.controller.subtitleContent.value, vertical: 4,
style: const TextStyle( )
color: Colors.white, : EdgeInsets.zero,
fontSize: 12, child: Text(
), widget.controller.subtitleContent.value,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
), ),
), ),
), )),
), ),
), ),
], ),
) ],
: const SizedBox(), ),
/// 手势 /// 手势
Positioned.fill( Positioned.fill(

32
lib/utils/subtitle.dart Normal file
View File

@ -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";
}
}