Skip to content

Instantly share code, notes, and snippets.

@stevengoldberg
Last active January 25, 2025 17:40
Show Gist options
  • Save stevengoldberg/f62190fc82bb1c7506a87b16d0d58d6c to your computer and use it in GitHub Desktop.
Save stevengoldberg/f62190fc82bb1c7506a87b16d0d58d6c to your computer and use it in GitHub Desktop.
Expo Module for listening to media library changes
import { AppState } from 'react-native'
import { useRef, useEffect } from 'react'
const App = () => {
const appState = useRef(AppState.currentState)
useEffect(() => {
const appStateSubscription = AppState.addEventListener(
'change',
async (nextAppState) => {
if (
appState.current === 'background' &&
nextAppState === 'active'
) {
const fetchChanges = async () => {
const changes = await LibraryListener.getChangesAsync()
console.log(changes)
LibraryListener.unsubscribeFromChanges()
if (
changes.addedPhotos.length > 0 ||
changes.removedPhotos.length > 0
) {
// handle photo updates
}
if (
changes.addedAlbums.length > 0 ||
changes.removedAlbums.length > 0
) {
// handle album updates
}
}
fetchChanges()
} else if (nextAppState === 'background') {
// optionally pass an albumId here
LibraryListener.subscribeToChanges()
}
appState.current = nextAppState
}
)
return () => {
appStateSubscription.remove()
}
}, [selectedAlbum])
}
export interface PhotoLocation {
latitude: number
longitude: number
altitude: number
timestamp: number
}
export interface PhotoAsset {
id: string
mediaType: 'photo'
creationTime: number
modificationTime: number
width: number
height: number
location: PhotoLocation
uri: string
filename: string
}
export interface Album {
id: string
title: string
assetCount: number
}
export interface LibraryChanges {
addedPhotos: PhotoAsset[]
removedPhotos: PhotoAsset[]
addedAlbums: Album[]
removedAlbums: Album[]
}
import ExpoModulesCore
import Photos
import CoreLocation
public class LibraryListenerModule: Module {
private let changeHandler = PhotoLibraryChangeHandler()
public func definition() -> ModuleDefinition {
Name("LibraryListener")
// Subscribe to library changes for a specific album ID (optional).
Function("subscribeToChanges") { (albumId: String?) in
self.changeHandler.albumId = albumId
self.changeHandler.register()
}
// Unsubscribe from library changes.
Function("unsubscribeFromChanges") {
self.changeHandler.unregister()
}
// Return a single consolidated object of all queued changes.
AsyncFunction("getChangesAsync") { (promise: Promise) in
let consolidatedChanges = self.changeHandler.consolidateAndClearChangeQueue()
promise.resolve(consolidatedChanges)
}
}
}
class PhotoLibraryChangeHandler: NSObject, PHPhotoLibraryChangeObserver {
/// Album identifier to track. If `nil`, track the entire camera roll.
var albumId: String?
/// Queued individual batch changes since last `getChangesAsync` call.
var changeQueue: [[String: Any]] = []
/// Holds the fetch result for the user‐chosen album (or the camera roll).
private var trackedAlbumAssetFetchResult: PHFetchResult<PHAsset>?
/// Holds a fetch result for all user albums (for detecting album additions/deletions).
private var allAlbumsFetchResult: PHFetchResult<PHAssetCollection>?
// MARK: - Registration
func register() {
// Always track newly created/removed albums:
allAlbumsFetchResult = PHAssetCollection.fetchAssetCollections(
with: .album,
subtype: .albumRegular,
options: nil
)
if let id = albumId {
// Fetch a specific album by localIdentifier
let albumCollections = PHAssetCollection.fetchAssetCollections(
withLocalIdentifiers: [id],
options: nil
)
if let album = albumCollections.firstObject {
trackedAlbumAssetFetchResult = PHAsset.fetchAssets(in: album, options: nil)
} else {
trackedAlbumAssetFetchResult = nil
}
} else {
// Track the entire camera roll (smart album "User Library")
let cameraRollCollections = PHAssetCollection.fetchAssetCollections(
with: .smartAlbum,
subtype: .smartAlbumUserLibrary,
options: nil
)
if let cameraRoll = cameraRollCollections.firstObject {
trackedAlbumAssetFetchResult = PHAsset.fetchAssets(in: cameraRoll, options: nil)
}
}
PHPhotoLibrary.shared().register(self)
}
func unregister() {
PHPhotoLibrary.shared().unregisterChangeObserver(self)
trackedAlbumAssetFetchResult = nil
allAlbumsFetchResult = nil
}
// MARK: - PHPhotoLibraryChangeObserver
func photoLibraryDidChange(_ changeInstance: PHChange) {
var changes: [String: Any] = [
"addedPhotos": [[String: Any]](),
"removedPhotos": [[String: Any]](),
"addedAlbums": [[String: Any]](),
"removedAlbums": [[String: Any]]()
]
// 1) Detect newly added or removed albums
if let albumsFetchResult = allAlbumsFetchResult,
let albumChangeDetails = changeInstance.changeDetails(for: albumsFetchResult) {
var addedAlbums: [[String: Any]] = []
var removedAlbums: [[String: Any]] = []
for album in albumChangeDetails.insertedObjects {
addedAlbums.append(albumToDictionary(album: album))
}
for album in albumChangeDetails.removedObjects {
removedAlbums.append(albumToDictionary(album: album))
}
changes["addedAlbums"] = addedAlbums
changes["removedAlbums"] = removedAlbums
// Update our reference
allAlbumsFetchResult = albumChangeDetails.fetchResultAfterChanges
}
// 2) Detect inserted or removed photos in the tracked album (or camera roll)
if let assetFetchResult = trackedAlbumAssetFetchResult,
let assetChangeDetails = changeInstance.changeDetails(for: assetFetchResult) {
var addedPhotos: [[String: Any]] = []
var removedPhotos: [[String: Any]] = []
// Insertions
if let insertedIndexes = assetChangeDetails.insertedIndexes {
for idx in insertedIndexes {
let asset = assetChangeDetails.fetchResultAfterChanges.object(at: idx)
// Only track images
if asset.mediaType == .image {
addedPhotos.append(assetToDictionary(asset: asset))
}
}
}
// Removals
if let removedIndexes = assetChangeDetails.removedIndexes {
for idx in removedIndexes {
let asset = assetChangeDetails.fetchResultBeforeChanges.object(at: idx)
if asset.mediaType == .image {
removedPhotos.append(assetToDictionary(asset: asset))
}
}
}
changes["addedPhotos"] = addedPhotos
changes["removedPhotos"] = removedPhotos
// Update our reference
trackedAlbumAssetFetchResult = assetChangeDetails.fetchResultAfterChanges
}
// Queue this batch of changes
DispatchQueue.main.async {
self.changeQueue.append(changes)
}
}
// MARK: - Consolidation
/// Combine all queued batches into a single object and clear the queue.
func consolidateAndClearChangeQueue() -> [String: Any] {
var finalResult: [String: Any] = [
"addedPhotos": [[String: Any]](),
"removedPhotos": [[String: Any]](),
"addedAlbums": [[String: Any]](),
"removedAlbums": [[String: Any]]()
]
for batch in changeQueue {
if let batchAddedPhotos = batch["addedPhotos"] as? [[String: Any]] {
var existing = finalResult["addedPhotos"] as? [[String: Any]] ?? []
existing.append(contentsOf: batchAddedPhotos)
finalResult["addedPhotos"] = existing
}
if let batchRemovedPhotos = batch["removedPhotos"] as? [[String: Any]] {
var existing = finalResult["removedPhotos"] as? [[String: Any]] ?? []
existing.append(contentsOf: batchRemovedPhotos)
finalResult["removedPhotos"] = existing
}
if let batchAddedAlbums = batch["addedAlbums"] as? [[String: Any]] {
var existing = finalResult["addedAlbums"] as? [[String: Any]] ?? []
existing.append(contentsOf: batchAddedAlbums)
finalResult["addedAlbums"] = existing
}
if let batchRemovedAlbums = batch["removedAlbums"] as? [[String: Any]] {
var existing = finalResult["removedAlbums"] as? [[String: Any]] ?? []
existing.append(contentsOf: batchRemovedAlbums)
finalResult["removedAlbums"] = existing
}
}
// Clear queue now that we've consolidated
changeQueue.removeAll()
return finalResult
}
// MARK: - Helpers
private func assetToDictionary(asset: PHAsset) -> [String: Any] {
let resources = PHAssetResource.assetResources(for: asset)
let filename = resources.first?.originalFilename ?? "unknown.jpg"
return [
"id": asset.localIdentifier,
"mediaType": "photo", // or map asset.mediaType to a string
"creationTime": (asset.creationDate?.timeIntervalSince1970 ?? 0) * 1000,
"modificationTime": (asset.modificationDate?.timeIntervalSince1970 ?? 0) * 1000,
"width": asset.pixelWidth,
"height": asset.pixelHeight,
"location": locationToDictionary(location: asset.location) ?? [:],
"uri": "ph://\(asset.localIdentifier)",
"filename": filename
]
}
private func shortLocalIdentifier(_ fullID: String) -> String {
return fullID.components(separatedBy: "/").first ?? fullID
}
private func albumToDictionary(album: PHAssetCollection) -> [String: Any] {
let shortId = shortLocalIdentifier(album.localIdentifier)
return [
"id": shortId,
"title": album.localizedTitle ?? "",
"assetCount": album.estimatedAssetCount
]
}
private func locationToDictionary(location: CLLocation?) -> [String: Any]? {
guard let location = location else { return nil }
return [
"latitude": location.coordinate.latitude,
"longitude": location.coordinate.longitude,
"altitude": location.altitude,
"timestamp": location.timestamp.timeIntervalSince1970
]
}
}
import { requireNativeModule, EventEmitter } from 'expo-modules-core'
import { LibraryChanges, PhotoAsset, Album } from './LibraryListener.types'
const LibraryListener = requireNativeModule('LibraryListener')
class LibraryListenerModule {
/**
* Subscribe to changes in the photo library or a specific album.
* @param albumId Optional id of the album to monitor. If not provided, monitors the entire photo library.
*/
subscribeToChanges(albumId?: string): void {
LibraryListener.subscribeToChanges(albumId || null)
}
/**
* Unsubscribe from photo library changes.
*/
unsubscribeFromChanges(): void {
LibraryListener.unsubscribeFromChanges()
}
/**
* Fetch queued changes since the last call.
* @returns A promise resolving to the queued library changes.
*/
async getChangesAsync(): Promise<LibraryChanges> {
return await LibraryListener.getChangesAsync()
}
}
export default new LibraryListenerModule()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment