新增歌曲标签编辑功能

This commit is contained in:
lyswhut 2023-12-15 17:14:15 +08:00
parent f1d8c9509d
commit c0d990c7ca
21 changed files with 632 additions and 25 deletions

View File

@ -58,7 +58,7 @@
"react-native-exception-handler": "^2.10.10", "react-native-exception-handler": "^2.10.10",
"react-native-fast-image": "^8.6.3", "react-native-fast-image": "^8.6.3",
"react-native-fs": "^2.20.0", "react-native-fs": "^2.20.0",
"react-native-local-media-metadata": "github:lyswhut/react-native-local-media-metadata#8c6525e20f5787dbcd2962e83b635787341e5be5", "react-native-local-media-metadata": "github:lyswhut/react-native-local-media-metadata#c2b993093ddd141b4b88673baba7cb4d7b565d04",
"react-native-navigation": "^7.37.2", "react-native-navigation": "^7.37.2",
"react-native-pager-view": "^6.2.3", "react-native-pager-view": "^6.2.3",
"react-native-quick-base64": "^2.0.8", "react-native-quick-base64": "^2.0.8",

View File

@ -3,6 +3,7 @@
- 新增自定义源实验性功能调用方式与PC端一致但需要注意的是移动端自定义源的环境与PC端不同某些环境API不可用详情看自定义说明文档 - 新增自定义源实验性功能调用方式与PC端一致但需要注意的是移动端自定义源的环境与PC端不同某些环境API不可用详情看自定义说明文档
- 新增长按收藏列表名自动跳转列表顶部的功能 - 新增长按收藏列表名自动跳转列表顶部的功能
- 新增实验性的添加本地歌曲到我的收藏支持与PC端类似在我的收藏的列表菜单中选择歌曲目录将添加所选目录下的所有歌曲目前支持mp3/flac/ogg/wav格式 - 新增实验性的添加本地歌曲到我的收藏支持与PC端类似在我的收藏的列表菜单中选择歌曲目录将添加所选目录下的所有歌曲目前支持mp3/flac/ogg/wav格式
- 新增歌曲标签编辑功能,允许编辑本地源且文件歌曲存在的歌曲标签信息
### 优化 ### 优化

View File

@ -0,0 +1,47 @@
import { memo } from 'react'
import { StyleSheet, View } from 'react-native'
import type { InputProps } from '@/components/common/Input'
import Input from '@/components/common/Input'
import { useTheme } from '@/store/theme/hook'
import Text from '@/components/common/Text'
export interface InputItemProps extends InputProps {
value: string
label: string
onChanged: (text: string) => void
}
export default memo(({ value, label, onChanged, ...props }: InputItemProps) => {
const theme = useTheme()
return (
<View style={styles.container} onStartShouldSetResponder={() => true}>
<Text style={styles.label} size={14}>{label}</Text>
<Input
value={value}
onChangeText={onChanged}
style={{ ...styles.input, backgroundColor: theme['c-primary-input-background'] }}
{...props}
/>
</View>
)
})
const styles = StyleSheet.create({
container: {
// paddingLeft: 25,
marginBottom: 15,
},
label: {
marginBottom: 2,
},
input: {
flexGrow: 1,
flexShrink: 1,
// borderRadius: 4,
// paddingTop: 3,
// paddingBottom: 3,
// maxWidth: 300,
},
})

View File

