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