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