Admin 386ca99889
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
deploy: sync FieldLens Print and camera lifecycle
2026-09-23 21:31:42 +00:00

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],
})
}