file: add path-only pick + native video thumbnail
Two new methods on `UxFile`, both designed to keep large file content
out of the platform-channel buffer (the failure mode of file_selector
on Android: a ~200 MB video PUT through the Pigeon codec OOM'd the
JVM via `byte[size]` allocation in `FileSelectorApiImpl`).
`UxFile.pick({mimeTypes})` returns `UxPickedFile?` with `path`, `name`,
`mimeType`, `size`. The platform channel reply carries only the
metadata; bytes never cross.
- Android: `ACTION_OPEN_DOCUMENT` + `EXTRA_MIME_TYPES`, registered
as `ActivityResultListener`. On result, stream-copies the SAF
content URI to `cacheDir/ux_pick/<ts>_<safeName>` via an 8 KB
buffer (no full-file allocation in JVM heap), returns the cache
path.
- iOS: `UIDocumentPickerViewController(documentTypes:in: .import)`
— `.import` mode copies the picked file into the app's
Documents/Inbox so the URL is stable. Strong-retained delegate
(the picker's delegate ref is weak).
- macOS: `NSOpenPanel` with `allowedFileTypes`. Sheet-modal when a
Flutter window exists; free-modal otherwise.
`UxFile.videoThumbnail({path, atMs, maxWidth})` returns
`UxVideoThumbnail?` (PNG bytes + dims).
- Android: `MediaMetadataRetriever.getFrameAtTime(..., OPTION_CLOSEST_SYNC)`,
`Bitmap.createScaledBitmap` to maxWidth, PNG-encode via
`ByteArrayOutputStream`, recycle bitmaps in `finally`, release
retriever in `finally`.
- iOS: `AVAssetImageGenerator` with `appliesPreferredTrackTransform = true`,
`maximumSize = (maxWidth, 0)` (preserve aspect), ±500 ms tolerance
for keyframe alignment, decode on `userInitiated` queue.
- macOS: same generator, encoded via `NSBitmapImageRep`.
Compatible with the package's existing iOS 13 / macOS 10.15 deployment
targets — uses legacy `kUTType*` + `UTTypeCreatePreferredIdentifierForTag`
instead of `UTType` (iOS 14 / macOS 11).
This commit is contained in:
@@ -1,7 +1,43 @@
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' show Rect;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// A single frame extracted from a video file. [pngBytes] is the encoded
|
||||
/// PNG ready to embed in a thumbnail proto / paint via `Image.memory`;
|
||||
/// [width] / [height] describe the encoded image, which may be smaller
|
||||
/// than the source video due to the `maxWidth` constraint at extraction.
|
||||
class UxVideoThumbnail {
|
||||
const UxVideoThumbnail({
|
||||
required this.pngBytes,
|
||||
required this.width,
|
||||
required this.height,
|
||||
});
|
||||
|
||||
final Uint8List pngBytes;
|
||||
final int width;
|
||||
final int height;
|
||||
}
|
||||
|
||||
/// A file the user picked. [path] is on local disk and readable by
|
||||
/// `dart:io File` — for Android content:// URIs the native side
|
||||
/// stream-copies the source to the app's cache; for iOS/macOS picks the
|
||||
/// file is copied to the temp dir so the path is stable after the picker
|
||||
/// dismisses. Bytes are never marshalled across the platform channel.
|
||||
class UxPickedFile {
|
||||
const UxPickedFile({
|
||||
required this.path,
|
||||
this.name,
|
||||
this.mimeType,
|
||||
this.size,
|
||||
});
|
||||
|
||||
final String path;
|
||||
final String? name;
|
||||
final String? mimeType;
|
||||
final int? size;
|
||||
}
|
||||
|
||||
class UxFile {
|
||||
UxFile._();
|
||||
|
||||
@@ -67,4 +103,62 @@ class UxFile {
|
||||
});
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
/// Present the system file picker. Returns the picked file's local-disk
|
||||
/// path (and optional metadata), or null if the user cancelled.
|
||||
///
|
||||
/// File content is **never** marshalled across the platform channel —
|
||||
/// the native side only ships back the path. Use `dart:io` to read the
|
||||
/// file: `File(picked.path).openRead()` etc.
|
||||
///
|
||||
/// [mimeTypes] filters the picker. Each entry can be a concrete type
|
||||
/// (`image/png`), a wildcard (`image/*`), or `*/*`. Null = `[*/*]`.
|
||||
/// Note: Apple platforms map MIME → UTType internally; common types
|
||||
/// (`image/*`, `video/*`, `application/pdf`) work on all three. For
|
||||
/// Apple-specific types prefer concrete MIME like `image/jpeg` over
|
||||
/// wildcards.
|
||||
static Future<UxPickedFile?> pick({
|
||||
List<String>? mimeTypes,
|
||||
}) async {
|
||||
final result = await _channel.invokeMapMethod<String, Object?>('pick', {
|
||||
if (mimeTypes != null) 'mimeTypes': mimeTypes,
|
||||
});
|
||||
if (result == null) return null;
|
||||
final path = result['path'] as String?;
|
||||
if (path == null) return null;
|
||||
return UxPickedFile(
|
||||
path: path,
|
||||
name: result['name'] as String?,
|
||||
mimeType: result['mimeType'] as String?,
|
||||
size: (result['size'] as num?)?.toInt(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Extract a single frame from the video at [path]. Returns null if the
|
||||
/// platform's media decoder couldn't open the file (unsupported codec /
|
||||
/// corrupt / not actually a video).
|
||||
///
|
||||
/// [atMs] picks the frame timestamp in milliseconds (default 0 = first
|
||||
/// available keyframe). [maxWidth] caps the output's longer edge while
|
||||
/// preserving aspect ratio.
|
||||
static Future<UxVideoThumbnail?> videoThumbnail({
|
||||
required String path,
|
||||
int atMs = 0,
|
||||
int maxWidth = 320,
|
||||
}) async {
|
||||
final result = await _channel.invokeMapMethod<String, Object?>(
|
||||
'videoThumbnail',
|
||||
<String, Object?>{
|
||||
'path': path,
|
||||
'atMs': atMs,
|
||||
'maxWidth': maxWidth,
|
||||
},
|
||||
);
|
||||
if (result == null) return null;
|
||||
final bytes = result['png'] as Uint8List?;
|
||||
final width = (result['width'] as num?)?.toInt();
|
||||
final height = (result['height'] as num?)?.toInt();
|
||||
if (bytes == null || width == null || height == null) return null;
|
||||
return UxVideoThumbnail(pngBytes: bytes, width: width, height: height);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user