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