UNPKG

sanity-plugin-mux-input

Version:

An input component that integrates Sanity Studio with Mux video encoding/hosting service.

1 lines 317 kB
{"version":3,"file":"index.mjs","sources":["../src/components/icons/ToolIcon.tsx","../src/hooks/useClient.ts","../src/util/createSearchFilter.ts","../src/hooks/useAssets.ts","../src/util/parsers.ts","../src/hooks/useMuxAssets.ts","../src/util/constants.ts","../src/hooks/useSecretsDocumentValues.ts","../src/hooks/useImportMuxAssets.ts","../src/hooks/useInView.ts","../src/util/readSecrets.ts","../src/util/generateJwt.ts","../src/util/getPlaybackId.ts","../src/util/getPlaybackPolicy.ts","../src/util/createUrlParamsObject.ts","../src/util/getAnimatedPosterSrc.ts","../src/util/getPosterSrc.ts","../src/components/VideoThumbnail.tsx","../src/components/ImportVideosFromMux.tsx","../src/components/SelectSortOptions.tsx","../src/components/SpinnerBox.tsx","../src/components/FormField.tsx","../src/components/IconInfo.tsx","../src/components/icons/Resolution.tsx","../src/components/icons/StopWatch.tsx","../src/context/DialogStateContext.tsx","../src/util/getVideoSrc.ts","../node_modules/use-device-pixel-ratio/dist/index.module.js","../src/util/formatSeconds.ts","../src/components/EditThumbnailDialog.tsx","../src/components/VideoPlayer.tsx","../src/actions/assets.ts","../src/components/documentPreview/MissingSchemaType.tsx","../src/components/documentPreview/TimeAgo.tsx","../src/components/documentPreview/DraftStatus.tsx","../src/components/documentPreview/PublishedStatus.tsx","../src/components/documentPreview/PaneItemPreview.tsx","../src/components/documentPreview/DocumentPreview.tsx","../src/components/VideoDetails/VideoReferences.tsx","../src/components/VideoDetails/DeleteDialog.tsx","../src/hooks/useDocReferences.ts","../src/util/getVideoMetadata.ts","../src/components/VideoDetails/useVideoDetails.ts","../src/components/VideoDetails/VideoDetails.tsx","../src/components/VideoMetadata.tsx","../src/components/VideoInBrowser.tsx","../src/components/VideosBrowser.tsx","../src/components/StudioTool.tsx","../src/hooks/useAssetDocumentValues.ts","../src/hooks/useDialogState.ts","../src/hooks/useMuxPolling.ts","../src/actions/secrets.ts","../src/hooks/useSaveSecrets.ts","../src/hooks/useSecretsFormState.ts","../src/components/MuxLogo.tsx","../src/components/ConfigureApi.styled.tsx","../src/components/ConfigureApi.tsx","../node_modules/use-error-boundary/lib/index.module.js","../src/components/ErrorBoundaryCard.tsx","../src/components/Input.styled.tsx","../src/hooks/useAccessControl.ts","../src/components/Onboard.tsx","../src/clients/upChunkObservable.ts","../src/actions/upload.ts","../src/util/asserters.ts","../src/util/extractFiles.ts","../src/components/SelectAsset.tsx","../src/components/InputBrowser.tsx","../src/hooks/useCancelUpload.ts","../src/components/Player.styled.tsx","../src/components/UploadProgress.tsx","../src/components/Player.tsx","../src/components/withFocusRing/helpers.ts","../src/components/FileInputMenuItem.styled.tsx","../src/components/FileInputMenuItem.tsx","../src/components/PlayerActionsMenu.tsx","../src/util/formatBytes.ts","../src/util/types.ts","../src/components/TextTracksEditor.tsx","../src/components/uploadConfiguration/PlaybackPolicyOption.tsx","../src/components/uploadConfiguration/PlaybackPolicyWarning.tsx","../src/components/uploadConfiguration/PlaybackPolicy.tsx","../src/components/UploadConfiguration.tsx","../src/components/withFocusRing/withFocusRing.ts","../src/components/Uploader.styled.tsx","../src/components/FileInputButton.tsx","../src/components/UploadPlaceholder.tsx","../src/components/Uploader.tsx","../src/components/Input.tsx","../src/plugin.tsx","../src/schema.ts","../src/_exports/index.ts"],"sourcesContent":["/**\n * Icon of a monitor with a play button.\n * Credits: material design icons & react-icons\n */\nconst ToolIcon = () => (\n <svg\n stroke=\"currentColor\"\n fill=\"currentColor\"\n strokeWidth=\"0\"\n viewBox=\"0 0 24 24\"\n height=\"1em\"\n width=\"1em\"\n xmlns=\"http://www.w3.org/2000/svg\"\n >\n <path d=\"M21 3H3c-1.11 0-2 .89-2 2v12c0 1.1.89 2 2 2h5v2h8v-2h5c1.1 0 1.99-.9 1.99-2L23 5c0-1.11-.9-2-2-2zm0 14H3V5h18v12zm-5-6l-7 4V7z\" />\n </svg>\n)\n\nexport default ToolIcon\n","// As it's required to specify the API Version this custom hook ensures it's all using the same version\nimport {useClient as useSanityClient} from 'sanity'\n\nexport const SANITY_API_VERSION = '2024-03-05'\n\nexport function useClient() {\n return useSanityClient({apiVersion: SANITY_API_VERSION})\n}\n","// Adaptation of Sanity's createSearchQuery for our limited use case:\n// https://github.com/sanity-io/sanity/blob/next/packages/sanity/src/core/search/weighted/createSearchQuery.ts\nimport {compact, toLower, trim, uniq, words} from 'lodash'\n\nconst SPECIAL_CHARS = /([^!@#$%^&*(),\\\\/?\";:{}|[\\]+<>\\s-])+/g\nconst STRIP_EDGE_CHARS = /(^[.]+)|([.]+$)/\n\nfunction tokenize(string: string): string[] {\n return (string.match(SPECIAL_CHARS) || []).map((token) => token.replace(STRIP_EDGE_CHARS, ''))\n}\n\nfunction toGroqParams(terms: string[]): Record<string, string> {\n const params: Record<string, string> = {}\n return terms.reduce((acc, term, i) => {\n acc[`t${i}`] = `*${term}*` // \"t\" is short for term\n return acc\n }, params)\n}\n\n/**\n * Convert a string into an array of tokenized terms.\n *\n * Any (multi word) text wrapped in double quotes will be treated as \"phrases\", or separate tokens that\n * will not have its special characters removed.\n * E.g.`\"the\" \"fantastic mr\" fox fox book` =\\> [\"the\", `\"fantastic mr\"`, \"fox\", \"book\"]\n *\n * Phrases wrapped in quotes are assigned relevance scoring differently from regular words.\n *\n * @internal\n */\nfunction extractTermsFromQuery(query: string): string[] {\n const quotedQueries = [] as string[]\n const unquotedQuery = query.replace(/(\"[^\"]*\")/g, (match) => {\n if (words(match).length > 1) {\n quotedQueries.push(match)\n return ''\n }\n return match\n })\n\n // Lowercase and trim quoted queries\n const quotedTerms = quotedQueries.map((str) => trim(toLower(str)))\n\n /**\n * Convert (remaining) search query into an array of deduped, sanitized tokens.\n * All white space and special characters are removed.\n * e.g. \"The saint of Saint-Germain-des-Prés\" =\\> ['the', 'saint', 'of', 'germain', 'des', 'pres']\n */\n const remainingTerms = uniq(compact(tokenize(toLower(unquotedQuery))))\n\n return [...quotedTerms, ...remainingTerms]\n}\n\n/**\n * Create GROQ constraints, given search terms and the full spec of available document types and fields.\n * Essentially a large list of all possible fields (joined by logical OR) to match our search terms against.\n */\nfunction createConstraints(terms: string[], includeAssetId: boolean) {\n const searchPaths = includeAssetId ? ['filename', 'assetId'] : ['filename']\n const constraints = terms\n .map((_term, i) => searchPaths.map((joinedPath) => `${joinedPath} match $t${i}`))\n .filter((constraint) => constraint.length > 0)\n\n return constraints.map((constraint) => `(${constraint.join(' || ')})`)\n}\n\nexport function createSearchFilter(query: string) {\n const terms = extractTermsFromQuery(query)\n\n return {\n filter: createConstraints(terms, query.length >= 8), // if the search is big enough, include the assetId (mux id) in the results\n params: {\n ...toGroqParams(terms),\n },\n }\n}\n","import {useMemo, useState} from 'react'\nimport {collate, createHookFromObservableFactory, DocumentStore, useDocumentStore} from 'sanity'\n\nimport {SANITY_API_VERSION} from '../hooks/useClient'\nimport {createSearchFilter} from '../util/createSearchFilter'\nimport type {VideoAssetDocument} from '../util/types'\n\nexport const ASSET_SORT_OPTIONS = {\n createdDesc: {groq: '_createdAt desc', label: 'Newest first'},\n createdAsc: {groq: '_createdAt asc', label: 'First created (oldest)'},\n filenameAsc: {groq: 'filename asc', label: 'By filename (A-Z)'},\n filenameDesc: {groq: 'filename desc', label: 'By filename (Z-A)'},\n}\n\nexport type SortOption = keyof typeof ASSET_SORT_OPTIONS\n\nconst useAssetDocuments = createHookFromObservableFactory<\n VideoAssetDocument[],\n {\n documentStore: DocumentStore\n sort: SortOption\n searchQuery: string\n }\n>(({documentStore, sort, searchQuery}) => {\n const search = createSearchFilter(searchQuery)\n const filter = [`_type == \"mux.videoAsset\"`, ...search.filter].filter(Boolean).join(' && ')\n\n const sortFragment = ASSET_SORT_OPTIONS[sort].groq\n return documentStore.listenQuery(\n /* groq */ `*[${filter}] | order(${sortFragment})`,\n search.params,\n {\n apiVersion: SANITY_API_VERSION,\n }\n )\n})\n\nexport default function useAssets() {\n const documentStore = useDocumentStore()\n const [sort, setSort] = useState<SortOption>('createdDesc')\n const [searchQuery, setSearchQuery] = useState('')\n\n const [assetDocuments = [], isLoading] = useAssetDocuments(\n useMemo(() => ({documentStore, sort, searchQuery}), [documentStore, sort, searchQuery])\n )\n\n const assets = useMemo(\n () =>\n // Avoid displaying both drafts & published assets by collating them together and giving preference to drafts\n collate<VideoAssetDocument>(assetDocuments).map(\n (collated) =>\n ({\n ...(collated.draft || collated.published || {}),\n _id: collated.id,\n }) as VideoAssetDocument\n ),\n [assetDocuments]\n )\n\n return {\n assets,\n isLoading,\n sort,\n searchQuery,\n setSort,\n setSearchQuery,\n }\n}\n","import type {MuxAsset} from './types'\n\nexport function parseMuxDate(date: MuxAsset['created_at']): Date {\n return new Date(Number(date) * 1000)\n}\n","import {useEffect, useState} from 'react'\nimport {defer, of, timer} from 'rxjs'\nimport {concatMap, expand, tap} from 'rxjs/operators'\n\nimport type {MuxAsset, Secrets} from '../util/types'\n\nconst FIRST_PAGE = 1\nconst ASSETS_PER_PAGE = 100\n\ntype MuxAssetsState = {\n pageNum: number\n loading: boolean\n data?: MuxAsset[]\n error?: FetchError\n}\n\ntype FetchError =\n | {\n _tag: 'FetchError'\n }\n | {_tag: 'MuxError'; error: unknown}\n\ntype PageResult = (\n | {\n data: MuxAsset[]\n }\n | {\n error: FetchError\n }\n) & {\n pageNum: number\n}\n\n/**\n * @docs {@link https://docs.mux.com/api-reference#video/operation/list-assets}\n */\nasync function fetchMuxAssetsPage(\n {secretKey, token}: Secrets,\n pageNum: number\n): Promise<PageResult> {\n try {\n const res = await fetch(\n `https://api.mux.com/video/v1/assets?limit=${ASSETS_PER_PAGE}&page=${pageNum}`,\n {\n headers: {\n Authorization: `Basic ${btoa(`${token}:${secretKey}`)}`,\n },\n }\n )\n const json = await res.json()\n\n if (json.error) {\n return {\n pageNum,\n error: {\n _tag: 'MuxError',\n error: json.error,\n },\n }\n }\n\n return {\n pageNum,\n data: json.data as MuxAsset[],\n }\n } catch (error) {\n return {\n pageNum,\n error: {_tag: 'FetchError'},\n }\n }\n}\n\nfunction accumulateIntermediateState(\n currentState: MuxAssetsState,\n pageResult: PageResult\n): MuxAssetsState {\n const currentData = ('data' in currentState && currentState.data) || []\n return {\n ...currentState,\n data: [\n ...currentData,\n ...(('data' in pageResult && pageResult.data) || []).filter(\n // De-duplicate assets for safety\n (asset) => !currentData.some((a) => a.id === asset.id)\n ),\n ],\n error:\n 'error' in pageResult\n ? pageResult.error\n : // Reset error if current page is successful\n undefined,\n pageNum: pageResult.pageNum,\n loading: true,\n }\n}\n\nfunction hasMorePages(pageResult: PageResult) {\n return (\n typeof pageResult === 'object' &&\n 'data' in pageResult &&\n Array.isArray(pageResult.data) &&\n pageResult.data.length > 0\n )\n}\n\n/**\n * Fetches all assets from a Mux environment. Rules:\n * - One page at a time\n * - Mux has no information on pagination\n * - We've finished fetching if a page returns `data.length === 0`\n * - Rate limiting to one request per 2 seconds\n * - Update state while still fetching to give feedback to users\n */\nexport default function useMuxAssets({secrets, enabled}: {enabled: boolean; secrets: Secrets}) {\n const [state, setState] = useState<MuxAssetsState>({loading: true, pageNum: FIRST_PAGE})\n\n useEffect(() => {\n if (!enabled) return\n\n const subscription = defer(() =>\n fetchMuxAssetsPage(\n secrets,\n // When we've already successfully loaded before (fully or partially), we start from the following page to avoid re-fetching\n 'data' in state && state.data && state.data.length > 0 && !state.error\n ? state.pageNum + 1\n : state.pageNum\n )\n )\n .pipe(\n // Here we replace \"concatMap\" with \"expand\" to recursively fetch next pages\n expand((pageResult) => {\n // if fetched page has data, we continue emitting, requesting the next page\n // after 2s to avoid rate limiting\n if (hasMorePages(pageResult)) {\n return timer(2000).pipe(\n // eslint-disable-next-line max-nested-callbacks\n concatMap(() => defer(() => fetchMuxAssetsPage(secrets, pageResult.pageNum + 1)))\n )\n }\n\n // Else, we stop emitting\n return of()\n }),\n\n // On each iteration, persist intermediate states to give feedback to users\n tap((pageResult) =>\n setState((prevState) => accumulateIntermediateState(prevState, pageResult))\n )\n )\n .subscribe({\n // Once done, let the user know we've stopped loading\n complete: () => {\n setState((prev) => ({\n ...prev,\n loading: false,\n }))\n },\n })\n\n // Unsubscribe on component unmount to prevent memory leaks or fetching unnecessarily\n // eslint-disable-next-line consistent-return\n return () => subscription.unsubscribe()\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [enabled])\n\n return state\n}\n","export const name = 'mux-input' as const\n\n// Caching namespace, as suspend-react might be in use by other components on the page we must ensure we don't collide\nexport const cacheNs = 'sanity-plugin-mux-input' as const\n\nexport const muxSecretsDocumentId = 'secrets.mux' as const\n\nexport const DIALOGS_Z_INDEX = 60_000\n\nexport const THUMBNAIL_ASPECT_RATIO = 16 / 9\n\n/** To prevent excessive height, thumbnails and input should not go beyond to this aspect ratio. */\nexport const MIN_ASPECT_RATIO = 5 / 4\n\nexport const AUDIO_ASPECT_RATIO = 5 / 1\n","import {useMemo} from 'react'\nimport {useDocumentValues} from 'sanity'\n\nimport {muxSecretsDocumentId} from '../util/constants'\nimport type {Secrets} from '../util/types'\n\nconst path = ['token', 'secretKey', 'enableSignedUrls', 'signingKeyId', 'signingKeyPrivate']\nexport const useSecretsDocumentValues = () => {\n const {error, isLoading, value} = useDocumentValues<Partial<Secrets> | null | undefined>(\n muxSecretsDocumentId,\n path\n )\n const cache = useMemo(() => {\n const exists = Boolean(value)\n const secrets: Secrets = {\n token: value?.token || null,\n secretKey: value?.secretKey || null,\n enableSignedUrls: value?.enableSignedUrls || false,\n signingKeyId: value?.signingKeyId || null,\n signingKeyPrivate: value?.signingKeyPrivate || null,\n }\n return {\n isInitialSetup: !exists,\n needsSetup: !secrets?.token || !secrets?.secretKey,\n secrets,\n }\n }, [value])\n\n return {error, isLoading, value: cache}\n}\n","import {uuid} from '@sanity/uuid'\nimport {useMemo, useState} from 'react'\nimport {\n createHookFromObservableFactory,\n type DocumentStore,\n truncateString,\n useClient,\n useDocumentStore,\n} from 'sanity'\n\nimport {parseMuxDate} from '../util/parsers'\nimport type {MuxAsset, VideoAssetDocument} from '../util/types'\nimport {SANITY_API_VERSION} from './useClient'\nimport useMuxAssets from './useMuxAssets'\nimport {useSecretsDocumentValues} from './useSecretsDocumentValues'\n\ntype ImportState = 'closed' | 'idle' | 'importing' | 'done' | 'error'\n\nexport type AssetInSanity = {\n uploadId: string\n assetId: string\n}\n\nexport default function useImportMuxAssets() {\n const documentStore = useDocumentStore()\n const client = useClient({\n apiVersion: SANITY_API_VERSION,\n })\n\n const [assetsInSanity, assetsInSanityLoading] = useAssetsInSanity(documentStore)\n\n const secretDocumentValues = useSecretsDocumentValues()\n const hasSecrets = !!secretDocumentValues.value.secrets?.secretKey\n\n const [importError, setImportError] = useState<unknown>()\n const [importState, setImportState] = useState<ImportState>('closed')\n const dialogOpen = importState !== 'closed'\n\n const muxAssets = useMuxAssets({\n secrets: secretDocumentValues.value.secrets,\n enabled: hasSecrets && dialogOpen,\n })\n\n const missingAssets = useMemo(() => {\n return assetsInSanity && muxAssets.data\n ? muxAssets.data.filter((a) => !assetExistsInSanity(a, assetsInSanity))\n : undefined\n }, [assetsInSanity, muxAssets.data])\n\n const [selectedAssets, setSelectedAssets] = useState<MuxAsset[]>([])\n\n const closeDialog = () => {\n if (importState !== 'importing') setImportState('closed')\n }\n const openDialog = () => {\n if (importState === 'closed') setImportState('idle')\n }\n\n async function importAssets() {\n setImportState('importing')\n const documents = selectedAssets.flatMap((asset) => muxAssetToSanityDocument(asset) || [])\n\n const tx = client.transaction()\n documents.forEach((doc) => tx.create(doc))\n\n try {\n await tx.commit({returnDocuments: false})\n setSelectedAssets([])\n setImportState('done')\n } catch (error) {\n setImportState('error')\n setImportError(error)\n }\n }\n\n return {\n assetsInSanityLoading,\n closeDialog,\n dialogOpen,\n importState,\n importError,\n hasSecrets,\n importAssets,\n missingAssets,\n muxAssets,\n openDialog,\n selectedAssets,\n setSelectedAssets,\n }\n}\n\nfunction muxAssetToSanityDocument(asset: MuxAsset): VideoAssetDocument | undefined {\n const playbackId = (asset.playback_ids || []).find((p) => p.id)?.id\n\n if (!playbackId) return undefined\n\n return {\n _id: uuid(),\n _type: 'mux.videoAsset',\n _updatedAt: new Date().toISOString(),\n _createdAt: parseMuxDate(asset.created_at).toISOString(),\n assetId: asset.id,\n playbackId,\n filename: asset.meta?.title ?? `Asset #${truncateString(asset.id, 15)}`,\n status: asset.status,\n data: asset,\n }\n}\n\nconst useAssetsInSanity = createHookFromObservableFactory<AssetInSanity[], DocumentStore>(\n (documentStore) => {\n return documentStore.listenQuery(\n /* groq */ `*[_type == \"mux.videoAsset\"] {\n \"uploadId\": coalesce(uploadId, data.upload_id),\n \"assetId\": coalesce(assetId, data.id),\n }`,\n {},\n {\n apiVersion: SANITY_API_VERSION,\n }\n )\n }\n)\n\nfunction assetExistsInSanity(asset: MuxAsset, existingAssets: AssetInSanity[]) {\n // Don't allow importing assets that are not ready\n if (asset.status !== 'ready') return false\n\n return existingAssets.some(\n (existing) => existing.assetId === asset.id || existing.uploadId === asset.upload_id\n )\n}\n","import {useEffect, useState} from 'react'\n\ntype IntersectionOptions = {\n root?: Element | null\n rootMargin?: string\n threshold?: number\n onChange?: (inView: boolean) => void\n}\n\nexport function useInView(\n ref: React.RefObject<HTMLDivElement | null>,\n options: IntersectionOptions = {}\n) {\n const [inView, setInView] = useState(false)\n\n useEffect(() => {\n if (!ref.current) return\n\n const observer = new IntersectionObserver(([entry], obs) => {\n // ==== from react-intersection-observer ====\n // While it would be nice if you could just look at isIntersecting to determine if the component is inside the viewport, browsers can't agree on how to use it.\n // -Firefox ignores `threshold` when considering `isIntersecting`, so it will never be false again if `threshold` is > 0\n const nowInView =\n entry.isIntersecting &&\n obs.thresholds.some((threshold) => entry.intersectionRatio >= threshold)\n\n // Update our state when observer callback fires\n setInView(nowInView)\n options?.onChange?.(nowInView)\n }, options)\n\n const toObserve = ref.current\n observer.observe(toObserve)\n\n // eslint-disable-next-line\n return () => {\n if (toObserve) observer.unobserve(toObserve)\n }\n }, [options, ref])\n\n return inView\n}\n","// Utils with a readName prefix are suspendable and should only be called in the render body\n// Not inside event callbacks or a useEffect.\n// They may be called dynamically, unlike useEffect\n\n// @TODO rename to readSigningPair\n\nimport type {SanityClient} from 'sanity'\nimport {suspend} from 'suspend-react'\n\nimport {cacheNs} from '../util/constants'\nimport {type Secrets} from '../util/types'\n\nexport const _id = 'secrets.mux' as const\n\nexport function readSecrets(client: SanityClient): Secrets {\n const {projectId, dataset} = client.config()\n return suspend(async () => {\n const data = await client.fetch(\n /* groq */ `*[_id == $_id][0]{\n token,\n secretKey,\n enableSignedUrls,\n signingKeyId,\n signingKeyPrivate\n }`,\n {_id}\n )\n return {\n token: data?.token || null,\n secretKey: data?.secretKey || null,\n enableSignedUrls: Boolean(data?.enableSignedUrls) || false,\n signingKeyId: data?.signingKeyId || null,\n signingKeyPrivate: data?.signingKeyPrivate || null,\n }\n }, [cacheNs, _id, projectId, dataset])\n}\n","import type {SanityClient} from 'sanity'\nimport {suspend} from 'suspend-react'\n\nimport {readSecrets} from './readSecrets'\nimport type {AnimatedThumbnailOptions, ThumbnailOptions} from './types'\n\nexport type Audience = 'g' | 's' | 't' | 'v'\n\nexport type Payload<T extends Audience> = T extends 'g'\n ? AnimatedThumbnailOptions\n : T extends 's'\n ? never\n : T extends 't'\n ? ThumbnailOptions\n : T extends 'v'\n ? never\n : never\n\nexport function generateJwt<T extends Audience>(\n client: SanityClient,\n playbackId: string,\n aud: T,\n payload?: Payload<T>\n): string {\n const {signingKeyId, signingKeyPrivate} = readSecrets(client)\n if (!signingKeyId) {\n throw new TypeError(\"Missing `signingKeyId`.\\n Check your plugin's configuration\")\n }\n if (!signingKeyPrivate) {\n throw new TypeError(\"Missing `signingKeyPrivate`.\\n Check your plugin's configuration\")\n }\n\n // @ts-expect-error - handle missing typings for this package\n const {default: sign} = suspend(() => import('jsonwebtoken-esm/sign'), ['jsonwebtoken-esm/sign'])\n\n return sign(\n payload ? JSON.parse(JSON.stringify(payload, (_, v) => v ?? undefined)) : {},\n atob(signingKeyPrivate),\n {\n algorithm: 'RS256',\n keyid: signingKeyId,\n audience: aud,\n subject: playbackId,\n noTimestamp: true,\n expiresIn: '12h',\n }\n )\n}\n","import type {VideoAssetDocument} from './types'\n\nexport function getPlaybackId(asset: Pick<VideoAssetDocument, 'playbackId'>): string {\n if (!asset?.playbackId) {\n console.error('Asset is missing a playbackId', {asset})\n throw new TypeError(`Missing playbackId`)\n }\n return asset.playbackId\n}\n","import type {PlaybackPolicy, VideoAssetDocument} from './types'\n\nexport function getPlaybackPolicy(\n asset: Pick<VideoAssetDocument, 'data' | 'playbackId'>\n): PlaybackPolicy {\n return (\n asset.data?.playback_ids?.find((playbackId) => asset.playbackId === playbackId.id)?.policy ??\n 'public'\n )\n}\n","import type {SanityClient} from 'sanity'\n\nimport {Audience, generateJwt} from './generateJwt'\nimport {getPlaybackId} from './getPlaybackId'\nimport {getPlaybackPolicy} from './getPlaybackPolicy'\nimport type {AssetThumbnailOptions} from './types'\n\nexport function createUrlParamsObject(\n client: SanityClient,\n asset: AssetThumbnailOptions['asset'],\n params: object,\n audience: Audience\n) {\n const playbackId = getPlaybackId(asset)\n\n let searchParams = new URLSearchParams(\n JSON.parse(JSON.stringify(params, (_, v) => v ?? undefined))\n )\n if (getPlaybackPolicy(asset) === 'signed') {\n const token = generateJwt(client, playbackId, audience, params)\n searchParams = new URLSearchParams({token})\n }\n\n return {playbackId, searchParams}\n}\n","import type {SanityClient} from 'sanity'\n\nimport {createUrlParamsObject} from './createUrlParamsObject'\nimport type {AnimatedThumbnailOptions, MuxAnimatedThumbnailUrl} from './types'\nimport {AssetThumbnailOptions} from './types'\n\nexport interface AnimatedPosterSrcOptions extends AnimatedThumbnailOptions {\n asset: AssetThumbnailOptions['asset']\n client: SanityClient\n}\n\nexport function getAnimatedPosterSrc({\n asset,\n client,\n height,\n width,\n start = asset.thumbTime ? Math.max(0, asset.thumbTime - 2.5) : 0,\n end = start + 5,\n fps = 15,\n}: AnimatedPosterSrcOptions): MuxAnimatedThumbnailUrl {\n const params = {height, width, start, end, fps}\n\n const {playbackId, searchParams} = createUrlParamsObject(client, asset, params, 'g')\n\n return `https://image.mux.com/${playbackId}/animated.gif?${searchParams}`\n}\n","import type {SanityClient} from 'sanity'\n\nimport {createUrlParamsObject} from './createUrlParamsObject'\nimport type {MuxThumbnailUrl, ThumbnailOptions} from './types'\nimport {AssetThumbnailOptions} from './types'\n\nexport interface PosterSrcOptions extends ThumbnailOptions {\n asset: AssetThumbnailOptions['asset']\n client: SanityClient\n}\n\nexport function getPosterSrc({\n asset,\n client,\n fit_mode,\n height,\n time = asset.thumbTime ?? undefined,\n width,\n}: PosterSrcOptions): MuxThumbnailUrl {\n const params = {fit_mode, height, width}\n if (time) {\n ;(params as any).time = time\n }\n\n const {playbackId, searchParams} = createUrlParamsObject(client, asset, params, 't')\n\n return `https://image.mux.com/${playbackId}/thumbnail.png?${searchParams}`\n}\n","import {ErrorOutlineIcon} from '@sanity/icons'\nimport {Box, Card, CardTone, Spinner, Stack, Text} from '@sanity/ui'\nimport {useMemo, useRef, useState} from 'react'\nimport {styled} from 'styled-components'\n\nimport {useClient} from '../hooks/useClient'\nimport {useInView} from '../hooks/useInView'\nimport {THUMBNAIL_ASPECT_RATIO} from '../util/constants'\nimport {getAnimatedPosterSrc} from '../util/getAnimatedPosterSrc'\nimport {getPosterSrc} from '../util/getPosterSrc'\nimport {AssetThumbnailOptions, MuxAnimatedThumbnailUrl, MuxThumbnailUrl} from '../util/types'\n\nconst Image = styled.img`\n transition: opacity 0.175s ease-out 0s;\n display: block;\n width: 100%;\n height: 100%;\n object-fit: contain;\n object-position: center center;\n`\n\ntype ImageStatus = 'loading' | 'error' | 'loaded'\n\nconst STATUS_TO_TONE: Record<ImageStatus, CardTone> = {\n loading: 'transparent',\n error: 'critical',\n loaded: 'default',\n}\n\nexport default function VideoThumbnail({\n asset,\n width,\n staticImage = false,\n}: {\n asset: AssetThumbnailOptions['asset']\n width?: number\n staticImage?: boolean\n}) {\n const ref = useRef<HTMLDivElement | null>(null)\n const inView = useInView(ref)\n const posterWidth = width || 250\n\n const [status, setStatus] = useState<ImageStatus>('loading')\n const client = useClient()\n\n const src = useMemo(() => {\n try {\n let thumbnail: MuxAnimatedThumbnailUrl | MuxThumbnailUrl\n if (staticImage) thumbnail = getPosterSrc({asset, client, width: posterWidth})\n else thumbnail = getAnimatedPosterSrc({asset, client, width: posterWidth})\n\n return thumbnail\n } catch {\n if (status !== 'error') setStatus('error')\n return undefined\n }\n }, [asset, client, posterWidth, status, staticImage])\n\n function handleLoad() {\n setStatus('loaded')\n }\n\n function handleError() {\n setStatus('error')\n }\n\n return (\n <Card\n style={{\n aspectRatio: THUMBNAIL_ASPECT_RATIO,\n position: 'relative',\n maxWidth: width ? `${width}px` : undefined,\n width: '100%',\n flex: 1,\n }}\n border\n radius={2}\n ref={ref}\n tone={STATUS_TO_TONE[status]}\n >\n {inView ? (\n <>\n {status === 'loading' && (\n <Box\n style={{\n position: 'absolute',\n left: '50%',\n top: '50%',\n transform: 'translate(-50%, -50%)',\n }}\n >\n <Spinner />\n </Box>\n )}\n {status === 'error' && (\n <Stack\n space={4}\n style={{\n position: 'absolute',\n width: '100%',\n left: 0,\n top: '50%',\n transform: 'translateY(-50%)',\n justifyItems: 'center',\n }}\n >\n <Text size={4} muted>\n <ErrorOutlineIcon style={{fontSize: '1.75em'}} />\n </Text>\n <Text muted align=\"center\">\n Failed loading thumbnail\n </Text>\n </Stack>\n )}\n <Image\n src={src}\n alt={`Preview for ${staticImage ? 'image' : 'video'} ${asset.filename || asset.assetId}`}\n onLoad={handleLoad}\n onError={handleError}\n style={{opacity: status === 'loaded' ? 1 : 0}}\n />\n </>\n ) : null}\n </Card>\n )\n}\n","import {CheckmarkCircleIcon, ErrorOutlineIcon, RetrieveIcon, RetryIcon} from '@sanity/icons'\nimport {\n Box,\n Button,\n Card,\n Checkbox,\n Code,\n Dialog,\n Flex,\n Heading,\n Spinner,\n Stack,\n Text,\n} from '@sanity/ui'\nimport {truncateString, useFormattedDuration} from 'sanity'\nimport {styled} from 'styled-components'\n\nimport useImportMuxAssets from '../hooks/useImportMuxAssets'\nimport {DIALOGS_Z_INDEX} from '../util/constants'\nimport type {MuxAsset} from '../util/types'\nimport VideoThumbnail from './VideoThumbnail'\n\nconst MissingAssetCheckbox = styled(Checkbox)`\n position: static !important;\n\n input::after {\n content: '';\n position: absolute;\n inset: 0;\n display: block;\n cursor: pointer;\n z-index: 1000;\n }\n`\n\nfunction MissingAsset({\n asset,\n selectAsset,\n selected,\n}: {\n asset: MuxAsset\n selectAsset: (selected: boolean) => void\n selected: boolean\n}) {\n const duration = useFormattedDuration(asset.duration * 1000)\n\n return (\n <Card\n key={asset.id}\n tone={selected ? 'positive' : undefined}\n border\n paddingX={2}\n paddingY={3}\n style={{position: 'relative'}}\n radius={1}\n >\n <Flex align=\"center\" gap={2}>\n <MissingAssetCheckbox\n checked={selected}\n onChange={(e) => {\n selectAsset(e.currentTarget.checked)\n }}\n aria-label={selected ? `Import video ${asset.id}` : `Skip import of video ${asset.id}`}\n />\n <VideoThumbnail\n asset={{\n assetId: asset.id,\n data: asset,\n filename: asset.id,\n playbackId: asset.playback_ids.find((p) => p.id)?.id,\n }}\n width={150}\n />\n <Stack space={2}>\n <Flex align=\"center\" gap={1}>\n <Code size={2}>{truncateString(asset.id, 15)}</Code>{' '}\n <Text muted size={2}>\n ({duration.formatted})\n </Text>\n </Flex>\n <Text size={1}>\n Uploaded at{' '}\n {new Date(Number(asset.created_at) * 1000).toLocaleDateString('en', {\n year: 'numeric',\n day: '2-digit',\n month: '2-digit',\n })}\n </Text>\n </Stack>\n </Flex>\n </Card>\n )\n}\n\n// eslint-disable-next-line complexity\nfunction ImportVideosDialog(props: ReturnType<typeof useImportMuxAssets>) {\n const {importState} = props\n\n const canTriggerImport =\n (importState === 'idle' || importState === 'error') && props.selectedAssets.length > 0\n const isImporting = importState === 'importing'\n const noAssetsToImport =\n props.missingAssets?.length === 0 && !props.muxAssets.loading && !props.assetsInSanityLoading\n\n return (\n <Dialog\n animate\n header={'Import videos from Mux'}\n zOffset={DIALOGS_Z_INDEX}\n id=\"video-details-dialog\"\n onClose={props.closeDialog}\n onClickOutside={props.closeDialog}\n width={1}\n position=\"fixed\"\n footer={\n importState !== 'done' &&\n !noAssetsToImport && (\n <Card padding={3}>\n <Flex justify=\"space-between\" align=\"center\">\n <Button\n fontSize={2}\n padding={3}\n mode=\"bleed\"\n text=\"Cancel\"\n tone=\"critical\"\n onClick={props.closeDialog}\n disabled={isImporting}\n />\n {props.missingAssets && (\n <Button\n icon={RetrieveIcon}\n fontSize={2}\n padding={3}\n mode=\"ghost\"\n text={\n props.selectedAssets?.length > 0\n ? `Import ${props.selectedAssets.length} video(s)`\n : 'No video(s) selected'\n }\n tone=\"positive\"\n onClick={props.importAssets}\n iconRight={isImporting && Spinner}\n disabled={!canTriggerImport}\n />\n )}\n </Flex>\n </Card>\n )\n }\n >\n <Box padding={3}>\n {/* LOADING ASSETS STATE */}\n {(props.muxAssets.loading || props.assetsInSanityLoading) && (\n <Card tone=\"primary\" marginBottom={5} padding={3} border>\n <Flex align=\"center\" gap={4}>\n <Spinner muted size={4} />\n <Stack space={2}>\n <Text size={2} weight=\"semibold\">\n Loading assets from Mux\n </Text>\n <Text size={1}>\n This may take a while.\n {props.missingAssets &&\n props.missingAssets.length > 0 &&\n ` There are at least ${props.missingAssets.length} video${props.missingAssets.length > 1 ? 's' : ''} currently not in Sanity...`}\n </Text>\n </Stack>\n </Flex>\n </Card>\n )}\n\n {/* ERROR LOADING MUX */}\n {props.muxAssets.error && (\n <Card tone=\"critical\" marginBottom={5} padding={3} border>\n <Flex align=\"center\" gap={2}>\n <ErrorOutlineIcon fontSize={36} />\n <Stack space={2}>\n <Text size={2} weight=\"semibold\">\n There was an error getting all data from Mux\n </Text>\n <Text size={1}>\n {props.missingAssets\n ? `But we've found ${props.missingAssets.length} video${props.missingAssets.length > 1 ? 's' : ''} not in Sanity, which you can start importing now.`\n : 'Please try again or contact a developer for help.'}\n </Text>\n </Stack>\n </Flex>\n </Card>\n )}\n\n {/* IMPORTING STATE */}\n {importState === 'importing' && (\n <Card tone=\"primary\" marginBottom={5} padding={3} border>\n <Flex align=\"center\" gap={4}>\n <Spinner muted size={4} />\n <Stack space={2}>\n <Text size={2} weight=\"semibold\">\n Importing {props.selectedAssets.length} video\n {props.selectedAssets.length > 1 && 's'} from Mux\n </Text>\n </Stack>\n </Flex>\n </Card>\n )}\n\n {/* ERROR IMPORTING */}\n {importState === 'error' && (\n <Card tone=\"critical\" marginBottom={5} padding={3} border>\n <Flex align=\"center\" gap={2}>\n <ErrorOutlineIcon fontSize={36} />\n <Stack space={2}>\n <Text size={2} weight=\"semibold\">\n There was an error importing videos\n </Text>\n <Text size={1}>\n {props.importError\n ? `Error: ${props.importError}`\n : 'Please try again or contact a developer for help.'}\n </Text>\n <Box marginTop={1}>\n <Button\n icon={RetryIcon}\n text=\"Retry\"\n tone=\"primary\"\n onClick={props.importAssets}\n />\n </Box>\n </Stack>\n </Flex>\n </Card>\n )}\n\n {/* NO ASSETS TO IMPORT or SUCESS STATE */}\n {(noAssetsToImport || importState === 'done') && (\n <Stack paddingY={5} marginBottom={4} space={3} style={{textAlign: 'center'}}>\n <Box>\n <CheckmarkCircleIcon fontSize={48} />\n </Box>\n <Heading size={2}>\n {importState === 'done'\n ? `Videos imported successfully`\n : 'There are no Mux videos to import'}\n </Heading>\n <Text size={2}>\n {importState === 'done'\n ? 'You can now use them in your Sanity content.'\n : \"They're all in Sanity and ready to be used in your content.\"}\n </Text>\n </Stack>\n )}\n\n {/* MISSING ASSETS SELECTOR */}\n {props.missingAssets &&\n props.missingAssets.length > 0 &&\n (importState === 'idle' || importState === 'error') && (\n <Stack space={4}>\n <Heading size={1}>\n There are {props.missingAssets.length}\n {props.muxAssets.loading && '+'} Mux video{props.missingAssets.length > 1 && 's'}{' '}\n not in Sanity\n </Heading>\n {!props.muxAssets.loading && (\n <Flex align=\"center\" paddingX={2}>\n <Checkbox\n id=\"import-all\"\n style={{display: 'block'}}\n onClick={(e) => {\n const selectAll = e.currentTarget.checked\n if (selectAll) {\n // eslint-disable-next-line no-unused-expressions\n props.missingAssets && props.setSelectedAssets(props.missingAssets)\n } else {\n props.setSelectedAssets([])\n }\n }}\n checked={props.selectedAssets.length === props.missingAssets.length}\n />\n <Box flex={1} paddingLeft={3} as=\"label\" htmlFor=\"import-all\">\n <Text>Import all</Text>\n </Box>\n </Flex>\n )}\n {props.missingAssets.map((asset) => (\n <MissingAsset\n key={asset.id}\n asset={asset}\n selectAsset={(selected) => {\n if (selected) {\n props.setSelectedAssets([...props.selectedAssets, asset])\n } else {\n props.setSelectedAssets(props.selectedAssets.filter((a) => a.id !== asset.id))\n }\n }}\n selected={props.selectedAssets.some((a) => a.id === asset.id)}\n />\n ))}\n </Stack>\n )}\n </Box>\n </Dialog>\n )\n}\n\nexport default function ImportVideosFromMux() {\n const importAssets = useImportMuxAssets()\n\n if (!importAssets.hasSecrets) {\n return\n }\n\n if (importAssets.dialogOpen) {\n // eslint-disable-next-line consistent-return\n return <ImportVideosDialog {...importAssets} />\n }\n\n // eslint-disable-next-line consistent-return\n return <Button mode=\"bleed\" text=\"Import from Mux\" onClick={importAssets.openDialog} />\n}\n","import {SortIcon} from '@sanity/icons'\nimport {Button, Menu, MenuButton, MenuItem, PopoverProps} from '@sanity/ui'\nimport {useId} from 'react'\n\nimport {ASSET_SORT_OPTIONS, SortOption} from '../hooks/useAssets'\n\nexport const CONTEXT_MENU_POPOVER_PROPS: PopoverProps = {\n constrainSize: true,\n placement: 'bottom',\n portal: true,\n width: 0,\n}\n\n/**\n * @sanity/ui components adapted from:\n * https://github.com/sanity-io/sanity/blob/next/packages/sanity/src/desk/components/pane/PaneContextMenuButton.tsx#L19\n */\nexport function SelectSortOptions(props: {sort: SortOption; setSort: (s: SortOption) => void}) {\n const id = useId()\n\n return (\n <MenuButton\n button={\n <Button text=\"Sort\" icon={SortIcon} mode=\"bleed\" padding={3} style={{cursor: 'pointer'}} />\n }\n id={id}\n menu={\n <Menu>\n {Object.entries(ASSET_SORT_OPTIONS).map(([type, {label}]) => (\n <MenuItem\n key={type}\n data-as=\"button\"\n onClick={() => props.setSort(type as SortOption)}\n padding={3}\n tone=\"default\"\n text={label}\n pressed={type === props.sort}\n />\n ))}\n </Menu>\n }\n popover={CONTEXT_MENU_POPOVER_PROPS}\n />\n )\n}\n","import {Box, Spinner} from '@sanity/ui'\n\nconst SpinnerBox: React.FC = () => (\n <Box\n style={{\n display: 'flex',\n alignItems: 'center',\n justifyContent: 'center',\n minHeight: '150px',\n }}\n >\n <Spinner />\n </Box>\n)\n\nexport default SpinnerBox\n","import {Box, Flex, Stack, Text} from '@sanity/ui'\nimport React, {memo} from 'react'\n\n// @TODO: get rid of this once v3 core is stable\n\nexport interface Props {\n children: React.ReactNode\n title: React.ReactNode\n description?: React.ReactNode\n inputId: string\n}\n\nfunction FormField(props: Props) {\n const {children, title, description, inputId} = props\n\n return (\n <Stack space={1}>\n <Flex align=\"flex-end\">\n <Box flex={1} paddingY={2}>\n <Stack space={2}>\n <Text as=\"label\" htmlFor={inputId} weight=\"semibold\" size={1}>\n {title || <em>Untitled</em>}\n </Text>\n\n {description && (\n <Text muted size={1}>\n {description}\n </Text>\n )}\n </Stack>\n </Box>\n </Flex>\n <div>{children}</div>\n </Stack>\n )\n}\n\nexport default memo(FormField)\n","import {Flex, Text} from '@sanity/ui'\n\nconst IconInfo: React.FC<{\n text: string\n icon: React.FC\n size?: number\n muted?: boolean\n}> = (props) => {\n const Icon = props.icon\n return (\n <Flex gap={2} align=\"center\" padding={1}>\n <Text size={(props.size || 1) + 1} muted>\n <Icon />\n </Text>\n <Text size={props.size || 1} muted={props.muted}>\n {props.text}\n </Text>\n </Flex>\n )\n}\n\nexport default IconInfo\n","import type {SVGProps} from 'react'\n\nexport function ResolutionIcon(props: SVGProps<SVGSVGElement>) {\n return (\n <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\" {...props}>\n <path\n fill=\"currentColor\"\n d=\"M20 9V6h-3V4h5v5h-2ZM2 9V4h5v2H4v3H2Zm15 11v-2h3v-3h2v5h-5ZM2 20v-5h2v3h3v2H2Zm4-4V8h12v8H6Zm2-2h8v-4H8v4Zm0 0v-4v4Z\"\n />\n </svg>\n )\n}\n","import type {SVGProps} from 'react'\n\nexport function StopWatchIcon(props: SVGProps<SVGSVGElement>) {\n return (\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"1em\"\n height=\"1em\"\n viewBox=\"0 0 512 512\"\n {...props}\n >\n <path d=\"M232 306.667h48V176h-48v130.667z\" fill=\"currentColor\" />\n <path\n d=\"M407.67 170.271l30.786-30.786-33.942-33.941-30.785 30.786C341.217 111.057 300.369 96 256 96 149.961 96 64 181.961 64 288s85.961 192 192 192 192-85.961 192-192c0-44.369-15.057-85.217-40.33-117.729zm-45.604 223.795C333.734 422.398 296.066 438 256 438s-77.735-15.602-106.066-43.934C121.602 365.735 106 328.066 106 288s15.602-77.735 43.934-106.066C178.265 153.602 215.934 138 256 138s77.734 15.602 106.066 43.934C390.398 210.265 406 247.934 406 288s-15.602 77.735-43.934 106.066z\"\n fill=\"currentColor\"\n />\n <path d=\"M192 32h128v48H192z\" fill=\"currentColor\" />\n </svg>\n )\n}\n","import React, {createContext, useContext} from 'react'\n\nimport {type DialogState, type SetDialogState} from '../hooks/useDialogState'\n\ntype DialogStateContextProps = {\n dialogState: DialogState\n setDialogState: SetDialogState\n}\n\nconst DialogStateContext = createContext<DialogStateContextProps>({\n dialogState: false,\n setDialogState: () => {\n return null\n },\n})\n\ninterface DialogStateProviderProps extends DialogStateContextProps {\n children: React.ReactNode\n}\n\nexport const DialogStateProvider = ({\n dialogState,\n setDialogState,\n children,\n}: DialogStateProviderProps) => {\n return (\n <DialogStateContext.Provider value={{dialogState, setDialogState}}>\n {children}\n </DialogStateContext.Provider>\n )\n}\n\nexport const useDialogStateContext = () => {\n const context = useContext(DialogStateContext)\n return context\n}\n","import type {SanityClient} from 'sanity'\n\nimport {generateJwt} from './generateJwt'\nimport {getPlaybackId} from './getPlaybackId'\nimport {getPlaybackPolicy} from './getPlaybackPolicy'\nimport type {MuxVideoUrl, VideoAssetDocument} from './types'\n\ninterface VideoSrcOptions {\n asset: VideoAssetDocument\n client: SanityClient\n}\n\nexport function getVideoSrc({asset, client}: VideoSrcOptions): MuxVideoUrl {\n const playbackId = getPlaybackId(asset)\n const searchParams = new URLSearchParams()\n\n if (getPlaybackPolicy(asset) === 'signed') {\n const token = generateJwt(client, playbackId, 'v')\n searchParams.set('token', token)\n }\n\n return `https://stream.mux.com/${playbackId}.m3u8?${searchParams}`\n}\n","import { useState, useEffect } from 'react';\n\n/**\r\n * Get the device pixel ratio, potentially rounded and capped.\r\n * Will emit new values if it changes.\r\n *\r\n * @param options\r\n * @returns The current device pixel ratio, or the default if none can be resolved\r\n */\n\nfunction useDevicePixelRatio(options) {\n const dpr = getDevicePixelRatio(options);\n const [currentDpr, setCurrentDpr] = useState(dpr);\n const {\n defaultDpr,\n maxDpr,\n round\n } = options || {};\n useEffect(() => {\n const canListen = typeof window !== 'undefined' && 'matchMedia' in window;\n\n if (!canListen) {\n return;\n }\n\n const updateDpr = () => setCurrentDpr(getDevicePixelRatio({\n defaultDpr,\n maxDpr,\n round\n }));\n\n const mediaMatcher = window.matchMedia(`screen and (resolution: ${currentDpr}dppx)`); // Safari 13.1 does not have `addEventListener`, but does have `addListener`\n\n if (mediaMatcher.addEventListener) {\n mediaMatcher.addEventListener('change', updateDpr);\n } else {\n mediaMatcher.addListener(updateDpr);\n }\n\n return () => {\n if (mediaMatcher.removeEventListener) {\n mediaMatcher.removeEventListener('change', updateDpr);\n } else {\n mediaMatcher.removeListener(updateDpr);\n }\n };\n }, [currentDpr, defaultDpr, maxDpr, round]);\n return currentDpr;\n}\n/**\r\n * Returns the current device pixel ratio (DPR) given the passed options\r\n *\r\n * @param options\r\n * @returns current device pixel ratio\r\n */\n\nfunction getDevicePixelRatio(options) {\n const {\n defaultDpr = 1,\n maxDpr = 3,\n round = true\n } = options || {};\n const hasDprProp = typeof window !== 'undefined' && typeof window.devicePixelRatio === 'number';\n const dpr = hasDprProp ? window.devicePixelRatio : defaultDpr;\n const rounded = Math.min(Math.max(1, round ? Math.floor(dpr) : dpr), maxDpr);\n return rounded;\n}\n\nexport { getDevicePixelRatio, useDevicePixelRatio };\n//# sourceMappingURL=index.module.js.map\n","/* eslint-disable */\n// From: https://stackoverflow.com/a/11486026/10433647\nexport function formatSeconds(seconds: number): string {\n if (typeof seconds !== 'number' || Number.isNaN(seconds)) {\n return ''\n }\n // Hours, minutes and seconds\n const hrs = ~~(seconds / 3600)\n const mins = ~~((seconds % 3600) / 60)\n const secs = ~~seconds % 60\n\n // Output like \"1:01\" or \"4:03:59\" or \"123:03:59\"\n let ret = ''\n\n if (hrs > 0) {\n ret += '' + hrs + ':' + (mins < 10 ? '0' : '')\n }\n\n ret += '' + mins + ':' + (secs < 10 ? '0' : '')\n ret += '' + secs\n return ret\n}\n\n// Output like \"05:14:01\"\nexport function formatSecondsToHHMMSS(seconds: number): string {\n const hrs = Math.floor(seconds / 3600)\n .toString()\n .padStart(2, '0')\n const mins = Math.floor((seconds % 3600) / 60)\n .toString()\n .p