خطاف Headless
أنشئ واجهة صوتية مخصصة بالكامل باستخدام خطاف React useThunderPhone
يمنحك الخطاف useThunderPhone تحكمًا كاملًا في واجهة المستخدم بينما يدير ThunderPhone جلسة الصوت وتوجيه الصوت وحالة الاتصال. استخدمه عندما تريد واجهة مستخدم مخصصة بالكامل — بأزرارك وتخطيطاتك ورسومك المتحركة وهويتك التجارية — بينما يتولى ThunderPhone كل شيء في الخلفية.
متى تستخدم الخطاف بلا واجهة
يغطي المكوّن ThunderPhoneWidget المُنشأ مسبقًا معظم حالات الاستخدام، لكن استخدم الخطاف بلا واجهة عندما تحتاج إلى:
- واجهة مكالمات مخصصة بالكامل تتوافق مع منظومة تصميم تطبيقك
- تصورات مرئية تتفاعل مع الصوت (أشكال موجية أو كرات أو مؤشرات نابضة) مدفوعة بمستويات الصوت في الوقت الفعلي
- تدفقات مكالمات مخصصة، مثل نماذج ما قبل المكالمة واستبيانات ما بعد المكالمة أو دردشة مضمّنة بجانب الصوت
- التكامل مع مكتبة مكوّنات موجودة (Material UI أو Chakra أو Radix وغيرها)
التثبيت
npm install @thunderphone/widgetالاستخدام الأساسي
import { useThunderPhone } from '@thunderphone/widget'
function CustomCallButton() {
const phone = useThunderPhone({
publishableKey: 'pk_live_your_publishable_key',
})
const handleClick = () => {
if (phone.state === 'connected') {
phone.disconnect()
} else {
phone.connect()
}
}
return (
<>
<button onClick={handleClick} disabled={phone.state === 'connecting'}>
{phone.state === 'connecting'
? 'Connecting...'
: phone.state === 'connected'
? 'End call'
: 'Start call'}
</button>
{phone.audio}
</>
)
}الخيارات
مرّر هذه الخيارات إلى useThunderPhone عبر UseThunderPhoneOptions:
| الخيار | النوع | مطلوب | الافتراضي | الوصف |
|---|---|---|---|---|
publishableKey | string | نعم | -- | مفتاح API قابل للنشر (pk_live_...). يُحدَّد الوكيل تلقائيًا من إعدادات الأداة المرتبطة بالمفتاح. |
apiBase | string | لا | 'https://api.thunderphone.com/v1' | تجاوز عنوان URL الأساسي لواجهة API. |
language | string | لا | -- | تجاوز اللغة لكل جلسة — رمز لغة أو إعدادات محلية مثل en أو es أو fr-FR. عند عدم تعيينه، تُطبّق اللغة المُعدّة للوكيل. |
voice | string | لا | -- | تجاوز الصوت لكل جلسة — اسم صوت مثل maria. عند عدم تعيينه، يُطبّق الصوت المُعدّ للوكيل. |
context | string | لا | -- | سياق واقعي للصفحة أو الموقع لكل جلسة يُمرَّر إلى الوكيل. يُقتطع من جهة الخادم إلى 12,000 حرف. |
onConnect | () => void | لا | -- | يُستدعى عند اتصال جلسة الصوت. |
onDisconnect | () => void | لا | -- | يُستدعى عند انتهاء الجلسة. |
onError | (error) => void | لا | -- | يُستدعى عند حدوث أخطاء. يحتوي الخطأ على الحقلين error (الرمز) وmessage. |
ringtone | boolean | string | لا | false | شغّل نغمة رنين أثناء الاتصال. استخدم true لنغمة الرنين الافتراضية، أو سلسلة URL لصوت مخصص. |
قيمة الإرجاع
يعيد الخطاف كائن UseThunderPhoneReturn:
| الخاصية | النوع | الوصف |
|---|---|---|
state | 'idle' | 'connecting' | 'connected' | 'disconnected' | 'error' | حالة الاتصال الحالية. |
connect | () => void | ابدأ جلسة صوتية. |
disconnect | () => void | أنهِ الجلسة الحالية. |
toggleMute | () => void | بدّل كتم صوت الميكروفون بين التشغيل والإيقاف. |
isMuted | boolean | ما إذا كان الميكروفون مكتومًا حاليًا. |
error | string | undefined | رسالة الخطأ عندما تكون الحالة 'error'. |
agentName | string | undefined | اسم العرض للوكيل المتصل. |
audioLevel | number | مهمل -- دائمًا 0. عنصر نائب ثابت مُحتفَظ به للتوافق مع الإصدارات السابقة؛ لا يتم تحديثه مطلقًا. اقرأ audioLevelRef.current بدلًا من ذلك. |
audioLevelRef | React.RefObject<number> | مرجع قابل للتغيير يحتوي على مستوى الصوت في الوقت الفعلي (0--1) -- الأعلى بين صوت الوكيل وميكروفون الزائر -- ويُحدَّث في كل إطار رسوم متحركة، خارج دورة عرض React. اقرأ audioLevelRef.current داخل حلقات requestAnimationFrame للحصول على رسوم متحركة سلسة دون تقطّع، أو خذ عينة منه على فاصل زمني عندما تحتاج إلى القيمة في حالة React. |
audio | ReactNode | عنصر غير مرئي يتعامل مع اتصال الصوت -- يجب عرضه. |
واجهة مستخدم متفاعلة مع الصوت
يمنحك المرجع audioLevelRef مستويات الصوت بمعدل الإطارات دون تشغيل عمليات إعادة العرض في React، ما يجعله مثاليًا لتشغيل تصورات موجية سلسة، أو كرات نابضة، أو أي حركة مرتبطة بالمحادثة. يعكس المستوى أيهما أعلى صوتًا: صوت الوكيل أو ميكروفون الزائر.
مثال على الشكل الموجي
import { useRef, useEffect } from 'react'
import { useThunderPhone } from '@thunderphone/widget'
function WaveformCall() {
const phone = useThunderPhone({
publishableKey: 'pk_live_your_publishable_key',
})
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
if (phone.state !== 'connected') return
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')!
let animId: number
const draw = () => {
const level = phone.audioLevelRef.current ?? 0
ctx.clearRect(0, 0, canvas.width, canvas.height)
// Draw bars that react to audio level
const barCount = 24
const barWidth = canvas.width / barCount
for (let i = 0; i < barCount; i++) {
const distance = Math.abs(i - barCount / 2) / (barCount / 2)
const height = level * canvas.height * (1 - distance * 0.6)
const y = (canvas.height - height) / 2
ctx.fillStyle = '#0ea5e9'
ctx.fillRect(i * barWidth + 1, y, barWidth - 2, height)
}
animId = requestAnimationFrame(draw)
}
animId = requestAnimationFrame(draw)
return () => cancelAnimationFrame(animId)
}, [phone.state, phone.audioLevelRef])
return (
<div>
{phone.state === 'connected' && (
<canvas ref={canvasRef} width={240} height={80} />
)}
<button
onClick={phone.state === 'connected' ? phone.disconnect : phone.connect}
disabled={phone.state === 'connecting'}
>
{phone.state === 'connected' ? 'End call' : 'Start call'}
</button>
{phone.audio}
</div>
)
}مثال على الكرة النابضة
import { useRef, useEffect } from 'react'
import { useThunderPhone } from '@thunderphone/widget'
function PulsingOrb() {
const phone = useThunderPhone({
publishableKey: 'pk_live_your_publishable_key',
})
const orbRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (phone.state !== 'connected') return
let animId: number
const animate = () => {
const level = phone.audioLevelRef.current ?? 0
if (orbRef.current) {
const scale = 1 + level * 0.5
orbRef.current.style.transform = `scale(${scale})`
orbRef.current.style.opacity = `${0.6 + level * 0.4}`
}
animId = requestAnimationFrame(animate)
}
animId = requestAnimationFrame(animate)
return () => cancelAnimationFrame(animId)
}, [phone.state, phone.audioLevelRef])
return (
<div style={{ textAlign: 'center' }}>
<div
ref={orbRef}
style={{
width: 80,
height: 80,
borderRadius: '50%',
background: '#0ea5e9',
margin: '20px auto',
transition: 'transform 0.05s ease-out',
}}
/>
<button
onClick={phone.state === 'connected' ? phone.disconnect : phone.connect}
disabled={phone.state === 'connecting'}
>
{phone.state === 'connected' ? 'End call' : 'Call'}
</button>
{phone.audio}
</div>
)
}مثال على مؤشر التحدث
بالنسبة إلى واجهة مستخدم معروضة عبر React وتتغير مع مستوى الصوت، مثل شارة «يتحدث» تعتمد على عتبة، خذ عينة من audioLevelRef.current على فترات وخزّن النتيجة في الحالة:
import { useEffect, useState } from 'react'
import { useThunderPhone } from '@thunderphone/widget'
function SpeakingBadge() {
const phone = useThunderPhone({
publishableKey: 'pk_live_your_publishable_key',
})
const [speaking, setSpeaking] = useState(false)
useEffect(() => {
if (phone.state !== 'connected') {
setSpeaking(false)
return
}
const interval = setInterval(() => {
setSpeaking((phone.audioLevelRef.current ?? 0) > 0.1)
}, 100)
return () => clearInterval(interval)
}, [phone.state, phone.audioLevelRef])
return (
<div>
{phone.state === 'connected' && (
<span>{speaking ? 'Speaking' : 'Listening'}</span>
)}
<button onClick={phone.state === 'connected' ? phone.disconnect : phone.connect}>
{phone.state === 'connected' ? 'End call' : 'Start call'}
</button>
{phone.audio}
</div>
)
}آلة الحالات
تتبع الخاصية state دورة الحياة التالية:
idle --> connecting --> connected --> disconnected --> idle (after 1.5s)
\
--> error (stays until connect() is called again)
| الحالة | الوصف |
|---|---|
idle | لا توجد جلسة نشطة. جاهز لاستدعاء connect(). |
connecting | يجري إنشاء الجلسة. عطّل زر الاتصال أثناء هذه الحالة. |
connected | الجلسة الصوتية نشطة. يتحدث المستخدم مع الوكيل. |
disconnected | انتهت الجلسة بشكل سليم. تنتقل تلقائيًا إلى idle بعد 1.5 ثانية. |
error | حدث خطأ ما. تحقّق من phone.error لمعرفة الرسالة. لا تتم إزالة الحالة تلقائيًا -- يؤدي استدعاء connect() مجددًا إلى بدء محاولة جديدة وإعادة تعيين الخطأ. |
أمثلة
مع التحكم في كتم الصوت
import { useThunderPhone } from '@thunderphone/widget'
function CallWithMute() {
const phone = useThunderPhone({
publishableKey: 'pk_live_your_publishable_key',
})
return (
<div>
{phone.state === 'connected' && (
<div>
<p>Talking to {phone.agentName ?? 'Agent'}</p>
<button onClick={phone.toggleMute}>
{phone.isMuted ? 'Unmute' : 'Mute'}
</button>
<button onClick={phone.disconnect}>End call</button>
</div>
)}
{phone.state !== 'connected' && (
<button
onClick={phone.connect}
disabled={phone.state === 'connecting'}
>
{phone.state === 'connecting' ? 'Connecting...' : 'Call support'}
</button>
)}
{phone.state === 'error' && (
<p style={{ color: 'red' }}>{phone.error}</p>
)}
{phone.audio}
</div>
)
}مع نغمة الرنين
شغّل صوت رنين أثناء الاتصال لمحاكاة مكالمة هاتفية:
import { useThunderPhone } from '@thunderphone/widget'
function PhoneCallButton() {
const phone = useThunderPhone({
publishableKey: 'pk_live_your_publishable_key',
ringtone: true, // or a custom URL: 'https://example.com/ringtone.mp3'
})
return (
<>
<button
onClick={phone.state === 'connected' ? phone.disconnect : phone.connect}
disabled={phone.state === 'connecting'}
>
{phone.state === 'connecting'
? 'Ringing...'
: phone.state === 'connected'
? 'Hang up'
: 'Call'}
</button>
{phone.audio}
</>
)
}تتكرر نغمة الرنين أثناء حالة connecting وتتلاشى عند اتصال الوكيل. مرّر true لاستخدام نغمة الرنين الافتراضية المضمّنة، أو سلسلة URL لاستخدام ملفك الصوتي الخاص.
مع استدعاءات الأحداث
import { useThunderPhone } from '@thunderphone/widget'
function TrackedCallButton() {
const phone = useThunderPhone({
publishableKey: 'pk_live_your_publishable_key',
onConnect: () => {
analytics.track('call_started')
},
onDisconnect: () => {
analytics.track('call_ended')
},
onError: (error) => {
analytics.track('call_error', { code: error.error, message: error.message })
},
})
return (
<>
<button
onClick={phone.state === 'connected' ? phone.disconnect : phone.connect}
disabled={phone.state === 'connecting'}
>
{phone.state === 'connected' ? 'Hang up' : 'Talk to AI'}
</button>
{phone.audio}
</>
)
}واجهة مستخدم مخصصة بالكامل
import { useThunderPhone } from '@thunderphone/widget'
function FullCustomUI() {
const phone = useThunderPhone({
publishableKey: 'pk_live_your_publishable_key',
})
return (
<div className="call-panel">
<div className="call-status">
{phone.state === 'idle' && <span>Ready</span>}
{phone.state === 'connecting' && <span className="pulse">Connecting...</span>}
{phone.state === 'connected' && (
<span>On call with {phone.agentName}</span>
)}
{phone.state === 'error' && <span className="error">{phone.error}</span>}
</div>
<div className="call-controls">
{phone.state === 'connected' ? (
<>
<button className="mute-btn" onClick={phone.toggleMute}>
{phone.isMuted ? 'Unmute' : 'Mute'}
</button>
<button className="end-btn" onClick={phone.disconnect}>
End
</button>
</>
) : (
<button
className="start-btn"
onClick={phone.connect}
disabled={phone.state === 'connecting'}
>
Start call
</button>
)}
</div>
{/* Required -- handles audio under the hood */}
{phone.audio}
</div>
)
}نصائح
اعرض phone.audio دائمًا
العنصر phone.audio غير مرئي لكنه مطلوب. ضعه في أي مكان داخل JSX — فهو لا يعرض أي DOM مرئي، لكنه يدير اتصال الصوت عبر WebRTC داخليًا.
عطّل الزر أثناء الاتصال
قد تستمر حالة connecting لمدة 1-3 ثوانٍ. عطّل زر الاتصال خلال هذه الحالة لمنع محاولات الاتصال المكررة.
تعامل مع حالة الخطأ بسلاسة
عندما تكون الحالة error، اعرض phone.error للمستخدم وأبقِ زر الاتصال مفعّلًا. لا تخرج الخطّافة من حالة error تلقائيًا — إذ يؤدي استدعاء connect() مرة أخرى إلى بدء محاولة جديدة ومسح الخطأ السابق.
استخدم ردود النداء للتأثيرات الجانبية
تُعد ردود النداء onConnect وonDisconnect وonError مثالية للتحليلات أو التسجيل أو تشغيل منطق آخر في التطبيق دون استطلاع الحالة.
اقرأ مستويات الصوت من audioLevelRef
يُعد audioLevelRef المصدر الوحيد المباشر لمستوى الصوت. اقرأ audioLevelRef.current داخل requestAnimationFrame للحصول على رسوم متحركة سلسة، مثل الأشكال الموجية (قراءة المرجع لا تؤدي إلى إعادة العرض)، أو خذ عينة منه على فواصل زمنية وخزّن النتيجة في الحالة لواجهة مستخدم يعرضها React. الرقم audioLevel مهمل وتكون قيمته دائمًا 0 — لا تبنِ عليه أي منطق.