新增单个列表导入/导出功能,新增删除列表前的确认弹窗,防止误删列表

This commit is contained in:
lyswhut 2021-10-21 14:17:30 +08:00
parent d9179c745c
commit 83086356b3
10 changed files with 292 additions and 69 deletions

View File

@ -2,6 +2,8 @@
- 新增歌曲评论显示可在播放详情页进入。与PC端一样目前仅支持显示部分评论
- 新增播放、收藏整个排行榜功能,可长按排行榜名字后在弹出的菜单中操作
- 新增单个列表导入/导出功能,可以方便分享歌曲列表,可在点击“我的列表”里的列表名右侧的按钮后弹出的菜单中使用
- 新增删除列表前的确认弹窗,防止误删列表
### 优化

View File

@ -23,3 +23,5 @@ export const NAV_VIEW_NAMES = {
list: 3,
setting: 4,
}
const LXM_FILE_EXT_RXP = /\.(json|lxmc)$/

View File

@ -25,6 +25,8 @@
"date_format_minute": "{{num}} minutes ago",
"date_format_second": "{{num}} seconds ago",
"delete": "Delete",
"dialog_cancel": "No",
"dialog_confirm": "OK",
"disagree": "Disagree",
"disagree_tip": "Cancelled...",
"input_error": "Don't input indiscriminately 😡",
@ -39,11 +41,20 @@
"list_edit_action_tip_remove_success": "Removed successfully",
"list_end": "In The End",
"list_error": "Loading failed 😥",
"list_export": "Export",
"list_export_part_desc": "Choose where to save the list file",
"list_import": "Import",
"list_import_part_button_cancel": "No",
"list_import_part_button_confirm": "Overwrite",
"list_import_part_confirm": "The imported list ({{importName}}) has the same ID as the local list ({{localName}}). Do you overwrite the local list?",
"list_import_part_desc": "Select list file",
"list_import_part_tip_failed": "This does not seem to be a single list file",
"list_loading": "Loading...",
"list_multi_add_title_first_add": "Add selected",
"list_multi_add_title_first_move": "Move the selected one",
"list_multi_add_title_last": "First song to...",
"list_remove": "Remove",
"list_remove_tip_button": "Yes, that's right",
"list_rename": "Rename",
"list_rename_title": "Rename List",
"list_select_all": "Select All",

View File

@ -25,6 +25,8 @@
"date_format_minute": "{{num}}分钟前",
"date_format_second": "{{num}}秒前",
"delete": "删除",
"dialog_cancel": "我不",
"dialog_confirm": "好的",
"disagree": "我就不",
"disagree_tip": "那算了... 🙄",
"input_error": "不要乱输好吧😡",
@ -39,11 +41,21 @@
"list_edit_action_tip_remove_success": "移除成功",
"list_end": "到底啦~",
"list_error": "加载失败😥",
"list_export": "导出",
"list_export_part_desc": "选择列表文件保存位置",
"list_import": "导入",
"list_import_part_button_cancel": "不要啊",
"list_import_part_button_confirm": "覆盖掉",
"list_import_part_confirm": "导入的列表({{importName}})与本地列表({{localName}}的ID相同是否覆盖本地列表",
"list_import_part_desc": "选择列表文件",
"list_import_part_tip_failed": "这似乎不是单个的列表文件哦",
"list_loading": "加载中...",
"list_multi_add_title_first_add": "添加已选的",
"list_multi_add_title_first_move": "移动已选的",
"list_multi_add_title_last": "首歌曲到...",
"list_remove": "移除",
"list_remove_tip": "你真的想要移除 {{name}} 吗?",
"list_remove_tip_button": "是的 没错",
"list_rename": "重命名",
"list_rename_title": "重命名列表",
"list_select_all": "全选",

View File

