Last active
January 25, 2025 17:40
-
-
Save stevengoldberg/f62190fc82bb1c7506a87b16d0d58d6c to your computer and use it in GitHub Desktop.
Expo Module for listening to media library changes
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]) | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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[] | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
] | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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