@ -0,0 +1,128 @@
import { useImperativeHandle, forwardRef, useState, useCallback } from 'react'
import { View } from 'react-native'
import { createStyle } from '@/utils/tools'
import InputItem from './InputItem'
import { useI18n } from '@/lang'
import TextAreaItem from './TextAreaItem'
import PicItem from './PicItem'
import Text from '@/components/common/Text'
import { useTheme } from '@/store/theme/hook'
export interface Metadata {
name: string // 歌曲名
singer: string // 艺术家名
albumName: string // 歌曲专辑名称
pic: string
lyric: string
}
export const defaultData = {
name: '',
singer: '',
albumName: '',
pic: '',
lyric: '',
}
export interface MetadataFormType {
setForm: (path: string, metadata: Metadata) => void
getForm: () => Metadata
}
export default forwardRef<MetadataFormType, {}>((props, ref) => {
const t = useI18n()
const [path, setPath] = useState('')
const [data, setData] = useState({ ...defaultData })
const theme = useTheme()
useImperativeHandle(ref, () => ({
setForm(path, data) {
setPath(path)
setData(data)
},
getForm() {
return {
...data,
name: data.name.trim(),
singer: data.singer.trim(),
albumName: data.albumName.trim(),
}
},
}))
const handleUpdateName = useCallback((name: string) => {
if (name.length > 150) name = name.substring(0, 150)
setData(data => {
return { ...data, name }
})
}, [])
const handleUpdateSinger = useCallback((singer: string) => {
if (singer.length > 150) singer = singer.substring(0, 150)
setData(data => {
return { ...data, singer }
})
}, [])
const handleUpdateAlbumName = useCallback((albumName: string) => {
if (albumName.length > 150) albumName = albumName.substring(0, 150)
setData(data => {
return { ...data, albumName }
})
}, [])
const handleUpdatePic = useCallback((path: string) => {
setData(data => {
return { ...data, pic: path }
})
}, [])
const handleUpdateLyric = useCallback((lyric: string) => {
setData(data => {
return { ...data, lyric }
})
}, [])
return (
<View style={styles.container}>
<View>
<Text size={14}>{global.i18n.t('metadata_edit_modal_file_path')}</Text>
<Text size={14} selectable color={theme['c-primary-font']} style={styles.pathText}>{path}</Text>
</View>
<InputItem
value={data.name}
label={t('metadata_edit_modal_form_name')}
onChanged={handleUpdateName}
keyboardType="name-phone-pad" />
<InputItem
value={data.singer}
label={t('metadata_edit_modal_form_singer')}
onChanged={handleUpdateSinger}
keyboardType="name-phone-pad" />
<InputItem
value={data.albumName}
label={t('metadata_edit_modal_form_album_name')}
onChanged={handleUpdateAlbumName}
keyboardType="name-phone-pad" />
<PicItem
value={data.pic}
label={t('metadata_edit_modal_form_pic')}
onChanged={handleUpdatePic} />
<TextAreaItem
value={data.lyric}
label={t('metadata_edit_modal_form_lyric')}
onChanged={handleUpdateLyric}
keyboardType="default" />
</View>
)
})
const styles = createStyle({
container: {
flexGrow: 1,
flexShrink: 1,
flexDirection: 'column',
width: 360,
maxWidth: '100%',
},
pathText: {
marginBottom: 10,
},
})

View File

@ -0,0 +1,86 @@
import { memo, useCallback, useRef } from 'react'
import { TouchableOpacity, View } from 'react-native'
import { useTheme } from '@/store/theme/hook'
import Text from '@/components/common/Text'
import { createStyle } from '@/utils/tools'
import Image from '@/components/common/Image'
import FileSelect, { type FileSelectType } from '@/components/common/FileSelect'
import { BorderWidths } from '@/theme'
export interface PicItemProps {
value: string
label: string
onChanged: (text: string) => void
}
export default memo(({ value, label, onChanged }: PicItemProps) => {
const theme = useTheme()
const fileSelectRef = useRef<FileSelectType>(null)
const handleRemoveFile = useCallback(() => {
onChanged('')
}, [onChanged])
const handleShowSelectFile = useCallback(() => {
fileSelectRef.current?.show({
title: global.i18n.t('metadata_edit_modal_form_select_pic_title'),
dirOnly: false,
filter: /jpg|jpeg|png/,
}, (path) => {
onChanged(path)
})
}, [onChanged])
return (
<View style={styles.container} onStartShouldSetResponder={() => true}>
<View style={styles.header}>
<Text style={styles.label} size={14}>{label}</Text>
<View style={styles.btns}>
<TouchableOpacity onPress={handleRemoveFile}>
<Text size={13} color={theme['c-button-font']}>{global.i18n.t('metadata_edit_modal_form_remove_pic')}</Text>
</TouchableOpacity>
<TouchableOpacity onPress={handleShowSelectFile}>
<Text size={13} color={theme['c-button-font']}>{global.i18n.t('metadata_edit_modal_form_select_pic')}</Text>
</TouchableOpacity>
</View>
</View>
<View style={styles.picContent}>
<Image
url={value}
style={{ ...styles.pic, borderColor: theme['c-border-background'] }}
/>
</View>
<FileSelect ref={fileSelectRef} />
</View>
)
})
const styles = createStyle({
container: {
// paddingLeft: 25,
marginBottom: 15,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
gap: 30,
},
label: {
marginBottom: 2,
},
btns: {
flexDirection: 'row',
gap: 15,
},
picContent: {
// backgroundColor: 'rgba(0,0,0,0.2)',
marginTop: 5,
position: 'relative',
},
pic: {
width: 180,
height: 180,
borderWidth: BorderWidths.normal,
borderStyle: 'dashed',
},
})

View File

