276 lines
6.8 KiB
Dart
276 lines
6.8 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
import 'dart:ui' as ui;
|
|
|
|
import 'package:photo_manager/photo_manager.dart';
|
|
|
|
import 'desktop_video_probe.dart';
|
|
|
|
enum GalleryPermission { granted, limited, denied }
|
|
|
|
abstract class GalleryItem {
|
|
String get id;
|
|
bool get isVideo;
|
|
Duration? get duration;
|
|
File? get localFile;
|
|
Future<Uint8List?> thumbnail(int size);
|
|
Future<File?> originFile();
|
|
Future<(int, int)?> dimensions();
|
|
Future<Uint8List?> encodeForUpload({
|
|
required int maxDimension,
|
|
required int quality,
|
|
});
|
|
|
|
static GalleryItem fromFile(File file) => _FileGalleryItem(file);
|
|
}
|
|
|
|
class PickedPhoto {
|
|
final GalleryItem item;
|
|
final File? editedFile;
|
|
|
|
const PickedPhoto({required this.item, this.editedFile});
|
|
}
|
|
|
|
Future<(int, int)?> imageFileDimensions(File file) async {
|
|
ui.ImmutableBuffer? buffer;
|
|
ui.ImageDescriptor? descriptor;
|
|
try {
|
|
final bytes = await file.readAsBytes();
|
|
buffer = await ui.ImmutableBuffer.fromUint8List(bytes);
|
|
descriptor = await ui.ImageDescriptor.encoded(buffer);
|
|
return (descriptor.width, descriptor.height);
|
|
} catch (_) {
|
|
return null;
|
|
} finally {
|
|
descriptor?.dispose();
|
|
buffer?.dispose();
|
|
}
|
|
}
|
|
|
|
abstract class GallerySource {
|
|
Future<GalleryPermission> ensurePermission();
|
|
Future<List<GalleryItem>> load({int limit});
|
|
Future<void> openSettings();
|
|
Future<void> manageAccess();
|
|
|
|
factory GallerySource.create() {
|
|
if (Platform.isAndroid || Platform.isIOS) {
|
|
return _PhotoManagerSource();
|
|
}
|
|
return _DesktopGallerySource();
|
|
}
|
|
}
|
|
|
|
class _PhotoManagerSource implements GallerySource {
|
|
@override
|
|
Future<GalleryPermission> ensurePermission() async {
|
|
final state = await PhotoManager.requestPermissionExtend();
|
|
if (state.isAuth) return GalleryPermission.granted;
|
|
if (state.hasAccess) return GalleryPermission.limited;
|
|
return GalleryPermission.denied;
|
|
}
|
|
|
|
@override
|
|
Future<List<GalleryItem>> load({int limit = 120}) async {
|
|
final paths = await PhotoManager.getAssetPathList(
|
|
type: RequestType.common,
|
|
onlyAll: true,
|
|
filterOption: FilterOptionGroup(
|
|
orders: const [
|
|
OrderOption(type: OrderOptionType.createDate, asc: false),
|
|
],
|
|
),
|
|
);
|
|
if (paths.isEmpty) return const [];
|
|
final assets = await paths.first.getAssetListRange(start: 0, end: limit);
|
|
return assets.map((a) => _AssetGalleryItem(a)).toList();
|
|
}
|
|
|
|
@override
|
|
Future<void> openSettings() => PhotoManager.openSetting();
|
|
|
|
@override
|
|
Future<void> manageAccess() => PhotoManager.presentLimited();
|
|
}
|
|
|
|
class _AssetGalleryItem implements GalleryItem {
|
|
final AssetEntity asset;
|
|
|
|
_AssetGalleryItem(this.asset);
|
|
|
|
@override
|
|
String get id => asset.id;
|
|
|
|
@override
|
|
bool get isVideo => asset.type == AssetType.video;
|
|
|
|
@override
|
|
Duration? get duration => isVideo ? Duration(seconds: asset.duration) : null;
|
|
|
|
@override
|
|
File? get localFile => null;
|
|
|
|
@override
|
|
Future<Uint8List?> thumbnail(int size) =>
|
|
asset.thumbnailDataWithSize(ThumbnailSize.square(size));
|
|
|
|
@override
|
|
Future<File?> originFile() => asset.file;
|
|
|
|
@override
|
|
Future<Uint8List?> encodeForUpload({
|
|
required int maxDimension,
|
|
required int quality,
|
|
}) => asset.thumbnailDataWithSize(
|
|
ThumbnailSize(maxDimension, maxDimension),
|
|
format: ThumbnailFormat.jpeg,
|
|
quality: quality,
|
|
);
|
|
|
|
@override
|
|
Future<(int, int)?> dimensions() async {
|
|
if (asset.width > 0 && asset.height > 0) {
|
|
return (asset.width, asset.height);
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const Set<String> kGalleryImageExtensions = {
|
|
'.jpg',
|
|
'.jpeg',
|
|
'.png',
|
|
'.gif',
|
|
'.webp',
|
|
'.bmp',
|
|
'.heic',
|
|
'.heif',
|
|
};
|
|
|
|
const Set<String> kGalleryVideoExtensions = {
|
|
'.mp4',
|
|
'.mov',
|
|
'.m4v',
|
|
'.mkv',
|
|
'.webm',
|
|
'.avi',
|
|
'.3gp',
|
|
};
|
|
|
|
String _fileExtension(String path) {
|
|
final dot = path.lastIndexOf('.');
|
|
if (dot < 0) return '';
|
|
return path.substring(dot).toLowerCase();
|
|
}
|
|
|
|
bool isVideoPath(String path) =>
|
|
kGalleryVideoExtensions.contains(_fileExtension(path));
|
|
|
|
class _DesktopGallerySource implements GallerySource {
|
|
@override
|
|
Future<GalleryPermission> ensurePermission() async =>
|
|
GalleryPermission.granted;
|
|
|
|
@override
|
|
Future<List<GalleryItem>> load({int limit = 120}) async {
|
|
final entries = <({File file, DateTime modified})>[];
|
|
for (final dir in _candidateDirs()) {
|
|
if (!dir.existsSync()) continue;
|
|
try {
|
|
for (final entity in dir.listSync(followLinks: false)) {
|
|
if (entity is! File || !_isMedia(entity.path)) continue;
|
|
entries.add((file: entity, modified: entity.statSync().modified));
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
entries.sort((a, b) => b.modified.compareTo(a.modified));
|
|
final items = entries
|
|
.take(limit)
|
|
.map((e) => _FileGalleryItem(e.file))
|
|
.toList();
|
|
const batch = 8;
|
|
const eager = 24;
|
|
|
|
Future<void> probeRange(int from, int to) async {
|
|
for (var i = from; i < to; i += batch) {
|
|
final end = i + batch > to ? to : i + batch;
|
|
await Future.wait(items.sublist(i, end).map((it) => it.probe()));
|
|
}
|
|
}
|
|
|
|
final head = items.length < eager ? items.length : eager;
|
|
await probeRange(0, head);
|
|
if (head < items.length) unawaited(probeRange(head, items.length));
|
|
return items;
|
|
}
|
|
|
|
@override
|
|
Future<void> openSettings() async {}
|
|
|
|
@override
|
|
Future<void> manageAccess() async {}
|
|
|
|
List<Directory> _candidateDirs() {
|
|
final home =
|
|
Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'];
|
|
if (home == null || home.isEmpty) return const [];
|
|
return [
|
|
Directory('$home/Pictures'),
|
|
Directory('$home/Изображения'),
|
|
Directory('$home/Images'),
|
|
Directory('$home/Videos'),
|
|
Directory('$home/Видео'),
|
|
Directory('$home/Movies'),
|
|
];
|
|
}
|
|
|
|
bool _isMedia(String path) {
|
|
final ext = _fileExtension(path);
|
|
return kGalleryImageExtensions.contains(ext) ||
|
|
kGalleryVideoExtensions.contains(ext);
|
|
}
|
|
}
|
|
|
|
class _FileGalleryItem implements GalleryItem {
|
|
final File file;
|
|
Duration? _duration;
|
|
|
|
_FileGalleryItem(this.file, {Duration? duration}) : _duration = duration;
|
|
|
|
Future<void> probe() async {
|
|
if (!isVideo || _duration != null) return;
|
|
_duration = await DesktopVideoProbe.duration(file.path);
|
|
}
|
|
|
|
@override
|
|
String get id => file.path;
|
|
|
|
@override
|
|
bool get isVideo => isVideoPath(file.path);
|
|
|
|
@override
|
|
Duration? get duration => _duration;
|
|
|
|
@override
|
|
File? get localFile => file;
|
|
|
|
@override
|
|
Future<Uint8List?> thumbnail(int size) async =>
|
|
isVideo ? DesktopVideoProbe.thumbnail(file.path, size) : null;
|
|
|
|
@override
|
|
Future<File?> originFile() async => file;
|
|
|
|
@override
|
|
Future<Uint8List?> encodeForUpload({
|
|
required int maxDimension,
|
|
required int quality,
|
|
}) async => null;
|
|
|
|
@override
|
|
Future<(int, int)?> dimensions() => isVideo
|
|
? DesktopVideoProbe.dimensions(file.path)
|
|
: imageFileDimensions(file);
|
|
}
|