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,
}