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:
agra
2026-05-02 13:14:21 +03:00
parent a7735fdbb1
commit afc7e9c872
5 changed files with 581 additions and 9 deletions

View File

@@ -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);
}
}