claude-code-best/packages/remote-control-server/web/src/hooks/useQRScanner.ts
2026-05-01 21:39:30 +08:00

164 lines
4.3 KiB
TypeScript

import { useState, useEffect, useRef, useCallback } from 'react'
import QrScanner from 'qr-scanner'
/** QR code data format for scanning */
export interface QRCodeData {
url: string
token: string
}
export interface UseQRScannerOptions {
/** Called when a valid QR code is scanned */
onScan: (data: QRCodeData) => void
/** Called when an error occurs */
onError?: (error: string) => void
}
export interface UseQRScannerResult {
/** Whether the scanner is currently active */
isScanning: boolean
/** Ref to attach to the video element */
videoRef: React.RefObject<HTMLVideoElement | null>
/** Start scanning */
startScanning: () => void
/** Stop scanning */
stopScanning: () => void
/** Scan QR code from a file (e.g., from photo album) */
scanFromFile: (file: File) => Promise<void>
}
/**
* Hook for QR code scanning functionality.
* Manages QrScanner lifecycle and camera access.
*/
export function useQRScanner({
onScan,
onError,
}: UseQRScannerOptions): UseQRScannerResult {
const [isScanning, setIsScanning] = useState(false)
const videoRef = useRef<HTMLVideoElement | null>(null)
const qrScannerRef = useRef<QrScanner | null>(null)
// Store callbacks in refs to avoid re-creating scanner when callbacks change
// This allows callers to pass inline functions without causing re-renders
const onScanRef = useRef(onScan)
const onErrorRef = useRef(onError)
// Keep refs up to date
useEffect(() => {
onScanRef.current = onScan
onErrorRef.current = onError
}, [onScan, onError])
const startScanning = useCallback(() => {
setIsScanning(true)
}, [])
const stopScanning = useCallback(() => {
if (qrScannerRef.current) {
qrScannerRef.current.stop()
qrScannerRef.current.destroy()
qrScannerRef.current = null
}
setIsScanning(false)
}, [])
// Scan QR code from a file (photo album)
const scanFromFile = useCallback(async (file: File) => {
try {
const result = await QrScanner.scanImage(file, {
returnDetailedScanResult: true,
})
const data = JSON.parse(result.data) as QRCodeData
if (data.url && data.token) {
onScanRef.current(data)
} else {
onErrorRef.current?.('Invalid QR code: missing url or token')
}
} catch (e) {
const message = e instanceof Error ? e.message : 'No QR code found'
onErrorRef.current?.(message)
}
}, [])
// Initialize scanner when isScanning becomes true
useEffect(() => {
if (!isScanning || !videoRef.current) return
let isCancelled = false
let scanner: QrScanner | null = null
const initScanner = async () => {
try {
const newScanner = new QrScanner(
videoRef.current!,
result => {
try {
const data = JSON.parse(result.data) as QRCodeData
if (data.url && data.token) {
// Stop scanning and notify
newScanner.stop()
newScanner.destroy()
qrScannerRef.current = null
setIsScanning(false)
onScanRef.current(data)
}
} catch {
// Not valid JSON, ignore
}
},
{
returnDetailedScanResult: true,
highlightScanRegion: true,
highlightCodeOutline: true,
},
)
if (isCancelled) {
newScanner.destroy()
return
}
scanner = newScanner
qrScannerRef.current = newScanner
await newScanner.start()
if (isCancelled) {
newScanner.stop()
newScanner.destroy()
qrScannerRef.current = null
}
} catch (e) {
if (!isCancelled) {
onErrorRef.current?.(`Camera error: ${(e as Error).message}`)
setIsScanning(false)
}
}
}
initScanner()
return () => {
isCancelled = true
if (scanner) {
scanner.stop()
scanner.destroy()
}
if (qrScannerRef.current) {
qrScannerRef.current.stop()
qrScannerRef.current.destroy()
qrScannerRef.current = null
}
}
}, [isScanning]) // Only depend on isScanning, callbacks are accessed via refs
return {
isScanning,
videoRef,
startScanning,
stopScanning,
scanFromFile,
}
}