Fix download for large video (#1367)
This commit is contained in:
commit
c92cd4e17b
|
@ -2,10 +2,8 @@ import 'dart:async';
|
|||
import "dart:io";
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import "package:flutter/services.dart";
|
||||
import "package:logging/logging.dart";
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import "package:photos/core/configuration.dart";
|
||||
import "package:photos/generated/l10n.dart";
|
||||
import 'package:photos/services/local_sync_service.dart';
|
||||
import 'package:photos/ui/common/gradient_button.dart';
|
||||
|
|
|
@ -11,6 +11,7 @@ import 'package:photos/core/event_bus.dart';
|
|||
import 'package:photos/db/files_db.dart';
|
||||
import 'package:photos/events/local_photos_updated_event.dart';
|
||||
import "package:photos/generated/l10n.dart";
|
||||
import "package:photos/l10n/l10n.dart";
|
||||
import "package:photos/models/file/extensions/file_props.dart";
|
||||
import 'package:photos/models/file/file.dart';
|
||||
import 'package:photos/models/file/file_type.dart';
|
||||
|
@ -307,7 +308,11 @@ class FileAppBarState extends State<FileAppBar> {
|
|||
}
|
||||
|
||||
Future<void> _download(EnteFile file) async {
|
||||
final dialog = createProgressDialog(context, "Downloading...");
|
||||
final dialog = createProgressDialog(
|
||||
context,
|
||||
context.l10n.downloading,
|
||||
isDismissible: true,
|
||||
);
|
||||
await dialog.show();
|
||||
try {
|
||||
final FileType type = file.fileType;
|
||||
|
|
|
@ -92,6 +92,9 @@ class _VideoWidgetState extends State<VideoWidget> {
|
|||
getFileFromServer(
|
||||
widget.file,
|
||||
progressCallback: (count, total) {
|
||||
if(!mounted) {
|
||||
return;
|
||||
}
|
||||
_progressNotifier.value = count / (widget.file.fileSize ?? total);
|
||||
if (_progressNotifier.value == 1) {
|
||||
if (mounted) {
|
||||
|
@ -100,18 +103,24 @@ class _VideoWidgetState extends State<VideoWidget> {
|
|||
}
|
||||
},
|
||||
).then((file) {
|
||||
if (file != null) {
|
||||
if (file != null && mounted) {
|
||||
_setVideoPlayerController(file: file);
|
||||
}
|
||||
}).onError((error, stackTrace) {
|
||||
showErrorDialog(context, "Error", S.of(context).failedToDownloadVideo);
|
||||
if(mounted) {
|
||||
showErrorDialog(context, "Error", S
|
||||
.of(context).failedToDownloadVideo,);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
removeCallBack(widget.file);
|
||||
_videoPlayerController?.dispose();
|
||||
_chewieController?.dispose();
|
||||
_progressNotifier.dispose();
|
||||
|
||||
if (_wakeLockEnabledHere) {
|
||||
unawaited(
|
||||
WakelockPlus.enabled.then((isEnabled) {
|
||||
|
|
44
lib/utils/fake_progress.dart
Normal file
44
lib/utils/fake_progress.dart
Normal file
|
@ -0,0 +1,44 @@
|
|||
import 'dart:async';
|
||||
|
||||
import "package:flutter/foundation.dart";
|
||||
|
||||
typedef FakeProgressCallback = void Function(int count);
|
||||
|
||||
class FakePeriodicProgress {
|
||||
final FakeProgressCallback? callback;
|
||||
final Duration duration;
|
||||
late Timer _timer;
|
||||
bool _shouldRun = true;
|
||||
int runCount = 0;
|
||||
|
||||
FakePeriodicProgress({
|
||||
required this.callback,
|
||||
required this.duration,
|
||||
});
|
||||
|
||||
void start() {
|
||||
assert(_shouldRun, "Cannot start a stopped FakePeriodicProgress");
|
||||
Future.delayed(duration, _invokePeriodically);
|
||||
}
|
||||
|
||||
void stop() {
|
||||
if (_shouldRun) {
|
||||
_shouldRun = false;
|
||||
_timer.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
void _invokePeriodically() {
|
||||
if (_shouldRun) {
|
||||
try {
|
||||
runCount++;
|
||||
callback?.call(runCount);
|
||||
} catch (e) {
|
||||
debugPrint("Error in FakePeriodicProgress callback: $e");
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
_timer = Timer(duration, _invokePeriodically);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,75 +1,97 @@
|
|||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import "package:computer/computer.dart";
|
||||
import 'package:dio/dio.dart';
|
||||
import "package:flutter/foundation.dart";
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:photos/core/configuration.dart';
|
||||
import 'package:photos/core/network/network.dart';
|
||||
import 'package:photos/models/file/file.dart';
|
||||
import "package:photos/models/file/file_type.dart";
|
||||
import 'package:photos/services/collections_service.dart';
|
||||
import 'package:photos/utils/crypto_util.dart';
|
||||
import "package:photos/utils/data_util.dart";
|
||||
import "package:photos/utils/fake_progress.dart";
|
||||
|
||||
final _logger = Logger("file_download_util");
|
||||
|
||||
Future<File?> downloadAndDecrypt(
|
||||
EnteFile file, {
|
||||
ProgressCallback? progressCallback,
|
||||
}) {
|
||||
}) async {
|
||||
final String logPrefix = 'File-${file.uploadedFileID}:';
|
||||
_logger.info('$logPrefix starting download');
|
||||
final encryptedFilePath = Configuration.instance.getTempDirectory() +
|
||||
file.generatedID.toString() +
|
||||
".encrypted";
|
||||
_logger
|
||||
.info('$logPrefix starting download ${formatBytes(file.fileSize ?? 0)}');
|
||||
|
||||
final String tempDir = Configuration.instance.getTempDirectory();
|
||||
final String encryptedFilePath = "$tempDir${file.generatedID}.encrypted";
|
||||
final encryptedFile = File(encryptedFilePath);
|
||||
|
||||
final startTime = DateTime.now().millisecondsSinceEpoch;
|
||||
return NetworkClient.instance
|
||||
.getDio()
|
||||
.download(
|
||||
file.downloadUrl,
|
||||
encryptedFilePath,
|
||||
options: Options(
|
||||
headers: {"X-Auth-Token": Configuration.instance.getToken()},
|
||||
),
|
||||
onReceiveProgress: progressCallback,
|
||||
)
|
||||
.then((response) async {
|
||||
if (response.statusCode != 200) {
|
||||
_logger.warning('$logPrefix download failed ${response.toString()}');
|
||||
return null;
|
||||
} else if (!encryptedFile.existsSync()) {
|
||||
_logger.warning('$logPrefix incomplete download, file not found');
|
||||
|
||||
try {
|
||||
final response = await NetworkClient.instance.getDio().download(
|
||||
file.downloadUrl,
|
||||
encryptedFilePath,
|
||||
options: Options(
|
||||
headers: {"X-Auth-Token": Configuration.instance.getToken()},
|
||||
),
|
||||
onReceiveProgress: (a, b) {
|
||||
if (kDebugMode) {
|
||||
_logger.fine(
|
||||
"$logPrefix download progress: ${formatBytes(a)} / ${formatBytes(b)}",
|
||||
);
|
||||
}
|
||||
progressCallback?.call(a, b);
|
||||
},
|
||||
);
|
||||
if (response.statusCode != 200 || !encryptedFile.existsSync()) {
|
||||
_logger.warning('$logPrefix download failed ${response.toString()}');
|
||||
return null;
|
||||
}
|
||||
final int sizeInBytes = ((file.fileSize ?? 0) > 0)
|
||||
? file.fileSize!
|
||||
: await encryptedFile.length();
|
||||
final double speedInKBps = sizeInBytes /
|
||||
1024.0 /
|
||||
((DateTime.now().millisecondsSinceEpoch - startTime) / 1000);
|
||||
|
||||
final int sizeInBytes = file.fileSize ?? await encryptedFile.length();
|
||||
final double elapsedSeconds =
|
||||
(DateTime.now().millisecondsSinceEpoch - startTime) / 1000;
|
||||
final double speedInKBps = sizeInBytes / 1024.0 / elapsedSeconds;
|
||||
|
||||
_logger.info(
|
||||
"$logPrefix download completed: ${formatBytes(sizeInBytes)}, avg speed: ${speedInKBps.toStringAsFixed(2)} KB/s",
|
||||
'$logPrefix download completed: ${formatBytes(sizeInBytes)}, avg speed: ${speedInKBps.toStringAsFixed(2)} KB/s',
|
||||
);
|
||||
|
||||
final decryptedFilePath = Configuration.instance.getTempDirectory() +
|
||||
file.generatedID.toString() +
|
||||
".decrypted";
|
||||
final String decryptedFilePath = "$tempDir${file.generatedID}.decrypted";
|
||||
// As decryption can take time, emit fake progress for large files during
|
||||
// decryption
|
||||
final FakePeriodicProgress? fakeProgress = file.fileType == FileType.video
|
||||
? FakePeriodicProgress(
|
||||
callback: (count) {
|
||||
progressCallback?.call(sizeInBytes, sizeInBytes);
|
||||
},
|
||||
duration: const Duration(milliseconds: 5000),
|
||||
)
|
||||
: null;
|
||||
try {
|
||||
// Start the periodic callback after initial 5 seconds
|
||||
fakeProgress?.start();
|
||||
await CryptoUtil.decryptFile(
|
||||
encryptedFilePath,
|
||||
decryptedFilePath,
|
||||
CryptoUtil.base642bin(file.fileDecryptionHeader!),
|
||||
getFileKey(file),
|
||||
);
|
||||
fakeProgress?.stop();
|
||||
_logger.info('$logPrefix decryption completed');
|
||||
} catch (e, s) {
|
||||
_logger.severe("failed to decrypt file", e, s);
|
||||
fakeProgress?.stop();
|
||||
_logger.severe("Critical: $logPrefix failed to decrypt", e, s);
|
||||
return null;
|
||||
}
|
||||
_logger.info('$logPrefix decryption completed');
|
||||
await encryptedFile.delete();
|
||||
return File(decryptedFilePath);
|
||||
});
|
||||
} catch (e, s) {
|
||||
_logger.severe("$logPrefix failed to download or decrypt", e, s);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Uint8List getFileKey(EnteFile file) {
|
||||
|
|
|
@ -14,6 +14,7 @@ import 'package:photos/core/cache/thumbnail_in_memory_cache.dart';
|
|||
import 'package:photos/core/cache/video_cache_manager.dart';
|
||||
import 'package:photos/core/configuration.dart';
|
||||
import 'package:photos/core/constants.dart';
|
||||
import "package:photos/models/file/extensions/file_props.dart";
|
||||
import 'package:photos/models/file/file.dart';
|
||||
import 'package:photos/models/file/file_type.dart';
|
||||
import 'package:photos/utils/file_download_util.dart';
|
||||
|
@ -108,8 +109,21 @@ void preloadThumbnail(EnteFile file) {
|
|||
}
|
||||
}
|
||||
|
||||
final Map<String, Future<File?>> fileDownloadsInProgress =
|
||||
final Map<String, Future<File?>> _fileDownloadsInProgress =
|
||||
<String, Future<File?>>{};
|
||||
Map<String, ProgressCallback?> _progressCallbacks = {};
|
||||
|
||||
void removeCallBack(EnteFile file) {
|
||||
if (!file.isUploaded) {
|
||||
return;
|
||||
}
|
||||
String id = file.uploadedFileID.toString() + false.toString();
|
||||
_progressCallbacks.remove(id);
|
||||
if (file.isLivePhoto) {
|
||||
id = file.uploadedFileID.toString() + true.toString();
|
||||
_progressCallbacks.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
Future<File?> getFileFromServer(
|
||||
EnteFile file, {
|
||||
|
@ -124,30 +138,44 @@ Future<File?> getFileFromServer(
|
|||
return fileFromCache.file;
|
||||
}
|
||||
final downloadID = file.uploadedFileID.toString() + liveVideo.toString();
|
||||
if (!fileDownloadsInProgress.containsKey(downloadID)) {
|
||||
|
||||
if (progressCallback != null) {
|
||||
_progressCallbacks[downloadID] = progressCallback;
|
||||
}
|
||||
|
||||
if (!_fileDownloadsInProgress.containsKey(downloadID)) {
|
||||
final completer = Completer<File?>();
|
||||
_fileDownloadsInProgress[downloadID] = completer.future;
|
||||
|
||||
Future<File?> downloadFuture;
|
||||
if (file.fileType == FileType.livePhoto) {
|
||||
fileDownloadsInProgress[downloadID] = _getLivePhotoFromServer(
|
||||
downloadFuture = _getLivePhotoFromServer(
|
||||
file,
|
||||
progressCallback: progressCallback,
|
||||
progressCallback: (count, total) {
|
||||
_progressCallbacks[downloadID]?.call(count, total);
|
||||
},
|
||||
needLiveVideo: liveVideo,
|
||||
);
|
||||
fileDownloadsInProgress[downloadID]!.whenComplete(() {
|
||||
fileDownloadsInProgress.remove(downloadID);
|
||||
});
|
||||
} else {
|
||||
fileDownloadsInProgress[downloadID] = _downloadAndCache(
|
||||
downloadFuture = _downloadAndCache(
|
||||
file,
|
||||
cacheManager,
|
||||
progressCallback: progressCallback,
|
||||
progressCallback: (count, total) {
|
||||
_progressCallbacks[downloadID]?.call(count, total);
|
||||
},
|
||||
);
|
||||
fileDownloadsInProgress[downloadID]!.whenComplete(() {
|
||||
fileDownloadsInProgress.remove(downloadID);
|
||||
});
|
||||
}
|
||||
downloadFuture.then((downloadedFile) {
|
||||
completer.complete(downloadedFile);
|
||||
_fileDownloadsInProgress.remove(downloadID);
|
||||
_progressCallbacks.remove(downloadID);
|
||||
});
|
||||
}
|
||||
return fileDownloadsInProgress[downloadID];
|
||||
return _fileDownloadsInProgress[downloadID];
|
||||
}
|
||||
|
||||
|
||||
|
||||
Future<bool> isFileCached(EnteFile file, {bool liveVideo = false}) async {
|
||||
final cacheManager = (file.fileType == FileType.video || liveVideo)
|
||||
? VideoCacheManager.instance
|
||||
|
@ -237,9 +265,9 @@ Future<_LivePhoto?> _downloadLivePhoto(
|
|||
final videoFile = File(decodePath);
|
||||
await videoFile.create(recursive: true);
|
||||
await videoFile.writeAsBytes(data);
|
||||
videoFileCache = await VideoCacheManager.instance.putFile(
|
||||
videoFileCache = await VideoCacheManager.instance.putFileStream(
|
||||
file.downloadUrl,
|
||||
await videoFile.readAsBytes(),
|
||||
videoFile.openRead(),
|
||||
eTag: file.downloadUrl,
|
||||
maxAge: const Duration(days: 365),
|
||||
fileExtension: fileExtension,
|
||||
|
@ -263,7 +291,7 @@ Future<_LivePhoto?> _downloadLivePhoto(
|
|||
Future<File?> _downloadAndCache(
|
||||
EnteFile file,
|
||||
BaseCacheManager cacheManager, {
|
||||
ProgressCallback? progressCallback,
|
||||
required ProgressCallback progressCallback,
|
||||
}) async {
|
||||
return downloadAndDecrypt(file, progressCallback: progressCallback)
|
||||
.then((decryptedFile) async {
|
||||
|
@ -287,9 +315,9 @@ Future<File?> _downloadAndCache(
|
|||
}
|
||||
await decryptedFile.delete();
|
||||
}
|
||||
final cachedFile = await cacheManager.putFile(
|
||||
final cachedFile = await cacheManager.putFileStream(
|
||||
file.downloadUrl,
|
||||
await outputFile.readAsBytes(),
|
||||
outputFile.openRead(),
|
||||
eTag: file.downloadUrl,
|
||||
maxAge: const Duration(days: 365),
|
||||
fileExtension: fileExtension,
|
||||
|
|
Loading…
Reference in a new issue