mirror of
https://github.com/immich-app/immich.git
synced 2026-06-03 05:15:20 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b4d4b8c2d |
@@ -2141,6 +2141,8 @@
|
||||
"share_assets_selected": "{count} selected",
|
||||
"share_dialog_preparing": "Preparing...",
|
||||
"share_link": "Share Link",
|
||||
"share_original": "Share original",
|
||||
"share_preview": "Share preview",
|
||||
"shared": "Shared",
|
||||
"shared_album_activities_input_disable": "Comment is disabled",
|
||||
"shared_album_activity_remove_content": "Do you want to delete this activity?",
|
||||
|
||||
@@ -13,6 +13,8 @@ enum AssetVisibilityEnum { timeline, hidden, archive, locked }
|
||||
|
||||
enum ActionSource { timeline, viewer }
|
||||
|
||||
enum ShareAssetFileType { original, preview }
|
||||
|
||||
enum CleanupStep { selectDate, scan, delete }
|
||||
|
||||
enum AssetKeepType { none, photosOnly, videosOnly }
|
||||
|
||||
@@ -48,6 +48,33 @@ class _SharePreparingDialog extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ShareFileTypeDialog extends StatelessWidget {
|
||||
const _ShareFileTypeDialog();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text('share'.t(context: context)),
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 8),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.high_quality_rounded),
|
||||
title: Text('share_original'.t(context: context)),
|
||||
onTap: () => context.pop(ShareAssetFileType.original),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_size_select_large_rounded),
|
||||
title: Text('share_preview'.t(context: context)),
|
||||
onTap: () => context.pop(ShareAssetFileType.preview),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ShareActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
final bool iconOnly;
|
||||
@@ -60,6 +87,15 @@ class ShareActionButton extends ConsumerWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
final fileType = await showDialog<ShareAssetFileType>(
|
||||
context: context,
|
||||
builder: (_) => const _ShareFileTypeDialog(),
|
||||
useRootNavigator: false,
|
||||
);
|
||||
if (fileType == null || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final cancelCompleter = Completer<void>();
|
||||
final progress = ValueNotifier<double?>(null);
|
||||
final preparingDialog = _SharePreparingDialog(progress: progress);
|
||||
@@ -71,6 +107,7 @@ class ShareActionButton extends ConsumerWidget {
|
||||
.shareAssets(
|
||||
source,
|
||||
context,
|
||||
fileType: fileType,
|
||||
cancelCompleter: cancelCompleter,
|
||||
onAssetDownloadProgress: (value) => progress.value = value,
|
||||
)
|
||||
|
||||
@@ -513,19 +513,21 @@ class ActionNotifier extends Notifier<void> {
|
||||
Future<ActionResult> shareAssets(
|
||||
ActionSource source,
|
||||
BuildContext context, {
|
||||
ShareAssetFileType fileType = ShareAssetFileType.original,
|
||||
Completer<void>? cancelCompleter,
|
||||
void Function(double progress)? onAssetDownloadProgress,
|
||||
}) async {
|
||||
final ids = _getAssets(source).toList(growable: false);
|
||||
|
||||
try {
|
||||
await _service.shareAssets(
|
||||
final count = await _service.shareAssets(
|
||||
ids,
|
||||
context,
|
||||
fileType: fileType,
|
||||
cancelCompleter: cancelCompleter,
|
||||
onAssetDownloadProgress: onAssetDownloadProgress,
|
||||
);
|
||||
return ActionResult(count: ids.length, success: true);
|
||||
return ActionResult(count: count, success: count > 0 || ids.isEmpty);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to share assets', error, stack);
|
||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
@@ -14,6 +15,9 @@ import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
@@ -22,6 +26,8 @@ final assetMediaRepositoryProvider = Provider((ref) => AssetMediaRepository(ref.
|
||||
class AssetMediaRepository {
|
||||
final NativeSyncApi _nativeSyncApi;
|
||||
static final Logger _log = Logger("AssetMediaRepository");
|
||||
static const int _localPreviewMaxDimension = 1440;
|
||||
static const int _localPreviewQuality = 90;
|
||||
|
||||
const AssetMediaRepository(this._nativeSyncApi);
|
||||
|
||||
@@ -105,9 +111,210 @@ class AssetMediaRepository {
|
||||
);
|
||||
}
|
||||
|
||||
String? _getLocalId(BaseAsset asset) {
|
||||
if (asset is LocalAsset) {
|
||||
return asset.id;
|
||||
}
|
||||
if (asset is RemoteAsset) {
|
||||
return asset.localId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _getRemoteId(BaseAsset asset) {
|
||||
if (asset is RemoteAsset) {
|
||||
return asset.id;
|
||||
}
|
||||
return asset.remoteId;
|
||||
}
|
||||
|
||||
String _sanitizeFilename(String filename) {
|
||||
return filename.replaceAll(RegExp(r'[\\/]'), '_');
|
||||
}
|
||||
|
||||
String _getPreviewFilename(BaseAsset asset) {
|
||||
final sanitizedFilename = _sanitizeFilename(asset.name);
|
||||
final baseName = p.basenameWithoutExtension(sanitizedFilename);
|
||||
final fallbackName = asset.remoteId ?? asset.localId ?? 'asset';
|
||||
return '${baseName.isEmpty ? fallbackName : baseName}-preview.jpg';
|
||||
}
|
||||
|
||||
ThumbnailSize _getLocalPreviewSize(BaseAsset asset) {
|
||||
final width = asset.width;
|
||||
final height = asset.height;
|
||||
if (width == null || height == null || width <= 0 || height <= 0) {
|
||||
return const ThumbnailSize.square(_localPreviewMaxDimension);
|
||||
}
|
||||
|
||||
if (width >= height) {
|
||||
final scaledHeight = (height * _localPreviewMaxDimension / width).round();
|
||||
return ThumbnailSize(_localPreviewMaxDimension, scaledHeight < 1 ? 1 : scaledHeight);
|
||||
}
|
||||
|
||||
final scaledWidth = (width * _localPreviewMaxDimension / height).round();
|
||||
return ThumbnailSize(scaledWidth < 1 ? 1 : scaledWidth, _localPreviewMaxDimension);
|
||||
}
|
||||
|
||||
Future<({File file, bool cleanup})?> _getLocalOriginalShareFile(BaseAsset asset, String localId) async {
|
||||
final file = await AssetEntity(
|
||||
id: localId,
|
||||
width: asset.width ?? 1,
|
||||
height: asset.height ?? 1,
|
||||
typeInt: asset.type.index,
|
||||
).originFile;
|
||||
if (file == null) {
|
||||
_log.warning("Local original file not found for sharing: $asset");
|
||||
return null;
|
||||
}
|
||||
|
||||
return (file: file, cleanup: CurrentPlatform.isIOS);
|
||||
}
|
||||
|
||||
Future<({File file, bool cleanup})?> _getLocalPreviewShareFile(BaseAsset asset, String localId) async {
|
||||
final entity = AssetEntity(
|
||||
id: localId,
|
||||
width: asset.width ?? 1,
|
||||
height: asset.height ?? 1,
|
||||
typeInt: asset.type.index,
|
||||
);
|
||||
final data = await entity.thumbnailDataWithSize(
|
||||
_getLocalPreviewSize(asset),
|
||||
format: ThumbnailFormat.jpeg,
|
||||
quality: _localPreviewQuality,
|
||||
);
|
||||
if (data == null) {
|
||||
_log.warning("Local preview file not found for sharing: $asset");
|
||||
return null;
|
||||
}
|
||||
|
||||
final tempDirectory = await getTemporaryDirectory();
|
||||
final file = File(
|
||||
p.join(tempDirectory.path, 'immich-share-${DateTime.now().microsecondsSinceEpoch}-${_getPreviewFilename(asset)}'),
|
||||
);
|
||||
await file.writeAsBytes(data, flush: true);
|
||||
return (file: file, cleanup: true);
|
||||
}
|
||||
|
||||
Future<({File file, bool cleanup})?> _downloadRemoteShareFile({
|
||||
required String taskId,
|
||||
required String url,
|
||||
required String filename,
|
||||
Completer<void>? cancelCompleter,
|
||||
required void Function(double progress) onProgress,
|
||||
}) async {
|
||||
final task = DownloadTask(
|
||||
taskId: taskId,
|
||||
url: url,
|
||||
headers: ApiService.getRequestHeaders(),
|
||||
filename: filename,
|
||||
baseDirectory: BaseDirectory.temporary,
|
||||
group: kShareDownloadGroup,
|
||||
updates: Updates.statusAndProgress,
|
||||
);
|
||||
final downloader = FileDownloader();
|
||||
final statusUpdate = await downloader.download(
|
||||
task,
|
||||
onProgress: (value) {
|
||||
if (cancelCompleter != null && cancelCompleter.isCompleted) {
|
||||
unawaited(downloader.cancelTaskWithId(taskId));
|
||||
return;
|
||||
}
|
||||
onProgress(value);
|
||||
},
|
||||
);
|
||||
|
||||
if (cancelCompleter != null && cancelCompleter.isCompleted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (statusUpdate.status == TaskStatus.complete) {
|
||||
return (file: File(await task.filePath()), cleanup: true);
|
||||
}
|
||||
|
||||
_log.severe("Download for $filename failed with status ${statusUpdate.status}", statusUpdate.exception);
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<({File file, bool cleanup})?> _getRemoteOriginalShareFile(
|
||||
BaseAsset asset,
|
||||
String remoteId, {
|
||||
Completer<void>? cancelCompleter,
|
||||
required void Function(double progress) onProgress,
|
||||
}) {
|
||||
return _downloadRemoteShareFile(
|
||||
taskId: 'share-original-$remoteId-${DateTime.now().microsecondsSinceEpoch}',
|
||||
url: getOriginalUrlForRemoteId(remoteId, edited: asset.isEdited),
|
||||
filename: _sanitizeFilename(asset.name),
|
||||
cancelCompleter: cancelCompleter,
|
||||
onProgress: onProgress,
|
||||
);
|
||||
}
|
||||
|
||||
Future<({File file, bool cleanup})?> _getRemotePreviewShareFile(
|
||||
BaseAsset asset,
|
||||
String remoteId, {
|
||||
Completer<void>? cancelCompleter,
|
||||
required void Function(double progress) onProgress,
|
||||
}) {
|
||||
return _downloadRemoteShareFile(
|
||||
taskId: 'share-preview-$remoteId-${DateTime.now().microsecondsSinceEpoch}',
|
||||
url: getThumbnailUrlForRemoteId(remoteId, type: AssetMediaSize.preview, edited: asset.isEdited),
|
||||
filename: _getPreviewFilename(asset),
|
||||
cancelCompleter: cancelCompleter,
|
||||
onProgress: onProgress,
|
||||
);
|
||||
}
|
||||
|
||||
Future<({File file, bool cleanup})?> _getOriginalShareFile(
|
||||
BaseAsset asset, {
|
||||
Completer<void>? cancelCompleter,
|
||||
required void Function(double progress) onProgress,
|
||||
}) {
|
||||
final localId = _getLocalId(asset);
|
||||
if (localId != null && !asset.isEdited) {
|
||||
return _getLocalOriginalShareFile(asset, localId);
|
||||
}
|
||||
|
||||
final remoteId = _getRemoteId(asset);
|
||||
if (remoteId == null) {
|
||||
_log.warning("Asset has no remote ID for sharing: $asset");
|
||||
return Future.value(null);
|
||||
}
|
||||
|
||||
return _getRemoteOriginalShareFile(asset, remoteId, cancelCompleter: cancelCompleter, onProgress: onProgress);
|
||||
}
|
||||
|
||||
Future<({File file, bool cleanup})?> _getPreviewShareFile(
|
||||
BaseAsset asset, {
|
||||
Completer<void>? cancelCompleter,
|
||||
required void Function(double progress) onProgress,
|
||||
}) async {
|
||||
final remoteId = _getRemoteId(asset);
|
||||
if (remoteId != null) {
|
||||
final remotePreview = await _getRemotePreviewShareFile(
|
||||
asset,
|
||||
remoteId,
|
||||
cancelCompleter: cancelCompleter,
|
||||
onProgress: onProgress,
|
||||
);
|
||||
if (remotePreview != null || asset.isEdited) {
|
||||
return remotePreview;
|
||||
}
|
||||
}
|
||||
|
||||
final localId = _getLocalId(asset);
|
||||
if (localId != null) {
|
||||
return _getLocalPreviewShareFile(asset, localId);
|
||||
}
|
||||
|
||||
_log.warning("Asset has no local or remote ID for preview sharing: $asset");
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<int> shareAssets(
|
||||
List<BaseAsset> assets,
|
||||
BuildContext context, {
|
||||
ShareAssetFileType fileType = ShareAssetFileType.original,
|
||||
Completer<void>? cancelCompleter,
|
||||
void Function(double progress)? onAssetDownloadProgress,
|
||||
}) async {
|
||||
@@ -129,75 +336,43 @@ class AssetMediaRepository {
|
||||
|
||||
updateProgress();
|
||||
|
||||
for (var asset in assets) {
|
||||
for (final asset in assets) {
|
||||
if (cancelCompleter != null && cancelCompleter.isCompleted) {
|
||||
// if cancelled, delete any temp files created so far
|
||||
await _cleanupTempFiles(tempFiles);
|
||||
return 0;
|
||||
}
|
||||
|
||||
final localId = (asset is LocalAsset)
|
||||
? asset.id
|
||||
: asset is RemoteAsset
|
||||
? asset.localId
|
||||
: null;
|
||||
if (localId != null && !asset.isEdited) {
|
||||
File? f = await AssetEntity(id: localId, width: 1, height: 1, typeInt: 0).originFile;
|
||||
downloadedXFiles.add(XFile(f!.path));
|
||||
processedAssets++;
|
||||
updateProgress();
|
||||
if (CurrentPlatform.isIOS) {
|
||||
tempFiles.add(f);
|
||||
}
|
||||
} else {
|
||||
final remoteId = (asset is RemoteAsset) ? asset.id : asset.remoteId;
|
||||
if (remoteId == null) {
|
||||
_log.warning("Asset has no remote ID for sharing: $asset");
|
||||
processedAssets++;
|
||||
updateProgress();
|
||||
continue;
|
||||
}
|
||||
final shareFile = switch (fileType) {
|
||||
ShareAssetFileType.original => await _getOriginalShareFile(
|
||||
asset,
|
||||
cancelCompleter: cancelCompleter,
|
||||
onProgress: updateProgress,
|
||||
),
|
||||
ShareAssetFileType.preview => await _getPreviewShareFile(
|
||||
asset,
|
||||
cancelCompleter: cancelCompleter,
|
||||
onProgress: updateProgress,
|
||||
),
|
||||
};
|
||||
|
||||
final taskId = 'share-$remoteId-${DateTime.now().microsecondsSinceEpoch}';
|
||||
final sanitizedFilename = asset.name.replaceAll(RegExp(r'[\\/]'), '_');
|
||||
final task = DownloadTask(
|
||||
taskId: taskId,
|
||||
url: getOriginalUrlForRemoteId(remoteId, edited: asset.isEdited),
|
||||
headers: ApiService.getRequestHeaders(),
|
||||
filename: sanitizedFilename,
|
||||
baseDirectory: BaseDirectory.temporary,
|
||||
group: kShareDownloadGroup,
|
||||
updates: Updates.statusAndProgress,
|
||||
);
|
||||
final statusUpdate = await FileDownloader().download(
|
||||
task,
|
||||
onProgress: (value) {
|
||||
if (cancelCompleter != null && cancelCompleter.isCompleted) {
|
||||
unawaited(FileDownloader().cancelTaskWithId(taskId));
|
||||
return;
|
||||
}
|
||||
updateProgress(value);
|
||||
},
|
||||
);
|
||||
|
||||
if (cancelCompleter != null && cancelCompleter.isCompleted) {
|
||||
await _cleanupTempFiles(tempFiles);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (statusUpdate.status == TaskStatus.complete) {
|
||||
final filePath = await task.filePath();
|
||||
final file = File(filePath);
|
||||
tempFiles.add(file);
|
||||
downloadedXFiles.add(XFile(filePath));
|
||||
processedAssets++;
|
||||
updateProgress();
|
||||
continue;
|
||||
}
|
||||
_log.severe("Download for ${asset.name} failed with status ${statusUpdate.status}", statusUpdate.exception);
|
||||
processedAssets++;
|
||||
updateProgress();
|
||||
if (cancelCompleter != null && cancelCompleter.isCompleted) {
|
||||
await _cleanupTempFiles(tempFiles);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (shareFile == null) {
|
||||
processedAssets++;
|
||||
updateProgress();
|
||||
continue;
|
||||
}
|
||||
|
||||
downloadedXFiles.add(XFile(shareFile.file.path));
|
||||
if (shareFile.cleanup) {
|
||||
tempFiles.add(shareFile.file);
|
||||
}
|
||||
processedAssets++;
|
||||
updateProgress();
|
||||
}
|
||||
|
||||
if (downloadedXFiles.isEmpty) {
|
||||
|
||||
@@ -272,12 +272,14 @@ class ActionService {
|
||||
Future<int> shareAssets(
|
||||
List<BaseAsset> assets,
|
||||
BuildContext context, {
|
||||
ShareAssetFileType fileType = ShareAssetFileType.original,
|
||||
Completer<void>? cancelCompleter,
|
||||
void Function(double progress)? onAssetDownloadProgress,
|
||||
}) {
|
||||
return _assetMediaRepository.shareAssets(
|
||||
assets,
|
||||
context,
|
||||
fileType: fileType,
|
||||
cancelCompleter: cancelCompleter,
|
||||
onAssetDownloadProgress: onAssetDownloadProgress,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user