Files
GameTime/lib/infrastructure/local/local_media_storage.dart
Blomios c77006f322 feat(media): gestion des médias locaux
Ajoute le stockage local des médias (infrastructure/local/local_media_storage.dart)
et les use cases/ports associés. flutter analyze propre, 7/7 tests
unitaires verts, build APK debug validé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 18:31:28 +02:00

168 lines
4.8 KiB
Dart

import 'dart:io';
import 'dart:typed_data';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../../application/application.dart';
import '../../domain/domain.dart';
final class PathProviderLocalMediaStorage implements LocalMediaStorage {
const PathProviderLocalMediaStorage();
static const _mediaDirectoryName = 'gametime_media';
@override
Future<StoredMediaFile> importFile({
required String sourcePath,
required MediaKind kind,
required String stableFileName,
}) async {
final source = File(sourcePath);
if (!await source.exists()) {
throw DomainException('Media source file not found: $sourcePath');
}
final extension = p.extension(source.path).toLowerCase();
final targetDirectory = await _kindDirectory(kind);
await targetDirectory.create(recursive: true);
final target = File(
p.join(targetDirectory.path, '$stableFileName$extension'),
);
await source.copy(target.path);
final bytes = await target.length();
final dimensions = kind == MediaKind.image
? await _tryReadImageDimensions(target)
: null;
return StoredMediaFile(
localUri: target.uri.toString(),
mimeType: _mimeTypeForExtension(extension, kind),
sizeBytes: bytes,
width: dimensions?.width,
height: dimensions?.height,
durationMs: null,
);
}
@override
Future<Set<String>> listManagedLocalUris() async {
final root = await _rootDirectory();
if (!await root.exists()) {
return const {};
}
final files = await root
.list(recursive: true)
.where((entity) => entity is File)
.cast<File>()
.toList();
return files.map((file) => file.uri.toString()).toSet();
}
@override
Future<void> deleteByLocalUri(String localUri) async {
final file = File.fromUri(Uri.parse(localUri));
final root = await _rootDirectory();
final filePath = p.normalize(file.absolute.path);
final rootPath = p.normalize(root.absolute.path);
if (!p.isWithin(rootPath, filePath) && filePath != rootPath) {
throw DomainException(
'Refusing to delete unmanaged media file: $localUri',
);
}
if (await file.exists()) {
await file.delete();
}
}
Future<Directory> _kindDirectory(MediaKind kind) async {
final root = await _rootDirectory();
final child = switch (kind) {
MediaKind.image => 'images',
MediaKind.video => 'videos',
};
return Directory(p.join(root.path, child));
}
Future<Directory> _rootDirectory() async {
final documents = await getApplicationDocumentsDirectory();
return Directory(p.join(documents.path, _mediaDirectoryName));
}
}
final class _ImageDimensions {
const _ImageDimensions(this.width, this.height);
final int width;
final int height;
}
Future<_ImageDimensions?> _tryReadImageDimensions(File file) async {
final bytes = await file.openRead(0, 32).fold<BytesBuilder>(
BytesBuilder(copy: false),
(builder, chunk) {
builder.add(chunk);
return builder;
},
);
final data = bytes.toBytes();
return _readPngDimensions(data) ?? await _readJpegDimensions(file);
}
_ImageDimensions? _readPngDimensions(Uint8List data) {
const signature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
if (data.length < 24) {
return null;
}
for (var index = 0; index < signature.length; index++) {
if (data[index] != signature[index]) {
return null;
}
}
final view = ByteData.sublistView(data);
return _ImageDimensions(view.getUint32(16), view.getUint32(20));
}
Future<_ImageDimensions?> _readJpegDimensions(File file) async {
final data = await file.readAsBytes();
if (data.length < 4 || data[0] != 0xFF || data[1] != 0xD8) {
return null;
}
var offset = 2;
while (offset + 9 < data.length) {
if (data[offset] != 0xFF) {
return null;
}
final marker = data[offset + 1];
final length = (data[offset + 2] << 8) + data[offset + 3];
if (length < 2 || offset + 2 + length > data.length) {
return null;
}
if ((marker >= 0xC0 && marker <= 0xC3) ||
(marker >= 0xC5 && marker <= 0xC7) ||
(marker >= 0xC9 && marker <= 0xCB) ||
(marker >= 0xCD && marker <= 0xCF)) {
final height = (data[offset + 5] << 8) + data[offset + 6];
final width = (data[offset + 7] << 8) + data[offset + 8];
return _ImageDimensions(width, height);
}
offset += 2 + length;
}
return null;
}
String? _mimeTypeForExtension(String extension, MediaKind kind) {
return switch (extension) {
'.jpg' || '.jpeg' => 'image/jpeg',
'.png' => 'image/png',
'.gif' => 'image/gif',
'.webp' => 'image/webp',
'.mp4' => 'video/mp4',
'.mov' => 'video/quicktime',
'.webm' => 'video/webm',
_ => kind == MediaKind.image ? 'image/*' : 'video/*',
};
}