mirror of https://github.com/immich-app/immich.git
Implemented EXIF store and display (#19)
* Added EXIF extracting in the backend * Added EXIF displaying on `image_viewer_page.dart` * Added Icon for backup option not enablepull/23/head
parent
d1498506a8
commit
de1dbcea9c
@ -0,0 +1,45 @@
|
||||
import 'dart:convert';
|
||||
|
||||
class ImageViewerPageState {
|
||||
final bool isBottomSheetEnable;
|
||||
ImageViewerPageState({
|
||||
required this.isBottomSheetEnable,
|
||||
});
|
||||
|
||||
ImageViewerPageState copyWith({
|
||||
bool? isBottomSheetEnable,
|
||||
}) {
|
||||
return ImageViewerPageState(
|
||||
isBottomSheetEnable: isBottomSheetEnable ?? this.isBottomSheetEnable,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'isBottomSheetEnable': isBottomSheetEnable,
|
||||
};
|
||||
}
|
||||
|
||||
factory ImageViewerPageState.fromMap(Map<String, dynamic> map) {
|
||||
return ImageViewerPageState(
|
||||
isBottomSheetEnable: map['isBottomSheetEnable'] ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory ImageViewerPageState.fromJson(String source) => ImageViewerPageState.fromMap(json.decode(source));
|
||||
|
||||
@override
|
||||
String toString() => 'ImageViewerPageState(isBottomSheetEnable: $isBottomSheetEnable)';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is ImageViewerPageState && other.isBottomSheetEnable == isBottomSheetEnable;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => isBottomSheetEnable.hashCode;
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart';
|
||||
import 'package:immich_mobile/modules/home/models/home_page_state.model.dart';
|
||||
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
|
||||
|
||||
class ImageViewerPageStateNotifier extends StateNotifier<ImageViewerPageState> {
|
||||
ImageViewerPageStateNotifier() : super(ImageViewerPageState(isBottomSheetEnable: false));
|
||||
|
||||
void toggleBottomSheet() {
|
||||
bool isBottomSheetEnable = state.isBottomSheetEnable;
|
||||
|
||||
if (isBottomSheetEnable) {
|
||||
state.copyWith(isBottomSheetEnable: false);
|
||||
} else {
|
||||
state.copyWith(isBottomSheetEnable: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final homePageStateProvider = StateNotifierProvider<ImageViewerPageStateNotifier, ImageViewerPageState>(
|
||||
((ref) => ImageViewerPageStateNotifier()));
|
||||
@ -0,0 +1,118 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/shared/models/immich_asset_with_exif.model.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
class ExifBottomSheet extends ConsumerWidget {
|
||||
final ImmichAssetWithExif assetDetail;
|
||||
|
||||
const ExifBottomSheet({Key? key, required this.assetDetail}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 8),
|
||||
child: ListView(
|
||||
children: [
|
||||
assetDetail.exifInfo?.dateTimeOriginal != null
|
||||
? Text(
|
||||
DateFormat('E, LLL d, y • h:mm a').format(
|
||||
DateTime.parse(assetDetail.exifInfo!.dateTimeOriginal!),
|
||||
),
|
||||
style: TextStyle(
|
||||
color: Colors.grey[400],
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
)
|
||||
: Container(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16.0),
|
||||
child: Text(
|
||||
"Add Description...",
|
||||
style: TextStyle(
|
||||
color: Colors.grey[500],
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Location
|
||||
assetDetail.exifInfo?.latitude != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(top: 32.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Divider(
|
||||
thickness: 1,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
Text(
|
||||
"LOCATION",
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey[400]),
|
||||
),
|
||||
Text(
|
||||
"${assetDetail.exifInfo!.latitude!.toStringAsFixed(4)}, ${assetDetail.exifInfo!.longitude!.toStringAsFixed(4)}",
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey[400]),
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
: Container(),
|
||||
// Detail
|
||||
assetDetail.exifInfo != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(top: 32.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Divider(
|
||||
thickness: 1,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Text(
|
||||
"DETAILS",
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey[400]),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.all(0),
|
||||
dense: true,
|
||||
textColor: Colors.grey[300],
|
||||
iconColor: Colors.grey[300],
|
||||
leading: const Icon(Icons.image),
|
||||
title: Text(
|
||||
"${assetDetail.exifInfo?.imageName!}${p.extension(assetDetail.originalPath)}",
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(
|
||||
"${assetDetail.exifInfo?.exifImageHeight!} x ${assetDetail.exifInfo?.exifImageWidth!} ${assetDetail.exifInfo?.fileSizeInByte!}B "),
|
||||
),
|
||||
assetDetail.exifInfo?.make != null
|
||||
? ListTile(
|
||||
contentPadding: const EdgeInsets.all(0),
|
||||
dense: true,
|
||||
textColor: Colors.grey[300],
|
||||
iconColor: Colors.grey[300],
|
||||
leading: const Icon(Icons.camera),
|
||||
title: Text(
|
||||
"${assetDetail.exifInfo?.make} ${assetDetail.exifInfo?.model}",
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(
|
||||
"ƒ/${assetDetail.exifInfo?.fNumber} 1/${(1 / assetDetail.exifInfo!.exposureTime!).toStringAsFixed(0)} ${assetDetail.exifInfo?.focalLength}mm ISO${assetDetail.exifInfo?.iso} "),
|
||||
)
|
||||
: Container()
|
||||
],
|
||||
),
|
||||
)
|
||||
: Container()
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,57 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
|
||||
|
||||
class TopControlAppBar extends StatelessWidget with PreferredSizeWidget {
|
||||
const TopControlAppBar({Key? key, required this.asset, required this.onMoreInfoPressed}) : super(key: key);
|
||||
|
||||
final ImmichAsset asset;
|
||||
final Function onMoreInfoPressed;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
double iconSize = 18.0;
|
||||
|
||||
return AppBar(
|
||||
foregroundColor: Colors.grey[100],
|
||||
toolbarHeight: 60,
|
||||
backgroundColor: Colors.black,
|
||||
leading: IconButton(
|
||||
onPressed: () {
|
||||
AutoRouter.of(context).pop();
|
||||
},
|
||||
icon: const Icon(
|
||||
Icons.arrow_back_ios_new_rounded,
|
||||
size: 20.0,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
iconSize: iconSize,
|
||||
splashRadius: iconSize,
|
||||
onPressed: () {
|
||||
print("backup");
|
||||
},
|
||||
icon: const Icon(Icons.backup_outlined),
|
||||
),
|
||||
IconButton(
|
||||
iconSize: iconSize,
|
||||
splashRadius: iconSize,
|
||||
onPressed: () {
|
||||
print("favorite");
|
||||
},
|
||||
icon: asset.isFavorite ? const Icon(Icons.favorite_rounded) : const Icon(Icons.favorite_border_rounded),
|
||||
),
|
||||
IconButton(
|
||||
iconSize: iconSize,
|
||||
splashRadius: iconSize,
|
||||
onPressed: () {
|
||||
onMoreInfoPressed();
|
||||
},
|
||||
icon: const Icon(Icons.more_horiz_rounded))
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||
}
|
||||
@ -0,0 +1,85 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
|
||||
import 'package:immich_mobile/modules/home/services/asset.service.dart';
|
||||
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
|
||||
import 'package:immich_mobile/shared/models/immich_asset_with_exif.model.dart';
|
||||
import 'package:photo_view/photo_view.dart';
|
||||
|
||||
// ignore: must_be_immutable
|
||||
class ImageViewerPage extends HookConsumerWidget {
|
||||
final String imageUrl;
|
||||
final String heroTag;
|
||||
final String thumbnailUrl;
|
||||
final ImmichAsset asset;
|
||||
final AssetService _assetService = AssetService();
|
||||
ImmichAssetWithExif? assetDetail;
|
||||
|
||||
ImageViewerPage(
|
||||
{Key? key, required this.imageUrl, required this.heroTag, required this.thumbnailUrl, required this.asset})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
var box = Hive.box(userInfoBox);
|
||||
|
||||
getAssetExif() async {
|
||||
assetDetail = await _assetService.getAssetById(asset.id);
|
||||
}
|
||||
|
||||
useEffect(() {
|
||||
getAssetExif();
|
||||
}, []);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: TopControlAppBar(
|
||||
asset: asset,
|
||||
onMoreInfoPressed: () {
|
||||
showModalBottomSheet(
|
||||
backgroundColor: Colors.black,
|
||||
barrierColor: Colors.transparent,
|
||||
isScrollControlled: false,
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return ExifBottomSheet(assetDetail: assetDetail!);
|
||||
});
|
||||
},
|
||||
),
|
||||
body: Center(
|
||||
child: Hero(
|
||||
tag: heroTag,
|
||||
child: CachedNetworkImage(
|
||||
fit: BoxFit.cover,
|
||||
imageUrl: imageUrl,
|
||||
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
|
||||
fadeInDuration: const Duration(milliseconds: 250),
|
||||
errorWidget: (context, url, error) => const Icon(Icons.error),
|
||||
imageBuilder: (context, imageProvider) {
|
||||
return PhotoView(imageProvider: imageProvider);
|
||||
},
|
||||
placeholder: (context, url) {
|
||||
return CachedNetworkImage(
|
||||
fit: BoxFit.cover,
|
||||
imageUrl: thumbnailUrl,
|
||||
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
|
||||
placeholderFadeInDuration: const Duration(milliseconds: 0),
|
||||
progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
|
||||
scale: 0.2,
|
||||
child: CircularProgressIndicator(value: downloadProgress.progress),
|
||||
),
|
||||
errorWidget: (context, url, error) => const Icon(Icons.error),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,187 @@
|
||||
import 'dart:convert';
|
||||
|
||||
class ImmichExif {
|
||||
final int? id;
|
||||
final String? assetId;
|
||||
final String? make;
|
||||
final String? model;
|
||||
final String? imageName;
|
||||
final int? exifImageWidth;
|
||||
final int? exifImageHeight;
|
||||
final int? fileSizeInByte;
|
||||
final String? orientation;
|
||||
final String? dateTimeOriginal;
|
||||
final String? modifyDate;
|
||||
final String? lensModel;
|
||||
final double? fNumber;
|
||||
final double? focalLength;
|
||||
final int? iso;
|
||||
final double? exposureTime;
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
|
||||
ImmichExif({
|
||||
this.id,
|
||||
this.assetId,
|
||||
this.make,
|
||||
this.model,
|
||||
this.imageName,
|
||||
this.exifImageWidth,
|
||||
this.exifImageHeight,
|
||||
this.fileSizeInByte,
|
||||
this.orientation,
|
||||
this.dateTimeOriginal,
|
||||
this.modifyDate,
|
||||
this.lensModel,
|
||||
this.fNumber,
|
||||
this.focalLength,
|
||||
this.iso,
|
||||
this.exposureTime,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
});
|
||||
|
||||
ImmichExif copyWith({
|
||||
int? id,
|
||||
String? assetId,
|
||||
String? make,
|
||||
String? model,
|
||||
String? imageName,
|
||||
int? exifImageWidth,
|
||||
int? exifImageHeight,
|
||||
int? fileSizeInByte,
|
||||
String? orientation,
|
||||
String? dateTimeOriginal,
|
||||
String? modifyDate,
|
||||
String? lensModel,
|
||||
double? fNumber,
|
||||
double? focalLength,
|
||||
int? iso,
|
||||
double? exposureTime,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
}) {
|
||||
return ImmichExif(
|
||||
id: id ?? this.id,
|
||||
assetId: assetId ?? this.assetId,
|
||||
make: make ?? this.make,
|
||||
model: model ?? this.model,
|
||||
imageName: imageName ?? this.imageName,
|
||||
exifImageWidth: exifImageWidth ?? this.exifImageWidth,
|
||||
exifImageHeight: exifImageHeight ?? this.exifImageHeight,
|
||||
fileSizeInByte: fileSizeInByte ?? this.fileSizeInByte,
|
||||
orientation: orientation ?? this.orientation,
|
||||
dateTimeOriginal: dateTimeOriginal ?? this.dateTimeOriginal,
|
||||
modifyDate: modifyDate ?? this.modifyDate,
|
||||
lensModel: lensModel ?? this.lensModel,
|
||||
fNumber: fNumber ?? this.fNumber,
|
||||
focalLength: focalLength ?? this.focalLength,
|
||||
iso: iso ?? this.iso,
|
||||
exposureTime: exposureTime ?? this.exposureTime,
|
||||
latitude: latitude ?? this.latitude,
|
||||
longitude: longitude ?? this.longitude,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'id': id,
|
||||
'assetId': assetId,
|
||||
'make': make,
|
||||
'model': model,
|
||||
'imageName': imageName,
|
||||
'exifImageWidth': exifImageWidth,
|
||||
'exifImageHeight': exifImageHeight,
|
||||
'fileSizeInByte': fileSizeInByte,
|
||||
'orientation': orientation,
|
||||
'dateTimeOriginal': dateTimeOriginal,
|
||||
'modifyDate': modifyDate,
|
||||
'lensModel': lensModel,
|
||||
'fNumber': fNumber,
|
||||
'focalLength': focalLength,
|
||||
'iso': iso,
|
||||
'exposureTime': exposureTime,
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
};
|
||||
}
|
||||
|
||||
factory ImmichExif.fromMap(Map<String, dynamic> map) {
|
||||
return ImmichExif(
|
||||
id: map['id']?.toInt(),
|
||||
assetId: map['assetId'],
|
||||
make: map['make'],
|
||||
model: map['model'],
|
||||
imageName: map['imageName'],
|
||||
exifImageWidth: map['exifImageWidth']?.toInt(),
|
||||
exifImageHeight: map['exifImageHeight']?.toInt(),
|
||||
fileSizeInByte: map['fileSizeInByte']?.toInt(),
|
||||
orientation: map['orientation'],
|
||||
dateTimeOriginal: map['dateTimeOriginal'],
|
||||
modifyDate: map['modifyDate'],
|
||||
lensModel: map['lensModel'],
|
||||
fNumber: map['fNumber']?.toDouble(),
|
||||
focalLength: map['focalLength']?.toDouble(),
|
||||
iso: map['iso']?.toInt(),
|
||||
exposureTime: map['exposureTime']?.toDouble(),
|
||||
latitude: map['latitude']?.toDouble(),
|
||||
longitude: map['longitude']?.toDouble(),
|
||||
);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory ImmichExif.fromJson(String source) => ImmichExif.fromMap(json.decode(source));
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ImmichExif(id: $id, assetId: $assetId, make: $make, model: $model, imageName: $imageName, exifImageWidth: $exifImageWidth, exifImageHeight: $exifImageHeight, fileSizeInByte: $fileSizeInByte, orientation: $orientation, dateTimeOriginal: $dateTimeOriginal, modifyDate: $modifyDate, lensModel: $lensModel, fNumber: $fNumber, focalLength: $focalLength, iso: $iso, exposureTime: $exposureTime, latitude: $latitude, longitude: $longitude)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is ImmichExif &&
|
||||
other.id == id &&
|
||||
other.assetId == assetId &&
|
||||
other.make == make &&
|
||||
other.model == model &&
|
||||
other.imageName == imageName &&
|
||||
other.exifImageWidth == exifImageWidth &&
|
||||
other.exifImageHeight == exifImageHeight &&
|
||||
other.fileSizeInByte == fileSizeInByte &&
|
||||
other.orientation == orientation &&
|
||||
other.dateTimeOriginal == dateTimeOriginal &&
|
||||
other.modifyDate == modifyDate &&
|
||||
other.lensModel == lensModel &&
|
||||
other.fNumber == fNumber &&
|
||||
other.focalLength == focalLength &&
|
||||
other.iso == iso &&
|
||||
other.exposureTime == exposureTime &&
|
||||
other.latitude == latitude &&
|
||||
other.longitude == longitude;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return id.hashCode ^
|
||||
assetId.hashCode ^
|
||||
make.hashCode ^
|
||||
model.hashCode ^
|
||||
imageName.hashCode ^
|
||||
exifImageWidth.hashCode ^
|
||||
exifImageHeight.hashCode ^
|
||||
fileSizeInByte.hashCode ^
|
||||
orientation.hashCode ^
|
||||
dateTimeOriginal.hashCode ^
|
||||
modifyDate.hashCode ^
|
||||
lensModel.hashCode ^
|
||||
fNumber.hashCode ^
|
||||
focalLength.hashCode ^
|
||||
iso.hashCode ^
|
||||
exposureTime.hashCode ^
|
||||
latitude.hashCode ^
|
||||
longitude.hashCode;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,133 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:immich_mobile/shared/models/exif.model.dart';
|
||||
|
||||
class ImmichAssetWithExif {
|
||||
final String id;
|
||||
final String deviceAssetId;
|
||||
final String userId;
|
||||
final String deviceId;
|
||||
final String type;
|
||||
final String createdAt;
|
||||
final String modifiedAt;
|
||||
final String originalPath;
|
||||
final bool isFavorite;
|
||||
final String? duration;
|
||||
final ImmichExif? exifInfo;
|
||||
|
||||
ImmichAssetWithExif({
|
||||
required this.id,
|
||||
required this.deviceAssetId,
|
||||
required this.userId,
|
||||
required this.deviceId,
|
||||
required this.type,
|
||||
required this.createdAt,
|
||||
required this.modifiedAt,
|
||||
required this.originalPath,
|
||||
required this.isFavorite,
|
||||
this.duration,
|
||||
this.exifInfo,
|
||||
});
|
||||
|
||||
ImmichAssetWithExif copyWith({
|
||||
String? id,
|
||||
String? deviceAssetId,
|
||||
String? userId,
|
||||
String? deviceId,
|
||||
String? type,
|
||||
String? createdAt,
|
||||
String? modifiedAt,
|
||||
String? originalPath,
|
||||
bool? isFavorite,
|
||||
String? duration,
|
||||
ImmichExif? exifInfo,
|
||||
}) {
|
||||
return ImmichAssetWithExif(
|
||||
id: id ?? this.id,
|
||||
deviceAssetId: deviceAssetId ?? this.deviceAssetId,
|
||||
userId: userId ?? this.userId,
|
||||
deviceId: deviceId ?? this.deviceId,
|
||||
type: type ?? this.type,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
modifiedAt: modifiedAt ?? this.modifiedAt,
|
||||
originalPath: originalPath ?? this.originalPath,
|
||||
isFavorite: isFavorite ?? this.isFavorite,
|
||||
duration: duration ?? this.duration,
|
||||
exifInfo: exifInfo ?? this.exifInfo,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'id': id,
|
||||
'deviceAssetId': deviceAssetId,
|
||||
'userId': userId,
|
||||
'deviceId': deviceId,
|
||||
'type': type,
|
||||
'createdAt': createdAt,
|
||||
'modifiedAt': modifiedAt,
|
||||
'originalPath': originalPath,
|
||||
'isFavorite': isFavorite,
|
||||
'duration': duration,
|
||||
'exifInfo': exifInfo?.toMap(),
|
||||
};
|
||||
}
|
||||
|
||||
factory ImmichAssetWithExif.fromMap(Map<String, dynamic> map) {
|
||||
return ImmichAssetWithExif(
|
||||
id: map['id'] ?? '',
|
||||
deviceAssetId: map['deviceAssetId'] ?? '',
|
||||
userId: map['userId'] ?? '',
|
||||
deviceId: map['deviceId'] ?? '',
|
||||
type: map['type'] ?? '',
|
||||
createdAt: map['createdAt'] ?? '',
|
||||
modifiedAt: map['modifiedAt'] ?? '',
|
||||
originalPath: map['originalPath'] ?? '',
|
||||
isFavorite: map['isFavorite'] ?? false,
|
||||
duration: map['duration'],
|
||||
exifInfo: map['exifInfo'] != null ? ImmichExif.fromMap(map['exifInfo']) : null,
|
||||
);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory ImmichAssetWithExif.fromJson(String source) => ImmichAssetWithExif.fromMap(json.decode(source));
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ImmichAssetWithExif(id: $id, deviceAssetId: $deviceAssetId, userId: $userId, deviceId: $deviceId, type: $type, createdAt: $createdAt, modifiedAt: $modifiedAt, originalPath: $originalPath, isFavorite: $isFavorite, duration: $duration, exifInfo: $exifInfo)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is ImmichAssetWithExif &&
|
||||
other.id == id &&
|
||||
other.deviceAssetId == deviceAssetId &&
|
||||
other.userId == userId &&
|
||||
other.deviceId == deviceId &&
|
||||
other.type == type &&
|
||||
other.createdAt == createdAt &&
|
||||
other.modifiedAt == modifiedAt &&
|
||||
other.originalPath == originalPath &&
|
||||
other.isFavorite == isFavorite &&
|
||||
other.duration == duration &&
|
||||
other.exifInfo == exifInfo;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return id.hashCode ^
|
||||
deviceAssetId.hashCode ^
|
||||
userId.hashCode ^
|
||||
deviceId.hashCode ^
|
||||
type.hashCode ^
|
||||
createdAt.hashCode ^
|
||||
modifiedAt.hashCode ^
|
||||
originalPath.hashCode ^
|
||||
isFavorite.hashCode ^
|
||||
duration.hashCode ^
|
||||
exifInfo.hashCode;
|
||||
}
|
||||
}
|
||||
@ -1,64 +0,0 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
|
||||
class ImageViewerPage extends StatelessWidget {
|
||||
final String imageUrl;
|
||||
final String heroTag;
|
||||
final String thumbnailUrl;
|
||||
|
||||
const ImageViewerPage({Key? key, required this.imageUrl, required this.heroTag, required this.thumbnailUrl})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var box = Hive.box(userInfoBox);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
toolbarHeight: 60,
|
||||
backgroundColor: Colors.black,
|
||||
leading: IconButton(
|
||||
onPressed: () {
|
||||
AutoRouter.of(context).pop();
|
||||
},
|
||||
icon: const Icon(Icons.arrow_back_ios)),
|
||||
),
|
||||
body: Dismissible(
|
||||
direction: DismissDirection.vertical,
|
||||
onDismissed: (_) {
|
||||
AutoRouter.of(context).pop();
|
||||
},
|
||||
key: Key(heroTag),
|
||||
child: Center(
|
||||
child: Hero(
|
||||
tag: heroTag,
|
||||
child: CachedNetworkImage(
|
||||
fit: BoxFit.cover,
|
||||
imageUrl: imageUrl,
|
||||
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
|
||||
fadeInDuration: const Duration(milliseconds: 250),
|
||||
errorWidget: (context, url, error) => const Icon(Icons.error),
|
||||
placeholder: (context, url) {
|
||||
return CachedNetworkImage(
|
||||
fit: BoxFit.cover,
|
||||
imageUrl: thumbnailUrl,
|
||||
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
|
||||
placeholderFadeInDuration: const Duration(milliseconds: 0),
|
||||
progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
|
||||
scale: 0.2,
|
||||
child: CircularProgressIndicator(value: downloadProgress.progress),
|
||||
),
|
||||
errorWidget: (context, url, error) => const Icon(Icons.error),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,48 @@
|
||||
import { IsNotEmpty, IsOptional } from 'class-validator';
|
||||
|
||||
export class CreateExifDto {
|
||||
@IsNotEmpty()
|
||||
assetId: string;
|
||||
|
||||
@IsOptional()
|
||||
make: string;
|
||||
|
||||
@IsOptional()
|
||||
model: string;
|
||||
|
||||
@IsOptional()
|
||||
imageName: string;
|
||||
|
||||
@IsOptional()
|
||||
exifImageWidth: number;
|
||||
|
||||
@IsOptional()
|
||||
exifImageHeight: number;
|
||||
|
||||
@IsOptional()
|
||||
fileSizeInByte: number;
|
||||
|
||||
@IsOptional()
|
||||
orientation: string;
|
||||
|
||||
@IsOptional()
|
||||
dateTimeOriginal: Date;
|
||||
|
||||
@IsOptional()
|
||||
modifiedDate: Date;
|
||||
|
||||
@IsOptional()
|
||||
lensModel: string;
|
||||
|
||||
@IsOptional()
|
||||
fNumber: number;
|
||||
|
||||
@IsOptional()
|
||||
focalLenght: number;
|
||||
|
||||
@IsOptional()
|
||||
iso: number;
|
||||
|
||||
@IsOptional()
|
||||
exposureTime: number;
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateExifDto } from './create-exif.dto';
|
||||
|
||||
export class UpdateExifDto extends PartialType(CreateExifDto) {}
|
||||
@ -0,0 +1,67 @@
|
||||
import { Index, JoinColumn, OneToOne } from 'typeorm';
|
||||
import { Column } from 'typeorm/decorator/columns/Column';
|
||||
import { PrimaryGeneratedColumn } from 'typeorm/decorator/columns/PrimaryGeneratedColumn';
|
||||
import { Entity } from 'typeorm/decorator/entity/Entity';
|
||||
import { AssetEntity } from './asset.entity';
|
||||
|
||||
@Entity('exif')
|
||||
export class ExifEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: string;
|
||||
|
||||
@Index({ unique: true })
|
||||
@Column({ type: 'uuid' })
|
||||
assetId: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
make: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
model: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
imageName: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
exifImageWidth: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
exifImageHeight: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
fileSizeInByte: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
orientation: string;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
dateTimeOriginal: Date;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
modifyDate: Date;
|
||||
|
||||
@Column({ nullable: true })
|
||||
lensModel: string;
|
||||
|
||||
@Column({ type: 'float8', nullable: true })
|
||||
fNumber: number;
|
||||
|
||||
@Column({ type: 'float8', nullable: true })
|
||||
focalLength: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
iso: number;
|
||||
|
||||
@Column({ type: 'float', nullable: true })
|
||||
exposureTime: number;
|
||||
|
||||
@Column({ type: 'float', nullable: true })
|
||||
latitude: number;
|
||||
|
||||
@Column({ type: 'float', nullable: true })
|
||||
longitude: number;
|
||||
|
||||
@OneToOne(() => AssetEntity, { onDelete: 'CASCADE', nullable: true })
|
||||
@JoinColumn({ name: 'assetId', referencedColumnName: 'id' })
|
||||
asset: ExifEntity;
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
import { BullModule } from '@nestjs/bull';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
|
||||
import { ExifEntity } from '../../api-v1/asset/entities/exif.entity';
|
||||
import { BackgroundTaskProcessor } from './background-task.processor';
|
||||
import { BackgroundTaskService } from './background-task.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
BullModule.registerQueue({
|
||||
name: 'background-task',
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: false,
|
||||
},
|
||||
}),
|
||||
TypeOrmModule.forFeature([AssetEntity, ExifEntity]),
|
||||
],
|
||||
providers: [BackgroundTaskService, BackgroundTaskProcessor],
|
||||
exports: [BackgroundTaskService],
|
||||
})
|
||||
export class BackgroundTaskModule {}
|
||||
@ -0,0 +1,59 @@
|
||||
import { InjectQueue, Process, Processor } from '@nestjs/bull';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Job, Queue } from 'bull';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import exifr from 'exifr';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { ExifEntity } from '../../api-v1/asset/entities/exif.entity';
|
||||
|
||||
@Processor('background-task')
|
||||
export class BackgroundTaskProcessor {
|
||||
constructor(
|
||||
@InjectRepository(AssetEntity)
|
||||
private assetRepository: Repository<AssetEntity>,
|
||||
|
||||
@InjectRepository(ExifEntity)
|
||||
private exifRepository: Repository<ExifEntity>,
|
||||
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
@Process('extract-exif')
|
||||
async extractExif(job: Job) {
|
||||
const { savedAsset, fileName, fileSize }: { savedAsset: AssetEntity; fileName: string; fileSize: number } =
|
||||
job.data;
|
||||
|
||||
const fileBuffer = await readFile(savedAsset.originalPath);
|
||||
|
||||
const exifData = await exifr.parse(fileBuffer);
|
||||
|
||||
const newExif = new ExifEntity();
|
||||
newExif.assetId = savedAsset.id;
|
||||
newExif.make = exifData['Make'] || null;
|
||||
newExif.model = exifData['Model'] || null;
|
||||
newExif.imageName = fileName || null;
|
||||
newExif.exifImageHeight = exifData['ExifImageHeight'] || null;
|
||||
newExif.exifImageWidth = exifData['ExifImageWidth'] || null;
|
||||
newExif.fileSizeInByte = fileSize || null;
|
||||
newExif.orientation = exifData['Orientation'] || null;
|
||||
newExif.dateTimeOriginal = exifData['DateTimeOriginal'] || null;
|
||||
newExif.modifyDate = exifData['ModifyDate'] || null;
|
||||
newExif.lensModel = exifData['LensModel'] || null;
|
||||
newExif.fNumber = exifData['FNumber'] || null;
|
||||
newExif.focalLength = exifData['FocalLength'] || null;
|
||||
newExif.iso = exifData['ISO'] || null;
|
||||
newExif.exposureTime = exifData['ExposureTime'] || null;
|
||||
newExif.latitude = exifData['latitude'] || null;
|
||||
newExif.longitude = exifData['longitude'] || null;
|
||||
|
||||
await this.exifRepository.save(newExif);
|
||||
|
||||
try {
|
||||
} catch (e) {
|
||||
Logger.error(`Error extracting EXIF ${e.toString()}`, 'extractExif');
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
import { InjectQueue } from '@nestjs/bull/dist/decorators';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Queue } from 'bull';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
|
||||
|
||||
@Injectable()
|
||||
export class BackgroundTaskService {
|
||||
constructor(
|
||||
@InjectQueue('background-task')
|
||||
private backgroundTaskQueue: Queue,
|
||||
) {}
|
||||
|
||||
async extractExif(savedAsset: AssetEntity, fileName: string, fileSize: number) {
|
||||
const job = await this.backgroundTaskQueue.add(
|
||||
'extract-exif',
|
||||
{
|
||||
savedAsset,
|
||||
fileName,
|
||||
fileSize,
|
||||
},
|
||||
{ jobId: randomUUID() },
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue