|
1 | 1 | import 'dart:io'; |
2 | 2 | import 'dart:typed_data'; |
3 | 3 |
|
| 4 | +import 'package:flutter/foundation.dart' show compute; |
| 5 | +import 'package:image/image.dart' as img; |
4 | 6 | import 'package:image_picker/image_picker.dart'; |
| 7 | +import 'package:path/path.dart' as p; |
5 | 8 |
|
| 9 | +import 'package:submersion/features/media/data/services/capture_time_reader.dart'; |
6 | 10 | import 'package:submersion/features/media/data/services/photo_picker_service.dart'; |
7 | 11 | import 'package:submersion/features/media/domain/value_objects/media_source_metadata.dart'; |
8 | 12 |
|
9 | 13 | /// Photo picker implementation for Windows and Linux using image_picker. |
10 | 14 | /// |
11 | 15 | /// This is a fallback implementation that doesn't support date-filtered |
12 | 16 | /// gallery browsing. Users must manually browse and select files. |
| 17 | +/// |
| 18 | +/// Windows and Linux have no platform photo library, so every [AssetInfo] |
| 19 | +/// this service produces carries its [AssetInfo.filePath]. That path is the |
| 20 | +/// only durable pointer to the file: [AssetInfo.id] is a synthetic key into |
| 21 | +/// [_filePathCache], which lives and dies with the process. Importers must |
| 22 | +/// persist the path (as a `localFile` row) rather than the id, or the photo |
| 23 | +/// resolves through photo_manager -- which has no Windows backend -- and |
| 24 | +/// renders "File not found". |
13 | 25 | class PhotoPickerServiceDesktop implements PhotoPickerService { |
14 | 26 | final ImagePicker _picker = ImagePicker(); |
15 | 27 |
|
@@ -44,43 +56,44 @@ class PhotoPickerServiceDesktop implements PhotoPickerService { |
44 | 56 | return []; |
45 | 57 | } |
46 | 58 |
|
47 | | - final List<AssetInfo> results = []; |
48 | | - |
49 | | - for (final file in files) { |
50 | | - final path = file.path; |
51 | | - final ioFile = File(path); |
52 | | - |
53 | | - // Check if file exists |
54 | | - if (!await ioFile.exists()) continue; |
55 | | - |
56 | | - // Get file metadata |
57 | | - final stat = await ioFile.stat(); |
58 | | - final modified = stat.modified; |
59 | | - |
60 | | - // Generate a unique ID for this file |
61 | | - final id = '${modified.millisecondsSinceEpoch}_${path.hashCode}'; |
62 | | - _filePathCache[id] = path; |
63 | | - |
64 | | - // Determine if it's a video based on extension |
65 | | - final extension = path.toLowerCase().split('.').last; |
66 | | - final isVideo = ['mp4', 'mov', 'avi', 'mkv', 'webm'].contains(extension); |
67 | | - |
68 | | - results.add( |
69 | | - AssetInfo( |
70 | | - id: id, |
71 | | - type: isVideo ? AssetType.video : AssetType.image, |
72 | | - createDateTime: modified, |
73 | | - width: 0, // Not available without decoding |
74 | | - height: 0, |
75 | | - durationSeconds: null, |
76 | | - latitude: null, |
77 | | - longitude: null, |
78 | | - filename: path.split(Platform.pathSeparator).last, |
79 | | - ), |
80 | | - ); |
| 59 | + // Reading capture time and dimensions means reading each file's bytes, |
| 60 | + // which would jank the UI thread for a pick of large photos (the old |
| 61 | + // stat()-only implementation was cheap enough to inline). Do the batch on |
| 62 | + // a background isolate, then register the paths here -- the isolate only |
| 63 | + // ever mutates its own copy of [_filePathCache]. |
| 64 | + final assets = await compute( |
| 65 | + _extractAssets, |
| 66 | + files.map((f) => f.path).toList(), |
| 67 | + ); |
| 68 | + for (final asset in assets) { |
| 69 | + final path = asset.filePath; |
| 70 | + if (path != null) _filePathCache[asset.id] = path; |
81 | 71 | } |
| 72 | + return assets; |
| 73 | + } |
82 | 74 |
|
83 | | - return results; |
| 75 | + /// Builds the [AssetInfo] for one file chosen from the desktop file dialog |
| 76 | + /// and registers its path under the returned [AssetInfo.id]. |
| 77 | + /// |
| 78 | + /// Returns null when [ioFile] does not exist. |
| 79 | + /// |
| 80 | + /// The capture time comes from the file's own container metadata (JPEG / |
| 81 | + /// HEIC EXIF `DateTimeOriginal`, or the MP4/MOV `mvhd`) via |
| 82 | + /// [readLocalCaptureTime], falling back to the mtime only when the file |
| 83 | + /// carries no capture time at all. Reporting the mtime unconditionally -- |
| 84 | + /// as this service used to -- dates a photo to when it was copied off the |
| 85 | + /// camera card rather than when it was shot, which pushes it outside the |
| 86 | + /// dive window and leaves it unmatched. |
| 87 | + /// |
| 88 | + /// [readLocalCaptureTime] returns wall-clock-UTC, but [AssetInfo] is |
| 89 | + /// contractually LOCAL (photo_manager's convention on mobile, which |
| 90 | + /// consumers such as `TripMediaScanner.toWallClockUtc` reinterpret). The |
| 91 | + /// components are therefore carried across verbatim into a local DateTime; |
| 92 | + /// returning the UTC value directly would double-convert it. |
| 93 | + AssetInfo? assetInfoForFile(File ioFile) { |
| 94 | + final asset = _assetInfoForPath(ioFile.path); |
| 95 | + if (asset != null) _filePathCache[asset.id] = asset.filePath!; |
| 96 | + return asset; |
84 | 97 | } |
85 | 98 |
|
86 | 99 | @override |
@@ -121,3 +134,103 @@ class PhotoPickerServiceDesktop implements PhotoPickerService { |
121 | 134 | @override |
122 | 135 | Future<MediaSourceMetadata?> getAssetMetadata(String assetId) async => null; |
123 | 136 | } |
| 137 | + |
| 138 | +/// Batch entry point for [compute]: top-level so it can cross the isolate |
| 139 | +/// boundary (an instance method would close over `this`). Paths that no |
| 140 | +/// longer resolve are dropped. |
| 141 | +List<AssetInfo> _extractAssets(List<String> paths) { |
| 142 | + final results = <AssetInfo>[]; |
| 143 | + for (final path in paths) { |
| 144 | + final asset = _assetInfoForPath(path); |
| 145 | + // Null means the picked path vanished between dialog and read. |
| 146 | + if (asset != null) results.add(asset); |
| 147 | + } |
| 148 | + return results; |
| 149 | +} |
| 150 | + |
| 151 | +/// Pure metadata read for one path. Top-level and cache-free so it is safe to |
| 152 | +/// run on a [compute] isolate; [PhotoPickerServiceDesktop.assetInfoForFile] |
| 153 | +/// wraps it to register the path on the main isolate. |
| 154 | +AssetInfo? _assetInfoForPath(String path) { |
| 155 | + final ioFile = File(path); |
| 156 | + if (!ioFile.existsSync()) return null; |
| 157 | + |
| 158 | + final modified = ioFile.lastModifiedSync(); |
| 159 | + final mime = _mimeFromExtension(p.extension(path).toLowerCase()); |
| 160 | + |
| 161 | + final capturedUtc = readLocalCaptureTime(ioFile, mime); |
| 162 | + final createDateTime = capturedUtc == null |
| 163 | + ? modified |
| 164 | + : DateTime( |
| 165 | + capturedUtc.year, |
| 166 | + capturedUtc.month, |
| 167 | + capturedUtc.day, |
| 168 | + capturedUtc.hour, |
| 169 | + capturedUtc.minute, |
| 170 | + capturedUtc.second, |
| 171 | + capturedUtc.millisecond, |
| 172 | + // Today's readers are all second-granularity (EXIF |
| 173 | + // DateTimeOriginal, mvhd creation_time), so this is defensive -- |
| 174 | + // but dropping sub-second components on a reinterpretation that |
| 175 | + // exists only to swap the isUtc flag would be a silent lossy step. |
| 176 | + capturedUtc.microsecond, |
| 177 | + ); |
| 178 | + |
| 179 | + final size = _dimensionsOf(ioFile, mime); |
| 180 | + |
| 181 | + return AssetInfo( |
| 182 | + // Keyed on mtime + path so re-picking the same file in one session reuses |
| 183 | + // the entry. Only meaningful in-process; the durable pointer is the path. |
| 184 | + id: '${modified.millisecondsSinceEpoch}_${path.hashCode}', |
| 185 | + type: mime.startsWith('video/') ? AssetType.video : AssetType.image, |
| 186 | + createDateTime: createDateTime, |
| 187 | + // AssetResolutionService's timestamp+dimensions tiers reject a 0x0 row |
| 188 | + // outright, so real dimensions are what keep those tiers usable. |
| 189 | + width: size?.width ?? 0, |
| 190 | + height: size?.height ?? 0, |
| 191 | + durationSeconds: null, |
| 192 | + latitude: null, |
| 193 | + longitude: null, |
| 194 | + filename: p.basename(path), |
| 195 | + filePath: path, |
| 196 | + ); |
| 197 | +} |
| 198 | + |
| 199 | +/// Reads pixel dimensions from an image's header. |
| 200 | +/// |
| 201 | +/// Only the header is parsed -- `startDecode` stops before any pixel decode -- |
| 202 | +/// but the decoder API takes bytes, so the file is read in full first. That |
| 203 | +/// read is why the batch runs on a [compute] isolate; on the main thread a |
| 204 | +/// pick of large images would visibly jank the UI. |
| 205 | +/// |
| 206 | +/// Returns null for videos and for anything the decoder cannot read. |
| 207 | +({int width, int height})? _dimensionsOf(File file, String mime) { |
| 208 | + if (!mime.startsWith('image/')) return null; |
| 209 | + try { |
| 210 | + final bytes = file.readAsBytesSync(); |
| 211 | + // Extension first, then content sniffing for files whose name lies. |
| 212 | + final decoder = |
| 213 | + img.findDecoderForNamedImage(file.path) ?? |
| 214 | + img.findDecoderForData(bytes); |
| 215 | + final info = decoder?.startDecode(bytes); |
| 216 | + if (info == null) return null; |
| 217 | + return (width: info.width, height: info.height); |
| 218 | + } on Object { |
| 219 | + // Unsupported or truncated container: dimensions are optional here. |
| 220 | + return null; |
| 221 | + } |
| 222 | +} |
| 223 | + |
| 224 | +String _mimeFromExtension(String ext) => switch (ext) { |
| 225 | + '.jpg' || '.jpeg' => 'image/jpeg', |
| 226 | + '.png' => 'image/png', |
| 227 | + '.heic' => 'image/heic', |
| 228 | + '.heif' => 'image/heif', |
| 229 | + '.webp' => 'image/webp', |
| 230 | + '.gif' => 'image/gif', |
| 231 | + '.mp4' => 'video/mp4', |
| 232 | + '.mov' => 'video/quicktime', |
| 233 | + '.m4v' => 'video/x-m4v', |
| 234 | + '.avi' || '.mkv' || '.webm' => 'video/x-generic', |
| 235 | + _ => 'application/octet-stream', |
| 236 | +}; |
0 commit comments