sanity-plugin-mux-input 2.13.0 → 2.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +25 -24
  2. package/dist/index.d.mts +35 -2
  3. package/dist/index.d.ts +35 -2
  4. package/dist/index.js +2176 -461
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +2178 -463
  7. package/dist/index.mjs.map +1 -1
  8. package/package.json +1 -1
  9. package/src/_exports/index.ts +1 -0
  10. package/src/actions/assets.ts +75 -0
  11. package/src/actions/secrets.ts +6 -1
  12. package/src/actions/upload.ts +1 -1
  13. package/src/components/AddCaptionDialog.tsx +421 -0
  14. package/src/components/CaptionsDialog.tsx +23 -0
  15. package/src/components/ConfigureApi.tsx +51 -5
  16. package/src/components/EditCaptionDialog.tsx +508 -0
  17. package/src/components/InputBrowser.tsx +8 -2
  18. package/src/components/Onboard.tsx +2 -2
  19. package/src/components/PageSelector.tsx +54 -0
  20. package/src/components/Player.styled.tsx +7 -2
  21. package/src/components/PlayerActionsMenu.tsx +14 -6
  22. package/src/components/SelectAsset.tsx +9 -3
  23. package/src/components/StudioTool.tsx +2 -2
  24. package/src/components/TextTracksManager.tsx +781 -0
  25. package/src/components/UploadConfiguration.tsx +104 -343
  26. package/src/components/Uploader.styled.tsx +8 -15
  27. package/src/components/Uploader.tsx +25 -7
  28. package/src/components/VideoDetails/VideoDetails.tsx +43 -7
  29. package/src/components/VideoInBrowser.tsx +53 -6
  30. package/src/components/VideoPlayer.tsx +122 -47
  31. package/src/components/VideoThumbnail.tsx +84 -72
  32. package/src/components/VideosBrowser.tsx +15 -5
  33. package/src/components/uploadConfiguration/PlaybackPolicy.tsx +95 -6
  34. package/src/components/uploadConfiguration/PlaybackPolicyOption.tsx +26 -10
  35. package/src/components/uploadConfiguration/ResolutionTierSelector.tsx +71 -0
  36. package/src/components/uploadConfiguration/StaticRenditionSelector.tsx +179 -0
  37. package/src/context/DrmPlaybackWarningContext.tsx +93 -0
  38. package/src/hooks/useAccessControl.ts +1 -0
  39. package/src/hooks/useDialogState.ts +1 -1
  40. package/src/hooks/useFetchFileSize.ts +54 -0
  41. package/src/hooks/useMediaMetadata.ts +100 -0
  42. package/src/hooks/useSaveSecrets.ts +10 -3
  43. package/src/hooks/useSecretsDocumentValues.ts +9 -1
  44. package/src/hooks/useSecretsFormState.ts +6 -3
  45. package/src/util/asserters.ts +14 -0
  46. package/src/util/createUrlParamsObject.ts +7 -3
  47. package/src/util/generateJwt.ts +11 -2
  48. package/src/util/getPlaybackPolicy.ts +63 -4
  49. package/src/util/getStoryboardSrc.ts +7 -3
  50. package/src/util/getVideoMetadata.ts +4 -1
  51. package/src/util/getVideoSrc.ts +9 -9
  52. package/src/util/readSecrets.ts +3 -1
  53. package/src/util/textTracks.ts +222 -0
  54. package/src/util/tryWithSuspend.ts +22 -0
  55. package/src/util/types.ts +39 -6
  56. package/src/util/getPlaybackId.ts +0 -9
@@ -0,0 +1,179 @@
1
+ import {Checkbox, Flex, Label, Radio, Stack, Text} from '@sanity/ui'
2
+ import {ActionDispatch, useMemo, useState} from 'react'
3
+ import {FormField} from 'sanity'
4
+
5
+ import {type StaticRenditionResolution, type UploadConfig} from '../../util/types'
6
+ import {UploadConfigurationStateAction} from '../UploadConfiguration'
7
+
8
+ const ADVANCED_RESOLUTIONS: {value: StaticRenditionResolution; label: string}[] = [
9
+ {value: '270p', label: '270p'},
10
+ {value: '360p', label: '360p'},
11
+ {value: '480p', label: '480p'},
12
+ {value: '540p', label: '540p'},
13
+ {value: '720p', label: '720p'},
14
+ {value: '1080p', label: '1080p'},
15
+ {value: '1440p', label: '1440p'},
16
+ {value: '2160p', label: '2160p'},
17
+ ]
18
+
19
+ export const StaticRenditionSelector = ({
20
+ id,
21
+ config,
22
+ dispatch,
23
+ }: {
24
+ id: string
25
+ config: UploadConfig
26
+ dispatch: ActionDispatch<[action: UploadConfigurationStateAction]>
27
+ }) => {
28
+ // Determine if user is in advanced mode based on selected renditions
29
+ const isAdvancedMode = useMemo(() => {
30
+ const specificResolutions = config.static_renditions.filter(
31
+ (r) => r !== 'highest' && r !== 'audio-only'
32
+ )
33
+ return specificResolutions.length > 0
34
+ }, [config.static_renditions])
35
+
36
+ const [renditionMode, setRenditionMode] = useState<'standard' | 'advanced'>(
37
+ isAdvancedMode ? 'advanced' : 'standard'
38
+ )
39
+
40
+ // Helper to toggle a rendition
41
+ const toggleRendition = (rendition: StaticRenditionResolution) => {
42
+ const current = config.static_renditions
43
+ const hasRendition = current.includes(rendition)
44
+
45
+ if (hasRendition) {
46
+ dispatch({
47
+ action: 'static_renditions',
48
+ value: current.filter((r) => r !== rendition),
49
+ })
50
+ } else {
51
+ dispatch({
52
+ action: 'static_renditions',
53
+ value: [...current, rendition],
54
+ })
55
+ }
56
+ }
57
+
58
+ // When switching modes, clear renditions that don't apply
59
+ const handleModeChange = (mode: 'standard' | 'advanced') => {
60
+ setRenditionMode(mode)
61
+ if (mode === 'standard') {
62
+ // Remove specific resolutions, keep only highest and audio-only
63
+ dispatch({
64
+ action: 'static_renditions',
65
+ value: config.static_renditions.filter((r) => r === 'highest' || r === 'audio-only'),
66
+ })
67
+ } else {
68
+ // Remove highest, keep specific resolutions and audio-only
69
+ dispatch({
70
+ action: 'static_renditions',
71
+ value: config.static_renditions.filter((r) => r !== 'highest'),
72
+ })
73
+ }
74
+ }
75
+ return (
76
+ <Stack space={3}>
77
+ <FormField
78
+ title="Static Renditions"
79
+ description="Generate downloadable MP4 or M4A files. Note: Mux will not upscale to produce MP4 renditions - renditions that would cause upscaling are skipped."
80
+ >
81
+ <Stack space={3}>
82
+ {/* Mode Selector */}
83
+ <Flex gap={3}>
84
+ <Flex align="center" gap={2}>
85
+ <Radio
86
+ checked={renditionMode === 'standard'}
87
+ name="rendition-mode"
88
+ onChange={() => handleModeChange('standard')}
89
+ value="standard"
90
+ id={`${id}--mode-standard`}
91
+ />
92
+ <Text as="label" htmlFor={`${id}--mode-standard`}>
93
+ Standard
94
+ </Text>
95
+ </Flex>
96
+ <Flex align="center" gap={2}>
97
+ <Radio
98
+ checked={renditionMode === 'advanced'}
99
+ name="rendition-mode"
100
+ onChange={() => handleModeChange('advanced')}
101
+ value="advanced"
102
+ id={`${id}--mode-advanced`}
103
+ />
104
+ <Text as="label" htmlFor={`${id}--mode-advanced`}>
105
+ Advanced
106
+ </Text>
107
+ </Flex>
108
+ </Flex>
109
+
110
+ {/* Standard Mode Options */}
111
+ {renditionMode === 'standard' && (
112
+ <Stack space={2}>
113
+ <Flex align="center" gap={2} padding={[0, 2]}>
114
+ <Checkbox
115
+ id={`${id}--highest`}
116
+ style={{display: 'block'}}
117
+ checked={config.static_renditions.includes('highest')}
118
+ onChange={() => toggleRendition('highest')}
119
+ />
120
+ <Text as="label" htmlFor={`${id}--highest`}>
121
+ Highest Resolution (up to 4K)
122
+ </Text>
123
+ </Flex>
124
+ <Flex align="center" gap={2} padding={[0, 2]}>
125
+ <Checkbox
126
+ id={`${id}--audio-only-standard`}
127
+ style={{display: 'block'}}
128
+ checked={config.static_renditions.includes('audio-only')}
129
+ onChange={() => toggleRendition('audio-only')}
130
+ />
131
+ <Text as="label" htmlFor={`${id}--audio-only-standard`}>
132
+ Audio Only (M4A)
133
+ </Text>
134
+ </Flex>
135
+ </Stack>
136
+ )}
137
+
138
+ {/* Advanced Mode Options */}
139
+ {renditionMode === 'advanced' && (
140
+ <Stack space={2}>
141
+ <Label size={1} muted>
142
+ Select specific resolutions:
143
+ </Label>
144
+ <Flex gap={2} wrap="wrap">
145
+ {ADVANCED_RESOLUTIONS.map(({value, label}) => {
146
+ const inputId = `${id}--resolution-${value}`
147
+ return (
148
+ <Flex key={value} align="center" gap={2}>
149
+ <Checkbox
150
+ id={inputId}
151
+ style={{display: 'block'}}
152
+ checked={config.static_renditions.includes(value)}
153
+ onChange={() => toggleRendition(value)}
154
+ />
155
+ <Text as="label" htmlFor={inputId} size={1}>
156
+ {label}
157
+ </Text>
158
+ </Flex>
159
+ )
160
+ })}
161
+ </Flex>
162
+ <Flex align="center" gap={2} padding={[2, 2, 0, 2]}>
163
+ <Checkbox
164
+ id={`${id}--audio-only-advanced`}
165
+ style={{display: 'block'}}
166
+ checked={config.static_renditions.includes('audio-only')}
167
+ onChange={() => toggleRendition('audio-only')}
168
+ />
169
+ <Text as="label" htmlFor={`${id}--audio-only-advanced`}>
170
+ Audio Only (M4A)
171
+ </Text>
172
+ </Flex>
173
+ </Stack>
174
+ )}
175
+ </Stack>
176
+ </FormField>
177
+ </Stack>
178
+ )
179
+ }
@@ -0,0 +1,93 @@
1
+ import {Button, Card, Dialog, Stack, Text} from '@sanity/ui'
2
+ import React, {createContext, useContext, useState} from 'react'
3
+
4
+ import {PluginConfig} from '../util/types'
5
+
6
+ const LOCAL_STORAGE_HAS_SHOWN_WARNING_KEY = 'mux-plugin-has-shown-drm-playback-warning'
7
+
8
+ type DrmPlaybackWarningContextContextProps = {
9
+ hasShownWarning: boolean
10
+ setHasWarnedAboutDrmPlayback: (b: boolean) => void
11
+ }
12
+
13
+ const DrmPlaybackWarningContext = createContext<DrmPlaybackWarningContextContextProps>({
14
+ hasShownWarning: false,
15
+ setHasWarnedAboutDrmPlayback: () => {
16
+ return null
17
+ },
18
+ })
19
+
20
+ interface DrmPlaybackWarningContextProviderProps {
21
+ config?: PluginConfig
22
+ children: React.ReactNode
23
+ }
24
+
25
+ export const DrmPlaybackWarningContextProvider = ({
26
+ config,
27
+ children,
28
+ }: DrmPlaybackWarningContextProviderProps) => {
29
+ const warningDisabled = config?.disableDrmPlaybackWarning ?? false
30
+ const hasWarned: boolean =
31
+ warningDisabled || window.localStorage.getItem(LOCAL_STORAGE_HAS_SHOWN_WARNING_KEY) === 'true'
32
+ const [hasWarnedAboutDrmPlayback, setHasWarnedAboutDrmPlayback] = useState(hasWarned)
33
+
34
+ const setHasShownWarning = (b: boolean) => {
35
+ window.localStorage.setItem(LOCAL_STORAGE_HAS_SHOWN_WARNING_KEY, b.toString())
36
+ setHasWarnedAboutDrmPlayback(b)
37
+ }
38
+ return (
39
+ <DrmPlaybackWarningContext.Provider
40
+ value={{
41
+ hasShownWarning: hasWarnedAboutDrmPlayback,
42
+ setHasWarnedAboutDrmPlayback: setHasShownWarning,
43
+ }}
44
+ >
45
+ {children}
46
+ </DrmPlaybackWarningContext.Provider>
47
+ )
48
+ }
49
+
50
+ export const useDrmPlaybackWarningContext = () => {
51
+ const context = useContext(DrmPlaybackWarningContext)
52
+ return context
53
+ }
54
+
55
+ export const DRMWarningDialog = ({onClose}: {onClose: () => void}) => {
56
+ const {setHasWarnedAboutDrmPlayback} = useDrmPlaybackWarningContext()
57
+ const _onClose = () => {
58
+ setHasWarnedAboutDrmPlayback(true)
59
+ onClose()
60
+ }
61
+ return (
62
+ <Dialog
63
+ open
64
+ id="drm-playback-warn"
65
+ onClose={_onClose}
66
+ header="DRM Playback Warning"
67
+ footer={
68
+ <Stack padding={3}>
69
+ <Button mode="ghost" tone="primary" onClick={_onClose} text="Ok" />
70
+ </Stack>
71
+ }
72
+ >
73
+ <Stack space={3} padding={3}>
74
+ <Card padding={[3, 3, 3]} radius={2}>
75
+ <Stack space={3}>
76
+ <Text size={1} weight="semibold">
77
+ DRM-protected playback will generate a license with a small associated cost. The
78
+ plugin will attempt to play signed or public playback IDs instead whenever possible.
79
+ </Text>
80
+ </Stack>
81
+ </Card>
82
+ <Card padding={[3, 3, 3]} radius={2} tone="suggest">
83
+ <Stack space={3}>
84
+ <Text size={1} weight="semibold">
85
+ This is a one time warning. If it persists, you can disable it from your plugin
86
+ configuration.
87
+ </Text>
88
+ </Stack>
89
+ </Card>
90
+ </Stack>
91
+ </Dialog>
92
+ )
93
+ }
@@ -1,4 +1,5 @@
1
1
  import {useCurrentUser} from 'sanity'
