334 lines
8.8 KiB
Dart
334 lines
8.8 KiB
Dart
import 'dart:io';
|
||
import 'dart:typed_data';
|
||
|
||
import 'package:path/path.dart' as p;
|
||
import '../entities/photo.dart';
|
||
|
||
class PhotoRepository {
|
||
final String basePath;
|
||
|
||
PhotoRepository(this.basePath);
|
||
|
||
Future<List<Photo>> getPhotosByAlbumId(int id) async {
|
||
final dir = Directory(basePath);
|
||
|
||
if (!await dir.exists()) {
|
||
return [];
|
||
}
|
||
|
||
Directory? albumDir;
|
||
try {
|
||
albumDir =
|
||
await dir
|
||
.list()
|
||
.where((f) => f is Directory)
|
||
.where((d) => p.basename(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: fileName.hashCode,
|
||
albumId: id,
|
||
filePath: file.path,
|
||
fileName: fileName,
|
||
fileSize: stat.size,
|
||
mimeType: mimeType,
|
||
width: dimensions?.$1,
|
||
height: dimensions?.$2,
|
||
createdAt: stat.changed,
|
||
);
|
||
});
|
||
|
||
return Future.wait(photoFutures);
|
||
}
|
||
|
||
Future<Photo?> getPhotoById(int id) async {
|
||
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) => p.basename(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: p.basename(file.path).hashCode,
|
||
albumId: p.basename(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';
|
||
}
|
||
}
|
||
}
|