849 lines
28 KiB
TypeScript
849 lines
28 KiB
TypeScript
import { getParticipantArtwork } from '@/utils/artwork'
|
|
import { getPageStyle } from '@/utils/page-style'
|
|
import { View, Text, ScrollView, Image } from '@tarojs/components'
|
|
import { useEffect, useRef, useState } from 'react'
|
|
import Taro, { useRouter } from '@tarojs/taro'
|
|
import {
|
|
getMessages,
|
|
getCharacter,
|
|
sendParentVoiceMessage,
|
|
ChatMessage,
|
|
Character,
|
|
ConversationSource,
|
|
PeerKind,
|
|
} from '../../../services/chat'
|
|
import { useSystemBanner } from '@/components/system-banner/use-system-banner'
|
|
import { usePageShare } from '@/utils/page-share'
|
|
import './index.scss'
|
|
|
|
function normalizePeerKind(value?: string): PeerKind {
|
|
if (value === 'child' || value === 'parent' || value === 'ai') return value
|
|
return 'ai'
|
|
}
|
|
|
|
function normalizeSource(value?: string): ConversationSource {
|
|
return value === 'im' ? 'im' : 'ai'
|
|
}
|
|
|
|
function normalizeNumericId(value?: string): number | null {
|
|
const normalized = Number(value)
|
|
if (!Number.isFinite(normalized) || normalized <= 0) return null
|
|
return normalized
|
|
}
|
|
|
|
function formatParticipantLabel(message: ChatMessage): string {
|
|
const senderType = message.senderType || 'unknown'
|
|
const senderId = message.senderId || '--'
|
|
const name = message.senderName || ''
|
|
|
|
if (senderType === 'parent') {
|
|
return name === '我' ? '我' : name || '家长'
|
|
}
|
|
if (senderType === 'child') {
|
|
return name || `宝贝ID ${senderId}`
|
|
}
|
|
if (senderType === 'device') {
|
|
return name || `设备ID ${senderId}`
|
|
}
|
|
if (senderType === 'ai') {
|
|
return name || 'AI'
|
|
}
|
|
return `${senderType} ${senderId}`
|
|
}
|
|
|
|
function formatAudioDuration(durationMs?: number | null): string {
|
|
const totalSeconds = Math.max(1, Math.round((durationMs || 0) / 1000))
|
|
return `${totalSeconds}s`
|
|
}
|
|
|
|
type VoiceRuntimeInfo = {
|
|
platform: string
|
|
system: string
|
|
brand: string
|
|
model: string
|
|
version: string
|
|
SDKVersion: string
|
|
isIOS: boolean
|
|
isAndroid: boolean
|
|
isHarmony: boolean
|
|
canUseRecorderManager: boolean
|
|
canUseInnerAudioContext: boolean
|
|
supportsMp3Record: boolean
|
|
}
|
|
|
|
type RecordProfile = {
|
|
format: 'mp3' | 'aac'
|
|
mimeType: string
|
|
sampleRate: number
|
|
encodeBitRate: number
|
|
}
|
|
|
|
const DEFAULT_RECORD_PROFILE: RecordProfile = {
|
|
format: 'mp3',
|
|
mimeType: 'audio/mpeg',
|
|
sampleRate: 16000,
|
|
encodeBitRate: 96000,
|
|
}
|
|
|
|
const COMPAT_RECORD_PROFILE: RecordProfile = {
|
|
format: 'aac',
|
|
mimeType: 'audio/aac',
|
|
sampleRate: 44100,
|
|
encodeBitRate: 192000,
|
|
}
|
|
|
|
const FALLBACK_RUNTIME_INFO: VoiceRuntimeInfo = {
|
|
platform: '',
|
|
system: '',
|
|
brand: '',
|
|
model: '',
|
|
version: '',
|
|
SDKVersion: '',
|
|
isIOS: false,
|
|
isAndroid: false,
|
|
isHarmony: false,
|
|
canUseRecorderManager: false,
|
|
canUseInnerAudioContext: false,
|
|
supportsMp3Record: true,
|
|
}
|
|
|
|
const CHAT_BOTTOM_ANCHOR_ID = 'chat-bottom'
|
|
const RECORD_LONG_PRESS_DELAY_MS = 350
|
|
|
|
type LoadChatOptions = {
|
|
showLoading?: boolean
|
|
scrollToBottom?: 'jump' | 'animate' | false
|
|
}
|
|
|
|
function compareVersion(left: string, right: string): number {
|
|
const leftParts = left.split('.').map((item) => Number(item) || 0)
|
|
const rightParts = right.split('.').map((item) => Number(item) || 0)
|
|
const length = Math.max(leftParts.length, rightParts.length)
|
|
for (let index = 0; index < length; index += 1) {
|
|
const diff = (leftParts[index] || 0) - (rightParts[index] || 0)
|
|
if (diff !== 0) return diff
|
|
}
|
|
return 0
|
|
}
|
|
|
|
function getCanIUse(apiName: string): boolean {
|
|
try {
|
|
if (typeof Taro.canIUse !== 'function') return false
|
|
return Boolean(Taro.canIUse(apiName))
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
function readVoiceRuntimeInfo(): VoiceRuntimeInfo {
|
|
let systemInfo: Record<string, any> = {}
|
|
try {
|
|
systemInfo = Taro.getSystemInfoSync() as Record<string, any>
|
|
} catch (error) {
|
|
console.warn('[voice-runtime] getSystemInfoSync failed', error)
|
|
}
|
|
|
|
const platform = String(systemInfo.platform || '').toLowerCase()
|
|
const system = String(systemInfo.system || '')
|
|
const brand = String(systemInfo.brand || '')
|
|
const model = String(systemInfo.model || '')
|
|
const version = String(systemInfo.version || '')
|
|
const SDKVersion = String(systemInfo.SDKVersion || '')
|
|
const systemText = `${system} ${brand} ${model}`.toLowerCase()
|
|
const isHarmony = systemText.includes('harmony') || systemText.includes('openharmony') || systemText.includes('鸿蒙')
|
|
const isIOS = platform === 'ios' || systemText.includes('ios')
|
|
const isAndroid = platform === 'android' || systemText.includes('android') || isHarmony
|
|
|
|
return {
|
|
platform,
|
|
system,
|
|
brand,
|
|
model,
|
|
version,
|
|
SDKVersion,
|
|
isIOS,
|
|
isAndroid,
|
|
isHarmony,
|
|
canUseRecorderManager: typeof Taro.getRecorderManager === 'function',
|
|
canUseInnerAudioContext: typeof Taro.createInnerAudioContext === 'function',
|
|
supportsMp3Record: !SDKVersion || compareVersion(SDKVersion, '2.6.0') >= 0,
|
|
}
|
|
}
|
|
|
|
function getPreferredRecordProfile(runtime: VoiceRuntimeInfo): RecordProfile {
|
|
if (runtime.isIOS || runtime.isHarmony || !runtime.supportsMp3Record) return COMPAT_RECORD_PROFILE
|
|
return DEFAULT_RECORD_PROFILE
|
|
}
|
|
|
|
function getAudioUrlDebugInfo(rawUrl: string) {
|
|
const protocolMatch = rawUrl.match(/^([a-z]+):\/\//i)
|
|
let host = ''
|
|
try {
|
|
const match = rawUrl.match(/^[a-z]+:\/\/([^/?#]+)/i)
|
|
host = match?.[1] || ''
|
|
} catch {
|
|
host = ''
|
|
}
|
|
|
|
return {
|
|
rawUrl,
|
|
protocol: protocolMatch?.[1]?.toLowerCase() || '',
|
|
host,
|
|
isHttp: rawUrl.startsWith('http://'),
|
|
isHttps: rawUrl.startsWith('https://'),
|
|
}
|
|
}
|
|
|
|
export default function ChatDetail() {
|
|
usePageShare()
|
|
const systemBanner = useSystemBanner()
|
|
const router = useRouter()
|
|
const params = router.params
|
|
|
|
const initialConversationId = normalizeNumericId(params.id) || 0
|
|
const conversationSource = normalizeSource(params.source)
|
|
const conversationPeerKind = normalizePeerKind(params.peerKind)
|
|
const decodedRoleKey = decodeURIComponent(params.roleKey || '')
|
|
const decodedName = decodeURIComponent(params.name || '玩伴')
|
|
const decodedConversationTypeName = decodeURIComponent(params.conversationTypeName || '')
|
|
const decodedPeerId = decodeURIComponent(params.peerId || '')
|
|
const decodedDeviceId = decodeURIComponent(params.deviceId || '')
|
|
const decodedChildName = decodeURIComponent(params.childName || '')
|
|
const childId = normalizeNumericId(params.childId)
|
|
const parentUserId = normalizeNumericId(params.parentUserId)
|
|
const canSend = params.canSend === '1'
|
|
|
|
const [activeConversationId, setActiveConversationId] = useState(initialConversationId)
|
|
const [character, setCharacter] = useState<Character>({
|
|
id: initialConversationId,
|
|
name: decodedName,
|
|
description: '交流记录',
|
|
icon: '🤖',
|
|
source: conversationSource,
|
|
peerKind: conversationPeerKind,
|
|
conversationTypeName: decodedConversationTypeName,
|
|
childId,
|
|
childName: decodedChildName,
|
|
peerId: decodedPeerId,
|
|
parentUserId,
|
|
deviceId: decodedDeviceId,
|
|
channelLabel: '--',
|
|
canSend,
|
|
})
|
|
const [messages, setMessages] = useState<ChatMessage[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [sending, setSending] = useState(false)
|
|
const [recording, setRecording] = useState(false)
|
|
const [playingMessageId, setPlayingMessageId] = useState<number | null>(null)
|
|
const [recordHint, setRecordHint] = useState('长按开始留言')
|
|
const [scrollTarget, setScrollTarget] = useState('')
|
|
const [scrollWithAnimation, setScrollWithAnimation] = useState(false)
|
|
const audioContextRef = useRef<Taro.InnerAudioContext | null>(null)
|
|
const recorderManagerRef = useRef<Taro.RecorderManager | null>(null)
|
|
const activeConversationIdRef = useRef(initialConversationId)
|
|
const latestLoadChatDataRef = useRef<(conversationId: number, options?: LoadChatOptions) => Promise<void>>(
|
|
async () => {}
|
|
)
|
|
const recordingChildIdRef = useRef<number | null>(null)
|
|
const recordProfileRef = useRef<RecordProfile>(DEFAULT_RECORD_PROFILE)
|
|
const runtimeInfoRef = useRef<VoiceRuntimeInfo>(FALLBACK_RUNTIME_INFO)
|
|
const recordStartedAtRef = useRef<number>(0)
|
|
const playingMessageIdRef = useRef<number | null>(null)
|
|
const scrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
const recordStartTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
const hasLoadedChatRef = useRef(false)
|
|
const skipNextAutoLoadRef = useRef(false)
|
|
const recordPressedRef = useRef(false)
|
|
const recordStartPendingRef = useRef(false)
|
|
const recordingActiveRef = useRef(false)
|
|
|
|
const scrollToBottom = (animated: boolean) => {
|
|
if (scrollTimerRef.current) {
|
|
clearTimeout(scrollTimerRef.current)
|
|
}
|
|
setScrollWithAnimation(animated)
|
|
setScrollTarget('')
|
|
scrollTimerRef.current = setTimeout(() => {
|
|
setScrollWithAnimation(animated)
|
|
setScrollTarget(CHAT_BOTTOM_ANCHOR_ID)
|
|
scrollTimerRef.current = null
|
|
}, 80)
|
|
}
|
|
|
|
useEffect(() => {
|
|
runtimeInfoRef.current = readVoiceRuntimeInfo()
|
|
recordProfileRef.current = getPreferredRecordProfile(runtimeInfoRef.current)
|
|
console.info('[voice-runtime] detected', {
|
|
...runtimeInfoRef.current,
|
|
preferredRecordProfile: recordProfileRef.current,
|
|
canIUse: {
|
|
getRecorderManager: getCanIUse('getRecorderManager'),
|
|
createInnerAudioContext: getCanIUse('createInnerAudioContext'),
|
|
uploadFile: getCanIUse('uploadFile'),
|
|
},
|
|
})
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (typeof Taro.createInnerAudioContext !== 'function') {
|
|
console.warn('[audio] InnerAudioContext unavailable');
|
|
return;
|
|
}
|
|
|
|
// 关键1: 忽略 iOS 静音开关
|
|
Taro.setInnerAudioOption({ obeyMuteSwitch: false });
|
|
|
|
// 关键2: 使用 useWebAudioImplement 尝试绕过原生问题
|
|
const audioContext = Taro.createInnerAudioContext({
|
|
useWebAudioImplement: true, // 基础库 2.19.0+
|
|
});
|
|
audioContextRef.current = audioContext;
|
|
|
|
audioContext.onEnded(() => {
|
|
setPlayingMessageId(null);
|
|
});
|
|
audioContext.onStop(() => {
|
|
setPlayingMessageId(null);
|
|
});
|
|
audioContext.onError((error) => {
|
|
console.error('[audio] error detail:', JSON.stringify(error));
|
|
setPlayingMessageId(null);
|
|
Taro.showToast({ title: `播放失败: ${error.errMsg || '错误'}`, icon: 'none' });
|
|
});
|
|
audioContext.onPlay(() => {
|
|
console.info('[audio] play event', { playingMessageId: playingMessageIdRef.current, src: audioContext.src })
|
|
})
|
|
audioContext.onWaiting(() => {
|
|
console.info('[audio] waiting', { playingMessageId: playingMessageIdRef.current, src: audioContext.src })
|
|
})
|
|
audioContext.onCanplay(() => {
|
|
console.info('[audio] canplay', { playingMessageId: playingMessageIdRef.current, src: audioContext.src })
|
|
})
|
|
|
|
return () => {
|
|
audioContext.destroy()
|
|
audioContextRef.current = null
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (scrollTimerRef.current) {
|
|
clearTimeout(scrollTimerRef.current)
|
|
scrollTimerRef.current = null
|
|
}
|
|
if (recordStartTimerRef.current) {
|
|
clearTimeout(recordStartTimerRef.current)
|
|
recordStartTimerRef.current = null
|
|
}
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
activeConversationIdRef.current = activeConversationId
|
|
}, [activeConversationId])
|
|
|
|
useEffect(() => {
|
|
playingMessageIdRef.current = playingMessageId
|
|
}, [playingMessageId])
|
|
|
|
useEffect(() => {
|
|
if (skipNextAutoLoadRef.current) {
|
|
skipNextAutoLoadRef.current = false
|
|
return
|
|
}
|
|
void loadChatData(activeConversationId)
|
|
}, [activeConversationId, conversationSource, conversationPeerKind])
|
|
|
|
const loadChatData = async (conversationId: number, options: LoadChatOptions = {}) => {
|
|
const shouldShowLoading = options.showLoading ?? !hasLoadedChatRef.current
|
|
if (shouldShowLoading) {
|
|
setLoading(true)
|
|
}
|
|
try {
|
|
const char = await getCharacter({
|
|
conversationId,
|
|
source: conversationSource,
|
|
peerKind: conversationPeerKind,
|
|
name: decodedName,
|
|
roleKey: decodedRoleKey,
|
|
conversationTypeName: decodedConversationTypeName,
|
|
childId,
|
|
childName: decodedChildName,
|
|
peerId: decodedPeerId,
|
|
parentUserId,
|
|
deviceId: decodedDeviceId,
|
|
canSend,
|
|
})
|
|
if (char) {
|
|
setCharacter(char)
|
|
Taro.setNavigationBarTitle({ title: char.name })
|
|
}
|
|
const msgs = await getMessages(conversationId, conversationSource, decodedConversationTypeName)
|
|
setMessages(msgs)
|
|
const scrollMode = options.scrollToBottom ?? (!hasLoadedChatRef.current ? 'jump' : false)
|
|
if (msgs.length > 0 && scrollMode) {
|
|
scrollToBottom(scrollMode === 'animate')
|
|
}
|
|
hasLoadedChatRef.current = true
|
|
} catch (error: any) {
|
|
console.error('[chat-detail] load failed:', error)
|
|
Taro.showToast({
|
|
title: error?.message || '加载失败,请稍后重试',
|
|
icon: 'none',
|
|
})
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
latestLoadChatDataRef.current = loadChatData
|
|
|
|
useEffect(() => {
|
|
if (typeof Taro.getRecorderManager !== 'function') {
|
|
console.warn('[record] RecorderManager unavailable', runtimeInfoRef.current)
|
|
return
|
|
}
|
|
|
|
const recorderManager = Taro.getRecorderManager()
|
|
recorderManagerRef.current = recorderManager
|
|
|
|
recorderManager.onStart(() => {
|
|
recordingActiveRef.current = true
|
|
recordStartPendingRef.current = false
|
|
console.info('[record] started', {
|
|
profile: recordProfileRef.current,
|
|
runtime: runtimeInfoRef.current,
|
|
})
|
|
})
|
|
|
|
recorderManager.onError((error) => {
|
|
recordingChildIdRef.current = null
|
|
recordStartedAtRef.current = 0
|
|
recordStartPendingRef.current = false
|
|
recordingActiveRef.current = false
|
|
setRecording(false)
|
|
setRecordHint('长按开始留言')
|
|
console.error('[record] error', {
|
|
error,
|
|
profile: recordProfileRef.current,
|
|
runtime: runtimeInfoRef.current,
|
|
})
|
|
Taro.showToast({ title: '录音失败,请重试', icon: 'none' })
|
|
})
|
|
|
|
recorderManager.onStop(async (result) => {
|
|
const currentChildId = recordingChildIdRef.current
|
|
const startedAt = recordStartedAtRef.current
|
|
const fallbackDurationMs = startedAt > 0 ? Date.now() - startedAt : 0
|
|
recordingChildIdRef.current = null
|
|
recordStartedAtRef.current = 0
|
|
recordStartPendingRef.current = false
|
|
recordingActiveRef.current = false
|
|
setRecording(false)
|
|
setRecordHint('长按开始留言')
|
|
|
|
console.info('[record] stopped', {
|
|
childId: currentChildId,
|
|
tempFilePath: result.tempFilePath,
|
|
duration: result.duration,
|
|
fileSize: result.fileSize,
|
|
fallbackDurationMs,
|
|
profile: recordProfileRef.current,
|
|
runtime: runtimeInfoRef.current,
|
|
})
|
|
|
|
if (!currentChildId) return
|
|
if (!result.tempFilePath) {
|
|
console.warn('[record] missing tempFilePath', { result })
|
|
Taro.showToast({ title: '录音文件不存在', icon: 'none' })
|
|
return
|
|
}
|
|
|
|
const durationMs = Math.max(0, Number(result.duration || 0) || fallbackDurationMs)
|
|
if (durationMs < 800) {
|
|
console.warn('[record] too short', {
|
|
durationMs,
|
|
resultDuration: result.duration,
|
|
fallbackDurationMs,
|
|
})
|
|
Taro.showToast({ title: '留言太短,请重试', icon: 'none' })
|
|
return
|
|
}
|
|
|
|
setSending(true)
|
|
try {
|
|
const profile = recordProfileRef.current
|
|
const response = await sendParentVoiceMessage(currentChildId, {
|
|
filePath: result.tempFilePath,
|
|
durationMs,
|
|
audioFormat: profile.format,
|
|
mimeType: profile.mimeType,
|
|
runtime: runtimeInfoRef.current,
|
|
})
|
|
console.info('[record] uploaded', {
|
|
childId: currentChildId,
|
|
conversationId: response.conversation_id,
|
|
messageId: response.message?.id,
|
|
})
|
|
if (response.conversation_id && response.conversation_id !== activeConversationIdRef.current) {
|
|
skipNextAutoLoadRef.current = true
|
|
setActiveConversationId(response.conversation_id)
|
|
await latestLoadChatDataRef.current(response.conversation_id, {
|
|
showLoading: false,
|
|
scrollToBottom: 'animate',
|
|
})
|
|
} else {
|
|
await latestLoadChatDataRef.current(activeConversationIdRef.current, {
|
|
showLoading: false,
|
|
scrollToBottom: 'animate',
|
|
})
|
|
}
|
|
Taro.showToast({ title: '留言已发送', icon: 'success' })
|
|
} catch (error: any) {
|
|
console.error('[chat-detail] voice send failed:', error)
|
|
Taro.showToast({
|
|
title: error?.message || '留言发送失败,请重试',
|
|
icon: 'none',
|
|
})
|
|
} finally {
|
|
setSending(false)
|
|
}
|
|
})
|
|
|
|
return () => {
|
|
recorderManagerRef.current = null
|
|
}
|
|
}, [])
|
|
|
|
const handleBack = () => {
|
|
Taro.navigateBack()
|
|
}
|
|
|
|
const ensureRecordPermission = async (): Promise<boolean> => {
|
|
try {
|
|
const settings = await Taro.getSetting()
|
|
const granted = Boolean(settings.authSetting['scope.record'])
|
|
console.info('[record] permission status', {
|
|
granted,
|
|
runtime: runtimeInfoRef.current,
|
|
})
|
|
if (granted) return true
|
|
await Taro.authorize({ scope: 'scope.record' })
|
|
console.info('[record] permission granted by authorize')
|
|
return true
|
|
} catch (error) {
|
|
console.warn('[record] permission denied or authorize failed', {
|
|
error,
|
|
runtime: runtimeInfoRef.current,
|
|
})
|
|
const modal = await Taro.showModal({
|
|
title: '需要录音权限',
|
|
content: '请允许小程序使用麦克风后再留言',
|
|
confirmText: '去设置',
|
|
})
|
|
if (modal.confirm) {
|
|
console.info('[record] openSetting requested')
|
|
await Taro.openSetting()
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
const resetRecordStartTimer = () => {
|
|
if (recordStartTimerRef.current) {
|
|
clearTimeout(recordStartTimerRef.current)
|
|
recordStartTimerRef.current = null
|
|
}
|
|
}
|
|
|
|
const startRecording = async () => {
|
|
if (sending || recordingActiveRef.current || recordStartPendingRef.current) return
|
|
if (!character.canSend || !character.childId) {
|
|
Taro.showToast({ title: '当前会话不支持发送', icon: 'none' })
|
|
return
|
|
}
|
|
|
|
const recorderManager = recorderManagerRef.current
|
|
if (!recorderManager) {
|
|
Taro.showToast({ title: '当前环境不支持录音', icon: 'none' })
|
|
return
|
|
}
|
|
|
|
recordStartPendingRef.current = true
|
|
const hasPermission = await ensureRecordPermission()
|
|
if (!recordPressedRef.current) {
|
|
recordStartPendingRef.current = false
|
|
console.info('[record] start skipped: touch already ended')
|
|
return
|
|
}
|
|
if (!hasPermission) {
|
|
recordStartPendingRef.current = false
|
|
return
|
|
}
|
|
|
|
const runtime = readVoiceRuntimeInfo()
|
|
const profile = getPreferredRecordProfile(runtime)
|
|
runtimeInfoRef.current = runtime
|
|
recordProfileRef.current = profile
|
|
recordingChildIdRef.current = character.childId
|
|
recordStartedAtRef.current = Date.now()
|
|
recordingActiveRef.current = true
|
|
setRecording(true)
|
|
setRecordHint('松开发送留言')
|
|
console.info('[record] start request', {
|
|
childId: character.childId,
|
|
profile,
|
|
runtime,
|
|
})
|
|
try {
|
|
recorderManager.start({
|
|
duration: 60000,
|
|
format: profile.format,
|
|
numberOfChannels: 1,
|
|
sampleRate: profile.sampleRate,
|
|
encodeBitRate: profile.encodeBitRate,
|
|
})
|
|
} catch (error: any) {
|
|
recordingChildIdRef.current = null
|
|
recordStartedAtRef.current = 0
|
|
recordStartPendingRef.current = false
|
|
recordingActiveRef.current = false
|
|
setRecording(false)
|
|
setRecordHint('长按开始留言')
|
|
console.error('[record] start failed:', {
|
|
error,
|
|
profile,
|
|
runtime,
|
|
})
|
|
Taro.showToast({
|
|
title: '无法开始录音,请重试',
|
|
icon: 'none',
|
|
})
|
|
}
|
|
}
|
|
|
|
const handleRecordTouchStart = () => {
|
|
if (sending || recordingActiveRef.current || recordStartPendingRef.current) return
|
|
recordPressedRef.current = true
|
|
resetRecordStartTimer()
|
|
setRecordHint('继续按住开始录音')
|
|
console.info('[record] touch start', {
|
|
delayMs: RECORD_LONG_PRESS_DELAY_MS,
|
|
runtime: runtimeInfoRef.current,
|
|
})
|
|
recordStartTimerRef.current = setTimeout(() => {
|
|
recordStartTimerRef.current = null
|
|
if (!recordPressedRef.current) return
|
|
console.info('[record] long press threshold reached')
|
|
void startRecording()
|
|
}, RECORD_LONG_PRESS_DELAY_MS)
|
|
}
|
|
|
|
const handleRecordStop = () => {
|
|
recordPressedRef.current = false
|
|
resetRecordStartTimer()
|
|
console.info('[record] touch end', {
|
|
active: recordingActiveRef.current,
|
|
pending: recordStartPendingRef.current,
|
|
elapsedMs: recordStartedAtRef.current > 0 ? Date.now() - recordStartedAtRef.current : 0,
|
|
})
|
|
|
|
if (recordStartPendingRef.current && !recordingActiveRef.current) {
|
|
setRecordHint('长按开始留言')
|
|
return
|
|
}
|
|
if (!recordingActiveRef.current) {
|
|
setRecordHint('长按开始留言')
|
|
return
|
|
}
|
|
|
|
recordingActiveRef.current = false
|
|
const recorderManager = recorderManagerRef.current
|
|
if (!recorderManager) {
|
|
console.warn('[record] stop skipped: recorder unavailable')
|
|
recordingChildIdRef.current = null
|
|
recordStartedAtRef.current = 0
|
|
setRecording(false)
|
|
setRecordHint('长按开始留言')
|
|
return
|
|
}
|
|
|
|
setRecordHint('正在处理留言...')
|
|
console.info('[record] stop request', {
|
|
elapsedMs: recordStartedAtRef.current > 0 ? Date.now() - recordStartedAtRef.current : 0,
|
|
})
|
|
try {
|
|
recorderManager.stop()
|
|
} catch (error) {
|
|
recordingChildIdRef.current = null
|
|
recordStartedAtRef.current = 0
|
|
recordStartPendingRef.current = false
|
|
setRecording(false)
|
|
setRecordHint('长按开始留言')
|
|
console.error('[record] stop failed', {
|
|
error,
|
|
profile: recordProfileRef.current,
|
|
runtime: runtimeInfoRef.current,
|
|
})
|
|
Taro.showToast({ title: '录音停止失败,请重试', icon: 'none' })
|
|
}
|
|
}
|
|
|
|
const handlePlayAudio = (message: ChatMessage) => {
|
|
if (message.contentType !== 2 || !message.mediaUrl) {
|
|
Taro.showToast({ title: '语音地址不存在', icon: 'none' })
|
|
return
|
|
}
|
|
|
|
const urlInfo = getAudioUrlDebugInfo(message.mediaUrl)
|
|
console.info('[audio] play request', {
|
|
messageId: message.id,
|
|
conversationId: message.conversationId,
|
|
contentType: message.contentType,
|
|
mediaDurationMs: message.mediaDurationMs,
|
|
mediaMimeType: message.mediaMimeType,
|
|
url: urlInfo,
|
|
runtime: runtimeInfoRef.current,
|
|
})
|
|
if (urlInfo.isHttp) {
|
|
console.warn('[audio] http url may fail in experience/release builds', urlInfo)
|
|
}
|
|
|
|
const audioContext = audioContextRef.current
|
|
if (!audioContext) {
|
|
console.warn('[audio] play skipped: audio context unavailable', runtimeInfoRef.current)
|
|
Taro.showToast({ title: '播放器未就绪', icon: 'none' })
|
|
return
|
|
}
|
|
|
|
if (playingMessageId === message.id) {
|
|
console.info('[audio] stop current', { messageId: message.id })
|
|
audioContext.stop()
|
|
setPlayingMessageId(null)
|
|
return
|
|
}
|
|
|
|
audioContext.stop()
|
|
audioContext.src = message.mediaUrl
|
|
audioContext.autoplay = true
|
|
audioContext.play()
|
|
console.info('[audio] play invoked', {
|
|
messageId: message.id,
|
|
srcRaw: message.mediaUrl,
|
|
src: audioContext.src,
|
|
})
|
|
setPlayingMessageId(message.id)
|
|
}
|
|
|
|
const renderMessageBody = (msg: ChatMessage) => {
|
|
if (msg.contentType === 2 && msg.mediaUrl) {
|
|
const isPlaying = playingMessageId === msg.id
|
|
return (
|
|
<View className={`audio-bubble ${isPlaying ? 'playing' : ''}`} onClick={() => handlePlayAudio(msg)}>
|
|
<Text className='audio-bubble-icon'>{isPlaying ? '[]' : '>'}</Text>
|
|
<Text className='audio-bubble-duration'>{formatAudioDuration(msg.mediaDurationMs)}</Text>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
return <Text>{msg.content}</Text>
|
|
}
|
|
|
|
return (
|
|
<View className='chat-detail-page' style={getPageStyle(true)}>
|
|
<View className='chat-header'>
|
|
<View className='back-btn' onClick={handleBack}>
|
|
<Image src={require('../../../assets/tab-icons/arrow-left.png')} className='back-arrow-img' />
|
|
</View>
|
|
<View className='character-info'>
|
|
<View className='character-avatar'>
|
|
<Image className='chat-avatar-art' src={getParticipantArtwork(character.peerKind, decodedRoleKey)} mode='aspectFit' />
|
|
</View>
|
|
<View className='character-text'>
|
|
<Text className='character-name'>{character.name}</Text>
|
|
<Text className='character-desc'>{character.description}</Text>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
|
|
<ScrollView
|
|
className='chat-list'
|
|
scrollY
|
|
enhanced
|
|
showScrollbar={false}
|
|
scrollWithAnimation={scrollWithAnimation}
|
|
scrollIntoView={scrollTarget}
|
|
>
|
|
{loading ? (
|
|
<View className='empty-chat'>
|
|
<Text className='empty-chat-text'>加载中...</Text>
|
|
</View>
|
|
) : messages.length === 0 ? (
|
|
<View className='empty-chat'>
|
|
<Text className='empty-chat-text'>
|
|
{character.canSend ? '还没有留言,长按发送第一条吧' : '这个会话还没有消息'}
|
|
</Text>
|
|
</View>
|
|
) : (
|
|
messages.map((msg, index) => (
|
|
<View key={msg.id}>
|
|
{(index === 0 || messages[index - 1].time !== msg.time) && (
|
|
<View className='time-divider'>
|
|
<Text>{msg.time}</Text>
|
|
</View>
|
|
)}
|
|
|
|
{msg.type === 'user' ? (
|
|
<View className='message-item user'>
|
|
<View className='message-main'>
|
|
<Text className='message-meta'>{formatParticipantLabel(msg)}</Text>
|
|
<View className='message-bubble'>
|
|
{renderMessageBody(msg)}
|
|
</View>
|
|
</View>
|
|
<View className='user-avatar'>
|
|
<Image className='chat-avatar-art' src={getParticipantArtwork(msg.senderType)} mode='aspectFit' />
|
|
</View>
|
|
</View>
|
|
) : (
|
|
<View className='message-item peer'>
|
|
<View className='peer-avatar'>
|
|
<Image className='chat-avatar-art' src={getParticipantArtwork(msg.senderType || character.peerKind, decodedRoleKey)} mode='aspectFit' />
|
|
</View>
|
|
<View className='message-main'>
|
|
<Text className='message-meta'>{formatParticipantLabel(msg)}</Text>
|
|
<View className='message-bubble'>
|
|
{renderMessageBody(msg)}
|
|
</View>
|
|
</View>
|
|
</View>
|
|
)}
|
|
</View>
|
|
))
|
|
)}
|
|
<View id={CHAT_BOTTOM_ANCHOR_ID} />
|
|
</ScrollView>
|
|
|
|
{character.canSend && character.childId && (
|
|
<View className='composer'>
|
|
<View className='composer-tip'>
|
|
<Text>{sending ? '正在发送留言...' : recordHint}</Text>
|
|
</View>
|
|
<View
|
|
className={`composer-record ${recording ? 'recording' : ''} ${sending ? 'disabled' : ''}`}
|
|
onTouchStart={handleRecordTouchStart}
|
|
onTouchEnd={handleRecordStop}
|
|
onTouchCancel={handleRecordStop}
|
|
>
|
|
<Text className='composer-record-text'>
|
|
{sending ? '发送中...' : recording ? '松开发送' : '按住留言'}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
)}
|
|
{systemBanner}
|
|
</View>
|
|
)
|
|
}
|