diff --git a/README.md b/README.md index 1e7a98614..6907b9b0c 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ ## feat +- [x] 离线缓存/播放 - [x] 移动端支持点击弹幕悬停,点赞、复制、举报 by [@My-Responsitories](https://github.com/My-Responsitories) - [x] 播放音频 - [x] 跳过番剧片头/片尾 diff --git a/lib/grpc/grpc_req.dart b/lib/grpc/grpc_req.dart index 74e907f6d..6068e7ca5 100644 --- a/lib/grpc/grpc_req.dart +++ b/lib/grpc/grpc_req.dart @@ -50,7 +50,6 @@ class GrpcReq { platform: _device, ).writeToBuffer(), ); - options = Options(headers: headers, responseType: ResponseType.bytes); } static final Map headers = { @@ -112,7 +111,7 @@ class GrpcReq { 'x-bili-exps-bin': '', }; - static Options options = Options( + static final Options options = Options( headers: headers, responseType: ResponseType.bytes, ); @@ -147,8 +146,8 @@ class GrpcReq { options: options, ); - if (response.data is Map) { - return Error(response.data['message']); + if (response.data case final Map map) { + return Error(map['message']); } if (response.headers.value('Grpc-Status') == '0') { diff --git a/lib/http/download.dart b/lib/http/download.dart new file mode 100644 index 000000000..7d9f31943 --- /dev/null +++ b/lib/http/download.dart @@ -0,0 +1,236 @@ +import 'package:PiliPlus/http/video.dart'; +import 'package:PiliPlus/models/common/account_type.dart'; +import 'package:PiliPlus/models/common/video/audio_quality.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/download/bili_download_media_file_info.dart'; +import 'package:PiliPlus/utils/accounts.dart'; +import 'package:PiliPlus/utils/extension.dart'; +import 'package:PiliPlus/utils/storage_pref.dart'; +import 'package:PiliPlus/utils/video_utils.dart'; +import 'package:get/get_utils/get_utils.dart'; + +abstract final class DownloadHttp { + static const String referer = "https://www.bilibili.com/"; + static const String userAgent = "Bilibili Freedoooooom/MarkII"; + + static Future getVideoUrl({ + required BiliDownloadEntryInfo entry, + SourceInfo? source, + PageInfo? pageData, + EpInfo? ep, + }) async { + final isLogin = Accounts.get(AccountType.video).isLogin; + final res = await VideoHttp.videoUrl( + avid: entry.avid, + bvid: entry.bvid, + cid: entry.cid, + seasonId: entry.seasonId, + epid: ep?.episodeId, + qn: entry.preferedVideoQuality, + tryLook: !isLogin && Pref.p1080, + videoType: switch (ep?.from) { + 'pugv' => VideoType.pugv, + != null when isLogin => VideoType.pgc, + _ => VideoType.ugc, + }, + ); + if (res.isSuccess) { + final PlayUrlModel data = res.data; + final Dash? dash = data.dash; + if (dash != null) { + final List videoList = dash.video!; + final curHighestVideoQa = videoList.first.quality.code; + final preferVideoQa = entry.preferedVideoQuality; + int targetVideoQa = curHighestVideoQa; + if (data.acceptQuality?.isNotEmpty == true && + preferVideoQa <= curHighestVideoQa) { + // 如果预设的画质低于当前最高 + targetVideoQa = data.acceptQuality!.findClosestTarget( + (e) => e <= preferVideoQa, + (a, b) => a > b ? a : b, + ); + } + + /// 取出符合当前画质的videoList + final List videosList = videoList + .where((e) => e.quality.code == targetVideoQa) + .toList(); + + /// 优先顺序 设置中指定解码格式 -> 当前可选的首个解码格式 + final List supportFormats = data.supportFormats!; + // 根据画质选编码格式 + final FormatItem targetSupportFormats = supportFormats.firstWhere( + (e) => e.quality == targetVideoQa, + orElse: () => supportFormats.first, + ); + final List supportDecodeFormats = targetSupportFormats.codecs!; + + entry + ..typeTag = targetVideoQa.toString() + ..videoQuality = targetVideoQa + ..preferedVideoQuality = targetVideoQa + ..qualityPithyDescription = + targetSupportFormats.newDesc ?? + VideoQuality.fromCode(targetVideoQa).desc; + + String preferDecode = Pref.defaultDecode; // def avc + String preferSecondDecode = Pref.secondDecode; // def av1 + + // 默认从设置中取AV1 + VideoDecodeFormatType currentDecodeFormats = + VideoDecodeFormatType.fromString(preferDecode); + VideoDecodeFormatType secondDecodeFormats = + VideoDecodeFormatType.fromString(preferSecondDecode); + // 当前视频没有对应格式返回第一个 + 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 + final videoDash = videosList.firstWhere( + (e) => currentDecodeFormats.codes.any(e.codecs!.startsWith), + orElse: () => videosList.first, + ); + + final videoUrl = VideoUtils.getCdnUrl(videoDash); + + final Type2File videoFile = Type2File( + id: videoDash.id!, + baseUrl: videoUrl, + bandwidth: videoDash.bandWidth!, + codecid: videoDash.codecid!, + size: 0, + md5: '', + noRexcode: false, + frameRate: videoDash.frameRate ?? '', + width: videoDash.width!, + height: videoDash.height!, + dashDrmType: 0, + ); + List? audioFileList; + final List? audioDashList = dash.audio; + if (audioDashList != null && audioDashList.isNotEmpty) { + final preferAudioQa = Pref.defaultAudioQa; + final List audioIds = audioDashList + .map((map) => map.id!) + .toList(); + int closestNumber = audioIds.findClosestTarget( + (e) => e <= preferAudioQa, + (a, b) => a > b ? a : b, + ); + if (!audioIds.contains(preferAudioQa) && + audioIds.any((e) => e > preferAudioQa)) { + closestNumber = AudioQuality.k192.code; + } + final AudioItem audioDash = audioDashList.firstWhere( + (e) => e.id == closestNumber, + orElse: () => audioDashList.first, + ); + final audioUrl = VideoUtils.getCdnUrl(audioDash); + audioFileList = [ + Type2File( + id: audioDash.id!, + baseUrl: audioUrl, + bandwidth: audioDash.bandWidth!, + codecid: audioDash.codecid!, + size: 0, + md5: '', + noRexcode: false, + frameRate: audioDash.frameRate!, + width: audioDash.width!, + height: audioDash.height!, + dashDrmType: 0, + ), + ]; + } + return Type2( + duration: dash.duration!, + video: [videoFile], + audio: audioFileList, + referer: referer, + userAgent: userAgent, + ); + } else { + final first = data.durl!.first; + final List segmentList = [ + Type1Segment( + backupUrls: [], + bytes: first.size!, + duration: first.length!, + md5: '', + metaUrl: '', + order: first.order!, + url: first.backupUrl?.lastOrNull ?? first.url!, + ), + ]; + final FormatItem? formatItem = data.supportFormats?.firstWhereOrNull( + (e) => e.quality == data.quality, + ); + final String description = + formatItem?.newDesc ?? VideoQuality.clear480.desc; + final int targetVideoQa = + formatItem?.quality ?? VideoQuality.clear480.code; + + entry + ..typeTag = targetVideoQa.toString() + ..videoQuality = targetVideoQa + ..preferedVideoQuality = targetVideoQa + ..qualityPithyDescription = description; + + final List playerCodecConfigList = [ + Type1PlayerCodecConfig( + player: "IJK_PLAYER", + useIjkMediaCodec: false, + ), + Type1PlayerCodecConfig( + player: "ANDROID_PLAYER", + useIjkMediaCodec: false, + ), + ]; + entry.mediaType = 1; + return Type1( + from: pageData?.from ?? ep?.from, + quality: entry.preferedVideoQuality, + typeTag: entry.typeTag, + description: description, + playerCodecConfigList: playerCodecConfigList, + segmentList: segmentList, + parseTimestampMilli: 0, + availablePeriodMilli: 0, + isDownloaded: false, + isResolved: true, + timeLength: 0, + marlinToken: '', + videoCodecId: 0, + videoProject: true, + format: data.format!, + playerError: 0, + needVip: false, + needLogin: false, + intact: false, + referer: referer, + userAgent: userAgent, + ); + } + } else { + throw res.toString(); + } + } +} diff --git a/lib/http/init.dart b/lib/http/init.dart index 188e09723..731bb3ab2 100644 --- a/lib/http/init.dart +++ b/lib/http/init.dart @@ -176,10 +176,11 @@ class Request { ); } - dio.transformer = BackgroundTransformer(); - dio.options.validateStatus = (int? status) { - return status! >= 200 && status < 300; - }; + dio + ..transformer = BackgroundTransformer() + ..options.validateStatus = (int? status) { + return status != null && status >= 200 && status < 300; + }; } /* diff --git a/lib/http/retry_interceptor.dart b/lib/http/retry_interceptor.dart index 62461064b..7bf1d8918 100644 --- a/lib/http/retry_interceptor.dart +++ b/lib/http/retry_interceptor.dart @@ -10,6 +10,9 @@ class RetryInterceptor extends Interceptor { @override void onError(DioException err, ErrorInterceptorHandler handler) { + if (err.requestOptions.responseType == ResponseType.stream) { + return handler.next(err); + } if (err.response != null) { final options = err.requestOptions; if (options.followRedirects && options.maxRedirects > 0) { diff --git a/lib/http/search.dart b/lib/http/search.dart index 31e1cb0e8..2288f43da 100644 --- a/lib/http/search.dart +++ b/lib/http/search.dart @@ -84,7 +84,7 @@ class SearchHttp { if (gaiaVtoken != null) 'cookie': 'x-bili-gaia-vtoken=$gaiaVtoken', 'origin': 'https://search.bilibili.com', 'referer': - 'https://search.bilibili.com/${searchType.name}?keyword=${Uri.encodeQueryComponent(keyword)}', + 'https://search.bilibili.com/${searchType.name}?keyword=${Uri.encodeFull(keyword)}', }, ), ); diff --git a/lib/http/video.dart b/lib/http/video.dart index a34a35875..9c349e780 100644 --- a/lib/http/video.dart +++ b/lib/http/video.dart @@ -229,18 +229,19 @@ class VideoHttp { switch (videoType) { case VideoType.ugc: data = PlayUrlModel.fromJson(res.data['data']); - + break; case VideoType.pugv: - var result = res.data['data']; + final result = res.data['data']; data = PlayUrlModel.fromJson(result) ..lastPlayTime = result?['play_view_business_info']?['user_status']?['watch_progress']?['current_watch_progress']; - + break; case VideoType.pgc: - var result = res.data['result']; + final result = res.data['result']; data = PlayUrlModel.fromJson(result['video_info']) ..lastPlayTime = result?['play_view_business_info']?['user_status']?['watch_progress']?['current_watch_progress']; + break; } return Success(data); } else if (epid != null && videoType == VideoType.ugc) { diff --git a/lib/main.dart b/lib/main.dart index 1a8807653..94f6fd329 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,13 +9,15 @@ import 'package:PiliPlus/models/common/theme/theme_color_type.dart'; import 'package:PiliPlus/plugin/pl_player/controller.dart'; import 'package:PiliPlus/router/app_pages.dart'; import 'package:PiliPlus/services/account_service.dart'; +import 'package:PiliPlus/services/download/download_service.dart'; import 'package:PiliPlus/services/logger.dart'; import 'package:PiliPlus/services/service_locator.dart'; import 'package:PiliPlus/utils/app_scheme.dart'; -import 'package:PiliPlus/utils/cache_manage.dart'; +import 'package:PiliPlus/utils/cache_manager.dart'; import 'package:PiliPlus/utils/calc_window_position.dart'; import 'package:PiliPlus/utils/date_utils.dart'; import 'package:PiliPlus/utils/page_utils.dart'; +import 'package:PiliPlus/utils/path_utils.dart'; import 'package:PiliPlus/utils/request_utils.dart'; import 'package:PiliPlus/utils/storage.dart'; import 'package:PiliPlus/utils/storage_key.dart'; @@ -44,6 +46,8 @@ WebViewEnvironment? webViewEnvironment; void main() async { WidgetsFlutterBinding.ensureInitialized(); MediaKit.ensureInitialized(); + tmpDirPath = (await getTemporaryDirectory()).path; + appSupportDirPath = (await getApplicationSupportDirectory()).path; try { await GStorage.init(); } catch (e) { @@ -51,10 +55,39 @@ void main() async { if (kDebugMode) debugPrint('GStorage init error: $e'); exit(0); } - Get.lazyPut(AccountService.new); + if (Utils.isDesktop) { + final customDownPath = Pref.downloadPath; + if (customDownPath != null && customDownPath.isNotEmpty) { + try { + final dir = Directory(customDownPath); + if (!dir.existsSync()) { + await dir.create(recursive: true); + } + downloadPath = customDownPath; + } catch (e) { + downloadPath = defDownloadPath; + await GStorage.setting.delete(SettingBoxKey.downloadPath); + if (kDebugMode) { + debugPrint('download path error: $e'); + } + } + } else { + downloadPath = defDownloadPath; + } + } else if (Platform.isAndroid) { + final externalStorageDirPath = (await getExternalStorageDirectory())?.path; + downloadPath = externalStorageDirPath != null + ? path.join(externalStorageDirPath, PathUtils.downloadDir) + : defDownloadPath; + } else { + downloadPath = defDownloadPath; + } + Get + ..lazyPut(AccountService.new) + ..lazyPut(DownloadService.new); HttpOverrides.global = _CustomHttpOverrides(); - CacheManage.autoClearCache(); + CacheManager.autoClearCache(); if (Utils.isMobile) { await Future.wait([ @@ -73,10 +106,9 @@ void main() async { if (Platform.isWindows) { if (await WebViewEnvironment.getAvailableVersion() != null) { - final dir = await getApplicationSupportDirectory(); webViewEnvironment = await WebViewEnvironment.create( settings: WebViewEnvironmentSettings( - userDataFolder: path.join(dir.path, 'flutter_inappwebview'), + userDataFolder: path.join(appSupportDirPath, 'flutter_inappwebview'), ), ); } diff --git a/lib/models/common/video/source_type.dart b/lib/models/common/video/source_type.dart index 8dc973899..5d4f19037 100644 --- a/lib/models/common/video/source_type.dart +++ b/lib/models/common/video/source_type.dart @@ -25,14 +25,15 @@ enum SourceType { mediaType: 3, extraId: 4, playlistSource: PlaylistSource.MEDIA_LIST, - ); + ), + file; - final int mediaType; + final int? mediaType; final int? extraId; - final PlaylistSource playlistSource; + final PlaylistSource? playlistSource; const SourceType({ - required this.mediaType, + this.mediaType, this.extraId, - required this.playlistSource, + this.playlistSource, }); } diff --git a/lib/models/common/video/video_quality.dart b/lib/models/common/video/video_quality.dart index ac5bfee4f..37f37b445 100644 --- a/lib/models/common/video/video_quality.dart +++ b/lib/models/common/video/video_quality.dart @@ -1,14 +1,15 @@ enum VideoQuality { + hdrVivid(129, 'HDR Vivid', 'HDR Vivid'), super8k(127, '8K 超高清', '8K'), dolbyVision(126, '杜比视界', '杜比'), - hdr(125, 'HDR 真彩色', 'HDR'), - super4K(120, '4K 超清', '4K'), - high108060(116, '1080P60 高帧率', '1080P60'), - high1080plus(112, '1080P+ 高码率', '1080P+'), + hdr(125, 'HDR 真彩', 'HDR'), + super4K(120, '4K 超高清', '4K'), + high108060(116, '1080P 60帧', '1080P60'), + high1080plus(112, '1080P 高码率', '1080P+'), high1080(80, '1080P 高清', '1080P'), - high72060(74, '720P60 高帧率', '720P60'), - high720(64, '720P 高清', '720P'), - clear480(32, '480P 清晰', '480P'), + high72060(74, '720P 60帧', '720P60'), + high720(64, '720P 准高清', '720P'), + clear480(32, '480P 标清', '480P'), fluent360(16, '360P 流畅', '360P'), speed240(6, '240P 极速', '240P'); diff --git a/lib/models/model_owner.dart b/lib/models/model_owner.dart index add6e448b..2a091ba51 100644 --- a/lib/models/model_owner.dart +++ b/lib/models/model_owner.dart @@ -25,4 +25,10 @@ class Owner implements BaseOwner { name = json["name"]; face = json['face']; } + + Map toJson() => { + 'mid': mid, + 'name': name, + 'face': face, + }; } diff --git a/lib/models/video/play/url.dart b/lib/models/video/play/url.dart index 8639b565d..7e049b02e 100644 --- a/lib/models/video/play/url.dart +++ b/lib/models/video/play/url.dart @@ -199,9 +199,7 @@ class Durl { ahead: json['ahead'], vhead: json['vhead'], url: json['url'], - backupUrl: json['backup_url'] != null - ? List.from(json['backup_url']) - : [], + backupUrl: (json['backup_url'] as List?)?.fromCast(), ); } } @@ -248,11 +246,9 @@ abstract class BaseItem { BaseItem.fromJson(Map json) { id = json['id']; baseUrl = json['baseUrl'] ?? json['base_url']; - final backupUrls = - ((json['backupUrl'] ?? json['backup_url']) as List?) - ?.fromCast() ?? - []; - backupUrl = backupUrls.isNotEmpty + final backupUrls = ((json['backupUrl'] ?? json['backup_url']) as List?) + ?.fromCast(); + backupUrl = backupUrls != null && backupUrls.isNotEmpty ? backupUrls.firstWhere( (i) => !_isMCDNorPCDN(i), orElse: () => backupUrls.first, diff --git a/lib/models_new/download/bili_download_entry_info.dart b/lib/models_new/download/bili_download_entry_info.dart new file mode 100644 index 000000000..1d0a619b9 --- /dev/null +++ b/lib/models_new/download/bili_download_entry_info.dart @@ -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 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) + : null, + seasonId: json['season_id'] as String?, + source: json['source'] != null + ? SourceInfo.fromJson(json['source'] as Map) + : null, + ep: json['ep'] != null + ? EpInfo.fromJson(json['ep'] as Map) + : null, + ); + + Map toJson() => { + '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 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 toJson() => { + '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 json) => SourceInfo( + avId: json['av_id'] as int, + cid: json['cid'] as int, + ); + + Map toJson() => { + '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 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 toJson() => { + '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); +} diff --git a/lib/models_new/download/bili_download_media_file_info.dart b/lib/models_new/download/bili_download_media_file_info.dart new file mode 100644 index 000000000..0a7551827 --- /dev/null +++ b/lib/models_new/download/bili_download_media_file_info.dart @@ -0,0 +1,295 @@ +import 'package:PiliPlus/utils/extension.dart'; + +sealed class BiliDownloadMediaInfo { + const BiliDownloadMediaInfo(); + + Map get httpHeader => {}; + + Map 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 playerCodecConfigList; + final int playerError; + final int quality; + final List segmentList; + final int timeLength; + final String? typeTag; + final String? userAgent; + final String? referer; + final int videoCodecId; + final bool videoProject; + + @override + Map 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 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) + .map((e) => Type1PlayerCodecConfig.fromJson(e as Map)) + .toList(), + playerError: json['player_error'] as int, + quality: json['quality'] as int, + segmentList: (json['segment_list'] as List) + .map((e) => Type1Segment.fromJson(e as Map)) + .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 toJson() => { + '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 json) => + Type1PlayerCodecConfig( + player: json['player'] as String, + useIjkMediaCodec: json['use_ijk_media_codec'] as bool, + ); + + Map toJson() => { + 'player': player, + 'use_ijk_media_codec': useIjkMediaCodec, + }; +} + +class Type1Segment { + final List 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 json) => Type1Segment( + backupUrls: List.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 toJson() => { + '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 video; + final List? audio; + final String? userAgent; + final String? referer; + + Type2({ + this.duration = 0, + required this.video, + this.audio, + this.userAgent, + this.referer, + }); + + @override + Map get httpHeader => { + if (referer?.isNotEmpty ?? false) 'referer': referer!, + if (userAgent?.isNotEmpty ?? false) 'user-agent': userAgent!, + }; + + factory Type2.fromJson(Map json) => Type2( + duration: json['duration'] as int, + video: (json['video'] as List) + .map((e) => Type2File.fromJson(e as Map)) + .toList(), + audio: (json['audio'] as List?) + ?.map((e) => Type2File.fromJson(e as Map)) + .toList(), + userAgent: json['user_agent'] as String?, + referer: json['referer'] as String?, + ); + + @override + Map toJson() => { + '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? 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 json) => Type2File( + id: json['id'] as int, + baseUrl: json['base_url'] as String, + backupUrl: (json['backup_url'] as List?)?.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 toJson() => { + '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 toJson() { + throw UnimplementedError(); + } +} diff --git a/lib/models_new/download/download_info.dart b/lib/models_new/download/download_info.dart new file mode 100644 index 000000000..cde637901 --- /dev/null +++ b/lib/models_new/download/download_info.dart @@ -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 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, + }); +} diff --git a/lib/models_new/video/video_detail/page.dart b/lib/models_new/video/video_detail/page.dart index 54568fc66..ba2d35725 100644 --- a/lib/models_new/video/video_detail/page.dart +++ b/lib/models_new/video/video_detail/page.dart @@ -4,7 +4,7 @@ import 'package:PiliPlus/models_new/video/video_detail/episode.dart'; class Part extends BaseEpisodeItem { int? page; String? from; - String? pagePart; + String? part; int? duration; String? vid; String? weblink; @@ -16,7 +16,7 @@ class Part extends BaseEpisodeItem { super.cid, this.page, this.from, - this.pagePart, + this.part, this.duration, this.vid, this.weblink, @@ -30,7 +30,7 @@ class Part extends BaseEpisodeItem { cid: json['cid'] as int?, page: json['page'] as int?, from: json['from'] as String?, - pagePart: json['part'] as String?, + part: json['part'] as String?, duration: json['duration'] as int?, vid: json['vid'] as String?, weblink: json['weblink'] as String?, diff --git a/lib/pages/about/view.dart b/lib/pages/about/view.dart index d698c05cc..c819b61d9 100644 --- a/lib/pages/about/view.dart +++ b/lib/pages/about/view.dart @@ -10,7 +10,7 @@ import 'package:PiliPlus/pages/mine/controller.dart'; import 'package:PiliPlus/services/logger.dart'; import 'package:PiliPlus/utils/accounts.dart'; import 'package:PiliPlus/utils/accounts/account.dart'; -import 'package:PiliPlus/utils/cache_manage.dart'; +import 'package:PiliPlus/utils/cache_manager.dart'; import 'package:PiliPlus/utils/context_ext.dart'; import 'package:PiliPlus/utils/date_utils.dart'; import 'package:PiliPlus/utils/login_utils.dart'; @@ -58,8 +58,8 @@ class _AboutPageState extends State { } Future getCacheSize() async { - cacheSize.value = CacheManage.formatSize( - await CacheManage.loadApplicationCache(), + cacheSize.value = CacheManager.formatSize( + await CacheManager.loadApplicationCache(), ); } @@ -215,7 +215,7 @@ Commit Hash: ${BuildConfig.commitHash}''', onConfirm: () async { SmartDialog.showLoading(msg: '正在清除...'); try { - await CacheManage.clearLibraryCache(); + await CacheManager.clearLibraryCache(); SmartDialog.showToast('清除成功'); } catch (err) { SmartDialog.showToast(err.toString()); @@ -242,9 +242,7 @@ Commit Hash: ${BuildConfig.commitHash}''', onTap: () => showInportExportDialog( context, title: '登录信息', - toJson: () => const JsonEncoder.withIndent( - ' ', - ).convert(Accounts.account.toMap()), + toJson: () => Utils.jsonEncoder.convert(Accounts.account.toMap()), fromJson: (json) async { final res = json.map( (key, value) => MapEntry(key, LoginAccount.fromJson(value)), @@ -304,6 +302,7 @@ Commit Hash: ${BuildConfig.commitHash}''', GStorage.video.clear(), GStorage.historyWord.clear(), Accounts.clear(), + GStorage.watchProgress.clear(), ]); SmartDialog.showToast('重置成功'); }, @@ -377,7 +376,7 @@ Future showInportExportDialog( late final String formatText; try { json = jsonDecode(text); - formatText = const JsonEncoder.withIndent(' ').convert(json); + formatText = Utils.jsonEncoder.convert(json); } catch (e) { SmartDialog.showToast('解析json失败:$e'); return; diff --git a/lib/pages/audio/view.dart b/lib/pages/audio/view.dart index f64524bf2..6c5b78a5e 100644 --- a/lib/pages/audio/view.dart +++ b/lib/pages/audio/view.dart @@ -169,7 +169,7 @@ class _AudioPageState extends State { final theme = Theme.of(context); final colorScheme = theme.colorScheme; return FractionallySizedBox( - heightFactor: !context.mediaQuerySize.isPortrait && Utils.isMobile + heightFactor: Utils.isMobile && !context.mediaQuerySize.isPortrait ? 1.0 : 0.7, alignment: Alignment.bottomCenter, diff --git a/lib/pages/common/common_intro_controller.dart b/lib/pages/common/common_intro_controller.dart index b1fbc2039..c24e14d99 100644 --- a/lib/pages/common/common_intro_controller.dart +++ b/lib/pages/common/common_intro_controller.dart @@ -9,8 +9,8 @@ import 'package:PiliPlus/models_new/fav/fav_folder/data.dart'; import 'package:PiliPlus/models_new/video/video_detail/data.dart'; import 'package:PiliPlus/models_new/video/video_detail/stat_detail.dart'; import 'package:PiliPlus/models_new/video/video_tag/data.dart'; +import 'package:PiliPlus/pages/video/controller.dart'; import 'package:PiliPlus/pages/video/introduction/ugc/widgets/triple_mixin.dart'; -import 'package:PiliPlus/services/account_service.dart'; import 'package:PiliPlus/utils/accounts.dart'; import 'package:PiliPlus/utils/global_data.dart'; import 'package:PiliPlus/utils/id_utils.dart'; @@ -42,7 +42,7 @@ abstract class CommonIntroController extends GetxController } } - AccountService accountService = Get.find(); + late final isLogin = Accounts.main.isLogin; StatDetail? getStat(); @@ -68,6 +68,8 @@ abstract class CommonIntroController extends GetxController late final RxInt cid; + late final videoDetailCtr = Get.find(tag: heroTag); + @override void onInit() { super.onInit(); diff --git a/lib/pages/contact/view.dart b/lib/pages/contact/view.dart index 11d630b8b..95d0336de 100644 --- a/lib/pages/contact/view.dart +++ b/lib/pages/contact/view.dart @@ -3,7 +3,7 @@ import 'package:PiliPlus/pages/fan/view.dart'; import 'package:PiliPlus/pages/follow/child/child_view.dart'; import 'package:PiliPlus/pages/follow_search/view.dart'; import 'package:PiliPlus/pages/share/view.dart' show UserModel; -import 'package:PiliPlus/services/account_service.dart'; +import 'package:PiliPlus/utils/accounts.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; @@ -18,7 +18,7 @@ class ContactPage extends StatefulWidget { class _ContactPageState extends State with SingleTickerProviderStateMixin { - AccountService accountService = Get.find(); + late final mid = Accounts.main.mid; late final _controller = TabController(length: 2, vsync: this); @override @@ -50,7 +50,7 @@ class _ContactPageState extends State UserModel? userModel = await Navigator.of(context).push( GetPageRoute( page: () => FollowSearchPage( - mid: accountService.mid, + mid: mid, isFromSelect: widget.isFromSelect, ), ), @@ -68,7 +68,7 @@ class _ContactPageState extends State controller: _controller, children: [ FollowChildPage( - mid: accountService.mid, + mid: mid, onSelect: widget.isFromSelect ? onSelect : null, ), FansPage( diff --git a/lib/pages/danmaku/controller.dart b/lib/pages/danmaku/controller.dart index 243490c5e..c071d7fdb 100644 --- a/lib/pages/danmaku/controller.dart +++ b/lib/pages/danmaku/controller.dart @@ -1,27 +1,37 @@ +import 'dart:convert'; +import 'dart:io' show File; + import 'package:PiliPlus/grpc/bilibili/community/service/dm/v1.pb.dart'; import 'package:PiliPlus/grpc/dm.dart'; import 'package:PiliPlus/plugin/pl_player/controller.dart'; -import 'package:PiliPlus/services/account_service.dart'; -import 'package:get/get.dart'; +import 'package:PiliPlus/utils/accounts.dart'; +import 'package:fixnum/fixnum.dart' show Int64; +import 'package:flutter/foundation.dart' show kDebugMode; +import 'package:path/path.dart' as path; +import 'package:xml/xml.dart'; class PlDanmakuController { PlDanmakuController( this.cid, this.plPlayerController, + this.isFileSource, ) : mergeDanmaku = plPlayerController.mergeDanmaku; + final int cid; final PlPlayerController plPlayerController; final bool mergeDanmaku; + final bool isFileSource; - AccountService accountService = Get.find(); + late final isLogin = Accounts.main.isLogin; Map> dmSegMap = {}; // 已请求的段落标记 - Set requestedSeg = {}; + late final Set requestedSeg = {}; static const int segmentLength = 60 * 6 * 1000; void dispose() { + closed = true; dmSegMap.clear(); requestedSeg.clear(); } @@ -31,6 +41,9 @@ class PlDanmakuController { } Future queryDanmaku(int segmentIndex) async { + if (isFileSource) { + return; + } if (requestedSeg.contains(segmentIndex)) { return; } @@ -45,51 +58,127 @@ class PlDanmakuController { if (data.state == 1) { plPlayerController.dmState.add(cid); } - if (data.elems.isNotEmpty) { - final Map counts = {}; - if (mergeDanmaku) { - data.elems.retainWhere((item) { - int? count = counts[item.content]; - counts[item.content] = count != null ? count + 1 : 1; - return count == null; - }); - } - - final shouldFilter = plPlayerController.filters.count != 0; - for (final element in data.elems) { - if (element.mode == 7 && !plPlayerController.showSpecialDanmaku) { - continue; - } - if (accountService.isLogin.value) { - element.isSelf = element.midHash == plPlayerController.midHash; - } - if (!element.isSelf) { - if (element.weight < plPlayerController.danmakuWeight || - (shouldFilter && plPlayerController.filters.remove(element))) { - continue; - } - } - if (mergeDanmaku) { - final count = counts[element.content]; - if (count != 1) { - element.count = count!; - } - } - int pos = element.progress ~/ 100; //每0.1秒存储一次 - (dmSegMap[pos] ??= []).add(element); - } - } + handleDanmaku(data.elems); } else { requestedSeg.remove(segmentIndex); } } + void handleDanmaku(List elems) { + if (elems.isEmpty) return; + late final Map counts = {}; + if (mergeDanmaku) { + elems.retainWhere((item) { + int? count = counts[item.content]; + counts[item.content] = count != null ? count + 1 : 1; + return count == null; + }); + } + + final shouldFilter = plPlayerController.filters.count != 0; + for (final element in elems) { + if (element.mode == 7 && !plPlayerController.showSpecialDanmaku) { + continue; + } + if (isLogin) { + element.isSelf = element.midHash == plPlayerController.midHash; + } + if (!element.isSelf) { + if (element.weight < plPlayerController.danmakuWeight || + (shouldFilter && plPlayerController.filters.remove(element))) { + continue; + } + } + if (mergeDanmaku) { + final count = counts[element.content]; + if (count != 1) { + element.count = count!; + } + } + final int pos = element.progress ~/ 100; //每0.1秒存储一次 + (dmSegMap[pos] ??= []).add(element); + } + } + List? getCurrentDanmaku(int progress) { - int segmentIndex = calcSegment(progress); - if (!requestedSeg.contains(segmentIndex)) { - queryDanmaku(segmentIndex); - return null; + if (isFileSource) { + initXmlDmIfNeeded(); + } else { + final int segmentIndex = calcSegment(progress); + if (!requestedSeg.contains(segmentIndex)) { + queryDanmaku(segmentIndex); + return null; + } } return dmSegMap[progress ~/ 100]; } + + bool closed = false; + + late bool _xmlDmLoaded = false; + + void initXmlDmIfNeeded() { + if (_xmlDmLoaded) return; + _xmlDmLoaded = true; + _initXmlDm(); + } + + Future _initXmlDm() async { + try { + final file = File(path.join(plPlayerController.dirPath!, 'danmaku.xml')); + final stream = file.openRead().transform(utf8.decoder); + final buffer = StringBuffer(); + await for (final chunk in stream) { + if (closed) { + return; + } + buffer.write(chunk); + } + if (closed) { + return; + } + final xmlString = buffer.toString(); + final document = XmlDocument.parse(xmlString); + final danmakus = document.findAllElements('d').toList(); + final elems = []; + for (final dm in danmakus) { + if (closed) { + return; + } + try { + final pAttr = dm.getAttribute('p'); + if (pAttr != null) { + final parts = pAttr.split(','); + final progress = double.parse(parts[0]); // sec + final mode = int.parse(parts[1]); + final fontsize = int.parse(parts[2]); + final color = int.parse(parts[3]); + // final ctime = int.parse(parts[4]); + // final pool = int.parse(parts[5]); + final midHash = parts[6]; + final id = int.parse(parts[7]); + final weight = int.parse(parts[8]); + final content = dm.innerText; + elems.add( + DanmakuElem( + progress: (progress * 1000).toInt(), + mode: mode, + fontsize: fontsize, + color: color, + midHash: midHash, + id: Int64(id), + weight: weight, + content: content, + ), + ); + } + } catch (_) { + if (kDebugMode) rethrow; + } + } + handleDanmaku(elems); + } catch (_) { + if (kDebugMode) rethrow; + } + } } diff --git a/lib/pages/danmaku/view.dart b/lib/pages/danmaku/view.dart index 481cc2d19..173d467fd 100644 --- a/lib/pages/danmaku/view.dart +++ b/lib/pages/danmaku/view.dart @@ -16,6 +16,7 @@ class PlDanmaku extends StatefulWidget { final PlPlayerController playerController; final bool isPipMode; final bool isFullScreen; + final bool isFileSource; const PlDanmaku({ super.key, @@ -23,6 +24,7 @@ class PlDanmaku extends StatefulWidget { required this.playerController, this.isPipMode = false, required this.isFullScreen, + required this.isFileSource, }); @override @@ -42,13 +44,18 @@ class _PlDanmakuState extends State { _plDanmakuController = PlDanmakuController( widget.cid, playerController, + widget.isFileSource, ); if (playerController.enableShowDanmaku.value) { - _plDanmakuController.queryDanmaku( - _plDanmakuController.calcSegment( - playerController.position.value.inMilliseconds, - ), - ); + if (widget.isFileSource) { + _plDanmakuController.initXmlDmIfNeeded(); + } else { + _plDanmakuController.queryDanmaku( + _plDanmakuController.calcSegment( + playerController.position.value.inMilliseconds, + ), + ); + } } playerController ..addStatusLister(playerListener) diff --git a/lib/pages/download/controller.dart b/lib/pages/download/controller.dart new file mode 100644 index 000000000..ff6b2f071 --- /dev/null +++ b/lib/pages/download/controller.dart @@ -0,0 +1,75 @@ +import 'dart:async'; + +import 'package:PiliPlus/models_new/download/download_info.dart'; +import 'package:PiliPlus/services/download/download_service.dart'; +import 'package:get/get.dart'; + +class DownloadPageController extends GetxController { + final _downloadService = Get.find(); + late final StreamSubscription _sub; + final pages = RxList(); + final flag = RxInt(0); + + @override + void onInit() { + super.onInit(); + _loadList(); + _sub = _downloadService.downloaFlag.listen((_) { + _loadList(); + }); + } + + @override + void onClose() { + _sub.cancel(); + super.onClose(); + } + + Future _loadList() async { + await _downloadService.waitForInitialization; + if (isClosed) return; + if (_downloadService.downloadList.isEmpty) { + pages.clear(); + return; + } + final list = []; + for (final entry in _downloadService.downloadList) { + final pageId = entry.pageId; + final page = list.firstWhereOrNull((e) => e.pageId == pageId); + if (page != null) { + final aSortKey = entry.sortKey; + if (!entry.isCompleted) { + if (page.entry case final lastEntry?) { + if (aSortKey < lastEntry.sortKey) { + page.entry = entry; + } + } else { + page.entry = entry; + } + } + final bSortKey = page.sortKey; + if (aSortKey < bSortKey) { + page + ..cover = entry.cover + ..sortKey = aSortKey; + } + page.entrys.add(entry); + } else { + list.add( + DownloadPageInfo( + pageId: pageId, + dirPath: entry.pageDirPath, + title: entry.title, + cover: entry.cover, + sortKey: entry.sortKey, + seasonType: entry.ep?.seasonType, + entrys: [entry], + entry: entry.isCompleted ? null : entry, + ), + ); + } + } + pages.value = list; + flag.value++; + } +} diff --git a/lib/pages/download/detail/view.dart b/lib/pages/download/detail/view.dart new file mode 100644 index 000000000..ca81b28b0 --- /dev/null +++ b/lib/pages/download/detail/view.dart @@ -0,0 +1,117 @@ +import 'dart:async'; + +import 'package:PiliPlus/common/widgets/loading_widget/http_error.dart'; +import 'package:PiliPlus/common/widgets/view_sliver_safe_area.dart'; +import 'package:PiliPlus/models_new/download/bili_download_entry_info.dart'; +import 'package:PiliPlus/pages/download/controller.dart'; +import 'package:PiliPlus/pages/download/detail/widgets/item.dart'; +import 'package:PiliPlus/services/download/download_service.dart'; +import 'package:PiliPlus/utils/grid.dart'; +import 'package:PiliPlus/utils/storage.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class DownloadDetailPage extends StatefulWidget { + const DownloadDetailPage({ + super.key, + required this.pageId, + required this.title, + required this.progress, + }); + + final String pageId; + final String title; + final ValueNotifier progress; + + @override + State createState() => _DownloadDetailPageState(); +} + +class _DownloadDetailPageState extends State + with GridMixin { + StreamSubscription? _sub; + final _downloadItems = RxList(); + final _controller = Get.find(); + final _downloadService = Get.find(); + + @override + void initState() { + super.initState(); + _loadList(); + _sub = _controller.flag.listen((_) { + _loadList(); + }); + } + + Future _closeSub() async { + if (_sub != null) { + await _sub?.cancel(); + _sub = null; + } + } + + @override + void dispose() { + _closeSub(); + super.dispose(); + } + + void _loadList() { + final list = + _controller.pages + .firstWhereOrNull((e) => e.pageId == widget.pageId) + ?.entrys + ?..sort((a, b) => a.sortKey.compareTo(b.sortKey)); + if (list != null) { + _downloadItems.value = list; + } else { + _downloadItems.clear(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + resizeToAvoidBottomInset: false, + appBar: AppBar(title: Text(widget.title)), + body: CustomScrollView( + slivers: [ + ViewSliverSafeArea( + sliver: Obx(() { + if (_downloadItems.isNotEmpty) { + return SliverGrid.builder( + gridDelegate: gridDelegate, + itemBuilder: (context, index) { + final entry = _downloadItems[index]; + return DetailItem( + entry: entry, + progress: widget.progress, + downloadService: _downloadService, + showTitle: false, + onDelete: () async { + if (_downloadItems.length == 1) { + await _closeSub(); + await _downloadService.deletePage( + pageDirPath: entry.pageDirPath, + ); + if (context.mounted) { + Get.back(); + } + } else { + _downloadService.deleteDownload(entry: entry); + } + GStorage.watchProgress.delete(entry.cid.toString()); + }, + ); + }, + itemCount: _downloadItems.length, + ); + } + return const HttpError(); + }), + ), + ], + ), + ); + } +} diff --git a/lib/pages/download/detail/widgets/item.dart b/lib/pages/download/detail/widgets/item.dart new file mode 100644 index 000000000..1552b48c8 --- /dev/null +++ b/lib/pages/download/detail/widgets/item.dart @@ -0,0 +1,290 @@ +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/progress_bar/video_progress_indicator.dart'; +import 'package:PiliPlus/models/common/badge_type.dart'; +import 'package:PiliPlus/models/common/video/source_type.dart'; +import 'package:PiliPlus/models/common/video/video_quality.dart'; +import 'package:PiliPlus/models_new/download/bili_download_entry_info.dart'; +import 'package:PiliPlus/services/download/download_service.dart'; +import 'package:PiliPlus/utils/cache_manager.dart'; +import 'package:PiliPlus/utils/duration_utils.dart'; +import 'package:PiliPlus/utils/page_utils.dart'; +import 'package:PiliPlus/utils/storage.dart'; +import 'package:PiliPlus/utils/utils.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:get/get.dart'; + +class DetailItem extends StatelessWidget { + const DetailItem({ + super.key, + required this.entry, + required this.progress, + required this.downloadService, + required this.onDelete, + required this.showTitle, + }); + + final BiliDownloadEntryInfo entry; + final ValueNotifier progress; + final DownloadService downloadService; + final VoidCallback onDelete; + final bool showTitle; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final outline = theme.colorScheme.outline; + final cid = entry.source?.cid ?? entry.pageData?.cid; + void onLongPress() => showDialog( + context: context, + builder: (context) { + return AlertDialog( + clipBehavior: Clip.hardEdge, + contentPadding: const EdgeInsets.symmetric(vertical: 12), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + onTap: () { + Get.back(); + showConfirmDialog( + context: context, + title: '确定删除该视频?', + onConfirm: onDelete, + ); + }, + dense: true, + title: const Text( + '删除', + style: TextStyle(fontSize: 14), + ), + ), + ListTile( + onTap: () async { + Get.back(); + final res = await downloadService.downloadDanmaku( + entry: entry, + isUpdate: true, + ); + if (res) { + SmartDialog.showToast('更新成功'); + } + }, + dense: true, + title: const Text( + '更新弹幕', + style: TextStyle(fontSize: 14), + ), + ), + ], + ), + ); + }, + ); + return Material( + type: MaterialType.transparency, + child: InkWell( + onTap: () async { + if (entry.isCompleted) { + await PageUtils.toVideoPage( + aid: entry.avid, + cid: cid!, + cover: entry.cover, + title: entry.showTitle, + extraArguments: { + 'sourceType': SourceType.file, + 'entry': entry, + 'dirPath': entry.entryDirPath, + }, + ); + if (context.mounted) { + Future.delayed(const Duration(milliseconds: 400), () { + if (context.mounted) { + // ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member + progress.notifyListeners(); + } + }); + } + } else { + final curDownload = downloadService.curDownload.value; + if (curDownload != null && + curDownload.cid == cid && + curDownload.status!.index <= 3) { + downloadService.cancelDownload( + isDelete: false, + downloadNext: false, + ); + } else { + if (entry.status == DownloadStatus.wait) { + downloadService.waitDownloadQueue.remove(entry); + } + downloadService.startDownload(entry); + } + } + }, + onLongPress: onLongPress, + onSecondaryTap: Utils.isMobile ? null : onLongPress, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: StyleString.safeSpace, + vertical: 5, + ), + child: Row( + spacing: 10, + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + AspectRatio( + aspectRatio: StyleString.aspectRatio, + child: LayoutBuilder( + builder: (context, constraints) => NetworkImgLayer( + src: entry.cover, + width: constraints.maxWidth, + height: constraints.maxHeight, + ), + ), + ), + if (entry.videoQuality case final videoQuality?) + PBadge( + text: VideoQuality.fromCode(videoQuality).shortDesc, + right: 6.0, + top: 6.0, + type: PBadgeType.gray, + ), + ValueListenableBuilder( + valueListenable: progress, + builder: (_, _, _) { + final progress = GStorage.watchProgress.get( + cid.toString(), + ); + if (progress != null) { + return Positioned( + left: 0, + right: 0, + bottom: 0, + child: Stack( + clipBehavior: Clip.none, + children: [ + videoProgressIndicator( + progress / entry.totalTimeMilli, + ), + PBadge( + text: progress >= entry.totalTimeMilli - 400 + ? '已看完' + : '${DurationUtils.formatDuration( + progress ~/ 1000, + )}/' + '${DurationUtils.formatDuration( + entry.totalTimeMilli ~/ 1000, + )}', + right: 6, + bottom: 7, + type: PBadgeType.gray, + ), + ], + ), + ); + } + return PBadge( + text: DurationUtils.formatDuration( + entry.totalTimeMilli ~/ 1000, + ), + right: 6.0, + bottom: 7.0, + type: PBadgeType.gray, + ); + }, + ), + ], + ), + Expanded( + child: Stack( + clipBehavior: Clip.none, + children: [ + Column( + spacing: 5, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + showTitle ? entry.title : entry.showTitle, + textAlign: TextAlign.start, + style: TextStyle( + fontSize: theme.textTheme.bodyMedium!.fontSize, + height: 1.42, + letterSpacing: 0.3, + ), + maxLines: showTitle + ? entry.ep != null + ? 1 + : 2 + : 2, + overflow: TextOverflow.ellipsis, + ), + if (showTitle) ...[ + if (entry.pageData?.part case final part?) + if (part != entry.title) + Text( + part, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + if (entry.ep?.showTitle case final showTitle?) + Text( + showTitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + if (entry.isCompleted) ...[ + Positioned( + left: 0, + bottom: 0, + child: Text( + '${CacheManager.formatSize(entry.totalBytes)}${entry.ownerName != null ? ' ${entry.ownerName}' : ''}', + style: TextStyle( + fontSize: 12, + height: 1.6, + color: outline, + ), + ), + ), + Positioned( + right: 0, + bottom: 0, + child: entry.moreBtn(theme), + ), + ] else + Positioned( + left: 0, + right: 0, + bottom: 0, + child: entry.progressWidget( + theme: theme, + downloadService: downloadService, + isPage: false, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/download/view.dart b/lib/pages/download/view.dart new file mode 100644 index 000000000..6e771139f --- /dev/null +++ b/lib/pages/download/view.dart @@ -0,0 +1,250 @@ +import 'dart:async'; + +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/loading_widget/http_error.dart'; +import 'package:PiliPlus/common/widgets/view_sliver_safe_area.dart'; +import 'package:PiliPlus/models/common/badge_type.dart'; +import 'package:PiliPlus/models_new/download/download_info.dart'; +import 'package:PiliPlus/pages/download/controller.dart'; +import 'package:PiliPlus/pages/download/detail/view.dart'; +import 'package:PiliPlus/pages/download/detail/widgets/item.dart'; +import 'package:PiliPlus/services/download/download_service.dart'; +import 'package:PiliPlus/utils/grid.dart'; +import 'package:PiliPlus/utils/storage.dart'; +import 'package:PiliPlus/utils/utils.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:get/get.dart'; + +class DownloadPage extends StatefulWidget { + const DownloadPage({super.key}); + + @override + State createState() => _DownloadPageState(); +} + +class _DownloadPageState extends State with GridMixin { + final _downloadService = Get.find(); + final _controller = Get.put(DownloadPageController()); + final _progress = ValueNotifier(null); + + @override + void dispose() { + _progress.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Scaffold( + resizeToAvoidBottomInset: false, + appBar: AppBar(title: const Text('离线缓存')), + body: CustomScrollView( + slivers: [ + ViewSliverSafeArea( + sliver: Obx(() { + if (_controller.pages.isNotEmpty) { + return SliverGrid.builder( + gridDelegate: gridDelegate, + itemBuilder: (context, index) { + final item = _controller.pages[index]; + if (item.entrys.length == 1) { + final entry = item.entrys.first; + return DetailItem( + entry: entry, + progress: _progress, + downloadService: _downloadService, + showTitle: true, + onDelete: () { + _downloadService.deleteDownload(entry: entry); + GStorage.watchProgress.delete(entry.cid.toString()); + }, + ); + } + return _buildItem(theme, item); + }, + itemCount: _controller.pages.length, + ); + } + return const HttpError(); + }), + ), + ], + ), + ); + } + + Widget _buildItem(ThemeData theme, DownloadPageInfo pageInfo) { + final outline = theme.colorScheme.outline; + final entry = pageInfo.entry; + final isCompleted = entry == null; + void onLongPress() => isCompleted + ? showDialog( + context: context, + builder: (context) { + return AlertDialog( + clipBehavior: Clip.hardEdge, + contentPadding: const EdgeInsets.symmetric(vertical: 12), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + onTap: () { + Get.back(); + showConfirmDialog( + context: context, + title: '确定删除?', + onConfirm: () async { + final watchProgress = GStorage.watchProgress; + await Future.wait( + pageInfo.entrys.map((e) { + final cid = e.pageData?.cid ?? e.source?.cid; + return watchProgress.delete(cid.toString()); + }), + ); + _downloadService.deletePage( + pageDirPath: pageInfo.dirPath, + ); + }, + ); + }, + dense: true, + title: const Text( + '删除', + style: TextStyle(fontSize: 14), + ), + ), + ListTile( + onTap: () async { + Get.back(); + final res = await Future.wait( + pageInfo.entrys.map( + (e) => _downloadService.downloadDanmaku( + entry: e, + isUpdate: true, + ), + ), + ); + if (res.every((e) => e)) { + SmartDialog.showToast('更新成功'); + } else { + SmartDialog.showToast('更新失败'); + } + }, + dense: true, + title: const Text( + '更新弹幕', + style: TextStyle(fontSize: 14), + ), + ), + ], + ), + ); + }, + ) + : null; + return Material( + type: MaterialType.transparency, + child: InkWell( + onTap: () => Get.to( + DownloadDetailPage( + pageId: pageInfo.pageId, + title: pageInfo.title, + progress: _progress, + ), + ), + onLongPress: onLongPress, + onSecondaryTap: Utils.isMobile ? null : onLongPress, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: StyleString.safeSpace, + vertical: 5, + ), + child: Row( + spacing: 10, + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + AspectRatio( + aspectRatio: StyleString.aspectRatio, + child: LayoutBuilder( + builder: (context, constraints) => NetworkImgLayer( + src: pageInfo.cover, + width: constraints.maxWidth, + height: constraints.maxHeight, + ), + ), + ), + PBadge( + text: '${pageInfo.entrys.length}个视频', + right: 6.0, + bottom: 6.0, + isBold: false, + type: PBadgeType.gray, + ), + if (pageInfo.seasonType case final pgcType?) + PBadge( + text: switch (pgcType) { + -1 => '课程', + 1 => '番剧', + 2 => '电影', + 3 => '纪录片', + 4 => '国创', + 5 => '电视剧', + 7 => '综艺', + _ => null, + }, + right: 6.0, + top: 6.0, + ), + ], + ), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + pageInfo.title, + textAlign: TextAlign.start, + style: TextStyle( + fontSize: theme.textTheme.bodyMedium!.fontSize, + height: 1.42, + letterSpacing: 0.3, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + if (isCompleted) + Text( + '已完成', + maxLines: 1, + style: TextStyle( + fontSize: 12, + height: 1, + color: outline, + ), + ) + else + entry.progressWidget( + theme: theme, + downloadService: _downloadService, + isPage: true, + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/dynamics/widgets/vote.dart b/lib/pages/dynamics/widgets/vote.dart index 0955d0534..5622020af 100644 --- a/lib/pages/dynamics/widgets/vote.dart +++ b/lib/pages/dynamics/widgets/vote.dart @@ -88,7 +88,7 @@ class _VotePanelState extends State { itemCount: _voteInfo.options.length, itemBuilder: (context, index) => _buildOptions(index), separatorBuilder: (context, index) => - const SizedBox(height: 10), + const SizedBox(height: 6), ), ), ), diff --git a/lib/pages/dynamics_topic/controller.dart b/lib/pages/dynamics_topic/controller.dart index c0533d59a..821ced4c3 100644 --- a/lib/pages/dynamics_topic/controller.dart +++ b/lib/pages/dynamics_topic/controller.dart @@ -6,7 +6,7 @@ import 'package:PiliPlus/models_new/dynamic/dyn_topic_feed/topic_card_list.dart' import 'package:PiliPlus/models_new/dynamic/dyn_topic_feed/topic_sort_by_conf.dart'; import 'package:PiliPlus/models_new/dynamic/dyn_topic_top/top_details.dart'; import 'package:PiliPlus/pages/common/common_list_controller.dart'; -import 'package:PiliPlus/services/account_service.dart'; +import 'package:PiliPlus/utils/accounts.dart'; import 'package:PiliPlus/utils/extension.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; @@ -28,7 +28,7 @@ class DynTopicController Rx> topState = LoadingState.loading().obs; - AccountService accountService = Get.find(); + late final isLogin = Accounts.main.isLogin; @override void onInit() { @@ -92,7 +92,7 @@ class DynTopicController } Future onFav() async { - if (!accountService.isLogin.value) { + if (!isLogin) { SmartDialog.showToast('账号未登录'); return; } @@ -113,7 +113,7 @@ class DynTopicController } Future onLike() async { - if (!accountService.isLogin.value) { + if (!isLogin) { SmartDialog.showToast('账号未登录'); return; } diff --git a/lib/pages/dynamics_topic/view.dart b/lib/pages/dynamics_topic/view.dart index f84146875..2b387ec02 100644 --- a/lib/pages/dynamics_topic/view.dart +++ b/lib/pages/dynamics_topic/view.dart @@ -46,7 +46,7 @@ class _DynTopicPageState extends State with DynMixin { resizeToAvoidBottomInset: false, floatingActionButton: FloatingActionButton.extended( onPressed: () { - if (_controller.accountService.isLogin.value) { + if (_controller.isLogin) { CreateDynPanel.onCreateDyn( context, topic: Pair( @@ -321,7 +321,7 @@ class _DynTopicPageState extends State with DynMixin { PopupMenuItem( child: const Text('举报'), onTap: () { - if (!_controller.accountService.isLogin.value) { + if (!_controller.isLogin) { SmartDialog.showToast('账号未登录'); return; } diff --git a/lib/pages/episode_panel/view.dart b/lib/pages/episode_panel/view.dart index 5c927037f..8380bfe7e 100644 --- a/lib/pages/episode_panel/view.dart +++ b/lib/pages/episode_panel/view.dart @@ -15,7 +15,6 @@ import 'package:PiliPlus/http/video.dart'; import 'package:PiliPlus/models/common/badge_type.dart'; import 'package:PiliPlus/models/common/episode_panel_type.dart'; import 'package:PiliPlus/models/common/stat_type.dart'; -import 'package:PiliPlus/models/user/info.dart'; import 'package:PiliPlus/models_new/pgc/pgc_info_model/episode.dart' as pgc; import 'package:PiliPlus/models_new/video/video_detail/episode.dart' as ugc; import 'package:PiliPlus/models_new/video/video_detail/page.dart'; @@ -158,7 +157,9 @@ class _EpisodePanelState extends State widget.list.length, (i) => ScrollController( initialScrollOffset: i == widget.initialTabIndex - ? _calcItemOffset(_currentItemIndex) + ? _currentItemIndex == 0 + ? 0 + : _calcItemOffset(_currentItemIndex) : 0, ), growable: false, @@ -365,6 +366,7 @@ class _EpisodePanelState extends State ); } + late final int? vipStatus = Pref.userInfoCache?.vipStatus; Widget _buildEpisodeItem({ required ThemeData theme, required ugc.BaseEpisodeItem episode, @@ -384,7 +386,7 @@ class _EpisodePanelState extends State switch (episode) { case Part part: cover = part.firstFrame ?? widget.cover; - title = part.pagePart!; + title = part.part!; duration = part.duration; pubdate = part.ctime; break; @@ -429,13 +431,9 @@ class _EpisodePanelState extends State type: MaterialType.transparency, child: InkWell( onTap: () { - if (episode.badge == "会员") { - UserInfoData? userInfo = Pref.userInfoCache; - int vipStatus = userInfo?.vipStatus ?? 0; - if (vipStatus != 1) { - SmartDialog.showToast('需要大会员'); - // return; - } + if (episode.badge == "会员" && vipStatus != 1) { + SmartDialog.showToast('需要大会员'); + // return; } SmartDialog.showToast('切换到:$title'); widget.onClose?.call(); diff --git a/lib/pages/fav/video/controller.dart b/lib/pages/fav/video/controller.dart index 969009194..544276188 100644 --- a/lib/pages/fav/video/controller.dart +++ b/lib/pages/fav/video/controller.dart @@ -3,11 +3,10 @@ import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/models_new/fav/fav_folder/data.dart'; import 'package:PiliPlus/models_new/fav/fav_folder/list.dart'; import 'package:PiliPlus/pages/common/common_list_controller.dart'; -import 'package:PiliPlus/services/account_service.dart'; -import 'package:get/get.dart'; +import 'package:PiliPlus/utils/accounts.dart'; class FavController extends CommonListController { - AccountService accountService = Get.find(); + late final account = Accounts.main; @override void onInit() { @@ -17,7 +16,7 @@ class FavController extends CommonListController { @override Future queryData([bool isRefresh = true]) { - if (!accountService.isLogin.value) { + if (!account.isLogin) { loadingState.value = const Error('账号未登录'); return Future.value(); } @@ -36,6 +35,6 @@ class FavController extends CommonListController { Future> customGetData() => FavHttp.userfavFolder( pn: page, ps: 20, - mid: accountService.mid, + mid: account.mid, ); } diff --git a/lib/pages/fav_detail/controller.dart b/lib/pages/fav_detail/controller.dart index 36d288047..bba1b4f05 100644 --- a/lib/pages/fav_detail/controller.dart +++ b/lib/pages/fav_detail/controller.dart @@ -10,7 +10,7 @@ import 'package:PiliPlus/pages/common/common_list_controller.dart'; import 'package:PiliPlus/pages/common/multi_select/base.dart'; import 'package:PiliPlus/pages/common/multi_select/multi_select_controller.dart'; import 'package:PiliPlus/pages/fav_sort/view.dart'; -import 'package:PiliPlus/services/account_service.dart'; +import 'package:PiliPlus/utils/accounts.dart'; import 'package:PiliPlus/utils/extension.dart'; import 'package:PiliPlus/utils/page_utils.dart'; import 'package:PiliPlus/utils/storage.dart'; @@ -86,7 +86,7 @@ class FavDetailController @override bool get isOwner => _isOwner.value ?? false; - AccountService accountService = Get.find(); + late final account = Accounts.main; late double dx = 0; late final RxBool isPlayAll = Pref.enablePlayAll.obs; @@ -130,7 +130,7 @@ class FavDetailController if (isRefresh) { FavDetailData data = response.response; folderInfo.value = data.info!; - _isOwner.value = data.info?.mid == accountService.mid; + _isOwner.value = data.info?.mid == account.mid; } return false; } @@ -173,7 +173,7 @@ class FavDetailController } Future onFav(bool isFav) async { - if (!accountService.isLogin.value) { + if (!account.isLogin) { SmartDialog.showToast('账号未登录'); return; } diff --git a/lib/pages/fav_detail/view.dart b/lib/pages/fav_detail/view.dart index a98c1a98c..b18ea2705 100644 --- a/lib/pages/fav_detail/view.dart +++ b/lib/pages/fav_detail/view.dart @@ -320,7 +320,7 @@ class _FavDetailPageState extends State with GridMixin { isCopy: true, ctr: _favDetailController, mediaId: _favDetailController.mediaId, - mid: _favDetailController.accountService.mid, + mid: _favDetailController.account.mid, ), child: Text( '复制', @@ -339,7 +339,7 @@ class _FavDetailPageState extends State with GridMixin { isCopy: false, ctr: _favDetailController, mediaId: _favDetailController.mediaId, - mid: _favDetailController.accountService.mid, + mid: _favDetailController.account.mid, ), child: Text( '移动', diff --git a/lib/pages/group_panel/view.dart b/lib/pages/group_panel/view.dart index f4d34fa79..6d9e6bbeb 100644 --- a/lib/pages/group_panel/view.dart +++ b/lib/pages/group_panel/view.dart @@ -122,6 +122,7 @@ class _GroupPanelState extends State { Widget build(BuildContext context) { final theme = Theme.of(context); return Column( + crossAxisAlignment: CrossAxisAlignment.end, children: [ AppBar( backgroundColor: Colors.transparent, @@ -139,22 +140,16 @@ class _GroupPanelState extends State { ), Padding( padding: EdgeInsets.only( - left: 20, right: 20, top: 12, bottom: MediaQuery.viewPaddingOf(context).bottom + 12, ), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - FilledButton.tonal( - onPressed: onSave, - style: FilledButton.styleFrom( - visualDensity: VisualDensity.compact, - ), - child: Obx(() => Text(showDefaultBtn.value ? '保存至默认分组' : '保存')), - ), - ], + child: FilledButton.tonal( + onPressed: onSave, + style: FilledButton.styleFrom( + visualDensity: VisualDensity.compact, + ), + child: Obx(() => Text(showDefaultBtn.value ? '保存至默认分组' : '保存')), ), ), ], diff --git a/lib/pages/later/child_view.dart b/lib/pages/later/child_view.dart index f6f174e03..51dad6d9a 100644 --- a/lib/pages/later/child_view.dart +++ b/lib/pages/later/child_view.dart @@ -86,7 +86,7 @@ class _LaterViewChildPageState extends State .baseCtr .counts[LaterViewType.all], 'favTitle': '稍后再看', - 'mediaId': _laterController.accountService.mid, + 'mediaId': _laterController.mid, 'desc': _laterController.asc.value, 'isContinuePlaying': index != 0, } diff --git a/lib/pages/later/controller.dart b/lib/pages/later/controller.dart index 6797e4a08..9e1b23374 100644 --- a/lib/pages/later/controller.dart +++ b/lib/pages/later/controller.dart @@ -10,7 +10,7 @@ import 'package:PiliPlus/pages/common/common_list_controller.dart' import 'package:PiliPlus/pages/common/multi_select/base.dart'; import 'package:PiliPlus/pages/common/multi_select/multi_select_controller.dart'; import 'package:PiliPlus/pages/later/base_controller.dart'; -import 'package:PiliPlus/services/account_service.dart'; +import 'package:PiliPlus/utils/accounts.dart'; import 'package:PiliPlus/utils/extension.dart'; import 'package:PiliPlus/utils/page_utils.dart'; import 'package:flutter/material.dart'; @@ -92,7 +92,7 @@ class LaterController extends MultiSelectController LaterController(this.laterViewType); final LaterViewType laterViewType; - AccountService accountService = Get.find(); + late final mid = Accounts.main.mid; final RxBool asc = false.obs; @@ -177,7 +177,7 @@ class LaterController extends MultiSelectController 'sourceType': SourceType.watchLater, 'count': baseCtr.counts[LaterViewType.all], 'favTitle': '稍后再看', - 'mediaId': accountService.mid, + 'mediaId': mid, 'desc': asc.value, }, ); diff --git a/lib/pages/later/view.dart b/lib/pages/later/view.dart index 32870d83b..05881adbd 100644 --- a/lib/pages/later/view.dart +++ b/lib/pages/later/view.dart @@ -169,7 +169,7 @@ class _LaterPageState extends State isCopy: true, ctr: ctr, mediaId: null, - mid: ctr.accountService.mid, + mid: ctr.mid, ); }, child: Text( @@ -190,7 +190,7 @@ class _LaterPageState extends State isCopy: false, ctr: ctr, mediaId: null, - mid: ctr.accountService.mid, + mid: ctr.mid, ); }, child: Text( diff --git a/lib/pages/live_area/controller.dart b/lib/pages/live_area/controller.dart index 62580cddf..bc8596704 100644 --- a/lib/pages/live_area/controller.dart +++ b/lib/pages/live_area/controller.dart @@ -3,13 +3,13 @@ import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/models_new/live/live_area_list/area_item.dart'; import 'package:PiliPlus/models_new/live/live_area_list/area_list.dart'; import 'package:PiliPlus/pages/common/common_list_controller.dart'; -import 'package:PiliPlus/services/account_service.dart'; +import 'package:PiliPlus/utils/accounts.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; class LiveAreaController extends CommonListController?, AreaList> { - AccountService accountService = Get.find(); + late final isLogin = Accounts.main.isLogin; late final isEditing = false.obs; late final favInfo = {}; @@ -17,7 +17,7 @@ class LiveAreaController @override void onInit() { super.onInit(); - if (accountService.isLogin.value) { + if (isLogin) { queryFavTags(); } queryData(); @@ -25,7 +25,7 @@ class LiveAreaController @override Future onRefresh() { - if (accountService.isLogin.value) { + if (isLogin) { queryFavTags(); } return super.onRefresh(); diff --git a/lib/pages/live_area/view.dart b/lib/pages/live_area/view.dart index 3096cbdb6..f8dff4379 100644 --- a/lib/pages/live_area/view.dart +++ b/lib/pages/live_area/view.dart @@ -34,7 +34,7 @@ class _LiveAreaPageState extends State { resizeToAvoidBottomInset: false, appBar: AppBar( title: const Text('全部标签'), - actions: _controller.accountService.isLogin.value + actions: _controller.isLogin ? [ TextButton( onPressed: _controller.onEdit, @@ -54,7 +54,7 @@ class _LiveAreaPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (_controller.accountService.isLogin.value) + if (_controller.isLogin) Obx(() => _buildFavWidget(theme, _controller.favState.value)), Expanded( child: Obx( diff --git a/lib/pages/member/controller.dart b/lib/pages/member/controller.dart index dfeec6f46..2905f3d0a 100644 --- a/lib/pages/member/controller.dart +++ b/lib/pages/member/controller.dart @@ -10,7 +10,7 @@ import 'package:PiliPlus/models_new/space/space/live.dart'; import 'package:PiliPlus/models_new/space/space/setting.dart'; import 'package:PiliPlus/models_new/space/space/tab2.dart'; import 'package:PiliPlus/pages/common/common_data_controller.dart'; -import 'package:PiliPlus/services/account_service.dart'; +import 'package:PiliPlus/utils/accounts.dart'; import 'package:PiliPlus/utils/request_utils.dart'; import 'package:PiliPlus/utils/storage_pref.dart'; import 'package:PiliPlus/utils/utils.dart'; @@ -26,7 +26,7 @@ class MemberController extends CommonDataController int mid; String? username; - AccountService accountService = Get.find(); + late final account = Accounts.main; Live? live; int? silence; @@ -105,7 +105,7 @@ class MemberController extends CommonDataController ); } } - if (mid == accountService.mid) { + if (mid == account.mid) { spaceSetting = data.setting; } loadingState.value = response; @@ -142,7 +142,7 @@ class MemberController extends CommonDataController ); void blockUser(BuildContext context) { - if (!accountService.isLogin.value) { + if (!account.isLogin) { SmartDialog.showToast('账号未登录'); return; } @@ -190,12 +190,12 @@ class MemberController extends CommonDataController } void onFollow(BuildContext context) { - if (mid == accountService.mid) { + if (mid == account.mid) { Get.toNamed('/editProfile'); } else if (relation.value == 128) { _onBlock(); } else { - if (!accountService.isLogin.value) { + if (!account.isLogin) { SmartDialog.showToast('账号未登录'); return; } diff --git a/lib/pages/member/view.dart b/lib/pages/member/view.dart index 3377376b0..fc4e80b11 100644 --- a/lib/pages/member/view.dart +++ b/lib/pages/member/view.dart @@ -112,8 +112,8 @@ class _MemberPageState extends State { PopupMenuButton( icon: const Icon(Icons.more_vert), itemBuilder: (BuildContext context) => [ - if (_userController.accountService.isLogin.value && - _userController.accountService.mid != _mid) ...[ + if (_userController.account.isLogin && + _userController.account.mid != _mid) ...[ PopupMenuItem( onTap: () => _userController.blockUser(context), child: Row( @@ -148,7 +148,7 @@ class _MemberPageState extends State { const Icon(Icons.share_outlined, size: 19), const SizedBox(width: 10), Text( - _userController.accountService.mid != _mid ? '分享UP主' : '分享我的主页', + _userController.account.mid != _mid ? '分享UP主' : '分享我的主页', ), ], ), @@ -169,8 +169,8 @@ class _MemberPageState extends State { ], ), ), - if (_userController.accountService.isLogin.value) - if (_userController.mid == _userController.accountService.mid) ...[ + if (_userController.account.isLogin) + if (_userController.mid == _userController.account.mid) ...[ if ((_userController .loadingState .value @@ -334,8 +334,7 @@ class _MemberPageState extends State { title: Text(_userController.username ?? ''), flexibleSpace: Obx( () => UserInfoCard( - isOwner: - _userController.mid == _userController.accountService.mid, + isOwner: _userController.mid == _userController.account.mid, relation: _userController.relation.value, card: response.card!, images: response.images!, diff --git a/lib/pages/member_coin_arc/view.dart b/lib/pages/member_coin_arc/view.dart index 95b94d1d4..5d9bc5eb6 100644 --- a/lib/pages/member_coin_arc/view.dart +++ b/lib/pages/member_coin_arc/view.dart @@ -6,7 +6,7 @@ import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/models_new/member/coin_like_arc/item.dart'; import 'package:PiliPlus/pages/member_coin_arc/controller.dart'; import 'package:PiliPlus/pages/member_coin_arc/widgets/item.dart'; -import 'package:PiliPlus/services/account_service.dart'; +import 'package:PiliPlus/utils/accounts.dart'; import 'package:PiliPlus/utils/grid.dart'; import 'package:PiliPlus/utils/utils.dart'; import 'package:flutter/material.dart'; @@ -27,8 +27,7 @@ class MemberCoinArcPage extends StatefulWidget { } class _MemberCoinArcPageState extends State { - AccountService accountService = Get.find(); - + late final mid = Accounts.main.mid; late final _ctr = Get.put( MemberCoinArcController(mid: widget.mid), tag: Utils.makeHeroTag(widget.mid), @@ -41,7 +40,7 @@ class _MemberCoinArcPageState extends State { resizeToAvoidBottomInset: false, appBar: AppBar( title: Text( - '${widget.mid == accountService.mid ? '我' : '${widget.name}'}的最近投币', + '${widget.mid == mid ? '我' : '${widget.name}'}的最近投币', ), ), body: refreshIndicator( diff --git a/lib/pages/member_like_arc/view.dart b/lib/pages/member_like_arc/view.dart index 0e4b93e02..36412f2ef 100644 --- a/lib/pages/member_like_arc/view.dart +++ b/lib/pages/member_like_arc/view.dart @@ -6,7 +6,7 @@ import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/models_new/member/coin_like_arc/item.dart'; import 'package:PiliPlus/pages/member_coin_arc/widgets/item.dart'; import 'package:PiliPlus/pages/member_like_arc/controller.dart'; -import 'package:PiliPlus/services/account_service.dart'; +import 'package:PiliPlus/utils/accounts.dart'; import 'package:PiliPlus/utils/grid.dart'; import 'package:PiliPlus/utils/utils.dart'; import 'package:flutter/material.dart'; @@ -27,8 +27,7 @@ class MemberLikeArcPage extends StatefulWidget { } class _MemberLikeArcPageState extends State { - AccountService accountService = Get.find(); - + late final mid = Accounts.main.mid; late final _ctr = Get.put( MemberLikeArcController(mid: widget.mid), tag: Utils.makeHeroTag(widget.mid), @@ -41,7 +40,7 @@ class _MemberLikeArcPageState extends State { resizeToAvoidBottomInset: false, appBar: AppBar( title: Text( - '${widget.mid == accountService.mid ? '我' : '${widget.name}'}的推荐', + '${widget.mid == mid ? '我' : '${widget.name}'}的推荐', ), ), body: refreshIndicator( diff --git a/lib/pages/member_video/view.dart b/lib/pages/member_video/view.dart index 31bcb4419..0018c02f8 100644 --- a/lib/pages/member_video/view.dart +++ b/lib/pages/member_video/view.dart @@ -10,7 +10,7 @@ import 'package:PiliPlus/pages/member_video/controller.dart'; import 'package:PiliPlus/pages/member_video/widgets/video_card_h_member_video.dart'; import 'package:PiliPlus/utils/grid.dart'; import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart'; -import 'package:flutter/foundation.dart'; +import 'package:flutter/foundation.dart' show kDebugMode; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:get/get.dart'; diff --git a/lib/pages/mine/controller.dart b/lib/pages/mine/controller.dart index dcc5840c0..19930930e 100644 --- a/lib/pages/mine/controller.dart +++ b/lib/pages/mine/controller.dart @@ -36,49 +36,45 @@ class MineController ThemeType get nextThemeType => ThemeType.values[(themeType.value.index + 1) % ThemeType.values.length]; - late final list = <({IconData icon, String title, VoidCallback onTap})>[ - ( - icon: Icons.history, - title: '观看记录', - onTap: () { - if (isLogin) { - Get.toNamed('/history'); - } - }, - ), - ( - icon: Icons.subscriptions_outlined, - title: '我的订阅', - onTap: () { - if (isLogin) { - Get.toNamed('/subscription'); - } - }, - ), - ( - icon: Icons.watch_later_outlined, - title: '稍后再看', - onTap: () { - if (isLogin) { - Get.toNamed('/later'); - } - }, - ), - ( - icon: Icons.create_outlined, - title: '创作中心', - onTap: () { - if (isLogin) { - Get.toNamed( - '/webview', - parameters: { - 'url': 'https://member.bilibili.com/platform/home', - }, - ); - } - }, - ), - ]; + late final list = + <({IconData icon, double size, String title, VoidCallback onTap})>[ + ( + size: 23, + icon: MdiIcons.folderDownloadOutline, + title: '离线缓存', + onTap: () => Get.toNamed('/download'), + ), + ( + size: 23, + icon: Icons.history, + title: '观看记录', + onTap: () { + if (isLogin) { + Get.toNamed('/history'); + } + }, + ), + ( + size: 20, + icon: Icons.subscriptions_outlined, + title: '我的订阅', + onTap: () { + if (isLogin) { + Get.toNamed('/subscription'); + } + }, + ), + ( + size: 22, + icon: Icons.watch_later_outlined, + title: '稍后再看', + onTap: () { + if (isLogin) { + Get.toNamed('/later'); + } + }, + ), + ]; @override void onInit() { diff --git a/lib/pages/mine/view.dart b/lib/pages/mine/view.dart index e0365e8f9..1c481a408 100644 --- a/lib/pages/mine/view.dart +++ b/lib/pages/mine/view.dart @@ -116,7 +116,7 @@ class _MediaPageState extends CommonPageState mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( - size: 22, + size: e.size, e.icon, color: primary, ), diff --git a/lib/pages/rank/view.dart b/lib/pages/rank/view.dart index 3c3cc03c9..940769332 100644 --- a/lib/pages/rank/view.dart +++ b/lib/pages/rank/view.dart @@ -20,8 +20,8 @@ class _RankPageState extends State @override Widget build(BuildContext context) { - final theme = Theme.of(context); super.build(context); + final theme = Theme.of(context); return Row( children: [ SizedBox( diff --git a/lib/pages/setting/models/extra_settings.dart b/lib/pages/setting/models/extra_settings.dart index 1e062ea02..8205b918e 100644 --- a/lib/pages/setting/models/extra_settings.dart +++ b/lib/pages/setting/models/extra_settings.dart @@ -25,15 +25,18 @@ import 'package:PiliPlus/pages/setting/widgets/select_dialog.dart'; import 'package:PiliPlus/pages/setting/widgets/slide_dialog.dart'; import 'package:PiliPlus/pages/video/reply/widgets/reply_item_grpc.dart'; import 'package:PiliPlus/plugin/pl_player/controller.dart'; +import 'package:PiliPlus/services/download/download_service.dart'; import 'package:PiliPlus/utils/accounts.dart'; -import 'package:PiliPlus/utils/cache_manage.dart'; +import 'package:PiliPlus/utils/cache_manager.dart'; import 'package:PiliPlus/utils/feed_back.dart'; import 'package:PiliPlus/utils/image_utils.dart'; +import 'package:PiliPlus/utils/path_utils.dart'; import 'package:PiliPlus/utils/storage.dart'; import 'package:PiliPlus/utils/storage_key.dart'; import 'package:PiliPlus/utils/storage_pref.dart'; import 'package:PiliPlus/utils/update.dart'; import 'package:PiliPlus/utils/utils.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:flutter/foundation.dart' show kDebugMode; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -43,7 +46,7 @@ import 'package:get/get.dart'; import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; List get extraSettings => [ - if (Utils.isDesktop) + if (Utils.isDesktop) ...[ SettingsModel( settingsType: SettingsType.sw1tch, title: '退出时最小化', @@ -56,6 +59,63 @@ List get extraSettings => [ } catch (_) {} }, ), + SettingsModel( + settingsType: SettingsType.normal, + title: '缓存路径', + getSubtitle: () => downloadPath, + leading: const Icon(Icons.storage), + onTap: (setState) { + showDialog( + context: Get.context!, + builder: (context) { + return AlertDialog( + clipBehavior: Clip.hardEdge, + contentPadding: const EdgeInsets.symmetric(vertical: 12), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + onTap: () { + Get.back(); + Utils.copyText(downloadPath); + }, + dense: true, + title: const Text('复制', style: TextStyle(fontSize: 14)), + ), + ListTile( + onTap: () { + Get.back(); + final defPath = defDownloadPath; + if (downloadPath == defPath) return; + downloadPath = defPath; + setState(); + Get.find().readDownloadList(); + GStorage.setting.delete(SettingBoxKey.downloadPath); + }, + dense: true, + title: const Text('重置', style: TextStyle(fontSize: 14)), + ), + ListTile( + onTap: () async { + Get.back(); + final path = await FilePicker.platform.getDirectoryPath(); + if (path == null || path == downloadPath) return; + downloadPath = path; + setState(); + Get.find().readDownloadList(); + GStorage.setting.put(SettingBoxKey.downloadPath, path); + }, + dense: true, + title: const Text('设置新路径', style: TextStyle(fontSize: 14)), + ), + ], + ), + ); + }, + ); + }, + ), + ], SettingsModel( settingsType: SettingsType.sw1tch, title: '空降助手', @@ -1104,7 +1164,7 @@ List get extraSettings => [ title: '最大缓存大小', getSubtitle: () { final num = Pref.maxCacheSize; - return '当前最大缓存大小: 「${num == 0 ? '无限' : CacheManage.formatSize(Pref.maxCacheSize)}」'; + return '当前最大缓存大小: 「${num == 0 ? '无限' : CacheManager.formatSize(Pref.maxCacheSize)}」'; }, onTap: (setState) { showDialog( diff --git a/lib/pages/setting/widgets/select_dialog.dart b/lib/pages/setting/widgets/select_dialog.dart index c23495ab5..1f605117f 100644 --- a/lib/pages/setting/widgets/select_dialog.dart +++ b/lib/pages/setting/widgets/select_dialog.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:PiliPlus/http/constants.dart'; +import 'package:PiliPlus/http/ua_type.dart'; import 'package:PiliPlus/http/video.dart'; import 'package:PiliPlus/models/common/video/cdn_type.dart'; import 'package:PiliPlus/models/common/video/video_type.dart'; @@ -80,18 +81,19 @@ class CdnSelectDialog extends StatefulWidget { class _CdnSelectDialogState extends State { late final List> _cdnResList; - late final CancelToken _cancelToken; + late final List _tokens; late final bool _cdnSpeedTest; @override void initState() { _cdnSpeedTest = Pref.cdnSpeedTest; if (_cdnSpeedTest) { + final length = CDNService.values.length; _cdnResList = List.generate( - CDNService.values.length, + length, (_) => ValueNotifier(null), ); - _cancelToken = CancelToken(); + _tokens = List.generate(length, (_) => CancelToken()); _startSpeedTest(); } super.initState(); @@ -100,10 +102,13 @@ class _CdnSelectDialogState extends State { @override void dispose() { if (_cdnSpeedTest) { - _cancelToken.cancel(); + for (final e in _tokens) { + e?.cancel(); + } for (final notifier in _cdnResList) { notifier.dispose(); } + _dio.close(force: true); } super.dispose(); } @@ -145,26 +150,38 @@ class _CdnSelectDialogState extends State { } } + late final _dio = Dio() + ..options.headers = { + 'user-agent': UaType.pc.ua, + 'referer': HttpString.baseUrl, + }; + Future _measureDownloadSpeed(String url, int index) async { const maxSize = 8 * 1024 * 1024; int downloaded = 0; - final dio = Dio()..options.headers['referer'] = HttpString.baseUrl; + + final cancelToken = _tokens[index]; final start = DateTime.now().microsecondsSinceEpoch; - await dio.get( + void onClose() { + cancelToken?.cancel(); + _tokens[index] = null; + } + + await _dio.get( url, - cancelToken: _cancelToken, + cancelToken: cancelToken, onReceiveProgress: (count, total) { if (!mounted) { - dio.close(force: true); return; } + final duration = DateTime.now().microsecondsSinceEpoch - start; downloaded += count; if (duration > 15000000) { - dio.close(force: true); + onClose(); if (downloaded > 0) { _updateSpeedResult(index, downloaded, duration); downloaded = 0; @@ -172,7 +189,7 @@ class _CdnSelectDialogState extends State { throw TimeoutException('测速超时'); } } else if (downloaded >= maxSize) { - dio.close(force: true); + onClose(); _updateSpeedResult(index, downloaded, duration); downloaded = 0; } @@ -186,6 +203,9 @@ class _CdnSelectDialogState extends State { } void _handleSpeedTestError(dynamic error, int index) { + _tokens + ..[index]?.cancel() + ..[index] = null; final item = _cdnResList[index]; if (item.value != null) return; diff --git a/lib/pages/share/view.dart b/lib/pages/share/view.dart index 82661486f..c923320ea 100644 --- a/lib/pages/share/view.dart +++ b/lib/pages/share/view.dart @@ -177,7 +177,7 @@ class _SharePanelState extends State { onTap: () async { _focusNode.unfocus(); UserModel? userModel = await Navigator.of(context).push( - GetPageRoute(page: () => const ContactPage()), + GetPageRoute(page: ContactPage.new), ); if (userModel != null) { _userList diff --git a/lib/pages/subscription/controller.dart b/lib/pages/subscription/controller.dart index 6d08b6033..db65d81e3 100644 --- a/lib/pages/subscription/controller.dart +++ b/lib/pages/subscription/controller.dart @@ -4,13 +4,13 @@ import 'package:PiliPlus/http/user.dart'; import 'package:PiliPlus/models_new/sub/sub/data.dart'; import 'package:PiliPlus/models_new/sub/sub/list.dart'; import 'package:PiliPlus/pages/common/common_list_controller.dart'; -import 'package:PiliPlus/services/account_service.dart'; +import 'package:PiliPlus/utils/accounts.dart'; import 'package:flutter/material.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; class SubController extends CommonListController { - AccountService accountService = Get.find(); + late final account = Accounts.main; @override void onInit() { @@ -20,7 +20,7 @@ class SubController extends CommonListController { @override Future queryData([bool isRefresh = true]) { - if (!accountService.isLogin.value) { + if (!account.isLogin) { loadingState.value = const Error('账号未登录'); return Future.value(); } @@ -77,6 +77,6 @@ class SubController extends CommonListController { Future> customGetData() => UserHttp.userSubFolder( pn: page, ps: 20, - mid: accountService.mid, + mid: account.mid, ); } diff --git a/lib/pages/subscription/widgets/item.dart b/lib/pages/subscription/widgets/item.dart index 1cccaad3d..5985038ac 100644 --- a/lib/pages/subscription/widgets/item.dart +++ b/lib/pages/subscription/widgets/item.dart @@ -108,21 +108,27 @@ class SubItem extends StatelessWidget { clipBehavior: Clip.none, children: [ Column( - spacing: 4, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - item.title!, - textAlign: TextAlign.start, - style: const TextStyle( - letterSpacing: 0.3, + Expanded( + child: Text( + item.title!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.start, + style: const TextStyle( + letterSpacing: 0.3, + ), ), ), Text( 'UP主: ${item.upper!.name!}', textAlign: TextAlign.start, style: style, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), + const SizedBox(height: 4), Text( '${item.mediaCount}个视频', textAlign: TextAlign.start, diff --git a/lib/pages/video/controller.dart b/lib/pages/video/controller.dart index e06ba9bfe..cb9725fbf 100644 --- a/lib/pages/video/controller.dart +++ b/lib/pages/video/controller.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math' show min; import 'dart:ui'; import 'package:PiliPlus/common/widgets/pair.dart'; @@ -9,6 +10,7 @@ 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'; @@ -25,9 +27,13 @@ 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/data.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/sponsor_block/segment_item.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/data.dart'; @@ -35,6 +41,8 @@ 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/search/widgets/search_text.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'; @@ -45,8 +53,11 @@ 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/context_ext.dart'; import 'package:PiliPlus/utils/duration_utils.dart'; +import 'package:PiliPlus/utils/extension.dart'; import 'package:PiliPlus/utils/page_utils.dart'; import 'package:PiliPlus/utils/storage.dart'; import 'package:PiliPlus/utils/storage_pref.dart'; @@ -86,6 +97,8 @@ class VideoDetailController extends GetxController // 页面来源 稍后再看 收藏夹 late bool isPlayAll; late SourceType sourceType; + late BiliDownloadEntryInfo entry; + late bool isFileSource; late bool _mediaDesc = false; late final RxList mediaList = [].obs; late String watchLaterTitle; @@ -103,7 +116,7 @@ class VideoDetailController extends GetxController late VideoDecodeFormatType currentDecodeFormats; // 是否开始自动播放 存在多p的情况下,第二p需要为true - final RxBool autoPlay = true.obs; + final RxBool autoPlay = Pref.autoPlayEnable.obs; final videoPlayerKey = GlobalKey(); final childKey = GlobalKey(); @@ -113,7 +126,6 @@ class VideoDetailController extends GetxController bool get setSystemBrightness => plPlayerController.setSystemBrightness; late VideoItem firstVideo; - late AudioItem firstAudio; String? videoUrl; String? audioUrl; Duration? defaultST; @@ -126,13 +138,23 @@ class VideoDetailController extends GetxController Box setting = GStorage.setting; - late String cacheDecode; - late String cacheSecondDecode; + // 预设的解码格式 + late String cacheDecode = Pref.defaultDecode; // def avc + late String cacheSecondDecode = Pref.secondDecode; // def av1 - bool get showReply => isUgc + 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; @@ -169,55 +191,60 @@ class VideoDetailController extends GetxController } void setVideoHeight() { - 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) { + 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; - this.videoHeight = minVideoHeight; - animationController.forward(from: 1); - } else if (currentHeight < minVideoHeightPrecise) { - // expande - isExpanding = true; - animationController.forward(from: currentHeight / minVideoHeight); - this.videoHeight = minVideoHeight; - } else { - // collapse - isCollapsing = true; animationController.forward( - from: scrollCtr.offset / (maxVideoHeight - minVideoHeight), + from: (minVideoHeight - scrollCtr.offset) / maxVideoHeight, ); - this.videoHeight = minVideoHeight; + 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) { + // expande + 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); + } } - } else { - if (scrollCtr.offset != 0) { - isExpanding = true; - animationController.forward(from: 1 - scrollCtr.offset / videoHeight); - } + } catch (_) { + if (kDebugMode) rethrow; } } @@ -245,6 +272,37 @@ class VideoDetailController extends GetxController final isLoginVideo = Accounts.get(AccountType.video).isLogin; + late final watchProgress = GStorage.watchProgress; + void cacheLocalProgress() { + 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(); @@ -268,8 +326,11 @@ class VideoDetailController extends GetxController cover = RxString(args['cover'] ?? ''); sourceType = args['sourceType'] ?? SourceType.normal; - isPlayAll = sourceType != SourceType.normal; - if (isPlayAll) { + isFileSource = sourceType == SourceType.file; + isPlayAll = sourceType != SourceType.normal && !isFileSource; + if (isFileSource) { + initFileSource(args['entry']); + } else if (isPlayAll) { watchLaterTitle = args['favTitle']; _mediaDesc = args['desc']; getMediaList(); @@ -280,12 +341,6 @@ class VideoDetailController extends GetxController vsync: this, initialIndex: Pref.defaultShowComment ? 1 : 0, ); - - autoPlay.value = Pref.autoPlayEnable; - - // 预设的解码格式 - cacheDecode = Pref.defaultDecode; - cacheSecondDecode = Pref.secondDecode; } Future getMediaList({ @@ -1086,10 +1141,11 @@ class VideoDetailController extends GetxController } FutureOr _initPlayerIfNeeded() { - if ((autoPlay.value || - (plPlayerController.preInitPlayer && - !plPlayerController.processing)) && - videoPlayerKey.currentState?.mounted == true) { + if (autoPlay.value || + (plPlayerController.preInitPlayer && !plPlayerController.processing) && + (isFileSource + ? true + : videoPlayerKey.currentState?.mounted == true)) { return playerInit(); } } @@ -1102,20 +1158,22 @@ class VideoDetailController extends GetxController bool? autoplay, Volume? volume, }) async { + final onlyPlayAudio = plPlayerController.onlyPlayAudio.value; await plPlayerController.setDataSource( DataSource( - videoSource: plPlayerController.onlyPlayAudio.value + videoSource: isFileSource + ? null + : onlyPlayAudio ? audio ?? audioUrl : video ?? videoUrl, - audioSource: plPlayerController.onlyPlayAudio.value - ? '' - : audio ?? audioUrl, - type: DataSourceType.network, - httpHeaders: { - 'user-agent': - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36', - 'referer': HttpString.baseUrl, - }, + audioSource: isFileSource || onlyPlayAudio ? null : audio ?? audioUrl, + type: isFileSource ? DataSourceType.file : DataSourceType.network, + httpHeaders: isFileSource + ? null + : { + 'user-agent': UaType.pc.ua, + 'referer': HttpString.baseUrl, + }, ), seekTo: seekToTime ?? defaultST ?? playedTime, duration: @@ -1141,18 +1199,23 @@ class VideoDetailController extends GetxController 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 (plPlayerController.enableSponsorBlock) { - initSkip(); - } + if (!isFileSource) { + if (plPlayerController.enableSponsorBlock) { + initSkip(); + } - if (vttSubtitlesIndex.value == -1) { - _queryPlayInfo(); - } + if (vttSubtitlesIndex.value == -1) { + _queryPlayInfo(); + } - if (plPlayerController.showDmChart && dmTrend.value == null) { - _getDmTrend(); + if (plPlayerController.showDmChart && dmTrend.value == null) { + _getDmTrend(); + } } defaultST = null; @@ -1178,6 +1241,9 @@ class VideoDetailController extends GetxController Duration? defaultST, bool fromReset = false, }) async { + if (isFileSource) { + return _initPlayerIfNeeded(); + } if (isQuerying) { return; } @@ -1241,19 +1307,21 @@ class VideoDetailController extends GetxController ); } if (data.dash == null && data.durl != null) { - videoUrl = data.durl!.first.url!; + final first = data.durl!.first; + videoUrl = first.backupUrl?.lastOrNull ?? first.url!; audioUrl = ''; // 实际为FLV/MP4格式,但已被淘汰,这里仅做兜底处理 + final videoQuality = VideoQuality.fromCode(data.quality!); firstVideo = VideoItem( id: data.quality!, baseUrl: videoUrl, codecs: 'avc1', - quality: VideoQuality.fromCode(data.quality!), + quality: videoQuality, ); setVideoHeight(); currentDecodeFormats = VideoDecodeFormatType.fromString('avc1'); - currentVideoQa.value = VideoQuality.fromCode(data.quality!); + currentVideoQa.value = videoQuality; await _initPlayerIfNeeded(); isQuerying = false; return; @@ -1268,28 +1336,25 @@ class VideoDetailController extends GetxController isQuerying = false; return; } - final List allVideosList = data.dash!.video!; + final List videoList = data.dash!.video!; // if (kDebugMode) debugPrint("allVideosList:${allVideosList}"); // 当前可播放的最高质量视频 - int currentHighVideoQa = allVideosList.first.quality.code; + final curHighestVideoQa = videoList.first.quality.code; // 预设的画质为null,则当前可用的最高质量 - int resVideoQa = currentHighVideoQa; + int targetVideoQa = curHighestVideoQa; if (data.acceptQuality?.isNotEmpty == true && - plPlayerController.cacheVideoQa! <= currentHighVideoQa) { + plPlayerController.cacheVideoQa! <= curHighestVideoQa) { // 如果预设的画质低于当前最高 - final List numbers = data.acceptQuality! - .where((e) => e <= currentHighVideoQa) - .toList(); - resVideoQa = Utils.findClosestNumber( - plPlayerController.cacheVideoQa!, - numbers, + targetVideoQa = data.acceptQuality!.findClosestTarget( + (e) => e <= plPlayerController.cacheVideoQa!, + (a, b) => a > b ? a : b, ); } - currentVideoQa.value = VideoQuality.fromCode(resVideoQa); + currentVideoQa.value = VideoQuality.fromCode(targetVideoQa); /// 取出符合当前画质的videoList - final List videosList = allVideosList - .where((e) => e.quality.code == resVideoQa) + final List videosList = videoList + .where((e) => e.quality.code == targetVideoQa) .toList(); /// 优先顺序 设置中指定解码格式 -> 当前可选的首个解码格式 @@ -1297,7 +1362,7 @@ class VideoDetailController extends GetxController // 根据画质选编码格式 final List supportDecodeFormats = supportFormats .firstWhere( - (e) => e.quality == resVideoQa, + (e) => e.quality == targetVideoQa, orElse: () => supportFormats.first, ) .codecs!; @@ -1307,11 +1372,11 @@ class VideoDetailController extends GetxController VideoDecodeFormatType.fromString(cacheSecondDecode); // 当前视频没有对应格式返回第一个 int flag = 0; - for (var i in supportDecodeFormats) { - if (currentDecodeFormats.codes.any(i.startsWith)) { + for (final e in supportDecodeFormats) { + if (currentDecodeFormats.codes.any(e.startsWith)) { flag = 1; break; - } else if (secondDecodeFormats.codes.any(i.startsWith)) { + } else if (secondDecodeFormats.codes.any(e.startsWith)) { flag = 2; } } @@ -1334,27 +1399,26 @@ class VideoDetailController extends GetxController /// 优先顺序 设置中指定质量 -> 当前可选的最高质量 AudioItem? firstAudio; - final audiosList = data.dash?.audio; - if (audiosList != null && audiosList.isNotEmpty) { - final List numbers = audiosList.map((map) => map.id!).toList(); - int closestNumber = Utils.findClosestNumber( - plPlayerController.cacheAudioQa, - numbers, + 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 (!numbers.contains(plPlayerController.cacheAudioQa) && - numbers.any((e) => e > plPlayerController.cacheAudioQa)) { - closestNumber = 30280; + if (!audioIds.contains(plPlayerController.cacheAudioQa) && + audioIds.any((e) => e > plPlayerController.cacheAudioQa)) { + closestNumber = AudioQuality.k192.code; } - firstAudio = audiosList.firstWhere( + firstAudio = audioList.firstWhere( (e) => e.id == closestNumber, - orElse: () => audiosList.first, + orElse: () => audioList.first, ); audioUrl = VideoUtils.getCdnUrl(firstAudio); - if (firstAudio.id != null) { - currentAudioQa = AudioQuality.fromCode(firstAudio.id!); + if (firstAudio.id case final int id?) { + currentAudioQa = AudioQuality.fromCode(id); } } else { - firstAudio = AudioItem(); audioUrl = ''; } await _initPlayerIfNeeded(); @@ -1605,6 +1669,11 @@ class VideoDetailController extends GetxController @override void onClose() { + if (isFileSource) { + cacheLocalProgress(); + } + introScrollCtr?.dispose(); + introScrollCtr = null; tabCtr.dispose(); scrollCtr ..removeListener(scrollListener) @@ -1614,53 +1683,59 @@ class VideoDetailController extends GetxController } void onReset({bool isStein = false}) { + if (isFileSource) { + cacheLocalProgress(); + } + playedTime = null; defaultST = null; videoUrl = null; audioUrl = null; - // language - languages.value = null; - currLang.value = null; - if (scrollRatio.value != 0) { scrollRatio.refresh(); } - // dm trend - if (plPlayerController.showDmChart) { - dmTrend.value = null; - } - // danmaku savedDanmaku = null; - // subtitle - subtitles.clear(); - vttSubtitlesIndex.value = -1; - _vttSubtitles.clear(); + if (!isFileSource) { + // language + languages.value = null; + currLang.value = null; - // view point - if (plPlayerController.showViewPoints) { - viewPointList.clear(); - } + // dm trend + if (plPlayerController.showDmChart) { + dmTrend.value = null; + } - // sponsor block - if (plPlayerController.enableSponsorBlock) { - _lastPos = null; - positionSubscription?.cancel(); - positionSubscription = null; - videoLabel.value = ''; - segmentList.clear(); - segmentProgressList.clear(); - } + // subtitle + subtitles.clear(); + vttSubtitlesIndex.value = -1; + _vttSubtitles.clear(); - // interactive video - if (isStein != true) { - graphVersion = null; + // view point + if (plPlayerController.showViewPoints) { + viewPointList.clear(); + } + + // sponsor block + if (plPlayerController.enableSponsorBlock) { + _lastPos = null; + positionSubscription?.cancel(); + positionSubscription = null; + videoLabel.value = ''; + segmentList.clear(); + segmentProgressList.clear(); + } + + // interactive video + if (isStein != true) { + graphVersion = null; + } + steinEdgeInfo = null; + showSteinEdgeInfo.value = false; } - steinEdgeInfo = null; - showSteinEdgeInfo.value = false; } late final Rx>?> dmTrend = @@ -1756,7 +1831,7 @@ class VideoDetailController extends GetxController if (isPlayAll) { id = args['mediaId']; extraId = sourceType.extraId; - from = sourceType.playlistSource; + from = sourceType.playlistSource!; } else if (isUgc) { try { final ctr = Get.find(tag: heroTag); @@ -1779,4 +1854,134 @@ class VideoDetailController extends GetxController 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?.isNotEmpty == true) { + final downloadService = Get.find(); + await downloadService.waitForInitialization; + if (!context.mounted) { + return; + } + final Set cidSet = downloadService.downloadList + .map((e) => e.cid) + .toSet(); + final index = episodes!.indexWhere( + (e) => e.cid == (seasonCid ?? cid.value), + ); + final size = context.mediaQuerySize; + final maxChildSize = Utils.isMobile && !size.isPortrait ? 1.0 : 0.7; + showModalBottomSheet( + context: context, + useSafeArea: true, + isScrollControlled: true, + constraints: BoxConstraints(maxWidth: min(640, size.shortestSide)), + builder: (context) => 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: const BoxConstraints(maxWidth: 425, minWidth: 425), + 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('确定'), + ), + ], + ), + ); + } } diff --git a/lib/pages/video/download_panel/view.dart b/lib/pages/video/download_panel/view.dart new file mode 100644 index 000000000..4f99e6a86 --- /dev/null +++ b/lib/pages/video/download_panel/view.dart @@ -0,0 +1,559 @@ +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/id_utils.dart'; +import 'package:PiliPlus/utils/storage_pref.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 episodes; + final ScrollController scrollController; + final VideoDetailController videoDetailController; + final String heroTag; + final UgcIntroController? ugcIntroController; + final Set cidSet; + + @override + State createState() => _DownloadPanelState(); +} + +class _DownloadPanelState extends State { + final DownloadService _downloadService = Get.find(); + final ListController _listController = ListController(); + + late final cidSet = widget.cidSet; + VideoQuality _quality = VideoQuality.fromCode(Pref.defaultVideoQa); + + @override + void initState() { + super.initState(); + 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) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 0, 12), + child: Row( + spacing: 16, + children: [ + Text( + '最高画质', + style: TextStyle(color: theme.colorScheme.onSurfaceVariant), + ), + Builder( + builder: (context) => PopupMenuButton( + 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), + ), + const Icon( + size: 18, + Icons.keyboard_arrow_down, + ), + ], + ), + ), + ), + ), + ], + ), + ); + } + + 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; + 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('downloded'); + } + return false; + } + + if (kReleaseMode && episode.badge == '会员') { + if (vipStatus != 1) { + if (!isDownloadAll) { + SmartDialog.showToast('需要大会员'); + } + return false; + } + } + + if (episode is ugc.EpisodeItem && episode.pages!.length > 1) { + if (isFromList && kDebugMode) { + SmartDialog.showToast('hasParts'); + } + if (isDownloadAll) { + for (int i = 0; i < episode.pages!.length; i++) { + _onDownload( + index: i, + episode: 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) { + if (kDebugMode) rethrow; + 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; + String? cover; + num? duration; + int? pubdate; + int? view; + int? danmaku; + bool? isCharging; + int? cid; + + 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; + break; + case ugc.EpisodeItem item: + cid = item.cid; + title = item.title!; + cover = item.arc?.pic; + duration = item.arc?.duration; + pubdate = item.arc?.pubdate; + view = item.arc?.stat?.view; + danmaku = item.arc?.stat?.danmaku; + 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; + 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, + ), + 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, + 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, + ); + } + setState(() {}); + }, + ); + }, + ), + SizedBox( + height: 20, + child: VerticalDivider( + width: 1, + color: dividerColor, + ), + ), + _buildBottomBtn( + text: '查看缓存', + onTap: () => Navigator.of(context).push( + GetPageRoute(page: DownloadPage.new), + ), + ), + ], + ), + ); + } + + 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, + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/video/introduction/local/controller.dart b/lib/pages/video/introduction/local/controller.dart new file mode 100644 index 000000000..0bf4f8040 --- /dev/null +++ b/lib/pages/video/introduction/local/controller.dart @@ -0,0 +1,139 @@ +import 'package:PiliPlus/models_new/download/bili_download_entry_info.dart'; +import 'package:PiliPlus/models_new/video/video_detail/stat_detail.dart'; +import 'package:PiliPlus/pages/common/common_intro_controller.dart'; +import 'package:PiliPlus/pages/download/controller.dart'; +import 'package:PiliPlus/plugin/pl_player/models/play_repeat.dart'; +import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart'; +import 'package:flutter/foundation.dart' show kDebugMode; +import 'package:flutter/scheduler.dart' show SchedulerBinding; +import 'package:get/get.dart'; + +class LocalIntroController extends CommonIntroController { + @override + void queryVideoIntro() {} + + @override + void actionCoinVideo() {} + + @override + void actionLikeVideo() {} + + @override + void actionShareVideo(context) {} + + @override + void actionTriple() {} + + @override + Future actionFavVideo({bool isQuick = false}) async {} + + @override + (Object, int) get getFavRidType => throw UnimplementedError(); + + @override + StatDetail? getStat() => null; + + @override + bool get isShowOnlineTotal => false; + + late final Set aidSet = {}; + + @override + void onClose() { + aidSet.clear(); + super.onClose(); + } + + @override + void onInit() { + super.onInit(); + videoDetail.value.title = videoDetailCtr.args['title']; + final controller = Get.find(); + final list = []; + for (final e in controller.pages) { + final items = e.entrys..sort((a, b) => a.sortKey.compareTo(b.sortKey)); + final completed = items.where((e) => e.isCompleted); + list.addAllIf(completed.isNotEmpty, completed); + if (completed.length == 1) { + aidSet.add(e.pageId); + } + } + this.list.value = list; + final currCid = videoDetailCtr.cid.value; + final index = list.indexWhere((e) => e.cid == currCid); + this.index.value = index; + if (index != 0) { + SchedulerBinding.instance.addPostFrameCallback((_) { + try { + if (videoDetailCtr.scrollKey.currentState?.mounted ?? false) { + (videoDetailCtr.scrollKey.currentState!.innerController + as ExtendedNestedScrollController) + .nestedPositions + .first + .localJumpTo(_offset); + } else if (videoDetailCtr.introScrollCtr?.hasClients ?? false) { + videoDetailCtr.introScrollCtr!.jumpTo(_offset); + } + } catch (_) { + if (kDebugMode) rethrow; + } + }); + } + } + + final index = (-1).obs; + double get _offset => index * 100 + 7 - 35; + final list = RxList(); + + @override + bool nextPlay() { + final next = index.value + 1; + if (next < list.length) { + playIndex(next); + return true; + } else { + final playCtr = videoDetailCtr.plPlayerController; + if (playCtr.playRepeat == PlayRepeat.listCycle) { + if (list.length == 1) { + if (playCtr.videoPlayerController case final ctr?) { + ctr.seek(Duration.zero).whenComplete(ctr.play); + } + } else { + playIndex(0); + } + return true; + } + } + return false; + } + + @override + bool prevPlay() { + final prev = index.value - 1; + if (prev >= 0) { + playIndex(prev); + return true; + } + return false; + } + + void playIndex( + int index, { + BiliDownloadEntryInfo? entry, + }) { + entry ??= list[index]; + videoDetailCtr + ..onReset() + ..cover.value = entry.cover + ..aid = entry.avid + ..bvid = entry.bvid + ..cid.value = entry.cid + ..args['dirPath'] = entry.entryDirPath + ..initFileSource(entry, isInit: false) + ..playerInit(); + videoDetail + ..value.title = entry.showTitle + ..refresh(); + this.index.value = index; + } +} diff --git a/lib/pages/video/introduction/local/view.dart b/lib/pages/video/introduction/local/view.dart new file mode 100644 index 000000000..0acd47999 --- /dev/null +++ b/lib/pages/video/introduction/local/view.dart @@ -0,0 +1,172 @@ +import 'package:PiliPlus/common/constants.dart'; +import 'package:PiliPlus/common/widgets/badge.dart'; +import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; +import 'package:PiliPlus/models/common/badge_type.dart'; +import 'package:PiliPlus/models/common/video/video_quality.dart'; +import 'package:PiliPlus/models_new/download/bili_download_entry_info.dart'; +import 'package:PiliPlus/pages/video/introduction/local/controller.dart'; +import 'package:PiliPlus/utils/duration_utils.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class LocalIntroPanel extends StatefulWidget { + const LocalIntroPanel({super.key, required this.heroTag}); + + final String heroTag; + + @override + State createState() => _LocalIntroPanelState(); +} + +class _LocalIntroPanelState extends State + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + late final _controller = Get.find(tag: widget.heroTag); + + @override + Widget build(BuildContext context) { + super.build(context); + final theme = Theme.of(context); + return Obx(() { + final currIndex = _controller.index.value; + return SliverFixedExtentList.builder( + itemCount: _controller.list.length, + itemBuilder: (context, index) { + final item = _controller.list[index]; + return _buildItem(theme, currIndex == index, index, item); + }, + itemExtent: 100, + ); + }); + } + + Widget _buildItem( + ThemeData theme, + bool isCurr, + int index, + BiliDownloadEntryInfo entry, + ) { + final outline = theme.colorScheme.outline; + return Padding( + padding: const EdgeInsets.only(bottom: 2), + child: SizedBox( + height: 98, + child: Material( + type: MaterialType.transparency, + child: InkWell( + onTap: () { + if (isCurr) { + return; + } + _controller.playIndex(index, entry: entry); + }, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: StyleString.safeSpace, + vertical: 5, + ), + child: Row( + spacing: 10, + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + NetworkImgLayer( + src: entry.cover, + width: 140.8, + height: 88, + ), + PBadge( + text: DurationUtils.formatDuration( + entry.totalTimeMilli ~/ 1000, + ), + right: 6.0, + bottom: 6.0, + type: PBadgeType.gray, + ), + if (entry.videoQuality case final videoQuality?) + PBadge( + text: VideoQuality.fromCode(videoQuality).shortDesc, + right: 6.0, + top: 6.0, + type: PBadgeType.gray, + ), + ], + ), + Expanded( + child: Stack( + clipBehavior: Clip.none, + children: [ + Column( + spacing: 5, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + entry.title, + textAlign: TextAlign.start, + style: TextStyle( + fontSize: theme.textTheme.bodyMedium!.fontSize, + height: 1.42, + letterSpacing: 0.3, + color: isCurr + ? theme.colorScheme.primary + : null, + fontWeight: isCurr ? FontWeight.bold : null, + ), + maxLines: entry.ep != null ? 1 : 2, + overflow: TextOverflow.ellipsis, + ), + if (entry.pageData?.part case final part?) + if (part != entry.title) + Text( + part, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + if (entry.ep?.showTitle case final showTitle?) + Text( + showTitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + if (entry.ownerName case final ownerName?) + Align( + alignment: Alignment.bottomLeft, + child: Text( + ownerName, + maxLines: 1, + style: TextStyle( + fontSize: 12, + height: 1, + color: outline, + ), + ), + ), + Align( + alignment: Alignment.bottomRight, + child: entry.moreBtn(theme), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/video/introduction/pgc/controller.dart b/lib/pages/video/introduction/pgc/controller.dart index eab191f87..1a9a05122 100644 --- a/lib/pages/video/introduction/pgc/controller.dart +++ b/lib/pages/video/introduction/pgc/controller.dart @@ -19,7 +19,6 @@ import 'package:PiliPlus/models_new/video/video_detail/episode.dart' import 'package:PiliPlus/models_new/video/video_detail/stat_detail.dart'; import 'package:PiliPlus/pages/common/common_intro_controller.dart'; import 'package:PiliPlus/pages/dynamics_repost/view.dart'; -import 'package:PiliPlus/pages/video/controller.dart'; import 'package:PiliPlus/pages/video/pay_coins/view.dart'; import 'package:PiliPlus/pages/video/reply/controller.dart'; import 'package:PiliPlus/plugin/pl_player/models/play_repeat.dart'; @@ -66,7 +65,7 @@ class PgcIntroController extends CommonIntroController { super.onInit(); if (isPgc) { - if (accountService.isLogin.value) { + if (isLogin) { queryIsFollowed(); if (epId != null) { queryPgcLikeCoinFav(); @@ -101,7 +100,7 @@ class PgcIntroController extends CommonIntroController { // (取消)点赞 @override Future actionLikeVideo() async { - if (!accountService.isLogin.value) { + if (!isLogin) { SmartDialog.showToast('账号未登录'); return; } @@ -119,7 +118,7 @@ class PgcIntroController extends CommonIntroController { // 投币 @override void actionCoinVideo() { - if (!accountService.isLogin.value) { + if (!isLogin) { SmartDialog.showToast('账号未登录'); return; } @@ -294,7 +293,7 @@ class PgcIntroController extends CommonIntroController { this.epId = epId; this.bvid = bvid; - final videoDetailCtr = Get.find(tag: heroTag) + videoDetailCtr ..plPlayerController.pause() ..makeHeartBeat() ..onReset() @@ -316,7 +315,7 @@ class PgcIntroController extends CommonIntroController { } catch (_) {} } - if (isPgc && accountService.isLogin.value) { + if (isPgc && isLogin) { queryPgcLikeCoinFav(); } @@ -364,9 +363,6 @@ class PgcIntroController extends CommonIntroController { @override bool prevPlay() { final episodes = pgcItem.episodes!; - VideoDetailController videoDetailCtr = Get.find( - tag: heroTag, - ); int currentIndex = episodes.indexWhere( (e) => e.cid == videoDetailCtr.cid.value, ); @@ -388,9 +384,7 @@ class PgcIntroController extends CommonIntroController { bool nextPlay() { try { final episodes = pgcItem.episodes!; - VideoDetailController videoDetailCtr = Get.find( - tag: heroTag, - ); + PlayRepeat playRepeat = videoDetailCtr.plPlayerController.playRepeat; int currentIndex = episodes.indexWhere( @@ -418,7 +412,7 @@ class PgcIntroController extends CommonIntroController { @override Future actionTriple() async { feedBack(); - if (!accountService.isLogin.value) { + if (!isLogin) { SmartDialog.showToast('账号未登录'); return; } diff --git a/lib/pages/video/introduction/ugc/controller.dart b/lib/pages/video/introduction/ugc/controller.dart index 5f8691244..fa1c237d1 100644 --- a/lib/pages/video/introduction/ugc/controller.dart +++ b/lib/pages/video/introduction/ugc/controller.dart @@ -24,7 +24,6 @@ import 'package:PiliPlus/models_new/video/video_detail/stat_detail.dart'; import 'package:PiliPlus/models_new/video/video_detail/ugc_season.dart'; import 'package:PiliPlus/pages/common/common_intro_controller.dart'; import 'package:PiliPlus/pages/dynamics_repost/view.dart'; -import 'package:PiliPlus/pages/video/controller.dart'; import 'package:PiliPlus/pages/video/pay_coins/view.dart'; import 'package:PiliPlus/pages/video/related/controller.dart'; import 'package:PiliPlus/pages/video/reply/controller.dart'; @@ -104,15 +103,12 @@ class UgcIntroController extends CommonIntroController with ReloadMixin { } videoDetail.value = data; try { - final videoDetailController = Get.find( - tag: heroTag, - ); - if (videoDetailController.cover.value.isEmpty || - (videoDetailController.videoUrl.isNullOrEmpty && - !videoDetailController.isQuerying)) { - videoDetailController.cover.value = data.pic ?? ''; + if (videoDetailCtr.cover.value.isEmpty || + (videoDetailCtr.videoUrl.isNullOrEmpty && + !videoDetailCtr.isQuerying)) { + videoDetailCtr.cover.value = data.pic ?? ''; } - if (videoDetailController.showReply) { + if (videoDetailCtr.showReply) { try { Get.find(tag: heroTag).count.value = data.stat?.reply ?? 0; @@ -129,7 +125,7 @@ class UgcIntroController extends CommonIntroController with ReloadMixin { status.value = false; } - if (accountService.isLogin.value) { + if (isLogin) { queryAllStatus(); queryFollowStatus(); } @@ -184,7 +180,7 @@ class UgcIntroController extends CommonIntroController with ReloadMixin { @override Future actionTriple() async { feedBack(); - if (!accountService.isLogin.value) { + if (!isLogin) { SmartDialog.showToast('账号未登录'); return; } @@ -224,7 +220,7 @@ class UgcIntroController extends CommonIntroController with ReloadMixin { // (取消)点赞 @override Future actionLikeVideo() async { - if (!accountService.isLogin.value) { + if (!isLogin) { SmartDialog.showToast('账号未登录'); return; } @@ -246,7 +242,7 @@ class UgcIntroController extends CommonIntroController with ReloadMixin { } Future actionDislikeVideo() async { - if (!accountService.isLogin.value) { + if (!isLogin) { SmartDialog.showToast('账号未登录'); return; } @@ -274,7 +270,7 @@ class UgcIntroController extends CommonIntroController with ReloadMixin { // 投币 @override void actionCoinVideo() { - if (!accountService.isLogin.value) { + if (!isLogin) { SmartDialog.showToast('账号未登录'); return; } @@ -426,7 +422,7 @@ class UgcIntroController extends CommonIntroController with ReloadMixin { // 关注/取关up Future actionRelationMod(BuildContext context) async { - if (!accountService.isLogin.value) { + if (!isLogin) { SmartDialog.showToast('账号未登录'); return; } @@ -479,7 +475,6 @@ class UgcIntroController extends CommonIntroController with ReloadMixin { final String? cover = episode.cover; // 重新获取视频资源 - final videoDetailCtr = Get.find(tag: heroTag); if (videoDetailCtr.isPlayAll) { if (videoDetailCtr.mediaList.indexWhere((item) => item.bvid == bvid) == @@ -566,7 +561,6 @@ class UgcIntroController extends CommonIntroController with ReloadMixin { final List episodes = []; bool isPart = false; - final videoDetailCtr = Get.find(tag: heroTag); final videoDetail = this.videoDetail.value; if (!skipPart && (videoDetail.pages?.length ?? 0) > 1) { @@ -632,7 +626,6 @@ class UgcIntroController extends CommonIntroController with ReloadMixin { try { final List episodes = []; bool isPart = false; - final videoDetailCtr = Get.find(tag: heroTag); final videoDetail = this.videoDetail.value; // part -> playall -> season diff --git a/lib/pages/video/introduction/ugc/widgets/page.dart b/lib/pages/video/introduction/ugc/widgets/page.dart index 986a4e719..a77136103 100644 --- a/lib/pages/video/introduction/ugc/widgets/page.dart +++ b/lib/pages/video/introduction/ugc/widgets/page.dart @@ -6,6 +6,7 @@ import 'package:PiliPlus/pages/video/controller.dart'; import 'package:PiliPlus/pages/video/introduction/ugc/controller.dart'; import 'package:PiliPlus/utils/id_utils.dart'; import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:get/get.dart'; // TODO refa @@ -18,6 +19,8 @@ class PagesPanel extends StatefulWidget { required this.heroTag, this.showEpisodes, required this.ugcIntroController, + this.onDownload, + this.cidSet, }); final List? list; @@ -28,6 +31,9 @@ class PagesPanel extends StatefulWidget { final Function? showEpisodes; final UgcIntroController ugcIntroController; + final Set? cidSet; + final bool Function(Part part)? onDownload; + @override State createState() => _PagesPanelState(); } @@ -105,7 +111,7 @@ class _PagesPanelState extends State { const Text('视频选集 '), Expanded( child: Text( - ' 正在播放:${pages[pageIndex].pagePart}', + ' 正在播放:${pages[pageIndex].part}', overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 12, @@ -160,6 +166,12 @@ class _PagesPanelState extends State { child: InkWell( borderRadius: const BorderRadius.all(Radius.circular(6)), onTap: () { + if (widget.onDownload case final onDownload?) { + if (onDownload(item) && mounted) { + setState(() {}); + } + return; + } if (widget.showEpisodes == null) { Get.back(); } @@ -193,7 +205,7 @@ class _PagesPanelState extends State { ], Expanded( child: Text( - item.pagePart!, + item.part!, maxLines: 1, style: TextStyle( fontSize: 13, @@ -204,6 +216,14 @@ class _PagesPanelState extends State { overflow: TextOverflow.ellipsis, ), ), + if (widget.cidSet?.contains(item.cid) ?? false) + Icon( + size: 13, + color: theme.colorScheme.secondary.withValues( + alpha: 0.8, + ), + FontAwesomeIcons.circleDown, + ), ], ), ), diff --git a/lib/pages/video/introduction/ugc/widgets/triple_mixin.dart b/lib/pages/video/introduction/ugc/widgets/triple_mixin.dart index 6e243fade..c77d863d7 100644 --- a/lib/pages/video/introduction/ugc/widgets/triple_mixin.dart +++ b/lib/pages/video/introduction/ugc/widgets/triple_mixin.dart @@ -20,7 +20,7 @@ mixin TripleMixin on GetxController, TickerProvider { bool get hasTriple => hasLike.value && hasCoin && hasFav.value; void actionTriple(); - Future actionLikeVideo(); + void actionLikeVideo(); // no need for pugv AnimationController? _tripleAnimCtr; diff --git a/lib/pages/video/medialist/view.dart b/lib/pages/video/medialist/view.dart index b1a5d1829..8adce4d29 100644 --- a/lib/pages/video/medialist/view.dart +++ b/lib/pages/video/medialist/view.dart @@ -57,7 +57,7 @@ class _MediaListPanelState extends State final bvid = widget.bvid; final bvIndex = widget.mediaList.indexWhere((item) => item.bvid == bvid); _controller = ScrollController( - initialScrollOffset: bvIndex == -1 ? 0 : bvIndex * 100.0 + 7, + initialScrollOffset: bvIndex <= 0 ? 0 : bvIndex * 100.0 + 7, ); } diff --git a/lib/pages/video/member/view.dart b/lib/pages/video/member/view.dart index 59cef2e7e..3f0bad578 100644 --- a/lib/pages/video/member/view.dart +++ b/lib/pages/video/member/view.dart @@ -17,7 +17,7 @@ import 'package:PiliPlus/pages/member_video/widgets/video_card_h_member_video.da import 'package:PiliPlus/pages/video/controller.dart'; import 'package:PiliPlus/pages/video/introduction/ugc/controller.dart'; import 'package:PiliPlus/pages/video/member/controller.dart'; -import 'package:PiliPlus/services/account_service.dart'; +import 'package:PiliPlus/utils/accounts.dart'; import 'package:PiliPlus/utils/extension.dart'; import 'package:PiliPlus/utils/num_utils.dart'; import 'package:PiliPlus/utils/page_utils.dart'; @@ -45,7 +45,7 @@ class HorizontalMemberPage extends StatefulWidget { class _HorizontalMemberPageState extends State { late final HorizontalMemberPageController _controller; - AccountService accountService = Get.find(); + late final account = Accounts.main; late final String _bvid; @override @@ -332,10 +332,10 @@ class _HorizontalMemberPageState extends State { visualDensity: const VisualDensity(vertical: -2), ), onPressed: () { - if (widget.mid == accountService.mid) { + if (widget.mid == account.mid) { Get.toNamed('/editProfile'); } else { - if (!accountService.isLogin.value) { + if (!account.isLogin) { SmartDialog.showToast('账号未登录'); return; } @@ -352,7 +352,7 @@ class _HorizontalMemberPageState extends State { } }, child: Text( - widget.mid == accountService.mid + widget.mid == account.mid ? '编辑资料' : memberInfoModel.isFollowed == true ? '已关注' diff --git a/lib/pages/video/note/view.dart b/lib/pages/video/note/view.dart index 6532f8b0d..0bd808126 100644 --- a/lib/pages/video/note/view.dart +++ b/lib/pages/video/note/view.dart @@ -132,7 +132,7 @@ class _NoteListPageState extends State bottom: MediaQuery.viewPaddingOf(context).bottom + 6, ), decoration: BoxDecoration( - color: theme.colorScheme.onInverseSurface, + color: theme.hoverColor, border: Border( top: BorderSide( width: 0.5, diff --git a/lib/pages/video/reply_new/view.dart b/lib/pages/video/reply_new/view.dart index 51234b772..74ba941de 100644 --- a/lib/pages/video/reply_new/view.dart +++ b/lib/pages/video/reply_new/view.dart @@ -20,6 +20,7 @@ import 'package:PiliPlus/pages/video/reply_search_item/view.dart'; import 'package:PiliPlus/utils/context_ext.dart'; import 'package:PiliPlus/utils/duration_utils.dart'; import 'package:PiliPlus/utils/grid.dart'; +import 'package:PiliPlus/utils/path_utils.dart'; import 'package:PiliPlus/utils/storage_pref.dart'; import 'package:PiliPlus/utils/utils.dart'; import 'package:flutter/material.dart' hide TextField; @@ -383,7 +384,7 @@ class _ReplyPageState extends CommonRichTextPubPageState { ?.screenshot(format: 'image/png'); if (res != null) { final file = File( - '${await Utils.temporaryDirectory}/${Utils.generateRandomString(8)}.png', + '$tmpDirPath/${Utils.generateRandomString(8)}.png', ); await file.writeAsBytes(res); pathList.add(file.path); diff --git a/lib/pages/video/view.dart b/lib/pages/video/view.dart index 015eea1a2..869a6beb7 100644 --- a/lib/pages/video/view.dart +++ b/lib/pages/video/view.dart @@ -13,6 +13,7 @@ import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/main.dart'; import 'package:PiliPlus/models/common/episode_panel_type.dart'; import 'package:PiliPlus/models_new/pgc/pgc_info_model/result.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_detail/ugc_season.dart'; import 'package:PiliPlus/models_new/video/video_tag/data.dart'; @@ -21,6 +22,8 @@ import 'package:PiliPlus/pages/danmaku/view.dart'; import 'package:PiliPlus/pages/episode_panel/view.dart'; import 'package:PiliPlus/pages/video/ai_conclusion/view.dart'; import 'package:PiliPlus/pages/video/controller.dart'; +import 'package:PiliPlus/pages/video/introduction/local/controller.dart'; +import 'package:PiliPlus/pages/video/introduction/local/view.dart'; import 'package:PiliPlus/pages/video/introduction/pgc/controller.dart'; import 'package:PiliPlus/pages/video/introduction/pgc/view.dart'; import 'package:PiliPlus/pages/video/introduction/pgc/widgets/intro_detail.dart'; @@ -77,15 +80,17 @@ class _VideoDetailPageVState extends State late final VideoDetailController videoDetailController; late final VideoReplyController _videoReplyController; PlPlayerController? plPlayerController; - late final CommonIntroController introController = videoDetailController.isUgc + + // intro ctr + late final CommonIntroController introController = + videoDetailController.isFileSource + ? localIntroController + : videoDetailController.isUgc ? ugcIntroController : pgcIntroController; late final UgcIntroController ugcIntroController; late final PgcIntroController pgcIntroController; - ScrollController? _introScrollController; - - ScrollController get introScrollController => - _introScrollController ??= ScrollController(); + late final LocalIntroController localIntroController; bool get autoExitFullscreen => videoDetailController.plPlayerController.autoExitFullscreen; @@ -105,7 +110,9 @@ class _VideoDetailPageVState extends State videoDetailController.plPlayerController.isFullScreen.value; bool get _shouldShowSeasonPanel { - if (isPortrait || !videoDetailController.isUgc) { + if (videoDetailController.isFileSource || + isPortrait || + !videoDetailController.isUgc) { return false; } late final videoDetail = ugcIntroController.videoDetail.value; @@ -135,7 +142,10 @@ class _VideoDetailPageVState extends State tag: heroTag, ); } - if (videoDetailController.isUgc) { + + if (videoDetailController.isFileSource) { + localIntroController = Get.put(LocalIntroController(), tag: heroTag); + } else if (videoDetailController.isUgc) { ugcIntroController = Get.put(UgcIntroController(), tag: heroTag); } else { pgcIntroController = Get.put(PgcIntroController(), tag: heroTag); @@ -244,11 +254,7 @@ class _VideoDetailPageVState extends State /// 顺序播放 列表循环 if (plPlayerController!.playRepeat != PlayRepeat.pause && plPlayerController!.playRepeat != PlayRepeat.singleCycle) { - if (videoDetailController.isUgc) { - notExitFlag = ugcIntroController.nextPlay(); - } else { - notExitFlag = pgcIntroController.nextPlay(); - } + notExitFlag = introController.nextPlay(); } /// 单个循环 @@ -284,17 +290,19 @@ class _VideoDetailPageVState extends State /// 未开启自动播放时触发播放 Future handlePlay() async { - if (videoDetailController.isQuerying) { - if (kDebugMode) debugPrint('handlePlay: querying'); - return; - } - if (videoDetailController.videoUrl == null || - videoDetailController.audioUrl == null) { - if (kDebugMode) { - debugPrint('handlePlay: videoUrl/audioUrl not initialized'); + if (!videoDetailController.isFileSource) { + if (videoDetailController.isQuerying) { + if (kDebugMode) debugPrint('handlePlay: querying'); + return; + } + if (videoDetailController.videoUrl == null || + videoDetailController.audioUrl == null) { + if (kDebugMode) { + debugPrint('handlePlay: videoUrl/audioUrl not initialized'); + } + videoDetailController.queryVideoUrl(); + return; } - videoDetailController.queryVideoUrl(); - return; } plPlayerController = videoDetailController.plPlayerController; videoDetailController.autoPlay.value = true; @@ -332,13 +340,14 @@ class _VideoDetailPageVState extends State PlPlayerController.setPlayCallBack(null); } - _introScrollController?.dispose(); - if (videoDetailController.isUgc) { - ugcIntroController - ..canelTimer() - ..videoDetail.close(); - } else { - pgcIntroController.canelTimer(); + if (!videoDetailController.isFileSource) { + if (videoDetailController.isUgc) { + ugcIntroController + ..canelTimer() + ..videoDetail.close(); + } else { + pgcIntroController.canelTimer(); + } } if (!videoDetailController.horizontalScreen) { AutoOrientation.portraitUpMode(); @@ -785,25 +794,27 @@ class _VideoDetailPageVState extends State bottom: -2, child: GestureDetector( onTap: () async { - if (videoDetailController.isQuerying) { - if (kDebugMode) { - debugPrint( - 'handlePlay: querying', - ); + if (!videoDetailController.isFileSource) { + if (videoDetailController.isQuerying) { + if (kDebugMode) { + debugPrint( + 'handlePlay: querying', + ); + } + return; } - return; - } - if (videoDetailController.videoUrl == - null || - videoDetailController.audioUrl == - null) { - if (kDebugMode) { - debugPrint( - 'handlePlay: videoUrl/audioUrl not initialized', - ); + if (videoDetailController.videoUrl == + null || + videoDetailController.audioUrl == + null) { + if (kDebugMode) { + debugPrint( + 'handlePlay: videoUrl/audioUrl not initialized', + ); + } + videoDetailController.queryVideoUrl(); + return; } - videoDetailController.queryVideoUrl(); - return; } videoDetailController.scrollRatio.value = 0; @@ -1014,6 +1025,8 @@ class _VideoDetailPageVState extends State return childSplit(16 / 9); } final introHeight = maxHeight - height - padding.top; + final showIntro = + videoDetailController.isUgc && videoDetailController.showRelatedVideo; return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -1028,19 +1041,20 @@ class _VideoDetailPageVState extends State height: videoHeight, ), ), - Offstage( - offstage: isFullScreen, - child: SizedBox( - width: width, - height: introHeight, - child: videoIntro( + if (!videoDetailController.isFileSource) + Offstage( + offstage: isFullScreen, + child: SizedBox( width: width, height: introHeight, - needRelated: false, - needCtr: false, + child: videoIntro( + width: width, + height: introHeight, + needRelated: false, + needCtr: false, + ), ), ), - ), ], ), Offstage( @@ -1057,23 +1071,22 @@ class _VideoDetailPageVState extends State children: [ buildTabbar( introText: '相关视频', - showIntro: - videoDetailController.isUgc && - videoDetailController - .plPlayerController - .showRelatedVideo, + showIntro: videoDetailController.isFileSource + ? true + : showIntro, ), Expanded( child: videoTabBarView( controller: videoDetailController.tabCtr, children: [ - if (videoDetailController.isUgc && - videoDetailController - .plPlayerController - .showRelatedVideo) + if (videoDetailController.isFileSource) + localIntroPanel() + else if (showIntro) KeepAliveWrapper( builder: (context) => CustomScrollView( - controller: introScrollController, + key: const PageStorageKey(CommonIntroController), + controller: + videoDetailController.effectiveIntroScrollCtr, slivers: [ RelatedVideoPanel( key: videoRelatedKey, @@ -1293,6 +1306,11 @@ class _VideoDetailPageVState extends State onTap: () => videoDetailController.showNoteList(context), child: const Text('查看笔记'), ), + if (!videoDetailController.isFileSource) + PopupMenuItem( + onTap: () => videoDetailController.onDownload(context), + child: const Text('缓存视频'), + ), if (videoDetailController.cover.value.isNotEmpty) PopupMenuItem( onTap: () => ImageUtils.downloadImg( @@ -1301,7 +1319,7 @@ class _VideoDetailPageVState extends State ), child: const Text('保存封面'), ), - if (videoDetailController.isUgc) + if (!videoDetailController.isFileSource && videoDetailController.isUgc) PopupMenuItem( onTap: videoDetailController.toAudioPage, child: const Text('听音频'), @@ -1335,9 +1353,7 @@ class _VideoDetailPageVState extends State maxHeight: height, plPlayerController: plPlayerController!, videoDetailController: videoDetailController, - introController: videoDetailController.isUgc - ? ugcIntroController - : pgcIntroController, + introController: introController, headerControl: HeaderControl( key: videoDetailController.headerCtrKey, isPortrait: isPortrait, @@ -1354,6 +1370,7 @@ class _VideoDetailPageVState extends State cid: videoDetailController.cid.value, playerController: plPlayerController!, isFullScreen: plPlayerController!.isFullScreen.value, + isFileSource: videoDetailController.isFileSource, ), ), showEpisodes: showEpisodes, @@ -1384,9 +1401,7 @@ class _VideoDetailPageVState extends State if (videoDetailController.plPlayerController.keyboardControl) { child = PlayerFocus( plPlayerController: videoDetailController.plPlayerController, - introController: videoDetailController.isUgc - ? ugcIntroController - : pgcIntroController, + introController: introController, onSendDanmaku: videoDetailController.showShootDanmakuSheet, canPlay: () { if (videoDetailController.autoPlay.value) { @@ -1406,12 +1421,13 @@ class _VideoDetailPageVState extends State Widget buildTabbar({ bool needIndicator = true, - String introText = '简介', + String? introText, bool showIntro = true, VoidCallback? onTap, }) { List tabs = [ - if (showIntro) introText, + if (showIntro) + videoDetailController.isFileSource ? '离线视频' : introText ?? '简介', if (videoDetailController.showReply) '评论', if (_shouldShowSeasonPanel) '播放列表', ]; @@ -1445,8 +1461,10 @@ class _VideoDetailPageVState extends State return; } String text = tabs[value]; - if (text == '简介' || text == '相关视频') { - _introScrollController?.animToTop(); + if (videoDetailController.isFileSource || + text == '简介' || + text == '相关视频') { + videoDetailController.introScrollCtr?.animToTop(); } else if (text.startsWith('评论')) { _videoReplyController.animateToTop(); } @@ -1720,6 +1738,29 @@ class _VideoDetailPageVState extends State ); } + Widget localIntroPanel({ + bool needCtr = true, + }) { + return CustomScrollView( + controller: needCtr + ? videoDetailController.effectiveIntroScrollCtr + : null, + physics: !needCtr + ? const AlwaysScrollableScrollPhysics(parent: ClampingScrollPhysics()) + : null, + key: const PageStorageKey(CommonIntroController), + slivers: [ + SliverPadding( + padding: EdgeInsets.only(top: 7, bottom: padding.bottom + 100), + sliver: LocalIntroPanel( + key: videoRelatedKey, + heroTag: heroTag, + ), + ), + ], + ); + } + Widget videoIntro({ double? width, double? height, @@ -1728,10 +1769,16 @@ class _VideoDetailPageVState extends State bool needCtr = true, bool isNested = false, }) { + if (videoDetailController.isFileSource) { + return localIntroPanel(needCtr: needCtr); + } Widget introPanel() => KeepAliveWrapper( builder: (context) { final child = CustomScrollView( - controller: needCtr ? introScrollController : null, + key: const PageStorageKey(CommonIntroController), + controller: needCtr + ? videoDetailController.effectiveIntroScrollCtr + : null, physics: !needCtr ? const AlwaysScrollableScrollPhysics( parent: ClampingScrollPhysics(), @@ -1748,10 +1795,7 @@ class _VideoDetailPageVState extends State isPortrait: isPortrait, isHorizontal: isHorizontal ?? width! / height! >= kScreenRatio, ), - if (needRelated && - videoDetailController - .plPlayerController - .showRelatedVideo) ...[ + if (needRelated && videoDetailController.showRelatedVideo) ...[ SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.only(top: StyleString.safeSpace), @@ -1977,7 +2021,7 @@ class _VideoDetailPageVState extends State void showEpisodes([ int? index, UgcSeason? season, - episodes, + List? episodes, String? bvid, int? aid, int? cid, diff --git a/lib/pages/video/widgets/header_control.dart b/lib/pages/video/widgets/header_control.dart index 23dedb703..1750ca47c 100644 --- a/lib/pages/video/widgets/header_control.dart +++ b/lib/pages/video/widgets/header_control.dart @@ -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 { 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 { @override void initState() { super.initState(); - if (videoDetailCtr.isUgc) { + if (isFileSource) { + introController = Get.find(tag: heroTag); + } else if (videoDetailCtr.isUgc) { introController = Get.find(tag: heroTag); } else { introController = Get.find(tag: heroTag); @@ -279,6 +285,19 @@ class HeaderControlState extends State { 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 { 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 { ], ), ), - 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 { ); }, ), - 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 { ], ), ), - 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 { 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 { ), onTap: () => Utils.copyText( 'Resolution\n${state.width}x${state.height}', - needToast: false, ), ), ListTile( @@ -615,7 +653,6 @@ class HeaderControlState extends State { ), onTap: () => Utils.copyText( 'VideoParams\n${state.videoParams}', - needToast: false, ), ), ListTile( @@ -626,7 +663,6 @@ class HeaderControlState extends State { ), onTap: () => Utils.copyText( 'AudioParams\n${state.audioParams}', - needToast: false, ), ), ListTile( @@ -637,7 +673,6 @@ class HeaderControlState extends State { ), onTap: () => Utils.copyText( 'Media\n${state.playlist}', - needToast: false, ), ), ListTile( @@ -648,7 +683,6 @@ class HeaderControlState extends State { ), onTap: () => Utils.copyText( 'AudioTrack\n${state.track.audio}', - needToast: false, ), ), ListTile( @@ -659,26 +693,21 @@ class HeaderControlState extends State { ), 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 { ), onTap: () => Utils.copyText( 'AudioBitrate\n${state.audioBitrate}', - needToast: false, ), ), ListTile( @@ -697,19 +725,14 @@ class HeaderControlState extends State { 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 { SmartDialog.showToast('当前视频不支持选择画质'); return; } - final List videoFormat = videoInfo.supportFormats!; final VideoQuality? currentVideoQa = videoDetailCtr.currentVideoQa.value; if (currentVideoQa == null) return; + final List videoFormat = videoInfo.supportFormats!; + /// 总质量分类 final int totalQaSam = videoFormat.length; @@ -813,10 +837,11 @@ class HeaderControlState extends State { 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 { 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 { 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 { 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 { // 选择解码格式 void showSetDecodeFormats() { - // 当前选中的解码格式 - final VideoDecodeFormatType currentDecodeFormats = - videoDetailCtr.currentDecodeFormats; final VideoItem firstVideo = videoDetailCtr.firstVideo; // 当前视频可用的解码格式 final List videoFormat = videoInfo.supportFormats!; @@ -959,6 +982,9 @@ class HeaderControlState extends State { return; } + // 当前选中的解码格式 + final VideoDecodeFormatType currentDecodeFormats = + videoDetailCtr.currentDecodeFormats; showBottomSheet( (context, setState) { final theme = Theme.of(context); @@ -982,28 +1008,28 @@ class HeaderControlState extends State { 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 { 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 { 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 { (e) => e.cid == videoDetailCtr.cid.value, ) - ?.pagePart ?? + ?.part ?? videoDetail.title!; } return MarqueeText( @@ -2320,71 +2349,74 @@ class HeaderControlState extends State { 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, diff --git a/lib/pages/webview/view.dart b/lib/pages/webview/view.dart index 3df5aa778..112c606d6 100644 --- a/lib/pages/webview/view.dart +++ b/lib/pages/webview/view.dart @@ -4,7 +4,7 @@ import 'package:PiliPlus/http/ua_type.dart'; import 'package:PiliPlus/main.dart'; import 'package:PiliPlus/models/common/webview_menu_type.dart'; import 'package:PiliPlus/utils/app_scheme.dart'; -import 'package:PiliPlus/utils/cache_manage.dart'; +import 'package:PiliPlus/utils/cache_manager.dart'; import 'package:PiliPlus/utils/login_utils.dart'; import 'package:PiliPlus/utils/page_utils.dart'; import 'package:PiliPlus/utils/utils.dart'; @@ -243,7 +243,7 @@ class _WebviewPageState extends State { builder: (context) { String suggestedFilename = request.suggestedFilename .toString(); - String fileSize = CacheManage.formatSize( + String fileSize = CacheManager.formatSize( request.contentLength.toDouble(), ); try { diff --git a/lib/pages/whisper_detail/controller.dart b/lib/pages/whisper_detail/controller.dart index c1ca4af63..7022d32d0 100644 --- a/lib/pages/whisper_detail/controller.dart +++ b/lib/pages/whisper_detail/controller.dart @@ -8,7 +8,7 @@ import 'package:PiliPlus/grpc/im.dart'; import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/http/msg.dart'; import 'package:PiliPlus/pages/common/common_list_controller.dart'; -import 'package:PiliPlus/services/account_service.dart'; +import 'package:PiliPlus/utils/accounts.dart'; import 'package:PiliPlus/utils/extension.dart'; import 'package:PiliPlus/utils/feed_back.dart'; import 'package:fixnum/fixnum.dart'; @@ -17,7 +17,7 @@ import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; class WhisperDetailController extends CommonListController { - AccountService accountService = Get.find(); + late final account = Accounts.main; final int talkerId = Get.arguments['talkerId']; final String name = Get.arguments['name']; @@ -78,12 +78,12 @@ class WhisperDetailController extends CommonListController { _isSending = true; feedBack(); SmartDialog.dismiss(); - if (!accountService.isLogin.value) { + if (!account.isLogin) { SmartDialog.showToast('请先登录'); return; } var result = await ImGrpc.sendMsg( - senderUid: accountService.mid, + senderUid: account.mid, receiverId: mid!, content: msgType == 5 ? message! diff --git a/lib/pages/whisper_detail/view.dart b/lib/pages/whisper_detail/view.dart index 4f3cdf97f..46a30f5aa 100644 --- a/lib/pages/whisper_detail/view.dart +++ b/lib/pages/whisper_detail/view.dart @@ -190,7 +190,7 @@ class _WhisperDetailPageState eInfos: _whisperDetailController.eInfos, onLongPress: item.senderUid.toInt() == - _whisperDetailController.accountService.mid + _whisperDetailController.account.mid ? () => onLongPress(index, item) : null, ); diff --git a/lib/plugin/pl_player/controller.dart b/lib/plugin/pl_player/controller.dart index ffcc471b3..e8f46f25c 100644 --- a/lib/plugin/pl_player/controller.dart +++ b/lib/plugin/pl_player/controller.dart @@ -36,6 +36,7 @@ import 'package:PiliPlus/utils/extension.dart'; import 'package:PiliPlus/utils/feed_back.dart'; import 'package:PiliPlus/utils/image_utils.dart'; import 'package:PiliPlus/utils/page_utils.dart' show PageUtils; +import 'package:PiliPlus/utils/path_utils.dart'; import 'package:PiliPlus/utils/storage.dart'; import 'package:PiliPlus/utils/storage_key.dart'; import 'package:PiliPlus/utils/storage_pref.dart'; @@ -55,7 +56,6 @@ import 'package:hive/hive.dart'; import 'package:media_kit/media_kit.dart'; import 'package:media_kit_video/media_kit_video.dart'; import 'package:path/path.dart' as path; -import 'package:path_provider/path_provider.dart'; import 'package:window_manager/window_manager.dart'; class PlPlayerController { @@ -629,6 +629,12 @@ class PlPlayerController { bool _processing = false; bool get processing => _processing; + // offline + bool isFileSource = false; + String? dirPath; + String? typeTag; + int? mediaType; + // 初始化资源 Future setDataSource( DataSource dataSource, { @@ -655,8 +661,15 @@ class PlPlayerController { VideoType? videoType, VoidCallback? callback, Volume? volume, + String? dirPath, + String? typeTag, + int? mediaType, }) async { try { + this.dirPath = dirPath; + this.typeTag = typeTag; + this.mediaType = mediaType; + isFileSource = dataSource.type == DataSourceType.file; _processing = true; this.isLive = isLive; _videoType = videoType ?? VideoType.ugc; @@ -723,30 +736,25 @@ class PlPlayerController { } } - Directory? shadersDirectory; - Future copyShadersToExternalDirectory() async { - if (shadersDirectory != null) { - return shadersDirectory; - } - final manifestContent = await rootBundle.loadString('AssetManifest.json'); - final Map manifestMap = json.decode(manifestContent); - final directory = await getApplicationSupportDirectory(); - shadersDirectory = Directory(path.join(directory.path, 'anime_shaders')); - - if (!shadersDirectory!.existsSync()) { - await shadersDirectory!.create(recursive: true); + String? shadersDirPath; + Future get copyShadersToExternalDirectory async { + if (shadersDirPath != null) { + return shadersDirPath!; } - final shaderFiles = manifestMap.keys.where( - (String key) => - key.startsWith('assets/shaders/') && key.endsWith('.glsl'), - ); + final dir = Directory(path.join(appSupportDirPath, 'anime_shaders')); + if (!dir.existsSync()) { + await dir.create(recursive: true); + } - // int copiedFilesCount = 0; + final shaderFilesPath = + (Constants.mpvAnime4KShaders + Constants.mpvAnime4KShadersLite) + .map((e) => 'assets/shaders/$e') + .toList(); - for (var filePath in shaderFiles) { + for (final filePath in shaderFilesPath) { final fileName = filePath.split('/').last; - final targetFile = File(path.join(shadersDirectory!.path, fileName)); + final targetFile = File(path.join(dir.path, fileName)); if (targetFile.existsSync()) { continue; } @@ -755,12 +763,11 @@ class PlPlayerController { final data = await rootBundle.load(filePath); final List bytes = data.buffer.asUint8List(); await targetFile.writeAsBytes(bytes); - // copiedFilesCount++; } catch (e) { if (kDebugMode) debugPrint('$e'); } } - return shadersDirectory; + return shadersDirPath = dir.path; } late final isAnim = _pgcType == 1 || _pgcType == 4; @@ -786,8 +793,8 @@ class PlPlayerController { 'change-list', 'glsl-shaders', 'set', - Utils.buildShadersAbsolutePath( - (await copyShadersToExternalDirectory())?.path ?? '', + PathUtils.buildShadersAbsolutePath( + await copyShadersToExternalDirectory, Constants.mpvAnime4KShadersLite, ), ]); @@ -796,8 +803,8 @@ class PlPlayerController { 'change-list', 'glsl-shaders', 'set', - Utils.buildShadersAbsolutePath( - (await copyShadersToExternalDirectory())?.path ?? '', + PathUtils.buildShadersAbsolutePath( + await copyShadersToExternalDirectory, Constants.mpvAnime4KShaders, ), ]); @@ -861,29 +868,19 @@ class PlPlayerController { } // 音轨 - if (dataSource.audioSource?.isNotEmpty == true) { - await pp.setProperty( - 'audio-files', - Platform.isWindows - ? dataSource.audioSource!.replaceAll(';', '\\;') - : dataSource.audioSource!.replaceAll(':', '\\:'), - ); + late final String audioUri; + if (isFileSource) { + audioUri = onlyPlayAudio.value || mediaType == 1 + ? '' + : path.join(dirPath!, typeTag!, PathUtils.audioNameType2); + } else if (dataSource.audioSource?.isNotEmpty == true) { + audioUri = Platform.isWindows + ? dataSource.audioSource!.replaceAll(';', '\\;') + : dataSource.audioSource!.replaceAll(':', '\\:'); } else { - await pp.setProperty('audio-files', ''); - } - - // 字幕 - if (dataSource.subFiles?.isNotEmpty == true) { - await pp.setProperty( - 'sub-files', - Platform.isWindows - ? dataSource.subFiles!.replaceAll(';', '\\;') - : dataSource.subFiles!.replaceAll(':', '\\:'), - ); - await pp.setProperty("subs-with-matching-audio", "no"); - await pp.setProperty("sub-forced-only", "yes"); - await pp.setProperty("blend-subtitles", "video"); + audioUri = ''; } + await pp.setProperty('audio-files', audioUri); _videoController ??= VideoController( player, @@ -928,41 +925,39 @@ class PlPlayerController { filters = null; } - if (kDebugMode) debugPrint(filters.toString()); + // if (kDebugMode) debugPrint(filters.toString()); - if (dataSource.type == DataSourceType.asset) { - final assetUrl = dataSource.videoSource!.startsWith("asset://") - ? dataSource.videoSource! - : "asset://${dataSource.videoSource!}"; - await player.open( - Media( - assetUrl, - httpHeaders: dataSource.httpHeaders, - start: seekTo, - extras: filters, - ), - play: false, + late final String videoUri; + if (isFileSource) { + videoUri = path.join( + dirPath!, + typeTag!, + mediaType == 1 + ? PathUtils.videoNameType1 + : onlyPlayAudio.value + ? PathUtils.audioNameType2 + : PathUtils.videoNameType2, ); } else { - await player.open( - Media( - dataSource.videoSource!, - httpHeaders: dataSource.httpHeaders, - start: seekTo, - extras: filters, - ), - play: false, - ); + videoUri = dataSource.videoSource!; } - // 音轨 - // player.setAudioTrack( - // AudioTrack.uri(dataSource.audioSource!), - // ); + await player.open( + Media( + videoUri, + httpHeaders: dataSource.httpHeaders, + start: seekTo, + extras: filters, + ), + play: false, + ); return player; } Future refreshPlayer() async { + if (isFileSource) { + return true; + } if (_videoPlayerController == null) { // SmartDialog.showToast('视频播放器为空,请重新进入本页面'); return false; @@ -1123,7 +1118,12 @@ class PlPlayerController { debugPrint(log.toString()); })), videoPlayerController!.stream.error.listen((String event) { - debugPrint('MPV Exception: $event'); + if (kDebugMode) { + debugPrint('MPV Exception: $event'); + } + if (isFileSource && event.startsWith("Failed to open file")) { + return; + } if (isLive) { if (event.startsWith('tcp: ffurl_read returned ') || event.startsWith("Failed to open https://") || diff --git a/lib/plugin/pl_player/models/data_source.dart b/lib/plugin/pl_player/models/data_source.dart index 2eecc5a1d..a453b1b38 100644 --- a/lib/plugin/pl_player/models/data_source.dart +++ b/lib/plugin/pl_player/models/data_source.dart @@ -1,54 +1,37 @@ -import 'dart:io'; - /// The way in which the video was originally loaded. /// /// This has nothing to do with the video's file type. It's just the place /// from which the video is fetched from. enum DataSourceType { - /// The video was included in the app's asset files. - asset, - /// The video was downloaded from the internet. network, /// The video was loaded off of the local filesystem. file, - - /// The video is available via contentUri. Android only. - contentUri, } class DataSource { - File? file; String? videoSource; String? audioSource; - String? subFiles; DataSourceType type; Map? httpHeaders; // for headers + DataSource({ - this.file, this.videoSource, this.audioSource, - this.subFiles, required this.type, this.httpHeaders, - }) : assert( - (type == DataSourceType.file && file != null) || videoSource != null, - ); + }); DataSource copyWith({ - File? file, String? videoSource, String? audioSource, - String? subFiles, DataSourceType? type, Map? httpHeaders, }) { return DataSource( - file: file ?? this.file, videoSource: videoSource ?? this.videoSource, audioSource: audioSource ?? this.audioSource, - subFiles: subFiles ?? this.subFiles, type: type ?? this.type, httpHeaders: httpHeaders ?? this.httpHeaders, ); diff --git a/lib/plugin/pl_player/view.dart b/lib/plugin/pl_player/view.dart index d80652cee..ad38b8fc7 100644 --- a/lib/plugin/pl_player/view.dart +++ b/lib/plugin/pl_player/view.dart @@ -19,6 +19,7 @@ import 'package:PiliPlus/models/common/sponsor_block/segment_type.dart'; import 'package:PiliPlus/models/common/super_resolution_type.dart'; import 'package:PiliPlus/models/common/video/video_quality.dart'; import 'package:PiliPlus/models/video/play/url.dart'; +import 'package:PiliPlus/models_new/video/video_detail/episode.dart' as ugc; import 'package:PiliPlus/models_new/video/video_detail/episode.dart'; import 'package:PiliPlus/models_new/video/video_detail/section.dart'; import 'package:PiliPlus/models_new/video/video_detail/ugc_season.dart'; @@ -51,6 +52,7 @@ import 'package:PiliPlus/utils/duration_utils.dart'; import 'package:PiliPlus/utils/extension.dart'; import 'package:PiliPlus/utils/id_utils.dart'; import 'package:PiliPlus/utils/image_utils.dart'; +import 'package:PiliPlus/utils/path_utils.dart'; import 'package:PiliPlus/utils/storage.dart'; import 'package:PiliPlus/utils/storage_key.dart'; import 'package:PiliPlus/utils/utils.dart'; @@ -98,7 +100,14 @@ class PLVideoPlayer extends StatefulWidget { final Widget headerControl; final Widget? bottomControl; final Widget? danmuWidget; - final void Function([int?, UgcSeason?, dynamic, String?, int?, int?])? + final void Function([ + int?, + UgcSeason?, + List?, + String?, + int?, + int?, + ])? showEpisodes; final VoidCallback? showViewPoints; final Color fill; @@ -517,6 +526,10 @@ class _PLVideoPlayerState extends State color: Colors.white, ), onTap: () { + if (videoDetailController.isFileSource) { + // TODO + return; + } // part -> playAll -> season(pgc) if (isPlayAll && !isPart) { widget.showEpisodes?.call(); @@ -525,7 +538,7 @@ class _PLVideoPlayerState extends State int? index; int currentCid = plPlayerController.cid!; String bvid = plPlayerController.bvid; - List episodes = []; + List episodes = []; if (isSeason) { final List sections = videoDetail.ugcSeason!.sections!; for (int i = 0; i < sections.length; i++) { @@ -836,10 +849,12 @@ class _PLVideoPlayerState extends State ), }; + final isNotFileSource = !plPlayerController.isFileSource; + List userSpecifyItemLeft = [ BottomControlType.playOrPause, BottomControlType.time, - if (anySeason) ...[ + if (!isNotFileSource || anySeason) ...[ BottomControlType.pre, BottomControlType.next, ], @@ -848,15 +863,19 @@ class _PLVideoPlayerState extends State final flag = isFullScreen || plPlayerController.isDesktopPip || maxWidth >= 500; List userSpecifyItemRight = [ - if (plPlayerController.showDmChart) BottomControlType.dmChart, + if (isNotFileSource && plPlayerController.showDmChart) + BottomControlType.dmChart, if (plPlayerController.isAnim) BottomControlType.superResolution, - if (plPlayerController.showViewPoints) BottomControlType.viewPoints, - if (anySeason) BottomControlType.episode, + if (isNotFileSource && plPlayerController.showViewPoints) + BottomControlType.viewPoints, + if (isNotFileSource || anySeason) BottomControlType.episode, if (flag) BottomControlType.fit, - BottomControlType.aiTranslate, - BottomControlType.subtitle, + if (isNotFileSource) ...[ + BottomControlType.aiTranslate, + BottomControlType.subtitle, + ], BottomControlType.speed, - if (flag) BottomControlType.qa, + if (isNotFileSource && flag) BottomControlType.qa, if (!plPlayerController.isDesktopPip) BottomControlType.fullscreen, ]; @@ -1034,7 +1053,8 @@ class _PLVideoPlayerState extends State plPlayerController ..onUpdatedSliderProgress(result) ..onChangedSliderStart(); - if (plPlayerController.showSeekPreview && + if (!plPlayerController.isFileSource && + plPlayerController.showSeekPreview && plPlayerController.cancelSeek != true) { plPlayerController.updatePreviewIndex(newPos ~/ 1000); } @@ -1285,7 +1305,8 @@ class _PLVideoPlayerState extends State plPlayerController ..onUpdatedSliderProgress(result) ..onChangedSliderStart(); - if (plPlayerController.showSeekPreview && + if (!plPlayerController.isFileSource && + plPlayerController.showSeekPreview && plPlayerController.cancelSeek != true) { plPlayerController.updatePreviewIndex(newPos ~/ 1000); } @@ -2186,7 +2207,7 @@ class _PLVideoPlayerState extends State final progress = 0.0.obs; final name = '${ctr.cid}-${segment.first.toStringAsFixed(3)}_${segment.second.toStringAsFixed(3)}.webp'; - final file = '${await Utils.temporaryDirectory}/$name'; + final file = '$tmpDirPath/$name'; final mpv = MpvConvertWebp( url!, diff --git a/lib/plugin/pl_player/widgets/bottom_control.dart b/lib/plugin/pl_player/widgets/bottom_control.dart index 9ec6163d0..a20d43ea1 100644 --- a/lib/plugin/pl_player/widgets/bottom_control.dart +++ b/lib/plugin/pl_player/widgets/bottom_control.dart @@ -45,7 +45,7 @@ class BottomControl extends StatelessWidget { } void onDragUpdate(ThumbDragDetails duration, int max) { - if (controller.showSeekPreview) { + if (!controller.isFileSource && controller.showSeekPreview) { controller.updatePreviewIndex( duration.timeStamp.inSeconds, ); diff --git a/lib/router/app_pages.dart b/lib/router/app_pages.dart index a9451a5c2..03ee66374 100644 --- a/lib/router/app_pages.dart +++ b/lib/router/app_pages.dart @@ -4,6 +4,7 @@ import 'package:PiliPlus/pages/article_list/view.dart'; import 'package:PiliPlus/pages/audio/view.dart'; import 'package:PiliPlus/pages/blacklist/view.dart'; import 'package:PiliPlus/pages/danmaku_block/view.dart'; +import 'package:PiliPlus/pages/download/view.dart'; import 'package:PiliPlus/pages/dynamics/view.dart'; import 'package:PiliPlus/pages/dynamics_create_vote/view.dart'; import 'package:PiliPlus/pages/dynamics_detail/view.dart'; @@ -227,6 +228,7 @@ class Routes { CustomGetPage(name: '/mainReply', page: () => const MainReplyPage()), CustomGetPage(name: '/followed', page: () => const FollowedPage()), CustomGetPage(name: '/sameFollowing', page: () => const FollowSamePage()), + CustomGetPage(name: '/download', page: () => const DownloadPage()), ]; } diff --git a/lib/services/audio_handler.dart b/lib/services/audio_handler.dart index 32258add5..e1ea9ff0d 100644 --- a/lib/services/audio_handler.dart +++ b/lib/services/audio_handler.dart @@ -145,7 +145,7 @@ class VideoPlayerServiceHandler extends BaseAudioHandler with SeekHandler { ); mediaItem = MediaItem( id: id, - title: current?.pagePart ?? '', + title: current?.part ?? '', artist: data.owner?.name, duration: Duration(seconds: current?.duration ?? 0), artUri: Uri.parse(data.pic ?? ''), @@ -180,7 +180,7 @@ class VideoPlayerServiceHandler extends BaseAudioHandler with SeekHandler { } else if (data is Part) { mediaItem = MediaItem( id: id, - title: data.pagePart ?? '', + title: data.part ?? '', artist: artist, duration: Duration(seconds: data.duration ?? 0), artUri: Uri.parse(cover ?? ''), diff --git a/lib/services/download/download_manager.dart b/lib/services/download/download_manager.dart new file mode 100644 index 000000000..712dd38d4 --- /dev/null +++ b/lib/services/download/download_manager.dart @@ -0,0 +1,195 @@ +import 'dart:async' show Completer, StreamSubscription; +import 'dart:io'; + +import 'package:PiliPlus/http/init.dart'; +import 'package:PiliPlus/models_new/download/bili_download_entry_info.dart'; +import 'package:PiliPlus/utils/extension.dart'; +import 'package:dio/dio.dart'; + +class DownloadManager { + final String url; + final String path; + final Function({required int progress, required int total}) onTaskRunning; + final Function() onTaskComplete; + final Function({ + required int progress, + required int total, + required Object error, + }) + onTaskError; + + bool _closed = false; + DownloadStatus _status = DownloadStatus.wait; + DownloadStatus get status => _status; + CancelToken? _cancelToken; + Completer? _completer; + + DownloadManager({ + required this.url, + required this.path, + required this.onTaskRunning, + required this.onTaskComplete, + required this.onTaskError, + }); + + void _complete() { + if (_completer?.isCompleted == false) { + _completer?.complete(); + } + } + + Future start() async { + _completer = Completer(); + _cancelToken = CancelToken(); + _status = DownloadStatus.downloading; + + final file = File(path); + // If the file already exists, the method fails. + if (!file.existsSync()) { + file.createSync(recursive: true); + } + + final int downloadedSize = await file.length(); + + // Shouldn't call file.writeAsBytesSync(list, flush: flush), + // because it can write all bytes by once. Consider that the file is + // a very big size (up to 1 Gigabytes), it will be expensive in memory. + RandomAccessFile raf = file.openSync( + mode: downloadedSize == 0 ? FileMode.write : FileMode.append, + ); + + Future? asyncWrite; + Future closeAndDelete({bool delete = false}) async { + if (!_closed) { + _closed = true; + await asyncWrite; + await raf.close().catchError((_) => raf); + if (delete && file.existsSync()) { + await file.delete().catchError((_) => file); + } + } + } + + final Response response; + try { + response = await Request.dio.get( + url.http2https, + options: Options( + headers: {'range': 'bytes=$downloadedSize-'}, + responseType: ResponseType.stream, + validateStatus: (status) { + return status == 416 || + (status != null && status >= 200 && status < 300); + }, + ), + cancelToken: _cancelToken, + ); + } on DioException catch (e) { + final isFailed = e.response?.statusCode != 416; + if (isFailed) { + _status = DownloadStatus.failDownload; + onTaskError(progress: 0, total: 0, error: e); + } else { + _status = DownloadStatus.completed; + onTaskComplete(); + } + closeAndDelete(delete: isFailed); + _complete(); + return; + } + + int received = downloadedSize; + + // Stream + final stream = response.data!.stream; + + final total = + int.parse(response.headers.value(Headers.contentLengthHeader) ?? '0') + + downloadedSize; + + if (downloadedSize == 0) { + onTaskRunning(progress: 0, total: total); + } + + late StreamSubscription subscription; + subscription = stream.listen( + (data) { + subscription.pause(); + // Write file asynchronously + asyncWrite = raf + .writeFrom(data) + .then((result) async { + // Notify progress + received += data.length; + onTaskRunning(progress: received, total: total); + + raf = result; + if (_cancelToken != null && !_cancelToken!.isCancelled) { + subscription.resume(); + } + }) + .catchError((Object e) async { + try { + await subscription.cancel().catchError((_) {}); + _closed = true; + await raf.close().catchError((_) => raf); + if (file.existsSync()) { + await file.delete().catchError((_) => file); + } + } catch (e) { + _status = DownloadStatus.failDownload; + onTaskError(progress: received, total: total, error: e); + } finally { + _complete(); + } + }); + }, + onDone: () async { + try { + await asyncWrite; + _closed = true; + await raf.close().catchError((_) => raf); + _status = DownloadStatus.completed; + onTaskComplete(); + } catch (e) { + _status = DownloadStatus.failDownload; + onTaskError(progress: received, total: total, error: e); + } finally { + _complete(); + } + }, + onError: (e) async { + try { + await closeAndDelete(delete: true); + } catch (e) { + _cancel(); + _status = DownloadStatus.failDownload; + onTaskError(progress: received, total: total, error: e); + } finally { + _complete(); + } + }, + cancelOnError: true, + ); + _cancelToken?.whenCancel.then((_) async { + await subscription.cancel(); + await closeAndDelete(); + _complete(); + }); + } + + Future? _cancel() { + if (_cancelToken != null) { + _cancelToken?.cancel(); + _cancelToken = null; + } + return _completer?.future; + } + + Future? cancel({required bool isDelete}) { + if (!isDelete && _status == DownloadStatus.downloading) { + _status = DownloadStatus.pause; + } + return _cancel(); + } +} diff --git a/lib/services/download/download_service.dart b/lib/services/download/download_service.dart new file mode 100644 index 000000000..28cefe9b9 --- /dev/null +++ b/lib/services/download/download_service.dart @@ -0,0 +1,576 @@ +import 'dart:async'; +import 'dart:convert' show jsonDecode, utf8; +import 'dart:io' show Directory, File, FileSystemEntity; + +import 'package:PiliPlus/http/download.dart'; +import 'package:PiliPlus/http/init.dart'; +import 'package:PiliPlus/models/common/video/video_quality.dart'; +import 'package:PiliPlus/models_new/download/bili_download_entry_info.dart'; +import 'package:PiliPlus/models_new/download/bili_download_media_file_info.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/services/download/download_manager.dart'; +import 'package:PiliPlus/utils/extension.dart'; +import 'package:PiliPlus/utils/id_utils.dart'; +import 'package:PiliPlus/utils/path_utils.dart'; +import 'package:PiliPlus/utils/utils.dart'; +import 'package:archive/archive.dart' show Inflate; +import 'package:dio/dio.dart' show Options, ResponseType; +import 'package:flutter/foundation.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:get/get.dart'; +import 'package:path/path.dart' as path; +import 'package:synchronized/synchronized.dart'; + +// ref https://github.com/10miaomiao/bilimiao2/blob/master/bilimiao-download/src/main/java/cn/a10miaomiao/bilimiao/download/DownloadService.kt + +class DownloadService extends GetxService { + static const _entryFile = 'entry.json'; + static const _indexFile = 'index.json'; + static const _danmakuFile = 'danmaku.xml'; + static const _coverFile = 'cover.jpg'; + + final _lock = Lock(); + + final downloaFlag = RxnInt(); + final waitDownloadQueue = []; + final downloadList = RxList(); + + final curDownload = Rxn(); + void _updateCurStatus(DownloadStatus status) { + if (curDownload.value != null) { + curDownload + ..value!.status = status + ..refresh(); + } + } + + DownloadManager? _downloadManager; + DownloadManager? _audioDownloadManager; + + Completer? _completer; + Future? get waitForInitialization => _completer?.future; + + @override + void onInit() { + super.onInit(); + readDownloadList(); + } + + Future readDownloadList() async { + _completer = Completer(); + final downloadDir = Directory(await _getDownloadPath()); + final list = []; + for (final dir in downloadDir.listSync()) { + if (dir is Directory) { + list.addAll(await _readDownloadDirectory(dir)); + } + } + downloadList.value = list + ..sort((a, b) => b.timeUpdateStamp.compareTo(a.timeUpdateStamp)); + if (!_completer!.isCompleted) { + _completer!.complete(); + } + } + + Future> _readDownloadDirectory( + FileSystemEntity pageDir, + ) async { + final result = []; + + if (!pageDir.existsSync() || pageDir is! Directory) { + return result; + } + + for (final entryDir in pageDir.listSync()) { + if (entryDir is Directory) { + final entryFile = File(path.join(entryDir.path, _entryFile)); + if (entryFile.existsSync()) { + try { + final entryJson = await entryFile.readAsString(); + final entry = BiliDownloadEntryInfo.fromJson(jsonDecode(entryJson)) + ..pageDirPath = pageDir.path + ..entryDirPath = entryDir.path; + result.add(entry); + if (!entry.isCompleted) { + waitDownloadQueue.add(entry); + } + } catch (_) { + if (kDebugMode) rethrow; + } + } + } + } + + return result; + } + + void downloadVideo( + Part page, + VideoDetailData? videoDetail, + ugc.EpisodeItem? videoArc, + VideoQuality videoQuality, + ) { + final cid = page.cid!; + if (downloadList.indexWhere((e) => e.cid == cid) != -1) { + return; + } + final pageData = PageInfo( + cid: cid, + page: page.page!, + from: page.from, + part: page.part, + vid: page.vid, + hasAlias: false, + tid: 0, + width: 0, + height: 0, + rotate: 0, + downloadTitle: '视频已缓存完成', + downloadSubtitle: videoDetail?.title ?? videoArc!.title, + ); + final currentTime = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final entry = BiliDownloadEntryInfo( + mediaType: 2, + hasDashAudio: true, + isCompleted: false, + totalBytes: 0, + downloadedBytes: 0, + title: videoDetail?.title ?? videoArc!.title!, + typeTag: videoQuality.code.toString(), + cover: videoDetail?.pic ?? videoArc!.cover!, + preferedVideoQuality: videoQuality.code, + qualityPithyDescription: videoQuality.desc, + guessedTotalBytes: 0, + totalTimeMilli: (page.duration ?? 0) * 1000, + danmakuCount: + videoDetail?.stat?.danmaku ?? videoArc?.arc?.stat?.danmaku ?? 0, + timeUpdateStamp: currentTime, + timeCreateStamp: currentTime, + canPlayInAdvance: true, + interruptTransformTempFile: false, + avid: videoDetail?.aid ?? videoArc!.aid!, + spid: 0, + seasonId: null, + ep: null, + source: null, + bvid: videoDetail?.bvid ?? videoArc!.bvid!, + ownerId: videoDetail?.owner?.mid ?? videoArc?.arc?.author?.mid, + ownerName: videoDetail?.owner?.name ?? videoArc?.arc?.author?.name, + pageData: pageData, + ); + _createDownload(entry); + } + + void downloadBangumi( + int index, + PgcInfoModel pgcItem, + pgc.EpisodeItem episode, + VideoQuality quality, + ) { + final cid = episode.cid!; + if (downloadList.indexWhere((e) => e.cid == cid) != -1) { + return; + } + final currentTime = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final source = SourceInfo( + avId: episode.aid!, + cid: cid, + ); + final ep = EpInfo( + avId: source.avId, + page: index, + danmaku: source.cid, + cover: episode.cover!, + episodeId: episode.id!, + index: episode.title!, + indexTitle: episode.longTitle ?? '', + showTitle: episode.showTitle, + from: episode.from ?? 'bangumi', + seasonType: pgcItem.type ?? (episode.from == 'pugv' ? -1 : 0), + width: 0, + height: 0, + rotate: 0, + link: episode.link ?? '', + bvid: episode.bvid ?? IdUtils.av2bv(source.avId), + sortIndex: index, + ); + final entry = BiliDownloadEntryInfo( + mediaType: 2, + hasDashAudio: true, + isCompleted: false, + totalBytes: 0, + downloadedBytes: 0, + title: pgcItem.seasonTitle ?? pgcItem.title ?? '', + typeTag: quality.code.toString(), + cover: episode.cover!, + preferedVideoQuality: quality.code, + qualityPithyDescription: quality.desc, + guessedTotalBytes: 0, + totalTimeMilli: + (episode.duration ?? 0) * + (episode.from == 'pugv' ? 1000 : 1), // pgc millisec,, pugv sec + danmakuCount: pgcItem.stat?.danmaku ?? 0, + timeUpdateStamp: currentTime, + timeCreateStamp: currentTime, + canPlayInAdvance: true, + interruptTransformTempFile: false, + spid: 0, + seasonId: pgcItem.seasonId!.toString(), + bvid: episode.bvid ?? IdUtils.av2bv(source.avId), + avid: source.avId, + ep: ep, + source: source, + ownerId: pgcItem.upInfo?.mid, + ownerName: pgcItem.upInfo?.uname, + pageData: null, + ); + _createDownload(entry); + } + + Future _createDownload(BiliDownloadEntryInfo entry) async { + final entryDir = await _getDownloadEntryDir(entry); + final entryJsonFile = File(path.join(entryDir.path, _entryFile)); + final entryJsonStr = Utils.jsonEncoder.convert(entry.toJson()); + await entryJsonFile.writeAsBytes(utf8.encode(entryJsonStr)); + entry + ..pageDirPath = entryDir.parent.path + ..entryDirPath = entryDir.path + ..status = DownloadStatus.wait; + downloadList.insert(0, entry); + downloaFlag.refresh(); + final currStatus = curDownload.value?.status?.index; + if (currStatus == null || currStatus > 3) { + startDownload(entry); + } else { + waitDownloadQueue.add(entry); + } + } + + Future _getDownloadEntryDir(BiliDownloadEntryInfo entry) async { + late final String dirName; + late final String pageDirName; + if (entry.ep case final ep?) { + dirName = 's_${entry.seasonId}'; + pageDirName = ep.episodeId.toString(); + } else if (entry.pageData case final page?) { + dirName = entry.avid.toString(); + pageDirName = 'c_${page.cid}'; + } + final pageDir = Directory( + path.join(await _getDownloadPath(), dirName, pageDirName), + ); + if (!pageDir.existsSync()) { + await pageDir.create(recursive: true); + } + return pageDir; + } + + Future _getDownloadPath() async { + final dir = Directory(downloadPath); + if (!dir.existsSync()) { + await dir.create(recursive: true); + } + return dir.path; + } + + Future startDownload(BiliDownloadEntryInfo entry) { + return _lock.synchronized(() async { + await _downloadManager?.cancel(isDelete: false); + await _audioDownloadManager?.cancel(isDelete: false); + _downloadManager = null; + _audioDownloadManager = null; + final prevStatus = curDownload.value?.status?.index; + if (prevStatus != null && prevStatus <= 3) { + curDownload.value?.status = DownloadStatus.pause; + } + + curDownload.value = entry; + await _startDownload(entry); + }); + } + + Future downloadDanmaku({ + required BiliDownloadEntryInfo entry, + bool isUpdate = false, + }) async { + final cid = entry.pageData?.cid ?? entry.source?.cid; + if (cid == null) { + return false; + } + final danmakuXMLFile = File(path.join(entry.entryDirPath, _danmakuFile)); + if (isUpdate || !danmakuXMLFile.existsSync()) { + try { + if (!isUpdate) { + _updateCurStatus(DownloadStatus.getDanmaku); + } + final res = await Request().get( + 'https://comment.bilibili.com/$cid.xml', + options: Options(responseType: ResponseType.bytes), + ); + final xmlBytes = Inflate((res.data as Uint8List)).getBytes(); + await danmakuXMLFile.writeAsBytes(xmlBytes); + return true; + } catch (e) { + if (!isUpdate) { + _updateCurStatus(DownloadStatus.failDanmaku); + } + if (kDebugMode) { + SmartDialog.showToast(e.toString()); + } + return false; + } + } + return true; + } + + Future _downloadCover({ + required BiliDownloadEntryInfo entry, + }) async { + try { + await Request.dio.download( + entry.cover.http2https, + path.join(entry.entryDirPath, _coverFile), + ); + return true; + } catch (_) {} + return false; + } + + Future _startDownload(BiliDownloadEntryInfo entry) async { + try { + _updateCurStatus(DownloadStatus.getPlayUrl); + + final BiliDownloadMediaInfo mediaFileInfo = + await DownloadHttp.getVideoUrl( + entry: entry, + ep: entry.ep, + source: entry.source, + pageData: entry.pageData, + ); + + final videoDir = Directory(path.join(entry.entryDirPath, entry.typeTag)); + if (!videoDir.existsSync()) { + await videoDir.create(recursive: true); + } + + final res = await Future.wait([ + downloadDanmaku(entry: entry), + _downloadCover(entry: entry), + ]); + + if (!res.first) { + return; + } + + final mediaJsonFile = File(path.join(videoDir.path, _indexFile)); + final mediaJsonStr = Utils.jsonEncoder.convert(mediaFileInfo.toJson()); + await mediaJsonFile.writeAsString(mediaJsonStr); + + if (curDownload.value?.cid != entry.cid) { + return; + } + + switch (mediaFileInfo) { + case Type1 mediaFileInfo: + final first = mediaFileInfo.segmentList.first; + _downloadManager = DownloadManager( + url: first.url, + path: path.join(videoDir.path, PathUtils.videoNameType1), + onTaskRunning: _onTaskRunning, + onTaskComplete: _onTaskComplete, + onTaskError: _onTaskError, + )..start(); + break; + case Type2 mediaFileInfo: + _downloadManager = DownloadManager( + url: mediaFileInfo.video.first.baseUrl, + path: path.join(videoDir.path, PathUtils.videoNameType2), + onTaskRunning: _onTaskRunning, + onTaskComplete: _onTaskComplete, + onTaskError: _onTaskError, + )..start(); + final audio = mediaFileInfo.audio; + if (audio != null && audio.isNotEmpty) { + _audioDownloadManager = DownloadManager( + url: audio.first.baseUrl, + path: path.join(videoDir.path, PathUtils.audioNameType2), + onTaskRunning: _onAudioTaskRunning, + onTaskComplete: _onAudioTaskComplete, + onTaskError: _onAudioTaskError, + )..start(); + } + late final first = mediaFileInfo.video.first; + entry.pageData + ?..width = first.width + ..height = first.height; + entry.ep + ?..width = first.width + ..height = first.height; + _updateBiliDownloadEntryJson(entry); + break; + default: + break; + } + } catch (e) { + _updateCurStatus(DownloadStatus.failPlayUrl); + if (kDebugMode) { + debugPrint('get download url error: $e'); + } + } + } + + Future _updateBiliDownloadEntryJson(BiliDownloadEntryInfo entry) async { + final entryJsonFile = File(path.join(entry.entryDirPath, _entryFile)); + final entryJsonStr = Utils.jsonEncoder.convert(entry.toJson()); + await entryJsonFile.writeAsString(entryJsonStr); + } + + void _onTaskRunning({required int progress, required int total}) { + if (progress == 0 && total != 0) { + if (curDownload.value case final curEntryInfo?) { + _updateBiliDownloadEntryJson(curEntryInfo..totalBytes = total); + } + } + if (curDownload.value case final entry?) { + entry + ..downloadedBytes = progress + ..status = DownloadStatus.downloading; + curDownload.refresh(); + } + } + + void _onTaskComplete() { + final audioStatus = _audioDownloadManager?.status; + final status = switch (audioStatus) { + DownloadStatus.downloading => DownloadStatus.audioDownloading, + DownloadStatus.failDownload => DownloadStatus.failDownloadAudio, + null => DownloadStatus.completed, + _ => audioStatus, + }; + _updateCurStatus(status); + if (status == DownloadStatus.completed) { + _completeDownload(); + } else { + if (curDownload.value case final curEntryInfo?) { + _updateBiliDownloadEntryJson( + curEntryInfo..downloadedBytes = curEntryInfo.totalBytes, + ); + } + } + } + + void _onTaskError({ + required int progress, + required int total, + required Object error, + }) { + _updateCurStatus(DownloadStatus.failDownload); + if (curDownload.value case final curEntryInfo?) { + curEntryInfo + ..totalBytes = total + ..downloadedBytes = progress; + _updateBiliDownloadEntryJson(curEntryInfo); + } + } + + void _onAudioTaskRunning({required int progress, required int total}) {} + + void _onAudioTaskComplete() { + if (_downloadManager?.status == DownloadStatus.completed) { + _completeDownload(); + } + } + + void _onAudioTaskError({ + required int progress, + required int total, + required Object error, + }) { + if (_downloadManager?.status == DownloadStatus.completed) { + _updateCurStatus(DownloadStatus.failDownloadAudio); + } + } + + Future _completeDownload() async { + final entry = curDownload.value; + if (entry == null) { + return; + } + entry + ..downloadedBytes = entry.totalBytes + ..isCompleted = true; + await _updateBiliDownloadEntryJson(entry); + downloaFlag.refresh(); + curDownload.value = null; + _downloadManager = null; + _audioDownloadManager = null; + _nextDownload(); + } + + void _nextDownload() { + if (waitDownloadQueue.isNotEmpty) { + final next = waitDownloadQueue.removeAt(0); + if (downloadList.contains(next)) { + startDownload(next); + } else { + _nextDownload(); + } + } + } + + Future deleteDownload({ + required BiliDownloadEntryInfo entry, + }) async { + if (curDownload.value?.cid == entry.cid) { + await cancelDownload(isDelete: true); + } + final downloadDir = Directory(entry.pageDirPath); + if (downloadDir.existsSync()) { + if (downloadDir.listSync().length <= 1) { + await downloadDir.tryDel(recursive: true); + } else { + final entryDir = Directory(entry.entryDirPath); + if (entryDir.existsSync()) { + await entryDir.tryDel(recursive: true); + } + } + } + downloadList.remove(entry); + waitDownloadQueue.remove(entry); + downloaFlag.refresh(); + } + + Future deletePage({required String pageDirPath}) async { + await Directory(pageDirPath).tryDel(recursive: true); + downloadList.removeWhere((e) => e.pageDirPath == pageDirPath); + downloaFlag.refresh(); + } + + Future cancelDownload({ + required bool isDelete, + bool downloadNext = true, + }) async { + await _downloadManager?.cancel(isDelete: isDelete); + await _audioDownloadManager?.cancel(isDelete: isDelete); + _downloadManager = null; + _audioDownloadManager = null; + if (!isDelete) { + final entry = curDownload.value; + if (entry != null) { + await _updateBiliDownloadEntryJson(entry); + } + } + if (isDelete) { + curDownload.value = null; + } else { + _updateCurStatus(DownloadStatus.pause); + } + if (downloadNext) { + _nextDownload(); + } + } +} diff --git a/lib/utils/accounts/account_manager/account_mgr.dart b/lib/utils/accounts/account_manager/account_mgr.dart index f55c1f7c0..61a51a36e 100644 --- a/lib/utils/accounts/account_manager/account_mgr.dart +++ b/lib/utils/accounts/account_manager/account_mgr.dart @@ -246,6 +246,9 @@ class AccountManager extends Interceptor { @override void onError(DioException err, ErrorInterceptorHandler handler) { + if (err.requestOptions.responseType == ResponseType.stream) { + return handler.next(err); + } if (err.requestOptions.method != 'POST') { toast(err); } diff --git a/lib/utils/cache_manage.dart b/lib/utils/cache_manager.dart similarity index 93% rename from lib/utils/cache_manage.dart rename to lib/utils/cache_manager.dart index 0ed89f7c4..ae333740e 100644 --- a/lib/utils/cache_manage.dart +++ b/lib/utils/cache_manager.dart @@ -7,13 +7,13 @@ import 'package:PiliPlus/utils/utils.dart'; import 'package:flutter/foundation.dart' show kDebugMode; import 'package:path_provider/path_provider.dart'; -abstract class CacheManage { +abstract class CacheManager { // 获取缓存目录 static Future loadApplicationCache([ final num maxSize = double.infinity, ]) async { try { - Directory tempDirectory = await getTemporaryDirectory(); + final Directory tempDirectory = await getTemporaryDirectory(); if (Utils.isDesktop) { final dir = Directory('${tempDirectory.path}/libCachedImageData'); if (dir.existsSync()) { @@ -62,7 +62,7 @@ abstract class CacheManage { // 清除 Library/Caches 目录及文件缓存 static Future clearLibraryCache() async { try { - var tempDirectory = await getTemporaryDirectory(); + final Directory tempDirectory = await getTemporaryDirectory(); if (Utils.isDesktop) { final dir = Directory('${tempDirectory.path}/libCachedImageData'); if (dir.existsSync()) { diff --git a/lib/utils/extension.dart b/lib/utils/extension.dart index aa8b4f641..97045f181 100644 --- a/lib/utils/extension.dart +++ b/lib/utils/extension.dart @@ -254,6 +254,14 @@ extension FileExt on File { } } +extension DirectoryExt on Directory { + Future tryDel({bool recursive = false}) async { + try { + await delete(recursive: recursive); + } catch (_) {} + } +} + extension SizeExt on Size { bool get isPortrait => width < 600 || height >= width; } diff --git a/lib/utils/image_utils.dart b/lib/utils/image_utils.dart index 602688d1b..b3f724dd6 100644 --- a/lib/utils/image_utils.dart +++ b/lib/utils/image_utils.dart @@ -5,6 +5,7 @@ import 'package:PiliPlus/common/constants.dart'; import 'package:PiliPlus/http/init.dart'; import 'package:PiliPlus/utils/extension.dart'; import 'package:PiliPlus/utils/global_data.dart'; +import 'package:PiliPlus/utils/path_utils.dart'; import 'package:PiliPlus/utils/permission_handler.dart'; import 'package:PiliPlus/utils/storage_pref.dart'; import 'package:PiliPlus/utils/utils.dart'; @@ -27,8 +28,7 @@ abstract class ImageUtils { static Future onShareImg(String url) async { try { SmartDialog.showLoading(); - final path = - '${await Utils.temporaryDirectory}/${Utils.getFileName(url)}'; + final path = '$tmpDirPath/${Utils.getFileName(url)}'; final res = await Request().downloadFile(url.http2https, path); SmartDialog.dismiss(); if (res.statusCode == 200) { @@ -113,11 +113,10 @@ abstract class ImageUtils { } if (!silentDownImg) SmartDialog.showLoading(msg: '正在下载'); - String tmpPath = await Utils.temporaryDirectory; late String imageName = "cover_${Utils.getFileName(url)}"; - late String imagePath = '$tmpPath/$imageName'; + late String imagePath = '$tmpDirPath/$imageName'; String videoName = "video_${Utils.getFileName(liveUrl)}"; - String videoPath = '$tmpPath/$videoName'; + String videoPath = '$tmpDirPath/$videoName'; final res = await Request().downloadFile(liveUrl.http2https, videoPath); if (res.statusCode != 200) throw '${res.statusCode}'; @@ -189,7 +188,7 @@ abstract class ImageUtils { ))?.file; if (file == null) { - final String filePath = '${await Utils.temporaryDirectory}/$name'; + final String filePath = '$tmpDirPath/$name'; final response = await Request().downloadFile( url.http2https, diff --git a/lib/utils/page_utils.dart b/lib/utils/page_utils.dart index f96c3dbc4..136649fcc 100644 --- a/lib/utils/page_utils.dart +++ b/lib/utils/page_utils.dart @@ -77,7 +77,7 @@ abstract class PageUtils { ); } else if (context.mounted) { UserModel? userModel = await Navigator.of(context).push( - GetPageRoute(page: () => const ContactPage()), + GetPageRoute(page: ContactPage.new), ); if (userModel != null) { selectedIndex = 0; @@ -319,7 +319,6 @@ abstract class PageUtils { context: context, useSafeArea: true, isScrollControlled: true, - sheetAnimationStyle: const AnimationStyle(curve: Curves.ease), constraints: BoxConstraints( maxWidth: min(640, context.mediaQueryShortestSide), ), @@ -707,7 +706,7 @@ abstract class PageUtils { } } - static void toVideoPage({ + static Future? toVideoPage({ VideoType videoType = VideoType.ugc, int? aid, String? bvid, @@ -719,7 +718,6 @@ abstract class PageUtils { String? title, int? progress, Map? extraArguments, - int? id, bool off = false, }) { final arguments = { @@ -737,17 +735,15 @@ abstract class PageUtils { ...?extraArguments, }; if (off) { - Get.offNamed( + return Get.offNamed( '/videoV', arguments: arguments, - id: id, preventDuplicates: false, ); } else { - Get.toNamed( + return Get.toNamed( '/videoV', arguments: arguments, - id: id, preventDuplicates: false, ); } diff --git a/lib/utils/path_utils.dart b/lib/utils/path_utils.dart new file mode 100644 index 000000000..9efb3d5a4 --- /dev/null +++ b/lib/utils/path_utils.dart @@ -0,0 +1,29 @@ +import 'dart:io' show Platform; + +import 'package:path/path.dart' as path; + +late final String tmpDirPath; + +late final String appSupportDirPath; + +late String downloadPath; + +String get defDownloadPath => + path.join(appSupportDirPath, PathUtils.downloadDir); + +abstract final class PathUtils { + static const videoNameType1 = '0.mp4'; + static const _fileExt = '.m4s'; + static const audioNameType2 = 'audio$_fileExt'; + static const videoNameType2 = 'video$_fileExt'; + static const downloadDir = 'download'; + + static String buildShadersAbsolutePath( + String baseDirectory, + List shaders, + ) { + return shaders + .map((shader) => path.join(baseDirectory, shader)) + .join(Platform.isWindows ? ';' : ':'); + } +} diff --git a/lib/utils/request_utils.dart b/lib/utils/request_utils.dart index 7e50e146c..e7248c5ee 100644 --- a/lib/utils/request_utils.dart +++ b/lib/utils/request_utils.dart @@ -176,9 +176,6 @@ abstract class RequestUtils { context: context, useSafeArea: true, isScrollControlled: true, - sheetAnimationStyle: const AnimationStyle( - curve: Curves.ease, - ), constraints: BoxConstraints( maxWidth: min(640, context.mediaQueryShortestSide), ), diff --git a/lib/utils/storage.dart b/lib/utils/storage.dart index 638a231f9..dd839d57f 100644 --- a/lib/utils/storage.dart +++ b/lib/utils/storage.dart @@ -1,5 +1,4 @@ import 'dart:convert'; -import 'dart:io'; import 'package:PiliPlus/models/model_owner.dart'; import 'package:PiliPlus/models/user/danmaku_rule_adapter.dart'; @@ -8,9 +7,11 @@ import 'package:PiliPlus/utils/accounts.dart'; import 'package:PiliPlus/utils/accounts/account_adapter.dart'; import 'package:PiliPlus/utils/accounts/account_type_adapter.dart'; import 'package:PiliPlus/utils/accounts/cookie_jar_adapter.dart'; +import 'package:PiliPlus/utils/path_utils.dart'; import 'package:PiliPlus/utils/set_int_adapter.dart'; +import 'package:PiliPlus/utils/utils.dart'; import 'package:hive_flutter/hive_flutter.dart'; -import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as path; abstract class GStorage { static late final Box userInfo; @@ -18,11 +19,10 @@ abstract class GStorage { static late final Box localCache; static late final Box setting; static late final Box video; + static late final Box watchProgress; static Future init() async { - final Directory dir = await getApplicationSupportDirectory(); - final String path = dir.path; - await Hive.initFlutter('$path/hive'); + await Hive.initFlutter(path.join(appSupportDirPath, 'hive')); regAdapter(); await Future.wait([ @@ -51,13 +51,18 @@ abstract class GStorage { ).then((res) => historyWord = res), // 视频设置 Hive.openBox('video').then((res) => video = res), - Accounts.init(), + Hive.openBox( + 'watchProgress', + compactionStrategy: (entries, deletedEntries) { + return deletedEntries > 4; + }, + ).then((res) => watchProgress = res), ]); } static String exportAllSettings() { - return const JsonEncoder.withIndent(' ').convert({ + return Utils.jsonEncoder.convert({ setting.name: setting.toMap(), video.name: video.toMap(), }); @@ -94,6 +99,7 @@ abstract class GStorage { setting.compact(), video.compact(), Accounts.account.compact(), + watchProgress.compact(), ]); } @@ -105,6 +111,7 @@ abstract class GStorage { setting.close(), video.close(), Accounts.account.close(), + watchProgress.close(), ]); } } diff --git a/lib/utils/storage_key.dart b/lib/utils/storage_key.dart index 14f1eeedd..3193fcbe7 100644 --- a/lib/utils/storage_key.dart +++ b/lib/utils/storage_key.dart @@ -143,7 +143,8 @@ abstract class SettingBoxKey { showMemberShop = 'showMemberShop', enablePlayAll = 'enablePlayAll', enableTapDm = 'enableTapDm', - setSystemBrightness = 'setSystemBrightness'; + setSystemBrightness = 'setSystemBrightness', + downloadPath = 'downloadPath'; static const String minimizeOnExit = 'minimizeOnExit', windowSize = 'windowSize', diff --git a/lib/utils/storage_pref.dart b/lib/utils/storage_pref.dart index 43a29e79a..325bab6d0 100644 --- a/lib/utils/storage_pref.dart +++ b/lib/utils/storage_pref.dart @@ -220,7 +220,7 @@ abstract class Pref { static String get defaultDecode => _setting.get( SettingBoxKey.defaultDecode, - defaultValue: VideoDecodeFormatType.values.last.codes.first, + defaultValue: VideoDecodeFormatType.AVC.codes.first, ); static String get secondDecode => _setting.get( @@ -868,4 +868,6 @@ abstract class Pref { static bool get setSystemBrightness => _setting.get(SettingBoxKey.setSystemBrightness, defaultValue: false); + + static String? get downloadPath => _setting.get(SettingBoxKey.downloadPath); } diff --git a/lib/utils/theme_utils.dart b/lib/utils/theme_utils.dart index 22c91df1f..0bd32d627 100644 --- a/lib/utils/theme_utils.dart +++ b/lib/utils/theme_utils.dart @@ -2,7 +2,6 @@ import 'package:PiliPlus/common/constants.dart'; import 'package:PiliPlus/main.dart'; import 'package:PiliPlus/utils/extension.dart'; import 'package:PiliPlus/utils/storage_pref.dart'; -import 'package:PiliPlus/utils/utils.dart'; import 'package:flex_seed_scheme/flex_seed_scheme.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; @@ -104,7 +103,6 @@ abstract class ThemeUtils { // ignore: deprecated_member_use sliderTheme: const SliderThemeData(year2023: false), tooltipTheme: TooltipThemeData( - preferBelow: Utils.isDesktop, textStyle: const TextStyle( color: Colors.white, fontSize: 14, diff --git a/lib/utils/utils.dart b/lib/utils/utils.dart index 879f23773..1b030c697 100644 --- a/lib/utils/utils.dart +++ b/lib/utils/utils.dart @@ -10,8 +10,6 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/services.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; -import 'package:path/path.dart' as path; -import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; abstract class Utils { @@ -26,6 +24,8 @@ abstract class Utils { static final bool isDesktop = Platform.isWindows || Platform.isMacOS || Platform.isLinux; + static const jsonEncoder = JsonEncoder.withIndent(' '); + static Future saveBytes2File({ required String name, required Uint8List bytes, @@ -74,13 +74,11 @@ abstract class Utils { Color(int.parse(color.replaceFirst('#', 'FF'), radix: 16)); static int? _sdkInt; - static Future get sdkInt async { return _sdkInt ??= (await DeviceInfoPlugin().androidInfo).version.sdkInt; } static bool? _isIpad; - static Future get isIpad async { if (!Platform.isIOS) return false; return _isIpad ??= (await DeviceInfoPlugin().iosInfo).model @@ -88,12 +86,6 @@ abstract class Utils { .contains('ipad'); } - static String? _tempDir; - - static Future get temporaryDirectory async { - return _tempDir ??= (await getTemporaryDirectory()).path; - } - static Future get sharePositionOrigin async { if (await isIpad) { final size = Get.size; @@ -116,15 +108,6 @@ abstract class Utils { } } - static String buildShadersAbsolutePath( - String baseDirectory, - List shaders, - ) { - return shaders - .map((shader) => path.join(baseDirectory, shader)) - .join(Platform.isWindows ? ';' : ':'); - } - static final numericRegex = RegExp(r'^[\d\.]+$'); static bool isStringNumeric(String str) { return numericRegex.hasMatch(str); @@ -156,13 +139,6 @@ abstract class Utils { return v.toString() + random.nextInt(9999).toString(); } - static int findClosestNumber(int target, List numbers) { - List filterNums = numbers.where((number) => number <= target).toList(); - return filterNums.isNotEmpty - ? filterNums.reduce((a, b) => a > b ? a : b) - : numbers.reduce((a, b) => a > b ? b : a); - } - static List generateRandomBytes(int minLength, int maxLength) { return List.generate( minLength + random.nextInt(maxLength - minLength + 1), diff --git a/lib/utils/video_utils.dart b/lib/utils/video_utils.dart index 13fcba7de..ec198e969 100644 --- a/lib/utils/video_utils.dart +++ b/lib/utils/video_utils.dart @@ -1,87 +1,85 @@ -import 'package:PiliPlus/grpc/bilibili/app/listener/v1.pb.dart' as audio; +import 'package:PiliPlus/grpc/bilibili/app/listener/v1.pb.dart' + show DashItem, ResponseUrl; import 'package:PiliPlus/models/common/video/cdn_type.dart'; import 'package:PiliPlus/models/video/play/url.dart'; import 'package:PiliPlus/models_new/live/live_room_play_info/codec.dart'; -import 'package:PiliPlus/utils/extension.dart'; import 'package:PiliPlus/utils/storage_pref.dart'; abstract final class VideoUtils { static String cdnService = Pref.defaultCDNService; static bool disableAudioCDN = Pref.disableAudioCDN; + /// [DashItem] audio + /// [VideoItem] [AudioItem] video + /// [CodecItem] live static String getCdnUrl(dynamic item, [String? defaultCDNService]) { String? backupUrl; String? videoUrl; defaultCDNService ??= cdnService; - if (item is AudioItem) { + if (item case final AudioItem e) { if (disableAudioCDN) { - return item.backupUrl?.isNotEmpty == true - ? item.backupUrl! - : item.baseUrl ?? ""; + return e.backupUrl?.isNotEmpty == true ? e.backupUrl! : e.baseUrl ?? ''; } } if (defaultCDNService == CDNService.baseUrl.code) { - return (item.baseUrl as String?)?.isNotEmpty == true - ? item.baseUrl - : item.backupUrl ?? ""; + if (item case final BaseItem e) { + return e.baseUrl?.isNotEmpty == true ? e.baseUrl! : e.backupUrl ?? ''; + } } - if (item is CodecItem) { - backupUrl = - (item.urlInfo?.first.host)! + - item.baseUrl! + - item.urlInfo!.first.extra!; - } else if (item is audio.DashItem) { - backupUrl = item.backupUrl.lastOrNull; + if (item case final CodecItem e) { + backupUrl = e.urlInfo!.first.host! + e.baseUrl! + e.urlInfo!.first.extra!; + } else if (item case final DashItem e) { + backupUrl = e.backupUrl.lastOrNull; } else { backupUrl = item.backupUrl; } if (defaultCDNService == CDNService.backupUrl.code) { - return backupUrl?.isNotEmpty == true ? backupUrl : item.baseUrl ?? ""; + return backupUrl?.isNotEmpty == true ? backupUrl! : item.baseUrl ?? ''; } videoUrl = backupUrl?.isNotEmpty == true ? backupUrl : item.baseUrl; - if (videoUrl.isNullOrEmpty) { - return ""; + if (videoUrl == null || videoUrl.isEmpty) { + return ''; } - // if (kDebugMode) debugPrint("videoUrl:$videoUrl"); + // if (kDebugMode) debugPrint('videoUrl:$videoUrl'); String defaultCDNHost = CDNService.fromCode(defaultCDNService).host; - // if (kDebugMode) debugPrint("defaultCDNHost:$defaultCDNHost"); - if (videoUrl!.contains("szbdyd.com")) { + // if (kDebugMode) debugPrint('defaultCDNHost:$defaultCDNHost'); + if (videoUrl.contains('szbdyd.com')) { final uri = Uri.parse(videoUrl); String hostname = uri.queryParameters['xy_usource'] ?? defaultCDNHost; videoUrl = uri.replace(host: hostname, port: 443).toString(); - } else if (videoUrl.contains(".mcdn.bilivideo") || - videoUrl.contains("/upgcxcode/")) { + } else if (videoUrl.contains('.mcdn.bilivideo') || + videoUrl.contains('/upgcxcode/')) { videoUrl = Uri.parse( videoUrl, ).replace(host: defaultCDNHost, port: 443).toString(); // videoUrl = // 'https://proxy-tf-all-ws.bilivideo.com/?url=${Uri.encodeComponent(videoUrl)}'; } - // if (kDebugMode) debugPrint("videoUrl:$videoUrl"); + // if (kDebugMode) debugPrint('videoUrl:$videoUrl'); // /// 先获取backupUrl 一般是upgcxcode地址 播放更稳定 // if (item is VideoItem) { - // backupUrl = item.backupUrl ?? ""; - // videoUrl = backupUrl.contains("http") ? backupUrl : (item.baseUrl ?? ""); + // backupUrl = item.backupUrl ?? ''; + // videoUrl = backupUrl.contains('http') ? backupUrl : (item.baseUrl ?? ''); // } else if (item is AudioItem) { - // backupUrl = item.backupUrl ?? ""; - // videoUrl = backupUrl.contains("http") ? backupUrl : (item.baseUrl ?? ""); + // backupUrl = item.backupUrl ?? ''; + // videoUrl = backupUrl.contains('http') ? backupUrl : (item.baseUrl ?? ''); // } else if (item is CodecItem) { // backupUrl = (item.urlInfo?.first.host)! + // item.baseUrl! + // item.urlInfo!.first.extra!; - // videoUrl = backupUrl.contains("http") ? backupUrl : (item.baseUrl ?? ""); + // videoUrl = backupUrl.contains('http') ? backupUrl : (item.baseUrl ?? ''); // } else { - // return ""; + // return ''; // } // // /// issues #70 - // if (videoUrl.contains(".mcdn.bilivideo")) { + // if (videoUrl.contains('.mcdn.bilivideo')) { // videoUrl = // 'https://proxy-tf-all-ws.bilivideo.com/?url=${Uri.encodeComponent(videoUrl)}'; - // } else if (videoUrl.contains("/upgcxcode/")) { + // } else if (videoUrl.contains('/upgcxcode/')) { // //CDN列表 // var cdnList = { // 'ali': 'upos-sz-mirrorali.bilivideo.com', @@ -89,15 +87,15 @@ abstract final class VideoUtils { // 'hw': 'upos-sz-mirrorhw.bilivideo.com', // }; // //取一个CDN - // var cdn = cdnList['cos'] ?? ""; + // var cdn = cdnList['cos'] ?? ''; // var reg = RegExp(r'(http|https)://(.*?)/upgcxcode/'); - // videoUrl = videoUrl.replaceAll(reg, "https://$cdn/upgcxcode/"); + // videoUrl = videoUrl.replaceAll(reg, 'https://$cdn/upgcxcode/'); // } return videoUrl; } - static String getDurlCdnUrl(audio.ResponseUrl item) { + static String getDurlCdnUrl(ResponseUrl item) { if (disableAudioCDN || cdnService == CDNService.backupUrl.code) { return item.backupUrl.lastOrNull ?? item.url; } diff --git a/pubspec.lock b/pubspec.lock index 951017729..0ff9f07cd 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -223,7 +223,7 @@ packages: description: path: "." ref: main - resolved-ref: "6fee1a981775bee401224c2d622ce50768313746" + resolved-ref: "7ab779b16f5221207c33c2f57badbb93f21f273b" url: "https://github.com/bggRGjQaUbCoE/canvas_danmaku.git" source: git version: "0.2.6" @@ -344,10 +344,10 @@ packages: dependency: "direct main" description: name: crypto - sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf url: "https://pub.dev" source: hosted - version: "3.0.6" + version: "3.0.7" csslib: dependency: transitive description: @@ -562,10 +562,10 @@ packages: dependency: "direct main" description: name: flex_seed_scheme - sha256: b06d8b367b84cbf7ca5c5603c858fa5edae88486c4e4da79ac1044d73b6c62ec + sha256: "828291a5a4d4283590541519d8b57821946660ac61d2e07d955f81cfcab22e5d" url: "https://pub.dev" source: hosted - version: "3.5.1" + version: "3.6.1" floating: dependency: "direct main" description: @@ -734,10 +734,10 @@ packages: dependency: "direct main" description: name: flutter_svg - sha256: b9c2ad5872518a27507ab432d1fb97e8813b05f0fc693f9d40fad06d073e0678 + sha256: "055de8921be7b8e8b98a233c7a5ef84b3a6fcc32f46f1ebf5b9bb3576d108355" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.2.2" flutter_test: dependency: "direct dev" description: flutter @@ -1383,10 +1383,10 @@ packages: dependency: "direct main" description: name: protobuf - sha256: "826d6a306be26f29e5cd9faeb0c97aad5897270341dab6dbd7b8acd675937006" + sha256: "2fcc8a202ca7ec17dab7c97d6b6d91cf03aa07fe6f65f8afbb6dfa52cc5bd902" url: "https://pub.dev" source: hosted - version: "5.0.0" + version: "5.1.0" pub_semver: dependency: transitive description: @@ -1511,10 +1511,10 @@ packages: dependency: transitive description: name: sentry - sha256: "0a3a1e6b3b3873070d4dbefc6968f0d31e698ed55b4eb8ee185b230f35733b59" + sha256: "10a0bc25f5f21468e3beeae44e561825aaa02cdc6829438e73b9b64658ff88d9" url: "https://pub.dev" source: hosted - version: "9.7.0" + version: "9.8.0" share_plus: dependency: "direct main" description: @@ -1624,14 +1624,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.1" - sprintf: - dependency: transitive - description: - name: sprintf - sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" - url: "https://pub.dev" - source: hosted - version: "7.0.0" sqflite: dependency: transitive description: @@ -1741,10 +1733,10 @@ packages: dependency: "direct main" description: name: tray_manager - sha256: "537e539f48cd82d8ee2240d4330158c7b44c7e043e8e18b5811f2f8f6b7df25a" + sha256: c5fd83b0ae4d80be6eaedfad87aaefab8787b333b8ebd064b0e442a81006035b url: "https://pub.dev" source: hosted - version: "0.5.1" + version: "0.5.2" tuple: dependency: transitive description: @@ -1845,10 +1837,10 @@ packages: dependency: "direct main" description: name: uuid - sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 url: "https://pub.dev" source: hosted - version: "4.5.1" + version: "4.5.2" vector_graphics: dependency: transitive description: @@ -1996,7 +1988,7 @@ packages: source: hosted version: "1.1.0" xml: - dependency: transitive + dependency: "direct main" description: name: xml sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" diff --git a/pubspec.yaml b/pubspec.yaml index f8652071e..966ae5f00 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -220,6 +220,7 @@ dependencies: git: url: https://github.com/bggRGjQaUbCoE/super_sliver_list.git ref: mod + xml: ^6.6.1 vector_math: any fixnum: any