diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index fd162103..4508a3c4 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,6 +3,11 @@ + + + - + diff --git a/android/app/src/main/java/com/noxplay/noxplayer/NoxAndroidAutoModule.kt b/android/app/src/main/java/com/noxplay/noxplayer/NoxAndroidAutoModule.kt index 010ee960..80b0135f 100644 --- a/android/app/src/main/java/com/noxplay/noxplayer/NoxAndroidAutoModule.kt +++ b/android/app/src/main/java/com/noxplay/noxplayer/NoxAndroidAutoModule.kt @@ -2,28 +2,86 @@ package com.noxplay.noxplayer import android.app.ActivityManager import android.app.ApplicationExitInfo +import android.content.ContentUris import android.content.Context import android.content.Intent import android.net.Uri import android.os.Build +import android.provider.MediaStore import android.provider.Settings +import android.util.Log import android.view.WindowManager -import androidx.annotation.RequiresApi +import com.facebook.react.bridge.Arguments 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 com.facebook.react.bridge.WritableArray +import com.facebook.react.bridge.WritableNativeArray -class NoxAndroidAutoModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { +class NoxAndroidAutoModule(reactContext: ReactApplicationContext) : + ReactContextBaseJavaModule(reactContext) { override fun getName() = "NoxAndroidAutoModule" + private fun _listMediaDir(relativeDir: String, subdir: Boolean, selection: String? = null): WritableArray { + val results: WritableArray = WritableNativeArray() + try { + val query = reactApplicationContext.contentResolver.query( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, + arrayOf( + MediaStore.Audio.Media._ID, + MediaStore.Audio.Media.RELATIVE_PATH, + MediaStore.Audio.Media.DISPLAY_NAME, + MediaStore.Audio.Media.DATA + ), selection,null, null) + query?.use { cursor -> + val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID) + val pathColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.RELATIVE_PATH) + val nameColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DISPLAY_NAME) + val dataColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATA) + while (cursor.moveToNext()) { + val mediaPath = cursor.getString(pathColumn) + if (mediaPath == relativeDir || (subdir && mediaPath.startsWith(relativeDir))) { + val mediaItem = Arguments.createMap() + mediaItem.putString("URI", + "content:/" + ContentUris.appendId( + Uri.Builder().path(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI.path), + cursor.getLong(idColumn)).build().toString()) + mediaItem.putString("relativePath",mediaPath) + mediaItem.putString("fileName", cursor.getString(nameColumn)) + mediaItem.putString("realPath", cursor.getString(dataColumn)) + results.pushMap(mediaItem) + } + } + } + } catch (e: Exception) { + Log.e("NoxFileUtil", e.toString()) + } + return results + } + + @ReactMethod fun listMediaDir(relativeDir: String, subdir: Boolean, callback: Promise) { + callback.resolve(_listMediaDir(relativeDir, subdir)) + } + + @ReactMethod fun listMediaFileByFName(filename: String, callback: Promise) { + callback.resolve(_listMediaDir("", true, + "${MediaStore.Audio.Media.DISPLAY_NAME} = $filename")) + } + + @ReactMethod fun listMediaFileByID(id: String, callback: Promise) { + callback.resolve(_listMediaDir("", true, + "${MediaStore.Audio.Media._ID} = $id")) + } @ReactMethod fun getLastExitReason(callback: Promise) { try { val activity = reactApplicationContext.currentActivity val am = activity?.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - val reason = am.getHistoricalProcessExitReasons("com.noxplay.noxplayer",0,0)[0].reason + val reason = am.getHistoricalProcessExitReasons( + "com.noxplay.noxplayer",0,0 + )[0].reason callback.resolve(reason in intArrayOf( ApplicationExitInfo.REASON_USER_REQUESTED, ApplicationExitInfo.REASON_USER_STOPPED, @@ -53,7 +111,10 @@ class NoxAndroidAutoModule(reactContext: ReactApplicationContext) : ReactContext @ReactMethod fun askDrawOverAppsPermission() { val context = reactApplicationContext - val intent = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:com.noxplay.noxplayer")) + val intent = Intent( + Settings.ACTION_MANAGE_OVERLAY_PERMISSION, + Uri.parse("package:com.noxplay.noxplayer") + ) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) context.startActivity(intent) } @@ -71,6 +132,8 @@ class NoxAndroidAutoModule(reactContext: ReactApplicationContext) : ReactContext @ReactMethod fun isGestureNavigationMode(callback: Promise) { val context = reactApplicationContext - callback.resolve(Settings.Secure.getInt(context.contentResolver, "navigation_mode", 0) == 2) + callback.resolve( + Settings.Secure.getInt(context.contentResolver, "navigation_mode", 0) == 2 + ) } } diff --git a/package.json b/package.json index e580f0f8..2a0d0b4b 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "dropbox": "git+https://lovegaoshi@github.com/lovegaoshi/dropbox-sdk-js.git", "expo": "^50.0.7", "expo-clipboard": "~5.0.1", + "expo-document-picker": "~11.10.1", "expo-image": "^1.10.6", "expo-keep-awake": "^12.8.2", "expo-secure-store": "~12.8.1", diff --git a/src/components/playlist/BiliSearch/BiliSearchbar.tsx b/src/components/playlist/BiliSearch/BiliSearchbar.tsx index 5ea54657..2a79f3ce 100644 --- a/src/components/playlist/BiliSearch/BiliSearchbar.tsx +++ b/src/components/playlist/BiliSearch/BiliSearchbar.tsx @@ -137,6 +137,7 @@ export default ({ toggleVisible={toggleVisible} menuCoords={menuCoords} showMusicFree={showMusicFree} + setSearchVal={setSearchVal} /> ), - LOCAL: () => ( + LOCAL: (fill?: string) => ( - + ), }; diff --git a/src/components/playlist/BiliSearch/SearchMenu.tsx b/src/components/playlist/BiliSearch/SearchMenu.tsx index f2aada7d..82c3a213 100644 --- a/src/components/playlist/BiliSearch/SearchMenu.tsx +++ b/src/components/playlist/BiliSearch/SearchMenu.tsx @@ -1,16 +1,23 @@ import * as React from 'react'; import { Menu } from 'react-native-paper'; +import * as DocumentPicker from 'expo-document-picker'; +import { Platform, NativeModules } from 'react-native'; +import { useTranslation } from 'react-i18next'; import { SEARCH_OPTIONS } from '@enums/Storage'; import { MUSICFREE } from '@utils/mediafetch/musicfree'; import ICONS from './Icons'; import { useNoxSetting } from '@stores/useApp'; +import { rgb2Hex } from '@utils/Utils'; + +const { NoxAndroidAutoModule } = NativeModules; interface Props { visible?: boolean; toggleVisible?: () => void; menuCoords?: NoxTheme.coordinates; showMusicFree?: boolean; + setSearchVal: (v: string) => void; } export default ({ @@ -18,12 +25,30 @@ export default ({ toggleVisible = () => undefined, menuCoords = { x: 0, y: 0 }, showMusicFree, + setSearchVal, }: Props) => { + const { t } = useTranslation(); + const playerStyle = useNoxSetting(state => state.playerStyle); const setSearchOption = useNoxSetting(state => state.setSearchOption); const setDefaultSearch = (defaultSearch: SEARCH_OPTIONS | MUSICFREE) => { toggleVisible(); setSearchOption(defaultSearch); }; + const chooseLocalFolder = async () => { + let selectedFile = ( + await DocumentPicker.getDocumentAsync({ + copyToCacheDirectory: false, + type: 'audio/*', + }) + ).assets; + if (!selectedFile) return; + const uri = selectedFile[0].uri; + let mediaFiles = await NoxAndroidAutoModule.listMediaFileByID( + uri.substring(uri.lastIndexOf('%3A') + 3) + ); + setSearchVal(`local://${mediaFiles[0].relativePath}`); + toggleVisible(); + }; return ( @@ -44,6 +69,13 @@ export default ({ title={`MusicFree.${MUSICFREE.aggregated}`} /> )} + {Platform.OS === 'android' && ( + ICONS.LOCAL(rgb2Hex(playerStyle.colors.primary))} + onPress={chooseLocalFolder} + title={t('Menu.local')} + /> + )} ); }; diff --git a/src/enums/MediaFetch.ts b/src/enums/MediaFetch.ts index d71fb821..6659b3c9 100644 --- a/src/enums/MediaFetch.ts +++ b/src/enums/MediaFetch.ts @@ -4,4 +4,5 @@ export enum SOURCE { steriatk = 'steriatk', ytbvideo = 'ytbvideo', biliBangumi = 'biliBangumi', + local = 'local', } diff --git a/src/localization/en/translation.json b/src/localization/en/translation.json index 2f44233a..fbbb2df1 100644 --- a/src/localization/en/translation.json +++ b/src/localization/en/translation.json @@ -284,5 +284,8 @@ }, "Accessibility": { "gif": "GIF" + }, + "Menu": { + "local": "Local" } } diff --git a/src/localization/zhcn/translation.json b/src/localization/zhcn/translation.json index 8aa033a6..44bcf9fe 100644 --- a/src/localization/zhcn/translation.json +++ b/src/localization/zhcn/translation.json @@ -253,5 +253,8 @@ "artistMatch": "搜索歌手", "albumMatch": "搜索专辑", "cachedMatch": "已缓存" + }, + "Menu": { + "local": "本di" } } diff --git a/src/utils/BiliSearch.ts b/src/utils/BiliSearch.ts index e5bdd1c6..ffab1cb7 100644 --- a/src/utils/BiliSearch.ts +++ b/src/utils/BiliSearch.ts @@ -20,6 +20,7 @@ import ytbmixlistFetch from './mediafetch/ytbmixlist'; import ytbsearchFetch from './mediafetch/ytbsearch'; import bililiveFetch from './mediafetch/bililive'; import bilisubliveFetch from './mediafetch/bilisublive'; +import localFetch from '@utils/mediafetch/local'; import { regexFetchProps } from './mediafetch/generic'; import { MUSICFREE, searcher } from './mediafetch/musicfree'; import { getMusicFreePlugin } from '@utils/ChromeStorage'; @@ -116,6 +117,10 @@ interface ReExtraction { } const reExtractions: ReExtraction[] = [ + { + match: localFetch.regexSearchMatch, + fetch: localFetch.regexFetch, + }, { match: biliBangumiFetch.regexSearchMatch, fetch: biliBangumiFetch.regexFetch, diff --git a/src/utils/SongOperations.ts b/src/utils/SongOperations.ts index 21274c27..2055a331 100644 --- a/src/utils/SongOperations.ts +++ b/src/utils/SongOperations.ts @@ -60,6 +60,7 @@ export const resolveUrl = async (song: NoxMedia.Song, iOS = true) => { logger.debug( `[SongResolveURL] cache ${cachedUrl ? 'found' : 'missed'}, ${song.id}` ); + const cacheWrapper = async ( song: NoxMedia.Song ): Promise => { diff --git a/src/utils/Utils.ts b/src/utils/Utils.ts index 7fd30f02..ce0069a0 100644 --- a/src/utils/Utils.ts +++ b/src/utils/Utils.ts @@ -49,6 +49,28 @@ export const rgb2rgba = (rgb: string, a = 1) => { return `rgba(${extractedRGB[0][0]}, ${extractedRGB[1][0]}, ${extractedRGB[2][0]}, ${a})`; }; +const rgbToHex = (r: number, g: number, b: number) => + '#' + + [r, g, b] + .map(x => { + const hex = x.toString(16); + return hex.length === 1 ? '0' + hex : hex; + }) + .join(''); + +export const rgb2Hex = (rgb: string) => { + try { + const extractedRGB = [...rgb.matchAll(/(\d+)/g)]; + return rgbToHex( + Number(extractedRGB[0][0]), + Number(extractedRGB[1][0]), + Number(extractedRGB[2][0]) + ); + } catch { + return rgb; + } +}; + export const getUniqObjects = ( objects: Array, property: (object: T) => string diff --git a/src/utils/ffmpeg/ffmpeg.ts b/src/utils/ffmpeg/ffmpeg.ts index d03698e7..cbaf675b 100644 --- a/src/utils/ffmpeg/ffmpeg.ts +++ b/src/utils/ffmpeg/ffmpeg.ts @@ -1,10 +1,27 @@ -import { FFmpegKit } from 'ffmpeg-kit-react-native'; +import { FFmpegKit, FFprobeKit } from 'ffmpeg-kit-react-native'; import RNFetchBlob from 'react-native-blob-util'; import TrackPlayer from 'react-native-track-player'; import { logger } from '../Logger'; import { r128gain2Volume } from '../Utils'; +export const cacheAlbumArt = async (fpath: string) => { + // HACK: exoplayer handles embedded art but I also need this for the UI... + await FFmpegKit.execute( + `-i '${fpath}' -an -vcodec copy ${RNFetchBlob.fs.dirs.CacheDir}/tempCover.jpg` + ); + return `${RNFetchBlob.fs.dirs.CacheDir}/tempCover.jpg`; +}; + +export const probeMetadata = async (fspath: string) => { + const session = await FFprobeKit.execute( + `-v quiet -print_format json -show_format '${fspath}'` + ); + const parsedMetadata = JSON.parse(await session.getOutput()); + logger.debug(parsedMetadata); + return parsedMetadata.format; +}; + const parseReplayGainLog = (log: string) => { const regex = /Parsed_replaygain.+ track_gain = (.+) dB/g; regex.exec(log); diff --git a/src/utils/mediafetch/local.ts b/src/utils/mediafetch/local.ts new file mode 100644 index 00000000..bd342ba7 --- /dev/null +++ b/src/utils/mediafetch/local.ts @@ -0,0 +1,72 @@ +/** + * refactor: + * bilisearch workflow: + * reExtractSearch matches regex patterns and use the corresponding fetch functions; + * fetch function takes extracted and calls a dataProcess.js fetch function; + * dataprocess fetch function fetches VIDEOINFO using data.js fetch function, then parses into SONGS + * data.js fetch function fetches VIDEOINFO. + * steps to refactor: + * each site needs a fetch to parse regex extracted, a videoinfo fetcher and a song fetcher. + */ +import { Platform, NativeModules } from 'react-native'; +import RNFetchBlob from 'react-native-blob-util'; + +import { probeMetadata, cacheAlbumArt } from '@utils/ffmpeg/ffmpeg'; +import { SOURCE } from '@enums/MediaFetch'; +import { regexFetchProps } from './generic'; +import SongTS from '@objects/Song'; + +const { NoxAndroidAutoModule } = NativeModules; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const songFetch = async ( + fpath: string, + favlist: string[] +): Promise => { + if (Platform.OS !== 'android') return []; + const mediaFiles = await NoxAndroidAutoModule.listMediaDir(fpath, true); + return Promise.all( + mediaFiles + .filter((v: any) => !favlist.includes(v.realPath)) + .map(async (v: any) => { + const probedMetadata = await probeMetadata(v.realPath); + return SongTS({ + cid: `${SOURCE.local}-${v.realPath}`, + bvid: `file://${v.realPath}`, + name: probedMetadata.tags?.title || v.fileName, + nameRaw: probedMetadata.tags?.title || v.fileName, + singer: probedMetadata.tags?.artist || '', + singerId: probedMetadata.tags?.artist || '', + cover: '', + lyric: '', + page: 0, + duration: Number(probedMetadata.duration) || 0, + album: probedMetadata.tags?.album || '', + source: SOURCE.local, + }); + }) + ); +}; + +const regexFetch = async ({ + reExtracted, + favList = [], +}: regexFetchProps): Promise => ({ + songList: await songFetch(reExtracted[1]!, favList), +}); + +const resolveURL = async (song: NoxMedia.Song) => { + const artworkURI = await cacheAlbumArt(song.bvid); + const artworkBase64 = await RNFetchBlob.fs.readFile(artworkURI, 'base64'); + return { url: song.bvid, cover: `data:image/png;base64,${artworkBase64}` }; +}; + +const refreshSong = (song: NoxMedia.Song) => song; + +export default { + regexSearchMatch: /local:\/\/(.+)/, + regexFetch, + regexResolveURLMatch: /^local-/, + resolveURL, + refreshSong, +}; diff --git a/src/utils/mediafetch/resolveURL.ts b/src/utils/mediafetch/resolveURL.ts index 141487b0..21c07d5b 100644 --- a/src/utils/mediafetch/resolveURL.ts +++ b/src/utils/mediafetch/resolveURL.ts @@ -3,6 +3,7 @@ import biliaudioFetch from './biliaudio'; import ytbvideoFetch from '@utils/mediafetch/ytbvideo'; import bililiveFetch from './bililive'; import biliBangumiFetch from './biliBangumi'; +import localFetch from '@utils/mediafetch/local'; import { logger } from '../Logger'; import { regexMatchOperations } from '../Utils'; import { resolver, MUSICFREE } from './musicfree'; @@ -37,6 +38,7 @@ export const fetchPlayUrlPromise = async ( [ytbvideoFetch.regexResolveURLMatch, ytbvideoFetch.resolveURL], [bililiveFetch.regexResolveURLMatch, bililiveFetch.resolveURL], [biliBangumiFetch.regexResolveURLMatch, biliBangumiFetch.resolveURL], + [localFetch.regexResolveURLMatch, localFetch.resolveURL], ]; const regexResolveURLsWrapped: regResolve = regexResolveURLs.map(entry => [ entry[0], diff --git a/yarn.lock b/yarn.lock index 23d39e76..4509b6fa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7002,6 +7002,11 @@ expo-constants@~15.4.0: dependencies: "@expo/config" "~8.5.0" +expo-document-picker@~11.10.1: + version "11.10.1" + resolved "https://registry.yarnpkg.com/expo-document-picker/-/expo-document-picker-11.10.1.tgz#03394d77842a2fd7cb0a784a60098ee1ddd1012e" + integrity sha512-A1MiLfyXQ+KxanRO5lYxYQy3ryV+25JHe5Ai/BLV+FJU0QXByUF+Y/dn35WVPx5gpdZXC8UJ4ejg5SKSoeconw== + expo-file-system@~16.0.0: version "16.0.4" resolved "https://registry.yarnpkg.com/expo-file-system/-/expo-file-system-16.0.4.tgz#f76b05e2224e705a30a75d50d650ee1dbb4dbaf7"