新增自定义源,但需要注意的是,移动端自定义源的环境与PC端不同,某些API不可用

This commit is contained in:
lyswhut 2023-11-03 21:04:28 +08:00
parent 1021bda5d5
commit e612a169de
38 changed files with 2090 additions and 29 deletions

View File

@ -188,6 +188,9 @@ android {
dependencies { dependencies {
// The version of react-native is set by the React Native Gradle Plugin // The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android") implementation("com.facebook.react:react-android")
// implementation "androidx.javascriptengine:javascriptengine:1.0.0-alpha07"
// implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
implementation group: 'wang.harlon.quickjs', name: 'wrapper-android', version: '1.0.0'
debugImplementation("com.facebook.flipper:flipper:${FLIPPER_VERSION}") debugImplementation("com.facebook.flipper:flipper:${FLIPPER_VERSION}")
debugImplementation("com.facebook.flipper:flipper-network-plugin:${FLIPPER_VERSION}") { debugImplementation("com.facebook.flipper:flipper-network-plugin:${FLIPPER_VERSION}") {

View File

@ -0,0 +1,507 @@
globalThis.lx_setup = (key, id, name, description, rawScript) => {
delete globalThis.setup
const _nativeCall = globalThis.__lx_native_call__
delete globalThis.__lx_native_call__
const set_timeout = globalThis.__lx_native_call__set_timeout
delete globalThis.__lx_native_call__set_timeout
const utils_str2b64 = globalThis.__lx_native_call__utils_str2b64
delete globalThis.__lx_native_call__utils_str2b64
const utils_b642buf = globalThis.__lx_native_call__utils_b642buf
delete globalThis.__lx_native_call__utils_b642buf
const utils_str2md5 = globalThis.__lx_native_call__utils_str2md5
delete globalThis.__lx_native_call__utils_str2md5
const utils_aes_encrypt = globalThis.__lx_native_call__utils_aes_encrypt
delete globalThis.__lx_native_call__utils_aes_encrypt
const utils_rsa_encrypt = globalThis.__lx_native_call__utils_rsa_encrypt
delete globalThis.__lx_native_call__utils_rsa_encrypt
const KEY_PREFIX = {
publicKeyStart: '-----BEGIN PUBLIC KEY-----',
publicKeyEnd: '-----END PUBLIC KEY-----',
privateKeyStart: '-----BEGIN PRIVATE KEY-----',
privateKeyEnd: '-----END PRIVATE KEY-----',
}
const RSA_PADDING = {
OAEPWithSHA1AndMGF1Padding: 'RSA/ECB/OAEPWithSHA1AndMGF1Padding',
NoPadding: 'RSA/ECB/NoPadding',
}
const AES_MODE = {
CBC_128_PKCS7Padding: 'AES/CBC/PKCS7Padding',
ECB_128_NoPadding: 'AES',
}
const nativeCall = (action, data) => {
data = JSON.stringify(data)
// console.log('nativeCall', action, data)
_nativeCall(key, action, data)
}
const callbacks = new Map()
let timeoutId = 0
const setTimeout = (callback, timeout = 0, ...params) => {
if (typeof timeout !== 'number' || timeout < 0) throw new Error('timeout required number')
if (timeoutId > 90000000000) throw new Error('max timeout')
const id = timeoutId++
callbacks.set(id, {
callback,
params,
})
set_timeout(id, parseInt(timeout))
return id
}
const clearTimeout = (id) => {
const tagret = callbacks.get(id)
if (!tagret) return
callbacks.delete(id)
}
const handleSetTimeout = (id) => {
const tagret = callbacks.get(id)
if (!tagret) return
callbacks.delete(id)
tagret.callback(...tagret.params)
}
// 将字节数组解码为字符串UTF-8
function bytesToString(bytes) {
let result = ''
let i = 0
while (i < bytes.length) {
const byte = bytes[i]
if (byte < 128) {
result += String.fromCharCode(byte)
i++
} else if (byte >= 192 && byte < 224) {
result += String.fromCharCode(((byte & 31) << 6) | (bytes[i + 1] & 63))
i += 2
} else {
result += String.fromCharCode(((byte & 15) << 12) | ((bytes[i + 1] & 63) << 6) | (bytes[i + 2] & 63))
i += 3
}
}
return result
}
// 将字符串编码为字节数组UTF-8
function stringToBytes(inputString) {
const bytes = []
for (let i = 0; i < inputString.length; i++) {
const charCode = inputString.charCodeAt(i)
if (charCode < 128) {
bytes.push(charCode)
} else if (charCode < 2048) {
bytes.push((charCode >> 6) | 192)
bytes.push((charCode & 63) | 128)
} else {
bytes.push((charCode >> 12) | 224)
bytes.push(((charCode >> 6) & 63) | 128)
bytes.push((charCode & 63) | 128)
}
}
return bytes
}
const NATIVE_EVENTS_NAMES = {
init: 'init',
showUpdateAlert: 'showUpdateAlert',
request: 'request',
cancelRequest: 'cancelRequest',
response: 'response',
'utils.crypto.aesEncrypt': 'utils.crypto.aesEncrypt',
'utils.crypto.rsaEncrypt': 'utils.crypto.rsaEncrypt',
'utils.crypto.randomBytes': 'utils.crypto.randomBytes',
'utils.crypto.md5': 'utils.crypto.md5',
'utils.buffer.from': 'utils.buffer.from',
'utils.buffer.bufToString': 'utils.buffer.bufToString',
'utils.zlib.inflate': 'utils.zlib.inflate',
'utils.zlib.deflate': 'utils.zlib.deflate',
}
const EVENT_NAMES = Object.freeze({
request: 'request',
inited: 'inited',
updateAlert: 'updateAlert',
})
const eventNames = Object.values(EVENT_NAMES)
const events = {
request: null,
}
const allSources = ['kw', 'kg', 'tx', 'wy', 'mg']
const supportQualitys = {
kw: ['128k', '320k', 'flac', 'flac24bit'],
kg: ['128k', '320k', 'flac', 'flac24bit'],
tx: ['128k', '320k', 'flac', 'flac24bit'],
wy: ['128k', '320k', 'flac', 'flac24bit'],
mg: ['128k', '320k', 'flac', 'flac24bit'],
}
const supportActions = {
kw: ['musicUrl'],
kg: ['musicUrl'],
tx: ['musicUrl'],
wy: ['musicUrl'],
mg: ['musicUrl'],
xm: ['musicUrl'],
}
const requestQueue = new Map()
let isInitedApi = false
let isShowedUpdateAlert = false
const sendNativeRequest = (url, options, callback) => {
const requestKey = Math.random().toString()
const requestInfo = {
aborted: false,
abort: () => {
nativeCall(NATIVE_EVENTS_NAMES.cancelRequest, requestKey)
},
}
requestQueue.set(requestKey, {
callback,
// timeout: setTimeout(() => {
// const req = requestQueue.get(requestKey)
// if (req) req.timeout = null
// nativeCall(NATIVE_EVENTS_NAMES.cancelRequest, requestKey)
// }, 30000),
requestInfo,
})
nativeCall(NATIVE_EVENTS_NAMES.request, { requestKey, url, options })
return requestInfo
}
const handleNativeResponse = ({ requestKey, error, response }) => {
const targetRequest = requestQueue.get(requestKey)
if (!targetRequest) return
requestQueue.delete(requestKey)
targetRequest.requestInfo.aborted = true
// if (targetRequest.timeout) clearTimeout(targetRequest.timeout)
if (error == null) targetRequest.callback(null, response)
else targetRequest.callback(new Error(error), null)
}
const handleRequest = ({ requestKey, data }) => {
// console.log(data)
if (!events.request) return nativeCall(NATIVE_EVENTS_NAMES.response, { requestKey, status: false, errorMessage: 'Request event is not defined' })
try {
events.request.call(globalThis.lx, { source: data.source, action: data.action, info: data.info }).then(response => {
let result
switch (data.action) {
case 'musicUrl':
result = {
source: data.source,
action: data.action,
data: {
type: data.info.type,
url: response,
},
}
break
}
nativeCall(NATIVE_EVENTS_NAMES.response, { requestKey, status: true, result })
}).catch(err => {
// console.log('handleRequest err', err)
nativeCall(NATIVE_EVENTS_NAMES.response, { requestKey, status: false, errorMessage: err.message })
})
} catch (err) {
// console.log('handleRequest call err', err)
nativeCall(NATIVE_EVENTS_NAMES.response, { requestKey, status: false, errorMessage: err.message })
}
}
const jsCall = (action, data) => {
// console.log('jsCall', action, data)
switch (action) {
case '__set_timeout__':
handleSetTimeout(data)
break
case 'request':
handleRequest(data)
break
case 'response':
handleNativeResponse(data)
break
case NATIVE_EVENTS_NAMES['utils.buffer.from']:
break
case NATIVE_EVENTS_NAMES['utils.buffer.bufToString']:
break
default:
break
}
return 'Unknown action: ' + action
}
Object.defineProperty(globalThis, '__lx_native__', {
enumerable: false,
configurable: false,
writable: false,
value: (_key, action, data) => {
if (key != _key) return 'Invalid key'
return jsCall(action, JSON.parse(data))
},
})
/**
*
* @param {*} info {
* openDevTools: false,
* status: true,
* message: 'xxx',
* sources: {
* kw: ['128k', '320k', 'flac', 'flac24bit'],
* kg: ['128k', '320k', 'flac', 'flac24bit'],
* tx: ['128k', '320k', 'flac', 'flac24bit'],
* wy: ['128k', '320k', 'flac', 'flac24bit'],
* mg: ['128k', '320k', 'flac', 'flac24bit'],
* }
* }
*/
const handleInit = (info) => {
if (!info) {
nativeCall(NATIVE_EVENTS_NAMES.init, { id, info: null, status: false, errorMessage: 'Init failed' })
// sendMessage(NATIVE_EVENTS_NAMES.init, false, null, typeof info.message === 'string' ? info.message.substring(0, 100) : '')
return
}
if (!info.status) {
nativeCall(NATIVE_EVENTS_NAMES.init, { id, info: null, status: false, errorMessage: 'Init failed' })
// sendMessage(NATIVE_EVENTS_NAMES.init, false, null, typeof info.message === 'string' ? info.message.substring(0, 100) : '')
return
}
const sourceInfo = {
sources: {},
}
try {
for (const source of allSources) {
const userSource = info.sources[source]
if (!userSource || userSource.type !== 'music') continue
const qualitys = supportQualitys[source]
const actions = supportActions[source]
sourceInfo.sources[source] = {
type: 'music',
actions: actions.filter(a => userSource.actions.includes(a)),
qualitys: qualitys.filter(q => userSource.qualitys.includes(q)),
}
}
} catch (error) {
// console.log(error)
nativeCall(NATIVE_EVENTS_NAMES.init, { id, info: null, status: false, errorMessage: error.message })
return
}
nativeCall(NATIVE_EVENTS_NAMES.init, { id, info: sourceInfo, status: true })
}
const handleShowUpdateAlert = (data, resolve, reject) => {
if (!data || typeof data != 'object') return reject(new Error('parameter format error.'))
if (!data.log || typeof data.log != 'string') return reject(new Error('log is required.'))
if (data.updateUrl && !/^https?:\/\/[^\s$.?#].[^\s]*$/.test(data.updateUrl) && data.updateUrl.length > 1024) delete data.updateUrl
if (data.log.length > 1024) data.log = data.log.substring(0, 1024) + '...'
nativeCall(NATIVE_EVENTS_NAMES.showUpdateAlert, { log: data.log, updateUrl: data.updateUrl, name })
resolve()
}
const dataToB64 = (data) => {
if (typeof data === 'string') return utils_str2b64(data)
else if (Array.isArray(data) || ArrayBuffer.isView(data)) return utils.buffer.bufToString(data, 'base64')
throw new Error('data type error: ' + typeof data + ' raw data: ' + data)
}
const utils = Object.freeze({
crypto: {
aesEncrypt(buffer, mode, key, iv) {
// console.log('aesEncrypt', buffer, mode, key, iv)
switch (mode) {
case 'aes-128-cbc':
return utils.buffer.from(utils_aes_encrypt(dataToB64(buffer), dataToB64(key), dataToB64(iv), AES_MODE.CBC_128_PKCS7Padding), 'base64')
case 'aes-128-ecb':
return utils.buffer.from(utils_aes_encrypt(dataToB64(buffer), dataToB64(key), '', AES_MODE.ECB_128_NoPadding), 'base64')
default:
throw new Error('Binary encoding is not supported for input strings')
}
},
rsaEncrypt(buffer, key) {
// console.log('rsaEncrypt', buffer, key)
if (typeof key !== 'string') throw new Error('Invalid RSA key')
key = key.replace(KEY_PREFIX.publicKeyStart, '')
.replace(KEY_PREFIX.publicKeyEnd, '')
return utils.buffer.from(utils_rsa_encrypt(dataToB64(buffer), key, RSA_PADDING.NoPadding), 'base64')
},
randomBytes(size) {
const byteArray = new Uint8Array(size)
for (let i = 0; i < size; i++) {
byteArray[i] = Math.floor(Math.random() * 256) // 随机生成一个字节的值0-255
}
return byteArray
},
md5(str) {
if (typeof str !== 'string') throw new Error('param required a string')
const md5 = utils_str2md5(str)
// console.log('md5', str, md5)
return md5
},
},
buffer: {
from(input, encoding) {
// console.log('buffer.from', input, encoding)
if (typeof input === 'string') {
switch (encoding) {
case 'binary':
throw new Error('Binary encoding is not supported for input strings')
case 'base64':
return new Uint8Array(JSON.parse(utils_b642buf(input)))
case 'hex':
return new Uint8Array(input.match(/.{1,2}/g).map(byte => parseInt(byte, 16)))
default:
return new Uint8Array(stringToBytes(input))
}
} else if (Array.isArray(input)) {
return new Uint8Array(input)
} else {
throw new Error('Unsupported input type: ' + input + ' encoding: ' + encoding)
}
},
bufToString(buf, format) {
// console.log('buffer.bufToString', buf, format)
if (Array.isArray(buf) || ArrayBuffer.isView(buf)) {
switch (format) {
case 'binary':
// return new TextDecoder('latin1').decode(new Uint8Array(buf))
return buf
case 'hex':
return new Uint8Array(buf).reduce((str, byte) => str + byte.toString(16).padStart(2, '0'), '')
case 'base64':
return utils_str2b64(bytesToString(Array.from(buf)))
case 'utf8':
case 'utf-8':
default:
return bytesToString(Array.from(buf))
}
} else {
throw new Error('Input is not a valid buffer: ' + buf + ' format: ' + format)
}
},
},
// zlib: {
// inflate(buf) {
// return new Promise((resolve, reject) => {
// zlib.inflate(buf, (err, data) => {
// if (err) reject(new Error(err.message))
// else resolve(data)
// })
// })
// },
// deflate(data) {
// return new Promise((resolve, reject) => {
// zlib.deflate(data, (err, buf) => {
// if (err) reject(new Error(err.message))
// else resolve(buf)
// })
// })
// },
// },
})
Object.defineProperty(globalThis, 'lx', {
configurable: false,
writable: false,
value: Object.freeze({
EVENT_NAMES,
request(url, { method = 'get', timeout, headers, body, form, formData, binary }, callback) {
let options = { headers, binary: binary === true }
// let data
// if (body) {
// data = body
// } else if (form) {
// data = form
// // data.content_type = 'application/x-www-form-urlencoded'
// options.json = false
// } else if (formData) {
// data = formData
// // data.content_type = 'multipart/form-data'
// options.json = false
// }
if (timeout && typeof timeout == 'number') options.timeout = Math.min(options.timeout, 60_000)
let request = sendNativeRequest(url, { method, body, form, formData, ...options }, (err, resp) => {
callback(err, {
statusCode: resp.statusCode,
statusMessage: resp.statusMessage,
headers: resp.headers,
// bytes: resp.bytes,
// raw: resp.raw,
body: resp.body,
}, resp.body)
})
return () => {
if (!request.aborted) request.abort()
request = null
}
},
send(eventName, data) {
return new Promise((resolve, reject) => {
if (!eventNames.includes(eventName)) return reject(new Error('The event is not supported: ' + eventName))
switch (eventName) {
case EVENT_NAMES.inited:
if (isInitedApi) return reject(new Error('Script is inited'))
isInitedApi = true
handleInit(data)
resolve()
break
case EVENT_NAMES.updateAlert:
if (isShowedUpdateAlert) return reject(new Error('The update alert can only be called once.'))
isShowedUpdateAlert = true
handleShowUpdateAlert(data, resolve, reject)
break
default:
reject(new Error('Unknown event name: ' + eventName))
}
})
},
on(eventName, handler) {
if (!eventNames.includes(eventName)) return Promise.reject(new Error('The event is not supported: ' + eventName))
switch (eventName) {
case EVENT_NAMES.request:
events.request = handler
break
default: return Promise.reject(new Error('The event is not supported: ' + eventName))
}
return Promise.resolve()
},
utils,
currentScriptInfo: Object.freeze({
name,
description,
}),
version: '1.0.0',
env: 'mobile',
}),
})
Object.defineProperty(globalThis, 'setTimeout', {
configurable: false,
writable: false,
value: setTimeout,
})
Object.defineProperty(globalThis, 'clearTimeout', {
configurable: false,
writable: false,
value: clearTimeout,
})
Object.defineProperty(globalThis, 'window', {
configurable: false,
writable: false,
value: globalThis,
})
Object.defineProperty(globalThis, 'document', {
configurable: false,
writable: false,
value: Object.freeze({
getElementsByTagName(name) {
if (name == 'script') {
return [
Object.freeze({
innerText: rawScript,
}),
]
}
return null
},
}),
})
console.log('Preload finished.')
}

View File

@ -15,6 +15,7 @@ import cn.toside.music.mobile.cache.CachePackage;
import cn.toside.music.mobile.crypto.CryptoPackage; import cn.toside.music.mobile.crypto.CryptoPackage;
import cn.toside.music.mobile.gzip.GzipPackage; import cn.toside.music.mobile.gzip.GzipPackage;
import cn.toside.music.mobile.lyric.LyricPackage; import cn.toside.music.mobile.lyric.LyricPackage;
import cn.toside.music.mobile.userApi.UserApiPackage;
import cn.toside.music.mobile.utils.UtilsPackage; import cn.toside.music.mobile.utils.UtilsPackage;
public class MainApplication extends NavigationApplication { public class MainApplication extends NavigationApplication {
@ -37,6 +38,7 @@ public class MainApplication extends NavigationApplication {
packages.add(new LyricPackage()); packages.add(new LyricPackage());
packages.add(new UtilsPackage()); packages.add(new UtilsPackage());
packages.add(new CryptoPackage()); packages.add(new CryptoPackage());
packages.add(new UserApiPackage());
return packages; return packages;
} }

View File

@ -0,0 +1,43 @@
package cn.toside.music.mobile.userApi;
import android.os.Handler;
import android.os.Message;
import android.util.Log;
import com.whl.quickjs.wrapper.QuickJSContext;
public class Console implements QuickJSContext.Console {
private final Handler eventHandler;
Console(Handler eventHandler) {
this.eventHandler = eventHandler;
}
private void sendLog(String type, String log) {
Message message = this.eventHandler.obtainMessage();
message.what = 1001;
message.obj = new Object[]{type, log};
Log.d("UserApi Log", "[" + type + "]" + log);
this.eventHandler.sendMessage(message);
}
@Override
public void log(String info) {
sendLog("log", info);
}
@Override
public void info(String info) {
sendLog("info", info);
}
@Override
public void warn(String info) {
sendLog("warn", info);
}
@Override
public void error(String info) {
sendLog("error", info);
}
}

View File

@ -0,0 +1,9 @@
package cn.toside.music.mobile.userApi;
public class HandlerWhat {
public static final int ACTION = 1000;
public static final int INIT = 99;
public static final int INIT_FAILED = 500;
public static final int INIT_SUCCESS = 200;
public static final int LOG = 1001;
}

View File

@ -0,0 +1,63 @@
package cn.toside.music.mobile.userApi;
import android.os.Bundle;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.Message;
import android.util.Log;
import androidx.annotation.NonNull;
import com.facebook.react.bridge.ReactApplicationContext;
public class JavaScriptThread extends HandlerThread {
private Handler handler;
private QuickJS javaScriptExecutor;
private final ReactApplicationContext reactContext;
private final Bundle scriptInfo;
JavaScriptThread(ReactApplicationContext context, Bundle info) {
super("JavaScriptThread");
this.reactContext = context;
this.scriptInfo = info;
}
public void prepareHandler(final Handler mainHandler) {
start();
Log.d("UserApi [thread]", "running 2");
this.handler = new Handler(getLooper()) {
@Override
public void handleMessage(@NonNull Message message) {
if (javaScriptExecutor == null) {
javaScriptExecutor = new QuickJS(reactContext, mainHandler);
Log.d("UserApi [thread]", "javaScript executor created");
String result = javaScriptExecutor.loadScript(scriptInfo);
if ("".equals(result)) {
Log.d("UserApi [thread]", "script loaded");
mainHandler.sendEmptyMessage(HandlerWhat.INIT_SUCCESS);
} else {
Log.w("UserApi [thread]", "script load failed: " + result);
Handler handler = mainHandler;
handler.sendMessage(handler.obtainMessage(HandlerWhat.INIT_FAILED, result));
}
}
if (message.what == HandlerWhat.INIT) return;
if (message.what == HandlerWhat.ACTION) {
Object[] data = (Object[]) message.obj;
Log.d("UserApi [handler]", "handler action: " + data[0]);
javaScriptExecutor.callJS((String) data[0], data[1]);
return;
}
Log.w("UserApi [handler]", "Unknown message what: " + message.what);
}
};
}
public Handler getHandler() {
return this.handler;
}
public void stopThread() {
quit();
}
}

View File

@ -0,0 +1,69 @@
package cn.toside.music.mobile.userApi;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.util.Log;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.WritableMap;
import java.util.Objects;
public class JsHandler extends Handler {
private final UtilsEvent utilsEvent;
JsHandler(Looper looper, UtilsEvent utilsEvent) {
super(looper);
this.utilsEvent = utilsEvent;
}
private void sendInitFailedEvent(String errorMessage) {
WritableMap params = Arguments.createMap();
params.putString("action", "init");
params.putString("data", "{ \"info\": null, \"status\": false, \"errorMessage\": \"Create JavaScript Env Failed\" }");
UtilsEvent utilsEvent = this.utilsEvent;
Objects.requireNonNull(utilsEvent);
utilsEvent.sendEvent("api-action", params);
sendLogEvent(new Object[]{"error", errorMessage});
}
private void sendLogEvent(Object[] data) {
WritableMap params = Arguments.createMap();
params.putString("action", "log");
params.putString("type", (String) data[0]);
params.putString("log", (String) data[1]);
UtilsEvent utilsEvent = this.utilsEvent;
Objects.requireNonNull(utilsEvent);
utilsEvent.sendEvent("api-action", params);
}
private void sendActionEvent(String action, String data) {
WritableMap params = Arguments.createMap();
params.putString("action", action);
params.putString("data", data);
UtilsEvent utilsEvent = this.utilsEvent;
Objects.requireNonNull(utilsEvent);
utilsEvent.sendEvent("api-action", params);
}
@Override
public void handleMessage(Message msg) {
switch (msg.what) {
case HandlerWhat.INIT_SUCCESS: break;
case HandlerWhat.INIT_FAILED:
sendInitFailedEvent((String) msg.obj);
break;
case HandlerWhat.ACTION:
Object[] action = (Object[]) msg.obj;
sendActionEvent((String) action[0], (String) action[1]);
Log.d("UserApi [api call]", "action: " + action[0]);
break;
case HandlerWhat.LOG:
sendLogEvent((Object[]) msg.obj);
break;
default:
Log.w("UserApi [api call]", "Unknown message what: " + msg.what);
break;
}
}
}

View File

@ -0,0 +1,188 @@
package cn.toside.music.mobile.userApi;
import android.os.Bundle;
import android.os.Handler;
import android.os.Message;
import android.util.Base64;
import android.util.Log;
import cn.toside.music.mobile.crypto.AES;
import cn.toside.music.mobile.crypto.RSA;
import com.facebook.react.bridge.ReactApplicationContext;
import com.whl.quickjs.android.QuickJSLoader;
import com.whl.quickjs.wrapper.JSCallFunction;
import com.whl.quickjs.wrapper.QuickJSContext;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.UUID;
import okhttp3.HttpUrl;
public class QuickJS {
private final Handler eventHandler;
private String key;
private final ReactApplicationContext reactContext;
private boolean isInited = false;
private QuickJSContext jsContext = null;
final Handler timeoutHandler = new Handler();
public QuickJS(ReactApplicationContext context, Handler eventHandler) {
this.reactContext = context;
this.eventHandler = eventHandler;
}
private void init() {
if (this.isInited) return;
QuickJSLoader.init();
this.key = UUID.randomUUID().toString();
this.isInited = true;
}
private String getPreloadScript() {
try {
InputStream inputStream = this.reactContext.getAssets().open("script/user-api-preload.js");
byte[] buffer = new byte[inputStream.available()];
inputStream.read(buffer);
inputStream.close();
return new String(buffer, StandardCharsets.UTF_8);
} catch (Exception e) {
return null;
}
}
private void createEnvObj(QuickJSContext jsContext) {
jsContext.getGlobalObject().setProperty("__lx_native_call__", args -> {
if (this.key.equals(args[0])) {
callNative((String) args[1], (String) args[2]);
return null;
}
return null;
});
jsContext.getGlobalObject().setProperty("__lx_native_call__utils_str2b64", args -> {
try {
return new String(Base64.encode(((String) args[0]).getBytes(StandardCharsets.UTF_8), Base64.NO_WRAP));
} catch (Exception e) {
Log.e("UserApi [utils]", "utils_str2b64 error: " + e.getMessage());
return "";
}
});
jsContext.getGlobalObject().setProperty("__lx_native_call__utils_b642buf", args -> {
try {
byte[] byteArray = Base64.decode(((String) args[0]).getBytes(StandardCharsets.UTF_8), Base64.NO_WRAP);
StringBuilder jsonArrayString = new StringBuilder("[");
for (int i = 0; i < byteArray.length; i++) {
jsonArrayString.append((int) byteArray[i]);
if (i < byteArray.length - 1) {
jsonArrayString.append(",");
}
}
jsonArrayString.append("]");
return jsonArrayString.toString();
} catch (Exception e) {
Log.e("UserApi [utils]", "utils_b642buf error: " + e.getMessage());
return "";
}
});
jsContext.getGlobalObject().setProperty("__lx_native_call__utils_str2md5", args -> {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] inputBytes = ((String) args[0]).getBytes();
byte[] md5Bytes = md.digest(inputBytes);
StringBuilder md5String = new StringBuilder();
for (byte b : md5Bytes) {
md5String.append(String.format("%02x", b));
}
return md5String.toString();
} catch (NoSuchAlgorithmException e) {
Log.e("UserApi [utils]", "utils_str2md5 error: " + e.getMessage());
return "";
}
});
jsContext.getGlobalObject().setProperty("__lx_native_call__utils_aes_encrypt", args -> {
try {
return AES.encrypt((String) args[0], (String) args[1], (String) args[2], (String) args[3]);
} catch (Exception e) {
Log.e("UserApi [utils]", "utils_aes_encrypt error: " + e.getMessage());
return "";
}
});
jsContext.getGlobalObject().setProperty("__lx_native_call__utils_rsa_encrypt", args -> {
try {
return RSA.encryptRSAToString((String) args[0], (String) args[1], (String) args[2]);
} catch (Exception e) {
Log.e("UserApi [utils]", "utils_rsa_encrypt error: " + e.getMessage());
return "";
}
});
jsContext.getGlobalObject().setProperty("__lx_native_call__set_timeout", args -> {
this.timeoutHandler.postDelayed(() -> {
callJS("__set_timeout__", args[0]);
}, (int) args[1]);
return null;
});
}
private boolean createJSEnv(String id, String name, String desc, String rawScript) {
init();
QuickJSContext quickJSContext = this.jsContext;
if (quickJSContext != null) quickJSContext.destroy();
QuickJSContext create = QuickJSContext.create();
this.jsContext = create;
create.setConsole(new Console(this.eventHandler));
String preloadScript = getPreloadScript();
if (preloadScript == null) return false;
createEnvObj(this.jsContext);
this.jsContext.evaluate(preloadScript);
this.jsContext.getGlobalObject().getJSFunction("lx_setup").call(this.key, id, name, desc, rawScript);
return true;
}
private void callNative(String action, String data) {
Message message = this.eventHandler.obtainMessage();
message.what = HandlerWhat.ACTION;
message.obj = new Object[]{action, data};
Log.d("UserApi [script call]", "script call action: " + action + " data: " + data);
this.eventHandler.sendMessage(message);
}
public String loadScript(Bundle scriptInfo) {
Log.d("UserApi", "UserApi Thread id: " + Thread.currentThread().getId());
String script = scriptInfo.getString("script", "");
if (createJSEnv(scriptInfo.getString("id", ""),
scriptInfo.getString("name", "Unknown"),
scriptInfo.getString("description", ""), script)) {
try {
this.jsContext.evaluate(script);
return "";
} catch (Exception e) {
Log.e("UserApi", "load script error: " + e.getMessage());
return e.getMessage();
}
}
return "create JavaScript Env failed";
}
public Object callJS(String action, Object... args) {
Object[] params;
if (args == null) {
params = new Object[]{this.key, action};
} else {
Object[] params2 = new Object[args.length + 2];
params2[0] = this.key;
params2[1] = action;
System.arraycopy(args, 0, params2, 2, args.length);
params = params2;
}
try {
return this.jsContext.getGlobalObject().getJSFunction("__lx_native__").call(params);
} catch (Exception e) {
Message message = eventHandler.obtainMessage();
message.what = HandlerWhat.LOG;
message.obj = new Object[]{"error", "Call script error: " + e.getMessage()};
eventHandler.sendMessage(message);
Log.e("UserApi", "Call script error: " + e.getMessage());
return null;
}
}
}

View File

@ -0,0 +1,69 @@
package cn.toside.music.mobile.userApi;
import android.os.Bundle;
import android.os.Message;
import android.util.Log;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.ReadableMap;
import java.lang.Thread;
public class UserApiModule extends ReactContextBaseJavaModule {
private JavaScriptThread javaScriptThread;
private final ReactApplicationContext reactContext;
private UtilsEvent utilsEvent;
UserApiModule(ReactApplicationContext reactContext) {
super(reactContext);
this.javaScriptThread = null;
this.utilsEvent = null;
this.reactContext = reactContext;
}
@Override
public String getName() {
return "UserApiModule";
}
@ReactMethod
public void loadScript(ReadableMap data) {
if (this.utilsEvent == null) this.utilsEvent = new UtilsEvent(this.reactContext);
if (this.javaScriptThread != null) destroy();
Bundle info = Arguments.toBundle(data);
this.javaScriptThread = new JavaScriptThread(this.reactContext, info);
final JsHandler jsHandler = new JsHandler(this.reactContext.getMainLooper(), this.utilsEvent);
this.javaScriptThread.prepareHandler(jsHandler);
this.javaScriptThread.getHandler().sendEmptyMessage(HandlerWhat.INIT);
this.javaScriptThread.setUncaughtExceptionHandler((thread, ex) -> {
Message message = jsHandler.obtainMessage();
message.what = HandlerWhat.LOG;
message.obj = new Object[]{"error", "Uncaught exception in JavaScriptThread: " + ex.getMessage()};
jsHandler.sendMessage(message);
Log.e("JavaScriptThread", "Uncaught exception in JavaScriptThread: " + ex.getMessage());
});
Log.d("UserApi", "Module Thread id: " + Thread.currentThread().getId());
}
@ReactMethod
public boolean sendAction(String action, String info) {
JavaScriptThread javaScriptThread = this.javaScriptThread;
if (javaScriptThread == null) return false;
Message message = javaScriptThread.getHandler().obtainMessage();
message.what = HandlerWhat.ACTION;
message.obj = new Object[]{action, info};
this.javaScriptThread.getHandler().sendMessage(message);
return true;
}
@ReactMethod
public void destroy() {
JavaScriptThread javaScriptThread = this.javaScriptThread;
if (javaScriptThread == null) {
return;
}
javaScriptThread.stopThread();
this.javaScriptThread = null;
}
}

View File

@ -0,0 +1,21 @@
package cn.toside.music.mobile.userApi;
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 UserApiPackage implements ReactPackage {
@Override
public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
return Collections.emptyList();
}
@Override
public List<NativeModule> createNativeModules(ReactApplicationContext reactContext) {
return Arrays.asList(new UserApiModule(reactContext));
}
}

View File

@ -0,0 +1,24 @@
package cn.toside.music.mobile.userApi;
import android.util.Log;
import androidx.annotation.Nullable;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.modules.core.DeviceEventManagerModule;
public class UtilsEvent {
final String API_ACTION = "api-action";
private final ReactApplicationContext reactContext;
UtilsEvent(ReactApplicationContext reactContext) {
this.reactContext = reactContext;
}
public void sendEvent(String eventName, @Nullable WritableMap params) {
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
.emit(eventName, params);
}
}

View File

@ -1,3 +1,7 @@
### 新增
- 新增自定义源但需要注意的是移动端自定义源的环境与PC端不同某些API不可用
### 优化 ### 优化
- 添加是否忽略电池优化检查用于提醒用户添加白名单确保APP后台播放稳定性 - 添加是否忽略电池优化检查用于提醒用户添加白名单确保APP后台播放稳定性

View File

@ -25,6 +25,8 @@ export interface Props {
*/ */
onPress?: (e: GestureResponderEvent) => void onPress?: (e: GestureResponderEvent) => void
size?: number
/** /**
* Custom color for checkbox. * Custom color for checkbox.
*/ */
@ -45,6 +47,7 @@ const PADDING = scaleSizeW(4)
const Checkbox = ({ const Checkbox = ({
status, status,
disabled, disabled,
size = 1,
onPress, onPress,
tintColors, tintColors,
...rest ...rest
@ -86,12 +89,12 @@ const Checkbox = ({
accessibilityRole="checkbox" accessibilityRole="checkbox"
accessibilityState={{ disabled, checked }} accessibilityState={{ disabled, checked }}
accessibilityLiveRegion="polite" accessibilityLiveRegion="polite"
style={{ ...styles.container, padding: PADDING }} style={{ ...styles.container, padding: PADDING, marginLeft: -PADDING }}
> >
<Icon <Icon
allowFontScaling={false} allowFontScaling={false}
name="checkbox-blank-outline" name="checkbox-blank-outline"
size={24} size={24 * size}
color={tintColors.false} color={tintColors.false}
/> />
<View style={[StyleSheet.absoluteFill, styles.fillContainer]}> <View style={[StyleSheet.absoluteFill, styles.fillContainer]}>
@ -99,7 +102,7 @@ const Checkbox = ({
<Icon <Icon
allowFontScaling={false} allowFontScaling={false}
name={icon} name={icon}
size={24} size={24 * size}
color={tintColors.true} color={tintColors.true}
/> />
</Animated.View> </Animated.View>

View File

@ -15,6 +15,7 @@ export interface CheckBoxProps {
onChange: (check: boolean) => void onChange: (check: boolean) => void
disabled?: boolean disabled?: boolean
need?: boolean need?: boolean
size?: number
marginRight?: number marginRight?: number
marginBottom?: number marginBottom?: number
@ -22,7 +23,7 @@ export interface CheckBoxProps {
helpDesc?: string helpDesc?: string
} }
export default ({ check, label, children, onChange, helpTitle, helpDesc, disabled = false, need = false, marginRight = 0, marginBottom = 0 }: CheckBoxProps) => { export default ({ check, label, children, onChange, helpTitle, helpDesc, disabled = false, need = false, marginRight = 0, marginBottom = 0, size = 1 }: CheckBoxProps) => {
const theme = useTheme() const theme = useTheme()
const [isDisabled, setDisabled] = useState(false) const [isDisabled, setDisabled] = useState(false)
const tintColors = { const tintColors = {
@ -65,10 +66,10 @@ export default ({ check, label, children, onChange, helpTitle, helpDesc, disable
} }
return (helpTitle ?? helpDesc) ? ( return (helpTitle ?? helpDesc) ? (
<TouchableOpacity style={styles.helpBtn} onPress={handleShowHelp}> <TouchableOpacity style={styles.helpBtn} onPress={handleShowHelp}>
<Icon name="help" /> <Icon size={15 * size} name="help" />
</TouchableOpacity> </TouchableOpacity>
) : null ) : null
}, [helpTitle, helpDesc]) }, [helpTitle, helpDesc, size])
const contentStyle = { ...styles.content, marginBottom: scaleSizeH(marginBottom) } const contentStyle = { ...styles.content, marginBottom: scaleSizeH(marginBottom) }
@ -78,16 +79,16 @@ export default ({ check, label, children, onChange, helpTitle, helpDesc, disable
disabled disabled
? ( ? (
<View style={contentStyle}> <View style={contentStyle}>
<CheckBox status={check ? 'checked' : 'unchecked'} disabled={true} tintColors={disabledTintColors} /> <CheckBox status={check ? 'checked' : 'unchecked'} disabled={true} tintColors={disabledTintColors} size={size} />
<View style={labelStyle}>{label ? <Text style={styles.name} color={theme['c-500']}>{label}</Text> : children}</View> <View style={labelStyle}>{label ? <Text style={styles.name} color={theme['c-500']} size={15 * size}>{label}</Text> : children}</View>
{helpComponent} {helpComponent}
</View> </View>
) )
: ( : (
<View style={contentStyle}> <View style={contentStyle}>
<CheckBox status={check ? 'checked' : 'unchecked'} disabled={isDisabled} onPress={handleLabelPress} tintColors={tintColors} /> <CheckBox status={check ? 'checked' : 'unchecked'} disabled={isDisabled} onPress={handleLabelPress} tintColors={tintColors} size={size} />
<TouchableOpacity style={labelStyle} activeOpacity={0.3} onPress={handleLabelPress}> <TouchableOpacity style={labelStyle} activeOpacity={0.3} onPress={handleLabelPress}>
{label ? <Text style={styles.name}>{label}</Text> : children} {label ? <Text style={styles.name} size={15 * size}>{label}</Text> : children}
</TouchableOpacity> </TouchableOpacity>
{helpComponent} {helpComponent}
</View> </View>

View File

@ -73,6 +73,8 @@ export const storageDataPrefix = {
cheatTip: '@cheat_tip', cheatTip: '@cheat_tip',
dislikeList: '@dislike_list', dislikeList: '@dislike_list',
userApi: '@user_api__',
} as const } as const
// v0.x.x 版本的 data keys // v0.x.x 版本的 data keys
@ -105,6 +107,7 @@ export const NAV_MENUS = [
export type NAV_ID_Type = typeof NAV_MENUS[number]['id'] export type NAV_ID_Type = typeof NAV_MENUS[number]['id']
export const LXM_FILE_EXT_RXP = /\.(json|lxmc)$/ export const LXM_FILE_EXT_RXP = /\.(json|lxmc)$/
export const USER_API_SOURCE_FILE_EXT_RXP = /\.(js)$/
export const MUSIC_TOGGLE_MODE = { export const MUSIC_TOGGLE_MODE = {
listLoop: 'listLoop', // 列表循环 listLoop: 'listLoop', // 列表循环

View File

@ -45,12 +45,14 @@ global.lx = {
// syncKeyInfo: {}, // syncKeyInfo: {},
isEnableSyncLog: false, isEnableSyncLog: false,
isEnableUserApiLog: false,
playerTrackId: '', playerTrackId: '',
gettingUrlId: '', gettingUrlId: '',
qualityList: {}, qualityList: {},
apis: {},
jumpMyListPosition: false, jumpMyListPosition: false,

View File

@ -3,26 +3,22 @@ import musicSdk from '@/utils/musicSdk'
// import apiSourceInfo from '@renderer/utils/musicSdk/api-source-info' // import apiSourceInfo from '@renderer/utils/musicSdk/api-source-info'
import { updateSetting } from './common' import { updateSetting } from './common'
import settingState from '@/store/setting/state' import settingState from '@/store/setting/state'
import { destroyUserApi, setUserApi } from './userApi'
import apiSourceInfo from '@/utils/musicSdk/api-source-info'
export const setUserApi = (apiId: string) => { export const setApiSource = (apiId: string) => {
if (/^user_api/.test(apiId)) { if (/^user_api/.test(apiId)) {
// qualityList.value = {} setUserApi(apiId).catch(err => {
// userApi.status = false console.log(err)
// userApi.message = 'initing' let api = apiSourceInfo.find(api => !api.disabled)
if (!api) return
// await setUserApiAction(apiId).then(() => { if (api.id != settingState.setting['common.apiSource']) setApiSource(api.id)
// apiSource.value = apiId })
// }).catch(err => {
// console.log(err)
// let api = apiSourceInfo.find(api => !api.disabled)
// if (!api) return
// apiSource.value = api.id
// if (api.id != appSetting['common.apiSource']) setApiSource(api.id)
// })
} else { } else {
// @ts-expect-error // @ts-expect-error
global.lx.qualityList = musicSdk.supportQuality[apiId] ?? {} global.lx.qualityList = musicSdk.supportQuality[apiId] ?? {}
destroyUserApi()
// apiSource.value = apiId // apiSource.value = apiId
// void setUserApiAction(apiId) // void setUserApiAction(apiId)
} }

View File

@ -2,11 +2,12 @@ import { initSetting, showPactModal } from '@/core/common'
import registerPlaybackService from '@/plugins/player/service' import registerPlaybackService from '@/plugins/player/service'
import initTheme from './theme' import initTheme from './theme'
import initI18n from './i18n' import initI18n from './i18n'
import initUserApi from './userApi'
import initPlayer from './player' import initPlayer from './player'
import dataInit from './dataInit' import dataInit from './dataInit'
import initSync from './sync' import initSync from './sync'
// import syncSetting from './syncSetting' // import syncSetting from './syncSetting'
import { setUserApi } from '@/core/apiSource' import { setApiSource } from '@/core/apiSource'
import commonActions from '@/store/common/action' import commonActions from '@/store/common/action'
import settingState from '@/store/setting/state' import settingState from '@/store/setting/state'
import { checkUpdate } from '@/core/version' import { checkUpdate } from '@/core/version'
@ -42,7 +43,10 @@ export default async() => {
await initI18n(setting) await initI18n(setting)
bootLog('I18n inited.') bootLog('I18n inited.')
setUserApi(setting['common.apiSource']) await initUserApi(setting)
bootLog('User Api inited.')
setApiSource(setting['common.apiSource'])
bootLog('Api inited.') bootLog('Api inited.')
registerPlaybackService() registerPlaybackService()

172
src/core/init/userApi.ts Normal file
View File

@ -0,0 +1,172 @@
import { type InitParams, onScriptAction, sendAction, type ResponseParams, type UpdateInfoParams } from '@/utils/nativeModules/userApi'
import { log, setUserApiList, setUserApiStatus } from '../userApi'
import settingState from '@/store/setting/state'
import BackgroundTimer from 'react-native-background-timer'
import { httpFetch } from '@/utils/request'
import { getUserApiList } from '@/utils/data'
export default async(setting: LX.AppSetting) => {
const requestQueue = new Map<string, { resolve: (value: ResponseParams['result']) => void, reject: (error: Error) => void, timeout: number }>()
const cancelRequest = (requestKey: string, message: string) => {
const target = requestQueue.get(requestKey)
if (!target) return
requestQueue.delete(requestKey)
BackgroundTimer.clearTimeout(target.timeout)
target.reject(new Error(message))
}
const sendUserApiRequest = async(data: LX.UserApi.UserApiRequestParams) => new Promise<ResponseParams['result']>((resolve, reject) => {
requestQueue.set(data.requestKey, {
resolve,
reject,
timeout: BackgroundTimer.setTimeout(() => {
const target = requestQueue.get(data.requestKey)
if (!target) return
requestQueue.delete(data.requestKey)
target.reject(new Error('request timeout'))
}, 30_000),
})
sendAction('request', data)
})
const handleUserApiResponse = ({ status, result, requestKey, errorMessage }: ResponseParams) => {
const target = requestQueue.get(requestKey)
if (!target) return
requestQueue.delete(requestKey)
BackgroundTimer.clearTimeout(target.timeout)
if (status) target.resolve(result)
else target.reject(new Error(errorMessage ?? 'failed'))
}
const handleStateChange = ({ status, errorMessage, info, id }: InitParams) => {
// console.log(status, message, info)
setUserApiStatus(status, errorMessage)
if (!status || !info?.sources || id !== settingState.setting['common.apiSource']) return
let apis: any = {}
let qualitys: LX.QualityList = {}
for (const [source, { actions, type, qualitys: sourceQualitys }] of Object.entries(info.sources)) {
if (type != 'music') continue
apis[source as LX.Source] = {}
for (const action of actions) {
switch (action) {
case 'musicUrl':
apis[source].getMusicUrl = (songInfo: LX.Music.MusicInfo, type: LX.Quality) => {
const requestKey = `request__${Math.random().toString().substring(2)}`
return {
canceleFn() {
// userApiRequestCancel(requestKey)
},
promise: sendUserApiRequest({
requestKey,
data: {
source,
action: 'musicUrl',
info: {
type,
musicInfo: songInfo,
},
},
// eslint-disable-next-line @typescript-eslint/promise-function-async
}).then(res => {
// console.log(res)
if (!/^https?:/.test(res.data.url)) return Promise.reject(new Error('Get url failed'))
return { type, url: res.data.url }
}).catch(async err => {
console.log(err.message)
return Promise.reject(err)
}),
}
}
break
default:
break
}
}
qualitys[source as LX.Source] = sourceQualitys
}
global.lx.qualityList = qualitys
global.lx.apis = apis
global.state_event.apiSourceUpdated(settingState.setting['common.apiSource'])
}
const showUpdateAlert = ({ name, log, updateUrl }: UpdateInfoParams) => {
// if (updateUrl) {
// void dialog({
// message: `${t('user_api__update_alert', { name })}\n${log}`,
// selection: true,
// showCancel: true,
// confirmButtonText: t('user_api__update_alert_open_url'),
// cancelButtonText: t('close'),
// }).then(confirm => {
// if (!confirm) return
// window.setTimeout(() => {
// void openUrl(updateUrl)
// }, 300)
// })
// } else {
// void dialog({
// message: `${t('user_api__update_alert', { name })}\n${log}`,
// selection: true,
// confirmButtonText: t('ok'),
// })
// }
}
onScriptAction((event) => {
// console.log('script actuon: ', event)
switch (event.action) {
case 'init':
handleStateChange(event.data)
break
case 'response':
handleUserApiResponse(event.data)
break
case 'request':
httpFetch(event.data.url, {
...event.data.options,
credentials: 'omit',
cache: 'default',
}).promise.then(response => {
// console.log(response)
sendAction('response', {
error: null,
requestKey: event.data.requestKey,
response,
})
}).catch(err => {
sendAction('response', {
error: err.message,
requestKey: event.data.requestKey,
response: null,
})
})
break
case 'cancelRequest':
cancelRequest(event.data, 'request canceled')
break
case 'showUpdateAlert':
showUpdateAlert(event.data)
break
case 'log':
switch ((event as unknown as { type: keyof typeof log }).type) {
case 'log':
case 'info':
log.info((event as unknown as { log: string }).log)
break
case 'error':
log.error((event as unknown as { log: string }).log)
break
case 'warn':
log.warn((event as unknown as { log: string }).log)
break
default:
break
}
break
default:
break
}
})
setUserApiList(await getUserApiList())
}

69
src/core/userApi.ts Normal file
View File

@ -0,0 +1,69 @@
import { action, state } from '@/store/userApi'
import { addUserApi, getUserApiScript, removeUserApi as removeUserApiFromStore, setUserApiAllowShowUpdateAlert as setUserApiAllowShowUpdateAlertFromStore } from '@/utils/data'
import { destroy, loadScript } from '@/utils/nativeModules/userApi'
import { log as writeLog } from '@/utils/log'
export const setUserApi = async(apiId: string) => {
global.lx.qualityList = {}
setUserApiStatus(false, 'initing')
const target = state.list.find(api => api.id === apiId)
if (!target) throw new Error('api not found')
const script = await getUserApiScript(target.id)
loadScript({ ...target, script })
}
export const destroyUserApi = () => {
destroy()
}
export const setUserApiStatus: typeof action['setStatus'] = (status, message) => {
action.setStatus(status, message)
}
export const setUserApiList: typeof action['setUserApiList'] = (list) => {
action.setUserApiList(list)
}
export const importUserApi = async(script: string) => {
const info = await addUserApi(script)
action.addUserApi(info)
}
export const removeUserApi = async(ids: string[]) => {
console.log(ids)
const list = await removeUserApiFromStore(ids)
console.log(list)
action.setUserApiList(list)
}
export const setUserApiAllowShowUpdateAlert = async(id: string, enable: boolean) => {
await setUserApiAllowShowUpdateAlertFromStore(id, enable)
action.setUserApiAllowShowUpdateAlert(id, enable)
}
export const log = {
r_info(...params: any[]) {
writeLog.info(...params)
},
r_warn(...params: any[]) {
writeLog.warn(...params)
},
r_error(...params: any[]) {
writeLog.error(...params)
},
log(...params: any[]) {
if (global.lx.isEnableUserApiLog) writeLog.info(...params)
},
info(...params: any[]) {
if (global.lx.isEnableUserApiLog) writeLog.info(...params)
},
warn(...params: any[]) {
if (global.lx.isEnableUserApiLog) writeLog.warn(...params)
},
error(...params: any[]) {
if (global.lx.isEnableUserApiLog) writeLog.error(...params)
},
}

View File

@ -12,6 +12,7 @@
"change_position_music_multi_title": "Adjust the position of the selected {num} song to", "change_position_music_multi_title": "Adjust the position of the selected {num} song to",
"change_position_music_title": "Adjust the position of {name} to", "change_position_music_title": "Adjust the position of {name} to",
"change_position_tip": "Please enter a new position", "change_position_tip": "Please enter a new position",
"close": "Close",
"collect": "Collect", "collect": "Collect",
"collect_songlist": "Collection Songlist", "collect_songlist": "Collection Songlist",
"collect_success": "Collection success", "collect_success": "Collection success",
@ -206,9 +207,13 @@
"setting_basic_show_exit_btn": "Show exit app button", "setting_basic_show_exit_btn": "Show exit app button",
"setting_basic_source": "Music source", "setting_basic_source": "Music source",
"setting_basic_source_direct": "Direct API", "setting_basic_source_direct": "Direct API",
"setting_basic_source_status_failed": "initialization failed",
"setting_basic_source_status_initing": "Initializing",
"setting_basic_source_status_success": "Initialization successful",
"setting_basic_source_temp": "Temporary API (some features not available; workaround if Test API unavailable)", "setting_basic_source_temp": "Temporary API (some features not available; workaround if Test API unavailable)",
"setting_basic_source_test": "Test API (Available for most software features)", "setting_basic_source_test": "Test API (Available for most software features)",
"setting_basic_source_title": "Choose a music source", "setting_basic_source_title": "Choose a music source",
"setting_basic_source_user_api_btn": "Custom source management",
"setting_basic_sourcename": "Source name", "setting_basic_sourcename": "Source name",
"setting_basic_sourcename_alias": "Aliases", "setting_basic_sourcename_alias": "Aliases",
"setting_basic_sourcename_real": "Original", "setting_basic_sourcename_real": "Original",
@ -260,6 +265,7 @@
"setting_other_log_sync_log": "Record synchronization log", "setting_other_log_sync_log": "Record synchronization log",
"setting_other_log_tip_clean_success": "Log cleaning completed", "setting_other_log_tip_clean_success": "Log cleaning completed",
"setting_other_log_tip_null": "The log is empty~", "setting_other_log_tip_null": "The log is empty~",
"setting_other_log_user_api_log": "Logging custom source logs",
"setting_play_audio_offload": "Enable audio offload", "setting_play_audio_offload": "Enable audio offload",
"setting_play_audio_offload_tip": "Enabling audio offloading can save power consumption, but on some devices, all songs may prompt \"Audio loading error\" or \"The whole song cannot be played completely\". This is caused by a bug in the current system.\nFor People who encounter this problem can turn off this option and restart the application completely to try again.", "setting_play_audio_offload_tip": "Enabling audio offloading can save power consumption, but on some devices, all songs may prompt \"Audio loading error\" or \"The whole song cannot be played completely\". This is caused by a bug in the current system.\nFor People who encounter this problem can turn off this option and restart the application completely to try again.",
"setting_play_auto_clean_played_list": "Automatically clear the played list", "setting_play_auto_clean_played_list": "Automatically clear the played list",
@ -382,6 +388,14 @@
"timeout_exit_tip_on": "Stop playing after {time}", "timeout_exit_tip_on": "Stop playing after {time}",
"toggle_source_failed": "Failed to change the source, please try to manually search for the song in other sources to play", "toggle_source_failed": "Failed to change the source, please try to manually search for the song in other sources to play",
"toggle_source_try": "Try switching to another source...", "toggle_source_try": "Try switching to another source...",
"user_api_allow_show_update_alert": "Allow update popups to be displayed",
"user_api_btn_import": "Import",
"user_api_import_desc": "Select custom source file",
"user_api_import_failed_tip": "Import failed",
"user_api_note": "Tip: Although we have isolated the running environment of the script as much as possible, importing scripts containing malicious behaviors may still affect your system, so please import with caution.",
"user_api_readme": "Source writing instructions: ",
"user_api_remove_tip": "Do you really want to remove {name}?",
"user_api_title": "Custom source management",
"version_btn_close": "Close", "version_btn_close": "Close",
"version_btn_downloading": "I am trying to download...{total}/{current} ({progress}%)", "version_btn_downloading": "I am trying to download...{total}/{current} ({progress}%)",
"version_btn_failed": "Retry", "version_btn_failed": "Retry",

View File

@ -12,6 +12,7 @@
"change_position_music_multi_title": "将已选的 {num} 首歌曲的位置调整到", "change_position_music_multi_title": "将已选的 {num} 首歌曲的位置调整到",
"change_position_music_title": "将 {name} 的位置调整到", "change_position_music_title": "将 {name} 的位置调整到",
"change_position_tip": "请输入新的位置", "change_position_tip": "请输入新的位置",
"close": "关闭",
"collect": "收藏", "collect": "收藏",
"collect_songlist": "收藏歌单", "collect_songlist": "收藏歌单",
"collect_success": "收藏成功", "collect_success": "收藏成功",
@ -206,9 +207,13 @@
"setting_basic_show_exit_btn": "显示退出应用按钮", "setting_basic_show_exit_btn": "显示退出应用按钮",
"setting_basic_source": "音乐来源", "setting_basic_source": "音乐来源",
"setting_basic_source_direct": "试听接口(这是最后的选择...", "setting_basic_source_direct": "试听接口(这是最后的选择...",
"setting_basic_source_status_failed": "初始化失败",
"setting_basic_source_status_initing": "初始化中",
"setting_basic_source_status_success": "初始化成功",
"setting_basic_source_temp": "临时接口(软件的某些功能不可用,建议测试接口不可用再使用本接口)", "setting_basic_source_temp": "临时接口(软件的某些功能不可用,建议测试接口不可用再使用本接口)",
"setting_basic_source_test": "测试接口(几乎软件的所有功能都可用)", "setting_basic_source_test": "测试接口(几乎软件的所有功能都可用)",
"setting_basic_source_title": "选择音乐来源", "setting_basic_source_title": "选择音乐来源",
"setting_basic_source_user_api_btn": "自定义源管理",
"setting_basic_sourcename": "音源名字", "setting_basic_sourcename": "音源名字",
"setting_basic_sourcename_alias": "别名", "setting_basic_sourcename_alias": "别名",
"setting_basic_sourcename_real": "原名", "setting_basic_sourcename_real": "原名",
@ -260,6 +265,7 @@
"setting_other_log_sync_log": "记录同步日志", "setting_other_log_sync_log": "记录同步日志",
"setting_other_log_tip_clean_success": "日志清理完成", "setting_other_log_tip_clean_success": "日志清理完成",
"setting_other_log_tip_null": "日志是空的哦~", "setting_other_log_tip_null": "日志是空的哦~",
"setting_other_log_user_api_log": "记录自定义源日志",
"setting_play_audio_offload": "启用音频卸载", "setting_play_audio_offload": "启用音频卸载",
"setting_play_audio_offload_tip": "启用音频卸载可以节省耗电量,但在某些设备上可能会出现所有歌曲都提示 「音频加载出错」 或者 「无法完整播放整首歌」 的问题这是由于当前系统的bug导致的。\n对于遇到这个问题的人可以关闭此选项后完全重启应用再试。", "setting_play_audio_offload_tip": "启用音频卸载可以节省耗电量,但在某些设备上可能会出现所有歌曲都提示 「音频加载出错」 或者 「无法完整播放整首歌」 的问题这是由于当前系统的bug导致的。\n对于遇到这个问题的人可以关闭此选项后完全重启应用再试。",
"setting_play_auto_clean_played_list": "自动清空已播放列表", "setting_play_auto_clean_played_list": "自动清空已播放列表",
@ -382,6 +388,14 @@
"timeout_exit_tip_on": "{time} 后停止播放", "timeout_exit_tip_on": "{time} 后停止播放",
"toggle_source_failed": "换源失败,请尝试手动在其他源搜索该歌曲播放", "toggle_source_failed": "换源失败,请尝试手动在其他源搜索该歌曲播放",
"toggle_source_try": "尝试切换到其他源...", "toggle_source_try": "尝试切换到其他源...",
"user_api_allow_show_update_alert": "允许显示更新弹窗",
"user_api_btn_import": "导入",
"user_api_import_desc": "选择自定义源文件",
"user_api_import_failed_tip": "导入失败",
"user_api_note": "提示:虽然我们已经尽可能地隔离了脚本的运行环境,但导入包含恶意行为的脚本仍可能会影响你的系统,请谨慎导入。",
"user_api_readme": "源编写说明:",
"user_api_remove_tip": "你真的要移除 {name} 吗?",
"user_api_title": "自定义源管理",
"version_btn_close": "关闭", "version_btn_close": "关闭",
"version_btn_downloading": "正在努力下载中...{total}/{current} ({progress}%)", "version_btn_downloading": "正在努力下载中...{total}/{current} ({progress}%)",
"version_btn_failed": "重试", "version_btn_failed": "重试",

View File

@ -1,14 +1,18 @@
import { memo, useCallback, useMemo } from 'react' import { memo, useCallback, useMemo, useRef } from 'react'
import { View } from 'react-native' import { View } from 'react-native'
import SubTitle from '../../components/SubTitle' import SubTitle from '../../components/SubTitle'
import CheckBox from '@/components/common/CheckBox' import CheckBox from '@/components/common/CheckBox'
import { createStyle } from '@/utils/tools' import { createStyle } from '@/utils/tools'
import { setUserApi } from '@/core/apiSource' import { setApiSource } from '@/core/apiSource'
import { useI18n } from '@/lang' import { useI18n } from '@/lang'
import apiSourceInfo from '@/utils/musicSdk/api-source-info' import apiSourceInfo from '@/utils/musicSdk/api-source-info'
import { useSettingValue } from '@/store/setting/hook' import { useSettingValue } from '@/store/setting/hook'
import { useStatus, useUserApiList } from '@/store/userApi'
import Button from '../../components/Button'
import UserApiEditModal, { type UserApiEditModalType } from './UserApiEditModal'
// import { importUserApi, removeUserApi } from '@/core/userApi'
const apiSourceList = apiSourceInfo.map(api => ({ const apiSourceList = apiSourceInfo.map(api => ({
id: api.id, id: api.id,
@ -40,8 +44,35 @@ export default memo(() => {
id: s.id, id: s.id,
})), [t]) })), [t])
const setApiSourceId = useCallback((id: string) => { const setApiSourceId = useCallback((id: string) => {
setUserApi(id) setApiSource(id)
}, []) }, [])
const userApiListRaw = useUserApiList()
const apiStatus = useStatus()
const apiSourceSetting = useSettingValue('common.apiSource')
const userApiList = useMemo(() => {
const getApiStatus = () => {
let status
if (apiStatus.status) status = t('setting_basic_source_status_success')
else if (apiStatus.message == 'initing') status = t('setting_basic_source_status_initing')
else status = t('setting_basic_source_status_failed')
return status
}
return userApiListRaw.map(api => {
return {
id: api.id,
name: `${api.name}${api.description ? `${api.description}` : ''}${api.id == apiSourceSetting ? `[${getApiStatus()}]` : ''}`,
// status: apiStatus.status,
// message: apiStatus.message,
// disabled: false,
}
})
}, [userApiListRaw, apiStatus, apiSourceSetting, t])
const modalRef = useRef<UserApiEditModalType>(null)
const handleShow = () => {
modalRef.current?.show()
}
return ( return (
<SubTitle title={t('setting_basic_source')}> <SubTitle title={t('setting_basic_source')}>
@ -49,7 +80,14 @@ export default memo(() => {
{ {
list.map(({ id, name }) => <Item name={name} id={id} key={id} change={setApiSourceId} />) list.map(({ id, name }) => <Item name={name} id={id} key={id} change={setApiSourceId} />)
} }
{
userApiList.map(({ id, name }) => <Item name={name} id={id} key={id} change={setApiSourceId} />)
}
</View> </View>
<View style={styles.btn}>
<Button onPress={handleShow}>{t('setting_basic_source_user_api_btn')}</Button>
</View>
<UserApiEditModal ref={modalRef} />
</SubTitle> </SubTitle>
) )
}) })
@ -61,4 +99,8 @@ const styles = createStyle({
// flexDirection: 'row', // flexDirection: 'row',
// flexWrap: 'wrap', // flexWrap: 'wrap',
}, },
btn: {
marginTop: 10,
flexDirection: 'row',
},
}) })

View File

@ -0,0 +1,119 @@
import { useCallback } from 'react'
import Text from '@/components/common/Text'
import { View, TouchableOpacity, ScrollView } from 'react-native'
import { confirmDialog, createStyle } from '@/utils/tools'
import { useTheme } from '@/store/theme/hook'
import { useI18n } from '@/lang'
import { useUserApiList } from '@/store/userApi'
import { useSettingValue } from '@/store/setting/hook'
import { removeUserApi, setUserApiAllowShowUpdateAlert } from '@/core/userApi'
import { BorderRadius } from '@/theme'
import CheckBox from '@/components/common/CheckBox'
import { Icon } from '@/components/common/Icon'
const ListItem = ({ item, activeId, onRemove, onChangeAllowShowUpdateAlert }: {
item: LX.UserApi.UserApiInfo
activeId: string
onRemove: (id: string, name: string) => void
onChangeAllowShowUpdateAlert: (id: string, enabled: boolean) => void
}) => {
const theme = useTheme()
const t = useI18n()
const changeAllowShowUpdateAlert = (check: boolean) => {
onChangeAllowShowUpdateAlert(item.id, check)
}
const handleRemove = () => {
onRemove(item.id, item.name)
}
return (
<View style={{ ...styles.listItem, backgroundColor: activeId == item.id ? theme['c-primary-background-active'] : 'transparent' }}>
<View>
<Text size={13}>{item.name}</Text>
<Text size={12} color={theme['c-font-label']}>{item.description}</Text>
<CheckBox check={item.allowShowUpdateAlert} label={t('user_api_allow_show_update_alert')} onChange={changeAllowShowUpdateAlert} size={0.8} />
</View>
<View>
<TouchableOpacity style={styles.btn} onPress={handleRemove}>
<Icon name="close" color={theme['c-button-font']} />
</TouchableOpacity>
</View>
</View>
)
}
export interface UserApiEditModalProps {
onSave: (rules: string) => void
// onSourceChange: SourceSelectorProps['onSourceChange']
}
export interface UserApiEditModalType {
show: (rules: string) => void
}
export default () => {
const userApiList = useUserApiList()
const apiSource = useSettingValue('common.apiSource')
const handleRemove = useCallback(async(id: string, name: string) => {
const confirm = await confirmDialog({
message: global.i18n.t('user_api_remove_tip', { name }),
cancelButtonText: global.i18n.t('cancel_button_text_2'),
confirmButtonText: global.i18n.t('confirm_button_text'),
bgClose: false,
})
if (!confirm) return
void removeUserApi([id])
}, [])
const handleChangeAllowShowUpdateAlert = useCallback((id: string, enabled: boolean) => {
void setUserApiAllowShowUpdateAlert(id, enabled)
}, [])
return (
<ScrollView style={styles.scrollView} keyboardShouldPersistTaps={'always'}>
<View onStartShouldSetResponder={() => true}>
{
userApiList.map((item) => {
return (
<ListItem
key={item.id}
item={item}
activeId={apiSource}
onRemove={handleRemove}
onChangeAllowShowUpdateAlert={handleChangeAllowShowUpdateAlert}
/>
)
})
}
</View>
</ScrollView>
)
}
const styles = createStyle({
scrollView: {
flexGrow: 0,
},
list: {
paddingBottom: 15,
flexDirection: 'column',
},
listItem: {
padding: 10,
borderRadius: BorderRadius.normal,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
// btns: {
// padding: 5,
// },
btn: {
padding: 10,
// backgroundColor: 'rgba(0, 0, 0, 0.2)',
},
})

View File

@ -0,0 +1,98 @@
import ChoosePath, { type ChoosePathType } from '@/components/common/ChoosePath'
import { USER_API_SOURCE_FILE_EXT_RXP } from '@/config/constant'
import { forwardRef, useImperativeHandle, useRef, useState } from 'react'
import { handleImport } from './action'
export interface SelectInfo {
// listInfo: LX.List.MyListInfo
// selectedList: LX.Music.MusicInfo[]
// index: number
// listId: string
// single: boolean
action: 'import'
}
const initSelectInfo = {}
// export interface ScriptImportExportProps {
// // onRename: (listInfo: LX.List.UserListInfo) => void
// // onImport: (index: number) => void
// // onExport: (listInfo: LX.List.MyListInfo) => void
// // onSync: (listInfo: LX.List.UserListInfo) => void
// // onRemove: (listInfo: LX.List.MyListInfo) => void
// }
export interface ScriptImportExportType {
import: () => void
// export: (listInfo: LX.List.MyListInfo, index: number) => void
}
export default forwardRef<ScriptImportExportType, {}>((props, ref) => {
const [visible, setVisible] = useState(false)
const choosePathRef = useRef<ChoosePathType>(null)
const selectInfoRef = useRef<SelectInfo>((initSelectInfo as SelectInfo))
// console.log('render import export')
useImperativeHandle(ref, () => ({
import() {
selectInfoRef.current = {
action: 'import',
}
if (visible) {
choosePathRef.current?.show({
title: global.i18n.t('user_api_import_desc'),
dirOnly: false,
filter: USER_API_SOURCE_FILE_EXT_RXP,
})
} else {
setVisible(true)
requestAnimationFrame(() => {
choosePathRef.current?.show({
title: global.i18n.t('user_api_import_desc'),
dirOnly: false,
filter: USER_API_SOURCE_FILE_EXT_RXP,
})
})
}
},
// export(listInfo, index) {
// selectInfoRef.current = {
// action: 'export',
// listInfo,
// index,
// }
// if (visible) {
// choosePathRef.current?.show({
// title: global.i18n.t('list_export_part_desc'),
// dirOnly: true,
// filter: LXM_FILE_EXT_RXP,
// })
// } else {
// setVisible(true)
// requestAnimationFrame(() => {
// choosePathRef.current?.show({
// title: global.i18n.t('list_export_part_desc'),
// dirOnly: true,
// filter: LXM_FILE_EXT_RXP,
// })
// })
// }
// },
}))
const onConfirmPath = (path: string) => {
switch (selectInfoRef.current.action) {
case 'import':
handleImport(path)
break
// case 'export':
// handleExport(selectInfoRef.current.listInfo, path)
// break
}
}
return (
visible
? <ChoosePath ref={choosePathRef} onConfirm={onConfirmPath} />
: null
)
})

View File

@ -0,0 +1,17 @@
import { importUserApi } from '@/core/userApi'
import { log } from '@/utils/log'
import { readFile } from '@/utils/nativeModules/utils'
import { toast } from '@/utils/tools'
export const handleImport = (path: string) => {
// toast(global.i18n.t('setting_backup_part_import_list_tip_unzip'))
void readFile(path).then(async script => {
if (script == null) throw new Error('Read file failed')
await importUserApi(script)
}).catch((error: any) => {
log.error(error.stack)
toast(global.i18n.t('user_api_import_failed_tip'))
})
}

View File

@ -0,0 +1,182 @@
import { useRef, useImperativeHandle, forwardRef, useState } from 'react'
import Text from '@/components/common/Text'
import { View, TouchableOpacity } from 'react-native'
import { createStyle, openUrl } from '@/utils/tools'
import { useTheme } from '@/store/theme/hook'
import { useI18n } from '@/lang'
import Dialog, { type DialogType } from '@/components/common/Dialog'
import Button from '@/components/common/Button'
import List from './List'
import ScriptImportExport, { type ScriptImportExportType } from './ScriptImportExport'
// interface UrlInputType {
// setText: (text: string) => void
// getText: () => string
// focus: () => void
// }
// const UrlInput = forwardRef<UrlInputType, {}>((props, ref) => {
// const theme = useTheme()
// const t = useI18n()
// const [text, setText] = useState('')
// const inputRef = useRef<InputType>(null)
// const [height, setHeight] = useState(100)
// useImperativeHandle(ref, () => ({
// getText() {
// return text.trim()
// },
// setText(text) {
// setText(text)
// },
// focus() {
// inputRef.current?.focus()
// },
// }))
// const handleLayout = useCallback(({ nativeEvent }: LayoutChangeEvent) => {
// setHeight(nativeEvent.layout.height)
// }, [])
// return (
// <View style={styles.inputContent} onLayout={handleLayout}>
// <Input
// ref={inputRef}
// value={text}
// onChangeText={setText}
// textAlignVertical="top"
// placeholder={t('setting_dislike_list_input_tip')}
// size={12}
// style={{ ...styles.input, height, backgroundColor: theme['c-primary-input-background'] }}
// />
// </View>
// )
// })
// export interface UserApiEditModalProps {
// onSave: (rules: string) => void
// // onSourceChange: SourceSelectorProps['onSourceChange']
// }
export interface UserApiEditModalType {
show: () => void
}
export default forwardRef<UserApiEditModalType, {}>((props, ref) => {
const dialogRef = useRef<DialogType>(null)
const scriptImportExportRef = useRef<ScriptImportExportType>(null)
// const sourceSelectorRef = useRef<SourceSelectorType>(null)
// const inputRef = useRef<UrlInputType>(null)
const [visible, setVisible] = useState(false)
const theme = useTheme()
const t = useI18n()
const handleShow = () => {
dialogRef.current?.setVisible(true)
// requestAnimationFrame(() => {
// inputRef.current?.setText('')
// sourceSelectorRef.current?.setSource(source)
// setTimeout(() => {
// inputRef.current?.focus()
// }, 300)
// })
}
useImperativeHandle(ref, () => ({
show() {
if (visible) handleShow()
else {
setVisible(true)
requestAnimationFrame(() => {
handleShow()
})
}
},
}))
const handleCancel = () => {
dialogRef.current?.setVisible(false)
}
const handleImport = () => {
scriptImportExportRef.current?.import()
}
const openFAQPage = () => {
void openUrl('https://lyswhut.github.io/lx-music-doc/mobile/custom-source')
}
return (
visible
? (
<Dialog ref={dialogRef} bgHide={false}>
<View style={styles.content}>
{/* <UrlInput ref={inputRef} /> */}
<Text style={styles.title}>{t('user_api_title')}</Text>
<List />
<View style={styles.tips}>
<Text style={styles.tipsText} size={12}>
{t('user_api_readme')}
</Text>
<TouchableOpacity onPress={openFAQPage}>
<Text style={{ ...styles.tipsText, textDecorationLine: 'underline' }} size={12} color={theme['c-primary-font']}>FAQ</Text>
</TouchableOpacity>
</View>
<Text style={styles.tipsText} size={12}>{t('user_api_note')}</Text>
</View>
<View style={styles.btns}>
<Button style={{ ...styles.btn, backgroundColor: theme['c-button-background'] }} onPress={handleCancel}>
<Text size={14} color={theme['c-button-font']}>{t('close')}</Text>
</Button>
<Button style={{ ...styles.btn, backgroundColor: theme['c-button-background'] }} onPress={handleImport}>
<Text size={14} color={theme['c-button-font']}>{t('user_api_btn_import')}</Text>
</Button>
<ScriptImportExport ref={scriptImportExportRef} />
</View>
</Dialog>
) : null
)
})
const styles = createStyle({
content: {
// flexGrow: 1,
flexShrink: 1,
paddingHorizontal: 15,
paddingTop: 15,
paddingBottom: 10,
flexDirection: 'column',
},
title: {
marginBottom: 15,
textAlign: 'center',
// backgroundColor: 'rgba(0, 0, 0, 0.2)',
},
tips: {
marginTop: 15,
flexDirection: 'row',
flexWrap: 'wrap',
},
tipsText: {
marginTop: 8,
textAlignVertical: 'bottom',
// lineHeight: 18,
// backgroundColor: 'rgba(0, 0, 0, 0.2)',
},
btns: {
flexDirection: 'row',
justifyContent: 'center',
paddingBottom: 15,
paddingLeft: 15,
// paddingRight: 15,
},
btn: {
flex: 1,
paddingTop: 8,
paddingBottom: 8,
paddingLeft: 10,
paddingRight: 10,
alignItems: 'center',
borderRadius: 4,
marginRight: 15,
},
})

View File

@ -17,6 +17,7 @@ export default memo(() => {
const [logText, setLogText] = useState('') const [logText, setLogText] = useState('')
const isUnmountedRef = useRef(true) const isUnmountedRef = useRef(true)
const [isEnableSyncErrorLog, setIsEnableSyncErrorLog] = useState(global.lx.isEnableSyncLog) const [isEnableSyncErrorLog, setIsEnableSyncErrorLog] = useState(global.lx.isEnableSyncLog)
const [isEnableUserApiLog, setIsEnableUserApiLog] = useState(global.lx.isEnableUserApiLog)
const getErrorLog = () => { const getErrorLog = () => {
void getLogs().then(log => { void getLogs().then(log => {
@ -45,6 +46,11 @@ export default memo(() => {
global.lx.isEnableSyncLog = enable global.lx.isEnableSyncLog = enable
} }
const handleSetEnableUserApiLog = (enable: boolean) => {
setIsEnableUserApiLog(enable)
global.lx.isEnableUserApiLog = enable
}
useEffect(() => { useEffect(() => {
isUnmountedRef.current = false isUnmountedRef.current = false
@ -58,6 +64,7 @@ export default memo(() => {
<SubTitle title={t('setting_other_log')}> <SubTitle title={t('setting_other_log')}>
<View style={styles.checkBox}> <View style={styles.checkBox}>
<CheckBoxItem check={isEnableSyncErrorLog} label={t('setting_other_log_sync_log')} onChange={handleSetEnableSyncErrorLog} /> <CheckBoxItem check={isEnableSyncErrorLog} label={t('setting_other_log_sync_log')} onChange={handleSetEnableSyncErrorLog} />
<CheckBoxItem check={isEnableUserApiLog} label={t('setting_other_log_user_api_log')} onChange={handleSetEnableUserApiLog} />
</View> </View>
<View style={styles.btn}> <View style={styles.btn}>
<Button onPress={openLogModal}>{t('setting_other_log_btn_show')}</Button> <Button onPress={openLogModal}>{t('setting_other_log_btn_show')}</Button>

View File

@ -0,0 +1,31 @@
import { state } from './state'
import { event } from './event'
export const setStatus = (status: LX.UserApi.UserApiStatus['status'], message: LX.UserApi.UserApiStatus['message']) => {
state.status.status = status
state.status.message = message
event.status_changed({ status, message })
}
export const setUserApiList = (list: LX.UserApi.UserApiInfo[]) => {
state.list = list
event.list_changed([...list])
}
export const addUserApi = (info: LX.UserApi.UserApiInfo) => {
state.list.push(info)
event.list_changed([...state.list])
}
export const setUserApiAllowShowUpdateAlert = (id: string, enable: boolean) => {
const targetIndex = state.list.findIndex(api => api.id == id)
if (targetIndex < 0) return
state.list.splice(targetIndex, 1, { ...state.list[targetIndex], allowShowUpdateAlert: enable })
event.list_changed([...state.list])
}

View File

@ -0,0 +1,26 @@
import Event from '@/event/Event'
class UserApiEvent extends Event {
status_changed(status: { status: boolean, message?: string }) {
this.emit('status_changed', status)
}
list_changed(list: LX.UserApi.UserApiInfo[]) {
this.emit('list_changed', list)
}
}
type EventMethods = Omit<EventType, keyof Event>
declare class EventType extends UserApiEvent {
on<K extends keyof EventMethods>(event: K, listener: EventMethods[K]): any
off<K extends keyof EventMethods>(event: K, listener: EventMethods[K]): any
}
type UserApiEventTypes = Omit<EventType, keyof Omit<Event, 'on' | 'off'>>
export const event: UserApiEventTypes = new UserApiEvent()

29
src/store/userApi/hook.ts Normal file
View File

@ -0,0 +1,29 @@
import { useEffect, useState } from 'react'
import { state } from './state'
import { event } from './event'
export const useStatus = () => {
const [value, update] = useState(state.status)
useEffect(() => {
event.on('status_changed', update)
return () => {
event.off('status_changed', update)
}
}, [])
return value
}
export const useUserApiList = () => {
const [value, update] = useState(state.list)
useEffect(() => {
event.on('list_changed', update)
return () => {
event.off('list_changed', update)
}
}, [])
return value
}

View File

@ -0,0 +1,4 @@
export * as action from './action'
export * from './state'
export * from './hook'

View File

@ -0,0 +1,22 @@
interface InitState {
list: LX.UserApi.UserApiInfo[]
status: {
status: boolean
message?: string
}
apis: Partial<LX.UserApi.UserApiSources>
}
const state: InitState = {
list: [],
status: {
status: false,
message: 'initing',
},
apis: {},
}
export {
state,
}

2
src/types/app.d.ts vendored
View File

@ -31,9 +31,11 @@ interface GlobalData {
isScreenKeepAwake: boolean isScreenKeepAwake: boolean
isPlayedStop: boolean isPlayedStop: boolean
isEnableSyncLog: boolean isEnableSyncLog: boolean
isEnableUserApiLog: boolean
playerTrackId: string playerTrackId: string
qualityList: LX.QualityList qualityList: LX.QualityList
apis: Partial<LX.UserApi.UserApiSources>
jumpMyListPosition: boolean jumpMyListPosition: boolean

60
src/types/user_api.d.ts vendored Normal file
View File

@ -0,0 +1,60 @@
declare namespace LX {
namespace UserApi {
type UserApiSourceInfoType = 'music'
type UserApiSourceInfoActions = 'musicUrl'
interface UserApiSourceInfo {
name: string
type: UserApiSourceInfoType
actions: UserApiSourceInfoActions[]
qualitys: LX.Quality[]
}
type UserApiSources = Record<LX.Source, UserApiSourceInfo>
interface UserApiInfo {
id: string
name: string
description: string
// script: string
allowShowUpdateAlert: boolean
sources?: UserApiSources
}
interface UserApiStatus {
status: boolean
message?: string
apiInfo?: UserApiInfo
}
interface UserApiUpdateInfo {
name: string
description: string
log: string
updateUrl?: string
}
interface UserApiRequestParams {
requestKey: string
data: any
}
interface UserApiRequestParams {
requestKey: string
data: any
}
type UserApiRequestCancelParams = string
type UserApiSetApiParams = string
interface UserApiSetAllowUpdateAlertParams {
id: string
enable: boolean
}
interface ImportUserApi {
apiInfo: UserApiInfo
apiList: UserApiInfo[]
}
}
}

View File

@ -26,6 +26,7 @@ const syncHostPrefix = storageDataPrefix.syncHost
const syncHostHistoryPrefix = storageDataPrefix.syncHostHistory const syncHostHistoryPrefix = storageDataPrefix.syncHostHistory
const listPrefix = storageDataPrefix.list const listPrefix = storageDataPrefix.list
const dislikeListPrefix = storageDataPrefix.dislikeList const dislikeListPrefix = storageDataPrefix.dislikeList
const userApiPrefix = storageDataPrefix.userApi
// const defaultListKey = listPrefix + 'default' // const defaultListKey = listPrefix + 'default'
// const loveListKey = listPrefix + 'love' // const loveListKey = listPrefix + 'love'
@ -460,3 +461,54 @@ export const removeSyncHostHistory = async(index: number) => {
await saveData(syncHostHistoryPrefix, syncHostHistory) await saveData(syncHostHistoryPrefix, syncHostHistory)
} }
let userApis: LX.UserApi.UserApiInfo[] = []
export const getUserApiList = async(): Promise<LX.UserApi.UserApiInfo[]> => {
userApis = await getData<LX.UserApi.UserApiInfo[]>(userApiPrefix) ?? []
return [...userApis]
}
export const getUserApiScript = async(id: string): Promise<string> => {
const script = await getData<string>(`${userApiPrefix}${id}`) ?? ''
return script
}
export const addUserApi = async(script: string): Promise<LX.UserApi.UserApiInfo> => {
let scriptInfo = script.split(/\r?\n/)
let name = scriptInfo[1] || ''
let description = scriptInfo[2] || ''
name = name.startsWith(' * @name ') ? name.replace(' * @name ', '').trim() : `user_api_${new Date().toLocaleString()}`
if (name.length > 24) name = name.substring(0, 24) + '...'
description = description.startsWith(' * @description ') ? description.replace(' * @description ', '').trim() : ''
if (description.length > 36) description = description.substring(0, 36) + '...'
const apiInfo = {
id: `user_api_${Math.random().toString().substring(2, 5)}_${Date.now()}`,
name,
description,
// script,
allowShowUpdateAlert: true,
}
userApis.push(apiInfo)
await saveDataMultiple([
[userApiPrefix, userApis],
[`${userApiPrefix}${apiInfo.id}`, script],
])
return apiInfo
}
export const removeUserApi = async(ids: string[]) => {
if (!userApis) return []
const _ids: string[] = []
for (let index = userApis.length - 1; index > -1; index--) {
if (ids.includes(userApis[index].id)) {
_ids.push(`${userApiPrefix}${userApis[index].id}`)
userApis.splice(index, 1)
ids.splice(index, 1)
}
}
await saveData(userApiPrefix, userApis)
if (_ids.length) await removeDataMultiple(_ids)
return [...userApis]
}
export const setUserApiAllowShowUpdateAlert = async(id: string, enable: boolean) => {
const targetApi = userApis?.find(api => api.id == id)
if (!targetApi) return
targetApi.allowShowUpdateAlert = enable
await saveData(userApiPrefix, userApis)
}

View File

@ -47,6 +47,7 @@ for (const api of apiSourceInfo) {
const getAPI = source => apiList[`${settingState.setting['common.apiSource']}_api_${source}`] const getAPI = source => apiList[`${settingState.setting['common.apiSource']}_api_${source}`]
const apis = source => { const apis = source => {
if (/^user_api/.test(settingState.setting['common.apiSource'])) return global.lx.apis[source]
const api = getAPI(source) const api = getAPI(source)
if (api) return api if (api) return api
throw new Error('Api is not found') throw new Error('Api is not found')

View File

@ -0,0 +1,89 @@
import { NativeEventEmitter, NativeModules } from 'react-native'
const { UserApiModule } = NativeModules
export const loadScript = (info: LX.UserApi.UserApiInfo & { script: string }) => {
UserApiModule.loadScript({
id: info.id,
name: info.name,
description: info.description,
script: info.script,
})
}
export interface SendResponseParams {
requestKey: string
error: string | null
response: {
statusCode: number
statusMessage: string
headers: Record<string, string>
body: any
} | null
}
export interface SendActions {
request: LX.UserApi.UserApiRequestParams
response: SendResponseParams
}
export const sendAction = <T extends keyof SendActions>(action: T, data: SendActions[T]) => {
UserApiModule.sendAction(action, JSON.stringify(data))
}
// export const clearAppCache = CacheModule.clearAppCache as () => Promise<void>
export interface InitParams {
id: string
status: boolean
errorMessage: string
info: LX.UserApi.UserApiInfo
}
export interface ResponseParams {
status: boolean
errorMessage?: string
requestKey: string
result: any
}
export interface UpdateInfoParams {
name: string
log: string
updateUrl: string
}
export interface RequestParams {
requestKey: string
url: string
options: {
method: string
data: any
timeout: number
headers: any
binary: boolean
}
}
export type CancelRequestParams = string
export interface Actions {
init: InitParams
request: RequestParams
cancelRequest: CancelRequestParams
response: ResponseParams
showUpdateAlert: UpdateInfoParams
log: string
}
export type ActionsEvent = { [K in keyof Actions]: { action: K, data: Actions[K] } }[keyof Actions]
export const onScriptAction = (callback: (event: ActionsEvent) => void): () => void => {
const eventEmitter = new NativeEventEmitter(UserApiModule)
const eventListener = eventEmitter.addListener('api-action', event => {
if (event.data) event.data = JSON.parse(event.data)
callback(event)
})
return () => {
eventListener.remove()
}
}
export const destroy = () => {
UserApiModule.destroy()
}