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);
}

View File

@@ -0,0 +1,295 @@
import 'package:PiliPlus/utils/extension.dart';
sealed class BiliDownloadMediaInfo {
const BiliDownloadMediaInfo();
Map<String, String> get httpHeader => {};
Map<String, dynamic> toJson();
}
class Type1 extends BiliDownloadMediaInfo {
final int availablePeriodMilli;
final String description;
final String format;
final String? from;
final bool intact;
final bool isDownloaded;
final bool isResolved;
final String marlinToken;
final bool needLogin;
final bool needVip;
final int parseTimestampMilli;
final List<Type1PlayerCodecConfig> playerCodecConfigList;
final int playerError;
final int quality;
final List<Type1Segment> segmentList;
final int timeLength;
final String? typeTag;
final String? userAgent;
final String? referer;
final int videoCodecId;
final bool videoProject;
@override
Map<String, String> get httpHeader => {
if (referer?.isNotEmpty ?? false) 'referer': referer!,
if (userAgent?.isNotEmpty ?? false) 'user-agent': userAgent!,
};
Type1({
required this.availablePeriodMilli,
required this.description,
required this.format,
this.from,
required this.intact,
required this.isDownloaded,
required this.isResolved,
required this.marlinToken,
required this.needLogin,
required this.needVip,
required this.parseTimestampMilli,
required this.playerCodecConfigList,
required this.playerError,
required this.quality,
required this.segmentList,
required this.timeLength,
this.typeTag,
this.userAgent,
this.referer,
required this.videoCodecId,
required this.videoProject,
});
factory Type1.fromJson(Map<String, dynamic> json) => Type1(
availablePeriodMilli: json['available_period_milli'] as int,
description: json['description'] as String,
format: json['format'] as String,
from: json['from'] as String?,
intact: json['intact'] as bool,
isDownloaded: json['is_downloaded'] as bool,
isResolved: json['is_resolved'] as bool,
marlinToken: json['marlin_token'] as String,
needLogin: json['need_login'] as bool,
needVip: json['need_vip'] as bool,
parseTimestampMilli: json['parse_timestamp_milli'] as int,
playerCodecConfigList: (json['player_codec_config_list'] as List<dynamic>)
.map((e) => Type1PlayerCodecConfig.fromJson(e as Map<String, dynamic>))
.toList(),
playerError: json['player_error'] as int,
quality: json['quality'] as int,
segmentList: (json['segment_list'] as List<dynamic>)
.map((e) => Type1Segment.fromJson(e as Map<String, dynamic>))
.toList(),
timeLength: json['time_length'] as int,
typeTag: json['type_tag'] as String?,
userAgent: json['user_agent'] as String?,
referer: json['referer'] as String?,
videoCodecId: json['video_codec_id'] as int,
videoProject: json['video_project'] as bool,
);
@override
Map<String, dynamic> toJson() => <String, dynamic>{
'available_period_milli': availablePeriodMilli,
'description': description,
'format': format,
'from': ?from,
'intact': intact,
'is_downloaded': isDownloaded,
'is_resolved': isResolved,
'marlin_token': marlinToken,
'need_login': needLogin,
'need_vip': needVip,
'parse_timestamp_milli': parseTimestampMilli,
'player_codec_config_list': playerCodecConfigList
.map((e) => e.toJson())
.toList(),
'player_error': playerError,
'quality': quality,
'segment_list': segmentList.map((e) => e.toJson()).toList(),
'time_length': timeLength,
'type_tag': ?typeTag,
'user_agent': ?userAgent,
'referer': ?referer,
'video_codec_id': videoCodecId,
'video_project': videoProject,
};
}
class Type1PlayerCodecConfig {
final String player;
final bool useIjkMediaCodec;
Type1PlayerCodecConfig({
required this.player,
required this.useIjkMediaCodec,
});
factory Type1PlayerCodecConfig.fromJson(Map<String, dynamic> json) =>
Type1PlayerCodecConfig(
player: json['player'] as String,
useIjkMediaCodec: json['use_ijk_media_codec'] as bool,
);
Map<String, dynamic> toJson() => <String, dynamic>{
'player': player,
'use_ijk_media_codec': useIjkMediaCodec,
};
}
class Type1Segment {
final List<String> backupUrls;
final int bytes;
final int duration;
final String md5;
final String metaUrl;
final int order;
final String url;
Type1Segment({
required this.backupUrls,
required this.bytes,
this.duration = 0,
required this.md5,
required this.metaUrl,
required this.order,
required this.url,
});
factory Type1Segment.fromJson(Map<String, dynamic> json) => Type1Segment(
backupUrls: List<String>.from(json['backup_urls']),
bytes: json['bytes'] as int,
duration: json['duration'] as int,
md5: json['md5'] as String,
metaUrl: json['meta_url'] as String,
order: json['order'] as int,
url: json['url'] as String,
);
Map<String, dynamic> toJson() => <String, dynamic>{
'backup_urls': backupUrls,
'bytes': bytes,
'duration': duration,
'md5': md5,
'meta_url': metaUrl,
'order': order,
'url': url,
};
}
class Type2 extends BiliDownloadMediaInfo {
final int duration;
final List<Type2File> video;
final List<Type2File>? audio;
final String? userAgent;
final String? referer;
Type2({
this.duration = 0,
required this.video,
this.audio,
this.userAgent,
this.referer,
});
@override
Map<String, String> get httpHeader => {
if (referer?.isNotEmpty ?? false) 'referer': referer!,
if (userAgent?.isNotEmpty ?? false) 'user-agent': userAgent!,
};
factory Type2.fromJson(Map<String, dynamic> json) => Type2(
duration: json['duration'] as int,
video: (json['video'] as List<dynamic>)
.map((e) => Type2File.fromJson(e as Map<String, dynamic>))
.toList(),
audio: (json['audio'] as List<dynamic>?)
?.map((e) => Type2File.fromJson(e as Map<String, dynamic>))
.toList(),
userAgent: json['user_agent'] as String?,
referer: json['referer'] as String?,
);
@override
Map<String, dynamic> toJson() => <String, dynamic>{
'duration': duration,
'video': video.map((e) => e.toJson()).toList(),
'audio': ?audio?.map((e) => e.toJson()).toList(),
'user_agent': ?userAgent,
'referer': ?referer,
};
}
class Type2File {
final int id;
final String baseUrl;
final List<String>? backupUrl;
final int bandwidth;
final int codecid;
int size;
final String md5;
final bool noRexcode;
final String frameRate;
final int width;
final int height;
final int dashDrmType;
Type2File({
required this.id,
required this.baseUrl,
this.backupUrl,
required this.bandwidth,
required this.codecid,
required this.size,
required this.md5,
required this.noRexcode,
this.frameRate = '',
this.width = 1,
this.height = 1,
this.dashDrmType = 0,
});
factory Type2File.fromJson(Map<String, dynamic> json) => Type2File(
id: json['id'] as int,
baseUrl: json['base_url'] as String,
backupUrl: (json['backup_url'] as List<dynamic>?)?.fromCast(),
bandwidth: json['bandwidth'] as int,
codecid: json['codecid'] as int,
size: json['size'] as int,
md5: json['md5'] as String,
noRexcode: json['no_rexcode'] as bool,
frameRate: json['frame_rate'] as String? ?? '',
width: json['width'] as int,
height: json['height'] as int,
dashDrmType: json['dash_drm_type'] as int,
);
Map<String, dynamic> toJson() => <String, dynamic>{
'id': id,
'base_url': baseUrl,
'backup_url': ?backupUrl,
'bandwidth': bandwidth,
'codecid': codecid,
'size': size,
'md5': md5,
'no_rexcode': noRexcode,
'frame_rate': frameRate,
'width': width,
'height': height,
'dash_drm_type': dashDrmType,
};
}
class None extends BiliDownloadMediaInfo {
final String message;
const None({
required this.message,
});
@override
Map<String, dynamic> toJson() {
throw UnimplementedError();
}
}

View File

@@ -0,0 +1,23 @@
import 'package:PiliPlus/models_new/download/bili_download_entry_info.dart';
class DownloadPageInfo {
final String pageId;
final String dirPath;
final String title;
String cover;
int sortKey;
final int? seasonType;
final List<BiliDownloadEntryInfo> entrys;
BiliDownloadEntryInfo? entry;
DownloadPageInfo({
required this.pageId,
required this.dirPath,
required this.title,
required this.cover,
required this.sortKey,
this.seasonType,
required this.entrys,
this.entry,
});
}