{"version":3,"file":"AudioPlayer.cjs","names":[],"sources":["../../../src/components/AudioPlayer/AudioPlayer.tsx"],"sourcesContent":["/**\n * @tempest-limits props-count, function-lines — src accepts a URL or a Blob, and\n * everything else is a facet of playing it: durationMs (because MediaRecorder WebM\n * carries none), sinkId (output device), autoPlay/loop, onEnded/onError, plus the\n * actions slot and locale for the labels. The body is the transport state machine —\n * object-URL lifecycle, seek, and the rAF-free time updates — which shares one audio\n * element ref throughout.\n */\nimport { Pause, Play } from \"lucide-react\";\nimport { useEffect, useRef, useState, type HTMLAttributes } from \"react\";\n\nimport { setAudioOutput } from \"@/audio/audio-output\";\nimport { formatDuration } from \"@/audio/duration\";\nimport { useObjectUrl } from \"@/hooks/use-object-url\";\nimport { cn } from \"@/utils/cn\";\n\nimport styles from \"./AudioPlayer.module.css\";\n\n/** DOM attributes this component redefines. */\ntype OverriddenDomProps = \"children\";\n\nexport interface AudioPlayerProps extends Omit<HTMLAttributes<HTMLDivElement>, OverriddenDomProps> {\n    /**\n     * What to play — a URL, or a `Blob`/`File` straight from a recorder.\n     *\n     * A `Blob` is wrapped in an object URL that is revoked when it changes or the\n     * component unmounts, so a page that records twenty notes does not leak twenty\n     * URLs for the lifetime of the tab.\n     */\n    src: string | Blob | null;\n    /**\n     * Known length in milliseconds.\n     *\n     * Pass it whenever you have it — a recording from `useAudioRecorder` always does.\n     * See the note on the seek bar for why the element's own `duration` is not\n     * trustworthy for a fresh recording.\n     */\n    durationMs?: number;\n    /** Output device, from `useMediaDevices().audioOutputs`. Chromium only. */\n    sinkId?: string;\n    /** Start playing as soon as `src` is ready. Default `false`. */\n    autoPlay?: boolean;\n    /** Loop. Default `false`. */\n    loop?: boolean;\n    /** Locale for the labels. Default `\"pt-BR\"`. */\n    locale?: \"pt-BR\" | \"en\";\n    /** Rendered to the right of the times — a download button, a delete button. */\n    actions?: React.ReactNode;\n    /** Fired when playback reaches the end. */\n    onEnded?: () => void;\n    /** Fired when the element reports a decode/network error. */\n    onError?: (error: unknown) => void;\n    /** No `src` yet, or playback not allowed. */\n    disabled?: boolean;\n}\n\nconst STRINGS = {\n    \"pt-BR\": { play: \"Tocar\", pause: \"Pausar\", seek: \"Posição\" },\n    en: { play: \"Play\", pause: \"Pause\", seek: \"Seek\" },\n} as const;\n\n/**\n * Playback transport for one clip: play/pause, a seek bar, elapsed and total time.\n *\n * Built around a real `<audio>` element rather than the SDK's `createAudioPlayer`,\n * which is a fire-and-forget handle for notification chimes and has no transport to\n * expose. Accepts a `Blob` directly, because the thing an app most often plays is the\n * recording it just made.\n *\n * The seek bar is a bare `<input type=\"range\">` rather than the SDK's `Slider`: that\n * component is a form field, with a label row and a value badge, and a transport wants\n * neither. The native input keeps the keyboard and screen-reader behaviour that\n * matters here for free.\n *\n * @example\n * const rec = useAudioRecorder(mic.stream);\n * {rec.recording && (\n *     <AudioPlayer src={rec.recording.blob} durationMs={rec.recording.durationMs} />\n * )}\n */\nexport function AudioPlayer({\n    src,\n    durationMs,\n    sinkId,\n    autoPlay = false,\n    loop = false,\n    locale = \"pt-BR\",\n    actions,\n    onEnded,\n    onError,\n    disabled = false,\n    className,\n    ...rest\n}: AudioPlayerProps) {\n    const strings = STRINGS[locale];\n    const audio = useRef<HTMLAudioElement | null>(null);\n    const [playing, setPlaying] = useState(false);\n    const [currentMs, setCurrentMs] = useState(0);\n    const [elementMs, setElementMs] = useState(Number.POSITIVE_INFINITY);\n    const probed = useRef(false);\n\n    const blobUrl = useObjectUrl(typeof src === \"string\" ? null : src);\n    const url = typeof src === \"string\" ? src : blobUrl;\n\n    /**\n     * Total length, preferring what the caller knows.\n     *\n     * `MediaRecorder` writes WebM with no duration in the header, so a fresh recording\n     * reports `Infinity` — which is why the recorder keeps its own clock and passes it\n     * here. `durationMs` wins whenever it is finite; the element's value is the\n     * fallback for a plain URL.\n     */\n    const totalMs = Number.isFinite(durationMs) ? (durationMs as number) : elementMs;\n    const seekable = Number.isFinite(totalMs) && totalMs > 0;\n\n    useEffect(() => {\n        setCurrentMs(0);\n        setPlaying(false);\n        setElementMs(Number.POSITIVE_INFINITY);\n        probed.current = false;\n    }, [url]);\n\n    useEffect(() => {\n        if (sinkId === undefined) return;\n        void setAudioOutput(audio.current, sinkId);\n    }, [sinkId, url]);\n\n    /**\n     * Coax a duration out of an element that reports `Infinity`.\n     *\n     * Seeking past the end forces the browser to demux to the last frame, after which\n     * it knows the real length. It is a hack with no alternative — the header simply\n     * does not carry the value — and it runs at most once per source, only when the\n     * caller gave us no `durationMs`.\n     */\n    const handleMetadata = (): void => {\n        const node = audio.current;\n        if (!node) return;\n        if (Number.isFinite(node.duration)) {\n            setElementMs(node.duration * 1000);\n            return;\n        }\n        if (probed.current || Number.isFinite(durationMs)) return;\n        probed.current = true;\n        const onSeeked = (): void => {\n            node.removeEventListener(\"timeupdate\", onSeeked);\n            if (Number.isFinite(node.duration)) setElementMs(node.duration * 1000);\n            node.currentTime = 0;\n        };\n        node.addEventListener(\"timeupdate\", onSeeked);\n        node.currentTime = 1e101;\n    };\n\n    const toggle = (): void => {\n        const node = audio.current;\n        if (!node || !url) return;\n        if (node.paused) {\n            void node\n                .play()\n                .then(() => setPlaying(true))\n                .catch((error: unknown) => onError?.(error));\n            return;\n        }\n        node.pause();\n        setPlaying(false);\n    };\n\n    const seek = (ms: number): void => {\n        const node = audio.current;\n        if (!node || !seekable) return;\n        node.currentTime = ms / 1000;\n        setCurrentMs(ms);\n    };\n\n    return (\n        <div className={cn(styles.player, className)} {...rest}>\n            <audio\n                ref={audio}\n                src={url ?? undefined}\n                loop={loop}\n                autoPlay={autoPlay}\n                preload=\"metadata\"\n                onLoadedMetadata={handleMetadata}\n                onDurationChange={handleMetadata}\n                onTimeUpdate={() => {\n                    const node = audio.current;\n                    if (node) setCurrentMs(node.currentTime * 1000);\n                }}\n                onPlay={() => setPlaying(true)}\n                onPause={() => setPlaying(false)}\n                onEnded={() => {\n                    setPlaying(false);\n                    setCurrentMs(0);\n                    onEnded?.();\n                }}\n                onError={(event) => onError?.(event)}\n            />\n\n            <button\n                type=\"button\"\n                className={styles.transport}\n                onClick={toggle}\n                disabled={disabled || !url}\n                aria-label={playing ? strings.pause : strings.play}\n                title={playing ? strings.pause : strings.play}\n            >\n                {playing ? <Pause size={16} aria-hidden /> : <Play size={16} aria-hidden />}\n            </button>\n\n            <input\n                type=\"range\"\n                className={styles.seek}\n                min={0}\n                max={seekable ? Math.round(totalMs) : 0}\n                step={100}\n                value={Math.min(Math.round(currentMs), seekable ? Math.round(totalMs) : 0)}\n                onChange={(event) => seek(Number(event.target.value))}\n                disabled={disabled || !seekable}\n                aria-label={strings.seek}\n                aria-valuetext={`${formatDuration(currentMs)} / ${formatDuration(totalMs)}`}\n            />\n\n            <span className={styles.time}>\n                <span>{formatDuration(currentMs)}</span>\n                <span aria-hidden=\"true\">/</span>\n                <span>{formatDuration(totalMs)}</span>\n            </span>\n\n            {actions && <span className={styles.actions}>{actions}</span>}\n        </div>\n    );\n}\n"],"mappings":"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"}