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