初始化项目

This commit is contained in:
2026-03-12 21:24:49 +08:00
commit c2b9c5d4c0
67 changed files with 8659 additions and 0 deletions

View File

@@ -0,0 +1,317 @@
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';
}
}
}