feat: 简单实现字幕功能
This commit is contained in:
@ -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<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};
|
||||
}
|
||||
}
|
||||
|
47
lib/models/common/subtitle_type.dart
Normal file
47
lib/models/common/subtitle_type.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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<String, dynamic> 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: [],
|
||||
);
|
||||
}
|
||||
|
@ -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<SubTitileContentModel> subtitleContents =
|
||||
<SubTitileContentModel>[].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<String, dynamic> 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<SubTitileContentModel>((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<SubTitileContentModel>((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 = [];
|
||||
}
|
||||
|
||||
/// 发送弹幕
|
||||
|
@ -212,6 +212,7 @@ class _VideoDetailPageState extends State<VideoDetailPage>
|
||||
videoIntroController.isPaused = true;
|
||||
plPlayerController!.removeStatusLister(playerListener);
|
||||
plPlayerController!.pause();
|
||||
vdCtr.clearSubtitleContent();
|
||||
}
|
||||
setState(() => isShowing = false);
|
||||
super.didPushNext();
|
||||
|
@ -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() {
|
||||
final double currentSpeed = widget.controller!.playbackSpeed;
|
||||
@ -1115,6 +1165,31 @@ class _HeaderControlState extends State<HeaderControl> {
|
||||
),
|
||||
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,
|
||||
|
@ -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<bool> _doubleSpeedStatus = false.obs;
|
||||
final Rx<bool> _controlsLock = 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;
|
||||
|
||||
@ -119,6 +122,7 @@ class PlPlayerController {
|
||||
PreferredSizeWidget? headerControl;
|
||||
PreferredSizeWidget? bottomControl;
|
||||
Widget? danmuWidget;
|
||||
late RxList subtitles;
|
||||
|
||||
/// 数据加载监听
|
||||
Stream<DataStatus> get onDataStatusChanged => dataStatus.status.stream;
|
||||
@ -148,6 +152,11 @@ class PlPlayerController {
|
||||
Rx<bool> get mute => _mute;
|
||||
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
|
||||
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<void> 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();
|
||||
|
@ -580,41 +580,44 @@ class _PLVideoPlayerState extends State<PLVideoPlayer>
|
||||
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(
|
||||
|
32
lib/utils/subtitle.dart
Normal file
32
lib/utils/subtitle.dart
Normal 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";
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user