import 'dart:async'; import 'dart:math' show min; import 'dart:ui'; import 'package:PiliPlus/common/constants.dart'; import 'package:PiliPlus/common/widgets/pair.dart'; import 'package:PiliPlus/common/widgets/progress_bar/segment_progress_bar.dart'; import 'package:PiliPlus/grpc/bilibili/app/listener/v1.pbenum.dart' show PlaylistSource; import 'package:PiliPlus/http/constants.dart'; import 'package:PiliPlus/http/fav.dart'; import 'package:PiliPlus/http/init.dart'; import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/http/ua_type.dart'; import 'package:PiliPlus/http/user.dart'; import 'package:PiliPlus/http/video.dart'; import 'package:PiliPlus/main.dart'; import 'package:PiliPlus/models/common/account_type.dart'; import 'package:PiliPlus/models/common/sponsor_block/action_type.dart'; import 'package:PiliPlus/models/common/sponsor_block/post_segment_model.dart'; import 'package:PiliPlus/models/common/sponsor_block/segment_model.dart'; import 'package:PiliPlus/models/common/sponsor_block/segment_type.dart'; import 'package:PiliPlus/models/common/video/audio_quality.dart'; import 'package:PiliPlus/models/common/video/source_type.dart'; import 'package:PiliPlus/models/common/video/subtitle_pref_type.dart'; import 'package:PiliPlus/models/common/video/video_decode_type.dart'; import 'package:PiliPlus/models/common/video/video_quality.dart'; import 'package:PiliPlus/models/common/video/video_type.dart'; import 'package:PiliPlus/models/video/play/url.dart'; import 'package:PiliPlus/models_new/download/bili_download_entry_info.dart'; import 'package:PiliPlus/models_new/media_list/media_list.dart'; import 'package:PiliPlus/models_new/pgc/pgc_info_model/result.dart'; import 'package:PiliPlus/models_new/video/video_detail/data.dart'; import 'package:PiliPlus/models_new/video/video_detail/episode.dart' as ugc; import 'package:PiliPlus/models_new/video/video_detail/page.dart'; import 'package:PiliPlus/models_new/video/video_pbp/data.dart'; import 'package:PiliPlus/models_new/video/video_play_info/subtitle.dart'; import 'package:PiliPlus/models_new/video/video_stein_edgeinfo/data.dart'; import 'package:PiliPlus/pages/audio/view.dart'; import 'package:PiliPlus/pages/common/publish/publish_route.dart'; import 'package:PiliPlus/pages/search/widgets/search_text.dart'; import 'package:PiliPlus/pages/sponsor_block/block_mixin.dart'; import 'package:PiliPlus/pages/video/download_panel/view.dart'; import 'package:PiliPlus/pages/video/introduction/pgc/controller.dart'; import 'package:PiliPlus/pages/video/introduction/ugc/controller.dart'; import 'package:PiliPlus/pages/video/medialist/view.dart'; import 'package:PiliPlus/pages/video/note/view.dart'; import 'package:PiliPlus/pages/video/post_panel/view.dart'; import 'package:PiliPlus/pages/video/send_danmaku/view.dart'; import 'package:PiliPlus/pages/video/widgets/header_control.dart'; import 'package:PiliPlus/plugin/pl_player/controller.dart'; import 'package:PiliPlus/plugin/pl_player/models/data_source.dart'; import 'package:PiliPlus/plugin/pl_player/models/heart_beat_type.dart'; import 'package:PiliPlus/plugin/pl_player/models/play_status.dart'; import 'package:PiliPlus/services/download/download_service.dart'; import 'package:PiliPlus/utils/accounts.dart'; import 'package:PiliPlus/utils/extension/context_ext.dart'; import 'package:PiliPlus/utils/extension/iterable_ext.dart'; import 'package:PiliPlus/utils/extension/num_ext.dart'; import 'package:PiliPlus/utils/extension/size_ext.dart'; import 'package:PiliPlus/utils/page_utils.dart'; import 'package:PiliPlus/utils/platform_utils.dart'; import 'package:PiliPlus/utils/storage.dart'; import 'package:PiliPlus/utils/storage_pref.dart'; import 'package:PiliPlus/utils/utils.dart'; import 'package:PiliPlus/utils/video_utils.dart'; import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart'; import 'package:flutter/foundation.dart' show kDebugMode; import 'package:flutter/material.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:flutter_volume_controller/flutter_volume_controller.dart'; import 'package:get/get.dart'; import 'package:hive/hive.dart'; import 'package:media_kit/media_kit.dart'; class VideoDetailController extends GetxController with GetTickerProviderStateMixin, BlockMixin { /// 路由传参 late final Map args; late String bvid; late int aid; late final RxInt cid; int? epId; int? seasonId; int? pgcType; late final String heroTag; late final RxString cover; // 视频类型 默认投稿视频 late final VideoType videoType; @override late final isUgc = videoType == VideoType.ugc; VideoType? _actualVideoType; // 页面来源 稍后再看 收藏夹 late bool isPlayAll; late SourceType sourceType; late BiliDownloadEntryInfo entry; late bool isFileSource; late bool _mediaDesc = false; late final RxList mediaList = [].obs; late String watchLaterTitle; /// tabs相关配置 late TabController tabCtr; // 请求返回的视频信息 late PlayUrlModel data; final Rx videoState = LoadingState.loading().obs; /// 播放器配置 画质 音质 解码格式 final Rxn currentVideoQa = Rxn(); AudioQuality? currentAudioQa; late VideoDecodeFormatType currentDecodeFormats; // 是否开始自动播放 存在多p的情况下,第二p需要为true final RxBool _autoPlay = Pref.autoPlayEnable.obs; final videoPlayerKey = GlobalKey(); final childKey = GlobalKey(); final plPlayerController = PlPlayerController.getInstance() ..brightness.value = -1; bool get setSystemBrightness => plPlayerController.setSystemBrightness; late VideoItem firstVideo; String? videoUrl; String? audioUrl; Duration? defaultST; Duration? playedTime; String get playedTimePos { final pos = playedTime?.inMilliseconds; return pos == null || pos == 0 ? '' : '?t=${pos / 1000}'; } // 亮度 double? brightness; late final headerCtrKey = GlobalKey(); Box setting = GStorage.setting; // 预设的解码格式 late String cacheDecode = Pref.defaultDecode; // def avc late String cacheSecondDecode = Pref.secondDecode; // def av1 bool get showReply => isFileSource ? false : isUgc ? plPlayerController.showVideoReply : plPlayerController.showBangumiReply; bool get showRelatedVideo => isFileSource ? false : plPlayerController.showRelatedVideo; ScrollController? introScrollCtr; ScrollController get effectiveIntroScrollCtr => introScrollCtr ??= ScrollController(); int? seasonCid; late final RxInt seasonIndex = 0.obs; PlayerStatus? playerStatus; late final scrollKey = GlobalKey(); late final RxBool isVertical = false.obs; late final RxDouble scrollRatio = 0.0.obs; ScrollController? _scrollCtr; ScrollController get scrollCtr => _scrollCtr ??= ScrollController()..addListener(scrollListener); late bool isExpanding = false; late bool isCollapsing = false; AnimationController? animController; AnimationController get animationController => animController ??= AnimationController( vsync: this, duration: const Duration(milliseconds: 200), ); late double minVideoHeight; late double maxVideoHeight; late double videoHeight; void animToTop() { final outerController = scrollKey.currentState!.outerController; if (outerController.hasClients) { outerController.animateTo( outerController.offset, duration: const Duration(milliseconds: 500), curve: Curves.easeInOut, ); } } @pragma('vm:notify-debugger-on-exception') void setVideoHeight() { try { final isVertical = firstVideo.width != null && firstVideo.height != null ? firstVideo.width! < firstVideo.height! : false; if (!scrollCtr.hasClients) { videoHeight = isVertical ? maxVideoHeight : minVideoHeight; this.isVertical.value = isVertical; return; } if (this.isVertical.value != isVertical) { this.isVertical.value = isVertical; double videoHeight = isVertical ? maxVideoHeight : minVideoHeight; if (this.videoHeight != videoHeight) { if (videoHeight > this.videoHeight) { // current minVideoHeight isExpanding = true; animationController.forward( from: (minVideoHeight - scrollCtr.offset) / maxVideoHeight, ); this.videoHeight = maxVideoHeight; } else { // current maxVideoHeight final currentHeight = (maxVideoHeight - scrollCtr.offset) .toPrecision(2); double minVideoHeightPrecise = minVideoHeight.toPrecision(2); if (currentHeight == minVideoHeightPrecise) { isExpanding = true; this.videoHeight = minVideoHeight; animationController.forward(from: 1); } else if (currentHeight < minVideoHeightPrecise) { // expand isExpanding = true; animationController.forward(from: currentHeight / minVideoHeight); this.videoHeight = minVideoHeight; } else { // collapse isCollapsing = true; animationController.forward( from: scrollCtr.offset / (maxVideoHeight - minVideoHeight), ); this.videoHeight = minVideoHeight; } } } } else { if (scrollCtr.offset != 0) { isExpanding = true; animationController.forward(from: 1 - scrollCtr.offset / videoHeight); } } } catch (_) {} } void scrollListener() { if (scrollCtr.hasClients) { if (scrollCtr.offset == 0) { scrollRatio.value = 0; } else { double offset = scrollCtr.offset - (videoHeight - minVideoHeight); if (offset > 0) { scrollRatio.value = clampDouble( offset.toPrecision(2) / (minVideoHeight - kToolbarHeight).toPrecision(2), 0.0, 1.0, ); } else { scrollRatio.value = 0; } } } } bool imageview = false; final isLoginVideo = Accounts.get(AccountType.video).isLogin; late final watchProgress = GStorage.watchProgress; void cacheLocalProgress() { if (plPlayerController.playerStatus.isCompleted) { watchProgress.put(cid.value.toString(), entry.totalTimeMilli); } else if (playedTime case final playedTime?) { watchProgress.put(cid.value.toString(), playedTime.inMilliseconds); } } void initFileSource(BiliDownloadEntryInfo entry, {bool isInit = true}) { this.entry = entry; firstVideo = VideoItem( quality: VideoQuality.fromCode(entry.preferedVideoQuality), width: entry.ep?.width ?? entry.pageData?.width ?? 1, height: entry.ep?.height ?? entry.pageData?.height ?? 1, ); if (watchProgress.get(cid.value.toString()) case final int progress?) { if (progress >= entry.totalTimeMilli - 400) { defaultST = Duration.zero; } else { defaultST = Duration(milliseconds: progress); } } else { defaultST = Duration.zero; } data = PlayUrlModel(timeLength: entry.totalTimeMilli); if (isInit) { Future.delayed(const Duration(milliseconds: 120), setVideoHeight); } else { setVideoHeight(); } } @override void onInit() { super.onInit(); args = Get.arguments; videoType = args['videoType']; if (videoType == VideoType.pgc) { if (!isLoginVideo) { _actualVideoType = VideoType.ugc; } } else if (args['pgcApi'] == true) { _actualVideoType = VideoType.pgc; } bvid = args['bvid']; aid = args['aid']; cid = RxInt(args['cid']); epId = args['epId']; seasonId = args['seasonId']; pgcType = args['pgcType']; heroTag = args['heroTag']; cover = RxString(args['cover'] ?? ''); sourceType = args['sourceType'] ?? SourceType.normal; isFileSource = sourceType == SourceType.file; isPlayAll = sourceType != SourceType.normal && !isFileSource; if (isFileSource) { initFileSource(args['entry']); } else if (isPlayAll) { watchLaterTitle = args['favTitle']; _mediaDesc = args['desc']; getMediaList(); } tabCtr = TabController( length: 2, vsync: this, initialIndex: Pref.defaultShowComment ? 1 : 0, ); } Future getMediaList({ bool isReverse = false, bool isLoadPrevious = false, }) async { final count = args['count']; if (!isReverse && count != null && mediaList.length >= count) { return; } final res = await UserHttp.getMediaList( type: args['mediaType'] ?? sourceType.mediaType, bizId: args['mediaId'] ?? -1, ps: 20, direction: isLoadPrevious ? true : false, oid: isReverse ? null : mediaList.isEmpty ? args['isContinuePlaying'] == true ? args['oid'] : null : isLoadPrevious ? mediaList.first.aid : mediaList.last.aid, otype: isReverse ? null : mediaList.isEmpty ? null : isLoadPrevious ? mediaList.first.type : mediaList.last.type, desc: _mediaDesc, sortField: args['sortField'] ?? 1, withCurrent: mediaList.isEmpty && args['isContinuePlaying'] == true ? true : false, ); if (res case Success(:final response)) { if (response.mediaList.isNotEmpty) { if (isReverse) { mediaList.value = response.mediaList; for (final item in mediaList) { if (item.cid != null) { try { Get.find( tag: heroTag, ).onChangeEpisode(item); } catch (_) {} break; } } } else if (isLoadPrevious) { mediaList.insertAll(0, response.mediaList); } else { mediaList.addAll(response.mediaList); } } } else { res.toast(); } } // 稍后再看面板展开 void showMediaListPanel(BuildContext context) { if (mediaList.isNotEmpty) { Widget panel() => MediaListPanel( mediaList: mediaList, onChangeEpisode: (episode) { try { Get.find(tag: heroTag).onChangeEpisode(episode); } catch (_) {} }, panelTitle: watchLaterTitle, bvid: bvid, count: args['count'], loadMoreMedia: getMediaList, desc: _mediaDesc, onReverse: () { _mediaDesc = !_mediaDesc; getMediaList(isReverse: true); }, loadPrevious: args['isContinuePlaying'] == true ? () => getMediaList(isLoadPrevious: true) : null, onDelete: sourceType == SourceType.watchLater || (sourceType == SourceType.fav && args['isOwner'] == true) ? (item, index) async { if (sourceType == SourceType.watchLater) { final res = await UserHttp.toViewDel( aids: item.aid.toString(), ); if (res.isSuccess) { mediaList.removeAt(index); } } else { final res = await FavHttp.favVideo( resources: '${item.aid}:${item.type}', delIds: '${args['mediaId']}', ); if (res.isSuccess) { mediaList.removeAt(index); SmartDialog.showToast('取消收藏'); } else { res.toast(); } } } : null, ); if (plPlayerController.isFullScreen.value || showVideoSheet) { PageUtils.showVideoBottomSheet( context, child: plPlayerController.darkVideoPage && MyApp.darkThemeData != null ? Theme( data: MyApp.darkThemeData!, child: panel(), ) : panel(), isFullScreen: () => plPlayerController.isFullScreen.value, ); } else { childKey.currentState?.showBottomSheet( backgroundColor: Colors.transparent, constraints: const BoxConstraints(), (context) => panel(), ); } } else { getMediaList(); } } bool isPortrait = true; bool get horizontalScreen => plPlayerController.horizontalScreen; bool get showVideoSheet => (!horizontalScreen && !isPortrait) || plPlayerController.isDesktopPip; @override late final RxString videoLabel = ''.obs; @override int? get timeLength => data.timeLength; @override BlockConfigMixin get blockConfig => plPlayerController; @override Player? get player => plPlayerController.videoPlayerController; @override bool get isFullScreen => plPlayerController.isFullScreen.value; @override bool get autoPlay => _autoPlay.value; set autoPlay(bool value) => _autoPlay.value = value; @override bool get preInitPlayer => plPlayerController.preInitPlayer; @override int get currPosInMilliseconds => defaultST?.inMilliseconds ?? plPlayerController.position.inMilliseconds; @override Future seekTo(Duration duration, {required bool isSeek}) => plPlayerController.seekTo(duration, isSeek: isSeek); @override Widget buildItem(Object item, Animation animation) { final theme = Get.theme; return Align( alignment: Alignment.centerLeft, child: SlideTransition( position: animation.drive( Tween( begin: const Offset(-1.0, 0.0), end: Offset.zero, ), ), child: Padding( padding: const EdgeInsets.only(top: 5), child: GestureDetector( onHorizontalDragUpdate: (DragUpdateDetails details) { if (details.delta.dx < 0) { onRemoveItem(listData.indexOf(item), item); } }, child: SearchText( bgColor: theme.colorScheme.secondaryContainer.withValues( alpha: 0.8, ), textColor: theme.colorScheme.onSecondaryContainer, padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), fontSize: 14, text: item is SegmentModel ? '跳过: ${item.segmentType.shortTitle}' : '上次看到第${(item as int) + 1}P,点击跳转', onTap: (_) { if (item is int) { try { UgcIntroController ugcIntroController = Get.find(tag: heroTag); Part part = ugcIntroController.videoDetail.value.pages![item]; ugcIntroController.onChangeEpisode(part); SmartDialog.showToast('已跳至第${item + 1}P'); } catch (e) { if (kDebugMode) debugPrint('$e'); SmartDialog.showToast('跳转失败'); } onRemoveItem(listData.indexOf(item), item); } else if (item is SegmentModel) { onSkip(item, isSeek: false); onRemoveItem(listData.indexOf(item), item); } }, ), ), ), ), ); } ({int mode, int fontSize, Color color})? dmConfig; String? savedDanmaku; /// 发送弹幕 Future showShootDanmakuSheet() async { if (plPlayerController.dmState.contains(cid.value)) { SmartDialog.showToast('UP主已关闭弹幕'); return; } final isPlaying = _autoPlay.value && plPlayerController.playerStatus.isPlaying; if (isPlaying) { await plPlayerController.pause(); } await Get.key.currentState!.push( PublishRoute( pageBuilder: (buildContext, animation, secondaryAnimation) { return SendDanmakuPanel( cid: cid.value, bvid: bvid, progress: plPlayerController.position.inMilliseconds, initialValue: savedDanmaku, onSave: (danmaku) => savedDanmaku = danmaku, onSuccess: (danmakuModel) { savedDanmaku = null; plPlayerController.danmakuController?.addDanmaku(danmakuModel); }, darkVideoPage: plPlayerController.darkVideoPage, dmConfig: dmConfig, onSaveDmConfig: (dmConfig) => this.dmConfig = dmConfig, ); }, ), ); if (isPlaying) { plPlayerController.play(); } } VideoItem findVideoByQa(int qa) { /// 根据currentVideoQa和currentDecodeFormats 重新设置videoUrl final videoList = data.dash!.video!.where((i) => i.id == qa).toList(); final currentDecodeFormats = this.currentDecodeFormats.codes; final defaultDecodeFormats = VideoDecodeFormatType.fromString( cacheDecode, ).codes; final secondDecodeFormats = VideoDecodeFormatType.fromString( cacheSecondDecode, ).codes; VideoItem? video; for (final i in videoList) { final codec = i.codecs!; if (currentDecodeFormats.any(codec.startsWith)) { video = i; break; } else if (defaultDecodeFormats.any(codec.startsWith)) { video = i; } else if (video == null && secondDecodeFormats.any(codec.startsWith)) { video = i; } } return video ?? videoList.first; } /// 更新画质、音质 void updatePlayer() { final currentVideoQa = this.currentVideoQa.value; if (currentVideoQa == null) return; _autoPlay.value = true; playedTime = plPlayerController.position; plPlayerController ..removeListeners() ..isBuffering.value = false ..buffered.value = Duration.zero; final video = findVideoByQa(currentVideoQa.code); if (firstVideo.codecs != video.codecs) { currentDecodeFormats = VideoDecodeFormatType.fromString(video.codecs!); } firstVideo = video; videoUrl = VideoUtils.getCdnUrl(firstVideo.playUrls); /// 根据currentAudioQa 重新设置audioUrl if (currentAudioQa != null) { final firstAudio = data.dash!.audio!.firstWhere( (i) => i.id == currentAudioQa!.code, orElse: () => data.dash!.audio!.first, ); audioUrl = VideoUtils.getCdnUrl(firstAudio.playUrls, isAudio: true); } playerInit(); } Future? _initPlayerIfNeeded() { if (_autoPlay.value || (plPlayerController.preInitPlayer && !plPlayerController.processing) && (isFileSource ? true : videoPlayerKey.currentState?.mounted == true)) { return playerInit(); } return null; } Future playerInit({ String? video, String? audio, Duration? seekToTime, Duration? duration, bool? autoplay, Volume? volume, }) async { final onlyPlayAudio = plPlayerController.onlyPlayAudio.value; Duration? seek = seekToTime ?? defaultST ?? playedTime; if (seek == null || seek == Duration.zero) { seek = getFirstSegment(); } await plPlayerController.setDataSource( DataSource( videoSource: isFileSource ? null : onlyPlayAudio ? audio ?? audioUrl : video ?? videoUrl, audioSource: isFileSource || onlyPlayAudio ? null : audio ?? audioUrl, type: isFileSource ? DataSourceType.file : DataSourceType.network, httpHeaders: isFileSource ? null : { 'user-agent': UaType.pc.ua, 'referer': HttpString.baseUrl, }, ), seekTo: seek, duration: duration ?? (data.timeLength == null ? null : Duration(milliseconds: data.timeLength!)), isVertical: isVertical.value, aid: aid, bvid: bvid, cid: cid.value, autoplay: autoplay ?? _autoPlay.value, epid: isUgc ? null : epId, seasonId: isUgc ? null : seasonId, pgcType: isUgc ? null : pgcType, videoType: videoType, onInit: () { if (videoState.value is! Success) { videoState.value = const Success(null); } setSubtitle(vttSubtitlesIndex.value); }, width: firstVideo.width, height: firstVideo.height, volume: volume ?? this.volume, dirPath: isFileSource ? args['dirPath'] : null, typeTag: isFileSource ? entry.typeTag : null, mediaType: isFileSource ? entry.mediaType : null, ); if (isClosed) return; if (!isFileSource) { if (plPlayerController.enableBlock) { initSkip(); } if (vttSubtitlesIndex.value == -1) { _queryPlayInfo(); } if (plPlayerController.showDmChart && dmTrend.value == null) { _getDmTrend(); } } defaultST = null; } bool isQuerying = false; final Rx?> languages = Rx?>(null); final Rx currLang = Rx(null); void setLanguage(String language) { if (currLang.value == language) return; if (!isLoginVideo) { SmartDialog.showToast('账号未登录'); return; } currLang.value = language; queryVideoUrl(defaultST: playedTime); } Volume? volume; // 视频链接 Future queryVideoUrl({ Duration? defaultST, bool fromReset = false, }) async { if (isFileSource) { return _initPlayerIfNeeded(); } if (isQuerying) { return; } isQuerying = true; if (plPlayerController.enableSponsorBlock && isBlock && !fromReset) { querySponsorBlock(bvid: bvid, cid: cid.value); } if (plPlayerController.cacheVideoQa == null) { final isWiFi = await Utils.isWiFi; plPlayerController ..cacheVideoQa = isWiFi ? Pref.defaultVideoQa : Pref.defaultVideoQaCellular ..cacheAudioQa = isWiFi ? Pref.defaultAudioQa : Pref.defaultAudioQaCellular; } final result = await VideoHttp.videoUrl( cid: cid.value, bvid: bvid, epid: epId, seasonId: seasonId, tryLook: plPlayerController.tryLook, videoType: _actualVideoType ?? videoType, language: currLang.value, ); if (result case Success(:final response)) { data = response; languages.value = data.language?.items; currLang.value = data.curLanguage; volume = data.volume; final progress = args.remove('progress'); if (progress != null) { this.defaultST = Duration(milliseconds: progress); } else if (defaultST == null && data.lastPlayTime != null) { this.defaultST = Duration(milliseconds: data.lastPlayTime!); } if (!isUgc && !fromReset && plPlayerController.enablePgcSkip) { if (data.clipInfoList case final clipInfoList?) { resetBlock(); handleSBData(clipInfoList); } } if (data.acceptDesc?.contains('试看') == true) { SmartDialog.showToast( '该视频为专属视频,仅提供试看', displayTime: const Duration(seconds: 3), ); } if (data.dash == null && data.durl != null) { final first = data.durl!.first; videoUrl = VideoUtils.getCdnUrl(first.playUrls); audioUrl = ''; // 实际为FLV/MP4格式,但已被淘汰,这里仅做兜底处理 final videoQuality = VideoQuality.fromCode(data.quality!); firstVideo = VideoItem( id: data.quality!, baseUrl: videoUrl, codecs: 'avc1', quality: videoQuality, ); setVideoHeight(); currentDecodeFormats = VideoDecodeFormatType.fromString('avc1'); currentVideoQa.value = videoQuality; await _initPlayerIfNeeded(); isQuerying = false; return; } if (data.dash == null) { SmartDialog.showToast('视频资源不存在'); _autoPlay.value = false; videoState.value = const Error('视频资源不存在'); if (plPlayerController.isFullScreen.value) { plPlayerController.toggleFullScreen(false); } isQuerying = false; return; } final List videoList = data.dash!.video!; // if (kDebugMode) debugPrint("allVideosList:${allVideosList}"); // 当前可播放的最高质量视频 final curHighestVideoQa = videoList.first.quality.code; // 预设的画质为null,则当前可用的最高质量 int targetVideoQa = curHighestVideoQa; if (data.acceptQuality?.isNotEmpty == true && plPlayerController.cacheVideoQa! <= curHighestVideoQa) { // 如果预设的画质低于当前最高 targetVideoQa = data.acceptQuality!.findClosestTarget( (e) => e <= plPlayerController.cacheVideoQa!, (a, b) => a > b ? a : b, ); } currentVideoQa.value = VideoQuality.fromCode(targetVideoQa); /// 取出符合当前画质的videoList final List videosList = videoList .where((e) => e.quality.code == targetVideoQa) .toList(); /// 优先顺序 设置中指定解码格式 -> 当前可选的首个解码格式 final List supportFormats = data.supportFormats!; // 根据画质选编码格式 final List supportDecodeFormats = supportFormats .firstWhere( (e) => e.quality == targetVideoQa, orElse: () => supportFormats.first, ) .codecs!; // 默认从设置中取AV1 currentDecodeFormats = VideoDecodeFormatType.fromString(cacheDecode); VideoDecodeFormatType secondDecodeFormats = VideoDecodeFormatType.fromString(cacheSecondDecode); // 当前视频没有对应格式返回第一个 int flag = 0; for (final e in supportDecodeFormats) { if (currentDecodeFormats.codes.any(e.startsWith)) { flag = 1; break; } else if (secondDecodeFormats.codes.any(e.startsWith)) { flag = 2; } } if (flag == 2) { currentDecodeFormats = secondDecodeFormats; } else if (flag == 0) { currentDecodeFormats = VideoDecodeFormatType.fromString( supportDecodeFormats.first, ); } /// 取出符合当前解码格式的videoItem firstVideo = videosList.firstWhere( (e) => currentDecodeFormats.codes.any(e.codecs!.startsWith), orElse: () => videosList.first, ); setVideoHeight(); videoUrl = VideoUtils.getCdnUrl(firstVideo.playUrls); /// 优先顺序 设置中指定质量 -> 当前可选的最高质量 AudioItem? firstAudio; final audioList = data.dash?.audio; if (audioList != null && audioList.isNotEmpty) { final List audioIds = audioList.map((map) => map.id!).toList(); int closestNumber = audioIds.findClosestTarget( (e) => e <= plPlayerController.cacheAudioQa, (a, b) => a > b ? a : b, ); if (!audioIds.contains(plPlayerController.cacheAudioQa) && audioIds.any((e) => e > plPlayerController.cacheAudioQa)) { closestNumber = AudioQuality.k192.code; } firstAudio = audioList.firstWhere( (e) => e.id == closestNumber, orElse: () => audioList.first, ); audioUrl = VideoUtils.getCdnUrl(firstAudio.playUrls, isAudio: true); if (firstAudio.id case final int id?) { currentAudioQa = AudioQuality.fromCode(id); } } else { audioUrl = ''; } await _initPlayerIfNeeded(); } else { _autoPlay.value = false; videoState.value = result..toast(); if (plPlayerController.isFullScreen.value) { plPlayerController.toggleFullScreen(false); } } isQuerying = false; } late final List postList = []; void onBlock(BuildContext context) { if (postList.isEmpty) { postList.add( PostSegmentModel( segment: Pair( first: 0, second: plPlayerController.position.inMilliseconds / 1000, ), category: SegmentType.sponsor, actionType: ActionType.skip, ), ); } if (plPlayerController.isFullScreen.value || showVideoSheet) { PageUtils.showVideoBottomSheet( context, child: plPlayerController.darkVideoPage && MyApp.darkThemeData != null ? Theme( data: MyApp.darkThemeData!, child: PostPanel( enableSlide: false, videoDetailController: this, plPlayerController: plPlayerController, ), ) : PostPanel( enableSlide: false, videoDetailController: this, plPlayerController: plPlayerController, ), isFullScreen: () => plPlayerController.isFullScreen.value, ); } else { childKey.currentState?.showBottomSheet( backgroundColor: Colors.transparent, constraints: const BoxConstraints(), (context) => PostPanel( videoDetailController: this, plPlayerController: plPlayerController, ), ); } } RxList subtitles = RxList(); final Map vttSubtitles = {}; late final RxInt vttSubtitlesIndex = (-1).obs; late final RxBool showVP = true.obs; late final RxList viewPointList = [].obs; // 设定字幕轨道 Future setSubtitle(int index) async { if (index <= 0) { await plPlayerController.videoPlayerController?.setSubtitleTrack( SubtitleTrack.no(), ); vttSubtitlesIndex.value = index; return; } Future setSub(({bool isData, String id}) subtitle) async { final sub = subtitles[index - 1]; await plPlayerController.videoPlayerController?.setSubtitleTrack( SubtitleTrack( subtitle.id, sub.lanDoc, sub.lan, uri: !subtitle.isData, data: subtitle.isData, ), ); vttSubtitlesIndex.value = index; } ({bool isData, String id})? subtitle = vttSubtitles[index - 1]; if (subtitle != null) { await setSub(subtitle); } else { final result = await VideoHttp.vttSubtitles( subtitles[index - 1].subtitleUrl!, ); if (!isClosed && result != null) { final subtitle = (isData: true, id: result); vttSubtitles[index - 1] = subtitle; await setSub(subtitle); } } } // interactive video int? graphVersion; EdgeInfoData? steinEdgeInfo; late final RxBool showSteinEdgeInfo = false.obs; Future getSteinEdgeInfo([int? edgeId]) async { steinEdgeInfo = null; try { final res = await Request().get( '/x/stein/edgeinfo_v2', queryParameters: { 'bvid': bvid, 'graph_version': graphVersion, 'edge_id': ?edgeId, }, ); if (res.data['code'] == 0) { steinEdgeInfo = EdgeInfoData.fromJson(res.data['data']); } else { if (kDebugMode) { debugPrint('getSteinEdgeInfo error: ${res.data['message']}'); } } } catch (e) { if (kDebugMode) debugPrint('getSteinEdgeInfo: $e'); } } late bool continuePlayingPart = Pref.continuePlayingPart; Future _queryPlayInfo() async { vttSubtitles.clear(); vttSubtitlesIndex.value = 0; if (plPlayerController.showViewPoints) { viewPointList.clear(); } final res = await VideoHttp.playInfo( bvid: bvid, cid: cid.value, seasonId: seasonId, epId: epId, ); if (res case Success(:final response)) { // interactive video if (isUgc && graphVersion == null) { try { final introCtr = Get.find(tag: heroTag); if (introCtr.videoDetail.value.rights?.isSteinGate == 1) { graphVersion = response.interaction?.graphVersion; getSteinEdgeInfo(); } } catch (e) { if (kDebugMode) debugPrint('handle stein: $e'); } } if (isUgc && continuePlayingPart) { continuePlayingPart = false; try { UgcIntroController ugcIntroController = Get.find( tag: heroTag, ); if ((ugcIntroController.videoDetail.value.pages?.length ?? 0) > 1 && response.lastPlayCid != null && response.lastPlayCid != 0) { if (response.lastPlayCid != cid.value) { int index = ugcIntroController.videoDetail.value.pages! .indexWhere((item) => item.cid == response.lastPlayCid); if (index != -1) { onAddItem(index); } } } } catch (_) {} } if (plPlayerController.showViewPoints && response.viewPoints?.firstOrNull?.type == 2) { try { viewPointList.value = response.viewPoints!.map((item) { final end = (item.to! / (data.timeLength! / 1000)).clamp(0.0, 1.0); return ViewPointSegment( end: end, title: item.content, url: item.imgUrl, from: item.from, to: item.to, ); }).toList(); } catch (_) {} } if (response.subtitle?.subtitles?.isNotEmpty == true) { subtitles.value = response.subtitle!.subtitles!; final idx = switch (Pref.subtitlePreferenceV2) { SubtitlePrefType.off => 0, SubtitlePrefType.on => 1, SubtitlePrefType.withoutAi => subtitles.first.lan.startsWith('ai') ? 0 : 1, SubtitlePrefType.auto => !subtitles.first.lan.startsWith('ai') || (PlatformUtils.isMobile && (await FlutterVolumeController.getVolume() ?? 0.0) <= 0.0) ? 1 : 0, }; await setSubtitle(idx); } } } void updateMediaListHistory(int aid) { if (args['sortField'] != null) { VideoHttp.medialistHistory( desc: _mediaDesc ? 1 : 0, oid: aid, upperMid: args['mediaId'], ); } } void makeHeartBeat() { if (plPlayerController.enableHeart && !plPlayerController.playerStatus.isCompleted && playedTime != null) { try { plPlayerController.makeHeartBeat( data.timeLength != null ? (data.timeLength! - playedTime!.inMilliseconds).abs() <= 1000 ? -1 : playedTime!.inSeconds : playedTime!.inSeconds, type: HeartBeatType.completed, isManual: true, aid: aid, bvid: bvid, cid: cid.value, epid: isUgc ? null : epId, seasonId: isUgc ? null : seasonId, pgcType: isUgc ? null : pgcType, videoType: videoType, ); } catch (_) {} } } @override void onClose() { cid.close(); if (isFileSource) { cacheLocalProgress(); } introScrollCtr?.dispose(); introScrollCtr = null; tabCtr.dispose(); _scrollCtr ?..removeListener(scrollListener) ..dispose(); animController?.dispose(); subtitles.clear(); vttSubtitles.clear(); super.onClose(); } void onReset({bool isStein = false}) { if (isFileSource) { cacheLocalProgress(); } playedTime = null; defaultST = null; videoUrl = null; audioUrl = null; if (scrollRatio.value != 0) { scrollRatio.refresh(); } // danmaku savedDanmaku = null; // subtitle subtitles.clear(); vttSubtitlesIndex.value = -1; vttSubtitles.clear(); if (!isFileSource) { // language languages.value = null; currLang.value = null; // dm trend if (plPlayerController.showDmChart) { dmTrend.value = null; } // view point if (plPlayerController.showViewPoints) { viewPointList.clear(); } // sponsor block if (blockConfig.enableBlock) { resetBlock(); } // interactive video if (!isStein) { graphVersion = null; } steinEdgeInfo = null; showSteinEdgeInfo.value = false; } } late final Rx>?> dmTrend = Rx>?>(null); late final RxBool showDmTrendChart = true.obs; Future _getDmTrend() async { dmTrend.value = LoadingState>.loading(); try { final res = await Request().get( 'https://bvc.bilivideo.com/pbp/data', queryParameters: { 'bvid': bvid, 'cid': cid.value, }, ); PbpData data = PbpData.fromJson(res.data); int stepSec = data.stepSec ?? 0; if (stepSec != 0 && data.events?.eDefault?.isNotEmpty == true) { dmTrend.value = Success(data.events!.eDefault!); return; } dmTrend.value = const Error(null); } catch (e) { dmTrend.value = const Error(null); if (kDebugMode) debugPrint('_getDmTrend: $e'); } } void showNoteList(BuildContext context) { String? title; try { title = Get.find( tag: heroTag, ).videoDetail.value.title; } catch (_) {} if (plPlayerController.isFullScreen.value || showVideoSheet) { PageUtils.showVideoBottomSheet( context, child: plPlayerController.darkVideoPage && MyApp.darkThemeData != null ? Theme( data: MyApp.darkThemeData!, child: NoteListPage( oid: aid, enableSlide: false, heroTag: heroTag, isStein: graphVersion != null, title: title, ), ) : NoteListPage( oid: aid, enableSlide: false, heroTag: heroTag, isStein: graphVersion != null, title: title, ), isFullScreen: () => plPlayerController.isFullScreen.value, ); } else { childKey.currentState?.showBottomSheet( backgroundColor: Colors.transparent, constraints: const BoxConstraints(), (context) => NoteListPage( oid: aid, heroTag: heroTag, isStein: graphVersion != null, title: title, ), ); } } @pragma('vm:notify-debugger-on-exception') bool onSkipSegment() { try { if (plPlayerController.enableBlock) { if (listData.lastOrNull case final SegmentModel item) { onSkip(item, isSeek: false); onRemoveItem(listData.indexOf(item), item); return true; } } } catch (e, s) { Utils.reportError(e, s); } return false; } void toAudioPage() { int? id; int? extraId; PlaylistSource from = PlaylistSource.UP_ARCHIVE; if (isPlayAll) { id = args['mediaId']; extraId = sourceType.extraId; from = sourceType.playlistSource!; } else if (isUgc) { try { final ctr = Get.find(tag: heroTag); id = ctr.videoDetail.value.ugcSeason?.id; if (id != null) { extraId = 8; from = PlaylistSource.MEDIA_LIST; } } catch (_) {} } AudioPage.toAudioPage( itemType: 1, id: id, oid: aid, subId: [cid.value], from: from, heroTag: _autoPlay.value ? heroTag : null, start: playedTime, audioUrl: audioUrl, extraId: extraId, ); } Future onDownload(BuildContext context) async { VideoDetailData? videoDetail; List? episodes; UgcIntroController? ugcIntroController; PgcInfoModel? pgcItem; if (isUgc) { try { ugcIntroController = Get.find(tag: heroTag); videoDetail = ugcIntroController.videoDetail.value; if (videoDetail.ugcSeason?.sections case final sections?) { episodes = []; for (final i in sections) { if (i.episodes case final e?) { episodes.addAll(e); } } } else { episodes = videoDetail.pages; } } catch (e, s) { if (kDebugMode) { debugPrint('download ugc: $e\n\n$s'); } } } else { try { pgcItem = Get.find(tag: heroTag).pgcItem; episodes = pgcItem.episodes; } catch (e, s) { if (kDebugMode) { debugPrint('download pgc: $e\n\n$s'); } } } if (episodes != null && episodes.isNotEmpty) { final downloadService = Get.find(); await downloadService.waitForInitialization; if (!context.mounted) { return; } final Set cidSet = downloadService.downloadList .followedBy(downloadService.waitDownloadQueue) .map((e) => e.cid) .toSet(); final index = episodes.indexWhere( (e) => e.cid == (seasonCid ?? cid.value), ); showModalBottomSheet( context: context, useSafeArea: true, isScrollControlled: true, constraints: BoxConstraints( maxWidth: min(640, context.mediaQueryShortestSide), ), builder: (context) { final maxChildSize = PlatformUtils.isMobile && !context.mediaQuerySize.isPortrait ? 1.0 : 0.7; return DraggableScrollableSheet( snap: true, expand: false, minChildSize: 0, snapSizes: [maxChildSize], maxChildSize: maxChildSize, initialChildSize: maxChildSize, builder: (context, scrollController) => DownloadPanel( index: index, videoDetail: videoDetail, pgcItem: pgcItem, episodes: episodes!, scrollController: scrollController, videoDetailController: this, heroTag: heroTag, ugcIntroController: ugcIntroController, cidSet: cidSet, ), ); }, ); } } void editPlayUrl() { String videoUrl = this.videoUrl ?? ''; String audioUrl = this.audioUrl ?? ''; Widget textField({ required String label, required String initialValue, required ValueChanged onChanged, }) => TextFormField( minLines: 1, maxLines: 3, onChanged: onChanged, initialValue: initialValue, decoration: InputDecoration( label: Text(label), border: const OutlineInputBorder(), ), ); showDialog( context: Get.context!, builder: (context) => AlertDialog( constraints: StyleString.dialogFixedConstraints, title: const Text('播放地址'), content: Column( spacing: 20, mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ textField( label: 'Video Url', initialValue: videoUrl, onChanged: (value) => videoUrl = value, ), textField( label: 'Audio Url', initialValue: audioUrl, onChanged: (value) => audioUrl = value, ), ], ), actions: [ TextButton( onPressed: () { Get.back(); this.videoUrl = videoUrl; this.audioUrl = audioUrl; playerInit(); }, child: const Text('确定'), ), ], ), ); } @pragma('vm:notify-debugger-on-exception') Future onCast() async { SmartDialog.showLoading(); final res = await VideoHttp.tvPlayUrl( cid: cid.value, objectId: epId ?? aid, playurlType: epId != null ? 2 : 1, qn: currentVideoQa.value?.code, ); SmartDialog.dismiss(); if (res case Success(:final response)) { final first = response.durl?.firstOrNull; if (first == null || first.playUrls.isEmpty) { SmartDialog.showToast('不支持投屏'); return; } final url = VideoUtils.getCdnUrl(first.playUrls); String? title; try { if (isUgc) { title = Get.find( tag: heroTag, ).videoDetail.value.title; } else { title = Get.find( tag: heroTag, ).videoDetail.value.title; } } catch (_) {} if (kDebugMode) { debugPrint(title); } Get.toNamed( '/dlna', parameters: { 'url': url, 'title': ?title, }, ); } else { res.toast(); } } }