feat: video download

Signed-off-by: bggRGjQaUbCoE <githubaccount56556@proton.me>
This commit is contained in:
bggRGjQaUbCoE
2025-11-06 12:12:32 +08:00
parent 976622df89
commit ffd4f9ee73
92 changed files with 4853 additions and 946 deletions

View File

@@ -23,6 +23,7 @@ import 'package:PiliPlus/pages/danmaku/dnamaku_model.dart';
import 'package:PiliPlus/pages/setting/widgets/select_dialog.dart';
import 'package:PiliPlus/pages/setting/widgets/switch_item.dart';
import 'package:PiliPlus/pages/video/controller.dart';
import 'package:PiliPlus/pages/video/introduction/local/controller.dart';
import 'package:PiliPlus/pages/video/introduction/pgc/controller.dart';
import 'package:PiliPlus/pages/video/introduction/ugc/controller.dart';
import 'package:PiliPlus/pages/video/introduction/ugc/widgets/action_item.dart';
@@ -201,7 +202,10 @@ class HeaderControlState extends State<HeaderControl> {
String get heroTag => widget.heroTag;
late final UgcIntroController ugcIntroController;
late final PgcIntroController pgcIntroController;
late CommonIntroController introController = videoDetailCtr.isUgc
late final LocalIntroController localIntroController;
late CommonIntroController introController = isFileSource
? localIntroController
: videoDetailCtr.isUgc
? ugcIntroController
: pgcIntroController;
@@ -217,7 +221,9 @@ class HeaderControlState extends State<HeaderControl> {
@override
void initState() {
super.initState();
if (videoDetailCtr.isUgc) {
if (isFileSource) {
introController = Get.find<LocalIntroController>(tag: heroTag);
} else if (videoDetailCtr.isUgc) {
introController = Get.find<UgcIntroController>(tag: heroTag);
} else {
introController = Get.find<PgcIntroController>(tag: heroTag);
@@ -279,6 +285,19 @@ class HeaderControlState extends State<HeaderControl> {
leading: const Icon(Icons.note_alt_outlined, size: 20),
title: const Text('查看笔记', style: titleStyle),
),
if (!isFileSource)
ListTile(
dense: true,
onTap: () {
Get.back();
videoDetailCtr.onDownload(this.context);
},
leading: const Icon(
MdiIcons.folderDownloadOutline,
size: 20,
),
title: const Text('离线缓存', style: titleStyle),
),
if (widget.videoDetailCtr.cover.value.isNotEmpty)
ListTile(
dense: true,
@@ -305,14 +324,27 @@ class HeaderControlState extends State<HeaderControl> {
dense: true,
onTap: () {
Get.back();
videoDetailCtr.queryVideoUrl(
defaultST: videoDetailCtr.playedTime,
fromReset: true,
);
videoDetailCtr.editPlayUrl();
},
leading: const Icon(Icons.refresh_outlined, size: 20),
title: const Text('重载视频', style: titleStyle),
leading: const Icon(
Icons.link,
size: 20,
),
title: const Text('播放地址', style: titleStyle),
),
if (!isFileSource)
ListTile(
dense: true,
onTap: () {
Get.back();
videoDetailCtr.queryVideoUrl(
defaultST: videoDetailCtr.playedTime,
fromReset: true,
);
},
leading: const Icon(Icons.refresh_outlined, size: 20),
title: const Text('重载视频', style: titleStyle),
),
ListTile(
dense: true,
leading: const Icon(
@@ -384,37 +416,38 @@ class HeaderControlState extends State<HeaderControl> {
],
),
),
ListTile(
dense: true,
title: const Text('CDN 设置', style: titleStyle),
leading: const Icon(MdiIcons.cloudPlusOutline, size: 20),
subtitle: Text(
'当前:${CDNService.fromCode(VideoUtils.cdnService).desc},无法播放请切换',
style: subTitleStyle,
),
onTap: () async {
Get.back();
String? result = await showDialog(
context: context,
builder: (context) {
return CdnSelectDialog(
sample: videoInfo.dash?.video?.first,
if (!isFileSource)
ListTile(
dense: true,
title: const Text('CDN 设置', style: titleStyle),
leading: const Icon(MdiIcons.cloudPlusOutline, size: 20),
subtitle: Text(
'当前:${CDNService.fromCode(VideoUtils.cdnService).desc},无法播放请切换',
style: subTitleStyle,
),
onTap: () async {
Get.back();
String? result = await showDialog(
context: context,
builder: (context) {
return CdnSelectDialog(
sample: videoInfo.dash?.video?.first,
);
},
);
if (result != null) {
VideoUtils.cdnService = result;
setting.put(SettingBoxKey.CDNService, result);
SmartDialog.showToast(
'已设置为 ${CDNService.fromCode(result).desc},正在重载视频',
);
},
);
if (result != null) {
VideoUtils.cdnService = result;
setting.put(SettingBoxKey.CDNService, result);
SmartDialog.showToast(
'已设置为 ${CDNService.fromCode(result).desc},正在重载视频',
);
videoDetailCtr.queryVideoUrl(
defaultST: videoDetailCtr.playedTime,
fromReset: true,
);
}
},
),
videoDetailCtr.queryVideoUrl(
defaultST: videoDetailCtr.playedTime,
fromReset: true,
);
}
},
),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
@@ -455,22 +488,25 @@ class HeaderControlState extends State<HeaderControl> {
);
},
),
Obx(
() {
final onlyPlayAudio =
plPlayerController.onlyPlayAudio.value;
return ActionRowLineItem(
iconData: Icons.headphones,
onTap: () {
plPlayerController.onlyPlayAudio.value =
!onlyPlayAudio;
widget.videoDetailCtr.playerInit();
},
text: " 听视频 ",
selectStatus: onlyPlayAudio,
);
},
),
if ((isFileSource && plPlayerController.mediaType != 1) ||
(!isFileSource &&
videoDetailCtr.audioUrl?.isNotEmpty == true))
Obx(
() {
final onlyPlayAudio =
plPlayerController.onlyPlayAudio.value;
return ActionRowLineItem(
iconData: Icons.headphones,
onTap: () {
plPlayerController.onlyPlayAudio.value =
!onlyPlayAudio;
widget.videoDetailCtr.playerInit();
},
text: " 听视频 ",
selectStatus: onlyPlayAudio,
);
},
),
Obx(
() => ActionRowLineItem(
iconData: Icons.play_circle_outline,
@@ -483,46 +519,48 @@ class HeaderControlState extends State<HeaderControl> {
],
),
),
ListTile(
dense: true,
onTap: () {
Get.back();
showSetVideoQa();
},
leading: const Icon(Icons.play_circle_outline, size: 20),
title: const Text('选择画质', style: titleStyle),
subtitle: Text(
'当前画质 ${videoDetailCtr.currentVideoQa.value?.desc}',
style: subTitleStyle,
),
),
if (videoDetailCtr.currentAudioQa != null)
if (!isFileSource) ...[
ListTile(
dense: true,
onTap: () {
Get.back();
showSetAudioQa();
showSetVideoQa();
},
leading: const Icon(Icons.album_outlined, size: 20),
title: const Text('选择', style: titleStyle),
leading: const Icon(Icons.play_circle_outline, size: 20),
title: const Text('选择', style: titleStyle),
subtitle: Text(
'当前${videoDetailCtr.currentAudioQa!.desc}',
'当前${videoDetailCtr.currentVideoQa.value?.desc}',
style: subTitleStyle,
),
),
ListTile(
dense: true,
onTap: () {
Get.back();
showSetDecodeFormats();
},
leading: const Icon(Icons.av_timer_outlined, size: 20),
title: const Text('解码格式', style: titleStyle),
subtitle: Text(
'当前解码格式 ${videoDetailCtr.currentDecodeFormats.description}',
style: subTitleStyle,
if (videoDetailCtr.currentAudioQa != null)
ListTile(
dense: true,
onTap: () {
Get.back();
showSetAudioQa();
},
leading: const Icon(Icons.album_outlined, size: 20),
title: const Text('选择音质', style: titleStyle),
subtitle: Text(
'当前音质 ${videoDetailCtr.currentAudioQa!.desc}',
style: subTitleStyle,
),
),
ListTile(
dense: true,
onTap: () {
Get.back();
showSetDecodeFormats();
},
leading: const Icon(Icons.av_timer_outlined, size: 20),
title: const Text('解码格式', style: titleStyle),
subtitle: Text(
'当前解码格式 ${videoDetailCtr.currentDecodeFormats.description}',
style: subTitleStyle,
),
),
),
],
ListTile(
dense: true,
onTap: () {
@@ -554,15 +592,16 @@ class HeaderControlState extends State<HeaderControl> {
leading: const Icon(CustomIcons.dm_settings, size: 20),
title: const Text('弹幕设置', style: titleStyle),
),
ListTile(
dense: true,
onTap: () {
Get.back();
showSetSubtitle();
},
leading: const Icon(Icons.subtitles_outlined, size: 20),
title: const Text('字幕设置', style: titleStyle),
),
if (!videoDetailCtr.isFileSource)
ListTile(
dense: true,
onTap: () {
Get.back();
showSetSubtitle();
},
leading: const Icon(Icons.subtitles_outlined, size: 20),
title: const Text('字幕设置', style: titleStyle),
),
if (videoDetailCtr.subtitles.isNotEmpty)
ListTile(
dense: true,
@@ -604,7 +643,6 @@ class HeaderControlState extends State<HeaderControl> {
),
onTap: () => Utils.copyText(
'Resolution\n${state.width}x${state.height}',
needToast: false,
),
),
ListTile(
@@ -615,7 +653,6 @@ class HeaderControlState extends State<HeaderControl> {
),
onTap: () => Utils.copyText(
'VideoParams\n${state.videoParams}',
needToast: false,
),
),
ListTile(
@@ -626,7 +663,6 @@ class HeaderControlState extends State<HeaderControl> {
),
onTap: () => Utils.copyText(
'AudioParams\n${state.audioParams}',
needToast: false,
),
),
ListTile(
@@ -637,7 +673,6 @@ class HeaderControlState extends State<HeaderControl> {
),
onTap: () => Utils.copyText(
'Media\n${state.playlist}',
needToast: false,
),
),
ListTile(
@@ -648,7 +683,6 @@ class HeaderControlState extends State<HeaderControl> {
),
onTap: () => Utils.copyText(
'AudioTrack\n${state.track.audio}',
needToast: false,
),
),
ListTile(
@@ -659,26 +693,21 @@ class HeaderControlState extends State<HeaderControl> {
),
onTap: () => Utils.copyText(
'VideoTrack\n${state.track.audio}',
needToast: false,
),
),
ListTile(
dense: true,
title: const Text("pitch"),
subtitle: Text(state.pitch.toString()),
onTap: () => Utils.copyText(
'pitch\n${state.pitch}',
needToast: false,
),
onTap: () =>
Utils.copyText('pitch\n${state.pitch}'),
),
ListTile(
dense: true,
title: const Text("rate"),
subtitle: Text(state.rate.toString()),
onTap: () => Utils.copyText(
'rate\n${state.rate}',
needToast: false,
),
onTap: () =>
Utils.copyText('rate\n${state.rate}'),
),
ListTile(
dense: true,
@@ -688,7 +717,6 @@ class HeaderControlState extends State<HeaderControl> {
),
onTap: () => Utils.copyText(
'AudioBitrate\n${state.audioBitrate}',
needToast: false,
),
),
ListTile(
@@ -697,19 +725,14 @@ class HeaderControlState extends State<HeaderControl> {
subtitle: Text(
state.volume.toString(),
),
onTap: () => Utils.copyText(
'Volume\n${state.volume}',
needToast: false,
),
onTap: () =>
Utils.copyText('Volume\n${state.volume}'),
),
ListTile(
dense: true,
title: const Text('hwdec'),
subtitle: Text(hwdec),
onTap: () => Utils.copyText(
'hwdec\n$hwdec',
needToast: false,
),
onTap: () => Utils.copyText('hwdec\n$hwdec'),
),
],
),
@@ -757,10 +780,11 @@ class HeaderControlState extends State<HeaderControl> {
SmartDialog.showToast('当前视频不支持选择画质');
return;
}
final List<FormatItem> videoFormat = videoInfo.supportFormats!;
final VideoQuality? currentVideoQa = videoDetailCtr.currentVideoQa.value;
if (currentVideoQa == null) return;
final List<FormatItem> videoFormat = videoInfo.supportFormats!;
/// 总质量分类
final int totalQaSam = videoFormat.length;
@@ -813,10 +837,11 @@ class HeaderControlState extends State<HeaderControl> {
itemCount: totalQaSam,
itemBuilder: (context, index) {
final item = videoFormat[index];
final isCurr = currentVideoQa.code == item.quality;
return ListTile(
dense: true,
onTap: () async {
if (currentVideoQa.code == item.quality) {
if (isCurr) {
return;
}
Get.back();
@@ -845,7 +870,7 @@ class HeaderControlState extends State<HeaderControl> {
horizontal: 20,
),
title: Text(item.newDesc!),
trailing: currentVideoQa.code == item.quality
trailing: isCurr
? Icon(
Icons.done,
color: theme.colorScheme.primary,
@@ -891,15 +916,16 @@ class HeaderControlState extends State<HeaderControl> {
SliverList.builder(
itemCount: audio.length,
itemBuilder: (context, index) {
final i = audio[index];
final item = audio[index];
final isCurr = currentAudioQa.code == item.id;
return ListTile(
dense: true,
onTap: () async {
if (currentAudioQa.code == i.id) {
if (isCurr) {
return;
}
Get.back();
final int quality = i.id!;
final int quality = item.id!;
final newQa = AudioQuality.fromCode(quality);
videoDetailCtr
..plPlayerController.cacheAudioQa = newQa.code
@@ -921,12 +947,12 @@ class HeaderControlState extends State<HeaderControl> {
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
),
title: Text(i.quality),
title: Text(item.quality),
subtitle: Text(
i.codecs!,
item.codecs!,
style: subTitleStyle,
),
trailing: currentAudioQa.code == i.id
trailing: isCurr
? Icon(
Icons.done,
color: theme.colorScheme.primary,
@@ -945,9 +971,6 @@ class HeaderControlState extends State<HeaderControl> {
// 选择解码格式
void showSetDecodeFormats() {
// 当前选中的解码格式
final VideoDecodeFormatType currentDecodeFormats =
videoDetailCtr.currentDecodeFormats;
final VideoItem firstVideo = videoDetailCtr.firstVideo;
// 当前视频可用的解码格式
final List<FormatItem> videoFormat = videoInfo.supportFormats!;
@@ -959,6 +982,9 @@ class HeaderControlState extends State<HeaderControl> {
return;
}
// 当前选中的解码格式
final VideoDecodeFormatType currentDecodeFormats =
videoDetailCtr.currentDecodeFormats;
showBottomSheet(
(context, setState) {
final theme = Theme.of(context);
@@ -982,28 +1008,28 @@ class HeaderControlState extends State<HeaderControl> {
SliverList.builder(
itemCount: list.length,
itemBuilder: (context, index) {
final i = list[index];
final format = VideoDecodeFormatType.fromString(i);
final item = list[index];
final format = VideoDecodeFormatType.fromString(item);
final isCurr = currentDecodeFormats.codes.any(
item.startsWith,
);
return ListTile(
dense: true,
onTap: () {
if (currentDecodeFormats.codes.any(
i.startsWith,
)) {
if (isCurr) {
return;
}
Get.back();
videoDetailCtr
..currentDecodeFormats = format
..updatePlayer();
Get.back();
},
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
),
title: Text(format.description),
subtitle: Text(i, style: subTitleStyle),
trailing:
currentDecodeFormats.codes.any(i.startsWith)
subtitle: Text(item, style: subTitleStyle),
trailing: isCurr
? Icon(
Icons.done,
color: theme.colorScheme.primary,
@@ -2181,11 +2207,14 @@ class HeaderControlState extends State<HeaderControl> {
clock = null;
}
late final isFileSource = videoDetailCtr.isFileSource;
@override
Widget build(BuildContext context) {
final isFullScreen = this.isFullScreen;
final isFSOrPip = isFullScreen || plPlayerController.isDesktopPip;
final showFSActionItem = plPlayerController.showFSActionItem && isFSOrPip;
final showFSActionItem =
!isFileSource && plPlayerController.showFSActionItem && isFSOrPip;
return AppBar(
elevation: 0,
scrolledUnderElevation: 0,
@@ -2263,7 +2292,7 @@ class HeaderControlState extends State<HeaderControl> {
final videoDetail =
introController.videoDetail.value;
final String title;
if (videoDetail.videos == 1) {
if (isFileSource || videoDetail.videos == 1) {
title = videoDetail.title!;
} else {
title =
@@ -2272,7 +2301,7 @@ class HeaderControlState extends State<HeaderControl> {
(e) =>
e.cid == videoDetailCtr.cid.value,
)
?.pagePart ??
?.part ??
videoDetail.title!;
}
return MarqueeText(
@@ -2320,71 +2349,74 @@ class HeaderControlState extends State<HeaderControl> {
return const SizedBox.shrink();
},
),
if (!isFSOrPip && videoDetailCtr.isUgc)
SizedBox(
width: 42,
height: 34,
child: IconButton(
tooltip: '听音频',
style: const ButtonStyle(
padding: WidgetStatePropertyAll(EdgeInsets.zero),
),
onPressed: videoDetailCtr.toAudioPage,
icon: const Icon(
Icons.headphones_outlined,
size: 19,
color: Colors.white,
if (!isFileSource) ...[
if ((!isFSOrPip && videoDetailCtr.isUgc))
SizedBox(
width: 42,
height: 34,
child: IconButton(
tooltip: '听音频',
style: const ButtonStyle(
padding: WidgetStatePropertyAll(EdgeInsets.zero),
),
onPressed: videoDetailCtr.toAudioPage,
icon: const Icon(
Icons.headphones_outlined,
size: 19,
color: Colors.white,
),
),
),
),
if (plPlayerController.enableSponsorBlock == true)
SizedBox(
width: 42,
height: 34,
child: IconButton(
tooltip: '提交片段',
style: const ButtonStyle(
padding: WidgetStatePropertyAll(EdgeInsets.zero),
),
onPressed: () => videoDetailCtr.onBlock(context),
icon: const Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
Icon(
Icons.shield_outlined,
size: 19,
color: Colors.white,
),
Icon(
Icons.play_arrow_rounded,
size: 13,
color: Colors.white,
),
],
),
),
),
Obx(
() => videoDetailCtr.segmentProgressList.isNotEmpty
? SizedBox(
width: 42,
height: 34,
child: IconButton(
tooltip: '片段信息',
style: const ButtonStyle(
padding: WidgetStatePropertyAll(EdgeInsets.zero),
),
onPressed: () => videoDetailCtr.showSBDetail(context),
icon: const Icon(
MdiIcons.advertisements,
if (plPlayerController.enableSponsorBlock == true)
SizedBox(
width: 42,
height: 34,
child: IconButton(
tooltip: '提交片段',
style: const ButtonStyle(
padding: WidgetStatePropertyAll(EdgeInsets.zero),
),
onPressed: () => videoDetailCtr.onBlock(context),
icon: const Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
Icon(
Icons.shield_outlined,
size: 19,
color: Colors.white,
),
),
)
: const SizedBox.shrink(),
),
Icon(
Icons.play_arrow_rounded,
size: 13,
color: Colors.white,
),
],
),
),
),
Obx(
() => videoDetailCtr.segmentProgressList.isNotEmpty
? SizedBox(
width: 42,
height: 34,
child: IconButton(
tooltip: '片段信息',
style: const ButtonStyle(
padding: WidgetStatePropertyAll(EdgeInsets.zero),
),
onPressed: () =>
videoDetailCtr.showSBDetail(context),
icon: const Icon(
MdiIcons.advertisements,
size: 19,
color: Colors.white,
),
),
)
: const SizedBox.shrink(),
),
],
if (isFSOrPip || Utils.isDesktop) ...[
SizedBox(
width: 42,