diff --git a/android/app/src/main/java/cn/toside/music/mobile/MainApplication.java b/android/app/src/main/java/cn/toside/music/mobile/MainApplication.java index adf0904b9..f1bf7e47d 100644 --- a/android/app/src/main/java/cn/toside/music/mobile/MainApplication.java +++ b/android/app/src/main/java/cn/toside/music/mobile/MainApplication.java @@ -1,20 +1,16 @@ package cn.toside.music.mobile; -import android.app.Application; import com.facebook.react.PackageList; import com.facebook.react.flipper.ReactNativeFlipper; import com.reactnativenavigation.NavigationApplication; import com.facebook.react.ReactNativeHost; import com.facebook.react.ReactPackage; import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint; -import com.facebook.react.defaults.DefaultReactNativeHost; import com.reactnativenavigation.react.NavigationReactNativeHost; -import com.facebook.soloader.SoLoader; import java.util.List; import cn.toside.music.mobile.cache.CachePackage; import cn.toside.music.mobile.crypto.CryptoPackage; -import cn.toside.music.mobile.gzip.GzipPackage; import cn.toside.music.mobile.lyric.LyricPackage; import cn.toside.music.mobile.userApi.UserApiPackage; import cn.toside.music.mobile.utils.UtilsPackage; @@ -35,7 +31,6 @@ protected List getPackages() { // Packages that cannot be autolinked yet can be added manually here, for example: // packages.add(new MyReactNativePackage()); packages.add(new CachePackage()); - packages.add(new GzipPackage()); packages.add(new LyricPackage()); packages.add(new UtilsPackage()); packages.add(new CryptoPackage()); diff --git a/android/app/src/main/java/cn/toside/music/mobile/gzip/GzipModule.java b/android/app/src/main/java/cn/toside/music/mobile/gzip/GzipModule.java deleted file mode 100644 index 45fe5577b..000000000 --- a/android/app/src/main/java/cn/toside/music/mobile/gzip/GzipModule.java +++ /dev/null @@ -1,49 +0,0 @@ -package cn.toside.music.mobile.gzip; - -import android.util.Base64; - -import com.facebook.common.internal.Throwables; -import com.facebook.react.bridge.Promise; -import com.facebook.react.bridge.ReactApplicationContext; -import com.facebook.react.bridge.ReactContextBaseJavaModule; -import com.facebook.react.bridge.ReactMethod; - -import cn.toside.music.mobile.utils.AsyncTask; - -// https://github.com/FWC1994/react-native-gzip/blob/main/android/src/main/java/com/reactlibrary/GzipModule.java -// https://www.digitalocean.com/community/tutorials/java-gzip-example-compress-decompress-file -// https://github.com/ammarahm-ed/react-native-gzip/blob/master/android/src/main/java/com/gzip/GzipModule.java -public class GzipModule extends ReactContextBaseJavaModule { - private final ReactApplicationContext reactContext; - - GzipModule(ReactApplicationContext reactContext) { - super(reactContext); - this.reactContext = reactContext; - } - - @Override - public String getName() { - return "GzipModule"; - } - - @ReactMethod - public void unGzipFromBase64(String base64, Promise promise) { - AsyncTask.runTask(new Utils.UnGzip(base64), promise); - } - - @ReactMethod - public void gzipStringToBase64(String data, Promise promise) { - AsyncTask.runTask(new Utils.Gzip(data), promise); - } - - @ReactMethod - public void unGzipFile(String source, String target, Boolean force, Promise promise) { - AsyncTask.runTask(new Utils.UnGzipFile(source, target, force), promise); - } - - @ReactMethod - public void gzipFile(String source, String target, Boolean force, Promise promise) { - AsyncTask.runTask(new Utils.GzipFile(source, target, force), promise); - } -} - diff --git a/android/app/src/main/java/cn/toside/music/mobile/gzip/GzipPackage.java b/android/app/src/main/java/cn/toside/music/mobile/gzip/GzipPackage.java deleted file mode 100644 index 19ae88d97..000000000 --- a/android/app/src/main/java/cn/toside/music/mobile/gzip/GzipPackage.java +++ /dev/null @@ -1,24 +0,0 @@ -package cn.toside.music.mobile.gzip; - -import com.facebook.react.ReactPackage; -import com.facebook.react.bridge.NativeModule; -import com.facebook.react.bridge.ReactApplicationContext; -import com.facebook.react.uimanager.ViewManager; - -import java.util.Arrays; -import java.util.Collections; -import java.util.List; - -public class GzipPackage implements ReactPackage { - - @Override - public List createViewManagers(ReactApplicationContext reactContext) { - return Collections.emptyList(); - } - - @Override - public List createNativeModules(ReactApplicationContext reactContext) { - return Arrays.asList(new GzipModule(reactContext)); - } - -} diff --git a/android/app/src/main/java/cn/toside/music/mobile/gzip/Utils.java b/android/app/src/main/java/cn/toside/music/mobile/gzip/Utils.java deleted file mode 100644 index 7ee5965e6..000000000 --- a/android/app/src/main/java/cn/toside/music/mobile/gzip/Utils.java +++ /dev/null @@ -1,195 +0,0 @@ -package cn.toside.music.mobile.gzip; - - -import static cn.toside.music.mobile.utils.Utils.deletePath; - -import android.util.Base64; -import android.util.Log; - -import com.facebook.common.internal.Throwables; - -import java.io.BufferedReader; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStreamReader; -import java.nio.charset.StandardCharsets; -import java.util.concurrent.Callable; -import java.util.zip.GZIPInputStream; -import java.util.zip.GZIPOutputStream; - -// https://github.com/FWC1994/react-native-gzip/blob/main/android/src/main/java/com/reactlibrary/GzipModule.java -public class Utils { - static public Boolean checkDir(File sourceFile, File targetFile, Boolean force) { - if (!sourceFile.exists()) { - return false; - } - - if (targetFile.exists()) { - if (!force) { - return false; - } - - deletePath(targetFile); - // targetFile.mkdirs(); - } - return true; - } - - static public Boolean checkFile(File sourceFile, File targetFile, Boolean force) { - if (!sourceFile.exists()) { - return false; - } - - if (targetFile.exists()) { - if (!force) { - return false; - } - - deletePath(targetFile); - } - return true; - } - - static class UnGzipFile implements Callable { - private final String source; - private final String target; - private final Boolean force; - - public UnGzipFile(String source, String target, Boolean force) { - this.source = source; - this.target = target; - this.force = force; - } - - @Override - public String call() { - // Log.d("Gzip", "source: " + source + ", target: " + target); - File sourceFile = new File(source); - File targetFile = new File(target); - if(!Utils.checkDir(sourceFile, targetFile, force)){ - return "error"; - } - - FileInputStream fileInputStream; - FileOutputStream fileOutputStream; - - try{ - fileInputStream = new FileInputStream(sourceFile); - fileOutputStream = new FileOutputStream(targetFile); - final GZIPInputStream gzipInputStream = new GZIPInputStream(fileInputStream); - - final byte[] buffer = new byte[4096]; - int len; - while((len = gzipInputStream.read(buffer)) != -1){ - fileOutputStream.write(buffer, 0, len); - } - //close resources - fileOutputStream.close(); - gzipInputStream.close(); - fileInputStream.close(); - - return ""; - } catch (IOException e) { - e.printStackTrace(); - - return "unGzip error: " + Throwables.getStackTraceAsString(e); - } - } - } - - static class GzipFile implements Callable { - private final String source; - private final String target; - private final Boolean force; - - public GzipFile(String source, String target, Boolean force) { - this.source = source; - this.target = target; - this.force = force; - } - - @Override - public String call() { - // Log.d("Gzip", "source: " + source + ", target: " + target); - File sourceFile = new File(source); - File targetFile = new File(target); - // Log.d("Gzip", "sourceFile: " + sourceFile.getAbsolutePath() + ", targetFile: " + targetFile.getAbsolutePath()); - if(!Utils.checkFile(sourceFile, targetFile, force)){ - return "error"; - } - - FileInputStream fileInputStream; - FileOutputStream fileOutputStream; - - try{ - fileInputStream = new FileInputStream(sourceFile); - fileOutputStream = new FileOutputStream(targetFile); - - GZIPOutputStream gzipOutputStream = new GZIPOutputStream(fileOutputStream); - final byte[] buffer = new byte[4096]; - int len; - while((len= fileInputStream.read(buffer)) != -1){ - gzipOutputStream.write(buffer, 0, len); - } - //close resources - gzipOutputStream.close(); - fileInputStream.close(); - fileOutputStream.close(); - - return ""; - } catch (IOException e) { - e.printStackTrace(); - return "gzip error: " + source.length() + "\nstack: " + Throwables.getStackTraceAsString(e); - } - } - } - - static class UnGzip implements Callable { - private final byte[] data; - - public UnGzip(String data) { - this.data = Base64.decode(data, Base64.DEFAULT); - } - - @Override - public String call() throws IOException { - // Log.d("Gzip", "source: " + source + ", target: " + target); - final int BUFFER_SIZE = 1024; - ByteArrayInputStream is = new ByteArrayInputStream(data); - GZIPInputStream gis = new GZIPInputStream(is, BUFFER_SIZE); - BufferedReader bf = new BufferedReader(new InputStreamReader(gis, StandardCharsets.UTF_8)); - final StringBuilder outStr = new StringBuilder(); - String line; - while ((line = bf.readLine()) != null) { - outStr.append(line); - } - gis.close(); - is.close(); - bf.close(); - return outStr.toString(); - } - } - - static class Gzip implements Callable { - private final String data; - - public Gzip(String data) { - this.data = data; - } - - @Override - public String call() throws IOException { - ByteArrayOutputStream os = new ByteArrayOutputStream(data.length()); - GZIPOutputStream gos = new GZIPOutputStream(os); - gos.write(data.getBytes(StandardCharsets.UTF_8)); - gos.close(); - byte[] compressed = os.toByteArray(); - os.close(); - return Base64.encodeToString(compressed, Base64.NO_WRAP); - } - } -} diff --git a/android/app/src/main/java/cn/toside/music/mobile/utils/Utils.java b/android/app/src/main/java/cn/toside/music/mobile/utils/Utils.java index df7a98177..14c8c107d 100644 --- a/android/app/src/main/java/cn/toside/music/mobile/utils/Utils.java +++ b/android/app/src/main/java/cn/toside/music/mobile/utils/Utils.java @@ -22,129 +22,18 @@ import java.util.concurrent.Callable; public class Utils { - public static boolean deletePath(File dir) { - if (dir.isDirectory()) { - String[] children = dir.list(); - for (int i=0; i< children.length; i++) { - boolean success = deletePath(new File(dir, children[i])); - if (!success) { - return false; - } - } - } - - // The directory is now empty so delete it - return dir.delete(); - } - - // https://gist.github.com/PauloLuan/4bcecc086095bce28e22?permalink_comment_id=2591001#gistcomment-2591001 - public static ArrayList getExternalStoragePath(ReactApplicationContext mContext, boolean is_removable) { - StorageManager mStorageManager = (StorageManager) mContext.getSystemService(Context.STORAGE_SERVICE); - Class storageVolumeClazz; - ArrayList paths = new ArrayList<>(); - try { - storageVolumeClazz = Class.forName("android.os.storage.StorageVolume"); - Method getVolumeList = mStorageManager.getClass().getMethod("getVolumeList"); - Method getPath = storageVolumeClazz.getMethod("getPath"); - Method isRemovable = storageVolumeClazz.getMethod("isRemovable"); - Object result = getVolumeList.invoke(mStorageManager); - final int length = Array.getLength(result); - for (int i = 0; i < length; i++) { - Object storageVolumeElement = Array.get(result, i); - String path = (String) getPath.invoke(storageVolumeElement); - boolean removable = (Boolean) isRemovable.invoke(storageVolumeElement); - if (is_removable == removable) { - paths.add(path); - } - } - } catch (Exception e) { - e.printStackTrace(); - } - return paths; - } - - public static String convertStreamToString(InputStream is) throws Exception { - BufferedReader reader = new BufferedReader(new InputStreamReader(is)); - StringBuilder sb = new StringBuilder(); - String line = null; - while ((line = reader.readLine()) != null) { - sb.append(line).append("\n"); - } - reader.close(); - return sb.toString(); - } - - // https://stackoverflow.com/a/13357785 - public static String getStringFromFile (String filePath) throws Exception { - File fl = new File(filePath); - if (!fl.exists()) return ""; - FileInputStream fin = new FileInputStream(fl); - String ret = convertStreamToString(fin); - //Make sure you close all streams. - fin.close(); - return ret; - } - - static class ReadStringFromFile implements Callable { - private final String filePath; - - public ReadStringFromFile(String filePath) { - this.filePath = filePath; - } - - @Override - public String call() throws Exception { - return getStringFromFile(filePath); - } - } - - private static void writeToFile(String filePath, String dataString) throws IOException { - File file = new File(filePath); - deletePath(file); - try (FileOutputStream fileOutputStream = new FileOutputStream(file)){ - fileOutputStream.write(dataString.getBytes()); - } - } - - static class WriteStringToFile implements Callable { - private final String filePath; - private final String dataString; - - public WriteStringToFile(String filePath, String dataString) { - this.filePath = filePath; - this.dataString = dataString; - } - - @Override - public Object call() throws Exception { - writeToFile(filePath, dataString); - return null; - } - } - - private static void deleteRecursive(File fileOrDirectory) { - if (fileOrDirectory.isDirectory()) { - for (File child : Objects.requireNonNull(fileOrDirectory.listFiles())) { - deleteRecursive(child); - } - } - - fileOrDirectory.delete(); - } - public static void unlink(String filepath) { - deleteRecursive(new File(filepath)); - } - static class Unlink implements Callable { - private final String filePath; - - public Unlink(String filePath) { - this.filePath = filePath; - } - - @Override - public Object call() { - unlink(filePath); - return null; - } - } +// public static boolean deletePath(File dir) { +// if (dir.isDirectory()) { +// String[] children = dir.list(); +// for (int i=0; i< children.length; i++) { +// boolean success = deletePath(new File(dir, children[i])); +// if (!success) { +// return false; +// } +// } +// } +// +// // The directory is now empty so delete it +// return dir.delete(); +// } } diff --git a/android/app/src/main/java/cn/toside/music/mobile/utils/UtilsModule.java b/android/app/src/main/java/cn/toside/music/mobile/utils/UtilsModule.java index a146e0dd3..5b791831c 100644 --- a/android/app/src/main/java/cn/toside/music/mobile/utils/UtilsModule.java +++ b/android/app/src/main/java/cn/toside/music/mobile/utils/UtilsModule.java @@ -305,21 +305,6 @@ public void shareText(String shareTitle, String title, String text) { Objects.requireNonNull(reactContext.getCurrentActivity()).startActivity(Intent.createChooser(shareIntent, shareTitle)); } - @ReactMethod - public void getStringFromFile(String filePath, Promise promise) { - AsyncTask.runTask(new Utils.ReadStringFromFile(filePath), promise); - } - - @ReactMethod - public void writeStringToFile(String filePath, String dataStr, Promise promise) { - AsyncTask.runTask(new Utils.WriteStringToFile(filePath, dataStr), promise); - } - - @ReactMethod - public void unlink(String filePath, Promise promise) { - AsyncTask.runTask(new Utils.Unlink(filePath), promise); - } - // https://stackoverflow.com/questions/73463341/in-per-app-language-how-to-get-app-locale-in-api-33-if-system-locale-is-diffe @ReactMethod public void getSystemLocales(Promise promise) { @@ -401,13 +386,5 @@ public void isIgnoringBatteryOptimization(Promise promise) { public void requestIgnoreBatteryOptimization(Promise promise) { promise.resolve(BatteryOptimizationUtil.requestIgnoreBatteryOptimization(reactContext.getApplicationContext(), reactContext.getPackageName())); } - - @ReactMethod - public void getExternalStoragePath(Promise promise) { - WritableArray arr = Arguments.createArray(); - ArrayList paths = Utils.getExternalStoragePath(reactContext, true); - for (String p: paths) arr.pushString(p); - promise.resolve(arr); - } } diff --git a/package-lock.json b/package-lock.json index 5fb4949f2..2c1bb9c40 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,8 +23,9 @@ "react-native-background-timer": "github:lyswhut/react-native-background-timer#f49f41d0283a796e3e38cb3d505198b5953dc249", "react-native-exception-handler": "^2.10.10", "react-native-fast-image": "^8.6.3", + "react-native-file-system": "github:lyswhut/react-native-file-system#291582cd202053246458bf9376efe27fbb75afb6", "react-native-fs": "^2.20.0", - "react-native-local-media-metadata": "github:lyswhut/react-native-local-media-metadata#ac715cd5bd3e338d313f585bb9cb3075607a4378", + "react-native-local-media-metadata": "github:lyswhut/react-native-local-media-metadata#35d7df1f68f19492749a9ea0541f02be77c0a675", "react-native-navigation": "^7.37.2", "react-native-pager-view": "^6.2.3", "react-native-quick-base64": "^2.0.8", @@ -8977,6 +8978,22 @@ "react-native": ">=0.60.0" } }, + "node_modules/react-native-file-system": { + "version": "0.1.0", + "resolved": "git+ssh://git@github.com/lyswhut/react-native-file-system.git#291582cd202053246458bf9376efe27fbb75afb6", + "integrity": "sha512-hHLZND+W5A1jS/bDaODe//e3WSME7jeDEWsuVHB1/rjictlh9NrM1pUZF/qfOQNP9v/OySp2zO47bK57Q5sugQ==", + "license": "MIT", + "workspaces": [ + "example" + ], + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-fs": { "version": "2.20.0", "resolved": "https://registry.npmjs.org/react-native-fs/-/react-native-fs-2.20.0.tgz", @@ -8997,8 +9014,8 @@ }, "node_modules/react-native-local-media-metadata": { "version": "0.1.0", - "resolved": "git+ssh://git@github.com/lyswhut/react-native-local-media-metadata.git#ac715cd5bd3e338d313f585bb9cb3075607a4378", - "integrity": "sha512-/ElZWRuXfxL/soMVx+yemaeizy3Ec7zSq+T47HKfmXSQWAc7CLQqk/icn5lH4ELapCEBhoTsOsDVIbsZQLTrKg==", + "resolved": "git+ssh://git@github.com/lyswhut/react-native-local-media-metadata.git#35d7df1f68f19492749a9ea0541f02be77c0a675", + "integrity": "sha512-Gbf8yuVQIWOAuJ4JQ5cRL9QNMePqsiPiSVw+e11ZfwKnZtpCtiwbgTixCjWpIcIyRsURNBz+3UJwQl2fPx6FWQ==", "license": "MIT", "workspaces": [ "example" @@ -17184,6 +17201,12 @@ "integrity": "sha512-Sdw4ESidXCXOmQ9EcYguNY2swyoWmx53kym2zRsvi+VeFCHEdkO+WG1DK+6W81juot40bbfLNhkc63QnWtesNg==", "requires": {} }, + "react-native-file-system": { + "version": "git+ssh://git@github.com/lyswhut/react-native-file-system.git#291582cd202053246458bf9376efe27fbb75afb6", + "integrity": "sha512-hHLZND+W5A1jS/bDaODe//e3WSME7jeDEWsuVHB1/rjictlh9NrM1pUZF/qfOQNP9v/OySp2zO47bK57Q5sugQ==", + "from": "react-native-file-system@github:lyswhut/react-native-file-system#291582cd202053246458bf9376efe27fbb75afb6", + "requires": {} + }, "react-native-fs": { "version": "2.20.0", "resolved": "https://registry.npmjs.org/react-native-fs/-/react-native-fs-2.20.0.tgz", @@ -17194,9 +17217,9 @@ } }, "react-native-local-media-metadata": { - "version": "git+ssh://git@github.com/lyswhut/react-native-local-media-metadata.git#ac715cd5bd3e338d313f585bb9cb3075607a4378", - "integrity": "sha512-/ElZWRuXfxL/soMVx+yemaeizy3Ec7zSq+T47HKfmXSQWAc7CLQqk/icn5lH4ELapCEBhoTsOsDVIbsZQLTrKg==", - "from": "react-native-local-media-metadata@github:lyswhut/react-native-local-media-metadata#ac715cd5bd3e338d313f585bb9cb3075607a4378", + "version": "git+ssh://git@github.com/lyswhut/react-native-local-media-metadata.git#35d7df1f68f19492749a9ea0541f02be77c0a675", + "integrity": "sha512-Gbf8yuVQIWOAuJ4JQ5cRL9QNMePqsiPiSVw+e11ZfwKnZtpCtiwbgTixCjWpIcIyRsURNBz+3UJwQl2fPx6FWQ==", + "from": "react-native-local-media-metadata@github:lyswhut/react-native-local-media-metadata#35d7df1f68f19492749a9ea0541f02be77c0a675", "requires": {} }, "react-native-navigation": { diff --git a/package.json b/package.json index 2dda07f71..92d1fb9d4 100644 --- a/package.json +++ b/package.json @@ -57,8 +57,9 @@ "react-native-background-timer": "github:lyswhut/react-native-background-timer#f49f41d0283a796e3e38cb3d505198b5953dc249", "react-native-exception-handler": "^2.10.10", "react-native-fast-image": "^8.6.3", + "react-native-file-system": "github:lyswhut/react-native-file-system#291582cd202053246458bf9376efe27fbb75afb6", "react-native-fs": "^2.20.0", - "react-native-local-media-metadata": "github:lyswhut/react-native-local-media-metadata#ac715cd5bd3e338d313f585bb9cb3075607a4378", + "react-native-local-media-metadata": "github:lyswhut/react-native-local-media-metadata#35d7df1f68f19492749a9ea0541f02be77c0a675", "react-native-navigation": "^7.37.2", "react-native-pager-view": "^6.2.3", "react-native-quick-base64": "^2.0.8", diff --git a/publish/changeLog.md b/publish/changeLog.md index 581d4f852..83a3b871c 100644 --- a/publish/changeLog.md +++ b/publish/changeLog.md @@ -2,7 +2,7 @@ - 新增自定义源(实验性功能),调用方式与PC端一致,但需要注意的是,移动端自定义源的环境与PC端不同,某些环境API不可用,详情看自定义说明文档 - 新增长按收藏列表名自动跳转列表顶部的功能 -- 新增实验性的添加本地歌曲到我的收藏支持,与PC端类似,在我的收藏的列表菜单中选择歌曲目录,将添加所选目录下的所有歌曲,目前支持mp3/flac/ogg/wav格式 +- 新增实验性的添加本地歌曲到我的收藏支持,与PC端类似,在我的收藏的列表菜单中选择歌曲目录,将添加所选目录下的所有歌曲,目前支持mp3/flac/ogg/wav等格式 - 新增歌曲标签编辑功能,允许编辑本地源且文件歌曲存在的歌曲标签信息 - 新增动态背景,默认关闭,启用后将使用当前歌曲封面做APP背景 @@ -14,7 +14,7 @@ - 优化播放详情页歌曲封面、控制按钮对各尺寸屏幕的适配,修改横屏下的控制栏按钮布局 - 优化横竖屏界面的暂时判断,现在趋于方屏的屏幕按竖屏的方式显示,横屏下的播放栏添加上一曲切歌按钮 - 添加对wy源某些歌曲有问题的歌词进行修复(#370) -- 文件选择器允许(在旧系统)选择外置存储设备上的路径,长按存储卡按钮可显示手动输入存储路径的窗口 +- 文件选择器允许选择外置存储设备上的路径,添加SD卡、USB存储等外置存储设备的读写支持 - 图片显示改用第三方的图片组件,支持gif类型的图片显示,尝试解决某些设备上图片过多导致的应用崩溃问题 - 歌曲评论内容过长时自动折叠,需手动展开 - 改进本地音乐在线信息的匹配机制 @@ -28,6 +28,7 @@ - 在更低版本的安卓上启用跟随系统亮暗主题功能(#317) - 由于歌曲评论的图片太大占用较多资源,评论图片不再直接加载,需要点击图片区域后再加载 +- 导入文件(歌单备份、自定义源文件等)不再需要设备存储权限 ### 其他 diff --git a/src/components/MetadataEditModal/MetadataForm.tsx b/src/components/MetadataEditModal/MetadataForm.tsx index e5f29d735..1587ec918 100644 --- a/src/components/MetadataEditModal/MetadataForm.tsx +++ b/src/components/MetadataEditModal/MetadataForm.tsx @@ -1,12 +1,15 @@ -import { useImperativeHandle, forwardRef, useState, useCallback } from 'react' +import { useImperativeHandle, forwardRef, useState, useCallback, useRef } from 'react' import { View } from 'react-native' -import { createStyle } from '@/utils/tools' +import { TEMP_FILE_PATH, createStyle, toast } from '@/utils/tools' import InputItem from './InputItem' import { useI18n } from '@/lang' import TextAreaItem from './TextAreaItem' import PicItem from './PicItem' import { useTheme } from '@/store/theme/hook' import ParseName from './ParseName' +import { downloadFile, mkdir, stat } from '@/utils/fs' +import { useUnmounted } from '@/utils/hooks' +import { getLyricInfo, getPicUrl } from '@/core/music/local' export interface Metadata { name: string // 歌曲名 @@ -14,6 +17,7 @@ export interface Metadata { albumName: string // 歌曲专辑名称 pic: string lyric: string + interval: string } export const defaultData = { name: '', @@ -21,21 +25,32 @@ export const defaultData = { albumName: '', pic: '', lyric: '', + interval: '', } export interface MetadataFormType { setForm: (path: string, metadata: Metadata) => void getForm: () => Metadata } + +const matcheingPic = new Set() +const matcheingLrc = new Set() export default forwardRef((props, ref) => { const t = useI18n() - const [path, setPath] = useState('') + const [fileName, setFileName] = useState('') + const filePath = useRef('') const [data, setData] = useState({ ...defaultData }) const theme = useTheme() + const isUnmounted = useUnmounted() useImperativeHandle(ref, () => ({ setForm(path, data) { - setPath(path) + filePath.current = path + // setPath(path) + void stat(path).then(info => { + if (isUnmounted.current) return + setFileName(info.name) + }) setData(data) }, getForm() { @@ -66,6 +81,84 @@ export default forwardRef((props, ref) => { return { ...data, albumName } }) }, []) + const handleOnlineMatchPic = useCallback(() => { + let path = filePath.current + if (matcheingPic.has(path)) return + matcheingPic.add(path) + void getPicUrl({ + skipFilePic: true, + musicInfo: { + id: path, + interval: data.interval, + meta: { + albumName: data.albumName, + ext: '', + filePath: path, + songId: path, + }, + name: data.name, + singer: data.singer, + source: 'local', + }, + isRefresh: false, + }).then(async(pic) => { + if (isUnmounted.current || path != filePath.current) return + let ext = pic.split('?')[0] + ext = ext.substring(ext.lastIndexOf('.') + 1) + if (ext.length > 5) ext = 'jpeg' + await mkdir(TEMP_FILE_PATH) + const picPath = `${TEMP_FILE_PATH}/${Math.random().toString().substring(5)}.${ext}` + return downloadFile(pic, picPath, { + connectionTimeout: 10000, + readTimeout: 10000, + }).promise.then((res) => { + if (isUnmounted.current || path != filePath.current) return + toast(t('metadata_edit_modal_form_match_pic_success')) + setData(data => { + return { ...data, pic: picPath } + }) + }) + }).catch((err) => { + console.log(err) + if (isUnmounted.current || path != filePath.current) return + toast(t('metadata_edit_modal_form_match_pic_failed')) + }).finally(() => { + matcheingPic.delete(path) + }) + }, [data.albumName, data.name, data.singer, t]) + const handleOnlineMatchLyric = useCallback(() => { + let path = filePath.current + if (matcheingLrc.has(path)) return + matcheingLrc.add(path) + void getLyricInfo({ + skipFileLyric: true, + musicInfo: { + id: path, + interval: data.interval, + meta: { + albumName: data.albumName, + ext: '', + filePath: path, + songId: path, + }, + name: data.name, + singer: data.singer, + source: 'local', + }, + isRefresh: false, + }).then(async({ lyric }) => { + if (isUnmounted.current || path != filePath.current) return + toast(t('metadata_edit_modal_form_match_lyric_success')) + setData(data => { + return { ...data, lyric } + }) + }).catch(() => { + if (isUnmounted.current || path != filePath.current) return + toast(t('metadata_edit_modal_form_match_lyric_failed')) + }).finally(() => { + matcheingLrc.delete(path) + }) + }, [data.albumName, data.name, data.singer, t]) const handleUpdatePic = useCallback((path: string) => { setData(data => { return { ...data, pic: path } @@ -80,9 +173,9 @@ export default forwardRef((props, ref) => { return ( @@ -98,7 +191,7 @@ export default forwardRef((props, ref) => { onChanged={handleUpdateSinger} keyboardType="name-phone-pad" /> @@ -111,10 +204,12 @@ export default forwardRef((props, ref) => { diff --git a/src/components/MetadataEditModal/ParseName.tsx b/src/components/MetadataEditModal/ParseName.tsx index 8938b05a9..8dbb0fd7f 100644 --- a/src/components/MetadataEditModal/ParseName.tsx +++ b/src/components/MetadataEditModal/ParseName.tsx @@ -8,25 +8,25 @@ import { useI18n } from '@/lang' export interface ParseNameProps { - path: string + fileName: string onNameChanged: (text: string) => void onSingerChanged: (text: string) => void } -const parsePath = (path: string) => { - return path.substring(path.lastIndexOf('/') + 1, path.lastIndexOf('.')).split('-').map(name => name.trim()) +const parsePath = (fileName: string) => { + return fileName.substring(0, fileName.lastIndexOf('.')).split('-').map(name => name.trim()) } -export default memo(({ path, onNameChanged, onSingerChanged }: ParseNameProps) => { +export default memo(({ fileName, onNameChanged, onSingerChanged }: ParseNameProps) => { const theme = useTheme() const t = useI18n() const handleParseNameSinger = () => { - const [name, singer] = parsePath(path) + const [name, singer] = parsePath(fileName) onNameChanged(name) if (singer) onSingerChanged(singer) } const handleParseSingerName = () => { - const [singer, name] = parsePath(path) + const [singer, name] = parsePath(fileName) onSingerChanged(singer) if (name) onNameChanged(name) } diff --git a/src/components/MetadataEditModal/PicItem.tsx b/src/components/MetadataEditModal/PicItem.tsx index 6c7c64dbc..2d5047187 100644 --- a/src/components/MetadataEditModal/PicItem.tsx +++ b/src/components/MetadataEditModal/PicItem.tsx @@ -12,10 +12,11 @@ import { BorderWidths } from '@/theme' export interface PicItemProps { value: string label: string + onOnlineMatch: () => void onChanged: (text: string) => void } -export default memo(({ value, label, onChanged }: PicItemProps) => { +export default memo(({ value, label, onOnlineMatch, onChanged }: PicItemProps) => { const theme = useTheme() const fileSelectRef = useRef(null) const handleRemoveFile = useCallback(() => { @@ -25,7 +26,7 @@ export default memo(({ value, label, onChanged }: PicItemProps) => { fileSelectRef.current?.show({ title: global.i18n.t('metadata_edit_modal_form_select_pic_title'), dirOnly: false, - filter: /jpg|jpeg|png/, + filter: ['jpg', 'jpeg', 'png'], }, (path) => { onChanged(path) }) @@ -38,6 +39,9 @@ export default memo(({ value, label, onChanged }: PicItemProps) => { {global.i18n.t('metadata_edit_modal_form_remove_pic')} + + {global.i18n.t('metadata_edit_modal_form_match_pic')} + {global.i18n.t('metadata_edit_modal_form_select_pic')} @@ -46,6 +50,7 @@ export default memo(({ value, label, onChanged }: PicItemProps) => { diff --git a/src/components/MetadataEditModal/TextAreaItem.tsx b/src/components/MetadataEditModal/TextAreaItem.tsx index 2b1b033c7..5c8d3aaf9 100644 --- a/src/components/MetadataEditModal/TextAreaItem.tsx +++ b/src/components/MetadataEditModal/TextAreaItem.tsx @@ -1,6 +1,6 @@ -import { memo } from 'react' +import { memo, useCallback } from 'react' -import { StyleSheet, View } from 'react-native' +import { StyleSheet, TouchableOpacity, View } from 'react-native' import type { InputProps } from '@/components/common/Input' import Input from '@/components/common/Input' import { useTheme } from '@/store/theme/hook' @@ -11,14 +11,33 @@ import { createStyle } from '@/utils/tools' export interface TextAreaItemProps extends InputProps { value: string label: string + onOnlineMatch?: () => void onChanged?: (text: string) => void } -export default memo(({ value, label, onChanged, style, ...props }: TextAreaItemProps) => { +export default memo(({ value, label, onOnlineMatch, onChanged, style, ...props }: TextAreaItemProps) => { const theme = useTheme() + const handleRemove = useCallback(() => { + onChanged?.('') + }, [onChanged]) + return ( - {label} + + {label} + { + onChanged ? ( + + + {global.i18n.t('metadata_edit_modal_form_remove_lyric')} + + + {global.i18n.t('metadata_edit_modal_form_match_lyric')} + + + ) : null + } + ((props, ref) => { void Promise.all([ readMetadata(filePath), readPic(filePath), - readLyric(filePath), - ]).then(([_metadata, pic, lyric]) => { + readLyric(filePath, false), + ]).then(async([_metadata, pic, lyric]) => { if (!_metadata) return if (isUnmounted.current) return metadata.current = { @@ -50,6 +52,7 @@ export default forwardRef((props, ref) => { singer: _metadata.singer, albumName: _metadata.albumName, pic, + interval: formatPlayTime2(_metadata.interval), lyric, } requestAnimationFrame(() => { @@ -95,6 +98,7 @@ export default forwardRef((props, ref) => { if (_metadata.pic != metadata.current.pic) { isUpdated ||= true await writePic(filePath.current, _metadata.pic) + if (_metadata.pic.startsWith(TEMP_FILE_PATH)) void unlink(_metadata.pic) } if (_metadata.lyric != metadata.current.lyric) { isUpdated ||= true diff --git a/src/components/common/ButtonPrimary.tsx b/src/components/common/ButtonPrimary.tsx index 5d03d4c93..53fe51031 100644 --- a/src/components/common/ButtonPrimary.tsx +++ b/src/components/common/ButtonPrimary.tsx @@ -5,24 +5,24 @@ import Text from '@/components/common/Text' import { useTheme } from '@/store/theme/hook' import { createStyle } from '@/utils/tools' -export type ButtonProps = BtnProps +export interface ButtonProps extends BtnProps { + size?: number +} -export default memo(({ disabled, onPress, children }: ButtonProps) => { +export default memo(({ disabled, size = 14, onPress, children }: ButtonProps) => { const theme = useTheme() return ( ) }) const styles = createStyle({ button: { - paddingLeft: 10, - paddingRight: 10, - paddingTop: 5, - paddingBottom: 5, + paddingHorizontal: 10, + paddingVertical: 5, borderRadius: 4, marginRight: 10, }, diff --git a/src/components/common/ChoosePath/List.tsx b/src/components/common/ChoosePath/List.tsx index d20094b4b..8fba2898e 100644 --- a/src/components/common/ChoosePath/List.tsx +++ b/src/components/common/ChoosePath/List.tsx @@ -1,6 +1,6 @@ import { useEffect, useState, useRef, forwardRef, useImperativeHandle } from 'react' import { View } from 'react-native' -import { readDir, externalStorageDirectoryPath } from '@/utils/fs' +import { externalStorageDirectoryPath, readDir } from '@/utils/fs' import { createStyle, toast } from '@/utils/tools' // import { useTranslation } from '@/plugins/i18n' import Modal, { type ModalType } from '@/components/common/Modal' @@ -11,11 +11,14 @@ import Footer from './components/Footer' import { sizeFormate } from '@/utils' import { useTheme } from '@/store/theme/hook' import { type PathItem } from './components/ListItem' +// import { getSelectedManagedFolder } from '@/utils/data' // let prevPath = externalStorageDirectoryPath +const parentDirInfo = new Map() const caches = new Map() -const handleReadDir = async(path: string, dirOnly: boolean, filter?: RegExp, isRefresh = false) => { +const handleReadDir = async(path: string, dirOnly: boolean, filter?: string[], isRefresh = false) => { + let filterRxp = filter ? new RegExp(`\\.(${filter.join('|')})$`, 'i') : null const cacheKey = `${path}_${dirOnly ? 'true' : 'false'}_${filter ? filter.toString() : 'null'}` if (!isRefresh && caches.has(cacheKey)) return caches.get(cacheKey)! return readDir(path).then(paths => { @@ -25,27 +28,27 @@ const handleReadDir = async(path: string, dirOnly: boolean, filter?: RegExp, isR // console.log(paths) for (const path of paths) { // console.log(path) - if (filter != null && path.isFile() && !filter.test(path.name)) continue + if (filterRxp != null && path.isFile && !filterRxp.test(path.name)) continue - const isDirectory = path.isDirectory() + const isDirectory = path.isDirectory if (dirOnly) { list.push({ name: path.name, path: path.path, - mtime: path.mtime, + mtime: new Date(path.lastModified), size: path.size, isDir: isDirectory, - sizeText: isDirectory ? '' : sizeFormate(path.size), - disabled: !isDirectory, + sizeText: isDirectory ? '' : sizeFormate(path.size ?? 0), + disabled: path.isFile, }) } else { list.push({ name: path.name, path: path.path, - mtime: path.mtime, + mtime: new Date(path.lastModified), size: path.size, isDir: isDirectory, - sizeText: isDirectory ? '' : sizeFormate(path.size), + sizeText: isDirectory ? '' : sizeFormate(path.size ?? 0), }) } } @@ -67,7 +70,7 @@ const handleReadDir = async(path: string, dirOnly: boolean, filter?: RegExp, isR interface ReadOptions { title: string dirOnly: boolean - filter?: RegExp + filter?: string[] } const initReadOptions = {} export interface ListProps { @@ -76,7 +79,7 @@ export interface ListProps { } export interface ListType { - show: (title: string, dirOnly?: boolean, filter?: RegExp) => void + show: (title: string, dir?: string, dirOnly?: boolean, filter?: string[]) => void hide: () => void } @@ -84,23 +87,28 @@ export default forwardRef(({ onConfirm, onHide = () => {}, }: ListProps, ref) => { - const [path, setPath] = useState(externalStorageDirectoryPath) + const [path, setPath] = useState('') const [list, setList] = useState([]) const isUnmountedRef = useRef(true) const readOptions = useRef(initReadOptions as ReadOptions) - const isReadingDir = useRef(false) + const [isReading, setIsReading] = useState(false) const modalRef = useRef(null) const theme = useTheme() useImperativeHandle(ref, () => ({ - show(title, dirOnly = false, filter) { + show(title, dir = '', dirOnly = false, filter) { readOptions.current = { title, dirOnly, filter, } modalRef.current?.setVisible(true) - void readDir(path, dirOnly, filter) + // void getSelectedManagedFolder().then(uri => { + // if (!uri) return + // void readDir(uri, dirOnly, filter) + // }) + if (dir) void readDir(dir, dirOnly, filter) + else void readDir(externalStorageDirectoryPath, dirOnly, filter) }, hide() { modalRef.current?.setVisible(false) @@ -114,39 +122,45 @@ export default forwardRef(({ } }, []) - const readDir = async(path: string, dirOnly: boolean, filter?: RegExp, isRefresh?: boolean) => { - if (isReadingDir.current) return - isReadingDir.current = true - return handleReadDir(path, dirOnly, filter, isRefresh).then(list => { + const readDir = async(newPath: string, dirOnly: boolean, filter?: string[], isRefresh?: boolean, isOpen?: boolean) => { + if (isReading) return + setIsReading(true) + return handleReadDir(newPath, dirOnly, filter, isRefresh).then(list => { if (isUnmountedRef.current) return + if (!isOpen && newPath != path && newPath.startsWith(path)) parentDirInfo.set(newPath, path) setList(list) - setPath(path) + setPath(newPath) }).catch((err: any) => { toast(`Read dir error: ${err.message as string}`, 'long') // console.log('prevPath', prevPath) // if (isReadingDir.current) return // setPath(prevPath) - throw err }).finally(() => { - isReadingDir.current = false + setIsReading(false) }) } const onSetPath = (pathInfo: PathItem) => { // console.log('onSetPath') if (pathInfo.isDir) { - void readDir(pathInfo.path, readOptions.current.dirOnly, readOptions.current.filter).catch(_ => _) + void readDir(pathInfo.path, readOptions.current.dirOnly, readOptions.current.filter) } else { onConfirm(pathInfo.path) // setPath(pathInfo.path) } } + const handleConfirm = () => { + if (!path) return + onConfirm(path) + } const toParentDir = () => { - const parentPath = path.substring(0, path.lastIndexOf('/')) - void readDir(parentPath.length ? parentPath : externalStorageDirectoryPath, readOptions.current.dirOnly, readOptions.current.filter).catch(() => { - void readDir(externalStorageDirectoryPath, readOptions.current.dirOnly, readOptions.current.filter).catch(_ => _) - }) + const parentPath = parentDirInfo.get(path) + if (parentPath) { + void readDir(parentPath, readOptions.current.dirOnly, readOptions.current.filter) + } else { + toast('Permission denied') + } } const handleHide = () => { @@ -161,10 +175,11 @@ export default forwardRef(({
readDir(path, readOptions.current.dirOnly, readOptions.current.filter, true)} + onOpenDir={async(path) => readDir(path, readOptions.current.dirOnly, readOptions.current.filter, false, true)} title={readOptions.current.title} path={path} /> -
-