Files
pilipala/lib/pages/video/detail/controller.dart
2024-12-08 23:52:53 +08:00

684 lines
22 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
import 'dart:io';
import 'package:floating/floating.dart';
import 'package:flutter/material.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
import 'package:hive/hive.dart';
import 'package:ns_danmaku/ns_danmaku.dart';
import 'package:pilipala/http/constants.dart';
import 'package:pilipala/http/user.dart';
import 'package:pilipala/http/video.dart';
import 'package:pilipala/models/common/reply_type.dart';
import 'package:pilipala/models/common/search_type.dart';
import 'package:pilipala/models/video/later.dart';
import 'package:pilipala/models/video/play/quality.dart';
import 'package:pilipala/models/video/play/url.dart';
import 'package:pilipala/models/video/reply/item.dart';
import 'package:pilipala/pages/video/detail/reply_reply/index.dart';
import 'package:pilipala/plugin/pl_player/index.dart';
import 'package:pilipala/utils/storage.dart';
import 'package:pilipala/utils/utils.dart';
import 'package:pilipala/utils/video_utils.dart';
import 'package:screen_brightness/screen_brightness.dart';
import '../../../models/video/subTitile/content.dart';
import '../../../http/danmaku.dart';
import '../../../plugin/pl_player/models/bottom_control_type.dart';
import '../../../utils/id_utils.dart';
import 'introduction/controller.dart';
import 'reply/controller.dart';
import 'widgets/header_control.dart';
import 'widgets/watch_later_list.dart';
class VideoDetailController extends GetxController
with GetSingleTickerProviderStateMixin {
/// 路由传参
String bvid = Get.parameters['bvid']!;
RxInt cid = int.parse(Get.parameters['cid']!).obs;
RxInt danmakuCid = 0.obs;
String heroTag = Get.arguments['heroTag'];
// 视频详情
Map videoItem = {};
// 视频类型 默认投稿视频
SearchType videoType = Get.arguments['videoType'] ?? SearchType.video;
// 页面来源 稍后再看 收藏夹
RxString sourceType = 'normal'.obs;
/// tabs相关配置
late TabController tabCtr;
RxList<String> tabs = <String>['简介', '评论'].obs;
// 请求返回的视频信息
late PlayUrlModel data;
// 请求状态
RxBool isLoading = false.obs;
/// 播放器配置 画质 音质 解码格式
late VideoQuality currentVideoQa;
AudioQuality? currentAudioQa;
VideoDecodeFormats? currentDecodeFormats;
// 是否开始自动播放 存在多p的情况下第二p需要为true
RxBool autoPlay = true.obs;
// 视频资源是否有效
RxBool isEffective = true.obs;
// 封面图的展示
RxBool isShowCover = true.obs;
// 硬解
RxBool enableHA = false.obs;
/// 本地存储
Box userInfoCache = GStrorage.userInfo;
Box localCache = GStrorage.localCache;
Box setting = GStrorage.setting;
RxInt oid = 0.obs;
// 评论id 请求楼中楼评论使用
int fRpid = 0;
ReplyItemModel? firstFloor;
final scaffoldKey = GlobalKey<ScaffoldState>();
RxString bgCover = ''.obs;
RxString cover = ''.obs;
PlPlayerController plPlayerController = PlPlayerController();
late VideoItem firstVideo;
late AudioItem firstAudio;
late String videoUrl;
late String audioUrl;
late Duration defaultST;
// 亮度
double? brightness;
// 默认记录历史记录
bool enableHeart = true;
var userInfo;
late bool isFirstTime = true;
Floating? floating;
late PreferredSizeWidget headerControl;
late bool enableCDN;
late int? cacheVideoQa;
late String cacheDecode;
late int defaultAudioQa;
PersistentBottomSheetController? replyReplyBottomSheetCtr;
RxList<SubTitileContentModel> subtitleContents =
<SubTitileContentModel>[].obs;
late bool enableRelatedVideo;
List subtitles = [];
RxList<BottomControlType> bottomList = [
BottomControlType.playOrPause,
BottomControlType.time,
BottomControlType.space,
BottomControlType.fit,
BottomControlType.fullscreen,
].obs;
RxDouble sheetHeight = 0.0.obs;
RxString archiveSourceType = 'dash'.obs;
ScrollController? replyScrollController;
List<MediaVideoItemModel> mediaList = <MediaVideoItemModel>[];
RxBool isWatchLaterVisible = false.obs;
RxString watchLaterTitle = ''.obs;
@override
void onInit() {
super.onInit();
final Map argMap = Get.arguments;
userInfo = userInfoCache.get('userInfoCache');
if (argMap.containsKey('videoItem')) {
var args = argMap['videoItem'];
updateCover(args.pic);
} else if (argMap.containsKey('pic')) {
updateCover(argMap['pic']);
}
tabCtr = TabController(length: 2, vsync: this);
autoPlay.value =
setting.get(SettingBoxKey.autoPlayEnable, defaultValue: true);
enableHA.value = setting.get(SettingBoxKey.enableHA, defaultValue: false);
enableRelatedVideo =
setting.get(SettingBoxKey.enableRelatedVideo, defaultValue: true);
if (userInfo == null ||
localCache.get(LocalCacheKey.historyPause) == true) {
enableHeart = false;
}
danmakuCid.value = cid.value;
///
if (Platform.isAndroid) {
floating = Floating();
}
// CDN优化
enableCDN = setting.get(SettingBoxKey.enableCDN, defaultValue: true);
// 预设的画质
cacheVideoQa = setting.get(SettingBoxKey.defaultVideoQa);
// 预设的解码格式
cacheDecode = setting.get(SettingBoxKey.defaultDecode,
defaultValue: VideoDecodeFormats.values.last.code);
defaultAudioQa = setting.get(SettingBoxKey.defaultAudioQa,
defaultValue: AudioQuality.hiRes.code);
oid.value = IdUtils.bv2av(Get.parameters['bvid']!);
getSubtitle();
headerControl = HeaderControl(
controller: plPlayerController,
videoDetailCtr: this,
floating: floating,
bvid: bvid,
videoType: videoType,
);
sourceType.value = argMap['sourceType'] ?? 'normal';
isWatchLaterVisible.value =
sourceType.value == 'watchLater' || sourceType.value == 'fav';
if (sourceType.value == 'watchLater') {
watchLaterTitle.value = '稍后再看';
fetchMediaList();
}
if (sourceType.value == 'fav') {
watchLaterTitle.value = argMap['favTitle'];
queryFavVideoList();
}
tabCtr.addListener(() {
onTabChanged();
});
}
showReplyReplyPanel(oid, fRpid, firstFloor, currentReply, loadMore) {
replyReplyBottomSheetCtr =
scaffoldKey.currentState?.showBottomSheet((BuildContext context) {
return VideoReplyReplyPanel(
oid: oid,
rpid: fRpid,
closePanel: () => {
fRpid = 0,
},
firstFloor: firstFloor,
replyType: ReplyType.video,
source: 'videoDetail',
sheetHeight: sheetHeight.value,
currentReply: currentReply,
loadMore: loadMore,
);
});
replyReplyBottomSheetCtr?.closed.then((value) {
fRpid = 0;
});
}
/// 更新画质、音质
/// TODO 继续进度播放
updatePlayer() {
defaultST = plPlayerController.position.value;
plPlayerController.removeListeners();
plPlayerController.isBuffering.value = false;
plPlayerController.buffered.value = Duration.zero;
if (archiveSourceType.value == 'dash') {
/// 根据currentVideoQa和currentDecodeFormats 重新设置videoUrl
List<VideoItem> videoList =
data.dash!.video!.where((i) => i.id == currentVideoQa.code).toList();
try {
firstVideo = videoList.firstWhere(
(i) => i.codecs!.startsWith(currentDecodeFormats?.code));
} catch (_) {
if (currentVideoQa == VideoQuality.dolbyVision) {
firstVideo = videoList.first;
currentDecodeFormats =
VideoDecodeFormatsCode.fromString(videoList.first.codecs!)!;
} else {
// 当前格式不可用
currentDecodeFormats = VideoDecodeFormatsCode.fromString(setting.get(
SettingBoxKey.defaultDecode,
defaultValue: VideoDecodeFormats.values.last.code))!;
firstVideo = videoList.firstWhere(
(i) => i.codecs!.startsWith(currentDecodeFormats?.code));
}
}
videoUrl = firstVideo.baseUrl!;
/// 根据currentAudioQa 重新设置audioUrl
if (currentAudioQa != null) {
final AudioItem firstAudio = data.dash!.audio!.firstWhere(
(AudioItem i) => i.id == currentAudioQa!.code,
orElse: () => data.dash!.audio!.first,
);
audioUrl = firstAudio.baseUrl ?? '';
}
}
if (archiveSourceType.value == 'durl') {
cacheVideoQa = VideoQualityCode.toCode(currentVideoQa);
queryVideoUrl();
}
playerInit();
}
Future playerInit({
video,
audio,
seekToTime,
duration,
bool? autoplay,
}) async {
/// 设置/恢复 屏幕亮度
if (brightness != null) {
ScreenBrightness().setScreenBrightness(brightness!);
} else {
ScreenBrightness().resetScreenBrightness();
}
await plPlayerController.setDataSource(
DataSource(
videoSource: video ?? videoUrl,
audioSource: audio ?? audioUrl,
type: DataSourceType.network,
httpHeaders: {
'user-agent':
'Mozilla/5.0 (Macintosh; Intel Mac OS X 13_3_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.4 Safari/605.1.15',
'referer': HttpString.baseUrl
},
),
// 硬解
enableHA: enableHA.value,
seekTo: seekToTime ?? defaultST,
duration: duration ?? Duration(milliseconds: data.timeLength ?? 0),
// 宽>高 水平 否则 垂直
direction: firstVideo.width != null && firstVideo.height != null
? ((firstVideo.width! - firstVideo.height!) > 0
? 'horizontal'
: 'vertical')
: null,
bvid: bvid,
cid: cid.value,
enableHeart: enableHeart,
isFirstTime: isFirstTime,
autoplay: autoplay ?? autoPlay.value,
);
/// 开启自动全屏时在player初始化完成后立即传入headerControl
plPlayerController.headerControl = headerControl;
plPlayerController.subtitles.value = subtitles;
}
// 视频链接
Future queryVideoUrl() async {
var result =
await VideoHttp.videoUrl(cid: cid.value, bvid: bvid, qn: cacheVideoQa);
if (result['status']) {
data = result['data'];
if (data.acceptDesc!.isNotEmpty && data.acceptDesc!.contains('试看')) {
SmartDialog.showToast(
'该视频为专属视频,仅提供试看',
displayTime: const Duration(seconds: 3),
);
videoUrl = data.durl!.first.url!;
audioUrl = '';
defaultST = Duration.zero;
firstVideo = VideoItem();
if (autoPlay.value) {
await playerInit();
isShowCover.value = false;
}
return result;
}
if (data.durl != null) {
archiveSourceType.value = 'durl';
videoUrl = data.durl!.first.url!;
audioUrl = '';
defaultST = Duration.zero;
firstVideo = VideoItem();
currentVideoQa = VideoQualityCode.fromCode(data.quality!)!;
if (autoPlay.value) {
await playerInit();
isShowCover.value = false;
}
return result;
}
final List<VideoItem> allVideosList = data.dash!.video!;
try {
archiveSourceType.value = 'dash';
// 当前可播放的最高质量视频
int currentHighVideoQa = allVideosList.first.quality!.code;
// 预设的画质为null则当前可用的最高质量
cacheVideoQa ??= currentHighVideoQa;
int resVideoQa = currentHighVideoQa;
if (cacheVideoQa! <= currentHighVideoQa) {
// 如果预设的画质低于当前最高
final List<int> numbers = data.acceptQuality!
.where((e) => e <= currentHighVideoQa)
.toList();
resVideoQa = Utils.findClosestNumber(cacheVideoQa!, numbers);
}
currentVideoQa = VideoQualityCode.fromCode(resVideoQa)!;
/// 取出符合当前画质的videoList
final List<VideoItem> videosList =
allVideosList.where((e) => e.quality!.code == resVideoQa).toList();
/// 优先顺序 设置中指定解码格式 -> 当前可选的首个解码格式
final List<FormatItem> supportFormats = data.supportFormats!;
// 根据画质选编码格式
final List supportDecodeFormats =
supportFormats.firstWhere((e) => e.quality == resVideoQa).codecs!;
// 默认从设置中取AVC
currentDecodeFormats = VideoDecodeFormatsCode.fromString(cacheDecode)!;
try {
// 当前视频没有对应格式返回第一个
bool flag = false;
for (var i in supportDecodeFormats) {
if (i.startsWith(currentDecodeFormats?.code)) {
flag = true;
}
}
currentDecodeFormats = flag
? currentDecodeFormats
: VideoDecodeFormatsCode.fromString(supportDecodeFormats.first)!;
} catch (err) {
SmartDialog.showToast('DecodeFormats error: $err');
}
/// 取出符合当前解码格式的videoItem
try {
firstVideo = videosList.firstWhere(
(e) => e.codecs!.startsWith(currentDecodeFormats?.code));
} catch (_) {
firstVideo = videosList.first;
}
videoUrl = enableCDN
? VideoUtils.getCdnUrl(firstVideo)
: (firstVideo.backupUrl ?? firstVideo.baseUrl!);
} catch (err) {
SmartDialog.showToast('firstVideo error: $err');
}
/// 优先顺序 设置中指定质量 -> 当前可选的最高质量
late AudioItem? firstAudio;
final List<AudioItem> audiosList = data.dash!.audio!;
try {
if (data.dash!.dolby?.audio?.isNotEmpty == true) {
// 杜比
audiosList.insert(0, data.dash!.dolby!.audio!.first);
}
if (data.dash!.flac?.audio != null) {
// 无损
audiosList.insert(0, data.dash!.flac!.audio!);
}
if (audiosList.isNotEmpty) {
final List<int> numbers = audiosList.map((map) => map.id!).toList();
int closestNumber = Utils.findClosestNumber(defaultAudioQa, numbers);
if (!numbers.contains(defaultAudioQa) &&
numbers.any((e) => e > defaultAudioQa)) {
closestNumber = 30280;
}
firstAudio = audiosList.firstWhere((e) => e.id == closestNumber);
} else {
firstAudio = AudioItem();
}
} catch (err) {
firstAudio = audiosList.isNotEmpty ? audiosList.first : AudioItem();
SmartDialog.showToast('firstAudio error: $err');
}
audioUrl = enableCDN
? VideoUtils.getCdnUrl(firstAudio)
: (firstAudio.backupUrl ?? firstAudio.baseUrl!);
//
if (firstAudio.id != null) {
currentAudioQa = AudioQualityCode.fromCode(firstAudio.id!)!;
}
defaultST = Duration(milliseconds: data.lastPlayTime!);
if (autoPlay.value) {
await playerInit();
isShowCover.value = false;
}
} else {
if (result['code'] == -404) {
isShowCover.value = false;
}
SmartDialog.showToast(result['msg'].toString());
}
return result;
}
// mob端全屏状态关闭二级回复
hiddenReplyReplyPanel() {
replyReplyBottomSheetCtr != null
? 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) {
subtitles = result['data'].subtitles;
getDanmaku(subtitles);
}
}
}
// 获取弹幕
Future getDanmaku(List subtitles) async {
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'];
}
}
}
setSubtitleContent() {
plPlayerController.subtitleContent.value = '';
plPlayerController.subtitles.value = subtitles;
}
clearSubtitleContent() {
plPlayerController.subtitleContent.value = '';
plPlayerController.subtitles.value = [];
}
/// 发送弹幕
void showShootDanmakuSheet() {
final TextEditingController textController = TextEditingController();
bool isSending = false; // 追踪是否正在发送
showDialog(
context: Get.context!,
builder: (BuildContext context) {
// TODO: 支持更多类型和颜色的弹幕
return AlertDialog(
title: const Text('发送弹幕'),
content: StatefulBuilder(
builder: (BuildContext context, StateSetter setState) {
return TextField(
controller: textController,
);
}),
actions: [
TextButton(
onPressed: () => Get.back(),
child: Text(
'取消',
style: TextStyle(color: Theme.of(context).colorScheme.outline),
),
),
StatefulBuilder(
builder: (BuildContext context, StateSetter setState) {
return TextButton(
onPressed: isSending
? null
: () async {
final String msg = textController.text;
if (msg.isEmpty) {
SmartDialog.showToast('弹幕内容不能为空');
return;
} else if (msg.length > 100) {
SmartDialog.showToast('弹幕内容不能超过100个字符');
return;
}
setState(() {
isSending = true; // 开始发送,更新状态
});
//修改按钮文字
// SmartDialog.showToast('弹幕发送中,\n$msg');
final dynamic res = await DanmakaHttp.shootDanmaku(
oid: cid.value,
msg: textController.text,
bvid: bvid,
progress:
plPlayerController.position.value.inMilliseconds,
type: 1,
);
setState(() {
isSending = false; // 发送结束,更新状态
});
if (res['status']) {
SmartDialog.showToast('发送成功');
// 发送成功,自动预览该弹幕,避免重新请求
// TODO: 暂停状态下预览弹幕仍会移动与计时可考虑添加到dmSegList或其他方式实现
plPlayerController.danmakuController?.addItems([
DanmakuItem(
msg,
color: Colors.white,
time: plPlayerController
.position.value.inMilliseconds,
type: DanmakuItemType.scroll,
isSend: true,
)
]);
Get.back();
} else {
SmartDialog.showToast('发送失败,错误信息为${res['msg']}');
}
},
child: Text(isSending ? '发送中...' : '发送'),
);
})
],
);
},
);
}
void updateCover(String? pic) {
if (pic != null) {
cover.value = videoItem['pic'] = pic;
}
}
void onControllerCreated(ScrollController controller) {
replyScrollController = controller;
}
void onTapTabbar(int index) {
if (tabCtr.animation!.isCompleted && index == 1 && tabCtr.index == 1) {
replyScrollController?.animateTo(0,
duration: const Duration(milliseconds: 300), curve: Curves.ease);
}
}
void toggeleWatchLaterVisible(bool val) {
if (sourceType.value == 'watchLater' || sourceType.value == 'fav') {
isWatchLaterVisible.value = !isWatchLaterVisible.value;
}
}
// 获取稍后再看列表
Future fetchMediaList() async {
final Map argMap = Get.arguments;
var count = argMap['count'];
var res = await UserHttp.getMediaList(
type: 2,
bizId: userInfo.mid,
ps: count,
);
if (res['status']) {
mediaList = res['data'].reversed.toList();
} else {
SmartDialog.showToast(res['msg']);
}
}
// 稍后再看面板展开
showMediaListPanel() {
replyReplyBottomSheetCtr =
scaffoldKey.currentState?.showBottomSheet((BuildContext context) {
return MediaListPanel(
sheetHeight: sheetHeight.value,
mediaList: mediaList,
changeMediaList: changeMediaList,
panelTitle: watchLaterTitle.value,
bvid: bvid,
mediaId: Get.arguments['mediaId'],
hasMore: mediaList.length != Get.arguments['count'],
);
});
replyReplyBottomSheetCtr?.closed.then((value) {
isWatchLaterVisible.value = true;
});
}
// 切换稍后再看
Future changeMediaList(bvidVal, cidVal, aidVal, coverVal) async {
final VideoIntroController videoIntroCtr =
Get.find<VideoIntroController>(tag: heroTag);
bvid = bvidVal;
oid.value = aidVal ?? IdUtils.bv2av(bvid);
cid.value = cidVal;
danmakuCid.value = cidVal;
cover.value = coverVal;
queryVideoUrl();
clearSubtitleContent();
await getSubtitle();
setSubtitleContent();
// 重新请求评论
try {
/// 未渲染回复组件时可能异常
final VideoReplyController videoReplyCtr =
Get.find<VideoReplyController>(tag: heroTag);
videoReplyCtr.aid = aidVal;
videoReplyCtr.queryReplyList(type: 'init');
} catch (_) {}
videoIntroCtr.lastPlayCid.value = cidVal;
videoIntroCtr.bvid = bvidVal;
replyReplyBottomSheetCtr!.close();
await videoIntroCtr.queryVideoIntro();
}
// 获取收藏夹视频列表
Future queryFavVideoList() async {
final Map argMap = Get.arguments;
var mediaId = argMap['mediaId'];
var oid = argMap['oid'];
var res = await UserHttp.parseFavVideo(
mediaId: mediaId,
oid: oid,
bvid: bvid,
);
if (res['status']) {
mediaList = res['data'];
}
}
// 监听tabBarView切换
void onTabChanged() {
isWatchLaterVisible.value = tabCtr.index == 0;
}
@override
void onClose() {
super.onClose();
plPlayerController.dispose();
tabCtr.removeListener(() {
onTabChanged();
});
}
}