@ -0,0 +1,53 @@
import { memo } from 'react'
import { View } from 'react-native'
import type { InputProps } from '@/components/common/Input'
import Input from '@/components/common/Input'
import { useTheme } from '@/store/theme/hook'
import Text from '@/components/common/Text'
import { createStyle } from '@/utils/tools'
export interface TextAreaItemProps extends InputProps {
value: string
label: string
onChanged: (text: string) => void
}
export default memo(({ value, label, onChanged, ...props }: TextAreaItemProps) => {
const theme = useTheme()
return (
<View style={styles.container} onStartShouldSetResponder={() => true}>
<Text style={styles.label} size={14}>{label}</Text>
<Input
value={value}
onChangeText={onChanged}
numberOfLines={6}
scrollEnabled={false}
textAlignVertical='top'
multiline
style={{ ...styles.textarea, backgroundColor: theme['c-primary-input-background'] }}
{...props}
/>
</View>
)
})
const styles = createStyle({
container: {
// paddingLeft: 25,
marginBottom: 15,
},
label: {
marginBottom: 2,
},
textarea: {
flexGrow: 1,
flexShrink: 1,
paddingTop: 3,
paddingBottom: 3,
height: 'auto',
// height: 300,
// maxWidth: 300,
},
})

View File

@ -0,0 +1,137 @@
import { useRef, useImperativeHandle, forwardRef, useState } from 'react'
import ConfirmAlert, { type ConfirmAlertType } from '@/components/common/ConfirmAlert'
import Text from '@/components/common/Text'
import { View } from 'react-native'
import { createStyle, toast } from '@/utils/tools'
import {
readMetadata,
readPic,
readLyric,
writeMetadata,
writePic,
writeLyric,
} from '@/utils/localMediaMetadata'
import { useUnmounted } from '@/utils/hooks'
import MetadataForm, { defaultData, type Metadata, type MetadataFormType } from './MetadataForm'
export type {
Metadata,
}
export interface MetadataEditType {
show: (filePath: string) => void
}
export interface MetadataEditProps {
onUpdate: (info: Metadata) => void
}
export default forwardRef<MetadataEditType, MetadataEditProps>((props, ref) => {
const alertRef = useRef<ConfirmAlertType>(null)
const metadataFormRef = useRef<MetadataFormType>(null)
const filePath = useRef<string>('')
const metadata = useRef<Metadata>({ ...defaultData })
const [visible, setVisible] = useState(false)
const [processing, setProcessing] = useState(false)
const isUnmounted = useUnmounted()
const handleShow = (filePath: string) => {
alertRef.current?.setVisible(true)
void Promise.all([
readMetadata(filePath),
readPic(filePath),
readLyric(filePath),
]).then(([_metadata, pic, lyric]) => {
if (!_metadata) return
if (isUnmounted) return
metadata.current = {
name: _metadata.name,
singer: _metadata.singer,
albumName: _metadata.albumName,
pic,
lyric,
}
requestAnimationFrame(() => {
metadataFormRef.current?.setForm(filePath, metadata.current)
})
})
}
useImperativeHandle(ref, () => ({
show(path) {
filePath.current = path
if (visible) handleShow(path)
else {
setVisible(true)
requestAnimationFrame(() => {
handleShow(path)
})
}
},
}))
const handleUpdate = async() => {
if (!metadataFormRef.current) return
let _metadata = metadataFormRef.current.getForm()
if (!_metadata.name) {
toast(global.i18n.t('metadata_edit_modal_tip'), 'long')
return
}
setProcessing(true)
let isUpdated = false
if (
_metadata.name != metadata.current.name ||
_metadata.singer != metadata.current.singer ||
_metadata.albumName != metadata.current.albumName
) {
isUpdated ||= true
await writeMetadata(filePath.current, {
name: _metadata.name,
singer: _metadata.singer,
albumName: _metadata.albumName,
})
}
if (_metadata.pic != metadata.current.pic) {
isUpdated ||= true
await writePic(filePath.current, _metadata.pic)
}
if (_metadata.lyric != metadata.current.lyric) {
isUpdated ||= true
await writeLyric(filePath.current, _metadata.lyric)
}
setProcessing(false)
if (isUpdated) toast(global.i18n.t('metadata_edit_modal_success'), 'long')
alertRef.current?.setVisible(false)
props.onUpdate(_metadata)
}
return (
visible
? <ConfirmAlert
ref={alertRef}
onConfirm={handleUpdate}
confirmText={processing ? global.i18n.t('metadata_edit_modal_processing') : global.i18n.t('metadata_edit_modal_confirm')}
disabledConfirm={processing}
>
<View style={styles.renameContent} onStartShouldSetResponder={() => true}>
<Text style={styles.title}>{global.i18n.t('metadata_edit_modal_title')}</Text>
<MetadataForm ref={metadataFormRef} />
</View>
</ConfirmAlert>
: null
)
})
const styles = createStyle({
renameContent: {
flexGrow: 1,
flexShrink: 1,
flexDirection: 'column',
},
title: {
marginBottom: 20,
textAlign: 'center',
},
})

