feat: ai translate

Closes #1285

Signed-off-by: bggRGjQaUbCoE <githubaccount56556@proton.me>
This commit is contained in:
bggRGjQaUbCoE
2025-09-20 11:46:38 +08:00
parent 9b171e04be
commit 0745d83e4b
10 changed files with 155 additions and 20 deletions

View File

@@ -196,6 +196,7 @@ class VideoHttp {
dynamic seasonId,
required bool tryLook,
required VideoType videoType,
String? language,
}) async {
final params = await WbiSign.makSign({
'avid': ?avid,
@@ -214,6 +215,7 @@ class VideoHttp {
'web_location': 1315873,
// 免登录查看1080p
if (tryLook) 'try_look': 1,
'cur_language': ?language,
});
try {

View File

@@ -39,6 +39,8 @@ class PlayUrlModel {
List<FormatItem>? supportFormats;
int? lastPlayTime;
int? lastPlayCid;
String? curLanguage;
Language? language;
PlayUrlModel.fromJson(Map<String, dynamic> json) {
from = json['from'];
@@ -62,6 +64,51 @@ class PlayUrlModel {
.toList();
lastPlayTime = json['last_play_time'];
lastPlayCid = json['last_play_cid'];
curLanguage = json['cur_language'];
language = json['language'] == null
? null
: Language.fromJson(json['language']);
}
}
class Language {
Language({
this.support,
this.items,
});
bool? support;
List<LanguageItem>? items;
Language.fromJson(Map<String, dynamic> json) {
support = json['support'];
items = (json['items'] as List?)
?.map((e) => LanguageItem.fromJson(e))
.toList();
}
}
class LanguageItem {
LanguageItem({
this.lang,
this.title,
this.subtitleLang,
this.videoDetext,
this.videoMouthShapeChange,
});
String? lang;
String? title;
String? subtitleLang;
bool? videoDetext;
bool? videoMouthShapeChange;
LanguageItem.fromJson(Map<String, dynamic> json) {
lang = json['lang'];
title = json['title'];
subtitleLang = json['subtitle_lang'];
videoDetext = json['video_detext'];
videoMouthShapeChange = json['video_mouth_shape_change'];
}
}

View File

@@ -29,6 +29,7 @@ class BottomControl extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isFullScreen = plPlayerController.isFullScreen.value;
return AppBar(
backgroundColor: Colors.transparent,
foregroundColor: Colors.white,
@@ -40,6 +41,7 @@ class BottomControl extends StatelessWidget {
PlayOrPauseButton(plPlayerController: plPlayerController),
ComBtn(
height: 30,
tooltip: '刷新',
icon: const Icon(
Icons.refresh,
size: 18,
@@ -50,6 +52,7 @@ class BottomControl extends StatelessWidget {
const Spacer(),
ComBtn(
height: 30,
tooltip: '屏蔽',
icon: const Icon(
size: 18,
Icons.block,
@@ -74,6 +77,7 @@ class BottomControl extends StatelessWidget {
final enableShowLiveDanmaku =
plPlayerController.enableShowLiveDanmaku.value;
return ComBtn(
tooltip: "${enableShowLiveDanmaku ? '关闭' : '开启'}弹幕",
icon: enableShowLiveDanmaku
? const Icon(
size: 18,
@@ -100,6 +104,7 @@ class BottomControl extends StatelessWidget {
),
Obx(
() => PopupMenuButton<VideoFitType>(
tooltip: '画面比例',
initialValue: plPlayerController.videoFit.value,
color: Colors.black.withValues(alpha: 0.8),
itemBuilder: (context) {
@@ -132,6 +137,7 @@ class BottomControl extends StatelessWidget {
),
Obx(
() => PopupMenuButton<int>(
tooltip: '画质',
padding: EdgeInsets.zero,
initialValue: liveRoomCtr.currentQn,
color: Colors.black.withValues(alpha: 0.8),
@@ -165,22 +171,20 @@ class BottomControl extends StatelessWidget {
),
ComBtn(
height: 30,
icon: plPlayerController.isFullScreen.value
tooltip: isFullScreen ? '退出全屏' : '全屏',
icon: isFullScreen
? const Icon(
Icons.fullscreen_exit,
semanticLabel: '退出全屏',
size: 24,
color: Colors.white,
)
: const Icon(
Icons.fullscreen,
semanticLabel: '全屏',
size: 24,
color: Colors.white,
),
onTap: () => plPlayerController.triggerFullScreen(
status: !plPlayerController.isFullScreen.value,
),
onTap: () =>
plPlayerController.triggerFullScreen(status: !isFullScreen),
),
],
),

View File

@@ -72,11 +72,13 @@ class LiveHeaderControl extends StatelessWidget {
children: [
if (isFullScreen)
ComBtn(
tooltip: '返回',
icon: const Icon(FontAwesomeIcons.arrowLeft, size: 15),
onTap: () => plPlayerController.triggerFullScreen(status: false),
),
child,
ComBtn(
tooltip: '发弹幕',
icon: const Icon(
size: 18,
Icons.comment_outlined,
@@ -88,6 +90,7 @@ class LiveHeaderControl extends StatelessWidget {
() {
final onlyPlayAudio = plPlayerController.onlyPlayAudio.value;
return ComBtn(
tooltip: '仅播放音频',
onTap: () {
plPlayerController.onlyPlayAudio.value = !onlyPlayAudio;
onPlayAudio();
@@ -108,6 +111,7 @@ class LiveHeaderControl extends StatelessWidget {
),
if (Platform.isAndroid)
ComBtn(
tooltip: '画中画',
onTap: () async {
try {
var floating = Floating();
@@ -130,6 +134,7 @@ class LiveHeaderControl extends StatelessWidget {
),
),
ComBtn(
tooltip: '定时关闭',
onTap: () => PageUtils.scheduleExit(
context,
plPlayerController.isFullScreen.value,

View File

@@ -247,13 +247,15 @@ class VideoDetailController extends GetxController
imageStatus = false;
}
final isLoginVideo = Accounts.get(AccountType.video).isLogin;
@override
void onInit() {
super.onInit();
args = Get.arguments;
videoType = args['videoType'];
if (videoType == VideoType.pgc) {
if (!Accounts.get(AccountType.video).isLogin) {
if (!isLoginVideo) {
_actualVideoType = VideoType.ugc;
}
} else if (args['pgcApi'] == true) {
@@ -1112,6 +1114,17 @@ class VideoDetailController extends GetxController
bool isQuerying = false;
final Rx<List<LanguageItem>?> languages = Rx<List<LanguageItem>?>(null);
final Rx<String?> currLang = Rx(null);
void setLanguage(String language) {
if (!isLoginVideo) {
SmartDialog.showToast('账号未登录');
return;
}
currLang.value = language;
queryVideoUrl(defaultST: playedTime);
}
// 视频链接
Future<void> queryVideoUrl({
Duration? defaultST,
@@ -1142,11 +1155,15 @@ class VideoDetailController extends GetxController
seasonId: seasonId,
tryLook: plPlayerController.tryLook,
videoType: _actualVideoType ?? videoType,
language: currLang.value,
);
if (result.isSuccess) {
data = result.data;
languages.value = data.language?.items;
currLang.value = data.curLanguage;
if (data.acceptDesc?.contains('试看') == true) {
SmartDialog.showToast(
'该视频为专属视频,仅提供试看',
@@ -1564,6 +1581,10 @@ class VideoDetailController extends GetxController
videoUrl = null;
audioUrl = null;
// language
languages.value = null;
currLang.value = null;
if (scrollRatio.value != 0) {
scrollRatio.refresh();
}

View File

@@ -12,4 +12,5 @@ enum BottomControlType {
superResolution,
dmChart,
qa,
aiTranslate,
}

View File

@@ -256,9 +256,9 @@ class _PLVideoPlayerState extends State<PLVideoPlayer>
BottomControlType.pre => ComBtn(
width: widgetWidth,
height: 30,
tooltip: '上一集',
icon: const Icon(
Icons.skip_previous,
semanticLabel: '上一集',
size: 22,
color: Colors.white,
),
@@ -273,9 +273,9 @@ class _PLVideoPlayerState extends State<PLVideoPlayer>
BottomControlType.next => ComBtn(
width: widgetWidth,
height: 30,
tooltip: '下一集',
icon: const Icon(
Icons.skip_next,
semanticLabel: '下一集',
size: 22,
color: Colors.white,
),
@@ -329,6 +329,7 @@ class _PLVideoPlayerState extends State<PLVideoPlayer>
return ComBtn(
width: widgetWidth,
height: 30,
tooltip: '高能进度条',
icon: videoDetailController.showDmTreandChart.value
? const Icon(
Icons.show_chart,
@@ -399,11 +400,11 @@ class _PLVideoPlayerState extends State<PLVideoPlayer>
: ComBtn(
width: widgetWidth,
height: 30,
tooltip: '分段信息',
icon: Transform.rotate(
angle: math.pi / 2,
child: const Icon(
MdiIcons.viewHeadline,
semanticLabel: '分段信息',
size: 22,
color: Colors.white,
),
@@ -421,9 +422,9 @@ class _PLVideoPlayerState extends State<PLVideoPlayer>
BottomControlType.episode => ComBtn(
width: widgetWidth,
height: 30,
tooltip: '选集',
icon: const Icon(
Icons.list,
semanticLabel: '选集',
size: 22,
color: Colors.white,
),
@@ -501,12 +502,58 @@ class _PLVideoPlayerState extends State<PLVideoPlayer>
),
),
BottomControlType.aiTranslate => Obx(
() {
final list = videoDetailController.languages.value;
if (list != null && list.isNotEmpty) {
return PopupMenuButton<String>(
tooltip: '原声翻译',
requestFocus: false,
initialValue: videoDetailController.currLang.value,
color: Colors.black.withValues(alpha: 0.8),
itemBuilder: (context) {
return [
PopupMenuItem<String>(
value: '',
onTap: () => videoDetailController.setLanguage(''),
child: const Text(
"关闭翻译",
style: TextStyle(color: Colors.white),
),
),
...list.map((e) {
return PopupMenuItem<String>(
value: e.lang,
onTap: () => videoDetailController.setLanguage(e.lang!),
child: Text(
e.title!,
style: const TextStyle(color: Colors.white),
),
);
}),
];
},
child: SizedBox(
width: widgetWidth,
height: 30,
child: const Icon(
Icons.translate,
size: 18,
color: Colors.white,
),
),
);
}
return const SizedBox.shrink();
},
),
/// 字幕
BottomControlType.subtitle => Obx(
() => videoDetailController.subtitles.isEmpty == true
? const SizedBox.shrink()
: PopupMenuButton<int>(
tooltip: '选择字幕',
tooltip: '字幕',
requestFocus: false,
initialValue: videoDetailController.vttSubtitlesIndex.value
.clamp(
@@ -676,16 +723,15 @@ class _PLVideoPlayerState extends State<PLVideoPlayer>
BottomControlType.fullscreen => ComBtn(
width: widgetWidth,
height: 30,
tooltip: isFullScreen ? '退出全屏' : '全屏',
icon: isFullScreen
? const Icon(
Icons.fullscreen_exit,
semanticLabel: '退出全屏',
size: 24,
color: Colors.white,
)
: const Icon(
Icons.fullscreen,
semanticLabel: '全屏',
size: 24,
color: Colors.white,
),
@@ -709,6 +755,7 @@ class _PLVideoPlayerState extends State<PLVideoPlayer>
if (plPlayerController.showViewPoints) BottomControlType.viewPoints,
if (anySeason) BottomControlType.episode,
if (isFullScreen) BottomControlType.fit,
BottomControlType.aiTranslate,
BottomControlType.subtitle,
BottomControlType.speed,
if (isFullScreen) BottomControlType.qa,
@@ -1251,6 +1298,8 @@ class _PLVideoPlayerState extends State<PLVideoPlayer>
// 头部、底部控制条
Positioned.fill(
top: -1,
bottom: -1,
child: ClipRect(
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -1489,16 +1538,15 @@ class _PLVideoPlayerState extends State<PLVideoPlayer>
final controlsLock =
plPlayerController.controlsLock.value;
return ComBtn(
tooltip: controlsLock ? '解锁' : '锁定',
icon: controlsLock
? const Icon(
FontAwesomeIcons.lock,
semanticLabel: '解锁',
size: 15,
color: Colors.white,
)
: const Icon(
FontAwesomeIcons.lockOpen,
semanticLabel: '锁定',
size: 15,
color: Colors.white,
),
@@ -1530,9 +1578,9 @@ class _PLVideoPlayerState extends State<PLVideoPlayer>
borderRadius: BorderRadius.all(Radius.circular(8)),
),
child: ComBtn(
tooltip: '截图',
icon: const Icon(
Icons.photo_camera,
semanticLabel: '截图',
size: 20,
color: Colors.white,
),

View File

@@ -36,7 +36,7 @@ class AppBarAni extends StatelessWidget {
end: Alignment.topCenter,
colors: <Color>[
Colors.transparent,
Colors.black54,
Color(0xBF000000),
],
tileMode: TileMode.mirror,
)
@@ -45,7 +45,7 @@ class AppBarAni extends StatelessWidget {
end: Alignment.bottomCenter,
colors: <Color>[
Colors.transparent,
Colors.black54,
Color(0xBF000000),
],
tileMode: TileMode.mirror,
),

View File

@@ -6,6 +6,7 @@ class ComBtn extends StatelessWidget {
final VoidCallback? onLongPress;
final double width;
final double height;
final String? tooltip;
const ComBtn({
super.key,
@@ -14,11 +15,12 @@ class ComBtn extends StatelessWidget {
this.onLongPress,
this.width = 34,
this.height = 34,
this.tooltip,
});
@override
Widget build(BuildContext context) {
return SizedBox(
final child = SizedBox(
width: width,
height: height,
child: GestureDetector(
@@ -28,5 +30,9 @@ class ComBtn extends StatelessWidget {
child: icon,
),
);
if (tooltip != null) {
return Tooltip(message: tooltip, child: child);
}
return child;
}
}