1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-27 10:58:13 +02:00

feat(mobile): enhance download operations (#12973)

* add packages

* create download task

* show progress

* save video and image

* show progress info

* live photo wip

* download and link live photos

* Update list of assets

* wip

* correct progress

* add state to download

* revert unncessary change

* repository pattern

* translation

* remove unused code

* update method call from repository

* remove unused variable

* handle multiple livephotos download

* remove logging statement

* lint

* not removing all records
This commit is contained in:
Alex 2024-09-29 15:22:02 +07:00 committed by GitHub
parent 2bcd27e166
commit fa9bb8074c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 868 additions and 285 deletions

View File

@ -588,5 +588,16 @@
"version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89",
"viewer_remove_from_stack": "Remove from Stack",
"viewer_stack_use_as_main_asset": "Use as Main Asset",
"viewer_unstack": "Un-Stack"
"viewer_unstack": "Un-Stack",
"downloading_media": "Downloading media",
"download_finished": "Download finished",
"download_filename": "file: {}",
"downloading": "Downloading...",
"download_complete": "Download complete",
"download_failed": "Download failed",
"download_canceled": "Download canceled",
"download_paused": "Download paused",
"download_enqueue": "Download enqueued",
"download_notfound": "Download not found",
"download_waiting_to_retry": "Waiting to retry"
}

View File

