expo-duooomi-app/app/(tabs)/explore.tsx

547 lines
17 KiB
TypeScript

import * as Sentry from '@sentry/react-native'
import { type Directory, Paths } from 'expo-file-system'
import * as FileSystem from 'expo-file-system/legacy'
import { Image } from 'expo-image'
import * as ImagePicker from 'expo-image-picker'
import * as MediaLibrary from 'expo-media-library'
import { observer } from 'mobx-react-lite'
import React, { useState } from 'react'
import { Button, StyleSheet, TextInput } from 'react-native'
import { KeyboardAwareScrollView } from 'react-native-keyboard-controller'
import { imgPicker } from '@/@share/apis'
import { Block, Toast, VideoBox } from '@/@share/components'
import { APP_VERSION } from '@/app.constants'
import { BLE_UUIDS, PROTOCOL_VERSION, useBleExplorer } from '@/ble'
import { ThemedText } from '@/components/themed-text'
import { ThemedView } from '@/components/themed-view'
import { userStore } from '@/stores'
import { uploadFile } from '@/utils'
import { buildCdnUrl } from '@/utils/getCDNKey'
export default observer(function TabTwoScreen() {
const { user } = userStore
const {
isScanning,
isConnected,
connectedDevice,
deviceInfo,
version,
isActivated,
transferProgress,
discoveredDevices,
loading,
error,
startScan,
stopScan,
connectToDevice,
disconnectDevice,
getDeviceInfo,
getDeviceVersion,
bindDevice,
unBindDevice,
transferMediaSingle,
deleteFile,
bleSeesionId,
contents,
} = useBleExplorer()
const [imageUri, setImageUri] = useState(
'file:///data/user/0/com.bowong.duooomi/cache/ImageManipulator/153903d9-f5e9-4e65-9c7d-f7bedd574f3c.jpg',
)
const [userId, setUserId] = useState(user?.id ?? '01duHavK1CMW7pawcgOtB5aUqQeHPeni')
// 查询设备版本
const queryDeviceVersion = async () => {
try {
await getDeviceVersion()
Toast?.show({ title: '版本查询请求已发送' })
} catch (error) {
console.error('Error querying version:', error)
Toast?.show({ title: '版本查询失败' })
}
}
// 请求设备信息
const requestDeviceInfo = async () => {
try {
await getDeviceInfo()
Toast?.show({ title: '设备信息查询请求已发送' })
} catch (error) {
console.error('Error requesting device info:', error)
Toast?.show({ title: `设备信息查询失败, ${error}` })
}
}
// 查询激活状态(通过获取设备信息来判断)
const queryActivationStatus = async () => {
try {
await getDeviceInfo()
Toast?.show({ title: '激活状态查询请求已发送' })
} catch (error) {
console.error('Error querying activation:', error)
Toast?.show({ title: `激活状态查询失败, ${error}` })
}
}
// 绑定设备
const handleBindDevice = async () => {
try {
await bindDevice(userId)
Toast?.show({ title: '设备绑定请求已发送' })
} catch (error) {
console.error('Error binding device:', error)
Toast?.show({ title: `设备绑定失败, ${error}` })
}
}
// 解绑设备
const handleUnbindDevice = async () => {
try {
await unBindDevice(userId)
Toast?.show({ title: '设备解绑请求已发送' })
} catch (error) {
console.error('Error unbinding device:', error)
Toast?.show({ title: `设备解绑失败, ${error}` })
}
}
const convertToANIAsBuffer = async (
tempDir: Directory,
media: ImagePicker.ImagePickerAsset,
): Promise<ArrayBuffer> => {
try {
const formData = new FormData()
formData.append('file', {
uri: media.uri,
name: media.fileName || 'video.mp4',
type: media.mimeType || 'video/mp4',
} as any)
const aniTest = `https://bowongai-test--ani-video-converter-fastapi-app.modal.run/api/convert/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: '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
} catch (error) {
console.error('Error converting video to ANI:', error)
throw error
}
}
const handleUploadMedia = async () => {
try {
const assetList = await imgPicker({ maxImages: 1, type: ImagePicker.MediaTypeOptions.All, resultType: 'asset' })
const asset = assetList[0] as ImagePicker.ImagePickerAsset
if (!asset) {
console.warn('No asset selected')
return
}
// return
if (asset?.uri) {
Toast.showLoading({ title: '文件传输中...', duration: 0 })
setImageUri(asset.uri)
const url = await uploadFile({
uri: asset.uri,
mimeType: asset.mimeType,
fileName: asset.fileName ?? undefined,
})
transferMediaSingle(url)
.then(() => {
Toast?.show({ title: '文件传输完成' })
})
.catch((error) => {
const { status } = error || {}
if (!status) {
Toast?.show({ title: `文件传输失败` })
return
}
if (status === 'no_space') {
Toast?.show({ title: '文件传输失败, 设备存储空间不足' })
}
if (status === 'duplicated') {
Toast?.show({ title: '文件传输失败, 设备已存在相同文件' })
}
})
.finally(() => {
Toast.hideLoading()
})
}
} catch (error) {
console.error('Error uploading media:', error)
}
}
const downloadFile = async () => {
try {
const perm = await MediaLibrary.requestPermissionsAsync()
if (perm.status !== 'granted') {
Toast?.show({ title: '请开启相册权限' })
return
}
const filename = assetFileName || 'image.jpg'
let localUri = imageUri
if (!localUri.startsWith('file://')) {
const target = `${Paths.documentDirectory}${filename}`
const res = await FileSystem.downloadAsync(imageUri, target)
localUri = res.uri
}
await MediaLibrary.saveToLibraryAsync(localUri)
console.log('已保存到系统相册:', localUri)
Toast?.show({ title: '保存成功' })
} catch (error) {
console.error('保存失败:', error)
Toast?.show({ title: '保存失败' })
}
}
const renderImage = () => {
if (!imageUri) {
return null
}
return (
<Block style={{ width: BLE_UUIDS.SCREEN_SIZE, height: BLE_UUIDS.SCREEN_SIZE }} onClick={downloadFile}>
<Image
contentFit="cover"
source={{ uri: imageUri }}
style={{ width: BLE_UUIDS.SCREEN_SIZE, height: BLE_UUIDS.SCREEN_SIZE }}
/>
</Block>
)
}
return (
<KeyboardAwareScrollView
bottomOffset={100}
contentContainerStyle={{ paddingHorizontal: 20, paddingTop: 40, paddingBottom: 200, backgroundColor: 'white' }}
>
<ThemedView style={styles.titleContainer}>
<ThemedText type="title">BLE Explorer</ThemedText>
<ThemedText>: {APP_VERSION}</ThemedText>
<ThemedText>Session ID: {bleSeesionId}</ThemedText>
<TextInput
editable={true}
placeholder=""
placeholderTextColor="#999"
style={styles.input}
value={bleSeesionId}
/>
</ThemedView>
<Button
title="Try!"
onPress={() => {
Sentry.captureMessage('Test from Android Build')
// throw new Error('Test Sentry Integration')
}}
/>
{/* Connection Status */}
<ThemedView style={styles.section}>
<ThemedText type="subtitle">Connection Status</ThemedText>
<ThemedText>Scanning: {isScanning ? 'Yes' : 'No'}</ThemedText>
<ThemedText>Connected: {isConnected ? 'Yes' : 'No'}</ThemedText>
<ThemedText>Device: {connectedDevice?.name || 'None'}</ThemedText>
{error && <ThemedText style={styles.errorText}>Error: {error}</ThemedText>}
</ThemedView>
{/* Discovered Devices */}
<ThemedView style={styles.section}>
<ThemedText type="subtitle">Discovered Devices</ThemedText>
<ThemedText style={styles.deviceCount}>Total devices found: {discoveredDevices.length}</ThemedText>
{discoveredDevices.length === 0 ? (
<ThemedText>No devices discovered yet. Start scanning to find devices.</ThemedText>
) : (
<ThemedView style={{ gap: 8 }}>
{discoveredDevices.map((item: any) => (
<ThemedView
key={item.id}
darkColor="#2a2a2a"
lightColor="#eee"
style={[styles.deviceItem, item.connected && styles.connectedDevice]}
>
<ThemedView darkColor="transparent" lightColor="transparent" style={styles.deviceInfo}>
<ThemedText style={item.connected && styles.connectedDeviceText}>
{item.name || 'Unknown Device'}
</ThemedText>
<ThemedText style={styles.deviceId}>{item.id}</ThemedText>
{item.serviceUUIDs && item.serviceUUIDs.length > 0 && (
<ThemedText style={styles.serviceUuids}>Services: {item.serviceUUIDs.join(', ')}</ThemedText>
)}
{item.connected && <ThemedText style={styles.connectionStatus}>Connected</ThemedText>}
</ThemedView>
<Button
disabled={isConnected || loading.connecting || item.connected}
title={loading.connecting ? 'Connecting...' : item.connected ? 'Connected' : 'Connect'}
onPress={() => connectToDevice(item)}
/>
</ThemedView>
))}
</ThemedView>
)}
</ThemedView>
{/* Device Info */}
<ThemedView style={styles.section}>
<ThemedText type="subtitle">Device Information</ThemedText>
<ThemedText>Activated: {isActivated ? 'Yes' : 'No'}</ThemedText>
<ThemedText>Version: {version || 'Unknown'}</ThemedText>
{deviceInfo && (
<>
<ThemedText>Name: {deviceInfo.name}</ThemedText>
<ThemedText>Total Space: {deviceInfo.allspace} KB</ThemedText>
<ThemedText>Free Space: {deviceInfo.freespace} KB</ThemedText>
<ThemedText>Brand: {deviceInfo.brand}</ThemedText>
</>
)}
</ThemedView>
{/* Transfer Status */}
<ThemedView style={styles.section}>
<ThemedText type="subtitle">File Transfer</ThemedText>
<ThemedText>Converting: {loading.converting ? 'Yes' : 'No'}</ThemedText>
<ThemedText>Transferring: {loading.transferring ? 'Yes' : 'No'}</ThemedText>
<ThemedText>Progress: {transferProgress}%</ThemedText>
</ThemedView>
{renderImage()}
{/* Control Buttons */}
<ThemedView style={styles.section}>
<ThemedText type="subtitle">Controls</ThemedText>
<ThemedView style={styles.buttonRow}>
<Button title={isScanning ? '停止扫描' : '开始扫描'} onPress={isScanning ? stopScan : startScan} />
<Button disabled={!isConnected} title="断开连接" onPress={disconnectDevice} />
</ThemedView>
<ThemedView style={styles.buttonRow}>
<Button
disabled={!isConnected}
title={loading.querying ? '获取中...' : '获取版本号'}
onPress={queryDeviceVersion}
/>
<Button
disabled={!isConnected}
title={loading.querying ? '获取中...' : '获取设备信息'}
onPress={requestDeviceInfo}
/>
</ThemedView>
<ThemedView style={styles.buttonRow}>
<Button
disabled={!isConnected || loading.transferring}
title={loading.transferring ? '传输中...' : '发文件到设备'}
onPress={handleUploadMedia}
/>
</ThemedView>
<ThemedView style={{ marginTop: 8 }}>
<ThemedText style={{ marginBottom: 4 }}>User ID:</ThemedText>
<TextInput
placeholder="输入用户ID"
style={styles.input}
value={userId}
onChangeText={setUserId}
placeholderTextColor="#999"
// editable={isConnected}
/>
</ThemedView>
<ThemedView style={styles.buttonRow}>
<Button
disabled={!isConnected || isActivated || !userId.trim()}
title="绑定设备"
onPress={handleBindDevice}
/>
<Button
disabled={!isConnected || !isActivated || !userId.trim()}
title="解绑设备"
onPress={handleUnbindDevice}
/>
</ThemedView>
<ThemedView style={styles.section}>
<ThemedText type="subtitle"> ({contents.length})</ThemedText>
<ThemedView style={{ gap: 8 }}>
{
// Array(5)
// .fill('https://cdn.roasmax.cn/material/8836b879f2d44af48eef8da82d13a755.mp4')
contents.map((item, index) => {
console.log('item-----------', item)
const url = buildCdnUrl(item)
if (!url) return null
const Width = 100
return (
<Block key={`${item}-${index}`} className="gap-2">
<ThemedText style={styles.contentText} numberOfLines={1}>
{item}
</ThemedText>
<VideoBox style={{ width: Width, height: Width }} url={url} />
<Button
title="删除"
color="red"
disabled={!isConnected}
onPress={async () => {
try {
await deleteFile(item)
Toast?.show({ title: '删除请求已发送' })
} catch (error) {
console.error('Error deleting file:', error)
Toast?.show({ title: `删除失败: ${error}` })
}
}}
/>
</Block>
)
})
}
</ThemedView>
</ThemedView>
</ThemedView>
{/* Protocol Info - Available in both modes */}
<ThemedView style={styles.section}>
<ThemedText type="subtitle">Protocol Information</ThemedText>
<ThemedText>Version: {PROTOCOL_VERSION}</ThemedText>
<ThemedText>Service UUID: {BLE_UUIDS.SERVICE}</ThemedText>
<ThemedText>Write UUID: {BLE_UUIDS.WRITE_CHARACTERISTIC}</ThemedText>
<ThemedText>Read UUID: {BLE_UUIDS.READ_CHARACTERISTIC}</ThemedText>
</ThemedView>
</KeyboardAwareScrollView>
)
})
const styles = StyleSheet.create({
headerImage: {
color: '#808080',
bottom: -90,
left: -35,
position: 'absolute',
},
titleContainer: {
flexDirection: 'column',
gap: 8,
},
section: {
gap: 8,
marginBottom: 8,
},
buttonRow: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 8,
gap: 8,
},
logHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
logContainer: {
maxHeight: 200,
padding: 8,
borderRadius: 4,
},
logText: {
fontSize: 12,
marginBottom: 2,
},
deviceItem: {
padding: 12,
marginBottom: 8,
borderRadius: 8,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
deviceInfo: {
flex: 1,
marginRight: 12,
},
deviceId: {
fontSize: 12,
opacity: 0.7,
},
deviceCount: {
fontSize: 14,
opacity: 0.8,
marginTop: 4,
marginBottom: 8,
fontWeight: '500',
},
serviceUuids: {
fontSize: 11,
opacity: 0.6,
marginTop: 2,
fontStyle: ' ',
},
connectionStatus: {
fontSize: 12,
fontWeight: 'bold',
marginTop: 4,
},
connectedDevice: {
borderWidth: 1,
borderColor: '#4CAF50',
},
connectedDeviceText: {
fontWeight: 'bold',
},
errorText: {
marginTop: 8,
fontWeight: 'bold',
},
modeSwitchContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginVertical: 12,
},
modeLabel: {
fontSize: 16,
marginHorizontal: 8,
},
modeDescription: {
fontSize: 12,
opacity: 0.7,
marginTop: 8,
},
input: {
borderWidth: 1,
borderColor: '#ccc',
borderRadius: 4,
padding: 8,
fontSize: 14,
backgroundColor: '#fff',
color: '#000',
},
contentItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: 8,
backgroundColor: '#f5f5f5',
borderRadius: 4,
gap: 8,
},
contentText: {
flex: 1,
fontSize: 12,
color: '#333',
},
})