View File

@ -240,7 +240,7 @@ const styles = createStyle({
inputContent: { inputContent: {
marginTop: 8, marginTop: 8,
flex: 1, flex: 1,
flexDirection: 'row', // flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
}, },
input: { input: {

View File

@ -8,7 +8,7 @@ import ConfirmAlert, { type ConfirmAlertType } from '@/components/common/Confirm
import { checkStoragePermissions, requestStoragePermission, toast } from '@/utils/tools' import { checkStoragePermissions, requestStoragePermission, toast } from '@/utils/tools'
import { useI18n } from '@/lang' import { useI18n } from '@/lang'
interface ReadOptions { export interface ReadOptions {
title: string title: string
dirOnly?: boolean dirOnly?: boolean
filter?: RegExp filter?: RegExp

View File

@ -63,6 +63,7 @@ export interface ConfirmAlertProps {
cancelText?: string cancelText?: string
confirmText?: string confirmText?: string
showConfirm?: boolean showConfirm?: boolean
disabledConfirm?: boolean
reverseBtn?: boolean reverseBtn?: boolean
children?: React.ReactNode | React.ReactNode[] children?: React.ReactNode | React.ReactNode[]
} }
@ -83,6 +84,7 @@ export default forwardRef<ConfirmAlertType, ConfirmAlertProps>(({
cancelText = '', cancelText = '',
confirmText = '', confirmText = '',
showConfirm = true, showConfirm = true,
disabledConfirm = false,
children, children,
reverseBtn = false, reverseBtn = false,
}: ConfirmAlertProps, ref) => { }: ConfirmAlertProps, ref) => {
@ -114,7 +116,7 @@ export default forwardRef<ConfirmAlertType, ConfirmAlertProps>(({
<Text color={theme['c-button-font']}>{cancelText || t('cancel')}</Text> <Text color={theme['c-button-font']}>{cancelText || t('cancel')}</Text>
</Button> </Button>
{showConfirm {showConfirm
? <Button style={{ ...styles.btn, ...(reverseBtn ? styles.btnReversedDirection : styles.btnDirection), backgroundColor: theme['c-button-background'] }} onPress={onConfirm}> ? <Button style={{ ...styles.btn, ...(reverseBtn ? styles.btnReversedDirection : styles.btnDirection), backgroundColor: theme['c-button-background'] }} onPress={onConfirm} disabled={disabledConfirm}>
<Text color={theme['c-button-font']}>{confirmText || t('confirm')}</Text> <Text color={theme['c-button-font']}>{confirmText || t('confirm')}</Text>
</Button> </Button>
: null} : null}

View File

@ -0,0 +1,33 @@
import ChoosePath, { type ReadOptions, type ChoosePathType } from '@/components/common/ChoosePath'
import { forwardRef, useImperativeHandle, useRef, useState } from 'react'
export interface FileSelectType {
show: (options: ReadOptions, onSelect: typeof noop) => void
}
const noop = (path: string) => {}
export default forwardRef<FileSelectType, {}>((props, ref) => {
const [visible, setVisible] = useState(false)
const choosePathRef = useRef<ChoosePathType>(null)
const onSelectRef = useRef<typeof noop>(noop)
// console.log('render import export')
useImperativeHandle(ref, () => ({
show(options, onSelect) {
onSelectRef.current = onSelect ?? noop
if (visible) {
choosePathRef.current?.show(options)
} else {
setVisible(true)
requestAnimationFrame(() => {
choosePathRef.current?.show(options)
})
}
},
}))
return (
visible
? <ChoosePath ref={choosePathRef} onConfirm={onSelectRef.current} />
: null
)
})

View File

@ -12,7 +12,11 @@ const defaultHeaders = {
} }
const Image = ({ url, resizeMode = FastImage.resizeMode.cover, ...props }: ImageProps) => { const Image = ({ url, resizeMode = FastImage.resizeMode.cover, ...props }: ImageProps) => {
let uri = typeof url == 'number' ? _Image.resolveAssetSource(url).uri : url let uri = typeof url == 'number'
? _Image.resolveAssetSource(url).uri
: url?.startsWith('/')
? 'file://' + url
: url
return ( return (
<FastImage <FastImage
{...props} {...props}

View File

@ -43,6 +43,7 @@
"disagree_tip": "Cancelled...", "disagree_tip": "Cancelled...",
"dislike": "Dislike", "dislike": "Dislike",
"duplicate_list_tip": "You have previously favorited the list [{name}], do you want to update the songs?", "duplicate_list_tip": "You have previously favorited the list [{name}], do you want to update the songs?",
"edit_metadata": "Edit tag",
"exit_app_tip": "Are you sure you want to quit the app?", "exit_app_tip": "Are you sure you want to quit the app?",
"ignoring_battery_optimization_check_tip": "LX Music is not on the list of ignored battery optimization, which may cause the problem of being suspended by the system when playing music in the background. Do you want to add LX Music to the whitelist?", "ignoring_battery_optimization_check_tip": "LX Music is not on the list of ignored battery optimization, which may cause the problem of being suspended by the system when playing music in the background. Do you want to add LX Music to the whitelist?",
"ignoring_battery_optimization_check_title": "Background running permission setting reminder", "ignoring_battery_optimization_check_title": "Background running permission setting reminder",
@ -106,6 +107,20 @@
"loading": "Loading...", "loading": "Loading...",
"location": "From {location}", "location": "From {location}",
"lyric__load_error": "Failed to get lyrics", "lyric__load_error": "Failed to get lyrics",
"metadata_edit_modal_confirm": "Save",
"metadata_edit_modal_file_path": "File path",
"metadata_edit_modal_form_album_name": "Album name",
"metadata_edit_modal_form_lyric": "LRC Lyrics",
"metadata_edit_modal_form_name": "Song name",
"metadata_edit_modal_form_pic": "Song cover",
"metadata_edit_modal_form_remove_pic": "Remove image",
"metadata_edit_modal_form_select_pic": "Select Image",
"metadata_edit_modal_form_select_pic_title": "Select song cover image",
"metadata_edit_modal_form_singer": "Artist",
"metadata_edit_modal_processing": "Writing...",
"metadata_edit_modal_success": "Saved successfully",
"metadata_edit_modal_tip": "Song name cannot be empty",
"metadata_edit_modal_title": "Edit song tags",
"move_to": "Move to...", "move_to": "Move to...",
"name": "Name: {name}", "name": "Name: {name}",
"nav_exit": "Exit application", "nav_exit": "Exit application",

View File

@ -43,6 +43,7 @@
"disagree_tip": "那算了... 🙄", "disagree_tip": "那算了... 🙄",
"dislike": "不喜欢", "dislike": "不喜欢",
"duplicate_list_tip": "你之前已收藏过该列表 [{name}],是否需要更新里面的歌曲?", "duplicate_list_tip": "你之前已收藏过该列表 [{name}],是否需要更新里面的歌曲?",
"edit_metadata": "编辑标签",
"exit_app_tip": "确定要退出应用吗?", "exit_app_tip": "确定要退出应用吗?",
"ignoring_battery_optimization_check_tip": "LX Music没有在忽略电池优化的名单中这可能会导致在后台播放音乐时被系统暂停的问题是否将LX Music加入该白名单中", "ignoring_battery_optimization_check_tip": "LX Music没有在忽略电池优化的名单中这可能会导致在后台播放音乐时被系统暂停的问题是否将LX Music加入该白名单中",
"ignoring_battery_optimization_check_title": "后台运行权限设置提醒", "ignoring_battery_optimization_check_title": "后台运行权限设置提醒",
@ -106,6 +107,20 @@
"loading": "加载中...", "loading": "加载中...",
"location": "来自{location}", "location": "来自{location}",
"lyric__load_error": "歌词获取失败", "lyric__load_error": "歌词获取失败",
"metadata_edit_modal_confirm": "保存",
"metadata_edit_modal_file_path": "文件路径",
"metadata_edit_modal_form_album_name": "专辑名",
"metadata_edit_modal_form_lyric": "LRC 歌词",
"metadata_edit_modal_form_name": "歌曲名",
"metadata_edit_modal_form_pic": "歌曲封面",
"metadata_edit_modal_form_remove_pic": "移除图片",
"metadata_edit_modal_form_select_pic": "选择图片",
"metadata_edit_modal_form_select_pic_title": "选择歌曲封面图片",
"metadata_edit_modal_form_singer": "艺术家",
"metadata_edit_modal_processing": "写入中...",
"metadata_edit_modal_success": "保存成功",
"metadata_edit_modal_tip": "歌曲名不能为空",
"metadata_edit_modal_title": "编辑歌曲标签",
"move_to": "移动到...", "move_to": "移动到...",
"name": "歌曲名:{name}", "name": "歌曲名:{name}",
"nav_exit": "退出应用", "nav_exit": "退出应用",

View File

@ -1,7 +1,8 @@
import { useMemo, useRef, useImperativeHandle, forwardRef, useState } from 'react' import { useRef, useImperativeHandle, forwardRef, useState } from 'react'
import { useI18n } from '@/lang' import { useI18n } from '@/lang'
import Menu, { type MenuType, type Position } from '@/components/common/Menu' import Menu, { type Menus, type MenuType, type Position } from '@/components/common/Menu'
import { hasDislike } from '@/core/dislikeList' import { hasDislike } from '@/core/dislikeList'
import { existsFile } from '@/utils/fs'
export interface SelectInfo { export interface SelectInfo {
musicInfo: LX.Music.MusicInfo musicInfo: LX.Music.MusicInfo
@ -17,6 +18,7 @@ export interface ListMenuProps {
onPlayLater: (selectInfo: SelectInfo) => void onPlayLater: (selectInfo: SelectInfo) => void
onAdd: (selectInfo: SelectInfo) => void onAdd: (selectInfo: SelectInfo) => void
onMove: (selectInfo: SelectInfo) => void onMove: (selectInfo: SelectInfo) => void
onEditMetadata: (selectInfo: SelectInfo) => void
onCopyName: (selectInfo: SelectInfo) => void onCopyName: (selectInfo: SelectInfo) => void
onChangePosition: (selectInfo: SelectInfo) => void onChangePosition: (selectInfo: SelectInfo) => void
onDislikeMusic: (selectInfo: SelectInfo) => void onDislikeMusic: (selectInfo: SelectInfo) => void
@ -30,17 +32,21 @@ export type {
Position, Position,
} }
const hasEditMetadata = async(musicInfo: LX.Music.MusicInfo) => {
if (musicInfo.source != 'local') return false
return existsFile(musicInfo.meta.filePath)
}
export default forwardRef<ListMenuType, ListMenuProps>((props, ref) => { export default forwardRef<ListMenuType, ListMenuProps>((props, ref) => {
const t = useI18n() const t = useI18n()
const [visible, setVisible] = useState(false) const [visible, setVisible] = useState(false)
const menuRef = useRef<MenuType>(null) const menuRef = useRef<MenuType>(null)
const selectInfoRef = useRef<SelectInfo>(initSelectInfo as SelectInfo) const selectInfoRef = useRef<SelectInfo>(initSelectInfo as SelectInfo)
const [isDislikeMusic, setDislikeMusic] = useState(false) const [menus, setMenus] = useState<Menus>([])
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
show(selectInfo, position) { show(selectInfo, position) {
selectInfoRef.current = selectInfo selectInfoRef.current = selectInfo
setDislikeMusic(hasDislike(selectInfo.musicInfo)) handleSetMenu(selectInfo.musicInfo)
if (visible) menuRef.current?.show(position) if (visible) menuRef.current?.show(position)
else { else {
setVisible(true) setVisible(true)
@ -51,8 +57,9 @@ export default forwardRef<ListMenuType, ListMenuProps>((props, ref) => {
}, },
})) }))
const menus = useMemo(() => { const handleSetMenu = (musicInfo: LX.Music.MusicInfo) => {
return [ let edit_metadata = false
const menu = [
{ action: 'play', label: t('play') }, { action: 'play', label: t('play') },
{ action: 'playLater', label: t('play_later') }, { action: 'playLater', label: t('play_later') },
// { action: 'download', label: '下载' }, // { action: 'download', label: '下载' },
@ -60,10 +67,36 @@ export default forwardRef<ListMenuType, ListMenuProps>((props, ref) => {
{ action: 'move', label: t('move_to') }, { action: 'move', label: t('move_to') },
{ action: 'copyName', label: t('copy_name') }, { action: 'copyName', label: t('copy_name') },
{ action: 'changePosition', label: t('change_position') }, { action: 'changePosition', label: t('change_position') },
{ action: 'dislike', label: t('dislike'), disabled: isDislikeMusic }, { action: 'dislike', disabled: hasDislike(musicInfo), label: t('dislike') },
{ action: 'remove', label: t('delete') }, { action: 'remove', label: t('delete') },
] as const ]
}, [t, isDislikeMusic]) if (musicInfo.source == 'local') menu.splice(4, 0, { action: 'editMetadata', disabled: !edit_metadata, label: t('edit_metadata') })
setMenus(menu)
void Promise.all([hasEditMetadata(musicInfo)]).then(([_edit_metadata]) => {
console.log(_edit_metadata)
let isUpdated = true
if (edit_metadata != _edit_metadata) {
edit_metadata = _edit_metadata
isUpdated ||= true
}
if (isUpdated) {
const menu = [
{ action: 'play', label: t('play') },
{ action: 'playLater', label: t('play_later') },
// { action: 'download', label: '下载' },
{ action: 'add', label: t('add_to') },
{ action: 'move', label: t('move_to') },
{ action: 'copyName', label: t('copy_name') },
{ action: 'changePosition', label: t('change_position') },
{ action: 'dislike', disabled: hasDislike(musicInfo), label: t('dislike') },
{ action: 'remove', label: t('delete') },
]
if (musicInfo.source == 'local') menu.splice(4, 0, { action: 'editMetadata', disabled: !edit_metadata, label: t('edit_metadata') })
setMenus(menu)
}
})
}
const handleMenuPress = ({ action }: typeof menus[number]) => { const handleMenuPress = ({ action }: typeof menus[number]) => {
const selectInfo = selectInfoRef.current const selectInfo = selectInfoRef.current
@ -89,6 +122,9 @@ export default forwardRef<ListMenuType, ListMenuProps>((props, ref) => {
// ? setVisibleMusicMultiAddModal(true) // ? setVisibleMusicMultiAddModal(true)
// : setVisibleMusicAddModal(true) // : setVisibleMusicAddModal(true)
break break
case 'editMetadata':
props.onEditMetadata(selectInfo)
break
case 'copyName': case 'copyName':
props.onCopyName(selectInfo) props.onCopyName(selectInfo)
break break

View File

@ -2,7 +2,7 @@ import { useCallback, useRef } from 'react'
import listState from '@/store/list/state' import listState from '@/store/list/state'
import ListMenu, { type ListMenuType, type Position, type SelectInfo } from './ListMenu' import ListMenu, { type ListMenuType, type Position, type SelectInfo } from './ListMenu'
import { handleDislikeMusic, handlePlay, handlePlayLater, handleRemove, handleShare, handleUpdateMusicPosition } from './listAction' import { handleDislikeMusic, handlePlay, handlePlayLater, handleRemove, handleShare, handleUpdateMusicInfo, handleUpdateMusicPosition } from './listAction'
import List, { type ListType } from './List' import List, { type ListType } from './List'
import ListMusicAdd, { type MusicAddModalType as ListMusicAddType } from '@/components/MusicAddModal' import ListMusicAdd, { type MusicAddModalType as ListMusicAddType } from '@/components/MusicAddModal'
import ListMusicMultiAdd, { type MusicMultiAddModalType as ListAddMultiType } from '@/components/MusicMultiAddModal' import ListMusicMultiAdd, { type MusicMultiAddModalType as ListAddMultiType } from '@/components/MusicMultiAddModal'
@ -13,6 +13,7 @@ import MultipleModeBar, { type SelectMode, type MultipleModeBarType } from './Mu
import ListSearchBar, { type ListSearchBarType } from './ListSearchBar' import ListSearchBar, { type ListSearchBarType } from './ListSearchBar'
import ListMusicSearch, { type ListMusicSearchType } from './ListMusicSearch' import ListMusicSearch, { type ListMusicSearchType } from './ListMusicSearch'
import MusicPositionModal, { type MusicPositionModalType } from './MusicPositionModal' import MusicPositionModal, { type MusicPositionModalType } from './MusicPositionModal'
import MetadataEditModal, { type MetadataEditType, type MetadataEditProps } from '@/components/MetadataEditModal'
export default () => { export default () => {
@ -25,10 +26,12 @@ export default () => {
const listMusicAddRef = useRef<ListMusicAddType>(null) const listMusicAddRef = useRef<ListMusicAddType>(null)
const listMusicMultiAddRef = useRef<ListAddMultiType>(null) const listMusicMultiAddRef = useRef<ListAddMultiType>(null)
const musicPositionModalRef = useRef<MusicPositionModalType>(null) const musicPositionModalRef = useRef<MusicPositionModalType>(null)
const metadataEditTypeRef = useRef<MetadataEditType>(null)
const listMenuRef = useRef<ListMenuType>(null) const listMenuRef = useRef<ListMenuType>(null)
const layoutHeightRef = useRef<number>(0) const layoutHeightRef = useRef<number>(0)
const isShowMultipleModeBar = useRef(false) const isShowMultipleModeBar = useRef(false)
const isShowSearchBarModeBar = useRef(false) const isShowSearchBarModeBar = useRef(false)
const selectedInfoRef = useRef<SelectInfo>()
// console.log('render index list') // console.log('render index list')
const hancelMultiSelect = useCallback(() => { const hancelMultiSelect = useCallback(() => {
@ -103,6 +106,15 @@ export default () => {
listMusicAddRef.current?.show({ musicInfo: info.musicInfo, listId: info.listId, isMove: true }) listMusicAddRef.current?.show({ musicInfo: info.musicInfo, listId: info.listId, isMove: true })
} }
}, []) }, [])
const handleEditMetadata = useCallback((info: SelectInfo) => {
if (info.musicInfo.source != 'local') return
selectedInfoRef.current = info
metadataEditTypeRef.current?.show(info.musicInfo.meta.filePath)
}, [])
const handleUpdateMetadata = useCallback<MetadataEditProps['onUpdate']>((info) => {
if (!selectedInfoRef.current || selectedInfoRef.current.musicInfo.source != 'local') return
handleUpdateMusicInfo(selectedInfoRef.current.listId, selectedInfoRef.current.musicInfo, info)
}, [])
return ( return (
@ -146,8 +158,13 @@ export default () => {
onCopyName={info => { handleShare(info.musicInfo) }} onCopyName={info => { handleShare(info.musicInfo) }}
onAdd={handleAddMusic} onAdd={handleAddMusic}
onMove={handleMoveMusic} onMove={handleMoveMusic}
onEditMetadata={handleEditMetadata}
onChangePosition={info => musicPositionModalRef.current?.show(info)} onChangePosition={info => musicPositionModalRef.current?.show(info)}
/> />
<MetadataEditModal
ref={metadataEditTypeRef}
onUpdate={handleUpdateMetadata}
/>
</View> </View>
) )
} }

View File

@ -1,4 +1,4 @@
import { removeListMusics, updateListMusicPosition } from '@/core/list' import { removeListMusics, updateListMusicPosition, updateListMusics } from '@/core/list'
import { playList, playNext } from '@/core/player/player' import { playList, playNext } from '@/core/player/player'
import { addTempPlayList } from '@/core/player/tempPlayList' import { addTempPlayList } from '@/core/player/tempPlayList'
import settingState from '@/store/setting/state' import settingState from '@/store/setting/state'
@ -8,6 +8,7 @@ import { addDislikeInfo, hasDislike } from '@/core/dislikeList'
import playerState from '@/store/player/state' import playerState from '@/store/player/state'
import type { SelectInfo } from './ListMenu' import type { SelectInfo } from './ListMenu'
import { type Metadata } from '@/components/MetadataEditModal'
export const handlePlay = (listId: SelectInfo['listId'], index: SelectInfo['index']) => { export const handlePlay = (listId: SelectInfo['listId'], index: SelectInfo['index']) => {
void playList(listId, index) void playList(listId, index)
@ -46,6 +47,23 @@ export const handleUpdateMusicPosition = (position: number, listId: SelectInfo['
} }
} }
export const handleUpdateMusicInfo = (listId: SelectInfo['listId'], musicInfo: LX.Music.MusicInfoLocal, newInfo: Metadata) => {
void updateListMusics([
{
id: listId,
musicInfo: {
...musicInfo,
name: newInfo.name,
singer: newInfo.singer,
meta: {
...musicInfo.meta,
albumName: newInfo.albumName,
},
},
},
])
}
export const handleShare = (musicInfo: SelectInfo['musicInfo']) => { export const handleShare = (musicInfo: SelectInfo['musicInfo']) => {
shareMusic(settingState.setting['common.shareType'], settingState.setting['download.fileName'], musicInfo) shareMusic(settingState.setting['common.shareType'], settingState.setting['download.fileName'], musicInfo)

View File

@ -100,14 +100,6 @@ declare namespace LX {
lyrics: LyricInfo lyrics: LyricInfo
} }
interface MusicFileMeta {
title: string
artist: string | null
album: string | null
APIC: string | null
lyrics: string | null
}
interface MusicUrlInfo { interface MusicUrlInfo {
id: string id: string
url: string url: string

View File

@ -7,3 +7,4 @@ export { default as useDeviceOrientation } from './useDeviceOrientation'
export { default as usePlayTime } from './usePlayTime' export { default as usePlayTime } from './usePlayTime'
export { default as useAssertApiSupport } from './useAssertApiSupport' export { default as useAssertApiSupport } from './useAssertApiSupport'
export { useDrag } from './useDrag' export { useDrag } from './useDrag'
export { useUnmounted } from './useUnmounted'

View File

@ -0,0 +1,13 @@
import { useEffect, useRef } from 'react'
export function useUnmounted() {
const isUnmountedRef = useRef(false)
useEffect(() => {
isUnmountedRef.current = false
return () => {
isUnmountedRef.current = true
}
}, [])
return isUnmountedRef.current
}

View File

@ -1,5 +1,14 @@
import { scanFiles } from 'react-native-local-media-metadata' import { scanFiles } from 'react-native-local-media-metadata'
export { type MusicMetadata, readMetadata, readPic, readLyric } from 'react-native-local-media-metadata' export {
type MusicMetadata,
type MusicMetadataFull,
readMetadata,
writeMetadata,
readPic,
writePic,
readLyric,
writeLyric,
} from 'react-native-local-media-metadata'
export const scanAudioFiles = async(dirPath: string): Promise<string[]> => { export const scanAudioFiles = async(dirPath: string): Promise<string[]> => {
return scanFiles(dirPath, ['mp3', 'flac', 'ogg', 'wav']) return scanFiles(dirPath, ['mp3', 'flac', 'ogg', 'wav'])