@obsidian_north/react-native-mediastore
v2.1.1
Published
Universal high-performance media indexing library for Android (MediaStore) and iOS (Photos Framework)
Maintainers
Readme
@obsidian_north/react-native-mediastore
Universal high-performance media indexing library for Android and iOS.
A reusable Expo Module that provides fast, production-grade access to media and indexed documents on Android (via MediaStore) and iOS (via Photos Framework) — no recursive filesystem scanning.
| Build | Lint | Type Check | Tests | Release | |-------|------|------------|-------|---------| | ✅ | ✅ | ✅ | ✅ | ✅ |
Architecture
App
│
▼
TypeScript SDK
│
▼
Expo Module (expo-module-core)
│
├── Permission Manager
├── Cache Manager (LRU + TTL)
├── Search Engine
├── Observer (ContentObserver / PHPhotoLibraryChangeObserver)
├── Repository
│
├─── Android ──→ MediaStore (SQLite Index) ──→ ContentResolver
│ │
│ ▼
│ Cursor → Mapper → Domain Models → JSON
│
└─── iOS ─────→ Photos Framework (PHAsset) ──→ PHImageManager / PHAssetFetchRequest
│
▼
PHAsset → Domain Models → JSONQuery Flow
getAudio()
│
▼
Permission Check ─── DENIED ──→ PermissionDenied error
│
▼ (granted)
Cache Lookup ─── HIT ──→ Return cached result
│
▼ (miss)
Native Query (ContentResolver / PHAsset)
│
▼
Object Mapping (typed projection / PHAsset properties)
│
▼
JSON Serialization
│
▼
React NativeThread Model
JS Thread ──→ async/await Promise
│
▼
Native Module Thread (coroutine dispatcher / DispatchQueue)
│
▼
IO Dispatcher (Dispatchers.IO) / Background Queue
│
▼
MediaStore / Photos Framework
│
▼
Back to JS (Promise resolved)All queries execute on background dispatchers — the UI thread is never blocked.
Memory Usage
100,000 songs
│
▼
Cursor / PHFetchResult (lazy, not loaded entirely)
│
▼
Stream rows individually
│
▼
Map each row → JSON object
│
▼
Collect results
│
▼
Dispose Cursor / Release PHFetchResultNo entire library is loaded into memory. Each row is mapped and collected incrementally, then the cursor is closed in a use block.
Features
- Cross-platform — Android (MediaStore) and iOS (Photos Framework) with a unified API
- Blazing fast — queries the native media database directly, no recursive directory scans
- All media types — audio, video, images, and documents (Android only; PDF, DOC/DOCX, XLS/XLSX, PPT/PPTX, TXT, EPUB, RTF, CSV, JSON, XML, ZIP, RAR, 7Z)
- Rich metadata — duration, resolution, bitrate, EXIF, GPS, album art, and more
- Sorted & filtered queries — sort by name, date, size, duration, artist, etc. Filter by MIME, extension, folder, date range, size range, and more
- Full-text search — prefix, partial, case-insensitive, multi-keyword, unicode-aware
- Pagination — limit/offset and cursor-based pagination on every API
- Real-time change observation —
ContentObserver(Android) /PHPhotoLibraryChangeObserver(iOS) fires events when files are added, removed, or modified - Permissions-aware — scoped
READ_MEDIA_*permissions on Android 13+,PHPhotoLibraryauthorization on iOS, automatic fallback - LRU caching — optional in-memory cache with configurable TTL, auto-invalidated on changes
- Fully typed — complete TypeScript definitions, no
any - Reactive — React hook
useMediaChangeEventfor real-time updates - Batch queries —
getLibrary()returns all media types in one native call - Thumbnail/artwork — helper methods for album art and video/image thumbnails
- Folder statistics — size histograms and per-type breakdowns for folders
- Incremental indexing — delta-only refresh tracking added, modified, and removed items
- Plugin hooks — extensible metadata system via JS-side plugin registration
- Improved batch queries — per-type pagination, selective type fetching, and query timing
Performance Benchmarks
| Library | 50k songs | |---------|----------:| | Recursive FS | 14 sec | | MediaStore | 280 ms |
| Operation | Time |
|-----------|-----:|
| getAudio | 120 ms |
| getVideos | 90 ms |
| getImages | 95 ms |
| getDocuments | 70 ms |
| search | 25 ms |
| getStatistics | 15 ms |
Measurements taken on a Pixel 7 (Android 14) with 50k audio, 2k video, 10k images. Results cached after first query.
Installation
npm install @obsidian_north/react-native-mediastoreOr with a development build:
npx expo install @obsidian_north/react-native-mediastorePrerequisites
- Expo SDK 52+ or React Native with New Architecture enabled
- Android: API 21+ (Android 5.0)
- Android 13+ (API 33): granular media permissions are requested automatically
- Android 12 and below:
READ_EXTERNAL_STORAGEpermission is required
- iOS: iOS 13.0+
- Photos Framework permission is requested automatically via
PHPhotoLibrary.requestAuthorization
- Photos Framework permission is requested automatically via
Permissions Matrix
| Platform | Version | Permission |
|----------|---------|-----------|
| iOS | 13.0+ | PHPhotoLibrary (read/write authorization) |
| Android 15 | 35 | READ_MEDIA_AUDIO, READ_MEDIA_VIDEO, READ_MEDIA_IMAGES |
| Android 14 | 34 | READ_MEDIA_AUDIO, READ_MEDIA_VIDEO, READ_MEDIA_IMAGES |
| Android 13 | 33 | READ_MEDIA_AUDIO, READ_MEDIA_VIDEO, READ_MEDIA_IMAGES |
| Android 12 | 32 | READ_EXTERNAL_STORAGE |
| Android 11 | 30–31 | READ_EXTERNAL_STORAGE |
| Android 10 | 29 | READ_EXTERNAL_STORAGE |
| Android 5–9 | 21–28 | READ_EXTERNAL_STORAGE |
Call requestPermissions() before querying media on first launch.
Compatibility Matrix
| Expo SDK | React Native | AGP | Kotlin | Gradle | Status | |----------|-------------|-----|--------|--------|--------| | 52 | 0.76 | 8.7 | 2.0.21 | 8.11 | ✅ | | 56 | 0.85 | 9.0 | 2.1.20 | 9.3 | ✅ |
Comparison
| Feature | react-native-mediastore | expo-file-system | react-native-fs | |---------|:---------------------------:|:----------------:|:----------------:| | Android | ✅ | ✅ | ✅ | | iOS | ✅ | ✅ | ✅ | | Audio (music) | ✅ | ❌ | ⚠️ | | Video | ✅ | ❌ | ⚠️ | | Images | ✅ | ❌ | ⚠️ | | Documents | ✅ (Android) | ❌ | ⚠️ | | Rich metadata | ✅ | ❌ | ❌ | | Album art | ✅ | ❌ | ❌ | | EXIF/GPS | ✅ | ❌ | ❌ | | Full-text search | ✅ | ❌ | ❌ | | Watch changes | ✅ | ❌ | ❌ | | Pagination | ✅ | ❌ | ❌ | | Sorting | ✅ | ❌ | ❌ | | Filters | ✅ | ❌ | ❌ | | Batch query | ✅ | ❌ | ❌ | | Duplicate detection | ✅ | ❌ | ❌ | | Statistics | ✅ | ❌ | ❌ | | Favorites | ✅ | ❌ | ❌ | | Folder histograms | ✅ | ❌ | ❌ | | Incremental indexing | ✅ | ❌ | ❌ | | Plugin metadata | ✅ | ❌ | ❌ |
⚠️ = partial support (no metadata, no filtering)
Quick Start
import { getAudio, getImages, useMediaChangeEvent } from "@obsidian_north/react-native-mediastore";
// Fetch all audio tracks
const songs = await getAudio(
{ field: "dateAdded", order: "desc" },
{ minDuration: 30_000 }
);
// Fetch recent images with pagination
const photos = await getImages(
{ field: "dateAdded", order: "desc" },
null,
{ limit: 20, offset: 0 }
);
// Batch query — audio, video, images, documents in one call
const library = await getLibrary(
{ field: "dateAdded", order: "desc" }
);
// Fetch album artwork
const artworkUri = await getAlbumArtwork(albumId);
// Listen for MediaStore changes
function MediaWatcher() {
const event = useMediaChangeEvent((e) => {
switch (e.type) {
case "added":
console.log(`New ${e.mediaType}: ${e.uri}`);
break;
case "removed":
console.log(`${e.mediaType} removed: ${e.itemId}`);
break;
case "modified":
console.log(`${e.mediaType} modified: ${e.itemId}`);
break;
}
});
return null;
}API Reference
Media Queries
| Function | Returns | Description |
|----------|---------|-------------|
| getAudio(sort?, filter?, pagination?) | AudioItem[] | Fetch audio tracks |
| getVideos(sort?, filter?, pagination?) | VideoItem[] | Fetch video files |
| getImages(sort?, filter?, pagination?) | ImageItem[] | Fetch images |
| getDocuments(sort?, filter?, pagination?) | DocumentItem[] | Fetch documents (Android only) |
| getAlbums(sort?, filter?, pagination?) | Album[] | Fetch audio albums |
| getArtists(sort?, pagination?) | Artist[] | Fetch artists |
| getGenres(sort?, pagination?) | Genre[] | Fetch genres |
| getPlaylists(sort?, pagination?) | Playlist[] | Fetch playlists |
| getFolders(sort?, filter?, pagination?) | Folder[] | Aggregate files by folder (Android only) |
Search & Lookup
| Function | Returns | Description |
|----------|---------|-------------|
| search(options) | SearchResult | Full-text search across media types |
| getById(mediaType, id) | AudioItem \| VideoItem \| ImageItem \| DocumentItem \| null | Lookup by database ID |
| getByUri(uri) | AudioItem \| VideoItem \| ImageItem \| DocumentItem \| null | Lookup by content URI |
Utility Queries
| Function | Returns | Description |
|----------|---------|-------------|
| getRecent(mediaType?, limit?) | (AudioItem \| VideoItem \| ImageItem \| DocumentItem)[] | Most recently added items |
| getFavorites(mediaType?, sort?, pagination?) | (AudioItem \| VideoItem \| ImageItem \| DocumentItem)[] | Starred/favorited items |
| getLargestFiles(mediaType?, limit?) | (AudioItem \| VideoItem \| ImageItem \| DocumentItem)[] | Largest files by size |
| getDuplicates(mediaType?) | DuplicateItem[] | Detect duplicate files (Android only) |
| getStatistics() | MediaStoreStatistics | Aggregate counts and sizes |
| getLibrary(sort?, filter?, pagination?) | LibraryResult | Audio, video, images, docs in one batch |
| getLibraryQuery(options?) | LibraryQueryResult | Improved batch query with per-type pagination and statistics |
| getFolderStatistics(folderPath?) | FolderStatistics[] | Size histograms and type breakdowns per folder |
| refreshIncremental(lastTimestamp?) | IncrementalChanges | Delta-only refresh tracking changes since timestamp |
| getLastRefreshTimestamp() | number | Get last refresh timestamp |
Plugin System
| Function | Returns | Description |
|----------|---------|-------------|
| registerPlugin(plugin) | void | Register a metadata extractor plugin |
| unregisterPlugin(pluginId) | boolean | Remove a plugin by ID |
| getRegisteredPlugins() | MetadataPlugin[] | List all registered plugins |
Artwork & Thumbnails
| Function | Returns | Description |
|----------|---------|-------------|
| getAlbumArtwork(albumId) | string \| null | Album art content URI |
| getVideoThumbnail(videoId, width?, height?) | string \| null | Video thumbnail URI |
| getImageThumbnail(imageId, width?, height?) | string \| null | Image thumbnail URI |
System
| Function | Returns | Description |
|----------|---------|-------------|
| refresh() | void | Invalidate all caches |
| checkPermissions() | PermissionStatus | Check current permission state |
| requestPermissions() | PermissionStatus | Request media permissions |
| useMediaChangeEvent(callback?) | MediaChangeEvent \| null | React hook for change events |
Error Codes
Every thrown error has a structured MediaStoreError with a typed code:
| Code | Meaning |
|------|---------|
| PERMISSION_DENIED | Required media permissions not granted |
| QUERY_FAILED | MediaStore / Photos query failed (database error) |
| INVALID_ARGUMENTS | Invalid sort field, MIME type, or filter option |
| INVALID_SORT_FIELD | The requested sort field is not valid for this media type |
| INVALID_MIME_TYPE | The MIME type filter does not match any known type |
| UNSUPPORTED_ANDROID_VERSION | Android version below minimum SDK (21) |
| FILE_UNAVAILABLE | File not found or inaccessible |
| CURSOR_CLOSED | Cursor was closed before iteration completed |
| CACHE_FAILURE | Cache operation failed |
| UNKNOWN_ERROR | Unexpected error |
try {
const songs = await getAudio();
} catch (error) {
if (error.code === "PERMISSION_DENIED") {
// Handle permission flow
}
}Event System
The module uses Android's ContentObserver and iOS's PHPhotoLibraryChangeObserver to monitor media changes and emit events to JavaScript.
Events
| Event | Payload | Description |
|-------|---------|-------------|
| mediaAdded | { type: "added", mediaType, itemId, uri } | New media file indexed |
| mediaRemoved | { type: "removed", mediaType, itemId, uri } | Media file deleted |
| mediaModified | { type: "modified", mediaType, itemId, uri } | Metadata or file modified |
| permissionChanged | { type: "permissionChanged", granted, mediaType } | Permission state changed |
| cacheInvalidated | { type: "cacheInvalidated" } | Cache cleared automatically |
React Hook
import { useMediaChangeEvent } from "@obsidian_north/react-native-mediastore";
function MyComponent() {
const lastEvent = useMediaChangeEvent((event) => {
// React to every change
});
return <Text>Last change: {lastEvent?.type}</Text>;
}Platform Support
Android
getFolders() aggregates files into folders by their relativePath.
interface Folder {
id: string;
name: string; // Display name (last segment)
path: string; // Full relative path
fileCount: number; // Total files in folder
totalSize: number; // Cumulative size in bytes
}- Grouping: Files are grouped by
relativePath(e.g.Music/Artist/Album) - Counts:
fileCountis the number of files in that folder - Sorting: Supports sort fields like
name,dateAdded,dateModified,fileSize - Filtering: Supports
mimeTypes,extensions,folder(deep path prefix),minSize/maxSize - Statistics: Use
getFolderStatistics()for size histograms and per-type breakdowns per folder
iOS
iOS uses Apple's Photos Framework (PHAsset) for media indexing:
- Audio, video, image queries with metadata via
PHAssetproperties - Album, artist, genre, playlist aggregation via
PHAssetCollection - Search with
CONTAINS[cd]matching (case/diacritic-insensitive) - Thumbnail generation via
PHImageManager.requestImage() - Real-time changes via
PHPhotoLibraryChangeObserver - Documents return an empty array (no Photos Framework equivalent)
- Folders return an empty array (no equivalent aggregation)
Advanced Search
import { search } from "@obsidian_north/react-native-mediastore";
const result = await search({
query: "beatles",
types: ["audio", "video"],
filter: {
artist: "Queen",
album: "Greatest Hits",
mimeTypes: ["audio/*"],
minDuration: 60_000,
},
sort: { field: "name", order: "asc" },
pagination: { limit: 50, cursor: "..." },
});
console.log(`${result.totalCount} results`);Caching
Request
│
▼
Cache Lookup ─── HIT (within TTL) ──→ Return cached
│
▼ (miss)
MediaStore Query
│
▼
Store in Cache (LRU eviction)
│
▼
Return result
│
▼ (on change)
Observer fires cacheInvalidated
│
▼
Cache cleared → next query goes to native DB- TTL: Configurable time-to-live per query (default: 30s)
- Eviction: LRU-based when cache reaches max entries (100)
- Invalidation: Automatic on media change events
- Refresh: Call
refresh()to manually clear all caches
Code Examples
Music Player Library
import { getAudio, getAlbums, getArtists, getAlbumArtwork } from "@obsidian_north/react-native-mediastore";
async function loadLibrary() {
const [songs, albums, artists] = await Promise.all([
getAudio({ field: "artist", order: "asc" }),
getAlbums(),
getArtists(),
]);
const albumArt = albums.reduce((map, album) => {
map[album.id] = getAlbumArtwork(album.id);
return map;
}, {} as Record<string, Promise<string | null>>);
return { songs, albums, artists, albumArt };
}Gallery with Thumbnails
import { getImages, getImageThumbnail } from "@obsidian_north/react-native-mediastore";
async function loadGallery() {
const images = await getImages(
{ field: "dateAdded", order: "desc" },
null,
{ limit: 100 }
);
return images.map((img) => ({
id: img.id,
uri: img.uri,
thumbnail: getImageThumbnail(img.id, 320, 320),
width: img.width,
height: img.height,
}));
}File Manager File List
import { getDocuments, getFolders } from "@obsidian_north/react-native-mediastore";
async function loadFileManager(folder?: string) {
const [files, folders] = await Promise.all([
getDocuments(null, { folder }),
getFolders(null, { folder }),
]);
return { files, folders };
}Search Screen
import { search, SearchResult } from "@obsidian_north/react-native-mediastore";
import { useState, useCallback } from "react";
function useSearch() {
const [results, setResults] = useState<SearchResult | null>(null);
const [loading, setLoading] = useState(false);
const query = useCallback(async (text: string) => {
setLoading(true);
const res = await search({ query: text, types: ["audio", "video", "image", "document"] });
setResults(res);
setLoading(false);
}, []);
return { results, loading, query };
}Infinite Scroll (Cursor-based)
import { getAudio } from "@obsidian_north/react-native-mediastore";
import { useState, useCallback } from "react";
const PAGE_SIZE = 30;
function useInfiniteScroll() {
const [songs, setSongs] = useState<any[]>([]);
const [cursor, setCursor] = useState<string | undefined>();
const loadMore = useCallback(async () => {
const page = await getAudio(
{ field: "name", order: "asc" },
null,
{ limit: PAGE_SIZE, cursor }
);
setSongs(prev => [...prev, ...page]);
}, [cursor]);
return { songs, loadMore };
}Pagination (Offset-based)
import { getVideos } from "@obsidian_north/react-native-mediastore";
async function getPage(page: number, pageSize: number = 20) {
return getVideos(
{ field: "dateAdded", order: "desc" },
null,
{ limit: pageSize, offset: page * pageSize }
);
}Album Browser
import { getAlbums, getAudio, getAlbumArtwork } from "@obsidian_north/react-native-mediastore";
async function loadAlbumBrowser() {
const albums = await getAlbums({ field: "year", order: "desc" });
const albumDetails = await Promise.all(
albums.map(async (album) => ({
...album,
songs: await getAudio(null, { album: album.title }),
artwork: await getAlbumArtwork(album.id),
}))
);
return albumDetails;
}Playlist Browser
import { getPlaylists, getAudio } from "@obsidian_north/react-native-mediastore";
async function loadPlaylistSongs(playlistId: string) {
return getAudio(null, { playlistId });
}Reactive Auto-refresh
import { getAudio, useMediaChangeEvent } from "@obsidian_north/react-native-mediastore";
import { useState, useEffect } from "react";
function useReactiveAudio() {
const [songs, setSongs] = useState<any[]>([]);
const refresh = async () => { setSongs(await getAudio()); };
useEffect(() => { refresh(); }, []);
useMediaChangeEvent(refresh);
return songs;
}Folder Statistics
import { getFolderStatistics } from "@obsidian_north/react-native-mediastore";
async function analyzeStorage() {
const folders = await getFolderStatistics();
for (const folder of folders) {
console.log(`${folder.path}: ${folder.fileCount} files, ${folder.totalSize} bytes`);
console.log(` <1MB: ${folder.histogram.lessThan1MB}`);
console.log(` 1-10MB: ${folder.histogram.from1to10MB}`);
console.log(` 10-100MB: ${folder.histogram.from10to100MB}`);
console.log(` Avg size: ${folder.averageFileSize} bytes`);
console.log(` Audio: ${folder.mediaTypeBreakdown.audio}, Video: ${folder.mediaTypeBreakdown.video}`);
}
}Incremental Indexing
import { refreshIncremental, getLastRefreshTimestamp } from "@obsidian_north/react-native-mediastore";
async function syncLibrary() {
const lastSync = await getLastRefreshTimestamp();
const changes = await refreshIncremental(lastSync);
console.log(`${changes.added} new items`);
console.log(`${changes.modified} modified items`);
console.log(`${changes.removed} removed items`);
}Plugin System
import { registerPlugin, getAudio, unregisterPlugin } from "@obsidian_north/react-native-mediastore";
// Register a plugin that adds star ratings based on duration
registerPlugin({
id: "duration-rating",
name: "Duration Rating",
version: "1.0.0",
extract: (item) => {
if ("duration" in item) {
const duration = (item as any).duration;
const rating = duration > 300000 ? "long" : duration > 60000 ? "medium" : "short";
return { durationRating: rating };
}
return {};
},
});
// All queries now include plugin metadata
const songs = await getAudio();
// songs[0].customMetadata?.durationRating === "long" | "medium" | "short"
unregisterPlugin("duration-rating");Improved Batch Query
import { getLibraryQuery } from "@obsidian_north/react-native-mediastore";
async function loadLibrary() {
const result = await getLibraryQuery({
types: ["audio", "video"],
typePagination: {
audio: { limit: 100, offset: 0 },
video: { limit: 20, offset: 0 },
},
includeStatistics: true,
});
console.log(`Total: ${result.totalCount} items (${result.totalSize} bytes)`);
console.log(`Query time: ${result.queryTime}ms`);
console.log(`Audio: ${result.perTypeStatistics?.audio.count} items`);
console.log(`Video: ${result.perTypeStatistics?.video.count} items`);
}Types
AudioItem
interface AudioItem {
id: string; uri: string; title: string;
artist: string; album: string; albumId: string;
genre: string | null; duration: number; size: number;
trackNumber: number; discNumber: number; year: number;
dateAdded: number; dateModified: number;
composer: string | null; lyrics: string | null;
albumArtist: string | null; isFavorite: boolean;
playCount: number; lastPlayed: number; bookmark: number;
bitrate: number | null; sampleRate: number | null;
channels: number | null; encoding: string | null;
mimeType: string; fileExtension: string; relativePath: string;
displayName: string; contentUri: string;
}VideoItem
interface VideoItem {
id: string; uri: string; title: string; duration: number;
width: number; height: number; frameRate: number | null;
rotation: number; size: number; mimeType: string;
relativePath: string; displayName: string; dateAdded: number;
dateModified: number; resolution: string; orientation: number;
}ImageItem
interface ImageItem {
id: string; uri: string; title: string; width: number;
height: number; orientation: number; cameraMake: string | null;
cameraModel: string | null; dateTaken: number;
gpsLatitude: number | null; gpsLongitude: number | null;
mimeType: string; size: number; relativePath: string;
displayName: string; dateAdded: number; dateModified: number;
}DocumentItem
interface DocumentItem {
id: string; uri: string; name: string; size: number;
mimeType: string; extension: string; relativePath: string;
dateAdded: number; dateModified: number;
}Album
interface Album {
id: string; title: string; artist: string;
songCount: number; duration: number;
artworkUri: string | null; dateAdded: number; year: number | null;
}Artist
interface Artist {
id: string; name: string;
albumCount: number; songCount: number;
duration: number; dateAdded: number;
}Genre
interface Genre {
id: string; name: string;
songCount: number; duration: number;
}Playlist
interface Playlist {
id: string; name: string;
songCount: number; duration: number;
dateAdded: number; dateModified: number;
}Folder
interface Folder {
id: string; name: string; path: string;
fileCount: number; totalSize: number;
}SearchResult
interface SearchResult {
audio: AudioItem[]; videos: VideoItem[];
images: ImageItem[]; documents: DocumentItem[];
totalCount: number; query: string;
}LibraryResult
interface LibraryResult {
audio: AudioItem[]; videos: VideoItem[];
images: ImageItem[]; documents: DocumentItem[];
totalCount: number;
}MediaStoreStatistics
interface MediaStoreStatistics {
totalAudio: number; totalVideo: number;
totalImages: number; totalDocuments: number;
totalSize: number; totalDuration: number;
}DuplicateItem
interface DuplicateItem {
fileHash: string; count: number;
items: AudioItem[]; totalSize: number;
}SortOptions
interface SortOptions { field: SortField; order: SortOrder; }
enum SortField { Name = "name", DateAdded = "dateAdded", DateModified = "dateModified", Duration = "duration", Artist = "artist", Album = "album", Year = "year", FileSize = "fileSize", Resolution = "resolution", Width = "width", Height = "height" }
enum SortOrder { Ascending = "asc", Descending = "desc" }FilterOptions
interface FilterOptions {
mimeTypes?: string[]; extensions?: string[]; folder?: string;
album?: string; artist?: string; minDuration?: number;
maxDuration?: number; minSize?: number; maxSize?: number;
minResolution?: number; maxResolution?: number; startDate?: number;
endDate?: number; includeHidden?: boolean; favoritesOnly?: boolean;
playlistId?: string;
}PaginationOptions
interface PaginationOptions { limit?: number; offset?: number; cursor?: string; }SearchOptions
interface SearchOptions {
query: string; types?: ("audio" | "video" | "image" | "document")[];
sort?: SortOptions; filter?: FilterOptions; pagination?: PaginationOptions;
}MediaChangeEvent
interface MediaChangeEvent {
type: "added" | "removed" | "modified";
mediaType: "audio" | "video" | "image" | "document";
itemId: string; uri: string;
}PermissionStatus
interface PermissionStatus { granted: boolean; audio: boolean; video: boolean; images: boolean; }MediaStoreError
type ErrorCode =
| "PERMISSION_DENIED" | "QUERY_FAILED" | "INVALID_ARGUMENTS"
| "INVALID_SORT_FIELD" | "INVALID_MIME_TYPE"
| "UNSUPPORTED_ANDROID_VERSION" | "FILE_UNAVAILABLE"
| "CURSOR_CLOSED" | "CACHE_FAILURE" | "UNKNOWN_ERROR";
interface MediaStoreError { code: ErrorCode; message: string; details?: string; }SizeHistogram
interface SizeHistogram {
lessThan1MB: number;
from1to10MB: number;
from10to100MB: number;
from100MBto1GB: number;
greaterThan1GB: number;
}FolderStatistics
interface FolderStatistics {
id: string; name: string; path: string;
fileCount: number; totalSize: number;
histogram: SizeHistogram;
mediaTypeBreakdown: MediaTypeBreakdown;
averageFileSize: number;
}
interface MediaTypeBreakdown {
audio: number; video: number; image: number; document: number;
}IncrementalChanges
interface IncrementalChanges {
added: number; modified: number;
removed: number; timestamp: number;
}MetadataPlugin
interface MetadataPlugin {
id: string; name: string; version: string;
extract: (item: AudioItem | VideoItem | ImageItem | DocumentItem)
=> Record<string, unknown> | Promise<Record<string, unknown>>;
}LibraryQueryOptions
interface LibraryQueryOptions {
sort?: SortOptions; filter?: FilterOptions;
pagination?: PaginationOptions;
types?: ("audio" | "video" | "image" | "document")[];
typePagination?: {
audio?: PaginationOptions; video?: PaginationOptions;
image?: PaginationOptions; document?: PaginationOptions;
};
includeStatistics?: boolean;
}LibraryQueryResult
interface LibraryQueryResult {
audio: AudioItem[]; videos: VideoItem[];
images: ImageItem[]; documents: DocumentItem[];
totalCount: number; totalSize: number;
perTypeStatistics?: LibraryPerTypeStatistics;
queryTime: number;
}
interface LibraryPerTypeStatistics {
audio: { count: number; totalSize: number; totalDuration: number };
video: { count: number; totalSize: number; totalDuration: number };
image: { count: number; totalSize: number };
document: { count: number; totalSize: number };
}Supported Document Types
| Format | MIME Type |
|--------|-----------|
| PDF | application/pdf |
| DOC | application/msword |
| DOCX | application/vnd.openxmlformats-officedocument.wordprocessingml.document |
| XLS | application/vnd.ms-excel |
| XLSX | application/vnd.openxmlformats-officedocument.spreadsheetml.sheet |
| PPT | application/vnd.ms-powerpoint |
| PPTX | application/vnd.openxmlformats-officedocument.presentationml.presentation |
| TXT | text/plain |
| EPUB | application/epub+zip |
| RTF | application/rtf |
| CSV | text/csv |
| JSON | application/json |
| XML | application/xml, text/xml |
| ZIP | application/zip |
| RAR | application/x-rar-compressed |
| 7Z | application/x-7z-compressed |
Document queries are Android-only. iOS returns an empty array.
FAQ
Q: Does it work in Expo Go? A: No. Requires native module support (Expo Dev Build or bare React Native).
Q: Does it work with Expo Dev Build? A: Yes.
Q: Does it work on iOS?
A: Yes. iOS 13.0+ is supported via the Photos Framework (PHAsset). Document queries return an empty array on iOS.
Q: Can I delete files?
A: No. This module is read-only. Use expo-file-system for mutations.
Q: Can I rename files? A: No. Renames belong in a filesystem module.
Q: Can I monitor changes?
A: Yes. Use useMediaChangeEvent or the native observer (ContentObserver on Android, PHPhotoLibraryChangeObserver on iOS).
Q: Can I search?
A: Yes. Full-text search via search() with multi-keyword, unicode support.
Q: Does it use MediaStore?
A: On Android, yes — all queries go through ContentResolver → MediaStore database. On iOS, it uses the Photos Framework (PHAsset).
Q: Does it support SD Cards? A: Yes, where indexed by the MediaStore database (Android).
Q: What Android versions are supported? A: Android 5.0+ (API 21+). Minimum SDK is 21.
Q: What iOS versions are supported?
A: iOS 13.0+. Uses Photos Framework with PHPhotoLibrary authorization.
Q: Does it require MANAGE_EXTERNAL_STORAGE? A: No. Uses standard MediaStore access pattern (Android) and Photos Framework (iOS).
Q: Can I get album artwork?
A: Yes. Use getAlbumArtwork(albumId).
Q: Can I get video thumbnails?
A: Yes. Use getVideoThumbnail(videoId).
Q: Is it typed?
A: Yes. 100% TypeScript with no any.
Project Structure
react-native-mediastore/
├── android/
│ └── src/main/java/expo/modules/mediastore/
│ ├── MediaStoreModule.kt
│ ├── MediaStoreRepository.kt
│ ├── MediaStoreQueryBuilder.kt
│ ├── MediaStoreMapper.kt
│ ├── MediaStoreObserver.kt
│ ├── MediaStorePermissions.kt
│ ├── MediaStoreCache.kt
│ ├── models/
│ ├── utils/
│ └── extensions/
├── ios/
│ ├── ExpoMediaStore.podspec
│ ├── MediaStoreModule.swift
│ ├── MediaStoreRepository.swift
│ ├── MediaStoreObserver.swift
│ └── MediaStorePermissions.swift
├── src/
│ ├── index.ts
│ ├── MediaStoreModule.ts
│ └── MediaStoreModule.types.ts
├── build/
├── __tests__/
│ └── types.test.ts
├── example/
│ ├── app/ (Music, Gallery, Documents, Search tabs)
│ └── package.json
├── .github/workflows/
│ ├── ci.yml
│ └── release.yml
├── expo-module.config.json
├── package.json
└── tsconfig.jsonRoadmap
2.0
✓ iOS support (Photos Framework: PHAsset, PHImageManager, PHPhotoLibraryChangeObserver)
✓ Thumbnail generation (video + image) with configurable dimensions
✓ Album artwork extraction via ContentResolver / PHImageManager
✓ Batch library query (getLibrary)
✓ Reactive subscriptions (useMediaChangeEvent hook)
✓ Duplicate detection via size + MD5 hash
✓ Document statistics in getStatistics
✓ Cache auto-invalidation on media change events
✓ Pagination actually applied (limit/offset/cursor)
✓ SQL injection prevention (escapeSql)
✓ ExifInterface for camera make/model metadata
2.1 (Current)
✓ Folder statistics (size histograms)
✓ Incremental indexing (delta-only refresh)
✓ Plugin hooks for custom metadata
✓ Batch library query improvements
3.0
☐ AI semantic search
☐ Smart albums / auto-playlists
☐ EXIF utilities (editing GPS, date)
☐ Waveform extraction (audio)
☐ Face clustering (images)
☐ OCR indexing (documents)
4.0
☐ Desktop (Electron / Tauri)
☐ Cloud sync abstraction
☐ Cross-platform unified APIContributing
See CONTRIBUTING.md for details.
Changelog
See CHANGELOG.md for release history.
License
MIT
