Media library
Read, save and organize the device’s photos and videos: query recent assets, save a file to the
library, build albums, and react to library changes. @symbiote-native/media-library wraps
expo-media-library for
every SymbioteNative adapter and ports both of upstream’s surfaces: the default export is the
modern Query/Asset/Album API built on JSI shared objects; the legacy function-based API lives
at the /legacy subpath. Permissions include the granular Android 13+ ones and the limited-access
picker (iOS, Android 14+).
| OS platform | Support |
|---|---|
| iOS | ✅ live |
| Android | ✅ live |
| Framework adapter | Support |
|---|---|
| React | ✅ live |
| Vue | ✅ live |
| Angular | ✅ live |
| Svelte | ✅ live |
| Solid | ✅ live |
Installation
Section titled “Installation”npm install @symbiote-native/media-libraryScaffolding or extending a SymbioteNative app? npx @symbiote-native/cli new --media-library (or
add --media-library in an existing app) installs and wires this for you — see
@symbiote-native/cli.
expo-media-library and expo-modules-core come along as regular dependencies, pinned to exact
versions — never install them yourself, and never add the expo meta-package to your project.
import { Query, Asset, Album, AssetField, MediaType, requestPermissionsAsync,} from '@symbiote-native/media-library';
const { granted } = await requestPermissionsAsync(false, ['photo']);if (granted) { const asset = await Asset.create(photoUri); const album = await Album.create('My Album', [asset]);
const recentPhotos = await new Query() .eq(AssetField.MEDIA_TYPE, MediaType.IMAGE) .orderBy(AssetField.CREATION_TIME) .limit(20) .exe();}Identical import surface on every adapter — @symbiote-native/media-library/react, /vue,
/svelte, /solid, /angular all re-export the same classes.
Builder pattern — every filter/sort method returns the same instance for chaining.
new Query().eq(field, value) / .within(field, value[]) / .gt(field, value) / .gte(field, value) /.lt(field, value) / .lte(field, value) // AssetField-keyed filters.limit(n) / .offset(n).orderBy(sortDescriptor | AssetField).album(album: Album).exe(): Promise<Asset[]>.exeForMetadata(): Promise<AssetMetadata[]> // lightweight fields onlynew Asset(id: string)static create(filePath, album?): Promise<Asset>static delete(assets: Asset[]): Promise<void>
.id.getCreationTime() / .getModificationTime(): Promise<number | null>.getDuration(): Promise<number | null> // audio/video only.getFilename() / .getUri(): Promise<string>.getWidth() / .getHeight(): Promise<number>.getMediaType(): Promise<MediaType>.getShape(): Promise<Shape | null>.getInfo(): Promise<AssetInfo>.getAlbums(): Promise<Album[]>.getLocation(): Promise<Location | null> // android needs ACCESS_MEDIA_LOCATION.getExif(): Promise<Record<string, unknown>>.getFavorite(): Promise<boolean> / .setFavorite(isFavorite): Promise<void>.delete(): Promise<void>.getMediaSubtypes(): Promise<MediaSubtype[]> // ios only.getLivePhotoVideoUri(): Promise<string | null> // ios only.getIsInCloud(): Promise<boolean> // ios only.getOrientation(): Promise<number | null> // ios onlynew Album(id: string)static create(name, assetsRefs, moveAssets?): Promise<Album>static delete(albums: Album[], deleteAssets?): Promise<void>static get(title): Promise<Album | null>static getAll(): Promise<Album[]>
.id.getAssets(): Promise<Asset[]>.getTitle(): Promise<string>.add(assets: Asset | Asset[]): Promise<void>.removeAssets(assets: Asset[]): Promise<void> // ios only.delete(): Promise<void>Permissions and change events
Section titled “Permissions and change events”requestPermissionsAsync(writeOnly?, granularPermissions?) /getPermissionsAsync(writeOnly?, granularPermissions?): Promise<PermissionResponse>usePermissions(options?)presentPermissionsPicker(mediaTypes?): Promise<void> // android 14+ / iosaddListener(listener) / removeAllListeners(): voidPlus the AssetField, MediaType, MediaSubtype runtime enums and the full I* type surface.
Errors
Section titled “Errors”Every native exception surfaces as an ordinary thrown Error or rejected Promise — no custom
error-class hierarchy.
| Trigger | When |
|---|---|
Calling an Asset/Album method on an ID that no longer exists on the device |
Native exception — “could not be found” |
Asset.getMediaSubtypes()/.getLivePhotoVideoUri()/.getIsInCloud()/.getOrientation() on Android |
UnavailabilityError — thrown synchronously, iOS only |
Album.removeAssets() on Android |
Native exception — an Android asset belongs to one album; delete it or add it to another instead |
getLocation() on Android without ACCESS_MEDIA_LOCATION |
Rejects — needs that runtime permission |
Legacy API (/legacy)
Section titled “Legacy API (/legacy)”Upstream’s original function-based surface — plain async functions over expo-modules-core
instead of JSI shared objects.
import { requestPermissionsAsync, createAssetAsync, getAssetsAsync, addListener } from '@symbiote-native/media-library/legacy';
const { granted } = await requestPermissionsAsync(false, ['photo']);if (granted) { const asset = await createAssetAsync(photoUri); const page = await getAssetsAsync({ first: 20, mediaType: 'photo' });
const subscription = addListener(event => { console.log('library changed', event.hasIncrementalChanges); }); // later: subscription.remove();}Legacy API reference
Section titled “Legacy API reference”isAvailableAsync(): Promise<boolean>requestPermissionsAsync(writeOnly?, granularPermissions?) / getPermissionsAsync(writeOnly?, granularPermissions?): Promise<IMediaLibraryPermissionResponse>usePermissions(options?)presentPermissionsPickerAsync(mediaTypes?): Promise<void> // android 14+ / ioscreateAssetAsync(localUri, album?): Promise<IMediaLibraryAsset>saveToLibraryAsync(localUri): Promise<void>addAssetsToAlbumAsync(assets, album, copy?): Promise<boolean>removeAssetsFromAlbumAsync(assets, album): Promise<boolean>deleteAssetsAsync(assets): Promise<boolean>getAssetInfoAsync(asset, options?): Promise<IMediaLibraryAssetInfo>getAssetContentUriAsync(asset): Promise<string> // androidgetAlbumsAsync(options?) / getAlbumAsync(title): Promise<IMediaLibraryAlbum[] | IMediaLibraryAlbum>createAlbumAsync(albumName, asset?, copyAsset?, initialAssetLocalUri?): Promise<IMediaLibraryAlbum>deleteAlbumsAsync(albums, deleteAssets?): Promise<boolean>getAssetsAsync(options?): Promise<IMediaLibraryPagedInfo<IMediaLibraryAsset>>addListener(listener) / removeAllListeners(): voidgetMomentsAsync(): Promise<IMediaLibraryAlbum[]> // iosmigrateAlbumIfNeededAsync(album) / albumNeedsMigrationAsync(album): Promise<void | boolean> // android R+setAssetFavoriteAsync(asset, isFavorite): Promise<boolean> // iosMediaType, SortBy // runtime constantsPlus the full IMediaLibrary* type surface.
Common questions
Section titled “Common questions”saveToLibraryAsync is missing or throws. The root entry is the modern class API. Save a file
with Asset.create(uri), which returns an Asset. If you still want the old function, import it
from @symbiote-native/media-library/legacy.
I need to save a photo to the Gallery after downloading it. Download it to the app’s cache with
the file system package, then Asset.create(localUri). Pass an
Album to file it under an album.
Android 13 and later: permission denied although I asked. Add the granular manifest permissions
you need (READ_MEDIA_IMAGES, READ_MEDIA_VIDEO, READ_MEDIA_AUDIO) and pass the matching subset to
requestPermissionsAsync(false, ['photo']). A permission missing from the manifest is silently
refused whatever you ask at runtime.
On iOS the uri starts with ph:// and I cannot read it as a file. That is a Photos library
identifier. Use asset.getInfo() (or getAssetInfoAsync in the legacy API) to get a local file URI.
Images show the wrong orientation on Android in getAssetsAsync. In the legacy API, EXIF data is
read only when you pass resolveWithFullInfo: true. The modern Asset API reads it with
getExif().
The user picked “limited access”. You then see only the photos they shared. Use
presentPermissionsPicker() (Android 14+ and iOS) to let them choose more.
Sources: Expo docs: MediaLibrary (legacy), Expo guide: migrate to the new expo-media-library API, expo/expo#50670 fix the screenshot save on SDK 57 by using Asset.create, bluesky-social/social-app#11662 migrate Expo media library API.
Legacy API notes
Section titled “Legacy API notes”sortBy’s single-tuple form must be double-nested.getAssetsAsync({ sortBy: [['creationTime', true]] })sorts by one key ascending; the unnestedsortBy: ['creationTime', true]reads as two independent (and here, invalid) sort keys. An upstream quirk, ported verbatim.getAssetContentUriAsyncis Android-only — a plaincontent://URI, still useful alongside the modernAsset/QueryAPI above.getMomentsAsyncandsetAssetFavoriteAsyncare iOS-only;getAssetContentUriAsync,migrateAlbumIfNeededAsync, andalbumNeedsMigrationAsyncare Android-only or Android-R+-only. Each throws (or, for the migration pair, resolves a safe default) on the wrong platform.