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 { bleStore } from '@/stores' 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, type VersionInfo, } 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([]) const flushTimerRef = useRef | null>(null) const pendingPrepareTransfersRef = useRef< Map< string, { resolve: (value: PrepareTransferResponse) => void reject: (reason?: any) => void timeoutId: ReturnType } > >(new Map()) // const [state, setState2] = useState({ // 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 state = bleStore.state const setState = useCallback((updater: (prev: BleState) => BleState) => { bleStore.setState(updater) }, []) // 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 => { 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 = (versionInfo: VersionInfo) => { setState((prev) => ({ ...prev, version: versionInfo?.version })) } const onBindStatus = (status: BindingResponse) => { const activated = status.success === 1 const contents = status.contents || [] console.log('onBindStatus-----', status) 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 // 手动过滤:必须包含目标 serviceUUID(BLE 库在某些平台不严格过滤) 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 => { 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 => { if (!state.connectedDevice) { const error = 'No device connected' setError(error) return Promise.reject(error) } try { // 创建 Promise 并存储到 Map const responsePromise = new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { pendingPrepareTransfersRef.current.delete(key) reject(new Error('Prepare transfer timeout')) }, 10e3) // 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 => { 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 => { 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 => { // 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) console.log('prepareResp-----', prepareResp) 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 { ...bleStore.state, startScan, stopScan, connectToDevice, disconnectDevice, getDeviceInfo, getDeviceVersion, bindDevice, unBindDevice, deleteFile, prepareTransfer, transferMedia, transferMediaSingle, clearLogs, bleSeesionId, } }