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

@@ -0,0 +1,469 @@
import 'package:PiliPlus/common/constants.dart';
import 'package:PiliPlus/models/common/video/video_type.dart';
import 'package:PiliPlus/services/download/download_service.dart';
import 'package:PiliPlus/utils/cache_manager.dart';
import 'package:PiliPlus/utils/page_utils.dart';
import 'package:flutter/material.dart';
import 'package:get/get_state_manager/src/rx_flutter/rx_obx_widget.dart';
import 'package:get/route_manager.dart';
class BiliDownloadEntryInfo {
int mediaType;
final bool hasDashAudio;
bool isCompleted;
int totalBytes;
int downloadedBytes;
final String title;
String? typeTag;
final String cover;
int? videoQuality;
int preferedVideoQuality;
String qualityPithyDescription;
final int guessedTotalBytes;
int totalTimeMilli;
final int danmakuCount;
final int timeUpdateStamp;
final int timeCreateStamp;
final bool canPlayInAdvance;
bool interruptTransformTempFile;
final int avid;
final int? spid;
final String bvid;
final int? ownerId;
final String? ownerName;
PageInfo? pageData;
final String? seasonId;
final SourceInfo? source;
EpInfo? ep;
late String pageDirPath;
late String entryDirPath;
DownloadStatus? status;
int get cid => source?.cid ?? pageData!.cid;
String get pageId => seasonId ?? avid.toString();
int get sortKey => ep?.sortIndex ?? pageData!.cid;
String get showTitle {
if (pageData case final pageData?) {
return pageData.part?.isNotEmpty == true ? pageData.part! : title;
}
if (ep case final ep?) {
return ep.showTitle ?? '${ep.index} ${ep.indexTitle}';
}
return title;
}
Widget moreBtn(ThemeData theme) => SizedBox(
width: 29,
height: 29,
child: PopupMenuButton(
padding: EdgeInsets.zero,
position: PopupMenuPosition.under,
icon: Icon(
Icons.more_vert_outlined,
color: theme.colorScheme.outline,
size: 18,
),
itemBuilder: (_) => [
PopupMenuItem(
height: 35,
child: const Text(
'查看详情页',
style: TextStyle(fontSize: 13),
),
onTap: () {
if (ep case final ep?) {
if (ep.from == VideoType.pugv.name) {
PageUtils.viewPugv(
seasonId: seasonId,
epId: ep.episodeId,
);
} else {
PageUtils.viewPgc(
seasonId: seasonId,
epId: ep.episodeId,
);
}
return;
}
PageUtils.toVideoPage(
aid: avid,
bvid: bvid,
cid: cid,
epId: ep?.episodeId,
title: title,
cover: cover,
);
},
),
if (ownerId case final mid?)
PopupMenuItem(
height: 35,
child: Text(
'访问${ownerName != null ? '$ownerName' : '用户主页'}',
style: const TextStyle(
fontSize: 13,
),
),
onTap: () => Get.toNamed('/member?mid=$mid'),
),
],
),
);
Widget progressWidget({
required ThemeData theme,
required DownloadService downloadService,
required bool isPage,
}) {
return Obx(() {
final curDownload = downloadService.curDownload.value;
final isCurr =
curDownload != null &&
(isPage ? curDownload.pageId == pageId : curDownload.cid == cid);
late final status = curDownload?.status;
late final isDownloading = status == DownloadStatus.downloading;
late final color = isCurr && status != DownloadStatus.pause
? theme.colorScheme.primary
: theme.colorScheme.outline;
return Column(
spacing: 6,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
isCurr ? status!.message : '暂停中',
style: TextStyle(
fontSize: 12,
height: 1,
color: color,
),
),
Text(
isCurr
? isDownloading || status == DownloadStatus.pause
? ' ${CacheManager.formatSize(curDownload.downloadedBytes)}/${CacheManager.formatSize(curDownload.totalBytes)}'
: ''
: totalBytes == 0
? ''
: ' ${CacheManager.formatSize(downloadedBytes)}/${CacheManager.formatSize(totalBytes)}',
style: TextStyle(
fontSize: 12,
height: 1,
color: color,
),
),
],
),
LinearProgressIndicator(
// ignore: deprecated_member_use
year2023: true,
minHeight: 2.5,
borderRadius: StyleString.mdRadius,
color: color,
backgroundColor: theme.highlightColor,
value: isCurr
? curDownload.totalBytes == 0
? 0
: curDownload.downloadedBytes / curDownload.totalBytes
: totalBytes == 0
? 0
: downloadedBytes / totalBytes,
),
],
);
});
}
BiliDownloadEntryInfo({
this.mediaType = 1,
this.hasDashAudio = false,
required this.isCompleted,
required this.totalBytes,
required this.downloadedBytes,
required this.title,
this.typeTag,
required this.cover,
this.videoQuality,
required this.preferedVideoQuality,
this.qualityPithyDescription = '',
required this.guessedTotalBytes,
required this.totalTimeMilli,
required this.danmakuCount,
this.timeUpdateStamp = 0,
this.timeCreateStamp = 0,
this.canPlayInAdvance = false,
this.interruptTransformTempFile = false,
required this.avid,
this.spid,
required this.bvid,
this.ownerId,
this.ownerName,
this.pageData,
this.seasonId,
this.source,
this.ep,
});
factory BiliDownloadEntryInfo.fromJson(Map<String, dynamic> json) =>
BiliDownloadEntryInfo(
mediaType: json['media_type'] as int,
hasDashAudio: json['has_dash_audio'] as bool,
isCompleted: json['is_completed'] as bool,
totalBytes: json['total_bytes'] as int,
downloadedBytes: json['downloaded_bytes'] as int,
title: json['title'] as String,
typeTag: json['type_tag'] as String?,
cover: json['cover'] as String,
videoQuality: json['video_quality'] as int?,
preferedVideoQuality: json['prefered_video_quality'] as int,
qualityPithyDescription: json['quality_pithy_description'] as String,
guessedTotalBytes: json['guessed_total_bytes'] as int,
totalTimeMilli: json['total_time_milli'] as int,
danmakuCount: json['danmaku_count'] as int,
timeUpdateStamp: json['time_update_stamp'] as int,
timeCreateStamp: json['time_create_stamp'] as int,
canPlayInAdvance: json['can_play_in_advance'] as bool,
interruptTransformTempFile:
json['interrupt_transform_temp_file'] as bool,
avid: json['avid'] as int,
spid: json['spid'] as int?,
bvid: json['bvid'] as String,
ownerId: json['owner_id'] as int?,
ownerName: json['owner_name'] as String?,
pageData: json['page_data'] != null
? PageInfo.fromJson(json['page_data'] as Map<String, dynamic>)
: null,
seasonId: json['season_id'] as String?,
source: json['source'] != null
? SourceInfo.fromJson(json['source'] as Map<String, dynamic>)
: null,
ep: json['ep'] != null
? EpInfo.fromJson(json['ep'] as Map<String, dynamic>)
: null,
);
Map<String, dynamic> toJson() => <String, dynamic>{
'media_type': mediaType,
'has_dash_audio': hasDashAudio,
'is_completed': isCompleted,
'total_bytes': totalBytes,
'downloaded_bytes': downloadedBytes,
'title': title,
'type_tag': ?typeTag,
'cover': cover,
'video_quality': ?videoQuality,
'prefered_video_quality': preferedVideoQuality,
'quality_pithy_description': qualityPithyDescription,
'guessed_total_bytes': guessedTotalBytes,
'total_time_milli': totalTimeMilli,
'danmaku_count': danmakuCount,
'time_update_stamp': timeUpdateStamp,
'time_create_stamp': timeCreateStamp,
'can_play_in_advance': canPlayInAdvance,
'interrupt_transform_temp_file': interruptTransformTempFile,
'avid': avid,
'spid': ?spid,
'bvid': bvid,
'owner_id': ownerId,
'owner_name': ownerName,
'page_data': ?pageData?.toJson(),
'season_id': ?seasonId,
'source': ?source?.toJson(),
'ep': ?ep?.toJson(),
};
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
if (other is BiliDownloadEntryInfo) {
return cid == other.cid;
}
return false;
}
@override
int get hashCode => cid.hashCode;
}
class PageInfo {
final int cid;
final int page;
final String? from;
final String? part;
final String? vid;
final bool hasAlias;
final int tid;
int width;
int height;
final int rotate;
final String? downloadTitle;
final String? downloadSubtitle;
PageInfo({
required this.cid,
required this.page,
this.from,
this.part,
this.vid,
required this.hasAlias,
required this.tid,
this.width = 0,
this.height = 0,
this.rotate = 0,
this.downloadTitle,
this.downloadSubtitle,
});
factory PageInfo.fromJson(Map<String, dynamic> json) => PageInfo(
cid: json['cid'] as int,
page: json['page'] as int,
from: json['from'] as String?,
part: json['part'] as String?,
vid: json['vid'] as String?,
hasAlias: json['has_alias'] as bool,
tid: json['tid'] as int,
width: json['width'] as int,
height: json['height'] as int,
rotate: json['rotate'] as int,
downloadTitle: json['download_title'] as String?,
downloadSubtitle: json['download_subtitle'] as String?,
);
Map<String, dynamic> toJson() => <String, dynamic>{
'cid': cid,
'page': page,
'from': ?from,
'part': ?part,
'vid': ?vid,
'has_alias': hasAlias,
'tid': tid,
'width': width,
'height': height,
'rotate': rotate,
'download_title': downloadTitle,
'download_subtitle': downloadSubtitle,
};
}
class SourceInfo {
final int avId;
final int cid;
SourceInfo({
required this.avId,
required this.cid,
});
factory SourceInfo.fromJson(Map<String, dynamic> json) => SourceInfo(
avId: json['av_id'] as int,
cid: json['cid'] as int,
);
Map<String, dynamic> toJson() => <String, dynamic>{
'av_id': avId,
'cid': cid,
};
}
class EpInfo {
final int avId;
final int page;
final int danmaku;
final String cover;
final int episodeId;
final String index;
final String indexTitle;
final String? showTitle;
final String from;
final int seasonType;
int width;
int height;
final int rotate;
final String link;
final String bvid;
final int sortIndex;
EpInfo({
required this.avId,
required this.page,
required this.danmaku,
required this.cover,
required this.episodeId,
required this.index,
required this.indexTitle,
this.showTitle,
required this.from,
required this.seasonType,
required this.width,
required this.height,
required this.rotate,
this.link = '',
this.bvid = '',
this.sortIndex = 0,
});
factory EpInfo.fromJson(Map<String, dynamic> json) => EpInfo(
avId: json['av_id'] as int,
page: json['page'] as int,
danmaku: json['danmaku'] as int,
cover: json['cover'] as String,
episodeId: json['episode_id'] as int,
index: json['index'] as String,
indexTitle: json['index_title'] as String,
showTitle: json['show_title'] as String?,
from: json['from'] as String,
seasonType: json['season_type'] as int,
width: json['width'] as int,
height: json['height'] as int,
rotate: json['rotate'] as int,
link: json['link'] as String,
bvid: json['bvid'] as String,
sortIndex: json['sort_index'] as int,
);
Map<String, dynamic> toJson() => <String, dynamic>{
'av_id': avId,
'page': page,
'danmaku': danmaku,
'cover': cover,
'episode_id': episodeId,
'index': index,
'index_title': indexTitle,
'show_title': showTitle,
'from': from,
'season_type': seasonType,
'width': width,
'height': height,
'rotate': rotate,
'link': link,
'bvid': bvid,
'sort_index': sortIndex,
};
}
enum DownloadStatus {
downloading('正在下载'),
audioDownloading('正在下载音频'),
getDanmaku('获取弹幕'),
getPlayUrl('获取播放地址'),
//
completed('下载完成'),
failDownload('下载失败'),
failDownloadAudio('音频下载失败'),
failDanmaku('获取弹幕失败'),
failPlayUrl('获取播放地址失败'),
pause('暂停中'),
wait('等待中');
final String message;
const DownloadStatus(this.message);
}