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

885 lines
31 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 { 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 DeviceInfo, 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[]
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 [state, setState2] = useState<BleState>({
isScanning: false,
isConnected: false,
connectedDevice: null,
deviceInfo: null,
version: '',
isActivated: false,
transferProgress: 0,
discoveredDevices: [],
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
setState((prev) => ({ ...prev, isActivated: activated }))
}
const onUnBindStatus = (status: UnBindResponse) => {
const activated = status.success === 1
setState((prev) => ({ ...prev, isActivated: activated }))
}
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)
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)
}
}, [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 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],
)
// 单个上传接口
// 视频直接转ani上传图片转jpg上传
// 本地缓存ani文件避免重复转换
const transferMediaSingle = useCallback(
async (uriOrUrl: string) => {
let imageUri = uriOrUrl
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')
// console.debug(`[${state.connectedDevice.id}] processing ${uriOrUrl}...`)
// 判断链接是否是视频
const isVideo =
uriOrUrl.toLowerCase().endsWith('.mp4') ||
uriOrUrl.toLowerCase().endsWith('.mov') ||
uriOrUrl.toLowerCase().endsWith('.webm')
if (!isVideo) {
// 不是视频统一转为jpg
imageUri = await transferJPG(uriOrUrl)
console.log(`Transferring image to device...`, imageUri)
}
// let tempFile: File;
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)
}
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, convertToANIAsBuffer, 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
}, [])
const clearLogs = useCallback(() => setState((prev) => ({ ...prev, logs: [] })), [])
return {
...state,
startScan,
stopScan,
connectToDevice,
disconnectDevice,
getDeviceInfo,
getDeviceVersion,
bindDevice,
unBindDevice,
transferMedia,
transferMediaSingle,
clearLogs,
bleSeesionId,
}
}