Skip to content

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
Terminal window
npm install @symbiote-native/media-library

Scaffolding 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 only
new 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 only
new 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>
requestPermissionsAsync(writeOnly?, granularPermissions?) /
getPermissionsAsync(writeOnly?, granularPermissions?): Promise<PermissionResponse>
usePermissions(options?)
presentPermissionsPicker(mediaTypes?): Promise<void> // android 14+ / ios
addListener(listener) / removeAllListeners(): void

Plus the AssetField, MediaType, MediaSubtype runtime enums and the full I* type surface.

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

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();
}
isAvailableAsync(): Promise<boolean>
requestPermissionsAsync(writeOnly?, granularPermissions?) / getPermissionsAsync(writeOnly?, granularPermissions?): Promise<IMediaLibraryPermissionResponse>
usePermissions(options?)
presentPermissionsPickerAsync(mediaTypes?): Promise<void> // android 14+ / ios
createAssetAsync(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> // android
getAlbumsAsync(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(): void
getMomentsAsync(): Promise<IMediaLibraryAlbum[]> // ios
migrateAlbumIfNeededAsync(album) / albumNeedsMigrationAsync(album): Promise<void | boolean> // android R+
setAssetFavoriteAsync(asset, isFavorite): Promise<boolean> // ios
MediaType, SortBy // runtime constants

Plus the full IMediaLibrary* type surface.

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.

  • sortBy’s single-tuple form must be double-nested. getAssetsAsync({ sortBy: [['creationTime', true]] }) sorts by one key ascending; the unnested sortBy: ['creationTime', true] reads as two independent (and here, invalid) sort keys. An upstream quirk, ported verbatim.
  • getAssetContentUriAsync is Android-only — a plain content:// URI, still useful alongside the modern Asset/Query API above.
  • getMomentsAsync and setAssetFavoriteAsync are iOS-only; getAssetContentUriAsync, migrateAlbumIfNeededAsync, and albumNeedsMigrationAsync are Android-only or Android-R+-only. Each throws (or, for the migration pair, resolves a safe default) on the wrong platform.