expo-duooomi-app/ble/hooks/useBleExplorer.ts

997 lines
35 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import * as Sentry from '@sentry/react-native'
import { Directory, File, Paths } from 'expo-file-system'
import * as ImageManipulator from 'expo-image-manipulator'
import * as ImagePicker from 'expo-image-picker'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Alert, Linking, PermissionsAndroid, Platform } from 'react-native'
import { ScanMode } from 'react-native-ble-plx'
import { aniStorage } from '@/utils/aniStorage'
import { extractCdnKey } from '@/utils/getCDNKey'
import { DeviceInfoService } from '../services/DeviceInfoService'
import { FileTransferService } from '../services/FileTransferService'
import { BleClient } from './../core/BleClient'
import { type BleDevice, type BleError, ConnectionState } from './../core/types'
import { BLE_UUIDS, COMMAND_TYPES, EVENT_TYPES } from './../protocol/Constants'
import {
type BindingResponse,
type DeleteFileResponse,
type DeviceInfo,
type PrepareTransferResponse,
type UnBindResponse,
} from './../protocol/types'
import { BleProtocolService } from './../services/BleProtocolService'
interface BleState {
isScanning: boolean
isConnected: boolean
connectedDevice: BleDevice | null
deviceInfo: DeviceInfo | null
version: string
isActivated: boolean
transferProgress: number
discoveredDevices: BleDevice[]
contents: string[]
loading: {
connecting: boolean
querying: boolean
converting: boolean
transferring: boolean
}
error: string | null
}
export const useBleExplorer = () => {
const bleClient = BleClient.getInstance()
const deviceInfoService = DeviceInfoService.getInstance()
const fileTransferService = FileTransferService.getInstance()
const protocolService = BleProtocolService.getInstance()
const [bleSeesionId, setBleSessionId] = useState(`ble-session-${Date.now().toString()}`)
// 用于暂存扫描到的设备,避免频繁更新状态
const pendingDevicesRef = useRef<BleDevice[]>([])
const flushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pendingPrepareTransfersRef = useRef<
Map<
string,
{
resolve: (value: PrepareTransferResponse) => void
reject: (reason?: any) => void
timeoutId: ReturnType<typeof setTimeout>
}
>
>(new Map())
const [state, setState2] = useState<BleState>({
isScanning: false,
isConnected: false,
connectedDevice: null,
deviceInfo: null,
version: '',
isActivated: false,
transferProgress: 0,
discoveredDevices: [],
contents: [],
loading: {
connecting: false,
querying: false,
converting: false,
transferring: false,
},
error: null,
})
const setState = useCallback((updater: (prev: BleState) => BleState) => {
try {
setState2(updater)
} catch (error) {
console.error('State update failed:', error)
}
}, [])
// 仅在关键操作时记录 Sentry 日志
const logToSentry = useCallback(
(operation: string) => {
try {
Sentry.captureMessage(`ble_${operation}`, {
level: 'info',
tags: {
sessionId: bleSeesionId,
component: 'useBleExplorer',
operation,
has_device: !!state.connectedDevice,
is_scanning: state.isScanning,
},
contexts: {
ble_state: {
connection: {
connected: state.isConnected,
device_id: state.connectedDevice?.id,
device_name: state.connectedDevice?.name,
},
device: {
activated: state.isActivated,
version: state.version,
free_space: state.deviceInfo?.freespace,
brand: state.deviceInfo?.brand,
},
transfer: {
progress: state.transferProgress,
converting: state.loading.converting,
transferring: state.loading.transferring,
},
discovery: {
scanning: state.isScanning,
devices_found: state.discoveredDevices.length,
},
},
},
extra: {
error_message: state.error,
loading_states: state.loading,
},
})
} catch (error) {
console.error('Sentry log failed:', error)
}
},
[
bleSeesionId,
state.connectedDevice,
state.isConnected,
state.isActivated,
state.version,
state.deviceInfo,
state.transferProgress,
state.loading,
state.isScanning,
state.discoveredDevices.length,
state.error,
],
)
// 批量刷新设备列表到 state防抖每 500ms 最多更新一次)
const flushPendingDevices = useCallback(() => {
if (pendingDevicesRef.current.length === 0) return
const devicesToAdd = [...pendingDevicesRef.current]
pendingDevicesRef.current = []
logToSentry('device_discovered_batch')
setState((prev) => {
const newDevices = devicesToAdd.filter((nd) => !prev.discoveredDevices?.some((ed) => ed?.id === nd?.id))
if (newDevices.length === 0) return prev
console.debug(`Batch adding ${newDevices.length} devices`)
return { ...prev, discoveredDevices: [...prev.discoveredDevices, ...newDevices] }
})
}, [setState])
// 添加设备到待处理队列(防抖更新)
const queueDevice = useCallback(
(device: BleDevice) => {
// 检查是否已在队列中
if (pendingDevicesRef.current?.some((d) => d?.id === device?.id)) return
pendingDevicesRef.current.push(device)
// 清除之前的定时器,设置新的
if (flushTimerRef.current) {
clearTimeout(flushTimerRef.current)
}
flushTimerRef.current = setTimeout(flushPendingDevices, 500)
},
[flushPendingDevices],
)
// 清理定时器
useEffect(() => {
return () => {
if (flushTimerRef.current) {
clearTimeout(flushTimerRef.current)
}
}
}, [])
const setError = useCallback((error: string | null) => {
setState((prev) => ({ ...prev, error }))
if (error) {
console.error(`ERROR: ${error}`)
}
}, [])
const requestBluetoothPermissions = useCallback(async (): Promise<boolean> => {
if (Platform.OS !== 'android') return true
try {
const sdk = Number(Platform.Version) || 0
const perms: string[] = []
if (sdk >= 31) {
perms.push(PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN)
perms.push(PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT)
} else {
perms.push(PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION)
}
// 先预检,有些 ROM如 MIUI会在 check 阶段就返回 false
const preChecks = await Promise.all(perms.map((p) => PermissionsAndroid.check(p as any)))
const needRequest = perms.filter((_, i) => !preChecks[i])
if (needRequest.length === 0) return true
const results = await PermissionsAndroid.requestMultiple(needRequest as any)
console.log('results-----------', results)
const allGranted = Object.values(results).every((r) => r === PermissionsAndroid.RESULTS.GRANTED)
if (allGranted) return true
const neverAsk = Object.values(results).some((r) => r === PermissionsAndroid.RESULTS.NEVER_ASK_AGAIN)
if (neverAsk) {
Alert.alert(
'需要权限',
'检测到蓝牙权限已被禁止且选择了不再询问。请在系统设置中为应用手动开启蓝牙和位置权限。',
[
{ text: '取消', style: 'cancel' },
{
text: '打开设置',
onPress: () =>
Linking.openSettings().catch(() => {
setError('无法打开系统设置,请手动前往应用权限设置')
}),
},
],
{ cancelable: true },
)
}
const denied = Object.entries(results)
.filter(([_, r]) => r !== PermissionsAndroid.RESULTS.GRANTED)
.map(([p]) => p.split('.').pop())
setError(`Bluetooth permissions required: ${denied.join(', ')}`)
return false
} catch (err) {
setError(`Permission request failed: ${err}`)
return false
}
}, [setError])
useEffect(() => {
if (Platform.OS === 'web') {
console.log('BLE not supported on web platform')
return
}
const onConnectionStateChange = ({ deviceId, state: connState }: { deviceId: string; state: ConnectionState }) => {
const isConnected = connState === ConnectionState.CONNECTED
setState((prev) => {
const updatedDevices = prev.discoveredDevices.map((d) => {
if (d.id === deviceId) {
const newDevice = Object.assign(Object.create(Object.getPrototypeOf(d)), d) as BleDevice
newDevice.connected = isConnected
return newDevice
}
return d
})
return {
...prev,
isConnected,
connectedDevice: connState === ConnectionState.DISCONNECTED ? null : prev.connectedDevice,
discoveredDevices: updatedDevices,
}
})
console.log(`Connection state ${deviceId}: ${connState}`)
}
const onScanError = (error: BleError) => {
setError(`Scan error: ${error.message}`)
setState((prev) => ({ ...prev, isScanning: false }))
}
bleClient.addListener('connectionStateChange', onConnectionStateChange)
bleClient.addListener('scanError', onScanError)
const onDeviceInfo = (info: DeviceInfo) => {
setState((prev) => ({ ...prev, deviceInfo: info }))
}
const onVersionInfo = (version: string) => {
setState((prev) => ({ ...prev, version: version }))
}
const onBindStatus = (status: BindingResponse) => {
const activated = status.success === 1
const contents = status.contents || []
setState((prev) => ({ ...prev, isActivated: activated, contents }))
}
const onUnBindStatus = (status: UnBindResponse) => {
const activated = status.success === 1
setState((prev) => ({ ...prev, isActivated: activated }))
}
const onDeleteFile = (response: DeleteFileResponse) => {
// 删除成功后,从 contents 中移除该文件
if (response.success === 0) {
// 暂时不更新 contents等待下次绑定时获取最新列表
console.log('File deleted successfully')
} else {
const errorMsg = response.success === 1 ? '删除失败' : response.success === 2 ? '文件不存在' : '未知错误'
console.error('Delete file failed:', errorMsg)
}
}
const onPrepareTransfer = (response: PrepareTransferResponse) => {
console.log('Prepare transfer response:', response)
// 检查是否有对应的待处理 Promise
const pending = pendingPrepareTransfersRef.current.get(response.key)
if (pending) {
clearTimeout(pending.timeoutId)
pendingPrepareTransfersRef.current.delete(response.key)
pending.resolve(response)
return // 早期返回,避免继续处理
}
// 如果没有待处理 Promise才做其他处理
if (response.status === 'ready') {
console.log('Device is ready to receive file:', response.key)
} else if (response.status === 'no_space') {
console.warn('Device has no space for file:', response.key)
} else if (response.status === 'duplicated') {
console.warn('File already exists on device:', response.key)
}
}
deviceInfoService.addListener(EVENT_TYPES.DEVICE_INFO.name, onDeviceInfo)
deviceInfoService.addListener(EVENT_TYPES.VERSION_INFO.name, onVersionInfo)
deviceInfoService.addListener(EVENT_TYPES.BIND_DEVICE.name, onBindStatus)
deviceInfoService.addListener(EVENT_TYPES.UNBIND_DEVICE.name, onUnBindStatus)
deviceInfoService.addListener(EVENT_TYPES.DELETE_FILE.name, onDeleteFile)
deviceInfoService.addListener(EVENT_TYPES.PREPARE_TRANSFER.name, onPrepareTransfer)
return () => {
bleClient.removeListener('connectionStateChange', onConnectionStateChange)
bleClient.removeListener('scanError', onScanError)
deviceInfoService.removeListener(EVENT_TYPES.DEVICE_INFO.name, onDeviceInfo)
deviceInfoService.removeListener(EVENT_TYPES.VERSION_INFO.name, onVersionInfo)
deviceInfoService.removeListener(EVENT_TYPES.BIND_DEVICE.name, onBindStatus)
deviceInfoService.removeListener(EVENT_TYPES.UNBIND_DEVICE.name, onUnBindStatus)
deviceInfoService.removeListener(EVENT_TYPES.DELETE_FILE.name, onDeleteFile)
deviceInfoService.removeListener(EVENT_TYPES.PREPARE_TRANSFER.name, onPrepareTransfer)
}
}, [bleClient, deviceInfoService, setError])
const startScan = useCallback(async () => {
if (Platform.OS === 'web') return
try {
setError(null)
console.log('Starting scan...')
const hasPerms = await requestBluetoothPermissions()
if (!hasPerms) return
setState((prev) => ({ ...prev, isScanning: true, discoveredDevices: [] }))
// Check for already connected devices
try {
const relevantDevices = await bleClient.getConnectedDevices([BLE_UUIDS.SERVICE])
if (relevantDevices.length > 0) {
console.log(`Found ${relevantDevices.length} system-connected devices`)
setState((prev) => {
// Avoid duplicates if any
const newDevices = relevantDevices.filter((nd) => !prev.discoveredDevices.some((ed) => ed.id === nd.id))
newDevices.forEach((d) => {
d.connected = false
}) // Treat as disconnected until explicit connect
return {
...prev,
discoveredDevices: [...prev.discoveredDevices, ...newDevices],
}
})
}
} catch (e) {
console.warn('Failed to check connected devices', e)
}
await bleClient.startScan(
[BLE_UUIDS.SERVICE],
{ scanMode: ScanMode.Balanced, allowDuplicates: false },
(result) => {
const device = result.device as BleDevice
// 手动过滤:必须包含目标 serviceUUIDBLE 库在某些平台不严格过滤)
const targetServiceUUID = BLE_UUIDS.SERVICE.toLowerCase()
const hasTargetService = device.serviceUUIDs?.some((uuid) => uuid?.toLowerCase() === targetServiceUUID)
// console.log('startScan--------', device)
if (!hasTargetService || !device?.id) {
return
}
device.connected = false
console.debug(
`Device found: ${device.name} (${device.id}), serviceUUIDs: ${JSON.stringify(device.serviceUUIDs)}`,
)
// 使用队列批量更新,避免频繁触发 setState
queueDevice(device)
},
)
} catch (e: any) {
setError(`Start scan failed: ${e.message}`)
console.log('scan error --------------', e)
// 解决安卓31以下爱无法检测到蓝牙是否开启的问题
setState((prev) => ({ ...prev, isScanning: false, discoveredDevices: [] }))
Alert.alert(
'手机蓝牙未开启',
'检测到手机蓝牙未打开,请打开手机蓝牙再试。',
[{ text: '确定', style: 'default' }],
{ cancelable: true },
)
}
}, [bleClient, requestBluetoothPermissions, setError, queueDevice])
const stopScan = useCallback(() => {
// 清理待处理的设备队列
if (flushTimerRef.current) {
clearTimeout(flushTimerRef.current)
flushTimerRef.current = null
}
pendingDevicesRef.current = []
bleClient.stopScan()
setState((prev) => ({ ...prev, isScanning: false }))
console.log('Scan stopped')
}, [bleClient, setState])
const connectToDevice = useCallback(
async (device: BleDevice): Promise<BleDevice> => {
stopScan()
setState((prev) => ({ ...prev, loading: { ...prev.loading, connecting: true } }))
console.log(`Connecting to ${device.name}...${device.id}`)
if (!device?.id) {
setState((prev) => ({ ...prev, loading: { ...prev.loading, connecting: false } }))
return Promise.reject('Device ID is missing')
}
try {
const connectedDevice = (await bleClient.connect(device.id)) as BleDevice
if (!connectedDevice) {
setState((prev) => ({ ...prev, loading: { ...prev.loading, connecting: false } }))
return Promise.reject('Failed to connect to device')
}
connectedDevice.connected = true
// Scan and log all services and characteristics
try {
const services = await connectedDevice.services()
for (const service of services) {
console.log(`[Service] ${service.uuid}`)
const characteristics = await service.characteristics()
for (const char of characteristics) {
const props = [
char.isReadable ? 'Read' : '',
char.isWritableWithResponse ? 'Write' : '',
char.isWritableWithoutResponse ? 'WriteNoResp' : '',
char.isNotifiable ? 'Notify' : '',
char.isIndicatable ? 'Indicate' : '',
]
.filter(Boolean)
.join(',')
console.log(` -> [Char] ${char.uuid} (${props})`)
}
}
} catch (scanError) {
console.error(`Error scanning services: ${scanError}`)
}
await protocolService.initialize(device.id)
setState((prev) => ({
...prev,
connectedDevice,
isConnected: true,
loading: { ...prev.loading, connecting: false },
}))
// 记录设备连接成功日志
logToSentry('device_connected')
console.log('Connected and Protocol initialized')
return connectedDevice
} catch (e: any) {
const errorMsg = e?.message || String(e) || 'Unknown connection error'
console.error(`Connection failed: ${errorMsg}`, e)
setError(`Connection failed: ${errorMsg}`)
setState((prev) => ({ ...prev, loading: { ...prev.loading, connecting: false } }))
throw new Error(errorMsg)
}
},
[bleClient, protocolService, stopScan, setError, setState, logToSentry],
)
const disconnectDevice = useCallback(async () => {
try {
console.log('Disconnecting...')
await bleClient.disconnect()
protocolService.disconnect()
} catch (e: any) {
console.error(`Disconnect failed: ${e.message}`)
}
}, [bleClient, protocolService])
const getDeviceInfo = useCallback(async () => {
if (!state.connectedDevice) return
try {
console.log(`[${state.connectedDevice.id}] Requesting Device Info...`)
await deviceInfoService.getDeviceInfo(state.connectedDevice.id)
console.log(`[${state.connectedDevice.id}] Device Info query request sent.`)
return Promise.resolve()
} catch (e: any) {
setError(`Request failed: ${e.message}`)
return Promise.reject(e.message)
}
}, [deviceInfoService, state.connectedDevice, setError])
const getDeviceVersion = useCallback(async () => {
if (!state.connectedDevice) return
try {
console.log(`[${state.connectedDevice.id}] Requesting Device Version...`)
await deviceInfoService.getDeviceVersion(state.connectedDevice.id)
console.log(`[${state.connectedDevice.id}] Device Version query request sent`)
} catch (e: any) {
setError(`Request failed: ${e.message}`)
}
}, [state.connectedDevice, deviceInfoService, setError])
const bindDevice = useCallback(
async (userId: string) => {
if (!state.connectedDevice) return
try {
await deviceInfoService.bindDevice(state.connectedDevice.id, userId)
return Promise.resolve()
} catch (e: any) {
setError(`Request failed: ${e.message}`)
return Promise.reject(e.message)
}
},
[state.connectedDevice, deviceInfoService, setError],
)
const unBindDevice = useCallback(
async (userId: string) => {
if (!state.connectedDevice) return
try {
await deviceInfoService.unbindDevice(state.connectedDevice.id, userId)
return Promise.resolve()
} catch (e: any) {
setError(`Request failed: ${e.message}`)
return Promise.reject(e.message)
}
},
[state.connectedDevice, deviceInfoService, setError],
)
const deleteFile = useCallback(
async (key: string) => {
if (!state.connectedDevice) {
const error = 'No device connected'
setError(error)
return Promise.reject(error)
}
try {
await deviceInfoService.deleteFile(state.connectedDevice.id, key)
return Promise.resolve()
} catch (e: any) {
setError(`Delete file failed: ${e.message}`)
return Promise.reject(e.message)
}
},
[state.connectedDevice, deviceInfoService, setError],
)
const prepareTransfer = useCallback(
async (key: string, size: number): Promise<PrepareTransferResponse> => {
if (!state.connectedDevice) {
const error = 'No device connected'
setError(error)
return Promise.reject(error)
}
try {
// 创建 Promise 并存储到 Map
const responsePromise = new Promise<PrepareTransferResponse>((resolve, reject) => {
const timeoutId = setTimeout(() => {
pendingPrepareTransfersRef.current.delete(key)
reject(new Error('Prepare transfer timeout'))
}, 10000) // 10秒超时
// 先注册 Promise再发送请求
pendingPrepareTransfersRef.current.set(key, { resolve, reject, timeoutId })
})
// ✅ 关键:先注册 Promise再发送请求
// 这样即使设备立即返回Promise 也已经准备好了
await deviceInfoService.prepareTransfer(state.connectedDevice.id, key, size)
// 等待响应
return await responsePromise
} catch (e: any) {
setError(`Prepare transfer failed: ${e.message}`)
return Promise.reject(e.message)
}
},
[state.connectedDevice, deviceInfoService, setError],
)
const pickImage = async () => {
// No permissions request is necessary for launching the image library.
// Manually request permissions for videos on iOS when `allowsEditing` is set to `false`
// and `videoExportPreset` is `'Passthrough'` (the default), ideally before launching the picker
// so the app users aren't surprised by a system dialog after picking a video.
// See "Invoke permissions for videos" sub section for more details.
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync()
if (!permissionResult.granted) {
Alert.alert('Permission required', 'Permission to access the media library is required.')
return []
}
let result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images', 'videos'],
allowsEditing: false,
aspect: [1, 1],
quality: 1.0,
})
if (!result.canceled) {
return result.assets
} else {
return []
}
}
const convertToANIAsFile = useCallback(
async (tempDir: Directory, media: ImagePicker.ImagePickerAsset): Promise<File> => {
const tempFile = new File(tempDir, `${media.fileName?.split('.')[0]}.ani`)
const formData = new FormData()
formData.append('file', {
uri: media.uri,
name: media.fileName || 'video.mp4',
type: media.mimeType || 'video/mp4',
} as any)
const response = await fetch('https://bowongai-test--ani-video-converter-fastapi-app.modal.run/api/convert/ani', {
method: 'POST',
body: formData,
headers: { Accept: 'multipart/form-data' },
})
if (!response.ok) {
throw new Error(`Conversion failed with status ${response.status}`)
}
const content = await response.arrayBuffer()
tempFile.write(new Uint8Array(content))
const tempFileInfo = tempFile.info()
console.debug(`Converted video saved to ${tempFile.uri}, size : ${tempFileInfo.size} bytes`)
return tempFile
},
[],
)
const convertToANIAsBuffer = useCallback(
async (tempDir: Directory, media: ImagePicker.ImagePickerAsset): Promise<ArrayBuffer> => {
const formData = new FormData()
formData.append('file', {
uri: media.uri,
name: media.fileName || 'video.mp4',
type: media.mimeType || 'video/mp4',
} as any)
const response = await fetch('https://bowongai-test--ani-video-converter-fastapi-app.modal.run/api/convert/ani', {
method: 'POST',
body: formData,
headers: { Accept: 'multipart/form-data' },
})
if (!response.ok) {
throw new Error(`Conversion failed with status ${response.status}`)
}
const content = await response.arrayBuffer()
console.debug(`Converted video size : ${content.byteLength} bytes`)
return content
},
[],
)
const transferMedia = useCallback(async () => {
if (!state.connectedDevice) {
setError('No device connected')
return
}
const tempDir = new Directory(Paths.cache, 'anis')
if (!tempDir.exists) tempDir.create()
const medias = await pickImage()
if (medias.length === 0) return
console.debug(`[${state.connectedDevice.id}] processing ${medias.length} files...`)
for (const media of medias) {
if (media.type === 'video') {
// let tempFile: File;
let tempBuffer: ArrayBuffer
console.debug(`Converting video: ${media.fileName || 'video'}...`)
setState((prev) => ({ ...prev, loading: { ...prev.loading, converting: true } }))
// tempFile = await convertToANIAsFile(tempDir, media)
tempBuffer = await convertToANIAsBuffer(tempDir, media)
setState((prev) => ({ ...prev, loading: { ...prev.loading, converting: false } }))
console.log(`Transferring converted file to device...`)
await fileTransferService.transferFile(
state.connectedDevice.id,
tempBuffer,
COMMAND_TYPES.TRANSFER_ANI_VIDEO,
(progress) => {
setState((prev) => ({ ...prev, transferProgress: progress * 100 }))
// Optional: throttle logs to avoid spam
if (Math.round(progress * 100) % 10 === 0) {
// addLog(`Transfer progress: ${Math.round(progress * 100)}%`);
}
},
)
// tempFile.delete();
console.log(`Transfer successful`)
} else if (media.type === 'image') {
try {
console.debug(`Processing image: ${media.fileName || 'image'}...`)
// Check if it is a GIF
const isGif = media.mimeType === 'image/gif' || media.uri.toLowerCase().endsWith('.gif')
if (isGif) {
console.debug(`Converting GIF to ANI: ${media.fileName || 'gif'}...`)
// const tempFile = await convertToANIAsFile(tempDir, media)
const tempBuffer = await convertToANIAsBuffer(tempDir, media)
console.log(`Transferring converted file to device...`)
await fileTransferService.transferFile(
state.connectedDevice.id,
tempBuffer,
COMMAND_TYPES.TRANSFER_ANI_VIDEO,
(progress) => {
setState((prev) => ({ ...prev, transferProgress: progress * 100 }))
},
)
console.log(`Transfer successful`)
// tempFile.delete();
console.log(`Cleaned up temp file`)
return
}
let imageUri = media.uri
// Check if conversion is needed (if not jpeg)
// Note: mimeType might not always be reliable, so we can also check filename extension if needed
// or just always run manipulator to ensure consistency.
// Here we check if it is NOT jpeg/jpg
const isJpeg =
media.mimeType === 'image/jpeg' ||
media.mimeType === 'image/jpg' ||
media.uri.toLowerCase().endsWith('.jpg') ||
media.uri.toLowerCase().endsWith('.jpeg')
if (!isJpeg) {
console.debug(`Converting image to JPEG...`)
const context = ImageManipulator.ImageManipulator.manipulate(media.uri).resize({
width: BLE_UUIDS.SCREEN_SIZE,
height: BLE_UUIDS.SCREEN_SIZE,
})
const imageRef = await context.renderAsync()
const result = await imageRef.saveAsync({
compress: 1,
format: ImageManipulator.SaveFormat.JPEG,
})
imageUri = result.uri
console.debug(`Conversion successful: ${imageUri}`)
}
console.log(`Transferring image to device...`)
await fileTransferService.transferFile(
state.connectedDevice.id,
imageUri,
COMMAND_TYPES.TRANSFER_JPEG_IMAGE, // Assuming PHOTO_ALBUM is for images, adjust if needed
(progress) => {
setState((prev) => ({ ...prev, transferProgress: progress * 100 }))
},
)
// If we created a temporary file via manipulation, we might want to clean it up?
// ImageManipulator results are usually in cache, which OS cleans up, but we can't easily delete if we don't own it.
// For this flow, we just leave it.
console.log(`Transfer successful`)
} catch (e: any) {
console.error(`Error: ${e.message}`)
setError(e.message)
}
} else {
console.log(`Unsupported media type: ${media.type}`)
}
}
}, [state.connectedDevice, fileTransferService, convertToANIAsBuffer, setError])
const transferJPG = useCallback(
async (uriOrUrl: string) => {
try {
console.debug(`transferJPG processing image: ${uriOrUrl}...`)
let imageUri = uriOrUrl
const context = ImageManipulator.ImageManipulator.manipulate(imageUri)
// .resize({
// width: BLE_UUIDS.SCREEN_SIZE,
// height: BLE_UUIDS.SCREEN_SIZE,
// })
const imageRef = await context.renderAsync()
const result = await imageRef.saveAsync({
compress: 1,
format: ImageManipulator.SaveFormat.JPEG,
})
imageUri = result.uri
console.debug(`ImageManipulator Conversion successful: ${imageUri}`)
return imageUri
} catch (error: any) {
console.log(`Image processing failed: ${error.message}`)
return Promise.reject(error.message)
}
},
[state.connectedDevice, setError],
)
function guessMimeType(fileName: string): string {
const ext = fileName.split('.').pop()?.toLowerCase()
switch (ext) {
case 'mp4':
return 'video/mp4'
case 'mov':
return 'video/quicktime'
case 'webm':
return 'video/webm'
case 'jpg':
case 'jpeg':
return 'image/jpeg'
case 'png':
return 'image/png'
default:
return 'application/octet-stream'
}
}
const convertImgToANIAsBuffer = useCallback(async (uriOrUrl: string): Promise<ArrayBuffer> => {
// 1⃣ 用 fetch 统一拿 Blob本地 / 网络都行)
const res = await fetch(uriOrUrl)
if (!res.ok) {
// throw new Error(`Failed to fetch media: ${res.status}`)
return Promise.reject(`Failed to fetch media: ${res.status}`)
}
const blob = await res.blob()
// 2⃣ 从 url / uri 推断文件名
const name = uriOrUrl.split('/').pop()?.split('?')[0] || 'media.bin'
// 3⃣ MIME 类型兜底
const type = blob.type || guessMimeType(name)
// 4⃣ 构造 FormData
const formData = new FormData()
formData.append('file', {
uri: uriOrUrl,
name,
type,
} as any)
// 5⃣ 上传到 ANI 服务
const aniProd = 'https://bowongai-prod--ani-video-converter-fastapi-app.modal.run/api/convert/ani'
const response = await fetch(aniProd, {
method: 'POST',
body: formData,
// headers: {
// Accept: 'application/json',
// },
})
if (!response.ok) {
throw new Error(`Conversion failed: ${response.status}`)
}
// 6⃣ 返回 ArrayBuffer
const content = await response.arrayBuffer()
console.debug(`Converted video size: ${content.byteLength} bytes`)
return content
}, [])
// 单个上传接口
// 视频直接转ani上传图片转jpg上传
// 本地缓存ani文件避免重复转换
const transferMediaSingle = useCallback(
async (uriOrUrl: string) => {
try {
if (!state.connectedDevice) {
setError('No device connected')
return Promise.reject('No device connected')
}
const tempDir = new Directory(Paths.cache, 'anis')
if (!tempDir.exists) tempDir.create()
if (!uriOrUrl) return Promise.reject('No uriOrUrl provided')
let tempBuffer: ArrayBuffer
const tempBufferExist = await aniStorage.has(uriOrUrl)
console.log('tempBufferExist-----', tempBufferExist)
if (!tempBufferExist) {
console.debug(`Converting video: ${uriOrUrl || 'video'}...`)
setState((prev) => ({ ...prev, loading: { ...prev.loading, converting: true } }))
tempBuffer = await convertImgToANIAsBuffer(uriOrUrl)
await aniStorage.set(uriOrUrl, tempBuffer)
} else {
tempBuffer = await aniStorage.get(uriOrUrl)
}
const fileSizeByte = tempBuffer.byteLength
const key = extractCdnKey(uriOrUrl)
if (!key) {
setState((prev) => ({ ...prev, loading: { ...prev.loading, converting: false } }))
return Promise.reject('Invalid uriOrUrl key')
}
const prepareResp = await prepareTransfer(key, fileSizeByte)
if (prepareResp.status !== 'ready') {
setState((prev) => ({ ...prev, loading: { ...prev.loading, converting: false } }))
console.log('prepareResp not ready-----', prepareResp)
return Promise.reject({ status: `${prepareResp.status}` })
}
console.log('prepareResp-----', prepareResp)
setState((prev) => ({ ...prev, loading: { ...prev.loading, converting: false, transferring: true } }))
console.log(`Transferring converted file to device...`)
await fileTransferService.transferFile(
state.connectedDevice.id,
tempBuffer,
COMMAND_TYPES.TRANSFER_ANI_VIDEO,
(progress) => {
setState((prev) => ({ ...prev, transferProgress: progress * 100 }))
},
)
setState((prev) => ({ ...prev, loading: { ...prev.loading, transferring: false } }))
console.log(`Transfer successful`)
return Promise.resolve()
} catch (error: any) {
setState((prev) => ({ ...prev, loading: { ...prev.loading, transferring: false } }))
console.log(`Transfer failed: ${error.message}`)
return Promise.reject(error.message)
}
},
[state.connectedDevice, fileTransferService, convertImgToANIAsBuffer, setError],
)
const clearLogs = useCallback(() => setState((prev) => ({ ...prev, logs: [] })), [])
return {
...state,
startScan,
stopScan,
connectToDevice,
disconnectDevice,
getDeviceInfo,
getDeviceVersion,
bindDevice,
unBindDevice,
deleteFile,
prepareTransfer,
transferMedia,
transferMediaSingle,
clearLogs,
bleSeesionId,
}
}