diff --git a/publish/changeLog.md b/publish/changeLog.md index 1b65375..54e3c4f 100644 --- a/publish/changeLog.md +++ b/publish/changeLog.md @@ -2,6 +2,8 @@ - 新增歌曲评论显示,可在播放详情页进入。(与PC端一样,目前仅支持显示部分评论) - 新增播放、收藏整个排行榜功能,可长按排行榜名字后在弹出的菜单中操作 +- 新增单个列表导入/导出功能,可以方便分享歌曲列表,可在点击“我的列表”里的列表名右侧的按钮后弹出的菜单中使用 +- 新增删除列表前的确认弹窗,防止误删列表 ### 优化 diff --git a/src/config/constant.js b/src/config/constant.js index 4a0cef3..834392a 100644 --- a/src/config/constant.js +++ b/src/config/constant.js @@ -23,3 +23,5 @@ export const NAV_VIEW_NAMES = { list: 3, setting: 4, } + +const LXM_FILE_EXT_RXP = /\.(json|lxmc)$/ diff --git a/src/lang/en_us.json b/src/lang/en_us.json index 170c7be..092e977 100644 --- a/src/lang/en_us.json +++ b/src/lang/en_us.json @@ -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", diff --git a/src/lang/zh_cn.json b/src/lang/zh_cn.json index 68581b1..ca45489 100644 --- a/src/lang/zh_cn.json +++ b/src/lang/zh_cn.json @@ -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": "全选", diff --git a/src/screens/Home/List/components/MyList.js b/src/screens/Home/List/components/MyList.js index f432774..d2b254c 100644 --- a/src/screens/Home/List/components/MyList.js +++ b/src/screens/Home/List/components/MyList.js @@ -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 ( + + ) +} + 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': - handleRemoveList(selectedListRef.current.id) + 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 }) => true}> - - - {defaultList.name} - - - - - {loveList.name} - - + handleToggleList(defaultList)} activeId={currentList.id} showMenu={showMenu} /> + handleToggleList(loveList)} activeId={currentList.id} showMenu={showMenu} /> {userList.map(({ id, name }, index) => handleToggleList({ id, name })} activeId={currentList.id} showMenu={showMenu} />)} @@ -208,6 +361,7 @@ const List = memo(({ setVisiblePanel, currentList, handleCancelMultiSelect }) => /> + setShowChoosePath(false)} selectedListRef={selectedListRef} /> ) }) diff --git a/src/screens/Home/Setting/Backup/Part.js b/src/screens/Home/Setting/Backup/Part.js index 4617076..ac2510a 100644 --- a/src/screens/Home/Setting/Backup/Part.js +++ b/src/screens/Home/Setting/Backup/Part.js @@ -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} /> ) diff --git a/src/store/modules/list/action.js b/src/store/modules/list/action.js index 792c389..1ce787a 100644 --- a/src/store/modules/list/action.js +++ b/src/store/modules/list/action.js @@ -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]) diff --git a/src/store/modules/list/reducer.js b/src/store/modules/list/reducer.js index 42dc467..15f7b48 100644 --- a/src/store/modules/list/reducer.js +++ b/src/store/modules/list/reducer.js @@ -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 }, diff --git a/src/utils/index.js b/src/utils/index.js index 093ad70..34dd5ba 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -156,3 +156,6 @@ export const debounce = (fn, delay = 100) => { }, delay) } } + +const fileNameRxp = /[\\/:*?#"<>|]/g +export const filterFileName = name => name.replace(fileNameRxp, '') diff --git a/src/utils/tools.js b/src/utils/tools.js index 8d4f517..2b58df9 100644 --- a/src/utils/tools.js +++ b/src/utils/tools.js @@ -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, }