@ -1,4 +1,6 @@
PODS:
- background_downloader (0.0.1):
- Flutter
- connectivity_plus (0.0.1):
- Flutter
- ReachabilitySwift
@ -99,6 +101,7 @@ PODS:
- Flutter
DEPENDENCIES:
- background_downloader (from `.symlinks/plugins/background_downloader/ios`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`)
@ -137,6 +140,8 @@ SPEC REPOS:
- Toast
EXTERNAL SOURCES:
background_downloader:
:path: ".symlinks/plugins/background_downloader/ios"
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
device_info_plus:
@ -189,6 +194,7 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/wakelock_plus/ios"
SPEC CHECKSUMS:
background_downloader: 9f788ffc5de45acf87d6380e91ca0841066c18cf
connectivity_plus: bf0076dd84a130856aa636df1c71ccaff908fa1d
device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c

View File

@ -0,0 +1,14 @@
import 'package:background_downloader/background_downloader.dart';
abstract interface class IDownloadRepository {
void Function(TaskStatusUpdate)? onImageDownloadStatus;
void Function(TaskStatusUpdate)? onVideoDownloadStatus;
void Function(TaskStatusUpdate)? onLivePhotoDownloadStatus;
void Function(TaskProgressUpdate)? onTaskProgress;
Future<List<TaskRecord>> getLiveVideoTasks();
Future<bool> download(DownloadTask task);
Future<bool> cancel(String id);
Future<void> deleteAllTrackingRecords();
Future<void> deleteRecordsWithIds(List<String> id);
}

View File

@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:io';
import 'package:background_downloader/background_downloader.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
@ -9,6 +10,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_displaymode/flutter_displaymode.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/utils/download.dart';
import 'package:timezone/data/latest.dart';
import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/services/background.service.dart';
@ -72,7 +74,6 @@ Future<void> initApp() async {
var log = Logger("ImmichErrorLogger");
FlutterError.onError = (details) {
debugPrint("FlutterError - Catch all: $details");
FlutterError.presentError(details);
log.severe(
'FlutterError - Catch all',
@ -82,11 +83,29 @@ Future<void> initApp() async {
};
PlatformDispatcher.instance.onError = (error, stack) {
debugPrint("FlutterError - Catch all: $error");
log.severe('PlatformDispatcher - Catch all', error, stack);
return true;
};
initializeTimeZones();
FileDownloader().configureNotification(
running: TaskNotification(
'downloading_media'.tr(),
'file: {filename}',
),
complete: TaskNotification(
'download_finished'.tr(),
'file: {filename}',
),
progressBar: true,
);
FileDownloader().trackTasksInGroup(
downloadGroupLivePhoto,
markDownloadedComplete: false,
);
}
Future<Isar> loadDb() async {
@ -188,8 +207,8 @@ class ImmichAppState extends ConsumerState<ImmichApp>
@override
Widget build(BuildContext context) {
var router = ref.watch(appRouterProvider);
var immichTheme = ref.watch(immichThemeProvider);
final router = ref.watch(appRouterProvider);
final immichTheme = ref.watch(immichThemeProvider);
return MaterialApp(
localizationsDelegates: context.localizationDelegates,

View File

@ -1,55 +0,0 @@
import 'dart:convert';
enum DownloadAssetStatus { idle, loading, success, error }
class AssetViewerPageState {
// enum
final DownloadAssetStatus downloadAssetStatus;
AssetViewerPageState({
required this.downloadAssetStatus,
});
AssetViewerPageState copyWith({
DownloadAssetStatus? downloadAssetStatus,
}) {
return AssetViewerPageState(
downloadAssetStatus: downloadAssetStatus ?? this.downloadAssetStatus,
);
}
Map<String, dynamic> toMap() {
final result = <String, dynamic>{};
result.addAll({'downloadAssetStatus': downloadAssetStatus.index});
return result;
}
factory AssetViewerPageState.fromMap(Map<String, dynamic> map) {
return AssetViewerPageState(
downloadAssetStatus:
DownloadAssetStatus.values[map['downloadAssetStatus'] ?? 0],
);
}
String toJson() => json.encode(toMap());
factory AssetViewerPageState.fromJson(String source) =>
AssetViewerPageState.fromMap(json.decode(source));
@override
String toString() =>
'ImageViewerPageState(downloadAssetStatus: $downloadAssetStatus)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is AssetViewerPageState &&
other.downloadAssetStatus == downloadAssetStatus;
}
@override
int get hashCode => downloadAssetStatus.hashCode;
}

View File

@ -0,0 +1,109 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:convert';
import 'package:background_downloader/background_downloader.dart';
import 'package:collection/collection.dart';
class DownloadInfo {
final String fileName;
final double progress;
// enum
final TaskStatus status;
DownloadInfo({
required this.fileName,
required this.progress,
required this.status,
});
DownloadInfo copyWith({
String? fileName,
double? progress,
TaskStatus? status,
}) {
return DownloadInfo(
fileName: fileName ?? this.fileName,
progress: progress ?? this.progress,
status: status ?? this.status,
);
}
Map<String, dynamic> toMap() {
return <String, dynamic>{
'fileName': fileName,
'progress': progress,
'status': status.index,
};
}
factory DownloadInfo.fromMap(Map<String, dynamic> map) {
return DownloadInfo(
fileName: map['fileName'] as String,
progress: map['progress'] as double,
status: TaskStatus.values[map['status'] as int],
);
}
String toJson() => json.encode(toMap());
factory DownloadInfo.fromJson(String source) =>
DownloadInfo.fromMap(json.decode(source) as Map<String, dynamic>);
@override
String toString() =>
'DownloadInfo(fileName: $fileName, progress: $progress, status: $status)';
@override
bool operator ==(covariant DownloadInfo other) {
if (identical(this, other)) return true;
return other.fileName == fileName &&
other.progress == progress &&
other.status == status;
}
@override
int get hashCode => fileName.hashCode ^ progress.hashCode ^ status.hashCode;
}
class DownloadState {
// enum
final TaskStatus downloadStatus;
final Map<String, DownloadInfo> taskProgress;
final bool showProgress;
DownloadState({
required this.downloadStatus,
required this.taskProgress,
required this.showProgress,
});
DownloadState copyWith({
TaskStatus? downloadStatus,
Map<String, DownloadInfo>? taskProgress,
bool? showProgress,
}) {
return DownloadState(
downloadStatus: downloadStatus ?? this.downloadStatus,
taskProgress: taskProgress ?? this.taskProgress,
showProgress: showProgress ?? this.showProgress,
);
}
@override
String toString() =>
'DownloadState(downloadStatus: $downloadStatus, taskProgress: $taskProgress, showProgress: $showProgress)';
@override
bool operator ==(covariant DownloadState other) {
if (identical(this, other)) return true;
final mapEquals = const DeepCollectionEquality().equals;
return other.downloadStatus == downloadStatus &&
mapEquals(other.taskProgress, taskProgress) &&
other.showProgress == showProgress;
}
@override
int get hashCode =>
downloadStatus.hashCode ^ taskProgress.hashCode ^ showProgress.hashCode;
}

View File

@ -0,0 +1,60 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:convert';
enum LivePhotosPart {
video,
image,
}
class LivePhotosMetadata {
// enum
LivePhotosPart part;
String id;
LivePhotosMetadata({
required this.part,
required this.id,
});
LivePhotosMetadata copyWith({
LivePhotosPart? part,
String? id,
}) {
return LivePhotosMetadata(
part: part ?? this.part,
id: id ?? this.id,
);
}
Map<String, dynamic> toMap() {
return <String, dynamic>{
'part': part.index,
'id': id,
};
}
factory LivePhotosMetadata.fromMap(Map<String, dynamic> map) {
return LivePhotosMetadata(
part: LivePhotosPart.values[map['part'] as int],
id: map['id'] as String,
);
}
String toJson() => json.encode(toMap());
factory LivePhotosMetadata.fromJson(String source) =>
LivePhotosMetadata.fromMap(json.decode(source) as Map<String, dynamic>);
@override
String toString() => 'LivePhotosMetadata(part: $part, id: $id)';
@override
bool operator ==(covariant LivePhotosMetadata other) {
if (identical(this, other)) return true;
return other.part == part && other.id == id;
}
@override
int get hashCode => part.hashCode ^ id.hashCode;
}

View File

@ -0,0 +1,150 @@
import 'package:background_downloader/background_downloader.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/asset_viewer/download.provider.dart';
class DownloadPanel extends ConsumerWidget {
const DownloadPanel({
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final showProgress = ref.watch(
downloadStateProvider.select((state) => state.showProgress),
);
final tasks = ref
.watch(
downloadStateProvider.select((state) => state.taskProgress),
)
.entries
.toList();
onCancelDownload(String id) {
ref.watch(downloadStateProvider.notifier).cancelDownload(id);
}
return Positioned(
bottom: 140,
left: 16,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: showProgress
? ConstrainedBox(
constraints:
BoxConstraints.loose(Size(context.width - 32, 300)),
child: ListView.builder(
shrinkWrap: true,
itemCount: tasks.length,
itemBuilder: (context, index) {
final task = tasks[index];
return DownloadTaskTile(
progress: task.value.progress,
fileName: task.value.fileName,
status: task.value.status,
onCancelDownload: () => onCancelDownload(task.key),
);
},
),
)
: const SizedBox.shrink(key: ValueKey('no_progress')),
),
);
}
}
class DownloadTaskTile extends StatelessWidget {
final double progress;
final String fileName;
final TaskStatus status;
final VoidCallback onCancelDownload;
const DownloadTaskTile({
super.key,
required this.progress,
required this.fileName,
required this.status,
required this.onCancelDownload,
});
@override
Widget build(BuildContext context) {
final progressPercent = (progress * 100).round();
getStatusText() {
switch (status) {
case TaskStatus.running:
return 'downloading'.tr();
case TaskStatus.complete:
return 'download_complete'.tr();
case TaskStatus.failed:
return 'download_failed'.tr();
case TaskStatus.canceled:
return 'download_canceled'.tr();
case TaskStatus.paused:
return 'download_paused'.tr();
case TaskStatus.enqueued:
return 'download_enqueue'.tr();
case TaskStatus.notFound:
return 'download_notfound'.tr();
case TaskStatus.waitingToRetry:
return 'download_waiting_to_retry'.tr();
}
}
return SizedBox(
key: const ValueKey('download_progress'),
width: MediaQuery.of(context).size.width - 32,
child: Card(
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: ListTile(
minVerticalPadding: 18,
leading: const Icon(Icons.video_file_outlined),
title: Text(
getStatusText(),
style: context.textTheme.labelLarge,
),
trailing: IconButton(
icon: Icon(Icons.close, color: context.colorScheme.onError),
onPressed: onCancelDownload,
style: ElevatedButton.styleFrom(
backgroundColor: context.colorScheme.error.withAlpha(200),
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
fileName,
style: context.textTheme.labelMedium,
),
Row(
children: [
Expanded(
child: LinearProgressIndicator(
minHeight: 8.0,
value: progress,
borderRadius:
const BorderRadius.all(Radius.circular(10.0)),
),
),
const SizedBox(width: 8),
Text(
'$progressPercent%',
style: context.textTheme.labelSmall,
),
],
),
],
),
),
),
);
}
}

View File

@ -11,6 +11,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/pages/common/download_panel.dart';
import 'package:immich_mobile/pages/common/video_viewer.page.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
@ -421,6 +422,7 @@ class GalleryViewerPage extends HookConsumerWidget {
],
),
),
const DownloadPanel(),
],
),
),

View File

@ -0,0 +1,191 @@
import 'package:background_downloader/background_downloader.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/download/download_state.model.dart';
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
import 'package:immich_mobile/services/download.service.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/services/share.service.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/common/share_dialog.dart';
class DownloadStateNotifier extends StateNotifier<DownloadState> {
final DownloadService _downloadService;
final ShareService _shareService;
DownloadStateNotifier(
this._downloadService,
this._shareService,
) : super(
DownloadState(
downloadStatus: TaskStatus.complete,
showProgress: false,
taskProgress: <String, DownloadInfo>{},
),
) {
_downloadService.onImageDownloadStatus = _downloadImageCallback;
_downloadService.onVideoDownloadStatus = _downloadVideoCallback;
_downloadService.onLivePhotoDownloadStatus = _downloadLivePhotoCallback;
_downloadService.onTaskProgress = _taskProgressCallback;
}
void _updateDownloadStatus(String taskId, TaskStatus status) {
if (status == TaskStatus.canceled) {
return;
}
state = state.copyWith(
taskProgress: <String, DownloadInfo>{}
..addAll(state.taskProgress)
..addAll({
taskId: DownloadInfo(
progress: state.taskProgress[taskId]?.progress ?? 0,
fileName: state.taskProgress[taskId]?.fileName ?? '',
status: status,
),
}),
);
}
// Download live photo callback
void _downloadLivePhotoCallback(TaskStatusUpdate update) {
_updateDownloadStatus(update.task.taskId, update.status);
switch (update.status) {
case TaskStatus.complete:
if (update.task.metaData.isEmpty) {
return;
}
final livePhotosId =
LivePhotosMetadata.fromJson(update.task.metaData).id;
_downloadService.saveLivePhotos(update.task, livePhotosId);
_onDownloadComplete(update.task.taskId);
break;
default:
break;
}
}
// Download image callback
void _downloadImageCallback(TaskStatusUpdate update) {
_updateDownloadStatus(update.task.taskId, update.status);
switch (update.status) {
case TaskStatus.complete:
_downloadService.saveImage(update.task);
_onDownloadComplete(update.task.taskId);
break;
default:
break;
}
}
// Download video callback
void _downloadVideoCallback(TaskStatusUpdate update) {
_updateDownloadStatus(update.task.taskId, update.status);
switch (update.status) {
case TaskStatus.complete:
_downloadService.saveVideo(update.task);
_onDownloadComplete(update.task.taskId);
break;
default:
break;
}
}
void _taskProgressCallback(TaskProgressUpdate update) {
// Ignore if the task is cancled or completed
if (update.progress == -2 || update.progress == -1) {
return;
}
state = state.copyWith(
showProgress: true,
taskProgress: <String, DownloadInfo>{}
..addAll(state.taskProgress)
..addAll({
update.task.taskId: DownloadInfo(
progress: update.progress,
fileName: update.task.filename,
status: TaskStatus.running,
),
}),
);
}
void _onDownloadComplete(String id) {
Future.delayed(const Duration(seconds: 2), () {
state = state.copyWith(
taskProgress: <String, DownloadInfo>{}
..addAll(state.taskProgress)
..remove(id),
);
if (state.taskProgress.isEmpty) {
state = state.copyWith(
showProgress: false,
);
}
});
}
void downloadAsset(Asset asset, BuildContext context) async {
await _downloadService.download(asset);
}
void cancelDownload(String id) async {
final isCanceled = await _downloadService.cancelDownload(id);
if (isCanceled) {
state = state.copyWith(
taskProgress: <String, DownloadInfo>{}
..addAll(state.taskProgress)
..remove(id),
);
}
if (state.taskProgress.isEmpty) {
state = state.copyWith(
showProgress: false,
);
}
}
void shareAsset(Asset asset, BuildContext context) async {
showDialog(
context: context,
builder: (BuildContext buildContext) {
_shareService.shareAsset(asset, context).then(
(bool status) {
if (!status) {
ImmichToast.show(
context: context,
msg: 'image_viewer_page_state_provider_share_error'.tr(),
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
}
buildContext.pop();
},
);
return const ShareDialog();
},
barrierDismissible: false,
);
}
}
final downloadStateProvider =
StateNotifierProvider<DownloadStateNotifier, DownloadState>(
((ref) => DownloadStateNotifier(
ref.watch(downloadServiceProvider),
ref.watch(shareServiceProvider),
)),
);

View File

@ -1,99 +0,0 @@
import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/services/album.service.dart';
import 'package:immich_mobile/models/asset_viewer/asset_viewer_page_state.model.dart';
import 'package:immich_mobile/services/image_viewer.service.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/services/share.service.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/common/share_dialog.dart';
class ImageViewerStateNotifier extends StateNotifier<AssetViewerPageState> {
final ImageViewerService _imageViewerService;
final ShareService _shareService;
final AlbumService _albumService;
ImageViewerStateNotifier(
this._imageViewerService,
this._shareService,
this._albumService,
) : super(
AssetViewerPageState(
downloadAssetStatus: DownloadAssetStatus.idle,
),
);
void downloadAsset(Asset asset, BuildContext context) async {
state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.loading);
ImmichToast.show(
context: context,
msg: 'download_started'.tr(),
toastType: ToastType.info,
gravity: ToastGravity.BOTTOM,
);
bool isSuccess = await _imageViewerService.downloadAsset(asset);
if (isSuccess) {
state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.success);
ImmichToast.show(
context: context,
msg: Platform.isAndroid
? 'download_sucess_android'.tr()
: 'download_sucess'.tr(),
toastType: ToastType.success,
gravity: ToastGravity.BOTTOM,
);
_albumService.refreshDeviceAlbums();
} else {
state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.error);
ImmichToast.show(
context: context,
msg: 'download_error'.tr(),
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
}
state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.idle);
}
void shareAsset(Asset asset, BuildContext context) async {
showDialog(
context: context,
builder: (BuildContext buildContext) {
_shareService.shareAsset(asset, context).then(
(bool status) {
if (!status) {
ImmichToast.show(
context: context,
msg: 'image_viewer_page_state_provider_share_error'.tr(),
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
}
buildContext.pop();
},
);
return const ShareDialog();
},
barrierDismissible: false,
);
}
}
final imageViewerStateProvider =
StateNotifierProvider<ImageViewerStateNotifier, AssetViewerPageState>(
((ref) => ImageViewerStateNotifier(
ref.watch(imageViewerServiceProvider),
ref.watch(shareServiceProvider),
ref.watch(albumServiceProvider),
)),
);

View File

@ -0,0 +1,68 @@
import 'package:background_downloader/background_downloader.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/interfaces/download.interface.dart';
import 'package:immich_mobile/utils/download.dart';
final downloadRepositoryProvider = Provider((ref) => DownloadRepository());
class DownloadRepository implements IDownloadRepository {
@override
void Function(TaskStatusUpdate)? onImageDownloadStatus;
@override
void Function(TaskStatusUpdate)? onVideoDownloadStatus;
@override
void Function(TaskStatusUpdate)? onLivePhotoDownloadStatus;
@override
void Function(TaskProgressUpdate)? onTaskProgress;
DownloadRepository() {
FileDownloader().registerCallbacks(
group: downloadGroupImage,
taskStatusCallback: (update) => onImageDownloadStatus?.call(update),
taskProgressCallback: (update) => onTaskProgress?.call(update),
);
FileDownloader().registerCallbacks(
group: downloadGroupVideo,
taskStatusCallback: (update) => onVideoDownloadStatus?.call(update),
taskProgressCallback: (update) => onTaskProgress?.call(update),
);
FileDownloader().registerCallbacks(
group: downloadGroupLivePhoto,
taskStatusCallback: (update) => onLivePhotoDownloadStatus?.call(update),
taskProgressCallback: (update) => onTaskProgress?.call(update),
);
}
@override
Future<bool> download(DownloadTask task) {
return FileDownloader().enqueue(task);
}
@override
Future<void> deleteAllTrackingRecords() {
return FileDownloader().database.deleteAllRecords();
}
@override
Future<bool> cancel(String id) {
return FileDownloader().cancelTaskWithId(id);
}
@override
Future<List<TaskRecord>> getLiveVideoTasks() {
return FileDownloader().database.allRecordsWithStatus(
TaskStatus.complete,
group: downloadGroupLivePhoto,
);
}
@override
Future<void> deleteRecordsWithIds(List<String> ids) {
return FileDownloader().database.deleteRecordsWithIds(ids);
}
}

View File

@ -0,0 +1,193 @@
import 'dart:io';
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/interfaces/download.interface.dart';
import 'package:immich_mobile/interfaces/file_media.interface.dart';
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
import 'package:immich_mobile/repositories/download.repository.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/download.dart';
final downloadServiceProvider = Provider(
(ref) => DownloadService(
ref.watch(fileMediaRepositoryProvider),
ref.watch(downloadRepositoryProvider),
),
);
class DownloadService {
final IDownloadRepository _downloadRepository;
final IFileMediaRepository _fileMediaRepository;
void Function(TaskStatusUpdate)? onImageDownloadStatus;
void Function(TaskStatusUpdate)? onVideoDownloadStatus;
void Function(TaskStatusUpdate)? onLivePhotoDownloadStatus;
void Function(TaskProgressUpdate)? onTaskProgress;
DownloadService(
this._fileMediaRepository,
this._downloadRepository,
) {
_downloadRepository.onImageDownloadStatus = _onImageDownloadCallback;
_downloadRepository.onVideoDownloadStatus = _onVideoDownloadCallback;
_downloadRepository.onLivePhotoDownloadStatus =
_onLivePhotoDownloadCallback;
_downloadRepository.onTaskProgress = _onTaskProgressCallback;
}
void _onTaskProgressCallback(TaskProgressUpdate update) {
onTaskProgress?.call(update);
}
void _onImageDownloadCallback(TaskStatusUpdate update) {
onImageDownloadStatus?.call(update);
}
void _onVideoDownloadCallback(TaskStatusUpdate update) {
onVideoDownloadStatus?.call(update);
}
void _onLivePhotoDownloadCallback(TaskStatusUpdate update) {
onLivePhotoDownloadStatus?.call(update);
}
Future<bool> saveImage(Task task) async {
final filePath = await task.filePath();
final title = task.filename;
final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null;
final data = await File(filePath).readAsBytes();
final Asset? resultAsset = await _fileMediaRepository.saveImage(
data,
title: title,
relativePath: relativePath,
);
return resultAsset != null;
}
Future<bool> saveVideo(Task task) async {
final filePath = await task.filePath();
final title = task.filename;
final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null;
final file = File(filePath);
final Asset? resultAsset = await _fileMediaRepository.saveVideo(
file,
title: title,
relativePath: relativePath,
);
return resultAsset != null;
}
Future<bool> saveLivePhotos(
Task task,
String livePhotosId,
) async {
try {
final records = await _downloadRepository.getLiveVideoTasks();
if (records.length < 2) {
return false;
}
final imageRecord = records.firstWhere(
(record) {
final metadata = LivePhotosMetadata.fromJson(record.task.metaData);
return metadata.id == livePhotosId &&
metadata.part == LivePhotosPart.image;
},
);
final videoRecord = records.firstWhere((record) {
final metadata = LivePhotosMetadata.fromJson(record.task.metaData);
return metadata.id == livePhotosId &&
metadata.part == LivePhotosPart.video;
});
final imageFilePath = await imageRecord.task.filePath();
final videoFilePath = await videoRecord.task.filePath();
final resultAsset = await _fileMediaRepository.saveLivePhoto(
image: File(imageFilePath),
video: File(videoFilePath),
title: task.filename,
);
await _downloadRepository.deleteRecordsWithIds([
imageRecord.task.taskId,
videoRecord.task.taskId,
]);
return resultAsset != null;
} catch (error) {
debugPrint("Error saving live photo: $error");
return false;
}
}
Future<bool> cancelDownload(String id) async {
return await FileDownloader().cancelTaskWithId(id);
}
Future<void> download(Asset asset) async {
if (asset.isImage && asset.livePhotoVideoId != null && Platform.isIOS) {
await _downloadRepository.download(
_buildDownloadTask(
asset.remoteId!,
asset.fileName,
group: downloadGroupLivePhoto,
metadata: LivePhotosMetadata(
part: LivePhotosPart.image,
id: asset.remoteId!,
).toJson(),
),
);
await _downloadRepository.download(
_buildDownloadTask(
asset.livePhotoVideoId!,
asset.fileName.toUpperCase().replaceAll(".HEIC", '.MOV'),
group: downloadGroupLivePhoto,
metadata: LivePhotosMetadata(
part: LivePhotosPart.video,
id: asset.remoteId!,
).toJson(),
),
);
} else {
await _downloadRepository.download(
_buildDownloadTask(
asset.remoteId!,
asset.fileName,
group: asset.isImage ? downloadGroupImage : downloadGroupVideo,
),
);
}
}
DownloadTask _buildDownloadTask(
String id,
String filename, {
String? group,
String? metadata,
}) {
final path = r'/assets/{id}/original'.replaceAll('{id}', id);
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final headers = ApiService.getRequestHeaders();
return DownloadTask(
taskId: id,
url: serverEndpoint + path,
headers: headers,
filename: filename,
updates: Updates.statusAndProgress,
group: group ?? '',
metaData: metadata ?? '',
);
}
}

View File

@ -1,117 +0,0 @@
import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/response_extensions.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/interfaces/file_media.interface.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart';
final imageViewerServiceProvider = Provider(
(ref) => ImageViewerService(
ref.watch(apiServiceProvider),
ref.watch(fileMediaRepositoryProvider),
),
);
class ImageViewerService {
final ApiService _apiService;
final IFileMediaRepository _fileMediaRepository;
final Logger _log = Logger("ImageViewerService");
ImageViewerService(this._apiService, this._fileMediaRepository);
Future<bool> downloadAsset(Asset asset) async {
File? imageFile;
File? videoFile;
try {
// Download LivePhotos image and motion part
if (asset.isImage && asset.livePhotoVideoId != null && Platform.isIOS) {
var imageResponse =
await _apiService.assetsApi.downloadAssetWithHttpInfo(
asset.remoteId!,
);
var motionResponse =
await _apiService.assetsApi.downloadAssetWithHttpInfo(
asset.livePhotoVideoId!,
);
if (imageResponse.statusCode != 200 ||
motionResponse.statusCode != 200) {
final failedResponse =
imageResponse.statusCode != 200 ? imageResponse : motionResponse;
_log.severe(
"Motion asset download failed",
failedResponse.toLoggerString(),
);
return false;
}
Asset? resultAsset;
final tempDir = await getTemporaryDirectory();
videoFile = await File('${tempDir.path}/livephoto.mov').create();
imageFile = await File('${tempDir.path}/livephoto.heic').create();
videoFile.writeAsBytesSync(motionResponse.bodyBytes);
imageFile.writeAsBytesSync(imageResponse.bodyBytes);
resultAsset = await _fileMediaRepository.saveLivePhoto(
image: imageFile,
video: videoFile,
title: asset.fileName,
);
if (resultAsset == null) {
_log.warning(
"Asset cannot be saved as a live photo. This is most likely a motion photo. Saving only the image file",
);
resultAsset = await _fileMediaRepository
.saveImage(imageResponse.bodyBytes, title: asset.fileName);
}
return resultAsset != null;
} else {
var res = await _apiService.assetsApi
.downloadAssetWithHttpInfo(asset.remoteId!);
if (res.statusCode != 200) {
_log.severe("Asset download failed", res.toLoggerString());
return false;
}
final Asset? resultAsset;
final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null;
if (asset.isImage) {
resultAsset = await _fileMediaRepository.saveImage(
res.bodyBytes,
title: asset.fileName,
relativePath: relativePath,
);
} else {
final tempDir = await getTemporaryDirectory();
videoFile = await File('${tempDir.path}/${asset.fileName}').create();
videoFile.writeAsBytesSync(res.bodyBytes);
resultAsset = await _fileMediaRepository.saveVideo(
videoFile,
title: asset.fileName,
relativePath: relativePath,
);
}
return resultAsset != null;
}
} catch (error, stack) {
_log.severe("Error saving downloaded asset", error, stack);
return false;
} finally {
// Clear temp files
imageFile?.delete();
videoFile?.delete();
}
}
}

View File

@ -0,0 +1,3 @@
const downloadGroupImage = 'group_image';
const downloadGroupVideo = 'group_video';
const downloadGroupLivePhoto = 'group_livephoto';

View File

@ -9,7 +9,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/album/current_album.provider.dart';
import 'package:immich_mobile/providers/album/shared_album.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/image_viewer_page_state.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/download.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
import 'package:immich_mobile/services/stack.service.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
@ -172,7 +172,16 @@ class BottomGalleryBar extends ConsumerWidget {
}
shareAsset() {
ref.read(imageViewerStateProvider.notifier).shareAsset(asset, context);
if (asset.isOffline) {
ImmichToast.show(
durationInSecond: 1,
context: context,
msg: 'asset_action_share_err_offline'.tr(),
gravity: ToastGravity.BOTTOM,
);
return;
}
ref.read(downloadStateProvider.notifier).shareAsset(asset, context);
}
void handleEdit() async {
@ -202,7 +211,17 @@ class BottomGalleryBar extends ConsumerWidget {
if (asset.isLocal) {
return;
}
ref.read(imageViewerStateProvider.notifier).downloadAsset(
if (asset.isOffline) {
ImmichToast.show(
durationInSecond: 1,
context: context,
msg: 'asset_action_share_err_offline'.tr(),
gravity: ToastGravity.BOTTOM,
);
return;
}
ref.read(downloadStateProvider.notifier).downloadAsset(
asset,
context,
);

View File

@ -5,7 +5,7 @@ import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/album/current_album.provider.dart';
import 'package:immich_mobile/widgets/album/add_to_album_bottom_sheet.dart';
import 'package:immich_mobile/providers/asset_viewer/image_viewer_page_state.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/download.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
import 'package:immich_mobile/widgets/asset_viewer/top_control_app_bar.dart';
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
@ -94,7 +94,7 @@ class GalleryAppBar extends ConsumerWidget {
}
handleDownloadAsset() {
ref.read(imageViewerStateProvider.notifier).downloadAsset(asset, context);
ref.read(downloadStateProvider.notifier).downloadAsset(asset, context);
}
return IgnorePointer(

View File

@ -176,7 +176,7 @@ class LoginForm extends HookConsumerWidget {
populateTestLoginInfo1() {
usernameController.text = 'testuser@email.com';
passwordController.text = 'password';
serverEndpointController.text = 'http://10.1.15.216:2283/api';
serverEndpointController.text = 'http://192.168.1.16:2283/api';
}
login() async {

View File

@ -78,6 +78,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "9.0.0"
background_downloader:
dependency: "direct main"
description:
name: background_downloader
sha256: "6a945db1a1c7727a4bc9c1d7c882cfb1a819f873b77e01d5e5dd6a3fb231cb28"
url: "https://pub.dev"
source: hosted
version: "8.5.5"
boolean_selector:
dependency: transitive
description:
@ -744,10 +752,10 @@ packages:
dependency: "direct main"
description:
name: http
sha256: "5895291c13fa8a3bd82e76d5627f69e0d85ca6a30dcac95c4ea19a5d555879c2"
sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010
url: "https://pub.dev"
source: hosted
version: "0.13.6"
version: "1.2.2"
http_multi_server:
dependency: transitive
description:

View File

@ -32,7 +32,7 @@ dependencies:
flutter_svg: ^2.0.9
package_info_plus: ^8.0.1
url_launcher: ^6.2.4
http: ^0.13.6
http: ^1.1.0
cancellation_token_http: ^2.0.0
easy_localization: ^3.0.3
share_plus: ^10.0.0
@ -56,6 +56,7 @@ dependencies:
thumbhash: 0.1.0+1
async: ^2.11.0
dynamic_color: ^1.7.0 #package to apply system theme
background_downloader: ^8.5.5
#image editing packages
crop_image: ^1.0.13