Skip to content

Commit

Permalink
Merge pull request #309 from lovegaoshi/dev-local-playback
Browse files Browse the repository at this point in the history
feat: local playback
  • Loading branch information
lovegaoshi authored Feb 23, 2024
2 parents 948ef3f + 2a1eeaf commit 11654f4
Show file tree
Hide file tree
Showing 16 changed files with 247 additions and 11 deletions.
11 changes: 8 additions & 3 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />

<!--com.github.yyued:SVGAPlayer-Android:2.6.1 has allowBackup = true; doing tools:replace below. -->
<application
Expand All @@ -15,14 +20,14 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:largeHeap="true"
android:theme="@style/AppTheme">
<profileable android:shell="true"/>
<profileable android:shell="true"
tools:targetApi="q" />
<activity
android:name="com.noxplay.noxplayer.MainActivity"
android:supportsPictureInPicture="true"
android:resizeableActivity="true"
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
android:label="@string/app_name"
android:launchMode="singleTask"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize"
android:networkSecurityConfig="@xml/network_security_config"
android:exported="true">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}
Expand All @@ -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
)
}
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"dropbox": "git+https://[email protected]/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",
Expand Down
1 change: 1 addition & 0 deletions src/components/playlist/BiliSearch/BiliSearchbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ export default ({
toggleVisible={toggleVisible}
menuCoords={menuCoords}
showMusicFree={showMusicFree}
setSearchVal={setSearchVal}
/>
</View>
<ProgressBar
Expand Down
7 changes: 5 additions & 2 deletions src/components/playlist/BiliSearch/Icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,12 @@ const ICONS = {
style={style.musicFreeIcon}
/>
),
LOCAL: () => (
LOCAL: (fill?: string) => (
<Svg width={24} height={24} viewBox="0 0 24 24">
<Path d="M120-160v-160h720v160H120Zm80-40h80v-80h-80v80Zm-80-440v-160h720v160H120Zm80-40h80v-80h-80v80Zm-80 280v-160h720v160H120Zm80-40h80v-80h-80v80Z"></Path>
<Path
fill={fill}
d="M17 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V7l-4-4zm-5 16c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3zm3-10H5V5h10v4z"
></Path>
</Svg>
),
};
Expand Down
32 changes: 32 additions & 0 deletions src/components/playlist/BiliSearch/SearchMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,54 @@
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 ({
visible = false,
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 (
<Menu visible={visible} onDismiss={toggleVisible} anchor={menuCoords}>
Expand All @@ -44,6 +69,13 @@ export default ({
title={`MusicFree.${MUSICFREE.aggregated}`}
/>
)}
{Platform.OS === 'android' && (
<Menu.Item
leadingIcon={() => ICONS.LOCAL(rgb2Hex(playerStyle.colors.primary))}
onPress={chooseLocalFolder}
title={t('Menu.local')}
/>
)}
</Menu>
);
};
1 change: 1 addition & 0 deletions src/enums/MediaFetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ export enum SOURCE {
steriatk = 'steriatk',
ytbvideo = 'ytbvideo',
biliBangumi = 'biliBangumi',
local = 'local',
}
3 changes: 3 additions & 0 deletions src/localization/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -284,5 +284,8 @@
},
"Accessibility": {
"gif": "GIF"
},
"Menu": {
"local": "Local"
}
}
3 changes: 3 additions & 0 deletions src/localization/zhcn/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -253,5 +253,8 @@
"artistMatch": "搜索歌手",
"albumMatch": "搜索专辑",
"cachedMatch": "已缓存"
},
"Menu": {
"local": "本di"
}
}
5 changes: 5 additions & 0 deletions src/utils/BiliSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -116,6 +117,10 @@ interface ReExtraction {
}

const reExtractions: ReExtraction[] = [
{
match: localFetch.regexSearchMatch,
fetch: localFetch.regexFetch,
},
{
match: biliBangumiFetch.regexSearchMatch,
fetch: biliBangumiFetch.regexFetch,
Expand Down
1 change: 1 addition & 0 deletions src/utils/SongOperations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NoxNetwork.ResolvedNoxMediaURL> => {
Expand Down
22 changes: 22 additions & 0 deletions src/utils/Utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <T>(
objects: Array<T>,
property: (object: T) => string
Expand Down
19 changes: 18 additions & 1 deletion src/utils/ffmpeg/ffmpeg.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down
Loading

0 comments on commit 11654f4

Please sign in to comment.