Files
loongyan/bin/domain/repositories/photo_repository.dart
lzw-723 d476d097dd
Some checks failed
Dart CI / build (push) Failing after 45s
照片列表支持分页
2026-04-05 16:41:45 +08:00

371 lines
10 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<List<Photo>> getPhotosByAlbumId(int id) async {
final all = await _loadPhotosForAlbum(id);
return all;
}
/// 获取相册中的照片(分页)
Future<PageResult<Photo>> getPhotosByAlbumIdPaged(
int id, {
int page = 1,
int size = 20,
}) async {
final all = await _loadPhotosForAlbum(id);
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;
}
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';
}
}
}