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