@ -1,5 +1,5 @@
import React, { memo, useMemo, useEffect, useCallback, useState, useRef } from 'react'
import { StyleSheet, Text, View, TouchableOpacity, ScrollView } from 'react-native'
import { StyleSheet, Text, View, TouchableOpacity, ScrollView, InteractionManager } from 'react-native'
import { useGetter, useDispatch } from '@/store'
import { useTranslation } from '@/plugins/i18n'
@ -10,9 +10,39 @@ import { BorderWidths } from '@/theme'
import Menu from '@/components/common/Menu'
import ConfirmAlert from '@/components/common/ConfirmAlert'
import Input from '@/components/common/Input'
import { getListScrollPosition, saveListScrollPosition, toast } from '@/utils/tools'
import { LIST_SCROLL_POSITION_KEY } from '@/config/constant'
import { filterFileName } from '@/utils'
import { getListScrollPosition, saveListScrollPosition, toast, handleSaveFile, handleReadFile, confirmDialog } from '@/utils/tools'
import { LIST_SCROLL_POSITION_KEY, LXM_FILE_EXT_RXP } from '@/config/constant'
import musicSdk from '@/utils/music'
import ChoosePath from '@/components/common/ChoosePath'
import { log } from '@/utils/log'
const exportList = async(list, path) => {
const data = JSON.parse(JSON.stringify({
type: 'playListPart',
data: list,
}))
for (const item of data.data.list) {
if (item.otherSource) delete item.otherSource
if (item.lrc) delete item.lrc
}
try {
await handleSaveFile(path + `/lx_list_part_${filterFileName(list.name)}.lxmc`, data)
} catch (error) {
log.error(error.stack)
}
}
const importList = async path => {
let listData
try {
listData = await handleReadFile(path)
} catch (error) {
log.error(error.stack)
return
}
console.log(listData.type)
return listData
}
const ListItem = ({ onPress, name, id, showMenu, activeId, loading, index }) => {
const theme = useGetter('common', 'theme')
@ -38,6 +68,89 @@ const ListItem = ({ onPress, name, id, showMenu, activeId, loading, index }) =>
)
}
const ImportExport = ({ actionType, visible, hide, selectedListRef }) => {
const [title, setTitle] = useState('')
const [dirOnly, setDirOnly] = useState(false)
const setList = useDispatch('list', 'setList')
const createUserList = useDispatch('list', 'createUserList')
const { t } = useTranslation()
useEffect(() => {
switch (actionType) {
case 'import':
setTitle(t('list_import_part_desc'))
setDirOnly(false)
break
case 'export':
default:
setTitle(t('list_export_part_desc'))
setDirOnly(true)
break
}
}, [actionType, t])
const onConfirmPath = useCallback(path => {
hide()
switch (actionType) {
case 'import':
toast(t('setting_backup_part_import_list_tip_unzip'))
importList(path).then(async listData => {
if (listData.type != 'playListPart') return toast(t('list_import_part_tip_failed'))
const targetList = global.allList[listData.data.id]
if (targetList) {
const confirm = await confirmDialog({
message: t('list_import_part_confirm', { importName: listData.data.name, localName: targetList.name }),
cancelButtonText: t('list_import_part_button_cancel'),
confirmButtonText: t('list_import_part_button_confirm'),
bgClose: false,
})
if (confirm) {
listData.data.name = targetList.name
setList({
name: listData.data.name,
id: listData.data.id,
list: listData.data.list,
source: listData.data.source,
sourceListId: listData.data.sourceListId,
})
return
}
listData.data.id += `__${Date.now()}`
}
createUserList({
name: listData.data.name,
id: listData.data.id,
list: listData.data.list,
source: listData.data.source,
sourceListId: listData.data.sourceListId,
position: Math.max(selectedListRef.current.index, -1),
})
})
break
case 'export':
InteractionManager.runAfterInteractions(() => {
toast(t('setting_backup_part_export_list_tip_zip'))
exportList(selectedListRef.current.listInfo, path).then(() => {
toast(t('setting_backup_part_export_list_tip_success'))
}).catch(err => {
log.error(err.message)
toast(t('setting_backup_part_export_list_tip_failed') + ': ' + err.message)
})
})
break
}
}, [actionType, createUserList, hide, setList, t])
return (
<ChoosePath
visible={visible}
hide={hide}
title={title}
dirOnly={dirOnly}
filter={LXM_FILE_EXT_RXP}
onConfirm={onConfirmPath} />
)
}
const List = memo(({ setVisiblePanel, currentList, handleCancelMultiSelect }) => {
const theme = useGetter('common', 'theme')
const defaultList = useGetter('list', 'defaultList')
@ -61,6 +174,8 @@ const List = memo(({ setVisiblePanel, currentList, handleCancelMultiSelect }) =>
const getBoardListAll = useDispatch('top', 'getListAll')
const getListDetailAll = useDispatch('songList', 'getListDetailAll')
const [fetchingListStatus, setFetchingListStatus] = useState({})
const [isShowChoosePath, setShowChoosePath] = useState(false)
const [actionType, setActionType] = useState('')
useEffect(() => {
userListRef.current = userList
@ -75,6 +190,29 @@ const List = memo(({ setVisiblePanel, currentList, handleCancelMultiSelect }) =>
removeUserList({ id })
}, [removeUserList])
const getTargetListInfo = useCallback(index => {
let list
switch (index) {
case -2:
list = defaultList
break
case -1:
list = loveList
break
default:
list = userListRef.current[index]
break
}
return list
}, [defaultList, loveList])
const handleImportAndExportList = useCallback((type, index) => {
const list = getTargetListInfo(index)
if (!list) return
selectedListRef.current.listInfo = list
setActionType(type)
setShowChoosePath(true)
}, [getTargetListInfo])
const hideMenu = useCallback(() => {
setVisibleMenu(false)
@ -113,6 +251,12 @@ const List = memo(({ setVisiblePanel, currentList, handleCancelMultiSelect }) =>
setListNameText(selectedListRef.current.name)
setVisibleRename(true)
break
case 'import':
handleImportAndExportList('import', selectedListRef.current.index)
break
case 'export':
handleImportAndExportList('export', selectedListRef.current.index)
break
case 'sync':
handleSyncSourceList(selectedListRef.current.index)
break
@ -120,26 +264,50 @@ const List = memo(({ setVisiblePanel, currentList, handleCancelMultiSelect }) =>
// break
case 'remove':
confirmDialog({
message: t('list_remove_tip', { name: selectedListRef.current.name }),
confirmButtonText: t('list_remove_tip_button'),
}).then(isRemove => {
if (!isRemove) return
handleRemoveList(selectedListRef.current.id)
})
break
default:
break
}
}, [handleRemoveList, handleSyncSourceList])
}, [handleImportAndExportList, handleRemoveList, handleSyncSourceList, t])
const menus = useMemo(() => {
const list = userList[selectedListIndex]
let list
let rename = false
let sync = false
let remove = false
switch (selectedListIndex) {
case -2:
list = defaultList
break
case -1:
list = loveList
break
default:
list = userList[selectedListIndex]
rename = true
remove = true
sync = list.source && !!musicSdk[list.source].songList
break
}
if (!list) return []
const source = list.source
return [
{ action: 'rename', label: t('list_rename') },
{ action: 'sync', label: t('list_sync'), disabled: !source || !musicSdk[source].songList },
{ action: 'rename', disabled: !rename, label: t('list_rename') },
{ action: 'sync', disabled: !sync, label: t('list_sync') },
{ action: 'import', label: t('list_import') },
{ action: 'export', label: t('list_export') },
// { action: 'changePosition', label: t('change_position') },
{ action: 'remove', label: t('list_remove') },
{ action: 'remove', disabled: !remove, label: t('list_remove') },
]
}, [selectedListIndex, userList, t])
}, [selectedListIndex, t, defaultList, loveList, userList])
const handleCancelRename = useCallback(() => {
setVisibleRename(false)
@ -150,13 +318,6 @@ const List = memo(({ setVisiblePanel, currentList, handleCancelMultiSelect }) =>
setVisibleRename(false)
}, [listNameText, setUserListName])
const handleToggleDefaultList = () => {
handleToggleList(defaultList)
}
const handleToggleLoveList = () => {
handleToggleList(loveList)
}
const handleScroll = useCallback(({ nativeEvent }) => {
saveListScrollPosition(LIST_SCROLL_POSITION_KEY, nativeEvent.contentOffset.y)
}, [])
@ -167,7 +328,7 @@ const List = memo(({ setVisiblePanel, currentList, handleCancelMultiSelect }) =>
})
const showMenu = useCallback((id, name, index, position) => {
// console.log(position)
if (id == 'default' || id == 'love') return
// if (id == 'default' || id == 'love') return
setButtonPosition({ ...position })
selectedListRef.current.id = id
selectedListRef.current.name = name
@ -179,16 +340,8 @@ const List = memo(({ setVisiblePanel, currentList, handleCancelMultiSelect }) =>
<View style={{ ...styles.container, borderBottomColor: theme.secondary10 }}>
<ScrollView style={{ flexShrink: 1, flexGrow: 0 }} onScroll={handleScroll} ref={scrollViewRef} keyboardShouldPersistTaps={'always'}>
<View style={{ ...styles.listContainer, backgroundColor: theme.primary }} onStartShouldSetResponder={() => true}>
<View style={{ ...styles.listItem, borderBottomColor: theme.secondary45 }}>
<TouchableOpacity style={styles.listName} onPress={handleToggleDefaultList}>
<Text numberOfLines={1} style={{ color: theme.normal }}>{defaultList.name}</Text>
</TouchableOpacity>
</View>
<View style={{ ...styles.listItem, borderBottomColor: theme.secondary45 }}>
<TouchableOpacity style={styles.listName} onPress={handleToggleLoveList}>
<Text numberOfLines={1} style={{ color: theme.normal }}>{loveList.name}</Text>
</TouchableOpacity>
</View>
<ListItem name={defaultList.name} id={defaultList.id} index={-2} loading={false} onPress={() => handleToggleList(defaultList)} activeId={currentList.id} showMenu={showMenu} />
<ListItem name={loveList.name} id={loveList.id} index={-1} loading={false} onPress={() => handleToggleList(loveList)} activeId={currentList.id} showMenu={showMenu} />
{userList.map(({ id, name }, index) => <ListItem key={id} name={name} id={id} index={index} loading={fetchingListStatus[id]} onPress={() => handleToggleList({ id, name })} activeId={currentList.id} showMenu={showMenu} />)}
</View>
</ScrollView>
@ -208,6 +361,7 @@ const List = memo(({ setVisiblePanel, currentList, handleCancelMultiSelect }) =>
/>
</View>
</ConfirmAlert>
<ImportExport actionType={actionType} visible={isShowChoosePath} hide={() => setShowChoosePath(false)} selectedListRef={selectedListRef} />
</View>
)
})

View File

@ -1,7 +1,7 @@
import React, { memo, useCallback, useState, useRef } from 'react'
import { StyleSheet, View, Text, InteractionManager } from 'react-native'
import { readFile, writeFile, temporaryDirectoryPath, unlink } from '@/utils/fs'
import { StyleSheet, View, InteractionManager } from 'react-native'
import { log } from '@/utils/log'
import { LXM_FILE_EXT_RXP } from '@/config/constant'
import { useGetter, useDispatch } from '@/store'
// import { gzip, ungzip } from 'pako'
@ -10,33 +10,9 @@ import SubTitle from '../components/SubTitle'
import Button from '../components/Button'
import ChoosePath from '@/components/common/ChoosePath'
import { useTranslation } from '@/plugins/i18n'
import { toast } from '@/utils/tools'
import { gzip, ungzip } from '@/utils/gzip'
import { toast, handleSaveFile, handleReadFile } from '@/utils/tools'
const lxmFileExt = /\.(json|lxmc)$/
const handleSaveFile = async(path, data) => {
// if (!path.endsWith('.json')) path += '.json'
// const buffer = gzip(data)
const tempFilePath = `${temporaryDirectoryPath}/tempFile.json`
await writeFile(tempFilePath, data, 'utf8')
await gzip(tempFilePath, path)
await unlink(tempFilePath)
}
const handleReadFile = async(path) => {
let isJSON = path.endsWith('.json')
let data
if (isJSON) {
data = await readFile(path, 'utf8')
} else {
const tempFilePath = `${temporaryDirectoryPath}/tempFile.json`
await ungzip(path, tempFilePath)
data = await readFile(tempFilePath, 'utf8')
await unlink(tempFilePath)
}
return data
}
const exportList = async(allList, path) => {
const exportAllList = async(allList, path) => {
const data = JSON.parse(JSON.stringify({
type: 'playList',
data: allList,
@ -46,12 +22,16 @@ const exportList = async(allList, path) => {
if (item.otherSource) delete item.otherSource
}
}
await handleSaveFile(path + '/lx_list.lxmc', JSON.stringify(data))
try {
await handleSaveFile(path + '/lx_list.lxmc', data)
} catch (error) {
log.error(error.stack)
}
}
const importList = async path => {
const importAllList = async path => {
let listData
try {
listData = JSON.parse(await handleReadFile(path))
listData = await handleReadFile(path)
} catch (error) {
log.error(error.stack)
return
@ -116,7 +96,7 @@ export default memo(() => {
case 'import_list':
InteractionManager.runAfterInteractions(() => {
toast(t('setting_backup_part_import_list_tip_unzip'))
importList(path).then(listData => {
importAllList(path).then(listData => {
// 兼容0.6.2及以前版本的列表数据
if (listData.type === 'defautlList') {
handleSetList(setList, [
@ -164,7 +144,7 @@ export default memo(() => {
case 'export_list':
InteractionManager.runAfterInteractions(() => {
toast(t('setting_backup_part_export_list_tip_zip'))
exportList(allList, path).then(() => {
exportAllList(allList, path).then(() => {
toast(t('setting_backup_part_export_list_tip_success'))
}).catch(err => {
log.error(err.message)
@ -206,7 +186,7 @@ export default memo(() => {
hide={() => setShowChoosePath(false)}
title={title}
dirOnly={dirOnly}
filter={lxmFileExt}
filter={LXM_FILE_EXT_RXP}
onConfirm={onConfirmPath} />
</>
)

View File

@ -256,14 +256,14 @@ export const updateMusicInfo = ({ listId, id, data, isSync }) => (dispatch, getS
saveList(global.allList[listId])
}
export const createUserList = ({ name, id = `userlist_${Date.now()}`, list = [], source, sourceListId, isSync }) => async(dispatch, getState) => {
export const createUserList = ({ name, id = `userlist_${Date.now()}`, list = [], source, sourceListId, position, isSync }) => async(dispatch, getState) => {
if (!isSync) {
listSync.sendListAction('create_user_list', { name, id, list, source, sourceListId })
listSync.sendListAction('create_user_list', { name, id, list, source, sourceListId, position })
}
dispatch({
type: TYPES.createUserList,
payload: { name, id, source, sourceListId },
payload: { name, id, source, sourceListId, position },
})
dispatch(listAddMultiple({ id, list, isSync: true }))
await saveList(global.allList[id])

View File

@ -241,7 +241,7 @@ const mutations = {
return updateStateList({ ...state }, [listId])
},
[TYPES.createUserList](state, { name, id, source, sourceListId }) {
[TYPES.createUserList](state, { name, id, source, sourceListId, position }) {
let newList = state.userList.find(item => item.id === id)
if (newList) return state
const newState = { ...state }
@ -252,7 +252,13 @@ const mutations = {
source,
sourceListId,
}
newState.userList = [...state.userList, newList]
const userList = [...state.userList]
if (position == null) {
userList.push(newList)
} else {
userList.splice(position + 1, 0, newList)
}
newState.userList = userList
allListUpdate(newList)
return newState
},

View File

@ -156,3 +156,6 @@ export const debounce = (fn, delay = 100) => {
}, delay)
}
}
const fileNameRxp = /[\\/:*?#"<>|]/g
export const filterFileName = name => name.replace(fileNameRxp, '')

View File

@ -1,8 +1,10 @@
import { Platform, NativeModules, ToastAndroid, BackHandler, Linking, Dimensions } from 'react-native'
import { Platform, NativeModules, ToastAndroid, BackHandler, Linking, Dimensions, Alert } from 'react-native'
import ExtraDimensions from 'react-native-extra-dimensions-android'
import { getData, setData, getAllKeys, removeData, removeDataMultiple, setDataMultiple, getDataMultiple } from '@/plugins/storage'
import { storageDataPrefix } from '@/config'
import { throttle } from './index'
import { gzip, ungzip } from '@/utils/gzip'
import { readFile, writeFile, temporaryDirectoryPath, unlink } from '@/utils/fs'
const playInfoStorageKey = storageDataPrefix.playInfo
const listPositionPrefix = storageDataPrefix.listPosition
@ -242,6 +244,57 @@ export const setSyncHost = async({ host, port }) => {
export const exitApp = BackHandler.exitApp
export const handleSaveFile = async(path, data) => {
// if (!path.endsWith('.json')) path += '.json'
// const buffer = gzip(data)
const tempFilePath = `${temporaryDirectoryPath}/tempFile.json`
await writeFile(tempFilePath, JSON.stringify(data), 'utf8')
await gzip(tempFilePath, path)
await unlink(tempFilePath)
}
export const handleReadFile = async(path) => {
let isJSON = path.endsWith('.json')
let data
if (isJSON) {
data = await readFile(path, 'utf8')
} else {
const tempFilePath = `${temporaryDirectoryPath}/tempFile.json`
await ungzip(path, tempFilePath)
data = await readFile(tempFilePath, 'utf8')
await unlink(tempFilePath)
}
return JSON.parse(data)
}
export const confirmDialog = ({
message = '',
cancelButtonText = global.i18n.t('dialog_cancel'),
confirmButtonText = global.i18n.t('dialog_confirm'),
bgClose = true,
}) => {
return new Promise(resolve => {
Alert.alert(null, message, [
{
text: cancelButtonText,
onPress() {
resolve(false)
},
},
{
text: confirmButtonText,
onPress() {
resolve(true)
},
},
], {
cancelable: bgClose,
onDismiss() {
resolve(false)
},
})
})
}
export {
deviceLanguage,
}