Files
loongyan/bin/domain/repositories/photo_repository.dart
lzw-723 a0bc231c3c
All checks were successful
Dart CI / build (push) Successful in 31s
格式化代码
2026-04-05 19:46:02 +08:00

408 lines
11 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:collection';
import 'dart:io';
import 'dart:typed_data';
import 'package:path/path.dart' as p;
import '../entities/photo.dart';
import '../page_result.dart';
class PhotoRepository {
final String basePath;
final Map<int, List<Photo>> _map = HashMap();
final Map<int, DateTime> _updatedMap = HashMap();
PhotoRepository(this.basePath);
/// 获取相册中的照片(分页)
Future<PageResult<Photo>> getPhotosByAlbumIdPaged(
int id, {
int page = 1,
int size = 20,
String sortBy = 'fileName',
String order = 'asc',
}) async {
final all = await _loadPhotosForAlbum(id);
all.sort(_createComparator(sortBy, order));
final total = all.length;
final startIndex = (page - 1) * size;
final endIndex = startIndex + size;
final items = startIndex < total
? all.sublist(startIndex, endIndex > total ? total : endIndex)
: <Photo>[];
return PageResult(items: items, total: total, page: page, size: size);
}
/// 内部方法:加载相册照片(带缓存)
Future<List<Photo>> _loadPhotosForAlbum(int id) async {
if (_updatedMap.containsKey(id) &&
DateTime.now().difference(_updatedMap[id]!).inSeconds <= 60) {
return _map[id]!;
}
_updatedMap[id] = DateTime.now();
final dir = Directory(basePath);
if (!await dir.exists()) {
return [];
}
Directory? albumDir;
try {
albumDir =
await dir
.list()
.where((f) => f is Directory)
.where((d) => d.path.hashCode == id)
.first
as Directory;
} on StateError {
return [];
}
// 获取相册中的所有文件
final files = await albumDir
.list()
.where((f) => f is File)
.map((f) => f as File)
.where((f) => _isImageOrVideo(f.path))
.toList();
// 并行处理所有文件的尺寸获取
final photoFutures = files.map((file) async {
final stat = file.statSync();
final fileName = p.basename(file.path);
final mimeType = _getMimeType(file.path);
final dimensions = _getImageDimensions(file, mimeType);
return Photo(
id: file.path.hashCode,
albumId: id,
filePath: file.path,
fileName: fileName,
fileSize: stat.size,
mimeType: mimeType,
width: dimensions?.$1,
height: dimensions?.$2,
createdAt: stat.changed,
);
});
final photos = await Future.wait(photoFutures);
_map[id] = List.from(photos);
return photos;
}
/// 创建排序比较器
int Function(Photo, Photo) _createComparator(String sortBy, String order) {
final ascending = order == 'asc';
int compare(Photo a, Photo b) {
// Null always sorts last regardless of ascending/descending
int nullAwareCompare(int? valueA, int? valueB) {
if (valueA == null && valueB == null) return 0;
if (valueA == null) return 1;
if (valueB == null) return -1;
return ascending ? valueA.compareTo(valueB) : valueB.compareTo(valueA);
}
switch (sortBy) {
case 'fileName':
return ascending
? a.fileName.compareTo(b.fileName)
: b.fileName.compareTo(a.fileName);
case 'fileSize':
return ascending
? a.fileSize.compareTo(b.fileSize)
: b.fileSize.compareTo(a.fileSize);
case 'createdAt':
return ascending
? a.createdAt.compareTo(b.createdAt)
: b.createdAt.compareTo(a.createdAt);
case 'width':
return nullAwareCompare(a.width, b.width);
case 'height':
return nullAwareCompare(a.height, b.height);
default:
return ascending
? a.fileName.compareTo(b.fileName)
: b.fileName.compareTo(a.fileName);
}
}
return compare;
}
Future<Photo?> getPhotoById(int id) async {
try {
return _map.values.expand((l) => l).firstWhere((p) => p.id == id);
} catch (e) {}
final dir = Directory(basePath);
if (!await dir.exists()) {
return null;
}
File file;
try {
file = await dir
.list(recursive: true)
.where((f) => f is File)
.map((f) => f as File)
.firstWhere((f) => f.path.hashCode == id);
} on StateError {
// firstWhere throws StateError when no element is found
return null;
}
final stat = file.statSync();
final mimeType = _getMimeType(file.path);
final dimensions = _getImageDimensions(file, mimeType);
return Photo(
id: file.path.hashCode,
albumId: file.parent.path.hashCode,
filePath: file.path,
fileName: p.basename(file.path),
fileSize: stat.size,
mimeType: mimeType,
width: dimensions?.$1,
height: dimensions?.$2,
createdAt: stat.changed,
);
}
/// 获取图片的宽度和高度
/// 返回 (width, height) 或 null如果是视频或无法解析
(int, int)? _getImageDimensions(File file, String mimeType) {
// 视频文件不返回尺寸
if (mimeType.startsWith('video/')) {
return null;
}
try {
// 只读取文件头部的一小部分来获取尺寸
final raf = file.openSync();
try {
// 读取前 32KB 应该足够获取图片尺寸
final bytesToRead = 32 * 1024;
final buffer = Uint8List(bytesToRead);
final bytesRead = raf.readIntoSync(buffer);
final data = buffer.sublist(0, bytesRead);
return _parseImageDimensions(data, mimeType);
} finally {
raf.closeSync();
}
} catch (_) {
// 如果无法解析图片,返回 null
return null;
}
}
/// 解析图片尺寸
/// 支持 JPEG, PNG, GIF, BMP, WebP 格式
(int, int)? _parseImageDimensions(Uint8List data, String mimeType) {
try {
switch (mimeType) {
case 'image/jpeg':
return _parseJpegDimensions(data);
case 'image/png':
return _parsePngDimensions(data);
case 'image/gif':
return _parseGifDimensions(data);
case 'image/bmp':
return _parseBmpDimensions(data);
case 'image/webp':
return _parseWebpDimensions(data);
default:
return null;
}
} catch (_) {
return null;
}
}
/// 解析 JPEG 图片尺寸
(int, int)? _parseJpegDimensions(Uint8List data) {
if (data.length < 2 || data[0] != 0xFF || data[1] != 0xD8) {
return null; // 不是有效的 JPEG
}
var offset = 2;
while (offset < data.length - 1) {
if (data[offset] != 0xFF) {
offset++;
continue;
}
final marker = data[offset + 1];
// SOF 标记 (Start Of Frame)
if (marker >= 0xC0 && marker <= 0xC3 ||
marker >= 0xC5 && marker <= 0xC7 ||
marker >= 0xC9 && marker <= 0xCB ||
marker >= 0xCD && marker <= 0xCF) {
if (offset + 9 < data.length) {
final height = (data[offset + 5] << 8) | data[offset + 6];
final width = (data[offset + 7] << 8) | data[offset + 8];
return (width, height);
}
}
// 跳过这个标记
if (marker >= 0xD0 && marker <= 0xD9) {
offset += 2;
} else if (marker == 0xFF) {
offset += 2;
} else {
if (offset + 3 < data.length) {
final length = (data[offset + 2] << 8) | data[offset + 3];
offset += 2 + length;
} else {
break;
}
}
}
return null;
}
/// 解析 PNG 图片尺寸
(int, int)? _parsePngDimensions(Uint8List data) {
if (data.length < 24 ||
data[0] != 0x89 ||
data[1] != 0x50 ||
data[2] != 0x4E ||
data[3] != 0x47 ||
data[4] != 0x0D ||
data[5] != 0x0A ||
data[6] != 0x1A ||
data[7] != 0x0A) {
return null; // 不是有效的 PNG
}
// IHDR chunk 在第 16-23 字节
final width =
(data[16] << 24) | (data[17] << 16) | (data[18] << 8) | data[19];
final height =
(data[20] << 24) | (data[21] << 16) | (data[22] << 8) | data[23];
return (width, height);
}
/// 解析 GIF 图片尺寸
(int, int)? _parseGifDimensions(Uint8List data) {
if (data.length < 10 ||
data[0] != 0x47 ||
data[1] != 0x49 ||
data[2] != 0x46 ||
data[3] != 0x38) {
return null; // 不是有效的 GIF
}
final width = data[6] | (data[7] << 8);
final height = data[8] | (data[9] << 8);
return (width, height);
}
/// 解析 BMP 图片尺寸
(int, int)? _parseBmpDimensions(Uint8List data) {
if (data.length < 26 || data[0] != 0x42 || data[1] != 0x4D) {
return null; // 不是有效的 BMP
}
final width =
data[18] | (data[19] << 8) | (data[20] << 16) | (data[21] << 24);
final height =
data[22] | (data[23] << 8) | (data[24] << 16) | (data[25] << 24);
return (width, height.abs()); // BMP 高度可能是负数
}
/// 解析 WebP 图片尺寸
(int, int)? _parseWebpDimensions(Uint8List data) {
if (data.length < 30 ||
data[0] != 0x52 ||
data[1] != 0x49 ||
data[2] != 0x46 ||
data[3] != 0x46 ||
data[8] != 0x57 ||
data[9] != 0x45 ||
data[10] != 0x42 ||
data[11] != 0x50) {
return null; // 不是有效的 WebP
}
final type = String.fromCharCodes(data.sublist(12, 16));
if (type == 'VP8 ') {
// Lossy WebP
if (data.length < 30) return null;
final key = (data[23] << 8) | data[22];
if (key != 0x9D01) return null;
final width = data[26] | (data[27] << 8);
final height = data[28] | (data[29] << 8);
return (width, height);
} else if (type == 'VP8L') {
// Lossless WebP
if (data.length < 29) return null;
if (data[21] != 0x2F) return null;
final width = 1 + ((data[22] | (data[23] << 8)) & 0x3FFF);
final height =
1 + (((data[23] >> 6) | (data[24] << 2) | (data[25] << 10)) & 0x3FFF);
return (width, height);
} else if (type == 'VP8X') {
// Extended WebP
if (data.length < 30) return null;
final width = 1 + (data[24] | (data[25] << 8) | (data[26] << 16));
final height = 1 + (data[27] | (data[28] << 8) | (data[29] << 16));
return (width, height);
}
return null;
}
Future<void> deletePhoto(int id) {
// TODO: implement deletePhoto
throw UnimplementedError();
}
bool _isImageOrVideo(String path) {
final ext = p.extension(path).toLowerCase();
return [
'.jpg',
'.jpeg',
'.png',
'.gif',
'.bmp',
'.webp',
'.mp4',
'.avi',
'.mov',
'.mkv',
].contains(ext);
}
String _getMimeType(String path) {
final ext = p.extension(path).toLowerCase();
switch (ext) {
case '.jpg':
case '.jpeg':
return 'image/jpeg';
case '.png':
return 'image/png';
case '.gif':
return 'image/gif';
case '.bmp':
return 'image/bmp';
case '.webp':
return 'image/webp';
case '.mp4':
return 'video/mp4';
case '.avi':
return 'video/avi';
case '.mov':
return 'video/quicktime';
case '.mkv':
return 'video/x-matroska';
default:
return 'application/octet-stream';
}
}
}