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 tabs = ['简介', '评论'].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 = GStorage.userInfo; Box localCache = GStorage.localCache; Box setting = GStorage.setting; RxInt oid = 0.obs; // 评论id 请求楼中楼评论使用 int fRpid = 0; ReplyItemModel? firstFloor; final scaffoldKey = GlobalKey(); 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 subtitleContents = [].obs; late bool enableRelatedVideo; List subtitles = []; RxList bottomList = [ BottomControlType.playOrPause, BottomControlType.time, BottomControlType.space, BottomControlType.fit, BottomControlType.fullscreen, ].obs; RxDouble sheetHeight = 0.0.obs; RxString archiveSourceType = 'dash'.obs; ScrollController? replyScrollController; List mediaList = []; 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 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 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 numbers = data.acceptQuality! .where((e) => e <= currentHighVideoQa) .toList(); resVideoQa = Utils.findClosestNumber(cacheVideoQa!, numbers); } currentVideoQa = VideoQualityCode.fromCode(resVideoQa)!; /// 取出符合当前画质的videoList final List videosList = allVideosList.where((e) => e.quality!.code == resVideoQa).toList(); /// 优先顺序 设置中指定解码格式 -> 当前可选的首个解码格式 final List 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 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 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 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(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(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(); tabCtr.removeListener(() { onTabChanged(); }); } }