{"version":3,"file":"use-screen-capture.cjs","names":[],"sources":["../../src/capture/use-screen-capture.ts"],"sourcesContent":["/**\n * @tempest-limits hook-lines — getDisplayMedia's cancellation is indistinguishable\n * from a policy block, so the hook holds the classification, the track's own `ended`\n * event (the user can stop sharing from the browser's bar) and the stop path in one\n * place — all three end the same session.\n */\nimport { useCallback, useEffect, useRef, useState } from \"react\";\n\nimport {\n    classifyMediaError,\n    missingCaptureApiError,\n    type MediaAccessError,\n} from \"@/audio/media-access\";\nimport { useStableCallback } from \"@/hooks/use-stable-callback\";\n\n/** Lifecycle of a screen share. */\nexport type ScreenCaptureStatus = \"idle\" | \"requesting\" | \"sharing\" | \"error\";\n\n/** Which surface to put first in the picker. */\nexport type DisplaySurfaceHint = \"monitor\" | \"window\" | \"browser\";\n\n/**\n * Hints `getDisplayMedia` takes that TypeScript's `DisplayMediaStreamOptions` does not\n * list yet.\n */\ntype DisplayMediaOptionsWithHints = DisplayMediaStreamOptions & {\n    preferCurrentTab?: boolean;\n    selfBrowserSurface?: \"include\" | \"exclude\";\n    surfaceSwitching?: \"include\" | \"exclude\";\n    systemAudio?: \"include\" | \"exclude\";\n};\n\n/** `displaySurface` is a constrainable property of a display track, but not in the DOM lib. */\ntype DisplayVideoConstraints = MediaTrackConstraints & { displaySurface?: DisplaySurfaceHint };\n\n/** Options for {@link useScreenCapture}. */\nexport interface UseScreenCaptureOptions {\n    /**\n     * Capture the tab's audio too. Default `false`.\n     *\n     * Chromium only offers this for a **tab** — sharing a window or a whole screen\n     * yields no audio track no matter what you ask for, and Safari has no display\n     * audio at all. Ask for it and check what you got.\n     */\n    audio?: boolean;\n    /**\n     * Which surface the picker should offer first — `\"browser\"` is a tab.\n     *\n     * A hint, never a guarantee: the user can always pick something else, and Firefox\n     * ignores it. Read `surface` afterwards to learn what actually happened.\n     */\n    displaySurface?: DisplaySurfaceHint;\n    /**\n     * Put *this* tab at the top of the picker. Default `false`.\n     *\n     * The right setting for \"record what you are seeing right now\" in a support flow.\n     * Chromium only.\n     */\n    preferCurrentTab?: boolean;\n    /** Offer this tab in the list at all. `\"exclude\"` prevents the hall-of-mirrors capture. */\n    selfBrowserSurface?: \"include\" | \"exclude\";\n    /** Let the user switch to a different surface mid-share, without a new prompt. */\n    surfaceSwitching?: \"include\" | \"exclude\";\n    /** Include the system audio when a whole screen is shared. Chromium, Windows only. */\n    systemAudio?: \"include\" | \"exclude\";\n    /** Escape hatch: full options, replacing everything above. */\n    options?: DisplayMediaStreamOptions;\n    /**\n     * The user stopped the share from the browser's own bar.\n     *\n     * The single most important callback here — see the note on\n     * {@link useScreenCapture}.\n     */\n    onEnded?: () => void;\n    /**\n     * The user dismissed the picker. **Not an error.**\n     *\n     * Receives the rejection so an app that needs to tell a dismissal from an OS-level\n     * block (macOS screen-recording permission) can look at the message. Most should\n     * simply return the UI to its previous state.\n     */\n    onCancelled?: (reason: unknown) => void;\n}\n\n/** Value returned by {@link useScreenCapture}. */\nexport interface UseScreenCaptureResult {\n    status: ScreenCaptureStatus;\n    /** The live stream, or `null`. Feed it to {@link useVideoRecorder} or a `<video>`. */\n    stream: MediaStream | null;\n    /** Classified error, or `null`. A cancelled picker leaves this `null`. */\n    error: MediaAccessError | null;\n    /** What the user actually picked, when the browser reports it. */\n    surface: string | null;\n    /** Whether the stream carries an audio track — ask, do not assume. */\n    hasAudio: boolean;\n    /** `false` when `getDisplayMedia` is missing (every browser on iOS, insecure pages). */\n    supported: boolean;\n    /** Open the picker. Must be called from a user gesture. */\n    start: () => void;\n    /** Stop sharing from the app side. Fires nothing — you asked for it. */\n    stop: () => void;\n}\n\n/** Whether `getDisplayMedia` is reachable at all. */\nexport function isScreenCaptureSupported(): boolean {\n    return (\n        typeof navigator !== \"undefined\" &&\n        navigator.mediaDevices !== undefined &&\n        typeof navigator.mediaDevices.getDisplayMedia === \"function\"\n    );\n}\n\n/**\n * A rejection from `getDisplayMedia` that means \"the user said no thanks\".\n *\n * There is no distinct exception for a dismissed picker: closing it produces the same\n * `NotAllowedError` as a policy block, and some builds report `AbortError` instead. The\n * useful default is therefore to treat both as a cancellation, because a\n * display-capture prompt is **always** user-initiated — nothing can open it behind\n * their back — so the overwhelmingly likely cause is that they changed their mind, and\n * a red error toast for that punishes them for it. The rejection is handed to\n * `onCancelled` so the rarer causes stay diagnosable.\n */\nfunction isCancellation(err: unknown): boolean {\n    return (\n        err instanceof DOMException && (err.name === \"NotAllowedError\" || err.name === \"AbortError\")\n    );\n}\n\n/**\n * Capture a screen, a window or a tab with `getDisplayMedia`.\n *\n * Three states decide whether this feels right, and two of them are easy to miss:\n *\n * - **The user dismissed the picker.** A rejection, but not a failure. It leaves\n *   `error` at `null` and the status back at `\"idle\"`, and calls `onCancelled`.\n * - **The user stopped the share from the browser's own bar.** Nothing in your UI was\n *   clicked and no promise rejects — the *only* signal is the video track's `ended`\n *   event, so the hook listens for it and clears the stream. Without that listener, an\n *   app shows \"gravando\" over a stream that is already dead.\n * - **The share is live.** `surface` says what was picked and `hasAudio` says whether\n *   audio actually came along, which is not what you asked for but what you got.\n *\n * The stream is owned here: `stop()` and unmount both release every track. A recorder\n * built on it (`useVideoRecorder`) deliberately does not, so stopping a recording\n * leaves the share running for the next take.\n *\n * @param options - See {@link UseScreenCaptureOptions}.\n * @returns The stream, its status, a classified error and `start`/`stop`.\n *\n * @example\n * const screen = useScreenCapture({ preferCurrentTab: true, onEnded: () => save() });\n * const rec = useVideoRecorder(screen.stream);\n * <button onClick={screen.start}>Compartilhar tela</button>\n */\nexport function useScreenCapture(options: UseScreenCaptureOptions = {}): UseScreenCaptureResult {\n    const {\n        audio = false,\n        displaySurface,\n        preferCurrentTab,\n        selfBrowserSurface,\n        surfaceSwitching,\n        systemAudio,\n        options: raw,\n        onEnded,\n        onCancelled,\n    } = options;\n\n    const [status, setStatus] = useState<ScreenCaptureStatus>(\"idle\");\n    const [stream, setStream] = useState<MediaStream | null>(null);\n    const [error, setError] = useState<MediaAccessError | null>(null);\n    const [surface, setSurface] = useState<string | null>(null);\n    const [hasAudio, setHasAudio] = useState(false);\n    const [supported] = useState(isScreenCaptureSupported);\n\n    const streamRef = useRef<MediaStream | null>(null);\n    const detach = useRef<(() => void) | null>(null);\n    const generation = useRef(0);\n\n    const emitEnded = useStableCallback(() => onEnded?.());\n    const emitCancelled = useStableCallback((reason: unknown) => onCancelled?.(reason));\n\n    const release = useCallback((): void => {\n        detach.current?.();\n        detach.current = null;\n        streamRef.current?.getTracks().forEach((track) => track.stop());\n        streamRef.current = null;\n    }, []);\n\n    const reset = useCallback((): void => {\n        setStream(null);\n        setSurface(null);\n        setHasAudio(false);\n    }, []);\n\n    const stop = useCallback((): void => {\n        generation.current += 1;\n        release();\n        reset();\n        setStatus(\"idle\");\n        setError(null);\n    }, [release, reset]);\n\n    const start = useCallback((): void => {\n        if (streamRef.current) return;\n        const run = (generation.current += 1);\n        setStatus(\"requesting\");\n        setError(null);\n\n        if (!isScreenCaptureSupported()) {\n            setError(missingCaptureApiError(\"screen\"));\n            setStatus(\"error\");\n            return;\n        }\n\n        const video: DisplayVideoConstraints = displaySurface ? { displaySurface } : {};\n        const request: DisplayMediaOptionsWithHints = raw ?? {\n            video,\n            audio,\n            ...(preferCurrentTab !== undefined ? { preferCurrentTab } : {}),\n            ...(selfBrowserSurface !== undefined ? { selfBrowserSurface } : {}),\n            ...(surfaceSwitching !== undefined ? { surfaceSwitching } : {}),\n            ...(systemAudio !== undefined ? { systemAudio } : {}),\n        };\n\n        void navigator.mediaDevices\n            .getDisplayMedia(request)\n            .then((next) => {\n                if (run !== generation.current) {\n                    next.getTracks().forEach((track) => track.stop());\n                    return;\n                }\n                streamRef.current = next;\n                const track = next.getVideoTracks()[0];\n                const ended = (): void => {\n                    if (run !== generation.current) return;\n                    release();\n                    reset();\n                    setStatus(\"idle\");\n                    emitEnded();\n                };\n                track?.addEventListener(\"ended\", ended);\n                detach.current = () => track?.removeEventListener(\"ended\", ended);\n\n                setStream(next);\n                setSurface(track?.getSettings().displaySurface ?? null);\n                setHasAudio(next.getAudioTracks().length > 0);\n                setStatus(\"sharing\");\n            })\n            .catch((err: unknown) => {\n                if (run !== generation.current) return;\n                if (isCancellation(err)) {\n                    setStatus(\"idle\");\n                    emitCancelled(err);\n                    return;\n                }\n                setError(classifyMediaError(err, \"screen\"));\n                setStatus(\"error\");\n            });\n    }, [\n        audio,\n        displaySurface,\n        preferCurrentTab,\n        selfBrowserSurface,\n        surfaceSwitching,\n        systemAudio,\n        raw,\n        release,\n        reset,\n        emitEnded,\n        emitCancelled,\n    ]);\n\n    useEffect(() => release, [release]);\n\n    return { status, stream, error, surface, hasAudio, supported, start, stop };\n}\n"],"mappings":"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"}