Files
PiliPlus/lib/pages/video/download_panel/view.dart
2026-02-22 15:22:12 +08:00

599 lines
20 KiB
Dart

import 'package:PiliPlus/common/constants.dart';
import 'package:PiliPlus/common/widgets/badge.dart';
import 'package:PiliPlus/common/widgets/dialog/dialog.dart';
import 'package:PiliPlus/common/widgets/image/network_img_layer.dart';
import 'package:PiliPlus/common/widgets/stat/stat.dart';
import 'package:PiliPlus/models/common/badge_type.dart';
import 'package:PiliPlus/models/common/stat_type.dart';
import 'package:PiliPlus/models/common/video/video_quality.dart';
import 'package:PiliPlus/models_new/pgc/pgc_info_model/episode.dart' as pgc;
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/pages/download/view.dart';
import 'package:PiliPlus/pages/video/controller.dart';
import 'package:PiliPlus/pages/video/introduction/ugc/controller.dart';
import 'package:PiliPlus/pages/video/introduction/ugc/widgets/page.dart';
import 'package:PiliPlus/services/download/download_service.dart';
import 'package:PiliPlus/utils/date_utils.dart';
import 'package:PiliPlus/utils/duration_utils.dart';
import 'package:PiliPlus/utils/extension/num_ext.dart';
import 'package:PiliPlus/utils/id_utils.dart';
import 'package:PiliPlus/utils/platform_utils.dart';
import 'package:PiliPlus/utils/storage_pref.dart';
import 'package:PiliPlus/utils/utils.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/foundation.dart' show kDebugMode, kReleaseMode;
import 'package:flutter/material.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:get/get.dart';
import 'package:super_sliver_list/super_sliver_list.dart';
class DownloadPanel extends StatefulWidget {
const DownloadPanel({
super.key,
required this.index,
this.pgcItem,
this.videoDetail,
required this.episodes,
required this.scrollController,
required this.videoDetailController,
required this.heroTag,
this.ugcIntroController,
required this.cidSet,
});
final int index;
final PgcInfoModel? pgcItem;
final VideoDetailData? videoDetail;
final List<ugc.BaseEpisodeItem> episodes;
final ScrollController scrollController;
final VideoDetailController videoDetailController;
final String heroTag;
final UgcIntroController? ugcIntroController;
final Set<int> cidSet;
@override
State<DownloadPanel> createState() => _DownloadPanelState();
}
class _DownloadPanelState extends State<DownloadPanel> {
final DownloadService _downloadService = Get.find<DownloadService>();
final ListController _listController = ListController();
late final cidSet = widget.cidSet;
VideoQuality _quality = VideoQuality.fromCode(Pref.defaultVideoQa);
@override
void initState() {
super.initState();
if (widget.index != -1) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_listController.jumpToItem(
index: widget.index,
scrollController: widget.scrollController,
alignment: 0,
);
});
}
}
@override
void dispose() {
_listController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final dividerColor = theme.colorScheme.outline.withValues(alpha: 0.2);
return Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
_buildHeader(theme),
_buildBody(theme),
Divider(height: 1, color: dividerColor),
_buildFooter(theme, dividerColor),
],
);
}
Widget _buildHeader(ThemeData theme) {
final textStyle = TextStyle(color: theme.colorScheme.onSurfaceVariant);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 0, 12),
child: Row(
spacing: 16,
children: [
Text(
'最高画质',
style: textStyle,
),
Builder(
builder: (context) => PopupMenuButton<VideoQuality>(
initialValue: _quality,
onSelected: (value) {
_quality = value;
(context as Element).markNeedsBuild();
},
itemBuilder: (context) => VideoQuality.values
.map(
(e) => PopupMenuItem(
value: e,
child: Text(e.desc),
),
)
.toList(),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_quality.desc,
style: const TextStyle(height: 1),
strutStyle: const StrutStyle(height: 1, leading: 0),
),
Icon(
size: 18,
Icons.keyboard_arrow_down,
color: theme.colorScheme.onSurfaceVariant,
),
],
),
),
),
),
if (kDebugMode || PlatformUtils.isMobile) ...[
const Spacer(),
StreamBuilder(
stream: Connectivity().onConnectivityChanged,
builder: (context, snapshot) {
if (snapshot.data case final data?) {
final network = data.contains(ConnectivityResult.wifi)
? 'WIFI'
: '数据';
return Text('当前网络:$network', style: textStyle);
}
return const SizedBox.shrink();
},
),
const SizedBox(width: 4),
],
],
),
);
}
Widget _buildBody(ThemeData theme) {
final episodes = widget.episodes;
return Expanded(
child: Material(
type: MaterialType.transparency,
child: CustomScrollView(
controller: widget.scrollController,
slivers: [
SliverPadding(
padding: const EdgeInsets.only(bottom: 100),
sliver: SuperSliverList.builder(
itemCount: episodes.length,
listController: _listController,
itemBuilder: (context, index) {
final episode = episodes[index];
final hasParts =
episode is ugc.EpisodeItem && episode.pages!.length > 1;
Widget child = _buildItem(
theme: theme,
index: index,
hasParts: hasParts,
episode: episode,
isCurrentIndex: index == widget.index,
);
if (hasParts) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
child,
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 5,
),
child: PagesPanel(
list: episode.pages,
cover: episode.arc?.pic,
heroTag: widget.heroTag,
ugcIntroController: widget.ugcIntroController!,
bvid: episode.bvid ?? IdUtils.av2bv(episode.aid!),
cidSet: cidSet,
onDownload: (Part part) => _onDownload(
index: index,
episode: part,
parent: episode,
),
),
),
],
);
}
return child;
},
),
),
],
),
),
);
}
late final int? vipStatus = Pref.userInfoCache?.vipStatus;
@pragma('vm:notify-debugger-on-exception')
bool _onDownload({
required int index,
required ugc.BaseEpisodeItem episode,
bool isFromList = false,
bool isDownloadAll = false,
ugc.EpisodeItem? parent,
}) {
final cid = episode.cid;
// on download
if (cid == null) {
SmartDialog.showToast('null cid');
return false;
}
if (cidSet.contains(cid)) {
if (kDebugMode) {
SmartDialog.showToast('downloaded');
}
return false;
}
if (kReleaseMode && episode.badge == '会员') {
if (vipStatus != 1) {
if (!isDownloadAll) {
SmartDialog.showToast('需要大会员');
}
return false;
}
}
if (episode is ugc.EpisodeItem) {
final pages = episode.pages!;
if (pages.length > 1) {
if (isFromList && kDebugMode) {
SmartDialog.showToast('hasParts');
}
if (isDownloadAll) {
for (int i = 0; i < pages.length; i++) {
_onDownload(
index: i,
episode: pages[i],
parent: episode,
);
}
return true;
}
return false;
}
}
try {
switch (episode) {
case Part part:
_downloadService.downloadVideo(
part,
parent == null ? widget.videoDetail : null,
parent,
_quality,
);
break;
case ugc.EpisodeItem episode:
_downloadService.downloadVideo(
episode.pages!.first,
null,
episode,
_quality,
);
break;
case pgc.EpisodeItem episode:
_downloadService.downloadBangumi(
index,
widget.pgcItem!,
episode,
_quality,
);
break;
}
cidSet.add(cid);
return true;
} catch (e, s) {
Utils.reportError(e, s);
SmartDialog.showToast(e.toString());
}
return false;
}
Widget _buildItem({
required ThemeData theme,
required int index,
required bool hasParts,
required bool isCurrentIndex,
required ugc.BaseEpisodeItem episode,
}) {
late String title;
num? duration;
int? pubdate;
int? view;
int? danmaku;
bool? isCharging;
int? cid;
String? cover;
bool? cacheWidth;
switch (episode) {
case Part part:
cid = part.cid;
cover = part.firstFrame ?? widget.videoDetail?.pic;
title = part.part ?? widget.videoDetail!.title!;
duration = part.duration;
pubdate = part.ctime;
cacheWidth = part.dimension?.cacheWidth;
break;
case ugc.EpisodeItem item:
cid = item.cid;
title = item.title!;
if (item.arc case final arc?) {
cover = arc.pic;
duration = arc.duration;
pubdate = arc.pubdate;
if (arc.stat case final stat?) {
view = stat.view;
danmaku = stat.danmaku;
}
cacheWidth = arc.dimension?.cacheWidth;
}
if (item.attribute == 8) {
isCharging = true;
}
break;
case pgc.EpisodeItem item:
cid = item.cid;
title = item.showTitle ?? item.title!;
cover = item.cover;
if (item.from == 'pugv') {
duration = item.duration;
view = item.play;
} else {
duration = item.duration == null ? null : item.duration! ~/ 1000;
}
pubdate = item.pubTime;
cacheWidth = item.dimension?.cacheWidth;
break;
}
late final primary = theme.colorScheme.primary;
return Padding(
padding: const EdgeInsets.only(bottom: 2),
child: SizedBox(
height: 98,
child: Builder(
builder: (context) {
return Material(
type: MaterialType.transparency,
child: InkWell(
onTap: () {
if (_onDownload(
index: index,
episode: episode,
isFromList: true,
)) {
(context as Element).markNeedsBuild();
}
},
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: StyleString.safeSpace,
vertical: 5,
),
child: Row(
spacing: 10,
children: [
if (cover?.isNotEmpty == true)
Stack(
clipBehavior: Clip.none,
children: [
NetworkImgLayer(
src: cover,
width: 140.8,
height: 88,
cacheWidth: cacheWidth,
),
if (duration != null && duration > 0)
PBadge(
text: DurationUtils.formatDuration(duration),
right: 6.0,
bottom: 6.0,
type: PBadgeType.gray,
),
if (isCharging == true)
const PBadge(
text: '充电专属',
top: 6,
right: 6,
type: PBadgeType.error,
)
else if (episode.badge != null)
PBadge(
text: episode.badge,
top: 6,
right: 6,
type: switch (episode.badge) {
'预告' => PBadgeType.gray,
'限免' => PBadgeType.free,
_ => PBadgeType.primary,
},
),
],
)
else if (isCurrentIndex)
Image.asset(
'assets/images/live.png',
color: primary,
height: 12,
cacheHeight: 12.cacheSize(context),
semanticLabel: '正在播放:',
),
Expanded(
child: Stack(
clipBehavior: Clip.none,
children: [
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
title,
textAlign: TextAlign.start,
style: TextStyle(
fontSize:
theme.textTheme.bodyMedium!.fontSize,
height: 1.42,
letterSpacing: 0.3,
fontWeight: isCurrentIndex
? FontWeight.bold
: null,
color: isCurrentIndex ? primary : null,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
if (pubdate != null)
Text(
DateFormatUtils.format(pubdate),
maxLines: 1,
style: TextStyle(
fontSize: 12,
height: 1,
color: theme.colorScheme.outline,
overflow: TextOverflow.clip,
),
),
const SizedBox(height: 2),
Row(
spacing: 8,
children: [
if (view != null)
StatWidget(
value: view,
type: StatType.play,
),
if (danmaku != null)
StatWidget(
value: danmaku,
type: StatType.danmaku,
),
],
),
],
),
if (!hasParts && cidSet.contains(cid))
Positioned(
bottom: 0,
right: 0,
child: Icon(
size: 13,
color: theme.colorScheme.secondary.withValues(
alpha: 0.8,
),
FontAwesomeIcons.circleDown,
),
),
],
),
),
],
),
),
),
);
},
),
),
);
}
Widget _buildFooter(ThemeData theme, Color dividerColor) {
return Container(
color: theme.hoverColor,
padding: EdgeInsets.only(
bottom: MediaQuery.viewPaddingOf(context).bottom,
),
child: Row(
children: [
_buildBottomBtn(
text: '缓存全部',
onTap: () {
showConfirmDialog(
context: context,
title: '确定缓存全部?',
onConfirm: () {
for (int i = 0; i < widget.episodes.length; i++) {
_onDownload(
index: i,
episode: widget.episodes[i],
isDownloadAll: true,
);
}
if (mounted) setState(() {});
},
);
},
),
SizedBox(
height: 20,
child: VerticalDivider(
width: 1,
color: dividerColor,
),
),
_buildBottomBtn(
text: '查看缓存',
onTap: () => Navigator.of(context).push(
GetPageRoute(page: () => const DownloadPage()),
),
),
],
),
);
}
Widget _buildBottomBtn({
required String text,
required VoidCallback onTap,
}) {
return Expanded(
child: InkWell(
onTap: onTap,
child: SizedBox(
height: 40,
width: double.infinity,
child: Center(
child: Text(
text,
textAlign: TextAlign.center,
),
),
),
),
);
}
}