mirror of
https://github.com/ikun0014/lx-music-mobile.git
synced 2025-07-04 09:52:10 +08:00
564 lines
16 KiB
TypeScript
564 lines
16 KiB
TypeScript
import { Platform, ToastAndroid, BackHandler, Linking, Dimensions, Alert, Appearance, PermissionsAndroid, AppState, StyleSheet, type ScaledSize } from 'react-native'
|
||
// import ExtraDimensions from 'react-native-extra-dimensions-android'
|
||
import Clipboard from '@react-native-clipboard/clipboard'
|
||
import { storageDataPrefix } from '@/config/constant'
|
||
import { gzipFile, readFile, temporaryDirectoryPath, unGzipFile, unlink, writeFile } from '@/utils/fs'
|
||
import { getSystemLocales, isIgnoringBatteryOptimization, isNotificationsEnabled, requestNotificationPermission, requestIgnoreBatteryOptimization, shareText } from '@/utils/nativeModules/utils'
|
||
import musicSdk from '@/utils/musicSdk'
|
||
import { getData, removeData, saveData } from '@/plugins/storage'
|
||
import BackgroundTimer from 'react-native-background-timer'
|
||
import { scaleSizeH, scaleSizeW, setSpText } from './pixelRatio'
|
||
import { toOldMusicInfo } from './index'
|
||
import { stringMd5 } from 'react-native-quick-md5'
|
||
import { windowSizeTools } from '@/utils/windowSizeTools'
|
||
import dataInit from '@/core/init/dataInit'
|
||
|
||
|
||
// https://stackoverflow.com/a/47349998
|
||
export const getDeviceLanguage = async() => {
|
||
// let deviceLanguage = Platform.OS === 'ios'
|
||
// ? NativeModules.SettingsManager.settings.AppleLocale ||
|
||
// NativeModules.SettingsManager.settings.AppleLanguages[0] // iOS 13
|
||
// : await getSystemLocales()
|
||
// deviceLanguage = typeof deviceLanguage === 'string' ? deviceLanguage.substring(0, 5).toLocaleLowerCase() : ''
|
||
return getSystemLocales()
|
||
}
|
||
|
||
|
||
export const isAndroid = Platform.OS === 'android'
|
||
// @ts-expect-error
|
||
export const osVer = Platform.constants.Release as string
|
||
|
||
export const isActive = () => AppState.currentState == 'active'
|
||
|
||
export const TEMP_FILE_PATH = temporaryDirectoryPath + '/tempFile'
|
||
|
||
// fix https://github.com/facebook/react-native/issues/4934
|
||
// export const getWindowSise = (windowDimensions?: ReturnType<(typeof Dimensions)['get']>) => {
|
||
// return windowSizeTools.getSize()
|
||
// // windowDimensions ??= Dimensions.get('window')
|
||
// // if (Platform.OS === 'ios') return windowDimensions
|
||
// // return windowDimensions
|
||
// // const windowSize = {
|
||
// // width: ExtraDimensions.getRealWindowWidth(),
|
||
// // height: ExtraDimensions.getRealWindowHeight(),
|
||
// // }
|
||
// // if (
|
||
// // (windowDimensions.height > windowDimensions.width && windowSize.height < windowSize.width) ||
|
||
// // (windowDimensions.width > windowDimensions.height && windowSize.width < windowSize.height)
|
||
// // ) {
|
||
// // windowSize.height = windowSize.width
|
||
// // }
|
||
// // windowSize.width = windowDimensions.width
|
||
|
||
// // if (ExtraDimensions.isSoftMenuBarEnabled()) {
|
||
// // windowSize.height -= ExtraDimensions.getSoftMenuBarHeight()
|
||
// // }
|
||
// // return windowSize
|
||
// }
|
||
|
||
export const checkStoragePermissions = async() => PermissionsAndroid.check(PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE)
|
||
|
||
export const requestStoragePermission = async() => {
|
||
const isGranted = await checkStoragePermissions()
|
||
if (isGranted) return isGranted
|
||
|
||
try {
|
||
const granted = await PermissionsAndroid.requestMultiple(
|
||
[
|
||
PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE,
|
||
PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE,
|
||
],
|
||
// {
|
||
// title: '存储读写权限申请',
|
||
// message:
|
||
// '洛雪音乐助手需要使用存储读写权限才能下载歌曲.',
|
||
// buttonNeutral: '一会再问我',
|
||
// buttonNegative: '取消',
|
||
// buttonPositive: '确定',
|
||
// },
|
||
)
|
||
// console.log(granted)
|
||
// console.log(Object.values(granted).every(r => r === PermissionsAndroid.RESULTS.GRANTED))
|
||
// console.log(PermissionsAndroid.RESULTS)
|
||
const granteds = Object.values(granted)
|
||
return granteds.every(r => r === PermissionsAndroid.RESULTS.GRANTED)
|
||
? true
|
||
: granteds.includes(PermissionsAndroid.RESULTS.NEVER_ASK_AGAIN)
|
||
? null
|
||
: false
|
||
// if (granted === PermissionsAndroid.RESULTS.GRANTED) {
|
||
// console.log('You can use the storage')
|
||
// } else {
|
||
// console.log('Storage permission denied')
|
||
// }
|
||
} catch (err: any) {
|
||
// console.warn(err)
|
||
return false
|
||
}
|
||
}
|
||
|
||
|
||
/**
|
||
* 显示toast
|
||
* @param message 消息
|
||
* @param duration 时长
|
||
* @param position 位置
|
||
*/
|
||
export const toast = (message: string, duration: 'long' | 'short' = 'short', position: 'top' | 'center' | 'bottom' = 'bottom') => {
|
||
let _duration
|
||
switch (duration) {
|
||
case 'long':
|
||
_duration = ToastAndroid.LONG
|
||
break
|
||
case 'short':
|
||
default:
|
||
_duration = ToastAndroid.SHORT
|
||
break
|
||
}
|
||
let _position
|
||
switch (position) {
|
||
case 'top':
|
||
_position = ToastAndroid.TOP
|
||
break
|
||
case 'center':
|
||
_position = ToastAndroid.CENTER
|
||
break
|
||
case 'bottom':
|
||
default:
|
||
_position = ToastAndroid.BOTTOM
|
||
break
|
||
}
|
||
ToastAndroid.showWithGravity(message, _duration, _position)
|
||
}
|
||
|
||
export const openUrl = async(url: string): Promise<void> => Linking.canOpenURL(url).then(async() => Linking.openURL(url))
|
||
|
||
export const assertApiSupport = (source: LX.Source): boolean => {
|
||
return source == 'local' || global.lx.qualityList[source] != null
|
||
}
|
||
|
||
// const handleRemoveDataMultiple = async keys => {
|
||
// await removeDataMultiple(keys.splice(0, 500))
|
||
// if (keys.length) return handleRemoveDataMultiple(keys)
|
||
// }
|
||
|
||
export const exitApp = () => {
|
||
BackHandler.exitApp()
|
||
}
|
||
|
||
export const handleSaveFile = async(path: string, data: any) => {
|
||
// if (!path.endsWith('.json')) path += '.json'
|
||
// const buffer = gzip(data)
|
||
const tempFilePath = `${temporaryDirectoryPath}/tempFile.json`
|
||
await writeFile(tempFilePath, JSON.stringify(data))
|
||
await gzipFile(tempFilePath, path)
|
||
await unlink(tempFilePath)
|
||
}
|
||
export const handleReadFile = async<T = unknown>(path: string): Promise<T> => {
|
||
let isJSON = path.endsWith('.json')
|
||
let data
|
||
if (isJSON) {
|
||
data = await readFile(path)
|
||
} else {
|
||
const tempFilePath = `${temporaryDirectoryPath}/tempFile.json`
|
||
await unGzipFile(path, tempFilePath)
|
||
data = await readFile(tempFilePath)
|
||
await unlink(tempFilePath)
|
||
}
|
||
data = JSON.parse(data)
|
||
|
||
// 修复PC v1.14.0出现的导出数据被序列化两次的问题
|
||
if (typeof data != 'object') {
|
||
try {
|
||
data = JSON.parse(data as string)
|
||
} catch (err) {
|
||
return data
|
||
}
|
||
}
|
||
|
||
return data
|
||
}
|
||
|
||
export const confirmDialog = async({
|
||
title = '',
|
||
message = '',
|
||
cancelButtonText = global.i18n.t('dialog_cancel'),
|
||
confirmButtonText = global.i18n.t('dialog_confirm'),
|
||
bgClose = true,
|
||
}) => {
|
||
return new Promise<boolean>(resolve => {
|
||
Alert.alert(title, message, [
|
||
{
|
||
text: cancelButtonText,
|
||
onPress() {
|
||
resolve(false)
|
||
},
|
||
},
|
||
{
|
||
text: confirmButtonText,
|
||
onPress() {
|
||
resolve(true)
|
||
},
|
||
},
|
||
], {
|
||
cancelable: bgClose,
|
||
onDismiss() {
|
||
resolve(false)
|
||
},
|
||
})
|
||
})
|
||
}
|
||
|
||
export const tipDialog = async({
|
||
title = '',
|
||
message = '',
|
||
btnText = global.i18n.t('dialog_confirm'),
|
||
bgClose = true,
|
||
}) => {
|
||
return new Promise<void>(resolve => {
|
||
Alert.alert(title, message, [
|
||
{
|
||
text: btnText,
|
||
onPress() {
|
||
resolve()
|
||
},
|
||
},
|
||
], {
|
||
cancelable: bgClose,
|
||
onDismiss() {
|
||
resolve()
|
||
},
|
||
})
|
||
})
|
||
}
|
||
|
||
export const clipboardWriteText = (str: string) => {
|
||
Clipboard.setString(str)
|
||
}
|
||
|
||
|
||
export const checkNotificationPermission = async() => {
|
||
const isHide = await getData(storageDataPrefix.notificationTipEnable)
|
||
if (isHide != null) return
|
||
const enabled = await isNotificationsEnabled()
|
||
if (enabled) return
|
||
return new Promise<void>((resolve) => {
|
||
Alert.alert(
|
||
global.i18n.t('notifications_check_title'),
|
||
global.i18n.t('notifications_check_tip'),
|
||
[
|
||
{
|
||
text: global.i18n.t('never_show'),
|
||
onPress: () => {
|
||
void saveData(storageDataPrefix.notificationTipEnable, '1')
|
||
toast(global.i18n.t('disagree_tip'))
|
||
resolve()
|
||
},
|
||
},
|
||
{
|
||
text: global.i18n.t('disagree'),
|
||
onPress: () => {
|
||
toast(global.i18n.t('disagree_tip'))
|
||
resolve()
|
||
},
|
||
},
|
||
{
|
||
text: global.i18n.t('agree_go'),
|
||
onPress: () => {
|
||
requestAnimationFrame(() => {
|
||
void requestNotificationPermission().then((result) => {
|
||
if (!result) toast(global.i18n.t('disagree_tip'))
|
||
resolve()
|
||
})
|
||
})
|
||
},
|
||
},
|
||
],
|
||
)
|
||
})
|
||
}
|
||
|
||
|
||
export const checkIgnoringBatteryOptimization = async() => {
|
||
const isHide = await getData(storageDataPrefix.ignoringBatteryOptimizationTipEnable)
|
||
if (isHide != null) return
|
||
const enabled = await isIgnoringBatteryOptimization()
|
||
if (enabled) return
|
||
return new Promise<void>((resolve) => {
|
||
Alert.alert(
|
||
global.i18n.t('ignoring_battery_optimization_check_title'),
|
||
global.i18n.t('ignoring_battery_optimization_check_tip'),
|
||
[
|
||
{
|
||
text: global.i18n.t('never_show'),
|
||
onPress: () => {
|
||
void saveData(storageDataPrefix.ignoringBatteryOptimizationTipEnable, '1')
|
||
toast(global.i18n.t('disagree_tip'))
|
||
resolve()
|
||
},
|
||
},
|
||
{
|
||
text: global.i18n.t('disagree'),
|
||
onPress: () => {
|
||
toast(global.i18n.t('disagree_tip'))
|
||
resolve()
|
||
},
|
||
},
|
||
{
|
||
text: global.i18n.t('agree_to'),
|
||
onPress: () => {
|
||
requestAnimationFrame(() => {
|
||
void requestIgnoreBatteryOptimization().then((result) => {
|
||
if (!result) toast(global.i18n.t('disagree_tip'))
|
||
resolve()
|
||
})
|
||
})
|
||
},
|
||
},
|
||
],
|
||
)
|
||
})
|
||
}
|
||
export const resetNotificationPermissionCheck = async() => {
|
||
return removeData(storageDataPrefix.notificationTipEnable)
|
||
}
|
||
export const resetIgnoringBatteryOptimizationCheck = async() => {
|
||
return removeData(storageDataPrefix.ignoringBatteryOptimizationTipEnable)
|
||
}
|
||
|
||
export const shareMusic = (shareType: LX.ShareType, downloadFileName: LX.AppSetting['download.fileName'], musicInfo: LX.Music.MusicInfo) => {
|
||
const name = musicInfo.name
|
||
const singer = musicInfo.singer
|
||
const detailUrl = musicInfo.source == 'local' ? '' : musicSdk[musicInfo.source]?.getMusicDetailPageUrl(toOldMusicInfo(musicInfo)) ?? ''
|
||
const musicTitle = downloadFileName.replace('歌名', name).replace('歌手', singer)
|
||
switch (shareType) {
|
||
case 'system':
|
||
void shareText(global.i18n.t('share_card_title_music', { name }), global.i18n.t('share_title_music'), `${musicTitle.replace(/\s/g, '')}${detailUrl ? '\n' + detailUrl : ''}`)
|
||
break
|
||
case 'clipboard':
|
||
clipboardWriteText(`${musicTitle}${detailUrl ? '\n' + detailUrl : ''}`)
|
||
toast(global.i18n.t('copy_name_tip'))
|
||
break
|
||
}
|
||
}
|
||
|
||
export const onDimensionChange = (handler: (info: { window: ScaledSize, screen: ScaledSize }) => void) => {
|
||
return Dimensions.addEventListener('change', handler)
|
||
}
|
||
|
||
|
||
export const getAppearance = () => {
|
||
return Appearance.getColorScheme() ?? 'light'
|
||
}
|
||
|
||
export const onAppearanceChange = (callback: (colorScheme: Parameters<Parameters<typeof Appearance['addChangeListener']>[0]>[0]['colorScheme']) => void) => {
|
||
return Appearance.addChangeListener(({ colorScheme }) => {
|
||
callback(colorScheme)
|
||
})
|
||
}
|
||
|
||
let isSupportedAutoTheme: boolean | null = null
|
||
export const getIsSupportedAutoTheme = () => {
|
||
if (isSupportedAutoTheme == null) {
|
||
const osVerNum = parseInt(osVer)
|
||
isSupportedAutoTheme = isAndroid
|
||
? osVerNum >= 5
|
||
: osVerNum >= 13
|
||
}
|
||
return isSupportedAutoTheme
|
||
}
|
||
|
||
|
||
export const showImportTip = (type: string) => {
|
||
let message
|
||
switch (type) {
|
||
case 'defautlList':
|
||
case 'playList':
|
||
case 'playList_v2':
|
||
message = global.i18n.t('list_import_tip__playlist')
|
||
break
|
||
case 'setting':
|
||
case 'setting_v2':
|
||
message = global.i18n.t('list_import_tip__setting')
|
||
break
|
||
case 'allData':
|
||
case 'allData_v2':
|
||
message = global.i18n.t('list_import_tip__alldata')
|
||
break
|
||
case 'playListPart':
|
||
case 'playListPart_v2':
|
||
message = global.i18n.t('list_import_tip__playlist_part')
|
||
break
|
||
|
||
default:
|
||
message = global.i18n.t('list_import_tip__unknown')
|
||
break
|
||
}
|
||
void tipDialog({
|
||
title: global.i18n.t('list_import_tip__failed'),
|
||
message,
|
||
btnText: global.i18n.t('ok'),
|
||
})
|
||
}
|
||
|
||
|
||
/**
|
||
* 生成节流函数
|
||
* @param fn 回调
|
||
* @param delay 延迟
|
||
* @returns
|
||
*/
|
||
export function throttleBackgroundTimer<Args extends any[]>(fn: (...args: Args) => void | Promise<void>, delay = 100) {
|
||
let timer: number | null = null
|
||
let _args: Args
|
||
return (...args: Args) => {
|
||
_args = args
|
||
if (timer) return
|
||
timer = BackgroundTimer.setTimeout(() => {
|
||
timer = null
|
||
void fn(..._args)
|
||
}, delay)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 生成防抖函数
|
||
* @param fn 回调
|
||
* @param delay 延迟
|
||
* @returns
|
||
*/
|
||
export function debounceBackgroundTimer<Args extends any[]>(fn: (...args: Args) => void | Promise<void>, delay = 100) {
|
||
let timer: number | null = null
|
||
let _args: Args
|
||
return (...args: Args) => {
|
||
_args = args
|
||
if (timer) BackgroundTimer.clearTimeout(timer)
|
||
timer = BackgroundTimer.setTimeout(() => {
|
||
timer = null
|
||
void fn(..._args)
|
||
}, delay)
|
||
}
|
||
}
|
||
|
||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||
type Styles = StyleSheet.NamedStyles<Record<string, {}>>
|
||
type Style = Styles[keyof Styles]
|
||
const trasformeProps: Array<keyof Style> = [
|
||
// @ts-expect-error
|
||
'fontSize',
|
||
// @ts-expect-error
|
||
'lineHeight',
|
||
// 'margin',
|
||
// 'marginLeft',
|
||
// 'marginRight',
|
||
// 'marginTop',
|
||
// 'marginBottom',
|
||
// 'padding',
|
||
// 'paddingLeft',
|
||
// 'paddingRight',
|
||
// 'paddingTop',
|
||
// 'paddingBottom',
|
||
'left',
|
||
'right',
|
||
'top',
|
||
'bottom',
|
||
]
|
||
export const trasformeStyle = <T extends Style>(styles: T): T => {
|
||
const newStyle: T = { ...styles }
|
||
|
||
for (const [p, v] of Object.entries(newStyle) as Array<[keyof Style, Style[keyof Style]]>) {
|
||
if (typeof v != 'number') continue
|
||
switch (p) {
|
||
case 'height':
|
||
case 'minHeight':
|
||
case 'marginTop':
|
||
case 'marginBottom':
|
||
case 'paddingTop':
|
||
case 'paddingBottom':
|
||
case 'paddingVertical':
|
||
newStyle[p] = scaleSizeH(v)
|
||
break
|
||
case 'width':
|
||
case 'minWidth':
|
||
case 'marginLeft':
|
||
case 'marginRight':
|
||
case 'paddingLeft':
|
||
case 'paddingRight':
|
||
case 'paddingHorizontal':
|
||
case 'gap':
|
||
newStyle[p] = scaleSizeW(v)
|
||
break
|
||
case 'padding':
|
||
newStyle.paddingRight = newStyle.paddingLeft = scaleSizeW(v)
|
||
newStyle.paddingBottom = newStyle.paddingTop = scaleSizeH(v)
|
||
break
|
||
case 'margin':
|
||
newStyle.marginRight = newStyle.marginLeft = scaleSizeW(v)
|
||
newStyle.marginBottom = newStyle.marginTop = scaleSizeH(v)
|
||
break
|
||
default:
|
||
// @ts-expect-error
|
||
if (trasformeProps.includes(p)) newStyle[p] = setSpText(v)
|
||
break
|
||
}
|
||
}
|
||
return newStyle
|
||
}
|
||
|
||
export const createStyle = <T extends StyleSheet.NamedStyles<T>>(styles: T | StyleSheet.NamedStyles<T>): T => {
|
||
const newStyle: Record<string, Style> = { ...styles }
|
||
for (const [n, s] of Object.entries(newStyle)) {
|
||
newStyle[n] = trasformeStyle(s)
|
||
}
|
||
// @ts-expect-error
|
||
return StyleSheet.create(newStyle as StyleSheet.NamedStyles<T>)
|
||
}
|
||
|
||
export const isHorizontalMode = (width: number, height: number): boolean => {
|
||
return width / height > 1.2
|
||
}
|
||
|
||
|
||
export interface RowInfo {
|
||
rowNum: number | undefined
|
||
rowWidth: `${number}%`
|
||
}
|
||
|
||
export type RowInfoType = 'full' | 'medium'
|
||
|
||
export const getRowInfo = (type: RowInfoType = 'full'): RowInfo => {
|
||
const win = windowSizeTools.getSize()
|
||
let isMultiRow = isHorizontalMode(win.width, win.height)
|
||
if (type == 'medium' && win.width / win.height < 1.8) isMultiRow = false
|
||
// console.log('getRowInfo')
|
||
return {
|
||
rowNum: isMultiRow ? 2 : undefined,
|
||
rowWidth: isMultiRow ? '50%' : '100%',
|
||
}
|
||
}
|
||
|
||
export const toMD5 = stringMd5
|
||
|
||
|
||
export const cheatTip = async() => {
|
||
const isRead = await getData<boolean>(storageDataPrefix.cheatTip)
|
||
if (isRead) return
|
||
|
||
return tipDialog({
|
||
title: '提示',
|
||
message: `本项目是对LX Music的二次开发,请勿将本项目用于商业用途,否则后果自负。如有疑问,请加入QQ群:690309707。`,
|
||
btnText: '我知道了 (Close)',
|
||
bgClose: false,
|
||
}).then(() => {
|
||
void saveData(storageDataPrefix.cheatTip, true)
|
||
})
|
||
}
|
||
|
||
export const hitokoto = () => {
|
||
fetch('https://v1.hitokoto.cn')
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
return toast(data.hitokoto)
|
||
})
|
||
} |