1import { useFocusEffect } from '@react-navigation/native'; 2import { StackScreenProps } from '@react-navigation/stack'; 3import * as FileSystem from 'expo-file-system'; 4import * as MediaLibrary from 'expo-media-library'; 5import React from 'react'; 6import { 7 Alert, 8 ActivityIndicator, 9 Button as RNButton, 10 Dimensions, 11 FlatList, 12 ListRenderItem, 13 RefreshControl, 14 StyleSheet, 15 Text, 16 View, 17} from 'react-native'; 18 19import MediaLibraryCell from './MediaLibraryCell'; 20import Button from '../../components/Button'; 21import HeadingText from '../../components/HeadingText'; 22import Colors from '../../constants/Colors'; 23 24const COLUMNS = 3; 25const PAGE_SIZE = COLUMNS * 10; 26const WINDOW_SIZE = Dimensions.get('window'); 27 28const mediaTypeStates: { [key in MediaLibrary.MediaTypeValue]: MediaLibrary.MediaTypeValue } = { 29 [MediaLibrary.MediaType.unknown]: MediaLibrary.MediaType.photo, 30 [MediaLibrary.MediaType.photo]: MediaLibrary.MediaType.video, 31 [MediaLibrary.MediaType.video]: MediaLibrary.MediaType.audio, 32 [MediaLibrary.MediaType.audio]: MediaLibrary.MediaType.unknown, 33}; 34 35const sortByStates: { [key in MediaLibrary.SortByKey]: MediaLibrary.SortByKey } = { 36 [MediaLibrary.SortBy.default]: MediaLibrary.SortBy.creationTime, 37 [MediaLibrary.SortBy.creationTime]: MediaLibrary.SortBy.modificationTime, 38 [MediaLibrary.SortBy.modificationTime]: MediaLibrary.SortBy.mediaType, 39 [MediaLibrary.SortBy.mediaType]: MediaLibrary.SortBy.width, 40 [MediaLibrary.SortBy.width]: MediaLibrary.SortBy.height, 41 [MediaLibrary.SortBy.height]: MediaLibrary.SortBy.duration, 42 [MediaLibrary.SortBy.duration]: MediaLibrary.SortBy.default, 43}; 44 45type Links = { 46 MediaLibrary: { asset: MediaLibrary.Asset; onGoBack: () => void; album: MediaLibrary.Album }; 47 MediaDetails: { asset: MediaLibrary.Asset; onGoBack: () => void; album: MediaLibrary.Album }; 48 MediaAlbums: undefined; 49}; 50 51type Props = StackScreenProps<Links, 'MediaLibrary'> & { 52 accessPrivileges?: MediaLibrary.PermissionResponse['accessPrivileges']; 53}; 54 55type FetchState = { 56 refreshing: boolean; 57 fetching: boolean; 58 assets: MediaLibrary.Asset[]; 59 endCursor: string | null; 60 hasNextPage: boolean; 61}; 62 63const initialState: FetchState = { 64 refreshing: true, 65 fetching: true, 66 assets: [], 67 endCursor: null, 68 hasNextPage: true, 69}; 70 71function reducer( 72 state: FetchState, 73 { 74 type, 75 ...action 76 }: ({ type: 'reset' } & Partial<FetchState>) | ({ type: 'update' } & Partial<FetchState>) 77): FetchState { 78 switch (type) { 79 case 'reset': 80 return { ...initialState, ...action }; 81 case 'update': 82 return { ...state, ...action }; 83 } 84} 85 86function useMediaLibraryPermissions(): [undefined | MediaLibrary.PermissionResponse] { 87 const [permissions, setPermissions] = React.useState< 88 undefined | MediaLibrary.PermissionResponse 89 >(); 90 91 React.useEffect(() => { 92 async function askAsync() { 93 const response = await MediaLibrary.requestPermissionsAsync(); 94 setPermissions(response); 95 } 96 97 askAsync(); 98 }, []); 99 100 return [permissions]; 101} 102 103export default function MediaLibraryScreen({ navigation, route }: Props) { 104 const album = route.params?.album; 105 106 // Set the navigation options 107 React.useLayoutEffect(() => { 108 const goToAlbums = () => navigation.navigate('MediaAlbums'); 109 const clearAlbumSelection = () => navigation.setParams({ album: undefined }); 110 const addImage = async () => { 111 const randomNameGenerator: (num: number) => string = (num) => { 112 let res = ''; 113 for (let i = 0; i < num; i++) { 114 const random = Math.floor(Math.random() * 27); 115 res += String.fromCharCode(97 + random); 116 } 117 return res; 118 }; 119 120 const localPath = FileSystem.cacheDirectory + randomNameGenerator(5) + '.jpg'; 121 await FileSystem.downloadAsync('https://picsum.photos/200', localPath); 122 await MediaLibrary.saveToLibraryAsync(localPath); 123 await FileSystem.deleteAsync(localPath); 124 }; 125 126 const removeAlbum = async () => { 127 await MediaLibrary.deleteAlbumsAsync(album); 128 clearAlbumSelection(); 129 }; 130 131 navigation.setOptions({ 132 title: 'Media Library', 133 headerRight: () => ( 134 <View style={{ marginRight: 5, flexDirection: 'row' }}> 135 <RNButton 136 title={album ? 'Remove' : 'Add'} 137 onPress={album ? removeAlbum : addImage} 138 color={Colors.tintColor} 139 /> 140 <View style={{ width: 5 }} /> 141 <RNButton 142 title={album ? 'Show all' : 'Albums'} 143 onPress={album ? clearAlbumSelection : goToAlbums} 144 color={Colors.tintColor} 145 /> 146 </View> 147 ), 148 }); 149 }, [album, navigation]); 150 151 // Ensure the permissions are granted. 152 const [permission] = useMediaLibraryPermissions(); 153 154 if (!permission) { 155 return null; 156 } 157 if (!permission.granted) { 158 return ( 159 <View style={styles.permissions}> 160 <Text> 161 Missing MEDIA_LIBRARY permission. To continue, you'll need to allow media gallery access 162 in Settings. 163 </Text> 164 </View> 165 ); 166 } 167 168 return ( 169 <MediaLibraryView 170 navigation={navigation} 171 route={route} 172 accessPrivileges={(permission as MediaLibrary.PermissionResponse).accessPrivileges} 173 /> 174 ); 175} 176 177// The fetching and sorting logic is split out from the navigation and permission logic for simplicity. 178function MediaLibraryView({ navigation, route, accessPrivileges }: Props) { 179 const album = route.params?.album; 180 181 const isLoadingAssets = React.useRef(false); 182 183 const [sortBy, setSortBy] = React.useState<MediaLibrary.SortByKey>(MediaLibrary.SortBy.default); 184 const [mediaType, setMediaType] = React.useState<MediaLibrary.MediaTypeValue>( 185 MediaLibrary.MediaType.photo 186 ); 187 188 const [state, dispatch] = React.useReducer(reducer, initialState); 189 190 // Update without showing the refresh indicator whenever the sorting parameters change. 191 React.useEffect(() => { 192 dispatch({ type: 'reset', refreshing: false }); 193 }, [mediaType, sortBy, album?.id]); 194 195 const toggleMediaType = React.useCallback(() => { 196 setMediaType(mediaTypeStates[mediaType]); 197 }, [setMediaType, mediaType]); 198 199 const toggleSortBy = React.useCallback(() => { 200 setSortBy(sortByStates[sortBy]); 201 }, [setSortBy, sortBy]); 202 203 const loadMoreAssets = React.useCallback(async () => { 204 if ( 205 // if a fetch operation is still in progress or there are no more assets then bail out. 206 isLoadingAssets.current || 207 !state.hasNextPage 208 ) { 209 return; 210 } 211 // Prevent fetching while another request is still in progress. 212 isLoadingAssets.current = true; 213 214 try { 215 // Make a native request for assets. 216 const { assets, endCursor, hasNextPage } = await MediaLibrary.getAssetsAsync({ 217 first: PAGE_SIZE, 218 after: state.endCursor ?? undefined, 219 mediaType, 220 sortBy, 221 album: album?.id, 222 }); 223 224 // Get the last asset currently in the state. 225 const lastAsset = state.assets[state.assets.length - 1]; 226 227 const shouldUpdateState = !lastAsset || lastAsset.id === state.endCursor; 228 // Guard against updating on an unmounted component. 229 if (shouldUpdateState) { 230 dispatch({ 231 type: 'update', 232 fetching: false, 233 refreshing: false, 234 assets: ([] as MediaLibrary.Asset[]).concat(state.assets, assets), 235 endCursor, 236 hasNextPage, 237 }); 238 } 239 } finally { 240 // Toggle this back to false in a finally to ensure we can reload later, even if an error ocurred. 241 isLoadingAssets.current = false; 242 } 243 }, [state.endCursor, state.hasNextPage, state.assets, mediaType, sortBy, album?.id]); 244 245 // Fetch data whenever the state.fetching value is true. 246 React.useEffect(() => { 247 if (state.fetching) { 248 loadMoreAssets(); 249 } 250 }, [loadMoreAssets, state.fetching]); 251 252 const refresh = React.useCallback((refreshing = true) => { 253 dispatch({ type: 'reset', refreshing }); 254 }, []); 255 256 // Subscribe to state changes 257 useFocusEffect( 258 React.useCallback(() => { 259 // When new media is added or removed, update the library 260 const subscription = MediaLibrary.addListener((event) => { 261 if (!event.hasIncrementalChanges) { 262 dispatch({ type: 'reset', refreshing: false }); 263 return; 264 } 265 dispatch({ type: 'update', fetching: true, endCursor: null, hasNextPage: true }); 266 }); 267 return () => { 268 subscription.remove(); 269 }; 270 }, []) 271 ); 272 273 const onCellPress = React.useCallback( 274 (asset: MediaLibrary.Asset) => { 275 navigation.navigate('MediaDetails', { 276 asset, 277 album, 278 onGoBack: refresh, 279 }); 280 }, 281 [navigation, album, refresh] 282 ); 283 284 const renderRowItem: ListRenderItem<MediaLibrary.Asset> = React.useCallback( 285 ({ item }) => { 286 return ( 287 <MediaLibraryCell 288 style={{ width: WINDOW_SIZE.width / COLUMNS }} 289 asset={item} 290 onPress={onCellPress} 291 /> 292 ); 293 }, 294 [onCellPress] 295 ); 296 297 const renderHeader = React.useCallback(() => { 298 return ( 299 <View style={styles.header}> 300 <HeadingText style={styles.headerText}> 301 {album ? `Album: ${album.title}` : 'All albums'} 302 </HeadingText> 303 304 <View style={styles.headerButtons}> 305 <Button 306 style={styles.button} 307 title={`Media type: ${mediaType}`} 308 onPress={toggleMediaType} 309 /> 310 <Button style={styles.button} title={`Sort by key: ${sortBy}`} onPress={toggleSortBy} /> 311 </View> 312 {accessPrivileges === 'limited' && ( 313 <View style={styles.headerButtons}> 314 <Button 315 style={styles.button} 316 title="Change permissions" 317 onPress={async () => { 318 try { 319 await MediaLibrary.presentPermissionsPickerAsync(); 320 } catch (e) { 321 Alert.alert(JSON.stringify(e)); 322 } 323 }} 324 /> 325 </View> 326 )} 327 </View> 328 ); 329 }, [mediaType, album, sortBy, toggleMediaType, toggleSortBy]); 330 331 const renderFooter = React.useCallback(() => { 332 if (state.refreshing) { 333 return ( 334 <View style={styles.footer}> 335 <ActivityIndicator animating /> 336 </View> 337 ); 338 } 339 if (state.assets.length === 0) { 340 return ( 341 <View style={styles.noAssets}> 342 <Text>{`You don't have any assets with type: ${mediaType}`}</Text> 343 </View> 344 ); 345 } 346 return null; 347 }, [state.refreshing, state.assets, mediaType]); 348 349 const keyExtractor = (item: MediaLibrary.Asset) => item.id; 350 351 const onEndReached = React.useCallback(() => { 352 dispatch({ type: 'update', fetching: true }); 353 }, []); 354 355 return ( 356 <FlatList 357 contentContainerStyle={styles.flatList} 358 data={state.assets} 359 numColumns={COLUMNS} 360 keyExtractor={keyExtractor} 361 onEndReachedThreshold={0.5} 362 onEndReached={onEndReached} 363 renderItem={renderRowItem} 364 ListHeaderComponent={renderHeader} 365 ListFooterComponent={renderFooter} 366 refreshControl={<RefreshControl refreshing={state.refreshing} onRefresh={refresh} />} 367 /> 368 ); 369} 370 371const styles = StyleSheet.create({ 372 mediaGallery: { 373 flex: 1, 374 }, 375 flatList: { 376 marginHorizontal: 1, 377 }, 378 permissions: { 379 flex: 1, 380 justifyContent: 'center', 381 alignItems: 'center', 382 }, 383 button: { 384 marginHorizontal: 5, 385 }, 386 header: { 387 paddingTop: 0, 388 paddingBottom: 16, 389 paddingHorizontal: 10, 390 }, 391 headerText: { 392 alignSelf: 'center', 393 }, 394 headerButtons: { 395 marginTop: 5, 396 paddingVertical: 10, 397 flexDirection: 'row', 398 justifyContent: 'center', 399 alignItems: 'center', 400 }, 401 footer: { 402 padding: 10, 403 }, 404 noAssets: { 405 paddingVertical: 20, 406 justifyContent: 'center', 407 alignItems: 'center', 408 }, 409}); 410