Some checks failed
CI / verify (push) Has been cancelled
CI / print-rust (push) Has been cancelled
Desktop Release / Windows x64 NSIS (push) Has been cancelled
Desktop Release / Linux x64 DEB (push) Has been cancelled
Desktop Release / Publish tagged desktop release (push) Has been cancelled
146 lines
4.3 KiB
TypeScript
146 lines
4.3 KiB
TypeScript
const CAMERA_GRANTED_KEY = 'fieldlens.camera-granted'
|
|
const DEFAULT_RELEASE_DELAY_MS = 8_000
|
|
|
|
export type CameraPermissionState = PermissionState | 'unsupported'
|
|
|
|
let sharedStream: MediaStream | null = null
|
|
let releaseTimer: number | null = null
|
|
|
|
function cancelScheduledRelease() {
|
|
if (releaseTimer !== null) {
|
|
window.clearTimeout(releaseTimer)
|
|
releaseTimer = null
|
|
}
|
|
}
|
|
|
|
function liveVideoTrack(stream: MediaStream | null) {
|
|
return stream?.getVideoTracks().find((track) => track.readyState === 'live') ?? null
|
|
}
|
|
|
|
function stopStream(stream: MediaStream | null) {
|
|
stream?.getTracks().forEach((track) => track.stop())
|
|
}
|
|
|
|
export async function readCameraPermission(): Promise<CameraPermissionState> {
|
|
if (!navigator.permissions?.query) return 'unsupported'
|
|
try {
|
|
const status = await navigator.permissions.query({ name: 'camera' as PermissionName })
|
|
return status.state
|
|
} catch {
|
|
return 'unsupported'
|
|
}
|
|
}
|
|
|
|
export function rememberCameraGrant() {
|
|
try {
|
|
window.localStorage.setItem(CAMERA_GRANTED_KEY, '1')
|
|
} catch {
|
|
// Private browsing/storage policy should not prevent camera use.
|
|
}
|
|
}
|
|
|
|
export function hasRememberedCameraGrant() {
|
|
try {
|
|
return window.localStorage.getItem(CAMERA_GRANTED_KEY) === '1'
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
export function isIosDevice(userAgent = navigator.userAgent, platform = navigator.platform, maxTouchPoints = navigator.maxTouchPoints) {
|
|
return /iPad|iPhone|iPod/.test(userAgent) || (platform === 'MacIntel' && maxTouchPoints > 1)
|
|
}
|
|
|
|
export function isStandaloneDisplay() {
|
|
return window.matchMedia('(display-mode: standalone)').matches
|
|
|| Boolean((navigator as Navigator & { standalone?: boolean }).standalone)
|
|
}
|
|
|
|
export function shouldAutoStartCamera(permission: CameraPermissionState) {
|
|
if (permission === 'granted') return true
|
|
if (permission === 'denied' || permission === 'prompt') return false
|
|
|
|
// Safari/WebKit does not consistently expose camera through Permissions API.
|
|
// Reuse a previous grant in normal Safari, but require an explicit tap after a
|
|
// fresh iOS Home Screen PWA launch to avoid surprise permission prompts.
|
|
return hasRememberedCameraGrant() && !(isIosDevice() && isStandaloneDisplay())
|
|
}
|
|
|
|
export function cameraPermissionHelp() {
|
|
if (isIosDevice()) {
|
|
return 'Allow Camera for FieldLens in Safari website settings, then return here and retry.'
|
|
}
|
|
if (/Android/i.test(navigator.userAgent)) {
|
|
return 'In Chrome, open Site settings → Camera → Allow, then return here and retry.'
|
|
}
|
|
return 'Allow camera access for this site in your browser settings, then retry.'
|
|
}
|
|
|
|
export async function acquireCameraStream(deviceId = '') {
|
|
cancelScheduledRelease()
|
|
|
|
const existingTrack = liveVideoTrack(sharedStream)
|
|
if (existingTrack) {
|
|
const currentDeviceId = existingTrack.getSettings().deviceId ?? ''
|
|
if (!deviceId || !currentDeviceId || currentDeviceId === deviceId) {
|
|
return sharedStream!
|
|
}
|
|
|
|
stopStream(sharedStream)
|
|
sharedStream = null
|
|
}
|
|
|
|
const stream = await navigator.mediaDevices.getUserMedia({
|
|
audio: false,
|
|
video: deviceId
|
|
? { deviceId: { exact: deviceId } }
|
|
: { facingMode: { ideal: 'environment' } },
|
|
})
|
|
|
|
sharedStream = stream
|
|
rememberCameraGrant()
|
|
|
|
const track = liveVideoTrack(stream)
|
|
if (track) {
|
|
track.addEventListener('ended', () => {
|
|
if (sharedStream === stream) sharedStream = null
|
|
}, { once: true })
|
|
}
|
|
|
|
return stream
|
|
}
|
|
|
|
export function releaseCameraStream(delayMs = DEFAULT_RELEASE_DELAY_MS) {
|
|
cancelScheduledRelease()
|
|
if (!sharedStream) return
|
|
|
|
releaseTimer = window.setTimeout(() => {
|
|
stopStream(sharedStream)
|
|
sharedStream = null
|
|
releaseTimer = null
|
|
}, delayMs)
|
|
}
|
|
|
|
export function stopCameraStream() {
|
|
cancelScheduledRelease()
|
|
stopStream(sharedStream)
|
|
sharedStream = null
|
|
}
|
|
|
|
export function getCameraTrack(stream: MediaStream | null) {
|
|
return liveVideoTrack(stream)
|
|
}
|
|
|
|
export function cameraTrackHasTorch(track: MediaStreamTrack | null) {
|
|
if (!track?.getCapabilities) return false
|
|
const capabilities = track.getCapabilities() as MediaTrackCapabilities & { torch?: boolean }
|
|
return Boolean(capabilities.torch)
|
|
}
|
|
|
|
export async function setCameraTorch(track: MediaStreamTrack | null, enabled: boolean) {
|
|
if (!track) throw new Error('No active camera track')
|
|
await track.applyConstraints({
|
|
advanced: [{ torch: enabled } as MediaTrackConstraintSet],
|
|
})
|
|
}
|