xref: /MusicFree/src/pages/fileSelector/index.tsx (revision 734113be9d256a2b4d36bb272d6d3565beaeb236)
1import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
2import {Pressable, StyleSheet, View} from 'react-native';
3import rpx from '@/utils/rpx';
4import ThemeText from '@/components/base/themeText';
5import {
6    ExternalStorageDirectoryPath,
7    readDir,
8    getAllExternalFilesDirs,
9    exists,
10} from 'react-native-fs';
11import {FlatList} from 'react-native-gesture-handler';
12import useColors from '@/hooks/useColors';
13import Color from 'color';
14import IconButton from '@/components/base/iconButton';
15import FileItem from './fileItem';
16import Empty from '@/components/base/empty';
17import useHardwareBack from '@/hooks/useHardwareBack';
18import {useNavigation} from '@react-navigation/native';
19import Loading from '@/components/base/loading';
20import {useParams} from '@/entry/router';
21import StatusBar from '@/components/base/statusBar';
22import VerticalSafeAreaView from '@/components/base/verticalSafeAreaView';
23import globalStyle from '@/constants/globalStyle';
24
25interface IPathItem {
26    path: string;
27    parent: null | IPathItem;
28}
29
30interface IFileItem {
31    path: string;
32    type: 'file' | 'folder';
33}
34
35const ITEM_HEIGHT = rpx(96);
36
37export default function FileSelector() {
38    const {
39        fileType = 'file-and-folder',
40        multi = true,
41        actionText = '确定',
42        matchExtension,
43        onAction,
44    } = useParams<'file-selector'>() ?? {};
45
46    const [currentPath, setCurrentPath] = useState<IPathItem>({
47        path: '/',
48        parent: null,
49    });
50    const currentPathRef = useRef<IPathItem>(currentPath);
51    const [filesData, setFilesData] = useState<IFileItem[]>([]);
52    const [checkedItems, setCheckedItems] = useState<IFileItem[]>([]);
53
54    const checkedPaths = useMemo(
55        () => checkedItems.map(_ => _.path),
56        [checkedItems],
57    );
58    const navigation = useNavigation();
59    const colors = useColors();
60    const [loading, setLoading] = useState(false);
61
62    useEffect(() => {
63        (async () => {
64            // 路径变化时,重新读取
65            setLoading(true);
66            try {
67                if (currentPath.path === '/') {
68                    try {
69                        const allExt = await getAllExternalFilesDirs();
70                        if (allExt.length > 1) {
71                            const sdCardPaths = allExt.map(sdp =>
72                                sdp.substring(0, sdp.indexOf('/Android')),
73                            );
74                            if (
75                                (
76                                    await Promise.all(
77                                        sdCardPaths.map(_ => exists(_)),
78                                    )
79                                ).every(val => val)
80                            ) {
81                                setFilesData(
82                                    sdCardPaths.map(_ => ({
83                                        type: 'folder',
84                                        path: _,
85                                    })),
86                                );
87                            }
88                        } else {
89                            setCurrentPath({
90                                path: ExternalStorageDirectoryPath,
91                                parent: null,
92                            });
93                            return;
94                        }
95                    } catch {
96                        setCurrentPath({
97                            path: ExternalStorageDirectoryPath,
98                            parent: null,
99                        });
100                        return;
101                    }
102                } else {
103                    const res = (await readDir(currentPath.path)) ?? [];
104                    let folders: IFileItem[] = [];
105                    let files: IFileItem[] = [];
106                    if (
107                        fileType === 'folder' ||
108                        fileType === 'file-and-folder'
109                    ) {
110                        folders = res
111                            .filter(_ => _.isDirectory())
112                            .map(_ => ({
113                                type: 'folder',
114                                path: _.path,
115                            }));
116                    }
117                    if (fileType === 'file' || fileType === 'file-and-folder') {
118                        files = res
119                            .filter(
120                                _ =>
121                                    _.isFile() &&
122                                    (matchExtension
123                                        ? matchExtension(_.path)
124                                        : true),
125                            )
126                            .map(_ => ({
127                                type: 'file',
128                                path: _.path,
129                            }));
130                    }
131                    setFilesData([...folders, ...files]);
132                }
133            } catch {
134                setFilesData([]);
135            }
136            setLoading(false);
137            currentPathRef.current = currentPath;
138        })();
139    }, [currentPath.path]);
140
141    useHardwareBack(() => {
142        // 注意闭包
143        const _currentPath = currentPathRef.current;
144        if (_currentPath.parent !== null) {
145            setCurrentPath(_currentPath.parent);
146        } else {
147            navigation.goBack();
148        }
149        return true;
150    });
151
152    const selectPath = useCallback((item: IFileItem, nextChecked: boolean) => {
153        if (multi) {
154            setCheckedItems(prev => {
155                if (nextChecked) {
156                    return [...prev, item];
157                } else {
158                    return prev.filter(_ => _ !== item);
159                }
160            });
161        } else {
162            setCheckedItems(nextChecked ? [item] : []);
163        }
164    }, []);
165
166    const renderItem = ({item}: {item: IFileItem}) => (
167        <FileItem
168            path={item.path}
169            type={item.type}
170            parentPath={currentPath.path}
171            onItemPress={currentChecked => {
172                if (item.type === 'folder') {
173                    setCurrentPath(prev => ({
174                        parent: prev,
175                        path: item.path,
176                    }));
177                } else {
178                    selectPath(item, !currentChecked);
179                }
180            }}
181            checked={checkedPaths.includes(item.path)}
182            onCheckedChange={checked => {
183                selectPath(item, checked);
184            }}
185        />
186    );
187
188    return (
189        <VerticalSafeAreaView style={globalStyle.fwflex1}>
190            <StatusBar />
191            <View style={[style.header, {backgroundColor: colors.primary}]}>
192                <IconButton
193                    size="small"
194                    name="keyboard-backspace"
195                    onPress={() => {
196                        // 返回上一级
197                        if (currentPath.parent !== null) {
198                            setCurrentPath(currentPath.parent);
199                        }
200                    }}
201                />
202                <ThemeText
203                    numberOfLines={2}
204                    ellipsizeMode="head"
205                    style={style.headerPath}>
206                    {currentPath.path}
207                </ThemeText>
208            </View>
209            {loading ? (
210                <Loading />
211            ) : (
212                <>
213                    <FlatList
214                        ListEmptyComponent={Empty}
215                        style={globalStyle.fwflex1}
216                        data={filesData}
217                        getItemLayout={(_, index) => ({
218                            length: ITEM_HEIGHT,
219                            offset: ITEM_HEIGHT * index,
220                            index,
221                        })}
222                        renderItem={renderItem}
223                    />
224                </>
225            )}
226            <Pressable
227                onPress={async () => {
228                    if (checkedItems.length) {
229                        const shouldBack = await onAction?.(checkedItems);
230                        if (shouldBack) {
231                            navigation.goBack();
232                        }
233                    }
234                }}>
235                <View
236                    style={[
237                        style.scanBtn,
238                        {
239                            backgroundColor: Color(colors.primary)
240                                .alpha(0.8)
241                                .toString(),
242                        },
243                    ]}>
244                    <ThemeText
245                        fontColor={
246                            checkedItems.length > 0 ? 'normal' : 'secondary'
247                        }>
248                        {actionText}
249                        {multi && checkedItems?.length > 0
250                            ? ` (选中${checkedItems.length})`
251                            : ''}
252                    </ThemeText>
253                </View>
254            </Pressable>
255        </VerticalSafeAreaView>
256    );
257}
258
259const style = StyleSheet.create({
260    header: {
261        height: rpx(88),
262        flexDirection: 'row',
263        alignItems: 'center',
264        width: '100%',
265        paddingHorizontal: rpx(24),
266    },
267    headerPath: {
268        marginLeft: rpx(28),
269    },
270    scanBtn: {
271        width: '100%',
272        height: rpx(120),
273        alignItems: 'center',
274        justifyContent: 'center',
275    },
276});
277