2
+
2
3
  import {PluginConfig} from '../util/types'
3
4
 
4
5
  export const useAccessControl = (config: PluginConfig) => {
@@ -2,7 +2,7 @@
2
2
 
3
3
  import {useState} from 'react'
4
4
 
5
- export type DialogState = 'secrets' | 'select-video' | 'edit-thumbnail' | false
5
+ export type DialogState = 'secrets' | 'select-video' | 'edit-thumbnail' | 'edit-captions' | false
6
6
 
7
7
  export function useDialogState() {
8
8
  return useState<DialogState>(false)
@@ -0,0 +1,54 @@
1
+ import {useEffect, useState} from 'react'
2
+
3
+ import {StagedUpload} from '../components/Uploader'
4
+
5
+ export function useFetchFileSize(stagedUpload: StagedUpload, maxFileSize?: number) {
6
+ const [fileSize, setFileSize] = useState<number | null>(null)
7
+ const [isLoadingFileSize, setIsLoadingFileSize] = useState(false)
8
+ const [canSkipFileSizeValidation, setCanSkipFileSizeValidation] = useState(false)
9
+
10
+ useEffect(() => {
11
+ // Fetch URL Upload file size
12
+ if (stagedUpload.type === 'url') {
13
+ setIsLoadingFileSize(false)
14
+ setCanSkipFileSizeValidation(false)
15
+ setFileSize(null)
16
+ const url = stagedUpload.url
17
+
18
+ // Get file size from URL
19
+ const fetchFileSize = async () => {
20
+ setIsLoadingFileSize(true)
21
+ try {
22
+ const response = await fetch(url, {method: 'HEAD'})
23
+ const contentLength = response.headers.get('content-length')
24
+ const newFileSize = contentLength ? parseInt(contentLength, 10) : null
25
+
26
+ setIsLoadingFileSize(false)
27
+ if (newFileSize) {
28
+ setFileSize(newFileSize)
29
+ }
30
+ if (newFileSize === null && maxFileSize !== undefined) {
31
+ // Size unknown but size limit is configured - skip file size validation
32
+ setCanSkipFileSizeValidation(true)
33
+ }
34
+ } catch {
35
+ console.warn('Could not validate file size from URL')
36
+ // Skip validation of file size, but still validate duration
37
+ setCanSkipFileSizeValidation(true)
38
+ setIsLoadingFileSize(false)
39
+ }
40
+ }
41
+
42
+ fetchFileSize()
43
+ }
44
+ if (stagedUpload.type === 'file') {
45
+ setFileSize(stagedUpload.files[0].size)
46
+ }
47
+ }, [maxFileSize, stagedUpload, stagedUpload.type])
48
+
49
+ return {
50
+ fileSize,
51
+ isLoadingFileSize,
52
+ canSkipFileSizeValidation,
53
+ }
54
+ }
@@ -0,0 +1,100 @@
1
+ import {useEffect, useState} from 'react'
2
+
3
+ import {StagedUpload} from '../components/Uploader'
4
+
5
+ export interface VideoAssetMetadata {
6
+ width?: number
7
+ height?: number
8
+ isAudioOnly?: boolean
9
+ duration?: number
10
+ size?: number
11
+ }
12
+
13
+ export function useMediaMetadata(stagedUpload: StagedUpload) {
14
+ const [videoAssetMetadata, setVideoAssetMetadata] = useState<VideoAssetMetadata | null>(null)
15
+ const [isLoadingMetadata, setIsLoadingMetadata] = useState(false)
16
+ useEffect(() => {
17
+ let videoSrc = null
18
+ // Validate file uploads
19
+ if (stagedUpload.type === 'file') {
20
+ const file = stagedUpload.files[0]
21
+ videoSrc = URL.createObjectURL(file)
22
+ }
23
+
24
+ // Validate URL uploads
25
+ if (stagedUpload.type === 'url') {
26
+ videoSrc = stagedUpload.url
27
+ }
28
+
29
+ setVideoAssetMetadata((old) => ({
30
+ ...old,
31
+ duration: undefined,
32
+ width: undefined,
33
+ height: undefined,
34
+ }))
35
+
36
+ if (!videoSrc) return () => null
37
+
38
+ setIsLoadingMetadata(true)
39
+ const videoElement = document.createElement('video')
40
+ videoElement.preload = 'metadata'
41
+
42
+ const metadataListeners = [
43
+ () => {
44
+ setIsLoadingMetadata(false)
45
+ },
46
+ () => {
47
+ const duration = videoElement.duration
48
+ const width = videoElement.videoWidth
49
+ const height = videoElement.videoHeight
50
+ const isAudioOnly = width <= 0 && height <= 0
51
+ setVideoAssetMetadata((old) => {
52
+ return {
53
+ ...old,
54
+ duration: duration,
55
+ width: width,
56
+ height: height,
57
+ isAudioOnly: isAudioOnly,
58
+ }
59
+ })
60
+ },
61
+ ]
62
+
63
+ const cleanupVideo = (videoEl: HTMLVideoElement) => {
64
+ const currentVideoSrc = videoEl?.src
65
+ if (videoEl) {
66
+ metadataListeners.forEach((listener) =>
67
+ videoEl.removeEventListener('loadedmetadata', listener)
68
+ )
69
+ videoEl.onerror = null
70
+ videoEl.src = ''
71
+ videoEl.load()
72
+ }
73
+ if (currentVideoSrc?.startsWith('blob:')) {
74
+ URL.revokeObjectURL(currentVideoSrc)
75
+ }
76
+ }
77
+ metadataListeners.push(() => setTimeout(() => cleanupVideo(videoElement), 0))
78
+
79
+ videoElement.onerror = () => {
80
+ setIsLoadingMetadata(false)
81
+ console.warn('Could not read video metadata for validation')
82
+ cleanupVideo(videoElement)
83
+ }
84
+
85
+ metadataListeners.forEach((listener) =>
86
+ videoElement.addEventListener('loadedmetadata', listener)
87
+ )
88
+ videoElement.src = videoSrc
89
+
90
+ return () => {
91
+ cleanupVideo(videoElement)
92
+ }
93
+ }, [stagedUpload.type, stagedUpload])
94
+
95
+ return {
96
+ videoAssetMetadata,
97
+ setVideoAssetMetadata,
98
+ isLoadingMetadata,
99
+ }
100
+ }
@@ -10,7 +10,11 @@ export const useSaveSecrets = (client: SanityClient, secrets: Secrets) => {
10
10
  token,
11
11
  secretKey,
12
12
  enableSignedUrls,
13
- }: Pick<Secrets, 'token' | 'secretKey' | 'enableSignedUrls'>): Promise<Secrets> => {
13
+ drmConfigId,
14
+ }: Pick<
15
+ Secrets,
16
+ 'token' | 'secretKey' | 'enableSignedUrls' | 'drmConfigId'
17
+ >): Promise<Secrets> => {
14
18
  let {signingKeyId, signingKeyPrivate} = secrets
15
19
 
16
20
  try {
@@ -20,7 +24,8 @@ export const useSaveSecrets = (client: SanityClient, secrets: Secrets) => {
20
24
  secretKey!,
21
25
  enableSignedUrls,
22
26
  signingKeyId!,
23
- signingKeyPrivate!
27
+ signingKeyPrivate!,
28
+ drmConfigId!
24
29
  )
25
30
  const valid = await testSecrets(client)
26
31
  if (!valid?.status && token && secretKey) {
@@ -49,7 +54,8 @@ export const useSaveSecrets = (client: SanityClient, secrets: Secrets) => {
49
54
  secretKey!,
50
55
  enableSignedUrls,
51
56
  signingKeyId,
52
- signingKeyPrivate
57
+ signingKeyPrivate,
58
+ drmConfigId ?? ''
53
59
  )
54
60
  } catch (err: any) {
55
61
  // eslint-disable-next-line no-console
@@ -64,6 +70,7 @@ export const useSaveSecrets = (client: SanityClient, secrets: Secrets) => {
64
70
  enableSignedUrls,
65
71
  signingKeyId,
66
72
  signingKeyPrivate,
73
+ drmConfigId,
67
74
  }
68
75
  },
69
76
  [client, secrets]
@@ -4,7 +4,14 @@ import {useDocumentValues} from 'sanity'
4
4
  import {muxSecretsDocumentId} from '../util/constants'
5
5
  import type {Secrets} from '../util/types'
6
6
 
7
- const path = ['token', 'secretKey', 'enableSignedUrls', 'signingKeyId', 'signingKeyPrivate']
7
+ const path = [
8
+ 'token',
9
+ 'secretKey',
10
+ 'enableSignedUrls',
11
+ 'signingKeyId',
12
+ 'signingKeyPrivate',
13
+ 'drmConfigId',
14
+ ]
8
15
  export const useSecretsDocumentValues = () => {
9
16
  const {error, isLoading, value} = useDocumentValues<Partial<Secrets> | null | undefined>(
10
17
  muxSecretsDocumentId,
@@ -18,6 +25,7 @@ export const useSecretsDocumentValues = () => {
18
25
  enableSignedUrls: value?.enableSignedUrls || false,
19
26
  signingKeyId: value?.signingKeyId || null,
20
27
  signingKeyPrivate: value?.signingKeyPrivate || null,
28
+ drmConfigId: value?.drmConfigId || null,
21
29
  }
22
30
  return {
23
31
  isInitialSetup: !exists,
@@ -2,7 +2,8 @@ import {useReducer} from 'react'
2
2
 
3
3
  import type {Secrets} from '../util/types'
4
4
 
5
- export interface State extends Pick<Secrets, 'token' | 'secretKey' | 'enableSignedUrls'> {
5
+ export interface State
6
+ extends Pick<Secrets, 'token' | 'secretKey' | 'enableSignedUrls' | 'drmConfigId'> {
6
7
  submitting: boolean
7
8
  error: string | null
8
9
  }
@@ -13,7 +14,8 @@ export type Action =
13
14
  | {type: 'change'; payload: {name: 'token'; value: string}}
14
15
  | {type: 'change'; payload: {name: 'secretKey'; value: string}}
15
16
  | {type: 'change'; payload: {name: 'enableSignedUrls'; value: boolean}}
16
- function init({token, secretKey, enableSignedUrls}: Secrets): State {
17
+ | {type: 'change'; payload: {name: 'drmConfigId'; value: string}}
18
+ function init({token, secretKey, enableSignedUrls, drmConfigId}: Secrets): State {
17
19
  return {
18
20
  submitting: false,
19
21
  error: null,
@@ -22,6 +24,7 @@ function init({token, secretKey, enableSignedUrls}: Secrets): State {
22
24
  token: token ?? '',
23
25
  secretKey: secretKey ?? '',
24
26
  enableSignedUrls: enableSignedUrls ?? false,
27
+ drmConfigId: drmConfigId ?? '',
25
28
  }
26
29
  }
27
30
  function reducer(state: State, action: Action) {
@@ -35,7 +38,7 @@ function reducer(state: State, action: Action) {
35
38
  case 'change':
36
39
  return {...state, [action.payload.name]: action.payload.value}
37
40
  default:
38
- throw new Error(`Unknown action type: ${(action as any)?.type}`)
41
+ throw new Error(`Unknown action type: ${(action as unknown as Action)?.type}`)
39
42
  }
40
43
  }
41
44
 
@@ -1,3 +1,4 @@
1
+ import {ServerError} from '@sanity/client'
1
2
  import {type InputProps, isObjectInputProps, type PreviewLayoutKey, type PreviewProps} from 'sanity'
2
3
 
3
4
  import type {MuxInputPreviewProps, MuxInputProps} from './types'
@@ -20,3 +21,16 @@ export function isValidUrl(url: string): boolean {
20
21
  return false
21
22
  }
22
23
  }
24
+
25
+ /**
26
+ * We consider a server error one with status code 5XX.
27
+ * Used mainly to handle unknown Proxy issues.
28
+ */
29
+ export function isServerError(error: Error): error is ServerError {
30
+ return (
31
+ 'statusCode' in error &&
32
+ typeof error.statusCode === 'number' &&
33
+ 500 <= error.statusCode &&
34
+ error.statusCode <= 600
35
+ )
36
+ }
@@ -1,10 +1,13 @@
1
1
  import type {SanityClient} from 'sanity'
2
2
 
3
+ import {getPlaybackId} from '../util/getPlaybackPolicy'
3
4
  import {Audience, generateJwt} from './generateJwt'
4
- import {getPlaybackId} from './getPlaybackId'
5
- import {getPlaybackPolicy} from './getPlaybackPolicy'
5
+ import {getPlaybackPolicyById} from './getPlaybackPolicy'
6
6
  import type {AssetThumbnailOptions} from './types'
7
7
 
8
+ /**
9
+ * May throw a Promise. Call this with {@link tryWithSuspend} or rethrow the Promise
10
+ */
8
11
  export function createUrlParamsObject(
9
12
  client: SanityClient,
10
13
  asset: AssetThumbnailOptions['asset'],
@@ -16,7 +19,8 @@ export function createUrlParamsObject(
16
19
  let searchParams = new URLSearchParams(
17
20
  JSON.parse(JSON.stringify(params, (_, v) => v ?? undefined))
18
21
  )
19
- if (getPlaybackPolicy(asset) === 'signed') {
22
+ const playbackPolicy = getPlaybackPolicyById(asset, playbackId)?.policy
23
+ if (playbackPolicy === 'signed' || playbackPolicy === 'drm') {
20
24
  const token = generateJwt(client, playbackId, audience, params)
21
25
  searchParams = new URLSearchParams({token})
22
26
  }
@@ -4,7 +4,7 @@ import {suspend} from 'suspend-react'
4
4
  import {readSecrets} from './readSecrets'
5
5
  import type {AnimatedThumbnailOptions, ThumbnailOptions} from './types'
6
6
 
7
- export type Audience = 'g' | 's' | 't' | 'v'
7
+ export type Audience = 'g' | 's' | 't' | 'v' | 'd'
8
8
 
9
9
  export type Payload<T extends Audience> = T extends 'g'
10
10
  ? AnimatedThumbnailOptions
@@ -14,8 +14,13 @@ export type Payload<T extends Audience> = T extends 'g'
14
14
  ? ThumbnailOptions
15
15
  : T extends 'v'
16
16
  ? never
17
- : never
17
+ : T extends 'd'
18
+ ? never
19
+ : never
18
20
 
21
+ /**
22
+ * Uses suspend. Call this with {@link tryWithSuspend} or rethrow the Promise
23
+ */
19
24
  export function generateJwt<T extends Audience>(
20
25
  client: SanityClient,
21
26
  playbackId: string,
@@ -30,6 +35,10 @@ export function generateJwt<T extends Audience>(
30
35
  throw new TypeError("Missing `signingKeyPrivate`.\n Check your plugin's configuration")
31
36
  }
32
37
 
38
+ /* Using suspend means we need to use Suspense on parent components.
39
+ Also, this will throw a Promise under the hood (apparently common in React),
40
+ so if we want to catch errors we have to take this into account in catch blocks
41
+ and rethrow promises. */
33
42
  // @ts-expect-error - handle missing typings for this package
34
43
  const {default: sign} = suspend(() => import('jsonwebtoken-esm/sign'), ['jsonwebtoken-esm/sign'])
35
44