juice_media 0.5.0 copy "juice_media: ^0.5.0" to clipboard
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.

pub package License: MIT

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.

0
likes
150
points
16
downloads

Documentation

Documentation
API reference

Publisher

unverified uploader

Weekly Downloads

Media acquisition (camera/gallery) and per-item upload state as a Juice bloc, behind swappable seams.

Homepage
Repository (GitHub)
View/report issues
Contributing

Topics

#media #image-picker #upload #bloc #state-management

Funding

Consider supporting this project:

github.com

License

MIT (license)

Dependencies

flutter, image_picker, juice

More

Packages that depend on juice_media