juice_media 0.5.0
juice_media: ^0.5.0 copied to clipboard
Media acquisition (camera/gallery) and per-item upload state as a Juice bloc, behind swappable seams.
juice_media #
Media acquisition (camera/gallery) and per-item upload state as a Juice bloc, behind swappable seams.
Why #
Picking media is one async call; the hard part is the state around it — multiple items, per-item upload progress, cancellation, retry, permissions. This models all of that as a testable bloc where each item's progress widget rebuilds only when that item changes.
What it owns #
Acquired items and their upload state. It does not own byte persistence
(that's storage / your backend), the upload transport (the MediaUploader
seam), or editing/cropping UI.
Install #
dependencies:
juice_media: ^0.1.0
The default source uses image_picker — follow its platform setup (Info.plist /
AndroidManifest camera & photo strings).
Use #
final media = MediaBloc.withConfig(MediaConfig(
uploader: MyUploader(), // required for uploads
));
media.pickFromGallery(multiple: true);
media.captureFromCamera();
media.uploadAll();
Per-item selective rebuild #
Each item owns a rebuild group. Progress on one item rebuilds only that tile:
class Tile extends StatelessJuiceWidget<MediaBloc> {
Tile({required this.id}) : super(key: ValueKey(id), groups: {MediaGroups.item(id)});
final String id;
@override
Widget onBuild(BuildContext context, StreamStatus status) {
final up = bloc.state.uploads[id];
return LinearProgressIndicator(value: up?.progress ?? 0);
}
}
The upload seam (where your backend plugs in) #
There's no universal uploader, so you inject one. It's handle-based — a progress stream, a result, and a cancel — mirroring real upload clients:
class MyUploader implements MediaUploader {
@override
MediaUpload upload(MediaItem item) => MyUpload(item); // Dio onSendProgress + CancelToken, S3, Firebase Storage…
@override
Future<void> dispose() async {}
}
abstract class MediaUpload {
Stream<double> get progress; // 0..1
Future<String> get result; // remote URL
void cancel();
}
upload / uploadAll start uploads; cancelUpload(id) aborts one. Status flows
queued → uploading → completed / failed / cancelled.
Remote items (mixed galleries) #
Real edit screens show existing hosted images alongside newly-picked local
ones. Add remote-origin items and they slot into the same gallery — rendered,
counted, and skipped by uploadAll automatically (they're seeded as
completed):
// At init…
MediaConfig(
uploader: MyUploader(),
initialItems: [
MediaItem.remote(id: 'a', uri: 'https://cdn/a.jpg', name: 'a.jpg'),
],
);
// …or at runtime:
media.addRemoteItems([MediaItem.remote(id: 'b', uri: 'https://cdn/b.jpg', name: 'b.jpg')]);
item.isRemote distinguishes them. Render remote items with
Image.network(item.uri!), local items from path/bytes. uploadAll uploads
only the local, not-yet-uploaded ones.
Pick sessions (draft partitioning, 0.4) #
When several contexts share one bloc (e.g. a capture draft opened while a previous draft's photos are still uploading), tag each context's picks with a session and filter:
media.pickFromGallery(session: draftId); // items stamped with the tag
final draftItems = media.state.inSession(draftId); // only this draft's items
No more snapshot-and-diff of item ids.
Local items (re-upload after a restart, 0.4) #
Items normally enter via pick(). To upload a file you persisted earlier
(e.g. media saved while signed out, re-synced after an app restart), rebuild the
item from its path:
media.addLocalItems([
MediaItem.local(id: rowId, path: '/…/photo.jpg', name: 'photo.jpg'),
]);
media.upload(rowId); // normal upload path, progress and all
Fails loud on a remote-origin item or one with no path/bytes.
Asset identity (dedupe across paths, 0.5) #
A MediaItem carries an optional assetId — the source library asset id
(PHAsset.localIdentifier / MediaStore id), the stable identity of the
underlying photo. The default image_picker source can't surface it (it hands
back a copied file), so it stays null there; a MediaSource that does have it
(e.g. a photo_manager-backed gallery source you inject via MediaConfig(source:))
sets it:
final media = MediaBloc.withConfig(MediaConfig(source: MyPhotoManagerSource()));
// picked items now carry item.assetId — persist it to dedupe the same photo
// across re-picks and other ingestion paths (e.g. a bulk library scan).
Fail-loud #
Calling upload with no uploader configured marks the item failed and sets
state.lastError — never a silent no-op.
Permissions #
Capability-tier: the bloc holds permissionGranted, set via
setPermissionStatus. Wire it from juice_permissions:
PermissionBinding(permissions, JuicePermission.photos,
onStatus: (s) => media.setPermissionStatus(s == PermissionStatus.granted),
)..start();
No juice_permissions dependency leaks in.
State #
| Field / getter | Meaning |
|---|---|
items |
acquired MediaItems (selection order) |
uploads |
id → UploadState (status/progress/remoteUrl/error) |
picking |
an acquisition is in flight |
isUploading / allUploaded |
derived |
permissionGranted / lastError |
informational |
Rebuild groups: MediaGroups.item(id), any, picking, permission, error.
License #
MIT License — see LICENSE.